66 Commits
Author SHA1 Message Date
Hare f966470d33 fix: acknowledge durable submissions before run preparation 2026-09-07 21:38:19 +09:00
Hare c76ede2ab4 fix: allow slow initial input commits 2026-09-07 20:21:22 +09:00
Hare 2fd043b634 fix: expand worker creation timeout budgets 2026-09-07 19:43:26 +09:00
Hare fb6bbe9145 fix: wait for worker shutdown before deletion 2026-09-07 19:31:32 +09:00
Hare d1585d7483 fix: route worker failure logs through tracing 2026-09-07 18:14:07 +09:00
Hare 13d853217d fix: log failed worker creations to stdout 2026-09-07 04:56:54 +09:00
Hare cc27d57e4a fix: reclaim failed worker creations 2026-09-07 04:44:19 +09:00
Hare f5c5ea5a0b fix: route worker consoles through logical resources 2026-09-07 03:53:34 +09:00
Hare f1baea1705 feat: distribute latest workspace config to runtimes 2026-09-07 02:40:03 +09:00
Hare 31f7d39647 feat: add Server database migration command 2026-09-06 16:17:48 +09:00
Hare c5a834bfd2 fix: restore composable Workspace schema migrations 2026-09-06 16:17:40 +09:00
Hare 1eef9b75ee Merge branch 'develop' into hare/develop 2026-09-06 13:39:37 +09:00
Hare ba9c885f52 feat: integrate Worker state authority 2026-09-06 11:00:00 +09:00
Hare 88683a8d8f test: provide authoritative websocket snapshot 2026-09-06 10:41:20 +09:00
Hare 0c48c5dee3 fix: fail closed without worker snapshots 2026-09-06 10:33:45 +09:00
Hare 101a0acb6b fix: terminalize removed worker reservations 2026-09-06 10:32:45 +09:00
Hare 2d1956b653 fix: separate worker state from runtime lifecycle 2026-09-06 10:05:54 +09:00
Hare b7bba8b53a fix: preserve workspace deletion checkpoints 2026-09-06 10:02:18 +09:00
Hare 7b25b767f8 fix: checkpoint workspace cleanup children 2026-09-06 09:35:44 +09:00
Hare 4631b95144 fix: fence workspace deletion side effects 2026-09-06 09:21:37 +09:00
Hare 38627c498b fix: harden workspace deletion recovery 2026-09-06 08:57:54 +09:00
Hare 282a8d31b5 fix: enforce monotonic worker state projection 2026-09-06 07:56:25 +09:00
Hare ab4fb4c1ee fix: enforce workspace deletion blockers 2026-09-06 07:54:52 +09:00
Hare e3e9e83bc1 feat: add guarded workspace deletion workflow 2026-09-06 07:22:33 +09:00
Hare 4269ebec04 chore: merge develop into worker state snapshot 2026-09-06 07:17:40 +09:00
Hare e8b9adcde4 feat: add revisioned worker execution state 2026-09-06 06:54:29 +09:00
Hare 668a9062b3 fix: fence runtime detail route state 2026-09-06 06:11:23 +09:00
Hare 5fd2ccf084 fix: gate runtime key reveal and revoke confirmation 2026-09-06 05:50:29 +09:00
Hare 5686bbc9fd fix: preview runtime trust rotation fingerprint 2026-09-06 05:25:03 +09:00
Hare 2cd57a32b2 fix: align runtime trust schema and built-in controls 2026-09-06 05:17:27 +09:00
Hare 89f4f99622 Merge branch 'develop' into work/T-595-runtime-trust-key-management
# Conflicts:
#	web/workspace/deno.json
2026-09-06 05:07:26 +09:00
Hare 78d571ed14 feat: add workspace runtime trust key management 2026-09-06 05:06:37 +09:00
Hare e5332f4a7f feat: route SubWorker workdir execution through parent 2026-09-06 04:28:52 +09:00
Hare 3ed1545c3c feat: integrate worker submit queue 2026-09-06 04:24:07 +09:00
Hare 9da20d15da fix: compose passive notify with queued submit 2026-09-06 04:15:30 +09:00
Hare 052d60bd7d fix: fence late command events and spawn rollback 2026-09-06 04:10:23 +09:00
Hare 2456d6fda5 fix: preserve pending activation order on restore 2026-09-06 04:00:02 +09:00
Hare e7803d1aba fix: qualify runtime binding helper 2026-09-06 03:52:42 +09:00
Hare ca5fddf89b fix: fence recursive SubWorker shutdown 2026-09-06 03:48:54 +09:00
Hare 88fad3893e Merge commit '5e9f7a7dc3d1169118412376f16b72850aab63f2' into work/T-593-workspace-runtime-bindings
# Conflicts:
#	crates/workspace-server/src/server.rs
2026-09-06 03:46:09 +09:00
Hare 82f9b0e48c chore: merge current develop into T-588 2026-09-06 03:45:30 +09:00
Hare ddb4c1454d fix: enable running composer delivery 2026-09-06 03:32:23 +09:00
Hare 7b1cf854f2 fix: serialize scoped command teardown 2026-09-06 03:29:50 +09:00
Hare 51c6d7f835 fix: require authenticated runtime bindings 2026-09-06 03:28:19 +09:00
Hare 9d55ce0a87 fix: reconcile interrupted upload pins 2026-09-06 03:15:44 +09:00
Hare 0f8d61188a fix: order SubWorker cleanup before Workdir release 2026-09-06 03:12:55 +09:00
Hare 7363dffb9d fix: gate runtime projection broadcasts 2026-09-06 03:00:36 +09:00
Hare 8e4b7deaa4 fix: preserve authenticated submit source 2026-09-06 02:56:51 +09:00
Hare ec845cbc25 fix: fence revoked runtime bindings 2026-09-06 02:52:15 +09:00
Hare e7079e223f fix: close scoped SubWorker command authority 2026-09-06 02:37:13 +09:00
Hare dea5bd581d fix: secure submit activation handoff 2026-09-06 02:31:10 +09:00
Hare 75b85b46d1 feat: scope runtime bindings by workspace 2026-09-06 02:30:13 +09:00
Hare 68f00bc948 refactor: broker SubWorker Workdir tools through parent 2026-09-06 02:17:44 +09:00
Hare 5e9f7a7dc3 feat: add versioned runtime connection ping 2026-09-06 02:17:26 +09:00
Hare b038f022d3 fix: harden submit queue durability 2026-09-06 01:35:26 +09:00
Hare cf7515fb35 fix: keep tool overview neutral on failures 2026-09-06 01:20:39 +09:00
Hare bb4c1dfe4f docs: update flow submit protocol example 2026-09-05 23:25:25 +09:00
Hare 72b56964c3 chore: refresh T-588 onto develop 2026-09-05 23:19:52 +09:00
Hare 5b0a6691f8 fix: fence pending queue controls 2026-09-05 23:16:19 +09:00
Hare 1239c638a5 Merge branch 'develop' into hare/develop 2026-09-05 22:39:57 +09:00
Hare d2fa0787d8 fix: retain child-local bash output scope 2026-09-05 22:29:19 +09:00
Hare a7056702e8 fix: keep worker-host paths out of remote delegations 2026-09-05 22:18:46 +09:00
Hare bb56283063 feat: persist worker submit activation queue 2026-09-05 22:06:59 +09:00
Hare 130ef1f0fe fix: align Backend worker picker columns 2026-09-05 21:19:18 +09:00
Hare 724205b1df feat: create Backend workers from bare TUI launch 2026-09-05 21:19:12 +09:00
Hare 69824ea45d fix: defer resident Memory loading until first run 2026-09-05 07:52:47 +09:00
133 changed files with 24075 additions and 5958 deletions
Generated
+17
View File
@@ -5257,6 +5257,16 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-serde"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
dependencies = [
"serde",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
@@ -5267,12 +5277,15 @@ dependencies = [
"nu-ansi-term",
"once_cell",
"regex-automata",
"serde",
"serde_json",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
"tracing-serde",
]
[[package]]
@@ -6650,6 +6663,7 @@ dependencies = [
"serial_test",
"session-metrics",
"session-store",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.18",
"ticket",
@@ -6694,6 +6708,8 @@ dependencies = [
"tokio-tungstenite 0.29.0",
"toml",
"tower",
"tracing",
"tracing-subscriber",
"url",
"uuid",
"workdir",
@@ -6839,6 +6855,7 @@ dependencies = [
"toml",
"tower",
"tracing",
"tracing-subscriber",
"ts-rs",
"url",
"uuid",
+1
View File
@@ -132,6 +132,7 @@ tokio-tungstenite = "0.29"
tower = "0.5"
toml = "1.1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
url = "2.5"
uuid = "1.23"
zeroize = "1"
+284 -2
View File
@@ -7,10 +7,15 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
pub use workspace_api::{
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary,
BrowserCreateWorkerResponse as BackendCreateWorkerResponse,
CreateWorkspaceWorkerRequest as BackendCreateWorkerRequest, Diagnostic as BackendDiagnostic,
DiagnosticSeverity as BackendDiagnosticSeverity, ListResponse as BackendRuntimeListResponse,
RuntimeSummary as BackendRuntimeSummary,
WorkerCapabilitySummary as BackendWorkerCapabilitySummary,
WorkerImplementationSummary as BackendWorkerImplementationSummary,
WorkerLaunchOptionsResponse as BackendWorkerLaunchOptions,
WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate,
WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption,
WorkerRestoreResponse as BackendWorkerRestoreResponse,
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
@@ -171,6 +176,47 @@ struct UploadedFileResponse {
file: protocol::UploadedFileRef,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkerLaunchTarget {
pub base_url: String,
pub workspace_id: Option<String>,
}
impl BackendWorkerLaunchTarget {
pub fn new(base_url: impl Into<String>, workspace_id: Option<String>) -> Self {
Self {
base_url: base_url.into(),
workspace_id,
}
}
pub fn select_workspace(&mut self, workspace_id: impl Into<String>) {
self.workspace_id = Some(workspace_id.into());
}
pub fn workspace_id(&self) -> Option<&str> {
self.workspace_id.as_deref()
}
pub fn runtime_target(
&self,
runtime_id: impl Into<String>,
worker_id: impl Into<String>,
) -> Result<BackendRuntimeTarget, BackendRuntimeClientError> {
let workspace_id = self.workspace_id.clone().ok_or_else(|| {
BackendRuntimeClientError::InvalidTarget(
"workspace_id is required before creating a Backend worker".to_string(),
)
})?;
Ok(BackendRuntimeTarget::new(
self.base_url.clone(),
workspace_id,
runtime_id,
worker_id,
))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendRuntimeListTarget {
pub base_url: String,
@@ -255,6 +301,58 @@ impl From<reqwest::Error> for BackendRuntimeClientError {
}
}
pub async fn get_backend_worker_launch_options(
target: &BackendWorkerLaunchTarget,
) -> Result<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
validate_launch_target(target)?;
let api = BackendApiClient::from_stored_token(&target.base_url)?;
get_backend_worker_launch_options_with_client(target, &api).await
}
async fn get_backend_worker_launch_options_with_client(
target: &BackendWorkerLaunchTarget,
api: &BackendApiClient,
) -> Result<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
let path = backend_workspace_workers_launch_options_path(
target
.workspace_id
.as_deref()
.expect("validated Backend Workspace scope"),
);
let response = api.request(HttpMethod::GET, &path)?.send().await?;
let response = api.require_success(response).await?;
Ok(response.json::<BackendWorkerLaunchOptions>().await?)
}
pub async fn create_backend_worker(
target: &BackendWorkerLaunchTarget,
request: &BackendCreateWorkerRequest,
) -> Result<BackendCreateWorkerResponse, BackendRuntimeClientError> {
validate_launch_target(target)?;
let api = BackendApiClient::from_stored_token(&target.base_url)?;
create_backend_worker_with_client(target, request, &api).await
}
async fn create_backend_worker_with_client(
target: &BackendWorkerLaunchTarget,
request: &BackendCreateWorkerRequest,
api: &BackendApiClient,
) -> Result<BackendCreateWorkerResponse, BackendRuntimeClientError> {
let path = backend_workspace_workers_path(
target
.workspace_id
.as_deref()
.expect("validated Backend Workspace scope"),
);
let response = api
.request(HttpMethod::POST, &path)?
.json(request)
.send()
.await?;
let response = api.require_success(response).await?;
Ok(response.json::<BackendCreateWorkerResponse>().await?)
}
pub async fn list_backend_workers(
target: &BackendRuntimeListTarget,
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
@@ -462,6 +560,30 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl
Ok(())
}
fn validate_launch_target(
target: &BackendWorkerLaunchTarget,
) -> Result<(), BackendRuntimeClientError> {
if target.base_url.trim().is_empty() {
return Err(BackendRuntimeClientError::InvalidTarget(
"Backend API base URL is required".to_string(),
));
}
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
return Err(BackendRuntimeClientError::InvalidTarget(
"Backend API base URL must start with http:// or https://".to_string(),
));
}
match target.workspace_id.as_deref() {
Some("") => Err(BackendRuntimeClientError::InvalidTarget(
"workspace_id must not be empty".to_string(),
)),
None => Err(BackendRuntimeClientError::InvalidTarget(
"workspace selection is required before creating a Backend worker".to_string(),
)),
Some(_) => Ok(()),
}
}
fn validate_list_target(
target: &BackendRuntimeListTarget,
) -> Result<(), BackendRuntimeClientError> {
@@ -496,6 +618,17 @@ fn validate_list_target(
Ok(())
}
fn backend_workspace_workers_path(workspace_id: &str) -> String {
format!("/api/w/{}/workers", path_segment_encode(workspace_id))
}
fn backend_workspace_workers_launch_options_path(workspace_id: &str) -> String {
format!(
"{}/launch-options",
backend_workspace_workers_path(workspace_id)
)
}
fn backend_runtimes_path(workspace_id: &str) -> String {
format!("/api/w/{}/runtimes", path_segment_encode(workspace_id))
}
@@ -580,6 +713,155 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
async fn serve_json_once(body: serde_json::Value) -> (String, tokio::task::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let header_end = loop {
let mut buffer = [0_u8; 4096];
let read = socket.read(&mut buffer).await.unwrap();
assert!(read > 0, "client closed before sending HTTP headers");
request.extend_from_slice(&buffer[..read]);
if let Some(position) = request.windows(4).position(|part| part == b"\r\n\r\n") {
break position + 4;
}
};
let headers = String::from_utf8_lossy(&request[..header_end]);
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().unwrap())
})
.unwrap_or(0);
while request.len() < header_end + content_length {
let mut buffer = [0_u8; 4096];
let read = socket.read(&mut buffer).await.unwrap();
assert!(read > 0, "client closed before sending HTTP body");
request.extend_from_slice(&buffer[..read]);
}
let body = serde_json::to_vec(&body).unwrap();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
socket.write_all(response.as_bytes()).await.unwrap();
socket.write_all(&body).await.unwrap();
String::from_utf8(request).unwrap()
});
(base_url, task)
}
#[tokio::test]
async fn launch_options_request_uses_workspace_path_and_bearer_auth() {
let (base_url, server) = serve_json_once(serde_json::json!({
"workspace_id": "team main",
"runtimes": [{
"runtime_id": "embedded",
"display_name": "Embedded",
"built_in": true,
"worker_creation_available": true,
"working_directory_required": false,
"status": "online",
"diagnostics": []
}],
"default_profile": "builtin:default",
"profiles": [{
"id": "builtin:default",
"label": "Default",
"description": ""
}],
"repositories": [],
"working_directories": [],
"diagnostics": []
}))
.await;
let target = BackendWorkerLaunchTarget::new(&base_url, Some("team main".to_string()));
let api = BackendApiClient::from_access_token_for_test(&base_url, "launch-secret").unwrap();
let response = get_backend_worker_launch_options_with_client(&target, &api)
.await
.unwrap();
assert_eq!(response.runtimes[0].runtime_id, "embedded");
let request = server.await.unwrap();
assert!(request.starts_with("GET /api/w/team%20main/workers/launch-options HTTP/1.1\r\n"));
assert!(
request
.to_ascii_lowercase()
.contains("authorization: bearer launch-secret\r\n")
);
}
#[tokio::test]
async fn create_worker_posts_frontend_contract_to_workspace_path() {
let (base_url, server) = serve_json_once(serde_json::json!({
"workspace_id": "workspace-1",
"runtime_id": "embedded",
"worker_id": "worker-1",
"console_href": "/w/workspace-1/workers/embedded/worker-1",
"worker": {
"runtime_id": "embedded",
"worker_id": "worker-1",
"host_id": "host-1",
"display_name": "Coder one",
"label": "Coder one",
"profile": "builtin:coder",
"singleton_key": null,
"tags": [],
"workspace": {
"visibility": "workspace",
"identity": "workspace",
"workspace_id": "workspace-1"
},
"state": "idle",
"last_seen_at": null,
"pinned": false,
"retention_state": "resident",
"implementation": {"kind": "embedded", "display_hint": "Embedded"},
"capabilities": {"can_stop": true, "can_spawn_followup": false},
"diagnostics": []
},
"diagnostics": []
}))
.await;
let target = BackendWorkerLaunchTarget::new(&base_url, Some("workspace-1".to_string()));
let api = BackendApiClient::from_access_token_for_test(&base_url, "create-secret").unwrap();
let create = BackendCreateWorkerRequest {
runtime_id: "embedded".to_string(),
display_name: "Coder one".to_string(),
profile: Some("builtin:coder".to_string()),
ticket_assignment: None,
initial_submit: Vec::new(),
working_directory: None,
control_operation_id: None,
};
let response = create_backend_worker_with_client(&target, &create, &api)
.await
.unwrap();
assert_eq!(response.worker_id, "worker-1");
let request = server.await.unwrap();
assert!(request.starts_with("POST /api/w/workspace-1/workers HTTP/1.1\r\n"));
assert!(
request
.to_ascii_lowercase()
.contains("authorization: bearer create-secret\r\n")
);
let body = request.split_once("\r\n\r\n").unwrap().1;
let body: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(body["runtime_id"], "embedded");
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);
}
#[test]
fn protocol_url_uses_backend_runtime_worker_identity() {
+8 -7
View File
@@ -112,26 +112,27 @@ mod tests {
async fn encodes_methods_and_decodes_events_above_transport() {
let mut socket = TestSocket::default();
socket.incoming.push_back(
encode_event(&Event::Status {
status: WorkerStatus::Idle,
encode_event(&Event::WorkerState {
snapshot: WorkerStatus::Idle.into(),
})
.expect("encode event"),
);
let mut client = Client::new(socket);
client
.send(&Method::run_text("hello"))
.send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await
.expect("send method");
assert!(matches!(
decode_method(&client.socket.sent[0]),
Ok(Method::Run { .. })
Ok(Method::Submit { .. })
));
assert!(matches!(
client.next_event().await,
Ok(Some(Event::Status {
status: WorkerStatus::Idle
}))
Ok(Some(Event::WorkerState { .. }))
));
}
}
+11 -8
View File
@@ -21,11 +21,14 @@ pub use backend_auth::{
poll_device_login, start_device_login, wait_for_device_login,
};
pub use backend_runtime::{
BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClientError,
BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary,
BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime,
BackendCreateWorkerRequest, BackendCreateWorkerResponse, BackendDiagnostic,
BackendDiagnosticSeverity, BackendRuntimeClientError, BackendRuntimeListResponse,
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
BackendWorkerLaunchTarget, BackendWorkerRestoreResponse, BackendWorkerRestoreResult,
BackendWorkerSummary, BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary,
connect_backend_runtime, create_backend_worker, get_backend_worker_launch_options,
list_backend_stopped_workers, list_backend_workers, restore_backend_worker,
};
pub use backend_workspace::{
@@ -35,9 +38,9 @@ pub use backend_workspace::{
};
pub use client::{Client, ClientError};
pub use target::{
BackendTarget, Dashboard, ResolvedTarget, StandaloneTarget, StandaloneWorkerListIntent,
StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection,
WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
BackendTarget, BackendWorkerLaunch, Dashboard, ResolvedTarget, StandaloneTarget,
StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind,
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
};
pub use workspace_api::{
CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition,
+22 -1
View File
@@ -2,7 +2,7 @@ use std::{fmt, path::PathBuf};
use crate::{
BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget,
BackendRuntimeTarget,
BackendRuntimeTarget, BackendWorkerLaunchTarget,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -123,6 +123,11 @@ pub struct Dashboard {
pub workspace_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkerLaunch {
pub target: BackendWorkerLaunchTarget,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerList {
pub backend_target: BackendRuntimeListTarget,
@@ -199,6 +204,13 @@ pub trait Target: fmt::Debug + Send + Sync {
Err(TargetError::unsupported("Worker dashboard", self.kind()))
}
fn launch_backend_worker(&self) -> Result<BackendWorkerLaunch, TargetError> {
Err(TargetError::unsupported(
"Backend Worker launch",
self.kind(),
))
}
fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Err(TargetError::unsupported("Worker listing", self.kind()))
}
@@ -299,6 +311,15 @@ impl Target for BackendTarget {
})
}
fn launch_backend_worker(&self) -> Result<BackendWorkerLaunch, TargetError> {
Ok(BackendWorkerLaunch {
target: BackendWorkerLaunchTarget::new(
self.base_url.clone(),
self.workspace_id.clone(),
),
})
}
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Ok(WorkerList {
backend_target: BackendRuntimeListTarget::new(
+8 -7
View File
@@ -89,17 +89,20 @@ mod tests {
let mut client = Client::new(socket);
client
.send(&Method::run_text("hello"))
.send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await
.expect("send method");
assert!(matches!(
peer.next().await.as_deref().map(decode_method),
Some(Ok(Method::Run { .. }))
Some(Ok(Method::Submit { .. }))
));
peer.send(
encode_event(&Event::Status {
status: WorkerStatus::Idle,
encode_event(&Event::WorkerState {
snapshot: WorkerStatus::Idle.into(),
})
.expect("encode event"),
)
@@ -107,9 +110,7 @@ mod tests {
.expect("send event");
assert!(matches!(
client.next_event().await,
Ok(Some(Event::Status {
status: WorkerStatus::Idle
}))
Ok(Some(Event::WorkerState { .. }))
));
}
}
+11 -10
View File
@@ -113,8 +113,8 @@ mod tests {
let listener = UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let event = encode_event(&Event::Status {
status: WorkerStatus::Idle,
let event = encode_event(&Event::WorkerState {
snapshot: WorkerStatus::Idle.into(),
})
.unwrap();
stream.write_all(event.as_bytes()).await.unwrap();
@@ -126,12 +126,7 @@ mod tests {
.await
.expect("client should receive event while alive")
.expect("transport should succeed");
assert!(matches!(
event,
Some(Event::Status {
status: WorkerStatus::Idle
})
));
assert!(matches!(event, Some(Event::WorkerState { .. })));
server.await.unwrap();
}
@@ -147,12 +142,18 @@ mod tests {
let mut client = Client::new(Socket::connect(&socket_path).await.unwrap());
client
.send(&Method::run_text("hello"))
.send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await
.expect("send method");
let received = server.await.unwrap().expect("method message");
assert!(matches!(decode_method(&received), Ok(Method::Run { .. })));
assert!(matches!(
decode_method(&received),
Ok(Method::Submit { .. })
));
}
#[tokio::test]
+8 -7
View File
@@ -114,10 +114,10 @@ mod tests {
assert!(matches!(
message,
Message::Text(ref text)
if matches!(decode_method(text), Ok(Method::Run { .. }))
if matches!(decode_method(text), Ok(Method::Submit { .. }))
));
let event = encode_event(&Event::Status {
status: WorkerStatus::Idle,
let event = encode_event(&Event::WorkerState {
snapshot: WorkerStatus::Idle.into(),
})
.unwrap();
socket.send(Message::Text(event.into())).await.unwrap();
@@ -126,14 +126,15 @@ mod tests {
let request = format!("ws://{address}").into_client_request().unwrap();
let mut client = Client::new(Socket::connect(request).await.unwrap());
client
.send(&Method::run_text("hello"))
.send(&Method::submit_text(
protocol::new_submission_request_id(),
"hello",
))
.await
.expect("send method");
assert!(matches!(
client.next_event().await,
Ok(Some(Event::Status {
status: WorkerStatus::Idle
}))
Ok(Some(Event::WorkerState { .. }))
));
server.await.unwrap();
}
+51 -2
View File
@@ -12,8 +12,10 @@ use workspace_api::{
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse,
ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest, RuntimeTrustKeyRevealResponse,
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
};
use crate::{BackendApiClient, BackendWorkspaceClientError};
@@ -241,6 +243,53 @@ impl BackendWorkspaceProductClient {
)
}
pub fn list_runtimes(
&self,
) -> Result<ListResponse<WorkspaceRuntimeResource>, BackendWorkspaceClientError> {
self.get_json("/runtimes")
}
pub fn runtime_detail(
&self,
runtime_id: &str,
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
self.get_json(&format!("/runtimes/{}", encode_path_segment(runtime_id)))
}
pub fn reveal_runtime_trust_key(
&self,
runtime_id: &str,
) -> Result<RuntimeTrustKeyRevealResponse, BackendWorkspaceClientError> {
self.get_json(&format!(
"/runtimes/{}/trust-key",
encode_path_segment(runtime_id)
))
}
pub fn put_runtime_trust_key(
&self,
runtime_id: &str,
request: &PutRuntimeTrustKeyRequest,
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
self.send_json(
Method::PUT,
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
Some(request),
)
}
pub fn revoke_runtime_trust_key(
&self,
runtime_id: &str,
request: &RevokeRuntimeTrustKeyRequest,
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
self.send_json(
Method::DELETE,
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
Some(request),
)
}
pub fn memory_document(&self) -> Result<MemoryDocumentResponse, BackendWorkspaceClientError> {
self.get_json("/memory")
}
+534 -104
View File
@@ -11,6 +11,11 @@ use serde::{Deserialize, Serialize};
pub use identity::{WorkerId, WorkerIdParseError};
/// Allocate an opaque idempotency key for one client Submit request.
pub fn new_submission_request_id() -> String {
uuid::Uuid::now_v7().to_string()
}
fn default_true() -> bool {
true
}
@@ -27,21 +32,264 @@ fn is_false(value: &bool) -> bool {
// Method (Client → Worker via Unix Socket)
// ---------------------------------------------------------------------------
/// Trusted Server → Runtime transport header carrying the authenticated
/// browser Account identity for one Worker protocol connection.
///
/// Runtime accepts this only after its normal HTTP authentication succeeds;
/// serialized [`Method`] payloads cannot set authenticated source identity.
pub const AUTHENTICATED_ACCOUNT_ID_HEADER: &str = "x-yoi-authenticated-account-id";
/// Trusted source identity attached by an authenticated transport boundary.
///
/// Public clients cannot select this value directly. Runtime/Backend adapters
/// stamp it before forwarding an accepted Submit or Notify to a Worker.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum AuthenticatedInputSource {
/// Assigned whenever a serialized tracked method crosses an untrusted
/// protocol boundary. Receivers must handle it exactly like public input.
UntrustedWire,
Account {
account_id: String,
},
Worker {
runtime_id: String,
worker_id: String,
},
SubWorker {
session_id: String,
},
Backend {
operation_id: String,
},
}
impl Default for AuthenticatedInputSource {
fn default() -> Self {
Self::UntrustedWire
}
}
impl AuthenticatedInputSource {
pub fn namespace(&self) -> String {
match self {
Self::UntrustedWire => "untrusted-wire".into(),
Self::Account { account_id } => format!("account:{account_id}"),
Self::Worker {
runtime_id,
worker_id,
} => format!("worker:{runtime_id}:{worker_id}"),
Self::SubWorker { session_id } => format!("sub_worker:{session_id}"),
Self::Backend { operation_id } => format!("backend:{operation_id}"),
}
}
}
/// Immutable identity and revision fence for one state-changing Worker command.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkerCommandEnvelope {
/// Caller-owned sequence. A controller accepts command ids in strictly
/// increasing order for one execution generation.
pub command_id: u64,
pub expected_execution_generation: u64,
pub expected_worker_state_revision: u64,
}
impl WorkerCommandEnvelope {
pub fn for_snapshot(command_id: u64, snapshot: &WorkerStateSnapshot) -> Self {
Self {
command_id,
expected_execution_generation: snapshot.execution_generation,
expected_worker_state_revision: snapshot.revision,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkerCommandKind {
Resume,
Cancel,
Pause,
Compact,
Shutdown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkerCommandDisposition {
Accepted,
StaleExecutionGeneration,
StaleWorkerStateRevision,
StaleCommandId,
Conflict,
InvalidState,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkerCommandAcknowledgement {
pub command_id: u64,
pub command: WorkerCommandKind,
pub disposition: WorkerCommandDisposition,
/// The complete authoritative state observed after command admission.
pub state: WorkerStateSnapshot,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", content = "state", rename_all = "snake_case")]
pub enum WorkerState {
Idle,
Busy(WorkerBusyState),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", content = "state", rename_all = "snake_case")]
pub enum WorkerBusyState {
Run(WorkerRunState),
Maintenance(WorkerMaintenanceState),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkerRunState {
Running,
Pausing,
Paused,
Cancelling,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkerMaintenanceState {
Compacting,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkerStateSnapshot {
pub execution_generation: u64,
pub revision: u64,
/// Highest lifecycle command id observed by this controller generation.
pub last_command_id: u64,
pub state: WorkerState,
}
impl WorkerStateSnapshot {
pub fn initial(execution_generation: u64) -> Self {
Self {
execution_generation,
revision: 0,
last_command_id: 0,
state: WorkerState::Idle,
}
}
/// Compatibility projection for Runtime catalog lifecycle. This value is
/// never command-admission authority and cannot produce `Stopped`.
pub fn catalog_status(&self) -> WorkerStatus {
match self.state {
WorkerState::Idle => WorkerStatus::Idle,
WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)) => WorkerStatus::Paused,
WorkerState::Busy(WorkerBusyState::Run(_))
| WorkerState::Busy(WorkerBusyState::Maintenance(_)) => WorkerStatus::Running,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkerStateSnapshotApply {
Applied,
Duplicate,
Stale,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WorkerStateSnapshotConflict {
pub execution_generation: u64,
pub revision: u64,
}
impl std::fmt::Display for WorkerStateSnapshotConflict {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"conflicting worker state snapshots at generation {} revision {}",
self.execution_generation, self.revision
)
}
}
impl std::error::Error for WorkerStateSnapshotConflict {}
pub fn apply_worker_state_snapshot(
current: &mut WorkerStateSnapshot,
incoming: &WorkerStateSnapshot,
) -> Result<WorkerStateSnapshotApply, WorkerStateSnapshotConflict> {
use std::cmp::Ordering;
let ordering = (incoming.execution_generation, incoming.revision)
.cmp(&(current.execution_generation, current.revision));
match ordering {
Ordering::Greater => {
*current = incoming.clone();
Ok(WorkerStateSnapshotApply::Applied)
}
Ordering::Less => Ok(WorkerStateSnapshotApply::Stale),
Ordering::Equal if incoming == current => Ok(WorkerStateSnapshotApply::Duplicate),
Ordering::Equal => Err(WorkerStateSnapshotConflict {
execution_generation: incoming.execution_generation,
revision: incoming.revision,
}),
}
}
impl From<WorkerStatus> for WorkerStateSnapshot {
fn from(status: WorkerStatus) -> Self {
let state = match status {
WorkerStatus::Idle | WorkerStatus::Stopped => WorkerState::Idle,
WorkerStatus::Running => {
WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running))
}
WorkerStatus::Paused => WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)),
};
Self {
execution_generation: 1,
revision: 0,
last_command_id: 0,
state,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
pub enum Method {
Run {
/// Durably accept typed input for immediate activation or the session FIFO.
///
/// `submission_request_id` is generated by the authenticated caller and is
/// used only for idempotent retry. Worker allocates the durable
/// `submission_id` returned by [`Event::SubmissionAccepted`].
Submit {
submission_request_id: String,
input: Vec<Segment>,
},
/// Runtime-internal Run carrying an opaque correlation id that is committed
/// with the resulting UserInput entry. This variant is not serializable on
/// the public Client → Worker protocol.
#[serde(skip)]
/// Authenticated transport form of Submit. Trusted adapters replace
/// public Submit before forwarding it to the Worker.
#[cfg_attr(feature = "typescript", ts(skip))]
RunTracked {
SubmitTracked {
submission_request_id: String,
input: Vec<Segment>,
submission_id: String,
#[serde(skip_deserializing, default)]
source: AuthenticatedInputSource,
},
/// Human-readable text injected into the target Worker's LLM context
/// as a non-blocking system message. `auto_run` controls whether an
@@ -50,26 +298,63 @@ pub enum Method {
/// No side effects beyond LLM context; use `WorkerEvent` for typed
/// lifecycle reports.
Notify {
notification_request_id: String,
message: String,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
auto_run: bool,
},
/// Authenticated transport form of Notify.
#[cfg_attr(feature = "typescript", ts(skip))]
NotifyTracked {
notification_request_id: String,
message: String,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
auto_run: bool,
#[serde(skip_deserializing, default)]
source: AuthenticatedInputSource,
},
/// Typed lifecycle report from a child Worker to its direct parent.
WorkerEvent(WorkerEvent),
Resume,
Cancel,
/// Return the authoritative FIFO summary without exposing queued payloads.
ListPendingSubmissions,
/// Remove one queued submission. Running or already activated submissions
/// are immutable and therefore cannot be cancelled here.
CancelPendingSubmission {
submission_id: String,
expected_revision: u64,
},
/// Remove every queued submission while preserving the active run.
ClearPendingSubmissions {
expected_revision: u64,
},
/// Activate the next queued submission while the Worker is idle. This is an
/// explicit recovery operation and never resumes a paused run implicitly.
ContinuePending {
expected_revision: u64,
expected_head_id: String,
},
Resume {
command: WorkerCommandEnvelope,
},
Cancel {
command: WorkerCommandEnvelope,
},
/// Stop the in-flight turn and transition to `Paused`.
///
/// Unlike `Cancel` (which discards and returns to `Idle`), a paused
/// Worker can resume the interrupted work via `Resume`, or start a
/// fresh turn via `Run` (orphan `tool_use` items are closed with a
/// Worker can resume the interrupted work via `Resume`, or accept a
/// fresh `Submit` (orphan `tool_use` items are closed with a
/// synthetic tool result before the new user message is appended).
Pause,
Pause {
command: WorkerCommandEnvelope,
},
/// Request an explicit compaction while the Worker is otherwise idle.
///
/// This is a typed control method: clients must not send `compact` as a
/// `Method::Run` user message.
Compact,
/// `Method::Submit` user message.
Compact {
command: WorkerCommandEnvelope,
},
/// Ask the Worker to list valid rewind targets from its authoritative session log.
ListRewindTargets,
/// Truncate the current session back to the selected rewind target and
@@ -78,7 +363,9 @@ pub enum Method {
target: RewindTargetId,
expected_head_entries: usize,
},
Shutdown,
Shutdown {
command: WorkerCommandEnvelope,
},
/// Request a list of completion candidates from the Worker.
///
/// Reply is sent on the same socket as `Event::Completions` (not
@@ -181,7 +468,7 @@ impl WorkerEvent {
/// One typed piece of a user submission.
///
/// `Method::Run` and `Event::UserMessage` carry `Vec<Segment>`. Dumb
/// `Method::Submit` and `Event::UserMessage` carry `Vec<Segment>`. Dumb
/// clients (CLI piping, scripts) only need to produce a single
/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
/// (paste chips, file refs) and
@@ -404,12 +691,13 @@ impl Segment {
}
impl Method {
/// Convenience: a `Run` carrying a single `Segment::Text`.
/// Convenience: a `Submit` carrying a single `Segment::Text`.
/// Used by dumb clients, inter-Worker tools, and tests that only have
/// a string to forward.
pub fn run_text(s: impl Into<String>) -> Self {
Self::Run {
input: vec![Segment::text(s)],
pub fn submit_text(submission_request_id: impl Into<String>, text: impl Into<String>) -> Self {
Self::Submit {
submission_request_id: submission_request_id.into(),
input: vec![Segment::text(text)],
}
}
}
@@ -503,6 +791,39 @@ pub enum ToolResultDisposition {
OutcomeUnknown,
}
/// Durable acceptance result for one idempotent Submit request.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum SubmissionDisposition {
Started,
Queued,
}
/// Bounded public projection of one pending submission. Payload segments and
/// provenance remain in the session log and are intentionally not exposed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct PendingSubmissionSummary {
pub submission_id: String,
pub accepted_at_ms: u64,
pub segment_count: u32,
pub byte_len: u64,
}
/// Revisioned session-owned FIFO projection used by snapshots and live events.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct PendingSubmissionsSnapshot {
pub revision: u64,
#[serde(default)]
pub notification_count: u32,
#[serde(default)]
pub head_id: Option<String>,
#[serde(default)]
pub submissions: Vec<PendingSubmissionSummary>,
}
/// Canonical, storage-independent projection of committed session history.
///
/// Worker protocols expose this DTO instead of append-log records. New
@@ -511,6 +832,8 @@ pub enum ToolResultDisposition {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct SessionSnapshot {
#[serde(default)]
pub pending_submissions: PendingSubmissionsSnapshot,
pub entries: Vec<SessionSnapshotEntry>,
}
@@ -609,16 +932,28 @@ pub struct SessionToolAttachment {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
pub enum Event {
/// A user input message was accepted, persisted as
/// `LogEntry::AnnotatedUserInput`, and is about to start a new turn.
/// Broadcast to every subscribed client so TUI / GUI instances show
/// the same user line that reconnect snapshots would replay from
/// history; clients must not synthesize a separate pending/fake
/// message for accepted runs.
///
/// Fires exactly once per committed user input, after
/// `InvokeStart { kind: UserSend }` and before the first
/// `TurnStart`. Rejected runs (e.g. `AlreadyRunning`) do not emit.
/// Durable Submit acceptance. A `Queued` receipt follows the durable FIFO
/// checkpoint. A `Started` receipt follows the pending activation checkpoint;
/// the corresponding [`Event::UserMessage`] marks the later history commit.
/// Repeating the same request id and exact payload returns the same receipt
/// without appending or activating twice.
SubmissionAccepted {
submission_request_id: String,
submission_id: String,
disposition: SubmissionDisposition,
},
/// Correlated rejection before durable acceptance.
SubmissionRejected {
submission_request_id: String,
message: String,
},
/// Revisioned FIFO replacement following enqueue, activation, cancel, or clear.
PendingSubmissionsChanged {
pending: PendingSubmissionsSnapshot,
},
/// A user input message persisted as `LogEntry::AnnotatedUserInput` and
/// activated for a turn. Broadcast to every subscribed client so TUI / GUI
/// instances show the same user line that reconnect snapshots replay.
UserMessage {
segments: Vec<Segment>,
},
@@ -641,7 +976,7 @@ pub enum Event {
///
/// Marker event for the start of an Invoke range; the range extends
/// implicitly until the next `InvokeStart`. Fires for every accepted
/// `Method::Run` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),
/// `Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),
/// `Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other
/// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output,
/// typed system reminder insertion that doesn't break IDLE) do not
@@ -798,8 +1133,9 @@ pub enum Event {
Snapshot {
session: SessionSnapshot,
greeting: Greeting,
#[serde(default)]
status: WorkerStatus,
/// Full revisioned live execution state. `Stopped` remains Runtime
/// catalog authority and is deliberately not represented here.
state: WorkerStateSnapshot,
/// Unfinished model output that has already streamed in the current
/// run but is not yet represented by committed snapshot entries.
#[serde(default, skip_serializing_if = "InFlightSnapshot::is_empty")]
@@ -836,8 +1172,11 @@ pub enum Event {
},
/// Current Worker controller status. Broadcast on every controller-level
/// transition and included in `History` snapshots for late attach.
Status {
status: WorkerStatus,
WorkerState {
snapshot: WorkerStateSnapshot,
},
CommandAcknowledged {
acknowledgement: WorkerCommandAcknowledgement,
},
/// Bounded, provider-owned command telemetry for the live Console. This is
/// intentionally not a history entry and is reconstructed from
@@ -1193,7 +1532,7 @@ pub enum TurnResult {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum InvokeKind {
/// `Method::Run` — a user submission.
/// `Method::Submit` — a user submission.
UserSend,
/// `Method::Notify` — free-text notification injected into history.
Notify,
@@ -1216,7 +1555,7 @@ pub enum RunResult {
Finished,
Paused,
LimitReached,
/// The accepted Method::Run produced no assistant/tool output before
/// The accepted Method::Submit produced no assistant/tool output before
/// user interruption, so the Worker rolled the submit-time turn state back
/// to its pre-submit snapshot. Clients should treat the Worker as Idle and
/// restore the just-submitted input into the editable composer if desired.
@@ -1285,26 +1624,82 @@ mod tests {
use super::*;
#[test]
fn method_run_json_roundtrip() {
let json = r#"{"method":"run","params":{"input":[{"kind":"text","content":"Hello"}]}}"#;
fn worker_state_snapshot_apply_is_monotonic_and_detects_conflicts() {
let mut current = WorkerStateSnapshot::initial(4);
let mut newer = current.clone();
newer.revision = 1;
newer.state = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running));
assert_eq!(
apply_worker_state_snapshot(&mut current, &newer),
Ok(WorkerStateSnapshotApply::Applied)
);
assert_eq!(
apply_worker_state_snapshot(&mut current, &newer),
Ok(WorkerStateSnapshotApply::Duplicate)
);
let stale_revision = WorkerStateSnapshot::initial(4);
assert_eq!(
apply_worker_state_snapshot(&mut current, &stale_revision),
Ok(WorkerStateSnapshotApply::Stale)
);
let stale_generation = WorkerStateSnapshot {
execution_generation: 3,
revision: u64::MAX,
..newer.clone()
};
assert_eq!(
apply_worker_state_snapshot(&mut current, &stale_generation),
Ok(WorkerStateSnapshotApply::Stale)
);
let conflicting = WorkerStateSnapshot {
state: WorkerState::Idle,
..newer.clone()
};
assert_eq!(
apply_worker_state_snapshot(&mut current, &conflicting),
Err(WorkerStateSnapshotConflict {
execution_generation: 4,
revision: 1,
})
);
assert_eq!(current, newer);
let next_generation = WorkerStateSnapshot::initial(5);
assert_eq!(
apply_worker_state_snapshot(&mut current, &next_generation),
Ok(WorkerStateSnapshotApply::Applied)
);
assert_eq!(current, next_generation);
}
#[test]
fn method_submit_json_roundtrip_and_run_is_rejected() {
let json = r#"{"method":"submit","params":{"submission_request_id":"request-1","input":[{"kind":"text","content":"Hello"}]}}"#;
let method: Method = serde_json::from_str(json).unwrap();
match &method {
Method::Run { input } => {
Method::Submit { input, .. } => {
assert_eq!(input.len(), 1);
match &input[0] {
Segment::Text { content } => assert_eq!(content, "Hello"),
other => panic!("expected Text, got {other:?}"),
}
}
other => panic!("expected Run, got {other:?}"),
other => panic!("expected Submit, got {other:?}"),
}
let serialized = serde_json::to_string(&method).unwrap();
assert_eq!(serialized, json);
assert!(
serde_json::from_str::<Method>(r#"{"method":"run","params":{"input":[]}}"#).is_err()
);
}
#[test]
fn method_run_paste_segment_roundtrip() {
let method = Method::Run {
fn method_submit_paste_segment_roundtrip() {
let method = Method::Submit {
submission_request_id: "request-1".to_string(),
input: vec![
Segment::text("see "),
Segment::Paste {
@@ -1318,7 +1713,7 @@ mod tests {
let json = serde_json::to_string(&method).unwrap();
let decoded: Method = serde_json::from_str(&json).unwrap();
match decoded {
Method::Run { input } => {
Method::Submit { input, .. } => {
assert_eq!(input.len(), 2);
match &input[1] {
Segment::Paste {
@@ -1335,7 +1730,7 @@ mod tests {
other => panic!("expected Paste, got {other:?}"),
}
}
other => panic!("expected Run, got {other:?}"),
other => panic!("expected Submit, got {other:?}"),
}
}
@@ -1389,8 +1784,9 @@ mod tests {
}
#[test]
fn method_run_flow_segment_roundtrip() {
let method = Method::Run {
fn method_submit_flow_segment_roundtrip() {
let method = Method::Submit {
submission_request_id: "request-1".to_string(),
input: vec![
Segment::Flow {
selector: "builtin:coder-review".to_string(),
@@ -1404,7 +1800,7 @@ mod tests {
let decoded = serde_json::from_str::<Method>(&json).unwrap();
assert!(matches!(
decoded,
Method::Run { input }
Method::Submit { input, .. }
if matches!(
input.as_slice(),
[
@@ -1416,15 +1812,26 @@ mod tests {
}
#[test]
fn runtime_tracked_run_is_not_public_protocol_json() {
let method = Method::RunTracked {
fn authenticated_submit_replaces_wire_source_with_transport_identity() {
let method = Method::SubmitTracked {
input: vec![Segment::text("private")],
submission_id: "submission-1".to_string(),
submission_request_id: "request-1".to_string(),
source: AuthenticatedInputSource::Account {
account_id: "account-1".into(),
},
};
assert!(serde_json::to_string(&method).is_err());
let json = serde_json::to_string(&method).unwrap();
let decoded = serde_json::from_str::<Method>(&json).unwrap();
assert!(matches!(
decoded,
Method::SubmitTracked {
source: AuthenticatedInputSource::UntrustedWire,
..
}
));
assert!(
serde_json::from_str::<Method>(
r#"{"method":"run_tracked","input":[],"submission_id":"forged"}"#,
r#"{"method":"submit_tracked","input":[],"submission_request_id":"forged"}"#,
)
.is_err()
);
@@ -1442,42 +1849,53 @@ mod tests {
}
#[test]
fn method_run_with_unknown_segment_decodes() {
let json = r#"{"method":"run","params":{"input":[{"kind":"text","content":"hi"},{"kind":"future_thing","x":1}]}}"#;
fn method_submit_with_unknown_segment_decodes() {
let json = r#"{"method":"submit","params":{"submission_request_id":"request-1","input":[{"kind":"text","content":"hi"},{"kind":"future_thing","x":1}]}}"#;
let method: Method = serde_json::from_str(json).unwrap();
match method {
Method::Run { input } => {
Method::Submit { input, .. } => {
assert_eq!(input.len(), 2);
assert!(matches!(input[0], Segment::Text { .. }));
assert!(matches!(input[1], Segment::Unknown));
}
other => panic!("expected Run, got {other:?}"),
other => panic!("expected Submit, got {other:?}"),
}
}
#[test]
fn method_without_params() {
let json = r#"{"method":"resume"}"#;
let method: Method = serde_json::from_str(json).unwrap();
assert!(matches!(method, Method::Resume));
fn lifecycle_method_without_command_fails_closed() {
let error = serde_json::from_str::<Method>(r#"{"method":"resume"}"#).unwrap_err();
assert!(error.to_string().contains("params"));
}
#[test]
fn method_pause_roundtrip() {
let json = r#"{"method":"pause"}"#;
let method: Method = serde_json::from_str(json).unwrap();
assert!(matches!(method, Method::Pause));
let serialized = serde_json::to_string(&method).unwrap();
assert_eq!(serialized, json);
}
#[test]
fn method_compact_roundtrip() {
let json = r#"{"method":"compact"}"#;
let method: Method = serde_json::from_str(json).unwrap();
assert!(matches!(method, Method::Compact));
let serialized = serde_json::to_string(&method).unwrap();
assert_eq!(serialized, json);
fn lifecycle_methods_roundtrip_with_fences() {
for method in [
Method::Pause {
command: WorkerCommandEnvelope {
command_id: 11,
expected_execution_generation: 4,
expected_worker_state_revision: 8,
},
},
Method::Compact {
command: WorkerCommandEnvelope {
command_id: 12,
expected_execution_generation: 4,
expected_worker_state_revision: 9,
},
},
] {
let json = serde_json::to_string(&method).unwrap();
let decoded: Method = serde_json::from_str(&json).unwrap();
match decoded {
Method::Pause { command } | Method::Compact { command } => {
assert_eq!(command.expected_execution_generation, 4);
assert!(command.command_id >= 11);
}
other => panic!("unexpected lifecycle method: {other:?}"),
}
}
}
#[test]
@@ -1648,11 +2066,11 @@ mod tests {
#[test]
fn method_notify_json_roundtrip_defaults_to_auto_run() {
let json = r#"{"method":"notify","params":{"message":"turn done"}}"#;
let json = r#"{"method":"notify","params":{"notification_request_id":"notification-1","message":"turn done"}}"#;
let method: Method = serde_json::from_str(json).unwrap();
assert!(matches!(
method,
Method::Notify { ref message, auto_run: true } if message == "turn done"
Method::Notify { ref message, auto_run: true, .. } if message == "turn done"
));
let serialized = serde_json::to_string(&method).unwrap();
assert_eq!(serialized, json);
@@ -1660,11 +2078,11 @@ mod tests {
#[test]
fn method_notify_weak_json_roundtrip_serializes_auto_run_false() {
let json = r#"{"method":"notify","params":{"message":"progress","auto_run":false}}"#;
let json = r#"{"method":"notify","params":{"notification_request_id":"notification-1","message":"progress","auto_run":false}}"#;
let method: Method = serde_json::from_str(json).unwrap();
assert!(matches!(
method,
Method::Notify { ref message, auto_run: false } if message == "progress"
Method::Notify { ref message, auto_run: false, .. } if message == "progress"
));
assert_eq!(serde_json::to_string(&method).unwrap(), json);
}
@@ -1725,6 +2143,7 @@ mod tests {
fn event_snapshot_format() {
let event = Event::Snapshot {
session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(),
entries: vec![SessionSnapshotEntry {
entry_id: "entry-1".into(),
timestamp: 1,
@@ -1745,7 +2164,7 @@ mod tests {
context_window: 200_000,
context_tokens: 42_000,
},
status: WorkerStatus::Paused,
state: WorkerStatus::Paused.into(),
in_flight: InFlightSnapshot::default(),
internal_workers: Vec::new(),
};
@@ -1762,12 +2181,13 @@ mod tests {
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_000);
assert_eq!(parsed["data"]["status"], "paused");
assert_eq!(parsed["data"]["state"]["state"]["kind"], "busy");
assert_eq!(parsed["data"]["state"]["state"]["state"]["state"], "paused");
}
#[test]
fn event_snapshot_in_flight_roundtrip_and_default() {
let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"status":"running"}}"#;
let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"state":{"execution_generation":1,"revision":1,"last_command_id":0,"state":{"kind":"busy","state":{"kind":"run","state":"running"}}}}}"#;
let decoded: Event = serde_json::from_str(inbound).unwrap();
match decoded {
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
@@ -1776,6 +2196,7 @@ mod tests {
let event = Event::Snapshot {
session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: Greeting {
@@ -1788,7 +2209,7 @@ mod tests {
context_window: 0,
context_tokens: 0,
},
status: WorkerStatus::Running,
state: WorkerStatus::Running.into(),
in_flight: InFlightSnapshot {
blocks: vec![
InFlightBlock::Text {
@@ -1844,6 +2265,7 @@ mod tests {
fn event_segment_rotated_roundtrip() {
let event = Event::SegmentRotated {
session: SessionSnapshot {
pending_submissions: PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
};
@@ -1875,20 +2297,32 @@ mod tests {
}
#[test]
fn event_status_format() {
let event = Event::Status {
status: WorkerStatus::Running,
fn event_worker_state_format() {
let event = Event::WorkerState {
snapshot: WorkerStateSnapshot {
execution_generation: 7,
revision: 3,
last_command_id: 9,
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
},
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "status");
assert_eq!(parsed["data"]["status"], "running");
assert_eq!(parsed["event"], "worker_state");
assert_eq!(parsed["data"]["snapshot"]["execution_generation"], 7);
assert_eq!(parsed["data"]["snapshot"]["revision"], 3);
assert_eq!(parsed["data"]["snapshot"]["state"]["kind"], "busy");
let decoded: Event = serde_json::from_str(&json).unwrap();
assert!(matches!(
decoded,
Event::Status {
status: WorkerStatus::Running
Event::WorkerState {
snapshot: WorkerStateSnapshot {
execution_generation: 7,
revision: 3,
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
..
}
}
));
}
@@ -1929,19 +2363,10 @@ mod tests {
}
#[test]
fn event_snapshot_without_status_defaults_to_idle() {
fn event_snapshot_without_worker_state_fails_closed() {
let json = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
let decoded: Event = serde_json::from_str(json).unwrap();
match decoded {
Event::Snapshot {
status, greeting, ..
} => {
assert_eq!(status, WorkerStatus::Idle);
assert_eq!(greeting.context_window, 0);
assert_eq!(greeting.context_tokens, 0);
}
other => panic!("expected Snapshot, got {other:?}"),
}
let error = serde_json::from_str::<Event>(json).unwrap_err();
assert!(error.to_string().contains("state"));
}
#[test]
@@ -2354,7 +2779,12 @@ mod tests {
"scope_summary": "scope",
"tools": []
},
"status": "idle"
"state": {
"execution_generation": 1,
"revision": 0,
"last_command_id": 0,
"state": { "kind": "idle" }
}
}
}))
.unwrap();
+6
View File
@@ -573,6 +573,11 @@ pub struct SubscriptionWorker {
pub resource_key: Option<String>,
/// Producer-owned monotonic revision for this Worker subject.
pub subject_revision: u64,
/// Latest revisioned foreground state observed from the Worker. This remains
/// absent until an authoritative Worker snapshot/event has been applied.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<crate::WorkerStateSnapshot>,
/// Runtime catalog lifecycle compatibility projection; not foreground-state authority.
pub state: SubscriptionWorkerState,
#[serde(default)]
pub has_running_internal_workers: bool,
@@ -874,6 +879,7 @@ mod tests {
runtime_id: None,
resource_key: None,
subject_revision: 0,
worker_state: None,
state: SubscriptionWorkerState::Idle,
has_running_internal_workers: false,
workspace_id: Some("workspace-1".to_string()),
+20 -5
View File
@@ -8,11 +8,14 @@ use crate::{
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
PasteArtifactRef, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerEvent,
WorkerStatus,
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
RewindSummary, RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, SessionContentPart,
SessionEntryProvenance, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, ToolResultDisposition,
TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
WorkerStateSnapshot, WorkerStatus,
subscription::{
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
@@ -46,6 +49,15 @@ pub fn generated_protocol_types() -> String {
push_decl::<AlertSource>(&cfg, &mut output);
push_decl::<CompletionKind>(&cfg, &mut output);
push_decl::<WorkerStatus>(&cfg, &mut output);
push_decl::<WorkerCommandEnvelope>(&cfg, &mut output);
push_decl::<WorkerCommandKind>(&cfg, &mut output);
push_decl::<WorkerCommandDisposition>(&cfg, &mut output);
push_decl::<WorkerCommandAcknowledgement>(&cfg, &mut output);
push_decl::<WorkerRunState>(&cfg, &mut output);
push_decl::<WorkerMaintenanceState>(&cfg, &mut output);
push_decl::<WorkerBusyState>(&cfg, &mut output);
push_decl::<WorkerState>(&cfg, &mut output);
push_decl::<WorkerStateSnapshot>(&cfg, &mut output);
push_decl::<TurnResult>(&cfg, &mut output);
push_decl::<InvokeKind>(&cfg, &mut output);
push_decl::<RunResult>(&cfg, &mut output);
@@ -75,6 +87,9 @@ pub fn generated_protocol_types() -> String {
push_decl::<SessionToolAttachment>(&cfg, &mut output);
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
push_decl::<PendingSubmissionSummary>(&cfg, &mut output);
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
push_decl::<SubmissionDisposition>(&cfg, &mut output);
push_decl::<SessionSnapshot>(&cfg, &mut output);
push_decl::<InternalWorkerKind>(&cfg, &mut output);
push_decl::<InternalWorkerRef>(&cfg, &mut output);
+171 -9
View File
@@ -21,8 +21,10 @@ use crate::segment_log::LogEntry;
use crate::store::{Store, StoreError};
use crate::uploaded_file::{
bind_uploaded_file, clear_uploaded_file_binding, copy_committed_uploaded_files,
delete_uncommitted_uploaded_files, delete_uploaded_file, list_uploaded_file_refs,
read_uploaded_file, read_uploaded_file_by_id, write_uploaded_file,
delete_uncommitted_uploaded_files, delete_uploaded_file, finalize_uploaded_file_binding,
list_uploaded_file_refs, pin_uploaded_file, read_uploaded_file, read_uploaded_file_by_id,
reconcile_uploaded_file_pins, release_uploaded_file_pin, uploaded_file_has_pending_owner,
write_uploaded_file,
};
use crate::{
PasteArtifactLimits, SegmentId, SessionId, UploadedFileLimits, UploadedFileUploadContext,
@@ -518,6 +520,61 @@ impl Store for FsStore {
}
}
fn pin_uploaded_file(
&self,
session_id: SessionId,
reference: &UploadedFileRef,
owner_id: &str,
) -> Result<(), StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
pin_uploaded_file(&self.paste_artifact_dir(session_id), reference, owner_id)
}
fn release_uploaded_file_pin(
&self,
session_id: SessionId,
artifact_id: &str,
owner_id: &str,
) -> Result<(), StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
release_uploaded_file_pin(&self.paste_artifact_dir(session_id), artifact_id, owner_id)
}
fn finalize_uploaded_file_binding(
&self,
session_id: SessionId,
artifact_id: &str,
source_entry_id: &str,
) -> Result<(), StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
finalize_uploaded_file_binding(
&self.paste_artifact_dir(session_id),
artifact_id,
source_entry_id,
)
}
fn reconcile_uploaded_file_pins(
&self,
session_id: SessionId,
live_owner_ids: &[String],
) -> Result<u64, StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
reconcile_uploaded_file_pins(&self.paste_artifact_dir(session_id), live_owner_ids)
}
fn delete_uploaded_file(
&self,
session_id: SessionId,
@@ -541,13 +598,18 @@ impl Store for FsStore {
let Some(source_entry_id) = reference.source_entry_id.as_deref() else {
continue;
};
if !self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? {
clear_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
if delete_uploaded_file(&dir, &reference.artifact_id)? {
removed = removed
.checked_add(1)
.ok_or(StoreError::ArtifactQuotaExceeded)?;
}
if self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? {
finalize_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
continue;
}
if uploaded_file_has_pending_owner(&dir, &reference.artifact_id)? {
continue;
}
clear_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
if delete_uploaded_file(&dir, &reference.artifact_id)? {
removed = removed
.checked_add(1)
.ok_or(StoreError::ArtifactQuotaExceeded)?;
}
}
Ok(removed)
@@ -865,6 +927,106 @@ mod tests {
assert!(store.read_uploaded_file(owner, &reference).is_err());
}
#[test]
fn pending_upload_pin_survives_cleanup_until_release_or_history_binding() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FsStore::new(tmp.path()).unwrap();
let session_id = new_session_id();
let limits = UploadedFileLimits {
max_file_bytes: 64,
max_session_bytes: 128,
};
let pending = store
.write_uploaded_file(session_id, "pending.txt", "text/plain", b"pending", limits)
.unwrap();
store
.pin_uploaded_file(session_id, &pending, "submission-1")
.unwrap();
assert!(matches!(
store.pin_uploaded_file(session_id, &pending, "submission-other"),
Err(StoreError::ArtifactAlreadyCommitted)
));
drop(store);
let store = FsStore::new(tmp.path()).unwrap();
assert_eq!(
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
0
);
assert_eq!(
store
.read_uploaded_file_by_id(session_id, &pending.artifact_id)
.unwrap()
.1,
b"pending"
);
let fork_session_id = new_session_id();
assert_eq!(
store
.copy_committed_uploaded_files(session_id, fork_session_id)
.unwrap(),
0
);
assert!(
store
.read_uploaded_file_by_id(fork_session_id, &pending.artifact_id)
.is_err()
);
let committed = store
.bind_uploaded_file(session_id, &pending, "entry-1")
.unwrap();
assert_eq!(
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
0
);
assert!(
store
.read_uploaded_file_by_id(session_id, &pending.artifact_id)
.is_ok()
);
store
.create_segment(
session_id,
new_segment_id(),
&[LogEntry::InputSegmentsCheckpoint {
ts: 1,
user_segments: vec![vec![protocol::Segment::UploadedFile {
file: committed.clone(),
}]],
}],
)
.unwrap();
assert_eq!(
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
0
);
assert!(
store
.release_uploaded_file_pin(session_id, &pending.artifact_id, "submission-1")
.is_err()
);
let releasable = store
.write_uploaded_file(session_id, "cancelled.txt", "text/plain", b"cancel", limits)
.unwrap();
store
.pin_uploaded_file(session_id, &releasable, "submission-2")
.unwrap();
store
.release_uploaded_file_pin(session_id, &releasable.artifact_id, "submission-2")
.unwrap();
assert_eq!(
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
1
);
assert!(
store
.read_uploaded_file_by_id(session_id, &releasable.artifact_id)
.is_err()
);
}
#[test]
fn uploaded_file_validation_and_shared_quota_fail_closed() {
let tmp = tempfile::TempDir::new().unwrap();
@@ -183,6 +183,7 @@ fn canonicalize_history_entry(
item,
metadata: legacy_metadata(segment_id, line_index, 0),
},
extensions: Vec::new(),
},
}
}
+5 -2
View File
@@ -71,7 +71,7 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess
entries.push(history_entry(entry, *ts, data));
}
}
LogEntry::AnnotatedSystemItem { ts, entry } => entries.push(system_entry(
LogEntry::AnnotatedSystemItem { ts, entry, .. } => entries.push(system_entry(
&entry.item,
entry.metadata.entry_id.0.clone(),
*ts,
@@ -100,7 +100,10 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess
}
}
SessionSnapshot { entries }
SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries,
}
}
fn extend_history(
+1
View File
@@ -287,6 +287,7 @@ pub fn append_system_item(
LogEntry::AnnotatedSystemItem {
ts: segment_log::now_millis(),
entry,
extensions: Vec::new(),
},
)
}
+10 -1
View File
@@ -112,6 +112,8 @@ pub enum LogEntry {
AnnotatedSystemItem {
ts: u64,
entry: LoggedSystemHistoryEntry,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extensions: Vec<SessionExtension>,
},
/// Turn boundary. Records the turn count after increment.
@@ -312,12 +314,19 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.annotated_history.push(entry.clone());
state.history.push(Item::from(entry.item.clone()));
}
LogEntry::AnnotatedSystemItem { entry, .. } => {
LogEntry::AnnotatedSystemItem {
entry, extensions, ..
} => {
state.annotated_history.push(LoggedHistoryEntry {
item: LoggedItem::from(entry.item.to_history_item()),
metadata: entry.metadata.clone(),
});
state.history.push(entry.item.to_history_item());
state.extensions.extend(
extensions
.iter()
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
);
}
LogEntry::TurnEnd { turn_count, .. } => {
if let Some(active_turn_count) = &mut state.active_run_turn_count {
+41
View File
@@ -226,6 +226,47 @@ pub trait Store: Send + Sync {
Err(StoreError::PasteArtifactUnsupported)
}
/// Retain an uploaded file while a durable pending operation owns it.
fn pin_uploaded_file(
&self,
_session_id: SessionId,
_reference: &UploadedFileRef,
_owner_id: &str,
) -> Result<(), StoreError> {
Err(StoreError::PasteArtifactUnsupported)
}
/// Release a pending-operation pin without changing committed ownership.
fn release_uploaded_file_pin(
&self,
_session_id: SessionId,
_artifact_id: &str,
_owner_id: &str,
) -> Result<(), StoreError> {
Err(StoreError::PasteArtifactUnsupported)
}
/// Complete the pending-to-history handoff after the history entry commits.
fn finalize_uploaded_file_binding(
&self,
_session_id: SessionId,
_artifact_id: &str,
_source_entry_id: &str,
) -> Result<(), StoreError> {
Err(StoreError::PasteArtifactUnsupported)
}
/// Clear pending-operation pins that have no owner in restored durable
/// Worker Session state. This repairs an interrupted pin-before-checkpoint
/// acceptance without disturbing live queue owners or committed history.
fn reconcile_uploaded_file_pins(
&self,
_session_id: SessionId,
_live_owner_ids: &[String],
) -> Result<u64, StoreError> {
Ok(0)
}
/// Delete an uncommitted uploaded file owned by `session_id`.
fn delete_uploaded_file(
&self,
+143 -2
View File
@@ -24,6 +24,12 @@ pub const DEFAULT_MAX_FILES_PER_SUBMISSION: usize = 8;
pub const DEFAULT_MAX_SESSION_UPLOADED_FILES: u64 = 256;
const MAX_FILE_NAME_CHARS: usize = 255;
const MAX_MEDIA_TYPE_BYTES: usize = 127;
fn validate_pending_owner_id(owner_id: &str) -> Result<()> {
if owner_id.is_empty() || owner_id.len() > 256 {
return Err(StoreError::ArtifactIntegrityMismatch);
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UploadedFileLimits {
@@ -59,6 +65,8 @@ struct StoredUploadedFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
source_entry_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pending_owner_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
upload_context: Option<UploadedFileUploadContext>,
content_base64: String,
}
@@ -291,6 +299,7 @@ pub(crate) fn write_uploaded_file(
byte_len,
sha256: sha256.clone(),
source_entry_id: None,
pending_owner_id: None,
upload_context: context.cloned(),
content_base64: BASE64.encode(content),
};
@@ -338,6 +347,12 @@ pub(crate) fn read_uploaded_file_by_id(
Ok((reference, content))
}
pub(crate) fn uploaded_file_has_pending_owner(dir: &Path, artifact_id: &str) -> Result<bool> {
let path = record_path(dir, artifact_id)?;
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(path)?)?;
Ok(stored.pending_owner_id.is_some())
}
pub(crate) fn read_uploaded_file(dir: &Path, reference: &UploadedFileRef) -> Result<Vec<u8>> {
let (stored_reference, content) = read_uploaded_file_by_id(dir, &reference.artifact_id)?;
if stored_reference.file_name != reference.file_name
@@ -376,6 +391,98 @@ pub(crate) fn clear_uploaded_file_binding(
Ok(())
}
pub(crate) fn pin_uploaded_file(
dir: &Path,
reference: &UploadedFileRef,
owner_id: &str,
) -> Result<()> {
validate_pending_owner_id(owner_id)?;
if reference.source_entry_id.is_some() {
return Err(StoreError::ArtifactAlreadyCommitted);
}
let aggregate_lock = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(dir.join(".aggregate.lock"))?;
FileExt::lock_exclusive(&aggregate_lock)?;
let path = record_path(dir, &reference.artifact_id)?;
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
if stored.file_name != reference.file_name
|| stored.media_type != reference.media_type
|| stored.created_at_ms != reference.created_at_ms
|| stored.byte_len != reference.byte_len
|| stored.sha256 != reference.sha256
{
return Err(StoreError::ArtifactIntegrityMismatch);
}
if stored.source_entry_id.is_some() {
return Err(StoreError::ArtifactAlreadyCommitted);
}
if let Some(existing_owner) = stored.pending_owner_id.as_deref() {
return if existing_owner == owner_id {
Ok(())
} else {
Err(StoreError::ArtifactAlreadyCommitted)
};
}
stored.pending_owner_id = Some(owner_id.to_owned());
let temp = dir.join(format!(".{}.file.pin.tmp", reference.artifact_id));
fs::write(&temp, serde_json::to_vec(&stored)?)?;
fs::rename(temp, path)?;
Ok(())
}
pub(crate) fn release_uploaded_file_pin(
dir: &Path,
artifact_id: &str,
owner_id: &str,
) -> Result<()> {
validate_pending_owner_id(owner_id)?;
let aggregate_lock = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(dir.join(".aggregate.lock"))?;
FileExt::lock_exclusive(&aggregate_lock)?;
let path = record_path(dir, artifact_id)?;
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
if stored.pending_owner_id.as_deref() != Some(owner_id) {
return Err(StoreError::ArtifactIntegrityMismatch);
}
stored.pending_owner_id = None;
let temp = dir.join(format!(".{artifact_id}.file.unpin.tmp"));
fs::write(&temp, serde_json::to_vec(&stored)?)?;
fs::rename(temp, path)?;
Ok(())
}
pub(crate) fn finalize_uploaded_file_binding(
dir: &Path,
artifact_id: &str,
source_entry_id: &str,
) -> Result<()> {
let aggregate_lock = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(dir.join(".aggregate.lock"))?;
FileExt::lock_exclusive(&aggregate_lock)?;
let path = record_path(dir, artifact_id)?;
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
if stored.source_entry_id.as_deref() != Some(source_entry_id) {
return Err(StoreError::ArtifactIntegrityMismatch);
}
if stored.pending_owner_id.is_none() {
return Ok(());
}
stored.pending_owner_id = None;
let temp = dir.join(format!(".{artifact_id}.file.finalize.tmp"));
fs::write(&temp, serde_json::to_vec(&stored)?)?;
fs::rename(temp, path)?;
Ok(())
}
pub(crate) fn bind_uploaded_file(
dir: &Path,
reference: &UploadedFileRef,
@@ -479,6 +586,40 @@ pub(crate) fn copy_committed_uploaded_files(source_dir: &Path, target_dir: &Path
Ok(copied)
}
pub(crate) fn reconcile_uploaded_file_pins(dir: &Path, live_owner_ids: &[String]) -> Result<u64> {
fs::create_dir_all(dir)?;
let aggregate_lock = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(dir.join(".aggregate.lock"))?;
FileExt::lock_exclusive(&aggregate_lock)?;
let mut reconciled = 0_u64;
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
let Some(artifact_id) = file_name.strip_suffix(".file.json") else {
continue;
};
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
let Some(owner_id) = stored.pending_owner_id.as_deref() else {
continue;
};
if live_owner_ids.iter().any(|live| live == owner_id) {
continue;
}
stored.pending_owner_id = None;
let temp = dir.join(format!(".{artifact_id}.file.reconcile.tmp"));
fs::write(&temp, serde_json::to_vec(&stored)?)?;
fs::rename(temp, path)?;
reconciled = reconciled.saturating_add(1);
}
Ok(reconciled)
}
pub(crate) fn delete_uncommitted_uploaded_files(dir: &Path) -> Result<u64> {
fs::create_dir_all(dir)?;
let aggregate_lock = fs::OpenOptions::new()
@@ -499,7 +640,7 @@ pub(crate) fn delete_uncommitted_uploaded_files(dir: &Path) -> Result<u64> {
continue;
}
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
if stored.source_entry_id.is_none() {
if stored.source_entry_id.is_none() && stored.pending_owner_id.is_none() {
fs::remove_file(path)?;
removed = removed
.checked_add(1)
@@ -523,7 +664,7 @@ pub(crate) fn delete_uploaded_file(dir: &Path, artifact_id: &str) -> Result<bool
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
};
if stored.source_entry_id.is_some() {
if stored.source_entry_id.is_some() || stored.pending_owner_id.is_some() {
return Err(StoreError::ArtifactAlreadyCommitted);
}
match fs::remove_file(path) {
+10 -2
View File
@@ -318,7 +318,11 @@ impl StandaloneHost {
}
pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> {
let _ = self.handle.send(Method::Shutdown).await;
let command = protocol::WorkerCommandEnvelope::for_snapshot(
u64::MAX,
&self.handle.shared_state.snapshot(),
);
let _ = self.handle.send(Method::Shutdown { command }).await;
let Some(shutdown) = self.shutdown.take() else {
self.retain_lease();
return Err(StandaloneShutdownError::ConfirmationLost);
@@ -500,7 +504,11 @@ fn active_pointer(
}
async fn stop_started_worker(started: BootstrappedWorker) {
let _ = started.handle.send(Method::Shutdown).await;
let command = protocol::WorkerCommandEnvelope::for_snapshot(
u64::MAX,
&started.handle.shared_state.snapshot(),
);
let _ = started.handle.send(Method::Shutdown { command }).await;
let _ = tokio::time::timeout(Duration::from_secs(2), started.shutdown).await;
}
+13 -3
View File
@@ -99,7 +99,10 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
let mut protocol_client = host.connect();
protocol_client
.send(&Method::run_text("read the probe"))
.send(&Method::submit_text(
protocol::new_submission_request_id(),
"read the probe",
))
.await
.expect("submit input");
@@ -336,11 +339,15 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
let worker_id = host.worker_id();
let mut protocol_client = host.connect();
protocol_client
.send(&Method::run_text("first request"))
.send(&Method::submit_text(
protocol::new_submission_request_id(),
"first request",
))
.await?;
wait_for_run_end(&mut protocol_client).await?;
protocol_client
.send(&Method::Notify {
notification_request_id: protocol::new_submission_request_id(),
message: "persisted notification".to_string(),
auto_run: true,
})
@@ -394,7 +401,10 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
assert!(snapshot.contains("persisted notification"), "{snapshot}");
protocol_client
.send(&Method::run_text("continue after restore"))
.send(&Method::submit_text(
protocol::new_submission_request_id(),
"continue after restore",
))
.await?;
wait_for_run_end(&mut protocol_client).await?;
let request = second_inspection
+1
View File
@@ -118,6 +118,7 @@ impl Tool for BashTool {
command: params.command,
timeout_secs,
output_limit: INLINE_BYTE_BUDGET,
cwd: None,
spill_dir: Some(self.output_dir.clone()),
tool_call_id: Some(call_id.clone()),
})
+283 -192
View File
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
use protocol::{
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock,
InFlightSnapshot, InFlightToolCallState, InternalWorkerRef, InternalWorkerSnapshot, Method,
RewindTarget, RunResult, Segment, WorkerStatus,
RewindTarget, RunResult, Segment, WorkerCommandEnvelope, WorkerStateSnapshot, WorkerStatus,
};
use crate::block::{
@@ -102,23 +102,6 @@ struct RollbackSubmitState {
turn_before: usize,
}
#[derive(Clone)]
pub struct QueuedInput {
segments: Vec<Segment>,
preview: String,
}
impl QueuedInput {
fn new(segments: Vec<Segment>) -> Self {
let preview = Segment::flatten_to_text(&segments);
Self { segments, preview }
}
pub fn preview(&self) -> &str {
&self.preview
}
}
struct ComposerInputHistory {
entries: VecDeque<Vec<Segment>>,
browse: Option<ComposerInputHistoryBrowse>,
@@ -242,8 +225,10 @@ pub struct WorkerViewTab {
pub struct App {
pub worker_name: String,
pub connected: bool,
/// Last controller status reported by the Worker. Drives the status line
/// and Ctrl-key routing; do not infer this solely from replayed history.
/// Latest authoritative revisioned live execution state.
pub worker_state: WorkerStateSnapshot,
next_command_id: u64,
/// Derived Runtime-catalog compatibility projection used by existing UI.
pub worker_status: WorkerStatus,
/// True while the Worker is in `WorkerStatus::Running`.
pub running: bool,
@@ -272,7 +257,7 @@ pub struct App {
/// Current transient actionbar notice. Notices are local UI state only:
/// they are never appended to transcript/session history or LLM context.
actionbar_notice: Option<ActionbarNotice>,
/// Normal composer input that is submitted as `Method::Run`.
/// Normal composer input that is submitted as `Method::Submit`.
pub input: InputBuffer,
/// Separate command-line input. It is never submitted as a user message.
pub command_input: InputBuffer,
@@ -333,9 +318,8 @@ pub struct App {
/// Top entry index of the task pane's visible window. Clamped on
/// render so it never points past the end of the list.
pub task_pane_scroll: usize,
/// TUI-local FIFO of user inputs submitted while the Worker is already running.
/// Entries have not been sent to the Worker yet, so they remain editable/cancellable locally.
queued_inputs: VecDeque<QueuedInput>,
/// Authoritative WorkerSession FIFO summary received from snapshot/live events.
pending_submissions: protocol::PendingSubmissionsSnapshot,
/// TUI-local readline-style composer input history. This is intentionally
/// client-side only: recalled entries are plain drafts until submitted again.
input_history: ComposerInputHistory,
@@ -355,6 +339,8 @@ impl App {
Self {
worker_name,
connected: false,
worker_state: WorkerStateSnapshot::initial(1),
next_command_id: 1,
worker_status: WorkerStatus::Idle,
running: false,
paused: false,
@@ -395,7 +381,7 @@ impl App {
text_selection: TextSelectionState::default(),
task_pane_open: false,
task_pane_scroll: 0,
queued_inputs: VecDeque::new(),
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
input_history: ComposerInputHistory::new(),
input_history_store: None,
pending_submit_rollback: None,
@@ -763,23 +749,40 @@ impl App {
if self.paused {
self.input_history.cancel_browse();
self.input.clear();
return Some(Method::Resume);
let command = self.next_command_envelope();
return Some(Method::Resume { command });
}
return None;
}
self.record_input_history(segments.clone());
if self.running {
self.queued_inputs.push_back(QueuedInput::new(segments));
self.input.clear();
self.completion = None;
return None;
}
self.input.clear();
Some(self.method_for_run(segments))
}
pub fn submit_notify_input(&mut self) -> Option<Method> {
let segments = self.input.submit_segments();
if segments_are_blank(&segments) {
return None;
}
if segments
.iter()
.any(|segment| matches!(segment, Segment::UploadedFile { .. }))
{
self.push_error("Notify accepts text only; remove attachments or queue a Submit.");
return None;
}
let message = Segment::flatten_to_text(&segments);
self.record_input_history(segments);
self.input.clear();
Some(Method::Notify {
notification_request_id: protocol::new_submission_request_id(),
message,
auto_run: true,
})
}
pub fn restore_unsent_run(&mut self, method: &Method) {
let Method::Run { input } = method else {
let Method::Submit { input, .. } = method else {
return;
};
self.pending_submit_rollback = None;
@@ -787,8 +790,9 @@ impl App {
self.input.replace_with_segments(input);
self.completion = None;
} else {
self.queued_inputs
.push_front(QueuedInput::new(input.clone()));
self.push_error(
"Submit transport failed; current Composer was preserved and the unsent input was not queued.",
);
}
}
@@ -804,7 +808,10 @@ impl App {
block_start: self.blocks.len(),
turn_before: self.turn_index,
});
Method::Run { input: segments }
Method::Submit {
submission_request_id: protocol::new_submission_request_id(),
input: segments,
}
}
fn record_input_history(&mut self, segments: Vec<Segment>) {
@@ -825,7 +832,7 @@ impl App {
}
pub fn queued_input_count(&self) -> usize {
self.queued_inputs.len()
self.pending_submissions.submissions.len()
}
#[cfg(test)]
@@ -910,36 +917,31 @@ impl App {
}
}
pub fn continue_pending_method(&self) -> Option<Method> {
Some(Method::ContinuePending {
expected_revision: self.pending_submissions.revision,
expected_head_id: self.pending_submissions.head_id.clone()?,
})
}
pub fn clear_pending_method(&self) -> Method {
Method::ClearPendingSubmissions {
expected_revision: self.pending_submissions.revision,
}
}
pub fn cancel_pending_method(&self, submission_id: String) -> Method {
Method::CancelPendingSubmission {
submission_id,
expected_revision: self.pending_submissions.revision,
}
}
pub fn next_queued_input_preview(&self) -> Option<&str> {
self.queued_inputs.front().map(QueuedInput::preview)
}
pub fn clear_queued_inputs(&mut self) -> usize {
let cleared = self.queued_inputs.len();
self.queued_inputs.clear();
cleared
}
pub fn restore_next_queued_input_to_composer(&mut self) -> bool {
if self.queued_inputs.is_empty() {
return false;
}
if !self.input.is_empty() {
self.push_error("Composer is not empty; clear it before editing queued input.");
return false;
}
let Some(queued) = self.queued_inputs.pop_front() else {
return false;
};
self.input_history.cancel_browse();
self.input.replace_with_segments(&queued.segments);
self.completion = None;
true
}
fn pop_next_queued_run(&mut self) -> Option<Method> {
let queued = self.queued_inputs.pop_front()?;
Some(self.method_for_run(queued.segments))
self.pending_submissions
.submissions
.first()
.map(|submission| submission.submission_id.as_str())
}
pub fn clear_actionbar_notice(&mut self) {
@@ -1117,12 +1119,42 @@ impl App {
}
}
pub fn next_command_envelope(&mut self) -> WorkerCommandEnvelope {
let command_id = self
.next_command_id
.max(self.worker_state.last_command_id.saturating_add(1));
let command = WorkerCommandEnvelope::for_snapshot(command_id, &self.worker_state);
self.next_command_id = command_id.saturating_add(1);
command
}
fn apply_worker_state_snapshot(&mut self, snapshot: &WorkerStateSnapshot) {
match protocol::apply_worker_state_snapshot(&mut self.worker_state, snapshot) {
Ok(protocol::WorkerStateSnapshotApply::Applied) => {
self.set_worker_status(self.worker_state.catalog_status());
}
Ok(
protocol::WorkerStateSnapshotApply::Duplicate
| protocol::WorkerStateSnapshotApply::Stale,
) => {}
Err(error) => self.handle_error(
ErrorCode::Internal,
format!("worker state stream rejected: {error}"),
),
}
}
pub fn handle_worker_event(&mut self, event: Event) -> Option<Method> {
if self.rewind_refresh_fence && event_is_stale_after_rewind(&event) {
return None;
}
match event {
Event::SubmissionAccepted { .. } => {}
Event::SubmissionRejected { message, .. } => self.push_error(message),
Event::PendingSubmissionsChanged { pending } => {
self.pending_submissions = pending;
}
Event::UserMessage { segments } => {
self.turn_index += 1;
self.blocks.push(Block::TurnHeader {
@@ -1148,18 +1180,14 @@ impl App {
self.assistant_streaming = false;
}
Event::TurnStart { .. } => {
self.set_worker_status(WorkerStatus::Running);
self.run_requests += 1;
self.current_tool = None;
self.latest_llm_wait_event = None;
self.assistant_streaming = false;
}
Event::InvokeStart { .. } => {
self.set_worker_status(WorkerStatus::Running);
}
Event::InvokeStart { .. } => {}
// UI consumers of per-attempt LlmCall semantics remain out of scope;
// the run-level status starts at InvokeStart and TurnStart counts each
// LLM request within that run.
// authoritative run state comes only from WorkerStateSnapshot.
Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => {
self.latest_llm_wait_event = None;
}
@@ -1366,15 +1394,7 @@ impl App {
output_tokens: self.run_output_tokens,
});
self.pending_submit_rollback = None;
self.reset_run_state(match result {
RunResult::Paused => WorkerStatus::Paused,
RunResult::Finished | RunResult::LimitReached | RunResult::RolledBack => {
WorkerStatus::Idle
}
});
if matches!(result, RunResult::Finished | RunResult::LimitReached) {
return self.pop_next_queued_run();
}
self.reset_run_state();
}
}
Event::CompactStart { .. } => {
@@ -1444,14 +1464,15 @@ impl App {
Event::Snapshot {
session,
greeting,
status,
state,
in_flight,
internal_workers,
} => {
self.rewind_refresh_fence = false;
self.pending_submissions = session.pending_submissions.clone();
self.restore_snapshot(&session, greeting, in_flight);
self.replace_internal_worker_snapshots(internal_workers);
self.set_worker_status(status);
self.apply_worker_state_snapshot(&state);
}
Event::InternalWorker {
worker,
@@ -1461,9 +1482,12 @@ impl App {
Event::InternalWorkerRemoved { worker, revision } => {
self.remove_internal_worker(worker, revision)
}
Event::Status { status } => {
Event::WorkerState { snapshot } => {
self.rewind_refresh_fence = false;
self.set_worker_status(status);
self.apply_worker_state_snapshot(&snapshot);
}
Event::CommandAcknowledged { acknowledgement } => {
self.apply_worker_state_snapshot(&acknowledgement.state);
}
// Command telemetry is an operational Web Console surface. The
// TUI continues to render the final Bash ToolResult from history.
@@ -1503,7 +1527,7 @@ impl App {
};
self.completion = None;
self.close_rewind_picker();
self.reset_run_state(self.worker_status);
self.reset_run_state();
let mut message = if restored_composer {
format!(
"Rewound session: discarded {} log entries; restored selected input to composer.",
@@ -1551,8 +1575,7 @@ impl App {
None
}
fn reset_run_state(&mut self, status: WorkerStatus) {
self.set_worker_status(status);
fn reset_run_state(&mut self) {
self.run_requests = 0;
self.run_upload_tokens = 0;
self.run_output_tokens = 0;
@@ -1582,7 +1605,7 @@ impl App {
"Rolled back empty assistant turn; no local submitted input was available to restore."
.to_owned()
};
self.reset_run_state(WorkerStatus::Idle);
self.reset_run_state();
self.blocks.push(Block::Alert {
level: AlertLevel::Warn,
source: AlertSource::Worker,
@@ -2026,12 +2049,18 @@ impl App {
self.input_mode = CommandInputMode::Composer;
self.command_completion_selected = None;
}
if let Some(Method::ListRewindTargets) = result.method.as_ref() {
let mut method = result.method;
if let Some(Method::Compact { .. }) = method {
method = Some(Method::Compact {
command: self.next_command_envelope(),
});
}
if let Some(Method::ListRewindTargets) = method.as_ref() {
self.completion = None;
self.rewind_picker = None;
self.rewind_request_pending = true;
}
result.method
method
}
fn push_command_diagnostic(&mut self, message: impl Into<String>) {
@@ -2681,7 +2710,10 @@ mod rewind_refresh_tests {
});
app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot { entries: vec![] },
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
input: vec![Segment::text("selected rewind input")],
summary: summary(3),
});
@@ -2700,7 +2732,10 @@ mod rewind_refresh_tests {
});
app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot { entries: vec![] },
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
input: vec![Segment::text("rewound input")],
summary: summary(1),
});
@@ -2743,7 +2778,10 @@ mod rewind_refresh_tests {
});
app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot { entries: vec![] },
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
input: vec![Segment::text("rewound input")],
summary: summary(2),
});
@@ -2752,8 +2790,8 @@ mod rewind_refresh_tests {
});
assert!(!blocks_contain(&app, "stale tail after rewind"));
app.handle_worker_event(Event::Status {
status: WorkerStatus::Idle,
app.handle_worker_event(Event::WorkerState {
snapshot: WorkerStatus::Idle.into(),
});
app.handle_worker_event(Event::TextDelta {
text: "new live tail after status".into(),
@@ -2877,7 +2915,7 @@ mod composer_history_persistence_tests {
path: "src/lib.rs".into(),
},
]);
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
let mut reloaded = App::new_with_input_history_store("test".into(), store);
assert!(reloaded.browse_input_history_older());
@@ -2958,7 +2996,7 @@ mod composer_history_persistence_tests {
app.insert_char(c);
}
match app.submit_input() {
Some(Method::Run { input }) => input,
Some(Method::Submit { input, .. }) => input,
other => panic!("expected Run, got {other:?}"),
}
}
@@ -3424,72 +3462,44 @@ mod completion_flow_tests {
}
#[test]
fn running_submit_is_queued_locally_and_clears_composer() {
fn running_submit_is_sent_to_the_worker_and_not_queued_locally() {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "queued turn");
assert!(app.submit_input().is_none());
let method = app.submit_input();
assert_eq!(app.queued_input_count(), 1);
assert_eq!(app.next_queued_input_preview(), Some("queued turn"));
assert!(matches!(method, Some(Method::Submit { .. })));
assert_eq!(app.queued_input_count(), 0);
assert_eq!(input_text(&app), "");
}
#[test]
fn finished_run_auto_sends_next_queued_input() {
fn pending_submission_projection_is_worker_authoritative() {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "next turn");
assert!(app.submit_input().is_none());
let method = app.handle_worker_event(Event::RunEnd {
result: RunResult::Finished,
app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot {
revision: 3,
notification_count: 0,
head_id: Some("submission-1".into()),
submissions: vec![protocol::PendingSubmissionSummary {
submission_id: "submission-1".into(),
accepted_at_ms: 7,
segment_count: 2,
byte_len: 42,
}],
},
});
match method {
Some(Method::Run { input }) => {
assert_eq!(Segment::flatten_to_text(&input), "next turn");
}
other => panic!("expected queued Run, got {other:?}"),
}
assert_eq!(app.queued_input_count(), 0);
}
#[test]
fn limit_reached_run_auto_sends_next_queued_input() {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "next after limit");
assert!(app.submit_input().is_none());
let method = app.handle_worker_event(Event::RunEnd {
result: RunResult::LimitReached,
});
match method {
Some(Method::Run { input }) => {
assert_eq!(Segment::flatten_to_text(&input), "next after limit");
}
other => panic!("expected queued Run, got {other:?}"),
}
assert_eq!(app.queued_input_count(), 0);
}
#[test]
fn paused_and_rolled_back_run_do_not_auto_send_queue() {
for result in [RunResult::Paused, RunResult::RolledBack] {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "held turn");
assert!(app.submit_input().is_none());
let method = app.handle_worker_event(Event::RunEnd { result });
assert!(method.is_none());
assert_eq!(app.queued_input_count(), 1);
assert_eq!(app.next_queued_input_preview(), Some("held turn"));
}
assert_eq!(app.queued_input_count(), 1);
assert_eq!(app.next_queued_input_preview(), Some("submission-1"));
assert!(
app.handle_worker_event(Event::RunEnd {
result: RunResult::Finished,
})
.is_none()
);
assert_eq!(app.queued_input_count(), 1);
}
#[test]
@@ -3497,25 +3507,7 @@ mod completion_flow_tests {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Paused);
assert!(matches!(app.submit_input(), Some(Method::Resume)));
assert_eq!(app.queued_input_count(), 0);
}
#[test]
fn queued_input_can_be_restored_to_composer_or_cleared() {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "edit me");
assert!(app.submit_input().is_none());
assert!(app.restore_next_queued_input_to_composer());
assert_eq!(app.queued_input_count(), 0);
assert_eq!(input_text(&app), "edit me");
app.input.clear();
insert_text(&mut app, "clear me");
assert!(app.submit_input().is_none());
assert_eq!(app.clear_queued_inputs(), 1);
assert!(matches!(app.submit_input(), Some(Method::Resume { .. })));
assert_eq!(app.queued_input_count(), 0);
}
@@ -3530,7 +3522,7 @@ mod completion_flow_tests {
app.insert_char(c);
}
match app.submit_input() {
Some(Method::Run { input }) => input,
Some(Method::Submit { input, .. }) => input,
other => panic!("expected Run, got {other:?}"),
}
}
@@ -3570,7 +3562,7 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
session: public_session(vec![session_start_value]),
status: WorkerStatus::Running,
state: test_worker_state(WorkerStatus::Running),
in_flight: Default::default(),
internal_workers: Vec::new(),
});
@@ -3581,6 +3573,90 @@ mod completion_flow_tests {
assert!(matches!(app.blocks.first(), Some(Block::Greeting(_))));
}
#[test]
fn occurrence_events_do_not_infer_foreground_worker_state() {
let mut app = App::new("test".into());
app.handle_worker_event(Event::TurnStart { turn: 1 });
app.handle_worker_event(Event::InvokeStart {
kind: protocol::InvokeKind::UserSend,
});
app.handle_worker_event(Event::RunEnd {
result: RunResult::Paused,
});
assert_eq!(app.worker_state.state, protocol::WorkerState::Idle);
assert_eq!(app.worker_status, WorkerStatus::Idle);
let running = WorkerStateSnapshot {
execution_generation: 1,
revision: 1,
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
)),
last_command_id: 0,
};
app.handle_worker_event(Event::WorkerState {
snapshot: running.clone(),
});
app.handle_worker_event(Event::RunEnd {
result: RunResult::Finished,
});
assert_eq!(app.worker_state, running);
assert_eq!(app.worker_status, WorkerStatus::Running);
}
#[test]
fn worker_state_events_and_acknowledgements_share_monotonic_application() {
let mut app = App::new("test".into());
let running = WorkerStateSnapshot {
execution_generation: 4,
revision: 3,
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
)),
last_command_id: 2,
};
app.handle_worker_event(Event::WorkerState {
snapshot: running.clone(),
});
app.handle_worker_event(Event::WorkerState {
snapshot: WorkerStateSnapshot {
revision: 2,
state: protocol::WorkerState::Idle,
..running.clone()
},
});
assert_eq!(app.worker_state, running);
let paused = WorkerStateSnapshot {
revision: 4,
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Paused,
)),
last_command_id: 3,
..running.clone()
};
app.handle_worker_event(Event::CommandAcknowledged {
acknowledgement: protocol::WorkerCommandAcknowledgement {
command_id: 3,
command: protocol::WorkerCommandKind::Pause,
disposition: protocol::WorkerCommandDisposition::Accepted,
state: paused.clone(),
},
});
assert_eq!(app.worker_state, paused);
app.handle_worker_event(Event::WorkerState {
snapshot: WorkerStateSnapshot {
state: protocol::WorkerState::Idle,
..paused.clone()
},
});
assert_eq!(app.worker_state, paused);
assert!(app.run_error_messages.iter().any(|message| {
message.contains("conflicting worker state snapshots at generation 4 revision 4")
}));
}
#[test]
fn snapshot_replaces_live_error_with_one_durable_run_error_block() {
let mut app = App::new("test".into());
@@ -3588,8 +3664,8 @@ mod completion_flow_tests {
code: ErrorCode::ProviderError,
message: "provider unavailable".into(),
});
app.handle_worker_event(Event::Status {
status: WorkerStatus::Idle,
app.handle_worker_event(Event::WorkerState {
snapshot: WorkerStatus::Idle.into(),
});
let live_errors = app
@@ -3614,7 +3690,7 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
session: public_session(vec![serde_json::to_value(run_errored).unwrap()]),
status: WorkerStatus::Idle,
state: test_worker_state(WorkerStatus::Idle),
in_flight: Default::default(),
internal_workers: Vec::new(),
});
@@ -3675,9 +3751,10 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
status: WorkerStatus::Running,
state: test_worker_state(WorkerStatus::Running),
in_flight: InFlightSnapshot {
blocks: vec![
InFlightBlock::Thinking {
@@ -3783,6 +3860,7 @@ mod completion_flow_tests {
revision,
status: WorkerStatus::Idle,
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
in_flight: protocol::InFlightSnapshot::default(),
@@ -4000,9 +4078,10 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
status: WorkerStatus::Idle,
state: test_worker_state(WorkerStatus::Idle),
in_flight: Default::default(),
internal_workers: Vec::new(),
});
@@ -4051,9 +4130,10 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
status: WorkerStatus::Idle,
state: test_worker_state(WorkerStatus::Idle),
in_flight: Default::default(),
internal_workers: vec![InternalWorkerSnapshot {
worker: InternalWorkerRef {
@@ -4064,6 +4144,7 @@ mod completion_flow_tests {
},
revision: 4,
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
status: WorkerStatus::Running,
@@ -4200,6 +4281,13 @@ mod completion_flow_tests {
.count()
}
fn test_worker_state(status: WorkerStatus) -> WorkerStateSnapshot {
let mut snapshot = WorkerStateSnapshot::from(status);
snapshot.execution_generation = 1;
snapshot.revision = 1;
snapshot
}
fn test_greeting() -> protocol::Greeting {
protocol::Greeting {
worker_name: "test".into(),
@@ -4222,10 +4310,11 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting,
status: WorkerStatus::Idle,
state: test_worker_state(WorkerStatus::Idle),
in_flight: Default::default(),
internal_workers: Vec::new(),
});
@@ -4424,7 +4513,7 @@ mod completion_flow_tests {
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
session: public_session(assistant_item_entries),
status: WorkerStatus::Running,
state: test_worker_state(WorkerStatus::Running),
in_flight: Default::default(),
internal_workers: Vec::new(),
});
@@ -4437,23 +4526,23 @@ mod completion_flow_tests {
}
#[test]
fn input_history_records_queued_inputs_and_suppresses_consecutive_duplicates() {
fn input_history_records_running_submits_and_suppresses_consecutive_duplicates() {
let mut app = App::new("test".into());
app.running = true;
for c in "repeat".chars() {
app.insert_char(c);
}
assert!(app.submit_input().is_none());
assert!(app.submit_input().is_some());
assert_eq!(app.input_history_len(), 1);
assert_eq!(app.queued_input_count(), 1);
assert_eq!(app.queued_input_count(), 0);
for c in "repeat".chars() {
app.insert_char(c);
}
assert!(app.submit_input().is_none());
assert!(app.submit_input().is_some());
assert_eq!(app.input_history_len(), 1);
assert_eq!(app.queued_input_count(), 2);
assert_eq!(app.queued_input_count(), 0);
app.insert_char(' ');
assert!(app.submit_input().is_none());
@@ -4481,7 +4570,7 @@ mod completion_flow_tests {
},
];
app.input.replace_with_segments(&original);
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
assert!(app.browse_input_history_older());
assert_eq!(app.input.submit_segments(), original);
@@ -4493,7 +4582,7 @@ mod completion_flow_tests {
for c in "sent".chars() {
app.insert_char(c);
}
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
for c in "draft".chars() {
app.insert_char(c);
@@ -4511,7 +4600,7 @@ mod completion_flow_tests {
for c in "sent".chars() {
app.insert_char(c);
}
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
assert!(app.browse_input_history_older());
assert!(app.input_history_is_browsing());
@@ -4528,17 +4617,19 @@ mod completion_flow_tests {
for c in "first".chars() {
app.insert_char(c);
}
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
for c in "second".chars() {
app.insert_char(c);
}
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
assert!(matches!(app.submit_input(), Some(Method::Submit { .. })));
assert!(app.browse_input_history_older());
assert!(app.browse_input_history_older());
let method = app.submit_input();
match method {
Some(Method::Run { input }) => assert_eq!(Segment::flatten_to_text(&input), "first"),
Some(Method::Submit { input, .. }) => {
assert_eq!(Segment::flatten_to_text(&input), "first")
}
other => panic!("expected recalled run, got {other:?}"),
}
assert_eq!(app.input_history_len(), 3);
+483
View File
@@ -0,0 +1,483 @@
use client::{
BackendCreateWorkerRequest, BackendWorkerLaunchOptions, BackendWorkerLaunchProfileCandidate,
BackendWorkerLaunchRuntimeOption, BackendWorkerLaunchTarget, create_backend_worker,
get_backend_worker_launch_options,
};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use crate::backend_workspace_picker::select_backend_workspace;
use crate::console;
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Field {
Name,
Runtime,
Profile,
}
impl Field {
fn next(self) -> Self {
match self {
Self::Name => Self::Runtime,
Self::Runtime => Self::Profile,
Self::Profile => Self::Name,
}
}
fn previous(self) -> Self {
match self {
Self::Name => Self::Profile,
Self::Runtime => Self::Name,
Self::Profile => Self::Runtime,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Selection {
runtime_id: String,
display_name: String,
profile: String,
}
struct FormState {
field: Field,
display_name: String,
runtime_index: usize,
profile_index: usize,
status: String,
}
impl FormState {
fn new(options: &BackendWorkerLaunchOptions) -> Self {
let runtime_index = options
.runtimes
.iter()
.position(runtime_supports_workdirless_creation)
.unwrap_or(0);
let profile_index = options
.default_profile
.as_deref()
.and_then(|default| {
options
.profiles
.iter()
.position(|candidate| candidate.id == default)
})
.unwrap_or(0);
Self {
field: Field::Name,
display_name: "Worker".to_string(),
runtime_index,
profile_index,
status: String::new(),
}
}
fn current_runtime<'a>(
&self,
options: &'a BackendWorkerLaunchOptions,
) -> Option<&'a BackendWorkerLaunchRuntimeOption> {
options.runtimes.get(self.runtime_index)
}
fn current_profile<'a>(
&self,
options: &'a BackendWorkerLaunchOptions,
) -> Option<&'a BackendWorkerLaunchProfileCandidate> {
options.profiles.get(self.profile_index)
}
fn cycle_runtime(&mut self, options: &BackendWorkerLaunchOptions, delta: isize) {
self.runtime_index = cycle_index(self.runtime_index, options.runtimes.len(), delta);
self.status.clear();
}
fn cycle_profile(&mut self, options: &BackendWorkerLaunchOptions, delta: isize) {
self.profile_index = cycle_index(self.profile_index, options.profiles.len(), delta);
self.status.clear();
}
fn submit(&mut self, options: &BackendWorkerLaunchOptions) -> Option<Selection> {
let display_name = self.display_name.trim();
if display_name.is_empty() {
self.status = "Worker name is required.".to_string();
self.field = Field::Name;
return None;
}
let Some(runtime) = self.current_runtime(options) else {
self.status = "No Runtime is available in this Workspace.".to_string();
self.field = Field::Runtime;
return None;
};
if !runtime.worker_creation_available {
self.status = "The selected Runtime cannot create Workers right now.".to_string();
self.field = Field::Runtime;
return None;
}
if runtime.working_directory_required {
self.status =
"The selected Runtime requires a workdir; this launch flow does not select one yet."
.to_string();
self.field = Field::Runtime;
return None;
}
let Some(profile) = self.current_profile(options) else {
self.status = "No Worker profile is available.".to_string();
self.field = Field::Profile;
return None;
};
Some(Selection {
runtime_id: runtime.runtime_id.clone(),
display_name: display_name.to_string(),
profile: profile.id.clone(),
})
}
}
pub async fn run(mut target: BackendWorkerLaunchTarget) -> Result<(), Box<dyn std::error::Error>> {
if target.workspace_id().is_none() {
let Some(workspace) = select_backend_workspace(&target.base_url).await? else {
return Ok(());
};
target.select_workspace(workspace);
}
let options = get_backend_worker_launch_options(&target).await?;
let Some(selection) = select_worker(&options)? else {
return Ok(());
};
let request = request_from_selection(selection);
let created = create_backend_worker(&target, &request).await?;
let runtime_target = target.runtime_target(created.runtime_id, created.worker_id)?;
console::run_backend_runtime(runtime_target).await
}
fn request_from_selection(selection: Selection) -> BackendCreateWorkerRequest {
BackendCreateWorkerRequest {
runtime_id: selection.runtime_id,
display_name: selection.display_name,
profile: Some(selection.profile),
initial_submit: Vec::new(),
working_directory: None,
ticket_assignment: None,
control_operation_id: None,
}
}
const VIEWPORT_LINES: u16 = 14;
fn select_worker(
options: &BackendWorkerLaunchOptions,
) -> Result<Option<Selection>, Box<dyn std::error::Error>> {
with_inline_terminal(VIEWPORT_LINES, |terminal| run_form(terminal, options))
}
fn run_form(
terminal: &mut InlineTerminal,
options: &BackendWorkerLaunchOptions,
) -> Result<Option<Selection>, Box<dyn std::error::Error>> {
let mut state = FormState::new(options);
loop {
terminal.draw(|frame| render(frame, &state, options))?;
let event = event::read()?;
let Event::Key(key) = event else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(None);
}
match key.code {
KeyCode::Esc => {
return Ok(None);
}
KeyCode::Tab | KeyCode::Down => {
state.field = state.field.next();
state.status.clear();
}
KeyCode::BackTab | KeyCode::Up => {
state.field = state.field.previous();
state.status.clear();
}
KeyCode::Left => match state.field {
Field::Runtime => state.cycle_runtime(options, -1),
Field::Profile => state.cycle_profile(options, -1),
Field::Name => {}
},
KeyCode::Right => match state.field {
Field::Runtime => state.cycle_runtime(options, 1),
Field::Profile => state.cycle_profile(options, 1),
Field::Name => {}
},
KeyCode::Enter => {
if let Some(selection) = state.submit(options) {
return Ok(Some(selection));
}
}
KeyCode::Backspace if state.field == Field::Name => {
state.display_name.pop();
state.status.clear();
}
KeyCode::Char(character)
if state.field == Field::Name
&& !key.modifiers.contains(KeyModifiers::CONTROL)
&& !character.is_control() =>
{
state.display_name.push(character);
state.status.clear();
}
_ => {}
}
}
}
fn render(frame: &mut ratatui::Frame<'_>, state: &FormState, options: &BackendWorkerLaunchOptions) {
let area = frame.area();
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(1),
])
.split(area);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
"New Backend Worker",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(format!(" Workspace: {}", options.workspace_id)),
])),
vertical[0],
);
let focused = Style::default().fg(Color::Cyan);
frame.render_widget(
Paragraph::new(state.display_name.as_str()).block(
Block::default()
.borders(Borders::ALL)
.title(" Name ")
.border_style(if state.field == Field::Name {
focused
} else {
Style::default()
}),
),
vertical[1],
);
let runtime_text = state
.current_runtime(options)
.map(runtime_label)
.unwrap_or_else(|| "No Runtime available".to_string());
frame.render_widget(
Paragraph::new(runtime_text).block(
Block::default()
.borders(Borders::ALL)
.title(runtime_title(state, options))
.border_style(if state.field == Field::Runtime {
focused
} else {
Style::default()
}),
),
vertical[2],
);
let profile_text = state
.current_profile(options)
.map(|profile| {
if profile.description.is_empty() {
profile.label.clone()
} else {
format!("{}{}", profile.label, profile.description)
}
})
.unwrap_or_else(|| "No profile available".to_string());
frame.render_widget(
Paragraph::new(profile_text).block(
Block::default()
.borders(Borders::ALL)
.title(profile_title(state, options))
.border_style(if state.field == Field::Profile {
focused
} else {
Style::default()
}),
),
vertical[3],
);
let status = if state.status.is_empty() {
"Tab/↑/↓: field ←/→: choice Enter: create Esc/Ctrl-C: cancel"
} else {
state.status.as_str()
};
frame.render_widget(
Paragraph::new(status)
.style(if state.status.is_empty() {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::Yellow)
})
.wrap(Wrap { trim: true }),
vertical[4],
);
if state.field == Field::Name {
let max_cursor = vertical[1].width.saturating_sub(2) as usize;
frame.set_cursor_position((
vertical[1].x + 1 + state.display_name.chars().count().min(max_cursor) as u16,
vertical[1].y + 1,
));
}
}
fn runtime_title(state: &FormState, options: &BackendWorkerLaunchOptions) -> String {
if options.runtimes.is_empty() {
" Runtime ".to_string()
} else {
format!(
" Runtime ({}/{}) ",
state.runtime_index + 1,
options.runtimes.len()
)
}
}
fn profile_title(state: &FormState, options: &BackendWorkerLaunchOptions) -> String {
if options.profiles.is_empty() {
" Profile ".to_string()
} else {
format!(
" Profile ({}/{}) ",
state.profile_index + 1,
options.profiles.len()
)
}
}
fn runtime_label(runtime: &BackendWorkerLaunchRuntimeOption) -> String {
let availability = if !runtime.worker_creation_available {
"unavailable"
} else if runtime.working_directory_required {
"workdir required"
} else {
"no workdir"
};
format!(
"{} [{}] — {availability}",
runtime.display_name, runtime.runtime_id
)
}
fn runtime_supports_workdirless_creation(runtime: &BackendWorkerLaunchRuntimeOption) -> bool {
runtime.worker_creation_available && !runtime.working_directory_required
}
fn cycle_index(current: usize, len: usize, delta: isize) -> usize {
if len == 0 {
return 0;
}
(current as isize + delta).rem_euclid(len as isize) as usize
}
#[cfg(test)]
mod tests {
use super::*;
use client::{BackendDiagnostic, BackendWorkerLaunchOptions};
fn options() -> BackendWorkerLaunchOptions {
BackendWorkerLaunchOptions {
workspace_id: "workspace-1".to_string(),
runtimes: vec![
BackendWorkerLaunchRuntimeOption {
runtime_id: "external".to_string(),
display_name: "External".to_string(),
built_in: false,
worker_creation_available: true,
working_directory_required: true,
status: "online".to_string(),
diagnostics: Vec::new(),
},
BackendWorkerLaunchRuntimeOption {
runtime_id: "embedded".to_string(),
display_name: "Embedded".to_string(),
built_in: true,
worker_creation_available: true,
working_directory_required: false,
status: "online".to_string(),
diagnostics: Vec::new(),
},
],
profiles: vec![
BackendWorkerLaunchProfileCandidate {
id: "builtin:default".to_string(),
label: "Default".to_string(),
description: String::new(),
},
BackendWorkerLaunchProfileCandidate {
id: "builtin:coder".to_string(),
label: "Coder".to_string(),
description: "Ticket implementation".to_string(),
},
],
default_profile: Some("builtin:coder".to_string()),
repositories: Vec::new(),
working_directories: Vec::new(),
diagnostics: Vec::<BackendDiagnostic>::new(),
}
}
#[test]
fn defaults_to_workdirless_runtime_and_backend_default_profile() {
let options = options();
let state = FormState::new(&options);
assert_eq!(
state.current_runtime(&options).unwrap().runtime_id,
"embedded"
);
assert_eq!(state.current_profile(&options).unwrap().id, "builtin:coder");
assert_eq!(state.display_name, "Worker");
}
#[test]
fn workdir_required_runtime_cannot_be_submitted() {
let options = options();
let mut state = FormState::new(&options);
state.runtime_index = 0;
assert_eq!(state.submit(&options), None);
assert!(state.status.contains("requires a workdir"));
assert_eq!(state.field, Field::Runtime);
}
#[test]
fn selection_builds_workdirless_create_request() {
let request = request_from_selection(Selection {
runtime_id: "embedded".to_string(),
display_name: "Coder one".to_string(),
profile: "builtin:coder".to_string(),
});
assert_eq!(request.runtime_id, "embedded");
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.ticket_assignment.is_none());
}
}
+168 -33
View File
@@ -12,6 +12,7 @@ use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use unicode_width::UnicodeWidthStr;
use crate::backend_workspace_picker::select_backend_workspace;
use crate::console;
@@ -235,9 +236,10 @@ fn draw(frame: &mut Frame<'_>, state: &BackendWorkerPickerState) {
layout[0],
);
let column_widths = WorkerColumnWidths::from_workers(&state.workers);
for (i, worker) in state.workers.iter().enumerate() {
frame.render_widget(
Paragraph::new(row_line(worker, i == state.selected)),
Paragraph::new(row_line(worker, &column_widths, i == state.selected)),
layout[i + 1],
);
}
@@ -272,7 +274,28 @@ fn picker_title(target: &BackendRuntimeListTarget) -> String {
format!("backend workers workspace: {workspace} runtime: {runtime}")
}
fn row_line(worker: &BackendWorkerSummary, selected: bool) -> Line<'static> {
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct WorkerColumnWidths {
identity: usize,
name: usize,
state: usize,
}
impl WorkerColumnWidths {
fn from_workers(workers: &[BackendWorkerSummary]) -> Self {
workers.iter().fold(Self::default(), |widths, worker| Self {
identity: widths.identity.max(text_width(&short_worker_id(worker))),
name: widths.name.max(text_width(worker_name(worker))),
state: widths.state.max(text_width(&worker_state(worker))),
})
}
}
fn row_line(
worker: &BackendWorkerSummary,
widths: &WorkerColumnWidths,
selected: bool,
) -> Line<'static> {
let marker = if selected { "" } else { " " };
let id_style = if selected {
Style::default()
@@ -281,42 +304,70 @@ fn row_line(worker: &BackendWorkerSummary, selected: bool) -> Line<'static> {
} else {
Style::default().fg(Color::Cyan)
};
let preview_style = if selected {
let name_style = if selected {
Style::default().fg(Color::White)
} else {
Style::default().fg(Color::DarkGray)
};
let label = if worker.label.is_empty() {
worker.worker_id.as_str()
} else {
worker.label.as_str()
};
let profile = worker.profile.as_deref().unwrap_or("-");
Line::from(vec![
Span::raw(marker),
Span::styled(short_worker_id(worker), id_style),
Span::raw(" "),
Span::styled(
format!("[{}]", worker.state),
state_style(worker.state.as_str()),
pad_column(&short_worker_id(worker), widths.identity),
id_style,
),
Span::raw(" "),
Span::styled(pad_column(worker_name(worker), widths.name), name_style),
Span::raw(" "),
Span::styled(
format!("profile:{profile}"),
Style::default().fg(Color::DarkGray),
pad_column(&worker_state(worker), widths.state),
state_style(worker_state_label(worker)),
),
Span::raw(" "),
Span::styled(
working_directory_text(worker),
Style::default().fg(Color::DarkGray),
),
Span::raw(" "),
Span::styled(label.to_string(), preview_style),
])
}
fn worker_name(worker: &BackendWorkerSummary) -> &str {
if !worker.label.is_empty() {
worker.label.as_str()
} else if !worker.display_name.is_empty() {
worker.display_name.as_str()
} else {
worker.worker_id.as_str()
}
}
fn worker_state_label(worker: &BackendWorkerSummary) -> &str {
match worker.worker_state.as_ref().map(|state| &state.state) {
Some(protocol::WorkerState::Idle) => "idle",
Some(protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Paused,
))) => "paused",
Some(protocol::WorkerState::Busy(_)) => "running",
None if worker.state == "stopped" => "stopped",
None => "unknown",
}
}
fn worker_state(worker: &BackendWorkerSummary) -> String {
format!("[{}]", worker_state_label(worker))
}
fn text_width(value: &str) -> usize {
UnicodeWidthStr::width(value)
}
fn pad_column(value: &str, width: usize) -> String {
format!(
"{value}{}",
" ".repeat(width.saturating_sub(text_width(value)))
)
}
fn state_style(state: &str) -> Style {
match state {
"running" | "idle" | "active" => Style::default()
@@ -347,11 +398,7 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String {
let Some(wd) = worker.working_directory.as_ref() else {
return "wd:—".to_string();
};
let cleanliness = wd.cleanliness.as_deref().unwrap_or("unknown");
format!(
"wd:{}:{} {} {}",
wd.repository_key, wd.working_directory_id, wd.status, cleanliness
)
format!("wd:{}{}", wd.repository_key, wd.working_directory_id)
}
#[cfg(test)]
@@ -378,7 +425,15 @@ mod tests {
identity: "ws".to_string(),
workspace_id: Some("ws".to_string()),
},
state: "running".to_string(),
state: "idle".to_string(),
worker_state: Some(protocol::WorkerStateSnapshot {
execution_generation: 1,
revision: 1,
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
)),
last_command_id: 0,
}),
last_seen_at: None,
pinned: false,
retention_state: String::new(),
@@ -395,18 +450,98 @@ mod tests {
}
}
#[test]
fn worker_row_matches_inline_picker_shape() {
let row = row_line(&worker("runtime-a", "worker-b", Some("default")), true);
let text = row
fn row_text(worker: &BackendWorkerSummary, widths: &WorkerColumnWidths) -> String {
row_line(worker, widths, false)
.spans
.into_iter()
.map(|span| span.content)
.collect::<String>();
assert!(text.starts_with("▶ W-1"));
assert!(text.contains("[running]"));
assert!(text.contains("profile:default"));
assert!(text.contains("wd:—"));
.collect()
}
fn display_column(text: &str, value: &str) -> usize {
let byte_offset = text.find(value).expect("value in rendered row");
text_width(&text[..byte_offset])
}
#[test]
fn worker_row_orders_and_simplifies_columns() {
let mut worker = worker("runtime-a", "worker-b", Some("builtin:coder"));
worker.resource_key = "W-90".to_string();
worker.display_name = "Coder".to_string();
worker.label = "Coder · T-585".to_string();
worker.state = "stopped".to_string();
worker.worker_state = None;
worker.working_directory = Some(
serde_json::from_value(serde_json::json!({
"working_directory_id": "001a06a9f0202000000",
"repository_key": "main",
"materializer_kind": "local_git_worktree",
"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"
);
assert!(!text.contains("profile:"));
assert!(!text.contains("active clean"));
}
#[test]
fn worker_rows_align_identity_name_state_and_workdir_columns() {
let mut short = worker("runtime-a", "worker-a", None);
short.resource_key = "W-2".to_string();
short.label = "Coder".to_string();
short.display_name = short.label.clone();
short.state = "idle".to_string();
short.worker_state = Some(protocol::WorkerStateSnapshot {
execution_generation: 1,
revision: 2,
state: protocol::WorkerState::Idle,
last_command_id: 0,
});
let mut long = worker("runtime-a", "worker-b", None);
long.resource_key = "W-100".to_string();
long.label = "Longer worker · T-9".to_string();
long.display_name = long.label.clone();
long.state = "stopped".to_string();
long.worker_state = None;
for worker in [&mut short, &mut long] {
worker.working_directory = Some(
serde_json::from_value(serde_json::json!({
"working_directory_id": "workdir-1",
"repository_key": "main",
"materializer_kind": "local_git_worktree",
"status": "active"
}))
.unwrap(),
);
}
let workers = vec![short, long];
let widths = WorkerColumnWidths::from_workers(&workers);
let first = row_text(&workers[0], &widths);
let second = row_text(&workers[1], &widths);
assert_eq!(
display_column(&first, "Coder"),
display_column(&second, "Longer")
);
assert_eq!(
display_column(&first, "[idle]"),
display_column(&second, "[stopped]")
);
assert_eq!(
display_column(&first, "wd:main"),
display_column(&second, "wd:main")
);
}
#[test]
+7 -2
View File
@@ -409,7 +409,12 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.environment;
let _ = invocation.args.raw();
CommandExecution {
method: Some(Method::Compact),
method: Some(Method::Compact {
command: protocol::WorkerCommandEnvelope::for_snapshot(
0,
&protocol::WorkerStateSnapshot::initial(1),
),
}),
diagnostics: vec![CommandDiagnostic::new("compact requested")],
exit_command_mode: true,
clear_input: true,
@@ -483,7 +488,7 @@ mod tests {
fn compact_command_returns_compact_method_not_run() {
let registry = CommandRegistry::builtins();
let result = registry.dispatch("compact", &env());
assert!(matches!(result.method, Some(Method::Compact)));
assert!(matches!(result.method, Some(Method::Compact { .. })));
assert!(result.exit_command_mode);
assert!(result.clear_input);
assert!(result.diagnostics[0].message.contains("compact requested"));
+177 -128
View File
@@ -270,8 +270,8 @@ impl<T: Socket> ConsoleConnection<T> {
async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> {
let mut prepared = method.clone();
let carries_attachments =
matches!(prepared, Method::Run { .. }) && !self.pending_attachments.is_empty();
if let Method::Run { input } = &mut prepared {
matches!(prepared, Method::Submit { .. }) && !self.pending_attachments.is_empty();
if let Method::Submit { input, .. } = &mut prepared {
input.extend(
self.pending_attachments
.iter()
@@ -569,9 +569,10 @@ async fn run_e2e_rewind_fixture(
app.connected = true;
app.handle_worker_event(Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
status: WorkerStatus::Idle,
state: WorkerStatus::Idle.into(),
greeting: Greeting {
worker_name: worker_name.clone(),
cwd: workspace_root.display().to_string(),
@@ -697,6 +698,7 @@ async fn run_e2e_rewind_fixture(
if submitted_at.elapsed() >= apply_delay {
app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
input: vec![Segment::text("rewind-live-refresh")],
@@ -916,7 +918,7 @@ async fn run_loop<T: Socket>(
}
fn attachment_command_path(method: &Method) -> Option<PathBuf> {
let Method::Run { input } = method else {
let Method::Submit { input, .. } = method else {
return None;
};
let [Segment::Text { content }] = input.as_slice() else {
@@ -927,7 +929,7 @@ fn attachment_command_path(method: &Method) -> Option<PathBuf> {
}
fn is_clear_attachments_command(method: &Method) -> bool {
let Method::Run { input } = method else {
let Method::Submit { input, .. } = method else {
return false;
};
matches!(
@@ -941,7 +943,7 @@ async fn send_console_method<T: Socket>(
client: &mut ConsoleConnection<T>,
method: &Method,
) -> Result<(), Box<dyn std::error::Error>> {
if matches!(method, Method::Run { .. }) && client.has_active_uploads() {
if matches!(method, Method::Submit { .. }) && client.has_active_uploads() {
app.restore_unsent_run(method);
app.flash_actionbar_notice(
"Attachment upload is still in progress; wait or use /clear-attachments.",
@@ -953,7 +955,7 @@ async fn send_console_method<T: Socket>(
}
let sends_attachments =
matches!(method, Method::Run { .. }) && !client.pending_attachments.is_empty();
matches!(method, Method::Submit { .. }) && !client.pending_attachments.is_empty();
if let Err(error) = client.send(method).await {
if sends_attachments {
app.restore_unsent_run(method);
@@ -1148,18 +1150,27 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.clear_command_input();
Some(None)
}
KeyCode::Char(c)
if c.eq_ignore_ascii_case(&'d') && alt && !ctrl && !app.is_command_mode() =>
{
Some(
app.next_queued_input_preview()
.map(str::to_owned)
.map(|submission_id| app.cancel_pending_method(submission_id)),
)
}
KeyCode::Char(c)
if c.eq_ignore_ascii_case(&'n') && alt && !ctrl && !app.is_command_mode() =>
{
Some(app.submit_notify_input())
}
KeyCode::Char(c)
if c.eq_ignore_ascii_case(&'q') && alt && !ctrl && !app.is_command_mode() =>
{
if app.restore_next_queued_input_to_composer() {
Some(app.refresh_completion())
} else {
Some(None)
}
Some(app.continue_pending_method())
}
KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c') && alt && !ctrl => {
app.clear_queued_inputs();
Some(None)
Some(Some(app.clear_pending_method()))
}
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
KeyCode::Char('x') if ctrl => Some(handle_cancel_or_shutdown(app)),
@@ -1427,14 +1438,15 @@ fn handle_cancel_or_shutdown(app: &mut App) -> Option<Method> {
WorkerStatus::Running | WorkerStatus::Paused
) {
app.shutdown_confirm = None;
app.clear_queued_inputs();
return Some(Method::Cancel);
let command = app.next_command_envelope();
return Some(Method::Cancel { command });
}
if let Some(pressed_at) = app.shutdown_confirm
&& pressed_at.elapsed() < CONFIRM_TIMEOUT
{
app.shutdown_confirm = None;
return Some(Method::Shutdown);
let command = app.next_command_envelope();
return Some(Method::Shutdown { command });
}
app.shutdown_confirm = Some(std::time::Instant::now());
app.flash_actionbar_notice(
@@ -1450,8 +1462,8 @@ fn handle_cancel_or_shutdown(app: &mut App) -> Option<Method> {
/// Idle / Paused → 2-tap to quit the TUI (the Worker keeps running).
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
if app.worker_status == WorkerStatus::Running {
app.clear_queued_inputs();
return Some(Method::Pause);
let command = app.next_command_envelope();
return Some(Method::Pause { command });
}
if let Some(t) = app.quit_confirm
&& t.elapsed() < CONFIRM_TIMEOUT
@@ -1476,8 +1488,8 @@ mod tests {
use crate::text_selection::{HistoryViewport, SelectionRow};
use async_trait::async_trait;
use protocol::{
Event, RewindTarget, RewindTargetId, RunResult, Segment, UploadedFileAvailability,
UploadedFileRef, WorkerStatus,
Event, RewindTarget, RewindTargetId, Segment, UploadedFileAvailability, UploadedFileRef,
WorkerStatus,
};
#[test]
@@ -1490,7 +1502,8 @@ mod tests {
#[test]
fn client_local_attachment_commands_are_typed_and_do_not_send_the_path() {
let attach = Method::Run {
let attach = Method::Submit {
submission_request_id: protocol::new_submission_request_id(),
input: vec![Segment::text("/attach /tmp/report.md")],
};
assert_eq!(
@@ -1499,7 +1512,8 @@ mod tests {
);
assert!(!is_clear_attachments_command(&attach));
let clear = Method::Run {
let clear = Method::Submit {
submission_request_id: protocol::new_submission_request_id(),
input: vec![Segment::text("/clear-attachments")],
};
assert!(is_clear_attachments_command(&clear));
@@ -1605,7 +1619,7 @@ mod tests {
}
#[tokio::test]
async fn queued_attachment_send_failure_restores_draft_without_exiting_console() {
async fn running_attachment_submit_failure_restores_draft_without_exiting_console() {
let file = UploadedFileRef {
artifact_id: "artifact-queued".into(),
file_name: "queued.txt".into(),
@@ -1631,13 +1645,10 @@ mod tests {
let mut app = App::new("worker".into());
app.set_worker_status(WorkerStatus::Running);
app.input.insert_str("queued inspect");
assert!(app.submit_input().is_none());
let method = app
.handle_worker_event(Event::RunEnd {
result: RunResult::Finished,
})
.expect("queued run must be released");
.submit_input()
.expect("running Submit is sent immediately");
send_console_method(&mut app, &mut connection, &method)
.await
.unwrap();
@@ -1960,7 +1971,7 @@ mod tests {
}
#[test]
fn running_enter_queues_instead_of_sending_run() {
fn running_enter_sends_submit_to_worker() {
let mut app = App::new("agent".to_string());
app.set_worker_status(WorkerStatus::Running);
for c in "queued".chars() {
@@ -1973,102 +1984,128 @@ mod tests {
);
}
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
assert!(matches!(
handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
Some(Method::Submit { .. })
));
assert_eq!(app.queued_input_count(), 1);
assert_eq!(app.next_queued_input_preview(), Some("queued"));
assert_eq!(app.queued_input_count(), 0);
assert_eq!(input_text(&app), "");
}
#[test]
fn queued_input_keybindings_restore_and_clear() {
let mut app = App::new("agent".to_string());
fn running_alt_n_sends_explicit_notify_without_implicit_submit_conversion() {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
for c in "edit queued".chars() {
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
)
.is_none()
);
for character in "progress".chars() {
app.insert_char(character);
}
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT)
)
.is_none()
let method = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('n'), KeyModifiers::ALT),
);
assert_eq!(app.queued_input_count(), 0);
assert_eq!(input_text(&app), "edit queued");
app.input.clear();
for c in "clear queued".chars() {
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
)
.is_none()
);
}
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
assert_eq!(app.queued_input_count(), 1);
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT)
)
.is_none()
);
assert_eq!(app.queued_input_count(), 0);
assert!(matches!(
method,
Some(Method::Notify {
ref message,
auto_run: true,
..
}) if message == "progress"
));
assert_eq!(input_text(&app), "");
}
#[test]
fn pause_and_cancel_clear_queued_input() {
let mut app = App::new("agent".to_string());
app.set_worker_status(WorkerStatus::Running);
for c in "queued".chars() {
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
)
.is_none()
);
}
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
assert_eq!(app.queued_input_count(), 1);
fn pending_queue_shortcuts_send_worker_operations() {
let mut app = App::new("test".into());
app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot {
revision: 2,
notification_count: 0,
head_id: Some("submission-1".into()),
submissions: vec![protocol::PendingSubmissionSummary {
submission_id: "submission-1".into(),
accepted_at_ms: 1,
segment_count: 1,
byte_len: 6,
}],
},
});
let pause = handle_key(
let continue_next = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::ALT),
);
assert!(matches!(pause, Some(Method::Pause)));
assert_eq!(app.queued_input_count(), 0);
for c in "queued again".chars() {
assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
)
.is_none()
);
}
assert!(handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)).is_none());
assert!(matches!(
continue_next,
Some(Method::ContinuePending {
expected_revision: 2,
ref expected_head_id,
}) if expected_head_id == "submission-1"
));
assert_eq!(app.queued_input_count(), 1);
let cancel = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::ALT),
);
assert!(matches!(cancel, Some(Method::Cancel)));
assert_eq!(app.queued_input_count(), 0);
assert!(matches!(
cancel,
Some(Method::CancelPendingSubmission {
expected_revision: 2,
ref submission_id,
}) if submission_id == "submission-1"
));
let clear = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::ALT),
);
assert!(matches!(
clear,
Some(Method::ClearPendingSubmissions {
expected_revision: 2
})
));
assert_eq!(app.queued_input_count(), 1);
}
#[test]
fn pause_and_cancel_preserve_authoritative_pending_queue() {
let mut app = App::new("test".into());
app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot {
revision: 2,
notification_count: 0,
head_id: Some("submission-1".into()),
submissions: vec![protocol::PendingSubmissionSummary {
submission_id: "submission-1".into(),
accepted_at_ms: 1,
segment_count: 1,
byte_len: 6,
}],
},
});
app.set_worker_status(WorkerStatus::Running);
assert!(matches!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
),
Some(Method::Pause { .. })
));
assert_eq!(app.queued_input_count(), 1);
app.set_worker_status(WorkerStatus::Running);
assert!(matches!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
),
Some(Method::Cancel { .. })
));
assert_eq!(app.queued_input_count(), 1);
}
#[test]
@@ -2080,7 +2117,7 @@ mod tests {
&mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
);
assert!(matches!(cancel, Some(Method::Cancel)));
assert!(matches!(cancel, Some(Method::Cancel { .. })));
}
#[test]
@@ -2102,7 +2139,7 @@ mod tests {
assert!(matches!(
handle_key(&mut app, ctrl_x()),
Some(Method::Shutdown)
Some(Method::Shutdown { .. })
));
assert!(app.shutdown_confirm.is_none());
}
@@ -2432,7 +2469,7 @@ mod tests {
}
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(method, Some(protocol::Method::Compact)));
assert!(matches!(method, Some(protocol::Method::Compact { .. })));
assert!(!app.is_command_mode());
assert_eq!(input_text(&app), "");
assert_eq!(app.queued_input_count(), 0);
@@ -2535,13 +2572,19 @@ mod tests {
let mut app = App::new("agent".to_string());
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
session: protocol::SessionSnapshot { entries: vec![] },
status: WorkerStatus::Idle,
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
});
app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot { entries: vec![] },
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
input: vec![Segment::Text {
content: "retry this".into(),
}],
@@ -2562,15 +2605,21 @@ mod tests {
let mut app = App::new("agent".to_string());
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
session: protocol::SessionSnapshot { entries: vec![] },
status: WorkerStatus::Idle,
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
});
type_keys(&mut app, "draft");
app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot { entries: vec![] },
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: vec![],
},
input: vec![Segment::Text {
content: "retry this".into(),
}],
@@ -2697,8 +2746,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::Status {
status: WorkerStatus::Running,
event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(),
}),
});
enter_command_mode(&mut app);
@@ -2813,8 +2862,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::Status {
status: WorkerStatus::Running,
event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(),
}),
});
@@ -2839,8 +2888,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::Status {
status: WorkerStatus::Running,
event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(),
}),
});
handle_key(&mut app, key(KeyCode::Tab));
@@ -2856,7 +2905,7 @@ mod tests {
);
assert!(first.is_none());
assert!(matches!(second, Some(Method::Shutdown)));
assert!(matches!(second, Some(Method::Shutdown { .. })));
assert_eq!(app.worker_status, WorkerStatus::Idle);
}
@@ -2878,8 +2927,8 @@ mod tests {
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 1,
event: Box::new(Event::Status {
status: WorkerStatus::Running,
event: Box::new(Event::WorkerState {
snapshot: WorkerStatus::Running.into(),
}),
});
@@ -2918,12 +2967,12 @@ mod tests {
type_keys(&mut app, "first");
assert!(matches!(
handle_key(&mut app, key(KeyCode::Enter)),
Some(Method::Run { .. })
Some(Method::Submit { .. })
));
type_keys(&mut app, "second");
assert!(matches!(
handle_key(&mut app, key(KeyCode::Enter)),
Some(Method::Run { .. })
Some(Method::Submit { .. })
));
assert_eq!(input_text(&app), "");
@@ -2954,7 +3003,7 @@ mod tests {
type_keys(&mut app, "sent");
assert!(matches!(
handle_key(&mut app, key(KeyCode::Enter)),
Some(Method::Run { .. })
Some(Method::Submit { .. })
));
type_keys(&mut app, "draft\nbody");
app.move_cursor_start();
+7
View File
@@ -1,5 +1,6 @@
mod app;
mod backend_dashboard;
mod backend_spawn;
mod backend_worker_picker;
mod backend_workspace_picker;
mod block;
@@ -51,6 +52,8 @@ pub enum LaunchMode {
/// Restore one client-owned standalone Worker. The current cwd is the default scope;
/// `include_all` opts into all standalone Workers under the same client data root.
StandaloneResume { include_all: bool },
/// Create one Backend Worker and attach to it.
BackendSpawn,
/// List Backend Workers and attach to the selected Worker.
Workers {
runtime_id: Option<String>,
@@ -161,6 +164,10 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
}
}
LaunchMode::BackendSpawn => match target.launch_backend_worker() {
Ok(launch) => backend_spawn::run(launch.target).await,
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},
LaunchMode::Workers {
runtime_id,
include_stopped,
+22 -14
View File
@@ -1880,7 +1880,7 @@ fn actionbar_left_item(app: &App, now: Instant) -> Option<(String, Style)> {
}
if app.queued_input_count() > 0 {
return Some((
"Alt-q edit queued Alt-c clear queued".to_string(),
"Alt-n notify Alt-q continue Alt-d cancel queued Alt-c clear queued".to_string(),
Style::default().fg(Color::DarkGray),
));
}
@@ -2136,9 +2136,25 @@ mod tests {
use super::*;
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use crate::block::{ToolCallBlock, ToolCallState};
use protocol::WorkerStatus;
use protocol::Event;
use std::time::{Duration, Instant};
fn set_pending_submission(app: &mut App, id: &str) {
app.handle_worker_event(Event::PendingSubmissionsChanged {
pending: protocol::PendingSubmissionsSnapshot {
revision: 1,
notification_count: 0,
head_id: Some(id.into()),
submissions: vec![protocol::PendingSubmissionSummary {
submission_id: id.into(),
accepted_at_ms: 1,
segment_count: 1,
byte_len: 1,
}],
},
});
}
#[test]
fn run_status_line_matches_console_metrics_and_spinner_frame() {
let now = Instant::now();
@@ -2251,15 +2267,11 @@ mod tests {
#[test]
fn queue_status_text_includes_count_and_preview() {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
for c in "queued preview".chars() {
app.insert_char(c);
}
assert!(app.submit_input().is_none());
set_pending_submission(&mut app, "submission-1");
assert_eq!(
queue_status_text(&app),
Some("queued: 1 — queued preview".to_string())
Some("queued: 1 — submission-1".to_string())
);
}
@@ -2289,14 +2301,10 @@ mod tests {
Some("Worker keeps running. Press Ctrl-C again to exit TUI.".into())
);
app.set_worker_status(WorkerStatus::Running);
for c in "queued turn".chars() {
app.insert_char(c);
}
assert!(app.submit_input().is_none());
set_pending_submission(&mut app, "submission-1");
assert_eq!(
actionbar_left_item(&app, now).map(|(text, _)| text),
Some("Alt-q edit queued Alt-c clear queued".into())
Some("Alt-n notify Alt-q continue Alt-d cancel queued Alt-c clear queued".into())
);
app.enter_command_mode();
File diff suppressed because it is too large Load Diff
+3 -41
View File
@@ -68,12 +68,10 @@ pub enum WorkdirSessionOperation {
CommandCancel(CommandHandle),
}
/// Wire envelope for an operation and its optional provider-enforced child scope.
/// Wire envelope for one provider operation.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkdirSessionOperationRequest {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delegations: Vec<crate::WorkdirDelegationRequest>,
pub operation: WorkdirSessionOperation,
}
@@ -289,7 +287,7 @@ mod client {
use reqwest::{Client, StatusCode, Url};
use super::*;
use crate::{Workdir, WorkdirSession, WorkdirSessionHandle};
use crate::{Workdir, WorkdirSession};
/// Provides a fresh bearer token for each Runtime request. Backend
/// implementations can mint short-lived capability tokens without making a
@@ -324,7 +322,6 @@ mod client {
workdir: Workdir,
session_id: WorkdirSessionId,
capabilities: WorkdirSessionCapabilities,
delegations: Vec<crate::WorkdirDelegationRequest>,
closed: AtomicBool,
}
@@ -377,7 +374,6 @@ mod client {
workdir: Workdir::new(opened.workdir_id.as_str()),
session_id: opened.session_id,
capabilities: opened.capabilities,
delegations: Vec::new(),
closed: AtomicBool::new(false),
})
}
@@ -404,10 +400,7 @@ mod client {
"operations",
],
)?;
let operation = WorkdirSessionOperationRequest {
delegations: self.delegations.clone(),
operation,
};
let operation = WorkdirSessionOperationRequest { operation };
let response = self
.client
.post(url)
@@ -436,37 +429,6 @@ mod client {
self.capabilities
}
fn transports_delegation_context(&self) -> bool {
true
}
async fn capture_delegation_source(
&self,
request: &crate::WorkdirDelegationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> {
if self.closed.load(Ordering::Acquire) {
return Err(WorkdirError::SessionClosed);
}
let mut delegations = self.delegations.clone();
delegations.push(request.clone());
let candidate = Arc::new(Self {
client: self.client.clone(),
base_url: self.base_url.clone(),
authorization: self.authorization.clone(),
workdir: self.workdir.clone(),
session_id: self.session_id.clone(),
capabilities: self.capabilities,
delegations,
closed: AtomicBool::new(false),
});
candidate
.stat(StatRequest {
path: fs_operation::FsPath::new("").expect("empty Workdir path is valid"),
})
.await?;
Ok(candidate)
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
WorkdirSessionOperationResult::Stat(result) => Ok(result),
+5 -39
View File
@@ -5,10 +5,10 @@
//! bound to one Worker. Tools consume sessions; they do not own Workdir
//! materialization or cleanup.
mod delegation;
pub mod http;
mod local;
mod operation;
mod scope;
pub mod workspace;
use std::path::{Path, PathBuf};
@@ -18,11 +18,6 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
pub use delegation::{
AppliedWorkdirDelegation, ReadOnlyWorkdirSession, WorkdirDelegation,
WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule,
apply_delegation_chain, delegation_capable_session,
};
pub use fs_operation::{
ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest,
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
@@ -32,6 +27,10 @@ pub use local::{
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
};
pub use operation::*;
pub use scope::{
ReadOnlyWorkdirSession, WorkdirScopeLease, WorkdirToolBroker, WorkdirToolScope,
WorkdirToolScopePermission, WorkdirToolScopeRule,
};
/// Persistent, opaque identity of one materialized Workdir.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -148,39 +147,6 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
fn workdir(&self) -> &Workdir;
fn capabilities(&self) -> WorkdirSessionCapabilities;
fn is_delegation_capable(&self) -> bool {
false
}
/// Whether this session transports the delegation chain to another
/// provider boundary that will apply logical cwd/path resolution there.
fn transports_delegation_context(&self) -> bool {
false
}
/// Capture a provider-specific source for a delegated child session.
/// Remote providers use this boundary to pin attachment identity without
/// exposing transport handles or host paths.
async fn capture_delegation_source(
&self,
_request: &WorkdirDelegationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> {
Err(WorkdirError::Denied(
"workdir provider does not support delegated sessions".into(),
))
}
/// Attenuate this session into a revocable child lease. Only sessions
/// created with [`delegation_capable_session`] implement this operation.
async fn delegate(
&self,
_request: WorkdirDelegationRequest,
) -> Result<WorkdirDelegation, WorkdirError> {
Err(WorkdirError::Denied(
"workdir session is not delegation-capable".into(),
))
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>;
+23 -69
View File
@@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope};
use manifest::{Scope, SharedScope};
use sha2::{Digest, Sha256};
use tokio::process::Command;
use tokio::sync::{Mutex, broadcast, watch};
@@ -28,10 +28,8 @@ use crate::{
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
ReadResult, StatRequest, StatResult, Workdir, WorkdirDelegationPermission,
WorkdirDelegationRequest, WorkdirError, WorkdirPath, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, WriteRequest,
WriteResult,
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath, WorkdirSession,
WorkdirSessionCapabilities, WorkdirSessionCapability, WriteRequest, WriteResult,
};
#[cfg(test)]
use crate::{EntryKind, WriteOutcome};
@@ -558,69 +556,6 @@ impl WorkdirSession for LocalWorkdirSession {
self.inner.capabilities
}
async fn capture_delegation_source(
&self,
request: &WorkdirDelegationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> {
let host_rules = request
.rules
.iter()
.map(|rule| ScopeRule {
target: self.inner.root.join(rule.target.as_str()),
permission: match rule.permission {
WorkdirDelegationPermission::Read => Permission::Read,
WorkdirDelegationPermission::Write => Permission::Write,
},
recursive: rule.recursive,
})
.collect::<Vec<_>>();
for (logical, host) in request.rules.iter().zip(&host_rules) {
if logical.permission == WorkdirDelegationPermission::Write {
let resolved = Scope::resolved_target(host)
.map_err(|error| WorkdirError::Denied(error.to_string()))?;
if resolved != host.target {
return Err(WorkdirError::Denied(format!(
"write delegation target `{}` traverses a symlink",
logical.target
)));
}
}
}
let parent_scope = self.inner.scope.snapshot();
for rule in &host_rules {
if !parent_scope
.allows_rule(rule)
.map_err(|error| WorkdirError::Denied(error.to_string()))?
{
return Err(WorkdirError::Denied(format!(
"delegated provider scope `{}` exceeds the parent session",
rule.target.display()
)));
}
}
let child_scope = Scope::from_config(&ScopeConfig {
allow: host_rules,
deny: Vec::new(),
})
.map_err(|error| WorkdirError::Denied(error.to_string()))?;
let child_cwd = self.inner.root.join(request.cwd.as_str());
if !child_scope.is_readable(&child_cwd)
|| !std::fs::metadata(&child_cwd).is_ok_and(|metadata| metadata.is_dir())
{
return Err(WorkdirError::Denied(format!(
"delegated cwd `{}` is not a readable Workdir directory",
request.cwd
)));
}
Ok(Arc::new(LocalWorkdirSession::materialized_bound(
self.inner.workdir.clone(),
self.inner.root.clone(),
self.inner.root.clone(),
SharedScope::new(child_scope),
self.inner.capabilities,
)))
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Read)?;
let logical = request.path.clone();
@@ -694,9 +629,20 @@ impl WorkdirSession for LocalWorkdirSession {
{
return Err(WorkdirError::OutOfScope(spill_dir.to_path_buf()));
}
let cwd = if let Some(logical_cwd) = request.cwd.as_ref() {
let cwd = self.resolve(logical_cwd);
let scope = self.inner.scope.snapshot();
if !scope.is_readable(&cwd)
|| !std::fs::metadata(&cwd).is_ok_and(|metadata| metadata.is_dir())
{
return Err(WorkdirError::OutOfScope(cwd));
}
cwd
} else {
self.inner.cwd.clone()
};
let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
let handle = CommandHandle(format!("command-{id}"));
let cwd = self.inner.cwd.clone();
let (completion_tx, completion) = watch::channel(false);
let command_id = handle.0.clone();
let telemetry = self.inner.command_telemetry.clone();
@@ -1516,6 +1462,7 @@ mod tests {
command: "sleep 30".to_owned(),
timeout_secs: 60,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: None,
},
@@ -2043,6 +1990,7 @@ mod tests {
command: "pwd && printf provider-command".into(),
timeout_secs: 5,
output_limit: 4096,
cwd: None,
spill_dir: None,
tool_call_id: None,
},
@@ -2141,6 +2089,7 @@ mod tests {
command: "printf hidden".into(),
timeout_secs: 5,
output_limit: 1,
cwd: None,
spill_dir: Some(spill.path().to_path_buf()),
tool_call_id: None,
},
@@ -2178,6 +2127,7 @@ mod tests {
command: "i=0; while [ $i -lt 200 ]; do printf 'line-%03d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'".into(),
timeout_secs: 5,
output_limit: 64,
cwd: None,
spill_dir: Some(spill.path().to_path_buf()),
tool_call_id: None,
},
@@ -2224,6 +2174,7 @@ mod tests {
command: "printf 'aéz'".into(),
timeout_secs: 5,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: None,
},
@@ -2449,6 +2400,7 @@ mod tests {
command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(),
timeout_secs: 5,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: Some("tool-7".into()),
},
@@ -2553,6 +2505,7 @@ mod tests {
command: "sleep 30".into(),
timeout_secs: 1,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: None,
},
@@ -2623,6 +2576,7 @@ mod tests {
command: "sleep 30".into(),
timeout_secs: 60,
output_limit: 1024,
cwd: None,
spill_dir: None,
tool_call_id: None,
},
+4
View File
@@ -11,6 +11,10 @@ pub struct CommandRequest {
pub command: String,
pub timeout_secs: u64,
pub output_limit: usize,
/// Workdir-relative command directory. Providers validate it against the
/// active session before process start.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<fs_operation::FsPath>,
/// Provider-local directory where complete output is retained when the
/// inline result exceeds `output_limit`.
pub spill_dir: Option<PathBuf>,
File diff suppressed because it is too large Load Diff
-10
View File
@@ -104,15 +104,5 @@ mod tests {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceWorkdirSessionOperationRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_session_fence: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub delegations: Vec<crate::WorkdirDelegationRequest>,
pub operation: crate::http::WorkdirSessionOperation,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceWorkdirSessionFence {
pub value: String,
}
+2
View File
@@ -40,6 +40,8 @@ ring.workspace = true
tar.workspace = true
thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
tracing.workspace = true
tracing-subscriber.workspace = true
toml.workspace = true
url.workspace = true
uuid = { workspace = true, features = ["v7"] }
+10 -14
View File
@@ -15,32 +15,22 @@ pub enum ProfileSelector {
Named(String),
}
/// Runtime fetch/caching metadata for a Backend-authored Decodal profile source archive.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileSourceArchiveHttpRef {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
pub archive: ProfileSourceArchiveRef,
}
/// Profile source material available to a Runtime during Worker creation.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProfileSourceArchiveSource {
/// Backend-internal embedded runtimes may receive already-built archive bytes.
Embedded { archive: ProfileSourceArchive },
/// Standalone runtimes fetch/cache the tar archive over HTTP.
Http {
location: ProfileSourceArchiveHttpRef,
},
/// Standalone runtimes resolve this immutable archive from the latest
/// Workspace Config bundle before creating the Worker.
WorkspaceConfig { archive: ProfileSourceArchiveRef },
}
impl ProfileSourceArchiveSource {
pub fn reference(&self) -> ProfileSourceArchiveRef {
match self {
Self::Embedded { archive } => archive.reference.clone(),
Self::Http { location } => location.archive.clone(),
Self::WorkspaceConfig { archive } => archive.clone(),
}
}
}
@@ -307,6 +297,8 @@ pub struct WorkerSummary {
pub worker_id: WorkerId,
pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
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>,
@@ -325,6 +317,8 @@ pub struct WorkerDetail {
pub worker_id: WorkerId,
pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
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>,
@@ -341,6 +335,8 @@ pub struct WorkerDetail {
pub struct WorkerLifecycleAck {
pub worker_ref: WorkerRef,
pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<protocol::WorkerStateSnapshot>,
}
#[cfg(test)]
@@ -9,6 +9,11 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const CONFIG_BUNDLE_DIGEST_ALGORITHM: &str = "sha256";
pub const WORKSPACE_CONFIG_ETAG_PREFIX: &str = "workspace-config:";
pub fn workspace_config_etag(digest: &str) -> String {
format!("\"{WORKSPACE_CONFIG_ETAG_PREFIX}{digest}\"")
}
/// Backend-synced Profile/config bundle stored by a Runtime.
///
+67 -45
View File
@@ -1,6 +1,7 @@
use crate::catalog::{
RepositoryRefObservation, RepositoryRefObservationRequest,
ConfigBundleRef, ProfileSelector, RepositoryRefObservation, RepositoryRefObservationRequest,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkspaceApiRef,
};
use crate::config_bundle::ConfigBundle;
use crate::error::RuntimeError;
@@ -15,18 +16,6 @@ use std::fmt;
use std::sync::Arc;
use workdir::WorkdirSessionHandle;
/// Current execution-side run state for a Worker.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkerExecutionRunState {
#[default]
Stopped,
Idle,
Busy,
Rejected,
Errored,
}
/// Execution operation that produced a result.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -41,14 +30,12 @@ pub enum WorkerExecutionOperation {
Cancel,
}
/// Evidence that a user input reached the durable Worker session boundary.
///
/// This is intentionally distinct from accepting a method on the Worker's
/// in-memory channel. For Flow submissions, the committed UserInput entry also
/// carries the initial Flow runtime-state extension.
/// Evidence that a Submit request reached the durable Worker session boundary.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerInputCommitAck {
pub struct WorkerSubmissionAck {
pub submission_request_id: String,
pub submission_id: String,
pub disposition: protocol::SubmissionDisposition,
}
/// Typed execution result class. Results are transient operation outcomes and
@@ -57,11 +44,12 @@ pub struct WorkerInputCommitAck {
pub struct WorkerExecutionResult {
pub operation: WorkerExecutionOperation,
pub outcome: WorkerExecutionOutcome,
pub run_state: WorkerExecutionRunState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<protocol::WorkerStateSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_commit: Option<WorkerInputCommitAck>,
pub submission: Option<WorkerSubmissionAck>,
}
/// Backend result class for a Worker execution operation.
@@ -76,31 +64,36 @@ pub enum WorkerExecutionOutcome {
}
impl WorkerExecutionResult {
pub fn accepted(
operation: WorkerExecutionOperation,
run_state: WorkerExecutionRunState,
) -> Self {
pub fn accepted(operation: WorkerExecutionOperation) -> Self {
Self {
operation,
outcome: WorkerExecutionOutcome::Accepted,
run_state,
worker_state: None,
message: None,
input_commit: None,
submission: None,
}
}
pub fn accepted_input_committed(
pub fn with_worker_state(mut self, worker_state: protocol::WorkerStateSnapshot) -> Self {
self.worker_state = Some(worker_state);
self
}
pub fn accepted_submission(
operation: WorkerExecutionOperation,
run_state: WorkerExecutionRunState,
submission_request_id: impl Into<String>,
submission_id: impl Into<String>,
disposition: protocol::SubmissionDisposition,
) -> Self {
Self {
operation,
outcome: WorkerExecutionOutcome::Accepted,
run_state,
worker_state: None,
message: None,
input_commit: Some(WorkerInputCommitAck {
submission: Some(WorkerSubmissionAck {
submission_request_id: submission_request_id.into(),
submission_id: submission_id.into(),
disposition,
}),
}
}
@@ -109,9 +102,9 @@ impl WorkerExecutionResult {
Self {
operation,
outcome: WorkerExecutionOutcome::Busy,
run_state: WorkerExecutionRunState::Busy,
worker_state: None,
message: Some(message.into()),
input_commit: None,
submission: None,
}
}
@@ -119,9 +112,9 @@ impl WorkerExecutionResult {
Self {
operation,
outcome: WorkerExecutionOutcome::Rejected,
run_state: WorkerExecutionRunState::Stopped,
worker_state: None,
message: Some(message.into()),
input_commit: None,
submission: None,
}
}
@@ -129,9 +122,9 @@ impl WorkerExecutionResult {
Self {
operation,
outcome: WorkerExecutionOutcome::Errored,
run_state: WorkerExecutionRunState::Errored,
worker_state: None,
message: Some(message.into()),
input_commit: None,
submission: None,
}
}
@@ -139,9 +132,9 @@ impl WorkerExecutionResult {
Self {
operation,
outcome: WorkerExecutionOutcome::Unsupported,
run_state: WorkerExecutionRunState::Stopped,
worker_state: None,
message: Some(message.into()),
input_commit: None,
submission: None,
}
}
@@ -273,12 +266,27 @@ pub struct WorkerExecutionRestoreRequest {
pub config_bundle: Option<ConfigBundle>,
}
/// Runtime-side request to refresh the latest Workspace Config before Worker creation.
#[derive(Clone, Debug)]
pub struct WorkspaceConfigFetchRequest {
pub workspace_api: WorkspaceApiRef,
pub profile: ProfileSelector,
pub expected: ConfigBundleRef,
pub cached: Option<ConfigBundleRef>,
}
/// Result of a conditional Workspace Config fetch.
#[derive(Clone, Debug)]
pub enum WorkspaceConfigFetchResult {
NotModified,
Modified(ConfigBundle),
}
/// Backend outcome for Worker spawn/restore operations.
#[derive(Clone, Debug)]
pub enum WorkerExecutionSpawnResult {
Connected {
handle: WorkerExecutionHandle,
run_state: WorkerExecutionRunState,
working_directory: Option<WorkingDirectoryStatus>,
},
Rejected(WorkerExecutionResult),
@@ -288,12 +296,10 @@ pub enum WorkerExecutionSpawnResult {
impl WorkerExecutionSpawnResult {
pub fn connected(
handle: WorkerExecutionHandle,
run_state: WorkerExecutionRunState,
working_directory: Option<WorkingDirectoryStatus>,
) -> Self {
Self::Connected {
handle,
run_state,
working_directory,
}
}
@@ -302,6 +308,13 @@ impl WorkerExecutionSpawnResult {
pub trait WorkerExecutionBackend: Send + Sync + 'static {
fn backend_id(&self) -> &str;
fn fetch_workspace_config(
&self,
_request: WorkspaceConfigFetchRequest,
) -> Result<WorkspaceConfigFetchResult, String> {
Err("execution backend does not support Workspace Config fetching".to_string())
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
fn restore_worker(
@@ -483,6 +496,13 @@ impl WorkerExecutionBackendRef {
})
}
pub(crate) fn fetch_workspace_config(
&self,
request: WorkspaceConfigFetchRequest,
) -> Result<WorkspaceConfigFetchResult, String> {
self.backend.fetch_workspace_config(request)
}
pub(crate) fn spawn_worker(
&self,
request: WorkerExecutionSpawnRequest,
@@ -618,14 +638,16 @@ mod tests {
use super::*;
#[test]
fn input_commit_ack_survives_json_round_trip() {
let result = WorkerExecutionResult::accepted_input_committed(
fn submission_ack_survives_json_round_trip() {
let result = WorkerExecutionResult::accepted_submission(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
"request-1",
"submission-1",
protocol::SubmissionDisposition::Started,
);
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"submission_request_id\":\"request-1\""));
assert!(json.contains("\"submission_id\":\"submission-1\""));
assert_eq!(
serde_json::from_str::<WorkerExecutionResult>(&json).unwrap(),
+356 -152
View File
@@ -33,7 +33,7 @@ use axum::extract::rejection::{JsonRejection, QueryRejection};
#[cfg(feature = "ws-server")]
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{DefaultBodyLimit, Extension, Path, Query, State};
use axum::http::{Method, Request, StatusCode, header};
use axum::http::{HeaderMap, Method, Request, StatusCode, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
@@ -66,6 +66,11 @@ use workdir::{
};
const DEFAULT_RUNTIME_HTTP_PORT: u16 = 38800;
pub const RUNTIME_HTTP_PROTOCOL_MIN_VERSION: u32 = 1;
pub const RUNTIME_HTTP_PROTOCOL_MAX_VERSION: u32 = 1;
pub const RUNTIME_HTTP_PROTOCOL_VERSION: u32 = RUNTIME_HTTP_PROTOCOL_MAX_VERSION;
pub const RUNTIME_PING_PERMISSION: &str = "runtime:ping";
pub const RUNTIME_WORKSPACE_SCOPE_HEADER: &str = "x-yoi-workspace-id";
fn default_runtime_http_bind_addr() -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], DEFAULT_RUNTIME_HTTP_PORT))
@@ -187,6 +192,7 @@ fn runtime_http_router_with_optional_auth(
};
let router = Router::new()
.route("/v1/ping", get(get_runtime_ping))
.route("/v1/runtime", get(get_runtime))
.route(
"/v1/config-bundles",
@@ -340,6 +346,14 @@ enum RuntimeHttpWorkerStatusFilter {
Stopped,
}
/// `GET /v1/ping` response.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeHttpPingResponse {
pub runtime_id: String,
pub protocol_version: u32,
}
/// `GET /v1/workers` response.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpWorkersResponse {
@@ -461,6 +475,48 @@ struct RuntimeWorkerEventsWsQuery {
type RestResult<T> = Result<Json<T>, RuntimeHttpRestError>;
async fn get_runtime_ping(
State(state): State<RuntimeHttpState>,
Extension(auth): Extension<RuntimeAuthContext>,
headers: HeaderMap,
) -> RestResult<RuntimeHttpPingResponse> {
let requested_workspace_id = headers
.get(RUNTIME_WORKSPACE_SCOPE_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"runtime_ping_workspace_scope_required",
"Runtime ping requires the target Workspace scope",
)
})?;
if requested_workspace_id != auth.workspace_id {
return Err(RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"runtime_ping_workspace_scope_mismatch",
"Runtime ping Workspace scope does not match the authenticated capability",
));
}
let runtime_id = state
.auth
.as_ref()
.map(|config| config.runtime_id.trim())
.filter(|runtime_id| !runtime_id.is_empty())
.ok_or_else(|| {
RuntimeHttpRestError::new(
StatusCode::SERVICE_UNAVAILABLE,
"runtime_ping_identity_unavailable",
"Runtime ping identity is not configured",
)
})?;
Ok(Json(RuntimeHttpPingResponse {
runtime_id: runtime_id.to_string(),
protocol_version: RUNTIME_HTTP_PROTOCOL_VERSION,
}))
}
async fn get_runtime(
State(state): State<RuntimeHttpState>,
) -> RestResult<RuntimeHttpSummaryResponse> {
@@ -718,8 +774,7 @@ async fn run_workdir_session_operation(
.ok_or_else(RuntimeHttpWorkdirError::not_found)?;
record.session.clone()
};
let applied = workdir::apply_delegation_chain(source, request.delegations).await?;
let session = applied.scoped_session.as_ref();
let session = source.as_ref();
let operation = request.operation;
let result = match operation {
@@ -1184,10 +1239,12 @@ async fn worker_protocol_ws(
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>,
Query(query): Query<RuntimeWorkerEventsWsQuery>,
headers: HeaderMap,
ws: WebSocketUpgrade,
) -> Result<Response, RuntimeHttpRestError> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let scope = auth_workspace_scope(&state, auth.as_ref())?;
let input_source = authenticated_protocol_input_source(&headers)?;
match scope.as_ref() {
Some(scope) => state
.runtime
@@ -1198,17 +1255,89 @@ async fn worker_protocol_ws(
.map_err(RuntimeHttpRestError::runtime)?;
Ok(ws
.on_upgrade(move |socket| {
worker_protocol_ws_session(state.runtime, scope, worker_ref, query, socket)
worker_protocol_ws_session(
state.runtime,
scope,
worker_ref,
query,
input_source,
socket,
)
})
.into_response())
}
#[cfg(feature = "ws-server")]
fn authenticated_protocol_input_source(
headers: &HeaderMap,
) -> Result<Option<protocol::AuthenticatedInputSource>, RuntimeHttpRestError> {
let Some(value) = headers.get(protocol::AUTHENTICATED_ACCOUNT_ID_HEADER) else {
return Ok(None);
};
let account_id = value.to_str().map_err(|_| {
RuntimeHttpRestError::new(
StatusCode::BAD_REQUEST,
"authenticated_input_source_invalid",
"authenticated Worker input source is invalid",
)
})?;
if account_id.trim().is_empty() || account_id.len() > 128 {
return Err(RuntimeHttpRestError::new(
StatusCode::BAD_REQUEST,
"authenticated_input_source_invalid",
"authenticated Worker input source is invalid",
));
}
Ok(Some(protocol::AuthenticatedInputSource::Account {
account_id: account_id.to_owned(),
}))
}
#[cfg(feature = "ws-server")]
fn authorize_runtime_protocol_method(
method: protocol::Method,
transport_source: Option<&protocol::AuthenticatedInputSource>,
) -> protocol::Method {
match method {
protocol::Method::SubmitTracked {
submission_request_id,
input,
..
} => protocol::Method::SubmitTracked {
source: transport_source.cloned().unwrap_or_else(|| {
protocol::AuthenticatedInputSource::Backend {
operation_id: submission_request_id.clone(),
}
}),
submission_request_id,
input,
},
protocol::Method::NotifyTracked {
notification_request_id,
message,
auto_run,
..
} => protocol::Method::NotifyTracked {
source: transport_source.cloned().unwrap_or_else(|| {
protocol::AuthenticatedInputSource::Backend {
operation_id: notification_request_id.clone(),
}
}),
notification_request_id,
message,
auto_run,
},
other => other,
}
}
#[cfg(feature = "ws-server")]
async fn worker_protocol_ws_session(
runtime: Runtime,
scope: Option<RuntimeWorkspaceScope>,
worker_ref: WorkerRef,
query: RuntimeWorkerEventsWsQuery,
input_source: Option<protocol::AuthenticatedInputSource>,
mut socket: WebSocket,
) {
let mut cursor = match query.cursor.as_deref() {
@@ -1291,6 +1420,8 @@ async fn worker_protocol_ws_session(
match inbound {
Some(Ok(WsMessage::Text(text))) => match decode_method(&text) {
Ok(method) => {
let method =
authorize_runtime_protocol_method(method, input_source.as_ref());
let result = match scope.as_ref() {
Some(scope) => {
runtime.send_protocol_method_scoped(scope, &worker_ref, method)
@@ -1767,6 +1898,9 @@ fn auth_workspace_scope(
}
fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static str> {
if path == "/v1/ping" && *method == Method::GET {
return Some(RUNTIME_PING_PERMISSION);
}
if path == "/v1/runtime" {
return None;
}
@@ -2071,8 +2205,8 @@ mod tests {
};
use crate::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
WorkerExecutionRestoreRequest, WorkerExecutionResult, WorkerExecutionRunState,
WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
WorkerExecutionRestoreRequest, WorkerExecutionResult, WorkerExecutionSpawnRequest,
WorkerExecutionSpawnResult,
};
use crate::management::RuntimeOptions;
use axum::body::to_bytes;
@@ -2080,10 +2214,143 @@ mod tests {
use manifest::{Scope, SharedScope};
use tower::ServiceExt;
use workdir::{
GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, StatRequest, Workdir,
WorkdirPath, WorkdirSessionCapabilities,
GrepOutputMode, GrepRequest, LocalWorkdirSession, StatRequest, Workdir, WorkdirPath,
WorkdirSessionCapabilities,
};
#[tokio::test]
async fn ping_requires_scoped_permission_and_returns_versioned_identity() {
let runtime = Runtime::new_memory();
let (auth, signer) = auth_config_and_signer();
let app = runtime_http_router_with_auth(runtime, None, auth);
let token =
token_for_workspace_with_permissions(&signer, "workspace-a", [RUNTIME_PING_PERMISSION]);
let request = Request::builder()
.method(Method::GET)
.uri("/v1/ping")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
serde_json::from_slice::<RuntimeHttpPingResponse>(&body).unwrap(),
RuntimeHttpPingResponse {
runtime_id: "runtime-test".to_string(),
protocol_version: RUNTIME_HTTP_PROTOCOL_VERSION,
}
);
let wrong_scope_token =
token_for_workspace_with_permissions(&signer, "workspace-a", [RUNTIME_PING_PERMISSION]);
let wrong_scope_request = Request::builder()
.method(Method::GET)
.uri("/v1/ping")
.header(header::AUTHORIZATION, format!("Bearer {wrong_scope_token}"))
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-b")
.body(Body::empty())
.unwrap();
assert_eq!(
app.oneshot(wrong_scope_request).await.unwrap().status(),
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn ping_rejects_token_without_ping_permission() {
let runtime = Runtime::new_memory();
let (auth, signer) = auth_config_and_signer();
let app = runtime_http_router_with_auth(runtime, None, auth);
let missing_credential = Request::builder()
.method(Method::GET)
.uri("/v1/ping")
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
.body(Body::empty())
.unwrap();
assert_eq!(
app.clone()
.oneshot(missing_credential)
.await
.unwrap()
.status(),
StatusCode::UNAUTHORIZED
);
let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:read"]);
let request = Request::builder()
.method(Method::GET)
.uri("/v1/ping")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
.body(Body::empty())
.unwrap();
assert_eq!(
app.oneshot(request).await.unwrap().status(),
StatusCode::FORBIDDEN
);
}
#[test]
fn runtime_protocol_replaces_serialized_tracked_source() {
let wire = serde_json::to_string(&protocol::Method::SubmitTracked {
submission_request_id: "request-1".into(),
input: vec![protocol::Segment::text("hello")],
source: protocol::AuthenticatedInputSource::Account {
account_id: "forged".into(),
},
})
.unwrap();
let decoded: protocol::Method = serde_json::from_str(&wire).unwrap();
assert!(matches!(
decoded,
protocol::Method::SubmitTracked {
source: protocol::AuthenticatedInputSource::UntrustedWire,
..
}
));
assert!(matches!(
authorize_runtime_protocol_method(decoded, None),
protocol::Method::SubmitTracked {
source: protocol::AuthenticatedInputSource::Backend { operation_id },
..
} if operation_id == "request-1"
));
}
#[test]
fn runtime_protocol_uses_transport_authenticated_account_source() {
let mut headers = HeaderMap::new();
headers.insert(
protocol::AUTHENTICATED_ACCOUNT_ID_HEADER,
"account-1".parse().unwrap(),
);
let source = authenticated_protocol_input_source(&headers)
.unwrap()
.expect("account source header must resolve");
let wire = serde_json::to_string(&protocol::Method::NotifyTracked {
notification_request_id: "notification-1".into(),
message: "hello".into(),
auto_run: true,
source: protocol::AuthenticatedInputSource::Account {
account_id: "forged".into(),
},
})
.unwrap();
let decoded: protocol::Method = serde_json::from_str(&wire).unwrap();
assert!(matches!(
authorize_runtime_protocol_method(decoded, Some(&source)),
protocol::Method::NotifyTracked {
source: protocol::AuthenticatedInputSource::Account { account_id },
..
} if account_id == "account-1"
));
}
#[test]
fn attachment_routes_require_worker_input_permission() {
assert_eq!(
@@ -2427,22 +2694,22 @@ mod tests {
create_fingerprint: "test-create".to_string(),
profile,
display_name: None,
profile_source: crate::catalog::ProfileSourceArchiveSource::Http {
location: crate::catalog::ProfileSourceArchiveHttpRef {
url: "http://127.0.0.1/profile-source.tar".to_string(),
etag: None,
archive: crate::profile_archive::ProfileSourceArchiveRef {
profile_source: crate::catalog::ProfileSourceArchiveSource::Embedded {
archive: crate::profile_archive::ProfileSourceArchive::build(
crate::profile_archive::ProfileSourceArchiveInput {
id: "test-profile-source".to_string(),
digest: "test-digest".to_string(),
size_bytes: 0,
source_graph: crate::profile_archive::ProfileSourceGraphSummary {
source_count: 0,
total_source_bytes: 0,
entrypoints: std::collections::BTreeMap::new(),
import_count: 0,
},
entrypoints: std::collections::BTreeMap::from([(
"builtin:coder".to_string(),
"profiles/coder.dcdl".to_string(),
)]),
imports: std::collections::BTreeMap::new(),
sources: std::collections::BTreeMap::from([(
"profiles/coder.dcdl".to_string(),
"{}".to_string(),
)]),
},
},
)
.unwrap(),
},
config_bundle: Some(ConfigBundleRef {
id: bundle.metadata.id,
@@ -2502,16 +2769,6 @@ mod tests {
async fn workdir_session_operations_enforce_owner_and_close_terminally() {
let temp = tempfile::tempdir().expect("tempdir");
std::fs::write(temp.path().join("hello.txt"), "hello").expect("write fixture");
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
std::fs::create_dir(temp.path().join("granted")).expect("granted directory");
std::fs::write(temp.path().join("granted/visible"), "visible")
.expect("visible fixture");
std::fs::create_dir(temp.path().join("secret")).expect("secret directory");
std::fs::write(temp.path().join("secret/key"), "hidden").expect("secret fixture");
symlink("../secret/key", temp.path().join("granted/link")).expect("symlink fixture");
}
let scope = SharedScope::new(Scope::writable(temp.path()).expect("scope"));
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
Workdir::new("wd-1"),
@@ -2545,7 +2802,6 @@ mod tests {
expires_at: u64::MAX,
};
let operation = WorkdirSessionOperationRequest {
delegations: Vec::new(),
operation: WorkdirSessionOperation::Stat(StatRequest {
path: WorkdirPath::new("hello.txt").expect("logical path"),
}),
@@ -2562,7 +2818,6 @@ mod tests {
assert!(matches!(result, WorkdirSessionOperationResult::Stat(_)));
let grep = WorkdirSessionOperationRequest {
delegations: Vec::new(),
operation: WorkdirSessionOperation::Grep(GrepRequest {
pattern: "hello".into(),
path: WorkdirPath::new("hello.txt").unwrap(),
@@ -2585,78 +2840,7 @@ mod tests {
)
.await
.expect("grep direct file through provider operation");
match result {
WorkdirSessionOperationResult::Grep(result) => {
assert_eq!(result.match_count, 1);
assert_eq!(result.matched_files, 1);
assert!(result.output.starts_with("hello.txt\n"));
assert!(result.output.contains("> 1 │ hello"));
}
other => panic!("unexpected workdir grep result: {other:?}"),
}
#[cfg(unix)]
{
let delegated_visible = WorkdirSessionOperationRequest {
delegations: vec![workdir::WorkdirDelegationRequest {
rules: vec![workdir::WorkdirDelegationRule {
target: WorkdirPath::new("granted").unwrap(),
permission: workdir::WorkdirDelegationPermission::Read,
recursive: true,
}],
cwd: WorkdirPath::new("granted").unwrap(),
}],
operation: WorkdirSessionOperation::Read(ReadRequest {
path: WorkdirPath::new("visible").unwrap(),
offset: 0,
limit: 20,
max_bytes: 1024,
}),
};
let visible = run_workdir_session_operation(
State(state.clone()),
Path("session-1".to_string()),
Some(Extension(auth.clone())),
Ok(Json(delegated_visible)),
)
.await
.expect("non-root delegated cwd should resolve once")
.0;
assert!(matches!(
visible,
WorkdirSessionOperationResult::Read(result) if result.bytes == b"visible"
));
let delegated_read = WorkdirSessionOperationRequest {
delegations: vec![workdir::WorkdirDelegationRequest {
rules: vec![workdir::WorkdirDelegationRule {
target: WorkdirPath::new("granted").unwrap(),
permission: workdir::WorkdirDelegationPermission::Read,
recursive: true,
}],
cwd: WorkdirPath::new("granted").unwrap(),
}],
operation: WorkdirSessionOperation::Read(ReadRequest {
path: WorkdirPath::new("link").unwrap(),
offset: 0,
limit: 20,
max_bytes: 1024,
}),
};
let error = run_workdir_session_operation(
State(state.clone()),
Path("session-1".to_string()),
Some(Extension(auth.clone())),
Ok(Json(delegated_read)),
)
.await
.expect_err("provider must reject delegated symlink escape");
assert_ne!(error.status, StatusCode::OK);
assert_eq!(
std::fs::read_to_string(temp.path().join("secret/key")).unwrap(),
"hidden"
);
}
assert!(matches!(result, WorkdirSessionOperationResult::Grep(_)));
let wrong_owner = RuntimeAuthContext {
workspace_id: "workspace-b".to_string(),
@@ -2711,7 +2895,6 @@ mod tests {
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
working_directory: request
.working_directory
.as_ref()
@@ -2725,7 +2908,6 @@ mod tests {
) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
working_directory: request.previous_working_directory,
}
}
@@ -2735,25 +2917,20 @@ mod tests {
_handle: &WorkerExecutionHandle,
input: WorkerInput,
) -> WorkerExecutionResult {
if let Some(submission_id) = input.submission_id {
WorkerExecutionResult::accepted_input_committed(
if let Some(submission_id) = input.submission_request_id {
WorkerExecutionResult::accepted_submission(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
submission_id.clone(),
submission_id,
protocol::SubmissionDisposition::Started,
)
} else {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
)
WorkerExecutionResult::accepted(WorkerExecutionOperation::Input)
}
}
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Stop,
WorkerExecutionRunState::Stopped,
)
WorkerExecutionResult::accepted(WorkerExecutionOperation::Stop)
}
}
@@ -3025,8 +3202,7 @@ mod ws_tests {
};
use crate::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
WorkerExecutionResult, WorkerExecutionRunState, WorkerExecutionSpawnRequest,
WorkerExecutionSpawnResult,
WorkerExecutionResult, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
};
use crate::management::RuntimeOptions;
use futures::{SinkExt, StreamExt};
@@ -3046,7 +3222,6 @@ mod ws_tests {
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
working_directory: request
.working_directory
.as_ref()
@@ -3059,29 +3234,49 @@ mod ws_tests {
_handle: &WorkerExecutionHandle,
input: WorkerInput,
) -> WorkerExecutionResult {
if let Some(submission_id) = input.submission_id {
WorkerExecutionResult::accepted_input_committed(
if let Some(submission_id) = input.submission_request_id {
WorkerExecutionResult::accepted_submission(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
submission_id.clone(),
submission_id,
protocol::SubmissionDisposition::Started,
)
} else {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
)
WorkerExecutionResult::accepted(WorkerExecutionOperation::Input)
}
}
fn worker_snapshot(&self, handle: &WorkerExecutionHandle) -> Option<protocol::Event> {
Some(protocol::Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: protocol::Greeting {
worker_name: handle.worker_ref().worker_id.to_string(),
cwd: String::new(),
provider: "ws-test".to_string(),
model: "ws-test".to_string(),
scope_summary: "WebSocket test execution snapshot".to_string(),
tools: Vec::new(),
context_window: 0,
context_tokens: 0,
},
state: protocol::WorkerStateSnapshot::initial(1),
in_flight: protocol::InFlightSnapshot {
blocks: Vec::new(),
commands: Vec::new(),
},
internal_workers: Vec::new(),
})
}
fn dispatch_method(
&self,
_handle: &WorkerExecutionHandle,
_method: protocol::Method,
) -> WorkerExecutionResult {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::ProtocolMethod,
WorkerExecutionRunState::Idle,
)
WorkerExecutionResult::accepted(WorkerExecutionOperation::ProtocolMethod)
}
}
@@ -3117,22 +3312,22 @@ mod ws_tests {
create_fingerprint: "test-create".to_string(),
profile: ProfileSelector::Builtin("builtin:companion".to_string()),
display_name: None,
profile_source: crate::catalog::ProfileSourceArchiveSource::Http {
location: crate::catalog::ProfileSourceArchiveHttpRef {
url: "http://127.0.0.1/profile-source.tar".to_string(),
etag: None,
archive: crate::profile_archive::ProfileSourceArchiveRef {
profile_source: crate::catalog::ProfileSourceArchiveSource::Embedded {
archive: crate::profile_archive::ProfileSourceArchive::build(
crate::profile_archive::ProfileSourceArchiveInput {
id: "test-profile-source".to_string(),
digest: "test-digest".to_string(),
size_bytes: 0,
source_graph: crate::profile_archive::ProfileSourceGraphSummary {
source_count: 0,
total_source_bytes: 0,
entrypoints: std::collections::BTreeMap::new(),
import_count: 0,
},
entrypoints: std::collections::BTreeMap::from([(
"builtin:coder".to_string(),
"profiles/coder.dcdl".to_string(),
)]),
imports: std::collections::BTreeMap::new(),
sources: std::collections::BTreeMap::from([(
"profiles/coder.dcdl".to_string(),
"{}".to_string(),
)]),
},
},
)
.unwrap(),
},
config_bundle: Some(ConfigBundleRef {
id: bundle.metadata.id,
@@ -3292,16 +3487,16 @@ mod ws_tests {
runtime
.observe_worker_event(
&other.worker_ref,
protocol::Event::Status {
status: protocol::WorkerStatus::Running,
protocol::Event::WorkerState {
snapshot: protocol::WorkerStatus::Running.into(),
},
)
.unwrap();
runtime
.observe_worker_event(
&worker_ref,
protocol::Event::Status {
status: protocol::WorkerStatus::Running,
protocol::Event::WorkerState {
snapshot: protocol::WorkerStatus::Running.into(),
},
)
.unwrap();
@@ -3316,7 +3511,16 @@ mod ws_tests {
..
}) if delivered_subscription_id == subscription_id
&& worker.worker_id.as_str() == worker_ref.worker_id.to_string()
&& worker.state == protocol::subscription::SubscriptionWorkerState::Running
&& worker.state == protocol::subscription::SubscriptionWorkerState::Idle
&& matches!(
worker.worker_state,
Some(protocol::WorkerStateSnapshot {
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running
)),
..
})
)
));
let unsubscribe_request_id =
+24 -6
View File
@@ -25,10 +25,10 @@ impl WorkerInputKind {
pub struct WorkerInput {
pub kind: WorkerInputKind,
pub content: String,
/// Runtime-generated correlation id. This is never accepted from public
/// JSON input and is consumed only by the execution backend.
#[serde(skip)]
pub submission_id: Option<String>,
/// Authenticated client-generated idempotency key. Runtime generates one
/// only for trusted internal callers that omit it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub submission_request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub segments: Option<Vec<Segment>>,
}
@@ -38,7 +38,7 @@ impl WorkerInput {
Self {
kind: WorkerInputKind::User,
content: content.into(),
submission_id: None,
submission_request_id: None,
segments: None,
}
}
@@ -47,7 +47,7 @@ impl WorkerInput {
Self {
kind: WorkerInputKind::Notify,
content: content.into(),
submission_id: None,
submission_request_id: None,
segments: None,
}
}
@@ -57,6 +57,21 @@ impl WorkerInput {
mod tests {
use super::WorkerInput;
#[test]
fn submission_request_id_round_trips_for_authenticated_client_retry() {
let input: WorkerInput = serde_json::from_value(serde_json::json!({
"kind": "user",
"content": "message",
"submission_request_id": "request-1"
}))
.unwrap();
assert_eq!(input.submission_request_id.as_deref(), Some("request-1"));
assert_eq!(
serde_json::to_value(input).unwrap()["submission_request_id"],
"request-1"
);
}
#[test]
fn notify_is_an_operation_and_legacy_system_kind_is_rejected() {
assert_eq!(
@@ -78,4 +93,7 @@ mod tests {
pub struct WorkerInteractionAck {
pub worker_ref: WorkerRef,
pub status: WorkerStatus,
/// Present for User Submit and absent for non-Submit interactions.
#[serde(skip_serializing_if = "Option::is_none")]
pub submission: Option<crate::execution::WorkerSubmissionAck>,
}
+13
View File
@@ -53,6 +53,18 @@ fn main() -> ExitCode {
}
}
fn init_serve_tracing() {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stdout)
.with_ansi(false)
.json()
.flatten_event(true)
.try_init();
}
fn run() -> Result<(), ProcessError> {
let args = env::args().skip(1).collect::<Vec<_>>();
if matches!(args.first().map(String::as_str), Some("migrate")) {
@@ -68,6 +80,7 @@ fn run() -> Result<(), ProcessError> {
println!("{}", usage());
return Ok(());
};
init_serve_tracing();
config.http.auth = load_runtime_http_auth(&config)?;
let runtime = tokio::runtime::Builder::new_current_thread()
-19
View File
@@ -6,8 +6,6 @@ use crate::identity::WorkerId;
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str =
"application/vnd.yoi.profile-source-archive+tar";
@@ -266,23 +264,6 @@ impl BackendResourceClient for HttpBackendResourceClient {
}
}
#[derive(Default, Debug)]
pub struct ProfileSourceArchiveCache {
archives: Mutex<HashMap<String, ProfileSourceArchive>>,
}
impl ProfileSourceArchiveCache {
pub fn get(&self, digest: &str) -> Option<ProfileSourceArchive> {
self.archives.lock().ok()?.get(digest).cloned()
}
pub fn insert(&self, archive: ProfileSourceArchive) {
if let Ok(mut archives) = self.archives.lock() {
archives.insert(archive.reference.digest.clone(), archive);
}
}
}
pub fn build_profile_source_archive_fetch_request(
handle: BackendResourceHandle,
runtime_id: &str,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@ protocol = { workspace = true, features = ["json-schema"] }
client = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
reqwest = { version = "0.13", default-features = false, features = ["blocking", "native-tls"] }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
+4 -1
View File
@@ -101,7 +101,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Send a run method
handle
.send(Method::run_text("What is the capital of France?"))
.send(Method::submit_text(
protocol::new_submission_request_id(),
"What is the capital of France?",
))
.await?;
// Wait for completion
File diff suppressed because it is too large Load Diff
+36 -11
View File
@@ -779,10 +779,10 @@ async fn probe_socket(socket_path: &Path) -> LiveInfo {
loop {
match tokio::time::timeout(PROBE_TIMEOUT, reader.next::<Event>()).await {
Ok(Ok(Some(Event::Snapshot {
status: snapshot_status,
state: snapshot_state,
..
}))) => {
status = Some(snapshot_status);
status = Some(snapshot_state.catalog_status());
break;
}
Ok(Ok(Some(Event::Alert(_)))) => continue,
@@ -1012,7 +1012,19 @@ async fn send_peer_notify(socket_path: &Path, message: String) -> io::Result<()>
}
async fn send_notify(socket_path: &Path, message: String, auto_run: bool) -> io::Result<()> {
connect_and_send(socket_path, &Method::Notify { message, auto_run }).await
let notification_request_id = protocol::new_submission_request_id();
connect_and_send(
socket_path,
&Method::NotifyTracked {
notification_request_id: notification_request_id.clone(),
message,
auto_run,
source: protocol::AuthenticatedInputSource::Backend {
operation_id: notification_request_id,
},
},
)
.await
}
fn json_content<T: Serialize>(value: &T) -> Result<String, ToolError> {
@@ -1482,6 +1494,7 @@ mod tests {
writer
.write(&Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: protocol::Greeting {
@@ -1494,7 +1507,7 @@ mod tests {
context_window: 0,
context_tokens: 0,
},
status: WorkerStatus::Idle,
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
})
@@ -1517,6 +1530,7 @@ mod tests {
writer
.write(&Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: protocol::Greeting {
@@ -1529,14 +1543,17 @@ mod tests {
context_window: 0,
context_tokens: 0,
},
status: WorkerStatus::Idle,
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
})
.await
.unwrap();
let method = reader.next::<Method>().await.unwrap().unwrap();
if let Method::Notify { message, auto_run } = method {
if let Method::NotifyTracked {
message, auto_run, ..
} = method
{
assert!(auto_run);
tx.send(message).await.unwrap();
} else {
@@ -1608,6 +1625,7 @@ mod tests {
writer
.write(&Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: protocol::Greeting {
@@ -1620,7 +1638,7 @@ mod tests {
context_window: 0,
context_tokens: 0,
},
status: WorkerStatus::Idle,
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
})
@@ -1634,6 +1652,7 @@ mod tests {
writer
.write(&Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: protocol::Greeting {
@@ -1646,14 +1665,17 @@ mod tests {
context_window: 0,
context_tokens: 0,
},
status: WorkerStatus::Idle,
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
})
.await
.unwrap();
let method = reader.next::<Method>().await.unwrap().unwrap();
if let Method::Notify { message, auto_run } = method {
if let Method::NotifyTracked {
message, auto_run, ..
} = method
{
assert!(!auto_run);
tx.send(message).await.unwrap();
} else {
@@ -1738,6 +1760,7 @@ mod tests {
writer
.write(&Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: Vec::new(),
},
greeting: protocol::Greeting {
@@ -1750,7 +1773,7 @@ mod tests {
context_window: 0,
context_tokens: 0,
},
status: WorkerStatus::Paused,
state: WorkerStatus::Paused.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
})
@@ -1790,6 +1813,8 @@ mod tests {
let _ = writer
.write(&Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(
),
entries: Vec::new(),
},
greeting: protocol::Greeting {
@@ -1802,7 +1827,7 @@ mod tests {
context_window: 0,
context_tokens: 0,
},
status: WorkerStatus::Idle,
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
})
@@ -5,6 +5,8 @@
//! endpoints, credentials, materializer handles, and operation sessions stay
//! behind [`WorkspaceClient`].
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -12,7 +14,7 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::json;
use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult};
use workdir::workspace::{WorkspaceWorkdirSessionFence, WorkspaceWorkdirSessionOperationRequest};
use workdir::workspace::WorkspaceWorkdirSessionOperationRequest;
use workdir::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
@@ -52,16 +54,48 @@ const LIST_DESCRIPTION: &str = "List persistent Workdirs in the current Workspac
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.";
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>;
const DELETE_DESCRIPTION: &str = "Request removal of one persistent Workdir by id through durable Backend Workspace authority. The input includes only the Workdir id and a bounded reason. The result reports removed, retained, or attention_required without exposing operation-table or provider internals.";
#[derive(Clone, Debug)]
#[derive(Clone)]
pub struct ManageWorkdirFeature {
client: Arc<dyn WorkspaceClient>,
before_workdir_release: Option<BeforeWorkdirRelease>,
after_workdir_attach: Option<AfterWorkdirAttach>,
}
impl std::fmt::Debug for ManageWorkdirFeature {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ManageWorkdirFeature")
.field("client_kind", &self.client.kind())
.field("release_guard", &self.before_workdir_release.is_some())
.finish()
}
}
impl ManageWorkdirFeature {
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
Self { client }
Self {
client,
before_workdir_release: None,
after_workdir_attach: None,
}
}
pub(crate) fn with_child_lifecycle(
client: Arc<dyn WorkspaceClient>,
before_workdir_release: BeforeWorkdirRelease,
after_workdir_attach: AfterWorkdirAttach,
) -> Self {
Self {
client,
before_workdir_release: Some(before_workdir_release),
after_workdir_attach: Some(after_workdir_attach),
}
}
}
@@ -81,7 +115,10 @@ impl FeatureModule for ManageWorkdirFeature {
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone());
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone()).with_child_lifecycle(
self.before_workdir_release.clone(),
self.after_workdir_attach.clone(),
);
for (name, definition) in [
(
LIST_TOOL,
@@ -142,9 +179,21 @@ impl FeatureModule for ManageWorkdirFeature {
}
}
#[derive(Clone, Debug)]
#[derive(Clone)]
struct WorkspaceHttpWorkdirBackend {
client: Arc<dyn WorkspaceClient>,
before_workdir_release: Option<BeforeWorkdirRelease>,
after_workdir_attach: Option<AfterWorkdirAttach>,
}
impl std::fmt::Debug for WorkspaceHttpWorkdirBackend {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("WorkspaceHttpWorkdirBackend")
.field("client_kind", &self.client.kind())
.field("release_guard", &self.before_workdir_release.is_some())
.finish()
}
}
/// Worker-local Workdir handle whose operation authority remains in the Workspace Backend.
@@ -156,8 +205,6 @@ struct WorkspaceHttpWorkdirBackend {
pub struct WorkspaceAttachedWorkdirSession {
client: Arc<dyn WorkspaceClient>,
workdir: Workdir,
expected_session_fence: Option<String>,
delegations: Vec<workdir::WorkdirDelegationRequest>,
}
impl WorkspaceAttachedWorkdirSession {
@@ -165,8 +212,6 @@ impl WorkspaceAttachedWorkdirSession {
Arc::new(Self {
client,
workdir: Workdir::new("workspace-attachment"),
expected_session_fence: None,
delegations: Vec::new(),
})
}
@@ -183,16 +228,13 @@ impl WorkspaceAttachedWorkdirSession {
"/api/w/{}/workers/self/workdir-session/operations",
encode_path_segment(workspace_id)
),
serde_json::to_string(&WorkspaceWorkdirSessionOperationRequest {
expected_session_fence: self.expected_session_fence.clone(),
delegations: self.delegations.clone(),
operation,
})
.map_err(|error| {
WorkdirError::Transport(format!(
"failed to encode Workspace Workdir operation: {error}"
))
})?,
serde_json::to_string(&WorkspaceWorkdirSessionOperationRequest { operation }).map_err(
|error| {
WorkdirError::Transport(format!(
"failed to encode Workspace Workdir operation: {error}"
))
},
)?,
);
let response = self
.client
@@ -241,59 +283,6 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
WorkdirSessionCapabilities::ALL
}
fn transports_delegation_context(&self) -> bool {
true
}
async fn capture_delegation_source(
&self,
request: &workdir::WorkdirDelegationRequest,
) -> Result<WorkdirSessionHandle, WorkdirError> {
let expected_session_fence = if let Some(fence) = &self.expected_session_fence {
fence.clone()
} else {
let workspace_id = self.client.workspace_id().ok_or_else(|| {
WorkdirError::Unavailable("Workspace identity is unavailable".to_string())
})?;
let response = self
.client
.execute(WorkspaceRequest {
method: WorkspaceRequestMethod::Get,
path: format!(
"/api/w/{}/workers/self/workdir-session/fence",
encode_path_segment(workspace_id)
),
body: None,
})
.map_err(|error| {
WorkdirError::Unavailable(format!(
"failed to capture Workdir attachment fence: {error}"
))
})?;
let fence: WorkspaceWorkdirSessionFence = serde_json::from_str(&response.body)
.map_err(|error| {
WorkdirError::Unavailable(format!(
"invalid Workdir attachment fence response: {error}"
))
})?;
fence.value
};
let mut delegations = self.delegations.clone();
delegations.push(request.clone());
let candidate = Arc::new(Self {
client: self.client.clone(),
workdir: self.workdir.clone(),
expected_session_fence: Some(expected_session_fence),
delegations,
});
candidate
.stat(StatRequest {
path: workdir::WorkdirPath::new("").expect("empty Workdir path is valid"),
})
.await?;
Ok(candidate)
}
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
match self.operate(WorkdirSessionOperation::Stat(request))? {
WorkdirSessionOperationResult::Stat(result) => Ok(result),
@@ -387,7 +376,21 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
impl WorkspaceHttpWorkdirBackend {
fn new(client: Arc<dyn WorkspaceClient>) -> Self {
Self { client }
Self {
client,
before_workdir_release: None,
after_workdir_attach: None,
}
}
fn with_child_lifecycle(
mut self,
before_workdir_release: Option<BeforeWorkdirRelease>,
after_workdir_attach: Option<AfterWorkdirAttach>,
) -> Self {
self.before_workdir_release = before_workdir_release;
self.after_workdir_attach = after_workdir_attach;
self
}
fn workspace_id(&self) -> Result<&str, ToolError> {
@@ -565,11 +568,26 @@ impl Tool for WorkspaceHttpWorkdirTool {
parse_input::<WorkdirCreateInput>(input_json)?,
ctx.call_id.to_string(),
),
WorkdirOperation::Attach => self
.backend
.attach(parse_input::<WorkdirAttachInput>(input_json)?),
WorkdirOperation::Attach => {
let result = self
.backend
.attach(parse_input::<WorkdirAttachInput>(input_json)?);
if result.is_ok()
&& let Some(after_attach) = &self.backend.after_workdir_attach
{
after_attach();
}
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| {
ToolError::ExecutionFailed(format!(
"stop Internal SubWorkers before Workdir detach: {error}"
))
})?;
}
self.backend.detach()
}
WorkdirOperation::Delete => self
@@ -765,6 +783,7 @@ struct WorkdirDeleteInput {
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
use crate::feature::{FeatureModule, FeatureRegistryBuilder};
@@ -1155,6 +1174,7 @@ mod tests {
command: "true".to_string(),
timeout_secs: 120,
output_limit: 1024,
cwd: None,
spill_dir: Some("/worker-local/bash-output".into()),
tool_call_id: Some("call-1".to_string()),
})
@@ -1178,83 +1198,6 @@ mod tests {
);
}
#[tokio::test]
async fn delegated_attached_session_carries_captured_fence_on_operations() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![
response(json!({"value": "attachment-fence"})),
response(json!({
"operation": "stat",
"result": {"path": "", "kind": "directory", "size": 0}
})),
response(json!({
"operation": "stat",
"result": {"path": "visible.txt", "kind": "file", "size": 8}
})),
]));
let parent = workdir::delegation_capable_session(WorkspaceAttachedWorkdirSession::handle(
client.clone(),
));
let delegation = parent
.delegate(workdir::WorkdirDelegationRequest {
rules: vec![workdir::WorkdirDelegationRule {
target: workdir::WorkdirPath::new("").unwrap(),
permission: workdir::WorkdirDelegationPermission::Read,
recursive: false,
}],
cwd: workdir::WorkdirPath::new("").unwrap(),
})
.await
.unwrap();
delegation
.scoped_session
.stat(StatRequest {
path: workdir::WorkdirPath::new("visible.txt").unwrap(),
})
.await
.unwrap();
let requests = client.requests();
assert_eq!(requests.len(), 3);
assert_eq!(
requests[0].path,
"/api/w/workspace%2Ftest/workers/self/workdir-session/fence"
);
let body: serde_json::Value =
serde_json::from_str(requests[2].body.as_deref().unwrap()).unwrap();
assert_eq!(body["expected_session_fence"], "attachment-fence");
assert_eq!(body["operation"]["operation"], "stat");
assert_eq!(body["delegations"][0]["rules"][0]["target"], "");
}
#[tokio::test]
async fn attached_provider_rejection_happens_before_delegation_is_returned() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![
response(json!({"value": "attachment-fence"})),
response(json!({"error": "provider rejected delegated write target"})),
]));
let parent = workdir::delegation_capable_session(WorkspaceAttachedWorkdirSession::handle(
client.clone(),
));
let result = parent
.delegate(workdir::WorkdirDelegationRequest {
rules: vec![workdir::WorkdirDelegationRule {
target: workdir::WorkdirPath::new("linked-target").unwrap(),
permission: workdir::WorkdirDelegationPermission::Write,
recursive: true,
}],
cwd: workdir::WorkdirPath::new("linked-target").unwrap(),
})
.await;
assert!(result.is_err(), "provider rejection must fail before lease");
let requests = client.requests();
assert_eq!(requests.len(), 2);
let validation: serde_json::Value =
serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap();
assert_eq!(validation["operation"]["operation"], "stat");
assert_eq!(validation["delegations"].as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn attached_session_preserves_typed_provider_validation_error() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![error_response(
@@ -1298,73 +1241,52 @@ mod tests {
}
#[tokio::test]
async fn nested_attached_session_preserves_full_delegation_chain() {
async fn scoped_broker_operations_carry_no_child_context() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![
response(json!({"value": "attachment-fence"})),
response(json!({
"operation": "stat",
"result": {"path": "", "kind": "directory", "size": 0}
"result": {"path": "visible.txt", "kind": "file", "size": 8}
})),
response(json!({
"operation": "stat",
"result": {"path": "nested", "kind": "directory", "size": 0}
})),
response(json!({
"operation": "stat",
"result": {"path": "nested/file", "kind": "file", "size": 1}
"result": {"path": "visible.txt", "kind": "file", "size": 8}
})),
]));
let parent = workdir::delegation_capable_session(WorkspaceAttachedWorkdirSession::handle(
let broker = workdir::WorkdirToolBroker::new(WorkspaceAttachedWorkdirSession::handle(
client.clone(),
));
let outer = parent
.delegate(workdir::WorkdirDelegationRequest {
rules: vec![workdir::WorkdirDelegationRule {
let scoped = broker
.scope(workdir::WorkdirToolScope {
rules: vec![workdir::WorkdirToolScopeRule {
target: workdir::WorkdirPath::new("").unwrap(),
permission: workdir::WorkdirDelegationPermission::Read,
permission: workdir::WorkdirToolScopePermission::Read,
recursive: true,
}],
cwd: workdir::WorkdirPath::new("").unwrap(),
command: false,
})
.await
.unwrap();
let nested = outer
.scoped_session
.delegate(workdir::WorkdirDelegationRequest {
rules: vec![workdir::WorkdirDelegationRule {
target: workdir::WorkdirPath::new("nested").unwrap(),
permission: workdir::WorkdirDelegationPermission::Read,
recursive: true,
}],
cwd: workdir::WorkdirPath::new("nested").unwrap(),
})
.await
.unwrap();
nested
.scoped_session
scoped
.stat(StatRequest {
path: workdir::WorkdirPath::new("file").unwrap(),
path: workdir::WorkdirPath::new("visible.txt").unwrap(),
})
.await
.unwrap();
let requests = client.requests();
assert_eq!(requests.len(), 4);
let outer_validation: serde_json::Value =
serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap();
let nested_validation: serde_json::Value =
serde_json::from_str(requests[2].body.as_deref().unwrap()).unwrap();
assert_eq!(outer_validation["delegations"].as_array().unwrap().len(), 1);
assert_eq!(
nested_validation["delegations"].as_array().unwrap().len(),
2
);
let body: serde_json::Value =
serde_json::from_str(requests[3].body.as_deref().unwrap()).unwrap();
assert_eq!(body["delegations"].as_array().unwrap().len(), 2);
assert_eq!(body["delegations"][0]["rules"][0]["target"], "");
assert_eq!(body["delegations"][1]["rules"][0]["target"], "nested");
assert_eq!(body["operation"]["request"]["path"], "file");
assert_eq!(requests.len(), 2);
for request in requests {
assert_eq!(
request.path,
"/api/w/workspace%2Ftest/workers/self/workdir-session/operations"
);
let body: serde_json::Value =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert!(body.get("delegations").is_none());
assert!(body.get("child").is_none());
assert!(body.get("expected_session_fence").is_none());
}
}
#[test]
@@ -1416,4 +1338,86 @@ mod tests {
assert!(client.requests().is_empty());
assert!(parse_input::<WorkdirListInput>(r#"{"path":"/tmp"}"#).is_err());
}
#[tokio::test]
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",
"attached": false
}))]));
let cleanup_calls = Arc::new(AtomicUsize::new(0));
let cleanup_calls_for_guard = cleanup_calls.clone();
let before_release: BeforeWorkdirRelease = Arc::new(move || {
let cleanup_calls = cleanup_calls_for_guard.clone();
Box::pin(async move {
cleanup_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
})
});
let tool = WorkspaceHttpWorkdirTool {
backend: WorkspaceHttpWorkdirBackend::new(client.clone())
.with_child_lifecycle(Some(before_release), None),
operation: WorkdirOperation::Detach,
};
tool.execute("{}", ToolExecutionContext::default())
.await
.unwrap();
assert_eq!(cleanup_calls.load(Ordering::SeqCst), 1);
assert_eq!(client.requests().len(), 1);
assert_eq!(
client.requests()[0].path,
"/api/w/workspace%2Ftest/workers/self/workdir-attachment"
);
}
#[tokio::test]
async fn detach_does_not_release_backend_when_child_cleanup_fails() {
let client = Arc::new(RecordingWorkspaceClient::new(Vec::new()));
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_child_lifecycle(Some(before_release), None),
operation: WorkdirOperation::Detach,
};
let error = tool
.execute("{}", ToolExecutionContext::default())
.await
.unwrap_err();
assert!(error.to_string().contains("stop Internal SubWorkers"));
assert!(client.requests().is_empty());
}
#[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",
"attached": true
}))]));
let reopen_calls = Arc::new(AtomicUsize::new(0));
let reopen_calls_for_hook = reopen_calls.clone();
let after_attach: AfterWorkdirAttach = Arc::new(move || {
reopen_calls_for_hook.fetch_add(1, Ordering::SeqCst);
});
let tool = WorkspaceHttpWorkdirTool {
backend: WorkspaceHttpWorkdirBackend::new(client)
.with_child_lifecycle(None, Some(after_attach)),
operation: WorkdirOperation::Attach,
};
tool.execute(
r#"{"workdir_id":"wd-attached"}"#,
ToolExecutionContext::default(),
)
.await
.unwrap();
assert_eq!(reopen_calls.load(Ordering::SeqCst), 1);
}
}
+65 -45
View File
@@ -23,7 +23,8 @@ use crate::feature::{
ToolDeclaration,
};
use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
SystemPromptContributionSource, WorkspaceClient, WorkspaceClientError, WorkspaceRequest,
WorkspaceRequestMethod,
};
#[derive(Clone, Debug)]
@@ -342,15 +343,44 @@ fn query_schema() -> serde_json::Value {
})
}
pub struct MemoryFeatureInstallPlan {
pub module: MemoryToolsFeature,
pub resident_summary: Option<String>,
pub system_prompt_override: Option<String>,
struct WorkspaceResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
}
#[async_trait]
impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
async fn load(&self) -> Option<String> {
match self
.client
.execute_memory_backend_operation(
memory::backend::MemoryBackendOperation::ResidentSummary(
memory::backend::MemoryResidentSummaryOperation::default(),
),
)
.await
{
Ok(memory::backend::MemoryBackendOperationResult::ToolOutput(output)) => output.content,
Ok(other) => {
tracing::debug!(?other, "unexpected resident Memory Backend result");
None
}
Err(error) => {
tracing::debug!(%error, "resident Memory summary unavailable");
None
}
}
}
}
pub(crate) struct MemoryFeatureInstallPlan {
pub(crate) module: MemoryToolsFeature,
pub(crate) resident_summary_source: Option<Arc<dyn SystemPromptContributionSource>>,
pub(crate) system_prompt_override: Option<String>,
pub(crate) resolved_config: manifest::ResolvedMemoryFeatureConfig,
}
impl MemoryFeatureInstallPlan {
pub async fn prepare(
pub fn prepare(
manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>,
prompts: Arc<crate::prompt::catalog::PromptCatalog>,
@@ -361,10 +391,9 @@ impl MemoryFeatureInstallPlan {
prompts,
manifest.profile.clone(),
)
.await
}
async fn prepare_resolved(
fn prepare_resolved(
config: manifest::ResolvedMemoryFeatureConfig,
client: Arc<dyn WorkspaceClient>,
prompts: Arc<crate::prompt::catalog::PromptCatalog>,
@@ -405,30 +434,11 @@ impl MemoryFeatureInstallPlan {
));
}
let resident_summary = if config.profile.resident.inject_summary {
match client
.execute_memory_backend_operation(
memory::backend::MemoryBackendOperation::ResidentSummary(
memory::backend::MemoryResidentSummaryOperation::default(),
),
)
.await
{
Ok(memory::backend::MemoryBackendOperationResult::ToolOutput(output)) => {
output.content
}
Ok(other) => {
tracing::debug!(?other, "unexpected resident Memory Backend result");
None
}
Err(error) => {
tracing::debug!(%error, "resident Memory summary unavailable");
None
}
}
} else {
None
};
let resident_summary_source = config.profile.resident.inject_summary.then(|| {
Arc::new(WorkspaceResidentSummarySource {
client: Arc::clone(&client),
}) as Arc<dyn SystemPromptContributionSource>
});
let system_prompt_override = if memory_consolidation_worker {
let language = settings.language;
Some(
@@ -442,7 +452,7 @@ impl MemoryFeatureInstallPlan {
Ok(Some(Self {
module: MemoryToolsFeature::new(client, config.profile.staging_tools),
resident_summary,
resident_summary_source,
system_prompt_override,
resolved_config: config,
}))
@@ -559,7 +569,6 @@ mod tests {
prompts.clone(),
None,
)
.await
.unwrap();
assert!(disabled.is_none());
@@ -573,7 +582,6 @@ mod tests {
prompts.clone(),
None,
)
.await
.is_err()
);
enabled
@@ -592,7 +600,6 @@ mod tests {
prompts.clone(),
None,
)
.await
.is_err()
);
let plan = MemoryFeatureInstallPlan::prepare_resolved(
@@ -601,10 +608,9 @@ mod tests {
prompts.clone(),
None,
)
.await
.unwrap()
.unwrap();
assert!(plan.resident_summary.is_none());
assert!(plan.resident_summary_source.is_none());
assert!(plan.system_prompt_override.is_none());
enabled.profile.resident.inject_summary = true;
@@ -614,14 +620,20 @@ mod tests {
prompts,
None,
)
.await
.unwrap()
.unwrap();
assert_eq!(plan.resident_summary.as_deref(), Some("# Durable Memory"));
assert_eq!(
plan.resident_summary_source
.unwrap()
.load()
.await
.as_deref(),
Some("# Durable Memory")
);
}
#[tokio::test]
async fn memory_prompt_contribution_rereads_resident_summary_for_each_install() {
async fn memory_prompt_contribution_defers_resident_summary_until_loaded() {
let prompts = crate::prompt::catalog::PromptCatalog::builtins_only().unwrap();
let mut config = manifest::ResolvedMemoryFeatureConfig::default();
config.profile.enabled = true;
@@ -639,7 +651,6 @@ mod tests {
prompts.clone(),
None,
)
.await
.unwrap()
.unwrap();
let restored = MemoryFeatureInstallPlan::prepare_resolved(
@@ -648,16 +659,25 @@ mod tests {
prompts,
None,
)
.await
.unwrap()
.unwrap();
assert_eq!(
first.resident_summary.as_deref(),
first
.resident_summary_source
.unwrap()
.load()
.await
.as_deref(),
Some("first resident summary")
);
assert_eq!(
restored.resident_summary.as_deref(),
restored
.resident_summary_source
.unwrap()
.load()
.await
.as_deref(),
Some("updated resident summary")
);
}
@@ -803,7 +803,10 @@ mod tests {
.collect();
Ok(WorkerSessionCapture {
segment_id: "segment".to_string(),
session: protocol::SessionSnapshot { entries },
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries,
},
})
}
}
+57 -21
View File
@@ -295,6 +295,38 @@ impl InternalWorkerSessionStatus {
}
}
fn send_internal_worker_state(
event_tx: &broadcast::Sender<Event>,
state_revision: &std::sync::atomic::AtomicU64,
status: InternalWorkerSessionStatus,
) {
let state = match status {
InternalWorkerSessionStatus::Idle
| InternalWorkerSessionStatus::Stopped
| InternalWorkerSessionStatus::Failed => protocol::WorkerState::Idle,
InternalWorkerSessionStatus::Paused => protocol::WorkerState::Busy(
protocol::WorkerBusyState::Run(protocol::WorkerRunState::Paused),
),
InternalWorkerSessionStatus::Running => protocol::WorkerState::Busy(
protocol::WorkerBusyState::Run(protocol::WorkerRunState::Running),
),
InternalWorkerSessionStatus::Stopping => protocol::WorkerState::Busy(
protocol::WorkerBusyState::Run(protocol::WorkerRunState::Cancelling),
),
};
let revision = state_revision
.fetch_add(1, std::sync::atomic::Ordering::AcqRel)
.saturating_add(1);
let _ = event_tx.send(Event::WorkerState {
snapshot: protocol::WorkerStateSnapshot {
execution_generation: 1,
revision,
last_command_id: 0,
state,
},
});
}
fn classify_internal_turn_result(
result: Result<WorkerRunResult, WorkerError>,
) -> (InternalWorkerSessionStatus, Option<String>) {
@@ -351,6 +383,7 @@ pub(crate) struct InternalWorkerSessionSnapshot {
pub(crate) struct InternalWorkerSessionHandle {
command_tx: tokio::sync::mpsc::Sender<InternalWorkerSessionCommand>,
status: Arc<std::sync::atomic::AtomicU8>,
state_revision: Arc<std::sync::atomic::AtomicU64>,
store: EphemeralSessionStore,
session_id: SessionId,
segment_id: SegmentId,
@@ -400,6 +433,10 @@ impl InternalWorkerSessionHandle {
self.in_flight.text_delta(block_id, text.to_owned());
}
fn emit_worker_state(&self, status: InternalWorkerSessionStatus) {
send_internal_worker_state(&self.event_tx, &self.state_revision, status);
}
pub(crate) fn protocol_snapshot(&self) -> InternalWorkerSessionSnapshot {
let (entries, in_flight) = {
let guard = self.in_flight.snapshot_guard();
@@ -473,9 +510,7 @@ impl InternalWorkerSessionHandle {
});
return Err(InternalWorkerSessionError::Unavailable);
}
let _ = self.event_tx.send(Event::Status {
status: WorkerStatus::Running,
});
self.emit_worker_state(InternalWorkerSessionStatus::Running);
Ok(())
}
@@ -709,7 +744,7 @@ pub(crate) fn prepare_internal_worker_from_spec(
}
Box::pin(prepare_internal_worker_session(
worker, store, visibility, None, None,
worker, store, visibility, None, None, None,
))
.await
})
@@ -746,13 +781,16 @@ pub(crate) async fn prepare_internal_worker_session(
visibility: InternalWorkerVisibility,
child_registry: Option<Arc<SpawnedWorkerRegistry>>,
on_turn_end: Option<Arc<dyn Fn(InternalWorkerSessionStatus) + Send + Sync>>,
command_event_broker: Option<workdir::WorkdirToolBroker>,
) -> Result<InternalWorkerSessionHandle, InternalWorkerSessionError> {
let (event_tx, _event_rx) = broadcast::channel(256);
let sink = worker.sink();
spawn_internal_log_event_bridge(sink.clone(), event_tx.clone());
let alerter = Alerter::new(event_tx.clone());
let in_flight = InFlightEvents::new(event_tx.clone());
if let Some(session) = worker.workdir_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);
}
let actor_in_flight = in_flight.clone();
@@ -767,11 +805,13 @@ pub(crate) async fn prepare_internal_worker_session(
let status = Arc::new(std::sync::atomic::AtomicU8::new(
InternalWorkerSessionStatus::Idle.encode(),
));
let state_revision = Arc::new(std::sync::atomic::AtomicU64::new(0));
let state_changed = Arc::new(tokio::sync::Notify::new());
let last_error = Arc::new(Mutex::new(None));
let handle = InternalWorkerSessionHandle {
command_tx,
status: status.clone(),
state_revision: state_revision.clone(),
store,
session_id,
segment_id,
@@ -807,19 +847,11 @@ pub(crate) async fn prepare_internal_worker_session(
message,
});
}
let protocol_status = match turn_status {
InternalWorkerSessionStatus::Idle => WorkerStatus::Idle,
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
InternalWorkerSessionStatus::Stopped
| InternalWorkerSessionStatus::Failed => WorkerStatus::Stopped,
InternalWorkerSessionStatus::Running
| InternalWorkerSessionStatus::Stopping => {
unreachable!("run completion cannot remain active")
}
};
let _ = event_tx.send(Event::Status {
status: protocol_status,
});
send_internal_worker_state(
&event_tx,
&state_revision,
turn_status,
);
if let Some(callback) = &on_turn_end {
callback(turn_status);
}
@@ -861,9 +893,11 @@ pub(crate) async fn prepare_internal_worker_session(
InternalWorkerSessionStatus::Stopped.encode(),
std::sync::atomic::Ordering::Release,
);
let _ = event_tx.send(Event::Status {
status: WorkerStatus::Stopped,
});
send_internal_worker_state(
&event_tx,
&state_revision,
InternalWorkerSessionStatus::Stopped,
);
let _ = event_tx.send(Event::Shutdown);
state_changed.notify_waiters();
if let Some(done) = stop_done {
@@ -887,6 +921,7 @@ pub(crate) async fn spawn_prepared_internal_worker_session(
InternalWorkerVisibility::ServicePrivate,
None,
on_turn_end,
None,
)
.await?;
handle.send(input).await?;
@@ -1114,6 +1149,7 @@ pub(crate) fn test_internal_worker_session(
status: Arc::new(std::sync::atomic::AtomicU8::new(
InternalWorkerSessionStatus::Idle.encode(),
)),
state_revision: Arc::new(std::sync::atomic::AtomicU64::new(0)),
store,
session_id,
segment_id,
+31 -6
View File
@@ -176,12 +176,23 @@ impl WorkerInterceptor {
/// `Item::system_message`s reach the worker via
/// `ContinueWith` / `pending_history_appends`, so on-disk order
/// matches worker-history order.
fn commit_system_items(&self, items: &[SystemItem]) -> Result<(), session_store::StoreError> {
fn commit_system_items_with_extensions(
&self,
items: &[(
SystemItem,
Vec<session_store::SessionExtension>,
Option<session_store::LoggedSessionHistoryOrigin>,
)],
) -> Result<(), session_store::StoreError> {
let Some(writer) = self.log_writer.as_ref() else {
return Ok(());
};
for item in items {
let entry = writer.commit_system_item(item.clone())?;
for (item, extensions, history_provenance) in items {
let entry = writer.commit_system_item_with_extensions(
item.clone(),
extensions.clone(),
history_provenance.clone(),
)?;
self.pending_committed_history
.lock()
.expect("pending committed history poisoned")
@@ -190,6 +201,16 @@ impl WorkerInterceptor {
Ok(())
}
fn commit_system_items(&self, items: &[SystemItem]) -> Result<(), session_store::StoreError> {
self.commit_system_items_with_extensions(
&items
.iter()
.cloned()
.map(|item| (item, Vec::new(), None))
.collect::<Vec<_>>(),
)
}
fn current_turn_index(&self) -> usize {
self.next_turn_index
.load(Ordering::Relaxed)
@@ -327,7 +348,11 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
projection_digest: projection.catalog_digest.clone(),
logical_name: "internal.notify_wrapper".to_string(),
};
let mut system_items: Vec<SystemItem> = Vec::with_capacity(drained.len());
let mut system_items: Vec<(
SystemItem,
Vec<session_store::SessionExtension>,
Option<session_store::LoggedSessionHistoryOrigin>,
)> = Vec::with_capacity(drained.len());
let mut items: Vec<Item> = Vec::with_capacity(drained.len());
for entry in &drained {
let system_item = match build_system_item_with_provenance(
@@ -345,9 +370,9 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
}
};
items.push(system_item.to_history_item());
system_items.push(system_item);
system_items.push((system_item, entry.extensions(), entry.history_provenance()));
}
if let Err(error) = self.commit_system_items(&system_items) {
if let Err(error) = self.commit_system_items_with_extensions(&system_items) {
self.pending_notifies.requeue_front(drained);
return Err(InterceptorError::new(
InterceptorErrorCategory::Dependency,
+70 -4
View File
@@ -25,7 +25,7 @@ use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use protocol::WorkerEvent;
use session_store::SystemItem;
use session_store::{LoggedSessionHistoryOrigin, SessionExtension, SystemItem};
use tracing::warn;
use crate::prompt::catalog::{CatalogError, PromptCatalog};
@@ -41,8 +41,33 @@ const CAPACITY: usize = 128;
/// is available.
#[derive(Debug, Clone)]
pub enum PendingNotify {
Notify { message: String, auto_run: bool },
WorkerEvent { event: WorkerEvent },
Notify {
message: String,
auto_run: bool,
extensions: Vec<SessionExtension>,
history_provenance: Option<LoggedSessionHistoryOrigin>,
},
WorkerEvent {
event: WorkerEvent,
},
}
impl PendingNotify {
pub(crate) fn extensions(&self) -> Vec<SessionExtension> {
match self {
PendingNotify::Notify { extensions, .. } => extensions.clone(),
PendingNotify::WorkerEvent { .. } => Vec::new(),
}
}
pub(crate) fn history_provenance(&self) -> Option<LoggedSessionHistoryOrigin> {
match self {
PendingNotify::Notify {
history_provenance, ..
} => history_provenance.clone(),
PendingNotify::WorkerEvent { .. } => None,
}
}
}
/// Shared, mutex-guarded buffer of pending entries.
@@ -62,7 +87,46 @@ impl NotifyBuffer {
/// oldest entry is dropped and a `tracing::warn` is emitted — the
/// caller should never hit this in normal operation.
pub fn push_notify(&self, message: String, auto_run: bool) {
self.push_entry(PendingNotify::Notify { message, auto_run });
self.push_entry(PendingNotify::Notify {
message,
auto_run,
extensions: Vec::new(),
history_provenance: None,
});
}
pub fn push_durable_notify(
&self,
message: String,
auto_run: bool,
history_provenance: LoggedSessionHistoryOrigin,
extension: SessionExtension,
) {
self.push_entry(PendingNotify::Notify {
message,
auto_run,
extensions: vec![extension],
history_provenance: Some(history_provenance),
});
}
pub(crate) fn replace_durable_notification_extension(
&self,
extension: SessionExtension,
) -> bool {
let mut queue = self.inner.lock().expect("notify buffer poisoned");
let Some(extensions) = queue.iter_mut().rev().find_map(|pending| match pending {
PendingNotify::Notify {
auto_run: false,
extensions,
..
} if !extensions.is_empty() => Some(extensions),
_ => None,
}) else {
return false;
};
*extensions = vec![extension];
true
}
/// Push a typed worker-event entry onto the queue.
@@ -202,6 +266,8 @@ mod tests {
let entry = PendingNotify::Notify {
message: "hello".into(),
auto_run: false,
extensions: Vec::new(),
history_provenance: None,
};
let catalog = PromptCatalog::builtins_only().unwrap();
let item = build_system_item(&entry, &catalog).unwrap();
+5 -5
View File
@@ -57,9 +57,9 @@ pub use session_history::{
};
pub use shared_state::WorkerSharedState;
pub use worker::{
LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError,
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceClient,
WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspacePromptCatalogResolution,
WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, WorkspaceWorkerDiscoveryRequest,
apply_worker_manifest, marker_workspace_client, unavailable_workspace_client,
LocalWorkingDirectory, Worker, WorkerError, WorkerFilesystemAuthority, WorkerRunResult,
WorkerWorkspaceContext, WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError,
WorkspacePromptCatalogResolution, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
WorkspaceWorkerDiscoveryRequest, apply_worker_manifest, marker_workspace_client,
unavailable_workspace_client,
};
+3 -2
View File
@@ -197,7 +197,6 @@ pub fn default_base() -> Result<PathBuf, io::Error> {
mod tests {
use super::*;
use crate::shared_state::WorkerSharedState;
use protocol::WorkerStatus;
fn test_state() -> WorkerSharedState {
WorkerSharedState::new(
@@ -247,7 +246,9 @@ mod tests {
let rt = RuntimeDir::create(tmp.path(), "my-worker").await.unwrap();
let state = test_state();
state.set_status(WorkerStatus::Running);
state.transition(protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
)));
rt.write_status(&state).await.unwrap();
let content = std::fs::read_to_string(rt.path().join("status.json")).unwrap();
+1
View File
@@ -291,6 +291,7 @@ mod tests {
prompt_provenance: None,
},
),
extensions: Vec::new(),
}
}
+240 -42
View File
@@ -1,28 +1,49 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{OnceLock, RwLock};
use std::collections::VecDeque;
use std::sync::{
OnceLock, RwLock,
atomic::{AtomicBool, Ordering},
};
use protocol::WorkerStatus;
use protocol::{
WorkerBusyState, WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind,
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
};
use serde_json::json;
use session_store::SegmentId;
use crate::fs_view::WorkerFsView;
const COMPLETED_COMMAND_RETENTION: usize = 256;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct AcceptedWorkerCommand {
envelope: WorkerCommandEnvelope,
kind: WorkerCommandKind,
disposition: Option<WorkerCommandDisposition>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WorkerCommandAdmission {
Accepted,
Retry,
Conflict,
StaleCommandId,
ExecutionGenerationMismatch,
StateRevisionMismatch,
}
/// Shared state between WorkerController and runtime directory.
///
/// Controller updates this in-memory; RuntimeDir writes the status
/// snapshot to disk. Wrapped in `Arc` for sharing.
///
/// History and typed user-segment mirrors used to live here so the
/// IPC layer could answer `Method::GetHistory`. Those reads now go
/// directly through the session-log sink (`Event::Snapshot` +
/// live events), so this struct holds only status, identity,
/// greeting, and filesystem completion lookup hubs.
/// `WorkerStateSnapshot` is the sole live execution-state authority. Runtime
/// catalog status remains a separate lifecycle projection because `Stopped`
/// describes the execution handle rather than a live controller state.
pub struct WorkerSharedState {
pub worker_name: String,
pub segment_id: SegmentId,
pub manifest_toml: String,
pub greeting: protocol::Greeting,
pub status: RwLock<WorkerStatus>,
state: RwLock<WorkerStateSnapshot>,
accepted_commands: RwLock<VecDeque<AcceptedWorkerCommand>>,
/// Worker-from-the-inside view of the filesystem. Set once in
/// `WorkerController::start` after the local WorkdirSession provider is
/// materialised, and read from the IPC server layer to answer
@@ -38,13 +59,24 @@ impl WorkerSharedState {
segment_id: SegmentId,
manifest_toml: String,
greeting: protocol::Greeting,
) -> Self {
Self::new_with_generation(worker_name, segment_id, manifest_toml, greeting, 1)
}
pub fn new_with_generation(
worker_name: String,
segment_id: SegmentId,
manifest_toml: String,
greeting: protocol::Greeting,
execution_generation: u64,
) -> Self {
Self {
worker_name,
segment_id,
manifest_toml,
greeting,
status: RwLock::new(WorkerStatus::Idle),
state: RwLock::new(WorkerStateSnapshot::initial(execution_generation)),
accepted_commands: RwLock::new(VecDeque::new()),
fs_view: OnceLock::new(),
flow_transition_enabled: AtomicBool::new(false),
}
@@ -70,21 +102,140 @@ impl WorkerSharedState {
self.flow_transition_enabled.load(Ordering::Acquire)
}
pub fn set_status(&self, status: WorkerStatus) {
if let Ok(mut s) = self.status.write() {
*s = status;
pub fn transition(&self, state: WorkerState) -> WorkerStateSnapshot {
let mut snapshot = self
.state
.write()
.expect("worker state lock poisoned; refusing an inferred fallback state");
if snapshot.state != state {
snapshot.revision = snapshot.revision.saturating_add(1);
snapshot.state = state;
}
snapshot.clone()
}
pub(crate) fn admit_command(
&self,
envelope: WorkerCommandEnvelope,
kind: WorkerCommandKind,
require_state_revision: bool,
) -> WorkerCommandAdmission {
let mut snapshot = self
.state
.write()
.expect("worker state lock poisoned; refusing command admission");
let mut accepted = self
.accepted_commands
.write()
.expect("worker command ledger lock poisoned; refusing command admission");
if let Some(existing) = accepted
.iter()
.find(|accepted| accepted.envelope.command_id == envelope.command_id)
{
return if existing.envelope == envelope && existing.kind == kind {
WorkerCommandAdmission::Retry
} else {
WorkerCommandAdmission::Conflict
};
}
if envelope.expected_execution_generation != snapshot.execution_generation {
return WorkerCommandAdmission::ExecutionGenerationMismatch;
}
if require_state_revision && envelope.expected_worker_state_revision != snapshot.revision {
return WorkerCommandAdmission::StateRevisionMismatch;
}
if envelope.command_id <= snapshot.last_command_id {
return WorkerCommandAdmission::StaleCommandId;
}
snapshot.last_command_id = envelope.command_id;
snapshot.revision = snapshot.revision.saturating_add(1);
accepted.push_back(AcceptedWorkerCommand {
envelope,
kind,
disposition: None,
});
WorkerCommandAdmission::Accepted
}
pub(crate) fn complete_command(
&self,
command_id: u64,
kind: WorkerCommandKind,
disposition: WorkerCommandDisposition,
) {
if !matches!(
disposition,
WorkerCommandDisposition::Accepted | WorkerCommandDisposition::InvalidState
) {
return;
}
let mut accepted = self
.accepted_commands
.write()
.expect("worker command ledger lock poisoned; refusing command completion");
if let Some(command) = accepted
.iter_mut()
.find(|command| command.envelope.command_id == command_id && command.kind == kind)
{
command.disposition.get_or_insert(disposition);
}
while accepted
.iter()
.filter(|command| command.disposition.is_some())
.count()
> COMPLETED_COMMAND_RETENTION
{
let Some(index) = accepted
.iter()
.position(|command| command.disposition.is_some())
else {
break;
};
accepted.remove(index);
}
}
pub fn get_status(&self) -> WorkerStatus {
self.status.read().map(|s| *s).unwrap_or(WorkerStatus::Idle)
#[cfg(test)]
pub(crate) fn command_result(
&self,
command_id: u64,
) -> Option<Option<WorkerCommandDisposition>> {
self.accepted_commands
.read()
.expect("worker command ledger lock poisoned")
.iter()
.find(|command| command.envelope.command_id == command_id)
.map(|command| command.disposition)
}
/// Serialize status as JSON.
pub fn snapshot(&self) -> WorkerStateSnapshot {
self.state
.read()
.expect("worker state lock poisoned; refusing an inferred fallback state")
.clone()
}
/// Runtime catalog projection. This must not be used as live command
/// admission authority.
pub fn catalog_status(&self) -> WorkerStatus {
match self.snapshot().state {
WorkerState::Idle => WorkerStatus::Idle,
WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)) => WorkerStatus::Paused,
WorkerState::Busy(WorkerBusyState::Run(_))
| WorkerState::Busy(WorkerBusyState::Maintenance(WorkerMaintenanceState::Compacting)) => {
WorkerStatus::Running
}
}
}
/// Serialize the runtime-directory lifecycle projection as JSON while
/// retaining the full state snapshot for diagnostics and reconnects.
pub fn status_json(&self) -> String {
let status = self.get_status();
let snapshot = self.snapshot();
json!({
"state": status,
"state": self.catalog_status(),
"worker_state": snapshot,
"segment_id": self.segment_id.to_string(),
"worker_name": self.worker_name,
})
@@ -97,11 +248,12 @@ mod tests {
use super::*;
fn test_state() -> WorkerSharedState {
WorkerSharedState::new(
WorkerSharedState::new_with_generation(
"test-worker".into(),
session_store::new_segment_id(),
"[engine]\nname = \"test-worker\"".into(),
test_greeting(),
7,
)
}
@@ -119,36 +271,82 @@ mod tests {
}
#[test]
fn initial_status_is_idle() {
fn initial_snapshot_is_idle() {
let state = test_state();
assert_eq!(state.get_status(), WorkerStatus::Idle);
assert_eq!(state.snapshot(), WorkerStateSnapshot::initial(7));
assert_eq!(state.catalog_status(), WorkerStatus::Idle);
}
#[test]
fn set_and_get_status() {
fn transitions_increment_revision_only_when_state_changes() {
let state = test_state();
state.set_status(WorkerStatus::Running);
assert_eq!(state.get_status(), WorkerStatus::Running);
state.set_status(WorkerStatus::Paused);
assert_eq!(state.get_status(), WorkerStatus::Paused);
let running = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running));
let snapshot = state.transition(running.clone());
assert_eq!(snapshot.revision, 1);
assert_eq!(snapshot.state, running);
assert_eq!(state.transition(running).revision, 1);
let paused = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused));
let snapshot = state.transition(paused.clone());
assert_eq!(snapshot.revision, 2);
assert_eq!(snapshot.state, paused);
assert_eq!(state.catalog_status(), WorkerStatus::Paused);
}
#[test]
fn status_json_contains_fields() {
fn accepted_command_identity_advances_revision_and_detects_reuse_conflicts() {
let state = test_state();
let json = state.status_json();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["state"], "idle");
let envelope = WorkerCommandEnvelope {
command_id: 9,
expected_execution_generation: 7,
expected_worker_state_revision: 0,
};
assert_eq!(
state.admit_command(envelope, WorkerCommandKind::Pause, true),
WorkerCommandAdmission::Accepted
);
assert_eq!(
state.snapshot(),
WorkerStateSnapshot {
execution_generation: 7,
revision: 1,
last_command_id: 9,
state: WorkerState::Idle,
}
);
assert_eq!(state.command_result(9), Some(None));
state.complete_command(
9,
WorkerCommandKind::Pause,
WorkerCommandDisposition::Accepted,
);
assert_eq!(
state.command_result(9),
Some(Some(WorkerCommandDisposition::Accepted))
);
assert_eq!(
state.admit_command(envelope, WorkerCommandKind::Pause, true),
WorkerCommandAdmission::Retry
);
assert_eq!(
state.admit_command(envelope, WorkerCommandKind::Cancel, true),
WorkerCommandAdmission::Conflict
);
assert_eq!(state.snapshot().revision, 1);
}
#[test]
fn status_json_contains_full_snapshot_and_catalog_projection() {
let state = test_state();
state.transition(WorkerState::Busy(WorkerBusyState::Maintenance(
WorkerMaintenanceState::Compacting,
)));
let parsed: serde_json::Value = serde_json::from_str(&state.status_json()).unwrap();
assert_eq!(parsed["state"], "running");
assert_eq!(parsed["worker_state"]["execution_generation"], 7);
assert_eq!(parsed["worker_state"]["revision"], 1);
assert_eq!(parsed["worker_state"]["state"]["kind"], "busy");
assert_eq!(parsed["worker_name"], "test-worker");
assert!(parsed["segment_id"].is_string());
}
#[test]
fn status_json_reflects_changes() {
let state = test_state();
state.set_status(WorkerStatus::Running);
let json = state.status_json();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["state"], "running");
}
}
+10 -3
View File
@@ -72,6 +72,7 @@ mod tests {
fn snapshot(entries: Vec<serde_json::Value>) -> Event {
Event::Snapshot {
session: protocol::SessionSnapshot {
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
entries: entries
.into_iter()
.enumerate()
@@ -96,7 +97,7 @@ mod tests {
context_window: 200_000,
context_tokens: 0,
},
status: WorkerStatus::Idle,
state: WorkerStatus::Idle.into(),
in_flight: Default::default(),
internal_workers: Vec::new(),
}
@@ -136,10 +137,16 @@ mod tests {
],
);
connect_and_send(&socket, &Method::Shutdown).await.unwrap();
let method = Method::Shutdown {
command: protocol::WorkerCommandEnvelope::for_snapshot(
1,
&protocol::WorkerStateSnapshot::initial(1),
),
};
connect_and_send(&socket, &method).await.unwrap();
let method = received.await.unwrap().expect("expected method");
assert!(matches!(method, Method::Shutdown));
assert!(matches!(method, Method::Shutdown { .. }));
}
#[tokio::test]
+294 -27
View File
@@ -12,7 +12,7 @@ use std::collections::{BTreeMap, HashSet};
use std::io;
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicU64, Ordering},
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
};
use std::time::Instant;
@@ -23,9 +23,9 @@ use protocol::{Event, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnaps
use session_store::{
LoggedItem, WorkerMetadataStore, WorkerReclaimedChild, WorkerSpawnedChild, WorkerStoreError,
};
use tokio::sync::broadcast;
use tokio::sync::{Notify, broadcast};
use tracing::warn;
use workdir::WorkdirDelegation;
use workdir::WorkdirScopeLease;
use crate::internal_worker::{InternalWorkerSessionHandle, InternalWorkerVisibility};
use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord};
@@ -68,10 +68,11 @@ pub(crate) struct SubWorkerStopSummary {
pub(crate) struct InternalSpawnedWorkerRecord {
pub worker_name: String,
pub scope_delegated: Vec<ScopeRule>,
pub workdir_delegation: Arc<WorkdirDelegation>,
pub workdir_tool_scope: Arc<WorkdirScopeLease>,
#[cfg(test)]
pub installed_tools: Arc<[String]>,
pub session: InternalWorkerSessionHandle,
pub child_registry: Arc<SpawnedWorkerRegistry>,
change_tracker: Option<tools::Tracker>,
started_at: Instant,
stop_lock: Arc<tokio::sync::Mutex<()>>,
@@ -86,18 +87,20 @@ impl InternalSpawnedWorkerRecord {
pub(crate) fn new(
worker_name: String,
scope_delegated: Vec<ScopeRule>,
workdir_delegation: WorkdirDelegation,
workdir_tool_scope: WorkdirScopeLease,
#[cfg(test)] installed_tools: Vec<String>,
session: InternalWorkerSessionHandle,
child_registry: Arc<SpawnedWorkerRegistry>,
change_tracker: Option<tools::Tracker>,
) -> Self {
Self {
worker_name,
scope_delegated,
workdir_delegation: Arc::new(workdir_delegation),
workdir_tool_scope: Arc::new(workdir_tool_scope),
#[cfg(test)]
installed_tools: installed_tools.into(),
session,
child_registry,
change_tracker,
started_at: Instant::now(),
stop_lock: Arc::new(tokio::sync::Mutex::new(())),
@@ -235,18 +238,56 @@ pub(crate) struct InternalSpawnReservation {
}
impl InternalSpawnReservation {
pub(crate) fn commit(mut self, record: InternalSpawnedWorkerRecord) -> io::Result<()> {
if record.worker_name != self.worker_name {
return Err(io::Error::new(
pub(crate) async fn commit(mut self, record: InternalSpawnedWorkerRecord) -> io::Result<()> {
let rejection = if record.worker_name != self.worker_name {
Some(io::Error::new(
io::ErrorKind::InvalidInput,
"internal SubWorker reservation name does not match record name",
));
))
} else {
match self.registry.internal_records.lock() {
Ok(mut records) => {
if self.registry.internal_shutting_down.load(Ordering::Acquire) {
Some(io::Error::new(
io::ErrorKind::Interrupted,
"internal SubWorker registry is shutting down",
))
} else {
records.push(record.clone());
None
}
}
Err(_) => Some(io::Error::other(
"internal spawned-worker registry lock poisoned",
)),
}
};
if let Some(error) = rejection {
let mut cleanup_failures = Vec::new();
if let Err(cleanup) = record.session.stop().await {
cleanup_failures.push(format!("stop rejected Internal SubWorker: {cleanup}"));
}
if let Err(cleanup) = Box::pin(record.child_registry.shutdown_internal()).await {
cleanup_failures.push(format!(
"stop rejected Internal SubWorker descendants: {cleanup}"
));
}
if let Err(cleanup) = record.workdir_tool_scope.close().await {
cleanup_failures.push(format!(
"close rejected Internal SubWorker Workdir tools: {cleanup}"
));
}
if cleanup_failures.is_empty() {
return Err(error);
}
self.registry
.internal_spawn_cleanup_failed
.store(true, Ordering::Release);
return Err(io::Error::other(format!(
"{error}; {}",
cleanup_failures.join("; ")
)));
}
self.registry
.internal_records
.lock()
.map_err(|_| io::Error::other("internal spawned-worker registry lock poisoned"))?
.push(record.clone());
self.registry.start_protocol_forwarding(record);
self.committed = true;
Ok(())
@@ -260,6 +301,10 @@ impl Drop for InternalSpawnReservation {
names.remove(&self.worker_name);
}
}
self.registry
.pending_internal_spawns
.fetch_sub(1, Ordering::AcqRel);
self.registry.pending_internal_notify.notify_waiters();
}
}
@@ -267,6 +312,10 @@ pub struct SpawnedWorkerRegistry {
internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>,
service_records: std::sync::Mutex<Vec<InternalServiceWorkerRecord>>,
internal_names: std::sync::Mutex<HashSet<String>>,
internal_shutting_down: AtomicBool,
pending_internal_spawns: AtomicUsize,
pending_internal_notify: Notify,
internal_spawn_cleanup_failed: AtomicBool,
parent_scope: Option<SharedScope>,
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
}
@@ -283,6 +332,10 @@ impl SpawnedWorkerRegistry {
internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()),
internal_shutting_down: AtomicBool::new(false),
pending_internal_spawns: AtomicUsize::new(0),
pending_internal_notify: Notify::new(),
internal_spawn_cleanup_failed: AtomicBool::new(false),
parent_scope: None,
parent_protocol: Mutex::new(None),
})
@@ -294,6 +347,10 @@ impl SpawnedWorkerRegistry {
internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()),
internal_shutting_down: AtomicBool::new(false),
pending_internal_spawns: AtomicUsize::new(0),
pending_internal_notify: Notify::new(),
internal_spawn_cleanup_failed: AtomicBool::new(false),
parent_scope: None,
parent_protocol: Mutex::new(None),
})
@@ -304,6 +361,10 @@ impl SpawnedWorkerRegistry {
internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()),
internal_shutting_down: AtomicBool::new(false),
pending_internal_spawns: AtomicUsize::new(0),
pending_internal_notify: Notify::new(),
internal_spawn_cleanup_failed: AtomicBool::new(false),
parent_scope: Some(parent_scope),
parent_protocol: Mutex::new(None),
})
@@ -383,6 +444,10 @@ impl SpawnedWorkerRegistry {
internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()),
internal_shutting_down: AtomicBool::new(false),
pending_internal_spawns: AtomicUsize::new(0),
pending_internal_notify: Notify::new(),
internal_spawn_cleanup_failed: AtomicBool::new(false),
parent_scope,
parent_protocol: Mutex::new(None),
}),
@@ -394,6 +459,16 @@ impl SpawnedWorkerRegistry {
self: &Arc<Self>,
worker_name: String,
) -> io::Result<InternalSpawnReservation> {
let records = self
.internal_records
.lock()
.map_err(|_| io::Error::other("internal Worker registry lock poisoned"))?;
if self.internal_shutting_down.load(Ordering::Acquire) {
return Err(io::Error::new(
io::ErrorKind::Interrupted,
"internal SubWorker registry is shutting down",
));
}
let mut names = self
.internal_names
.lock()
@@ -404,7 +479,9 @@ impl SpawnedWorkerRegistry {
format!("spawned worker `{worker_name}` is already registered"),
));
}
self.pending_internal_spawns.fetch_add(1, Ordering::AcqRel);
drop(names);
drop(records);
Ok(InternalSpawnReservation {
registry: Arc::clone(self),
worker_name,
@@ -679,18 +756,11 @@ impl SpawnedWorkerRegistry {
.unwrap_or_default()
}
pub(crate) fn reclaim_internal_scope(&self, worker_name: &str) -> io::Result<bool> {
let record = self.get_internal(worker_name).ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "internal SubWorker not found")
})?;
self.reclaim_record_scope(&record)
}
fn reclaim_record_scope(&self, record: &InternalSpawnedWorkerRecord) -> io::Result<bool> {
if !record.claim_scope_reclaim() {
return Ok(false);
}
record.workdir_delegation.release();
record.workdir_tool_scope.revoke();
let result = if let Some(parent_scope) = &self.parent_scope {
parent_scope
.update(|current| current.with_removed_deny_rules(delegated_write_rules(record)))
@@ -705,6 +775,58 @@ impl SpawnedWorkerRegistry {
result
}
pub(crate) async fn close_internal_scope(&self, name: &str) -> io::Result<bool> {
let Some(record) = self.get_internal(name) else {
return Ok(false);
};
Box::pin(record.child_registry.shutdown_internal()).await?;
record
.workdir_tool_scope
.close()
.await
.map_err(|error| io::Error::other(error.to_string()))?;
self.reclaim_record_scope(&record)
}
pub(crate) async fn shutdown_internal(&self) -> io::Result<()> {
let names = {
let records = self
.internal_records
.lock()
.map_err(|_| io::Error::other("internal Worker registry lock poisoned"))?;
self.internal_shutting_down.store(true, Ordering::Release);
records
.iter()
.map(|record| record.worker_name.clone())
.collect::<Vec<_>>()
};
loop {
let notified = self.pending_internal_notify.notified();
if self.pending_internal_spawns.load(Ordering::Acquire) == 0 {
break;
}
notified.await;
}
let mut first_error = None;
for name in names {
if let Err(error) = self.remove_internal(&name).await {
first_error.get_or_insert(error);
}
}
if first_error.is_none() && self.internal_spawn_cleanup_failed.load(Ordering::Acquire) {
first_error = Some(io::Error::other(
"an in-flight Internal SubWorker failed cleanup during shutdown",
));
}
first_error.map_or(Ok(()), Err)
}
pub(crate) fn reopen_internal(&self) {
self.internal_shutting_down.store(false, Ordering::Release);
self.internal_spawn_cleanup_failed
.store(false, Ordering::Release);
}
/// Stop one direct Internal SubWorker and discard its registry/scope state.
///
/// The child actor must acknowledge its stop before the registry is removed.
@@ -731,6 +853,12 @@ impl SpawnedWorkerRegistry {
.stop()
.await
.map_err(|error| io::Error::other(error.to_string()))?;
Box::pin(record.child_registry.shutdown_internal()).await?;
record
.workdir_tool_scope
.close()
.await
.map_err(|error| io::Error::other(error.to_string()))?;
let summary = record.stop_summary();
self.reclaim_record_scope(&record)?;
let removed =
@@ -966,7 +1094,7 @@ mod tests {
deny: Vec::new(),
})
.unwrap();
let source = workdir::delegation_capable_session(Arc::new(
let source = workdir::WorkdirToolBroker::new(Arc::new(
workdir::LocalWorkdirSession::materialized_bound(
workdir::Workdir::new("registry-test"),
root.clone(),
@@ -976,13 +1104,14 @@ mod tests {
),
));
let delegation = source
.delegate(workdir::WorkdirDelegationRequest {
rules: vec![workdir::WorkdirDelegationRule {
.scope(workdir::WorkdirToolScope {
rules: vec![workdir::WorkdirToolScopeRule {
target: workdir::WorkdirPath::new("").unwrap(),
permission: workdir::WorkdirDelegationPermission::Read,
permission: workdir::WorkdirToolScopePermission::Read,
recursive: true,
}],
cwd: workdir::WorkdirPath::new("").unwrap(),
command: false,
})
.await
.unwrap();
@@ -993,6 +1122,7 @@ mod tests {
delegation,
Vec::new(),
session,
registry(),
None,
),
sender,
@@ -1230,6 +1360,143 @@ mod tests {
}
}
#[tokio::test]
async fn parent_shutdown_stops_all_internal_workers_before_returning() {
let registry = registry();
for name in ["first", "second"] {
let (record, _events) = record(name, InternalWorkerVisibility::ParentClient).await;
record
.session
.force_status(InternalWorkerSessionStatus::Running);
install_record(&registry, record);
}
registry.shutdown_internal().await.unwrap();
assert!(registry.list_internal().is_empty());
assert!(registry.get_internal("first").is_none());
assert!(registry.get_internal("second").is_none());
}
#[tokio::test]
async fn shutdown_rejects_new_reservations_until_reopened() {
let registry = registry();
registry.shutdown_internal().await.unwrap();
assert!(registry.reserve_internal_name("late-child".into()).is_err());
registry.reopen_internal();
let reservation = registry.reserve_internal_name("late-child".into()).unwrap();
drop(reservation);
}
#[tokio::test]
async fn concurrent_commit_and_shutdown_leave_no_live_internal_worker() {
let registry = registry();
let reservation = registry
.reserve_internal_name("racing-child".into())
.unwrap();
let (record, _events) =
record("racing-child", InternalWorkerVisibility::ParentClient).await;
let scope = record.workdir_tool_scope.clone();
let barrier = Arc::new(tokio::sync::Barrier::new(2));
let commit_barrier = barrier.clone();
let commit = tokio::spawn(async move {
commit_barrier.wait().await;
reservation.commit(record).await
});
let shutdown_registry = registry.clone();
let shutdown = tokio::spawn(async move {
barrier.wait().await;
shutdown_registry.shutdown_internal().await
});
let commit = commit.await.unwrap();
shutdown.await.unwrap().unwrap();
if let Err(error) = commit {
assert_eq!(error.kind(), io::ErrorKind::Interrupted);
}
assert!(registry.list_internal().is_empty());
assert!(!scope.is_active());
}
#[tokio::test]
async fn shutdown_fences_a_reservation_that_has_not_committed() {
let registry = registry();
let reservation = registry
.reserve_internal_name("racing-child".into())
.unwrap();
let (record, _events) =
record("racing-child", InternalWorkerVisibility::ParentClient).await;
let mut shutdown = {
let registry = registry.clone();
tokio::spawn(async move { registry.shutdown_internal().await })
};
while !registry.internal_shutting_down.load(Ordering::Acquire) {
tokio::task::yield_now().await;
}
assert!(
tokio::time::timeout(std::time::Duration::from_millis(50), &mut shutdown)
.await
.is_err(),
"shutdown must wait for the pending spawn to roll back"
);
let error = reservation.commit(record).await.unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::Interrupted);
shutdown.await.unwrap().unwrap();
}
#[tokio::test]
async fn rejected_spawn_cleanup_failure_keeps_shutdown_failed_closed() {
let registry = registry();
let reservation = registry
.reserve_internal_name("cleanup-failure".into())
.unwrap();
let (record, _events) =
record("cleanup-failure", InternalWorkerVisibility::ParentClient).await;
record.session.force_stop_failure();
let shutdown = {
let registry = registry.clone();
tokio::spawn(async move { registry.shutdown_internal().await })
};
while !registry.internal_shutting_down.load(Ordering::Acquire) {
tokio::task::yield_now().await;
}
let error = reservation.commit(record).await.unwrap_err();
assert!(
error
.to_string()
.contains("stop rejected Internal SubWorker")
);
let shutdown_error = shutdown.await.unwrap().unwrap_err();
assert!(
shutdown_error
.to_string()
.contains("failed cleanup during shutdown")
);
assert!(registry.internal_shutting_down.load(Ordering::Acquire));
}
#[tokio::test]
async fn shutdown_recursively_stops_grandchildren_before_parent_scope_release() {
let registry = registry();
let (child, _child_events) = record("child", InternalWorkerVisibility::ParentClient).await;
let child_registry = child.child_registry.clone();
let (grandchild, _grandchild_events) =
record("grandchild", InternalWorkerVisibility::ParentClient).await;
let grandchild_scope = grandchild.workdir_tool_scope.clone();
install_record(&child_registry, grandchild);
install_record(&registry, child);
registry.shutdown_internal().await.unwrap();
assert!(registry.list_internal().is_empty());
assert!(child_registry.list_internal().is_empty());
assert!(!grandchild_scope.is_active());
}
#[tokio::test]
async fn running_worker_is_stopped_before_removal() {
let registry = registry();
+280 -83
View File
@@ -22,8 +22,7 @@ use manifest::{
use serde::Deserialize;
use tokio::sync::mpsc;
use workdir::{
WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule, WorkdirPath,
WorkdirSessionHandle,
WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission, WorkdirToolScopeRule,
};
use crate::PromptCatalogSource;
@@ -58,12 +57,15 @@ struct SubWorkerSpawnInput {
/// a host path and grants no authority. When omitted, the Workdir root is used.
#[serde(default)]
cwd: Option<String>,
/// First message sent to the spawned SubWorker via `Method::Run`.
/// First message sent to the spawned SubWorker via `Method::Submit`.
task: String,
/// Allow rules delegated to the spawned SubWorker. Must be a subset of the
/// spawner's explicit delegation authority; direct tool scope alone is not
/// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true.
scope: Vec<ScopeRuleInput>,
/// Explicitly grant command execution through the parent-owned Workdir tool broker.
#[serde(default)]
command: bool,
/// Binds an actual read-only builtin Reviewer child to the current Merge Request candidate.
/// Review capability material is generated by the trusted spawn layer.
#[serde(default)]
@@ -219,33 +221,50 @@ fn parse_spawn_profile_selector(raw: Option<&str>) -> Result<SpawnProfileSelecto
#[derive(Clone)]
pub(crate) enum ParentNotificationTarget {
Controller(mpsc::WeakSender<Method>),
Buffer(crate::ipc::notify_buffer::NotifyBuffer),
Controller {
sender: mpsc::WeakSender<Method>,
fallback: Arc<dyn Fn(Method) + Send + Sync>,
},
Durable(Arc<dyn Fn(Method) + Send + Sync>),
}
impl ParentNotificationTarget {
fn notify(&self, message: String, auto_run: bool) {
pub(crate) fn with_controller_fallback(
sender: mpsc::WeakSender<Method>,
fallback: ParentNotificationTarget,
) -> Self {
let ParentNotificationTarget::Durable(fallback) = fallback else {
unreachable!("controller fallback must use durable pending authority");
};
Self::Controller { sender, fallback }
}
pub(crate) fn notify(&self, child_session_id: String, message: String, auto_run: bool) {
let method = Method::NotifyTracked {
notification_request_id: protocol::new_submission_request_id(),
message,
auto_run,
source: protocol::AuthenticatedInputSource::SubWorker {
session_id: child_session_id,
},
};
match self {
Self::Controller(parent_method_tx) => {
let Some(parent_method_tx) = parent_method_tx.upgrade() else {
tracing::warn!(
"parent Worker controller closed before Internal SubWorker completion notification"
);
Self::Controller { sender, fallback } => {
let Some(parent_method_tx) = sender.upgrade() else {
fallback(method);
return;
};
let fallback = fallback.clone();
tokio::spawn(async move {
if let Err(error) = parent_method_tx
.send(Method::Notify { message, auto_run })
.await
{
if let Err(error) = parent_method_tx.send(method).await {
tracing::warn!(
%error,
"failed to notify parent Worker about Internal SubWorker completion"
"failed to notify parent Controller; using durable pending authority"
);
fallback(error.0);
}
});
}
Self::Buffer(parent_notifies) => parent_notifies.push_notify(message, auto_run),
Self::Durable(notify) => notify(method),
}
}
}
@@ -267,8 +286,8 @@ pub struct SubWorkerSpawnTool {
workspace_root: PathBuf,
/// Directory the spawned SubWorker's tools should use when the LLM did not
/// override it. Defaults to the spawner's cwd.
/// Active provider-backed Workdir session from which child leases are captured.
source_workdir_session: Option<WorkdirSessionHandle>,
/// Parent-owned broker for scoped Workdir tool execution.
workdir_tool_broker: Option<WorkdirToolBroker>,
/// Parent-owned in-memory registry shared by the five SubWorker tools.
registry: Arc<SpawnedWorkerRegistry>,
/// Spawner's resolved Manifest. `profile = "inherit"` derives the
@@ -295,7 +314,7 @@ impl SubWorkerSpawnTool {
runtime_base: PathBuf,
bash_output_dir: PathBuf,
workspace_root: PathBuf,
source_workdir_session: Option<WorkdirSessionHandle>,
workdir_tool_broker: Option<WorkdirToolBroker>,
registry: Arc<SpawnedWorkerRegistry>,
spawner_manifest: WorkerManifest,
prompt_loader: PromptCatalogSource,
@@ -308,7 +327,7 @@ impl SubWorkerSpawnTool {
runtime_base,
bash_output_dir,
workspace_root,
source_workdir_session,
workdir_tool_broker,
registry,
spawner_manifest,
prompt_loader,
@@ -341,6 +360,11 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro
"Merge Request Reviewer SubWorkers must include writable delegated scope".to_string(),
));
}
if !input.command {
return Err(ToolError::InvalidArgument(
"Merge Request Reviewer SubWorkers require an explicit command grant".to_string(),
));
}
Ok(())
}
@@ -370,7 +394,7 @@ impl Tool for SubWorkerSpawnTool {
.reserve_internal_name(input.name.clone())
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
let mut workdir_rules = parse_workdir_scope(&input.scope)?;
let workdir_rules = parse_workdir_scope(&input.scope)?;
let child_bash_output_dir = self.bash_output_dir.join("sub-workers").join(&input.name);
tokio::fs::create_dir_all(&child_bash_output_dir)
.await
@@ -380,21 +404,15 @@ impl Tool for SubWorkerSpawnTool {
child_bash_output_dir.display()
))
})?;
workdir_rules.push(WorkdirDelegationRule {
target: WorkdirPath::new_scoped(child_bash_output_dir.to_string_lossy())
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
permission: WorkdirDelegationPermission::Read,
recursive: true,
});
let source_workdir_session =
require_active_workdir_session(self.source_workdir_session.as_ref())?;
let delegation_request = workdir_delegation_request(input.cwd.as_deref(), workdir_rules)?;
let workdir_delegation = source_workdir_session
.delegate(delegation_request)
let workdir_tool_broker = require_workdir_tool_broker(self.workdir_tool_broker.as_ref())?;
let tool_scope = workdir_tool_scope(input.cwd.as_deref(), workdir_rules, input.command)?;
let workdir_scope = workdir_tool_broker
.scope(tool_scope)
.await
.map_err(|error| {
ToolError::InvalidArgument(format!("delegate Workdir session: {error}"))
ToolError::InvalidArgument(format!("scope parent-owned Workdir tools: {error}"))
})?;
let child_workdir_tool_broker = workdir_scope.broker();
let spawn_selector =
parse_spawn_profile_selector(input.profile.as_deref()).map_err(|msg| {
@@ -483,7 +501,6 @@ impl Tool for SubWorkerSpawnTool {
)
.await
.map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?;
child.bind_workdir_session(Some(workdir_delegation.scoped_session.clone()));
child
.add_scope_rules([ScopeRule {
target: child_bash_output_dir.clone(),
@@ -503,6 +520,7 @@ impl Tool for SubWorkerSpawnTool {
self.runtime_base.clone(),
child_registry.clone(),
None,
Some(child_workdir_tool_broker.clone()),
)
.await
.map_err(|error| {
@@ -531,20 +549,24 @@ impl Tool for SubWorkerSpawnTool {
InternalWorkerSessionStatus::Failed | InternalWorkerSessionStatus::Stopped
) {
if let Some(registry) = registry.upgrade() {
if let Err(error) = registry.reclaim_internal_scope(&child_name) {
tracing::warn!(
child_name,
%error,
"failed to reclaim delegated scope after Internal SubWorker failure"
);
}
let child_name = child_name.clone();
tokio::spawn(async move {
if let Err(error) = registry.close_internal_scope(&child_name).await {
tracing::warn!(
child_name,
%error,
"failed to close parent-owned Workdir tools after Internal SubWorker failure"
);
}
});
}
}
let message = format!(
"SubWorker `{child_name}` turn ended with status {status:?}. Inspect its committed session with worker-observation tools before making completion decisions."
);
parent_notifications.notify(message, true);
parent_notifications.notify(child_name.clone(), message, true);
})),
Some(child_workdir_tool_broker.clone()),
)
.await;
let session = session_result.map_err(|error| {
@@ -595,15 +617,19 @@ impl Tool for SubWorkerSpawnTool {
),
body.to_string(),
);
let response = self
.workspace_context
.client()
.execute(request)
.map_err(|error| {
ToolError::ExecutionFailed(format!("register review capability: {error}"))
})?;
let response = match self.workspace_context.client().execute(request) {
Ok(response) => response,
Err(error) => {
let _ = session.stop().await;
let _ = workdir_scope.close().await;
return Err(ToolError::ExecutionFailed(format!(
"register review capability: {error}"
)));
}
};
if !response.is_success() {
let _ = session.stop().await;
let _ = workdir_scope.close().await;
return Err(ToolError::ExecutionFailed(format!(
"register review capability failed with status {}: {}",
response.status, response.body
@@ -614,14 +640,14 @@ impl Tool for SubWorkerSpawnTool {
let record = crate::spawn::registry::InternalSpawnedWorkerRecord::new(
input.name.clone(),
scope_allow,
workdir_delegation,
workdir_scope,
#[cfg(test)]
installed_tools,
session.clone(),
child_registry,
child_change_tracker,
);
if let Err(error) = name_reservation.commit(record) {
let _ = session.stop().await;
if let Err(error) = name_reservation.commit(record).await {
return Err(ToolError::ExecutionFailed(format!(
"register Internal Worker session: {error}"
)));
@@ -667,18 +693,18 @@ fn logical_workdir_path(value: &str, field: &str) -> Result<FsPath, ToolError> {
})
}
fn parse_workdir_scope(rules: &[ScopeRuleInput]) -> Result<Vec<WorkdirDelegationRule>, ToolError> {
fn parse_workdir_scope(rules: &[ScopeRuleInput]) -> Result<Vec<WorkdirToolScopeRule>, ToolError> {
if rules.is_empty() {
return Err(ToolError::InvalidArgument("scope must not be empty".into()));
}
rules
.iter()
.map(|rule| {
Ok(WorkdirDelegationRule {
Ok(WorkdirToolScopeRule {
target: logical_workdir_path(&rule.target, "scope.target")?,
permission: match rule.permission {
PermissionInput::Read => WorkdirDelegationPermission::Read,
PermissionInput::Write => WorkdirDelegationPermission::Write,
PermissionInput::Read => WorkdirToolScopePermission::Read,
PermissionInput::Write => WorkdirToolScopePermission::Write,
},
recursive: rule.recursive,
})
@@ -686,22 +712,24 @@ fn parse_workdir_scope(rules: &[ScopeRuleInput]) -> Result<Vec<WorkdirDelegation
.collect()
}
fn workdir_delegation_request(
fn workdir_tool_scope(
cwd: Option<&str>,
rules: Vec<WorkdirDelegationRule>,
) -> Result<WorkdirDelegationRequest, ToolError> {
Ok(WorkdirDelegationRequest {
rules: Vec<WorkdirToolScopeRule>,
command: bool,
) -> Result<WorkdirToolScope, ToolError> {
Ok(WorkdirToolScope {
rules,
cwd: logical_workdir_path(cwd.unwrap_or("."), "cwd")?,
command,
})
}
fn require_active_workdir_session(
session: Option<&WorkdirSessionHandle>,
) -> Result<&WorkdirSessionHandle, ToolError> {
session.ok_or_else(|| {
fn require_workdir_tool_broker(
broker: Option<&WorkdirToolBroker>,
) -> Result<&WorkdirToolBroker, ToolError> {
broker.ok_or_else(|| {
ToolError::InvalidArgument(
"SubWorkerSpawn requires an active Workdir session; attach a Workdir before delegating filesystem access"
"SubWorkerSpawn requires parent-owned Workdir tools; attach a Workdir before granting filesystem access"
.to_string(),
)
})
@@ -939,7 +967,7 @@ pub(crate) fn sub_worker_spawn_tool(
runtime_base: PathBuf,
bash_output_dir: PathBuf,
workspace_root: PathBuf,
source_workdir_session: Option<WorkdirSessionHandle>,
workdir_tool_broker: Option<WorkdirToolBroker>,
registry: Arc<SpawnedWorkerRegistry>,
spawner_manifest: WorkerManifest,
prompts: Arc<ArcSwap<PromptCatalog>>,
@@ -951,7 +979,7 @@ pub(crate) fn sub_worker_spawn_tool(
runtime_base,
bash_output_dir,
workspace_root,
source_workdir_session,
workdir_tool_broker,
registry,
spawner_manifest,
prompts,
@@ -965,7 +993,7 @@ fn sub_worker_spawn_tool_impl(
runtime_base: PathBuf,
bash_output_dir: PathBuf,
workspace_root: PathBuf,
source_workdir_session: Option<WorkdirSessionHandle>,
workdir_tool_broker: Option<WorkdirToolBroker>,
registry: Arc<SpawnedWorkerRegistry>,
spawner_manifest: WorkerManifest,
prompts: Arc<ArcSwap<PromptCatalog>>,
@@ -997,7 +1025,7 @@ fn sub_worker_spawn_tool_impl(
runtime_base.clone(),
bash_output_dir.clone(),
workspace_root.clone(),
source_workdir_session.clone(),
workdir_tool_broker.clone(),
registry.clone(),
spawner_manifest.clone(),
prompts.load_full().source(),
@@ -1012,10 +1040,12 @@ mod tests {
use super::*;
use manifest::{DelegationScope, Permission, Scope, SharedScope};
use std::pin::Pin;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::Duration;
use crate::WorkspaceId;
use crate::feature::builtin::manage_workdir::WorkspaceAttachedWorkdirSession;
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request};
use async_trait::async_trait;
@@ -1028,12 +1058,12 @@ mod tests {
};
#[test]
fn missing_active_workdir_session_fails_deterministically() {
let error = require_active_workdir_session(None).unwrap_err();
fn missing_parent_workdir_tool_broker_fails_deterministically() {
let error = require_workdir_tool_broker(None).unwrap_err();
assert!(matches!(
error,
ToolError::InvalidArgument(message)
if message.contains("requires an active Workdir session")
if message.contains("requires parent-owned Workdir tools")
));
}
@@ -1070,6 +1100,7 @@ mod tests {
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
"name":"reviewer","task":"review","profile":"builtin:reviewer",
"scope":[{"target":"work","permission":"write"}],
"command":true,
"review":{"ticket_id":"T1"}
}))
.unwrap();
@@ -1125,12 +1156,41 @@ enabled = false
#[tokio::test]
async fn parent_controller_notification_target_does_not_keep_channel_open() {
let (parent_method_tx, mut parent_method_rx) = mpsc::channel(1);
let target = ParentNotificationTarget::Controller(parent_method_tx.downgrade());
let captured = Arc::new(std::sync::Mutex::new(false));
let captured_for_fallback = captured.clone();
let target = ParentNotificationTarget::with_controller_fallback(
parent_method_tx.downgrade(),
ParentNotificationTarget::Durable(Arc::new(move |_| {
*captured_for_fallback.lock().unwrap() = true;
})),
);
drop(parent_method_tx);
assert!(parent_method_rx.recv().await.is_none());
target.notify("late completion".to_string(), true);
target.notify("child-session".into(), "late completion".to_string(), true);
assert!(*captured.lock().unwrap());
}
#[test]
fn durable_parent_notification_target_preserves_child_source() {
let captured = Arc::new(std::sync::Mutex::new(None));
let captured_for_target = captured.clone();
let target = ParentNotificationTarget::Durable(Arc::new(move |method| {
*captured_for_target.lock().unwrap() = Some(method);
}));
target.notify("child-session".into(), "completed".into(), true);
assert!(matches!(
captured.lock().unwrap().take(),
Some(Method::NotifyTracked {
message,
auto_run: true,
source: protocol::AuthenticatedInputSource::SubWorker { session_id },
..
}) if session_id == "child-session" && message == "completed"
));
}
#[tokio::test]
@@ -1164,7 +1224,7 @@ enabled = false
let fail_requests = Arc::new(AtomicBool::new(false));
let prompt_loader = PromptCatalogSource::builtins_only();
let (parent_method_tx, mut parent_method_rx) = mpsc::channel(8);
let source_workdir_session = workdir::delegation_capable_session(Arc::new(
let workdir_tool_broker = workdir::WorkdirToolBroker::new(Arc::new(
workdir::LocalWorkdirSession::materialized_bound(
workdir::Workdir::new("test-workdir"),
workspace_root.clone(),
@@ -1176,11 +1236,14 @@ enabled = false
let tool = SubWorkerSpawnTool::new(
"parent".into(),
workspace_context,
ParentNotificationTarget::Controller(parent_method_tx.downgrade()),
ParentNotificationTarget::with_controller_fallback(
parent_method_tx.downgrade(),
ParentNotificationTarget::Durable(Arc::new(|_| {})),
),
runtime.path().to_path_buf(),
bash_output_dir.clone(),
workspace_root.clone(),
Some(source_workdir_session),
Some(workdir_tool_broker),
registry.clone(),
manifest.clone(),
prompt_loader,
@@ -1203,7 +1266,8 @@ enabled = false
"target": ".",
"permission": "write",
"recursive": true
}]
}],
"command": true
});
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
@@ -1264,10 +1328,13 @@ enabled = false
.expect("parent method channel remains open");
assert!(matches!(
completion,
Method::Notify {
Method::NotifyTracked {
message,
auto_run: true,
} if message.contains("SubWorker `reviewer-child` turn ended with status Idle")
source: protocol::AuthenticatedInputSource::SubWorker { session_id },
..
} if session_id == "reviewer-child"
&& message.contains("SubWorker `reviewer-child` turn ended with status Idle")
));
assert!(!runtime.path().join("reviewer-child/sock").exists());
@@ -1353,7 +1420,7 @@ enabled = false
"Stopped terminal child must release its delegated Workdir session"
);
assert!(
!record.workdir_delegation.is_active(),
!record.workdir_tool_scope.is_active(),
"stopped child must revoke cloned scoped sessions"
);
assert!(registry.get_internal("reviewer-child").is_some());
@@ -1384,6 +1451,94 @@ enabled = false
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
}
#[tokio::test]
async fn remote_subworker_spawn_keeps_worker_host_output_path_out_of_workdir_scope() {
let runtime = TempDir::new().unwrap();
let workspace_root = runtime.path().join("worker-host/project");
let bash_output_dir = runtime.path().join("worker-host/bash-output");
std::fs::create_dir_all(&workspace_root).unwrap();
std::fs::create_dir_all(&bash_output_dir).unwrap();
let mut manifest = parent_manifest(&workspace_root, None);
manifest
.scope
.allow
.push(abs_rule(&bash_output_dir, Permission::Read));
manifest.delegation_scope = ScopeConfig {
allow: vec![abs_rule(&workspace_root, Permission::Write)],
deny: Vec::new(),
};
let spawner_scope = SharedScope::new(Scope::from_config(&manifest.scope).unwrap());
let registry = SpawnedWorkerRegistry::new_internal("parent".into(), spawner_scope.clone());
let workspace_context = crate::worker::WorkerWorkspaceContext::with_client(
Some(WorkspaceId::new("workspace-test").unwrap()),
Arc::new(AvailableWorkspaceClient),
);
let remote_client = Arc::new(StrictRemoteWorkdirWorkspaceClient::default());
let workdir_tool_broker = workdir::WorkdirToolBroker::new(
WorkspaceAttachedWorkdirSession::handle(remote_client.clone()),
);
let calls = Arc::new(AtomicUsize::new(0));
let (parent_method_tx, _parent_method_rx) = mpsc::channel(8);
let tool = SubWorkerSpawnTool::new(
"parent".into(),
workspace_context,
ParentNotificationTarget::with_controller_fallback(
parent_method_tx.downgrade(),
ParentNotificationTarget::Durable(Arc::new(|_| {})),
),
runtime.path().to_path_buf(),
bash_output_dir.clone(),
workspace_root.clone(),
Some(workdir_tool_broker),
registry.clone(),
manifest,
PromptCatalogSource::builtins_only(),
AvailableProfiles::discover(&workspace_root),
)
.with_internal_client(Box::new(ScriptedInternalClient {
calls: calls.clone(),
parent_scope: spawner_scope,
delegated_path: workspace_root.clone(),
observed_parent_write_revoked: Arc::new(AtomicBool::new(false)),
observed_instruction_override: Arc::new(AtomicBool::new(false)),
fail_requests: Arc::new(AtomicBool::new(false)),
}));
tool.execute(
&serde_json::json!({
"name": "remote-child",
"profile": "inherit",
"instruction": "role.reviewer",
"task": "inspect the remote Workdir",
"scope": [{
"target": ".",
"permission": "write",
"recursive": true
}]
})
.to_string(),
agen::tool::ToolExecutionContext::direct(),
)
.await
.expect("remote Workdir delegation must not receive Worker-host paths");
let record = registry
.get_internal("remote-child")
.expect("remote Internal Worker registry record");
assert_eq!(
record.session.wait_until_idle().await,
crate::internal_worker::InternalWorkerSessionStatus::Idle
);
assert!(record.installed_tools.iter().any(|tool| tool == "Write"));
assert!(!record.installed_tools.iter().any(|tool| tool == "Bash"));
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(
remote_client.requests().is_empty(),
"spawning a child must not open or delegate a provider Workdir session"
);
}
#[test]
fn spawn_worker_input_schema_includes_optional_cwd() {
let schema = serde_json::to_value(schemars::schema_for!(SubWorkerSpawnInput)).unwrap();
@@ -1392,6 +1547,7 @@ enabled = false
.and_then(serde_json::Value::as_object)
.expect("schema properties");
assert!(properties.contains_key("cwd"), "schema: {schema}");
assert!(properties.contains_key("command"), "schema: {schema}");
let required = schema
.get("required")
.and_then(serde_json::Value::as_array)
@@ -1518,6 +1674,47 @@ enabled = false
}
}
#[derive(Debug, Default)]
struct StrictRemoteWorkdirWorkspaceClient {
requests: Mutex<Vec<WorkspaceRequest>>,
}
impl StrictRemoteWorkdirWorkspaceClient {
fn requests(&self) -> Vec<WorkspaceRequest> {
self.requests
.lock()
.expect("remote Workdir request lock")
.clone()
}
}
impl WorkspaceClient for StrictRemoteWorkdirWorkspaceClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace-test")
}
fn kind(&self) -> &str {
"strict-remote-workdir-test"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests
.lock()
.expect("remote Workdir request lock")
.push(request);
Err(WorkspaceClientError::Request(
"SubWorker spawn must not call the remote Workdir provider".into(),
))
}
}
fn parent_manifest(root: &Path, deny: Option<&Path>) -> WorkerManifest {
WorkerManifestConfig {
worker: WorkerMetaConfig {
File diff suppressed because it is too large Load Diff
+206 -7
View File
@@ -72,6 +72,41 @@ impl LlmClient for MockClient {
}
}
#[derive(Clone)]
struct BlockingCompactClient {
calls: Arc<AtomicUsize>,
}
impl BlockingCompactClient {
fn new() -> Self {
Self {
calls: Arc::new(AtomicUsize::new(0)),
}
}
}
#[async_trait]
impl LlmClient for BlockingCompactClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
_request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
let call = self.calls.fetch_add(1, Ordering::SeqCst);
if call == 0 {
Ok(Box::pin(futures::stream::iter(
single_text_events("seed").into_iter().map(Ok),
)))
} else {
Ok(Box::pin(futures::stream::pending()))
}
}
}
fn single_text_events(text: &str) -> Vec<LlmEvent> {
vec![
LlmEvent::text_block_start(0),
@@ -156,10 +191,10 @@ target = "./"
permission = "write"
"#;
async fn make_worker_with_manifest(
manifest_toml: &str,
client: MockClient,
) -> Worker<MockClient, TestStore> {
async fn make_worker_with_manifest<C>(manifest_toml: &str, client: C) -> Worker<C, TestStore>
where
C: LlmClient + Clone + Send + Sync + 'static,
{
let manifest = worker::WorkerManifest::from_toml(manifest_toml).unwrap();
let store_tmp = tempfile::tempdir().unwrap();
@@ -614,12 +649,144 @@ async fn pre_run_compact_failure_broadcasts_start_and_failed() {
);
}
#[tokio::test]
async fn manual_compact_cancel_terminalizes_before_returning_idle() {
let worker =
make_worker_with_manifest(POST_RUN_MANIFEST_TOML, BlockingCompactClient::new()).await;
let runtime_tmp = tempfile::tempdir().unwrap();
let bash_output_dir = runtime_tmp.path().join("bash-output");
let (handle, shutdown_receiver) =
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
.await
.unwrap();
let mut rx = handle.subscribe();
handle
.send(Method::submit_text(
protocol::new_submission_request_id(),
"seed history",
))
.await
.expect("send seed run");
loop {
if matches!(
tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("timeout waiting for seed run")
.expect("event"),
Event::RunEnd {
result: RunResult::Finished
}
) {
break;
}
}
let compact = protocol::WorkerCommandEnvelope::for_snapshot(1, &handle.shared_state.snapshot());
handle
.send(Method::Compact { command: compact })
.await
.expect("send compact");
loop {
if matches!(
tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("timeout waiting for compact start")
.expect("event"),
Event::CompactStart { .. }
) {
break;
}
}
let cancel = protocol::WorkerCommandEnvelope::for_snapshot(2, &handle.shared_state.snapshot());
handle
.send(Method::Cancel { command: cancel })
.await
.expect("send compact cancel");
let mut saw_interrupted = false;
let mut saw_idle = false;
while !(saw_interrupted && saw_idle) {
match tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("timeout waiting for compact cancellation")
.expect("event")
{
Event::CompactFailed { lifecycle }
if lifecycle.state == protocol::CompactionLifecycleState::Interrupted =>
{
saw_interrupted = true;
}
Event::WorkerState { snapshot }
if snapshot.catalog_status() == protocol::WorkerStatus::Idle =>
{
assert!(
saw_interrupted,
"Idle must follow durable Interrupted evidence"
);
saw_idle = true;
}
_ => {}
}
}
let compact = protocol::WorkerCommandEnvelope::for_snapshot(3, &handle.shared_state.snapshot());
handle
.send(Method::Compact { command: compact })
.await
.expect("send second compact");
loop {
if matches!(
tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("timeout waiting for second compact start")
.expect("event"),
Event::CompactStart { .. }
) {
break;
}
}
let shutdown =
protocol::WorkerCommandEnvelope::for_snapshot(4, &handle.shared_state.snapshot());
handle
.send(Method::Shutdown { command: shutdown })
.await
.expect("send shutdown during compact");
let mut interrupted_before_shutdown = false;
loop {
match tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("timeout waiting for shutdown")
.expect("event")
{
Event::CompactFailed { lifecycle }
if lifecycle.state == protocol::CompactionLifecycleState::Interrupted =>
{
interrupted_before_shutdown = true;
}
Event::Shutdown => {
assert!(
interrupted_before_shutdown,
"shutdown must await terminal compaction evidence"
);
break;
}
_ => {}
}
}
tokio::time::timeout(std::time::Duration::from_secs(2), shutdown_receiver)
.await
.expect("controller shutdown timeout")
.expect("shutdown confirmation");
}
#[tokio::test]
async fn controller_compact_method_emits_start_and_done() {
let client = MockClient::new(vec![
text_events_with_usage("hi", 1000),
write_summary_tool_use_events("manual-summary", "manual compact summary"),
single_text_events("done"),
single_text_events("follow-up"),
]);
let worker = make_worker_with_manifest(POST_RUN_MANIFEST_TOML, client).await;
let runtime_tmp = tempfile::tempdir().unwrap();
@@ -630,7 +797,10 @@ async fn controller_compact_method_emits_start_and_done() {
let mut rx = handle.subscribe();
handle
.send(Method::run_text("seed history"))
.send(Method::submit_text(
protocol::new_submission_request_id(),
"seed history",
))
.await
.expect("send run");
loop {
@@ -646,7 +816,11 @@ async fn controller_compact_method_emits_start_and_done() {
}
}
handle.send(Method::Compact).await.expect("send compact");
let command = protocol::WorkerCommandEnvelope::for_snapshot(1, &handle.shared_state.snapshot());
handle
.send(Method::Compact { command })
.await
.expect("send compact");
let mut saw_start = false;
loop {
match tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
@@ -667,5 +841,30 @@ async fn controller_compact_method_emits_start_and_done() {
}
assert!(saw_start, "manual compact should emit CompactStart");
let _ = handle.send(Method::Shutdown).await;
handle
.send(Method::submit_text(
protocol::new_submission_request_id(),
"run after compact",
))
.await
.expect("send follow-up run");
loop {
match tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await
.expect("timeout waiting for follow-up run")
.expect("event")
{
Event::RunEnd {
result: RunResult::Finished,
} => break,
_ => {}
}
}
assert_eq!(
handle.shared_state.catalog_status(),
protocol::WorkerStatus::Idle,
"successful manual compaction must release the execution fence"
);
let command = protocol::WorkerCommandEnvelope::for_snapshot(2, &handle.shared_state.snapshot());
let _ = handle.send(Method::Shutdown { command }).await;
}
File diff suppressed because it is too large Load Diff
+695 -7
View File
@@ -539,6 +539,8 @@ pub enum WorkspaceAuthConfig {
pub struct WorkspacePermissionSummary {
pub manage_repositories: bool,
pub manage_secrets: bool,
pub manage_runtimes: bool,
pub delete_workspace: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -605,6 +607,341 @@ pub struct WorkspaceMetadataMutationResponse {
pub diagnostics: Vec<Diagnostic>,
}
pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128;
pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128;
pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256;
pub const WORKSPACE_DELETION_MAX_BLOCKERS: usize = 1024;
pub const WORKSPACE_DELETION_MAX_CHILD_OPERATION_IDS: usize = 4096;
pub const WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES: usize = 128;
pub const WORKSPACE_DELETION_MAX_BLOCKER_MESSAGE_BYTES: usize = 512;
fn deserialize_workspace_deletion_operation_id<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
if value.len() > WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES {
return Err(serde::de::Error::custom(
"Workspace deletion operation_id is too long",
));
}
Ok(value)
}
fn deserialize_workspace_deletion_revision<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
if value.len() > WORKSPACE_DELETION_MAX_REVISION_BYTES {
return Err(serde::de::Error::custom(
"Workspace deletion revision is too long",
));
}
Ok(value)
}
fn deserialize_workspace_deletion_confirmation<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
if value.len() > WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES {
return Err(serde::de::Error::custom(
"Workspace deletion confirmation is too long",
));
}
Ok(value)
}
fn deserialize_workspace_deletion_resource_value<'de, D>(
deserializer: D,
) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
if value
.as_ref()
.is_some_and(|value| value.len() > WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES)
{
return Err(serde::de::Error::custom(
"Workspace deletion resource value is too long",
));
}
Ok(value)
}
fn deserialize_workspace_deletion_blocker_message<'de, D>(
deserializer: D,
) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
if value.len() > WORKSPACE_DELETION_MAX_BLOCKER_MESSAGE_BYTES {
return Err(serde::de::Error::custom(
"Workspace deletion blocker message is too long",
));
}
Ok(value)
}
fn deserialize_workspace_deletion_blockers<'de, D>(
deserializer: D,
) -> Result<Vec<WorkspaceDeletionBlocker>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Vec::<WorkspaceDeletionBlocker>::deserialize(deserializer)?;
if value.len() > WORKSPACE_DELETION_MAX_BLOCKERS {
return Err(serde::de::Error::custom(
"too many Workspace deletion blockers",
));
}
Ok(value)
}
fn deserialize_workspace_deletion_child_operation_ids<'de, D>(
deserializer: D,
) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Vec::<String>::deserialize(deserializer)?;
if value.len() > WORKSPACE_DELETION_MAX_CHILD_OPERATION_IDS {
return Err(serde::de::Error::custom(
"too many Workspace deletion child operations",
));
}
if value
.iter()
.any(|operation_id| operation_id.len() > WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES)
{
return Err(serde::de::Error::custom(
"Workspace deletion child operation_id is too long",
));
}
Ok(value)
}
/// Lifecycle state for one durable Workspace deletion operation.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceDeletionState {
Queued,
Running,
Blocked,
Failed,
Succeeded,
}
/// Stable category explaining why Workspace deletion cannot currently advance.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceDeletionBlockerKind {
LastAccessibleWorkspace,
RevisionConflict,
DirtyWorkdir,
WorkerRemovalBlocked,
WorkdirRemovalBlocked,
RetentionHold,
CleanupUnavailable,
}
/// One bounded, user-actionable blocker returned by preflight or execution.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkspaceDeletionBlocker {
pub kind: WorkspaceDeletionBlockerKind,
pub resource_kind: Option<String>,
pub resource_key: Option<String>,
pub message: String,
}
/// Workspace-owned resources summarized before destructive confirmation.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceDeletionResourceCounts {
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub workers: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub workdirs: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub repositories: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub runtime_bindings: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub secrets: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub artifacts: u64,
}
/// Owner-only impact preview for deleting one Workspace.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkspaceDeletionPreflightResponse {
pub workspace_id: String,
pub display_name: String,
/// Opaque persisted Workspace metadata revision used as a CAS fence.
pub expected_revision: String,
pub can_delete: bool,
pub resources: WorkspaceDeletionResourceCounts,
pub blockers: Vec<WorkspaceDeletionBlocker>,
}
/// Idempotent request to start or resume Workspace deletion.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkspaceDeletionRequest {
pub operation_id: String,
pub expected_revision: String,
pub confirmation: String,
}
/// Durable deletion operation projection used by request responses and polling.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkspaceDeletionOperationResponse {
pub operation_id: String,
pub workspace_id: String,
pub display_name: String,
pub state: WorkspaceDeletionState,
pub resources: WorkspaceDeletionResourceCounts,
pub child_operation_ids: Vec<String>,
pub blockers: Vec<WorkspaceDeletionBlocker>,
pub failure_category: Option<String>,
pub created_at: String,
pub updated_at: String,
pub completed_at: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkspaceDeletionBlockerWire {
kind: WorkspaceDeletionBlockerKind,
#[serde(deserialize_with = "deserialize_workspace_deletion_resource_value")]
resource_kind: Option<String>,
#[serde(deserialize_with = "deserialize_workspace_deletion_resource_value")]
resource_key: Option<String>,
#[serde(deserialize_with = "deserialize_workspace_deletion_blocker_message")]
message: String,
}
impl<'de> Deserialize<'de> for WorkspaceDeletionBlocker {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = WorkspaceDeletionBlockerWire::deserialize(deserializer)?;
Ok(Self {
kind: wire.kind,
resource_kind: wire.resource_kind,
resource_key: wire.resource_key,
message: wire.message,
})
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkspaceDeletionPreflightResponseWire {
workspace_id: String,
display_name: String,
#[serde(deserialize_with = "deserialize_workspace_deletion_revision")]
expected_revision: String,
can_delete: bool,
resources: WorkspaceDeletionResourceCounts,
#[serde(deserialize_with = "deserialize_workspace_deletion_blockers")]
blockers: Vec<WorkspaceDeletionBlocker>,
}
impl<'de> Deserialize<'de> for WorkspaceDeletionPreflightResponse {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = WorkspaceDeletionPreflightResponseWire::deserialize(deserializer)?;
Ok(Self {
workspace_id: wire.workspace_id,
display_name: wire.display_name,
expected_revision: wire.expected_revision,
can_delete: wire.can_delete,
resources: wire.resources,
blockers: wire.blockers,
})
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkspaceDeletionRequestWire {
#[serde(deserialize_with = "deserialize_workspace_deletion_operation_id")]
operation_id: String,
#[serde(deserialize_with = "deserialize_workspace_deletion_revision")]
expected_revision: String,
#[serde(deserialize_with = "deserialize_workspace_deletion_confirmation")]
confirmation: String,
}
impl<'de> Deserialize<'de> for WorkspaceDeletionRequest {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = WorkspaceDeletionRequestWire::deserialize(deserializer)?;
Ok(Self {
operation_id: wire.operation_id,
expected_revision: wire.expected_revision,
confirmation: wire.confirmation,
})
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkspaceDeletionOperationResponseWire {
#[serde(deserialize_with = "deserialize_workspace_deletion_operation_id")]
operation_id: String,
workspace_id: String,
display_name: String,
state: WorkspaceDeletionState,
resources: WorkspaceDeletionResourceCounts,
#[serde(deserialize_with = "deserialize_workspace_deletion_child_operation_ids")]
child_operation_ids: Vec<String>,
#[serde(deserialize_with = "deserialize_workspace_deletion_blockers")]
blockers: Vec<WorkspaceDeletionBlocker>,
failure_category: Option<String>,
created_at: String,
updated_at: String,
completed_at: Option<String>,
}
impl<'de> Deserialize<'de> for WorkspaceDeletionOperationResponse {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = WorkspaceDeletionOperationResponseWire::deserialize(deserializer)?;
Ok(Self {
operation_id: wire.operation_id,
workspace_id: wire.workspace_id,
display_name: wire.display_name,
state: wire.state,
resources: wire.resources,
child_operation_ids: wire.child_operation_ids,
blockers: wire.blockers,
failure_category: wire.failure_category,
created_at: wire.created_at,
updated_at: wire.updated_at,
completed_at: wire.completed_at,
})
}
}
/// Read-only Profile catalog projected from one active Workspace config revision.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@@ -1135,6 +1472,7 @@ pub struct ObjectiveLinkTicketRequest {
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeSourceKind {
EmbeddedWorkerRuntime,
@@ -1142,6 +1480,7 @@ pub enum RuntimeSourceKind {
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeSourceStatus {
Active,
@@ -1149,6 +1488,7 @@ pub enum RuntimeSourceStatus {
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeIdentityAuthority {
RuntimeRegistryProjection,
@@ -1156,6 +1496,8 @@ pub enum RuntimeIdentityAuthority {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimeSourceSummary {
pub kind: RuntimeSourceKind,
pub status: RuntimeSourceStatus,
@@ -1164,6 +1506,7 @@ pub struct RuntimeSourceSummary {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct RuntimeSummary {
pub runtime_id: String,
pub label: String,
@@ -1180,6 +1523,8 @@ pub struct RuntimeSummary {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimeManagementSummary {
pub built_in: bool,
pub config_managed: bool,
@@ -1189,12 +1534,124 @@ pub struct RuntimeManagementSummary {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct WorkspaceRuntimeResource {
#[serde(flatten)]
pub runtime: RuntimeSummary,
pub management: RuntimeManagementSummary,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeTrustKeyStatus {
Unconfigured,
Active,
Revoked,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimeTrustKeyState {
pub status: RuntimeTrustKeyStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updated_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeTrustAuditAction {
Created,
Replaced,
Reactivated,
Revoked,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimeTrustAuditEntry {
pub action: RuntimeTrustAuditAction,
pub actor_account_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_fingerprint: Option<String>,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub revision: u64,
pub at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceRuntimeDetail {
pub workspace_id: String,
pub runtime: WorkspaceRuntimeResource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
pub trust_key: RuntimeTrustKeyState,
#[serde(default)]
pub recent_audit: Vec<RuntimeTrustAuditEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimeTrustKeyRevealResponse {
pub public_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PutRuntimeTrustKeyRequest {
pub public_key: String,
#[serde(default)]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub expected_revision: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RevokeRuntimeTrustKeyRequest {
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub expected_revision: u64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeTrustConflictKind {
StaleRevision,
FingerprintInUse,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimeTrustConflictResponse {
pub error: RuntimeTrustConflictKind,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub current_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_fingerprint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateRemoteRuntimeRequest {
@@ -1205,16 +1662,39 @@ pub struct CreateRemoteRuntimeRequest {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeConnectionTestStatus {
Compatible,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeConnectionTestFailureKind {
Authentication,
Authorization,
NetworkUnreachable,
Timeout,
TlsOrTransport,
MalformedResponse,
ProtocolVersionMismatch,
RuntimeIdentityMismatch,
Configuration,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimeConnectionTestResponse {
pub workspace_id: String,
pub runtime_id: String,
pub checked_at: String,
pub state: String,
pub protocol_version: Option<String>,
pub compatibility_basis: String,
#[serde(default)]
pub capabilities: Vec<String>,
pub health_result: String,
pub status: RuntimeConnectionTestStatus,
pub failure_kind: Option<RuntimeConnectionTestFailureKind>,
pub expected_protocol_version: u32,
pub actual_protocol_version: Option<u32>,
#[serde(default)]
pub diagnostics: Vec<Diagnostic>,
}
@@ -1424,7 +1904,12 @@ pub struct WorkerSummary {
#[serde(default)]
pub tags: Vec<String>,
pub workspace: WorkerWorkspaceSummary,
/// Runtime catalog lifecycle compatibility state. Live foreground state, when
/// available, is carried separately in `worker_state`.
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub worker_state: Option<protocol::WorkerStateSnapshot>,
pub last_seen_at: Option<String>,
#[serde(default)]
pub pinned: bool,
@@ -2348,6 +2833,13 @@ pub fn catalog_typescript() -> String {
WorkspaceCreateResponse::decl(&config),
WorkspaceAuthConfig::decl(&config),
WorkspacePermissionSummary::decl(&config),
WorkspaceDeletionState::decl(&config),
WorkspaceDeletionBlockerKind::decl(&config),
WorkspaceDeletionBlocker::decl(&config),
WorkspaceDeletionResourceCounts::decl(&config),
WorkspaceDeletionPreflightResponse::decl(&config),
WorkspaceDeletionRequest::decl(&config),
WorkspaceDeletionOperationResponse::decl(&config),
DiagnosticSeverity::decl(&config),
Diagnostic::decl(&config),
WorkspaceExtensionPointState::decl(&config),
@@ -2371,6 +2863,26 @@ pub fn catalog_typescript() -> String {
RepositoryListResponse::decl(&config),
RepositoryDetailResponse::decl(&config),
RepositoryLogResponse::decl(&config),
RuntimeSourceKind::decl(&config),
RuntimeSourceStatus::decl(&config),
RuntimeIdentityAuthority::decl(&config),
RuntimeSourceSummary::decl(&config),
RuntimeSummary::decl(&config),
RuntimeManagementSummary::decl(&config),
WorkspaceRuntimeResource::decl(&config),
RuntimeTrustKeyStatus::decl(&config),
RuntimeTrustKeyState::decl(&config),
RuntimeTrustAuditAction::decl(&config),
RuntimeTrustAuditEntry::decl(&config),
WorkspaceRuntimeDetail::decl(&config),
RuntimeTrustKeyRevealResponse::decl(&config),
PutRuntimeTrustKeyRequest::decl(&config),
RevokeRuntimeTrustKeyRequest::decl(&config),
RuntimeTrustConflictKind::decl(&config),
RuntimeTrustConflictResponse::decl(&config),
RuntimeConnectionTestStatus::decl(&config),
RuntimeConnectionTestFailureKind::decl(&config),
RuntimeConnectionTestResponse::decl(&config),
]
.map(|declaration| format!("export {declaration}"));
@@ -2981,6 +3493,81 @@ mod tests {
}
}
#[test]
fn workspace_deletion_wire_contract_is_closed_and_typed() {
let preflight = WorkspaceDeletionPreflightResponse {
workspace_id: "workspace-test".to_string(),
display_name: "Test".to_string(),
expected_revision: "revision-7".to_string(),
can_delete: true,
resources: WorkspaceDeletionResourceCounts {
workers: 2,
workdirs: 1,
repositories: 1,
runtime_bindings: 1,
secrets: 0,
artifacts: 3,
},
blockers: Vec::new(),
};
let value = serde_json::to_value(&preflight).unwrap();
assert_eq!(
serde_json::from_value::<WorkspaceDeletionPreflightResponse>(value.clone()).unwrap(),
preflight
);
let mut stale = value.as_object().unwrap().clone();
stale.insert("revision".to_string(), serde_json::json!(7));
assert!(
serde_json::from_value::<WorkspaceDeletionPreflightResponse>(stale.into()).is_err()
);
assert!(
serde_json::from_value::<WorkspaceDeletionRequest>(serde_json::json!({
"operation_id": "delete-test",
"expected_revision": "revision-7",
"confirmation": "Test",
"workspace_id": "caller-controlled"
}))
.is_err()
);
assert!(
serde_json::from_value::<WorkspaceDeletionRequest>(serde_json::json!({
"operation_id": "x".repeat(WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES + 1),
"expected_revision": "revision-7",
"confirmation": "Test"
}))
.is_err()
);
assert!(
serde_json::from_value::<WorkspaceDeletionOperationResponse>(serde_json::json!({
"operation_id": "delete-test",
"workspace_id": "workspace-test",
"display_name": "Test",
"state": "blocked",
"resources": {
"workers": 0,
"workdirs": 0,
"repositories": 0,
"runtime_bindings": 0,
"secrets": 0,
"artifacts": 0
},
"child_operation_ids": [],
"blockers": (0..=WORKSPACE_DELETION_MAX_BLOCKERS).map(|_| serde_json::json!({
"kind": "cleanup_unavailable",
"resource_kind": null,
"resource_key": null,
"message": "blocked"
})).collect::<Vec<_>>(),
"failure_category": null,
"created_at": "1",
"updated_at": "1",
"completed_at": null
}))
.is_err()
);
}
#[test]
fn workspace_and_repository_response_shapes_round_trip() {
let workspace = serde_json::json!({
@@ -2996,7 +3583,9 @@ mod tests {
}},
"permissions": {
"manage_repositories": true,
"manage_secrets": true
"manage_secrets": true,
"manage_runtimes": true,
"delete_workspace": true
},
"extension_points": {
"store": "sqlite",
@@ -3060,6 +3649,102 @@ mod tests {
assert!(serde_json::from_value::<RepositoryListResponse>(stale).is_err());
}
#[test]
fn runtime_detail_and_trust_mutations_are_closed_and_typed() {
let detail = serde_json::json!({
"workspace_id": "workspace-test",
"runtime": {
"runtime_id": "runtime-test",
"label": "Runtime Test",
"kind": "remote_http",
"status": "active",
"source": {
"kind": "remote_http",
"status": "active",
"identity_authority": "runtime_registry_projection",
"note": "active"
},
"host_ids": [],
"worker_creation_available": true,
"os": "linux",
"arch": "x86_64",
"diagnostics": [],
"management": {
"built_in": false,
"config_managed": true,
"removable": true,
"endpoint_configured": true,
"token_ref_configured": false
}
},
"endpoint": "https://runtime.example",
"trust_key": {
"status": "active",
"fingerprint": "SHA256:test",
"revision": 2,
"created_at": "2026-09-01T12:00:00Z",
"updated_at": "2026-09-01T13:00:00Z"
},
"recent_audit": [{
"action": "replaced",
"actor_account_id": "account-owner",
"old_fingerprint": "SHA256:old",
"new_fingerprint": "SHA256:test",
"revision": 2,
"at": "2026-09-01T13:00:00Z"
}]
});
let parsed: WorkspaceRuntimeDetail = serde_json::from_value(detail.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), detail);
let mut unknown = detail;
unknown["trust_key"]["private_key"] = serde_json::json!("forbidden");
assert!(serde_json::from_value::<WorkspaceRuntimeDetail>(unknown).is_err());
assert!(
serde_json::from_value::<RuntimeTrustKeyRevealResponse>(serde_json::json!({
"public_key": "yoi-ed25519-pub:v1:key",
"private_key": "forbidden"
}))
.is_err()
);
assert!(
serde_json::from_value::<PutRuntimeTrustKeyRequest>(serde_json::json!({
"public_key": "key",
"expected_revision": 1,
"replace": true
}))
.is_err()
);
assert!(
serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({
"expected_revision": 1,
"delete_runtime": true
}))
.is_err()
);
}
#[test]
fn runtime_connection_test_response_is_closed_and_typed() {
let compatible = serde_json::json!({
"workspace_id": "workspace-test",
"runtime_id": "runtime-test",
"checked_at": "2026-09-01T12:00:00Z",
"status": "compatible",
"failure_kind": null,
"expected_protocol_version": 1,
"actual_protocol_version": 1,
"diagnostics": []
});
let parsed: RuntimeConnectionTestResponse =
serde_json::from_value(compatible.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), compatible);
let mut unknown = compatible;
unknown["capabilities"] = serde_json::json!(["shell"]);
assert!(serde_json::from_value::<RuntimeConnectionTestResponse>(unknown).is_err());
}
#[cfg(feature = "typescript")]
#[test]
fn generated_catalog_typescript_keeps_public_wrappers_and_nullability() {
@@ -3080,6 +3765,9 @@ mod tests {
assert!(output.contains(
"export type WorkspaceProfileSourceProvenance = \"project_profile_source_tree\""
));
assert!(output.contains("export type RuntimeConnectionTestResponse ="));
assert!(output.contains("status: RuntimeConnectionTestStatus"));
assert!(output.contains("failure_kind: RuntimeConnectionTestFailureKind | null"));
assert!(!output.contains("repository_key: string, display_name"));
}
+1
View File
@@ -45,6 +45,7 @@ workdir = { workspace = true, features = ["http-client"] }
worker-runtime.workspace = true
toml.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
ts-rs = { version = "12.0.1", optional = true }
url.workspace = true
uuid = { workspace = true, features = ["v7"] }
+7 -217
View File
@@ -5,12 +5,10 @@ use std::{fs, io};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::hosts::RemoteRuntimeConfig;
use crate::identity::WorkspaceIdentity;
use crate::server::{AuthConfig, ServerConfig};
use crate::{Error, Result};
pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173";
@@ -51,31 +49,6 @@ fn default_browser_public_url() -> String {
DEFAULT_BROWSER_PUBLIC_URL.to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct BackendRuntimesConfigFile {
#[serde(default)]
pub runtimes: WorkspaceBackendRuntimesConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendRuntimesConfig {
#[serde(default)]
pub remote: Vec<RemoteRuntimeConfigFile>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RemoteRuntimeConfigFile {
pub id: String,
pub endpoint: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub token_ref: Option<String>,
}
#[derive(Clone)]
pub struct ResolvedWorkspaceBackendConfig {
pub server: ServerConfig,
@@ -124,80 +97,11 @@ impl ServerHostConfigFile {
}
}
impl BackendRuntimesConfigFile {
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
}
pub fn default_path() -> Option<PathBuf> {
manifest::paths::config_dir().map(Self::path_for_config_dir)
}
pub fn load_default() -> Result<Self> {
match Self::default_path() {
Some(path) => Self::load_from_path(path),
None => Ok(Self::default()),
}
}
pub fn load_from_config_dir(config_dir: impl AsRef<Path>) -> Result<Self> {
Self::load_from_path(Self::path_for_config_dir(config_dir))
}
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
match fs::read_to_string(path) {
Ok(raw) => Self::parse_str(&raw, path),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn write_default(&self) -> Result<PathBuf> {
let path = Self::default_path().ok_or_else(|| {
Error::Config(
"YOI_CONFIG_DIR, YOI_HOME, XDG_CONFIG_HOME, or HOME is required to write Backend runtimes config"
.to_string(),
)
})?;
self.write_to_path(&path)?;
Ok(path)
}
pub fn write_to_config_dir(&self, config_dir: impl AsRef<Path>) -> Result<()> {
self.write_to_path(Self::path_for_config_dir(config_dir))
}
pub fn write_to_path(&self, path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let raw = toml::to_string_pretty(self).map_err(|error| {
Error::Config(format!(
"failed to serialize Backend runtimes config: {error}"
))
})?;
fs::write(path, raw)?;
Ok(())
}
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
toml::from_str(raw).map_err(|error| {
Error::Config(format!(
"failed to parse Backend runtimes config `{}`: {error}",
path.as_ref().display()
))
})
}
}
impl ResolvedWorkspaceBackendConfig {
pub fn local_dev(
workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity,
host_config: &ServerHostConfigFile,
runtime_config: &BackendRuntimesConfigFile,
) -> Result<Self> {
let workspace_root = workspace_root.as_ref();
let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id);
@@ -208,12 +112,7 @@ impl ResolvedWorkspaceBackendConfig {
server.database_path = database_path.clone();
server.embedded_runtime_store_root = data_root.join("embedded-runtime");
server.max_records = DEFAULT_MAX_RECORDS;
server.remote_runtime_sources = runtime_config
.runtimes
.remote
.iter()
.map(resolve_remote_runtime)
.collect::<Result<Vec<_>>>()?;
server.remote_runtime_sources = Vec::new();
server.auth = AuthConfig::Passkey {
rp_id: browser_rp_id,
origin: browser_public_url.clone(),
@@ -252,26 +151,6 @@ fn normalize_required_string(field: &str, value: &str) -> Result<String> {
Ok(trimmed.to_string())
}
pub(crate) fn resolve_remote_runtime(
config: &RemoteRuntimeConfigFile,
) -> Result<RemoteRuntimeConfig> {
if let Some(token_ref) = config.token_ref.as_deref() {
return Err(Error::Config(format!(
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for Backend runtime settings yet",
config.id
)));
}
Ok(RemoteRuntimeConfig::new(
config.id.clone(),
config
.display_name
.clone()
.unwrap_or_else(|| config.id.clone()),
config.endpoint.clone(),
None,
))
}
fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
let value = normalize_required_string("browser.public_url", value)?;
let url = Url::parse(&value).map_err(|error| {
@@ -314,22 +193,19 @@ mod tests {
}
}
fn resolved_with_runtimes(
runtimes: &BackendRuntimesConfigFile,
) -> ResolvedWorkspaceBackendConfig {
fn resolved() -> ResolvedWorkspaceBackendConfig {
let dir = tempfile::tempdir().unwrap();
ResolvedWorkspaceBackendConfig::local_dev(
dir.path(),
identity(),
&ServerHostConfigFile::default(),
runtimes,
)
.unwrap()
}
#[test]
fn default_settings_resolve_without_a_repository_file() {
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default());
let resolved = resolved();
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
let AuthConfig::Passkey {
@@ -354,7 +230,7 @@ mod tests {
#[test]
fn backend_base_url_is_explicit_and_normalized() {
let listen = "127.0.0.1:48787".parse().unwrap();
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default())
let resolved = resolved()
.with_listen(listen)
.with_backend_base_url("http://127.0.0.1:48787/");
@@ -376,7 +252,6 @@ mod tests {
tempfile::tempdir().unwrap().path(),
identity(),
&host_config,
&BackendRuntimesConfigFile::default(),
)
.unwrap();
@@ -407,7 +282,6 @@ mod tests {
tempfile::tempdir().unwrap().path(),
identity(),
&host_config,
&BackendRuntimesConfigFile::default(),
);
let error = match result {
Ok(_) => panic!("expected {value} to be rejected"),
@@ -446,92 +320,8 @@ mod tests {
}
#[test]
fn backend_runtimes_config_loads_from_config_dir() {
let dir = tempfile::tempdir().unwrap();
let config = BackendRuntimesConfigFile {
runtimes: WorkspaceBackendRuntimesConfig {
remote: vec![RemoteRuntimeConfigFile {
id: "arc".to_string(),
endpoint: "http://127.0.0.1:38800".to_string(),
display_name: Some("arc".to_string()),
token_ref: None,
}],
},
};
config.write_to_config_dir(dir.path()).unwrap();
let loaded = BackendRuntimesConfigFile::load_from_config_dir(dir.path()).unwrap();
assert_eq!(loaded, config);
assert_eq!(
BackendRuntimesConfigFile::path_for_config_dir(dir.path()),
dir.path().join("runtimes.toml")
);
}
#[test]
fn backend_runtimes_config_is_the_only_runtime_source() {
let runtime_config = BackendRuntimesConfigFile::parse_str(
r#"
[[runtimes.remote]]
id = "arc"
endpoint = "http://xdg.example.test"
display_name = "xdg arc"
"#,
"runtimes.toml",
)
.unwrap();
let resolved = resolved_with_runtimes(&runtime_config);
assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
assert_eq!(
resolved.server.remote_runtime_sources[0].base_url.as_str(),
"http://xdg.example.test"
);
}
#[test]
fn token_value_field_is_not_in_runtime_schema() {
let error = BackendRuntimesConfigFile::parse_str(
r#"
[[runtimes.remote]]
id = "remote"
endpoint = "http://127.0.0.1:8790"
token = "secret"
"#,
"runtimes.toml",
)
.unwrap_err();
assert!(
error.to_string().contains("unknown field"),
"unexpected error: {error}"
);
}
#[test]
fn token_ref_fails_closed_until_secret_resolution_exists() {
let runtime_config = BackendRuntimesConfigFile::parse_str(
r#"
[[runtimes.remote]]
id = "remote"
endpoint = "http://127.0.0.1:8790"
token_ref = "local:remote-token"
"#,
"runtimes.toml",
)
.unwrap();
let error = match ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(),
identity(),
&ServerHostConfigFile::default(),
&runtime_config,
) {
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("secret ref resolution is not implemented"),
"unexpected error: {error}"
);
fn local_host_config_does_not_supply_runtime_authority() {
let resolved = resolved();
assert!(resolved.server.remote_runtime_sources.is_empty());
}
}
+377 -136
View File
@@ -1,5 +1,7 @@
use crate::Error;
use crate::resource_broker::{BackendResourceBroker, BackendResourceTarget};
use crate::resource_broker::BackendResourceBroker;
#[cfg(test)]
use crate::resource_broker::BackendResourceTarget;
use chrono::Utc;
use protocol::Segment;
use reqwest::blocking::{Client as BlockingHttpClient, RequestBuilder};
@@ -9,7 +11,9 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
error::Error as _,
future::Future,
io::Read as _,
path::PathBuf,
pin::Pin,
sync::{Arc, RwLock},
@@ -22,8 +26,8 @@ use workdir::{
use worker_runtime::RuntimeWorkspaceScope;
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
ProfileSourceArchiveSource, RepositoryRefObservation, RepositoryRefObservationRequest,
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveSource,
RepositoryRefObservation, RepositoryRefObservationRequest,
WorkerDetail as EmbeddedWorkerDetail, WorkerStatus as EmbeddedWorkerStatus,
WorkingDirectoryClaim, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest,
WorkingDirectoryStatus, WorkingDirectorySummary, WorkspaceApiRef,
@@ -34,17 +38,17 @@ use worker_runtime::config_bundle::{
ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
};
use worker_runtime::error::RuntimeError as EmbeddedRuntimeError;
#[cfg(test)]
use worker_runtime::execution::WorkerExecutionRunState;
use worker_runtime::fs_store::FsRuntimeStoreOptions;
use worker_runtime::http_server::{
RUNTIME_PING_PERMISSION, RUNTIME_WORKSPACE_SCOPE_HEADER,
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
RuntimeHttpErrorResponse, RuntimeHttpRepositoryAccessResponse, RuntimeHttpSummaryResponse,
RuntimeHttpUploadedFileDeleteResponse, RuntimeHttpUploadedFileResponse,
RuntimeHttpWorkerCompletionsRequest, RuntimeHttpWorkerCompletionsResponse,
RuntimeHttpWorkerDeleteResponse, RuntimeHttpWorkerInputResponse,
RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse,
RuntimeHttpWorkerResponse, RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
RuntimeHttpErrorResponse, RuntimeHttpPingResponse, RuntimeHttpRepositoryAccessResponse,
RuntimeHttpSummaryResponse, RuntimeHttpUploadedFileDeleteResponse,
RuntimeHttpUploadedFileResponse, RuntimeHttpWorkerCompletionsRequest,
RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse,
RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest,
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse,
RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse,
};
@@ -55,15 +59,19 @@ use worker_runtime::interaction::{
WorkerInput as EmbeddedWorkerInput, WorkerInputKind as EmbeddedWorkerInputKind,
};
use worker_runtime::management::{RuntimeOptions as EmbeddedRuntimeOptions, RuntimeStatus};
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
use worker_runtime::profile_archive::ProfileSourceArchive;
use worker_runtime::retention::{
WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult, WorkerRetentionInventory,
};
pub(crate) const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
pub const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
const REMOTE_HOST_KIND: &str = "remote-worker-runtime-host";
const MAX_DIAGNOSTICS: usize = 16;
const MAX_RUNTIME_PING_RESPONSE_BYTES: usize = 8 * 1024;
// Runtime creation can spend up to 60s bootstrapping; durable Submit
// acceptance is acknowledged before the potentially long run preparation.
const REMOTE_WORKER_CREATE_TIMEOUT: Duration = Duration::from_secs(80);
const MAX_HOST_SCAN: usize = 256;
const MAX_IDENTIFIER_LEN: usize = 120;
const ID_DIGEST_HEX_LEN: usize = 16;
@@ -240,7 +248,10 @@ pub struct WorkerSummary {
#[serde(default)]
pub tags: Vec<String>,
pub workspace: WorkerWorkspaceSummary,
/// Runtime catalog lifecycle compatibility state.
pub state: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_state: Option<protocol::WorkerStateSnapshot>,
pub last_seen_at: Option<String>,
#[serde(default)]
pub pinned: bool,
@@ -332,6 +343,7 @@ pub(crate) fn workspace_worker_summary(
workspace_id: summary.workspace.workspace_id,
},
state: summary.state,
worker_state: summary.worker_state,
last_seen_at: summary.last_seen_at,
pinned: summary.pinned,
retention_state: summary.retention_state,
@@ -533,7 +545,7 @@ fn initial_worker_input(segments: &[Segment]) -> Option<EmbeddedWorkerInput> {
Some(EmbeddedWorkerInput {
kind: EmbeddedWorkerInputKind::User,
content: Segment::flatten_to_text(segments),
submission_id: None,
submission_request_id: None,
segments: Some(segments.to_vec()),
})
}
@@ -760,11 +772,50 @@ fn default_worker_input_kind() -> WorkerInputKind {
WorkerInputKind::User
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimePingFailureKind {
Authentication,
Authorization,
NetworkUnreachable,
Timeout,
TlsOrTransport,
MalformedResponse,
Configuration,
Unsupported,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimePingFailure {
pub kind: RuntimePingFailureKind,
pub diagnostic: RuntimeDiagnostic,
}
impl RuntimePingFailure {
fn new(
kind: RuntimePingFailureKind,
code: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
kind,
diagnostic: diagnostic(code, DiagnosticSeverity::Error, message.into()),
}
}
}
pub trait WorkspaceWorkerRuntime: Send + Sync {
fn runtime_id(&self) -> &str;
fn runtime_summary(&self, limit: usize) -> RuntimeSummary;
fn ping(&self) -> Result<RuntimeHttpPingResponse, RuntimePingFailure> {
Err(RuntimePingFailure::new(
RuntimePingFailureKind::Unsupported,
"runtime_ping_unsupported",
"Runtime connection testing is unavailable for this Runtime provider",
))
}
fn list_hosts(&self, limit: usize) -> RuntimeList<HostSummary>;
fn list_workers(&self, limit: usize) -> RuntimeList<WorkerSummary>;
@@ -1126,15 +1177,19 @@ pub enum RuntimeRegistryUnregisterResult {
},
}
type RuntimeBindingGate = Arc<dyn Fn(&str) -> bool + Send + Sync>;
#[derive(Clone)]
pub struct RuntimeRegistry {
runtimes: Arc<RwLock<Vec<Arc<dyn WorkspaceWorkerRuntime>>>>,
runtime_binding_gate: Arc<RwLock<Option<RuntimeBindingGate>>>,
}
impl RuntimeRegistry {
pub fn new(runtimes: Vec<Arc<dyn WorkspaceWorkerRuntime>>) -> Self {
Self {
runtimes: Arc::new(RwLock::new(runtimes)),
runtime_binding_gate: Arc::new(RwLock::new(None)),
}
}
@@ -1142,6 +1197,27 @@ impl RuntimeRegistry {
Self::new(vec![Arc::new(embedded_runtime)])
}
pub fn set_runtime_binding_gate<F>(&self, gate: F)
where
F: Fn(&str) -> bool + Send + Sync + 'static,
{
*self
.runtime_binding_gate
.write()
.expect("runtime binding gate lock poisoned") = Some(Arc::new(gate));
}
fn runtime_binding_is_active(&self, runtime_id: &str) -> bool {
if runtime_id == EMBEDDED_RUNTIME_ID {
return true;
}
self.runtime_binding_gate
.read()
.expect("runtime binding gate lock poisoned")
.as_ref()
.is_none_or(|gate| gate(runtime_id))
}
pub fn register<R>(&self, runtime: R)
where
R: WorkspaceWorkerRuntime + 'static,
@@ -1357,12 +1433,7 @@ impl RuntimeRegistry {
&self,
projection: worker::WorkspacePromptProjection,
) -> Vec<RuntimeDiagnostic> {
let runtimes = self
.runtimes
.read()
.map(|runtimes| runtimes.clone())
.unwrap_or_default();
runtimes
self.runtimes_snapshot()
.into_iter()
.filter_map(|runtime| {
runtime
@@ -1415,7 +1486,9 @@ impl RuntimeRegistry {
_ => {}
}
let runtime = self.runtime(runtime_id)?;
if let Some(bundle) = request.resolved_config_bundle.clone() {
if runtime_id == EMBEDDED_RUNTIME_ID
&& let Some(bundle) = request.resolved_config_bundle.clone()
{
let sync = runtime.sync_config_bundle(bundle);
if sync.state != WorkerOperationState::Accepted {
let message = sync
@@ -1791,17 +1864,34 @@ impl RuntimeRegistry {
})
}
pub fn ping(&self, runtime_id: &str) -> Result<RuntimeHttpPingResponse, RuntimePingFailure> {
let runtime = self.runtime(runtime_id).map_err(|_| {
RuntimePingFailure::new(
RuntimePingFailureKind::Configuration,
"runtime_ping_registration_unavailable",
"Registered Runtime binding is unavailable",
)
})?;
runtime.ping()
}
fn runtimes_snapshot(&self) -> Vec<Arc<dyn WorkspaceWorkerRuntime>> {
self.runtimes
.read()
.expect("runtime registry lock poisoned")
.clone()
.iter()
.filter(|runtime| self.runtime_binding_is_active(runtime.runtime_id()))
.cloned()
.collect()
}
fn runtime(
&self,
runtime_id: &str,
) -> Result<Arc<dyn WorkspaceWorkerRuntime>, RuntimeRegistryError> {
if !self.runtime_binding_is_active(runtime_id) {
return Err(RuntimeRegistryError::UnknownRuntime(runtime_id.to_string()));
}
self.runtimes
.read()
.expect("runtime registry lock poisoned")
@@ -1919,6 +2009,7 @@ impl EmbeddedWorkerRuntime {
workspace_id: summary.workspace_id.clone(),
},
state: embedded_worker_status_label(summary.status).to_string(),
worker_state: summary.worker_state.clone(),
last_seen_at: None,
pinned: false,
retention_state: "transient".to_string(),
@@ -1958,6 +2049,7 @@ impl EmbeddedWorkerRuntime {
workspace_id: detail.workspace_id.clone(),
},
state: embedded_worker_status_label(detail.status).to_string(),
worker_state: detail.worker_state.clone(),
last_seen_at: None,
pinned: false,
retention_state: "transient".to_string(),
@@ -2625,7 +2717,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
},
content: request.content,
submission_id: None,
submission_request_id: None,
segments: request.segments,
};
match self.runtime.send_input(&worker_ref, input) {
@@ -2887,7 +2979,6 @@ pub struct RemoteWorkerRuntime {
runtime_id: String,
display_name: String,
base_url: String,
backend_base_url: String,
workspace_id: String,
bearer_token: Option<String>,
auth: Option<RemoteRuntimeAuthConfig>,
@@ -2901,6 +2992,49 @@ pub struct RemoteWorkerRuntime {
async_http: AsyncHttpClient,
}
fn remote_runtime_ping_transport_failure(error: reqwest::Error) -> RuntimePingFailure {
if error.is_timeout() {
return RuntimePingFailure::new(
RuntimePingFailureKind::Timeout,
"runtime_ping_timeout",
"Runtime ping timed out",
);
}
let mut source = error.source();
let mut tls_error = false;
while let Some(current) = source {
let message = current.to_string().to_ascii_lowercase();
if message.contains("tls")
|| message.contains("certificate")
|| message.contains("unknownissuer")
|| message.contains("handshake")
{
tls_error = true;
break;
}
source = current.source();
}
if tls_error {
return RuntimePingFailure::new(
RuntimePingFailureKind::TlsOrTransport,
"runtime_ping_tls_failed",
"Runtime TLS connection failed",
);
}
if error.is_connect() {
return RuntimePingFailure::new(
RuntimePingFailureKind::NetworkUnreachable,
"runtime_ping_network_unreachable",
"Runtime could not be reached",
);
}
RuntimePingFailure::new(
RuntimePingFailureKind::TlsOrTransport,
"runtime_ping_transport_failed",
"Runtime ping transport failed",
)
}
fn all_remote_runtime_permissions() -> Vec<String> {
[
"workers:list",
@@ -2921,7 +3055,7 @@ impl RemoteWorkerRuntime {
pub fn new(
config: RemoteRuntimeConfig,
workspace_id: String,
backend_base_url: String,
_backend_base_url: String,
) -> Result<Self, RuntimeRegistryError> {
validate_backend_identifier("runtime_id", &config.runtime_id)?;
let base_url = config.base_url.trim_end_matches('/').to_string();
@@ -2949,7 +3083,6 @@ impl RemoteWorkerRuntime {
runtime_id: config.runtime_id,
display_name: config.display_name,
base_url,
backend_base_url: backend_base_url.trim_end_matches('/').to_string(),
workspace_id,
bearer_token: config.bearer_token,
auth: config.auth,
@@ -3049,14 +3182,18 @@ impl RemoteWorkerRuntime {
self.send_json(path, self.http.delete(self.endpoint(path)))
}
fn runtime_capability_token(&self, path: &str) -> Option<String> {
fn runtime_capability_token_with_permissions(
&self,
path: &str,
permissions: Vec<String>,
) -> Option<String> {
let auth = self.auth.as_ref()?;
let signer = CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key);
let claims = capability_claims(
&auth.server_id,
&self.runtime_id,
&self.workspace_id,
all_remote_runtime_permissions(),
permissions,
300,
)
.map_err(|error| {
@@ -3079,6 +3216,82 @@ impl RemoteWorkerRuntime {
.ok()
}
fn runtime_capability_token(&self, path: &str) -> Option<String> {
self.runtime_capability_token_with_permissions(path, all_remote_runtime_permissions())
}
fn ping_http(&self) -> Result<RuntimeHttpPingResponse, RuntimePingFailure> {
const PATH: &str = "/v1/ping";
let workspace_id = self.workspace_id.clone();
let bearer_token = self.bearer_token.clone();
let capability_token = self.runtime_capability_token_with_permissions(
PATH,
vec![RUNTIME_PING_PERMISSION.to_string()],
);
let request = self
.http
.get(self.endpoint(PATH))
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, &workspace_id);
run_blocking_http(move || {
let request = match capability_token.as_deref().or(bearer_token.as_deref()) {
Some(token) => request.header(AUTHORIZATION, format!("Bearer {token}")),
None => request,
};
let response = request
.send()
.map_err(remote_runtime_ping_transport_failure)?;
match response.status() {
StatusCode::UNAUTHORIZED => {
return Err(RuntimePingFailure::new(
RuntimePingFailureKind::Authentication,
"runtime_ping_authentication_failed",
"Runtime rejected the connection-test credential",
));
}
StatusCode::FORBIDDEN => {
return Err(RuntimePingFailure::new(
RuntimePingFailureKind::Authorization,
"runtime_ping_authorization_failed",
"Runtime rejected the connection-test scope or permission",
));
}
status if !status.is_success() => {
return Err(RuntimePingFailure::new(
RuntimePingFailureKind::TlsOrTransport,
"runtime_ping_http_failed",
"Runtime ping returned an unsuccessful HTTP response",
));
}
_ => {}
}
let mut body = Vec::new();
response
.take((MAX_RUNTIME_PING_RESPONSE_BYTES + 1) as u64)
.read_to_end(&mut body)
.map_err(|_| {
RuntimePingFailure::new(
RuntimePingFailureKind::TlsOrTransport,
"runtime_ping_response_read_failed",
"Runtime ping response could not be read",
)
})?;
if body.len() > MAX_RUNTIME_PING_RESPONSE_BYTES {
return Err(RuntimePingFailure::new(
RuntimePingFailureKind::MalformedResponse,
"runtime_ping_response_too_large",
"Runtime ping response exceeded the allowed size",
));
}
serde_json::from_slice::<RuntimeHttpPingResponse>(&body).map_err(|_| {
RuntimePingFailure::new(
RuntimePingFailureKind::MalformedResponse,
"runtime_ping_malformed_response",
"Runtime ping returned an unrecognized response",
)
})
})
}
fn send_json<T>(&self, path: &str, request: RequestBuilder) -> Result<T, RuntimeDiagnostic>
where
T: DeserializeOwned + Send + 'static,
@@ -3138,6 +3351,7 @@ impl RemoteWorkerRuntime {
workspace_id: summary.workspace_id.clone(),
},
state: embedded_worker_status_label(summary.status).to_string(),
worker_state: summary.worker_state.clone(),
last_seen_at: None,
pinned: false,
retention_state: "transient".to_string(),
@@ -3181,6 +3395,7 @@ impl RemoteWorkerRuntime {
workspace_id: detail.workspace_id.clone(),
},
state: embedded_worker_status_label(detail.status).to_string(),
worker_state: detail.worker_state.clone(),
last_seen_at: None,
pinned: false,
retention_state: "transient".to_string(),
@@ -3266,6 +3481,10 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
}
}
fn ping(&self) -> Result<RuntimeHttpPingResponse, RuntimePingFailure> {
self.ping_http()
}
fn list_hosts(&self, limit: usize) -> RuntimeList<HostSummary> {
if limit == 0 {
return RuntimeList::new(Vec::new(), Vec::new());
@@ -3508,28 +3727,24 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
};
}
let profile = request.profile.clone();
let profile_source = match profile_source_archive_http_source(
&request,
&profile,
&self.workspace_id,
Some(self.runtime_id.as_str()),
&self.resource_broker,
&self.backend_base_url,
) {
Ok(source) => source,
let profile_source_archive = match profile_source_archive_for_request(&request, &profile) {
Ok(archive) => archive,
Err(error) => {
return WorkerSpawnResult {
state: WorkerOperationState::Rejected,
worker: None,
acceptance_evidence: Vec::new(),
diagnostics: vec![diagnostic(
"remote_profile_source_archive_invalid",
"remote_workspace_config_invalid",
DiagnosticSeverity::Error,
error,
)],
};
}
};
let profile_source = ProfileSourceArchiveSource::WorkspaceConfig {
archive: profile_source_archive.reference,
};
let workspace_api = match required_worker_workspace_api(&request) {
Ok(workspace_api) => workspace_api,
Err(diagnostic) => {
@@ -3557,7 +3772,13 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
workspace_api: Some(workspace_api),
memory_settings: request.resolved_memory_settings.clone(),
};
match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) {
match self.send_json::<RuntimeHttpWorkerResponse>(
"/v1/workers",
self.http
.post(self.endpoint("/v1/workers"))
.timeout(REMOTE_WORKER_CREATE_TIMEOUT)
.json(&create),
) {
Ok(response) => WorkerSpawnResult {
state: WorkerOperationState::Accepted,
worker: Some(self.map_worker_detail(response.worker)),
@@ -3726,7 +3947,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
},
content: request.content,
submission_id: None,
submission_request_id: None,
segments: request.segments,
};
match self.post_json::<_, RuntimeHttpWorkerInputResponse>(
@@ -3874,7 +4095,7 @@ fn profile_source_archive_for_request(
{
return Ok(archive);
}
builtin_profile_source_archive(profile)
crate::profile_settings::builtin_profile_source_archive(profile)
}
fn profile_source_archive_source(
@@ -3886,36 +4107,14 @@ fn profile_source_archive_source(
})
}
fn profile_source_archive_http_source(
#[cfg(test)]
fn profile_source_archive_workspace_config_source(
request: &WorkerSpawnRequest,
profile: &ProfileSelector,
workspace_id: &str,
runtime_id: Option<&str>,
resource_broker: &BackendResourceBroker,
backend_base_url: &str,
) -> Result<ProfileSourceArchiveSource, String> {
let archive = profile_source_archive_for_request(request, profile)?;
let target = runtime_id
.map(BackendResourceTarget::Runtime)
.unwrap_or(BackendResourceTarget::Workspace);
let _handle = resource_broker.issue_profile_source_archive_handle(
workspace_id.to_string(),
target,
archive.clone(),
);
let etag = format!("\"profile-source:{}\"", archive.reference.digest);
let url = format!(
"{}/api/w/{}/profile-source-archives/{}",
backend_base_url.trim_end_matches('/'),
workspace_id,
archive.reference.digest
);
Ok(ProfileSourceArchiveSource::Http {
location: ProfileSourceArchiveHttpRef {
url,
etag: Some(etag),
archive: archive.reference.clone(),
},
Ok(ProfileSourceArchiveSource::WorkspaceConfig {
archive: archive.reference,
})
}
@@ -3940,7 +4139,7 @@ fn builtin_profile_config_bundle(
.unwrap_or_else(|| "default".to_string())
.replace([':', '/', ' '], "-")
);
let archive = builtin_profile_source_archive(profile)?;
let archive = crate::profile_settings::builtin_profile_source_archive(profile)?;
let (profile_source_archive, profile_source_archive_handle) = match archive_transport {
ProfileSourceArchiveTransport::Inline => (Some(archive), None),
ProfileSourceArchiveTransport::BackendResourceHandle => {
@@ -3994,39 +4193,6 @@ fn embedded_profile_label(profile: &ProfileSelector) -> Option<String> {
})
}
fn builtin_profile_source_archive(
profile: &ProfileSelector,
) -> Result<ProfileSourceArchive, String> {
let selected_profile = match profile {
ProfileSelector::Builtin(name) => {
if name.starts_with("builtin:") {
name.clone()
} else {
format!("builtin:{name}")
}
}
ProfileSelector::Named(name) => {
return Err(format!(
"embedded runtime does not provide named Profile `{name}`"
));
}
};
let catalog = manifest::builtin_profile_catalog_snapshot();
if !catalog.entrypoints.contains_key(&selected_profile) {
return Err(format!(
"embedded runtime does not provide Profile `{selected_profile}`"
));
}
ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: catalog.id.to_owned(),
sources: catalog.sources,
entrypoints: catalog.entrypoints,
imports: catalog.imports,
})
.map_err(|error| format!("failed to build built-in Profile source archive: {error}"))
}
const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation";
const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation";
const WORKSPACE_ORCHESTRATOR_PROFILE: &str = "orchestrator";
@@ -4524,6 +4690,7 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
workspace_id: None,
},
state: "unsupported".to_string(),
worker_state: None,
last_seen_at: None,
pinned: false,
retention_state: "transient".to_string(),
@@ -4562,11 +4729,16 @@ mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
use std::io::{Read as _, Write as _};
use std::io::Write as _;
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
use std::thread;
#[test]
fn remote_worker_create_timeout_covers_runtime_phase_budgets() {
assert!(REMOTE_WORKER_CREATE_TIMEOUT > Duration::from_secs(60 + 10 + 5));
}
fn test_create_binding() -> WorkerCreateBinding {
WorkerCreateBinding {
worker_id: EmbeddedWorkerId::now_v7(),
@@ -4725,7 +4897,7 @@ mod tests {
fn resolved_project_profile_archive_is_used_for_runtime_delivery() {
let broker = BackendResourceBroker::default();
let builtin_selector = ProfileSelector::Builtin("builtin:coder".to_string());
let archive = builtin_profile_source_archive(&builtin_selector)
let archive = crate::profile_settings::builtin_profile_source_archive(&builtin_selector)
.expect("build stand-in project profile archive");
let mut bundle = builtin_profile_config_bundle(
&builtin_selector,
@@ -4747,30 +4919,17 @@ mod tests {
}
#[test]
fn remote_profile_source_archive_url_uses_workspace_id_not_host_id() {
let broker = BackendResourceBroker::default();
let runtime_id = "remote:test";
fn remote_profile_source_uses_workspace_config_archive_reference() {
let request = embedded_spawn_request();
let source = profile_source_archive_http_source(
&request,
&ProfileSelector::Builtin("builtin:coder".to_string()),
"workspace-actual",
Some(runtime_id),
&broker,
"http://127.0.0.1:8787/",
)
.unwrap();
let ProfileSourceArchiveSource::Http { location } = source else {
panic!("remote profile source should be HTTP fetched");
};
assert!(
location.url.starts_with(
"http://127.0.0.1:8787/api/w/workspace-actual/profile-source-archives/"
),
"{}",
location.url
let profile = ProfileSelector::Builtin("builtin:coder".to_string());
let expected = profile_source_archive_for_request(&request, &profile)
.unwrap()
.reference;
let source = profile_source_archive_workspace_config_source(&request, &profile).unwrap();
assert_eq!(
source,
ProfileSourceArchiveSource::WorkspaceConfig { archive: expected }
);
assert!(!location.url.contains("remote-runtime"), "{}", location.url);
}
#[test]
@@ -4962,7 +5121,6 @@ mod tests {
request.worker_ref,
self.backend_id(),
),
run_state: WorkerExecutionRunState::Idle,
working_directory: request
.working_directory
.as_ref()
@@ -4987,12 +5145,12 @@ mod tests {
"missing test context",
);
};
let submission_id = input.submission_id.clone();
let submission_request_id = input.submission_request_id.clone();
let content = input.content;
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(10));
let _ = context.publish_protocol_event(protocol::Event::Status {
status: protocol::WorkerStatus::Running,
let _ = context.publish_protocol_event(protocol::Event::WorkerState {
snapshot: protocol::WorkerStatus::Running.into(),
});
let _ = context.publish_protocol_event(protocol::Event::TextDone {
text: format!("echo: {content}"),
@@ -5000,20 +5158,20 @@ mod tests {
let _ = context.publish_protocol_event(protocol::Event::RunEnd {
result: protocol::RunResult::Finished,
});
let _ = context.publish_protocol_event(protocol::Event::Status {
status: protocol::WorkerStatus::Idle,
let _ = context.publish_protocol_event(protocol::Event::WorkerState {
snapshot: protocol::WorkerStatus::Idle.into(),
});
});
if let Some(submission_id) = submission_id {
worker_runtime::execution::WorkerExecutionResult::accepted_input_committed(
if let Some(submission_request_id) = submission_request_id {
worker_runtime::execution::WorkerExecutionResult::accepted_submission(
worker_runtime::execution::WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
submission_id,
submission_request_id,
uuid::Uuid::now_v7().to_string(),
protocol::SubmissionDisposition::Started,
)
} else {
worker_runtime::execution::WorkerExecutionResult::accepted(
worker_runtime::execution::WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
)
}
}
@@ -5046,6 +5204,7 @@ mod tests {
workspace_id: None,
},
state: "available".to_string(),
worker_state: None,
last_seen_at: None,
pinned: false,
retention_state: "transient".to_string(),
@@ -5165,6 +5324,64 @@ mod tests {
assert_eq!(from_runtime_a.label, "worker from runtime a");
}
#[test]
fn registry_gate_rejects_cached_runtime_immediately_after_binding_revocation() {
let remote =
FixtureRuntime::with_worker("runtime-a", "host-a", "worker-a", "worker from runtime a");
let remote_observed = remote.observed_prompt_revisions.clone();
let embedded = FixtureRuntime::with_worker(
EMBEDDED_RUNTIME_ID,
"embedded-host",
"embedded-worker",
"embedded worker",
);
let embedded_observed = embedded.observed_prompt_revisions.clone();
let registry = RuntimeRegistry::new(vec![Arc::new(remote), Arc::new(embedded)]);
let active = Arc::new(Mutex::new(true));
let gate_state = active.clone();
registry.set_runtime_binding_gate(move |_| {
*gate_state.lock().expect("gate state lock poisoned")
});
assert_eq!(registry.list_runtimes(10).items.len(), 2);
assert!(
registry
.worker(&RuntimeWorkerRef::new("runtime-a", "worker-a"))
.is_ok()
);
*active.lock().expect("gate state lock poisoned") = false;
assert_eq!(registry.list_runtimes(10).items.len(), 1);
assert!(matches!(
registry.worker(&RuntimeWorkerRef::new("runtime-a", "worker-a")),
Err(RuntimeRegistryError::UnknownRuntime(runtime_id)) if runtime_id == "runtime-a"
));
let catalog = worker::EffectivePromptCatalog::new(
std::collections::BTreeMap::from([(
"default".to_string(),
"workspace prompt".to_string(),
)]),
12,
"schema",
"toolchain",
)
.unwrap();
let projection = worker::WorkspacePromptProjection::new(
"workspace-a",
"source-12",
catalog.catalog_digest.clone(),
catalog,
)
.unwrap();
assert!(
registry
.observe_workspace_prompt_projection(projection)
.is_empty()
);
assert!(remote_observed.lock().unwrap().is_empty());
assert_eq!(*embedded_observed.lock().unwrap(), vec![12]);
}
#[test]
fn registry_broadcasts_workspace_prompt_projection_revisions() {
let runtime =
@@ -5707,6 +5924,30 @@ mod tests {
assert_eq!(runtime.runtime_id(), "remote:async-init");
}
#[test]
fn remote_runtime_ping_classifies_unreachable_without_endpoint_leak() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
drop(listener);
let runtime = RemoteWorkerRuntime::new(
RemoteRuntimeConfig::new(
"remote:unreachable",
"Remote Unreachable",
endpoint.clone(),
Some("secret-token".to_string()),
),
"workspace-test".to_string(),
"http://127.0.0.1:8787".to_string(),
)
.unwrap();
let failure = runtime.ping().unwrap_err();
assert_eq!(failure.kind, RuntimePingFailureKind::NetworkUnreachable);
assert_eq!(failure.diagnostic.code, "runtime_ping_network_unreachable");
assert!(!failure.diagnostic.message.contains(&endpoint));
assert!(!format!("{failure:?}").contains("secret-token"));
}
#[test]
fn remote_runtime_registry_routes_commands_without_browser_secret_leaks() {
let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string();
+53 -8
View File
@@ -433,15 +433,37 @@ CREATE TABLE ticket_worker_assignments (
(principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0)
)
);
CREATE TABLE trusted_runtime_records (
runtime_id TEXT PRIMARY KEY,
CREATE TABLE workspace_runtime_bindings (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
display_name TEXT NOT NULL,
base_url TEXT NOT NULL,
public_key TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
revoked_at TEXT
, workspace_id TEXT REFERENCES workspaces(workspace_id) ON DELETE RESTRICT);
revoked_at TEXT,
PRIMARY KEY (workspace_id, runtime_id),
UNIQUE (workspace_id, public_key_fingerprint),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
);
CREATE TABLE workspace_runtime_binding_audit (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
actor_account_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')),
old_fingerprint TEXT,
new_fingerprint TEXT,
binding_revision INTEGER NOT NULL CHECK (binding_revision > 0),
at TEXT NOT NULL,
PRIMARY KEY (workspace_id, runtime_id, binding_revision),
FOREIGN KEY(workspace_id, runtime_id)
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT,
FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT
);
CREATE INDEX idx_workspace_runtime_binding_audit_recent
ON workspace_runtime_binding_audit(workspace_id, runtime_id, binding_revision DESC);
CREATE TABLE typed_ticket_artifacts (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, relative_path),
@@ -637,7 +659,7 @@ CREATE TABLE worker_create_reservations (
worker_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
create_fingerprint TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('reserved', 'created')),
state TEXT NOT NULL CHECK (state IN ('reserved', 'created', 'removed')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, request_fingerprint TEXT, memory_settings_revision INTEGER, memory_language TEXT,
PRIMARY KEY (workspace_id, allocation_key),
@@ -651,11 +673,12 @@ CREATE TABLE worker_diagnostics_archives (
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE);
CREATE TABLE worker_mutation_source_proof_jtis (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
jti TEXT NOT NULL,
expires_at INTEGER NOT NULL,
consumed_at TEXT NOT NULL,
PRIMARY KEY (runtime_id, jti)
PRIMARY KEY (workspace_id, runtime_id, jti)
);
CREATE TABLE worker_orphan_diagnostics (
diagnostic_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
@@ -857,8 +880,8 @@ CREATE INDEX idx_ticket_worker_assignments_principal
ON ticket_worker_assignments(workspace_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_at DESC);
CREATE INDEX idx_ticket_worker_assignments_ticket
ON ticket_worker_assignments(workspace_id, ticket_id, role, assigned_at DESC);
CREATE INDEX idx_trusted_runtime_records_workspace
ON trusted_runtime_records(workspace_id, revoked_at, runtime_id);
CREATE INDEX idx_workspace_runtime_bindings_workspace
ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id);
CREATE INDEX idx_typed_ticket_relations_workspace_target
ON typed_ticket_relations(workspace_id, target, at DESC);
CREATE INDEX idx_typed_tickets_workspace_state_updated
@@ -917,6 +940,28 @@ CREATE UNIQUE INDEX worker_workdir_links_active_worker_unique
WHERE unlinked_at IS NULL;
CREATE INDEX worker_workdir_links_workdir
ON worker_workdir_links(workspace_id, workdir_id);
CREATE TABLE workspace_deletion_operations (
operation_id TEXT PRIMARY KEY,
request_fingerprint TEXT NOT NULL,
workspace_id TEXT NOT NULL,
workspace_display_name TEXT NOT NULL,
workspace_revision TEXT NOT NULL,
owner_account_id TEXT NOT NULL,
actor_account_id TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('queued', 'running', 'blocked', 'failed', 'succeeded')),
resource_counts_json TEXT NOT NULL,
child_operation_ids_json TEXT NOT NULL,
blockers_json TEXT NOT NULL,
failure_category TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT,
FOREIGN KEY(owner_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT,
FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT
);
CREATE INDEX workspace_deletion_operations_workspace_recent
ON workspace_deletion_operations(workspace_id, created_at DESC);
CREATE TRIGGER seed_worker_retention_policy_after_workspace_insert AFTER INSERT ON workspaces BEGIN
INSERT INTO workspace_worker_retention_policy_revisions
(workspace_id,policy_id,revision,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at)
+13 -1
View File
@@ -33,6 +33,7 @@ pub mod workdir_create_operations;
mod workdir_removal;
pub mod worker_source;
pub mod workspace_catalog;
mod workspace_deletion;
mod workspace_subscription;
pub use authority::{
@@ -40,7 +41,7 @@ pub use authority::{
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
WorkspaceAuthority,
};
pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
pub use config::{ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader};
@@ -118,6 +119,17 @@ pub enum Error {
WorkspacePermissionDenied(String),
#[error("Workspace config update conflict: {0}")]
WorkspaceConfigConflict(String),
#[error("Runtime binding conflict: {0}")]
RuntimeBindingConflict(String),
#[error("Runtime binding revision conflict: expected {expected:?}, current {actual:?}")]
RuntimeBindingRevisionConflict {
expected: Option<u64>,
actual: Option<u64>,
},
#[error("Runtime public key fingerprint is already bound in this Workspace: {fingerprint}")]
RuntimeBindingFingerprintConflict { fingerprint: String },
#[error("Runtime binding was not found for {runtime_id}")]
RuntimeBindingNotFound { runtime_id: String },
#[error("Repository conflict: {0}")]
RepositoryConflict(String),
#[error("Registry inconsistency: {0}")]
+246 -87
View File
@@ -9,10 +9,10 @@ use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord};
use yoi_workspace_server::store::{SqliteWorkspaceStore, WorkspaceRuntimeBinding};
use yoi_workspace_server::{
BackendRuntimesConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig,
ServerHostConfigFile, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
};
#[derive(Debug)]
@@ -20,6 +20,7 @@ enum Command {
Serve(ServeOptions),
Identity(Vec<String>),
TrustRuntime(Vec<String>),
Migrate(MigrateOptions),
Skills(SkillsCommand),
Help,
}
@@ -30,6 +31,13 @@ struct ServeOptions {
config: Option<PathBuf>,
}
#[derive(Debug)]
struct MigrateOptions {
database: Option<PathBuf>,
dry_run: bool,
help: bool,
}
#[derive(Debug)]
struct SkillWorkspaceOptions {
workspace_id: String,
@@ -70,6 +78,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
Command::Serve(options) => run_serve(options).await,
Command::Identity(args) => run_identity_command(args),
Command::TrustRuntime(args) => run_trust_runtime_command(args),
Command::Migrate(options) => run_migrate(options),
Command::Skills(command) => run_skills(command),
Command::Help => Ok(()),
}
@@ -84,6 +93,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
match command.as_str() {
"identity" => Ok(Command::Identity(rest.to_vec())),
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
"migrate" => parse_migrate_options(rest).map(Command::Migrate),
"skills" => parse_skills_command(rest),
"serve" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
@@ -315,40 +325,48 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
let public_key = public_key
.ok_or_else(|| CliError("trust-runtime add requires --public-key".to_string()))?;
decode_public_key(&public_key)?;
ensure_trusted_runtime_replace_allowed(&store, &runtime_id, replace)?;
if let Some(existing) = store
.list_trusted_runtimes(true)?
.into_iter()
.find(|runtime| runtime.runtime_id == runtime_id)
{
if existing.workspace_id.as_deref() != Some(workspace_id.as_str()) {
return Err(Box::new(CliError(format!(
"runtime `{runtime_id}` is already assigned to Workspace `{}` and cannot be reparented",
existing.workspace_id.as_deref().unwrap_or("unassigned")
))));
}
}
let now = Utc::now().to_rfc3339();
store.upsert_trusted_runtime(&TrustedRuntimeRecord {
runtime_id: runtime_id.clone(),
workspace_id: Some(workspace_id.clone()),
display_name: display_name.unwrap_or_else(|| runtime_id.clone()),
base_url,
public_key,
created_at: now.clone(),
updated_at: now,
revoked_at: None,
})?;
println!("trusted_runtime_id={runtime_id}");
let outcome = store.upsert_workspace_runtime_binding(
WorkspaceRuntimeBinding {
workspace_id: workspace_id.clone(),
runtime_id: runtime_id.clone(),
display_name: display_name.unwrap_or_else(|| runtime_id.clone()),
base_url,
public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
created_at: now.clone(),
updated_at: now,
revoked_at: None,
},
replace,
)?;
println!("workspace_id={workspace_id}");
println!("runtime_id={runtime_id}");
println!(
"result={}",
match outcome {
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Created =>
"created",
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged =>
"unchanged",
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Replaced =>
"replaced",
}
);
println!("server_db={}", database_path.display());
Ok(())
}
"list" => {
let mut workspace_id = None;
let mut json = false;
let mut include_revoked = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--workspace-id" => {
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--json" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
json = true;
@@ -364,17 +382,20 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
}
}
}
let records = store.list_trusted_runtimes(include_revoked)?;
let workspace_id = workspace_id.ok_or_else(|| {
CliError("trust-runtime list requires --workspace-id".to_string())
})?;
let records = store.list_workspace_runtime_bindings(&workspace_id, include_revoked)?;
if json {
println!("{}", serde_json::to_string_pretty(&records)?);
} else {
for runtime in records {
println!(
"runtime_id={} workspace_id={} base_url={} public_key={} revoked_at={}",
"workspace_id={} runtime_id={} base_url={} public_key_fingerprint={} revoked_at={}",
runtime.workspace_id,
runtime.runtime_id,
runtime.workspace_id.unwrap_or_default(),
runtime.base_url,
runtime.public_key,
runtime.public_key_fingerprint,
runtime.revoked_at.unwrap_or_default()
);
}
@@ -382,10 +403,14 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
Ok(())
}
"revoke" => {
let mut workspace_id = None;
let mut runtime_id = None;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--workspace-id" => {
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--runtime-id" => {
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
}
@@ -396,11 +421,14 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
}
}
}
let workspace_id = workspace_id.ok_or_else(|| {
CliError("trust-runtime revoke requires --workspace-id".to_string())
})?;
let runtime_id = runtime_id.ok_or_else(|| {
CliError("trust-runtime revoke requires --runtime-id".to_string())
})?;
let now = Utc::now().to_rfc3339();
if !store.revoke_trusted_runtime(&runtime_id, &now)? {
if !store.revoke_workspace_runtime_binding(&workspace_id, &runtime_id, &now)? {
return Err(Box::new(CliError(format!(
"trusted runtime `{runtime_id}` is not registered or is already revoked"
))));
@@ -414,24 +442,6 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
}
}
fn ensure_trusted_runtime_replace_allowed(
store: &SqliteWorkspaceStore,
runtime_id: &str,
replace: bool,
) -> Result<(), Box<dyn std::error::Error>> {
if store
.list_trusted_runtimes(true)?
.iter()
.any(|runtime| runtime.runtime_id == runtime_id)
&& !replace
{
return Err(Box::new(CliError(format!(
"trusted runtime `{runtime_id}` already exists; pass --replace to update it"
))));
}
Ok(())
}
fn split_flag_value(arg: String) -> Result<(String, Option<String>), CliError> {
if let Some((flag, value)) = arg.split_once('=') {
if flag.is_empty() {
@@ -509,7 +519,54 @@ fn load_skill_workspace_config(
})
}
fn run_migrate(options: MigrateOptions) -> Result<(), Box<dyn std::error::Error>> {
if options.help {
print_migrate_help();
return Ok(());
}
let database_path = options
.database
.unwrap_or_else(ServerConfig::default_server_database_path);
let plan = if options.dry_run {
SqliteWorkspaceStore::migration_plan(&database_path)?
} else {
SqliteWorkspaceStore::migrate_database(&database_path)?
};
println!("server_db={}", database_path.display());
println!("current_schema_version={}", plan.current_schema_version);
println!("target_schema_version={}", plan.target_schema_version);
println!("migration_required={}", plan.migration_required());
for migration in &plan.migrations {
println!("migration={} {}", migration.version, migration.name);
}
println!(
"result={}",
if options.dry_run {
"dry-run-validated"
} else if plan.migration_required() {
"migrated"
} else {
"unchanged"
}
);
Ok(())
}
fn init_serve_tracing() {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stdout)
.with_ansi(false)
.json()
.flatten_event(true)
.try_init();
}
async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Error>> {
init_serve_tracing();
let database_path = ServerConfig::default_server_database_path();
if let Some(parent) = database_path.parent() {
tokio::fs::create_dir_all(parent).await?;
@@ -543,16 +600,11 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
Some(path) => ServerHostConfigFile::load_from_path(path)?,
None => ServerHostConfigFile::load_default()?,
};
let runtime_config = BackendRuntimesConfigFile::load_default()?;
let mut resolved = ResolvedWorkspaceBackendConfig::local_dev(
&workspace_root,
identity,
&host_config,
&runtime_config,
)?;
let mut resolved =
ResolvedWorkspaceBackendConfig::local_dev(&workspace_root, identity, &host_config)?;
resolved.database_path = database_path.clone();
resolved.server.database_path = database_path.clone();
append_trusted_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?;
append_workspace_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?;
if let Some(listen) = options.listen {
resolved = resolved.with_listen(listen);
}
@@ -572,22 +624,38 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
Ok(())
}
fn append_trusted_runtime_sources(
fn append_workspace_runtime_sources(
store: &SqliteWorkspaceStore,
remote_runtime_sources: &mut Vec<RemoteRuntimeConfig>,
) -> Result<(), Box<dyn std::error::Error>> {
let workspaces = store.list_workspaces()?;
let bindings = workspaces
.iter()
.map(|workspace| {
store
.list_workspace_runtime_bindings(&workspace.workspace_id, false)
.map(|bindings| {
bindings
.into_iter()
.filter(|binding| {
binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID
})
.collect::<Vec<_>>()
})
})
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect::<Vec<_>>();
let Some(server_identity) = read_server_identity_file(&server_identity_path())? else {
if !store.list_trusted_runtimes(false)?.is_empty() {
if !bindings.is_empty() {
return Err(Box::new(CliError(
"trusted runtimes are registered but server identity is not initialized; run `yoi-server identity init`".to_string(),
"Runtime bindings are registered but server identity is not initialized; run `yoi-server identity init`".to_string(),
)));
}
return Ok(());
};
for runtime in store.list_trusted_runtimes(false)? {
let Some(workspace_id) = runtime.workspace_id.clone() else {
continue;
};
for runtime in bindings {
let auth = RemoteRuntimeAuthConfig {
server_id: server_identity.identity.identity_id.clone(),
server_private_key: server_identity.identity.private_key.clone(),
@@ -598,9 +666,12 @@ fn append_trusted_runtime_sources(
runtime.base_url,
None,
)
.with_workspace_id(workspace_id)
.with_workspace_id(runtime.workspace_id.clone())
.with_auth(auth);
remote_runtime_sources.retain(|existing| existing.runtime_id != runtime.runtime_id);
remote_runtime_sources.retain(|existing| {
existing.workspace_id.as_deref() != Some(runtime.workspace_id.as_str())
|| existing.runtime_id != runtime.runtime_id
});
remote_runtime_sources.push(remote);
}
Ok(())
@@ -669,6 +740,44 @@ fn parse_skill_workspace_options(args: &[String]) -> Result<SkillWorkspaceOption
Ok(SkillWorkspaceOptions { workspace_id })
}
fn parse_migrate_options(args: &[String]) -> Result<MigrateOptions, CliError> {
let mut database = None;
let mut dry_run = false;
let mut help = false;
let mut index = 0;
while index < args.len() {
let arg = &args[index];
match arg.as_str() {
"--database" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--database requires a path".to_string()))?;
database = Some(PathBuf::from(value));
}
_ if arg.starts_with("--database=") => {
database = Some(PathBuf::from(value_after_equals(arg, "--database")?));
}
"--dry-run" => dry_run = true,
"--help" | "-h" => help = true,
_ if arg.starts_with('-') => {
return Err(CliError(format!("unknown migrate option `{arg}`")));
}
_ => {
return Err(CliError(format!(
"unexpected positional argument `{arg}`; use --database <PATH>"
)));
}
}
index += 1;
}
Ok(MigrateOptions {
database,
dry_run,
help,
})
}
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
let mut listen = None;
let mut config = None;
@@ -731,7 +840,13 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() {
println!(
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list --workspace-id <WORKSPACE_ID> [--json] [--include-revoked]\n yoi-server trust-runtime revoke --workspace-id <WORKSPACE_ID> --runtime-id <RUNTIME_ID>\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
);
}
fn print_migrate_help() {
println!(
"yoi-server migrate\n\nUsage:\n yoi-server migrate [OPTIONS]\n\nDescription:\n Validates and applies every retained Server DB schema migration in order. --dry-run copies the database into memory and runs the same migration path without changing the source database. Stop yoi-server before applying migrations.\n\nOptions:\n --database <PATH> Server DB path (default: canonical Yoi server DB)\n --dry-run Validate the complete migration without changing the source DB\n -h, --help Print help"
);
}
@@ -743,7 +858,7 @@ fn print_skills_help() {
fn print_serve_help() {
println!(
"yoi-server serve\n\nUsage:\n yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n --config <PATH> Host-level Server config path\n -h, --help Print help"
"yoi-server serve\n\nUsage:\n yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and Runtime bindings are loaded from the Server DB.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n --config <PATH> Host-level Server config path\n -h, --help Print help"
);
}
@@ -762,6 +877,28 @@ mod tests {
}
}
#[test]
fn parse_migrate_uses_the_shared_schema_path() {
let command = parse_command(&[
"migrate".to_string(),
"--dry-run".to_string(),
"--database=/tmp/server.db".to_string(),
])
.unwrap();
let Command::Migrate(options) = command else {
panic!("expected migrate command");
};
assert!(options.dry_run);
assert!(!options.help);
assert_eq!(options.database, Some(PathBuf::from("/tmp/server.db")));
}
#[test]
fn parse_migrate_rejects_unknown_options() {
let error = parse_command(&["migrate".to_string(), "--apply-all".to_string()]).unwrap_err();
assert_eq!(error.to_string(), "unknown migrate option `--apply-all`");
}
#[test]
fn parse_skills_requires_server_workspace_id() {
let error = parse_skills_command(&["list".to_string()]).unwrap_err();
@@ -823,30 +960,52 @@ mod tests {
}
#[test]
fn trusted_runtime_add_requires_replace_for_existing_record() {
fn runtime_binding_requires_explicit_replace_for_changed_authority() {
let temp = tempfile::tempdir().unwrap();
let store = SqliteWorkspaceStore::open(temp.path().join("server.db")).unwrap();
let path = temp.path().join("server.db");
let store = SqliteWorkspaceStore::open(&path).unwrap();
rusqlite::Connection::open(&path)
.unwrap()
.execute_batch(
"INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at)
VALUES ('owner', 'user', 'owner', 'Owner', '1', '1');
INSERT INTO workspaces(workspace_id, owner_account_id, display_name, state, created_at, updated_at)
VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1');",
)
.unwrap();
let public_key = RuntimeIdentityMaterial::generate("runtime-a")
.unwrap()
.public_key;
let binding = WorkspaceRuntimeBinding {
workspace_id: "workspace-a".to_string(),
runtime_id: "runtime-a".to_string(),
display_name: "Runtime A".to_string(),
base_url: "http://127.0.0.1:18080".to_string(),
public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
created_at: "2026-07-26T00:00:00Z".to_string(),
updated_at: "2026-07-26T00:00:00Z".to_string(),
revoked_at: None,
};
store
.upsert_trusted_runtime(&TrustedRuntimeRecord {
runtime_id: "runtime-a".to_string(),
workspace_id: None,
display_name: "Runtime A".to_string(),
base_url: "http://127.0.0.1:18080".to_string(),
public_key,
created_at: "2026-07-26T00:00:00Z".to_string(),
updated_at: "2026-07-26T00:00:00Z".to_string(),
revoked_at: None,
})
.upsert_workspace_runtime_binding(binding.clone(), false)
.unwrap();
let error = ensure_trusted_runtime_replace_allowed(&store, "runtime-a", false).unwrap_err();
assert_eq!(
error.to_string(),
"trusted runtime `runtime-a` already exists; pass --replace to update it"
assert!(matches!(
store
.upsert_workspace_runtime_binding(binding.clone(), false)
.unwrap(),
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged
));
let mut changed = binding;
changed.base_url = "http://127.0.0.1:18081".to_string();
assert!(
store
.upsert_workspace_runtime_binding(changed.clone(), false)
.is_err()
);
ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap();
store
.upsert_workspace_runtime_binding(changed, true)
.unwrap();
}
}
@@ -4,7 +4,7 @@ use std::path::{Component, Path, PathBuf};
use std::time::UNIX_EPOCH;
use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath};
use manifest::{ProfileSource, resolve_profile_artifact_value};
use manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use worker::EffectivePromptCatalog;
@@ -361,6 +361,38 @@ pub fn build_virtual_profile_config_bundle(
)
}
pub(crate) fn builtin_profile_source_archive(
profile: &worker_runtime::catalog::ProfileSelector,
) -> std::result::Result<ProfileSourceArchive, String> {
let selected_profile = match profile {
worker_runtime::catalog::ProfileSelector::Builtin(value) => {
if value.starts_with("builtin:") {
value.clone()
} else {
format!("builtin:{value}")
}
}
worker_runtime::catalog::ProfileSelector::Named(value) => {
return Err(format!(
"builtin profile source catalog has no named entrypoint for '{value}'"
));
}
};
let catalog = builtin_profile_catalog_snapshot();
if !catalog.entrypoints.contains_key(&selected_profile) {
return Err(format!(
"builtin profile source catalog has no entrypoint for '{selected_profile}'"
));
}
ProfileSourceArchive::build(ProfileSourceArchiveInput {
id: catalog.id.to_owned(),
entrypoints: catalog.entrypoints,
imports: catalog.imports,
sources: catalog.sources,
})
.map_err(|error| format!("failed to build builtin profile source archive: {error}"))
}
pub fn build_virtual_profile_config_bundle_with_prompt_projection(
projection: &ProfileConfigProjection,
state: &WorkspaceConfigState,
@@ -371,13 +403,17 @@ pub fn build_virtual_profile_config_bundle_with_prompt_projection(
) -> Result<Option<ConfigBundle>> {
validate_prompt_projection_matches_state(workspace_id, state, prompt_projection)?;
let prompt_catalog = prompt_projection.catalog().clone();
let archive = projection
.entries
.get(selector)
.map(|entry| build_virtual_profile_archive(selector, entry, &projection.sources, state))
.transpose()?;
let profile_selector = selector_for_builtin_candidate(selector)
.unwrap_or_else(|| worker_runtime::catalog::ProfileSelector::Named(selector.to_string()));
let archive = match projection.entries.get(selector) {
Some(entry) => Some(build_virtual_profile_archive(
selector,
entry,
&projection.sources,
state,
)?),
None => Some(builtin_profile_source_archive(&profile_selector).map_err(Error::Store)?),
};
let bundle_id = virtual_profile_bundle_id(
state,
workspace_id,
+11
View File
@@ -464,6 +464,7 @@ impl SqliteWorkspaceStore {
tx.execute("INSERT OR IGNORE INTO worker_tombstones(workspace_id,runtime_id,worker_id,display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,worker.display_name,worker.profile,worker.created_at,now,plan.archive_id,plan.policy_id,plan.policy_revision,operation_id])?;
}
let deleted=tx.execute("DELETE FROM worker_registry WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND updated_at=?4",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,plan.worker_revision])?;
tx.execute("UPDATE worker_create_reservations SET state='removed',updated_at=?4 WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND state='created'",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id,now])?;
if deleted!=1{return Err(StoreError::InvalidInput(format!("stale:{}:removal fence changed",plan.plan_id)));}
tx.execute("UPDATE worker_removal_operations SET state='succeeded',failure_category=NULL,updated_at=?1 WHERE operation_id=?2",params![now,operation_id])?;
tx.execute("INSERT OR IGNORE INTO worker_retention_audit_events(event_id,operation_id,workspace_id,event_kind,detail,created_at) VALUES(?1,?2,?3,'worker_removed',?4,?5)",params![stable("wre",operation_id),operation_id,workspace_id,format!("runtime_id={} worker_id={} session={} metadata={} diagnostics={}",plan.worker.runtime_id,plan.worker.worker_id,sess(plan.session_disposition),meta(plan.metadata_disposition),diag(plan.diagnostics_disposition)),now])?;
@@ -1166,6 +1167,7 @@ mod tests {
s.with_conn(|conn| {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','planning',1)", [])?;
conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','rev1')", [])?;
conn.execute("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at) VALUES('w','allocation-old',?1,'r','fingerprint','created','created','created')", [worker_id().to_string()])?;
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?;
conn.execute("DELETE FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id='1'", [])?;
conn.execute("DELETE FROM typed_tickets WHERE workspace_id='w' AND ticket_id='ticket-old'", [])?;
@@ -1212,6 +1214,15 @@ mod tests {
WorkerRemovalPlanState::Succeeded
);
assert!(s.worker_tombstone("w", &p.worker).unwrap().is_some());
let reservation_state: String = s.with_conn(|conn| {
conn.query_row(
"SELECT state FROM worker_create_reservations WHERE workspace_id='w' AND allocation_key='allocation-old'",
[],
|row| row.get(0),
)
.map_err(StoreError::from)
}).unwrap();
assert_eq!(reservation_state, "removed");
assert_eq!(
s.commit_worker_removal("w", &p.operation_id, &p.input_fingerprint, &result)
.unwrap()
@@ -1,15 +1,12 @@
use super::*;
use protocol::subscription::{SubscriptionWorkerIds, SubscriptionWorkerState};
use worker_runtime::Runtime;
use worker_runtime::catalog::{
CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
};
use worker_runtime::catalog::{CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveSource};
use worker_runtime::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
};
use worker_runtime::identity::WorkerId;
use worker_runtime::profile_archive::{ProfileSourceArchiveRef, ProfileSourceGraphSummary};
#[derive(Debug)]
struct TestExecutionBackend;
@@ -22,7 +19,6 @@ impl WorkerExecutionBackend for TestExecutionBackend {
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::connected(
WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
WorkerExecutionRunState::Idle,
None,
)
}
@@ -32,25 +28,20 @@ impl WorkerExecutionBackend for TestExecutionBackend {
_handle: &WorkerExecutionHandle,
input: worker_runtime::interaction::WorkerInput,
) -> WorkerExecutionResult {
if let Some(submission_id) = input.submission_id {
WorkerExecutionResult::accepted_input_committed(
if let Some(submission_request_id) = input.submission_request_id {
WorkerExecutionResult::accepted_submission(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
submission_id,
submission_request_id,
uuid::Uuid::now_v7().to_string(),
protocol::SubmissionDisposition::Started,
)
} else {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
)
WorkerExecutionResult::accepted(WorkerExecutionOperation::Input)
}
}
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Stop,
WorkerExecutionRunState::Stopped,
)
WorkerExecutionResult::accepted(WorkerExecutionOperation::Stop)
}
}
@@ -63,22 +54,11 @@ fn create_request(name: &str) -> CreateWorkerRequest {
profile: ProfileSelector::Builtin("builtin:companion".to_string()),
display_name: Some(name.to_string()),
config_bundle: None,
profile_source: ProfileSourceArchiveSource::Http {
location: ProfileSourceArchiveHttpRef {
url: "http://127.0.0.1/profiles/test".to_string(),
etag: None,
archive: ProfileSourceArchiveRef {
id: "test-profile-source".to_string(),
digest: "test-digest".to_string(),
size_bytes: 0,
source_graph: ProfileSourceGraphSummary {
source_count: 0,
total_source_bytes: 0,
entrypoints: std::collections::BTreeMap::new(),
import_count: 0,
},
},
},
profile_source: ProfileSourceArchiveSource::Embedded {
archive: crate::profile_settings::builtin_profile_source_archive(
&ProfileSelector::Builtin("builtin:default".to_string()),
)
.unwrap(),
},
initial_input: None,
working_directory_request: None,
@@ -197,8 +177,8 @@ async fn equal_downstream_selectors_share_one_upstream_subscription() {
runtime
.observe_worker_event(
&worker.worker_ref,
protocol::Event::Status {
status: protocol::WorkerStatus::Running,
protocol::Event::WorkerState {
snapshot: protocol::WorkerStatus::Running.into(),
},
)
.unwrap();
@@ -208,7 +188,16 @@ async fn equal_downstream_selectors_share_one_upstream_subscription() {
BrokerSubscriptionEvent::Event {
payload: SubscriptionEventPayload::WorkerUpserted { ref worker },
..
} if worker.state == SubscriptionWorkerState::Running
} if worker.state == SubscriptionWorkerState::Idle
&& matches!(
worker.worker_state,
Some(protocol::WorkerStateSnapshot {
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running
)),
..
})
)
));
}
let mut late = broker.subscribe("runtime-test", selector.clone()).unwrap();
@@ -218,7 +207,18 @@ async fn equal_downstream_selectors_share_one_upstream_subscription() {
assert!(matches!(
snapshot,
SubscriptionSnapshot::Workers { workers }
if workers.iter().any(|worker| worker.state == SubscriptionWorkerState::Running)
if workers.iter().any(|worker| {
worker.state == SubscriptionWorkerState::Idle
&& matches!(
worker.worker_state,
Some(protocol::WorkerStateSnapshot {
state: protocol::WorkerState::Busy(
protocol::WorkerBusyState::Run(protocol::WorkerRunState::Running)
),
..
})
)
})
));
drop(late);
@@ -337,14 +337,24 @@ async fn embedded_runtime_uses_in_process_subscription_source() {
runtime
.observe_worker_event(
&worker.worker_ref,
protocol::Event::Status {
status: protocol::WorkerStatus::Running,
protocol::Event::WorkerState {
snapshot: protocol::WorkerStatus::Running.into(),
},
)
.unwrap();
assert!(matches!(next_event(&mut subscription).await,
BrokerSubscriptionEvent::Event { payload: SubscriptionEventPayload::WorkerUpserted { worker }, .. }
if worker.runtime_id.as_deref() == Some("embedded-worker-runtime") && worker.state == SubscriptionWorkerState::Running));
BrokerSubscriptionEvent::Event { payload: SubscriptionEventPayload::WorkerUpserted { worker }, .. }
if worker.runtime_id.as_deref() == Some("embedded-worker-runtime")
&& worker.state == SubscriptionWorkerState::Idle
&& matches!(
worker.worker_state,
Some(protocol::WorkerStateSnapshot {
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running
)),
..
})
)));
let mut late = broker
.subscribe(
"embedded-worker-runtime",
@@ -357,7 +367,18 @@ async fn embedded_runtime_uses_in_process_subscription_source() {
assert!(matches!(
snapshot,
SubscriptionSnapshot::Workers { workers }
if workers.iter().any(|worker| worker.state == SubscriptionWorkerState::Running)
if workers.iter().any(|worker| {
worker.state == SubscriptionWorkerState::Idle
&& matches!(
worker.worker_state,
Some(protocol::WorkerStateSnapshot {
state: protocol::WorkerState::Busy(
protocol::WorkerBusyState::Run(protocol::WorkerRunState::Running)
),
..
})
)
})
));
runtime
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -14
View File
@@ -57,16 +57,12 @@ pub async fn verify_runtime_request_source_proof_with_store(
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss, workspace_id)?;
let trusted = store
.get_trusted_runtime(&unverified.iss)
.get_workspace_runtime_binding(workspace_id, &unverified.iss)
.await
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
.filter(|record| record.revoked_at.is_none())
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
let trusted_for_workspace = trusted.workspace_id.as_deref() == Some(workspace_id)
|| (unverified.iss == crate::hosts::EMBEDDED_RUNTIME_ID && trusted.workspace_id.is_none());
if !trusted_for_workspace {
return Err(WorkerMutationSourceProofError::WrongWorkspace);
}
let public_key = trusted.public_key.as_str();
let expected = RuntimeRequestSourceExpectation {
identity_id: &unverified.iss,
audience: audience.as_ref(),
@@ -78,8 +74,8 @@ pub async fn verify_runtime_request_source_proof_with_store(
body_digest,
now_unix: i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
};
let claims = verify_runtime_request_source(proof, &trusted.public_key, &expected)
.map_err(map_auth_error)?;
let claims =
verify_runtime_request_source(proof, public_key, &expected).map_err(map_auth_error)?;
let now_seconds = u64::try_from(expected.now_unix).unwrap_or(u64::MAX);
let expires_at = u64::try_from(claims.exp).unwrap_or(0);
let consumed_at = chrono::DateTime::from_timestamp(expected.now_unix, 0)
@@ -87,6 +83,7 @@ pub async fn verify_runtime_request_source_proof_with_store(
.to_rfc3339();
if !store
.consume_worker_mutation_source_jti(
workspace_id,
&claims.iss,
&claims.jti,
expires_at,
@@ -206,14 +203,12 @@ async fn verify_worker_remove_source_with(
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss, &config.workspace_id)?;
let trusted = store
.get_trusted_runtime(&unverified.iss)
.get_workspace_runtime_binding(&config.workspace_id, &unverified.iss)
.await
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?
.filter(|record| record.revoked_at.is_none())
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?;
if trusted.workspace_id.as_deref() != Some(config.workspace_id.as_str()) {
return Err(WorkerMutationSourceProofError::WrongWorkspace);
}
let public_key = trusted.public_key.as_str();
let expected = WorkerMutationSourceExpectation {
runtime_id: &unverified.iss,
audience: audience.as_ref(),
@@ -225,7 +220,7 @@ async fn verify_worker_remove_source_with(
target_worker_id,
permission: required_permission,
};
verify_worker_mutation_source_proof(&trusted.public_key, token, &expected, now)
verify_worker_mutation_source_proof(public_key, token, &expected, now)
.map_err(map_auth_error)?
}
PresentedWorkerMutationSourceProof::InProcess(proof) => {
@@ -263,7 +258,14 @@ async fn verify_worker_remove_source_with(
let consumed_at = chrono::Utc::now().to_rfc3339();
let consumed = store
.consume_worker_mutation_source_jti(&claims.iss, &claims.jti, claims.exp, now, &consumed_at)
.consume_worker_mutation_source_jti(
&config.workspace_id,
&claims.iss,
&claims.jti,
claims.exp,
now,
&consumed_at,
)
.await
.map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?;
if !consumed {
File diff suppressed because it is too large Load Diff
@@ -11,7 +11,9 @@ use tokio::sync::mpsc;
use worker_runtime::identity::RuntimeWorkerRef;
use crate::runtime_subscription::{BrokerSubscriptionEvent, RuntimeSubscriptionBroker};
use crate::server::{WorkspaceApi, connect_workspace_worker_protocol};
use crate::server::{
WorkspaceApi, authorize_browser_worker_method, connect_workspace_worker_protocol,
};
use crate::store::WorkspaceResourceKind;
const OUTBOUND_CAPACITY: usize = 256;
@@ -21,7 +23,11 @@ struct ActiveSubscription {
methods: Option<mpsc::Sender<protocol::Method>>,
}
pub(crate) async fn serve_workspace_subscription(api: WorkspaceApi, socket: WebSocket) {
pub(crate) async fn serve_workspace_subscription(
api: WorkspaceApi,
socket: WebSocket,
input_source: protocol::AuthenticatedInputSource,
) {
let broker = api.runtime_subscription_broker().clone();
let (mut socket_sender, mut socket_receiver) = socket.split();
let (control_outbound, mut control_receiver) = mpsc::channel::<WsMessage>(OUTBOUND_CAPACITY);
@@ -85,7 +91,13 @@ pub(crate) async fn serve_workspace_subscription(api: WorkspaceApi, socket: WebS
runtime_id: Some(runtime_id),
} => {
let worker = RuntimeWorkerRef::new(&runtime_id, worker_id.as_str());
match connect_workspace_worker_protocol(&api, &worker).await {
match connect_workspace_worker_protocol(
&api,
&worker,
Some(&input_source),
)
.await
{
Ok(connection) => {
let methods = connection.methods.clone();
let task = tokio::spawn(run_worker_protocol(
@@ -153,7 +165,12 @@ pub(crate) async fn serve_workspace_subscription(api: WorkspaceApi, socket: WebS
else {
break;
};
if methods.send(message.method).await.is_err() {
let Ok(method) =
authorize_browser_worker_method(message.method, &input_source)
else {
break;
};
if methods.send(method).await.is_err() {
break;
}
}
+43 -9
View File
@@ -363,10 +363,7 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
&workspace_root,
)?;
let mode = if target.kind() == client::TargetKind::Backend {
LaunchMode::Workers {
runtime_id: None,
include_stopped: false,
}
LaunchMode::BackendSpawn
} else {
LaunchMode::Spawn {
worker_name: None,
@@ -822,10 +819,7 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
.to_string(),
));
}
LaunchMode::Workers {
runtime_id: None,
include_stopped: false,
}
LaunchMode::BackendSpawn
};
Ok(Mode::Tui {
@@ -1720,7 +1714,7 @@ Target selection:
Ticket, Objective, Worker catalog, PID, socket, or subprocess authority.
Connection-aware commands:
yoi Standalone: new Console. Backend: Worker picker.
yoi Standalone: new Console. Backend: create and attach to a new Worker.
yoi resume Standalone Worker picker or stopped Backend Worker picker.
yoi workers Backend Workspace Worker picker.
yoi panel Backend Workspace dashboard.
@@ -2115,6 +2109,46 @@ backend = "shared"
}
}
#[test]
fn parse_default_backend_creates_a_worker_before_attach() {
let resolver = DefaultBackendCliConnectionResolver {
backend_url: "http://default-backend.example",
};
match parse_args_slice_with_connection_resolver(&[], &resolver).unwrap() {
Mode::Tui {
target,
mode: LaunchMode::BackendSpawn,
..
} => assert_eq!(target.kind(), TargetKind::Backend),
other => panic!("expected BackendSpawn mode, got {other:?}"),
}
}
#[test]
fn parse_bare_backend_creates_a_worker_before_attach() {
match parse_args_from([
"--backend",
"http://127.0.0.1:8787",
"--workspace-id",
"workspace-a",
])
.unwrap()
{
Mode::Tui {
target,
mode: LaunchMode::BackendSpawn,
..
} => {
assert_eq!(target.kind(), TargetKind::Backend);
let launch = target.launch_backend_worker().unwrap();
assert_eq!(launch.target.base_url, "http://127.0.0.1:8787");
assert_eq!(launch.target.workspace_id.as_deref(), Some("workspace-a"));
}
other => panic!("expected BackendSpawn mode, got {other:?}"),
}
}
#[test]
fn parse_workers_subcommand_uses_backend_runtime_picker() {
match parse_args_from([
+4 -3
View File
@@ -55,11 +55,12 @@ Workspace Server schema migration v26 removes the legacy `flow_instances`, `flow
## Worker boundary
Flow invocation uses the normal Submit/Run segment vector rather than a Worker-create field:
Flow invocation uses the normal Submit segment vector rather than a Worker-create field:
```json
{
"method": "run",
"method": "submit",
"submission_request_id": "018f4f15-5c41-7d3a-8a72-2e755bc71681",
"input": [
{ "kind": "flow", "selector": "builtin:coder-review" },
{ "kind": "text", "content": "Ticket 00001... implementation" }
@@ -69,7 +70,7 @@ Flow invocation uses the normal Submit/Run segment vector rather than a Worker-c
Runtime accepts exactly one Flow segment only when the resolved Profile enables `feature.flow` and a Workspace client is available. The Worker asks Workspace authority only for an immutable source snapshot, creates the instance locally, replaces the Flow segment with the entered state's instructions, and commits that runtime state atomically with the remaining Submit segments before LLM execution. A Worker with an active Flow rejects the duplicate input without changing its local state or events.
The generic model-facing `WorkerSpawn` accepts `initial_submit: Vec<Segment>` and routes them unchanged through the shared Workspace spawn request into Runtime `CreateWorkerRequest.initial_input`. It does not have a parallel `initial_text` or a role-specific `SpawnCoder` wrapper. Backend derives the flat content projection from the canonical segment vector, validates Flow shape before spawn, and includes the segment vector in lifecycle idempotency fingerprints. Runtime does not commit Worker creation or report spawn success merely because the initial Run method entered the Worker's in-memory channel: Runtime assigns the Submit an opaque id, the Worker commits that id as an extension on the same `UserInput` entry as any initial `FlowRuntimeState`, and the execution backend must return a matching typed input-commit acknowledgement. Restoring the same Worker never replays spawn initial segments.
The generic model-facing `WorkerSpawn` accepts `initial_submit: Vec<Segment>` and routes them unchanged through the shared Workspace spawn request into Runtime `CreateWorkerRequest.initial_input`. It does not have a parallel `initial_text` or a role-specific `SpawnCoder` wrapper. Backend derives the flat content projection from the canonical segment vector, validates Flow shape before spawn, and includes the segment vector in lifecycle idempotency fingerprints. Runtime does not commit Worker creation or report spawn success merely because the initial Submit request entered the Worker's in-memory channel: Runtime assigns the Submit an opaque id, the Worker commits that id as an extension on the same `UserInput` entry as any initial `FlowRuntimeState`, and the execution backend must return a matching typed input-commit acknowledgement. Restoring the same Worker never replays spawn initial segments.
When an Orchestrator supplies `ticket_id` to generic `WorkerSpawn`, the Worker tool derives the assignment operation id from the durable tool-call id rather than accepting lifecycle authority from model input. The shared Workspace worker-create route projects that request into a Coder Ticket-role intent and atomically applies the existing queued-Ticket assignment operation only after Runtime has returned the input-commit acknowledgement. A spawn or pre-commit input failure therefore leaves the Ticket queued and unassigned.
+9 -5
View File
@@ -110,18 +110,19 @@ On the Workspace Server host, register the Runtime public key copied from `yoi-r
```bash
yoi-server trust-runtime add \
--workspace-id '<WORKSPACE_ID>' \
--runtime-id runtime-main \
--base-url http://127.0.0.1:38800 \
--public-key '<RUNTIME_PUBLIC_KEY>' \
--display-name 'Runtime main'
```
This writes a trusted Runtime record to the Server DB. During `yoi-server serve`, active trusted Runtime records are loaded as remote Runtime sources and receive signed capability tokens. You do not need to duplicate the same Runtime in `runtimes.toml` for this trust-backed path.
This writes a Workspace-scoped Runtime binding and trust fingerprint to the Server DB. During `yoi-server serve`, active bindings are loaded as remote Runtime sources and receive signed capability tokens. Repository-external Runtime files are not registration or trust authority.
Verify:
```bash
yoi-server trust-runtime list --json
yoi-server trust-runtime list --workspace-id '<WORKSPACE_ID>' --json
```
## 5. Start Runtime and Workspace Server
@@ -160,7 +161,7 @@ An empty Server DB is valid. Open the Web UI, create or authenticate the Account
Check both trust stores:
```bash
yoi-server trust-runtime list --json
yoi-server trust-runtime list --workspace-id '<WORKSPACE_ID>' --json
yoi-runtime trust-server list --json
```
@@ -203,6 +204,7 @@ After Runtime identity rotation, Server must be updated with the new Runtime pub
```bash
yoi-server trust-runtime add \
--workspace-id '<WORKSPACE_ID>' \
--runtime-id runtime-main \
--base-url http://127.0.0.1:38800 \
--public-key '<NEW_RUNTIME_PUBLIC_KEY>' \
@@ -214,7 +216,9 @@ yoi-server trust-runtime add \
Revoke a trusted Runtime on Server:
```bash
yoi-server trust-runtime revoke --runtime-id runtime-main
yoi-server trust-runtime revoke \
--workspace-id '<WORKSPACE_ID>' \
--runtime-id runtime-main
```
Remove a trusted Server from Runtime:
@@ -252,7 +256,7 @@ Confirm the `--runtime-id` registered on Server exactly matches the Runtime iden
```bash
yoi-runtime identity show --json
yoi-server trust-runtime list --json
yoi-server trust-runtime list --workspace-id '<WORKSPACE_ID>' --json
```
`RUNTIME_ID` is the token audience; mismatches are rejected by Runtime.
+1 -1
View File
@@ -286,7 +286,7 @@ User triggers a Ticket action in yoi panel
-> client Ticket role launcher reads .yoi/workspace.toml [ticket] settings
-> launcher selects the role Profile
-> launcher spawns the role Worker
-> launcher sends Method::Run with Text segments
-> launcher sends Method::Submit with Text segments
-> launcher waits for run-acceptance evidence
-> Dashboard reports success/failure
```
+45 -27
View File
@@ -1,41 +1,59 @@
# Workspace database schema baseline
# Workspace schema migrations
The Workspace Server owns one control-plane SQLite database. New databases are created directly from the current canonical schema; the repository does not retain an executable chain of historical Workspace schema migrations.
Workspace schema authority belongs to `crates/workspace-server/src/store.rs`. The canonical
schema, ordered migrations, migration-history validation, startup upgrade path, and explicit
`yoi-server migrate` command must remain one contract.
Domain components such as Ticket and Merge Request contribute their current tables to the same database, but they do not create a second Workspace authority.
## Retained migration chain
## Compatibility boundary
Released or dogfooded schema migrations are retained and composed in version order. Adding a new
schema version does not authorize deleting the preceding migration. A migration may be removed
only as an explicit baseline-retirement operation after the supported installations that depend on
it have been migrated or intentionally discarded.
The Server accepts only the current canonical schema generation. Its `__yoi_schema_migrations` ledger must contain exactly one row naming that baseline. A database with an older, newer, or multi-generation Workspace migration history is rejected at startup.
The current retained Workspace chain is:
This is intentional while Yoi has only the dogfooding deployment. Schema changes may replace the baseline rather than adding permanent compatibility code. Existing dogfooding data must be migrated manually and atomically before starting the new binary.
1. schema 50: `workspace schema baseline`
2. schema 51: `workspace runtime bindings`
3. schema 52: `workspace Runtime binding revision and audit`
4. schema 53: `durable Workspace deletion operations`
## Updating the dogfooding database
A database may begin at any retained baseline. Its following history rows must be the exact prefix
of the ordered migration chain from that baseline. This allows both a freshly created current
database and a database upgraded across several releases while rejecting edited, reordered, or
unknown histories.
1. Stop every Server and Runtime process that can write the affected SQLite or Runtime stores.
2. Record the current binary revision and schema generation.
3. Take a SQLite-safe backup of `server.db` and a filesystem backup of any Runtime stores whose persisted contracts change.
4. Apply the data and schema repair explicitly. Keep Workspace SQL data and Runtime filesystem data as separate authorities; changing one does not repair the other.
5. Replace historical migration-ledger rows with the single marker expected by the current baseline.
6. Validate before startup:
## Runtime behavior
```sql
PRAGMA foreign_key_check;
PRAGMA integrity_check;
```
`SqliteWorkspaceStore::open` computes all pending retained migrations and applies them in order.
Each migration is transactional and restartable: if a later step fails, completed steps remain a
valid canonical prefix and the next run resumes from that version.
7. Start exactly one Server generation and verify the affected API contracts.
`yoi-server migrate` invokes the same store migration path without starting the Server:
There is no in-place down migration and no automatic upgrade from an old baseline. Rollback means restoring both the prior binary and the complete matching database and Runtime-store backups.
```sh
# Copy the DB into memory and validate the complete pending path without changing the source.
yoi-server migrate --dry-run
## Creating a new baseline
# Preflight the complete path, then apply it to the source DB.
yoi-server migrate
```
A baseline change must include:
Use `--database <PATH>` for a non-default Server DB. Stop `yoi-server` before applying migrations
and make an external backup before an operational upgrade.
- canonical DDL that creates a fresh database directly at the new generation;
- current-schema verification for Workspace, Ticket, and Merge Request tables;
- tests proving a fresh database records only the canonical baseline marker;
- an explicit, separately reviewed repair procedure for the current dogfooding data;
- removal of obsolete migration functions, fixtures, commands, and documentation.
## Development workflow
Do not put temporary legacy interpretation into normal request or projection paths. If persisted Runtime data also changes identity or shape, repair that Runtime authority explicitly instead of teaching steady-state Workspace APIs to accept both contracts indefinitely.
When changing the Workspace schema:
1. increment `LATEST_SCHEMA_VERSION`;
2. append one `Migration` entry with the new version, stable name, and apply function;
3. preserve all migrations at or above `OLDEST_SCHEMA_VERSION`;
4. update the canonical latest-schema creator for fresh databases;
5. add a fixture at the oldest retained version and prove migration through every retained step;
6. prove that `--dry-run` leaves the source DB unchanged;
7. keep DDL validation and cross-schema foreign-key checks in the shared store preparation path.
A deliberate baseline retirement must be a separately reviewed change. It must identify the oldest
remaining version, provide an operational migration/discard plan for older databases, update tests
and this document, and must not be inferred merely because a new migration was added.
@@ -17,7 +17,7 @@ context ではない。`crypto.randomUUID()` は secure context 限定なので
Worker Console の `connectProtocolTransport` は、最初に `protocolState`
`"connecting"` に設定してから `WorkspaceMultiplexer.subscribe()` を呼ぶ。
- `web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte`
- `web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte`
- `protocolState = "connecting"`
- 直後に `workspaceMultiplexer(...).subscribe(...)`
- `web/workspace/src/lib/workspace/multiplexer.ts`
@@ -0,0 +1,32 @@
# Schema baseline cleanup was generalized into migration deletion policy
## Summary
A one-time Workspace SQLite baseline cleanup in `89856eb7` was documented as a permanent policy
that retained historical migrations were not part of the contract. Later schema changes followed
that text by deleting the immediately preceding migration whenever a new version was added.
This left valid dogfooding databases stranded. A schema-50 database could run the historical
50-to-51 migration, but the schema-52 binary required a fresh schema-51 baseline and rejected the
resulting two-row history. The current schema-53 binary retained only 52-to-53. The CLI also listed
`migrate` as an expected command after its implementation had been removed.
## Why this was harmful
A request or decision to retire sufficiently old generations does not imply that every migration
should be discarded at the next version bump. Treating a baseline reset as a standing policy
removed the only executable data-preservation path without an explicit retention decision or
operational replacement.
The narrow tests reinforced the mistake: each version tested only a freshly constructed previous
baseline, not a database migrated from the oldest retained version through the full chain.
## Corrective rules
- Keep an ordered, composable migration chain from an explicit oldest supported version.
- Do not infer migration retirement from a schema-version increment.
- Require a separately reviewed baseline-retirement change and operational plan.
- Test the oldest retained fixture through every migration to the latest schema.
- Make startup migration and explicit migration CLI use the same planner and runner.
- Make dry-run execute the real path against an in-memory SQLite backup rather than maintaining a
second approximation.
+1 -1
View File
@@ -34,7 +34,7 @@
- いくつかの async/socket tests はまだ fixed sleeps と process-wide environment mutation (`YOI_RUNTIME_DIR`, `YOI_HOME`, `XDG_RUNTIME_DIR`) に依存している。一部ファイルでは env-changing tests を guards で serialize しているが、fixed timing と global env は高負荷 CI や parallel execution 下で flakiness risk のまま。
- Real provider wire behavior は意図的に `pod` の外側だが、crate と streaming edge cases の interaction はまだ大部分が mock されている。tests は重要な `Worker` outcomes をカバーしているが、malformed/partial provider streams を real provider adapter 経由では exercise していない。
- Prompt tests は数が多く有用だが、一部は behavior-coupled というより prose-coupled である。critical safety wording に対して、その文字列を意図的に stable contract として扱う場合だけこれは許容できる。そうでなければ maintenance noise になる。
- startup profile resolution、socket server、`Method::Run`、session persistence、shutdown、restore を跨ぐ full lifecycle integration は slice ごとにしかカバーされておらず、1 つの scenario としてはカバーされていない。
- startup profile resolution、socket server、`Method::Submit`、session persistence、shutdown、restore を跨ぐ full lifecycle integration は slice ごとにしかカバーされておらず、1 つの scenario としてはカバーされていない。
## 追加を提案するもの
+1 -1
View File
@@ -15,7 +15,7 @@
};
review = {
instructions = "Use the current Ticket Merge Request as review authority. Call `ShowMergeRequest` and confirm its source selector resolves to exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, write scope for Workdir inspection and command validation, and only the Ticket id in the structured review handoff. The trusted spawn layer records `ReviewRequested` with the exact source ref and injects review capability; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit `ReviewMergeRequest`; prose output and Worker observation are not approval authority. After the structured result for the exact current source ref exists, request a Flow transition.";
instructions = "Use the current Ticket Merge Request as review authority. Call `ShowMergeRequest` and confirm its source selector resolves to exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, write scope plus an explicit command grant for Workdir inspection and command validation, and only the Ticket id in the structured review handoff. The trusted spawn layer records `ReviewRequested` with the exact source ref and injects review capability; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit `ReviewMergeRequest`; prose output and Worker observation are not approval authority. After the structured result for the exact current source ref exists, request a Flow transition.";
transitions = {
approved = {
target = "complete";
@@ -1,8 +1,8 @@
Spawn a parent-owned Internal SubWorker session to split context for a delegated task. The parent Worker's write scope is reduced by the scope passed here; the Internal SubWorker starts running `task` immediately without creating a Runtime Worker record, OS process, PID, or Unix socket. It remains available for follow-up turns until explicitly stopped or its parent exits.
Optional `cwd`: when provided, the spawned SubWorker's tool default working directory only. It must be an absolute existing directory covered by the child's delegated readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority. `name` must be unique among this Worker's direct children.
Optional `cwd`: when provided, the spawned SubWorker's tool default working directory only. It must be a Workdir-relative existing directory covered by the child's readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority. `name` must be unique among this Worker's direct children.
Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Worker, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SubWorkerSpawn. `scope` is always the only delegated filesystem capability; profile scope is replaced by the explicit SubWorkerSpawn scope.
Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Worker, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SubWorkerSpawn. `scope` is the child's only filesystem capability and replaces profile scope. `command` is a separate explicit grant, defaults to false, and is accepted only with a writable scope; writable scope alone does not grant command execution.
Default profile: {{ default_profile }}
Special selector: inherit — derive reusable model/worker/tool policy from the spawner while replacing worker.name and scope.
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},
+57 -6
View File
@@ -10,6 +10,37 @@ export type CompletionKind = "file";
export type WorkerStatus = "idle" | "running" | "paused" | "stopped";
export type WorkerCommandEnvelope = {
/**
* Caller-owned sequence. A controller accepts command ids in strictly
* increasing order for one execution generation.
*/
command_id: number, expected_execution_generation: number, expected_worker_state_revision: number, };
export type WorkerCommandKind = "resume" | "cancel" | "pause" | "compact" | "shutdown";
export type WorkerCommandDisposition = "accepted" | "stale_execution_generation" | "stale_worker_state_revision" | "stale_command_id" | "conflict" | "invalid_state";
export type WorkerCommandAcknowledgement = { command_id: number, command: WorkerCommandKind, disposition: WorkerCommandDisposition,
/**
* The complete authoritative state observed after command admission.
*/
state: WorkerStateSnapshot, };
export type WorkerRunState = "running" | "pausing" | "paused" | "cancelling";
export type WorkerMaintenanceState = "compacting";
export type WorkerBusyState = { "kind": "run", "state": WorkerRunState } | { "kind": "maintenance", "state": WorkerMaintenanceState };
export type WorkerState = { "kind": "idle" } | { "kind": "busy", "state": WorkerBusyState };
export type WorkerStateSnapshot = { execution_generation: number, revision: number,
/**
* Highest lifecycle command id observed by this controller generation.
*/
last_command_id: number, state: WorkerState, };
export type TurnResult = "finished" | "paused";
export type InvokeKind = "user_send" | "notify" | "worker_event" | "system_reminder" | "wakeup";
@@ -103,7 +134,13 @@ entry_id: string,
*/
timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array<string>, } & ({ "kind": "user_input", segments: Array<Segment>, } | { "kind": "message", role: SessionMessageRole, content: Array<SessionContentPart>, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array<SessionToolAttachment>, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_error", message: string, });
export type SessionSnapshot = { entries: Array<SessionSnapshotEntry>, };
export type PendingSubmissionSummary = { submission_id: string, accepted_at_ms: number, segment_count: number, byte_len: number, };
export type PendingSubmissionsSnapshot = { revision: number, notification_count: number, head_id: string | null, submissions: Array<PendingSubmissionSummary>, };
export type SubmissionDisposition = "started" | "queued";
export type SessionSnapshot = { pending_submissions: PendingSubmissionsSnapshot, entries: Array<SessionSnapshotEntry>, };
export type InternalWorkerKind = "sub_worker" | { "service": { kind: string, } };
@@ -196,7 +233,16 @@ resource_key?: string | null,
/**
* Producer-owned monotonic revision for this Worker subject.
*/
subject_revision: number, state: SubscriptionWorkerState, has_running_internal_workers: boolean, workspace_id?: string | null, display_name?: string | null, profile?: string | null,
subject_revision: number,
/**
* Latest revisioned foreground state observed from the Worker. This remains
* absent until an authoritative Worker snapshot/event has been applied.
*/
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.
@@ -225,9 +271,9 @@ export type SubscriptionFramePayload = { "frame": "request", "message": Subscrip
export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod });
export type Method = { "method": "run", "params": { input: Array<Segment>, } } | { "method": "notify", "params": { message: string, auto_run?: boolean, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "resume" } | { "method": "cancel" } | { "method": "pause" } | { "method": "compact" } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown" } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array<Segment>, } } | { "method": "notify", "params": { notification_request_id: string, message: string, auto_run?: boolean, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
export type Event = { "event": "user_message", "data": { segments: Array<Segment>, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": { segments: Array<Segment>, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
/**
* The attempt that just failed. 1 origin.
*/
@@ -241,7 +287,12 @@ summary: string,
* Full tool output. Absent when the tool chose to return
* summary-only, or when the result was pruned.
*/
output?: string | null, disposition?: ToolResultDisposition | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { session: SessionSnapshot, greeting: Greeting, status: WorkerStatus,
output?: string | null, disposition?: ToolResultDisposition | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { session: SessionSnapshot, greeting: Greeting,
/**
* Full revisioned live execution state. `Stopped` remains Runtime
* catalog authority and is deliberately not represented here.
*/
state: WorkerStateSnapshot,
/**
* Unfinished model output that has already streamed in the current
* run but is not yet represented by committed snapshot entries.
@@ -251,4 +302,4 @@ in_flight?: InFlightSnapshot,
* Parent-owned Internal Worker sessions visible to this client.
* Service-private Internal Workers are deliberately excluded.
*/
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { session: SessionSnapshot, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { session: SessionSnapshot, } } | { "event": "worker_state", "data": { snapshot: WorkerStateSnapshot, } } | { "event": "command_acknowledged", "data": { acknowledgement: WorkerCommandAcknowledgement, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
@@ -47,6 +47,72 @@ export type WorkspaceAuthConfig = {
export type WorkspacePermissionSummary = {
manage_repositories: boolean;
manage_secrets: boolean;
manage_runtimes: boolean;
delete_workspace: boolean;
};
export type WorkspaceDeletionState =
| "queued"
| "running"
| "blocked"
| "failed"
| "succeeded";
export type WorkspaceDeletionBlockerKind =
| "last_accessible_workspace"
| "revision_conflict"
| "dirty_workdir"
| "worker_removal_blocked"
| "workdir_removal_blocked"
| "retention_hold"
| "cleanup_unavailable";
export type WorkspaceDeletionBlocker = {
kind: WorkspaceDeletionBlockerKind;
resource_kind: string | null;
resource_key: string | null;
message: string;
};
export type WorkspaceDeletionResourceCounts = {
workers: number;
workdirs: number;
repositories: number;
runtime_bindings: number;
secrets: number;
artifacts: number;
};
export type WorkspaceDeletionPreflightResponse = {
workspace_id: string;
display_name: string;
/**
* Opaque persisted Workspace metadata revision used as a CAS fence.
*/
expected_revision: string;
can_delete: boolean;
resources: WorkspaceDeletionResourceCounts;
blockers: Array<WorkspaceDeletionBlocker>;
};
export type WorkspaceDeletionRequest = {
operation_id: string;
expected_revision: string;
confirmation: string;
};
export type WorkspaceDeletionOperationResponse = {
operation_id: string;
workspace_id: string;
display_name: string;
state: WorkspaceDeletionState;
resources: WorkspaceDeletionResourceCounts;
child_operation_ids: Array<string>;
blockers: Array<WorkspaceDeletionBlocker>;
failure_category: string | null;
created_at: string;
updated_at: string;
completed_at: string | null;
};
export type DiagnosticSeverity = "info" | "warning" | "error";
@@ -220,3 +286,129 @@ export type RepositoryLogResponse = {
items: Array<GitCommitSummary>;
diagnostics: Array<Diagnostic>;
};
export type RuntimeSourceKind = "embedded_worker_runtime" | "remote_http";
export type RuntimeSourceStatus = "active" | "reserved";
export type RuntimeIdentityAuthority =
| "runtime_registry_projection"
| "server_runtime_configuration";
export type RuntimeSourceSummary = {
kind: RuntimeSourceKind;
status: RuntimeSourceStatus;
identity_authority: RuntimeIdentityAuthority;
note: string;
};
export type RuntimeSummary = {
runtime_id: string;
label: string;
kind: string;
status: string;
source: RuntimeSourceSummary;
host_ids: Array<string>;
worker_creation_available: boolean;
os: string;
arch: string;
diagnostics: Array<Diagnostic>;
};
export type RuntimeManagementSummary = {
built_in: boolean;
config_managed: boolean;
removable: boolean;
endpoint_configured: boolean;
token_ref_configured: boolean;
};
export type WorkspaceRuntimeResource = {
management: RuntimeManagementSummary;
runtime_id: string;
label: string;
kind: string;
status: string;
source: RuntimeSourceSummary;
host_ids: Array<string>;
worker_creation_available: boolean;
os: string;
arch: string;
diagnostics: Array<Diagnostic>;
};
export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked";
export type RuntimeTrustKeyState = {
status: RuntimeTrustKeyStatus;
fingerprint?: string | null;
revision?: number | null;
created_at?: string | null;
updated_at?: string | null;
revoked_at?: string | null;
};
export type RuntimeTrustAuditAction =
| "created"
| "replaced"
| "reactivated"
| "revoked";
export type RuntimeTrustAuditEntry = {
action: RuntimeTrustAuditAction;
actor_account_id: string;
old_fingerprint?: string | null;
new_fingerprint?: string | null;
revision: number;
at: string;
};
export type WorkspaceRuntimeDetail = {
workspace_id: string;
runtime: WorkspaceRuntimeResource;
endpoint?: string | null;
trust_key: RuntimeTrustKeyState;
recent_audit: Array<RuntimeTrustAuditEntry>;
};
export type RuntimeTrustKeyRevealResponse = { public_key: string };
export type PutRuntimeTrustKeyRequest = {
public_key: string;
expected_revision: number | null;
};
export type RevokeRuntimeTrustKeyRequest = { expected_revision: number };
export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use";
export type RuntimeTrustConflictResponse = {
error: RuntimeTrustConflictKind;
message: string;
current_revision?: number;
current_fingerprint?: string | null;
};
export type RuntimeConnectionTestStatus = "compatible" | "failed";
export type RuntimeConnectionTestFailureKind =
| "authentication"
| "authorization"
| "network_unreachable"
| "timeout"
| "tls_or_transport"
| "malformed_response"
| "protocol_version_mismatch"
| "runtime_identity_mismatch"
| "configuration";
export type RuntimeConnectionTestResponse = {
workspace_id: string;
runtime_id: string;
checked_at: string;
status: RuntimeConnectionTestStatus;
failure_kind: RuntimeConnectionTestFailureKind | null;
expected_protocol_version: number;
actual_protocol_version: number | null;
diagnostics: Array<Diagnostic>;
};
@@ -0,0 +1,142 @@
import type {
Diagnostic,
RuntimeConnectionTestFailureKind,
RuntimeConnectionTestResponse,
} from "$lib/generated/workspace-api";
const RESPONSE_KEYS = [
"workspace_id",
"runtime_id",
"checked_at",
"status",
"failure_kind",
"expected_protocol_version",
"actual_protocol_version",
"diagnostics",
] as const;
const DIAGNOSTIC_KEYS = ["code", "severity", "message"] as const;
const FAILURE_KINDS = new Set<RuntimeConnectionTestFailureKind>([
"authentication",
"authorization",
"network_unreachable",
"timeout",
"tls_or_transport",
"malformed_response",
"protocol_version_mismatch",
"runtime_identity_mismatch",
"configuration",
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasExactKeys(
record: Record<string, unknown>,
expected: readonly string[],
): boolean {
const actual = Object.keys(record).sort();
const wanted = [...expected].sort();
return actual.length === wanted.length &&
actual.every((key, index) => key === wanted[index]);
}
function isBoundedString(value: unknown, max = 1024): value is string {
return typeof value === "string" && value.length > 0 && value.length <= max;
}
function isProtocolVersion(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
function parseDiagnostic(value: unknown): Diagnostic | null {
if (!isRecord(value) || !hasExactKeys(value, DIAGNOSTIC_KEYS)) return null;
if (!isBoundedString(value.code, 128) || !isBoundedString(value.message)) {
return null;
}
if (
value.severity !== "info" && value.severity !== "warning" &&
value.severity !== "error"
) {
return null;
}
return {
code: value.code,
severity: value.severity,
message: value.message,
};
}
export function parseRuntimeConnectionTestResponse(
value: unknown,
): RuntimeConnectionTestResponse | null {
if (!isRecord(value) || !hasExactKeys(value, RESPONSE_KEYS)) return null;
if (
!isBoundedString(value.workspace_id, 256) ||
!isBoundedString(value.runtime_id, 256) ||
!isBoundedString(value.checked_at, 128) ||
Number.isNaN(Date.parse(value.checked_at)) ||
(value.status !== "compatible" && value.status !== "failed") ||
!isProtocolVersion(value.expected_protocol_version) ||
(value.actual_protocol_version !== null &&
!isProtocolVersion(value.actual_protocol_version)) ||
!Array.isArray(value.diagnostics) ||
value.diagnostics.length > 16
) {
return null;
}
const failureKind = value.failure_kind;
if (
failureKind !== null &&
!FAILURE_KINDS.has(failureKind as RuntimeConnectionTestFailureKind)
) {
return null;
}
const diagnostics = value.diagnostics.map(parseDiagnostic);
if (diagnostics.some((diagnostic) => diagnostic === null)) return null;
if (
(value.status === "compatible" &&
(failureKind !== null ||
value.actual_protocol_version !== value.expected_protocol_version ||
diagnostics.length !== 0)) ||
(value.status === "failed" && failureKind === null)
) {
return null;
}
return {
workspace_id: value.workspace_id,
runtime_id: value.runtime_id,
checked_at: value.checked_at,
status: value.status,
failure_kind: failureKind as RuntimeConnectionTestFailureKind | null,
expected_protocol_version: value.expected_protocol_version,
actual_protocol_version: value.actual_protocol_version,
diagnostics: diagnostics as Diagnostic[],
};
}
export async function testRuntimeConnection(
workspaceId: string,
runtimeId: string,
fetchImpl: typeof fetch = fetch,
): Promise<RuntimeConnectionTestResponse> {
const response = await fetchImpl(
`/api/w/${encodeURIComponent(workspaceId)}/runtimes/${
encodeURIComponent(runtimeId)
}/connection-tests`,
{ method: "POST" },
);
if (!response.ok) {
throw new Error(`Connection test failed (${response.status})`);
}
const parsed = parseRuntimeConnectionTestResponse(await response.json());
if (!parsed) {
throw new Error("Connection test returned an invalid response");
}
if (parsed.workspace_id !== workspaceId || parsed.runtime_id !== runtimeId) {
throw new Error(
"Connection test response did not match the selected Runtime",
);
}
return parsed;
}
@@ -0,0 +1,816 @@
import type {
Diagnostic,
PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest,
RuntimeIdentityAuthority,
RuntimeManagementSummary,
RuntimeSourceKind,
RuntimeSourceStatus,
RuntimeSourceSummary,
RuntimeTrustAuditAction,
RuntimeTrustAuditEntry,
RuntimeTrustConflictKind,
RuntimeTrustConflictResponse,
RuntimeTrustKeyRevealResponse,
RuntimeTrustKeyState,
RuntimeTrustKeyStatus,
WorkspaceRuntimeDetail,
WorkspaceRuntimeResource,
} from "$lib/generated/workspace-api.ts";
import type { ListResponse } from "$lib/workspace/sidebar/types";
import { workspaceApiPath } from "./http.ts";
export type WorkspaceRuntimeList = ListResponse<WorkspaceRuntimeResource>;
const LIMITS = {
runtimeItems: 200,
auditEntries: 20,
hostIds: 128,
diagnostics: 64,
idBytes: 256,
labelBytes: 512,
kindBytes: 128,
statusBytes: 128,
noteBytes: 2_048,
endpointBytes: 4_096,
publicKeyBytes: 16 * 1_024,
fingerprintBytes: 512,
timestampBytes: 128,
diagnosticCodeBytes: 128,
diagnosticMessageBytes: 2_048,
conflictMessageBytes: 1_024,
responseBytes: 512 * 1_024,
} as const;
const SOURCE_KINDS = new Set<RuntimeSourceKind>([
"embedded_worker_runtime",
"remote_http",
]);
const SOURCE_STATUSES = new Set<RuntimeSourceStatus>(["active", "reserved"]);
const IDENTITY_AUTHORITIES = new Set<RuntimeIdentityAuthority>([
"runtime_registry_projection",
"server_runtime_configuration",
]);
const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]);
const TRUST_STATUSES = new Set<RuntimeTrustKeyStatus>([
"unconfigured",
"active",
"revoked",
]);
const AUDIT_ACTIONS = new Set<RuntimeTrustAuditAction>([
"created",
"replaced",
"reactivated",
"revoked",
]);
const CONFLICT_KINDS = new Set<RuntimeTrustConflictKind>([
"stale_revision",
"fingerprint_in_use",
]);
const encoder = new TextEncoder();
type JsonObject = Record<string, unknown>;
export class RuntimeManagementValidationError extends Error {
constructor(message: string) {
super(message.slice(0, 256));
this.name = "RuntimeManagementValidationError";
}
}
export class RuntimeTrustConflictError extends Error {
readonly conflict: RuntimeTrustConflictResponse;
constructor(conflict: RuntimeTrustConflictResponse) {
super(conflict.message);
this.name = "RuntimeTrustConflictError";
this.conflict = conflict;
}
}
export class RuntimeTrustRequestError extends Error {
readonly field: "public_key" | null;
constructor(message: string, field: "public_key" | null = null) {
super(message.slice(0, 256));
this.name = "RuntimeTrustRequestError";
this.field = field;
}
}
export type RuntimeTrustRouteOperation = Readonly<{
runtimeId: string;
generation: number;
}>;
export class RuntimeTrustRouteFence {
#runtimeId: string | null = null;
#generation = 0;
enter(runtimeId: string): number {
if (this.#runtimeId !== runtimeId) {
this.#runtimeId = runtimeId;
this.#generation += 1;
}
return this.#generation;
}
capture(runtimeId: string): RuntimeTrustRouteOperation {
return { runtimeId, generation: this.enter(runtimeId) };
}
isCurrent(operation: RuntimeTrustRouteOperation, runtimeId: string): boolean {
return operation.runtimeId === runtimeId &&
operation.generation === this.#generation &&
this.#runtimeId === runtimeId;
}
}
function fail(path: string, message: string): never {
throw new RuntimeManagementValidationError(`${path} ${message}`);
}
function object(value: unknown, path: string): JsonObject {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return fail(path, "must be an object");
}
return value as JsonObject;
}
function exactKeys(
value: JsonObject,
required: readonly string[],
optional: readonly string[],
path: string,
): void {
const allowed = new Set([...required, ...optional]);
for (const key of Object.keys(value)) {
if (!allowed.has(key)) {
fail(`${path}.${key}`, "is not part of the wire contract");
}
}
for (const key of required) {
if (!Object.hasOwn(value, key)) fail(`${path}.${key}`, "is required");
}
}
function array(value: unknown, path: string, max: number): unknown[] {
if (!Array.isArray(value)) return fail(path, "must be an array");
if (value.length > max) {
return fail(path, `must contain at most ${max} items`);
}
return value;
}
function boundedString(
value: unknown,
path: string,
maxBytes: number,
allowEmpty = false,
): string {
if (typeof value !== "string") return fail(path, "must be a string");
if (!allowEmpty && value.length === 0) return fail(path, "must not be empty");
if (encoder.encode(value).byteLength > maxBytes) {
return fail(path, `must be at most ${maxBytes} UTF-8 bytes`);
}
return value;
}
function boolean(value: unknown, path: string): boolean {
if (typeof value !== "boolean") return fail(path, "must be a boolean");
return value;
}
function safeInteger(value: unknown, path: string, minimum = 0): number {
if (
typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum
) {
return fail(path, `must be a safe integer of at least ${minimum}`);
}
return value;
}
function safeRevision(value: unknown, path: string): number {
return safeInteger(value, path, 1);
}
function optionalNullableString(
value: unknown,
path: string,
maxBytes: number,
allowEmpty = false,
): string | null | undefined {
if (value === undefined || value === null) return value;
return boundedString(value, path, maxBytes, allowEmpty);
}
function optionalRevision(
value: unknown,
path: string,
): number | undefined {
if (value === undefined || value === null) return undefined;
return safeRevision(value, path);
}
function optionalNullableRevision(
value: unknown,
path: string,
): number | null | undefined {
if (value === undefined || value === null) return value;
return safeRevision(value, path);
}
function timestamp(value: unknown, path: string): string {
const result = boundedString(value, path, LIMITS.timestampBytes);
if (
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
.test(result)
) {
return fail(path, "must be an RFC 3339 timestamp");
}
return result;
}
function optionalNullableTimestamp(
value: unknown,
path: string,
): string | null | undefined {
if (value === undefined || value === null) return value;
return timestamp(value, path);
}
function enumValue<T extends string>(
value: unknown,
path: string,
variants: ReadonlySet<T>,
): T {
const result = boundedString(value, path, LIMITS.kindBytes);
if (!variants.has(result as T)) {
return fail(path, "contains an unknown enum value");
}
return result as T;
}
function diagnostic(value: unknown, path: string): Diagnostic {
const item = object(value, path);
exactKeys(item, ["code", "severity", "message"], [], path);
const severity = enumValue(
item.severity,
`${path}.severity`,
DIAGNOSTIC_SEVERITIES,
) as Diagnostic["severity"];
return {
code: boundedString(item.code, `${path}.code`, LIMITS.diagnosticCodeBytes),
severity,
message: boundedString(
item.message,
`${path}.message`,
LIMITS.diagnosticMessageBytes,
true,
),
};
}
function runtimeSource(value: unknown, path: string): RuntimeSourceSummary {
const item = object(value, path);
exactKeys(item, ["kind", "status", "identity_authority", "note"], [], path);
return {
kind: enumValue(item.kind, `${path}.kind`, SOURCE_KINDS),
status: enumValue(item.status, `${path}.status`, SOURCE_STATUSES),
identity_authority: enumValue(
item.identity_authority,
`${path}.identity_authority`,
IDENTITY_AUTHORITIES,
),
note: boundedString(item.note, `${path}.note`, LIMITS.noteBytes, true),
};
}
function runtimeManagement(
value: unknown,
path: string,
): RuntimeManagementSummary {
const item = object(value, path);
exactKeys(
item,
[
"built_in",
"config_managed",
"removable",
"endpoint_configured",
"token_ref_configured",
],
[],
path,
);
return {
built_in: boolean(item.built_in, `${path}.built_in`),
config_managed: boolean(item.config_managed, `${path}.config_managed`),
removable: boolean(item.removable, `${path}.removable`),
endpoint_configured: boolean(
item.endpoint_configured,
`${path}.endpoint_configured`,
),
token_ref_configured: boolean(
item.token_ref_configured,
`${path}.token_ref_configured`,
),
};
}
function runtimeResource(
value: unknown,
path: string,
): WorkspaceRuntimeResource {
const item = object(value, path);
exactKeys(
item,
[
"management",
"runtime_id",
"label",
"kind",
"status",
"source",
"host_ids",
"worker_creation_available",
"os",
"arch",
"diagnostics",
],
[],
path,
);
const hostIds = array(item.host_ids, `${path}.host_ids`, LIMITS.hostIds).map(
(entry, index) =>
boundedString(
entry,
`${path}.host_ids[${index}]`,
LIMITS.idBytes,
),
);
if (new Set(hostIds).size !== hostIds.length) {
fail(`${path}.host_ids`, "must not contain duplicate IDs");
}
return {
management: runtimeManagement(item.management, `${path}.management`),
runtime_id: boundedString(
item.runtime_id,
`${path}.runtime_id`,
LIMITS.idBytes,
),
label: boundedString(item.label, `${path}.label`, LIMITS.labelBytes),
kind: boundedString(item.kind, `${path}.kind`, LIMITS.kindBytes),
status: boundedString(item.status, `${path}.status`, LIMITS.statusBytes),
source: runtimeSource(item.source, `${path}.source`),
host_ids: hostIds,
worker_creation_available: boolean(
item.worker_creation_available,
`${path}.worker_creation_available`,
),
os: boundedString(item.os, `${path}.os`, LIMITS.kindBytes, true),
arch: boundedString(item.arch, `${path}.arch`, LIMITS.kindBytes, true),
diagnostics: array(
item.diagnostics,
`${path}.diagnostics`,
LIMITS.diagnostics,
).map((entry, index) => diagnostic(entry, `${path}.diagnostics[${index}]`)),
};
}
function trustKey(value: unknown, path: string): RuntimeTrustKeyState {
const item = object(value, path);
exactKeys(
item,
["status"],
["fingerprint", "revision", "created_at", "updated_at", "revoked_at"],
path,
);
const result: RuntimeTrustKeyState = {
status: enumValue(item.status, `${path}.status`, TRUST_STATUSES),
fingerprint: optionalNullableString(
item.fingerprint,
`${path}.fingerprint`,
LIMITS.fingerprintBytes,
),
revision: optionalNullableRevision(item.revision, `${path}.revision`),
created_at: optionalNullableTimestamp(
item.created_at,
`${path}.created_at`,
),
updated_at: optionalNullableTimestamp(
item.updated_at,
`${path}.updated_at`,
),
revoked_at: optionalNullableTimestamp(
item.revoked_at,
`${path}.revoked_at`,
),
};
const hasBinding = result.status !== "unconfigured";
if (
hasBinding &&
(result.fingerprint == null || result.revision == null ||
result.created_at == null || result.updated_at == null)
) {
fail(
path,
"must include fingerprint, revision, created_at, and updated_at",
);
}
if (
!hasBinding &&
Object.entries(result).some(([key, entry]) =>
key !== "status" && entry != null
)
) {
fail(path, "must not include binding values while unconfigured");
}
if (result.status === "revoked" && result.revoked_at == null) {
fail(`${path}.revoked_at`, "is required for a revoked key");
}
if (result.status === "active" && result.revoked_at != null) {
fail(`${path}.revoked_at`, "must be absent for an active key");
}
return result;
}
function auditEntry(value: unknown, path: string): RuntimeTrustAuditEntry {
const item = object(value, path);
exactKeys(
item,
["action", "actor_account_id", "revision", "at"],
["old_fingerprint", "new_fingerprint"],
path,
);
return {
action: enumValue(item.action, `${path}.action`, AUDIT_ACTIONS),
actor_account_id: boundedString(
item.actor_account_id,
`${path}.actor_account_id`,
LIMITS.idBytes,
),
old_fingerprint: optionalNullableString(
item.old_fingerprint,
`${path}.old_fingerprint`,
LIMITS.fingerprintBytes,
),
new_fingerprint: optionalNullableString(
item.new_fingerprint,
`${path}.new_fingerprint`,
LIMITS.fingerprintBytes,
),
revision: safeRevision(item.revision, `${path}.revision`),
at: timestamp(item.at, `${path}.at`),
};
}
export function parseWorkspaceRuntimeList(
value: unknown,
): WorkspaceRuntimeList {
const response = object(value, "Runtime list response");
exactKeys(
response,
["workspace_id", "limit", "items", "source", "diagnostics"],
[],
"Runtime list response",
);
const limit = safeInteger(response.limit, "Runtime list response.limit", 0);
if (limit > LIMITS.runtimeItems) {
fail(
"Runtime list response.limit",
`must not exceed ${LIMITS.runtimeItems}`,
);
}
const items = array(
response.items,
"Runtime list response.items",
LIMITS.runtimeItems,
).map((entry, index) =>
runtimeResource(entry, `Runtime list response.items[${index}]`)
);
if (items.length > limit) {
fail("Runtime list response.items", "must not exceed the declared limit");
}
return {
workspace_id: boundedString(
response.workspace_id,
"Runtime list response.workspace_id",
LIMITS.idBytes,
),
limit,
items,
source: boundedString(
response.source,
"Runtime list response.source",
LIMITS.kindBytes,
),
diagnostics: array(
response.diagnostics,
"Runtime list response.diagnostics",
LIMITS.diagnostics,
).map((entry, index) =>
diagnostic(entry, `Runtime list response.diagnostics[${index}]`)
),
};
}
export function parseWorkspaceRuntimeDetail(
value: unknown,
): WorkspaceRuntimeDetail {
const response = object(value, "Runtime detail response");
exactKeys(
response,
["workspace_id", "runtime", "trust_key", "recent_audit"],
["endpoint"],
"Runtime detail response",
);
return {
workspace_id: boundedString(
response.workspace_id,
"Runtime detail response.workspace_id",
LIMITS.idBytes,
),
runtime: runtimeResource(
response.runtime,
"Runtime detail response.runtime",
),
endpoint: optionalNullableString(
response.endpoint,
"Runtime detail response.endpoint",
LIMITS.endpointBytes,
),
trust_key: trustKey(
response.trust_key,
"Runtime detail response.trust_key",
),
recent_audit: array(
response.recent_audit,
"Runtime detail response.recent_audit",
LIMITS.auditEntries,
).map((entry, index) =>
auditEntry(entry, `Runtime detail response.recent_audit[${index}]`)
),
};
}
export function parseRuntimeTrustKeyRevealResponse(
value: unknown,
): RuntimeTrustKeyRevealResponse {
const response = object(value, "Runtime trust key reveal response");
exactKeys(
response,
["public_key"],
[],
"Runtime trust key reveal response",
);
return {
public_key: boundedString(
response.public_key,
"Runtime trust key reveal response.public_key",
LIMITS.publicKeyBytes,
),
};
}
export function parseRuntimeTrustConflict(
value: unknown,
): RuntimeTrustConflictResponse {
const response = object(value, "Runtime trust conflict");
exactKeys(
response,
["error", "message"],
["current_revision", "current_fingerprint"],
"Runtime trust conflict",
);
return {
error: enumValue(
response.error,
"Runtime trust conflict.error",
CONFLICT_KINDS,
),
message: boundedString(
response.message,
"Runtime trust conflict.message",
LIMITS.conflictMessageBytes,
),
current_revision: optionalRevision(
response.current_revision,
"Runtime trust conflict.current_revision",
),
current_fingerprint: optionalNullableString(
response.current_fingerprint,
"Runtime trust conflict.current_fingerprint",
LIMITS.fingerprintBytes,
),
};
}
function revisionForJson(revision: number | null): number | null {
if (revision === null) return null;
if (!Number.isSafeInteger(revision) || revision < 1) {
throw new RuntimeTrustRequestError(
"Runtime trust revision is not a safe integer",
);
}
return revision;
}
async function readBoundedJson(response: Response): Promise<unknown> {
const contentLength = response.headers.get("content-length");
if (contentLength !== null) {
const parsed = Number(contentLength);
if (Number.isFinite(parsed) && parsed > LIMITS.responseBytes) {
throw new RuntimeTrustRequestError(
"Runtime trust response exceeds its byte limit",
);
}
}
const text = await response.text();
if (encoder.encode(text).byteLength > LIMITS.responseBytes) {
throw new RuntimeTrustRequestError(
"Runtime trust response exceeds its byte limit",
);
}
try {
return JSON.parse(text) as unknown;
} catch {
throw new RuntimeTrustRequestError(
"Runtime trust response is not valid JSON",
);
}
}
function requestErrorFrom(
value: unknown,
status: number,
): RuntimeTrustRequestError {
try {
const response = object(value, "Runtime trust error");
exactKeys(
response,
["error", "message", "diagnostics"],
[],
"Runtime trust error",
);
const diagnostics = array(
response.diagnostics,
"Runtime trust error.diagnostics",
LIMITS.diagnostics,
).map((entry, index) =>
diagnostic(entry, `Runtime trust error.diagnostics[${index}]`)
);
const message = boundedString(
response.message,
"Runtime trust error.message",
LIMITS.conflictMessageBytes,
);
const field = diagnostics.some((entry) =>
entry.code.startsWith("runtime_public_key_")
)
? "public_key"
: null;
return new RuntimeTrustRequestError(message, field);
} catch {
return new RuntimeTrustRequestError(
`Runtime trust request failed (${status})`,
);
}
}
async function finishMutation(
response: Response,
workspaceId: string,
runtimeId: string,
): Promise<WorkspaceRuntimeDetail> {
const payload = await readBoundedJson(response);
if (response.status === 409) {
try {
throw new RuntimeTrustConflictError(parseRuntimeTrustConflict(payload));
} catch (error) {
if (error instanceof RuntimeTrustConflictError) throw error;
throw new RuntimeTrustRequestError(
"Runtime trust conflict response was invalid",
);
}
}
if (!response.ok) throw requestErrorFrom(payload, response.status);
let detail: WorkspaceRuntimeDetail;
try {
detail = parseWorkspaceRuntimeDetail(payload);
} catch {
throw new RuntimeTrustRequestError("Runtime trust response was invalid");
}
if (
detail.workspace_id !== workspaceId ||
detail.runtime.runtime_id !== runtimeId
) {
throw new RuntimeTrustRequestError(
"Runtime trust response did not match the selected Runtime",
);
}
return detail;
}
export async function revealRuntimeTrustKey(
workspaceId: string,
runtimeId: string,
): Promise<RuntimeTrustKeyRevealResponse> {
const response = await fetch(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
),
);
const payload = await readBoundedJson(response);
if (!response.ok) throw requestErrorFrom(payload, response.status);
return parseRuntimeTrustKeyRevealResponse(payload);
}
export async function previewRuntimePublicKeyFingerprint(
publicKey: string,
): Promise<string> {
const normalized = publicKey.trim();
const prefix = "yoi-ed25519-pub:v1:";
if (!normalized.startsWith(prefix)) {
throw new RuntimeTrustRequestError(
`Public key must start with ${prefix}`,
);
}
const encoded = normalized.slice(prefix.length);
if (!/^[A-Za-z0-9_-]+$/.test(encoded)) {
throw new RuntimeTrustRequestError("Public key encoding is invalid");
}
const padded = encoded.replaceAll("-", "+").replaceAll("_", "/") +
"=".repeat((4 - (encoded.length % 4)) % 4);
let decoded: string;
try {
decoded = atob(padded);
} catch {
throw new RuntimeTrustRequestError("Public key encoding is invalid");
}
if (decoded.length !== 32) {
throw new RuntimeTrustRequestError("Public key must contain 32 bytes");
}
const bytes = Uint8Array.from(
decoded,
(character) => character.charCodeAt(0),
);
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
const hex = Array.from(digest, (byte) => byte.toString(16).padStart(2, "0"))
.join("");
return `sha256:${hex}`;
}
export async function putRuntimeTrustKey(
workspaceId: string,
runtimeId: string,
request: PutRuntimeTrustKeyRequest,
fetchImpl: typeof fetch = fetch,
): Promise<WorkspaceRuntimeDetail> {
const response = await fetchImpl(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
),
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
public_key: request.public_key,
expected_revision: revisionForJson(request.expected_revision),
}),
},
);
return await finishMutation(response, workspaceId, runtimeId);
}
export async function revokeRuntimeTrustKey(
workspaceId: string,
runtimeId: string,
request: RevokeRuntimeTrustKeyRequest,
currentFingerprint: string,
confirmation: string,
fetchImpl: typeof fetch = fetch,
): Promise<WorkspaceRuntimeDetail> {
if (!currentFingerprint || confirmation.trim() !== currentFingerprint) {
throw new RuntimeTrustRequestError(
"Enter the current fingerprint exactly before revoking Workspace trust.",
);
}
const response = await fetchImpl(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
),
{
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({
expected_revision: revisionForJson(request.expected_revision),
}),
},
);
return await finishMutation(response, workspaceId, runtimeId);
}
@@ -14,6 +14,12 @@ import type {
WorkspaceAuthConfig,
WorkspaceCatalogListResponse,
WorkspaceCreateResponse,
WorkspaceDeletionBlocker,
WorkspaceDeletionBlockerKind,
WorkspaceDeletionOperationResponse,
WorkspaceDeletionPreflightResponse,
WorkspaceDeletionResourceCounts,
WorkspaceDeletionState,
WorkspaceExtensionPoints,
WorkspaceExtensionPointState,
WorkspacePermissionSummary,
@@ -32,6 +38,8 @@ export type {
RepositorySummary,
WorkspaceCatalogListResponse,
WorkspaceCreateResponse,
WorkspaceDeletionOperationResponse,
WorkspaceDeletionPreflightResponse,
WorkspacePermissionSummary,
WorkspaceResponse,
WorkspaceSummary,
@@ -367,13 +375,27 @@ function authConfig(value: unknown, path: string): WorkspaceAuthConfig {
function permissions(value: unknown, path: string): WorkspacePermissionSummary {
const item = object(value, path);
exactKeys(item, ["manage_repositories", "manage_secrets"], path);
exactKeys(
item,
[
"manage_repositories",
"manage_secrets",
"manage_runtimes",
"delete_workspace",
],
path,
);
return {
manage_repositories: boolean(
item.manage_repositories,
`${path}.manage_repositories`,
),
manage_secrets: boolean(item.manage_secrets, `${path}.manage_secrets`),
manage_runtimes: boolean(item.manage_runtimes, `${path}.manage_runtimes`),
delete_workspace: boolean(
item.delete_workspace,
`${path}.delete_workspace`,
),
};
}
@@ -561,6 +583,256 @@ export function parseRepositoryDetailResponse(
};
}
const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES = 128;
const WORKSPACE_DELETION_MAX_REVISION_BYTES = 128;
const WORKSPACE_DELETION_MAX_BLOCKERS = 1024;
const WORKSPACE_DELETION_MAX_CHILD_OPERATION_IDS = 4096;
const WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES = 128;
const WORKSPACE_DELETION_MAX_BLOCKER_MESSAGE_BYTES = 512;
function deletionBoundedString(
value: unknown,
path: string,
maxBytes: number,
): string {
const candidate = string(value, path);
if (new TextEncoder().encode(candidate).length > maxBytes) {
throw new Error(`${path} is too long`);
}
return candidate;
}
function deletionBoundedArray(
value: unknown,
path: string,
maxItems: number,
): unknown[] {
const candidate = array(value, path);
if (candidate.length > maxItems) {
throw new Error(`${path} has too many items`);
}
return candidate;
}
const deletionStates = new Set<WorkspaceDeletionState>([
"queued",
"running",
"blocked",
"failed",
"succeeded",
]);
const deletionBlockerKinds = new Set<WorkspaceDeletionBlockerKind>([
"last_accessible_workspace",
"revision_conflict",
"dirty_workdir",
"worker_removal_blocked",
"workdir_removal_blocked",
"retention_hold",
"cleanup_unavailable",
]);
function deletionState(value: unknown, path: string): WorkspaceDeletionState {
const candidate = string(value, path) as WorkspaceDeletionState;
if (!deletionStates.has(candidate)) throw new Error(`${path} is invalid`);
return candidate;
}
function deletionBlocker(
value: unknown,
path: string,
): WorkspaceDeletionBlocker {
const item = object(value, path);
exactKeys(item, ["kind", "resource_kind", "resource_key", "message"], path);
const kind = string(
item.kind,
`${path}.kind`,
) as WorkspaceDeletionBlockerKind;
if (!deletionBlockerKinds.has(kind)) {
throw new Error(`${path}.kind is invalid`);
}
const resourceKind = optionalNullableString(
item.resource_kind,
`${path}.resource_kind`,
);
const resourceKey = optionalNullableString(
item.resource_key,
`${path}.resource_key`,
);
return {
kind,
resource_kind: resourceKind === undefined || resourceKind === null
? null
: deletionBoundedString(
resourceKind,
`${path}.resource_kind`,
WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES,
),
resource_key: resourceKey === undefined || resourceKey === null
? null
: deletionBoundedString(
resourceKey,
`${path}.resource_key`,
WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES,
),
message: deletionBoundedString(
item.message,
`${path}.message`,
WORKSPACE_DELETION_MAX_BLOCKER_MESSAGE_BYTES,
),
};
}
function deletionResourceCounts(
value: unknown,
path: string,
): WorkspaceDeletionResourceCounts {
const item = object(value, path);
exactKeys(item, [
"workers",
"workdirs",
"repositories",
"runtime_bindings",
"secrets",
"artifacts",
], path);
return {
workers: integer(item.workers, `${path}.workers`),
workdirs: integer(item.workdirs, `${path}.workdirs`),
repositories: integer(item.repositories, `${path}.repositories`),
runtime_bindings: integer(
item.runtime_bindings,
`${path}.runtime_bindings`,
),
secrets: integer(item.secrets, `${path}.secrets`),
artifacts: integer(item.artifacts, `${path}.artifacts`),
};
}
export function parseWorkspaceDeletionPreflightResponse(
value: unknown,
): WorkspaceDeletionPreflightResponse {
const item = object(value, "Workspace deletion preflight");
exactKeys(item, [
"workspace_id",
"display_name",
"expected_revision",
"can_delete",
"resources",
"blockers",
], "Workspace deletion preflight");
return {
workspace_id: string(
item.workspace_id,
"Workspace deletion preflight.workspace_id",
),
display_name: string(
item.display_name,
"Workspace deletion preflight.display_name",
),
expected_revision: deletionBoundedString(
item.expected_revision,
"Workspace deletion preflight.expected_revision",
WORKSPACE_DELETION_MAX_REVISION_BYTES,
),
can_delete: boolean(
item.can_delete,
"Workspace deletion preflight.can_delete",
),
resources: deletionResourceCounts(
item.resources,
"Workspace deletion preflight.resources",
),
blockers: deletionBoundedArray(
item.blockers,
"Workspace deletion preflight.blockers",
WORKSPACE_DELETION_MAX_BLOCKERS,
).map(
(entry, index) =>
deletionBlocker(
entry,
`Workspace deletion preflight.blockers[${index}]`,
),
),
};
}
export function parseWorkspaceDeletionOperationResponse(
value: unknown,
): WorkspaceDeletionOperationResponse {
const item = object(value, "Workspace deletion operation");
exactKeys(item, [
"operation_id",
"workspace_id",
"display_name",
"state",
"resources",
"child_operation_ids",
"blockers",
"failure_category",
"created_at",
"updated_at",
"completed_at",
], "Workspace deletion operation");
return {
operation_id: deletionBoundedString(
item.operation_id,
"Workspace deletion operation.operation_id",
WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES,
),
workspace_id: string(
item.workspace_id,
"Workspace deletion operation.workspace_id",
),
display_name: string(
item.display_name,
"Workspace deletion operation.display_name",
),
state: deletionState(item.state, "Workspace deletion operation.state"),
resources: deletionResourceCounts(
item.resources,
"Workspace deletion operation.resources",
),
child_operation_ids: deletionBoundedArray(
item.child_operation_ids,
"Workspace deletion operation.child_operation_ids",
WORKSPACE_DELETION_MAX_CHILD_OPERATION_IDS,
).map((entry, index) =>
deletionBoundedString(
entry,
`Workspace deletion operation.child_operation_ids[${index}]`,
WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES,
)
),
blockers: deletionBoundedArray(
item.blockers,
"Workspace deletion operation.blockers",
WORKSPACE_DELETION_MAX_BLOCKERS,
).map(
(entry, index) =>
deletionBlocker(
entry,
`Workspace deletion operation.blockers[${index}]`,
),
),
failure_category: optionalNullableString(
item.failure_category,
"Workspace deletion operation.failure_category",
) ?? null,
created_at: string(
item.created_at,
"Workspace deletion operation.created_at",
),
updated_at: string(
item.updated_at,
"Workspace deletion operation.updated_at",
),
completed_at: optionalNullableString(
item.completed_at,
"Workspace deletion operation.completed_at",
) ?? null,
};
}
export function parseRepositoryLogResponse(
value: unknown,
): RepositoryLogResponse {
@@ -0,0 +1,136 @@
declare const Deno: {
test(name: string, fn: () => void): void;
};
import {
canDeliverComposerDraft,
sendComposerDelivery,
} from "./composer-delivery.ts";
function assertEquals(actual: unknown, expected: unknown): void {
if (actual !== expected) {
throw new Error(`Expected ${String(expected)}, got ${String(actual)}`);
}
}
const base = {
protocolOpen: true,
sending: false,
hasText: true,
hasAttachments: false,
};
Deno.test("running Composer enables Queue Submit and Notify but not immediate Submit", () => {
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "queue",
workerState: "running",
}),
true,
);
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "notify",
workerState: "running",
}),
true,
);
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "submit",
workerState: "running",
}),
false,
);
});
Deno.test("running Queue Submit and Notify dispatch their protocol methods", () => {
const sent: string[] = [];
assertEquals(
sendComposerDelivery(
{ ...base, delivery: "queue", workerState: "running" },
"submit",
(method) => sent.push(method),
),
true,
);
assertEquals(
sendComposerDelivery(
{ ...base, delivery: "notify", workerState: "running" },
"notify",
(method) => sent.push(method),
),
true,
);
assertEquals(sent.join(","), "submit,notify");
});
Deno.test("idle Composer enables only immediate Submit", () => {
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "submit",
workerState: "idle",
}),
true,
);
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "queue",
workerState: "idle",
}),
false,
);
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "notify",
workerState: "idle",
}),
false,
);
});
Deno.test("running delivery remains fenced by protocol, send state, and payload kind", () => {
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "queue",
workerState: "running",
protocolOpen: false,
}),
false,
);
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "notify",
workerState: "running",
sending: true,
}),
false,
);
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "notify",
workerState: "running",
hasAttachments: true,
}),
false,
);
assertEquals(
canDeliverComposerDraft({
...base,
delivery: "queue",
workerState: "running",
hasText: false,
hasAttachments: true,
}),
true,
);
});
@@ -0,0 +1,39 @@
export type ComposerDelivery = "submit" | "queue" | "notify";
export type ComposerDeliveryState = {
delivery: ComposerDelivery;
workerState: string;
protocolOpen: boolean;
sending: boolean;
hasText: boolean;
hasAttachments: boolean;
};
/**
* Resolve whether the current Composer draft can use one delivery action.
* Immediate Submit is idle-only; Queue and Notify are running-only.
*/
export function canDeliverComposerDraft(state: ComposerDeliveryState): boolean {
if (!state.protocolOpen || state.sending) return false;
const hasInput = state.hasText || state.hasAttachments;
switch (state.delivery) {
case "submit":
return state.workerState === "idle" && hasInput;
case "queue":
return state.workerState === "running" && hasInput;
case "notify":
return state.workerState === "running" && state.hasText &&
!state.hasAttachments;
}
}
export function sendComposerDelivery<T>(
state: ComposerDeliveryState,
method: T,
send: (method: T) => void,
): boolean {
if (!canDeliverComposerDraft(state)) return false;
send(method);
return true;
}

Some files were not shown because too many files have changed in this diff Show More