feat: support multiple worker workdir attachments

This commit is contained in:
2026-09-19 23:27:57 +09:00
parent 308ae19f5e
commit 37df3ec545
52 changed files with 3715 additions and 1404 deletions
+21 -15
View File
@@ -943,7 +943,7 @@ mod tests {
profile: Some("builtin:coder".to_string()),
ticket_assignment: None,
initial_submit: Vec::new(),
working_directory: None,
workdir_attachments: Vec::new(),
control_operation_id: None,
};
@@ -964,7 +964,7 @@ mod tests {
assert_eq!(body["display_name"], "Coder one");
assert_eq!(body["profile"], "builtin:coder");
assert_eq!(body["initial_submit"], serde_json::json!([]));
assert_eq!(body["working_directory"], serde_json::Value::Null);
assert_eq!(body["workdir_attachments"], serde_json::json!([]));
}
#[test]
@@ -1014,29 +1014,35 @@ mod tests {
"state": "idle",
"implementation": {"kind": "worker", "display_hint": "Coder"},
"capabilities": {"can_stop": true, "can_spawn_followup": false},
"working_directory": {
"working_directory_id": "wd-1",
"repository_key": "main",
"materializer_kind": "runtime_git_clone",
"status": "active",
"occupied_by": {
"runtime_id": "arcadia",
"worker_id": "worker-opaque-64",
"display_name": "Coder",
"linked_at": "2026-08-12T00:00:00Z"
"workdir_attachments": [{
"alias": "checkout",
"working_directory": {
"working_directory_id": "wd-1",
"repository_key": "main",
"materializer_kind": "runtime_git_clone",
"status": "active",
"occupied_by": {
"runtime_id": "arcadia",
"worker_id": "worker-opaque-64",
"display_name": "Coder",
"linked_at": "2026-08-12T00:00:00Z"
}
}
}
}]
});
let worker: BackendWorkerSummary = serde_json::from_value(payload.clone()).unwrap();
let workdir = worker.working_directory.unwrap();
let attachment = worker.workdir_attachments.into_iter().next().unwrap();
assert_eq!(attachment.alias, "checkout");
let workdir = attachment.working_directory;
assert_eq!(workdir.repository_key, "main");
let occupied_by = workdir.occupied_by.expect("occupied Workdir");
assert_eq!(occupied_by.runtime_id, "arcadia");
assert_eq!(occupied_by.worker_id, "worker-opaque-64");
let mut stale = payload;
stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64);
stale["workdir_attachments"][0]["working_directory"]["occupied_by"]["runtime_worker_id"] =
serde_json::json!(64);
assert!(serde_json::from_value::<BackendWorkerSummary>(stale).is_err());
}
+1 -1
View File
@@ -312,7 +312,7 @@ impl BackendWorkspaceProductClient {
initial_submit: vec![protocol::Segment::Text {
content: format!("Please handle intake for Ticket {ticket_id}."),
}],
working_directory: None,
workdir_attachments: Vec::new(),
control_operation_id: None,
};
let response: BrowserCreateWorkerResponse =
+83 -42
View File
@@ -572,6 +572,41 @@ pub enum SubscriptionWorkerAvailability {
Unavailable,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct SubscriptionWorkerWorkdirAttachment {
/// Stable Worker-local routing alias.
pub alias: String,
/// Runtime-internal Repository id. Workspace-facing TypeScript contracts
/// omit this field and require `repository_key` from the Server projection.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(skip))]
pub repository_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository_key: Option<String>,
pub working_directory_id: SubscriptionWorkdirId,
}
impl SubscriptionWorkerWorkdirAttachment {
pub fn validate(&self) -> Result<(), SubscriptionValidationError> {
validate_identifier(
"workdir_attachment_alias",
&self.alias,
MAX_RESOURCE_ID_BYTES,
)?;
self.working_directory_id.validate()?;
match (&self.repository_id, &self.repository_key) {
(Some(repository_id), None) => {
validate_identifier("repository_id", repository_id, MAX_RESOURCE_ID_BYTES)
}
(None, Some(repository_key)) => validate_repository_key(repository_key),
_ => Err(SubscriptionValidationError::InvalidIdentifier {
field: "repository_authority",
}),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct SubscriptionWorker {
@@ -604,15 +639,8 @@ pub struct SubscriptionWorker {
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(skip))]
pub repository_id: Option<String>,
/// Workspace-facing Repository key. Runtime producers leave this unset and
/// Workspace Server projections replace `repository_id` with this field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory_id: Option<SubscriptionWorkdirId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workdir_attachments: Vec<SubscriptionWorkerWorkdirAttachment>,
}
impl SubscriptionWorker {
@@ -624,19 +652,20 @@ impl SubscriptionWorker {
if let Some(resource_key) = &self.resource_key {
validate_identifier("resource_key", resource_key, MAX_RESOURCE_ID_BYTES)?;
}
if let Some(repository_id) = &self.repository_id {
validate_identifier("repository_id", repository_id, MAX_RESOURCE_ID_BYTES)?;
}
if let Some(repository_key) = &self.repository_key {
validate_repository_key(repository_key)?;
}
if self.repository_id.is_some() && self.repository_key.is_some() {
return Err(SubscriptionValidationError::InvalidIdentifier {
field: "repository_authority",
});
}
if let Some(working_directory_id) = &self.working_directory_id {
working_directory_id.validate()?;
let mut aliases = HashSet::new();
let mut workdir_ids = HashSet::new();
for attachment in &self.workdir_attachments {
attachment.validate()?;
if !aliases.insert(attachment.alias.as_str()) {
return Err(SubscriptionValidationError::InvalidIdentifier {
field: "workdir_attachment_alias",
});
}
if !workdir_ids.insert(attachment.working_directory_id.as_str()) {
return Err(SubscriptionValidationError::InvalidIdentifier {
field: "working_directory_id",
});
}
}
Ok(())
}
@@ -654,8 +683,6 @@ pub struct SubscriptionWorkdir {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository_key: Option<String>,
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<SubscriptionWorkerId>,
}
impl SubscriptionWorkdir {
@@ -673,9 +700,6 @@ impl SubscriptionWorkdir {
}
}
validate_identifier("workdir_state", &self.state, MAX_RESOURCE_ID_BYTES)?;
if let Some(worker_id) = &self.primary_worker_id {
worker_id.validate()?;
}
Ok(())
}
}
@@ -688,8 +712,6 @@ pub struct WorkspaceSubscriptionWorkdir {
pub working_directory_id: SubscriptionWorkdirId,
pub repository_key: String,
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<SubscriptionWorkerId>,
}
impl WorkspaceSubscriptionWorkdir {
@@ -697,9 +719,6 @@ impl WorkspaceSubscriptionWorkdir {
self.working_directory_id.validate()?;
validate_repository_key(&self.repository_key)?;
validate_identifier("workdir_state", &self.state, MAX_RESOURCE_ID_BYTES)?;
if let Some(worker_id) = &self.primary_worker_id {
worker_id.validate()?;
}
Ok(())
}
}
@@ -903,36 +922,58 @@ mod tests {
workspace_id: Some("workspace-1".to_string()),
display_name: Some(format!("Worker {value}")),
profile: Some("builtin:coder".to_string()),
repository_id: None,
repository_key: None,
working_directory_id: None,
workdir_attachments: Vec::new(),
}
}
#[test]
fn runtime_and_workspace_repository_identity_projections_do_not_alias() {
let mut runtime_worker = worker("worker-1");
runtime_worker.repository_id = Some("01890f47-3c22-7cc0-98c4-dc0c0c07398f".to_string());
runtime_worker
.workdir_attachments
.push(SubscriptionWorkerWorkdirAttachment {
alias: "checkout".to_string(),
repository_id: Some("01890f47-3c22-7cc0-98c4-dc0c0c07398f".to_string()),
repository_key: None,
working_directory_id: SubscriptionWorkdirId::new("workdir-1").unwrap(),
});
runtime_worker.validate().unwrap();
let runtime_json = serde_json::to_value(&runtime_worker).unwrap();
assert_eq!(
runtime_json["repository_id"],
runtime_json["workdir_attachments"][0]["repository_id"],
"01890f47-3c22-7cc0-98c4-dc0c0c07398f"
);
assert!(runtime_json.get("repository_key").is_none());
assert!(
runtime_json["workdir_attachments"][0]
.get("repository_key")
.is_none()
);
let mut workspace_worker = worker("worker-1");
workspace_worker.repository_key = Some("main".to_string());
workspace_worker
.workdir_attachments
.push(SubscriptionWorkerWorkdirAttachment {
alias: "checkout".to_string(),
repository_id: None,
repository_key: Some("main".to_string()),
working_directory_id: SubscriptionWorkdirId::new("workdir-1").unwrap(),
});
workspace_worker.validate().unwrap();
let workspace_json = serde_json::to_value(&workspace_worker).unwrap();
assert_eq!(workspace_json["repository_key"], "main");
assert!(workspace_json.get("repository_id").is_none());
assert_eq!(
workspace_json["workdir_attachments"][0]["repository_key"],
"main"
);
assert!(
workspace_json["workdir_attachments"][0]
.get("repository_id")
.is_none()
);
let workspace_workdir = WorkspaceSubscriptionWorkdir {
working_directory_id: SubscriptionWorkdirId::new("workdir-1").unwrap(),
repository_key: "main".to_string(),
state: "active".to_string(),
primary_worker_id: Some(worker_id("worker-1")),
};
workspace_workdir.validate().unwrap();
let workdir_json = serde_json::to_value(&workspace_workdir).unwrap();
+3 -1
View File
@@ -22,7 +22,8 @@ use crate::{
SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot,
SubscriptionTerminationCode, SubscriptionWorkdirId, SubscriptionWorker,
SubscriptionWorkerAvailability, SubscriptionWorkerId, SubscriptionWorkerIds,
SubscriptionWorkerProtocolMethod, SubscriptionWorkerState, WorkspaceSubscriptionWorkdir,
SubscriptionWorkerProtocolMethod, SubscriptionWorkerState,
SubscriptionWorkerWorkdirAttachment, WorkspaceSubscriptionWorkdir,
},
};
@@ -114,6 +115,7 @@ pub fn generated_protocol_types() -> String {
push_decl::<SubscriptionWorkerState>(&cfg, &mut output);
push_decl::<SubscriptionWorkerAvailability>(&cfg, &mut output);
push_decl::<EventSubscriptionSelector>(&cfg, &mut output);
push_decl::<SubscriptionWorkerWorkdirAttachment>(&cfg, &mut output);
push_decl::<SubscriptionWorker>(&cfg, &mut output);
push_decl::<WorkspaceSubscriptionWorkdir>(&cfg, &mut output);
push_decl::<SubscriptionSnapshot>(&cfg, &mut output);
+47 -8
View File
@@ -335,6 +335,8 @@ pub struct RepositoryMaterializationContext {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct WorkingDirectoryRequest {
pub repository: WorkingDirectoryRepository,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default)]
pub materializer: MaterializerKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -344,12 +346,31 @@ pub struct WorkingDirectoryRequest {
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct WorkingDirectoryClaim {
pub struct WorkingDirectoryAttachmentClaim {
/// Stable Worker-local routing key. This is not a Workdir id or display name.
pub alias: workdir::WorkdirAttachmentAlias,
pub working_directory_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relative_cwd: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct WorkerWorkdirAttachmentsRequest {
pub workdir_attachments: Vec<WorkingDirectoryAttachmentClaim>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct WorkingDirectoryAttachmentRequest {
pub alias: workdir::WorkdirAttachmentAlias,
pub working_directory: WorkingDirectoryRequest,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct WorkingDirectoryAttachmentStatus {
pub alias: workdir::WorkdirAttachmentAlias,
pub working_directory: WorkingDirectoryStatus,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct WorkingDirectoryStatus {
pub summary: RuntimeWorkingDirectorySummary,
@@ -387,10 +408,10 @@ pub struct CreateWorkerRequest {
pub config_bundle: Option<ConfigBundleRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_input: Option<WorkerInput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory_request: Option<WorkingDirectoryRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryClaim>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workdir_attachment_requests: Vec<WorkingDirectoryAttachmentRequest>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workdir_attachments: Vec<WorkingDirectoryAttachmentClaim>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub worker_observation_enabled: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -413,8 +434,8 @@ pub struct WorkerSummary {
pub worker_state: Option<WorkerStateSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryStatus>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workdir_attachments: Vec<WorkingDirectoryAttachmentStatus>,
pub profile: ProfileSelector,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
@@ -726,6 +747,17 @@ pub trait RuntimeApi {
#[body] request: WorkerWorkspaceApiRequest,
) -> Result<WorkerResponse, RuntimeApiError>;
#[post(
"/v1/workers/{worker_id}/workdir-attachments",
status = 200,
error_status = 400
)]
async fn replace_worker_workdir_attachments(
&self,
#[path] worker_id: String,
#[body] request: WorkerWorkdirAttachmentsRequest,
) -> Result<WorkerResponse, RuntimeApiError>;
#[post(
"/v1/workers/{worker_id}/completions",
status = 200,
@@ -1002,6 +1034,13 @@ mod tests {
) -> Result<WorkerResponse, RuntimeApiError> {
Err(test_error(501))
}
async fn replace_worker_workdir_attachments(
&self,
_worker_id: String,
_request: WorkerWorkdirAttachmentsRequest,
) -> Result<WorkerResponse, RuntimeApiError> {
Err(test_error(501))
}
async fn complete_worker_arguments(
&self,
_worker_id: String,
@@ -1072,7 +1111,7 @@ mod tests {
#[test]
fn contract_inventory_is_complete_and_unique() {
let operations = RuntimeApiMetadata::OPERATIONS;
assert_eq!(operations.len(), 15);
assert_eq!(operations.len(), 16);
let mut routes = operations
.iter()
.map(|operation| (format!("{:?}", operation.method), operation.path))
+41 -17
View File
@@ -1583,6 +1583,9 @@ pub struct RuntimeWorkingDirectoryCleanupTarget {
#[serde(deny_unknown_fields)]
pub struct RuntimeWorkingDirectorySummary {
pub working_directory_id: String,
/// Optional human-facing label, never a routing key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_selector: Option<String>,
@@ -1606,8 +1609,6 @@ pub struct RuntimeWorkingDirectorySummary {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanliness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub occupied_by: Option<WorkingDirectoryOccupancy>,
}
@@ -1618,6 +1619,9 @@ pub struct RuntimeWorkingDirectorySummary {
#[serde(deny_unknown_fields)]
pub struct WorkingDirectorySummary {
pub working_directory_id: String,
/// Optional human-facing label, never a routing key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub repository_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_selector: Option<String>,
@@ -1641,8 +1645,6 @@ pub struct WorkingDirectorySummary {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanliness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub occupied_by: Option<WorkingDirectoryOccupancy>,
}
@@ -1713,6 +1715,7 @@ impl From<workdir::workspace::RuntimeWorkingDirectorySummary> for RuntimeWorking
fn from(value: workdir::workspace::RuntimeWorkingDirectorySummary) -> Self {
Self {
working_directory_id: value.working_directory_id,
display_name: value.display_name,
repository_id: value.repository_id,
creation_selector: value.creation_selector,
creation_ref: value.creation_ref,
@@ -1725,7 +1728,6 @@ impl From<workdir::workspace::RuntimeWorkingDirectorySummary> for RuntimeWorking
cleanup_target: value.cleanup_target.map(Into::into),
status: value.status.into(),
cleanliness: value.cleanliness,
primary_worker_id: value.primary_worker_id,
occupied_by: value.occupied_by.map(Into::into),
}
}
@@ -1735,6 +1737,7 @@ impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySumma
fn from(value: workdir::workspace::WorkingDirectorySummary) -> Self {
Self {
working_directory_id: value.working_directory_id,
display_name: value.display_name,
repository_key: value.repository_key,
creation_selector: value.creation_selector,
creation_ref: value.creation_ref,
@@ -1747,7 +1750,6 @@ impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySumma
cleanup_target: value.cleanup_target.map(Into::into),
status: value.status.into(),
cleanliness: value.cleanliness,
primary_worker_id: value.primary_worker_id,
occupied_by: value.occupied_by.map(Into::into),
}
}
@@ -1765,6 +1767,9 @@ impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySumma
pub struct WorkingDirectoryCreateRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_id: Option<String>,
/// Optional human-facing label, independent of every Worker attachment alias.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub repository_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
@@ -2445,6 +2450,24 @@ pub struct WorkspaceWorkerDiscoveryPage {
pub next_cursor: Option<String>,
}
/// One Workspace Worker Workdir attachment. `alias` is the stable Worker-local
/// routing key; the nested Workdir id and display name are inventory metadata.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkerWorkdirAttachmentSummary {
pub alias: String,
pub working_directory: WorkingDirectorySummary,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimeWorkerWorkdirAttachmentSummary {
pub alias: String,
pub working_directory: RuntimeWorkingDirectorySummary,
}
/// Workspace-authoritative Worker projection.
///
/// `resource_key` is required here even though Runtime-internal Worker summaries
@@ -2478,8 +2501,8 @@ pub struct WorkerSummary {
pub retention_state: String,
pub implementation: WorkerImplementationSummary,
pub capabilities: WorkerCapabilitySummary,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectorySummary>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workdir_attachments: Vec<WorkerWorkdirAttachmentSummary>,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
}
@@ -2507,9 +2530,8 @@ pub struct WorkerLaunchWorkerSummary {
pub retention_state: String,
pub implementation: WorkerImplementationSummary,
pub capabilities: WorkerCapabilitySummary,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
pub working_directory: Option<RuntimeWorkingDirectorySummary>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workdir_attachments: Vec<RuntimeWorkerWorkdirAttachmentSummary>,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
}
@@ -2563,6 +2585,8 @@ pub struct WorkingDirectoryRepositoryOption {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct BrowserWorkerWorkingDirectorySelection {
/// Stable Worker-local routing alias.
pub alias: String,
pub working_directory_id: String,
#[serde(default)]
pub relative_cwd: Option<String>,
@@ -2589,7 +2613,7 @@ pub struct CreateWorkspaceWorkerRequest {
#[serde(default)]
pub initial_submit: Vec<protocol::Segment>,
#[serde(default)]
pub working_directory: Option<BrowserWorkerWorkingDirectorySelection>,
pub workdir_attachments: Vec<BrowserWorkerWorkingDirectorySelection>,
/// Backend idempotency key used only for authenticated Worker-owned spawn/control.
#[serde(default)]
pub control_operation_id: Option<String>,
@@ -3663,6 +3687,7 @@ pub fn worker_launch_api_typescript() -> String {
WorkerWorkspaceSummary::decl(&config),
WorkerImplementationSummary::decl(&config),
WorkerCapabilitySummary::decl(&config),
RuntimeWorkerWorkdirAttachmentSummary::decl(&config),
WorkerLaunchWorkerSummary::decl(&config),
WorkerLaunchRuntimeOption::decl(&config),
WorkerLaunchProfileCandidate::decl(&config),
@@ -4100,7 +4125,7 @@ mod tests {
can_stop: true,
can_spawn_followup: false,
},
working_directory: None,
workdir_attachments: Vec::new(),
diagnostics: Vec::new(),
}
}
@@ -4178,7 +4203,7 @@ mod tests {
profile: None,
ticket_assignment: None,
initial_submit: Vec::new(),
working_directory: None,
workdir_attachments: Vec::new(),
control_operation_id: None,
})
.unwrap();
@@ -4190,7 +4215,7 @@ mod tests {
"profile": null,
"ticket_assignment": null,
"initial_submit": [],
"working_directory": null,
"workdir_attachments": [],
"control_operation_id": null,
})
);
@@ -5097,6 +5122,7 @@ mod tests {
fn workdir_summary_omits_absent_optional_fields_on_the_wire() {
let value = serde_json::to_value(WorkingDirectorySummary {
working_directory_id: "workdir-1".into(),
display_name: None,
repository_key: "main".into(),
creation_selector: None,
creation_ref: None,
@@ -5109,7 +5135,6 @@ mod tests {
cleanup_target: None,
status: WorkingDirectoryStatusKind::Active,
cleanliness: None,
primary_worker_id: None,
occupied_by: None,
})
.expect("serialize Workdir summary");
@@ -5125,7 +5150,6 @@ mod tests {
"observed_at_epoch_seconds",
"cleanup_target",
"cleanliness",
"primary_worker_id",
"occupied_by",
] {
assert!(
+2 -2
View File
@@ -164,7 +164,7 @@ fn request_from_selection(selection: Selection) -> BackendCreateWorkerRequest {
display_name: selection.display_name,
profile: Some(selection.profile),
initial_submit: Vec::new(),
working_directory: None,
workdir_attachments: Vec::new(),
ticket_assignment: None,
control_operation_id: None,
}
@@ -477,7 +477,7 @@ mod tests {
assert_eq!(request.display_name, "Coder one");
assert_eq!(request.profile.as_deref(), Some("builtin:coder"));
assert!(request.initial_submit.is_empty());
assert!(request.working_directory.is_none());
assert!(request.workdir_attachments.is_empty());
assert!(request.ticket_assignment.is_none());
}
}
+39 -20
View File
@@ -408,10 +408,22 @@ fn short_worker_id(worker: &BackendWorkerSummary) -> String {
}
fn working_directory_text(worker: &BackendWorkerSummary) -> String {
let Some(wd) = worker.working_directory.as_ref() else {
if worker.workdir_attachments.is_empty() {
return "wd:—".to_string();
};
format!("wd:{}・{}", wd.repository_key, wd.working_directory_id)
}
if worker.workdir_attachments.len() > 1 {
return format!("wd:{} attachments", worker.workdir_attachments.len());
}
let attachment = &worker.workdir_attachments[0];
let directory = &attachment.working_directory;
let label = directory
.display_name
.as_deref()
.unwrap_or(&directory.repository_key);
format!(
"wd:{}:{}・{}",
attachment.alias, label, directory.working_directory_id
)
}
#[cfg(test)]
@@ -456,7 +468,7 @@ mod tests {
can_stop: true,
can_spawn_followup: false,
},
working_directory: None,
workdir_attachments: Vec::new(),
diagnostics: Vec::new(),
}
}
@@ -529,22 +541,26 @@ mod tests {
worker.label = "Coder · T-585".to_string();
worker.state = "stopped".to_string();
worker.worker_state = None;
worker.working_directory = Some(
worker.workdir_attachments = vec![
serde_json::from_value(serde_json::json!({
"working_directory_id": "001a06a9f0202000000",
"repository_key": "main",
"materializer_kind": "runtime_git_clone",
"status": "active",
"cleanliness": "clean"
"alias": "checkout",
"working_directory": {
"working_directory_id": "001a06a9f0202000000",
"display_name": "Checkout",
"repository_key": "main",
"materializer_kind": "runtime_git_clone",
"status": "active",
"cleanliness": "clean"
}
}))
.unwrap(),
);
];
let widths = WorkerColumnWidths::from_workers(std::slice::from_ref(&worker));
let text = row_text(&worker, &widths);
assert_eq!(
text,
" W-90 Coder · T-585 [stopped] wd:main・001a06a9f0202000000"
" W-90 Coder · T-585 [stopped] wd:checkout:Checkout・001a06a9f0202000000"
);
assert!(!text.contains("profile:"));
assert!(!text.contains("active clean"));
@@ -570,15 +586,18 @@ mod tests {
long.worker_state = None;
for worker in [&mut short, &mut long] {
worker.working_directory = Some(
worker.workdir_attachments = vec![
serde_json::from_value(serde_json::json!({
"working_directory_id": "workdir-1",
"repository_key": "main",
"materializer_kind": "runtime_git_clone",
"status": "active"
"alias": "checkout",
"working_directory": {
"working_directory_id": "workdir-1",
"repository_key": "main",
"materializer_kind": "runtime_git_clone",
"status": "active"
}
}))
.unwrap(),
);
];
}
let workers = vec![short, long];
@@ -595,8 +614,8 @@ mod tests {
display_column(&second, "[stopped]")
);
assert_eq!(
display_column(&first, "wd:main"),
display_column(&second, "wd:main")
display_column(&first, "wd:checkout:main"),
display_column(&second, "wd:checkout:main")
);
}
+2
View File
@@ -8,6 +8,7 @@
pub mod http;
mod local;
mod operation;
mod router;
mod scope;
pub mod workspace;
@@ -27,6 +28,7 @@ pub use local::{
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
};
pub use operation::*;
pub use router::{RoutedWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionRouter};
pub use scope::{
ReadOnlyWorkdirSession, WorkdirScopeAuthorizationRequest, WorkdirScopeLease,
WorkdirScopeOverlapRequest, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission,
+651
View File
@@ -0,0 +1,651 @@
//! Alias-keyed Worker Workdir session routing.
//!
//! The router owns live provider sessions without exposing whether a session is
//! local or transported over HTTP. Attachment aliases are Worker-local stable
//! identities; provider Workdir ids and human display names are deliberately not
//! routing keys.
use std::collections::{BTreeMap, HashSet};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use crate::{
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
CommandSnapshot, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest,
GrepResult, ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir,
WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
};
/// Stable Worker-local routing key for one attached Workdir.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct WorkdirAttachmentAlias(String);
impl WorkdirAttachmentAlias {
pub fn new(value: impl Into<String>) -> Result<Self, WorkdirError> {
let value = value.into();
let valid = !value.is_empty()
&& value.len() <= 64
&& value.bytes().enumerate().all(|(index, byte)| match byte {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' => true,
b'_' | b'-' | b'.' => index > 0,
_ => false,
});
if !valid {
return Err(WorkdirError::InvalidArgument(
"attachment alias must be 1-64 ASCII letters, digits, '.', '_' or '-', and start with a letter or digit"
.to_string(),
));
}
Ok(Self(value))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for WorkdirAttachmentAlias {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::new(value).map_err(serde::de::Error::custom)
}
}
impl std::fmt::Display for WorkdirAttachmentAlias {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.0)
}
}
#[derive(Debug)]
struct RoutedSessionState {
session: WorkdirSessionHandle,
detached: AtomicBool,
active_operations: AtomicUsize,
active_commands: Mutex<HashSet<CommandHandle>>,
}
impl RoutedSessionState {
fn enter(&self) -> Result<ActiveOperation<'_>, WorkdirError> {
if self.detached.load(Ordering::Acquire) {
return Err(WorkdirError::Unavailable(
"Workdir attachment is detached".to_string(),
));
}
self.active_operations.fetch_add(1, Ordering::AcqRel);
if self.detached.load(Ordering::Acquire) {
self.active_operations.fetch_sub(1, Ordering::AcqRel);
return Err(WorkdirError::Unavailable(
"Workdir attachment is detaching".to_string(),
));
}
Ok(ActiveOperation(self))
}
fn is_busy(&self) -> bool {
if self.active_operations.load(Ordering::Acquire) != 0 {
return true;
}
self.reconcile_terminal_commands(&self.session.command_snapshot());
!self
.active_commands
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_empty()
}
async fn is_busy_for_detach(&self) -> bool {
if self.active_operations.load(Ordering::Acquire) != 0 {
return true;
}
self.reconcile_terminal_commands(&self.session.command_snapshot());
let handles = self
.active_commands
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.cloned()
.collect::<Vec<_>>();
for handle in handles {
match self.session.command_status(handle.clone()).await {
Ok(status) => self.mark_terminal(&handle, status),
Err(WorkdirError::UnknownCommand(_)) => {
self.active_commands
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&handle);
}
Err(_) => {}
}
}
self.active_operations.load(Ordering::Acquire) != 0
|| !self
.active_commands
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_empty()
}
fn reconcile_terminal_commands(&self, snapshots: &[CommandSnapshot]) {
let terminal = snapshots
.iter()
.filter(|snapshot| snapshot.status != CommandStatus::Running)
.map(|snapshot| snapshot.command_id.as_str())
.collect::<HashSet<_>>();
if terminal.is_empty() {
return;
}
self.active_commands
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.retain(|handle| !terminal.contains(handle.0.as_str()));
}
fn mark_terminal(&self, handle: &CommandHandle, status: CommandStatus) {
if status != CommandStatus::Running {
self.active_commands
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(handle);
}
}
}
struct ActiveOperation<'a>(&'a RoutedSessionState);
impl Drop for ActiveOperation<'_> {
fn drop(&mut self) {
self.0.active_operations.fetch_sub(1, Ordering::AcqRel);
}
}
/// A session handle bound to one alias in a [`WorkdirSessionRouter`].
#[derive(Debug, Clone)]
pub struct RoutedWorkdirSession {
state: Arc<RoutedSessionState>,
}
#[async_trait]
impl WorkdirSession for RoutedWorkdirSession {
fn workdir(&self) -> &Workdir {
self.state.session.workdir()
}
fn capabilities(&self) -> WorkdirSessionCapabilities {
self.state.session.capabilities()
}
async fn authorize_scope_path(
&self,
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
let _active = self.state.enter()?;
self.state.session.authorize_scope_path(request).await
}
async fn scope_rules_overlap(
&self,
request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.scope_rules_overlap(request).await
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.stat(request).await
}
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.read(request).await
}
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.write(request).await
}
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.edit(request).await
}
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.list(request).await
}
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.glob(request).await
}
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError> {
let _active = self.state.enter()?;
self.state.session.grep(request).await
}
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
let _active = self.state.enter()?;
let handle = self.state.session.start_command(request).await?;
self.state
.active_commands
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(handle.clone());
Ok(handle)
}
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
let _active = self.state.enter()?;
let status = self.state.session.command_status(handle.clone()).await?;
self.state.mark_terminal(&handle, status);
Ok(status)
}
async fn command_output(
&self,
request: CommandOutputRequest,
) -> Result<CommandOutput, WorkdirError> {
let _active = self.state.enter()?;
let handle = request.handle.clone();
let output = self.state.session.command_output(request).await?;
self.state.mark_terminal(&handle, output.status);
Ok(output)
}
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
let _active = self.state.enter()?;
self.state.session.cancel_command(handle.clone()).await?;
self.state
.active_commands
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&handle);
Ok(())
}
fn subscribe_command_events(&self) -> Option<broadcast::Receiver<CommandEvent>> {
self.state.session.subscribe_command_events()
}
fn command_snapshot(&self) -> Vec<CommandSnapshot> {
let snapshots = self.state.session.command_snapshot();
self.state.reconcile_terminal_commands(&snapshots);
snapshots
}
async fn close(&self) -> Result<(), WorkdirError> {
if self.state.is_busy() {
return Err(WorkdirError::Conflict(
"Workdir attachment has active operations or commands".to_string(),
));
}
self.state.session.close().await
}
}
/// Worker-owned map of stable attachment aliases to provider-neutral sessions.
#[derive(Debug, Default)]
pub struct WorkdirSessionRouter {
sessions: RwLock<BTreeMap<WorkdirAttachmentAlias, Arc<RoutedSessionState>>>,
}
impl WorkdirSessionRouter {
pub fn new() -> Self {
Self::default()
}
pub fn aliases(&self) -> Vec<WorkdirAttachmentAlias> {
self.sessions
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.keys()
.cloned()
.collect()
}
pub fn len(&self) -> usize {
self.sessions
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn attach(
&self,
alias: WorkdirAttachmentAlias,
session: WorkdirSessionHandle,
) -> Result<WorkdirSessionHandle, WorkdirError> {
let mut sessions = self.sessions.write().map_err(|_| {
WorkdirError::Unavailable("Workdir attachment router is poisoned".to_string())
})?;
if sessions.contains_key(&alias) {
return Err(WorkdirError::Conflict(format!(
"Workdir attachment alias `{alias}` already exists"
)));
}
let state = Arc::new(RoutedSessionState {
session,
detached: AtomicBool::new(false),
active_operations: AtomicUsize::new(0),
active_commands: Mutex::new(HashSet::new()),
});
sessions.insert(alias, state.clone());
Ok(Arc::new(RoutedWorkdirSession { state }))
}
pub fn session(&self, alias: &WorkdirAttachmentAlias) -> Option<WorkdirSessionHandle> {
self.sessions
.read()
.ok()?
.get(alias)
.cloned()
.map(|state| Arc::new(RoutedWorkdirSession { state }) as WorkdirSessionHandle)
}
/// Compatibility projection used only while filesystem tool schemas have no
/// `target_workdir`: exactly one attachment is unambiguous; zero or multiple
/// attachments expose no implicit primary.
pub fn only_session(&self) -> Option<WorkdirSessionHandle> {
let sessions = self.sessions.read().ok()?;
if sessions.len() != 1 {
return None;
}
sessions
.values()
.next()
.cloned()
.map(|state| Arc::new(RoutedWorkdirSession { state }) as WorkdirSessionHandle)
}
pub fn can_detach(&self, alias: &WorkdirAttachmentAlias) -> Result<(), WorkdirError> {
let state = self
.sessions
.read()
.map_err(|_| {
WorkdirError::Unavailable("Workdir attachment router is poisoned".to_string())
})?
.get(alias)
.cloned()
.ok_or_else(|| WorkdirError::NotFound(alias.as_str().into()))?;
if state.detached.load(Ordering::Acquire) {
return Err(WorkdirError::Unavailable(
"Workdir attachment is already detaching".to_string(),
));
}
if state.is_busy() {
return Err(WorkdirError::Conflict(format!(
"Workdir attachment `{alias}` has active operations or commands"
)));
}
Ok(())
}
/// Atomically fence new operations before an external detach transaction begins.
pub async fn begin_detach(&self, alias: &WorkdirAttachmentAlias) -> Result<(), WorkdirError> {
let state = self
.sessions
.read()
.map_err(|_| {
WorkdirError::Unavailable("Workdir attachment router is poisoned".to_string())
})?
.get(alias)
.cloned()
.ok_or_else(|| WorkdirError::NotFound(alias.as_str().into()))?;
if state.detached.swap(true, Ordering::AcqRel) {
return Err(WorkdirError::Unavailable(
"Workdir attachment is already detaching".to_string(),
));
}
if state.is_busy_for_detach().await {
state.detached.store(false, Ordering::Release);
return Err(WorkdirError::Conflict(format!(
"Workdir attachment `{alias}` has active operations or commands"
)));
}
Ok(())
}
/// Reopen a route after the external detach transaction failed before commit.
pub fn cancel_detach(&self, alias: &WorkdirAttachmentAlias) {
if let Ok(sessions) = self.sessions.read()
&& let Some(state) = sessions.get(alias)
{
state.detached.store(false, Ordering::Release);
}
}
pub async fn finish_detach(
&self,
alias: &WorkdirAttachmentAlias,
) -> Result<WorkdirSessionHandle, WorkdirError> {
let state = {
let sessions = self.sessions.read().map_err(|_| {
WorkdirError::Unavailable("Workdir attachment router is poisoned".to_string())
})?;
sessions
.get(alias)
.cloned()
.ok_or_else(|| WorkdirError::NotFound(alias.as_str().into()))?
};
if !state.detached.load(Ordering::Acquire) {
return Err(WorkdirError::Conflict(format!(
"Workdir attachment `{alias}` was not prepared for detach"
)));
}
if state.is_busy_for_detach().await {
state.detached.store(false, Ordering::Release);
return Err(WorkdirError::Conflict(format!(
"Workdir attachment `{alias}` has active operations or commands"
)));
}
if let Err(error) = state.session.close().await {
state.detached.store(false, Ordering::Release);
return Err(error);
}
let mut sessions = self.sessions.write().map_err(|_| {
WorkdirError::Unavailable("Workdir attachment router is poisoned".to_string())
})?;
if sessions
.get(alias)
.is_some_and(|current| Arc::ptr_eq(current, &state))
{
sessions.remove(alias);
}
Ok(state.session.clone())
}
pub async fn detach(
&self,
alias: &WorkdirAttachmentAlias,
) -> Result<WorkdirSessionHandle, WorkdirError> {
self.begin_detach(alias).await?;
self.finish_detach(alias).await
}
/// Close every attachment during Worker shutdown or startup rollback.
///
/// Unlike [`Self::detach`], lifecycle teardown is authoritative and delegates
/// cancellation or draining of active commands to each provider session.
pub async fn close_all(&self) -> Result<(), WorkdirError> {
let aliases = self.aliases();
for alias in aliases {
let state = {
let sessions = self.sessions.read().map_err(|_| {
WorkdirError::Unavailable("Workdir attachment router is poisoned".to_string())
})?;
sessions.get(&alias).cloned()
};
let Some(state) = state else {
continue;
};
state.detached.store(true, Ordering::Release);
if let Err(error) = state.session.close().await {
state.detached.store(false, Ordering::Release);
return Err(error);
}
let mut sessions = self.sessions.write().map_err(|_| {
WorkdirError::Unavailable("Workdir attachment router is poisoned".to_string())
})?;
if sessions
.get(&alias)
.is_some_and(|current| Arc::ptr_eq(current, &state))
{
sessions.remove(&alias);
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::LocalWorkdirSession;
use manifest::Scope;
use tempfile::TempDir;
fn session(id: &str, dir: &TempDir) -> WorkdirSessionHandle {
Arc::new(LocalWorkdirSession::materialized_bound(
Workdir::new(id),
dir.path().to_path_buf(),
dir.path().to_path_buf(),
manifest::SharedScope::new(Scope::writable(dir.path()).unwrap()),
WorkdirSessionCapabilities::ALL,
))
}
#[tokio::test]
async fn router_holds_multiple_aliases_without_implicit_primary() {
let left = TempDir::new().unwrap();
let right = TempDir::new().unwrap();
let router = WorkdirSessionRouter::new();
router
.attach(
WorkdirAttachmentAlias::new("left").unwrap(),
session("wd-left", &left),
)
.unwrap();
router
.attach(
WorkdirAttachmentAlias::new("right").unwrap(),
session("wd-right", &right),
)
.unwrap();
assert_eq!(
router
.aliases()
.iter()
.map(WorkdirAttachmentAlias::as_str)
.collect::<Vec<_>>(),
["left", "right"]
);
assert!(router.only_session().is_none());
router.close_all().await.unwrap();
}
#[tokio::test]
async fn detach_allows_naturally_completed_command_without_final_poll() {
let dir = TempDir::new().unwrap();
let router = WorkdirSessionRouter::new();
let alias = WorkdirAttachmentAlias::new("checkout").unwrap();
let underlying = session("wd", &dir);
let routed = router
.attach(alias.clone(), Arc::clone(&underlying))
.unwrap();
let handle = routed
.start_command(CommandRequest {
command: "printf done".to_string(),
timeout_secs: 5,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: None,
})
.await
.unwrap();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
let completed = underlying.command_snapshot().iter().any(|snapshot| {
snapshot.command_id == handle.0 && snapshot.status != CommandStatus::Running
});
if completed {
break;
}
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
router.detach(&alias).await.unwrap();
assert!(router.is_empty());
}
#[tokio::test]
async fn lifecycle_close_all_closes_sessions_with_active_commands() {
let dir = TempDir::new().unwrap();
let router = WorkdirSessionRouter::new();
let alias = WorkdirAttachmentAlias::new("checkout").unwrap();
let underlying = session("wd", &dir);
let routed = router.attach(alias, Arc::clone(&underlying)).unwrap();
let handle = routed
.start_command(CommandRequest {
command: "sleep 30".to_string(),
timeout_secs: 60,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: None,
})
.await
.unwrap();
router.close_all().await.unwrap();
assert!(router.is_empty());
assert!(matches!(
underlying.command_status(handle).await,
Err(WorkdirError::Unavailable(_))
));
}
#[tokio::test]
async fn detach_rejects_active_command_until_cancelled() {
let dir = TempDir::new().unwrap();
let router = WorkdirSessionRouter::new();
let alias = WorkdirAttachmentAlias::new("checkout").unwrap();
let routed = router.attach(alias.clone(), session("wd", &dir)).unwrap();
let handle = routed
.start_command(CommandRequest {
command: "sleep 30".to_string(),
timeout_secs: 60,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: None,
})
.await
.unwrap();
assert!(matches!(
router.detach(&alias).await,
Err(WorkdirError::Conflict(_))
));
routed.cancel_command(handle).await.unwrap();
router.detach(&alias).await.unwrap();
assert!(router.is_empty());
}
}
+7 -7
View File
@@ -70,6 +70,9 @@ pub struct RuntimeWorkingDirectoryCleanupTarget {
#[serde(deny_unknown_fields)]
pub struct RuntimeWorkingDirectorySummary {
pub working_directory_id: String,
/// Optional human-facing label. Never used for routing or identity.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_selector: Option<String>,
@@ -92,8 +95,6 @@ pub struct RuntimeWorkingDirectorySummary {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanliness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub occupied_by: Option<WorkingDirectoryOccupancy>,
}
@@ -101,6 +102,9 @@ pub struct RuntimeWorkingDirectorySummary {
#[serde(deny_unknown_fields)]
pub struct WorkingDirectorySummary {
pub working_directory_id: String,
/// Optional human-facing label. Never used for attachment routing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub repository_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_selector: Option<String>,
@@ -123,8 +127,6 @@ pub struct WorkingDirectorySummary {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanliness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub occupied_by: Option<WorkingDirectoryOccupancy>,
}
@@ -184,8 +186,6 @@ pub struct WorkingDirectoryCurrentObservation {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanliness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub occupied_by: Option<WorkingDirectoryOccupancy>,
}
@@ -214,6 +214,7 @@ mod tests {
fn working_directory_summary_preserves_server_projection_wire_shape() {
let summary = WorkingDirectorySummary {
working_directory_id: "wd-1".to_string(),
display_name: Some("Checkout".to_string()),
repository_key: "repo-1".to_string(),
creation_selector: None,
creation_ref: None,
@@ -226,7 +227,6 @@ mod tests {
cleanup_target: None,
status: WorkingDirectoryStatusKind::Active,
cleanliness: None,
primary_worker_id: None,
occupied_by: None,
};
let value = serde_json::to_value(summary).unwrap();
+24 -9
View File
@@ -161,6 +161,8 @@ pub struct WorkingDirectoryRepositoryAccessRequest {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryRequest {
pub repository: WorkingDirectoryRepository,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default)]
pub materializer: MaterializerKind,
/// Backend-assigned stable Workdir id. Runtimes use this when present so the
@@ -197,12 +199,25 @@ pub struct RepositoryRefObservation {
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryClaim {
pub struct WorkingDirectoryAttachmentClaim {
pub alias: workdir::WorkdirAttachmentAlias,
pub working_directory_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relative_cwd: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryAttachmentRequest {
pub alias: workdir::WorkdirAttachmentAlias,
pub working_directory: WorkingDirectoryRequest,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryAttachmentStatus {
pub alias: workdir::WorkdirAttachmentAlias,
pub working_directory: WorkingDirectoryStatus,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryStatus {
pub summary: WorkingDirectorySummary,
@@ -247,10 +262,10 @@ pub struct CreateWorkerRequest {
pub config_bundle: Option<ConfigBundleRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_input: Option<WorkerInput>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory_request: Option<WorkingDirectoryRequest>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryClaim>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workdir_attachment_requests: Vec<WorkingDirectoryAttachmentRequest>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workdir_attachments: Vec<WorkingDirectoryAttachmentClaim>,
/// Backend-only feature enablement. Grants still define local Runtime peers;
/// the Workspace provider reauthorizes its dynamic set per operation.
#[serde(default, skip_serializing_if = "is_false")]
@@ -308,8 +323,8 @@ pub struct WorkerSummary {
pub worker_state: Option<protocol::WorkerStateSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryStatus>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workdir_attachments: Vec<WorkingDirectoryAttachmentStatus>,
pub profile: ProfileSelector,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
@@ -333,8 +348,8 @@ pub struct WorkerDetail {
pub worker_state: Option<protocol::WorkerStateSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryStatus>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workdir_attachments: Vec<WorkingDirectoryAttachmentStatus>,
pub profile: ProfileSelector,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
+8 -7
View File
@@ -12,6 +12,7 @@ use crate::observation::WorkerObservationEvent;
use crate::working_directory::{WorkingDirectoryBinding, WorkingDirectoryDiagnostic};
use protocol::{Method, UploadedFileRef};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
use std::sync::Arc;
use workdir::WorkdirSessionHandle;
@@ -246,7 +247,7 @@ pub struct WorkerExecutionSpawnRequest {
pub request: crate::catalog::CreateWorkerRequest,
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
pub context: WorkerExecutionContext,
pub working_directory: Option<WorkingDirectoryBinding>,
pub workdir_attachments: BTreeMap<workdir::WorkdirAttachmentAlias, WorkingDirectoryBinding>,
pub config_bundle: Option<ConfigBundle>,
}
@@ -257,8 +258,8 @@ pub struct WorkerExecutionRestoreRequest {
pub request: crate::catalog::CreateWorkerRequest,
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
pub context: WorkerExecutionContext,
pub previous_working_directory: Option<WorkingDirectoryStatus>,
pub working_directory: Option<WorkingDirectoryBinding>,
pub previous_workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
pub workdir_attachments: BTreeMap<workdir::WorkdirAttachmentAlias, WorkingDirectoryBinding>,
pub config_bundle: Option<ConfigBundle>,
}
@@ -284,7 +285,7 @@ pub enum WorkerExecutionSpawnResult {
Connected {
handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<WorkingDirectoryStatus>,
workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
},
/// A read-only preflight rejected the operation before live work started.
Rejected(WorkerExecutionResult),
@@ -297,7 +298,7 @@ pub enum WorkerExecutionSpawnResult {
result: WorkerExecutionResult,
handle: Option<WorkerExecutionHandle>,
worker_state: Option<protocol::WorkerStateSnapshot>,
working_directory: Option<WorkingDirectoryStatus>,
workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
},
/// Legacy spawn failure. Restore implementations must use `RolledBack` or
/// `ReconciliationRequired` after crossing their live side-effect boundary.
@@ -308,12 +309,12 @@ impl WorkerExecutionSpawnResult {
pub fn connected(
handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<WorkingDirectoryStatus>,
workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
) -> Self {
Self::Connected {
handle,
worker_state,
working_directory,
workdir_attachments,
}
}
}
+161 -7
View File
@@ -1,6 +1,7 @@
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerRestoreIntent, WorkerStatus,
WorkingDirectoryStatus,
WorkingDirectoryAttachmentClaim, WorkingDirectoryAttachmentRequest,
WorkingDirectoryAttachmentStatus, WorkingDirectoryRequest,
};
use crate::config_bundle::ConfigBundle;
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
@@ -442,7 +443,7 @@ pub(crate) struct PersistedWorkerRecord {
pub(crate) status: WorkerStatus,
pub(crate) execution_state: PersistedWorkerExecutionState,
pub(crate) workspace_id: Option<String>,
pub(crate) working_directory: Option<WorkingDirectoryStatus>,
pub(crate) workdir_attachments: Vec<WorkingDirectoryAttachmentStatus>,
}
fn runtime_io_error(operation: &'static str, path: &Path, source: std::io::Error) -> RuntimeError {
@@ -1685,11 +1686,18 @@ struct WorkerIdentityRecord {
status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
working_directory: Option<WorkingDirectoryStatus>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
workdir_attachments: Vec<WorkingDirectoryAttachmentStatus>,
/// Schema-v8 compatibility input. New records serialize only `workdir_attachments`.
#[serde(
default,
rename = "working_directory",
skip_serializing_if = "Option::is_none"
)]
legacy_working_directory: Option<crate::catalog::WorkingDirectoryStatus>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize)]
#[serde(deny_unknown_fields)]
struct WorkerExecutionRecord {
schema_version: u32,
@@ -1699,6 +1707,72 @@ struct WorkerExecutionRecord {
restore_intent: WorkerRestoreIntent,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawWorkerExecutionRecord {
schema_version: u32,
request: serde_json::Value,
#[serde(default)]
binding: Option<PersistedWorkerExecutionBinding>,
restore_intent: WorkerRestoreIntent,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LegacyWorkingDirectoryClaim {
working_directory_id: String,
#[serde(default)]
relative_cwd: Option<String>,
}
impl<'de> Deserialize<'de> for WorkerExecutionRecord {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = RawWorkerExecutionRecord::deserialize(deserializer)?;
let mut request_value = raw.request;
let request_object = request_value
.as_object_mut()
.ok_or_else(|| serde::de::Error::custom("Worker create request must be an object"))?;
let legacy_request = request_object.remove("working_directory_request");
let legacy_claim = request_object.remove("working_directory");
let mut request: CreateWorkerRequest =
serde_json::from_value(request_value).map_err(serde::de::Error::custom)?;
if request.workdir_attachment_requests.is_empty() && request.workdir_attachments.is_empty()
{
let alias = workdir::WorkdirAttachmentAlias::new("workdir")
.expect("static compatibility alias is valid");
if let Some(value) = legacy_request.filter(|value| !value.is_null()) {
let working_directory: WorkingDirectoryRequest =
serde_json::from_value(value).map_err(serde::de::Error::custom)?;
request
.workdir_attachment_requests
.push(WorkingDirectoryAttachmentRequest {
alias,
working_directory,
});
} else if let Some(value) = legacy_claim.filter(|value| !value.is_null()) {
let claim: LegacyWorkingDirectoryClaim =
serde_json::from_value(value).map_err(serde::de::Error::custom)?;
request
.workdir_attachments
.push(WorkingDirectoryAttachmentClaim {
alias,
working_directory_id: claim.working_directory_id,
relative_cwd: claim.relative_cwd,
});
}
}
Ok(Self {
schema_version: raw.schema_version,
request,
binding: raw.binding,
restore_intent: raw.restore_intent,
})
}
}
impl WorkerIdentityRecord {
fn from_persisted(worker: &PersistedWorkerRecord) -> Self {
Self {
@@ -1712,7 +1786,8 @@ impl WorkerIdentityRecord {
created_at_ms: worker.created_at_ms,
status: worker.status,
workspace_id: worker.workspace_id.clone(),
working_directory: worker.working_directory.clone(),
workdir_attachments: worker.workdir_attachments.clone(),
legacy_working_directory: None,
}
}
@@ -1760,6 +1835,19 @@ impl WorkerIdentityRecord {
self,
execution_state: PersistedWorkerExecutionState,
) -> PersistedWorkerRecord {
let workdir_attachments = if self.workdir_attachments.is_empty() {
self.legacy_working_directory
.map(|working_directory| {
vec![WorkingDirectoryAttachmentStatus {
alias: workdir::WorkdirAttachmentAlias::new("workdir")
.expect("static compatibility alias is valid"),
working_directory,
}]
})
.unwrap_or_default()
} else {
self.workdir_attachments
};
PersistedWorkerRecord {
worker_ref: self.worker_ref,
worker_id: self.worker_id,
@@ -1771,7 +1859,7 @@ impl WorkerIdentityRecord {
status: self.status,
execution_state,
workspace_id: self.workspace_id,
working_directory: self.working_directory,
workdir_attachments,
}
}
}
@@ -2215,6 +2303,72 @@ mod tests {
document
}
#[test]
fn schema_v8_singular_workdir_fields_normalize_to_one_attachment() {
let worker_id = WorkerId::now_v7();
let workdir_status = serde_json::json!({
"summary": {
"working_directory_id": "wd-legacy",
"repository_id": "repo",
"materializer_kind": "runtime_git_clone",
"status": "active"
}
});
let identity: WorkerIdentityRecord = serde_json::from_value(serde_json::json!({
"schema_version": SCHEMA_VERSION,
"worker_ref": { "worker_id": worker_id },
"worker_id": worker_id,
"profile": { "kind": "builtin", "value": "builtin:coder" },
"display_name": null,
"profile_source": {
"id": "archive-1",
"digest": "sha256:archive",
"size_bytes": 0,
"source_graph": {
"source_count": 0,
"total_source_bytes": 0,
"entrypoints": {},
"import_count": 0
}
},
"config_bundle": null,
"status": "stopped",
"working_directory": workdir_status
}))
.unwrap();
let persisted = identity.into_persisted(PersistedWorkerExecutionState::Unavailable);
assert_eq!(persisted.workdir_attachments.len(), 1);
assert_eq!(persisted.workdir_attachments[0].alias.as_str(), "workdir");
assert_eq!(
persisted.workdir_attachments[0]
.working_directory
.summary
.working_directory_id,
"wd-legacy"
);
let mut request = schema_v7_worker_document(worker_id)["request"].clone();
request["working_directory"] = serde_json::json!({
"working_directory_id": "wd-legacy",
"relative_cwd": "crates/yoi"
});
let execution: WorkerExecutionRecord = serde_json::from_value(serde_json::json!({
"schema_version": SCHEMA_VERSION,
"request": request,
"binding": null,
"restore_intent": "explicit"
}))
.unwrap();
assert!(execution.request.workdir_attachment_requests.is_empty());
assert_eq!(execution.request.workdir_attachments.len(), 1);
let attachment = &execution.request.workdir_attachments[0];
assert_eq!(attachment.alias.as_str(), "workdir");
assert_eq!(attachment.working_directory_id, "wd-legacy");
assert_eq!(attachment.relative_cwd.as_deref(), Some("crates/yoi"));
let serialized = serde_json::to_value(execution).unwrap();
assert!(serialized["request"].get("working_directory").is_none());
}
#[test]
fn startup_recovers_staging_created_before_source_backup() {
let temp = tempfile::tempdir().unwrap();
+30 -14
View File
@@ -2282,7 +2282,11 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
if path.ends_with("/workspace-api") {
return Some("workers:create");
}
if path.ends_with("/input") || path.ends_with("/restore") || path.contains("/attachments") {
if path.ends_with("/input")
|| path.ends_with("/restore")
|| path.contains("/attachments")
|| path.contains("/workdir-attachments")
{
return Some("workers:input");
}
if path.ends_with("/stop") || path.ends_with("/cancel") {
@@ -3072,8 +3076,8 @@ mod tests {
digest: bundle.metadata.digest,
}),
initial_input: None,
working_directory_request: None,
working_directory: None,
workdir_attachment_requests: Vec::new(),
workdir_attachments: Vec::new(),
worker_observation_enabled: false,
worker_observation_grants: Vec::new(),
workspace_api: None,
@@ -3319,10 +3323,16 @@ mod tests {
worker_state: protocol::WorkerStateSnapshot {
..protocol::WorkerStatus::Idle.into()
},
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
workdir_attachments: request
.workdir_attachments
.iter()
.map(
|(alias, binding)| crate::catalog::WorkingDirectoryAttachmentStatus {
alias: alias.clone(),
working_directory: binding.status(),
},
)
.collect(),
}
}
@@ -3335,7 +3345,7 @@ mod tests {
worker_state: protocol::WorkerStateSnapshot {
..protocol::WorkerStatus::Idle.into()
},
working_directory: request.previous_working_directory,
workdir_attachments: request.previous_workdir_attachments,
}
}
@@ -3907,10 +3917,16 @@ mod ws_tests {
worker_state: protocol::WorkerStateSnapshot {
..protocol::WorkerStatus::Idle.into()
},
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
workdir_attachments: request
.workdir_attachments
.iter()
.map(
|(alias, binding)| crate::catalog::WorkingDirectoryAttachmentStatus {
alias: alias.clone(),
working_directory: binding.status(),
},
)
.collect(),
}
}
@@ -4020,8 +4036,8 @@ mod ws_tests {
digest: bundle.metadata.digest,
}),
initial_input: None,
working_directory_request: None,
working_directory: None,
workdir_attachment_requests: Vec::new(),
workdir_attachments: Vec::new(),
worker_observation_enabled: false,
worker_observation_grants: Vec::new(),
workspace_api: None,
@@ -423,6 +423,31 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
Ok(runtime_api::WorkerResponse { worker })
}
async fn replace_worker_workdir_attachments(
&self,
worker_id: String,
value: runtime_api::WorkerWorkdirAttachmentsRequest,
) -> Result<runtime_api::WorkerResponse, runtime_api::RuntimeApiError> {
let attachments = request(value.workdir_attachments)?;
let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?;
let worker = match auth_workspace_scope(&self.state, auth_extension().as_ref())
.map_err(api_error)?
{
Some(scope) => self
.state
.runtime
.replace_worker_workdir_attachments_scoped(&scope, &worker_ref, attachments),
None => self
.state
.runtime
.replace_worker_workdir_attachments(&worker_ref, attachments),
}
.map_err(RuntimeHttpRestError::runtime)
.map_err(api_error)?;
let worker = response(worker)?;
Ok(runtime_api::WorkerResponse { worker })
}
async fn complete_worker_arguments(
&self,
worker_id: String,
+412 -147
View File
@@ -1,7 +1,8 @@
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveSource,
RepositoryRefObservation, RepositoryRefObservationRequest, WorkerDetail, WorkerLifecycleAck,
WorkerRestoreIntent, WorkerStatus, WorkerSummary, WorkingDirectoryRepositoryAccessRequest,
WorkerRestoreIntent, WorkerStatus, WorkerSummary, WorkingDirectoryAttachmentClaim,
WorkingDirectoryAttachmentStatus, WorkingDirectoryRepositoryAccessRequest,
WorkingDirectoryRequest, WorkingDirectoryStatus as CatalogWorkingDirectoryStatus,
WorkspaceApiRef,
};
@@ -43,7 +44,7 @@ use crate::retention::{
use protocol::subscription::{
EventSubscriptionSelector, SubscriptionEventPayload, SubscriptionSnapshot,
SubscriptionValidationError, SubscriptionWorkdirId, SubscriptionWorker, SubscriptionWorkerId,
SubscriptionWorkerState,
SubscriptionWorkerState, SubscriptionWorkerWorkdirAttachment,
};
use protocol::{Event, Method};
use server_api::WorkerRestoreState;
@@ -729,8 +730,9 @@ impl Runtime {
.get(&worker_ref.worker_id)
.is_some_and(|worker| {
worker.belongs_to_workspace(&scope.workspace_id)
&& worker.working_directory.as_ref().is_some_and(|status| {
status.summary.working_directory_id == working_directory_id
&& worker.workdir_attachments.iter().any(|attachment| {
attachment.working_directory.summary.working_directory_id
== working_directory_id
})
});
if !owns_workdir {
@@ -764,7 +766,7 @@ impl Runtime {
let backend = {
let state = self.lock()?;
state.ensure_running()?;
if let Some(worker_id) = state.primary_worker_id_for_workdir(working_directory_id) {
if let Some(worker_id) = state.worker_id_for_workdir(working_directory_id) {
return Err(RuntimeError::InvalidRequest(format!(
"working directory {working_directory_id} is assigned to worker {worker_id}"
)));
@@ -792,12 +794,8 @@ impl Runtime {
fn annotate_working_directory_status(
&self,
mut status: CatalogWorkingDirectoryStatus,
status: CatalogWorkingDirectoryStatus,
) -> Result<CatalogWorkingDirectoryStatus, RuntimeError> {
let state = self.lock()?;
status.summary.primary_worker_id = state
.primary_worker_id_for_workdir(status.summary.working_directory_id.as_str())
.map(|worker_id| worker_id.to_string());
Ok(status)
}
@@ -1023,10 +1021,8 @@ impl Runtime {
return Ok(existing.detail());
}
state.validate_worker_config_boundary(&request)?;
if let Some(working_directory_id) = requested_primary_workdir_id(&request) {
if let Some(owner_worker_id) =
state.primary_worker_id_for_workdir(working_directory_id)
{
for working_directory_id in requested_workdir_ids(&request) {
if let Some(owner_worker_id) = state.worker_id_for_workdir(working_directory_id) {
return Err(RuntimeError::InvalidRequest(format!(
"working directory {working_directory_id} is already assigned to worker {owner_worker_id}"
)));
@@ -1064,7 +1060,7 @@ impl Runtime {
execution_metadata_available: true,
execution_bound: true,
restore_intent: WorkerRestoreIntent::Explicit,
working_directory: None,
workdir_attachments: Vec::new(),
execution_handle: None,
internal_workers: InternalWorkerActivityProjection::default(),
};
@@ -1076,19 +1072,19 @@ impl Runtime {
request,
workspace_scope: scope.cloned(),
context: self.execution_context(worker_ref.clone()),
working_directory: None,
workdir_attachments: BTreeMap::new(),
config_bundle,
};
(backend, worker_ref, spawn_request)
};
let spawn_result = backend.spawn_worker(spawn_request);
let (handle, initial_worker_state, working_directory) = match spawn_result {
let (handle, initial_worker_state, workdir_attachments) = match spawn_result {
WorkerExecutionSpawnResult::Connected {
handle,
worker_state,
working_directory,
} => (handle, worker_state, working_directory),
workdir_attachments,
} => (handle, worker_state, workdir_attachments),
WorkerExecutionSpawnResult::Rejected(result)
| WorkerExecutionSpawnResult::RolledBack(result)
| WorkerExecutionSpawnResult::Errored(result) => {
@@ -1101,8 +1097,30 @@ impl Runtime {
result,
});
}
WorkerExecutionSpawnResult::ReconciliationRequired { result, .. } => {
self.rollback_failed_create(&worker_ref)?;
WorkerExecutionSpawnResult::ReconciliationRequired {
result,
handle,
worker_state,
workdir_attachments,
} => {
let retain_result =
if let (Some(handle), Some(worker_state)) = (handle, worker_state) {
self.retain_restore_execution_evidence(
&worker_ref,
handle,
worker_state,
workdir_attachments,
)
} else {
self.retain_create_reconciliation_pending(&worker_ref, workdir_attachments)
};
if let Err(error) = retain_result {
tracing::error!(
worker_id = %worker_ref.worker_id,
%error,
"failed to persist Worker create reconciliation evidence"
);
}
return Err(RuntimeError::WorkerExecutionRejected {
worker_id: worker_ref.worker_id.clone(),
operation: result.operation,
@@ -1160,7 +1178,7 @@ impl Runtime {
&worker_ref,
handle.clone(),
initial_worker_state.clone(),
working_directory,
workdir_attachments,
dispatch_result,
) {
Ok(detail) => detail,
@@ -1179,7 +1197,7 @@ impl Runtime {
&worker_ref,
handle.clone(),
initial_worker_state,
working_directory,
workdir_attachments,
WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn),
) {
Ok(detail) => Ok(detail),
@@ -1425,6 +1443,119 @@ impl Runtime {
Ok(state.worker(worker_ref)?.detail())
}
pub fn replace_worker_workdir_attachments_scoped(
&self,
scope: &RuntimeWorkspaceScope,
worker_ref: &WorkerRef,
attachments: Vec<WorkingDirectoryAttachmentClaim>,
) -> Result<WorkerDetail, RuntimeError> {
self.ensure_worker_in_workspace(scope, worker_ref)?;
self.replace_worker_workdir_attachments(worker_ref, attachments)
}
/// Persist the exact current attachment set used by live mutation and future restore.
pub fn replace_worker_workdir_attachments(
&self,
worker_ref: &WorkerRef,
mut attachments: Vec<WorkingDirectoryAttachmentClaim>,
) -> Result<WorkerDetail, RuntimeError> {
let operation_lock = self.worker_operation_lock(worker_ref.worker_id)?;
let _operation_guard = operation_lock
.lock()
.map_err(|_| RuntimeError::StatePoisoned)?;
let backend = {
let state = self.lock()?;
let worker = state.worker(worker_ref)?;
if worker.execution_handle.is_none() || !worker.status.is_active() {
return Err(RuntimeError::WorkerExecutionUnavailable {
worker_id: worker.worker_id,
message: "Workdir attachments can only be changed for a live Worker"
.to_string(),
});
}
let request = worker.request.as_ref().ok_or_else(|| {
RuntimeError::WorkerExecutionUnavailable {
worker_id: worker.worker_id,
message: "persisted Worker restore request is unavailable".to_string(),
}
})?;
for attachment in &mut attachments {
if attachment.relative_cwd.is_none()
&& let Some(previous) = request.workdir_attachments.iter().find(|previous| {
previous.alias == attachment.alias
&& previous.working_directory_id == attachment.working_directory_id
})
{
attachment.relative_cwd = previous.relative_cwd.clone();
}
}
let mut candidate = request.clone();
candidate.workdir_attachment_requests.clear();
candidate.workdir_attachments = attachments.clone();
validate_create_worker_request(&candidate)?;
state.execution_backend.clone().ok_or_else(|| {
RuntimeError::ExecutionBackendUnavailable {
message: "Workdir attachment mutation requires an execution backend"
.to_string(),
}
})?
};
let statuses = attachments
.iter()
.map(|attachment| {
backend
.working_directory(&attachment.working_directory_id)
.map(|working_directory| WorkingDirectoryAttachmentStatus {
alias: attachment.alias.clone(),
working_directory,
})
.map_err(RuntimeError::from)
})
.collect::<Result<Vec<_>, RuntimeError>>()?;
if let Some(inactive) = statuses.iter().find(|attachment| {
attachment.working_directory.summary.status
!= workdir::workspace::WorkingDirectoryStatusKind::Active
}) {
return Err(RuntimeError::InvalidRequest(format!(
"working directory {} is not active ({})",
inactive.working_directory.summary.working_directory_id,
inactive.working_directory.summary.status
)));
}
let mut state = self.lock()?;
for attachment in &attachments {
if let Some(owner_worker_id) =
state.worker_id_for_workdir(&attachment.working_directory_id)
&& owner_worker_id != worker_ref.worker_id
{
return Err(RuntimeError::InvalidRequest(format!(
"working directory {} is already assigned to worker {owner_worker_id}",
attachment.working_directory_id
)));
}
}
let previous = state.worker(worker_ref)?.clone();
{
let worker = state.worker_mut(worker_ref)?;
let request = worker
.request
.as_mut()
.expect("restore request checked above");
request.workdir_attachment_requests.clear();
request.workdir_attachments = attachments;
worker.workdir_attachments = statuses;
}
if let Err(error) = state.persist_worker(&worker_ref.worker_id) {
state.workers.insert(worker_ref.worker_id, previous);
return Err(error);
}
state.publish_worker_upsert(worker_ref.worker_id)?;
Ok(state.worker(worker_ref)?.detail())
}
/// Observe a Worker's live protocol availability or its retained Session.
/// The same lifecycle lock used by stop/restore fences the decision and read.
pub fn worker_session_scoped(
@@ -1570,8 +1701,8 @@ impl Runtime {
request: worker_request,
workspace_scope,
context: self.execution_context(worker_ref.clone()),
previous_working_directory: worker.working_directory.clone(),
working_directory: None,
previous_workdir_attachments: worker.workdir_attachments.clone(),
workdir_attachments: BTreeMap::new(),
config_bundle: None,
};
(backend, request)
@@ -1589,14 +1720,14 @@ impl Runtime {
WorkerExecutionSpawnResult::Connected {
handle,
worker_state,
working_directory,
workdir_attachments,
} => {
let commit = self.commit_restored_worker_execution(
worker_ref,
handle.clone(),
worker_state.clone(),
WorkerStatus::Idle,
working_directory.clone(),
workdir_attachments.clone(),
);
match commit {
Ok(worker) => Ok(RuntimeWorkerRestoreResult::accepted(worker)),
@@ -1625,7 +1756,7 @@ impl Runtime {
worker_ref,
handle,
worker_state,
working_directory,
workdir_attachments,
) {
tracing::error!(
worker_id = %worker_ref.worker_id,
@@ -1663,14 +1794,14 @@ impl Runtime {
result: _result,
handle,
worker_state,
working_directory,
workdir_attachments,
} => {
if let (Some(handle), Some(worker_state)) = (handle, worker_state) {
if let Err(retain_error) = self.retain_restore_execution_evidence(
worker_ref,
handle,
worker_state,
working_directory,
workdir_attachments,
) {
tracing::error!(
worker_id = %worker_ref.worker_id,
@@ -2079,7 +2210,7 @@ impl Runtime {
worker_ref: &WorkerRef,
handle: WorkerExecutionHandle,
initial_worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<CatalogWorkingDirectoryStatus>,
workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
result: WorkerExecutionResult,
) -> Result<WorkerDetail, RuntimeError> {
let mut state = self.lock()?;
@@ -2094,7 +2225,7 @@ impl Runtime {
let _ = worker.apply_worker_state(snapshot);
}
worker.restore_intent = restore_intent_for_status(worker.status);
worker.working_directory = working_directory;
worker.workdir_attachments = workdir_attachments;
worker.detail()
};
state.publish_worker_upsert(worker_ref.worker_id)?;
@@ -2367,9 +2498,13 @@ impl Runtime {
state.ensure_running()?;
state.ensure_worker_ref(worker_ref)?;
let worker = state.worker(worker_ref)?;
if worker.execution_handle.is_some() && worker.status.is_active() {
if worker.status.is_active()
&& (worker.execution_handle.is_some()
|| (!worker.execution_metadata_available
&& !worker.workdir_attachments.is_empty()))
{
return Err(RuntimeError::InvalidRequest(format!(
"worker {} is running and must be stopped before deletion",
"worker {} is active or requires reconciliation and must be stopped before deletion",
worker_ref.worker_id
)));
}
@@ -2398,9 +2533,12 @@ impl Runtime {
state.ensure_running()?;
state.ensure_worker_ref(worker_ref)?;
let worker = state.worker(worker_ref)?;
if worker.execution_handle.is_some() && worker.status.is_active() {
if worker.status.is_active()
&& (worker.execution_handle.is_some()
|| (!worker.execution_metadata_available && !worker.workdir_attachments.is_empty()))
{
return Err(RuntimeError::InvalidRequest(format!(
"worker {} became active before deletion",
"worker {} became active or reconciliation-required before deletion",
worker_ref.worker_id
)));
}
@@ -2662,7 +2800,7 @@ impl Runtime {
handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
status: WorkerStatus,
working_directory: Option<CatalogWorkingDirectoryStatus>,
workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
) -> Result<WorkerDetail, RuntimeError> {
let mut state = self.lock()?;
state.ensure_worker_ref(worker_ref)?;
@@ -2679,7 +2817,7 @@ impl Runtime {
candidate.status = status;
let _ = candidate.apply_worker_state(&worker_state);
candidate.restore_intent = restore_intent_for_status(candidate.status);
candidate.working_directory = working_directory;
candidate.workdir_attachments = workdir_attachments;
// Validate the exact subscription projection before persistence. The
// subsequent publish uses the same pure projection while the Worker
@@ -2708,7 +2846,7 @@ impl Runtime {
worker_ref: &WorkerRef,
handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<CatalogWorkingDirectoryStatus>,
workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
) -> Result<(), RuntimeError> {
let mut state = self.lock()?;
let mut candidate = state.worker(worker_ref)?.clone();
@@ -2718,7 +2856,26 @@ impl Runtime {
candidate.status = WorkerStatus::Idle;
let _ = candidate.apply_worker_state(&worker_state);
candidate.restore_intent = WorkerRestoreIntent::Automatic;
candidate.working_directory = working_directory;
candidate.workdir_attachments = workdir_attachments;
state.workers.insert(worker_ref.worker_id, candidate);
state.publish_worker_upsert(worker_ref.worker_id)?;
state.persist_worker(&worker_ref.worker_id)?;
Ok(())
}
fn retain_create_reconciliation_pending(
&self,
worker_ref: &WorkerRef,
workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
) -> Result<(), RuntimeError> {
let mut state = self.lock()?;
let mut candidate = state.worker(worker_ref)?.clone();
candidate.execution_handle = None;
candidate.execution_metadata_available = false;
candidate.execution_bound = true;
candidate.status = WorkerStatus::Idle;
candidate.restore_intent = WorkerRestoreIntent::Explicit;
candidate.workdir_attachments = workdir_attachments;
state.workers.insert(worker_ref.worker_id, candidate);
state.publish_worker_upsert(worker_ref.worker_id)?;
state.persist_worker(&worker_ref.worker_id)?;
@@ -3078,7 +3235,7 @@ impl RuntimeState {
execution_metadata_available,
execution_bound,
restore_intent,
working_directory: worker.working_directory,
workdir_attachments: worker.workdir_attachments,
execution_handle: None,
internal_workers: InternalWorkerActivityProjection::default(),
},
@@ -3394,18 +3551,25 @@ impl RuntimeState {
) -> Result<SubscriptionWorker, RuntimeError> {
let worker_id = SubscriptionWorkerId::new(worker.worker_id.to_string())
.map_err(subscription_validation_error)?;
let repository_id = worker
.working_directory
.as_ref()
.map(|working_directory| working_directory.summary.repository_id.clone());
let working_directory_id = worker
.working_directory
.as_ref()
.map(|working_directory| {
SubscriptionWorkdirId::new(working_directory.summary.working_directory_id.clone())
.map_err(subscription_validation_error)
let workdir_attachments = worker
.workdir_attachments
.iter()
.map(|attachment| {
Ok(SubscriptionWorkerWorkdirAttachment {
alias: attachment.alias.to_string(),
repository_id: Some(attachment.working_directory.summary.repository_id.clone()),
repository_key: None,
working_directory_id: SubscriptionWorkdirId::new(
attachment
.working_directory
.summary
.working_directory_id
.clone(),
)
.map_err(subscription_validation_error)?,
})
})
.transpose()?;
.collect::<Result<Vec<_>, RuntimeError>>()?;
let profile = match &worker.profile {
ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => Some(name.clone()),
};
@@ -3425,9 +3589,7 @@ impl RuntimeState {
workspace_id: worker.workspace_id.clone(),
display_name: worker.display_name.clone(),
profile,
repository_id,
repository_key: None,
working_directory_id,
workdir_attachments,
})
}
@@ -3525,22 +3687,15 @@ impl RuntimeState {
}
}
fn primary_worker_id_for_workdir(&self, working_directory_id: &str) -> Option<WorkerId> {
fn worker_id_for_workdir(&self, working_directory_id: &str) -> Option<WorkerId> {
self.workers.values().find_map(|worker| {
if worker
.working_directory
.as_ref()
.is_some_and(|binding| binding.summary.working_directory_id == working_directory_id)
|| worker
.request
.as_ref()
.and_then(requested_primary_workdir_id)
== Some(working_directory_id)
{
Some(worker.worker_id)
} else {
None
}
let has_status = worker.workdir_attachments.iter().any(|attachment| {
attachment.working_directory.summary.working_directory_id == working_directory_id
});
let has_request = worker.request.as_ref().is_some_and(|request| {
requested_workdir_ids(request).contains(&working_directory_id)
});
(has_status || has_request).then_some(worker.worker_id)
})
}
@@ -3976,7 +4131,7 @@ struct WorkerRecord {
execution_metadata_available: bool,
execution_bound: bool,
restore_intent: WorkerRestoreIntent,
working_directory: Option<CatalogWorkingDirectoryStatus>,
workdir_attachments: Vec<crate::catalog::WorkingDirectoryAttachmentStatus>,
execution_handle: Option<WorkerExecutionHandle>,
internal_workers: InternalWorkerActivityProjection,
}
@@ -3999,7 +4154,7 @@ impl WorkerRecord {
execution_metadata_available: self.execution_metadata_available,
worker_state: self.worker_state.clone(),
workspace_id: self.workspace_id.clone(),
working_directory: self.working_directory.clone(),
workdir_attachments: self.workdir_attachments.clone(),
profile: self.profile.clone(),
display_name: self.display_name.clone(),
profile_source: self.profile_source.clone(),
@@ -4016,7 +4171,7 @@ impl WorkerRecord {
execution_metadata_available: self.execution_metadata_available,
worker_state: self.worker_state.clone(),
workspace_id: self.workspace_id.clone(),
working_directory: self.working_directory.clone(),
workdir_attachments: self.workdir_attachments.clone(),
profile: self.profile.clone(),
display_name: self.display_name.clone(),
profile_source: self.profile_source.clone(),
@@ -4049,7 +4204,7 @@ impl WorkerRecord {
status: self.status,
execution_state,
workspace_id: self.workspace_id.clone(),
working_directory: self.working_directory.clone(),
workdir_attachments: self.workdir_attachments.clone(),
}
}
}
@@ -4100,25 +4255,28 @@ fn repository_resource_error(error: BackendResourceError) -> RuntimeError {
fn durable_create_worker_request(request: &CreateWorkerRequest) -> CreateWorkerRequest {
let mut durable = request.clone();
if let Some(working_directory) = durable.working_directory_request.as_mut()
&& let Some(materialization) = working_directory.materialization.as_mut()
{
materialization.ssh = None;
for attachment in &mut durable.workdir_attachment_requests {
if let Some(materialization) = attachment.working_directory.materialization.as_mut() {
materialization.ssh = None;
}
}
durable
}
fn requested_primary_workdir_id(request: &CreateWorkerRequest) -> Option<&str> {
fn requested_workdir_ids(request: &CreateWorkerRequest) -> Vec<&str> {
request
.working_directory
.as_ref()
.workdir_attachments
.iter()
.map(|claim| claim.working_directory_id.as_str())
.or_else(|| {
.chain(
request
.working_directory_request
.as_ref()
.and_then(|request| request.backend_workdir_id.as_deref())
})
.workdir_attachment_requests
.iter()
.filter_map(|attachment| {
attachment.working_directory.backend_workdir_id.as_deref()
}),
)
.collect()
}
fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), RuntimeError> {
@@ -4127,6 +4285,42 @@ fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), R
"create_fingerprint must not be empty".to_string(),
));
}
let mut aliases = std::collections::BTreeSet::new();
let mut workdir_ids = std::collections::BTreeSet::new();
for (alias, workdir_id) in request
.workdir_attachments
.iter()
.map(|attachment| {
(
&attachment.alias,
Some(attachment.working_directory_id.as_str()),
)
})
.chain(
request
.workdir_attachment_requests
.iter()
.map(|attachment| {
(
&attachment.alias,
attachment.working_directory.backend_workdir_id.as_deref(),
)
}),
)
{
if !aliases.insert(alias.as_str()) {
return Err(RuntimeError::InvalidRequest(format!(
"duplicate Workdir attachment alias `{alias}`"
)));
}
if let Some(workdir_id) = workdir_id
&& !workdir_ids.insert(workdir_id)
{
return Err(RuntimeError::InvalidRequest(format!(
"working directory `{workdir_id}` cannot be attached more than once"
)));
}
}
match &request.profile_source {
crate::catalog::ProfileSourceArchiveSource::Embedded { archive } => {
archive.verify().map_err(|err| {
@@ -4420,8 +4614,8 @@ mod tests {
use crate::catalog::{
ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext,
RepositorySshCredentialCandidate, RepositorySshMaterializationAccess, SensitiveString,
WorkingDirectoryClaim, WorkingDirectoryRepository, WorkingDirectoryRequest,
WorkspaceApiRef,
WorkingDirectoryAttachmentClaim, WorkingDirectoryAttachmentRequest,
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef,
};
use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
@@ -4913,8 +5107,8 @@ mod tests {
digest: bundle.metadata.digest,
}),
initial_input: None,
working_directory_request: None,
working_directory: None,
workdir_attachment_requests: Vec::new(),
workdir_attachments: Vec::new(),
worker_observation_enabled: false,
worker_observation_grants: Vec::new(),
workspace_api: None,
@@ -4929,60 +5123,64 @@ mod tests {
#[test]
fn durable_worker_request_omits_repository_credentials() {
let mut request = task_request("worker-secret-redaction");
request.working_directory_request = Some(WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: "repository-1".to_string(),
provider: "git".to_string(),
source: server_api::RepositorySource {
kind: server_api::RepositorySourceKind::Ssh,
uri: "ssh://git@example.test/repo.git".to_string(),
request.workdir_attachment_requests = vec![WorkingDirectoryAttachmentRequest {
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
working_directory: WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: "repository-1".to_string(),
provider: "git".to_string(),
source: server_api::RepositorySource {
kind: server_api::RepositorySourceKind::Ssh,
uri: "ssh://git@example.test/repo.git".to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
source_revision: 1,
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
materializer: MaterializerKind::RuntimeGitClone,
backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
ssh: Some(RepositorySshMaterializationAccess {
credential_candidates: vec![RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
private_key: SensitiveString::new("private-key-bytes"),
}],
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: server_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: repository_resource_handle(),
known_hosts_entry: SensitiveString::new("known-hosts-entry"),
display_name: None,
materializer: MaterializerKind::RuntimeGitClone,
backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
ssh: Some(RepositorySshMaterializationAccess {
credential_candidates: vec![RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
private_key: SensitiveString::new("private-key-bytes"),
}],
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: server_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: repository_resource_handle(),
known_hosts_entry: SensitiveString::new("known-hosts-entry"),
}),
}),
}),
});
},
}];
let durable = durable_create_worker_request(&request);
assert!(
request
.working_directory_request
.as_ref()
.and_then(|working_directory| working_directory.materialization.as_ref())
.workdir_attachment_requests
.first()
.and_then(|attachment| attachment.working_directory.materialization.as_ref())
.and_then(|materialization| materialization.ssh.as_ref())
.is_some()
);
assert!(
durable
.working_directory_request
.as_ref()
.and_then(|working_directory| working_directory.materialization.as_ref())
.workdir_attachment_requests
.first()
.and_then(|attachment| attachment.working_directory.materialization.as_ref())
.and_then(|materialization| materialization.ssh.as_ref())
.is_none()
);
@@ -5172,6 +5370,7 @@ mod tests {
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
display_name: None,
materializer: MaterializerKind::RuntimeGitClone,
backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext {
@@ -5342,6 +5541,7 @@ mod tests {
#[derive(Default)]
struct TestExecutionBackend {
spawn_result: Mutex<Option<WorkerExecutionSpawnResult>>,
dispatch_result: Mutex<Option<WorkerExecutionResult>>,
stop_result: Mutex<Option<WorkerExecutionResult>>,
stop_gate: Mutex<Option<Arc<RestoreGate>>>,
@@ -5447,6 +5647,9 @@ mod tests {
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
if let Some(result) = self.spawn_result.lock().unwrap().take() {
return result;
}
self.config_bundles
.lock()
.unwrap()
@@ -5460,10 +5663,16 @@ mod tests {
worker_state: protocol::WorkerStateSnapshot {
..protocol::WorkerStatus::Idle.into()
},
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
workdir_attachments: request
.workdir_attachments
.iter()
.map(
|(alias, binding)| crate::catalog::WorkingDirectoryAttachmentStatus {
alias: alias.clone(),
working_directory: binding.status(),
},
)
.collect(),
}
}
@@ -5502,10 +5711,16 @@ mod tests {
worker_state: protocol::WorkerStateSnapshot {
..protocol::WorkerStatus::Idle.into()
},
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
workdir_attachments: request
.workdir_attachments
.iter()
.map(
|(alias, binding)| crate::catalog::WorkingDirectoryAttachmentStatus {
alias: alias.clone(),
working_directory: binding.status(),
},
)
.collect(),
}
}
@@ -6263,10 +6478,11 @@ mod tests {
let runtime = runtime_with_backend();
let mut request = task_request("idempotent");
request.create_fingerprint = "sha256:input-1".to_string();
request.working_directory = Some(WorkingDirectoryClaim {
request.workdir_attachments = vec![WorkingDirectoryAttachmentClaim {
alias: workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
working_directory_id: "workdir-idempotent".to_string(),
relative_cwd: None,
});
}];
let first = runtime.create_worker(request.clone()).unwrap();
let workdir_count_after_first = runtime.list_working_directories().unwrap().len();
@@ -6631,6 +6847,49 @@ mod tests {
assert!(runtime.list_workers().unwrap().is_empty());
}
#[test]
fn reconciliation_required_create_retains_durable_worker_evidence() {
let (runtime, backend) = runtime_and_backend();
runtime.store_config_bundle(test_bundle()).unwrap();
*backend.spawn_result.lock().unwrap() =
Some(WorkerExecutionSpawnResult::ReconciliationRequired {
result: WorkerExecutionResult::errored(
WorkerExecutionOperation::Spawn,
"Workdir cleanup could not be proven",
),
handle: None,
worker_state: None,
workdir_attachments: vec![WorkingDirectoryAttachmentStatus {
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
working_directory: serde_json::from_value(serde_json::json!({
"summary": {
"working_directory_id": "wd-uncertain",
"repository_id": "repo",
"materializer_kind": "runtime_git_clone",
"status": "active"
}
}))
.unwrap(),
}],
});
let request = task_request("uncertain create cleanup");
let worker_ref = WorkerRef::new(request.worker_id);
let error = runtime.create_worker(request).unwrap_err();
assert!(matches!(
error,
RuntimeError::WorkerExecutionRejected { .. }
));
let retained = runtime.worker_detail(&worker_ref).unwrap();
assert_eq!(retained.status, WorkerStatus::Idle);
assert!(!retained.execution_metadata_available);
assert!(matches!(
runtime.delete_worker(&worker_ref),
Err(RuntimeError::InvalidRequest(_))
));
}
#[test]
fn create_worker_without_execution_backend_is_rejected_and_not_persisted() {
let runtime = Runtime::new_memory();
@@ -6804,10 +7063,16 @@ mod tests {
worker_state: protocol::WorkerStateSnapshot {
..protocol::WorkerStatus::Idle.into()
},
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
workdir_attachments: request
.workdir_attachments
.iter()
.map(
|(alias, binding)| crate::catalog::WorkingDirectoryAttachmentStatus {
alias: alias.clone(),
working_directory: binding.status(),
},
)
.collect(),
}
}
@@ -7667,7 +7932,7 @@ mod tests {
),
handle: None,
worker_state: None,
working_directory: None,
workdir_attachments: Vec::new(),
},
);
+470 -195
View File
@@ -7,7 +7,7 @@
//! socket paths, session paths, manifests, credentials, or handles leave this
//! module.
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -17,8 +17,9 @@ use std::time::Duration;
use crate::auth::{BACKEND_RESOURCE_FETCH_PERMISSION, RuntimeIdentityMaterial};
use crate::catalog::{
CreateWorkerRequest, ProfileSourceArchiveSource, RepositoryRefObservation,
RepositoryRefObservationRequest, WorkingDirectoryRepositoryAccessRequest,
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkingDirectoryStatusKind,
RepositoryRefObservationRequest, WorkingDirectoryAttachmentStatus,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkingDirectoryStatusKind,
};
use crate::config_bundle::{ConfigBundle, workspace_config_etag};
use crate::execution::{
@@ -44,6 +45,45 @@ use protocol::{Event, Method, Segment, WorkerCommandEnvelope};
static NEXT_INTERNAL_COMMAND_ID: AtomicU64 = AtomicU64::new(1);
fn rollback_materialized_spawn_workdirs(
materializer: &dyn WorkingDirectoryMaterializer,
bindings: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
workdir_ids: &[String],
failure_context: &str,
) -> Result<(), WorkerExecutionSpawnResult> {
let mut cleanup_failures = Vec::new();
let mut uncertain_attachments = Vec::new();
for workdir_id in workdir_ids.iter().rev() {
if let Err(error) = materializer.cleanup_working_directory(workdir_id) {
cleanup_failures.push(format!("{workdir_id}: {error}"));
if let Some((alias, binding)) = bindings
.iter()
.find(|(_, binding)| binding.working_directory.id == *workdir_id)
{
uncertain_attachments.push(WorkingDirectoryAttachmentStatus {
alias: alias.clone(),
working_directory: binding.status(),
});
}
}
}
if cleanup_failures.is_empty() {
return Ok(());
}
Err(WorkerExecutionSpawnResult::ReconciliationRequired {
result: WorkerExecutionResult::errored(
WorkerExecutionOperation::Spawn,
format!(
"{failure_context}; Workdir cleanup could not be proven: {}",
cleanup_failures.join("; ")
),
),
handle: None,
worker_state: None,
workdir_attachments: uncertain_attachments,
})
}
fn next_internal_command(
state: &RwLock<protocol::WorkerStateSnapshot>,
) -> Result<WorkerCommandEnvelope, String> {
@@ -68,7 +108,10 @@ use session_store::{FsStore, FsWorkerStore};
use tokio::runtime::Runtime;
#[cfg(feature = "ws-server")]
use tokio::sync::broadcast;
use workdir::{LocalWorkdirSession, Workdir, WorkdirSessionCapabilities, WorkdirSessionHandle};
use workdir::{
LocalWorkdirSession, Workdir, WorkdirAttachmentAlias, WorkdirSessionCapabilities,
WorkdirSessionHandle, WorkdirSessionRouter,
};
#[cfg(test)]
use worker::WorkerController;
@@ -730,6 +773,29 @@ fn runtime_local_workdir_session(
))
}
fn runtime_local_workdir_router(
attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
scope: manifest::SharedScope,
) -> Result<Arc<WorkdirSessionRouter>, String> {
let router = Arc::new(WorkdirSessionRouter::new());
for (alias, binding) in attachments {
router
.attach(
alias.clone(),
runtime_local_workdir_session(
&binding.working_directory.id,
binding.root(),
binding.cwd(),
scope.clone(),
binding.command_environment(),
binding.session_resources(),
),
)
.map_err(|error| format!("bind Workdir attachment `{alias}`: {error}"))?;
}
Ok(router)
}
fn bind_workspace_memory_settings(
manifest: &mut manifest::WorkerManifest,
request: &CreateWorkerRequest,
@@ -845,15 +911,14 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
) -> Result<RuntimeWorkerController, String> {
let worker_name = Self::runtime_worker_name(&request);
let profile = Self::runtime_profile(&request);
let has_local_filesystem = request.working_directory.is_some();
let worker_root = request
.working_directory
.as_ref()
let has_local_filesystem = request.workdir_attachments.len() == 1;
let only_binding = (request.workdir_attachments.len() == 1)
.then(|| request.workdir_attachments.values().next())
.flatten();
let worker_root = only_binding
.map(|binding| binding.root().to_path_buf())
.unwrap_or_else(|| self.profile_base_dir.clone());
let filesystem_authority = request
.working_directory
.as_ref()
let filesystem_authority = only_binding
.map(|binding| {
WorkerFilesystemAuthority::local(
binding.root().to_path_buf(),
@@ -965,18 +1030,10 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
})?;
let worker = prepared.worker_mut();
validate_worker_memory_settings(worker.manifest(), &request.request)?;
if let Some(binding) = request.working_directory.as_ref() {
worker.bind_workdir_session(Some(runtime_local_workdir_session(
&binding.working_directory.id,
binding.root(),
binding.cwd(),
worker.scope().clone(),
binding.command_environment(),
binding.session_resources(),
)));
} else {
worker.bind_workdir_session(None);
}
worker.bind_workdir_sessions(runtime_local_workdir_router(
&request.workdir_attachments,
worker.scope().clone(),
)?);
if let (Some(runtime_id), Some(workspace_id)) =
(observation_runtime_id, observation_workspace_id.clone())
&& observation_enabled
@@ -1095,9 +1152,10 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
request: WorkerExecutionRestoreRequest,
) -> Result<RuntimeWorkerController, String> {
let worker_name = Self::runtime_worker_name_for_ref(&request.worker_ref);
let filesystem_authority = request
.working_directory
.as_ref()
let only_binding = (request.workdir_attachments.len() == 1)
.then(|| request.workdir_attachments.values().next())
.flatten();
let filesystem_authority = only_binding
.map(|binding| {
WorkerFilesystemAuthority::local(
binding.root().to_path_buf(),
@@ -1201,18 +1259,10 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
};
validate_worker_memory_settings(worker.manifest(), &request.request)?;
let flow_transition_enabled = worker.manifest().feature.flow.enabled;
if let Some(binding) = request.working_directory.as_ref() {
worker.bind_workdir_session(Some(runtime_local_workdir_session(
&binding.working_directory.id,
binding.root(),
binding.cwd(),
worker.scope().clone(),
binding.command_environment(),
binding.session_resources(),
)));
} else {
worker.bind_workdir_session(None);
}
worker.bind_workdir_sessions(runtime_local_workdir_router(
&request.workdir_attachments,
worker.scope().clone(),
)?);
if let (Some(runtime_id), Some(workspace_id)) =
(observation_runtime_id, observation_workspace_id.clone())
&& observation_enabled
@@ -1738,7 +1788,7 @@ where
handle: WorkerHandle,
shutdown: Arc<tokio::sync::Mutex<Option<worker::ShutdownReceiver>>>,
controller_task: tokio::task::JoinHandle<()>,
working_directory: Option<WorkingDirectoryBinding>,
workdir_attachments: BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
workspace_client: Option<Arc<dyn WorkspaceClient>>,
) -> WorkerExecutionSpawnResult {
#[cfg(feature = "ws-server")]
@@ -1818,8 +1868,13 @@ where
Err(_) => {
let retained_state =
worker_state.read().ok().map(|state| state.clone());
let retained_working_directory =
working_directory.as_ref().map(|binding| binding.status());
let retained_workdir_attachments = workdir_attachments
.iter()
.map(|(alias, binding)| crate::catalog::WorkingDirectoryAttachmentStatus {
alias: alias.clone(),
working_directory: binding.status(),
})
.collect();
let retained_handle = self
.retain_uncertain_unconnected_controller(
&worker_ref,
@@ -1834,7 +1889,7 @@ where
result,
handle: retained_handle,
worker_state: retained_state,
working_directory: retained_working_directory,
workdir_attachments: retained_workdir_attachments,
}
}
}
@@ -1878,7 +1933,7 @@ where
result,
handle: Some(existing_handle),
worker_state: existing_worker_state,
working_directory: None,
workdir_attachments: Vec::new(),
},
}
} else {
@@ -1904,7 +1959,15 @@ where
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()),
worker_state: connected_worker_state,
working_directory: working_directory.map(|binding| binding.status()),
workdir_attachments: workdir_attachments
.into_iter()
.map(
|(alias, binding)| crate::catalog::WorkingDirectoryAttachmentStatus {
alias,
working_directory: binding.status(),
},
)
.collect(),
}
}
}
@@ -2118,67 +2181,134 @@ where
}
let mut request = request;
let mut rollback_working_directory = None;
let working_directory = match (
request.request.working_directory_request.as_ref(),
request.request.working_directory.as_ref(),
) {
(Some(_), Some(_)) => {
let Some(materializer) = self.working_directory_materializer.as_ref().or_else(|| {
(request.request.workdir_attachment_requests.is_empty()
&& request.request.workdir_attachments.is_empty())
.then_some(())
.and(None)
}) else {
if request.request.workdir_attachment_requests.is_empty()
&& request.request.workdir_attachments.is_empty()
{
// No provider is needed for a Workdir-less Worker.
let factory = self.factory.clone();
let bridge_context = request.context.clone();
let worker_ref = request.worker_ref.clone();
let spawn_result = self
.run_cancellable_on_adapter_runtime(self.spawn_restore_timeout, async move {
factory.spawn_controller(request).await
});
let controller = match spawn_result {
Ok(controller) => controller,
Err(message) => {
return WorkerExecutionSpawnResult::Errored(
WorkerExecutionResult::errored(
WorkerExecutionOperation::Spawn,
message,
),
);
}
};
return self.connect_handle(
WorkerExecutionOperation::Spawn,
worker_ref,
bridge_context,
controller.handle,
controller.shutdown,
controller.controller_task,
BTreeMap::new(),
Some(controller.workspace_client),
);
}
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
"Workdir attachments were requested, but no materializer is configured for this Runtime backend",
));
};
let mut rollback_workdirs: Vec<String> = Vec::new();
for attachment in request.request.workdir_attachment_requests.clone() {
if request.workdir_attachments.contains_key(&attachment.alias) {
let message = format!("duplicate Workdir attachment alias `{}`", attachment.alias);
if let Err(result) = rollback_materialized_spawn_workdirs(
materializer.as_ref(),
&request.workdir_attachments,
&rollback_workdirs,
&message,
) {
return result;
}
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
"worker spawn cannot specify both working_directory_request and working_directory",
message,
));
}
(Some(working_directory_request), None) => {
let Some(materializer) = self.working_directory_materializer.as_ref() else {
match materializer.materialize(&request.worker_ref, &attachment.working_directory) {
Ok(binding) => {
rollback_workdirs.push(binding.working_directory.id.clone());
request
.workdir_attachments
.insert(attachment.alias, binding);
}
Err(error) => {
let message = error.to_string();
if let Err(result) = rollback_materialized_spawn_workdirs(
materializer.as_ref(),
&request.workdir_attachments,
&rollback_workdirs,
&message,
) {
return result;
}
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
"working directory materialization requested, but no materializer is configured for this runtime backend",
message,
));
};
match materializer.materialize(&request.worker_ref, working_directory_request) {
Ok(binding) => {
request.working_directory = Some(binding.clone());
rollback_working_directory = Some(binding.clone());
Some(binding)
}
Err(error) => {
return WorkerExecutionSpawnResult::Rejected(
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
error.to_string(),
),
);
}
}
}
(None, Some(working_directory)) => {
let Some(materializer) = self.working_directory_materializer.as_ref() else {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
"working directory working_directory requested, but no materializer is configured for this runtime backend",
));
};
match materializer.bind_working_directory(
&working_directory.working_directory_id,
working_directory.relative_cwd.as_deref(),
}
for attachment in request.request.workdir_attachments.clone() {
if request.workdir_attachments.contains_key(&attachment.alias) {
let message = format!("duplicate Workdir attachment alias `{}`", attachment.alias);
if let Err(result) = rollback_materialized_spawn_workdirs(
materializer.as_ref(),
&request.workdir_attachments,
&rollback_workdirs,
&message,
) {
Ok(binding) => {
request.working_directory = Some(binding.clone());
Some(binding)
}
Err(error) => {
return WorkerExecutionSpawnResult::Rejected(
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
error.to_string(),
),
);
return result;
}
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
message,
));
}
match materializer.bind_working_directory(
&attachment.working_directory_id,
attachment.relative_cwd.as_deref(),
) {
Ok(binding) => {
request
.workdir_attachments
.insert(attachment.alias, binding);
}
Err(error) => {
let message = error.to_string();
if let Err(result) = rollback_materialized_spawn_workdirs(
materializer.as_ref(),
&request.workdir_attachments,
&rollback_workdirs,
&message,
) {
return result;
}
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Spawn,
message,
));
}
}
(None, None) => None,
};
}
let workdir_attachments = request.workdir_attachments.clone();
let factory = self.factory.clone();
let bridge_context = request.context.clone();
@@ -2191,11 +2321,13 @@ where
let controller = match spawn_result {
Ok(controller) => controller,
Err(message) => {
if let (Some(materializer), Some(binding)) = (
self.working_directory_materializer.as_ref(),
rollback_working_directory.as_ref(),
if let Err(result) = rollback_materialized_spawn_workdirs(
materializer.as_ref(),
&workdir_attachments,
&rollback_workdirs,
&message,
) {
let _ = materializer.cleanup_working_directory(&binding.working_directory.id);
return result;
}
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
WorkerExecutionOperation::Spawn,
@@ -2211,7 +2343,7 @@ where
controller.handle,
controller.shutdown,
controller.controller_task,
working_directory,
workdir_attachments,
Some(controller.workspace_client),
)
}
@@ -2232,46 +2364,56 @@ where
}
drop(workers);
if let Some(previous) = request.previous_working_directory.as_ref() {
if !request.previous_workdir_attachments.is_empty() {
let materializer = self
.working_directory_materializer
.as_ref()
.ok_or_else(|| {
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
"Persisted Worker Workdir binding cannot be restored by this Runtime",
"Persisted Worker Workdir attachments cannot be restored by this Runtime",
)
})?;
let current = materializer
.working_directory_status(&previous.summary.working_directory_id)
.map_err(|message| {
WorkerExecutionResult::rejected(
for previous in &request.previous_workdir_attachments {
let current = materializer
.working_directory_status(
&previous.working_directory.summary.working_directory_id,
)
.map_err(|message| {
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
format!(
"Persisted Worker Workdir attachment `{}` is unavailable: {message}",
previous.alias
),
)
})?;
if current.summary.status != WorkingDirectoryStatusKind::Active {
return Err(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
format!("Persisted Worker Workdir is unavailable: {message}"),
)
})?;
if current.summary.status != WorkingDirectoryStatusKind::Active {
return Err(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
"Persisted Worker Workdir is not active",
));
format!(
"Persisted Worker Workdir attachment `{}` is not active",
previous.alias
),
));
}
}
}
if request.previous_working_directory.is_none()
&& request.request.working_directory_request.is_some()
if request.previous_workdir_attachments.is_empty()
&& !request.request.workdir_attachment_requests.is_empty()
{
return Err(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
"Persisted Worker Workdir allocation is unavailable",
"Persisted Worker Workdir allocations are unavailable",
));
}
if request.previous_working_directory.is_none()
&& request.request.working_directory.is_some()
if request.previous_workdir_attachments.is_empty()
&& !request.request.workdir_attachments.is_empty()
&& self.working_directory_materializer.is_none()
{
return Err(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
"Persisted Worker Workdir claim cannot be restored by this Runtime",
"Persisted Worker Workdir claims cannot be restored by this Runtime",
));
}
if let Some(workspace_api) = request.request.workspace_api.as_ref() {
@@ -2321,58 +2463,40 @@ where
}
drop(workers);
let working_directory = match request.previous_working_directory.clone() {
Some(status) => {
let Some(materializer) = self.working_directory_materializer.as_ref() else {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
"persisted worker has a working directory binding, but no materializer is configured for this runtime backend",
));
};
let relative_cwd = request
.request
.working_directory
.as_ref()
.and_then(|working_directory| working_directory.relative_cwd.as_deref());
match materializer
.bind_working_directory(&status.summary.working_directory_id, relative_cwd)
{
Ok(binding) => {
request.working_directory = Some(binding.clone());
Some(binding)
}
Err(error) => {
return WorkerExecutionSpawnResult::Rejected(
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
error.to_string(),
),
);
}
}
}
None if request.request.working_directory_request.is_some() => {
let mut workdir_attachments = BTreeMap::new();
if !request.previous_workdir_attachments.is_empty() {
let Some(materializer) = self.working_directory_materializer.as_ref() else {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
"persisted worker requested a working directory, but no persisted working directory binding is available to restore",
"persisted Worker has Workdir attachments, but no materializer is configured for this Runtime backend",
));
}
None if request.request.working_directory.is_some() => {
let Some(materializer) = self.working_directory_materializer.as_ref() else {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
"persisted worker has a working directory claim, but no materializer is configured for this runtime backend",
));
};
let working_directory =
request.request.working_directory.as_ref().expect("checked");
};
for status in request.previous_workdir_attachments.clone() {
let relative_cwd = request
.request
.workdir_attachments
.iter()
.find(|claim| claim.alias == status.alias)
.and_then(|claim| claim.relative_cwd.as_deref());
match materializer.bind_working_directory(
&working_directory.working_directory_id,
working_directory.relative_cwd.as_deref(),
&status.working_directory.summary.working_directory_id,
relative_cwd,
) {
Ok(binding) => {
request.working_directory = Some(binding.clone());
Some(binding)
if workdir_attachments
.insert(status.alias.clone(), binding)
.is_some()
{
return WorkerExecutionSpawnResult::Rejected(
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
format!(
"duplicate persisted Workdir attachment alias `{}`",
status.alias
),
),
);
}
}
Err(error) => {
return WorkerExecutionSpawnResult::Rejected(
@@ -2384,8 +2508,43 @@ where
}
}
}
None => None,
};
} else if !request.request.workdir_attachments.is_empty() {
let Some(materializer) = self.working_directory_materializer.as_ref() else {
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
"persisted Worker has Workdir claims, but no materializer is configured for this Runtime backend",
));
};
for claim in request.request.workdir_attachments.clone() {
match materializer.bind_working_directory(
&claim.working_directory_id,
claim.relative_cwd.as_deref(),
) {
Ok(binding) => {
if workdir_attachments
.insert(claim.alias.clone(), binding)
.is_some()
{
return WorkerExecutionSpawnResult::Rejected(
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
format!("duplicate Workdir attachment alias `{}`", claim.alias),
),
);
}
}
Err(error) => {
return WorkerExecutionSpawnResult::Rejected(
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Restore,
error.to_string(),
),
);
}
}
}
}
request.workdir_attachments = workdir_attachments.clone();
let factory = self.factory.clone();
let bridge_context = request.context.clone();
@@ -2405,7 +2564,7 @@ where
),
handle: None,
worker_state: None,
working_directory: None,
workdir_attachments: Vec::new(),
};
}
};
@@ -2417,7 +2576,7 @@ where
controller.handle,
controller.shutdown,
controller.controller_task,
working_directory,
workdir_attachments,
Some(controller.workspace_client),
)
}
@@ -2739,8 +2898,8 @@ mod tests {
use crate::Runtime as EmbeddedRuntime;
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, MaterializerKind, ProfileSelector,
RepositorySelector, WorkingDirectoryClaim, WorkingDirectoryRepository,
WorkingDirectoryRequest, WorkspaceApiRef,
RepositorySelector, WorkingDirectoryAttachmentClaim, WorkingDirectoryAttachmentRequest,
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef,
};
use crate::execution::WorkerExecutionContext;
use crate::identity::WorkerId;
@@ -3279,18 +3438,17 @@ mod tests {
FsStore::new(&self.store_dir).map_err(|err| err.to_string())?,
FsWorkerStore::new(&self.worker_metadata_dir).map_err(|err| err.to_string())?,
);
let filesystem_authority = request
.working_directory
.as_ref()
let only_workdir = (request.workdir_attachments.len() == 1)
.then(|| request.workdir_attachments.values().next())
.flatten();
let filesystem_authority = only_workdir
.map(|binding| {
let cwd = binding.cwd().to_path_buf();
self.observed_cwds.lock().unwrap().push(cwd.clone());
WorkerFilesystemAuthority::local(binding.root().to_path_buf(), cwd)
})
.unwrap_or(WorkerFilesystemAuthority::None);
let scope_root = request
.working_directory
.as_ref()
let scope_root = only_workdir
.map(|binding| binding.root().to_path_buf())
.unwrap_or_else(|| self.cwd.clone());
let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_worker_request(
@@ -3348,7 +3506,7 @@ mod tests {
request: request.request,
workspace_scope: request.workspace_scope,
context: request.context,
working_directory: request.working_directory,
workdir_attachments: request.workdir_attachments,
config_bundle: request.config_bundle,
};
self.spawn_controller(request).await
@@ -3513,8 +3671,8 @@ mod tests {
digest: bundle.metadata.digest,
}),
initial_input: None,
working_directory_request: None,
working_directory: None,
workdir_attachment_requests: Vec::new(),
workdir_attachments: Vec::new(),
worker_observation_enabled: false,
worker_observation_grants: Vec::new(),
workspace_api: None,
@@ -3595,6 +3753,7 @@ mod tests {
source_fingerprint: "sha256:test".to_string(),
selector: Some(RepositorySelector::from("HEAD")),
},
display_name: None,
materializer: MaterializerKind::RuntimeGitClone,
backend_workdir_id: None,
materialization: None,
@@ -3698,7 +3857,7 @@ mod tests {
request: create_request("1"),
workspace_scope: None,
context: test_execution_context(worker_ref),
working_directory: None,
workdir_attachments: BTreeMap::new(),
config_bundle: None,
};
@@ -3830,8 +3989,8 @@ mod tests {
"server-main",
)),
context: test_execution_context(worker_ref),
previous_working_directory: None,
working_directory: None,
previous_workdir_attachments: Vec::new(),
workdir_attachments: BTreeMap::new(),
config_bundle: None,
})
.await
@@ -3920,8 +4079,8 @@ mod tests {
request: create_request("embedded restore"),
workspace_scope: None,
context: test_execution_context(worker_ref),
previous_working_directory: None,
working_directory: None,
previous_workdir_attachments: Vec::new(),
workdir_attachments: BTreeMap::new(),
config_bundle: None,
})
.await
@@ -3988,7 +4147,7 @@ mod tests {
&[
"WorkerBootstrap::new(",
".prepare()",
"worker.bind_workdir_session(",
"worker.bind_workdir_sessions(",
"worker.bind_worker_observation_provider(",
"install_runtime_flow_transition_feature()",
"prepared.start()",
@@ -3998,7 +4157,7 @@ mod tests {
restore,
&[
"Worker::restore_from_worker_metadata_with_context(",
"worker.bind_workdir_session(",
"worker.bind_workdir_sessions(",
"worker.bind_worker_observation_provider(",
"install_runtime_flow_transition_feature()",
"PreparedWorker::new(",
@@ -4354,7 +4513,10 @@ mod tests {
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let mut request = create_request("chat");
request.working_directory_request = Some(working_directory_request(repo.path()));
request.workdir_attachment_requests = vec![WorkingDirectoryAttachmentRequest {
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
working_directory: working_directory_request(repo.path()),
}];
let detail = runtime.create_worker(request).unwrap();
runtime
@@ -4376,7 +4538,7 @@ mod tests {
);
}
assert!(detail.working_directory.is_some());
assert_eq!(detail.workdir_attachments.len(), 1);
let cwds = observed_cwds.lock().unwrap();
assert_eq!(cwds.len(), 1);
let cwd = &cwds[0];
@@ -4387,6 +4549,71 @@ mod tests {
observed_workspace_clients.lock().unwrap().as_slice(),
&[("unavailable".to_string(), None, false)]
);
let workdir_id = detail.workdir_attachments[0]
.working_directory
.summary
.working_directory_id
.clone();
let second = runtime.create_worker(create_request("second")).unwrap();
let conflict = runtime
.replace_worker_workdir_attachments(
&second.worker_ref,
vec![WorkingDirectoryAttachmentClaim {
alias: workdir::WorkdirAttachmentAlias::new("conflict").unwrap(),
working_directory_id: workdir_id.clone(),
relative_cwd: None,
}],
)
.unwrap_err();
assert!(matches!(
conflict,
crate::error::RuntimeError::InvalidRequest(_)
));
let inactive = runtime
.create_working_directory(working_directory_request(repo.path()))
.unwrap();
let inactive_id = inactive.summary.working_directory_id;
runtime.cleanup_working_directory(&inactive_id).unwrap();
let inactive_error = runtime
.replace_worker_workdir_attachments(
&second.worker_ref,
vec![WorkingDirectoryAttachmentClaim {
alias: workdir::WorkdirAttachmentAlias::new("inactive").unwrap(),
working_directory_id: inactive_id,
relative_cwd: None,
}],
)
.unwrap_err();
assert!(matches!(
inactive_error,
crate::error::RuntimeError::InvalidRequest(_)
| crate::error::RuntimeError::WorkingDirectory(_)
));
let detached = runtime
.replace_worker_workdir_attachments(&detail.worker_ref, Vec::new())
.unwrap();
assert!(detached.workdir_attachments.is_empty());
runtime.stop_worker(&detail.worker_ref, None).unwrap();
let restored_without_workdir = runtime.restore_worker(&detail.worker_ref).unwrap();
assert!(restored_without_workdir.workdir_attachments.is_empty());
let alias = workdir::WorkdirAttachmentAlias::new("restored").unwrap();
runtime
.replace_worker_workdir_attachments(
&detail.worker_ref,
vec![WorkingDirectoryAttachmentClaim {
alias: alias.clone(),
working_directory_id: workdir_id,
relative_cwd: None,
}],
)
.unwrap();
runtime.stop_worker(&detail.worker_ref, None).unwrap();
let stopped = runtime.worker_detail(&detail.worker_ref).unwrap();
assert_eq!(stopped.workdir_attachments.len(), 1);
assert_eq!(stopped.workdir_attachments[0].alias, alias);
}
#[test]
@@ -4620,12 +4847,13 @@ mod tests {
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let mut request = create_request("chat");
request.working_directory_request = Some(working_directory_request(repo.path()));
request.workdir_attachment_requests = vec![WorkingDirectoryAttachmentRequest {
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
working_directory: working_directory_request(repo.path()),
}];
let detail = runtime.create_worker(request).unwrap();
let workdir_id = detail
let workdir_id = detail.workdir_attachments[0]
.working_directory
.as_ref()
.unwrap()
.summary
.working_directory_id
.clone();
@@ -4642,7 +4870,6 @@ mod tests {
crate::catalog::WorkingDirectoryStatusKind::Active
);
assert_eq!(status.summary.cleanliness.as_deref(), Some("clean"));
assert_eq!(status.summary.primary_worker_id, None);
}
#[test]
@@ -4663,10 +4890,11 @@ mod tests {
let clone_root = materialized_clone_root(runtime_base.path(), &workdir_id);
assert!(clone_root.join("README.md").exists());
let mut request = create_request("chat");
request.working_directory = Some(WorkingDirectoryClaim {
request.workdir_attachments = vec![WorkingDirectoryAttachmentClaim {
alias: workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
working_directory_id: workdir_id.clone(),
relative_cwd: None,
});
}];
let error = runtime.create_worker(request).unwrap_err();
@@ -4691,7 +4919,17 @@ mod tests {
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let mut request = create_request("chat");
request.working_directory_request = Some(working_directory_request(repo.path()));
let second_repo = create_clean_repo();
request.workdir_attachment_requests = vec![
WorkingDirectoryAttachmentRequest {
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
working_directory: working_directory_request(repo.path()),
},
WorkingDirectoryAttachmentRequest {
alias: workdir::WorkdirAttachmentAlias::new("docs").unwrap(),
working_directory: working_directory_request(second_repo.path()),
},
];
let error = runtime.create_worker(request).unwrap_err();
@@ -4708,4 +4946,41 @@ mod tests {
assert_eq!(remaining_workdirs, 0);
assert!(!working_directories_root.join(".repository-cache").exists());
}
#[test]
fn second_materialization_failure_rolls_back_first_attachment() {
let runtime_base = tempfile::tempdir().unwrap();
let repo = create_clean_repo();
let missing_repo = runtime_base.path().join("missing-repository");
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
.unwrap()
.with_working_directory_materializer(RuntimeGitMaterializer::new(runtime_base.path()));
let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let mut request = create_request("chat");
request.workdir_attachment_requests = vec![
WorkingDirectoryAttachmentRequest {
alias: workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
working_directory: working_directory_request(repo.path()),
},
WorkingDirectoryAttachmentRequest {
alias: workdir::WorkdirAttachmentAlias::new("docs").unwrap(),
working_directory: working_directory_request(&missing_repo),
},
];
let error = runtime.create_worker(request).unwrap_err();
assert!(format!("{error:?}").contains("repository"));
let remaining_workdirs = fs::read_dir(runtime_base.path())
.map(|entries| {
entries
.flatten()
.filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
.count()
})
.unwrap_or(0);
assert_eq!(remaining_workdirs, 0);
}
}
@@ -57,6 +57,8 @@ pub struct WorkingDirectoryEvidence {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectory {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub repository_id: String,
pub materializer_kind: MaterializerKind,
pub evidence: WorkingDirectoryEvidence,
@@ -68,6 +70,7 @@ impl WorkingDirectory {
pub fn status_summary(&self) -> WorkingDirectorySummary {
WorkingDirectorySummary {
working_directory_id: self.id.clone(),
display_name: self.display_name.clone(),
repository_id: self.repository_id.clone(),
creation_selector: self.evidence.requested_selector.clone(),
creation_ref: Some(self.evidence.resolved_commit.clone()),
@@ -80,7 +83,6 @@ impl WorkingDirectory {
cleanup_target: Some(self.cleanup_target.clone()),
status: self.status.clone(),
cleanliness: None,
primary_worker_id: None,
occupied_by: None,
}
}
@@ -717,6 +719,7 @@ impl RuntimeGitMaterializer {
WorkingDirectoryStatus {
summary: WorkingDirectorySummary {
working_directory_id: working_directory_id.to_string(),
display_name: None,
repository_id: "unknown".to_string(),
creation_selector: None,
creation_ref: None,
@@ -733,7 +736,6 @@ impl RuntimeGitMaterializer {
}),
status: WorkingDirectoryStatusKind::Corrupted,
cleanliness: Some("unknown".to_string()),
primary_worker_id: None,
occupied_by: None,
},
}
@@ -1072,6 +1074,7 @@ impl RuntimeGitMaterializer {
let context = request.materialization.as_ref();
let working_directory = WorkingDirectory {
id: working_directory_id.clone(),
display_name: request.display_name.clone(),
repository_id: request.repository.id.clone(),
materializer_kind: MaterializerKind::RuntimeGitClone,
evidence: WorkingDirectoryEvidence {
@@ -1211,6 +1214,7 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
let working_request = WorkingDirectoryRequest {
repository: request.repository.clone(),
display_name: None,
materializer: MaterializerKind::RuntimeGitClone,
backend_workdir_id: None,
materialization: request.materialization.clone(),
@@ -3316,6 +3320,7 @@ mod tests {
source_fingerprint: "sha256:test".to_string(),
selector: Some(RepositorySelector::from("HEAD")),
},
display_name: None,
materializer: MaterializerKind::RuntimeGitClone,
backend_workdir_id: None,
materialization: None,
+4 -7
View File
@@ -145,7 +145,7 @@ where
.map_err(WorkerBootstrapError::Worker)?;
if let Some(workdir_session) = self.workdir_session {
worker.bind_workdir_session(Some(workdir_session));
worker.bind_single_workdir_session(Some(workdir_session));
}
Ok(PreparedWorker::new(worker, self.layout, self.transport))
}
@@ -168,7 +168,7 @@ where
.map_err(WorkerBootstrapError::Worker)?;
if let Some(workdir_session) = self.workdir_session {
worker.bind_workdir_session(Some(workdir_session));
worker.bind_single_workdir_session(Some(workdir_session));
}
Ok(PreparedWorker::new(worker, self.layout, self.transport))
}
@@ -222,7 +222,7 @@ where
C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
let cleanup_session = worker.workdir_session().cloned();
let cleanup_sessions = worker.workdir_sessions();
let controller = match layout {
WorkerBootstrapLayout::Direct {
runtime_base,
@@ -257,10 +257,7 @@ where
controller_task,
}),
Err(source) => {
let cleanup_failed = match cleanup_session {
Some(session) => session.close().await.is_err(),
None => false,
};
let cleanup_failed = cleanup_sessions.close_all().await.is_err();
Err(WorkerBootstrapError::Controller {
source,
cleanup_failed,
+15 -19
View File
@@ -617,7 +617,7 @@ impl WorkerController {
C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
let session = worker.workdir_session().cloned();
let sessions = worker.workdir_sessions();
let result = Self::spawn_initialized(
worker,
runtime_base,
@@ -628,10 +628,9 @@ impl WorkerController {
)
.await;
if result.is_err()
&& let Some(session) = session
&& let Err(error) = session.close().await
&& let Err(error) = sessions.close_all().await
{
tracing::warn!(%error, "Workdir session close after controller startup failure failed");
tracing::warn!(%error, "Workdir sessions close after controller startup failure failed");
}
result
}
@@ -1154,20 +1153,17 @@ where
// below so the worker borrow doesn't conflict with reads on `worker`.
let feature_config = worker.manifest().feature.clone();
let mut workdir_tool_broker = inherited_workdir_tool_broker;
if feature_config.manage_workdir.enabled && worker.workdir_session().is_none() {
if feature_config.manage_workdir.enabled {
let workspace_client = worker.workspace_client_handle();
let broker = workdir::WorkdirToolBroker::new(
workdir_tool_broker = Some(workdir::WorkdirToolBroker::new(
crate::feature::builtin::manage_workdir::WorkspaceAttachedWorkdirSession::handle(
workspace_client,
),
);
worker.bind_workdir_session(Some(broker.tool_session()));
workdir_tool_broker = Some(broker);
));
} else if workdir_tool_broker.is_none()
&& let Some(existing) = worker.workdir_session().cloned()
&& let Some(existing) = worker.workdir_session()
{
let broker = workdir::WorkdirToolBroker::new(existing);
worker.bind_workdir_session(Some(broker.tool_session()));
workdir_tool_broker = Some(broker);
}
let worker_workdir = workdir_tool_broker
@@ -1344,6 +1340,7 @@ where
feature_registry.add_module(
crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle(
workspace_client,
worker.workdir_sessions(),
Arc::new(move || {
let child_registry = shutdown_registry.clone();
Box::pin(async move { child_registry.shutdown_internal().await })
@@ -2610,14 +2607,13 @@ async fn controller_loop<C, St>(
}
}
if let Some(session) = worker.workdir_session() {
loop {
match session.close().await {
Ok(()) => break,
Err(error) => {
tracing::warn!(%error, "Workdir session close failed; retrying");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
let sessions = worker.workdir_sessions();
while !sessions.is_empty() {
match sessions.close_all().await {
Ok(()) => break,
Err(error) => {
tracing::warn!(%error, "Workdir session set close failed; retrying");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
}
@@ -52,9 +52,9 @@ const DETACH_TOOL: &str = "WorkdirDetach";
const DELETE_TOOL: &str = "WorkdirDelete";
const LIST_DESCRIPTION: &str = "List persistent Workdirs in the current Workspace through Backend Workspace API authority. The result contains safe summaries and diagnostics, never host paths or Runtime connection details.";
const CREATE_DESCRIPTION: &str = "Materialize a persistent Workdir on a selected Runtime from a Workspace repository and optional selector. This does not change this Worker's attachment; use WorkdirAttach explicitly after creation.";
const ATTACH_DESCRIPTION: &str = "Attach this Worker to one existing Workdir. The Backend enforces one active Workdir per Worker and one active Worker per Workdir, then opens an ephemeral operation session.";
const DETACH_DESCRIPTION: &str = "Detach this Worker from its active Workdir and release Workdir occupancy. Any ephemeral operation session is closed.";
const CREATE_DESCRIPTION: &str = "Materialize a persistent Workdir with an optional human-facing display name on a selected Runtime from a Workspace repository and optional selector. The display name is independent of attachment aliases. This does not change this Worker's attachment; use WorkdirAttach explicitly after creation.";
const ATTACH_DESCRIPTION: &str = "Attach one existing Workdir to this Worker under a stable Worker-local alias. The Backend preserves one active Worker per Workdir while allowing this Worker to own multiple aliases.";
const DETACH_DESCRIPTION: &str = "Detach one Workdir attachment by its Worker-local alias. Active operations, commands, or Internal SubWorkers cause safe rejection rather than implicit authority loss.";
pub(crate) type BeforeWorkdirRelease =
Arc<dyn Fn() -> Pin<Box<dyn Future<Output = std::io::Result<()>> + Send>> + Send + Sync>;
pub(crate) type AfterWorkdirAttach = Arc<dyn Fn() + Send + Sync>;
@@ -64,6 +64,7 @@ const DELETE_DESCRIPTION: &str = "Request removal of one persistent Workdir by i
#[derive(Clone)]
pub struct ManageWorkdirFeature {
client: Arc<dyn WorkspaceClient>,
session_router: Arc<workdir::WorkdirSessionRouter>,
before_workdir_release: Option<BeforeWorkdirRelease>,
after_workdir_attach: Option<AfterWorkdirAttach>,
}
@@ -82,6 +83,7 @@ impl ManageWorkdirFeature {
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
Self {
client,
session_router: Arc::new(workdir::WorkdirSessionRouter::new()),
before_workdir_release: None,
after_workdir_attach: None,
}
@@ -89,11 +91,13 @@ impl ManageWorkdirFeature {
pub(crate) fn with_child_lifecycle(
client: Arc<dyn WorkspaceClient>,
session_router: Arc<workdir::WorkdirSessionRouter>,
before_workdir_release: BeforeWorkdirRelease,
after_workdir_attach: AfterWorkdirAttach,
) -> Self {
Self {
client,
session_router,
before_workdir_release: Some(before_workdir_release),
after_workdir_attach: Some(after_workdir_attach),
}
@@ -116,10 +120,12 @@ impl FeatureModule for ManageWorkdirFeature {
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone()).with_child_lifecycle(
self.before_workdir_release.clone(),
self.after_workdir_attach.clone(),
);
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
.with_session_router(self.session_router.clone())
.with_child_lifecycle(
self.before_workdir_release.clone(),
self.after_workdir_attach.clone(),
);
for (name, definition) in [
(
LIST_TOOL,
@@ -183,6 +189,8 @@ impl FeatureModule for ManageWorkdirFeature {
#[derive(Clone)]
struct WorkspaceHttpWorkdirBackend {
client: Arc<dyn WorkspaceClient>,
session_router: Arc<workdir::WorkdirSessionRouter>,
attachment_mutation_lock: Arc<tokio::sync::Mutex<()>>,
before_workdir_release: Option<BeforeWorkdirRelease>,
after_workdir_attach: Option<AfterWorkdirAttach>,
}
@@ -399,11 +407,18 @@ impl WorkspaceHttpWorkdirBackend {
fn new(client: Arc<dyn WorkspaceClient>) -> Self {
Self {
client,
session_router: Arc::new(workdir::WorkdirSessionRouter::new()),
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
before_workdir_release: None,
after_workdir_attach: None,
}
}
fn with_session_router(mut self, session_router: Arc<workdir::WorkdirSessionRouter>) -> Self {
self.session_router = session_router;
self
}
fn with_child_lifecycle(
mut self,
before_workdir_release: Option<BeforeWorkdirRelease>,
@@ -449,10 +464,12 @@ impl WorkspaceHttpWorkdirBackend {
.transpose()?;
let repository_key =
validate_identity(&input.repository_key, CREATE_TOOL, "repository_key")?;
let display_name = validate_optional_display_name(input.display_name)?;
let selector = validate_optional_selector(input.selector)?;
let workspace_id = encode_path_segment(self.workspace_id()?);
let request = WorkdirCreateRequest {
runtime_id: runtime_id.map(str::to_string),
display_name,
repository_key: repository_key.to_string(),
selector,
operation_id: Some(operation_id),
@@ -472,32 +489,67 @@ impl WorkspaceHttpWorkdirBackend {
}
fn attach(&self, input: WorkdirAttachInput) -> Result<ToolOutput, ToolError> {
let workdir_id = validate_identity(&input.workdir_id, ATTACH_TOOL, "workdir_id")?;
let response = self.attach_response(workdir_id)?;
workdir_output(format!("Attached to Workdir {workdir_id}"), &response)
let alias = validate_identity(&input.alias, ATTACH_TOOL, "alias")?;
workdir::WorkdirAttachmentAlias::new(alias)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
let working_directory_id = validate_identity(
&input.working_directory_id,
ATTACH_TOOL,
"working_directory_id",
)?;
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
if self.session_router.session(&alias_key).is_some() {
return Err(ToolError::InvalidArgument(format!(
"attachment alias `{alias}` already exists"
)));
}
let response = self.attach_response(alias, working_directory_id)?;
if let Err(error) = self.session_router.attach(
alias_key.clone(),
WorkspaceAttachedWorkdirSession::handle(self.client.clone()),
) {
let _ = self.detach(alias);
return Err(ToolError::ExecutionFailed(format!(
"attach Workdir session route: {error}"
)));
}
workdir_output(
format!("Attached Workdir {working_directory_id} as {alias}"),
&response,
)
}
fn attach_response(&self, workdir_id: &str) -> Result<WorkdirAttachmentResponse, ToolError> {
fn attach_response(
&self,
alias: &str,
working_directory_id: &str,
) -> Result<WorkdirAttachmentResponse, ToolError> {
let workspace_id = encode_path_segment(self.workspace_id()?);
self.execute_json::<WorkdirAttachmentResponse>(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/workers/self/workdir-attachment"),
format!("/api/w/{workspace_id}/workers/self/workdir-attachments"),
serde_json::to_string(&WorkdirAttachRequest {
workdir_id: workdir_id.to_string(),
alias: alias.to_string(),
working_directory_id: working_directory_id.to_string(),
})
.map_err(decode_error)?,
))
}
fn detach(&self) -> Result<ToolOutput, ToolError> {
fn detach(&self, alias: &str) -> Result<ToolOutput, ToolError> {
let workspace_id = encode_path_segment(self.workspace_id()?);
let alias_path = encode_path_segment(alias);
let response = self.execute_json::<WorkdirAttachmentResponse>(WorkspaceRequest {
method: WorkspaceRequestMethod::Delete,
path: format!("/api/w/{workspace_id}/workers/self/workdir-attachment"),
path: format!("/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}"),
body: None,
})?;
workdir_output(
format!("Detached from Workdir {}", response.workdir_id),
format!(
"Detached Workdir {} ({})",
response.working_directory_id, response.alias
),
&response,
)
}
@@ -590,6 +642,7 @@ impl Tool for WorkspaceHttpWorkdirTool {
ctx.call_id.to_string(),
),
WorkdirOperation::Attach => {
let _mutation_guard = self.backend.attachment_mutation_lock.lock().await;
let result = self
.backend
.attach(parse_input::<WorkdirAttachInput>(input_json)?);
@@ -601,15 +654,41 @@ impl Tool for WorkspaceHttpWorkdirTool {
result
}
WorkdirOperation::Detach => {
let _input = parse_input::<WorkdirDetachInput>(input_json)?;
if let Some(before_release) = &self.backend.before_workdir_release {
before_release().await.map_err(|error| {
let _mutation_guard = self.backend.attachment_mutation_lock.lock().await;
let input = parse_input::<WorkdirDetachInput>(input_json)?;
let alias = validate_identity(&input.alias, DETACH_TOOL, "alias")?;
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
self.backend
.session_router
.begin_detach(&alias_key)
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if let Some(before_release) = &self.backend.before_workdir_release
&& let Err(error) = before_release().await
{
self.backend.session_router.cancel_detach(&alias_key);
return Err(ToolError::ExecutionFailed(format!(
"stop Internal SubWorkers before Workdir detach: {error}"
)));
}
let result = match self.backend.detach(alias) {
Ok(result) => result,
Err(error) => {
self.backend.session_router.cancel_detach(&alias_key);
return Err(error);
}
};
self.backend
.session_router
.finish_detach(&alias_key)
.await
.map_err(|error| {
ToolError::ExecutionFailed(format!(
"stop Internal SubWorkers before Workdir detach: {error}"
"detach Workdir session route after Backend release: {error}"
))
})?;
}
self.backend.detach()
Ok(result)
}
WorkdirOperation::Delete => self
.backend
@@ -672,6 +751,25 @@ fn validate_delete_reason(reason: &str) -> Result<&str, ToolError> {
Ok(reason)
}
fn validate_optional_display_name(
display_name: Option<String>,
) -> Result<Option<String>, ToolError> {
let Some(display_name) = display_name else {
return Ok(None);
};
let display_name = display_name.trim();
if display_name.is_empty()
|| display_name.len() > 80
|| display_name.chars().any(char::is_control)
{
return Err(ToolError::InvalidArgument(
"WorkdirCreate display_name must be non-empty, contain no control characters, and be at most 80 bytes"
.to_string(),
));
}
Ok(Some(display_name.to_string()))
}
fn validate_optional_selector(selector: Option<String>) -> Result<Option<String>, ToolError> {
let Some(selector) = selector else {
return Ok(None);
@@ -725,6 +823,7 @@ fn create_schema() -> serde_json::Value {
"required": ["repository_key"],
"properties": {
"runtime_id": {"type": ["string", "null"], "minLength": 1},
"display_name": {"type": ["string", "null"], "minLength": 1, "maxLength": 80},
"repository_key": {"type": "string", "minLength": 1},
"selector": {"type": ["string", "null"], "minLength": 1}
}
@@ -735,15 +834,23 @@ fn attach_schema() -> serde_json::Value {
json!({
"type": "object",
"additionalProperties": false,
"required": ["workdir_id"],
"required": ["alias", "working_directory_id"],
"properties": {
"workdir_id": {"type": "string", "minLength": 1}
"alias": {"type": "string", "minLength": 1, "maxLength": 64},
"working_directory_id": {"type": "string", "minLength": 1}
}
})
}
fn detach_schema() -> serde_json::Value {
list_schema()
json!({
"type": "object",
"additionalProperties": false,
"required": ["alias"],
"properties": {
"alias": {"type": "string", "minLength": 1, "maxLength": 64}
}
})
}
fn delete_schema() -> serde_json::Value {
@@ -767,6 +874,8 @@ struct WorkdirListInput {}
struct WorkdirCreateInput {
#[serde(default)]
runtime_id: Option<String>,
#[serde(default)]
display_name: Option<String>,
repository_key: String,
#[serde(default)]
selector: Option<String>,
@@ -775,22 +884,27 @@ struct WorkdirCreateInput {
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkdirAttachInput {
workdir_id: String,
alias: String,
working_directory_id: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkdirDetachInput {}
struct WorkdirDetachInput {
alias: String,
}
#[derive(Debug, Serialize)]
struct WorkdirAttachRequest {
workdir_id: String,
alias: String,
working_directory_id: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct WorkdirAttachmentResponse {
workspace_id: String,
workdir_id: String,
alias: String,
working_directory_id: String,
attached: bool,
}
@@ -1021,7 +1135,10 @@ mod tests {
);
assert!(create["properties"].get("path").is_none());
assert!(create["properties"].get("session_id").is_none());
assert_eq!(attach_schema()["required"], json!(["workdir_id"]));
assert_eq!(
attach_schema()["required"],
json!(["alias", "working_directory_id"])
);
assert!(attach_schema()["properties"].get("session_id").is_none());
assert_eq!(
delete_schema()["required"],
@@ -1057,12 +1174,14 @@ mod tests {
})),
response(json!({
"workspace_id": "workspace/test",
"workdir_id": "wd-created",
"alias": "checkout",
"working_directory_id": "wd-created",
"attached": true
})),
response(json!({
"workspace_id": "workspace/test",
"workdir_id": "wd-created",
"alias": "checkout",
"working_directory_id": "wd-created",
"attached": false
})),
response(json!({
@@ -1089,6 +1208,7 @@ mod tests {
.create(
WorkdirCreateInput {
runtime_id: Some("runtime/one".to_string()),
display_name: Some("Review checkout".to_string()),
repository_key: "main".to_string(),
selector: Some("refs/heads/topic".to_string()),
},
@@ -1106,10 +1226,11 @@ mod tests {
assert_eq!(client.requests().len(), 2);
backend
.attach(WorkdirAttachInput {
workdir_id: "wd-created".to_string(),
alias: "checkout".to_string(),
working_directory_id: "wd-created".to_string(),
})
.unwrap();
backend.detach().unwrap();
backend.detach("checkout").unwrap();
backend
.delete(WorkdirDeleteInput {
working_directory_id: "wd-created".to_string(),
@@ -1132,16 +1253,17 @@ mod tests {
serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap();
assert_eq!(body["repository_key"], "main");
assert_eq!(body["runtime_id"], "runtime/one");
assert_eq!(body["display_name"], "Review checkout");
assert_eq!(body["operation_id"], "call-create-1");
assert_eq!(body["selector"], "refs/heads/topic");
assert_eq!(
requests[2].path,
"/api/w/workspace%2Ftest/workers/self/workdir-attachment"
"/api/w/workspace%2Ftest/workers/self/workdir-attachments"
);
assert_eq!(requests[2].method, WorkspaceRequestMethod::Post);
assert_eq!(
requests[3].path,
"/api/w/workspace%2Ftest/workers/self/workdir-attachment"
"/api/w/workspace%2Ftest/workers/self/workdir-attachments/checkout"
);
assert_eq!(requests[3].method, WorkspaceRequestMethod::Delete);
assert_eq!(
@@ -1323,6 +1445,7 @@ mod tests {
.create(
WorkdirCreateInput {
runtime_id: None,
display_name: None,
repository_key: "main".to_string(),
selector: None,
},
@@ -1348,6 +1471,7 @@ mod tests {
.create(
WorkdirCreateInput {
runtime_id: Some(" ".to_string()),
display_name: None,
repository_key: "main".to_string(),
selector: None,
},
@@ -1363,9 +1487,17 @@ mod tests {
async fn detach_stops_internal_subworkers_before_backend_release() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
"workspace_id": "workspace/test",
"workdir_id": "wd-attached",
"alias": "checkout",
"working_directory_id": "wd-attached",
"attached": false
}))]));
let router = Arc::new(workdir::WorkdirSessionRouter::new());
router
.attach(
workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
WorkspaceAttachedWorkdirSession::handle(client.clone()),
)
.unwrap();
let cleanup_calls = Arc::new(AtomicUsize::new(0));
let cleanup_calls_for_guard = cleanup_calls.clone();
let before_release: BeforeWorkdirRelease = Arc::new(move || {
@@ -1377,11 +1509,12 @@ mod tests {
});
let tool = WorkspaceHttpWorkdirTool {
backend: WorkspaceHttpWorkdirBackend::new(client.clone())
.with_session_router(router)
.with_child_lifecycle(Some(before_release), None),
operation: WorkdirOperation::Detach,
};
tool.execute("{}", ToolExecutionContext::default())
tool.execute(r#"{"alias":"checkout"}"#, ToolExecutionContext::default())
.await
.unwrap();
@@ -1389,23 +1522,31 @@ mod tests {
assert_eq!(client.requests().len(), 1);
assert_eq!(
client.requests()[0].path,
"/api/w/workspace%2Ftest/workers/self/workdir-attachment"
"/api/w/workspace%2Ftest/workers/self/workdir-attachments/checkout"
);
}
#[tokio::test]
async fn detach_does_not_release_backend_when_child_cleanup_fails() {
let client = Arc::new(RecordingWorkspaceClient::new(Vec::new()));
let router = Arc::new(workdir::WorkdirSessionRouter::new());
router
.attach(
workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
WorkspaceAttachedWorkdirSession::handle(client.clone()),
)
.unwrap();
let before_release: BeforeWorkdirRelease =
Arc::new(|| Box::pin(async { Err(std::io::Error::other("child cleanup failed")) }));
let tool = WorkspaceHttpWorkdirTool {
backend: WorkspaceHttpWorkdirBackend::new(client.clone())
.with_session_router(router)
.with_child_lifecycle(Some(before_release), None),
operation: WorkdirOperation::Detach,
};
let error = tool
.execute("{}", ToolExecutionContext::default())
.execute(r#"{"alias":"checkout"}"#, ToolExecutionContext::default())
.await
.unwrap_err();
@@ -1413,11 +1554,77 @@ mod tests {
assert!(client.requests().is_empty());
}
#[tokio::test]
async fn detach_reconciles_naturally_completed_workspace_command() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![
response(json!({
"workspace_id": "workspace/test",
"alias": "checkout",
"working_directory_id": "wd-attached",
"attached": true
})),
response(json!({
"operation": "command_start",
"result": "command-1"
})),
response(json!({
"operation": "command_status",
"result": "completed"
})),
response(json!({
"workspace_id": "workspace/test",
"alias": "checkout",
"working_directory_id": "wd-attached",
"attached": false
})),
]));
let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
backend
.attach(WorkdirAttachInput {
alias: "checkout".to_string(),
working_directory_id: "wd-attached".to_string(),
})
.unwrap();
let alias = workdir::WorkdirAttachmentAlias::new("checkout").unwrap();
backend
.session_router
.session(&alias)
.unwrap()
.start_command(CommandRequest {
command: "printf done".to_string(),
timeout_secs: 5,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: None,
})
.await
.unwrap();
let tool = WorkspaceHttpWorkdirTool {
backend,
operation: WorkdirOperation::Detach,
};
tool.execute(r#"{"alias":"checkout"}"#, ToolExecutionContext::default())
.await
.unwrap();
let requests = client.requests();
assert_eq!(requests.len(), 4);
assert!(
requests[2]
.body
.as_deref()
.is_some_and(|body| body.contains("command_status"))
);
}
#[tokio::test]
async fn successful_attach_reopens_internal_subworker_admission() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
"workspace_id": "workspace/test",
"workdir_id": "wd-attached",
"alias": "checkout",
"working_directory_id": "wd-attached",
"attached": true
}))]));
let reopen_calls = Arc::new(AtomicUsize::new(0));
@@ -1432,7 +1639,7 @@ mod tests {
};
tool.execute(
r#"{"workdir_id":"wd-attached"}"#,
r#"{"alias":"checkout","working_directory_id":"wd-attached"}"#,
ToolExecutionContext::default(),
)
.await
+4 -4
View File
@@ -165,7 +165,7 @@ where
})?;
worker.disable_manifest_lifecycle_features();
if let Some(session) = inherited_workdir_session {
worker.bind_workdir_session(Some(session));
worker.bind_single_workdir_session(Some(session));
}
let install_report = worker.install_features(features);
@@ -608,7 +608,7 @@ pub(crate) async fn spawn_internal_worker_session(
})?;
worker.disable_manifest_lifecycle_features();
if let Some(session) = inherited_workdir_session {
worker.bind_workdir_session(Some(session));
worker.bind_single_workdir_session(Some(session));
}
let install_report = worker.install_features(features);
let installed_tools = install_report.installed_tool_names();
@@ -701,7 +701,7 @@ pub(crate) fn prepare_internal_worker_from_spec(
})?;
worker.disable_manifest_lifecycle_features();
if let Some(session) = inherited_workdir_session {
worker.bind_workdir_session(Some(session));
worker.bind_single_workdir_session(Some(session));
}
let install_report = worker.install_features(features);
let installed_tools = install_report.installed_tool_names();
@@ -782,7 +782,7 @@ pub(crate) async fn prepare_internal_worker_session(
if let Some(broker) = command_event_broker.as_ref() {
wire_workdir_command_events(&broker.tool_session(), &in_flight);
} else if let Some(session) = worker.workdir_session() {
wire_workdir_command_events(session, &in_flight);
wire_workdir_command_events(&session, &in_flight);
}
let actor_in_flight = in_flight.clone();
worker.attach_alerter(alerter.clone());
+64 -31
View File
@@ -374,7 +374,8 @@ use protocol::{
use tokio::net::UnixStream;
use tokio::sync::broadcast;
use workdir::{
LocalWorkdirSession, ReadOnlyWorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle,
LocalWorkdirSession, ReadOnlyWorkdirSession, WorkdirAttachmentAlias,
WorkdirSessionCapabilities, WorkdirSessionHandle, WorkdirSessionRouter,
};
const RESTORE_RECONCILIATION_REACHABILITY_TIMEOUT: Duration = Duration::from_millis(500);
@@ -2128,9 +2129,9 @@ pub struct Worker<C: LlmClient, St: Store> {
/// Explicit local filesystem authority, or `None` for Workers with no
/// local cwd and no filesystem/Bash tool surface.
filesystem_authority: WorkerFilesystemAuthority,
/// Live WorkdirSession provider derived once from the Worker–Workdir binding.
/// Local tools, file views, and compaction workers clone this handle.
workdir_session: Option<WorkdirSessionHandle>,
/// Alias-keyed live Workdir sessions. Provider transport remains hidden
/// behind each handle and no attachment is designated primary.
workdir_sessions: Arc<WorkdirSessionRouter>,
/// Path-free workspace identity/client context injected by Runtime/host.
/// This never grants local filesystem authority.
workspace_context: WorkerWorkspaceContext,
@@ -2464,7 +2465,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
let prompts = Arc::new(ArcSwap::from(PromptCatalog::builtins_only()?));
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
let scope = SharedScope::new(scope);
let workdir_session = workdir_session_from_authority(&filesystem_authority, &scope);
let workdir_sessions = workdir_sessions_from_authority(&filesystem_authority, &scope);
let mut worker = Self {
manifest,
engine: Some(worker),
@@ -2475,7 +2476,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
worker_metadata_segment_cas: None,
segment_state: SegmentState::new(session_id, segment_id, 0),
filesystem_authority,
workdir_session,
workdir_sessions,
workspace_context,
flow_runtime_state: Arc::new(Mutex::new(None)),
flow_feature_enabled: false,
@@ -2652,15 +2653,36 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
self.filesystem_authority.as_local()
}
pub fn workdir_session(&self) -> Option<&WorkdirSessionHandle> {
self.workdir_session.as_ref()
/// Compatibility projection for tool schemas that do not yet carry a
/// target alias. It is available only when the attachment set has exactly
/// one member, so multiple attachments never acquire an implicit primary.
pub fn workdir_session(&self) -> Option<WorkdirSessionHandle> {
self.workdir_sessions.only_session()
}
/// Replace the constructor fallback with the provider binding resolved by
/// the owning Runtime. Runtime calls this before the Worker controller is
/// spawned, so tools only ever observe the Runtime-bound handle.
pub fn bind_workdir_session(&mut self, workdir_session: Option<WorkdirSessionHandle>) {
self.workdir_session = workdir_session;
pub fn workdir_sessions(&self) -> Arc<WorkdirSessionRouter> {
self.workdir_sessions.clone()
}
/// Replace the constructor fallback with the complete alias-keyed provider
/// set resolved by the owning Runtime before controller startup.
pub fn bind_workdir_sessions(&mut self, workdir_sessions: Arc<WorkdirSessionRouter>) {
self.workdir_sessions = workdir_sessions;
}
/// Bind one session for direct/Internal Worker construction. Runtime Worker
/// creation uses [`Self::bind_workdir_sessions`] with the complete set.
pub fn bind_single_workdir_session(&mut self, workdir_session: Option<WorkdirSessionHandle>) {
let router = Arc::new(WorkdirSessionRouter::new());
if let Some(session) = workdir_session {
router
.attach(
WorkdirAttachmentAlias::new("workdir").expect("static alias is valid"),
session,
)
.expect("fresh Workdir router accepts its only attachment");
}
self.workdir_sessions = router;
}
/// Path-free workspace identity, if Runtime/host associated this Worker
@@ -2929,8 +2951,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
location.segment_id,
);
let read_only_tools = self
.workdir_session
.clone()
.workdir_session()
.map(|source| Arc::new(ReadOnlyWorkdirSession::new(source)) as WorkdirSessionHandle)
.map(tools::read_only_builtin_tools)
.unwrap_or_default();
@@ -4061,7 +4082,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
/// unresolved placeholder stays in the flattened user message so the LLM
/// still sees the intent.
async fn resolve_file_refs(&self, segments: &[Segment]) -> Vec<SystemItem> {
let Some(workdir) = self.workdir_session.clone() else {
let Some(workdir) = self.workdir_session() else {
for seg in segments {
if let Segment::FileRef { path } = seg {
self.alert(
@@ -5428,7 +5449,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
// Build a normal parent-owned Internal Worker over a pinned immutable
// capture. Only SessionExplore and compaction output tools are installed.
let workdir = self.workdir_session.clone();
let workdir = self.workdir_session();
let read_only_workdir = workdir.clone().map(|session| {
Arc::new(ReadOnlyWorkdirSession::new(session)) as workdir::WorkdirSessionHandle
});
@@ -6139,7 +6160,8 @@ where
let worker_metadata_writer = Some(worker_metadata_writer_for_store(&store));
let worker_metadata_segment_cas = Some(worker_metadata_segment_cas_for_store(&store));
let scope = SharedScope::new(common.scope);
let workdir_session = workdir_session_from_authority(&common.filesystem_authority, &scope);
let workdir_sessions =
workdir_sessions_from_authority(&common.filesystem_authority, &scope);
let mut worker = Self {
manifest,
@@ -6151,7 +6173,7 @@ where
worker_metadata_segment_cas,
segment_state: SegmentState::new(session_id, segment_id, 0),
filesystem_authority: common.filesystem_authority,
workdir_session,
workdir_sessions,
workspace_context: common.workspace_context,
flow_runtime_state: Arc::new(Mutex::new(None)),
flow_feature_enabled: false,
@@ -6228,7 +6250,8 @@ where
apply_worker_manifest(&mut engine, &manifest.engine);
engine.set_cache_key(Some(segment_id.to_string()));
let scope = SharedScope::new(common.scope);
let workdir_session = workdir_session_from_authority(&common.filesystem_authority, &scope);
let workdir_sessions =
workdir_sessions_from_authority(&common.filesystem_authority, &scope);
let mut worker = Self {
manifest,
engine: Some(engine),
@@ -6239,7 +6262,7 @@ where
worker_metadata_segment_cas: None,
segment_state: SegmentState::new(session_id, segment_id, 0),
filesystem_authority: common.filesystem_authority,
workdir_session,
workdir_sessions,
workspace_context: common.workspace_context,
flow_runtime_state: Arc::new(Mutex::new(None)),
flow_feature_enabled: false,
@@ -6350,7 +6373,8 @@ where
let worker_metadata_writer = Some(worker_metadata_writer_for_store(&store));
let worker_metadata_segment_cas = Some(worker_metadata_segment_cas_for_store(&store));
let scope = SharedScope::new(common.scope);
let workdir_session = workdir_session_from_authority(&common.filesystem_authority, &scope);
let workdir_sessions =
workdir_sessions_from_authority(&common.filesystem_authority, &scope);
let mut worker = Self {
manifest,
@@ -6362,7 +6386,7 @@ where
worker_metadata_segment_cas,
segment_state: SegmentState::new(session_id, segment_id, 0),
filesystem_authority: common.filesystem_authority,
workdir_session,
workdir_sessions,
workspace_context: common.workspace_context,
flow_runtime_state: Arc::new(Mutex::new(None)),
flow_feature_enabled: false,
@@ -6726,7 +6750,8 @@ where
let worker_metadata_writer = Some(worker_metadata_writer_for_store(&store));
let worker_metadata_segment_cas = Some(worker_metadata_segment_cas_for_store(&store));
let scope = SharedScope::new(common.scope);
let workdir_session = workdir_session_from_authority(&common.filesystem_authority, &scope);
let workdir_sessions =
workdir_sessions_from_authority(&common.filesystem_authority, &scope);
let mut worker = Self {
manifest,
@@ -6738,7 +6763,7 @@ where
worker_metadata_segment_cas,
segment_state: SegmentState::new(session_id, segment_id, state.entries_count),
filesystem_authority: common.filesystem_authority,
workdir_session,
workdir_sessions,
workspace_context: common.workspace_context,
flow_runtime_state: Arc::new(Mutex::new(restored_flow_runtime_state(
&state.extensions,
@@ -7580,18 +7605,26 @@ pub enum WorkerError {
},
}
fn workdir_session_from_authority(
fn workdir_sessions_from_authority(
authority: &WorkerFilesystemAuthority,
scope: &SharedScope,
) -> Option<WorkdirSessionHandle> {
authority.as_local().map(|local| {
Arc::new(LocalWorkdirSession::materialized(
) -> Arc<WorkdirSessionRouter> {
let router = Arc::new(WorkdirSessionRouter::new());
if let Some(local) = authority.as_local() {
let session = Arc::new(LocalWorkdirSession::materialized(
local.root.clone(),
local.cwd.clone(),
scope.clone(),
WorkdirSessionCapabilities::ALL,
)) as WorkdirSessionHandle
})
)) as WorkdirSessionHandle;
router
.attach(
WorkdirAttachmentAlias::new("workdir").expect("static alias is valid"),
session,
)
.expect("fresh Workdir router accepts its only attachment");
}
router
}
/// Bundle of resources that every high-level Worker constructor needs:
+4 -4
View File
@@ -349,7 +349,7 @@ async fn shutdown_closes_bound_workdir_session() {
})
.await
.unwrap();
worker.bind_workdir_session(Some(Arc::clone(&session)));
worker.bind_single_workdir_session(Some(Arc::clone(&session)));
let runtime_base = tempfile::tempdir().unwrap();
let bash_output_dir = runtime_base.path().join("bash-output");
@@ -384,7 +384,7 @@ async fn controller_projects_workdir_command_events_and_snapshot_state() {
worker.scope().clone(),
WorkdirSessionCapabilities::ALL,
));
worker.bind_workdir_session(Some(Arc::clone(&session)));
worker.bind_single_workdir_session(Some(Arc::clone(&session)));
let handle = spawn_controller(worker).await;
let mut events = handle.subscribe();
@@ -495,7 +495,7 @@ async fn controller_refreshes_command_snapshot_after_high_output_provider_lag()
worker.scope().clone(),
WorkdirSessionCapabilities::ALL,
));
worker.bind_workdir_session(Some(Arc::clone(&session)));
worker.bind_single_workdir_session(Some(Arc::clone(&session)));
let handle = spawn_controller(worker).await;
// Local command telemetry uses 8 KiB chunks and a 256-event channel. One
@@ -572,7 +572,7 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
worker.scope().clone(),
WorkdirSessionCapabilities::ALL,
));
worker.bind_workdir_session(Some(Arc::clone(&session)));
worker.bind_single_workdir_session(Some(Arc::clone(&session)));
let runtime_base = tempfile::tempdir().unwrap();
let invalid_runtime_base = runtime_base.path().join("not-a-directory");
std::fs::write(&invalid_runtime_base, "file").unwrap();
+162 -41
View File
@@ -32,8 +32,9 @@ use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveSource,
RepositoryRefObservation, RepositoryRefObservationRequest,
WorkerDetail as EmbeddedWorkerDetail, WorkerStatus as EmbeddedWorkerStatus,
WorkingDirectoryClaim, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest,
WorkingDirectoryStatus, WorkingDirectorySummary, WorkspaceApiRef,
WorkingDirectoryAttachmentClaim, WorkingDirectoryAttachmentRequest,
WorkingDirectoryAttachmentStatus, WorkingDirectoryRepositoryAccessRequest,
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkspaceApiRef,
};
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
#[cfg(test)]
@@ -270,8 +271,8 @@ pub struct WorkerSummary {
pub retention_state: String,
pub implementation: WorkerImplementationSummary,
pub capabilities: WorkerCapabilitySummary,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectorySummary>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workdir_attachments: Vec<WorkingDirectoryAttachmentStatus>,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
@@ -336,7 +337,7 @@ impl From<RuntimeSummary> for server_api::RuntimeSummary {
pub(crate) fn workspace_worker_summary(
summary: WorkerSummary,
resource_key: String,
working_directory: Option<server_api::WorkingDirectorySummary>,
workdir_attachments: Vec<server_api::WorkerWorkdirAttachmentSummary>,
) -> server_api::WorkerSummary {
server_api::WorkerSummary {
runtime_id: summary.worker.runtime_id,
@@ -366,7 +367,7 @@ pub(crate) fn workspace_worker_summary(
can_stop: summary.capabilities.can_stop,
can_spawn_followup: summary.capabilities.can_spawn_followup,
},
working_directory,
workdir_attachments,
diagnostics: summary.diagnostics.into_iter().map(Into::into).collect(),
}
}
@@ -483,6 +484,13 @@ pub struct WorkerCreateBinding {
pub create_fingerprint: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkerSpawnWorkingDirectoryAttachmentRequest {
pub alias: workdir::WorkdirAttachmentAlias,
pub working_directory: WorkerSpawnWorkingDirectoryRequest,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkerSpawnRequest {
@@ -495,15 +503,13 @@ pub struct WorkerSpawnRequest {
pub ticket_assignment: Option<WorkerTicketAssignmentRequest>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub initial_submit: Vec<Segment>,
/// Optional safe working-directory creation request. The Workspace server resolves
/// this into a runtime-internal `WorkingDirectoryRequest` from configured
/// repositories before calling a host.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory_request: Option<WorkerSpawnWorkingDirectoryRequest>,
/// Safe working-directory materialization requests, keyed by stable Worker-local aliases.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workdir_attachment_requests: Vec<WorkerSpawnWorkingDirectoryAttachmentRequest>,
#[serde(skip, default)]
pub resolved_working_directory_request: Option<WorkingDirectoryRequest>,
pub resolved_workdir_attachment_requests: Vec<WorkingDirectoryAttachmentRequest>,
#[serde(skip, default)]
pub resolved_working_directory: Option<WorkingDirectoryClaim>,
pub resolved_workdir_attachments: Vec<WorkingDirectoryAttachmentClaim>,
#[serde(skip, default)]
pub resolved_config_bundle: Option<ConfigBundle>,
#[serde(skip, default)]
@@ -905,6 +911,24 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
}
}
fn replace_worker_workdir_attachments(
&self,
worker_id: &str,
_attachments: Vec<WorkingDirectoryAttachmentClaim>,
) -> WorkerWorkspaceApiResult {
WorkerWorkspaceApiResult {
state: WorkerOperationState::Unsupported,
worker: None,
diagnostics: vec![diagnostic(
"worker_workdir_attachments_replace_unsupported",
DiagnosticSeverity::Info,
format!(
"runtime does not support replacing Workdir attachments for worker `{worker_id}`"
),
)],
}
}
fn create_working_directory(
&self,
_request: WorkingDirectoryRequest,
@@ -1489,6 +1513,19 @@ impl RuntimeRegistry {
Ok(runtime.replace_worker_workspace_api(worker_id, workspace_api))
}
pub fn replace_worker_workdir_attachments(
&self,
worker: &RuntimeWorkerRef,
attachments: Vec<WorkingDirectoryAttachmentClaim>,
) -> Result<WorkerWorkspaceApiResult, RuntimeRegistryError> {
let runtime_id = worker.runtime_id.as_str();
let worker_id = worker.worker_id.as_str();
validate_backend_identifier("runtime_id", runtime_id)?;
validate_backend_identifier("worker_id", worker_id)?;
let runtime = self.runtime(runtime_id)?;
Ok(runtime.replace_worker_workdir_attachments(worker_id, attachments))
}
pub fn observe_workspace_prompt_projection(
&self,
projection: worker::WorkspacePromptProjection,
@@ -2176,7 +2213,7 @@ impl EmbeddedWorkerRuntime {
&& self.can_stop_embedded_worker(summary.status),
can_spawn_followup: false,
},
working_directory: summary.working_directory.map(|status| status.summary),
workdir_attachments: summary.workdir_attachments,
diagnostics: embedded_worker_projection_diagnostics(
summary.execution_metadata_available,
),
@@ -2220,7 +2257,7 @@ impl EmbeddedWorkerRuntime {
&& self.can_stop_embedded_worker(detail.status),
can_spawn_followup: false,
},
working_directory: detail.working_directory.map(|status| status.summary),
workdir_attachments: detail.workdir_attachments,
diagnostics: embedded_worker_projection_diagnostics(
detail.execution_metadata_available,
),
@@ -2429,6 +2466,39 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
}
}
fn replace_worker_workdir_attachments(
&self,
worker_id: &str,
attachments: Vec<WorkingDirectoryAttachmentClaim>,
) -> WorkerWorkspaceApiResult {
let Some(worker_ref) = self.worker_ref(worker_id) else {
return WorkerWorkspaceApiResult {
state: WorkerOperationState::Rejected,
worker: None,
diagnostics: vec![diagnostic(
"embedded_worker_id_invalid",
DiagnosticSeverity::Warning,
"Worker id was empty and cannot change Workdir attachments".to_string(),
)],
};
};
match self
.runtime
.replace_worker_workdir_attachments(&worker_ref, attachments)
{
Ok(detail) => WorkerWorkspaceApiResult {
state: WorkerOperationState::Accepted,
worker: Some(self.map_worker_detail(detail)),
diagnostics: Vec::new(),
},
Err(err) => WorkerWorkspaceApiResult {
state: WorkerOperationState::Rejected,
worker: None,
diagnostics: vec![embedded_runtime_diagnostic(&err)],
},
}
}
fn create_working_directory(
&self,
_request: WorkingDirectoryRequest,
@@ -2491,8 +2561,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
request: WorkerSpawnRequest,
) -> WorkerSpawnResult {
let mut diagnostics = Vec::new();
if request.resolved_working_directory_request.is_some()
|| request.resolved_working_directory.is_some()
if !request.resolved_workdir_attachment_requests.is_empty()
|| !request.resolved_workdir_attachments.is_empty()
{
diagnostics.push(embedded_workdir_unsupported_diagnostic());
return WorkerSpawnResult {
@@ -2573,8 +2643,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
config_bundle,
profile_source,
initial_input: initial_worker_input(&request.initial_submit),
working_directory_request: request.resolved_working_directory_request.clone(),
working_directory: request.resolved_working_directory.clone(),
workdir_attachment_requests: request.resolved_workdir_attachment_requests.clone(),
workdir_attachments: request.resolved_workdir_attachments.clone(),
worker_observation_enabled: request.resolved_worker_observation_enabled,
worker_observation_grants: request.resolved_worker_observation_grants.clone(),
workspace_api: Some(workspace_api),
@@ -3982,7 +4052,7 @@ impl RemoteWorkerRuntime {
&& runtime_worker_can_stop(true, summary.status),
can_spawn_followup: false,
},
working_directory: summary.working_directory.map(|status| status.summary),
workdir_attachments: summary.workdir_attachments,
diagnostics: remote_worker_projection_diagnostics(summary.execution_metadata_available),
}
}
@@ -4024,7 +4094,7 @@ impl RemoteWorkerRuntime {
&& runtime_worker_can_stop(true, detail.status),
can_spawn_followup: false,
},
working_directory: detail.working_directory.map(|status| status.summary),
workdir_attachments: detail.workdir_attachments,
diagnostics: remote_worker_projection_diagnostics(detail.execution_metadata_available),
}
}
@@ -4388,6 +4458,51 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
}
}
fn replace_worker_workdir_attachments(
&self,
worker_id: &str,
attachments: Vec<WorkingDirectoryAttachmentClaim>,
) -> WorkerWorkspaceApiResult {
let workdir_attachments = match runtime_contract_convert(attachments) {
Ok(attachments) => attachments,
Err(diagnostic) => {
return WorkerWorkspaceApiResult {
state: WorkerOperationState::Rejected,
worker: None,
diagnostics: vec![diagnostic],
};
}
};
let request = runtime_api::WorkerWorkdirAttachmentsRequest {
workdir_attachments,
};
let worker_id = worker_id.to_string();
match self
.run_runtime_api(
self.request_timeout,
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
move |client| async move {
client
.replace_worker_workdir_attachments(worker_id, request)
.await
},
)
.and_then(|value| {
runtime_contract_convert::<_, worker_runtime::catalog::WorkerDetail>(value.worker)
}) {
Ok(response) => WorkerWorkspaceApiResult {
state: WorkerOperationState::Accepted,
worker: Some(self.map_worker_detail(response)),
diagnostics: Vec::new(),
},
Err(diagnostic) => WorkerWorkspaceApiResult {
state: WorkerOperationState::Rejected,
worker: None,
diagnostics: vec![diagnostic],
},
}
}
fn create_working_directory(
&self,
request: WorkingDirectoryRequest,
@@ -4567,8 +4682,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
config_bundle,
profile_source,
initial_input: initial_worker_input(&request.initial_submit),
working_directory_request: request.resolved_working_directory_request.clone(),
working_directory: request.resolved_working_directory.clone(),
workdir_attachment_requests: request.resolved_workdir_attachment_requests.clone(),
workdir_attachments: request.resolved_workdir_attachments.clone(),
worker_observation_enabled: request.resolved_worker_observation_enabled,
worker_observation_grants: request.resolved_worker_observation_grants.clone(),
workspace_api: Some(workspace_api),
@@ -5724,7 +5839,7 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
can_stop: false,
can_spawn_followup: false,
},
working_directory: None,
workdir_attachments: Vec::new(),
diagnostics: vec![diagnostic(
"runtime_capability_unsupported",
DiagnosticSeverity::Info,
@@ -6265,10 +6380,16 @@ mod tests {
worker_state: protocol::WorkerStateSnapshot {
..protocol::WorkerStatus::Idle.into()
},
working_directory: request
.working_directory
.as_ref()
.map(|binding| binding.status()),
workdir_attachments: request
.workdir_attachments
.iter()
.map(|(alias, binding)| {
worker_runtime::catalog::WorkingDirectoryAttachmentStatus {
alias: alias.clone(),
working_directory: binding.status(),
}
})
.collect(),
}
}
@@ -6360,7 +6481,7 @@ mod tests {
can_stop: false,
can_spawn_followup: false,
},
working_directory: None,
workdir_attachments: Vec::new(),
diagnostics: Vec::new(),
}],
observed_prompt_revisions: Arc::new(Mutex::new(Vec::new())),
@@ -6650,9 +6771,9 @@ mod tests {
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
ticket_assignment: None,
initial_submit: Vec::new(),
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
workdir_attachment_requests: Vec::new(),
resolved_workdir_attachment_requests: Vec::new(),
resolved_workdir_attachments: Vec::new(),
resolved_config_bundle: None,
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
@@ -6890,9 +7011,9 @@ mod tests {
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
ticket_assignment: None,
initial_submit: Vec::new(),
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
workdir_attachment_requests: Vec::new(),
resolved_workdir_attachment_requests: Vec::new(),
resolved_workdir_attachments: Vec::new(),
resolved_config_bundle: None,
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
@@ -6990,9 +7111,9 @@ mod tests {
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
ticket_assignment: None,
initial_submit: Vec::new(),
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
workdir_attachment_requests: Vec::new(),
resolved_workdir_attachment_requests: Vec::new(),
resolved_workdir_attachments: Vec::new(),
resolved_config_bundle: None,
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
@@ -7029,9 +7150,9 @@ mod tests {
profile: ProfileSelector::Builtin("builtin:companion".to_string()),
ticket_assignment: None,
initial_submit: Vec::new(),
working_directory_request: None,
resolved_working_directory_request: None,
resolved_working_directory: None,
workdir_attachment_requests: Vec::new(),
resolved_workdir_attachment_requests: Vec::new(),
resolved_workdir_attachments: Vec::new(),
resolved_config_bundle: None,
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
@@ -667,6 +667,7 @@ CREATE TABLE workdir_create_credential_revision_retentions (
CREATE TABLE "workdir_registry" (
workspace_id TEXT NOT NULL,
workdir_id TEXT NOT NULL,
display_name TEXT,
runtime_id TEXT NOT NULL,
repository_id TEXT NOT NULL,
creation_selector TEXT,
@@ -859,10 +860,10 @@ CREATE TABLE "worker_workdir_links" (
runtime_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
workdir_id TEXT NOT NULL,
role TEXT NOT NULL,
alias TEXT NOT NULL,
linked_at TEXT NOT NULL,
unlinked_at TEXT,
PRIMARY KEY (workspace_id, worker_id, workdir_id, role),
PRIMARY KEY (workspace_id, worker_id, workdir_id, alias),
FOREIGN KEY (workspace_id, worker_id)
REFERENCES "worker_registry"(workspace_id, worker_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, workdir_id)
@@ -1093,8 +1094,8 @@ CREATE INDEX worker_removal_operations_worker_idx ON worker_removal_operations(w
CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique
ON worker_workdir_links(workspace_id, workdir_id)
WHERE unlinked_at IS NULL;
CREATE UNIQUE INDEX worker_workdir_links_active_worker_unique
ON worker_workdir_links(workspace_id, worker_id)
CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique
ON worker_workdir_links(workspace_id, worker_id, alias)
WHERE unlinked_at IS NULL;
CREATE INDEX worker_workdir_links_workdir
ON worker_workdir_links(workspace_id, workdir_id);
@@ -22,7 +22,7 @@ impl WorkerExecutionBackend for TestExecutionBackend {
protocol::WorkerStateSnapshot {
..protocol::WorkerStatus::Idle.into()
},
None,
Vec::new(),
)
}
@@ -64,8 +64,8 @@ fn create_request(name: &str) -> CreateWorkerRequest {
.unwrap(),
},
initial_input: None,
working_directory_request: None,
working_directory: None,
workdir_attachment_requests: Vec::new(),
workdir_attachments: Vec::new(),
worker_observation_enabled: false,
worker_observation_grants: Vec::new(),
workspace_api: None,
File diff suppressed because it is too large Load Diff
+139 -73
View File
@@ -21,7 +21,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
use crate::{Error, Result};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 62;
const LATEST_SCHEMA_VERSION: i64 = 63;
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
@@ -41,6 +41,8 @@ const RUNTIME_REMOVAL_OPERATION_MIGRATION_NAME: &str = "guarded Runtime removal
const REMOVE_WORKER_RUN_GENERATION_MIGRATION_NAME: &str = "remove obsolete Worker run generation";
const WORKER_REGISTRY_PROJECTION_MIGRATION_NAME: &str =
"durable Runtime-backed Worker registry projection";
const MULTI_WORKDIR_ATTACHMENTS_MIGRATION_NAME: &str =
"multiple Worker Workdir attachments and Workdir display names";
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
CREATE TABLE IF NOT EXISTS worker_registry_observations (
workspace_id TEXT NOT NULL,
@@ -146,6 +148,11 @@ const MIGRATIONS: &[Migration] = &[
name: WORKER_REGISTRY_PROJECTION_MIGRATION_NAME,
apply: migrate_worker_registry_projection_v61_to_v62,
},
Migration {
version: 63,
name: MULTI_WORKDIR_ATTACHMENTS_MIGRATION_NAME,
apply: migrate_multi_workdir_attachments_v62_to_v63,
},
];
#[derive(Clone, Copy)]
@@ -764,6 +771,7 @@ pub struct WorkdirCreateOperationRecord {
pub struct WorkdirRegistryRecord {
pub workspace_id: String,
pub workdir_id: String,
pub display_name: Option<String>,
pub runtime_id: String,
pub repository_id: String,
pub creation_selector: Option<String>,
@@ -784,7 +792,7 @@ pub struct WorkerWorkdirLinkRecord {
pub workspace_id: String,
pub worker: RuntimeWorkerRef,
pub workdir_id: String,
pub role: String,
pub alias: String,
pub linked_at: String,
pub unlinked_at: Option<String>,
}
@@ -7275,12 +7283,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
self.with_conn(|conn| {
conn.execute(
r#"INSERT INTO workdir_registry (
workspace_id, workdir_id, runtime_id, repository_id,
workspace_id, workdir_id, display_name, runtime_id, repository_id,
creation_selector, creation_ref, creation_tree,
current_selector, current_ref, current_tree, observed_at_epoch_seconds,
materialization_status, cleanliness, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
ON CONFLICT(workspace_id, workdir_id) DO UPDATE SET
display_name = excluded.display_name,
runtime_id = excluded.runtime_id,
repository_id = excluded.repository_id,
creation_selector = excluded.creation_selector,
@@ -7296,6 +7305,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
params![
record.workspace_id,
record.workdir_id,
record.display_name,
record.runtime_id,
record.repository_id,
record.creation_selector,
@@ -7474,10 +7484,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
record: &WorkerWorkdirLinkRecord,
reservation_id: &str,
) -> Result<WorkerWorkdirLinkRecord> {
if record.role != "attachment" || record.unlinked_at.is_some() {
workdir::WorkdirAttachmentAlias::new(record.alias.clone()).map_err(|error| {
Error::WorkdirAttachmentConflict(format!(
"invalid Workdir attachment alias `{}`: {error}",
record.alias
))
})?;
if record.unlinked_at.is_some() {
return Err(Error::WorkdirAttachmentConflict(
"reserved attachment finalization requires an active canonical attachment"
.to_string(),
"reserved attachment finalization requires an active attachment".to_string(),
));
}
self.with_conn(|conn| {
@@ -7501,9 +7516,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
tx.execute(
r#"INSERT INTO worker_workdir_links (
workspace_id, runtime_id, worker_id, workdir_id, role, linked_at, unlinked_at
workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL)
ON CONFLICT(workspace_id, worker_id, workdir_id, role) DO UPDATE SET
ON CONFLICT(workspace_id, worker_id, workdir_id, alias) DO UPDATE SET
linked_at = excluded.linked_at,
unlinked_at = NULL"#,
params![
@@ -7511,7 +7526,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
record.worker.runtime_id,
record.worker.worker_id,
record.workdir_id,
record.role,
record.alias,
record.linked_at,
],
)
@@ -7539,12 +7554,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
&self,
record: &WorkerWorkdirLinkRecord,
) -> Result<WorkerWorkdirLinkRecord> {
if record.role != "attachment" {
return Err(Error::WorkdirAttachmentConflict(format!(
"unsupported Workdir attachment role `{}`",
record.role
)));
}
workdir::WorkdirAttachmentAlias::new(record.alias.clone()).map_err(|error| {
Error::WorkdirAttachmentConflict(format!(
"invalid Workdir attachment alias `{}`: {error}",
record.alias
))
})?;
if record.unlinked_at.is_some() {
return Err(Error::WorkdirAttachmentConflict(
"a new attachment cannot already be unlinked".to_string(),
@@ -7583,32 +7598,34 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
record.workdir_id
)));
}
let active_for_worker = tx
let active_for_alias = tx
.query_row(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, role, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at
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 alias = ?4 AND unlinked_at IS NULL"#,
params![
record.workspace_id,
record.worker.runtime_id,
record.worker.worker_id,
record.alias,
],
read_worker_workdir_link_record,
)
.optional()?;
if let Some(active) = active_for_worker {
if let Some(active) = active_for_alias {
if active.workdir_id == record.workdir_id {
tx.commit()?;
return Ok(active);
}
return Err(Error::WorkdirAttachmentConflict(format!(
"Worker {}:{} is already attached to Workdir {}",
record.worker.runtime_id, record.worker.worker_id, active.workdir_id
"Worker {}:{} attachment alias `{}` is already bound to Workdir {}",
record.worker.runtime_id, record.worker.worker_id, record.alias, active.workdir_id
)));
}
let active_for_workdir = tx
.query_row(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, role, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at
FROM worker_workdir_links
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL"#,
params![record.workspace_id, record.workdir_id],
@@ -7623,9 +7640,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
let write = tx.execute(
r#"INSERT INTO worker_workdir_links (
workspace_id, runtime_id, worker_id, workdir_id, role, linked_at, unlinked_at
workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL)
ON CONFLICT(workspace_id, worker_id, workdir_id, role) DO UPDATE SET
ON CONFLICT(workspace_id, worker_id, workdir_id, alias) DO UPDATE SET
linked_at = excluded.linked_at,
unlinked_at = NULL"#,
params![
@@ -7633,7 +7650,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
record.worker.runtime_id,
record.worker.worker_id,
record.workdir_id,
record.role,
record.alias,
record.linked_at,
],
);
@@ -7665,32 +7682,32 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
)?;
let active = tx
.query_row(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, role, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at
FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND unlinked_at IS NULL"#,
params![workspace_id, worker.runtime_id, worker.worker_id],
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"#,
params![workspace_id, worker.runtime_id, worker.worker_id, expected_workdir_id],
read_worker_workdir_link_record,
)
.optional()?;
let Some(active) = active else {
if let Some(expected_workdir_id) = expected_workdir_id {
return Err(Error::WorkdirAttachmentConflict(format!(
"Worker {}:{} is not attached to Workdir {expected_workdir_id}",
worker.runtime_id, worker.worker_id
)));
}
tx.commit()?;
return Ok(None);
};
if let Some(expected_workdir_id) = expected_workdir_id {
if active.workdir_id != expected_workdir_id {
return Err(Error::WorkdirAttachmentConflict(format!(
"Worker {}:{} is attached to Workdir {}, not {expected_workdir_id}",
worker.runtime_id, worker.worker_id, active.workdir_id
)));
}
}
let changed = tx.execute(
r#"UPDATE worker_workdir_links
SET unlinked_at = ?4
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND unlinked_at IS NULL"#,
params![workspace_id, worker.runtime_id, worker.worker_id, unlinked_at],
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND (?5 IS NULL OR workdir_id = ?5) AND unlinked_at IS NULL"#,
params![workspace_id, worker.runtime_id, worker.worker_id, unlinked_at, expected_workdir_id],
)?;
if changed != 1 {
if changed == 0 || (expected_workdir_id.is_some() && changed != 1) {
return Err(Error::WorkdirAttachmentConflict(format!(
"Worker {}:{} attachment changed during detach",
worker.runtime_id, worker.worker_id
@@ -7729,7 +7746,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, role, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at
FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND unlinked_at IS NULL
ORDER BY linked_at DESC"#,
@@ -7750,7 +7767,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, role, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, linked_at, unlinked_at
FROM worker_workdir_links
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL
ORDER BY linked_at DESC"#,
@@ -8445,7 +8462,7 @@ fn read_worker_workdir_link_record(
workspace_id: row.get(0)?,
worker: RuntimeWorkerRef::new(row.get::<_, String>(1)?, row.get::<_, String>(2)?),
workdir_id: row.get(3)?,
role: row.get(4)?,
alias: row.get(4)?,
linked_at: row.get(5)?,
unlinked_at: row.get(6)?,
})
@@ -8621,9 +8638,7 @@ fn read_worker_registry_projection(
workspace_id: Some(workspace_id.to_string()),
display_name: Some(registry.display_name.clone()),
profile: registry.profile.clone(),
repository_id: None,
repository_key: None,
working_directory_id: None,
workdir_attachments: Vec::new(),
}
};
observed_worker.availability = availability;
@@ -9236,7 +9251,7 @@ fn require_expected_ticket_assignment(
fn workdir_registry_select_sql(where_clause: &str) -> String {
format!(
"SELECT workspace_id, workdir_id, runtime_id, repository_id, \
"SELECT workspace_id, workdir_id, display_name, runtime_id, repository_id, \
creation_selector, creation_ref, creation_tree, \
current_selector, current_ref, current_tree, observed_at_epoch_seconds, \
materialization_status, cleanliness, created_at, updated_at \
@@ -9250,19 +9265,20 @@ fn read_workdir_registry_record(
Ok(WorkdirRegistryRecord {
workspace_id: row.get(0)?,
workdir_id: row.get(1)?,
runtime_id: row.get(2)?,
repository_id: row.get(3)?,
creation_selector: row.get(4)?,
creation_ref: row.get(5)?,
creation_tree: row.get(6)?,
current_selector: row.get(7)?,
current_ref: row.get(8)?,
current_tree: row.get(9)?,
observed_at_epoch_seconds: row.get::<_, Option<i64>>(10)?.map(|value| value as u64),
materialization_status: row.get(11)?,
cleanliness: row.get(12)?,
created_at: row.get(13)?,
updated_at: row.get(14)?,
display_name: row.get(2)?,
runtime_id: row.get(3)?,
repository_id: row.get(4)?,
creation_selector: row.get(5)?,
creation_ref: row.get(6)?,
creation_tree: row.get(7)?,
current_selector: row.get(8)?,
current_ref: row.get(9)?,
current_tree: row.get(10)?,
observed_at_epoch_seconds: row.get::<_, Option<i64>>(11)?.map(|value| value as u64),
materialization_status: row.get(12)?,
cleanliness: row.get(13)?,
created_at: row.get(14)?,
updated_at: row.get(15)?,
})
}
@@ -10500,10 +10516,31 @@ fn migrate_worker_registry_projection_v61_to_v62(conn: &Connection) -> Result<()
tx.execute_batch(WORKER_REGISTRY_PROJECTION_SCHEMA)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![
LATEST_SCHEMA_VERSION,
WORKER_REGISTRY_PROJECTION_MIGRATION_NAME
],
params![62, WORKER_REGISTRY_PROJECTION_MIGRATION_NAME],
)?;
tx.commit()?;
Ok(())
}
fn migrate_multi_workdir_attachments_v62_to_v63(conn: &Connection) -> Result<()> {
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
if !column_exists(&tx, "workdir_registry", "display_name")? {
tx.execute_batch("ALTER TABLE workdir_registry ADD COLUMN display_name TEXT;")?;
}
if column_exists(&tx, "worker_workdir_links", "role")?
&& !column_exists(&tx, "worker_workdir_links", "alias")?
{
tx.execute_batch("ALTER TABLE worker_workdir_links RENAME COLUMN role TO alias;")?;
}
tx.execute_batch(
"DROP INDEX IF EXISTS worker_workdir_links_active_worker_unique;
CREATE UNIQUE INDEX IF NOT EXISTS worker_workdir_links_active_alias_unique
ON worker_workdir_links(workspace_id, worker_id, alias)
WHERE unlinked_at IS NULL;",
)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![63, MULTI_WORKDIR_ATTACHMENTS_MIGRATION_NAME],
)?;
tx.commit()?;
Ok(())
@@ -11884,6 +11921,10 @@ mod tests {
version: 62,
name: WORKER_REGISTRY_PROJECTION_MIGRATION_NAME.to_string(),
},
WorkspaceSchemaMigrationStep {
version: 63,
name: MULTI_WORKDIR_ATTACHMENTS_MIGRATION_NAME.to_string(),
},
]
);
@@ -11935,6 +11976,7 @@ mod tests {
REMOVE_WORKER_RUN_GENERATION_MIGRATION_NAME.to_string(),
),
(62, WORKER_REGISTRY_PROJECTION_MIGRATION_NAME.to_string()),
(63, MULTI_WORKDIR_ATTACHMENTS_MIGRATION_NAME.to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -12005,7 +12047,7 @@ mod tests {
.iter()
.map(|migration| migration.version)
.collect::<Vec<_>>(),
vec![52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62]
vec![52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63]
);
SqliteWorkspaceStore::migrate_database(&path).unwrap();
let conn = Connection::open(&path).unwrap();
@@ -12013,7 +12055,9 @@ mod tests {
current_schema_version(&conn).unwrap(),
LATEST_SCHEMA_VERSION
);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 13);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 14);
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
}
#[test]
@@ -14722,6 +14766,7 @@ INSERT INTO worker_registry (
let workdir = WorkdirRegistryRecord {
workspace_id: "local-dev".to_string(),
workdir_id: "0000019a00000000001".to_string(),
display_name: Some("Checkout".to_string()),
runtime_id: "embedded".to_string(),
repository_id: "repo".to_string(),
creation_selector: Some("develop".to_string()),
@@ -14740,6 +14785,7 @@ INSERT INTO worker_registry (
let unmanaged_workdir = WorkdirRegistryRecord {
workspace_id: "local-dev".to_string(),
workdir_id: "runtime-direct".to_string(),
display_name: None,
runtime_id: "embedded".to_string(),
repository_id: "repo".to_string(),
creation_selector: Some("feature".to_string()),
@@ -14760,7 +14806,7 @@ INSERT INTO worker_registry (
workspace_id: "local-dev".to_string(),
worker: worker.worker.clone(),
workdir_id: workdir.workdir_id.clone(),
role: "attachment".to_string(),
alias: "checkout".to_string(),
linked_at: "4".to_string(),
unlinked_at: None,
};
@@ -14825,6 +14871,30 @@ INSERT INTO worker_registry (
Err(Error::WorkdirAttachmentConflict(_))
));
let invalid_alias = WorkerWorkdirLinkRecord {
alias: "invalid/alias".to_string(),
..worker_conflict.clone()
};
assert!(matches!(
store.attach_worker_workdir(&invalid_alias),
Err(Error::WorkdirAttachmentConflict(_))
));
let second_attachment = WorkerWorkdirLinkRecord {
alias: "docs".to_string(),
..worker_conflict
};
assert_eq!(
store.attach_worker_workdir(&second_attachment).unwrap(),
second_attachment
);
assert_eq!(
store
.list_worker_workdir_links("local-dev", &worker.worker)
.unwrap()
.len(),
2
);
let second_worker = WorkerRegistryRecord {
worker: RuntimeWorkerRef::new("embedded", "2"),
display_name: "Browser 2".to_string(),
@@ -14919,9 +14989,7 @@ INSERT INTO worker_registry (
workspace_id: Some("local-dev".to_string()),
display_name: Some("Known Worker".to_string()),
profile: None,
repository_id: None,
repository_key: None,
working_directory_id: None,
workdir_attachments: Vec::new(),
};
let orphan = SubscriptionWorker {
worker_id: SubscriptionWorkerId::new("orphan").unwrap(),
@@ -15063,9 +15131,7 @@ INSERT INTO worker_registry (
workspace_id: Some("local-dev".to_string()),
display_name: None,
profile: None,
repository_id: None,
repository_key: None,
working_directory_id: None,
workdir_attachments: Vec::new(),
};
let commit = store
.apply_worker_registry_observation("local-dev", "embedded", 2, &event, "2")
@@ -25,6 +25,7 @@ pub fn request_fingerprint(
repository_id: &str,
selector: Option<&str>,
requested_runtime_id: Option<&str>,
display_name: Option<&str>,
repository_source_fingerprint: &str,
repository_source_revision: u64,
) -> String {
@@ -33,6 +34,7 @@ pub fn request_fingerprint(
Some(repository_id),
selector,
requested_runtime_id,
display_name,
Some(repository_source_fingerprint),
] {
match value {
@@ -569,6 +571,7 @@ mod tests {
"main",
Some("develop"),
None,
None,
"sha256:test",
1,
),
@@ -718,7 +721,7 @@ mod tests {
);
let mut changed_input = record.clone();
changed_input.request_fingerprint =
request_fingerprint("main", Some("main"), None, "sha256:test", 1);
request_fingerprint("main", Some("main"), None, None, "sha256:test", 1);
assert!(
store
.reserve_workdir_create_operation(&changed_input)
+16 -14
View File
@@ -716,7 +716,7 @@ fn load_workdir_record(
workdir_id: &str,
) -> Result<Option<WorkdirRegistryRecord>> {
conn.query_row(
r#"SELECT workspace_id, workdir_id, runtime_id, repository_id,
r#"SELECT workspace_id, workdir_id, display_name, runtime_id, repository_id,
creation_selector, creation_ref, creation_tree,
current_selector, current_ref, current_tree, observed_at_epoch_seconds,
materialization_status, cleanliness, created_at, updated_at
@@ -726,19 +726,20 @@ fn load_workdir_record(
Ok(WorkdirRegistryRecord {
workspace_id: row.get(0)?,
workdir_id: row.get(1)?,
runtime_id: row.get(2)?,
repository_id: row.get(3)?,
creation_selector: row.get(4)?,
creation_ref: row.get(5)?,
creation_tree: row.get(6)?,
current_selector: row.get(7)?,
current_ref: row.get(8)?,
current_tree: row.get(9)?,
observed_at_epoch_seconds: row.get::<_, Option<i64>>(10)?.map(|value| value as u64),
materialization_status: row.get(11)?,
cleanliness: row.get(12)?,
created_at: row.get(13)?,
updated_at: row.get(14)?,
display_name: row.get(2)?,
runtime_id: row.get(3)?,
repository_id: row.get(4)?,
creation_selector: row.get(5)?,
creation_ref: row.get(6)?,
creation_tree: row.get(7)?,
current_selector: row.get(8)?,
current_ref: row.get(9)?,
current_tree: row.get(10)?,
observed_at_epoch_seconds: row.get::<_, Option<i64>>(11)?.map(|value| value as u64),
materialization_status: row.get(12)?,
cleanliness: row.get(13)?,
created_at: row.get(14)?,
updated_at: row.get(15)?,
})
},
)
@@ -934,6 +935,7 @@ mod tests {
let workdir = WorkdirRegistryRecord {
workspace_id: "workspace-a".to_string(),
workdir_id: "workdir-a".to_string(),
display_name: None,
runtime_id: "runtime-a".to_string(),
repository_id: "repository-a".to_string(),
creation_selector: Some("refs/heads/develop".to_string()),
@@ -413,9 +413,7 @@ fn project_registry_worker(
workspace_id: Some(record.registry.workspace_id.clone()),
display_name: Some(record.registry.display_name.clone()),
profile: record.registry.profile.clone(),
repository_id: None,
repository_key: None,
working_directory_id: None,
workdir_attachments: Vec::new(),
}
};
worker.runtime_id = Some(runtime_id);
@@ -440,23 +438,33 @@ fn project_working_directory(
else {
return false;
};
if let Some(link) = links.first() {
let Ok(Some(workdir)) = api
.store
.get_workdir_registry(&api.config.workspace_id, &link.workdir_id)
else {
return false;
};
if workdir.runtime_id != worker_ref.runtime_id {
return false;
if !links.is_empty() {
let mut attachments = Vec::with_capacity(links.len());
for link in links {
let Ok(Some(workdir)) = api
.store
.get_workdir_registry(&api.config.workspace_id, &link.workdir_id)
else {
return false;
};
if workdir.runtime_id != worker_ref.runtime_id {
return false;
}
let Ok(working_directory_id) =
protocol::subscription::SubscriptionWorkdirId::new(workdir.workdir_id)
else {
return false;
};
attachments.push(
protocol::subscription::SubscriptionWorkerWorkdirAttachment {
alias: link.alias,
repository_id: Some(workdir.repository_id),
repository_key: None,
working_directory_id,
},
);
}
let Ok(working_directory_id) =
protocol::subscription::SubscriptionWorkdirId::new(workdir.workdir_id)
else {
return false;
};
worker.repository_id = Some(workdir.repository_id);
worker.working_directory_id = Some(working_directory_id);
worker.workdir_attachments = attachments;
} else {
let Ok(has_link_history) = api
.store
@@ -465,26 +473,28 @@ fn project_working_directory(
return false;
};
if has_link_history {
worker.repository_id = None;
worker.repository_key = None;
worker.working_directory_id = None;
worker.workdir_attachments.clear();
}
}
project_repository_key(api, worker)
project_repository_keys(api, worker)
}
fn project_repository_key(api: &WorkspaceApi, worker: &mut SubscriptionWorker) -> bool {
let Some(repository_id) = worker.repository_id.take() else {
worker.repository_key = None;
return true;
};
let Ok(Some(repository)) = api
.store
.get_repository(&api.config.workspace_id, &repository_id)
else {
return false;
};
worker.repository_key = Some(repository.repository_key);
fn project_repository_keys(api: &WorkspaceApi, worker: &mut SubscriptionWorker) -> bool {
for attachment in &mut worker.workdir_attachments {
let Some(repository_id) = attachment.repository_id.take() else {
if attachment.repository_key.is_none() {
return false;
}
continue;
};
let Ok(Some(repository)) = api
.store
.get_repository(&api.config.workspace_id, &repository_id)
else {
return false;
};
attachment.repository_key = Some(repository.repository_key);
}
true
}
+8 -7
View File
@@ -236,6 +236,12 @@ export type SubscriptionWorkerAvailability = "observed" | "unavailable";
export type EventSubscriptionSelector = { "topic": "runtime_workers" } | { "topic": "worker_lifecycle", worker_ids: SubscriptionWorkerIds, } | { "topic": "worker_protocol", worker_id: SubscriptionWorkerId, runtime_id?: string | null, } | { "topic": "workspace_workers" } | { "topic": "workspace_workdirs" };
export type SubscriptionWorkerWorkdirAttachment = {
/**
* Stable Worker-local routing alias.
*/
alias: string, repository_key?: string | null, working_directory_id: SubscriptionWorkdirId, };
export type SubscriptionWorker = { worker_id: SubscriptionWorkerId,
/**
* Set by the Workspace Server when projecting a Runtime-owned Worker to clients.
@@ -264,14 +270,9 @@ worker_state?: WorkerStateSnapshot | null,
/**
* Runtime catalog lifecycle compatibility projection; not foreground-state authority.
*/
state: SubscriptionWorkerState, has_running_internal_workers: boolean, workspace_id?: string | null, display_name?: string | null, profile?: string | null,
/**
* Workspace-facing Repository key. Runtime producers leave this unset and
* Workspace Server projections replace `repository_id` with this field.
*/
repository_key?: string | null, working_directory_id?: SubscriptionWorkdirId | null, };
state: SubscriptionWorkerState, has_running_internal_workers: boolean, workspace_id?: string | null, display_name?: string | null, profile?: string | null, workdir_attachments?: Array<SubscriptionWorkerWorkdirAttachment>, };
export type WorkspaceSubscriptionWorkdir = { working_directory_id: SubscriptionWorkdirId, repository_key: string, state: string, primary_worker_id?: SubscriptionWorkerId | null, };
export type WorkspaceSubscriptionWorkdir = { working_directory_id: SubscriptionWorkdirId, repository_key: string, state: string, };
export type SubscriptionSnapshot = { "topic": "workers", "data": { workers: Array<SubscriptionWorker>, } } | { "topic": "worker_protocol", "data": { worker_id: SubscriptionWorkerId, events: Array<Event>, } } | { "topic": "workspace_workdirs", "data": { workdirs: Array<WorkspaceSubscriptionWorkdir>, } };
+10 -2
View File
@@ -13,9 +13,17 @@ export type WorkingDirectoryCleanupTarget = { kind: string, working_directory_id
export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, };
export type WorkingDirectorySummary = { working_directory_id: string, repository_key: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, primary_worker_id?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
export type WorkingDirectorySummary = { working_directory_id: string,
/**
* Optional human-facing label, never a routing key.
*/
display_name?: string | null, repository_key: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
export type WorkingDirectoryCreateRequest = { runtime_id?: string | null, repository_key: string, selector?: string | null, operation_id?: string | null, };
export type WorkingDirectoryCreateRequest = { runtime_id?: string | null,
/**
* Optional human-facing label, independent of every Worker attachment alias.
*/
display_name?: string | null, repository_key: string, selector?: string | null, operation_id?: string | null, };
export type WorkingDirectoryListResponse = { workspace_id: string, items: Array<WorkingDirectorySummary>, diagnostics: Array<Diagnostic>, };
@@ -15,11 +15,19 @@ export type WorkingDirectoryCleanupTarget = { kind: string, working_directory_id
export type RuntimeWorkingDirectoryCleanupTarget = { kind: string, working_directory_id: string, repository_id: string, };
export type RuntimeWorkingDirectorySummary = { working_directory_id: string, repository_id: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: RuntimeWorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, primary_worker_id?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
export type RuntimeWorkingDirectorySummary = { working_directory_id: string,
/**
* Optional human-facing label, never a routing key.
*/
display_name?: string | null, repository_id: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: RuntimeWorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, };
export type WorkingDirectorySummary = { working_directory_id: string, repository_key: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, primary_worker_id?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
export type WorkingDirectorySummary = { working_directory_id: string,
/**
* Optional human-facing label, never a routing key.
*/
display_name?: string | null, repository_key: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, };
export type WorkerWorkspaceSummary = { visibility: string, identity: string, workspace_id?: string | null, };
@@ -27,7 +35,9 @@ export type WorkerImplementationSummary = { kind: string, display_hint: string,
export type WorkerCapabilitySummary = { can_stop: boolean, can_spawn_followup: boolean, };
export type WorkerLaunchWorkerSummary = { runtime_id: string, worker_id: string, host_id: string, display_name: string, label: string, profile: string | null, singleton_key: string | null, tags: Array<string>, workspace: WorkerWorkspaceSummary, state: string, last_seen_at: string | null, pinned: boolean, retention_state: string, implementation: WorkerImplementationSummary, capabilities: WorkerCapabilitySummary, working_directory?: RuntimeWorkingDirectorySummary | null, diagnostics: Array<Diagnostic>, };
export type RuntimeWorkerWorkdirAttachmentSummary = { alias: string, working_directory: RuntimeWorkingDirectorySummary, };
export type WorkerLaunchWorkerSummary = { runtime_id: string, worker_id: string, host_id: string, display_name: string, label: string, profile: string | null, singleton_key: string | null, tags: Array<string>, workspace: WorkerWorkspaceSummary, state: string, last_seen_at: string | null, pinned: boolean, retention_state: string, implementation: WorkerImplementationSummary, capabilities: WorkerCapabilitySummary, workdir_attachments?: Array<RuntimeWorkerWorkdirAttachmentSummary>, diagnostics: Array<Diagnostic>, };
export type WorkerLaunchRuntimeOption = { runtime_id: string, display_name: string, built_in: boolean, worker_creation_available: boolean, working_directory_required: boolean, status: string, diagnostics: Array<Diagnostic>, };
@@ -37,11 +47,15 @@ export type WorkingDirectoryRepositoryOption = { repository_key: string, default
export type WorkerLaunchOptionsResponse = { workspace_id: string, runtimes: Array<WorkerLaunchRuntimeOption>, default_profile: string | null, profiles: Array<WorkerLaunchProfileCandidate>, repositories: Array<WorkingDirectoryRepositoryOption>, working_directories: Array<WorkingDirectorySummary>, diagnostics: Array<Diagnostic>, };
export type BrowserWorkerWorkingDirectorySelection = { working_directory_id: string, relative_cwd: string | null, };
export type BrowserWorkerWorkingDirectorySelection = {
/**
* Stable Worker-local routing alias.
*/
alias: string, working_directory_id: string, relative_cwd: string | null, };
export type CreateWorkspaceWorkerTicketAssignmentRequest = { ticket_id: string, operation_id: string, };
export type CreateWorkspaceWorkerRequest = { runtime_id: string, display_name: string, profile: string | null, ticket_assignment: CreateWorkspaceWorkerTicketAssignmentRequest | null, initial_submit: Array<Segment>, working_directory: BrowserWorkerWorkingDirectorySelection | null,
export type CreateWorkspaceWorkerRequest = { runtime_id: string, display_name: string, profile: string | null, ticket_assignment: CreateWorkspaceWorkerTicketAssignmentRequest | null, initial_submit: Array<Segment>, workdir_attachments: Array<BrowserWorkerWorkingDirectorySelection>,
/**
* Backend idempotency key used only for authenticated Worker-owned spawn/control.
*/
@@ -11,6 +11,7 @@ import type {
const SUMMARY_KEYS = new Set([
"working_directory_id",
"display_name",
"repository_key",
"creation_selector",
"creation_ref",
@@ -23,11 +24,11 @@ const SUMMARY_KEYS = new Set([
"cleanup_target",
"status",
"cleanliness",
"primary_worker_id",
"occupied_by",
]);
const CREATE_REQUEST_KEYS = new Set([
"runtime_id",
"display_name",
"repository_key",
"selector",
"operation_id",
@@ -84,6 +85,7 @@ export function validateWorkingDirectoryCreateRequest(
repository_key: stringField(record, "repository_key"),
};
assignOptionalString(request, record, "runtime_id");
assignOptionalString(request, record, "display_name");
assignOptionalString(request, record, "selector");
assignOptionalString(request, record, "operation_id");
return request;
@@ -124,6 +126,7 @@ export function parseWorkingDirectorySummary(
"unknown",
]),
};
assignOptionalString(summary, record, "display_name");
assignOptionalString(summary, record, "creation_selector");
assignOptionalString(summary, record, "creation_ref");
assignOptionalString(summary, record, "creation_tree");
@@ -131,7 +134,6 @@ export function parseWorkingDirectorySummary(
assignOptionalString(summary, record, "current_ref");
assignOptionalString(summary, record, "current_tree");
assignOptionalString(summary, record, "cleanliness");
assignOptionalString(summary, record, "primary_worker_id");
if (record.observed_at_epoch_seconds !== undefined) {
const observedAt = record.observed_at_epoch_seconds;
if (observedAt === null) {
@@ -139,10 +139,11 @@ Deno.test("Worker create request parser requires the complete shared request", (
{ kind: "text", content: "Implement T-565." },
{ kind: "flow", selector: "builtin:coder-review" },
],
working_directory: {
workdir_attachments: [{
alias: "source",
working_directory_id: "workdir-a",
relative_cwd: null,
},
}],
control_operation_id: null,
};
+38 -17
View File
@@ -8,6 +8,7 @@ import type {
DiagnosticSeverity,
RuntimeWorkingDirectoryCleanupTarget,
RuntimeWorkingDirectorySummary,
RuntimeWorkerWorkdirAttachmentSummary,
WorkerCapabilitySummary,
WorkerImplementationSummary,
WorkerLaunchOptionsResponse,
@@ -261,6 +262,7 @@ function runtimeWorkingDirectory(
item,
[
"working_directory_id",
"display_name",
"repository_id",
"creation_selector",
"creation_ref",
@@ -273,7 +275,6 @@ function runtimeWorkingDirectory(
"cleanup_target",
"status",
"cleanliness",
"primary_worker_id",
"occupied_by",
],
label,
@@ -318,6 +319,11 @@ function runtimeWorkingDirectory(
item.working_directory_id,
`${label}.working_directory_id`,
),
display_name: optional(
item.display_name,
`${label}.display_name`,
string,
),
repository_id: string(item.repository_id, `${label}.repository_id`),
creation_selector: optional(
item.creation_selector,
@@ -350,15 +356,25 @@ function runtimeWorkingDirectory(
),
status: status as RuntimeWorkingDirectorySummary["status"],
cleanliness: optional(item.cleanliness, `${label}.cleanliness`, string),
primary_worker_id: optional(
item.primary_worker_id,
`${label}.primary_worker_id`,
string,
),
occupied_by: occupied,
};
}
function runtimeWorkdirAttachment(
value: unknown,
label: string,
): RuntimeWorkerWorkdirAttachmentSummary {
const item = record(value, label);
exact(item, ["alias", "working_directory"], label);
return {
alias: string(item.alias, `${label}.alias`),
working_directory: runtimeWorkingDirectory(
item.working_directory,
`${label}.working_directory`,
),
};
}
function workerSummary(
value: unknown,
label: string,
@@ -382,7 +398,7 @@ function workerSummary(
"retention_state",
"implementation",
"capabilities",
"working_directory",
"workdir_attachments",
"diagnostics",
],
label,
@@ -406,11 +422,13 @@ function workerSummary(
`${label}.implementation`,
),
capabilities: capabilitySummary(item.capabilities, `${label}.capabilities`),
working_directory: optional(
item.working_directory,
`${label}.working_directory`,
runtimeWorkingDirectory,
),
workdir_attachments: item.workdir_attachments === undefined
? undefined
: array(
item.workdir_attachments,
`${label}.workdir_attachments`,
runtimeWorkdirAttachment,
),
diagnostics: array(item.diagnostics, `${label}.diagnostics`, diagnostic),
};
}
@@ -464,8 +482,9 @@ function workingDirectorySelection(
label: string,
): BrowserWorkerWorkingDirectorySelection {
const item = record(value, label);
exact(item, ["working_directory_id", "relative_cwd"], label);
exact(item, ["alias", "working_directory_id", "relative_cwd"], label);
return {
alias: string(item.alias, `${label}.alias`),
working_directory_id: string(
item.working_directory_id,
`${label}.working_directory_id`,
@@ -638,7 +657,7 @@ export function parseCreateWorkspaceWorkerRequest(
"profile",
"ticket_assignment",
"initial_submit",
"working_directory",
"workdir_attachments",
"control_operation_id",
],
"Worker create request",
@@ -651,9 +670,11 @@ export function parseCreateWorkspaceWorkerRequest(
? null
: ticketAssignment(item.ticket_assignment, "ticket_assignment"),
initial_submit: array(item.initial_submit, "initial_submit", segment),
working_directory: item.working_directory === null
? null
: workingDirectorySelection(item.working_directory, "working_directory"),
workdir_attachments: array(
item.workdir_attachments,
"workdir_attachments",
workingDirectorySelection,
),
control_operation_id: nullableString(
item.control_operation_id,
"control_operation_id",
@@ -37,6 +37,14 @@
Math.max(0, workers.length - COLLAPSED_WORKER_COUNT),
);
function workdirMeta(worker: SidebarWorker): string {
const attachments = worker.workdir_attachments;
if (attachments.length === 0) return '—';
return attachments
.map((attachment) => `${attachment.alias}: ${attachment.repository_key ?? '—'}・${attachment.working_directory_id}`)
.join(', ');
}
function workerKey(worker: SidebarWorker): string {
return `${worker.runtime_id}:${worker.worker_id}`;
}
@@ -190,7 +198,7 @@
</span>
<span class="worker-nav-label">{label}</span>
<small class="worker-nav-meta">
{worker.repository_key ?? '—'}・{worker.working_directory_id ?? '—'}
{workdirMeta(worker)}
</small>
</a>
<button
@@ -89,6 +89,11 @@ export type WorkerCapabilities = {
can_spawn_followup: boolean;
};
export type WorkerWorkdirAttachment = {
alias: string;
working_directory: WorkingDirectorySummary;
};
export type Worker = {
runtime_id: string;
worker_id: string;
@@ -107,7 +112,7 @@ export type Worker = {
last_seen_at?: string | null;
implementation: { kind: string; display_hint: string };
capabilities: WorkerCapabilities;
working_directory?: WorkingDirectorySummary | null;
workdir_attachments?: WorkerWorkdirAttachment[];
diagnostics: Diagnostic[];
};
@@ -7,13 +7,14 @@ import type {
} from "./types";
type FetchFn = typeof fetch;
type WorkerActionTarget = Pick<Worker, "runtime_id" | "worker_id" | "state">;
type WorkerLifecycleResponse = {
state: string;
diagnostics?: Diagnostic[];
};
function workerPath(workspaceId: string, worker: Worker): string {
function workerPath(workspaceId: string, worker: WorkerActionTarget): string {
return workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(worker.runtime_id)}/workers/${
@@ -45,13 +46,13 @@ function diagnosticMessage(
fallback;
}
export function canDeleteSidebarWorker(worker: Worker): boolean {
export function canDeleteSidebarWorker(worker: WorkerActionTarget): boolean {
return worker.state === "stopped";
}
export async function stopSidebarWorker(
workspaceId: string,
worker: Worker,
worker: WorkerActionTarget,
fetchFn: FetchFn = fetch,
): Promise<void> {
const response = await fetchFn(`${workerPath(workspaceId, worker)}/stop`, {
@@ -71,7 +72,7 @@ export async function stopSidebarWorker(
export async function deleteSidebarWorker(
workspaceId: string,
worker: Worker,
worker: WorkerActionTarget,
fetchFn: FetchFn = fetch,
): Promise<void> {
const runtimePath = `/runtimes/${encodeURIComponent(worker.runtime_id)}`;
@@ -57,7 +57,6 @@ const options: WorkerLaunchOptionsResponse = {
materializer_kind: "runtime_git_clone",
status: "active",
cleanliness: "clean",
primary_worker_id: null,
cleanup_target: {
kind: "runtime_git_clone",
working_directory_id: "wd-1-repo",
@@ -180,7 +179,7 @@ Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () =>
assertEquals(form.working_directory_selector, "work/ticket");
});
Deno.test("buildCreateWorkspaceWorkerRequest sends working_directory id and relative cwd only", () => {
Deno.test("buildCreateWorkspaceWorkerRequest sends one aliased attachment", () => {
const request = buildCreateWorkspaceWorkerRequest({
runtime_id: "embedded",
display_name: "Worker",
@@ -198,10 +197,11 @@ Deno.test("buildCreateWorkspaceWorkerRequest sends working_directory id and rela
profile: "builtin:coder",
ticket_assignment: null,
initial_submit: [{ kind: "text", content: "go" }],
working_directory: {
workdir_attachments: [{
alias: "workdir",
working_directory_id: "wd-1-repo",
relative_cwd: "crates/yoi",
},
}],
control_operation_id: null,
});
});
@@ -221,7 +221,7 @@ Deno.test("buildCreateWorkspaceWorkerRequest sends no initial segments for an em
assertEquals(request.initial_submit, []);
});
Deno.test("buildCreateWorkspaceWorkerRequest emits null for embedded no-workdir launches", () => {
Deno.test("buildCreateWorkspaceWorkerRequest emits an empty attachment list for workdirless launches", () => {
const request = buildCreateWorkspaceWorkerRequest({
runtime_id: "embedded",
display_name: "Worker",
@@ -239,7 +239,7 @@ Deno.test("buildCreateWorkspaceWorkerRequest emits null for embedded no-workdir
profile: "builtin:companion",
ticket_assignment: null,
initial_submit: [{ kind: "text", content: "chat" }],
working_directory: null,
workdir_attachments: [],
control_operation_id: null,
});
});
@@ -31,7 +31,6 @@ export function defaultWorkerLaunchForm(
options?.working_directories.filter((directory) =>
directory.status === "active" &&
directory.cleanliness === "clean" &&
directory.primary_worker_id == null &&
directory.occupied_by == null
) ?? [];
const selectedRuntime = current.runtime_id
@@ -97,12 +96,13 @@ export function buildCreateWorkspaceWorkerRequest(
initial_submit: initialMessage
? [{ kind: "text", content: form.initial_text }]
: [],
working_directory: form.working_directory_id
? {
workdir_attachments: form.working_directory_id
? [{
alias: "workdir",
working_directory_id: form.working_directory_id,
relative_cwd: form.relative_cwd.trim() || null,
}
: null,
}]
: [],
control_operation_id: null,
});
}
@@ -32,7 +32,7 @@ function worker(
workspace_id: 'workspace-test',
display_name: null,
profile: null,
working_directory_id: null,
workdir_attachments: [],
};
}
@@ -1,5 +1,8 @@
import { readable, type Readable } from 'svelte/store';
import type { SubscriptionWorker } from '$lib/generated/protocol';
import type {
SubscriptionWorker,
SubscriptionWorkerWorkdirAttachment,
} from '$lib/generated/protocol';
import { workspaceMultiplexer } from '$lib/workspace/multiplexer';
import {
applyWorkspaceWorkersFrame,
@@ -9,9 +12,8 @@ import { liveWorkerState } from './worker-state';
import { compareWorkersForSidebar } from './workers';
import type { Worker } from './types';
export type SidebarWorker = Worker & {
repository_key: string | null;
working_directory_id: string | null;
export type SidebarWorker = Omit<Worker, 'workdir_attachments'> & {
workdir_attachments: SubscriptionWorkerWorkdirAttachment[];
has_running_internal_workers: boolean;
};
@@ -103,10 +105,8 @@ function projectWorker(worker: SubscriptionWorker): SidebarWorker {
can_stop: worker.availability !== 'unavailable' && worker.state !== 'stopped',
can_spawn_followup: false,
},
repository_key: worker.repository_key ?? null,
working_directory_id: worker.working_directory_id ?? null,
workdir_attachments: worker.workdir_attachments ?? [],
has_running_internal_workers: worker.has_running_internal_workers,
working_directory: null,
diagnostics: [],
};
}
@@ -40,7 +40,7 @@ function worker(overrides: Partial<Worker>): Worker {
can_stop: true,
can_spawn_followup: false,
},
working_directory: null,
workdir_attachments: [],
diagnostics: [],
...overrides,
};
@@ -19,11 +19,15 @@ export function sidebarWorkerActivity(
return 'none';
}
export function canShowWorkerInSidebar(worker: Worker): boolean {
export function canShowWorkerInSidebar(
worker: Pick<Worker, 'implementation'>,
): boolean {
return worker.implementation.kind !== 'backend_worker_registry';
}
export function canOpenWorkerConsole(worker: Worker): boolean {
export function canOpenWorkerConsole(
worker: Pick<Worker, 'implementation'>,
): boolean {
return canShowWorkerInSidebar(worker);
}
@@ -138,7 +138,10 @@
{#each workdirs as workdir}
{@const cleanup = cleanupCandidate(workdir)}
<tr>
<td><code>{workdir.working_directory_id}</code></td>
<td>
<span>{workdir.display_name ?? '—'}</span>
<small><code>{workdir.working_directory_id}</code></small>
</td>
<td>{workdir.repository_key}</td>
<td><code>{currentRevision(workdir)}</code></td>
<td>{workdir.status}</td>
@@ -145,11 +145,14 @@
}
function workerDirectory(worker: Worker): string {
const directory = worker.working_directory;
if (!directory) return '—';
const provider = data.repositories?.items.find((repository) => repository.repository_key === directory.repository_key)
?.provider;
return `${directory.repository_key} · ${formatCurrentWorkdirRevision(directory, provider)}`;
const attachments = worker.workdir_attachments ?? [];
if (attachments.length === 0) return '—';
return attachments.map(({ alias, working_directory: directory }) => {
const provider = data.repositories?.items.find((repository) => repository.repository_key === directory.repository_key)
?.provider;
const label = directory.display_name ?? directory.repository_key;
return `${alias}: ${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
}).join(', ');
}
</script>
@@ -33,7 +33,8 @@
function workdirOptionLabel(directory: WorkingDirectorySummary): string {
const provider = data.repositories?.items.find((repository) => repository.repository_key === directory.repository_key)
?.provider;
return `${directory.repository_key} · ${formatCurrentWorkdirRevision(directory, provider)}`;
const label = directory.display_name ?? directory.repository_key;
return `${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
}
let { data }: PageProps = $props();
@@ -62,6 +63,7 @@
);
let initialText = $state(ticketContext?.initialInput ?? '');
let workingDirectoryId = $state('');
let workingDirectoryDisplayName = $state('');
let workingDirectoryRepositoryKey = $state(ticketContext?.repositoryKey ?? '');
let workingDirectorySelector = $state(ticketContext?.refSelector ?? 'HEAD');
let relativeCwd = $state('');
@@ -78,7 +80,6 @@
: (options?.working_directories ?? []).filter((directory) =>
directory.status === 'active' &&
directory.cleanliness === 'clean' &&
directory.primary_worker_id == null &&
directory.occupied_by == null
),
);
@@ -168,6 +169,7 @@
try {
const request = validateWorkingDirectoryCreateRequest({
runtime_id: runtimeId,
...(workingDirectoryDisplayName.trim() ? { display_name: workingDirectoryDisplayName.trim() } : {}),
repository_key: workingDirectoryRepositoryKey,
...(workingDirectorySelector ? { selector: workingDirectorySelector } : {}),
});
@@ -340,6 +342,10 @@
<div class="new-working-directory-panel">
<h3>New workdir</h3>
<div class="new-working-directory-fields">
<label>
<span>Display name</span>
<input bind:value={workingDirectoryDisplayName} autocomplete="off" placeholder="Optional label" maxlength="80" />
</label>
<label>
<span>Repository</span>
<select bind:value={workingDirectoryRepositoryKey}>
+8
View File
@@ -10,6 +10,7 @@ import {
const summary = {
working_directory_id: "workdir-1",
display_name: "Checkout",
repository_key: "main",
materializer_kind: "runtime_git_clone",
status: "active",
@@ -27,6 +28,9 @@ Deno.test("Workdir REST validation accepts the generated list and create contrac
items: [summary],
diagnostics: [],
});
if (list.items[0]?.display_name !== "Checkout") {
throw new Error("display name was not preserved");
}
if (list.items[0]?.occupied_by?.runtime_id !== "arcadia") {
throw new Error("occupancy subject was not preserved");
}
@@ -60,6 +64,7 @@ Deno.test("Workdir REST validation rejects stale response JSON", () => {
Deno.test("Workdir REST validation enforces create operation fields", () => {
const request = validateWorkingDirectoryCreateRequest({
runtime_id: "arcadia",
display_name: "Review checkout",
repository_key: "main",
selector: "develop",
operation_id: "operation-1",
@@ -67,6 +72,9 @@ Deno.test("Workdir REST validation enforces create operation fields", () => {
if (request.operation_id !== "operation-1") {
throw new Error("operation id was not preserved");
}
if (request.display_name !== "Review checkout") {
throw new Error("display name was not preserved");
}
for (
const invalid of [