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