8 Commits
166 changed files with 2541 additions and 1252 deletions
+16
View File
@@ -1233,6 +1233,22 @@ mod tests {
) )
.unwrap(); .unwrap();
assert_eq!(resolved.manifest.worker.name, "role-worker"); assert_eq!(resolved.manifest.worker.name, "role-worker");
let compaction = resolved.manifest.compaction.as_ref().unwrap();
assert_eq!(compaction.worker_max_turns, Some(100), "{}", entry.name);
assert_eq!(
compaction.worker_context_max_tokens, 100_000,
"{}",
entry.name
);
assert_eq!(
compaction
.model
.as_ref()
.and_then(|model| model.ref_.as_deref()),
Some("codex-oauth/gpt-6-luna"),
"{}",
entry.name
);
} }
} }
+10
View File
@@ -240,6 +240,16 @@ pub enum Method {
#[serde(skip_deserializing, default)] #[serde(skip_deserializing, default)]
source: AuthenticatedInputSource, source: AuthenticatedInputSource,
}, },
/// Agent-controlled input: accept only at an Idle controller boundary, never
/// enqueue behind a run. Unlike human Submit, non-Idle admission is rejected.
#[cfg_attr(feature = "typescript", ts(skip))]
#[cfg_attr(feature = "json-schema", schemars(skip))]
SubmitIfIdle {
submission_request_id: String,
input: Vec<Segment>,
#[serde(skip_deserializing, default)]
source: AuthenticatedInputSource,
},
/// Human-readable text injected into the target Worker's LLM context as a /// Human-readable text injected into the target Worker's LLM context as a
/// non-blocking system message. Accepted notifications are delivered at the /// non-blocking system message. Accepted notifications are delivered at the
/// next between-turn boundary, or start a coalesced notification run while /// next between-turn boundary, or start a coalesced notification run while
+2
View File
@@ -402,6 +402,8 @@ pub struct WorkingDirectoryStatus {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkerInputKind { pub enum WorkerInputKind {
User, User,
/// Agent input accepted only while Idle; never enters the human Submit queue.
UserIfIdle,
Notify, Notify,
Compact, Compact,
ListRewindTargets, ListRewindTargets,
+1 -1
View File
@@ -15,7 +15,7 @@ pub enum RuntimeError {
#[error("worker {worker_id} was not found")] #[error("worker {worker_id} was not found")]
WorkerNotFound { worker_id: WorkerId }, WorkerNotFound { worker_id: WorkerId },
#[error("worker {worker_id} has no execution backend: {message}")] #[error("worker {worker_id} execution is unavailable: {message}")]
WorkerExecutionUnavailable { WorkerExecutionUnavailable {
worker_id: WorkerId, worker_id: WorkerId,
message: String, message: String,
+38 -8
View File
@@ -1556,6 +1556,19 @@ fn authorize_runtime_protocol_method(
submission_request_id, submission_request_id,
input, input,
}, },
protocol::Method::SubmitIfIdle {
submission_request_id,
input,
..
} => protocol::Method::SubmitIfIdle {
source: transport_source.cloned().unwrap_or_else(|| {
protocol::AuthenticatedInputSource::Backend {
operation_id: submission_request_id.clone(),
}
}),
submission_request_id,
input,
},
protocol::Method::NotifyTracked { protocol::Method::NotifyTracked {
notification_request_id, notification_request_id,
message, message,
@@ -3477,14 +3490,31 @@ mod tests {
conflicting_request.create_fingerprint = "different-fingerprint".to_string(); conflicting_request.create_fingerprint = "different-fingerprint".to_string();
let generated = client.create_worker(generated_request).await.unwrap(); let generated = client.create_worker(generated_request).await.unwrap();
assert_eq!(generated.worker.worker_id.to_string(), generated_worker_id); assert_eq!(generated.worker.worker_id.to_string(), generated_worker_id);
let generated_input: runtime_api::WorkerInput = serde_json::from_value( for kind in [
serde_json::to_value(WorkerInput::user("generated client input")).unwrap(), crate::interaction::WorkerInputKind::User,
) crate::interaction::WorkerInputKind::UserIfIdle,
.unwrap(); ] {
client let mut input = WorkerInput::user("generated client input");
.send_worker_input(generated_worker_id.clone(), generated_input) input.kind = kind.clone();
.await input.submission_request_id = Some(format!("generated-{kind:?}"));
.unwrap(); // The remote adapter performs this same conversion before sending.
// Both the generated API contract and the HTTP receiver must retain
// Idle-only admission rather than silently downgrading it to User.
let generated_input: runtime_api::WorkerInput =
serde_json::from_value(serde_json::to_value(&input).unwrap()).unwrap();
assert_eq!(
serde_json::to_value(&generated_input).unwrap(),
serde_json::to_value(&input).unwrap()
);
let response = client
.send_worker_input(generated_worker_id.clone(), generated_input)
.await
.unwrap();
assert_eq!(
Some(response.ack.submission.unwrap().submission_request_id),
input.submission_request_id
);
}
client client
.stop_worker( .stop_worker(
generated_worker_id.clone(), generated_worker_id.clone(),
+2
View File
@@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize};
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkerInputKind { pub enum WorkerInputKind {
User, User,
/// Agent input that must never enter the human Submit queue.
UserIfIdle,
Notify, Notify,
Compact, Compact,
ListRewindTargets, ListRewindTargets,
+74 -20
View File
@@ -1425,9 +1425,9 @@ impl Runtime {
let previous_workspace_api = { let previous_workspace_api = {
let state = self.lock()?; let state = self.lock()?;
let worker = state.worker(worker_ref)?; let worker = state.worker(worker_ref)?;
if worker.has_pending_lifecycle_operation() { if let Some(operation) = worker.pending_lifecycle_operation_name() {
return Err(RuntimeError::InvalidRequest(format!( return Err(RuntimeError::InvalidRequest(format!(
"worker {} has pending restore reconciliation", "worker {} has pending {operation} reconciliation",
worker_ref.worker_id worker_ref.worker_id
))); )));
} }
@@ -1453,9 +1453,9 @@ impl Runtime {
{ {
let mut state = self.lock()?; let mut state = self.lock()?;
let worker = state.worker_mut(worker_ref)?; let worker = state.worker_mut(worker_ref)?;
if worker.has_pending_lifecycle_operation() { if let Some(operation) = worker.pending_lifecycle_operation_name() {
return Err(RuntimeError::InvalidRequest(format!( return Err(RuntimeError::InvalidRequest(format!(
"worker {} has pending restore reconciliation", "worker {} has pending {operation} reconciliation",
worker_ref.worker_id worker_ref.worker_id
))); )));
} }
@@ -2052,10 +2052,10 @@ impl Runtime {
fn ensure_worker_execution(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> { fn ensure_worker_execution(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> {
let state = self.lock()?; let state = self.lock()?;
let worker = state.worker(worker_ref)?; let worker = state.worker(worker_ref)?;
if worker.has_pending_lifecycle_operation() { if let Some(operation) = worker.pending_lifecycle_operation_name() {
return Err(RuntimeError::WorkerExecutionUnavailable { return Err(RuntimeError::WorkerExecutionUnavailable {
worker_id: worker_ref.worker_id, worker_id: worker_ref.worker_id,
message: "worker restore reconciliation is pending".to_string(), message: format!("worker {operation} reconciliation is pending"),
}); });
} }
if worker.execution_handle.is_some() { if worker.execution_handle.is_some() {
@@ -2090,7 +2090,10 @@ impl Runtime {
mut input: WorkerInput, mut input: WorkerInput,
) -> Result<WorkerInteractionAck, RuntimeError> { ) -> Result<WorkerInteractionAck, RuntimeError> {
validate_worker_input(&input)?; validate_worker_input(&input)?;
if matches!(input.kind, WorkerInputKind::User | WorkerInputKind::Notify) { if matches!(
input.kind,
WorkerInputKind::User | WorkerInputKind::UserIfIdle | WorkerInputKind::Notify
) {
let request_id = input let request_id = input
.submission_request_id .submission_request_id
.clone() .clone()
@@ -2098,7 +2101,11 @@ impl Runtime {
.unwrap_or_else(|| Uuid::now_v7().to_string()); .unwrap_or_else(|| Uuid::now_v7().to_string());
input.submission_request_id = Some(request_id); input.submission_request_id = Some(request_id);
} }
let expected_submission_id = (input.kind == WorkerInputKind::User).then(|| { let expected_submission_id = matches!(
input.kind,
WorkerInputKind::User | WorkerInputKind::UserIfIdle
)
.then(|| {
input input
.submission_request_id .submission_request_id
.clone() .clone()
@@ -2110,6 +2117,17 @@ impl Runtime {
.clone() .clone()
.expect("Notify request id must be assigned before dispatch") .expect("Notify request id must be assigned before dispatch")
}); });
if input.kind == WorkerInputKind::UserIfIdle {
let state = self.lock()?;
let worker = state.worker(worker_ref)?;
// Stopped/restoring Workers have no controller to enforce admission.
// Live busy state is checked atomically by the controller, not here.
if worker.status == WorkerStatus::Stopped || worker.has_pending_lifecycle_operation() {
return Err(RuntimeError::InvalidRequest(
"WorkerSendInput requires an Idle Worker and never queues input. Use WorkerNotify for information about work already in progress.".to_string(),
));
}
}
self.ensure_worker_execution(worker_ref)?; self.ensure_worker_execution(worker_ref)?;
let (backend, handle) = { let (backend, handle) = {
let state = self.lock()?; let state = self.lock()?;
@@ -2716,9 +2734,9 @@ impl Runtime {
let state = self.lock()?; let state = self.lock()?;
state.ensure_running()?; state.ensure_running()?;
let worker = state.worker(worker_ref)?; let worker = state.worker(worker_ref)?;
if worker.has_pending_lifecycle_operation() { if let Some(operation) = worker.pending_lifecycle_operation_name() {
return Err(RuntimeError::InvalidRequest(format!( return Err(RuntimeError::InvalidRequest(format!(
"worker {} has pending restore reconciliation", "worker {} has pending {operation} reconciliation",
worker_ref.worker_id worker_ref.worker_id
))); )));
} }
@@ -2775,9 +2793,9 @@ impl Runtime {
state.ensure_running()?; state.ensure_running()?;
state.ensure_worker_ref(worker_ref)?; state.ensure_worker_ref(worker_ref)?;
let worker = state.worker(worker_ref)?; let worker = state.worker(worker_ref)?;
if worker.has_pending_lifecycle_operation() { if let Some(operation) = worker.pending_lifecycle_operation_name() {
return Err(RuntimeError::InvalidRequest(format!( return Err(RuntimeError::InvalidRequest(format!(
"worker {} has pending restore reconciliation and cannot be deleted", "worker {} has pending {operation} reconciliation and cannot be deleted",
worker_ref.worker_id worker_ref.worker_id
))); )));
} }
@@ -2816,9 +2834,9 @@ impl Runtime {
state.ensure_running()?; state.ensure_running()?;
state.ensure_worker_ref(worker_ref)?; state.ensure_worker_ref(worker_ref)?;
let worker = state.worker(worker_ref)?; let worker = state.worker(worker_ref)?;
if worker.has_pending_lifecycle_operation() { if let Some(operation) = worker.pending_lifecycle_operation_name() {
return Err(RuntimeError::InvalidRequest(format!( return Err(RuntimeError::InvalidRequest(format!(
"worker {} still has pending restore reconciliation", "worker {} still has pending {operation} reconciliation",
worker_ref.worker_id worker_ref.worker_id
))); )));
} }
@@ -3601,10 +3619,10 @@ impl Runtime {
"Worker retention requires a stopped Worker".to_string(), "Worker retention requires a stopped Worker".to_string(),
)); ));
} }
if worker.has_pending_lifecycle_operation() { if let Some(operation) = worker.pending_lifecycle_operation_name() {
return Err(RuntimeError::InvalidRequest( return Err(RuntimeError::InvalidRequest(format!(
"Worker retention cannot remove pending restore reconciliation".to_string(), "Worker retention cannot remove pending {operation} reconciliation"
)); )));
} }
let result = provider.execute(request)?; let result = provider.execute(request)?;
state.workers.remove(&request.worker_id); state.workers.remove(&request.worker_id);
@@ -4783,8 +4801,18 @@ impl WorkerRecord {
self.worker_state = Some(incoming.clone()); self.worker_state = Some(incoming.clone());
} }
fn pending_lifecycle_operation_name(&self) -> Option<&'static str> {
if self.pending_stop.is_some() {
Some("stop")
} else if self.pending_restore.is_some() {
Some("restore")
} else {
None
}
}
fn has_pending_lifecycle_operation(&self) -> bool { fn has_pending_lifecycle_operation(&self) -> bool {
self.pending_restore.is_some() || self.pending_stop.is_some() self.pending_lifecycle_operation_name().is_some()
} }
fn belongs_to_workspace(&self, workspace_id: &str) -> bool { fn belongs_to_workspace(&self, workspace_id: &str) -> bool {
@@ -5319,7 +5347,7 @@ fn input_protocol_event(input: &WorkerInput) -> Option<protocol::Event> {
match input.kind { match input.kind {
// Submit is projected only after the Worker commits UserInput. Queued // Submit is projected only after the Worker commits UserInput. Queued
// payloads must never become model- or client-visible history early. // payloads must never become model- or client-visible history early.
WorkerInputKind::User | WorkerInputKind::Notify => None, WorkerInputKind::User | WorkerInputKind::UserIfIdle | WorkerInputKind::Notify => None,
WorkerInputKind::Compact WorkerInputKind::Compact
| WorkerInputKind::ListRewindTargets | WorkerInputKind::ListRewindTargets
| WorkerInputKind::RegisterPeer => Some(protocol::Event::SystemItem { | WorkerInputKind::RegisterPeer => Some(protocol::Event::SystemItem {
@@ -7400,6 +7428,30 @@ mod tests {
assert!(runtime.worker_operations.lock().unwrap().is_empty()); assert!(runtime.worker_operations.lock().unwrap().is_empty());
} }
fn assert_pending_lifecycle_errors(runtime: &Runtime, worker_ref: &WorkerRef, operation: &str) {
let input_error = runtime
.send_input(worker_ref, WorkerInput::user("blocked while pending"))
.unwrap_err();
assert_eq!(
input_error.to_string(),
format!(
"worker {} execution is unavailable: worker {operation} reconciliation is pending",
worker_ref.worker_id
)
);
for error in [
runtime.cancel_worker(worker_ref, None).unwrap_err(),
runtime.delete_worker(worker_ref).unwrap_err(),
] {
assert!(
error
.to_string()
.contains(&format!("pending {operation} reconciliation")),
"{error}"
);
}
}
#[test] #[test]
fn failed_stop_cleanup_retains_execution_for_retry() { fn failed_stop_cleanup_retains_execution_for_retry() {
let (runtime, backend) = runtime_and_backend(); let (runtime, backend) = runtime_and_backend();
@@ -7436,6 +7488,7 @@ mod tests {
backend.stop_operation_ids.lock().unwrap().as_slice(), backend.stop_operation_ids.lock().unwrap().as_slice(),
&[operation_id] &[operation_id]
); );
assert_pending_lifecycle_errors(&runtime, &created.worker_ref, "stop");
let stopped = runtime.stop_worker(&created.worker_ref, None).unwrap(); let stopped = runtime.stop_worker(&created.worker_ref, None).unwrap();
assert_eq!(stopped.status, WorkerStatus::Stopped); assert_eq!(stopped.status, WorkerStatus::Stopped);
@@ -9115,6 +9168,7 @@ mod tests {
.pending_restore .pending_restore
.unwrap() .unwrap()
.operation_id; .operation_id;
assert_pending_lifecycle_errors(&runtime, &created.worker_ref, "restore");
let delete_error = runtime.delete_worker(&created.worker_ref).unwrap_err(); let delete_error = runtime.delete_worker(&created.worker_ref).unwrap_err();
assert!( assert!(
delete_error delete_error
+137 -11
View File
@@ -2982,7 +2982,7 @@ where
} }
let (method, submission_request_id) = match input.kind { let (method, submission_request_id) = match input.kind {
WorkerInputKind::User => { WorkerInputKind::User | WorkerInputKind::UserIfIdle => {
let Some(submission_id) = input let Some(submission_id) = input
.submission_request_id .submission_request_id
.filter(|submission_id| !submission_id.trim().is_empty()) .filter(|submission_id| !submission_id.trim().is_empty())
@@ -2992,18 +2992,26 @@ where
"Runtime user input is missing its internal submission id", "Runtime user input is missing its internal submission id",
); );
}; };
( let segments = input
.segments
.unwrap_or_else(|| vec![Segment::text(input.content.trim().to_string())]);
let source = protocol::AuthenticatedInputSource::Backend {
operation_id: submission_id.clone(),
};
let method = if input.kind == WorkerInputKind::UserIfIdle {
Method::SubmitIfIdle {
submission_request_id: submission_id.clone(),
input: segments,
source,
}
} else {
Method::SubmitTracked { Method::SubmitTracked {
submission_request_id: submission_id.clone(), submission_request_id: submission_id.clone(),
input: input.segments.unwrap_or_else(|| { input: segments,
vec![Segment::text(input.content.trim().to_string())] source,
}), }
source: protocol::AuthenticatedInputSource::Backend { };
operation_id: submission_id.clone(), (method, Some(submission_id))
},
},
Some(submission_id),
)
} }
WorkerInputKind::Notify => { WorkerInputKind::Notify => {
unreachable!("Notify input is dispatched before ordinary input mapping") unreachable!("Notify input is dispatched before ordinary input mapping")
@@ -5581,6 +5589,124 @@ mod tests {
assert_eq!(call_count.load(Ordering::SeqCst), 3); assert_eq!(call_count.load(Ordering::SeqCst), 3);
} }
#[test]
#[cfg(feature = "ws-server")]
#[serial_test::serial(worker_allocation)]
fn idle_only_input_acknowledges_idle_and_rejects_running_paused_and_stopped() {
let client = MockClient::sequential(vec![MockResponse::Hang(
simple_text_events().into_iter().take(2).collect(),
)]);
let runtime_base = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
let factory = MockFactory {
client,
runtime_base: runtime_base.path().to_path_buf(),
cwd: cwd.path().to_path_buf(),
store_dir: store.path().join("sessions"),
worker_metadata_dir: store.path().join("workers"),
observed_cwds: Arc::new(Mutex::new(Vec::new())),
observed_workspace_clients: Arc::new(Mutex::new(Vec::new())),
};
let backend = Arc::new(WorkerRuntimeExecutionBackend::new(factory).unwrap());
let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), backend.clone())
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let detail = runtime.create_worker(create_request("idle-only")).unwrap();
let input = |id: &str| {
let mut input = WorkerInput::user(id);
input.kind = WorkerInputKind::UserIfIdle;
input.submission_request_id = Some(id.to_string());
input
};
let ack = runtime
.send_input(&detail.worker_ref, input("idle-input"))
.unwrap();
assert_eq!(ack.submission.unwrap().submission_request_id, "idle-input");
wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Running);
let error = runtime
.send_input(&detail.worker_ref, input("running-input"))
.unwrap_err();
assert!(error.to_string().contains("WorkerNotify"), "{error}");
runtime
.send_protocol_method(
&detail.worker_ref,
Method::Pause {
command: adapter_command(&backend, &detail.worker_ref),
},
)
.unwrap();
wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Paused);
let error = runtime
.send_input(&detail.worker_ref, input("paused-input"))
.unwrap_err();
assert!(error.to_string().contains("WorkerNotify"), "{error}");
let handle = backend
.workers
.lock()
.unwrap()
.get(&detail.worker_ref)
.unwrap()
.handle
.clone();
let pending = backend
.run_on_adapter_runtime(async move {
let mut events = handle.subscribe();
handle
.send(Method::ListPendingSubmissions)
.await
.map_err(|e| e.to_string())?;
tokio::time::timeout(Duration::from_secs(2), async {
loop {
if let Ok(Event::PendingSubmissionsChanged { pending }) =
events.recv().await
{
break pending;
}
}
})
.await
.map_err(|e| e.to_string())
})
.unwrap();
assert!(pending.submissions.is_empty());
// Human Submit still queues while paused, and Notify remains advisory.
let ack = runtime
.send_input(&detail.worker_ref, WorkerInput::user("human-input"))
.unwrap();
assert_eq!(
ack.submission.unwrap().disposition,
protocol::SubmissionDisposition::Queued
);
let mut notify = WorkerInput::user("advisory");
notify.kind = WorkerInputKind::Notify;
assert!(
runtime
.send_input(&detail.worker_ref, notify)
.unwrap()
.notification
.is_some()
);
wait_for_adapter_state(&backend, &detail.worker_ref, WorkerStatus::Paused);
let handle = backend
.workers
.lock()
.unwrap()
.get(&detail.worker_ref)
.unwrap()
.handle
.clone();
let entries = serde_json::to_string(&handle.committed_entries()).unwrap();
assert!(!entries.contains("running-input"));
assert!(!entries.contains("paused-input"));
runtime.stop_worker(&detail.worker_ref, None).unwrap();
let error = runtime
.send_input(&detail.worker_ref, input("stopped-input"))
.unwrap_err();
assert!(error.to_string().contains("WorkerNotify"), "{error}");
}
#[test] #[test]
fn stopped_runtime_worker_can_restore_and_accept_input() { fn stopped_runtime_worker_can_restore_and_accept_input() {
let client = MockClient::new(simple_text_events()); let client = MockClient::new(simple_text_events());
+189 -20
View File
@@ -65,6 +65,14 @@ pub struct WorkerHandle {
impl WorkerHandle { impl WorkerHandle {
pub async fn send(&self, method: Method) -> Result<(), mpsc::error::SendError<Method>> { pub async fn send(&self, method: Method) -> Result<(), mpsc::error::SendError<Method>> {
// Reject known-busy sends before channel dispatch, including maintenance
// that cannot receive methods until it finishes. The controller checks
// again at admission to cover an Idle -> busy race after this snapshot.
if self.shared_state.snapshot().state != WorkerState::Idle
&& reject_idle_only_submission(&method, &self.working_event_tx)
{
return Ok(());
}
self.method_tx.send(method).await self.method_tx.send(method).await
} }
@@ -1523,6 +1531,21 @@ where
} }
} }
fn reject_idle_only_submission(method: &Method, events: &broadcast::Sender<Event>) -> bool {
let Method::SubmitIfIdle {
submission_request_id,
..
} = method
else {
return false;
};
let _ = events.send(Event::SubmissionRejected {
submission_request_id: submission_request_id.clone(),
message: "WorkerSendInput requires an Idle Worker and never queues input. Use WorkerNotify for information about work already in progress.".to_string(),
});
true
}
fn durably_accept_method_while_busy<St>( fn durably_accept_method_while_busy<St>(
method: Method, method: Method,
pending_submissions: &PendingSubmissionHandle<St>, pending_submissions: &PendingSubmissionHandle<St>,
@@ -1531,6 +1554,9 @@ fn durably_accept_method_while_busy<St>(
where where
St: session_store::Store + Clone, St: session_store::Store + Clone,
{ {
if reject_idle_only_submission(&method, working_event_tx) {
return None;
}
match method { match method {
Method::Submit { Method::Submit {
submission_request_id, submission_request_id,
@@ -1640,6 +1666,10 @@ fn reject_method_while_attention_locked(
| Method::SubmitTracked { | Method::SubmitTracked {
submission_request_id, submission_request_id,
.. ..
}
| Method::SubmitIfIdle {
submission_request_id,
..
} => { } => {
let _ = working_event_tx.send(Event::SubmissionRejected { let _ = working_event_tx.send(Event::SubmissionRejected {
submission_request_id: submission_request_id.clone(), submission_request_id: submission_request_id.clone(),
@@ -1946,6 +1976,12 @@ async fn controller_loop<C, St>(
} }
}; };
if shared_state.snapshot().state != WorkerState::Idle
&& reject_idle_only_submission(&method, &working_event_tx)
{
continue;
}
if worker.has_pending_compaction_cleanup() { if worker.has_pending_compaction_cleanup() {
match worker.retry_pending_compaction_cleanup().await { match worker.retry_pending_compaction_cleanup().await {
Ok(()) => { Ok(()) => {
@@ -2032,6 +2068,11 @@ async fn controller_loop<C, St>(
submission_request_id, submission_request_id,
input, input,
source, source,
}
| Method::SubmitIfIdle {
submission_request_id,
input,
source,
} => { } => {
let request_id = submission_request_id.clone(); let request_id = submission_request_id.clone();
let (source_namespace, provenance) = let (source_namespace, provenance) =
@@ -2418,7 +2459,9 @@ async fn controller_loop<C, St>(
) )
.await; .await;
} }
if let Err(error) = result { if let Err(error) = result
&& !matches!(&error, WorkerError::CompactCancelled)
{
let _ = working_event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: worker_error_code(&error), code: worker_error_code(&error),
message: error.to_string(), message: error.to_string(),
@@ -2842,11 +2885,7 @@ where
(WorkerStatus::Paused, shutdown_requested, false) (WorkerStatus::Paused, shutdown_requested, false)
} }
Err(e) => { Err(e) => {
let status = if matches!( let status = if worker_error_requires_attention(&e) {
&e,
WorkerError::SegmentActivationIncomplete { .. }
| WorkerError::CompactionCleanupPending { .. }
) {
// Metadata has already committed the replacement, but // Metadata has already committed the replacement, but
// one live authority failed to publish. Keep the // one live authority failed to publish. Keep the
// controller visibly busy/fail-closed; publishing Idle // controller visibly busy/fail-closed; publishing Idle
@@ -2855,20 +2894,22 @@ where
} else { } else {
WorkerStatus::Idle WorkerStatus::Idle
}; };
let code = worker_error_code(&e); if !matches!(&e, WorkerError::RunFailureAlreadyDelivered { .. }) {
let message = e.to_string(); let code = worker_error_code(&e);
let _ = working_event_tx.send(Event::Error { let message = e.to_string();
code, let _ = working_event_tx.send(Event::Error {
message: message.clone(), code,
}); message: message.clone(),
if parent_originated { });
crate::ipc::event::fire_and_forget( if parent_originated {
parent_socket.cloned(), crate::ipc::event::fire_and_forget(
protocol::WorkerEvent::Errored { parent_socket.cloned(),
worker_name: self_name.to_string(), protocol::WorkerEvent::Errored {
message, worker_name: self_name.to_string(),
}, message,
); },
);
}
} }
(status, shutdown_requested, false) (status, shutdown_requested, false)
} }
@@ -3017,6 +3058,9 @@ where
} }
} }
} }
Some(method @ Method::SubmitIfIdle { .. }) => {
reject_idle_only_submission(&method, working_event_tx);
}
Some(Method::SubmitTracked { Some(Method::SubmitTracked {
submission_request_id, submission_request_id,
input, input,
@@ -3331,8 +3375,22 @@ where
} }
} }
fn worker_error_requires_attention(error: &WorkerError) -> bool {
match error {
WorkerError::SegmentActivationIncomplete { .. }
| WorkerError::CompactionCleanupPending { .. } => true,
WorkerError::RunFailureAlreadyDelivered { source }
| WorkerError::RunTerminalPersistence {
run_failure: source,
..
} => worker_error_requires_attention(source),
_ => false,
}
}
fn worker_error_code(e: &WorkerError) -> ErrorCode { fn worker_error_code(e: &WorkerError) -> ErrorCode {
match e { match e {
WorkerError::RunFailureAlreadyDelivered { source } => worker_error_code(source),
WorkerError::Engine(we) => match we { WorkerError::Engine(we) => match we {
EngineError::Tool(_) => ErrorCode::ToolError, EngineError::Tool(_) => ErrorCode::ToolError,
EngineError::Client(_) => ErrorCode::ProviderError, EngineError::Client(_) => ErrorCode::ProviderError,
@@ -3558,6 +3616,36 @@ mod tests {
tokio::time::timeout(timeout, accept).await.ok().flatten() tokio::time::timeout(timeout, accept).await.ok().flatten()
} }
#[tokio::test]
async fn idle_only_input_is_rejected_in_busy_maintenance_admission() {
let env = make_env().await;
let mut events = env.working_event_tx.subscribe();
let method = Method::SubmitIfIdle {
submission_request_id: "maintenance-input".to_string(),
input: vec![protocol::Segment::text("must not queue")],
source: protocol::AuthenticatedInputSource::Backend {
operation_id: "maintenance-input".to_string(),
},
};
assert!(
durably_accept_method_while_busy(
method,
&env.pending_submissions,
&env.working_event_tx
)
.is_none()
);
let event = events.try_recv().unwrap();
assert!(
matches!(event, Event::SubmissionRejected { submission_request_id, message }
if submission_request_id == "maintenance-input" && message.contains("WorkerNotify"))
);
assert!(
events.try_recv().is_err(),
"must not emit durable acceptance or queue changes"
);
}
#[tokio::test] #[tokio::test]
async fn parent_originated_finished_fires_turn_ended() { async fn parent_originated_finished_fires_turn_ended() {
let mut env = make_env().await; let mut env = make_env().await;
@@ -3911,6 +3999,87 @@ mod tests {
} }
} }
#[tokio::test]
async fn delivered_run_failure_is_not_reported_again_by_controller() {
let mut env = make_env().await;
let listener = UnixListener::bind(&env.parent_socket_path).expect("bind listener");
let mut events = env.working_event_tx.subscribe();
let worker_future = async {
Err::<WorkerRunResult, _>(WorkerError::RunFailureAlreadyDelivered {
source: Box::new(WorkerError::CompactSummaryMissing),
})
};
let (status, _, may_drain_pending) = drive_turn(
worker_future,
&mut env.method_rx,
&env.working_event_tx,
&env.cancel_tx,
&env.pause_tx,
&env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer,
&env.pending_submissions,
Some(&env.parent_socket_path),
"child-worker",
&env.spawned_registry,
true,
)
.await;
assert_eq!(status, WorkerStatus::Idle);
assert!(!may_drain_pending, "failed runs must not become successful");
assert!(events.try_recv().is_err(), "must not emit a generic Error");
assert!(
tokio::time::timeout(Duration::from_millis(200), listener.accept())
.await
.is_err(),
"must not send a second parent Errored event"
);
}
#[tokio::test]
async fn terminal_persistence_failure_reports_both_causes_once() {
let mut env = make_env().await;
let mut events = env.working_event_tx.subscribe();
let worker_future = async {
Err::<WorkerRunResult, _>(WorkerError::RunTerminalPersistence {
run_failure: Box::new(WorkerError::CompactSummaryMissing),
persist_error: Box::new(WorkerError::Store(session_store::StoreError::Io(
std::io::Error::other("terminal append failed"),
))),
})
};
let (status, _, may_drain_pending) = drive_turn(
worker_future,
&mut env.method_rx,
&env.working_event_tx,
&env.cancel_tx,
&env.pause_tx,
&env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer,
&env.pending_submissions,
None,
"child-worker",
&env.spawned_registry,
false,
)
.await;
assert_eq!(status, WorkerStatus::Idle);
assert!(!may_drain_pending);
let Event::Error { message, .. } = events.try_recv().expect("one error event") else {
panic!("expected Error event");
};
assert!(message.contains("compact worker did not produce a summary"));
assert!(message.contains("terminal append failed"));
assert!(events.try_recv().is_err(), "must report once");
}
#[tokio::test] #[tokio::test]
async fn non_parent_originated_worker_error_stays_silent() { async fn non_parent_originated_worker_error_stays_silent() {
let mut env = make_env().await; let mut env = make_env().await;
@@ -245,11 +245,11 @@ impl WorkerControlService for WorkspaceWorkerControlService {
"unknown Worker or permission not granted".to_string(), "unknown Worker or permission not granted".to_string(),
) )
})?; })?;
record record.session.send(content).await.map_err(|error| {
.session WorkspaceClientError::Request(format!(
.send(content) "WorkerSendInput requires an Idle SubWorker and never queues input: {error}"
.await ))
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?; })?;
Ok(WorkspaceResponse { Ok(WorkspaceResponse {
status: 200, status: 200,
body: serde_json::json!({ body: serde_json::json!({
@@ -855,7 +855,7 @@ impl WorkerOperation {
"Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input." "Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input."
} }
Self::SendInput => { Self::SendInput => {
"Send user input to a known Runtime Worker. Idle Workers can start a fresh turn; Running or Paused Workers durably queue it for a later turn. Use WorkerNotify for advisory information during work already in progress. Direct SubWorkers require Idle." "Start a fresh turn for an Idle Worker. Non-Idle Workers reject WorkerSendInput without queueing. Use WorkerNotify for advisory information during work already in progress. Direct SubWorkers also require Idle."
} }
Self::Notify => { Self::Notify => {
"Send an advisory notification to a known Runtime Worker so information can be incorporated into work already in progress without creating a queued Submit." "Send an advisory notification to a known Runtime Worker so information can be incorporated into work already in progress without creating a queued Submit."
@@ -1536,6 +1536,17 @@ mod tests {
assert_eq!(client.requests.lock().unwrap().len(), 1); assert_eq!(client.requests.lock().unwrap().len(), 1);
} }
#[test]
fn send_input_rejection_is_a_tool_error_with_notify_guidance() {
let error = tool_output(WorkerOperation::SendInput, WorkspaceResponse {
status: 400,
body: "WorkerSendInput requires an Idle Worker. Use WorkerNotify for work already in progress.".to_string(),
}).unwrap_err();
assert!(matches!(error, ToolError::ExecutionFailed(_)));
assert!(error.to_string().contains("WorkerNotify"));
assert!(!error.to_string().contains("completed"));
}
#[tokio::test] #[tokio::test]
async fn control_service_routes_by_subject_without_cross_provider_fallback() { async fn control_service_routes_by_subject_without_cross_provider_fallback() {
let client = Arc::new(RecordingWorkspaceClient::default()); let client = Arc::new(RecordingWorkspaceClient::default());
+83 -14
View File
@@ -271,6 +271,17 @@ pub(crate) enum InternalWorkerSessionStatus {
Failed, Failed,
} }
/// Typed result of the most recent reusable Internal Worker turn.
///
/// Session status intentionally remains a compact presentation state. Owners that
/// make domain decisions (such as Compaction) must use this result instead of
/// guessing a stop cause from `Stopped` or parsing the presentation error string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum InternalWorkerTurnOutcome {
Completed(WorkerRunResult),
Failed { message: String },
}
impl InternalWorkerSessionStatus { impl InternalWorkerSessionStatus {
fn encode(self) -> u8 { fn encode(self) -> u8 {
match self { match self {
@@ -323,26 +334,53 @@ fn send_internal_worker_state(
fn classify_internal_turn_result( fn classify_internal_turn_result(
result: Result<WorkerRunResult, WorkerError>, result: Result<WorkerRunResult, WorkerError>,
) -> (InternalWorkerSessionStatus, Option<String>) { ) -> (
InternalWorkerSessionStatus,
Option<String>,
InternalWorkerTurnOutcome,
) {
match result { match result {
Ok(WorkerRunResult::Finished) => (InternalWorkerSessionStatus::Idle, None), Ok(result @ WorkerRunResult::Finished) => (
Ok(WorkerRunResult::Paused) => (InternalWorkerSessionStatus::Paused, None), InternalWorkerSessionStatus::Idle,
Ok(WorkerRunResult::LimitReached) => ( None,
InternalWorkerSessionStatus::Stopped, InternalWorkerTurnOutcome::Completed(result),
Some("internal Worker reached its turn limit".to_string()),
), ),
Ok(WorkerRunResult::Interrupted { message, .. }) => { Ok(result @ WorkerRunResult::Paused) => (
(InternalWorkerSessionStatus::Stopped, Some(message)) InternalWorkerSessionStatus::Paused,
None,
InternalWorkerTurnOutcome::Completed(result),
),
Ok(result @ WorkerRunResult::LimitReached) => {
let message = "internal Worker reached its turn limit".to_string();
(
InternalWorkerSessionStatus::Stopped,
Some(message),
InternalWorkerTurnOutcome::Completed(result),
)
} }
Ok(WorkerRunResult::Cancelled) => ( Ok(WorkerRunResult::Interrupted { code, message }) => (
InternalWorkerSessionStatus::Stopped,
Some(message.clone()),
InternalWorkerTurnOutcome::Completed(WorkerRunResult::Interrupted { code, message }),
),
Ok(result @ WorkerRunResult::Cancelled) => (
InternalWorkerSessionStatus::Stopped, InternalWorkerSessionStatus::Stopped,
Some("internal Worker run was cancelled".to_string()), Some("internal Worker run was cancelled".to_string()),
InternalWorkerTurnOutcome::Completed(result),
), ),
Ok(WorkerRunResult::RolledBack) => ( Ok(result @ WorkerRunResult::RolledBack) => (
InternalWorkerSessionStatus::Stopped, InternalWorkerSessionStatus::Stopped,
Some("internal Worker run was cancelled before AI output".to_string()), Some("internal Worker run was cancelled before AI output".to_string()),
InternalWorkerTurnOutcome::Completed(result),
), ),
Err(error) => (InternalWorkerSessionStatus::Failed, Some(error.to_string())), Err(error) => {
let message = error.to_string();
(
InternalWorkerSessionStatus::Failed,
Some(message.clone()),
InternalWorkerTurnOutcome::Failed { message },
)
}
} }
} }
@@ -389,6 +427,7 @@ pub(crate) struct InternalWorkerSessionHandle {
event_tx: broadcast::Sender<Event>, event_tx: broadcast::Sender<Event>,
visibility: InternalWorkerVisibility, visibility: InternalWorkerVisibility,
last_error: Arc<Mutex<Option<String>>>, last_error: Arc<Mutex<Option<String>>>,
last_outcome: Arc<Mutex<Option<InternalWorkerTurnOutcome>>>,
child_registry: Option<Arc<SpawnedWorkerRegistry>>, child_registry: Option<Arc<SpawnedWorkerRegistry>>,
sink: SegmentLogSink, sink: SegmentLogSink,
#[cfg(test)] #[cfg(test)]
@@ -488,6 +527,8 @@ impl InternalWorkerSessionHandle {
InternalWorkerSessionStatus::Idle => InternalWorkerSessionError::Unavailable, InternalWorkerSessionStatus::Idle => InternalWorkerSessionError::Unavailable,
}, },
)?; )?;
*self.last_error.lock().unwrap() = None;
*self.last_outcome.lock().unwrap() = None;
if self if self
.command_tx .command_tx
.send(InternalWorkerSessionCommand::Run(input.into())) .send(InternalWorkerSessionCommand::Run(input.into()))
@@ -501,6 +542,9 @@ impl InternalWorkerSessionHandle {
self.state_changed.notify_waiters(); self.state_changed.notify_waiters();
let message = "internal Worker session actor is unavailable".to_owned(); let message = "internal Worker session actor is unavailable".to_owned();
*self.last_error.lock().unwrap() = Some(message.clone()); *self.last_error.lock().unwrap() = Some(message.clone());
*self.last_outcome.lock().unwrap() = Some(InternalWorkerTurnOutcome::Failed {
message: message.clone(),
});
let _ = self.event_tx.send(Event::Error { let _ = self.event_tx.send(Event::Error {
code: protocol::ErrorCode::Internal, code: protocol::ErrorCode::Internal,
message, message,
@@ -524,6 +568,15 @@ impl InternalWorkerSessionHandle {
} }
} }
/// Wait for the active turn and return its typed outcome.
///
/// `None` means the actor stopped without publishing a turn result; callers
/// must treat that as unknown rather than manufacturing a cancellation.
pub(crate) async fn wait_for_turn_outcome(&self) -> Option<InternalWorkerTurnOutcome> {
self.wait_until_idle().await;
self.last_outcome.lock().unwrap().clone()
}
#[cfg(test)] #[cfg(test)]
pub(crate) fn force_status(&self, status: InternalWorkerSessionStatus) { pub(crate) fn force_status(&self, status: InternalWorkerSessionStatus) {
self.status self.status
@@ -808,6 +861,7 @@ pub(crate) async fn prepare_internal_worker_session(
)); ));
let state_changed = Arc::new(tokio::sync::Notify::new()); let state_changed = Arc::new(tokio::sync::Notify::new());
let last_error = Arc::new(Mutex::new(None)); let last_error = Arc::new(Mutex::new(None));
let last_outcome = Arc::new(Mutex::new(None));
let handle = InternalWorkerSessionHandle { let handle = InternalWorkerSessionHandle {
command_tx, command_tx,
status: status.clone(), status: status.clone(),
@@ -819,6 +873,7 @@ pub(crate) async fn prepare_internal_worker_session(
event_tx: event_tx.clone(), event_tx: event_tx.clone(),
visibility, visibility,
last_error: last_error.clone(), last_error: last_error.clone(),
last_outcome: last_outcome.clone(),
child_registry, child_registry,
sink, sink,
#[cfg(test)] #[cfg(test)]
@@ -836,8 +891,10 @@ pub(crate) async fn prepare_internal_worker_session(
loop { loop {
tokio::select! { tokio::select! {
result = &mut run => { result = &mut run => {
let (turn_status, error) = classify_internal_turn_result(result); let (turn_status, error, outcome) =
classify_internal_turn_result(result);
actor_in_flight.clear(); actor_in_flight.clear();
*last_outcome.lock().unwrap() = Some(outcome);
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release); status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
if let Some(message) = error { if let Some(message) = error {
*last_error.lock().unwrap() = Some(message.clone()); *last_error.lock().unwrap() = Some(message.clone());
@@ -1148,6 +1205,7 @@ pub(crate) fn test_internal_worker_session(
event_tx: event_tx.clone(), event_tx: event_tx.clone(),
visibility, visibility,
last_error: Arc::new(Mutex::new(None)), last_error: Arc::new(Mutex::new(None)),
last_outcome: Arc::new(Mutex::new(None)),
child_registry: None, child_registry: None,
sink, sink,
fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)), fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
@@ -1354,6 +1412,11 @@ permission = "write"
InternalWorkerSessionStatus::Stopped, InternalWorkerSessionStatus::Stopped,
true, true,
), ),
(
WorkerRunResult::Cancelled,
InternalWorkerSessionStatus::Stopped,
true,
),
( (
WorkerRunResult::RolledBack, WorkerRunResult::RolledBack,
InternalWorkerSessionStatus::Stopped, InternalWorkerSessionStatus::Stopped,
@@ -1362,16 +1425,22 @@ permission = "write"
]; ];
for (result, expected_status, expects_error) in cases { for (result, expected_status, expects_error) in cases {
let (status, error) = classify_internal_turn_result(Ok(result)); let expected_outcome = InternalWorkerTurnOutcome::Completed(result.clone());
let (status, error, outcome) = classify_internal_turn_result(Ok(result));
assert_eq!(status, expected_status); assert_eq!(status, expected_status);
assert_eq!(error.is_some(), expects_error); assert_eq!(error.is_some(), expects_error);
assert_eq!(outcome, expected_outcome);
} }
let (status, error) = classify_internal_turn_result(Err(WorkerError::Engine( let (status, error, outcome) = classify_internal_turn_result(Err(WorkerError::Engine(
EngineError::Aborted("fatal".to_string()), EngineError::Aborted("fatal".to_string()),
))); )));
assert_eq!(status, InternalWorkerSessionStatus::Failed); assert_eq!(status, InternalWorkerSessionStatus::Failed);
assert!(error.is_some_and(|message| message.contains("fatal"))); assert!(error.is_some_and(|message| message.contains("fatal")));
assert!(matches!(
outcome,
InternalWorkerTurnOutcome::Failed { message } if message.contains("fatal")
));
} }
#[tokio::test] #[tokio::test]
+2 -2
View File
@@ -937,8 +937,8 @@ mod tests {
"missing canonical tool {name}" "missing canonical tool {name}"
); );
} }
assert!(orchestration.contains("durably queue it for a later turn")); assert!(orchestration.contains("requires an Idle Worker and never queues input"));
assert!(orchestration.contains("Direct SubWorkers still require Idle")); assert!(orchestration.contains("Direct SubWorkers also require Idle"));
assert!(orchestration.contains("use `WorkerNotify` instead")); assert!(orchestration.contains("use `WorkerNotify` instead"));
assert!(orchestration.contains("do not create a queued Submit")); assert!(orchestration.contains("do not create a queued Submit"));
for alias in ["SubWorkerList", "SubWorkerSend", "SubWorkerStop"] { for alias in ["SubWorkerList", "SubWorkerSend", "SubWorkerStop"] {
+265 -88
View File
@@ -4941,11 +4941,19 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
if let Some(block) = request_block { if let Some(block) = request_block {
let error = automatic_compact_block_error(block); let error = automatic_compact_block_error(block);
if matches!(result, EngineRunExit::Yielded) { let error = if matches!(result, EngineRunExit::Yielded) {
self.persist_compaction_run_failure( self.terminalize_compaction_run_failure(
request_block_message.expect("blocked request has a terminal reason"), request_block_message.expect("blocked request has a terminal reason"),
); error,
} )
} else {
// `persist_turn` above already committed this non-yielded Run's
// compaction terminal. Preserve Err semantics while preventing a
// second Controller Error for the same durable entry.
WorkerError::RunFailureAlreadyDelivered {
source: Box::new(error),
}
};
if let Some(state) = &self.compact_state { if let Some(state) = &self.compact_state {
state.finish_logical_run(); state.finish_logical_run();
} }
@@ -4999,33 +5007,33 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
} }
} }
fn persist_compaction_run_failure(&mut self, message: String) { fn persist_compaction_run_failure(&mut self, message: String) -> Result<(), WorkerError> {
// Keep the original compaction/preparation error as controller authority. // The Store is the terminal authority. Only a successful append can be
// A terminal append failure is diagnosed separately and cannot publish a // marked as already delivered to clients through the session-log sink.
// false persisted result through the session-log sink. self.commit_entry(LogEntry::RunErrored {
match self.commit_entry(LogEntry::RunErrored {
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
interrupted: false, interrupted: false,
message, message,
failure: Some(RunFailureKind::Compaction), failure: Some(RunFailureKind::Compaction),
}) { })?;
Ok(()) => { self.last_run_interrupted = false;
self.last_run_interrupted = false; self.engine_mut().set_active_run_turn_count(None);
self.engine_mut().set_active_run_turn_count(None); Ok(())
} }
Err(persist_error) => {
warn!( fn terminalize_compaction_run_failure(
error = %persist_error, &mut self,
"failed to persist mid-run compaction terminal record" message: String,
); run_failure: WorkerError,
self.alert( ) -> WorkerError {
AlertLevel::Error, match self.persist_compaction_run_failure(message) {
AlertSource::Worker, Ok(()) => WorkerError::RunFailureAlreadyDelivered {
format!( source: Box::new(run_failure),
"mid-run compaction failed and its Run terminal record could not be saved: {persist_error}" },
), Err(persist_error) => WorkerError::RunTerminalPersistence {
); run_failure: Box::new(run_failure),
} persist_error: Box::new(persist_error),
},
} }
} }
@@ -5078,9 +5086,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
} }
let message = let message =
format!("mid-run resume preparation failed after compaction: {error}"); format!("mid-run resume preparation failed after compaction: {error}");
self.persist_compaction_run_failure(message.clone()); return Err(self.terminalize_compaction_run_failure(message, error));
self.alert(AlertLevel::Error, AlertSource::Compactor, message);
return Err(error);
} }
self.resume_prepared(RunResumeSource::Compaction).await self.resume_prepared(RunResumeSource::Compaction).await
} }
@@ -5102,14 +5108,11 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
} }
// The yielded logical Run is no longer resumable. Commit its // The yielded logical Run is no longer resumable. Commit its
// durable failure before any log-derived live event can claim // durable failure before returning a typed handled error. The
// the terminal transition. If storage itself is unavailable, // Controller suppresses only that exact handled variant; if
// the helper retains the original compaction error (and its // persistence fails it instead reports both failures through
// fail-closed controller classification) while diagnosing the // the non-durable error channel.
// missing terminal record separately. Err(self.terminalize_compaction_run_failure(message, e))
self.persist_compaction_run_failure(message.clone());
self.alert(AlertLevel::Error, AlertSource::Compactor, message);
Err(e)
} }
} }
}) })
@@ -5288,6 +5291,10 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
info!(new_segment_id = %new_segment_id, "Manual compaction succeeded"); info!(new_segment_id = %new_segment_id, "Manual compaction succeeded");
Ok(ManualCompactResult::Compacted { new_segment_id }) Ok(ManualCompactResult::Compacted { new_segment_id })
} }
Err(WorkerError::CompactCancelled) => {
info!("Manual compaction was cancelled");
Err(WorkerError::CompactCancelled)
}
Err(e) => { Err(e) => {
warn!(error = %e, "Manual compaction failed"); warn!(error = %e, "Manual compaction failed");
self.alert( self.alert(
@@ -5951,25 +5958,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
// outer compaction boundary performs the single stop attempt. // outer compaction boundary performs the single stop attempt.
return Err(WorkerError::InvalidState(error.to_string())); return Err(WorkerError::InvalidState(error.to_string()));
} }
match handle.wait_until_idle().await { ensure_compaction_internal_turn_succeeded(handle.wait_for_turn_outcome().await)?;
crate::internal_worker::InternalWorkerSessionStatus::Idle => {}
crate::internal_worker::InternalWorkerSessionStatus::Stopped => {
return Err(WorkerError::CompactCancelled);
}
crate::internal_worker::InternalWorkerSessionStatus::Failed => {
return Err(WorkerError::InvalidState(
handle
.protocol_snapshot()
.error
.unwrap_or_else(|| "compactor Internal Worker failed".into()),
));
}
status => {
return Err(WorkerError::InvalidState(format!(
"compactor Internal Worker ended in {status:?}"
)));
}
}
// Guard: nudge the worker once more if the expected outputs // Guard: nudge the worker once more if the expected outputs
// (summary, and any auto-read nominations when default refs // (summary, and any auto-read nominations when default refs
@@ -6003,20 +5992,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.send(prompt) .send(prompt)
.await .await
.map_err(|error| WorkerError::InvalidState(error.to_string()))?; .map_err(|error| WorkerError::InvalidState(error.to_string()))?;
match handle.wait_until_idle().await { ensure_compaction_internal_turn_succeeded(handle.wait_for_turn_outcome().await)?;
crate::internal_worker::InternalWorkerSessionStatus::Idle => {}
crate::internal_worker::InternalWorkerSessionStatus::Stopped => {
return Err(WorkerError::CompactCancelled);
}
_ => {
return Err(WorkerError::InvalidState(
handle
.protocol_snapshot()
.error
.unwrap_or_else(|| "compactor Internal Worker failed".into()),
));
}
}
} }
let mut final_ctx = ctx.lock().expect("compact ctx poisoned").clone(); let mut final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
@@ -6035,20 +6011,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.send(prompt) .send(prompt)
.await .await
.map_err(|error| WorkerError::InvalidState(error.to_string()))?; .map_err(|error| WorkerError::InvalidState(error.to_string()))?;
match handle.wait_until_idle().await { ensure_compaction_internal_turn_succeeded(handle.wait_for_turn_outcome().await)?;
crate::internal_worker::InternalWorkerSessionStatus::Idle => {}
crate::internal_worker::InternalWorkerSessionStatus::Stopped => {
return Err(WorkerError::CompactCancelled);
}
_ => {
return Err(WorkerError::InvalidState(
handle
.protocol_snapshot()
.error
.unwrap_or_else(|| "compactor Internal Worker failed".into()),
));
}
}
final_ctx = ctx.lock().expect("compact ctx poisoned").clone(); final_ctx = ctx.lock().expect("compact ctx poisoned").clone();
summary_text = final_ctx summary_text = final_ctx
.summary .summary
@@ -7904,6 +7867,37 @@ fn restored_flow_runtime_state(
.transpose() .transpose()
} }
fn ensure_compaction_internal_turn_succeeded(
outcome: Option<crate::internal_worker::InternalWorkerTurnOutcome>,
) -> Result<(), WorkerError> {
use crate::internal_worker::InternalWorkerTurnOutcome;
match outcome {
Some(InternalWorkerTurnOutcome::Completed(WorkerRunResult::Finished)) => Ok(()),
Some(InternalWorkerTurnOutcome::Completed(WorkerRunResult::LimitReached)) => {
Err(WorkerError::Engine(EngineError::Aborted(
"compactor Internal Worker reached its turn limit".to_string(),
)))
}
Some(InternalWorkerTurnOutcome::Completed(WorkerRunResult::Interrupted {
message,
..
})) => Err(WorkerError::Engine(EngineError::Aborted(message))),
Some(InternalWorkerTurnOutcome::Completed(
WorkerRunResult::Cancelled | WorkerRunResult::RolledBack,
)) => Err(WorkerError::CompactCancelled),
Some(InternalWorkerTurnOutcome::Completed(WorkerRunResult::Paused)) => Err(
WorkerError::InvalidState("compactor Internal Worker paused unexpectedly".to_string()),
),
Some(InternalWorkerTurnOutcome::Failed { message }) => {
Err(WorkerError::InvalidState(message))
}
None => Err(WorkerError::InvalidState(
"compactor Internal Worker stopped without a typed turn outcome".to_string(),
)),
}
}
fn automatic_compact_block_error(block: AutomaticCompactBlock) -> WorkerError { fn automatic_compact_block_error(block: AutomaticCompactBlock) -> WorkerError {
match block { match block {
AutomaticCompactBlock::Thrash => WorkerError::CompactThrash, AutomaticCompactBlock::Thrash => WorkerError::CompactThrash,
@@ -8019,6 +8013,26 @@ pub enum WorkerError {
#[error("compaction was cancelled")] #[error("compaction was cancelled")]
CompactCancelled, CompactCancelled,
/// The logical Run failed, and its terminal entry was already committed and
/// published through the session-log authority. Controllers must preserve the
/// error outcome without emitting a second generic error event.
#[error("{source}")]
RunFailureAlreadyDelivered {
#[source]
source: Box<WorkerError>,
},
/// The logical Run failed and its terminal entry could not be committed.
/// This remains unhandled so the Controller reports the run and persistence
/// failures together through its non-durable error channel.
#[error(
"run failed ({run_failure}) and its terminal record could not be persisted: {persist_error}"
)]
RunTerminalPersistence {
run_failure: Box<WorkerError>,
persist_error: Box<WorkerError>,
},
#[error("compact summary too large: {tokens} tokens exceeds max {max}")] #[error("compact summary too large: {tokens} tokens exceeds max {max}")]
CompactSummaryTooLarge { tokens: u64, max: u64 }, CompactSummaryTooLarge { tokens: u64, max: u64 },
@@ -9104,6 +9118,83 @@ mod build_summary_prompt_tests {
} }
} }
#[derive(Clone)]
struct FailRunTerminalStore {
inner: session_store::FsStore,
fail_run_terminal: Arc<AtomicBool>,
}
impl Store for FailRunTerminalStore {
fn append(
&self,
session_id: SessionId,
segment_id: SegmentId,
entry: &LogEntry,
) -> Result<(), StoreError> {
if matches!(entry, LogEntry::RunErrored { .. })
&& self.fail_run_terminal.swap(false, Ordering::SeqCst)
{
return Err(StoreError::Io(std::io::Error::other(
"synthetic terminal append failure",
)));
}
self.inner.append(session_id, segment_id, entry)
}
fn read_all(
&self,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<Vec<LogEntry>, StoreError> {
self.inner.read_all(session_id, segment_id)
}
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError> {
self.inner.list_sessions()
}
fn list_segments(&self, session_id: SessionId) -> Result<Vec<SegmentId>, StoreError> {
self.inner.list_segments(session_id)
}
fn lookup_session_of(
&self,
segment_id: SegmentId,
) -> Result<Option<SessionId>, StoreError> {
self.inner.lookup_session_of(segment_id)
}
fn create_segment(
&self,
session_id: SessionId,
segment_id: SegmentId,
entries: &[LogEntry],
) -> Result<(), StoreError> {
self.inner.create_segment(session_id, segment_id, entries)
}
fn exists(&self, session_id: SessionId, segment_id: SegmentId) -> Result<bool, StoreError> {
self.inner.exists(session_id, segment_id)
}
fn read_entry_count(
&self,
session_id: SessionId,
segment_id: SegmentId,
) -> Result<usize, StoreError> {
self.inner.read_entry_count(session_id, segment_id)
}
fn append_trace(
&self,
session_id: SessionId,
segment_id: SegmentId,
entry: &session_store::TraceEntry,
) -> Result<(), StoreError> {
self.inner.append_trace(session_id, segment_id, entry)
}
}
#[derive(Clone)] #[derive(Clone)]
struct NoopClient; struct NoopClient;
@@ -9314,9 +9405,9 @@ mod build_summary_prompt_tests {
.unwrap(); .unwrap();
worker.last_run_interrupted = true; worker.last_run_interrupted = true;
worker.persist_compaction_run_failure( worker
"mid-run compaction failed: summary unavailable".into(), .persist_compaction_run_failure("mid-run compaction failed: summary unavailable".into())
); .unwrap();
let entries = store let entries = store
.read_all(worker.session_id(), worker.segment_id()) .read_all(worker.session_id(), worker.segment_id())
@@ -9348,6 +9439,92 @@ mod build_summary_prompt_tests {
assert_eq!(worker.engine().active_run_turn_count(), None); assert_eq!(worker.engine().active_run_turn_count(), None);
} }
#[tokio::test]
async fn failed_compaction_terminal_append_stays_unhandled_and_unpublished() {
let dir = tempfile::tempdir().unwrap();
let store = FailRunTerminalStore {
inner: session_store::FsStore::new(dir.path().join("sessions")).unwrap(),
fail_run_terminal: Arc::new(AtomicBool::new(false)),
};
let mut worker = Worker::new(
minimal_manifest(),
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient),
store.clone(),
WorkerWorkspaceContext::no_workspace(),
WorkerFilesystemAuthority::None,
Scope::empty(),
)
.await
.unwrap();
worker.ensure_segment_head().await.unwrap();
store.fail_run_terminal.store(true, Ordering::SeqCst);
let error = worker.terminalize_compaction_run_failure(
"mid-run compaction failed: summary unavailable".into(),
WorkerError::CompactSummaryMissing,
);
assert!(matches!(
error,
WorkerError::RunTerminalPersistence {
run_failure,
persist_error,
} if matches!(*run_failure, WorkerError::CompactSummaryMissing)
&& persist_error.to_string().contains("synthetic terminal append failure")
));
assert!(
store
.read_all(worker.session_id(), worker.segment_id())
.unwrap()
.iter()
.all(|entry| !matches!(entry, LogEntry::RunErrored { .. }))
);
assert!(
worker
.sink()
.subscribe_with_snapshot()
.0
.iter()
.all(|entry| !matches!(entry, LogEntry::RunErrored { .. })),
"a failed append must not be published as durable"
);
}
#[test]
fn compaction_internal_turn_preserves_typed_stop_causes() {
use crate::internal_worker::InternalWorkerTurnOutcome;
let limit = ensure_compaction_internal_turn_succeeded(Some(
InternalWorkerTurnOutcome::Completed(WorkerRunResult::LimitReached),
))
.unwrap_err();
assert!(
matches!(limit, WorkerError::Engine(EngineError::Aborted(message)) if message.contains("turn limit"))
);
let interrupted = ensure_compaction_internal_turn_succeeded(Some(
InternalWorkerTurnOutcome::Completed(WorkerRunResult::Interrupted {
code: ErrorCode::ProviderError,
message: "summary provider failed".to_string(),
}),
))
.unwrap_err();
assert!(
matches!(interrupted, WorkerError::Engine(EngineError::Aborted(message)) if message == "summary provider failed")
);
let cancelled = ensure_compaction_internal_turn_succeeded(Some(
InternalWorkerTurnOutcome::Completed(WorkerRunResult::Cancelled),
))
.unwrap_err();
assert!(matches!(cancelled, WorkerError::CompactCancelled));
let unknown = ensure_compaction_internal_turn_succeeded(None).unwrap_err();
assert!(
matches!(unknown, WorkerError::InvalidState(message) if message.contains("without a typed turn outcome"))
);
}
#[tokio::test] #[tokio::test]
async fn intentional_cancel_commits_a_non_failure_terminal() { async fn intentional_cancel_commits_a_non_failure_terminal() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+118 -3
View File
@@ -991,12 +991,16 @@ async fn compact_failure_and_cancellation_emit_bounded_categories() {
let encoded = serde_json::to_string(&failure.metric).unwrap(); let encoded = serde_json::to_string(&failure.metric).unwrap();
assert!(!encoded.contains("missing summary")); assert!(!encoded.contains("missing summary"));
let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(true); let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
cancel_tx.send(true).unwrap();
let error = worker let error = worker
.manual_compact_with_cancel(cancel_rx) .manual_compact_with_cancel(cancel_rx)
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(error, worker::WorkerError::CompactCancelled)); assert!(
matches!(error, worker::WorkerError::CompactCancelled),
"unexpected cancellation error: {error:?}"
);
let metrics = session_metrics::read_session_metrics(worker.store(), session_id).unwrap(); let metrics = session_metrics::read_session_metrics(worker.store(), session_id).unwrap();
let cancelled = metrics let cancelled = metrics
.iter() .iter()
@@ -1503,7 +1507,14 @@ async fn compacted_context_above_request_threshold_fails_before_provider_request
.await .await
.expect_err("unsafe compacted context must fail closed"); .expect_err("unsafe compacted context must fail closed");
assert!(matches!(error, WorkerError::CompactThrash)); assert!(
matches!(
error,
WorkerError::RunFailureAlreadyDelivered { ref source }
if matches!(**source, WorkerError::CompactThrash)
),
"unexpected error: {error:?}"
);
assert_eq!( assert_eq!(
call_count.load(Ordering::SeqCst), call_count.load(Ordering::SeqCst),
3, 3,
@@ -1526,6 +1537,100 @@ async fn compacted_context_above_request_threshold_fails_before_provider_request
})); }));
} }
#[tokio::test]
async fn controller_does_not_repeat_a_persisted_mid_run_compaction_failure() {
let client = MockClient::new(vec![
grow_tool_use_events("grow-before-compaction", 1_000),
single_text_events("missing summary"),
single_text_events("still missing summary"),
]);
let (worker, store) = make_worker_with_manifest_and_store(MID_TURN_MANIFEST_TOML, client).await;
let session_id = worker.session_id();
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(),
"trigger failing compaction",
))
.await
.unwrap();
let mut observed = Vec::new();
tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
let event = rx.recv().await.expect("event");
let idle = matches!(
event,
Event::WorkerState {
snapshot: protocol::WorkerStateSnapshot {
state: protocol::WorkerState::Idle,
..
}
}
);
observed.push(event);
if idle {
break;
}
}
})
.await
.expect("timeout waiting for failed compaction and Idle");
tokio::task::yield_now().await;
while let Ok(event) = rx.try_recv() {
observed.push(event);
}
let delivered = handle
.committed_entries()
.into_iter()
.filter(|entry| matches!(entry, LogEntry::RunErrored { .. }))
.count();
assert_eq!(delivered, 1, "the durable terminal is the only delivery");
assert!(
observed
.iter()
.all(|event| !matches!(event, Event::Error { .. })),
"Controller must not add a generic Error after durable delivery"
);
assert!(
observed.iter().all(|event| !matches!(
event,
Event::Alert(protocol::Alert {
level: protocol::AlertLevel::Error,
..
})
)),
"mid-run terminal failure must not add an Error Alert"
);
let terminal_count = store
.list_segments(session_id)
.unwrap()
.into_iter()
.flat_map(|segment_id| store.read_all(session_id, segment_id).unwrap())
.filter(|entry| matches!(entry, LogEntry::RunErrored { .. }))
.count();
assert_eq!(terminal_count, 1);
handle
.send(Method::Shutdown {
command: protocol::WorkerCommandEnvelope::new(1),
})
.await
.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(2), shutdown_receiver)
.await
.expect("controller shutdown timeout")
.expect("shutdown confirmation");
}
#[tokio::test] #[tokio::test]
async fn pre_run_compact_failure_clears_runtime_progress() { async fn pre_run_compact_failure_clears_runtime_progress() {
// Only the first run has a response. Compaction will run the // Only the first run has a response. Compaction will run the
@@ -1621,6 +1726,7 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() {
.expect("send compact cancel"); .expect("send compact cancel");
let mut saw_interrupted = false; let mut saw_interrupted = false;
let mut saw_idle = false; let mut saw_idle = false;
let mut saw_cancel_error = false;
while !(saw_interrupted && saw_idle) { while !(saw_interrupted && saw_idle) {
match tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) match tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
.await .await
@@ -1630,6 +1736,11 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() {
Event::CompactionProgress { compaction: None } => { Event::CompactionProgress { compaction: None } => {
saw_interrupted = true; saw_interrupted = true;
} }
Event::Error { .. }
| Event::Alert(protocol::Alert {
level: protocol::AlertLevel::Error,
..
}) => saw_cancel_error = true,
Event::WorkerState { snapshot } Event::WorkerState { snapshot }
if snapshot.catalog_status() == protocol::WorkerStatus::Idle => if snapshot.catalog_status() == protocol::WorkerStatus::Idle =>
{ {
@@ -1642,6 +1753,10 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() {
_ => {} _ => {}
} }
} }
assert!(
!saw_cancel_error,
"intentional manual cancellation must not be reported as an Error"
);
let compact = protocol::WorkerCommandEnvelope::new(3); let compact = protocol::WorkerCommandEnvelope::new(3);
handle handle
+91
View File
@@ -2895,6 +2895,97 @@ async fn drain_until<F: FnMut(&Event) -> bool>(
} }
} }
fn idle_only_input(request_id: &str) -> Method {
Method::SubmitIfIdle {
submission_request_id: request_id.to_string(),
input: vec![protocol::Segment::text(request_id)],
source: protocol::AuthenticatedInputSource::Backend {
operation_id: request_id.to_string(),
},
}
}
#[tokio::test]
async fn idle_only_submit_rejects_admission_race_and_paused_without_persisting() {
let client = MockClient::sequential(vec![MockResponse::Hang(vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, "partial"),
])]);
let handle = spawn_controller(make_worker(client.clone()).await).await;
let mut rx = handle.subscribe();
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Idle);
// Both callers can observe Idle. Admission of the first request must make
// the second fail rather than silently scheduling a later turn.
handle.send(idle_only_input("first-idle")).await.unwrap();
handle.send(idle_only_input("racing-input")).await.unwrap();
let mut accepted_first = false;
assert!(
drain_until(&mut rx, std::time::Duration::from_secs(2), |event| {
match event {
Event::SubmissionAccepted {
submission_request_id,
..
} => {
assert_eq!(submission_request_id, "first-idle");
accepted_first = true;
false
}
Event::SubmissionRejected {
submission_request_id,
message,
} => {
assert_eq!(submission_request_id, "racing-input");
assert!(message.contains("WorkerNotify"));
true
}
_ => false,
}
})
.await
);
assert!(accepted_first);
wait_for_status(&handle, WorkerStatus::Running).await;
handle
.send(Method::Pause {
command: worker_command(&handle),
})
.await
.unwrap();
wait_for_status(&handle, WorkerStatus::Paused).await;
handle.send(idle_only_input("paused-input")).await.unwrap();
assert!(
drain_until(&mut rx, std::time::Duration::from_secs(2), |event| {
matches!(event, Event::SubmissionRejected { submission_request_id, message }
if submission_request_id == "paused-input" && message.contains("WorkerNotify"))
})
.await
);
assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Paused);
handle.send(Method::ListPendingSubmissions).await.unwrap();
assert!(
drain_until(&mut rx, std::time::Duration::from_secs(2), |event| {
if let Event::PendingSubmissionsChanged { pending } = event {
assert!(pending.submissions.is_empty());
true
} else {
false
}
})
.await
);
assert_eq!(client.captured_requests().len(), 1);
let entries = serde_json::to_string(&handle.committed_entries()).unwrap();
assert!(!entries.contains("racing-input"));
assert!(!entries.contains("paused-input"));
handle
.send(Method::Cancel {
command: worker_command(&handle),
})
.await
.unwrap();
wait_for_status(&handle, WorkerStatus::Idle).await;
}
/// Paused → Running → Idle. A notification accepted while Paused must not /// Paused → Running → Idle. A notification accepted while Paused must not
/// auto-resume the Worker, and explicit Resume must inject it while preserving /// auto-resume the Worker, and explicit Resume must inject it while preserving
/// the interrupted turn's history consistency. /// the interrupted turn's history consistency.
+93 -4
View File
@@ -696,6 +696,8 @@ pub struct WorkerLifecycleResult {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkerInputKind { pub enum WorkerInputKind {
User, User,
/// Agent input that must never enter the human Submit queue.
UserIfIdle,
Notify, Notify,
Compact, Compact,
ListRewindTargets, ListRewindTargets,
@@ -3037,6 +3039,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
let input = EmbeddedWorkerInput { let input = EmbeddedWorkerInput {
kind: match request.kind { kind: match request.kind {
WorkerInputKind::User => EmbeddedWorkerInputKind::User, WorkerInputKind::User => EmbeddedWorkerInputKind::User,
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify, WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact, WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets, WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
@@ -5028,6 +5031,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
let input = EmbeddedWorkerInput { let input = EmbeddedWorkerInput {
kind: match request.kind { kind: match request.kind {
WorkerInputKind::User => EmbeddedWorkerInputKind::User, WorkerInputKind::User => EmbeddedWorkerInputKind::User,
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify, WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact, WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets, WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
@@ -5518,6 +5522,10 @@ fn sanitize_embedded_execution_message(
) -> String { ) -> String {
let summary = let summary =
format!("Embedded Worker execution backend rejected {operation:?} with {outcome:?}"); format!("Embedded Worker execution backend rejected {operation:?} with {outcome:?}");
sanitize_embedded_execution_detail(&summary, message)
}
fn sanitize_embedded_execution_detail(summary: &str, message: &str) -> String {
let mut redact_next = false; let mut redact_next = false;
let detail = message let detail = message
.split_whitespace() .split_whitespace()
@@ -5554,7 +5562,7 @@ fn sanitize_embedded_execution_message(
.join(" "); .join(" ");
let detail = detail.trim(); let detail = detail.trim();
if detail.is_empty() { if detail.is_empty() {
return summary; return summary.to_string();
} }
let truncated = detail.chars().count() > 512; let truncated = detail.chars().count() > 512;
let mut detail = detail.chars().take(512).collect::<String>(); let mut detail = detail.chars().take(512).collect::<String>();
@@ -5576,11 +5584,18 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
HostDiagnosticSeverity::Warning, HostDiagnosticSeverity::Warning,
"Embedded Runtime worker was not found".to_string(), "Embedded Runtime worker was not found".to_string(),
), ),
EmbeddedRuntimeError::WorkerExecutionUnavailable { .. } EmbeddedRuntimeError::WorkerExecutionUnavailable { message, .. } => diagnostic(
| EmbeddedRuntimeError::ExecutionBackendUnavailable { .. } => diagnostic(
"embedded_worker_execution_unavailable", "embedded_worker_execution_unavailable",
HostDiagnosticSeverity::Warning, HostDiagnosticSeverity::Warning,
"Embedded Worker has no execution backend attached".to_string(), sanitize_embedded_execution_detail("Embedded Worker execution is unavailable", message),
),
EmbeddedRuntimeError::ExecutionBackendUnavailable { message } => diagnostic(
"embedded_worker_execution_unavailable",
HostDiagnosticSeverity::Warning,
sanitize_embedded_execution_detail(
"Embedded Runtime execution backend is unavailable",
message,
),
), ),
EmbeddedRuntimeError::WorkerExecutionRejected { EmbeddedRuntimeError::WorkerExecutionRejected {
operation, operation,
@@ -6051,6 +6066,58 @@ mod tests {
assert_eq!(result.diagnostics.len(), 1); assert_eq!(result.diagnostics.len(), 1);
} }
#[test]
fn embedded_execution_unavailable_preserves_the_actual_reason() {
for reason in [
"worker stop reconciliation is pending",
"worker restore reconciliation is pending",
"stopped worker requires an explicit restore",
"worker has no live execution handle",
"runtime has no execution backend",
] {
let diagnostic =
embedded_runtime_diagnostic(&EmbeddedRuntimeError::WorkerExecutionUnavailable {
worker_id: EmbeddedWorkerId::now_v7(),
message: reason.to_string(),
});
assert_eq!(diagnostic.code, "embedded_worker_execution_unavailable");
assert_eq!(diagnostic.severity, HostDiagnosticSeverity::Warning);
assert_eq!(
diagnostic.message,
format!("Embedded Worker execution is unavailable: {reason}")
);
}
let diagnostic =
embedded_runtime_diagnostic(&EmbeddedRuntimeError::ExecutionBackendUnavailable {
message: "Runtime has no execution backend".to_string(),
});
assert_eq!(
diagnostic.message,
"Embedded Runtime execution backend is unavailable: Runtime has no execution backend"
);
}
#[test]
fn embedded_execution_unavailable_detail_remains_bounded_and_redacted() {
let diagnostic = embedded_runtime_diagnostic(
&EmbeddedRuntimeError::WorkerExecutionUnavailable {
worker_id: EmbeddedWorkerId::now_v7(),
message: format!(
"worker stop reconciliation is pending /private/runtime Bearer sensitive-value {}",
"x".repeat(600)
),
},
);
assert!(
diagnostic
.message
.contains("stop reconciliation is pending")
);
assert!(!diagnostic.message.contains("/private/runtime"));
assert!(!diagnostic.message.contains("sensitive-value"));
assert!(diagnostic.message.chars().count() < 600);
}
#[test] #[test]
fn embedded_delete_persistence_failure_diagnostic_is_bounded_and_path_free() { fn embedded_delete_persistence_failure_diagnostic_is_bounded_and_path_free() {
let diagnostic = let diagnostic =
@@ -6337,6 +6404,28 @@ mod tests {
assert_eq!(delivered.content, archive.content); assert_eq!(delivered.content, archive.content);
} }
#[test]
fn runtime_input_contract_preserves_every_input_kind() {
for kind in [
EmbeddedWorkerInputKind::User,
EmbeddedWorkerInputKind::UserIfIdle,
EmbeddedWorkerInputKind::Notify,
EmbeddedWorkerInputKind::Compact,
EmbeddedWorkerInputKind::ListRewindTargets,
EmbeddedWorkerInputKind::RegisterPeer,
] {
let input = EmbeddedWorkerInput {
kind,
content: "contract test".to_string(),
submission_request_id: Some("contract-request".to_string()),
segments: Some(vec![protocol::Segment::text("contract test")]),
};
let wire: runtime_api::WorkerInput = runtime_contract_convert(input.clone()).unwrap();
let decoded: EmbeddedWorkerInput = runtime_contract_convert(wire).unwrap();
assert_eq!(decoded, input);
}
}
#[test] #[test]
fn remote_profile_source_uses_workspace_config_archive_reference() { fn remote_profile_source_uses_workspace_config_archive_reference() {
let request = embedded_spawn_request(); let request = embedded_spawn_request();
+131 -2
View File
@@ -17659,7 +17659,30 @@ async fn send_known_worker_input(
&path.worker, &path.worker,
permission, permission,
)?; )?;
scoped_send_runtime_worker_input(State(api), AxumPath(path), Json(request)).await let mut request = worker_input_request_from_api(request)?;
let idle_only = request.kind == WorkerInputKind::User;
if idle_only {
request.kind = WorkerInputKind::UserIfIdle;
}
let Json(result) =
dispatch_runtime_worker_input(api, path.worker.runtime_id, path.worker.worker_id, request)?;
if result.state != server_api::WorkerOperationState::Accepted {
let detail = result
.diagnostics
.iter()
.map(|diagnostic| diagnostic.message.as_str())
.collect::<Vec<_>>()
.join("; ");
let message = if idle_only {
format!(
"WorkerSendInput requires an Idle Worker and never queues input. Use WorkerNotify for information about work already in progress. {detail}"
)
} else {
detail
};
return Err(Error::InvalidInput(message).into());
}
Ok(Json(result))
} }
async fn cancel_known_worker( async fn cancel_known_worker(
@@ -24426,6 +24449,15 @@ async fn send_runtime_worker_input(
Json(request): Json<server_api::RuntimeWorkerInputRequest>, Json(request): Json<server_api::RuntimeWorkerInputRequest>,
) -> ApiResult<Json<server_api::RuntimeWorkerInputResult>> { ) -> ApiResult<Json<server_api::RuntimeWorkerInputResult>> {
let request = worker_input_request_from_api(request)?; let request = worker_input_request_from_api(request)?;
dispatch_runtime_worker_input(api, runtime_id, worker_id, request)
}
fn dispatch_runtime_worker_input(
api: WorkspaceApi,
runtime_id: String,
worker_id: String,
request: WorkerInputRequest,
) -> ApiResult<Json<server_api::RuntimeWorkerInputResult>> {
let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?;
if api if api
.store .store
@@ -24535,7 +24567,9 @@ pub(crate) fn authorize_browser_worker_method(
message, message,
source: source.clone(), source: source.clone(),
}), }),
protocol::Method::SubmitTracked { .. } | protocol::Method::NotifyTracked { .. } => { protocol::Method::SubmitTracked { .. }
| protocol::Method::SubmitIfIdle { .. }
| protocol::Method::NotifyTracked { .. } => {
Err("authenticated Worker input source is server-owned") Err("authenticated Worker input source is server-owned")
} }
other => Ok(other), other => Ok(other),
@@ -29862,6 +29896,7 @@ mod tests {
#[derive(Clone, Default)] #[derive(Clone, Default)]
struct WorkdirlessFixtureRuntime { struct WorkdirlessFixtureRuntime {
input_requests: Arc<Mutex<Vec<WorkerInputRequest>>>,
workers: Arc<Mutex<Vec<InternalWorkerSummary>>>, workers: Arc<Mutex<Vec<InternalWorkerSummary>>>,
spawn_requests: Arc<Mutex<Vec<WorkerSpawnRequest>>>, spawn_requests: Arc<Mutex<Vec<WorkerSpawnRequest>>>,
reject_next_spawn: Arc<Mutex<bool>>, reject_next_spawn: Arc<Mutex<bool>>,
@@ -29988,6 +30023,30 @@ mod tests {
} }
impl crate::hosts::WorkspaceWorkerRuntime for WorkdirlessFixtureRuntime { impl crate::hosts::WorkspaceWorkerRuntime for WorkdirlessFixtureRuntime {
fn send_input(
&self,
worker_id: &str,
request: WorkerInputRequest,
) -> crate::hosts::WorkerInputResult {
let idle_only = request.kind == WorkerInputKind::UserIfIdle;
self.input_requests.lock().unwrap().push(request);
crate::hosts::WorkerInputResult {
state: if idle_only {
InternalWorkerOperationState::Rejected
} else {
InternalWorkerOperationState::Accepted
},
disposition: if idle_only {
crate::hosts::WorkerInputDisposition::Rejected
} else {
crate::hosts::WorkerInputDisposition::Accepted
},
worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, worker_id),
runtime_run_id: None,
diagnostics: Vec::new(),
}
}
fn runtime_id(&self) -> &str { fn runtime_id(&self) -> &str {
Self::RUNTIME_ID Self::RUNTIME_ID
} }
@@ -33327,6 +33386,76 @@ mod tests {
assert_eq!(current.assignment_id, "existing-coder-assignment"); assert_eq!(current.assignment_id, "existing-coder-assignment");
} }
#[tokio::test]
async fn worker_control_input_is_idle_only_and_returns_rejection_as_http_error() {
let fixture = guarded_spawn_fixture().await;
let response = post_guarded_spawn(
&fixture,
guarded_spawn_payload(&fixture, "idle-only-control"),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let created: BrowserCreateWorkerResponse = serde_json::from_slice(&bytes).unwrap();
let mut headers = HeaderMap::new();
headers.insert(
"x-yoi-runtime-id",
fixture.controller.runtime_id.parse().unwrap(),
);
headers.insert(
"x-yoi-worker-id",
fixture.controller.worker_id.parse().unwrap(),
);
let path = || ScopedRuntimeWorkerPath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
worker: RuntimeWorkerRef::new(&created.runtime_id, &created.worker_id),
};
let request = |kind: &str| server_api::RuntimeWorkerInputRequest {
kind: Some(kind.to_string()),
content: "follow-up".to_string(),
segments: None,
};
let error = send_known_worker_input(
State(fixture.api.clone()),
AxumPath(path()),
headers.clone(),
Json(request("user")),
)
.await
.unwrap_err();
let response = error.into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert!(String::from_utf8_lossy(&bytes).contains("WorkerNotify"));
// Machine Notify is unchanged; the human endpoint must still use User.
let Json(notify) = send_known_worker_input(
State(fixture.api.clone()),
AxumPath(path()),
headers,
Json(request("notify")),
)
.await
.unwrap();
assert_eq!(notify.state, server_api::WorkerOperationState::Accepted);
let Json(human) = scoped_send_runtime_worker_input(
State(fixture.api.clone()),
AxumPath(path()),
Json(request("user")),
)
.await
.unwrap();
assert_eq!(human.state, server_api::WorkerOperationState::Accepted);
let requests = fixture.runtime.input_requests.lock().unwrap();
assert_eq!(
requests.iter().map(|r| r.kind.clone()).collect::<Vec<_>>(),
vec![
WorkerInputKind::UserIfIdle,
WorkerInputKind::Notify,
WorkerInputKind::User
]
);
}
#[tokio::test] #[tokio::test]
async fn worker_control_spawn_retry_converges_on_one_worker_and_one_grant() { async fn worker_control_spawn_retry_converges_on_one_worker_and_one_grant() {
let workspace = tempfile::tempdir().unwrap(); let workspace = tempfile::tempdir().unwrap();
+2
View File
@@ -20,6 +20,8 @@ compaction = {
threshold = 240000; threshold = 240000;
request_threshold = 270000; request_threshold = 270000;
worker_context_max_tokens = 100000; worker_context_max_tokens = 100000;
worker_max_turns = 100;
model = { ref = "codex-oauth/gpt-6-luna"; };
}; };
feature = { feature = {
@@ -4,7 +4,7 @@
When SubWorker-management tools are available, create direct children with `SubWorkerSpawn`, discover them with `WorkerList`, continue them with `WorkerSendInput`, and release their delegated authority with `WorkerStop`. Pass the exact `{ kind: "sub_worker", name }` subject returned by `WorkerList`; do not invent direct-only aliases. SubWorker notifications are background signals for the parent Worker to handle at a natural stopping point. Do not ignore routine follow-up, but do not interrupt the current user request unnecessarily. When SubWorker-management tools are available, create direct children with `SubWorkerSpawn`, discover them with `WorkerList`, continue them with `WorkerSendInput`, and release their delegated authority with `WorkerStop`. Pass the exact `{ kind: "sub_worker", name }` subject returned by `WorkerList`; do not invent direct-only aliases. SubWorker notifications are background signals for the parent Worker to handle at a natural stopping point. Do not ignore routine follow-up, but do not interrupt the current user request unnecessarily.
For Runtime Workers, `WorkerSendInput` submits user input: Idle Workers can start a fresh turn, while Running or Paused Workers durably queue it for a later turn. Queueing does not implicitly resume a paused run; Cancel/error retains pending input until explicitly continued. Direct SubWorkers still require Idle for `WorkerSendInput`. When `WorkerNotify` is available and information should be incorporated into work already in progress, use `WorkerNotify` instead; notifications are advisory and do not create a queued Submit. `WorkerSendInput` requires an Idle Worker and never queues input. Non-Idle Workers reject it; when information should be incorporated into work already in progress, use `WorkerNotify` instead. Notifications are advisory and do not create a queued Submit. Direct SubWorkers also require Idle for `WorkerSendInput`; do not send follow-up input until they are Idle. The Submit queue is for human-controlled Web/TUI interaction, not Orchestrator scheduling. Do not retry SendInput in a polling loop or cancel/pause work to bypass this restriction.
The parent Worker does not need to keep a turn open or call tools solely to wait for a notification. Do not use `sleep` or polling loops just to wait for SubWorker output; if there is no useful immediate work, return control and handle the SubWorker when notified or when the user next asks. The parent Worker does not need to keep a turn open or call tools solely to wait for a notification. Do not use `sleep` or polling loops just to wait for SubWorker output; if there is no useful immediate work, return control and handle the SubWorker when notified or when the user next asks.
+9
View File
@@ -208,6 +208,15 @@ Build `web/workspace` before capture. No real keys, accounts, or deletion reques
keyboard disclosure/focus return, full public bundle access, scoped errors, and exact-name deletion keyboard disclosure/focus return, full public bundle access, scoped errors, and exact-name deletion
confirmation. Interaction screenshots are saved under `target/web-ux/settings-interactions`. confirmation. Interaction screenshots are saved under `target/web-ux/settings-interactions`.
## Console pending input fixture
`scenarios/console-pending.json` uses the owned Console history fixture with `?pending=both`,
`queue`, `notifications`, `legacy`, or `empty`. Each state includes a task for typography comparison.
It captures 1440px, 768px, and 390px; `browser-tests/console_pending_shell_test.ts` additionally checks
320px and both themes, independent group visibility, full-width single groups, uniform 16px rows,
monospace inheritance, truncation, and retained-session cancellation gating. Live revision-fenced
cancellation and state transitions are covered by Console component tests. No real Worker is used.
## Adding a scenario ## Adding a scenario
1. Name the concrete user task and expected data state; do not write “looks correct”. 1. Name the concrete user task and expected data state; do not write “looks correct”.
@@ -174,17 +174,30 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
url.pathname === url.pathname ===
`/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}/session` `/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}/session`
) { ) {
const pendingMode = new URL(request.headers.get("referer") ?? request.url).searchParams.get("pending");
const hasQueue = pendingMode === "both" || pendingMode === "queue";
const hasNotifications = pendingMode === "both" || pendingMode === "notifications" || pendingMode === "legacy";
const submissions = hasQueue ? [
{ submission_id: "queued-1", preview: "First queued input", accepted_at_ms: 1, segment_count: 1, byte_len: 18 },
{ submission_id: "queued-2", preview: "Long queued input — " + "preview text ".repeat(30), accepted_at_ms: 2, segment_count: 1, byte_len: 500 },
] : [];
const taskEntries = pendingMode ? [{
kind: "tool_call", entry_id: "fixture-task", timestamp: 13, provenance: "model_output",
call_id: "fixture-task", name: "TaskCreate",
arguments: JSON.stringify({ subject: "Inspect pending input layout", description: "Synthetic visual fixture" }),
}] : [];
return json({ return json({
availability: "retained_snapshot", availability: "retained_snapshot",
identity: { session_id: "session-a", segment_id: "segment-a", entry_count: 36 }, identity: { session_id: "session-a", segment_id: "segment-a", entry_count: 36 },
snapshot: { snapshot: {
pending_submissions: { pending_submissions: {
revision: 0, revision: 0,
notification_count: 0, notification_count: hasNotifications ? 2 : 0,
head_id: null, ...(hasNotifications && pendingMode !== "legacy" ? { notification_previews: ["First notification", "Long notification — " + "notification text ".repeat(30)] } : {}),
submissions: [], head_id: submissions[0]?.submission_id ?? null,
submissions,
}, },
entries: [7, 8, 9, 10, 11, 12].flatMap(historyTurnEntries), entries: [...[7, 8, 9, 10, 11, 12].flatMap(historyTurnEntries), ...taskEntries],
}, },
}); });
} }
@@ -0,0 +1,179 @@
/// <reference lib="dom" />
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join } from "@std/path";
import { chromium } from "playwright";
const root = join(dirname(fromFileUrl(import.meta.url)), "../../..");
Deno.test("Console pending scopes are independent and match Tasks row typography", async () => {
const build = await new Deno.Command(Deno.execPath(), {
args: ["task", "build"],
cwd: join(root, "web/workspace"),
stdout: "piped",
stderr: "piped",
}).output();
assert(build.success, new TextDecoder().decode(build.stderr));
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (listener.addr as Deno.NetAddr).port;
listener.close();
const url = `http://127.0.0.1:${port}`;
const server = new Deno.Command(Deno.execPath(), {
args: [
"run",
"--allow-net",
"--allow-read",
join(root, "tools/web-ux/browser-tests/console_history_fixture_server.ts"),
String(port),
join(root, "web/workspace/build"),
],
stdout: "null",
stderr: "null",
}).spawn();
try {
let started = false;
for (let attempt = 0; attempt < 100; attempt++) {
try {
const response = await fetch(`${url}/health`);
await response.body?.cancel();
if (response.ok) {
started = true;
break;
}
} catch { /* owned fixture is starting */ }
await new Promise((resolve) => setTimeout(resolve, 50));
}
assert(started);
const browser = await chromium.launch({ headless: true });
try {
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390, 320]) {
const context = await browser.newContext({
viewport: { width, height: 900 },
colorScheme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(String(error)));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("requestfailed", (request) => errors.push(request.url()));
for (const mode of ["both", "queue", "notifications", "legacy", "empty"]) {
await page.goto(
`${url}/w/console-history-review/workers/W-900-console-fixture/console?pending=${mode}`,
);
await page.locator(".task-mini-row").waitFor();
const queue = mode === "both" || mode === "queue";
const notifications = ["both", "notifications", "legacy"].includes(mode);
assertEquals(
await page.getByRole("group", { name: "Queued inputs", exact: true }).count(),
queue ? 1 : 0,
);
assertEquals(
await page.getByRole("group", { name: "Notifications", exact: true }).count(),
notifications ? 1 : 0,
);
assertEquals(await page.getByRole("heading", { name: "0 Queued" }).count(), 0);
const region = page.getByRole("region", { name: "Pending activations" });
assertEquals(await region.count(), mode === "empty" ? 0 : 1);
if (mode === "empty") continue;
const geometry = await region.evaluate((region) => {
const task = document.querySelector<HTMLElement>(".task-mini-row")!;
const taskStyle = getComputedStyle(task);
const metrics = (node: Element) => {
const style = getComputedStyle(node);
return {
height: node.getBoundingClientRect().height,
family: style.fontFamily,
size: style.fontSize,
line: style.lineHeight,
};
};
return {
task: metrics(task),
rows: [...region.querySelectorAll("h2, li")].map(metrics),
lists: [...region.querySelectorAll("ol")].map((list) => ({
margin: getComputedStyle(list).margin,
padding: getComputedStyle(list).padding,
gap: getComputedStyle(list).rowGap,
})),
columns: [...region.querySelectorAll(".pending-column")].map((column) =>
column.getBoundingClientRect().toJSON()
),
width: region.getBoundingClientRect().width,
pageOverflow: document.documentElement.scrollWidth > innerWidth,
mainOverflow: document.querySelector("main")!.scrollWidth >
document.querySelector("main")!.clientWidth,
previews: [...region.querySelectorAll(".pending-submission-preview")].map((
preview,
) => ({
title: preview.getAttribute("title"),
whitespace: getComputedStyle(preview).whiteSpace,
overflow: getComputedStyle(preview).textOverflow,
})),
expectedGap: getComputedStyle(task.closest(".task-mini")!).rowGap,
taskMono: taskStyle.fontFamily.includes("mono"),
};
});
assertEquals(geometry.task.height, 16);
assertEquals(geometry.taskMono, true);
for (const row of geometry.rows) {
assertEquals(
row,
geometry.task,
`${width}px/${mode}: heading and preview rows must match Tasks`,
);
}
for (const list of geometry.lists) {
assertEquals(list, { margin: "0px", padding: "0px", gap: geometry.expectedGap });
}
if (mode === "both") assertEquals(geometry.columns[0].y, geometry.columns[1].y);
else {assertEquals(
geometry.columns[0].width,
geometry.width,
"a lone scope must not reserve an empty column",
);}
assertEquals(geometry.pageOverflow, false);
assertEquals(geometry.mainOverflow, false);
for (const preview of geometry.previews) {
assert(preview.title);
assertEquals(preview.whitespace, "nowrap");
assertEquals(preview.overflow, "ellipsis");
}
if (queue) {
const cancel = page.getByRole("button", { name: "Cancel queued input 1" });
assertEquals((await cancel.boundingBox())!.height, 16);
assertEquals(
await cancel.isDisabled(),
true,
"retained sessions cannot mutate the queue",
);
await page.locator(".pending-queue li").first().hover();
assertEquals(
await cancel.evaluate((button) => getComputedStyle(button).opacity),
"1",
);
}
if (mode === "legacy") {
await page.getByText("2 pending · Preview unavailable", { exact: true }).waitFor();
}
}
assertEquals(errors, []);
} finally {
await context.close();
}
}
}
} finally {
await browser.close();
}
} finally {
try {
server.kill("SIGTERM");
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
await server.status;
}
});
@@ -97,6 +97,16 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
diagnostics: [], diagnostics: [],
}); });
} }
// Keyboard focus can preload the Tickets route before navigation.
if (path === "/orchestrator") {
return json({
workspace_id: workspaceId,
online: false,
disposition: "offline",
worker: null,
diagnostics: [],
});
}
if (path === "/working-directories") { if (path === "/working-directories") {
return json({ workspace_id: workspaceId, items: [], diagnostics: [] }); return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
} }
@@ -66,6 +66,9 @@ Deno.test("production Home dashboard isolates failures, shows authoritative reco
}); });
page.on("requestfailed", (request) => errors.push(request.url())); page.on("requestfailed", (request) => errors.push(request.url()));
page.on("request", (request) => requests.push(request.url())); page.on("request", (request) => requests.push(request.url()));
page.on("response", (response) => {
if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`);
});
for (const workspace of ["home-owner", "home-member", "home-long"]) { for (const workspace of ["home-owner", "home-member", "home-long"]) {
await page.goto(`${url}/w/${workspace}`); await page.goto(`${url}/w/${workspace}`);
await ready(page); await ready(page);
+1 -1
View File
@@ -11,7 +11,7 @@
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts" "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/console_pending_shell_test.ts"
}, },
"fmt": { "fmt": {
"lineWidth": 100, "lineWidth": 100,
+137
View File
@@ -0,0 +1,137 @@
{
"schemaVersion": 1,
"id": "console-pending",
"title": "Console pending inputs and notifications",
"baseUrl": "http://127.0.0.1:15283",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{
"id": "fixture",
"label": "Retained Console fixture",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "both",
"label": "both",
"path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=both",
"goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.",
"dataState": "Synthetic retained Console: both; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.",
"ready": {
"kind": "selector",
"selector": ".task-mini-row"
},
"capturePoints": [
{
"id": "initial",
"label": "Pending input layout"
}
]
},
{
"id": "queue",
"label": "queue",
"path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=queue",
"goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.",
"dataState": "Synthetic retained Console: queue; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.",
"ready": {
"kind": "selector",
"selector": ".task-mini-row"
},
"capturePoints": [
{
"id": "initial",
"label": "Pending input layout"
}
]
},
{
"id": "notifications",
"label": "notifications",
"path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=notifications",
"goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.",
"dataState": "Synthetic retained Console: notifications; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.",
"ready": {
"kind": "selector",
"selector": ".task-mini-row"
},
"capturePoints": [
{
"id": "initial",
"label": "Pending input layout"
}
]
},
{
"id": "legacy",
"label": "legacy",
"path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=legacy",
"goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.",
"dataState": "Synthetic retained Console: legacy; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.",
"ready": {
"kind": "selector",
"selector": ".task-mini-row"
},
"capturePoints": [
{
"id": "initial",
"label": "Pending input layout"
}
]
},
{
"id": "empty",
"label": "empty",
"path": "/w/console-history-review/workers/W-900-console-fixture/console?pending=empty",
"goal": "Only populated scopes appear; headings and previews match Tasks typography and row height.",
"dataState": "Synthetic retained Console: empty; two long/short previews when present; one task. Cancellation is unavailable on retained sessions.",
"ready": {
"kind": "selector",
"selector": ".task-mini-row"
},
"capturePoints": [
{
"id": "initial",
"label": "Pending input layout"
}
]
}
],
"processes": [
{
"id": "console-fixture",
"command": "deno",
"cwd": "..",
"args": [
"run",
"--allow-net",
"--allow-read",
"browser-tests/console_history_fixture_server.ts",
"15283",
"../../web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15283/health"
}
]
}
+12 -11
View File
@@ -2,8 +2,13 @@
SvelteKit static SPA for the Yoi workspace control plane. SvelteKit static SPA for the Yoi workspace control plane.
The frontend is intentionally static. Workspace authority, validation, and API The frontend is intentionally static. Workspace authority, validation, and API behavior live in the Rust `yoi-server` backend.
behavior live in the Rust `yoi-server` backend.
## Toolchain
Dependencies are pinned in `deno.json` and `package.json`, with resolutions in `deno.lock`. Run `deno install --frozen` to reproduce the installed versions. The current toolchain is SvelteKit 3, Svelte 5, Vite 8, and Vitest 5. TypeScript is kept on 6.0.3 because SvelteKit 3.0.0 and svelte-check 4.7.6 do not yet declare support for TypeScript 7.
SvelteKit and adapter settings live in `vite.config.ts`. Internal library imports use the `#lib/*` package subpath mapping with explicit file extensions, shared by Vite, TypeScript, and Deno. `tsconfig.json` extends `$app/tsconfig`; generated WASM JavaScript is consumed through its generated `.d.ts`, not checked as handwritten JavaScript. Deno-only tests under `test/` are type-checked by `deno task test`.
## Development ## Development
@@ -23,9 +28,7 @@ cd web/workspace
deno task dev deno task dev
``` ```
The Vite dev server proxies `/api/*` to `http://127.0.0.1:8787`, so frontend hot The Vite dev server proxies `/api/*` to `http://127.0.0.1:8787`, so frontend hot reload works while the Rust backend serves the workspace API. Open the Vite URL printed by `deno task dev`.
reload works while the Rust backend serves the workspace API. Open the Vite URL
printed by `deno task dev`.
If you want to run the backend from the repository root instead: If you want to run the backend from the repository root instead:
@@ -33,10 +36,7 @@ If you want to run the backend from the repository root instead:
cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787 cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787
``` ```
The backend reads Workspace records from the Yoi server DB at The backend reads Workspace records from the Yoi server DB at `<data_dir>/server/server.db`. Run `cargo run -p yoi-workspace-server --bin yoi-server -- init --workspace .` first when the server DB has not been initialized.
`<data_dir>/server/server.db`. Run
`cargo run -p yoi-workspace-server --bin yoi-server -- init --workspace .` first when the server
DB has not been initialized.
## Static build ## Static build
@@ -46,13 +46,14 @@ Build the SPA:
deno task build deno task build
``` ```
Static asset packaging is a deployment concern. Local development normally uses Static asset packaging is a deployment concern. Local development normally uses the Vite dev server proxy plus the Rust backend command above.
the Vite dev server proxy plus the Rust backend command above.
## Checks ## Checks
```bash ```bash
deno task check deno task check
deno task test
deno task test:component
deno task build deno task build
``` ```
+23 -24
View File
@@ -3,40 +3,39 @@
"nodeModulesDir": "auto", "nodeModulesDir": "auto",
"tasks": { "tasks": {
"install": "deno install", "install": "deno install",
"dev": "deno run -A npm:vite@7.2.7 dev", "dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "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", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.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/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.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/route-ownership.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-workdir-meta.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/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.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/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.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/route-ownership.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-workdir-meta.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/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"build": "deno run -A npm:vite@7.2.7 build", "build": "deno run -A npm:vite@8.3.2 build",
"preview": "deno run -A npm:vite@7.2.7 preview", "preview": "deno run -A npm:vite@8.3.2 preview",
"generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types", "generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types",
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check", "check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check",
"generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types", "generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types",
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check" "check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check"
}, },
"imports": { "imports": {
"$lib/": "./src/lib/", "@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@4.0.0",
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9", "@sveltejs/kit": "npm:@sveltejs/kit@3.0.0",
"@sveltejs/kit": "npm:@sveltejs/kit@2.49.4", "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@7.3.1",
"@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", "@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.3",
"@codemirror/autocomplete": "npm:@codemirror/autocomplete@6.20.0", "@codemirror/commands": "npm:@codemirror/commands@6.11.1",
"@codemirror/commands": "npm:@codemirror/commands@6.9.0",
"@codemirror/language": "npm:@codemirror/language@6.12.4", "@codemirror/language": "npm:@codemirror/language@6.12.4",
"@codemirror/state": "npm:@codemirror/state@6.7.1", "@codemirror/state": "npm:@codemirror/state@6.7.6",
"@codemirror/view": "npm:@codemirror/view@6.43.8", "@codemirror/view": "npm:@codemirror/view@6.43.13",
"@lezer/common": "npm:@lezer/common@1.5.2", "@lezer/common": "npm:@lezer/common@1.5.3",
"@lezer/highlight": "npm:@lezer/highlight@1.2.3", "@lezer/highlight": "npm:@lezer/highlight@1.2.5",
"decodal-codemirror": "npm:decodal-codemirror@0.3.0", "decodal-codemirror": "npm:decodal-codemirror@0.3.1",
"clsx": "npm:clsx@2.1.1", "clsx": "npm:clsx@2.1.1",
"cookie": "npm:cookie@0.6.0", "cookie": "npm:cookie@2.0.1",
"devalue": "npm:devalue@5.6.4", "devalue": "npm:devalue@6.0.2",
"set-cookie-parser": "npm:set-cookie-parser@2.7.2", "set-cookie-parser": "npm:set-cookie-parser@3.1.2",
"shiki": "npm:shiki@4.4.2", "shiki": "npm:shiki@4.5.0",
"svelte": "npm:svelte@5.45.6", "svelte": "npm:svelte@5.57.1",
"svelte-check": "npm:svelte-check@4.3.4", "svelte-check": "npm:svelte-check@4.7.6",
"typescript": "npm:typescript@5.9.3", "typescript": "npm:typescript@6.0.3",
"vite": "npm:vite@7.2.7" "vite": "npm:vite@8.3.2"
}, },
"fmt": { "fmt": {
"proseWrap": "never" "proseWrap": "never"
+336 -603
View File
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -5,14 +5,17 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@fontsource/ibm-plex-mono": "5.3.0", "@fontsource/ibm-plex-mono": "5.3.0",
"@humanspeak/svelte-markdown": "1.9.1", "@humanspeak/svelte-markdown": "2.0.0",
"gen-interface-jp": "0.8.0", "gen-interface-jp": "0.8.0",
"shiki": "4.4.2", "shiki": "4.5.0",
"yaml": "2.9.0" "yaml": "2.9.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/svelte": "5.2.8", "@testing-library/svelte": "5.4.2",
"happy-dom": "20.12.0", "happy-dom": "20.14.5",
"vitest": "3.2.4" "vitest": "5.0.3"
},
"imports": {
"#lib/*": "./src/lib/*"
} }
} }
@@ -7,7 +7,7 @@ import {
workspaceSkillCatalogPath, workspaceSkillCatalogPath,
workspaceSkillDetailPath, workspaceSkillDetailPath,
} from "./http.ts"; } from "./http.ts";
import { SKILL_API_LIMITS } from "$lib/generated/skill-api.ts"; import { SKILL_API_LIMITS } from "#lib/generated/skill-api.ts";
declare const Deno: { declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void; test(name: string, fn: () => Promise<void> | void): void;
+3 -3
View File
@@ -1,13 +1,13 @@
import { SKILL_API_LIMITS } from "$lib/generated/skill-api.ts"; import { SKILL_API_LIMITS } from "#lib/generated/skill-api.ts";
import type { import type {
SkillCatalogResponse, SkillCatalogResponse,
SkillDetailResponse, SkillDetailResponse,
} from "$lib/generated/skill-api.ts"; } from "#lib/generated/skill-api.ts";
import { import {
parseSkillCatalogResponse, parseSkillCatalogResponse,
parseSkillDetailResponse, parseSkillDetailResponse,
SkillApiContractError, SkillApiContractError,
} from "$lib/workspace/skills/api.ts"; } from "#lib/workspace/skills/api.ts";
export type ApiResult<T> = { export type ApiResult<T> = {
data: T | null; data: T | null;
@@ -2,7 +2,7 @@ import type {
MergeRequestDetailResponse, MergeRequestDetailResponse,
MergeRequestListResponse, MergeRequestListResponse,
MergeRequestThreadEvent, MergeRequestThreadEvent,
} from "$lib/generated/ticket-api"; } from "#lib/generated/ticket-api.ts";
import { workspaceApiPath } from "./http"; import { workspaceApiPath } from "./http";
export type MergeRequestDetail = MergeRequestDetailResponse; export type MergeRequestDetail = MergeRequestDetailResponse;
@@ -3,19 +3,19 @@ import type {
CreateWorkspaceRepositoryResponse, CreateWorkspaceRepositoryResponse,
RepositoryDetailResponse, RepositoryDetailResponse,
RepositoryListResponse, RepositoryListResponse,
} from "$lib/generated/repository-api.ts"; } from "#lib/generated/repository-api.ts";
import { import {
type ApiResult, type ApiResult,
loadJson, loadJson,
readBoundedJson, readBoundedJson,
workspaceApiPath, workspaceApiPath,
} from "$lib/workspace/api/http.ts"; } from "#lib/workspace/api/http.ts";
import { import {
parseCreateWorkspaceRepositoryResponse, parseCreateWorkspaceRepositoryResponse,
parseRepositoryApiError, parseRepositoryApiError,
parseRepositoryDetailResponse, parseRepositoryDetailResponse,
parseRepositoryListResponse, parseRepositoryListResponse,
} from "$lib/workspace/api/workspace-model.ts"; } from "#lib/workspace/api/workspace-model.ts";
export const REPOSITORY_API_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; export const REPOSITORY_API_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
@@ -3,7 +3,7 @@ import type {
RuntimeConnectionTestFailureKind, RuntimeConnectionTestFailureKind,
RuntimeConnectionTestResponse, RuntimeConnectionTestResponse,
RuntimeVerificationEvidenceSummary, RuntimeVerificationEvidenceSummary,
} from "$lib/generated/runtime-api"; } from "#lib/generated/runtime-api.ts";
const RESPONSE_KEYS = [ const RESPONSE_KEYS = [
"workspace_id", "workspace_id",
@@ -24,7 +24,7 @@ import type {
WorkspaceRuntimeDetail, WorkspaceRuntimeDetail,
WorkspaceRuntimeListResponse, WorkspaceRuntimeListResponse,
WorkspaceRuntimeResource, WorkspaceRuntimeResource,
} from "$lib/generated/runtime-api.ts"; } from "#lib/generated/runtime-api.ts";
import { workspaceApiPath } from "./http.ts"; import { workspaceApiPath } from "./http.ts";
export type WorkspaceRuntimeList = Omit<WorkspaceRuntimeListResponse, "diagnostics"> & { export type WorkspaceRuntimeList = Omit<WorkspaceRuntimeListResponse, "diagnostics"> & {
@@ -11,7 +11,7 @@ import type {
RuntimeWorkerLifecycleResult, RuntimeWorkerLifecycleResult,
WorkerOperationState, WorkerOperationState,
WorkerRetentionResponse, WorkerRetentionResponse,
} from "$lib/generated/runtime-api"; } from "#lib/generated/runtime-api.ts";
const MAX_STRING_BYTES = 4_096; const MAX_STRING_BYTES = 4_096;
const MAX_CANDIDATES = 1_000; const MAX_CANDIDATES = 1_000;
@@ -38,7 +38,7 @@ import type {
TicketRoleAssignmentRecord, TicketRoleAssignmentRecord,
TicketRoleAssignmentSummary, TicketRoleAssignmentSummary,
TicketTarget, TicketTarget,
} from "$lib/generated/ticket-api.ts"; } from "#lib/generated/ticket-api.ts";
export const TICKET_BROWSER_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; export const TICKET_BROWSER_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
export const TICKET_BROWSER_API_LOAD_POLICY = { export const TICKET_BROWSER_API_LOAD_POLICY = {
@@ -21,12 +21,12 @@ import type {
WorkerWorkdirAttachmentSummary, WorkerWorkdirAttachmentSummary,
WorkerWorkspaceSummary, WorkerWorkspaceSummary,
WorkingDirectoryRepositoryOption, WorkingDirectoryRepositoryOption,
} from "$lib/generated/worker-launch-api"; } from "#lib/generated/worker-launch-api.ts";
import type { Segment } from "$lib/generated/protocol"; import type { Segment } from "#lib/generated/protocol.ts";
import { import {
parseExternalWorkdirPermissions, parseExternalWorkdirPermissions,
parseWorkingDirectorySummary, parseWorkingDirectorySummary,
} from "$lib/workspace/api/workdirs"; } from "#lib/workspace/api/workdirs.ts";
const DIAGNOSTIC_SEVERITIES = new Set<DiagnosticSeverity>([ const DIAGNOSTIC_SEVERITIES = new Set<DiagnosticSeverity>([
"info", "info",
@@ -4,8 +4,8 @@ import {
type RepositorySummary, type RepositorySummary,
type WorkspaceCreateResponse, type WorkspaceCreateResponse,
type WorkspaceSummary, type WorkspaceSummary,
} from "$lib/workspace/api/workspace-model"; } from "#lib/workspace/api/workspace-model.ts";
import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories.ts"; import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
export type WorkspaceCatalogRecord = WorkspaceSummary; export type WorkspaceCatalogRecord = WorkspaceSummary;
export type WorkspaceCatalogItem = WorkspaceCatalogRecord & { export type WorkspaceCatalogItem = WorkspaceCatalogRecord & {
@@ -1,4 +1,4 @@
import type { ApiResult } from "$lib/workspace/api/http"; import type { ApiResult } from "#lib/workspace/api/http.ts";
import type { import type {
WorkspaceAuthConfig, WorkspaceAuthConfig,
WorkspaceCatalogListResponse, WorkspaceCatalogListResponse,
@@ -15,7 +15,7 @@ import type {
WorkspaceRepositoryRecord, WorkspaceRepositoryRecord,
WorkspaceResponse, WorkspaceResponse,
WorkspaceSummary, WorkspaceSummary,
} from "$lib/generated/legacy-server-api.ts"; } from "#lib/generated/legacy-server-api.ts";
import type { import type {
CreateWorkspaceRepositoryResponse, CreateWorkspaceRepositoryResponse,
Diagnostic, Diagnostic,
@@ -35,7 +35,7 @@ import type {
RepositorySshConnectionTrustState, RepositorySshConnectionTrustState,
RepositorySshHostKeyCandidate, RepositorySshHostKeyCandidate,
RepositorySummary, RepositorySummary,
} from "$lib/generated/repository-api.ts"; } from "#lib/generated/repository-api.ts";
export type { export type {
WorkspaceCatalogListResponse, WorkspaceCatalogListResponse,
@@ -45,7 +45,7 @@ export type {
WorkspacePermissionSummary, WorkspacePermissionSummary,
WorkspaceResponse, WorkspaceResponse,
WorkspaceSummary, WorkspaceSummary,
} from "$lib/generated/legacy-server-api.ts"; } from "#lib/generated/legacy-server-api.ts";
export type { export type {
CreateWorkspaceRepositoryRequest, CreateWorkspaceRepositoryRequest,
CreateWorkspaceRepositoryResponse, CreateWorkspaceRepositoryResponse,
@@ -63,7 +63,7 @@ export type {
RepositorySshConnectionProbeResponse, RepositorySshConnectionProbeResponse,
RepositorySshHostKeyCandidate, RepositorySshHostKeyCandidate,
RepositorySummary, RepositorySummary,
} from "$lib/generated/repository-api.ts"; } from "#lib/generated/repository-api.ts";
type JsonObject = Record<string, unknown>; type JsonObject = Record<string, unknown>;
+2 -2
View File
@@ -4,7 +4,7 @@ import type {
PasskeyLoginOptionsRequest, PasskeyLoginOptionsRequest,
PasskeyRegistrationCompleteRequest, PasskeyRegistrationCompleteRequest,
PasskeyRegistrationOptionsRequest, PasskeyRegistrationOptionsRequest,
} from "$lib/generated/auth-api.ts"; } from "#lib/generated/auth-api.ts";
import { import {
authenticationCredentialToJson, authenticationCredentialToJson,
type AuthUser, type AuthUser,
@@ -17,7 +17,7 @@ import {
prepareRegistrationOptions, prepareRegistrationOptions,
registrationCredentialToJson, registrationCredentialToJson,
type WhoamiResponse, type WhoamiResponse,
} from "$lib/workspace/auth/model"; } from "#lib/workspace/auth/model.ts";
const MAX_AUTH_RESPONSE_BYTES = 256 * 1024; const MAX_AUTH_RESPONSE_BYTES = 256 * 1024;
@@ -12,7 +12,7 @@ import type {
PasskeyRegistrationOptionsResponse, PasskeyRegistrationOptionsResponse,
RequestActor, RequestActor,
WhoamiResponse, WhoamiResponse,
} from "$lib/generated/auth-api.ts"; } from "#lib/generated/auth-api.ts";
export type AuthUser = AuthenticatedUser; export type AuthUser = AuthenticatedUser;
export type PasskeyUserResponse = AuthUserResponse; export type PasskeyUserResponse = AuthUserResponse;
@@ -9,7 +9,7 @@ import type {
DiagnosticSeverity, DiagnosticSeverity,
WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryItem,
WorkspaceWorkerSubject, WorkspaceWorkerSubject,
} from "$lib/generated/companion-api"; } from "#lib/generated/companion-api.ts";
const MAX_TRANSCRIPT_ITEMS = 200; const MAX_TRANSCRIPT_ITEMS = 200;
const MAX_DIAGNOSTICS = 100; const MAX_DIAGNOSTICS = 100;
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import DecodalSourceEditor from "$lib/workspace/settings/DecodalSourceEditor.svelte"; import DecodalSourceEditor from "#lib/workspace/settings/DecodalSourceEditor.svelte";
import { import {
commitConfigTree, commitConfigTree,
fetchConfigTree, fetchConfigTree,
@@ -10,7 +10,7 @@ export type {
ToolchainContract, ToolchainContract,
WorkspaceConfigSchemaBundle, WorkspaceConfigSchemaBundle,
WorkspaceConfigTreeResponse, WorkspaceConfigTreeResponse,
} from "$lib/generated/legacy-server-api.ts"; } from "#lib/generated/legacy-server-api.ts";
// Browser-local Decodal/WASM projections remain owned by config-source. // Browser-local Decodal/WASM projections remain owned by config-source.
export type { ConfigDiagnostic } from "./generated/types/ConfigDiagnostic.ts"; export type { ConfigDiagnostic } from "./generated/types/ConfigDiagnostic.ts";
@@ -22,12 +22,12 @@
isolateHistory, isolateHistory,
} from "@codemirror/commands"; } from "@codemirror/commands";
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { Segment } from "$lib/generated/protocol.ts"; import type { Segment } from "#lib/generated/protocol.ts";
import { import {
handleComposerImagePaste, handleComposerImagePaste,
measureComposerPaste, measureComposerPaste,
type ComposerPasteMeasurement, type ComposerPasteMeasurement,
} from "$lib/workspace/console/composer-paste.ts"; } from "#lib/workspace/console/composer-paste.ts";
import { import {
composerDeletionRange, composerDeletionRange,
composerPasteAtoms, composerPasteAtoms,
@@ -37,7 +37,7 @@
type ComposerDraftSnapshot, type ComposerDraftSnapshot,
type ComposerPaste, type ComposerPaste,
type ComposerTextPaste, type ComposerTextPaste,
} from "$lib/workspace/console/composer-draft.ts"; } from "#lib/workspace/console/composer-draft.ts";
import { import {
ComposerHistory, ComposerHistory,
loadComposerHistory, loadComposerHistory,
@@ -45,9 +45,9 @@
shouldBrowseComposerHistory, shouldBrowseComposerHistory,
type ComposerHistoryDirection, type ComposerHistoryDirection,
type ComposerHistoryEntry, type ComposerHistoryEntry,
} from "$lib/workspace/console/composer-history.ts"; } from "#lib/workspace/console/composer-history.ts";
import { applyCompletion, completionSelection, completionTokenAt, localCommandCompletions, type ComposerCompletionEntry, type ComposerCompletionToken } from "./composer-completion"; import { applyCompletion, completionSelection, completionTokenAt, localCommandCompletions, type ComposerCompletionEntry, type ComposerCompletionToken } from "./composer-completion";
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts"; import { shouldSubmitChatKey } from "#lib/workspace/console/chat-submit.ts";
interface Props { interface Props {
disabled?: boolean; disabled?: boolean;
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import AnsiText from '$lib/workspace/console/AnsiText.svelte'; import AnsiText from '#lib/workspace/console/AnsiText.svelte';
import RichMarkdown from '$lib/workspace/console/RichMarkdown.svelte'; import RichMarkdown from '#lib/workspace/console/RichMarkdown.svelte';
import type { ConsoleLine } from '$lib/workspace/console/model'; import type { ConsoleLine } from '#lib/workspace/console/model.ts';
type Props = { type Props = {
item: ConsoleLine; item: ConsoleLine;
@@ -22,7 +22,7 @@
<script lang="ts"> <script lang="ts">
import type { Renderers } from "@humanspeak/svelte-markdown"; import type { Renderers } from "@humanspeak/svelte-markdown";
import { ShikiCode } from "@humanspeak/svelte-markdown/extensions/shiki"; import { ShikiCode } from "@humanspeak/svelte-markdown/extensions/shiki";
import SafeMarkdown from "$lib/workspace/markdown/SafeMarkdown.svelte"; import SafeMarkdown from "#lib/workspace/markdown/SafeMarkdown.svelte";
type Props = { type Props = {
text: string; text: string;
@@ -2,7 +2,7 @@
import type { import type {
InFlightCompaction, InFlightCompaction,
WorkerStateSnapshot, WorkerStateSnapshot,
} from "$lib/generated/protocol.ts"; } from "#lib/generated/protocol.ts";
import Spinner from "./Spinner.svelte"; import Spinner from "./Spinner.svelte";
import { import {
formatRunElapsed, formatRunElapsed,
@@ -1,8 +1,8 @@
import type { import type {
AttachmentUploadGrantResponse, AttachmentUploadGrantResponse,
WorkerFileUploadResponse, WorkerFileUploadResponse,
} from "$lib/generated/runtime-api.ts"; } from "#lib/generated/runtime-api.ts";
import type { UploadedFileRef } from "$lib/generated/protocol.ts"; import type { UploadedFileRef } from "#lib/generated/protocol.ts";
export const MAX_UPLOADED_FILE_BYTES = 10 * 1024 * 1024; export const MAX_UPLOADED_FILE_BYTES = 10 * 1024 * 1024;
export const MAX_FILES_PER_SUBMISSION = 8; export const MAX_FILES_PER_SUBMISSION = 8;
@@ -1,4 +1,4 @@
import type { Segment } from "$lib/generated/protocol"; import type { Segment } from "#lib/generated/protocol.ts";
export type WorkerConsoleInputKind = export type WorkerConsoleInputKind =
| "user" | "user"
@@ -1,13 +1,13 @@
import type { Segment } from "$lib/generated/protocol.ts"; import type { Segment } from "#lib/generated/protocol.ts";
import { import {
composerDeletionRange, composerDeletionRange,
type ComposerPaste, type ComposerPaste,
composerPasteToken, composerPasteToken,
pasteChipLabel, pasteChipLabel,
snapshotComposerDraft, snapshotComposerDraft,
} from "$lib/workspace/console/composer-draft.ts"; } from "#lib/workspace/console/composer-draft.ts";
import { buildComposerSegmentsRequest } from "$lib/workspace/console/composer-command.ts"; import { buildComposerSegmentsRequest } from "#lib/workspace/console/composer-command.ts";
import { measureComposerPaste } from "$lib/workspace/console/composer-paste.ts"; import { measureComposerPaste } from "#lib/workspace/console/composer-paste.ts";
declare const Deno: { declare const Deno: {
test(name: string, fn: () => void): void; test(name: string, fn: () => void): void;
@@ -1,4 +1,4 @@
import type { Segment } from "$lib/generated/protocol.ts"; import type { Segment } from "#lib/generated/protocol.ts";
const PASTE_TOKEN_PREFIX = "\uFFF9"; const PASTE_TOKEN_PREFIX = "\uFFF9";
const PASTE_TOKEN_SUFFIX = "\uFFFB"; const PASTE_TOKEN_SUFFIX = "\uFFFB";
@@ -1,4 +1,4 @@
import type { Segment } from "$lib/generated/protocol"; import type { Segment } from "#lib/generated/protocol.ts";
export const COMPOSER_HISTORY_LIMIT = 30; export const COMPOSER_HISTORY_LIMIT = 30;
const COMPOSER_HISTORY_VERSION = 1; const COMPOSER_HISTORY_VERSION = 1;
@@ -2,7 +2,7 @@ import type {
SessionConversationTurn, SessionConversationTurn,
SessionHistoryPage, SessionHistoryPage,
SessionSnapshotEntry, SessionSnapshotEntry,
} from "$lib/generated/protocol"; } from "#lib/generated/protocol.ts";
import { import {
applyConsoleHistoryPage, applyConsoleHistoryPage,
beginConsoleHistoryRequest, beginConsoleHistoryRequest,
@@ -3,7 +3,7 @@ import type {
SessionConversationTurn, SessionConversationTurn,
SessionHistoryPage, SessionHistoryPage,
SessionSnapshotEntry, SessionSnapshotEntry,
} from "$lib/generated/protocol"; } from "#lib/generated/protocol.ts";
export const CONSOLE_HISTORY_PAGE_TURNS = 5; export const CONSOLE_HISTORY_PAGE_TURNS = 5;
@@ -3,7 +3,7 @@ import type {
SessionSnapshotEntry, SessionSnapshotEntry,
WorkerStateSnapshot, WorkerStateSnapshot,
WorkerStatus, WorkerStatus,
} from "$lib/generated/protocol"; } from "#lib/generated/protocol.ts";
import { import {
type ConsoleEventInput, type ConsoleEventInput,
type ConsoleLine, type ConsoleLine,
@@ -338,6 +338,13 @@ Deno.test("durable compaction failure reconciles live alert and error in both vi
}, },
}, },
}, },
{
eventId: "controller-error",
event: {
event: "error",
data: { code: "internal", message: "summary unavailable" },
},
},
{ {
eventId: "failed", eventId: "failed",
event: transition("failed-entry", { event: transition("failed-entry", {
@@ -346,13 +353,6 @@ Deno.test("durable compaction failure reconciles live alert and error in both vi
failure: "compaction", failure: "compaction",
}), }),
}, },
{
eventId: "controller-error",
event: {
event: "error",
data: { code: "internal", message: "summary unavailable" },
},
},
]); ]);
const errors = projection.lines.filter((line) => line.kind === "error"); const errors = projection.lines.filter((line) => line.kind === "error");
@@ -362,6 +362,47 @@ Deno.test("durable compaction failure reconciles live alert and error in both vi
assertEquals(errors[0].entryId, "failed-entry"); assertEquals(errors[0].entryId, "failed-entry");
assertEquals(projectConsoleLines(projection.lines, "normal"), errors); assertEquals(projectConsoleLines(projection.lines, "normal"), errors);
assertEquals(projectConsoleLines(projection.lines, "overview"), errors); assertEquals(projectConsoleLines(projection.lines, "overview"), errors);
const history = projectSessionHistoryEntries([{
entry_id: "failed-entry",
timestamp: 10,
provenance: "legacy_unknown",
kind: "run_error",
message,
failure: "compaction",
}], null);
const merged = mergeCommittedHistoryLines(history, projection.lines);
assertEquals(merged.filter((line) => line.kind === "error").length, 1);
assertEquals(merged.find((line) => line.kind === "error")?.entryId, "failed-entry");
});
Deno.test("reapplying one committed run_error entry is idempotent in both views", () => {
const committed = (eventId: string): ConsoleEventInput => ({
eventId,
event: {
event: "session_entry_committed",
data: {
entry: {
entry_id: "same-run-error",
timestamp: 10,
provenance: "legacy_unknown",
kind: "run_error",
message: "mid-run compaction failed: summary unavailable",
failure: "compaction",
},
},
},
});
const projection = projectConsole([
committed("live-delivery"),
committed("snapshot-replay"),
]);
const errors = projection.lines.filter((line) => line.kind === "error");
assertEquals(errors.length, 1);
assertEquals(errors[0].entryId, "same-run-error");
assertEquals(projectConsoleLines(projection.lines, "normal").length, 1);
assertEquals(projectConsoleLines(projection.lines, "overview").length, 1);
}); });
Deno.test("snapshot restores compaction terminal without reviving transition progress", () => { Deno.test("snapshot restores compaction terminal without reviving transition progress", () => {
@@ -3232,6 +3273,26 @@ Deno.test("committed history reconciles overlapping Read groups before counting
} }
}); });
Deno.test("committed history keeps identical run failures with distinct entry identities", () => {
const message = "the same failure happened again";
const runError = (entryId: string): SessionSnapshotEntry => ({
kind: "run_error",
entry_id: entryId,
timestamp: 1,
provenance: "legacy_unknown",
message,
failure: "compaction",
});
const retained = projectSessionHistoryEntries([runError("run-error-1")], null);
const current = projectSessionHistoryEntries([runError("run-error-2")], null);
const merged = mergeCommittedHistoryLines(retained, current);
assertEquals(merged.map((line) => line.entryId), ["run-error-1", "run-error-2"]);
assertEquals(merged.map((line) => line.body), [message, message]);
assertEquals(projectConsoleLines(merged, "normal").length, 2);
assertEquals(projectConsoleLines(merged, "overview").length, 2);
});
Deno.test("committed history matches tool calls across live and durable result identities", () => { Deno.test("committed history matches tool calls across live and durable result identities", () => {
const retained: ConsoleLine = { const retained: ConsoleLine = {
...consoleLine("tool-call-a", "tool"), entryId: "call-entry-a", ...consoleLine("tool-call-a", "tool"), entryId: "call-entry-a",
@@ -14,7 +14,7 @@ import type {
WorkerState, WorkerState,
WorkerStateSnapshot, WorkerStateSnapshot,
WorkerStatus, WorkerStatus,
} from "$lib/generated/protocol"; } from "#lib/generated/protocol.ts";
import { stringify as stringifyYaml } from "yaml"; import { stringify as stringifyYaml } from "yaml";
import { import {
applyRunActivityEvent, applyRunActivityEvent,
@@ -1287,15 +1287,19 @@ function hasDurableRunFailure(
function appendDurableRunFailure( function appendDurableRunFailure(
projection: ConsoleProjection, projection: ConsoleProjection,
eventId: string, eventId: string,
entryId: string | undefined,
message: string, message: string,
failure: string | undefined, failure: string | undefined,
): void { ): void {
if (
entryId && projection.lines.some((line) => line.entryId === entryId)
) return;
projection.lines = projection.lines.filter((line) => { projection.lines = projection.lines.filter((line) => {
if (isDurableRunFailure(line)) return true; if (isDurableRunFailure(line)) return true;
return !(line.kind === "error" && runFailureMessagesMatch(line.body, message)); return !(line.kind === "error" && runFailureMessagesMatch(line.body, message));
}); });
projection.lines.push( projection.lines.push({
line( ...line(
eventId, eventId,
"error", "error",
failure === "compaction" ? "Compaction failed" : "Run error", failure === "compaction" ? "Compaction failed" : "Run error",
@@ -1304,7 +1308,8 @@ function appendDurableRunFailure(
false, false,
true, true,
), ),
); entryId,
});
} }
function appendAlertLine( function appendAlertLine(
@@ -2198,6 +2203,7 @@ function applySessionEntry(
appendDurableRunFailure( appendDurableRunFailure(
projection, projection,
eventId, eventId,
stringField(value, "entry_id"),
stringField(value, "message") ?? "Worker run failed.", stringField(value, "message") ?? "Worker run failed.",
stringField(value, "failure"), stringField(value, "failure"),
); );
@@ -2208,7 +2214,10 @@ function applySessionEntry(
const entryId = stringField(value, "entry_id"); const entryId = stringField(value, "entry_id");
if (entryId) { if (entryId) {
for ( for (
let index = Math.max(0, lineCountBefore - 1); let index = Math.max(
0,
Math.min(lineCountBefore, projection.lines.length - 1),
);
index < projection.lines.length; index < projection.lines.length;
index += 1 index += 1
) { ) {
@@ -2,7 +2,7 @@ import type {
Event as ProtocolEvent, Event as ProtocolEvent,
InFlightCompaction, InFlightCompaction,
WorkerStateSnapshot, WorkerStateSnapshot,
} from "$lib/generated/protocol"; } from "#lib/generated/protocol.ts";
export type RunActivityStats = { export type RunActivityStats = {
startedAtMs: number | null; startedAtMs: number | null;
@@ -35,7 +35,7 @@ export function consoleTurns(lines: readonly ConsoleLine[]): ConsoleTurn[] {
return turns; return turns;
} }
import type { SessionConversationTurn } from "$lib/generated/protocol"; import type { SessionConversationTurn } from "#lib/generated/protocol.ts";
import { type ConsoleTurnPreview, conversationTurnPreviews } from "./history"; import { type ConsoleTurnPreview, conversationTurnPreviews } from "./history";
export type TurnNavigationItem = ConsoleTurnPreview; export type TurnNavigationItem = ConsoleTurnPreview;
@@ -69,11 +69,11 @@ Deno.test("workspace feature css is owned outside app css", async () => {
"app.css should not own workspace, worker, or settings page implementation classes", "app.css should not own workspace, worker, or settings page implementation classes",
); );
assert( assert(
workspaceLayout.includes("$lib/workspace/styles/workspace-pages.css") && workspaceLayout.includes("#lib/workspace/styles/workspace-pages.css") &&
workspaceLayout.includes("$lib/workspace/styles/workers.css") && workspaceLayout.includes("#lib/workspace/styles/workers.css") &&
settingsLayout.includes("$lib/workspace/styles/settings.css") && settingsLayout.includes("#lib/workspace/styles/settings.css") &&
accountPage.includes("$lib/workspace/styles/settings.css") && accountPage.includes("#lib/workspace/styles/settings.css") &&
deviceLoginPage.includes("$lib/workspace/styles/settings.css"), deviceLoginPage.includes("#lib/workspace/styles/settings.css"),
"feature-owned CSS should be imported by the layouts/pages that need it", "feature-owned CSS should be imported by the layouts/pages that need it",
); );
}); });
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/state"; import { page } from "$app/state";
import WorkspaceSwitcher from "$lib/workspace/sidebar/WorkspaceSwitcher.svelte"; import WorkspaceSwitcher from "#lib/workspace/sidebar/WorkspaceSwitcher.svelte";
import type { WorkspaceResponse } from "$lib/workspace/sidebar/types"; import type { WorkspaceResponse } from "#lib/workspace/sidebar/types.ts";
import { buildWorkspaceBreadcrumbs } from "./breadcrumb-model"; import { buildWorkspaceBreadcrumbs } from "./breadcrumb-model";
type Props = { type Props = {
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import BevelLine from '$lib/workspace/ui/BevelLine.svelte'; import BevelLine from '#lib/workspace/ui/BevelLine.svelte';
import type { DashboardFeed } from './dashboard'; import type { DashboardFeed } from './dashboard';
let { id, title, empty, result }: { id: string; title: string; empty: string; result: Promise<DashboardFeed> } = $props(); let { id, title, empty, result }: { id: string; title: string; empty: string; result: Promise<DashboardFeed> } = $props();
@@ -1,14 +1,14 @@
import type { TicketListItemSummary } from "$lib/generated/ticket-api"; import type { TicketListItemSummary } from "#lib/generated/ticket-api.ts";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { import {
parseMergeRequestListResponse, parseMergeRequestListResponse,
parseObjectiveListResponse, parseObjectiveListResponse,
parseTicketDetail, parseTicketDetail,
parseTicketListResponse, parseTicketListResponse,
TICKET_BROWSER_API_LOAD_POLICY, TICKET_BROWSER_API_LOAD_POLICY,
} from "$lib/workspace/api/ticket-browser"; } from "#lib/workspace/api/ticket-browser.ts";
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests"; import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
import { objectiveHref, ticketHref } from "$lib/workspace/resource-links"; import { objectiveHref, ticketHref } from "#lib/workspace/resource-links.ts";
export const HOME_LIMIT = 5; export const HOME_LIMIT = 5;
export type DashboardRow = { export type DashboardRow = {
@@ -4,7 +4,7 @@
type Renderers, type Renderers,
type SvelteMarkdownProps, type SvelteMarkdownProps,
} from "@humanspeak/svelte-markdown"; } from "@humanspeak/svelte-markdown";
import MarkdownLink from "$lib/workspace/markdown/MarkdownLink.svelte"; import MarkdownLink from "#lib/workspace/markdown/MarkdownLink.svelte";
type Props = { type Props = {
text: string; text: string;
@@ -1,4 +1,4 @@
import { MEMORY_API_LIMITS } from "$lib/generated/memory-api"; import { MEMORY_API_LIMITS } from "#lib/generated/memory-api.ts";
import type { import type {
Diagnostic, Diagnostic,
DiagnosticSeverity, DiagnosticSeverity,
@@ -12,7 +12,7 @@ import type {
MemoryStagingEvidence, MemoryStagingEvidence,
MemoryStagingListResponse, MemoryStagingListResponse,
MemoryStagingRecord, MemoryStagingRecord,
} from "$lib/generated/memory-api"; } from "#lib/generated/memory-api.ts";
const MAX_STAGING_ITEMS = MEMORY_API_LIMITS.maxCollectionItems; const MAX_STAGING_ITEMS = MEMORY_API_LIMITS.maxCollectionItems;
const MAX_EVIDENCE_PER_RECORD = MEMORY_API_LIMITS.maxCollectionItems; const MAX_EVIDENCE_PER_RECORD = MEMORY_API_LIMITS.maxCollectionItems;
@@ -1,15 +1,15 @@
import { browser } from '$app/environment'; import { browser } from '$app/env';
import type { import type {
EventSubscriptionSelector, EventSubscriptionSelector,
Method, Method,
SubscriptionFrame, SubscriptionFrame,
SubscriptionId, SubscriptionId,
} from '$lib/generated/protocol'; } from '#lib/generated/protocol.ts';
import { import {
decodeSubscriptionFrame, decodeSubscriptionFrame,
subscriptionFrameMatchesSelector, subscriptionFrameMatchesSelector,
} from '$lib/generated/protocol-validator'; } from '#lib/generated/protocol-validator.ts';
import { workspaceApiPath } from '$lib/workspace/api/http'; import { workspaceApiPath } from '#lib/workspace/api/http.ts';
type Listener = { type Listener = {
onFrame(frame: SubscriptionFrame): void; onFrame(frame: SubscriptionFrame): void;
@@ -1,10 +1,10 @@
import type { EventSubscriptionSelector, SubscriptionFrame } from '$lib/generated/protocol'; import type { EventSubscriptionSelector, SubscriptionFrame } from '#lib/generated/protocol.ts';
import { import {
decodeSubscriptionFrame, decodeSubscriptionFrame,
MAX_SUBSCRIPTION_STRING_BYTES, MAX_SUBSCRIPTION_STRING_BYTES,
RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES, RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES,
subscriptionFrameMatchesSelector, subscriptionFrameMatchesSelector,
} from '$lib/generated/protocol-validator'; } from '#lib/generated/protocol-validator.ts';
declare const Deno: { declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void; test(name: string, fn: () => void | Promise<void>): void;
@@ -1,4 +1,4 @@
import type { WorkspaceRuntimeResource } from "$lib/generated/runtime-api"; import type { WorkspaceRuntimeResource } from "#lib/generated/runtime-api.ts";
export type RepositorySshProbeSelection<T> = Readonly<{ export type RepositorySshProbeSelection<T> = Readonly<{
changed: boolean; changed: boolean;
@@ -5,7 +5,7 @@ import {
type WorkerSessionRequestIdentity, type WorkerSessionRequestIdentity,
workerSessionRequestInit, workerSessionRequestInit,
} from "./session-observation"; } from "./session-observation";
import type { SessionSnapshot } from "$lib/generated/protocol"; import type { SessionSnapshot } from "#lib/generated/protocol.ts";
declare const Deno: { declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void; test(name: string, fn: () => Promise<void> | void): void;
@@ -1,4 +1,4 @@
import type { SessionSnapshot } from "$lib/generated/protocol"; import type { SessionSnapshot } from "#lib/generated/protocol.ts";
export type WorkerSessionObservation = export type WorkerSessionObservation =
| { availability: "live_protocol" } | { availability: "live_protocol" }
@@ -14,11 +14,11 @@
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view'; import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view';
import { tags } from '@lezer/highlight'; import { tags } from '@lezer/highlight';
import { decodal } from 'decodal-codemirror'; import { decodal } from 'decodal-codemirror';
import { shouldStartCompletionAfterTyping } from '$lib/workspace/config-source/completion.ts'; import { shouldStartCompletionAfterTyping } from '#lib/workspace/config-source/completion.ts';
import { import {
fixedSchemaWrapperExtension, fixedSchemaWrapperExtension,
moveSelectionIntoFixedWrapper, moveSelectionIntoFixedWrapper,
} from '$lib/workspace/config-source/fixed-schema-wrapper.ts'; } from '#lib/workspace/config-source/fixed-schema-wrapper.ts';
let { let {
value = '', value = '',
@@ -4,13 +4,13 @@
import type { import type {
Diagnostic, WorkspaceMetadataSettingsResponse, WorkspaceSigningIdentityResponse, Diagnostic, WorkspaceMetadataSettingsResponse, WorkspaceSigningIdentityResponse,
WorkspaceDeletionPreflightResponse, WorkspaceDeletionOperationResponse, WorkspaceDeletionRequest, WorkspaceDeletionPreflightResponse, WorkspaceDeletionOperationResponse, WorkspaceDeletionRequest,
} from '$lib/generated/legacy-server-api'; } from '#lib/generated/legacy-server-api.ts';
import { workspaceApiPath } from '$lib/workspace/api/http'; import { workspaceApiPath } from '#lib/workspace/api/http.ts';
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer'; import { disposeWorkspaceMultiplexer } from '#lib/workspace/multiplexer.ts';
import { disposeWorkspaceWorkersStore } from '$lib/workspace/sidebar/worker-subscription'; import { disposeWorkspaceWorkersStore } from '#lib/workspace/sidebar/worker-subscription.ts';
import { fetchWorkspaceMetadata, updateWorkspaceMetadata, fetchWorkspaceSigningIdentity, provisionWorkspaceSigningIdentity } from './profile-api'; import { fetchWorkspaceMetadata, updateWorkspaceMetadata, fetchWorkspaceSigningIdentity, provisionWorkspaceSigningIdentity } from './profile-api';
import { preflightWorkspaceDeletion, startWorkspaceDeletion, getWorkspaceDeletion } from './workspace-deletion-api'; import { preflightWorkspaceDeletion, startWorkspaceDeletion, getWorkspaceDeletion } from './workspace-deletion-api';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte'; import BevelLine from '#lib/workspace/ui/BevelLine.svelte';
// The route keys this component by Workspace and owner permission. Requests from // The route keys this component by Workspace and owner permission. Requests from
// a disposed scope must not publish state, focus, or navigation into a new one. // a disposed scope must not publish state, focus, or navigation into a new one.
@@ -1,4 +1,4 @@
import type { Diagnostic as ServerApiDiagnostic } from "$lib/generated/legacy-server-api"; import type { Diagnostic as ServerApiDiagnostic } from "#lib/generated/legacy-server-api.ts";
export type Diagnostic = ServerApiDiagnostic; export type Diagnostic = ServerApiDiagnostic;
@@ -12,8 +12,8 @@ import type {
WorkspaceSigningIdentityPublic, WorkspaceSigningIdentityPublic,
WorkspaceSigningIdentityResponse, WorkspaceSigningIdentityResponse,
WorkspaceSigningIdentityState, WorkspaceSigningIdentityState,
} from "$lib/generated/legacy-server-api"; } from "#lib/generated/legacy-server-api.ts";
import { readBoundedJson } from "$lib/workspace/api/http.ts"; import { readBoundedJson } from "#lib/workspace/api/http.ts";
const MAX_PROFILE_RESPONSE_BYTES = 4 * 1024 * 1024; const MAX_PROFILE_RESPONSE_BYTES = 4 * 1024 * 1024;
const MAX_PROFILE_ERROR_BYTES = 4 * 1024; const MAX_PROFILE_ERROR_BYTES = 4 * 1024;
@@ -1,4 +1,4 @@
import type { ExternalWorkdirPermissions } from "$lib/generated/workdir-api"; import type { ExternalWorkdirPermissions } from "#lib/generated/workdir-api.ts";
/** Render an effective attachment/session capability intersection without widening it. */ /** Render an effective attachment/session capability intersection without widening it. */
export function formatWorkdirPermissions( export function formatWorkdirPermissions(
@@ -2,12 +2,12 @@ import type {
WorkspaceDeletionOperationResponse, WorkspaceDeletionOperationResponse,
WorkspaceDeletionPreflightResponse, WorkspaceDeletionPreflightResponse,
WorkspaceDeletionRequest, WorkspaceDeletionRequest,
} from "$lib/generated/legacy-server-api"; } from "#lib/generated/legacy-server-api.ts";
import { loadJson } from "$lib/workspace/api/http"; import { loadJson } from "#lib/workspace/api/http.ts";
import { import {
parseWorkspaceDeletionOperationResponse, parseWorkspaceDeletionOperationResponse,
parseWorkspaceDeletionPreflightResponse, parseWorkspaceDeletionPreflightResponse,
} from "$lib/workspace/api/workspace-model"; } from "#lib/workspace/api/workspace-model.ts";
const deletionResponsePolicy = { const deletionResponsePolicy = {
maxResponseBytes: 2 * 1024 * 1024, maxResponseBytes: 2 * 1024 * 1024,
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { WorkspaceCatalogRecord } from "$lib/workspace/api/workspace-catalog"; import type { WorkspaceCatalogRecord } from "#lib/workspace/api/workspace-catalog.ts";
type Props = { type Props = {
currentPath: string; currentPath: string;
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { workspaceRoute } from "$lib/workspace/api/http"; import { workspaceRoute } from "#lib/workspace/api/http.ts";
type Props = { type Props = {
currentPath?: string; currentPath?: string;
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests"; import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
type Props = { type Props = {
currentPath?: string; currentPath?: string;
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http'; import { workspaceRoute } from '#lib/workspace/api/http.ts';
type Props = { type Props = {
currentPath?: string; currentPath?: string;
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http'; import { workspaceRoute } from '#lib/workspace/api/http.ts';
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace/settings/model'; import { SETTINGS_SECTIONS, settingsSectionHref } from '#lib/workspace/settings/model.ts';
import type { SettingsSectionId } from '$lib/workspace/settings/model'; import type { SettingsSectionId } from '#lib/workspace/settings/model.ts';
import type { SidebarSnippet } from './context'; import type { SidebarSnippet } from './context';
let { let {
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, type Snippet } from 'svelte'; import { onDestroy, type Snippet } from 'svelte';
import Bevel from '$lib/workspace/ui/Bevel.svelte'; import Bevel from '#lib/workspace/ui/Bevel.svelte';
import SidebarToggleIcon from './SidebarToggleIcon.svelte'; import SidebarToggleIcon from './SidebarToggleIcon.svelte';
import './sidebar.css'; import './sidebar.css';
@@ -93,6 +93,7 @@
<div class="sidebar-frame__surface"> <div class="sidebar-frame__surface">
<div <div
class="sidebar-hover-region" class="sidebar-hover-region"
role="presentation"
onpointerenter={enter} onpointerenter={enter}
onpointerleave={leave} onpointerleave={leave}
onpointercancel={leave} onpointercancel={leave}
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http'; import { workspaceRoute } from '#lib/workspace/api/http.ts';
type Props = { type Props = {
currentPath?: string; currentPath?: string;
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import Spinner from '$lib/workspace/console/Spinner.svelte'; import Spinner from '#lib/workspace/console/Spinner.svelte';
import { workerConsoleHref } from '$lib/workspace/resource-links'; import { workerConsoleHref } from '#lib/workspace/resource-links.ts';
import { pushWorkspaceAlert } from '$lib/workspace/alerts/store'; import { pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
import { import {
canStopSidebarWorker, canStopSidebarWorker,
canDeleteSidebarWorker, canDeleteSidebarWorker,
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import './sidebar.css'; import './sidebar.css';
import { workspaceRoute } from '$lib/workspace/api/http'; import { workspaceRoute } from '#lib/workspace/api/http.ts';
import { ownsRoutePath } from './route-ownership'; import { ownsRoutePath } from './route-ownership';
import ObjectivesNavSection from './ObjectivesNavSection.svelte'; import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import MemoryNavSection from './MemoryNavSection.svelte'; import MemoryNavSection from './MemoryNavSection.svelte';
@@ -3,7 +3,7 @@
import { import {
listWorkspaces, listWorkspaces,
type WorkspaceCatalogRecord, type WorkspaceCatalogRecord,
} from "$lib/workspace/api/workspace-catalog"; } from "#lib/workspace/api/workspace-catalog.ts";
let { let {
currentWorkspaceId, currentWorkspaceId,
@@ -4,7 +4,7 @@ import type {
CleanupWorkerCandidate, CleanupWorkerCandidate,
RuntimeCleanupExecutionResponse, RuntimeCleanupExecutionResponse,
RuntimeCleanupPlanResponse, RuntimeCleanupPlanResponse,
} from "$lib/generated/runtime-api"; } from "#lib/generated/runtime-api.ts";
import type { import type {
BrowserCreateWorkerResponse as SharedBrowserCreateWorkerResponse, BrowserCreateWorkerResponse as SharedBrowserCreateWorkerResponse,
BrowserWorkerWorkingDirectorySelection BrowserWorkerWorkingDirectorySelection
@@ -17,7 +17,7 @@ import type {
WorkerSummary as SharedWorkerSummary, WorkerSummary as SharedWorkerSummary,
WorkerWorkdirAttachmentSummary as SharedWorkerWorkdirAttachmentSummary, WorkerWorkdirAttachmentSummary as SharedWorkerWorkdirAttachmentSummary,
WorkingDirectoryRepositoryOption as SharedWorkingDirectoryRepositoryOption, WorkingDirectoryRepositoryOption as SharedWorkingDirectoryRepositoryOption,
} from "$lib/generated/worker-launch-api"; } from "#lib/generated/worker-launch-api.ts";
import type { import type {
WorkingDirectoryCreateRequest, WorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse, WorkingDirectoryCreateResponse,
@@ -25,14 +25,14 @@ import type {
WorkingDirectoryListResponse, WorkingDirectoryListResponse,
WorkingDirectoryOccupancy, WorkingDirectoryOccupancy,
WorkingDirectorySummary, WorkingDirectorySummary,
} from "$lib/generated/workdir-api"; } from "#lib/generated/workdir-api.ts";
import type { import type {
Event as PodProtocolEvent, Event as PodProtocolEvent,
Method as PodProtocolMethod, Method as PodProtocolMethod,
Segment as PodProtocolSegment, Segment as PodProtocolSegment,
SubscriptionWorkerJob, SubscriptionWorkerJob,
WorkerStateSnapshot, WorkerStateSnapshot,
} from "$lib/generated/protocol"; } from "#lib/generated/protocol.ts";
import type { import type {
GitCommitSummary as SharedGitCommitSummary, GitCommitSummary as SharedGitCommitSummary,
GitRemoteSummary as SharedGitRemoteSummary, GitRemoteSummary as SharedGitRemoteSummary,
@@ -42,7 +42,7 @@ import type {
RepositoryLogResponse as SharedRepositoryLogResponse, RepositoryLogResponse as SharedRepositoryLogResponse,
RepositorySummary as SharedRepositorySummary, RepositorySummary as SharedRepositorySummary,
WorkspaceResponse as SharedWorkspaceResponse, WorkspaceResponse as SharedWorkspaceResponse,
} from "$lib/workspace/api/workspace-model"; } from "#lib/workspace/api/workspace-model.ts";
export type { export type {
PodProtocolEvent, PodProtocolEvent,
@@ -157,7 +157,7 @@ export type {
TicketDetail, TicketDetail,
TicketEventDetail, TicketEventDetail,
TicketListResponse, TicketListResponse,
} from "$lib/generated/ticket-api"; } from "#lib/generated/ticket-api.ts";
export type { export type {
TicketDetailDerivedRelation as DerivedTicketRelation, TicketDetailDerivedRelation as DerivedTicketRelation,
TicketDetailRelation as TicketRelation, TicketDetailRelation as TicketRelation,
@@ -165,7 +165,7 @@ export type {
TicketDetailRelationNotice as TicketRelationNotice, TicketDetailRelationNotice as TicketRelationNotice,
TicketDetailRelationView as TicketRelationView, TicketDetailRelationView as TicketRelationView,
TicketListItemSummary as TicketSummary, TicketListItemSummary as TicketSummary,
} from "$lib/generated/ticket-api"; } from "#lib/generated/ticket-api.ts";
export type { export type {
CompanionCancelRequest, CompanionCancelRequest,
@@ -178,4 +178,4 @@ export type {
CompanionTranscriptProjection, CompanionTranscriptProjection,
CompanionTranscriptRole, CompanionTranscriptRole,
CompanionTransportSummary, CompanionTransportSummary,
} from "$lib/generated/companion-api"; } from "#lib/generated/companion-api.ts";
@@ -1,9 +1,9 @@
import { workspaceApiPath } from "$lib/workspace/api/http"; import { workspaceApiPath } from "#lib/workspace/api/http.ts";
import { import {
parseRuntimeCleanupExecution, parseRuntimeCleanupExecution,
parseRuntimeCleanupPlan, parseRuntimeCleanupPlan,
parseRuntimeWorkerLifecycleResult, parseRuntimeWorkerLifecycleResult,
} from "$lib/workspace/api/runtime-workers"; } from "#lib/workspace/api/runtime-workers.ts";
import type { Diagnostic, Worker } from "./types"; import type { Diagnostic, Worker } from "./types";
type FetchFn = typeof fetch; type FetchFn = typeof fetch;
@@ -1,8 +1,8 @@
import type { import type {
BrowserWorkerWorkingDirectorySelection, BrowserWorkerWorkingDirectorySelection,
CreateWorkspaceWorkerRequest, CreateWorkspaceWorkerRequest,
} from "$lib/generated/worker-launch-api"; } from "#lib/generated/worker-launch-api.ts";
import { parseCreateWorkspaceWorkerRequest } from "$lib/workspace/api/workers"; import { parseCreateWorkspaceWorkerRequest } from "#lib/workspace/api/workers.ts";
import type { WorkerLaunchOptionsResponse } from "./types"; import type { WorkerLaunchOptionsResponse } from "./types";
@@ -1,4 +1,4 @@
import type { WorkerStateSnapshot } from "$lib/generated/protocol"; import type { WorkerStateSnapshot } from "#lib/generated/protocol.ts";
export function liveWorkerState(worker: { export function liveWorkerState(worker: {
state: string; state: string;
@@ -11,7 +11,7 @@ import { afterEach, expect, test, vi } from "vitest";
import type { import type {
SubscriptionFrame, SubscriptionFrame,
SubscriptionWorker, SubscriptionWorker,
} from "$lib/generated/protocol"; } from "#lib/generated/protocol.ts";
import WorkersNavSection from "./WorkersNavSection.svelte"; import WorkersNavSection from "./WorkersNavSection.svelte";
import { disposeWorkspaceWorkersStore } from "./worker-subscription"; import { disposeWorkspaceWorkersStore } from "./worker-subscription";
@@ -19,7 +19,7 @@ const transport = vi.hoisted(() => ({
listener: null as { onFrame(frame: SubscriptionFrame): void } | null, listener: null as { onFrame(frame: SubscriptionFrame): void } | null,
close: vi.fn(), close: vi.fn(),
})); }));
vi.mock("$lib/workspace/multiplexer", () => ({ vi.mock("#lib/workspace/multiplexer.ts", () => ({
workspaceMultiplexer: () => ({ workspaceMultiplexer: () => ({
subscribe: ( subscribe: (
_selector: unknown, _selector: unknown,
@@ -2,7 +2,7 @@ import type {
SubscriptionEventPayload, SubscriptionEventPayload,
SubscriptionFrame, SubscriptionFrame,
SubscriptionWorker, SubscriptionWorker,
} from '$lib/generated/protocol'; } from '#lib/generated/protocol.ts';
export type WorkspaceWorkersProjection = { export type WorkspaceWorkersProjection = {
workers: Map<string, SubscriptionWorker>; workers: Map<string, SubscriptionWorker>;
@@ -1,4 +1,4 @@
import type { SubscriptionFrame, SubscriptionWorker } from '$lib/generated/protocol'; import type { SubscriptionFrame, SubscriptionWorker } from '#lib/generated/protocol.ts';
function assertEquals(actual: unknown, expected: unknown): void { function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) { if (JSON.stringify(actual) !== JSON.stringify(expected)) {
@@ -1,9 +1,9 @@
import { type Readable, readable } from "svelte/store"; import { type Readable, readable } from "svelte/store";
import type { WorkingDirectorySummary } from "$lib/generated/workdir-api"; import type { WorkingDirectorySummary } from "#lib/generated/workdir-api.ts";
import type { SubscriptionWorker } from "$lib/generated/protocol"; import type { SubscriptionWorker } from "#lib/generated/protocol.ts";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseWorkingDirectoryListResponse } from "$lib/workspace/api/workdirs"; import { parseWorkingDirectoryListResponse } from "#lib/workspace/api/workdirs.ts";
import { workspaceMultiplexer } from "$lib/workspace/multiplexer"; import { workspaceMultiplexer } from "#lib/workspace/multiplexer.ts";
import { import {
applyWorkspaceWorkersFrame, applyWorkspaceWorkersFrame,
createWorkspaceWorkersProjection, createWorkspaceWorkersProjection,
@@ -1,4 +1,4 @@
import type { WorkingDirectorySummary } from "$lib/generated/workdir-api"; import type { WorkingDirectorySummary } from "#lib/generated/workdir-api.ts";
import { import {
type SidebarWorkdirAttachment, type SidebarWorkdirAttachment,
sidebarWorkdirMeta, sidebarWorkdirMeta,
@@ -1,5 +1,5 @@
import type { WorkingDirectorySummary } from "$lib/generated/workdir-api"; import type { WorkingDirectorySummary } from "#lib/generated/workdir-api.ts";
import type { SubscriptionWorkerWorkdirAttachment } from "$lib/generated/protocol"; import type { SubscriptionWorkerWorkdirAttachment } from "#lib/generated/protocol.ts";
const SHORT_REFERENCE_LENGTH = 12; const SHORT_REFERENCE_LENGTH = 12;
@@ -12,7 +12,7 @@ import {
type SkillProvenance, type SkillProvenance,
type SkillResourceRef, type SkillResourceRef,
type SkillSourceKind, type SkillSourceKind,
} from "$lib/generated/skill-api.ts"; } from "#lib/generated/skill-api.ts";
export class SkillApiContractError extends Error { export class SkillApiContractError extends Error {
constructor(message: string) { constructor(message: string) {
@@ -1,8 +1,8 @@
import type { BrowserWorkspaceOrchestratorResponse } from "$lib/generated/worker-launch-api"; import type { BrowserWorkspaceOrchestratorResponse } from "#lib/generated/worker-launch-api.ts";
import type { import type {
TicketDetail, TicketDetail,
TicketListItemSummary, TicketListItemSummary,
} from "$lib/generated/ticket-api"; } from "#lib/generated/ticket-api.ts";
export const TICKET_STATES = [ export const TICKET_STATES = [
"planning", "planning",

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