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