Merge remote-tracking branch 'origin/develop' into work/T-668-subjektiv-memory-lifecycle

This commit is contained in:
2026-10-02 06:00:49 +09:00
53 changed files with 5108 additions and 892 deletions
+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,
+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,
+21 -3
View File
@@ -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()?;
@@ -5319,7 +5337,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 {
+137 -11
View File
@@ -3007,7 +3007,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())
@@ -3017,18 +3017,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")
@@ -5606,6 +5614,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
} }
@@ -1551,6 +1559,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>,
@@ -1559,6 +1582,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,
@@ -1668,6 +1694,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(),
@@ -1974,6 +2004,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(()) => {
@@ -2060,6 +2096,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) =
@@ -2446,7 +2487,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(),
@@ -2870,11 +2913,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
@@ -2883,20 +2922,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)
} }
@@ -3045,6 +3086,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,
@@ -3359,8 +3403,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,
@@ -3586,6 +3644,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;
@@ -3939,6 +4027,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
@@ -4949,11 +4949,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();
} }
@@ -5007,33 +5015,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),
},
} }
} }
@@ -5086,9 +5094,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
} }
@@ -5110,14 +5116,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)
} }
} }
}) })
@@ -5296,6 +5299,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(
@@ -5959,25 +5966,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
@@ -6011,20 +6000,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();
@@ -6043,20 +6019,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
@@ -7912,6 +7875,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,
@@ -8027,6 +8021,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 },
@@ -9112,6 +9126,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;
@@ -9322,9 +9413,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())
@@ -9356,6 +9447,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.
+26
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,
@@ -6337,6 +6341,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
@@ -18051,7 +18051,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(
@@ -24818,6 +24841,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
@@ -24927,7 +24959,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),
@@ -30254,6 +30288,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>>,
@@ -30380,6 +30415,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
} }
@@ -33719,6 +33778,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();
@@ -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.
+36
View File
@@ -181,6 +181,42 @@ is ignored by Git, while authentication state remains outside the repository. `c
`target/web-ux` when `--output` is omitted. Keep a bundle outside Git or publish it through the `target/web-ux` when `--output` is omitted. Keep a bundle outside Git or publish it through the
approved immutable artifact channel when durable review evidence is required. approved immutable artifact channel when durable review evidence is required.
## Workspace Home fixture
`scenarios/workspace-home.json` captures the production work dashboard with owner, non-owner,
long-name, empty, and intentionally failing-review Workspace projections at 1440px, 768px, and 390px.
The partial-error route deliberately returns HTTP 503 only for Merge Requests; captures should retain
that request/console/visible error while showing successful active-work and recent-update feeds.
It uses no real account or private Workspace data. Build `web/workspace` first, then run from `tools/web-ux`:
```sh
deno task web-ux capture --scenario scenarios/workspace-home.json --output ../../target/web-ux --run-id home-review
```
The scenario owns a fixture server on localhost port 15281 and stops only that server. The production
Home browser regression (`browser-tests/workspace_home_shell_test.ts`, included in `test:browser`)
also checks 320px, both themes, keyboard record navigation, bounded loading, independent slow/error
feeds, explicit refresh, and absence of Host requests.
## Workspace Identity settings fixture
`scenarios/workspace-settings.json` uses the same owned fixture server with synthetic metadata,
public identity, and deletion-preflight responses. It covers owner, member, long name, pending
provisioning, and intentional signing-identity HTTP503 states at 1440px, 768px, and 390px.
Build `web/workspace` before capture. No real keys, accounts, or deletion requests are used.
`browser-tests/workspace_settings_shell_test.ts` also checks 320px and both themes, Edit/Save/Cancel,
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`.
## 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],
}, },
}); });
} }
@@ -41,22 +41,134 @@ async function historyRequestCount(baseUrl: string): Promise<number> {
return state.history_requests; return state.history_requests;
} }
async function checkSidebarModeSwitch(page: Page, name: string): Promise<void> {
const button = page.getByRole("button", { name, exact: true });
await button.hover();
await button.focus();
const samples = await button.evaluate(async (button) => {
const frame = button.closest<HTMLElement>(".sidebar-frame")!;
await Promise.all(frame.getAnimations({ subtree: true }).map((animation) => animation.finished));
const panel = frame.querySelector<HTMLElement>(".sidebar-frame__bevel")!;
const content = frame.querySelector<HTMLElement>(".sidebar-frame-content")!;
const main = document.querySelector<HTMLElement>(".app-shell__main")!;
const sample = () => ({
mainLeft: main.getBoundingClientRect().left,
mainWidth: main.getBoundingClientRect().width,
stationary: {
panel: panel.getBoundingClientRect().toJSON(),
content: content.getBoundingClientRect().toJSON(),
button: button.getBoundingClientRect().toJSON(),
folded: frame.classList.contains("folded"),
unclipped: panel.contains(document.elementFromPoint(panel.getBoundingClientRect().right - 8, 40)),
motion: panel.getAnimations({ subtree: true }).filter((animation) =>
animation instanceof CSSTransition && ["width", "transform"].includes(animation.transitionProperty)
).length,
},
});
const samples = [sample()];
(button as HTMLButtonElement).click();
const start = performance.now();
do {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
samples.push(sample());
} while (performance.now() - start < 280);
return samples;
});
for (const sample of samples) {
assertEquals(sample.stationary, samples[0].stationary, `${name} must keep the open panel stationary without slide animation`);
assertEquals(sample.stationary.folded, false);
assertEquals(sample.stationary.motion, 0);
assertEquals(sample.stationary.unclipped, true);
}
const first = samples[0];
const last = samples.at(-1)!;
assert(samples.some((sample) => sample.mainLeft > Math.min(first.mainLeft, last.mainLeft) &&
sample.mainLeft < Math.max(first.mainLeft, last.mainLeft)), "main position must animate through intermediate coordinates");
assert(samples.some((sample) => sample.mainWidth > Math.min(first.mainWidth, last.mainWidth) &&
sample.mainWidth < Math.max(first.mainWidth, last.mainWidth)), "main width must animate through intermediate sizes");
await page.locator(".sidebar-fold-button").evaluate((button) => (button as HTMLButtonElement).blur());
}
async function checkSidebarPointerModeSwitch(page: Page, pinning: boolean): Promise<void> {
const button = page.getByRole("button", { name: pinning ? "Pin sidebar" : "Unpin sidebar", exact: true });
await button.hover();
await page.mouse.down();
await page.waitForTimeout(100);
assertEquals(await page.locator(".sidebar-frame.folded").count(), pinning ? 1 : 0,
"pointer-down must not open a preview before the mode changes on click");
const sampling = button.evaluate(async (button) => {
const frame = button.closest<HTMLElement>(".sidebar-frame")!;
const panel = frame.querySelector<HTMLElement>(".sidebar-frame__bevel")!;
const main = document.querySelector<HTMLElement>(".app-shell__main")!;
const content = frame.querySelector<HTMLElement>(".sidebar-frame-content")!;
const sample = () => ({
frameWidth: frame.getBoundingClientRect().width,
panelWidth: panel.getBoundingClientRect().width,
mainWidth: main.getBoundingClientRect().width,
expectedMainWidth: Math.min(parseFloat(getComputedStyle(main).maxWidth), innerWidth - panel.getBoundingClientRect().right),
contentWidth: content.getBoundingClientRect().width,
buttonLeft: button.getBoundingClientRect().left,
buttonBottom: button.getBoundingClientRect().bottom,
});
const samples = [sample()];
const start = performance.now();
do {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
samples.push(sample());
} while (performance.now() - start < 450);
return samples;
});
await page.mouse.up();
const samples = await sampling;
for (const sample of samples) {
assert(Math.abs(sample.frameWidth - sample.panelWidth) <= 1,
`sidebar and main must move together throughout the click: ${JSON.stringify(sample)}`);
assert(Math.abs(sample.mainWidth - sample.expectedMainWidth) <= 1);
assertEquals(sample.contentWidth, samples[0].contentWidth);
assertEquals(sample.buttonLeft, samples[0].buttonLeft);
assertEquals(sample.buttonBottom, samples[0].buttonBottom);
}
const first = samples[0].frameWidth;
const last = samples.at(-1)!.frameWidth;
assert(pinning ? last > first : last < first);
assert(samples.some((sample) => sample.frameWidth > Math.min(first, last) && sample.frameWidth < Math.max(first, last)),
"mode switch must animate through intermediate widths");
assertEquals(await page.locator(".sidebar-frame.folded").count(), pinning ? 0 : 1);
}
async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): Promise<void> { async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): Promise<void> {
const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-fold-button"); const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-hover-region");
const result = await toggle.evaluate(async (button, mobile) => { const result = await toggle.evaluate(async (button, { mobile, opening }) => {
const frame = document.querySelector<HTMLElement>(".sidebar-frame")!; const frame = document.querySelector<HTMLElement>(".sidebar-frame")!;
const content = frame.querySelector<HTMLElement>(".sidebar-frame-content")!; const content = frame.querySelector<HTMLElement>(".sidebar-frame-content")!;
const main = document.querySelector<HTMLElement>(".app-shell__main")!; const main = document.querySelector<HTMLElement>(".app-shell__main")!;
const moving = mobile ? frame : content; const moving = mobile ? frame : content;
const footer = frame.querySelector<HTMLElement>(".sidebar-control-row")!;
const pin = frame.querySelector<HTMLElement>(".sidebar-fold-button")!;
const sample = () => ({ const sample = () => ({
footerWidth: footer.getBoundingClientRect().width,
footerHeight: footer.getBoundingClientRect().height,
dividerLeft: parseFloat(getComputedStyle(footer, "::before").left),
dividerRight: parseFloat(getComputedStyle(footer, "::before").right),
footerBorder: getComputedStyle(footer).borderTopWidth,
pinWidth: pin.getBoundingClientRect().width,
pinHeight: pin.getBoundingClientRect().height,
x: moving.getBoundingClientRect().x, x: moving.getBoundingClientRect().x,
width: moving.getBoundingClientRect().width, width: moving.getBoundingClientRect().width,
frameWidth: frame.getBoundingClientRect().width, frameWidth: frame.getBoundingClientRect().width,
panelWidth: frame.querySelector(".sidebar-frame__bevel")!.getBoundingClientRect().width,
mainWidth: main.getBoundingClientRect().width, mainWidth: main.getBoundingClientRect().width,
mainHeight: main.getBoundingClientRect().height, mainHeight: main.getBoundingClientRect().height,
pinLeft: frame.querySelector(".sidebar-fold-button")!.getBoundingClientRect().left - frame.getBoundingClientRect().left,
pinBottom: frame.getBoundingClientRect().bottom - frame.querySelector(".sidebar-fold-button")!.getBoundingClientRect().bottom,
}); });
const before = sample(); const before = sample();
(button as HTMLButtonElement).click(); if (mobile) {
(button as HTMLButtonElement).click();
} else {
button.dispatchEvent(new PointerEvent(opening ? "pointerenter" : "pointerleave", { pointerType: "mouse" }));
if (!opening) await new Promise((resolve) => setTimeout(resolve, 190));
}
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
const animations = frame.getAnimations({ subtree: true }); const animations = frame.getAnimations({ subtree: true });
const slides = animations.filter((animation) => animation instanceof CSSTransition && const slides = animations.filter((animation) => animation instanceof CSSTransition &&
@@ -70,22 +182,34 @@ async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean):
for (const animation of animations) animation.finish(); for (const animation of animations) animation.finish();
await Promise.all(animations.map((animation) => animation.finished)); await Promise.all(animations.map((animation) => animation.finished));
return { before, middle, after: sample(), durations, contentInert: content.inert, mainInert: main.inert }; return { before, middle, after: sample(), durations, contentInert: content.inert, mainInert: main.inert };
}, mobile); }, { mobile, opening });
assert(result.durations.length >= (mobile ? 1 : 2)); assert(result.durations.length >= (mobile ? 1 : 2), `missing sidebar motion: ${JSON.stringify({ mobile, opening, result })}`);
assert(result.durations.every((duration) => duration === 220)); assert(result.durations.every((duration) => duration === 220));
const { before, middle, after } = result; const { before, middle, after } = result;
for (const sample of [before, middle, after]) {
assert(Math.abs(sample.pinLeft - 8) <= 1, "Pin must stay at the sidebar's left edge");
assert(Math.abs(sample.pinBottom - 8) <= 1, "Pin must stay at the sidebar's bottom edge");
assertEquals(sample.pinWidth, 32);
assertEquals(sample.pinHeight, 32);
assertEquals(sample.footerHeight, 48, "footer keeps compact, equal padding");
assertEquals(sample.dividerLeft, 8);
assertEquals(sample.dividerRight, 8);
assertEquals(sample.footerBorder, "0px", "divider must not touch the sidebar edges");
}
assert(opening ? before.x < middle.x && middle.x < after.x : before.x > middle.x && middle.x > after.x, assert(opening ? before.x < middle.x && middle.x < after.x : before.x > middle.x && middle.x > after.x,
`sidebar must slide horizontally through an intermediate position: ${JSON.stringify(result)}`); `sidebar must slide horizontally through an intermediate position: ${JSON.stringify(result)}`);
assertEquals(before.width, after.width, "sidebar contents must slide without reflowing"); assertEquals(before.width, after.width, "sidebar contents must slide without reflowing");
assertEquals(result.contentInert, !opening); assertEquals(result.contentInert, !opening);
assertEquals(result.mainInert, mobile && opening); assertEquals(result.mainInert, mobile && opening);
if (mobile) { assertEquals(before.mainWidth, after.mainWidth, "hover/touch previews must not resize the main content");
assertEquals(before.mainWidth, after.mainWidth); assertEquals(before.mainHeight, after.mainHeight);
assertEquals(before.mainHeight, after.mainHeight); assertEquals(after.frameWidth, before.frameWidth, "hover mode keeps a fixed rail width");
assertEquals(after.frameWidth, before.frameWidth); if (!mobile) {
} else { const folded = opening ? before : after;
assert(opening ? before.frameWidth < middle.frameWidth && middle.frameWidth < after.frameWidth assertEquals(folded.footerWidth, folded.footerHeight, "folded footer must be square");
: before.frameWidth > middle.frameWidth && middle.frameWidth > after.frameWidth); assert(opening ? before.panelWidth < middle.panelWidth && middle.panelWidth < after.panelWidth
: before.panelWidth > middle.panelWidth && middle.panelWidth > after.panelWidth,
"the overlay panel must expand past the rail and contract again");
} }
} }
@@ -384,24 +508,71 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
assertEquals(new Set(rowIds).size, rowIds.length); assertEquals(new Set(rowIds).size, rowIds.length);
await page.getByRole("button", { name: "Overview", exact: true }).click(); await page.getByRole("button", { name: "Overview", exact: true }).click();
assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12); assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12);
await checkSidebarSlide(page, false, false); const sidebar = page.locator(".sidebar-frame");
await checkSidebarSlide(page, false, true); const settleSidebar = () => sidebar.evaluate(async (element) => {
await checkSidebarSlide(page, false, false);
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "true");
await page.reload();
await page.getByRole("button", { name: "Unfold sidebar", exact: true }).waitFor();
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0);
await page.setViewportSize({ width: 600, height: viewportHeight });
await page.locator(".sidebar-frame").evaluate(async (element) => {
await Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished)); await Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished));
}); });
await checkSidebarModeSwitch(page, "Unpin sidebar");
await page.mouse.move(1000, 30);
await page.locator(".sidebar-frame.folded").waitFor();
await settleSidebar();
const mainBeforeHover = await page.locator(".app-shell__main").boundingBox();
await checkSidebarSlide(page, false, true);
await checkSidebarSlide(page, false, false);
// The entire footer, including its button, is outside hover activation.
const footer = sidebar.locator(".sidebar-control-row");
await footer.hover({ position: { x: 4, y: 4 } });
await page.waitForTimeout(250);
assertEquals(await page.locator(".sidebar-frame.folded").count(), 1);
await page.getByRole("button", { name: "Pin sidebar", exact: true }).hover();
await page.waitForTimeout(250);
assertEquals(await page.locator(".sidebar-frame.folded").count(), 1);
await sidebar.locator(".sidebar-hover-region").hover({ position: { x: 10, y: 24 } });
await page.locator(".sidebar-frame:not(.folded)").waitFor();
await settleSidebar();
await footer.hover({ position: { x: 4, y: 4 } });
await page.locator(".sidebar-frame.folded").waitFor();
await settleSidebar();
await checkSidebarPointerModeSwitch(page, true);
await checkSidebarPointerModeSwitch(page, false);
// Use real mouse and keyboard events as well as deterministic motion sampling.
await sidebar.hover({ position: { x: 10, y: 40 } });
await page.locator(".sidebar-frame:not(.folded)").waitFor();
await settleSidebar();
assertEquals(await page.locator(".app-shell__main").boundingBox(), mainBeforeHover);
const sidebarLink = sidebar.locator(".sidebar-frame-content a").first();
await sidebarLink.focus();
await page.mouse.move(1000, 30);
await page.waitForTimeout(250);
assertEquals(await page.locator(".sidebar-frame.folded").count(), 0, "keyboard focus must hold the preview open");
await page.getByRole("link", { name: "Open Account", exact: true }).focus();
await page.locator(".sidebar-frame.folded").waitFor();
await settleSidebar();
await sidebar.hover({ position: { x: 10, y: 40 } });
await checkSidebarModeSwitch(page, "Pin sidebar");
await page.mouse.move(1000, 30);
await page.waitForTimeout(250);
assertEquals(await page.locator(".sidebar-frame.folded").count(), 0);
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
await checkSidebarModeSwitch(page, "Unpin sidebar");
// Hold a preview open across reload; only the mode should be restored.
await sidebarLink.focus();
await page.mouse.move(1000, 30);
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover");
await page.reload();
await page.getByRole("button", { name: "Pin sidebar", exact: true }).waitFor();
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0);
await page.setViewportSize({ width: 600, height: viewportHeight });
await settleSidebar();
await checkSidebarSlide(page, true, true); await checkSidebarSlide(page, true, true);
await checkSidebarSlide(page, true, false); await checkSidebarSlide(page, true, false);
await checkSidebarSlide(page, true, true); await checkSidebarSlide(page, true, true);
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "false"); assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover");
await page.reload(); await page.reload();
await page.getByRole("button", { name: "Hide sidebar", exact: true }).waitFor(); await page.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 1); assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0);
await page.getByRole("button", { name: "Show sidebar", exact: true }).click();
await settleSidebar();
await page.emulateMedia({ reducedMotion: "reduce" }); await page.emulateMedia({ reducedMotion: "reduce" });
await page.getByRole("button", { name: "Hide sidebar", exact: true }).click(); await page.getByRole("button", { name: "Hide sidebar", exact: true }).click();
const reducedSidebar = await page.locator(".sidebar-frame").evaluate((element) => ({ const reducedSidebar = await page.locator(".sidebar-frame").evaluate((element) => ({
@@ -411,6 +582,31 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
})); }));
assertEquals(reducedSidebar, { animations: 0, right: 0, visibility: "hidden" }); assertEquals(reducedSidebar, { animations: 0, right: 0, visibility: "hidden" });
assertEquals(await page.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false); assertEquals(await page.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false);
if (viewportHeight === 600) {
const touchContext = await browser.newContext({
viewport: { width: 600, height: 600 }, hasTouch: true, isMobile: true,
storageState: await context.storageState(),
});
const touchPage = await touchContext.newPage();
await touchPage.goto(page.url());
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap();
await touchPage.getByRole("button", { name: "Pin sidebar", exact: true }).tap();
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
await touchPage.getByRole("button", { name: "Hide sidebar", exact: true }).tap();
await touchPage.reload();
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
// A wide touchscreen still has a temporary-open control in hover mode.
await touchPage.setViewportSize({ width: 1024, height: 600 });
await touchPage.getByRole("button", { name: "Unpin sidebar", exact: true }).tap();
await touchPage.locator(".sidebar-frame.folded").waitFor();
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap();
await touchPage.locator(".sidebar-frame:not(.folded)").waitFor();
await touchPage.getByRole("button", { name: "Hide sidebar", exact: true }).tap();
await touchPage.locator(".sidebar-frame.folded").waitFor();
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover");
await touchContext.close();
}
assertEquals(errors, []); assertEquals(errors, []);
await context.close(); await context.close();
} finally { } finally {
@@ -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;
}
});
@@ -0,0 +1,176 @@
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
import { dashboardFixture } from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts";
import { settingsFixtureHandler } from "../../../web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts";
const settingsFixture = settingsFixtureHandler();
const port = Number(Deno.args[0]);
const buildRoot = Deno.args[1];
if (!Number.isInteger(port) || !buildRoot) {
throw new Error("usage: server <port> <build-root>");
}
const names: Record<string, string> = {
"home-owner": "Workspace Home Review",
"home-member": "Shared Workspace",
"home-empty": "New Workspace",
"home-error": "Partial failure Workspace",
"home-long":
"Workspace with a long name — international documentation and distributed development",
};
const json = (value: unknown, status = 200) => Response.json(value, { status });
const mime: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".woff2": "font/woff2",
};
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") return new Response("ok");
const settingsResponse = await settingsFixture(request);
if (settingsResponse) return settingsResponse;
if (url.pathname === "/api/workspaces") {
return json(
Object.entries(names).map(([workspace_id, display_name]) => ({
workspace_id,
display_name,
owner_account_id: "fixture-owner",
state: "active",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
})),
);
}
const match = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/);
if (match) {
const [, workspaceId, path] = match;
const owner = workspaceId !== "home-member";
if (workspaceId === "home-error" && path === "/merge-requests") {
return json({ error: "fixture unavailable" }, 503);
}
const dashboardData = dashboardFixture(path, url.searchParams, workspaceId);
if (dashboardData) return json(dashboardData);
if (path === "/workspace") {
return json({
workspace_id: workspaceId,
display_name: names[workspaceId],
record_authority: "fixture",
schema_version: 1,
auth: {
Passkey: {
rp_id: "127.0.0.1",
origin: url.origin,
public_base_url: url.origin,
cookie_name: "fixture",
},
},
permissions: {
manage_repositories: owner,
manage_secrets: owner,
manage_runtimes: owner,
delete_workspace: owner,
},
extension_points: {
store: "fixture",
event_stream: { status: "ready", note: "fixture", diagnostics: [] },
host_worker_bridge: {
status: "ready",
note: "fixture",
diagnostics: [],
},
companion_console: {
status: "ready",
note: "fixture",
diagnostics: [],
},
},
});
}
if (path === "/repositories") {
return json({
workspace_id: workspaceId,
items: [],
source: "fixture",
diagnostics: [],
});
}
if (path === "/working-directories") {
return json({ workspace_id: workspaceId, items: [], diagnostics: [] });
}
// Retained only for before-change captures. The redesigned Home must not request Hosts.
if (path === "/hosts") {
return json({
workspace_id: workspaceId,
limit: 100,
source: "fixture",
diagnostics: [],
items: workspaceId === "home-member" ? [] : [{
runtime_id: "fixture-runtime",
host_id: "fixture-host",
label: "Development host",
kind: "local",
status: "available",
observed_at: "2026-01-02T00:00:00Z",
last_seen_at: null,
os: "linux",
arch: "x86_64",
diagnostics: [],
}],
});
}
if (path === "/memory") {
return json({
body_md: "# Home navigation destination",
bytes: 29,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
record_source: "fixture",
});
}
if (
path === "/protocol/ws" && request.headers.get("upgrade") === "websocket"
) {
const { socket, response } = Deno.upgradeWebSocket(request);
socket.onmessage = (event) => {
const frame = JSON.parse(String(event.data));
if (frame?.message?.method !== "subscribe_events") return;
socket.send(JSON.stringify({
protocol_version: 1,
frame: "response",
message: {
result: "subscribed",
payload: {
request_id: frame.message.params.request_id,
subscription_id: "fixture-workers",
selector: { topic: "workspace_workers" },
snapshot_revision: 1,
snapshot: { topic: "workers", data: { workers: [] } },
},
},
}));
};
return response;
}
return json({ error: "Unexpected fixture API request" }, 404);
}
const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html");
if (relative.startsWith("..")) {
return new Response("not found", { status: 404 });
}
let file = join(buildRoot, relative);
try {
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
return new Response(await Deno.readFile(file), {
headers: {
"content-type": mime[extname(file)] ?? "application/octet-stream",
},
});
} catch {
return new Response(await Deno.readFile(join(buildRoot, "index.html")), {
headers: { "content-type": mime[".html"] },
});
}
});
@@ -0,0 +1,186 @@
/// <reference lib="dom" />
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join } from "@std/path";
import { chromium, type Page } from "playwright";
const root = join(dirname(fromFileUrl(import.meta.url)), "../../..");
const ready = async (page: Page) => {
for (const id of ["home-attention", "home-active", "home-recent"]) {
await page.locator(`[data-feed-ready="${id}"]`).waitFor();
}
};
Deno.test("production Home dashboard isolates failures, shows authoritative records, and supports responsive keyboard navigation", 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/workspace_home_fixture_server.ts"),
String(port),
join(root, "web/workspace/build"),
],
stdout: "null",
stderr: "null",
}).spawn();
try {
let started = false;
for (let n = 0; n < 100; n++) {
try {
const response = await fetch(`${url}/health`);
await response.body?.cancel();
if (response.ok) {
started = true;
break;
}
} catch { /* 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[] = [];
const requests: 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()));
page.on("request", (request) => requests.push(request.url()));
for (const workspace of ["home-owner", "home-member", "home-long"]) {
await page.goto(`${url}/w/${workspace}`);
await ready(page);
assertEquals(await page.locator("main h1").allTextContents(), ["Activity"]);
assertEquals(await page.locator("main nav").count(), 0);
assertEquals(await page.locator("main [role=alert]").count(), 0);
assertEquals(await page.locator("[data-feed-ready=home-attention] a").count(), 3);
assertEquals(await page.locator("[data-feed-ready=home-active] a").count(), 2);
assertEquals(await page.locator("[data-feed-ready=home-recent] a").count(), 4);
await page.getByText("Blocked by T-99", { exact: true }).waitFor();
await page.getByText("W-7", { exact: true }).waitFor();
const geometry = await page.locator(".workspace-home").evaluate((home) => {
const main = home.closest("main")!;
const sections = [...home.querySelectorAll("section")].map((section) =>
section.getBoundingClientRect()
);
return {
pageOverflow: document.documentElement.scrollWidth > innerWidth,
mainOverflow: main.scrollWidth > main.clientWidth,
firstLinkTop: home.querySelector("a")!.getBoundingClientRect().top,
sideBySide: sections[1].left > sections[0].right &&
sections[2].left > sections[1].right,
stacked: sections[1].top >= sections[0].bottom &&
sections[2].top >= sections[1].bottom,
};
});
assertEquals(geometry.pageOverflow, false);
assertEquals(geometry.mainOverflow, false);
assert(geometry.firstLinkTop < 900);
assert(width === 1440 ? geometry.sideBySide : geometry.stacked);
const links = page.locator("main a");
await links.first().focus();
assertEquals(
await links.first().evaluate((link) => getComputedStyle(link).outlineStyle),
"solid",
);
await page.keyboard.press("Tab");
assertEquals(
await links.nth(1).evaluate((link) => document.activeElement === link),
true,
);
for (const link of await links.all()) {
assert(
new RegExp(`/w/${workspace}/(tickets|objectives|merge-requests)/[^/]+$`).test(
(await link.getAttribute("href"))!,
),
);
}
}
const beforeRefresh = requests.filter((request) =>
request.includes("/merge-requests?")
).length;
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await page.getByRole("button", { name: "Refresh", exact: true }).waitFor();
await ready(page);
assertEquals(
requests.filter((request) => request.includes("/merge-requests?")).length,
beforeRefresh + 1,
);
const objective = page.locator("main a[href*='/objectives/O-12-']");
await objective.focus();
await page.keyboard.press("Enter");
await page.waitForURL("**/objectives/O-12-*");
await page.getByText("Home dashboard navigation destination.", { exact: true })
.waitFor();
assertEquals(
requests.some((request) => new URL(request).pathname.endsWith("/hosts")),
false,
);
assertEquals(errors, []);
} finally {
await context.close();
}
}
}
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
try {
await page.goto(`${url}/w/home-empty`);
await ready(page);
assertEquals(await page.locator("main a").count(), 0);
await page.getByText("No open Merge Requests.", { exact: true }).waitFor();
await page.goto(`${url}/w/home-error`);
await ready(page);
assertEquals(
await page.locator("[data-feed-ready=home-attention] [role=alert]").count(),
1,
);
assertEquals(await page.getByText("No open Merge Requests.", { exact: true }).count(), 0);
assertEquals(await page.locator("[data-feed-ready=home-active] a").count(), 2);
assertEquals(await page.locator("[data-feed-ready=home-recent] a").count(), 4);
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
await page.route("**/merge-requests?*", async (route) => {
await held;
await route.continue();
});
await page.goto(`${url}/w/home-owner`);
await page.locator("[data-feed-ready=home-active]").waitFor();
assertEquals(await page.getByText("Loading needs attention…", { exact: true }).count(), 1);
release();
await ready(page);
} finally {
await page.close();
}
} finally {
await browser.close();
}
} finally {
try {
server.kill("SIGTERM");
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
await server.status;
}
});
@@ -0,0 +1,301 @@
/// <reference lib="dom" />
import { assert, assertEquals } from "@std/assert";
import { dirname, fromFileUrl, join } from "@std/path";
import { chromium, type Page } from "playwright";
import { metadataFixture } from "../../../web/workspace/src/lib/workspace/settings/identity.test-fixtures.ts";
const root = join(dirname(fromFileUrl(import.meta.url)), "../../..");
const evidence = join(root, "target/web-ux/settings-interactions");
async function ready(page: Page, owner = true) {
await page.locator("[data-name-ready=true]").waitFor();
if (owner) await page.locator("[data-identity-ready=true]").waitFor();
}
async function noOverflow(page: Page) {
assertEquals(
await page.locator("main").evaluate((main) => ({
page: document.documentElement.scrollWidth > innerWidth,
main: main.scrollWidth > main.clientWidth,
})),
{ page: false, main: false },
);
}
Deno.test("production Workspace Settings separates read/edit, discloses details, and preserves keyboard and destructive safety", 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));
await Deno.mkdir(evidence, { recursive: true });
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/workspace_home_fixture_server.ts"),
String(port),
join(root, "web/workspace/build"),
],
stdout: "null",
stderr: "null",
}).spawn();
try {
let started = false;
for (let n = 0; n < 100; n++) {
try {
const response = await fetch(`${url}/health`);
await response.body?.cancel();
if (response.ok) {
started = true;
break;
}
} catch { /* 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[] = [];
const mutations: 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()));
page.on("request", (request) => {
if (request.method() !== "GET") mutations.push(request.url());
});
for (
const workspace of ["home-owner", "home-member", "home-long"]
) {
const owner = workspace !== "home-member";
await page.goto(`${url}/w/${workspace}/settings`);
await ready(page, owner);
assertEquals(await page.locator("main h1").allTextContents(), [
"Workspace Identity",
]);
assertEquals(
await page.locator("main input:visible, main textarea:visible")
.count(),
0,
);
assertEquals(await page.locator("main details[open]").count(), 0);
assertEquals(await page.locator("main .badge").count(), 0);
assertEquals(
await page.getByRole("button", { name: "Copy bundle" }).count(),
owner ? 1 : 0,
);
const edit = page.getByRole("button", { name: "Edit name" });
assert((await edit.boundingBox())!.y < 900);
await noOverflow(page);
const saved = await page.locator(".saved-name").innerText();
await edit.focus();
await page.keyboard.press("Enter");
const input = page.getByRole("textbox", {
name: "New display name",
});
assertEquals(
await input.evaluate((node) => node === document.activeElement),
true,
);
assertEquals(await input.inputValue(), saved);
await input.fill("Discard draft");
if (workspace === "home-long") {
await page.screenshot({
path: join(evidence, `edit-${colorScheme}-${width}.png`),
});
}
await page.getByRole("button", { name: "Cancel", exact: true })
.click();
assertEquals(
await edit.evaluate((node) => node === document.activeElement),
true,
);
assertEquals(
await page.locator(".saved-name").innerText(),
saved,
);
const technical = page.getByText("Technical details", {
exact: true,
});
await technical.focus();
await page.keyboard.press("Enter");
assertEquals(
await page.getByText("Source", { exact: true }).isVisible(),
true,
);
await page.keyboard.press("Enter");
assertEquals(
await page.getByText("Source", { exact: true }).isVisible(),
false,
);
if (owner) {
const bundle = page.getByText("Public bundle and key details", {
exact: true,
});
await bundle.focus();
await page.keyboard.press("Enter");
assertEquals(
await page.getByRole("textbox", {
name: "Public identity bundle",
}).isVisible(),
true,
);
assertEquals(
await page.getByRole("textbox", {
name: "Public identity bundle",
}).getAttribute("readonly"),
"",
);
await noOverflow(page);
if (workspace === "home-owner") {
await page.screenshot({
path: join(evidence, `bundle-${colorScheme}-${width}.png`),
});
}
await bundle.click();
const danger = page.locator(".deletion > summary");
await danger.focus();
await page.keyboard.press("Enter");
await page.getByRole("button", {
name: "Review deletion impact",
}).click();
const confirmation = page.getByRole("textbox", {
name: /^Type .* to confirm$/,
});
await confirmation.waitFor();
assertEquals(
await confirmation.evaluate((node) => node === document.activeElement),
true,
);
const deletion = page.getByRole("button", {
name: "Delete Workspace",
exact: true,
});
assertEquals(await deletion.isDisabled(), true);
await confirmation.fill("wrong");
assertEquals(await deletion.isDisabled(), true);
await confirmation.fill(saved);
assertEquals(await deletion.isDisabled(), false);
await noOverflow(page);
if (workspace === "home-owner") {
await page.screenshot({
path: join(evidence, `delete-${colorScheme}-${width}.png`),
});
}
await page.getByRole("button", { name: "Cancel", exact: true })
.click();
assertEquals(
await danger.evaluate((node) => node === document.activeElement),
true,
);
assertEquals(await page.locator(".deletion[open]").count(), 0);
}
}
assertEquals(mutations, []);
assertEquals(errors, []);
await page.getByRole("button", { name: "Edit name" }).click();
const renamed = `Confirmed name ${width} ${colorScheme}`;
await page.getByRole("textbox", { name: "New display name" }).fill(
renamed,
);
await page.getByRole("button", { name: "Save name" }).click();
await page.getByText("Workspace name saved.", { exact: true })
.waitFor();
assertEquals(
await page.locator(".saved-name").innerText(),
renamed,
);
assertEquals(await page.locator("main input:visible").count(), 0);
assertEquals(mutations.length, 1);
assertEquals(errors, []);
// Restore the synthetic name so every width actually exercises long text.
const currentResponse = await context.request.get(`${url}/api/w/home-long/settings`);
const current = await currentResponse.json();
const reset = await context.request.put(`${url}/api/w/home-long/settings`, {
data: {
display_name: metadataFixture("home-long").display_name,
revision: current.revision,
},
});
assertEquals(reset.status(), 200);
} finally {
await context.close();
}
}
}
const page = await browser.newPage({
viewport: { width: 390, height: 844 },
});
try {
await page.goto(`${url}/w/home-empty/settings`);
await ready(page);
await page.getByText("Not provisioned", { exact: true }).waitFor();
await page.getByRole("button", { name: "Provision identity" }).click();
await page.getByRole("button", { name: "Copy bundle" }).waitFor();
await page.goto(`${url}/w/home-error/settings`);
await ready(page);
assertEquals(
await page.getByRole("button", { name: "Edit name" }).isVisible(),
true,
);
assertEquals(
await page.getByRole("button", { name: "Retry identity" })
.isVisible(),
true,
);
await page.goto(`${url}/w/home-owner/settings`);
await ready(page);
await page.route("**/settings", async (route) => {
if (route.request().method() === "PUT") {
await route.fulfill({
status: 409,
body: "Workspace metadata changed. Reload the saved name.",
});
} else await route.continue();
});
await page.getByRole("button", { name: "Edit name" }).click();
await page.getByRole("textbox", { name: "New display name" }).fill(
"Keep this draft",
);
await page.getByRole("button", { name: "Save name" }).click();
await page.getByRole("alert").waitFor();
assertEquals(
await page.getByRole("textbox", { name: "New display name" })
.inputValue(),
"Keep this draft",
);
await page.screenshot({
path: join(evidence, "save-error-mobile.png"),
});
await page.getByRole("button", { name: "Reload saved name" }).click();
await page.getByRole("button", { name: "Edit name" }).waitFor();
assertEquals(await page.getByRole("alert").count(), 0);
} finally {
await page.close();
}
} finally {
await browser.close();
}
} finally {
try {
server.kill("SIGTERM");
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
}
await server.status;
}
});
+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" "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"
}
]
}
@@ -0,0 +1,96 @@
{
"schemaVersion": 1,
"id": "workspace-home",
"title": "Workspace Home operational dashboard",
"baseUrl": "http://127.0.0.1:15281",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{
"id": "fixture",
"label": "Fixture account; permission projection varies by Workspace",
"auth": { "kind": "anonymous" }
}
],
"viewports": [
{ "label": "desktop", "width": 1440, "height": 900 },
{ "label": "narrow", "width": 768, "height": 900 },
{ "label": "mobile", "width": 390, "height": 844 }
],
"routes": [
{
"id": "owner",
"label": "Owner Home",
"path": "/w/home-owner",
"goal": "Identify review work, blocked or assigned active tickets, and recent updates; open an actual record.",
"dataState": "Mixed review states, in-progress/queued tickets with a coder and dependency blocker, done/closed tickets and an Objective.",
"ready": {
"kind": "selector",
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
},
"capturePoints": [{ "id": "initial", "label": "Dashboard" }]
},
{
"id": "non-owner",
"label": "Non-owner Home",
"path": "/w/home-member",
"goal": "Read the same work records without owner-only management controls.",
"dataState": "All management permissions false; same authorized read-only work projections as owner.",
"ready": {
"kind": "selector",
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
},
"capturePoints": [{ "id": "initial", "label": "Dashboard" }]
},
{
"id": "long-name",
"label": "Long names and selectors",
"path": "/w/home-long",
"goal": "Read long record titles and branch selectors without page-wide overflow.",
"dataState": "Owner with long Workspace name, Objective title, and Merge Request selectors.",
"ready": {
"kind": "selector",
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
},
"capturePoints": [{ "id": "initial", "label": "Dashboard" }]
},
{
"id": "empty",
"label": "Empty Workspace",
"path": "/w/home-empty",
"goal": "Distinguish empty work scopes without fabricated status totals.",
"dataState": "All dashboard resource queries successfully return empty collections.",
"ready": {
"kind": "selector",
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
},
"capturePoints": [{ "id": "initial", "label": "Empty scopes" }]
},
{
"id": "partial-error",
"label": "Reviews unavailable",
"path": "/w/home-error",
"goal": "Keep active work and recent updates usable when reviews fail; never claim all-clear.",
"dataState": "Intentional HTTP 503 from Merge Requests. Expected visible error and browser HTTP console diagnostic; other resource requests succeed.",
"ready": {
"kind": "selector",
"selector": ".workspace-home:has([data-feed-ready='home-attention']):has([data-feed-ready='home-active']):has([data-feed-ready='home-recent'])"
},
"capturePoints": [{ "id": "initial", "label": "Scoped review failure" }]
}
],
"processes": [{
"id": "home-fixture",
"command": "deno",
"cwd": "..",
"args": [
"run",
"--allow-net",
"--allow-read",
"browser-tests/workspace_home_fixture_server.ts",
"15281",
"../../web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15281/health"
}]
}
@@ -0,0 +1,137 @@
{
"schemaVersion": 1,
"id": "workspace-settings",
"title": "Workspace Identity settings disclosure",
"baseUrl": "http://127.0.0.1:15282",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{
"id": "fixture",
"label": "Synthetic Workspace permission projections",
"auth": {
"kind": "anonymous"
}
}
],
"viewports": [
{
"label": "desktop",
"width": 1440,
"height": 900
},
{
"label": "narrow",
"width": 768,
"height": 900
},
{
"label": "mobile",
"width": 390,
"height": 844
}
],
"routes": [
{
"id": "owner",
"label": "Owner settings",
"path": "/w/home-owner/settings",
"goal": "Read saved name and identity state without permanent edit forms or public JSON.",
"dataState": "Active public identity and saved name.",
"ready": {
"kind": "selector",
"selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))"
},
"capturePoints": [
{
"id": "initial",
"label": "Saved settings"
}
]
},
{
"id": "member",
"label": "Member settings",
"path": "/w/home-member/settings",
"goal": "Keep permitted metadata editing without owner identity or deletion controls.",
"dataState": "All owner permissions false; metadata read/write remains available under existing Backend contract.",
"ready": {
"kind": "selector",
"selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))"
},
"capturePoints": [
{
"id": "initial",
"label": "Member view"
}
]
},
{
"id": "long",
"label": "Long name",
"path": "/w/home-long/settings",
"goal": "Keep controls in the viewport and preserve complete long names.",
"dataState": "Long Workspace display name and active identity.",
"ready": {
"kind": "selector",
"selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))"
},
"capturePoints": [
{
"id": "initial",
"label": "Long name"
}
]
},
{
"id": "pending",
"label": "Pending identity",
"path": "/w/home-empty/settings",
"goal": "Show provisioning attention without pretending the identity is active.",
"dataState": "Identity pending_provisioning; metadata available.",
"ready": {
"kind": "selector",
"selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))"
},
"capturePoints": [
{
"id": "initial",
"label": "Provisioning required"
}
]
},
{
"id": "error",
"label": "Identity failure",
"path": "/w/home-error/settings",
"goal": "Keep name usable when public identity fails and offer scoped recovery.",
"dataState": "Intentional signing identity HTTP503 with its expected console diagnostic.",
"ready": {
"kind": "selector",
"selector": "main:has(#workspace-settings-title):not(:has-text(\"Loading\"))"
},
"capturePoints": [
{
"id": "initial",
"label": "Scoped error"
}
]
}
],
"processes": [
{
"id": "settings-fixture",
"command": "deno",
"cwd": "..",
"args": [
"run",
"--allow-net",
"--allow-read",
"browser-tests/workspace_home_fixture_server.ts",
"15282",
"../../web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15282/health"
}
]
}
+2
View File
@@ -94,6 +94,8 @@ Use semantic tokens from `app.css`. Do not introduce component-local color, spac
- `src/routes/+layout.svelte`: root application shell and override contexts. - `src/routes/+layout.svelte`: root application shell and override contexts.
- `src/routes/w/[workspaceId]/+layout.svelte`: Workspace Header and Sidebar registration. - `src/routes/w/[workspaceId]/+layout.svelte`: Workspace Header and Sidebar registration.
- `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration. - `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration.
- `src/lib/workspace/home/`: bounded, read-only work dashboard feeds and their presentation.
- `src/lib/workspace/settings/WorkspaceIdentitySettings.svelte`: Workspace-scoped name editing, public identity disclosure, and deletion confirmation.
- `src/lib/workspace/header/`: Header frame, context, and overrides. - `src/lib/workspace/header/`: Header frame, context, and overrides.
- `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack. - `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack.
- `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers. - `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers.
@@ -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",
@@ -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
) { ) {
@@ -245,15 +245,10 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
); );
assert( assert(
workspacePage.includes("ticketsHref") && workspacePage.includes("DashboardSection") &&
workspacePage.includes("runtimeSettingsHref") && !workspacePage.includes("<nav") &&
workspacePage.includes("workersHref") && !workspacePage.includes("/settings/runtimes"),
workspacePage.includes("workspaceRoute(workspaceId, '/tickets')") && "Home should show work records without duplicating Sidebar navigation",
workspacePage.includes(
"workspaceRoute(workspaceId, '/settings/runtimes')",
) &&
workspacePage.includes("workspaceRoute(workspaceId, '/workers')"),
"top workspace page should link to Tickets, Runtimes under Settings, and the Workers page",
); );
assert( assert(
!workspacePage.includes("workerConsoleHref") && !workspacePage.includes("workerConsoleHref") &&
@@ -1325,12 +1320,12 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
"Workspace layout should load workspace data, register with the parent slot, and provide the same slot contract to children", "Workspace layout should load workspace data, register with the parent slot, and provide the same slot contract to children",
); );
assert( assert(
sidebarFrame.includes("folded = $bindable(false)") && sidebarFrame.includes("mode: 'pinned' | 'hover'") &&
sidebarFrame.includes("sidebar-fold-button") && sidebarFrame.includes("sidebar-fold-button") &&
sidebarFrame.includes("Fold sidebar") && sidebarFrame.includes("Pin sidebar") &&
sidebarFrame.includes("Unfold sidebar") && sidebarFrame.includes("Unpin sidebar") &&
rootLayout.includes("let sidebarFolded = $state(loadSidebarFolded())") && rootLayout.includes("let sidebarMode = $state(loadSidebarMode())") &&
rootLayout.includes("<SidebarFrame bind:folded={sidebarFolded}>") && rootLayout.includes("mode={sidebarMode}") &&
!workspaceLayout.includes("sidebarFolded") && !workspaceLayout.includes("sidebarFolded") &&
!workspaceLayout.includes("onToggleFold") && !workspaceLayout.includes("onToggleFold") &&
!sidebar.includes("folded?: boolean") && !sidebar.includes("folded?: boolean") &&
@@ -0,0 +1,67 @@
<script lang="ts">
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
import type { DashboardFeed } from './dashboard';
let { id, title, empty, result }: { id: string; title: string; empty: string; result: Promise<DashboardFeed> } = $props();
function timestamp(value?: string | null): string | null {
return value && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null;
}
function displayDate(value: string): string {
return new Date(value).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
}
</script>
<section aria-labelledby={id} class="dashboard-section">
<h2 {id}>{title}</h2>
{#await result}
<p class="feed-state" role="status">Loading {title.toLowerCase()}…</p>
{:then feed}
<div data-feed-ready={id}>
{#each feed.errors as error}<p class="feed-error" role="alert">{error}</p>{/each}
{#if feed.rows.length}
<ul>
{#each feed.rows as row, index (row.key)}
{@const date = timestamp(row.updatedAt)}
<li>
<a href={row.href} class:attention={row.attention}>
<div class="row-meta"><span>{row.kind}</span><code>{row.reference}</code></div>
<strong class:machine-title={row.kind === 'Merge Request'}>{row.title}</strong>
<span class="row-status">{row.status}</span>
{#if row.workerKey}<span class="row-detail">Coder <code>{row.workerKey}</code></span>{/if}
{#if row.detail}<span class="row-detail">{row.detail}</span>{/if}
{#if date}<time datetime={date}>Updated {displayDate(date)}</time>{:else}<span class="row-detail">Update time unavailable</span>{/if}
</a>
{#if index < feed.rows.length - 1}<BevelLine decorative />{/if}
</li>
{/each}
</ul>
{:else if !feed.errors.length}
<p class="feed-state">{empty}</p>
{/if}
{#if feed.notice}<p class="feed-notice">{feed.notice}</p>{/if}
</div>
{:catch}
<p class="feed-error" role="alert">Could not load {title.toLowerCase()}. Refresh to try again.</p>
{/await}
</section>
<style>
section { min-width: 0; }
h2 { margin: 0 0 var(--space-2); font-size: var(--font-size-body); line-height: var(--line-height-body); color: var(--text-strong); }
ul { list-style: none; margin: 0; padding: 0; }
a { display: grid; gap: var(--space-1); min-width: 0; padding-block: var(--space-3); text-decoration: none; color: var(--text); overflow-wrap: anywhere; }
a:hover, a:focus-visible { background: var(--interactive-hover); }
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
a:active { background: var(--interactive-selected); }
strong { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden; color: var(--text-strong); font-size: var(--font-size-body); line-height: var(--line-height-body); font-weight: 600; }
.machine-title { font-family: var(--font-mono); }
.row-meta { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.row-meta, .row-detail, time, .row-status, .feed-notice { font-size: var(--font-size-compact); line-height: var(--line-height-compact); }
.row-meta, .row-detail, time, .feed-state, .feed-notice { color: var(--text-muted); }
code { font-family: var(--font-mono); }
.row-status { font-weight: 600; }
.attention .row-status { color: var(--warning); }
.feed-state { margin: var(--space-3) 0; }
.feed-error { margin: var(--space-3) 0; color: var(--danger); overflow-wrap: anywhere; }
.feed-notice { margin: var(--space-3) 0 0; }
</style>
@@ -0,0 +1,194 @@
// Deterministic read-only fixtures shared by Home component and browser tests.
export const fixturePage = (returned: number, more = false) => ({
returned,
limit: 5,
sort: "priority",
has_more: more,
next_cursor: more ? "next" : null,
source_limit: null,
source_truncated: false,
});
const updated = (hour: number) =>
`2026-09-28T${String(hour).padStart(2, "0")}:00:00Z`;
export function fixtureTicket(key: string, state: string, hour = 10) {
return {
id: `internal-${key}`,
resource_key: key,
title: key === "T-101"
? "Restore queued Worker submissions"
: `${state} ticket ${key}`,
state,
priority: "normal",
queued_at: null,
queued_by: null,
updated_at: updated(hour),
record_source: "fixture",
workspace_action_priority: state === "inprogress" || state === "queued"
? "active_work"
: "background",
};
}
export function fixtureDetail(key = "T-101") {
const summary = fixtureTicket(key, key === "T-101" ? "inprogress" : "queued");
const { workspace_action_priority: _, ...ticket } = summary;
return {
...ticket,
item_revision: "revision-1",
created_at: updated(1),
readiness: null,
body: "Implementation in progress.",
body_truncated: false,
resolution: null,
artifacts: [],
artifact_count: 0,
assignments: [],
assignment_diagnostics: [],
events: [],
event_count: 0,
event_page: fixturePage(0),
implementation_reports: [],
evidence: {
has_merge_request: false,
has_current_subject_ref: false,
has_review_request: false,
has_commit: false,
review_status: null,
approved_current_subject: false,
review_after_rescope: false,
unresolved_request_changes: false,
complete_for_integration: false,
missing: [],
},
current_coder: key === "T-101"
? {
assignment_id: "a-1",
runtime_id: "internal-runtime",
worker_id: "internal-worker",
worker_resource_key: "W-7",
}
: null,
action_eligibility: {
blockers: [],
can_assign_orchestrator: false,
can_queue: false,
can_start_manual_coder: false,
can_unassign_orchestrator: false,
queue_tickets: [],
},
linked_objectives: [],
merge_request: null,
merge_requests: [],
targets: [],
risk_flags: [],
relations: {
outgoing: [],
incoming: [],
notices: [],
blockers: key === "T-101"
? [{
blocking_resource_key: "T-99",
blocking_state: "ready",
blocking_ticket: "internal-T-99",
note: null,
reason_kind: "dependency",
relation_kind: "depends_on",
}]
: [],
},
};
}
export function dashboardFixture(
path: string,
search: URLSearchParams,
workspaceId = "home-owner",
): unknown | null {
const empty = workspaceId === "home-empty";
const long = workspaceId === "home-long";
if (path === "/tickets") {
const state = search.get("states")!;
const items = empty
? []
: state === "inprogress,queued"
? [fixtureTicket("T-101", "inprogress"), fixtureTicket("T-102", "queued")]
: state === "done"
? [fixtureTicket("T-103", "done", 12), fixtureTicket("T-104", "done", 7)]
: [fixtureTicket("T-105", "closed", 14)];
if (long && items[0]) {
items[0].title = "長いTicket名 — ".repeat(8) +
"preserve-the-full-title-without-horizontal-overflow";
}
return {
workspace_id: workspaceId,
limit: 5,
items,
page: fixturePage(items.length, !empty),
invalid_records: [],
record_authority: "fixture",
};
}
if (/^\/tickets\/T-10[12]/.test(path)) {
return fixtureDetail(path.includes("T-101") ? "T-101" : "T-102");
}
const objective = {
id: "internal-objective",
resource_key: "O-12",
title: long ? "Objective ".repeat(18) : "Improve daily Workspace operation",
state: "active",
created_at: updated(1),
updated_at: updated(13),
summary: "",
linked_tickets: [],
record_source: "fixture",
};
if (path === "/objectives") {
return {
workspace_id: workspaceId,
limit: 5,
items: empty ? [] : [objective],
invalid_records: [],
record_authority: "fixture",
};
}
if (path.startsWith("/objectives/O-12")) {
const { summary: _, ...detail } = objective;
return {
...detail,
body: "# Objective detail\n\nHome dashboard navigation destination.",
body_truncated: false,
revision: "revision-1",
linked_ticket_summaries: [],
resources: [],
events: [],
event_page: fixturePage(0),
};
}
if (path === "/merge-requests") {
return {
items: empty
? []
: ["request_changes", "pending", "approved"].map((status, index) => ({
summary: {
merge_request_id: `internal-merge-${index}`,
repository_key: "main",
state: "open",
selector_from: long
? "feature/" + "long-branch-".repeat(15)
: ["fix/queue", "feat/home", "fix/sidebar"][index],
selector_to: "develop",
review_status: status,
updated_at: updated(15 - index),
review_excerpt: null,
review_requested_at: updated(10),
review_submitted_at: null,
current_subject_ref: "abc123",
review_subject_ref: "abc123",
},
thread_event_count: 1,
ticket_ids: [],
ref_diagnostics: [],
})),
next_cursor: empty ? null : "older-reviews",
};
}
return null;
}
@@ -0,0 +1,263 @@
import type { TicketListItemSummary } from "$lib/generated/ticket-api";
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import {
parseMergeRequestListResponse,
parseObjectiveListResponse,
parseTicketDetail,
parseTicketListResponse,
TICKET_BROWSER_API_LOAD_POLICY,
} from "$lib/workspace/api/ticket-browser";
import { mergeRequestPagePath } from "$lib/workspace/api/merge-requests";
import { objectiveHref, ticketHref } from "$lib/workspace/resource-links";
export const HOME_LIMIT = 5;
export type DashboardRow = {
key: string;
kind: "Ticket" | "Objective" | "Merge Request";
reference: string;
title: string;
href: string;
status: string;
detail?: string;
workerKey?: string;
attention?: boolean;
updatedAt?: string | null;
};
export type DashboardFeed = {
rows: DashboardRow[];
errors: string[];
notice?: string;
};
export type Dashboard = {
reviews: Promise<DashboardFeed>;
active: Promise<DashboardFeed>;
recent: Promise<DashboardFeed>;
};
const states: Record<string, string> = {
inprogress: "In progress",
queued: "Queued",
done: "Done",
closed: "Closed",
active: "Active",
completed: "Completed",
paused: "Paused",
};
const reviewStates: Record<string, string> = {
none: "Not reviewed",
pending: "Review pending",
approved: "Approved",
request_changes: "Changes requested",
unresolved_changes: "Unresolved changes",
};
function ticketRow(
workspaceId: string,
ticket: TicketListItemSummary,
): DashboardRow {
return {
key: `ticket:${ticket.resource_key}`,
kind: "Ticket",
reference: ticket.resource_key,
title: ticket.title,
href: ticketHref(workspaceId, ticket),
status: states[ticket.state] ?? ticket.state,
updatedAt: ticket.updated_at,
};
}
function failure(label: string, error: string | null): string {
const status = error?.match(/HTTP \d{3}/)?.[0];
return `${label} unavailable.${
status ? ` ${status}.` : ""
} Refresh to retry.`;
}
function dateValue(value?: string | null): number {
const timestamp = value ? Date.parse(value) : NaN;
return Number.isFinite(timestamp) ? timestamp : -Infinity;
}
export function recentRows(rows: DashboardRow[]): DashboardRow[] {
const latest = new Map<string, DashboardRow>();
for (const row of rows) {
const previous = latest.get(row.key);
if (
!previous || dateValue(row.updatedAt) >= dateValue(previous.updatedAt)
) latest.set(row.key, row);
}
return [...latest.values()]
.sort((a, b) =>
dateValue(b.updatedAt) - dateValue(a.updatedAt) ||
a.key.localeCompare(b.key)
)
.slice(0, HOME_LIMIT);
}
// These are bounded, read-only browser endpoints. QueryTicket/QueryObjective are
// tool-only capabilities and must not be approximated by guessed HTTP routes.
export function loadDashboard(
fetchFn: typeof fetch,
workspaceId: string,
): Dashboard {
function get<T>(path: string, parse: (value: unknown) => T) {
return loadJson(
fetchFn,
workspaceApiPath(workspaceId, path),
{ signal: AbortSignal.timeout(10_000) },
parse,
TICKET_BROWSER_API_LOAD_POLICY,
);
}
const tickets = (state: string) =>
get(
`/tickets?${new URLSearchParams({
states: state,
limit: String(HOME_LIMIT),
})}`,
parseTicketListResponse,
);
const activeResult = tickets("inprogress,queued");
const doneResult = tickets("done");
const closedResult = tickets("closed");
const objectivesResult = get(
`/objectives?limit=${HOME_LIMIT}`,
parseObjectiveListResponse,
);
const reviewsResult = get(
`/merge-requests?state=open&limit=${HOME_LIMIT}`,
parseMergeRequestListResponse,
);
return {
reviews: reviewsResult.then((result): DashboardFeed => {
if (!result.data) {
return { rows: [], errors: [failure("Merge Requests", result.error)] };
}
return {
rows: result.data.items.slice(0, HOME_LIMIT).map((
{ summary, ref_diagnostics },
) => ({
key: `merge:${summary.merge_request_id}`,
kind: "Merge Request",
reference: summary.repository_key,
title: `${
summary.selector_from ?? "Source unavailable"
} → ${summary.selector_to}`,
href: mergeRequestPagePath(workspaceId, summary.merge_request_id),
status: reviewStates[summary.review_status] ?? summary.review_status,
attention: ["request_changes", "unresolved_changes"].includes(
summary.review_status,
) || Boolean(ref_diagnostics?.length),
detail: ref_diagnostics?.length
? "Source or target needs attention"
: undefined,
updatedAt: summary.updated_at,
})),
errors: [],
notice: result.data.next_cursor
? `Showing ${
Math.min(result.data.items.length, HOME_LIMIT)
} most recently updated open Merge Requests.`
: undefined,
};
}),
active: activeResult.then(async (result): Promise<DashboardFeed> => {
if (!result.data) {
return { rows: [], errors: [failure("Active tickets", result.error)] };
}
const details = await Promise.all(
result.data.items.slice(0, HOME_LIMIT).map(async (ticket) => {
const detail = await get(
`/tickets/${encodeURIComponent(ticket.resource_key)}`,
parseTicketDetail,
);
if (!detail.data) {
return {
row: {
...ticketRow(workspaceId, ticket),
detail: "Assignment and blockers unavailable",
},
error: failure(`${ticket.resource_key} details`, detail.error),
};
}
const current = detail.data;
// A concurrent transition can remove a Ticket from this snapshot's scope.
if (!["inprogress", "queued"].includes(current.state)) {
return { changed: true };
}
const blockers = current.relations.blockers;
const row = ticketRow(workspaceId, { ...ticket, ...current });
const blockedKeys = blockers.map((blocker) =>
blocker.blocking_resource_key
).filter(Boolean);
return {
row: {
...row,
status: blockers.length ? `${row.status} · Blocked` : row.status,
attention: blockers.length > 0,
detail: blockers.length
? (blockedKeys.length === blockers.length
? `Blocked by ${blockedKeys.join(", ")}`
: `${blockers.length} unresolved dependencies`)
: current.current_coder
? undefined
: "No coder assigned",
workerKey: current.current_coder?.worker_resource_key ??
undefined,
},
};
}),
);
return {
rows: details.flatMap((item) => item.row ? [item.row] : []),
errors: [
...details.flatMap((item) => item.error ? [item.error] : []),
...(result.data.invalid_records.length
? ["Some Ticket records could not be read."]
: []),
],
notice: details.some((item) => item.changed)
? "Work changed while loading. Refresh for the latest list."
: result.data.page.has_more || result.data.page.source_truncated
? `Showing ${
Math.min(result.data.items.length, HOME_LIMIT)
} active tickets, with in-progress work first.`
: undefined,
};
}),
recent: Promise.all([doneResult, closedResult, objectivesResult]).then((
[done, closed, objectives],
): DashboardFeed => ({
rows: recentRows([
...(done.data?.items ?? []).map((ticket) =>
ticketRow(workspaceId, ticket)
),
...(closed.data?.items ?? []).map((ticket) =>
ticketRow(workspaceId, ticket)
),
...(objectives.data?.items ?? []).map((objective): DashboardRow => ({
key: `objective:${objective.resource_key}`,
kind: "Objective",
reference: objective.resource_key,
title: objective.title,
href: objectiveHref(workspaceId, objective),
status: states[objective.state] ?? objective.state,
updatedAt: objective.updated_at,
})),
]),
errors: [
...(!done.data ? [failure("Done tickets", done.error)] : []),
...(!closed.data ? [failure("Closed tickets", closed.error)] : []),
...(!objectives.data ? [failure("Objectives", objectives.error)] : []),
...([done.data, closed.data, objectives.data].some((data) =>
data?.invalid_records.length
)
? ["Some records could not be read."]
: []),
],
notice:
[done.data, closed.data, objectives.data].some((data) =>
data?.items.length
)
? "Up to 5 latest updates to done / closed tickets and Objectives."
: undefined,
})),
};
}
@@ -0,0 +1,396 @@
<script lang="ts">
import { invalidate, goto } from '$app/navigation';
import { onMount, onDestroy, tick } from 'svelte';
import type {
Diagnostic, WorkspaceMetadataSettingsResponse, WorkspaceSigningIdentityResponse,
WorkspaceDeletionPreflightResponse, WorkspaceDeletionOperationResponse, WorkspaceDeletionRequest,
} from '$lib/generated/legacy-server-api';
import { workspaceApiPath } from '$lib/workspace/api/http';
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
import { disposeWorkspaceWorkersStore } from '$lib/workspace/sidebar/worker-subscription';
import { fetchWorkspaceMetadata, updateWorkspaceMetadata, fetchWorkspaceSigningIdentity, provisionWorkspaceSigningIdentity } from './profile-api';
import { preflightWorkspaceDeletion, startWorkspaceDeletion, getWorkspaceDeletion } from './workspace-deletion-api';
import BevelLine from '$lib/workspace/ui/BevelLine.svelte';
// 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.
let { workspaceId, workspaceName, canDelete }: { workspaceId: string; workspaceName: string; canDelete: boolean } = $props();
let disposed = false;
onDestroy(() => { disposed = true; });
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
let loading = $state(true);
let metadataError = $state<string | null>(null);
let diagnostics = $state<Diagnostic[]>([]);
let editing = $state(false);
let displayNameDraft = $state('');
let submitting = $state(false);
let nameError = $state<string | null>(null);
let nameNotice = $state<string | null>(null);
let editButton = $state<HTMLButtonElement>();
let identityCopyButton = $state<HTMLButtonElement>();
let provisionButton = $state<HTMLButtonElement>();
let signingIdentity = $state<WorkspaceSigningIdentityResponse | null>(null);
let identityLoading = $state(true);
let identityError = $state<string | null>(null);
let provisioningIdentity = $state(false);
let copyError = $state<string | null>(null);
let identityCopied = $state(false);
let identityBundleText = $derived(signingIdentity?.public_bundle ? JSON.stringify(signingIdentity.public_bundle, null, 2) : '');
let dangerOpen = $state(false);
let dangerSummary = $state<HTMLElement>();
let deletionOpen = $state(false);
let deletionLoading = $state(false);
let deletionSubmitting = $state(false);
let deletionConfirmation = $state('');
let deletionPreflight = $state<WorkspaceDeletionPreflightResponse | null>(null);
let deletionOperation = $state<WorkspaceDeletionOperationResponse | null>(null);
let deletionRequest = $state<WorkspaceDeletionRequest | null>(null);
let deletionError = $state<string | null>(null);
const errorText = (error: unknown, fallback: string) => error instanceof Error ? error.message : fallback;
const focusInput = (element: HTMLElement) => { element.focus(); };
const deletionStorageKey = () => `yoi:workspace-deletion:${workspaceId}`;
async function loadMetadata() {
loading = true;
metadataError = null;
try {
const response = await fetchWorkspaceMetadata(workspaceId);
if (disposed) return;
workspaceMetadata = response;
displayNameDraft = response.display_name;
diagnostics = response.diagnostics;
} catch (error) {
if (!disposed) metadataError = errorText(error, 'Workspace settings unavailable.');
} finally {
if (!disposed) loading = false;
}
}
async function loadIdentity() {
if (!canDelete) return;
identityLoading = true;
identityError = null;
try {
const response = await fetchWorkspaceSigningIdentity(workspaceId);
if (!disposed) signingIdentity = response;
} catch (error) {
if (!disposed) identityError = errorText(error, 'Public identity unavailable.');
} finally {
if (!disposed) identityLoading = false;
}
}
async function retryMetadata() {
await loadMetadata();
await tick();
if (!disposed && !metadataError) editButton?.focus();
}
async function retryIdentity() {
await loadIdentity();
await tick();
if (!disposed && !identityError) (identityCopyButton ?? provisionButton)?.focus();
}
function editName() {
if (!workspaceMetadata) return;
displayNameDraft = workspaceMetadata.display_name;
nameError = nameNotice = null;
editing = true;
}
async function cancelName() {
editing = false;
nameError = null;
displayNameDraft = workspaceMetadata?.display_name ?? '';
await tick();
if (!disposed) editButton?.focus();
}
async function reloadName() {
await loadMetadata();
if (!disposed && !metadataError) await cancelName();
}
async function submitWorkspaceName() {
if (!workspaceMetadata || submitting || !displayNameDraft.trim()) return;
submitting = true;
nameError = nameNotice = null;
try {
const response = await updateWorkspaceMetadata(workspaceId, {
display_name: displayNameDraft, revision: workspaceMetadata.revision,
});
if (disposed) return;
workspaceMetadata = response.workspace;
diagnostics = response.diagnostics.concat(response.workspace.diagnostics);
nameNotice = 'Workspace name saved.';
await cancelName();
if (disposed) return;
try { await invalidate(workspaceApiPath(workspaceId, '/workspace')); }
catch { if (!disposed) nameNotice = 'Workspace name saved. Reload the page to update navigation.'; }
} catch (error) {
if (!disposed) nameError = errorText(error, 'Workspace name could not be saved.');
} finally {
if (!disposed) submitting = false;
}
}
async function provisionIdentity() {
if (!canDelete || provisioningIdentity) return;
provisioningIdentity = true;
identityError = null;
try {
const response = await provisionWorkspaceSigningIdentity(workspaceId);
if (disposed) return;
signingIdentity = response;
await tick();
if (!disposed) identityCopyButton?.focus();
} catch (error) {
if (!disposed) identityError = errorText(error, 'Identity provisioning failed.');
} finally {
if (!disposed) provisioningIdentity = false;
}
}
async function copyIdentityBundle() {
if (!identityBundleText) return;
copyError = null;
identityCopied = false;
try {
await navigator.clipboard.writeText(identityBundleText);
if (!disposed) identityCopied = true;
} catch {
if (!disposed) copyError = 'Could not copy. Open Public bundle and key details to select the bundle manually.';
}
}
async function openDeletionConfirmation() {
if (!canDelete || editing || submitting || provisioningIdentity || deletionLoading || deletionSubmitting) return;
deletionOpen = true;
deletionLoading = true;
deletionError = null;
deletionPreflight = deletionOperation = deletionRequest = null;
deletionConfirmation = '';
try {
sessionStorage.removeItem(deletionStorageKey());
const response = await preflightWorkspaceDeletion(workspaceId);
if (!disposed) deletionPreflight = response;
} catch (error) {
if (!disposed) deletionError = errorText(error, 'Workspace deletion preflight failed.');
} finally {
if (!disposed) deletionLoading = false;
}
}
async function cancelDeletion() {
deletionOpen = dangerOpen = false;
await tick();
if (!disposed) dangerSummary?.focus();
}
async function trackDeletion(operationId: string) {
let operation = await getWorkspaceDeletion(operationId);
if (disposed) return;
deletionOperation = operation;
while (operation.state === 'queued' || operation.state === 'running') {
await new Promise((resolve) => setTimeout(resolve, 500));
if (disposed) return;
operation = await getWorkspaceDeletion(operation.operation_id);
if (disposed) return;
deletionOperation = operation;
}
if (operation.state === 'succeeded') {
sessionStorage.removeItem(deletionStorageKey());
disposeWorkspaceMultiplexer(workspaceId);
disposeWorkspaceWorkersStore(workspaceId);
await goto('/');
}
}
function storedDeletionRequest(): WorkspaceDeletionRequest | null {
try {
const value: unknown = JSON.parse(sessionStorage.getItem(deletionStorageKey()) ?? 'null');
if (typeof value !== 'object' || value === null) return null;
const record = value as Record<string, unknown>;
if (
Object.keys(record).sort().join(',') !== 'confirmation,expected_revision,operation_id' ||
typeof record.operation_id !== 'string' || record.operation_id.length === 0 || record.operation_id.length > 128 ||
!/^[A-Za-z0-9_-]+$/.test(record.operation_id) ||
typeof record.expected_revision !== 'string' || record.expected_revision.length > 128 ||
typeof record.confirmation !== 'string' || record.confirmation !== workspaceName || record.confirmation.length > 256
) return null;
return { operation_id: record.operation_id, expected_revision: record.expected_revision, confirmation: record.confirmation };
} catch { return null; }
}
onMount(() => {
void loadMetadata();
void loadIdentity();
if (!canDelete) return;
const request = storedDeletionRequest();
if (!request) return;
deletionRequest = request;
deletionConfirmation = request.confirmation;
dangerOpen = deletionOpen = deletionSubmitting = true;
void trackDeletion(request.operation_id)
.catch((error) => { if (!disposed) deletionError = errorText(error, 'Workspace deletion status failed.'); })
.finally(() => { if (!disposed) deletionSubmitting = false; });
});
async function deleteWorkspace() {
if (!canDelete || deletionSubmitting || (!deletionPreflight && !deletionRequest)) return;
if (!deletionRequest && (!deletionPreflight?.can_delete || deletionConfirmation !== deletionPreflight.display_name)) return;
deletionSubmitting = true;
deletionError = null;
try {
const request = deletionRequest ?? {
operation_id: crypto.randomUUID(), expected_revision: deletionPreflight!.expected_revision, confirmation: deletionConfirmation,
};
deletionRequest = request;
sessionStorage.setItem(deletionStorageKey(), JSON.stringify(request));
const operation = await startWorkspaceDeletion(workspaceId, request);
if (disposed) return;
deletionOperation = operation;
await trackDeletion(operation.operation_id);
} catch (error) {
if (!disposed) deletionError = errorText(error, 'Workspace deletion failed.');
} finally {
if (!disposed) deletionSubmitting = false;
}
}
</script>
<div class="identity-settings">
<h1 id="workspace-settings-title">Workspace Identity</h1>
<section aria-labelledby="workspace-name-title" data-name-ready={!loading}>
<div class="section-heading">
<h2 id="workspace-name-title">Display name</h2>
{#if workspaceMetadata && !editing}
<button bind:this={editButton} type="button" disabled={deletionOpen || deletionSubmitting} onclick={editName}>Edit name</button>
{/if}
</div>
{#if loading}<p class="muted" role="status">Loading workspace settings…</p>{/if}
{#if metadataError}
<p class="error" role="alert">{metadataError}</p>
<button type="button" onclick={retryMetadata} disabled={loading}>Retry name</button>
{/if}
{#if workspaceMetadata}
{#if editing}
<form onsubmit={(event) => { event.preventDefault(); void submitWorkspaceName(); }} aria-label="Edit Workspace name">
<label>New display name<input use:focusInput bind:value={displayNameDraft} required autocomplete="off" disabled={submitting || loading} aria-invalid={Boolean(nameError)} /></label>
<p class="muted">Current: {workspaceMetadata.display_name}</p>
{#if nameError}<p class="error" role="alert">{nameError}</p>{/if}
<div class="actions">
<button class="primary" type="submit" disabled={submitting || loading || !displayNameDraft.trim() || displayNameDraft === workspaceMetadata.display_name}>{submitting ? 'Saving…' : 'Save name'}</button>
<button type="button" disabled={submitting || loading} onclick={cancelName}>Cancel</button>
{#if nameError}<button type="button" disabled={submitting || loading} onclick={reloadName}>Reload saved name</button>{/if}
</div>
</form>
{:else}
<p class="saved-name">{workspaceMetadata.display_name}</p>
{/if}
{#if nameNotice}<p role="status">{nameNotice}</p>{/if}
{#each diagnostics.filter((item) => item.severity !== 'info') as diagnostic}
<p class:error={diagnostic.severity === 'error'} class:warning={diagnostic.severity === 'warning'} role="alert">{diagnostic.message}</p>
{/each}
<details class="technical">
<summary>Technical details</summary>
<dl>
<div><dt>Source</dt><dd><code>{workspaceMetadata.source}</code></dd></div>
<div><dt>Revision</dt><dd><code>{workspaceMetadata.revision}</code></dd></div>
</dl>
{#each diagnostics.filter((item) => item.severity === 'info') as diagnostic}<p>{diagnostic.message}</p>{/each}
</details>
{/if}
</section>
{#if canDelete}
<BevelLine decorative />
<section aria-labelledby="workspace-identity-title" data-identity-ready={!identityLoading}>
<div class="section-heading">
<h2 id="workspace-identity-title">Public identity</h2>
{#if signingIdentity?.public_bundle}<button bind:this={identityCopyButton} type="button" onclick={copyIdentityBundle}>Copy bundle</button>{/if}
</div>
{#if identityLoading}<p class="muted" role="status">Loading identity…</p>{/if}
{#if identityError}<p class="error" role="alert">{identityError}</p>{/if}
{#if !identityLoading && !signingIdentity}<button type="button" onclick={retryIdentity}>Retry identity</button>{/if}
{#if signingIdentity?.identity.state === 'pending_provisioning'}
<p class="warning">Not provisioned</p>
<p class="muted">Provision a signing identity before connecting a Runtime.</p>
<button bind:this={provisionButton} type="button" disabled={provisioningIdentity || deletionOpen || deletionSubmitting} onclick={provisionIdentity}>{provisioningIdentity ? 'Provisioning…' : 'Provision identity'}</button>
{:else if signingIdentity?.public_bundle}
<p>Active</p>
{#if identityCopied}<p role="status">Public bundle copied.</p>{/if}
{#if copyError}<p class="error" role="alert">{copyError}</p>{/if}
<details class="technical">
<summary>Public bundle and key details</summary>
<p>Use this public bundle when connecting a Runtime. It contains no private key material.</p>
<dl>
<div><dt>Key</dt><dd><code>{signingIdentity.identity.key_id}</code></dd></div>
<div><dt>Fingerprint</dt><dd><code>{signingIdentity.identity.public_key_fingerprint}</code></dd></div>
<div><dt>Revision</dt><dd><code>{signingIdentity.identity.revision}</code></dd></div>
</dl>
<label>Public identity bundle<textarea readonly rows="9" value={identityBundleText} spellcheck="false"></textarea></label>
</details>
{/if}
</section>
<BevelLine decorative />
<details class="deletion" bind:open={dangerOpen} ontoggle={(event) => { if (!event.currentTarget.open && !deletionSubmitting) deletionOpen = false; }}>
<summary bind:this={dangerSummary}>{deletionSubmitting ? 'Deleting Workspace…' : 'Delete Workspace'}</summary>
<p>Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data. This cannot be undone.</p>
{#if !deletionOpen}
<button type="button" disabled={editing || submitting || provisioningIdentity} onclick={openDeletionConfirmation}>Review deletion impact</button>
{:else}
<section aria-labelledby="delete-workspace-title">
<h2 id="delete-workspace-title">Delete {deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? workspaceMetadata?.display_name ?? workspaceName}?</h2>
{#if deletionLoading}<p role="status">Loading deletion impact…</p>{/if}
{#if deletionPreflight}
<ul>
<li>{deletionPreflight.resources.workers} Workers</li><li>{deletionPreflight.resources.workdirs} Workdirs</li>
<li>{deletionPreflight.resources.repositories} repositories</li><li>{deletionPreflight.resources.runtime_bindings} Runtime bindings</li>
<li>{deletionPreflight.resources.secrets} secret records</li><li>{deletionPreflight.resources.artifacts} artifacts</li>
</ul>
{#each deletionPreflight.blockers as blocker}<p class="error" role="alert">{blocker.message}</p>{/each}
{#if !deletionRequest}
<label>Type {deletionPreflight.display_name} to confirm<input use:focusInput bind:value={deletionConfirmation} autocomplete="off" disabled={deletionSubmitting || !deletionPreflight.can_delete} /></label>
{/if}
{/if}
{#if deletionOperation}
<p role="status">Deletion state: {deletionOperation.state}</p>
{#each deletionOperation.blockers as blocker}<p class="error" role="alert">{blocker.message}</p>{/each}
{/if}
{#if deletionError}<p class="error" role="alert">{deletionError}</p>{/if}
<div class="actions">
<button type="button" onclick={cancelDeletion} disabled={deletionSubmitting}>Cancel</button>
{#if deletionPreflight || deletionRequest}
<button class="danger" type="button" onclick={deleteWorkspace} disabled={deletionSubmitting || (!deletionRequest && (!deletionPreflight?.can_delete || deletionConfirmation !== deletionPreflight.display_name))}>{deletionSubmitting ? 'Deleting…' : deletionRequest ? 'Retry deletion' : 'Delete Workspace'}</button>
{:else if !deletionLoading}
<button type="button" onclick={openDeletionConfirmation}>Retry deletion check</button>
{/if}
</div>
</section>
{/if}
</details>
{/if}
</div>
<style>
.identity-settings { display: grid; gap: var(--space-5); max-width: 52rem; min-width: 0; container-type: inline-size; }
h1 { margin: 0; font-size: var(--font-size-title); line-height: var(--line-height-title); color: var(--text-strong); }
h2 { margin: 0; font-size: var(--font-size-body); line-height: var(--line-height-body); color: var(--text-strong); }
section, form { display: grid; gap: var(--space-3); min-width: 0; }
.section-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); }
p { margin: 0; overflow-wrap: anywhere; }
.muted, .technical { color: var(--text-muted); }
.error { color: var(--danger); }
.warning { color: var(--warning); }
label { display: grid; gap: var(--space-1); min-width: 0; }
input, textarea { width: 100%; min-width: 0; border: 1px solid var(--line); border-radius: var(--radius-soft); background: var(--bg-raised); color: var(--text-strong); padding: var(--space-2); font: inherit; }
input[aria-invalid="true"] { border-color: var(--danger); }
textarea { resize: vertical; font-family: var(--font-mono); font-size: var(--font-size-compact); line-height: var(--line-height-compact); }
code { font-family: var(--font-mono); }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
button { width: fit-content; max-width: 100%; border: 1px solid var(--line); border-radius: var(--radius-soft); padding: var(--space-2) var(--space-3); color: var(--text); background: transparent; cursor: pointer; font: inherit; }
button.primary { background: var(--text-strong); color: var(--bg); border-color: var(--text-strong); }
button.danger { color: var(--danger); border-color: var(--danger); }
button:hover:not(:disabled), summary:hover { background: var(--interactive-hover); color: var(--text-strong); }
button:active:not(:disabled), summary:active { background: var(--interactive-selected); color: var(--text-strong); }
button:focus-visible, summary:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled, input:disabled { opacity: 0.6; cursor: not-allowed; }
summary { cursor: pointer; width: fit-content; max-width: 100%; color: var(--text); padding-block: var(--space-2); }
details > :not(summary) { margin-top: var(--space-3); }
dl { display: grid; gap: var(--space-2); margin: 0; font-size: var(--font-size-compact); line-height: var(--line-height-compact); }
dl div { display: grid; grid-template-columns: 7rem minmax(0, 1fr); gap: var(--space-3); }
dd { margin: 0; overflow-wrap: anywhere; }
.deletion > summary { color: var(--danger); }
ul { margin: 0; padding-left: var(--space-5); }
@container (max-width: 28rem) { dl div { grid-template-columns: 1fr; gap: var(--space-1); } }
</style>
@@ -0,0 +1,125 @@
// Synthetic public data only; shared by component tests and production browser fixtures.
export function metadataFixture(workspaceId = "home-owner") {
return {
workspace_id: workspaceId,
display_name: workspaceId === "home-long"
? "Workspace with a long name — international documentation and distributed development"
: "Workspace Settings Review",
created_at: "2026-01-01T00:00:00Z",
revision: "2026-01-02T00:00:00Z",
source: "server_db",
diagnostics: [],
};
}
export function identityFixture(workspaceId = "home-owner", pending = false) {
const identity = {
workspace_id: workspaceId,
key_id: "fixture-workspace-key",
algorithm: "ed25519",
revision: 1,
state: pending ? "pending_provisioning" : "active",
created_at: "2026-01-01T00:00:00Z",
};
if (pending) return { identity };
const publicKey = "yoi-ed25519-pub:v1:synthetic-public-key";
const fingerprint = "SHA256:synthetic-public-fingerprint-for-ui-review";
return {
identity: {
...identity,
public_key: publicKey,
public_key_fingerprint: fingerprint,
provisioned_at: identity.created_at,
},
public_bundle: {
workspace_id: workspaceId,
backend_url: "https://workspace.example.test",
key_id: identity.key_id,
algorithm: identity.algorithm,
public_key: publicKey,
public_key_fingerprint: fingerprint,
revision: 1,
},
};
}
export function deletionFixture(workspaceId = "home-owner") {
return {
workspace_id: workspaceId,
display_name: metadataFixture(workspaceId).display_name,
expected_revision: metadataFixture(workspaceId).revision,
can_delete: true,
blockers: [],
resources: {
workers: 2,
workdirs: 1,
repositories: 1,
runtime_bindings: 1,
secrets: 0,
artifacts: 0,
},
};
}
export function settingsFixtureHandler() {
const names = new Map<string, ReturnType<typeof metadataFixture>>();
const provisioned = new Set<string>();
return async (request: Request): Promise<Response | null> => {
const url = new URL(request.url);
const match = url.pathname.match(/^\/api\/w\/([^/]+)\/settings(.*)$/);
if (match) {
const [, id, path] = match;
if (!path) {
const current = names.get(id) ?? metadataFixture(id);
if (request.method === "PUT") {
const body = await request.json();
if (body.revision !== current.revision) {
return new Response(
"Workspace metadata changed. Reload the saved name.",
{ status: 409 },
);
}
const next = {
...current,
display_name: String(body.display_name).trim(),
revision: String(Number.parseInt(current.revision, 10) + 1),
};
names.set(id, next);
return Response.json({ workspace: next, diagnostics: [] });
}
return Response.json(current);
}
if (path === "/signing-identity/provision") {
provisioned.add(id);
return Response.json(identityFixture(id));
}
if (path === "/signing-identity") {
if (id === "home-error") {
return new Response("Public identity is temporarily unavailable.", {
status: 503,
});
}
return Response.json(
identityFixture(id, id === "home-empty" && !provisioned.has(id)),
);
}
}
const deletion = url.pathname.match(
/^\/api\/workspaces\/([^/]+)\/deletion$/,
);
if (deletion) {
if (request.method !== "GET") {
return new Response("Fixture does not delete Workspaces.", {
status: 409,
});
}
return Response.json({
...deletionFixture(deletion[1]),
...(names.has(deletion[1])
? {
display_name: names.get(deletion[1])!.display_name,
expected_revision: names.get(deletion[1])!.revision,
}
: {}),
});
}
return null;
};
}
@@ -1,49 +1,124 @@
<script lang="ts"> <script lang="ts">
import 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 './sidebar.css'; import './sidebar.css';
type Props = { type Props = {
children: Snippet<[]>; children: Snippet<[]>;
folded?: boolean; mode: 'pinned' | 'hover';
open: boolean;
mobile: boolean;
onModeChange: (mode: 'pinned' | 'hover') => void;
onOpenChange: (open: boolean) => void;
}; };
let { children, folded = $bindable(false) }: Props = $props(); let { children, mode, open, mobile, onModeChange, onOpenChange }: Props = $props();
let frame: HTMLElement;
let pointerInside = false;
let focusInside = false;
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function toggleFold() { function clearCloseTimer() {
folded = !folded; clearTimeout(closeTimer);
closeTimer = undefined;
} }
function scheduleClose() {
clearCloseTimer();
if (mobile || pointerInside || focusInside) return;
closeTimer = setTimeout(() => {
if (!mobile && mode === 'hover' && !pointerInside && !focusInside) onOpenChange(false);
closeTimer = undefined;
}, 180);
}
function enter(event: PointerEvent) {
if (mobile || event.pointerType !== 'mouse') return;
pointerInside = true;
clearCloseTimer();
if (mode === 'hover') onOpenChange(true);
}
function leave(event: PointerEvent) {
if (event.pointerType !== 'mouse') return;
pointerInside = false;
scheduleClose();
}
function focusIn() {
focusInside = true;
clearCloseTimer();
if (!mobile && mode === 'hover') onOpenChange(true);
}
function focusOut(event: FocusEvent) {
if (event.relatedTarget instanceof Node && frame.contains(event.relatedTarget)) return;
focusInside = false;
scheduleClose();
}
function toggleMode(event: MouseEvent) {
const next = mode === 'pinned' ? 'hover' : 'pinned';
// A pointer click should not leave keyboard focus holding the preview open.
if (event.detail > 0 && document.activeElement instanceof HTMLElement && frame.contains(document.activeElement)) {
document.activeElement.blur();
}
clearCloseTimer();
onModeChange(next);
// Footer clicks close immediately with the layout change; only a real
// hover or keyboard focus keeps an existing preview open.
if (!mobile) onOpenChange(next === 'hover' && (pointerInside || focusInside));
}
$effect(() => {
mobile;
pointerInside = false;
focusInside = false;
clearCloseTimer();
});
onDestroy(clearCloseTimer);
</script> </script>
<div class="sidebar-frame" class:folded> <aside
<Bevel as="div" class="sidebar-frame__bevel" top={false} bottom={false} left={false} fill> class="sidebar-frame"
<aside class="sidebar-frame__surface" aria-label="Sidebar"> class:folded={!open}
<div class="sidebar-frame-content" inert={folded} aria-hidden={folded}> class:hover-mode={mode === 'hover'}
{@render children()} aria-label="Sidebar"
bind:this={frame}
onfocusin={focusIn}
onfocusout={focusOut}
>
<Bevel as="div" class="sidebar-frame__bevel" top={false} bottom={false} left={false}>
<div class="sidebar-frame__surface">
<div
class="sidebar-hover-region"
onpointerenter={enter}
onpointerleave={leave}
onpointercancel={leave}
>
<div class="sidebar-frame-content" inert={!open} aria-hidden={!open}>
{@render children()}
</div>
</div> </div>
<div class="sidebar-control-row"> <div class="sidebar-control-row">
<button <button
class="sidebar-fold-button" class="sidebar-fold-button"
type="button" type="button"
aria-label={folded ? 'Unfold sidebar' : 'Fold sidebar'} aria-label={mode === 'pinned' ? 'Unpin sidebar' : 'Pin sidebar'}
aria-expanded={!folded} aria-pressed={mode === 'pinned'}
title={folded ? 'Unfold sidebar' : 'Fold sidebar'} title={mode === 'pinned' ? 'Always visible — switch to show on hover' : 'Show on hover — pin sidebar'}
onclick={toggleFold} onpointerdown={(event) => {
// Do not open a focus preview before a pointer click changes mode.
// Keyboard focus still opens and holds the preview as usual.
if (event.button === 0) event.preventDefault();
}}
onclick={toggleMode}
> >
{#if folded} <SidebarToggleIcon open={mode === 'pinned'} />
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
</svg>
{:else}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
</svg>
{/if}
</button> </button>
</div> </div>
</aside> </div>
</Bevel> </Bevel>
</div> </aside>
@@ -0,0 +1,22 @@
<script lang="ts">
let { open }: { open: boolean } = $props();
</script>
<!-- Lucide panel-right-close / panel-right-open; ISC license: /licenses/lucide.txt -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
data-icon={open ? 'panel-right-close' : 'panel-right-open'}
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M15 3v18" />
<path d={open ? 'm8 9 3 3-3 3' : 'm10 15-3-3 3-3'} />
</svg>
@@ -5,6 +5,7 @@
--sidebar-item-hover: oklch(96% 0 0); --sidebar-item-hover: oklch(96% 0 0);
--sidebar-item-active: oklch(92.5% 0 0); --sidebar-item-active: oklch(92.5% 0 0);
--sidebar-width: clamp(220px, 20vw, 280px); --sidebar-width: clamp(220px, 20vw, 280px);
--sidebar-rail-width: calc(32px + var(--space-2) + var(--space-2) + 1px);
--sidebar-motion-duration: 220ms; --sidebar-motion-duration: 220ms;
--sidebar-motion-easing: cubic-bezier(0.2, 0, 0, 1); --sidebar-motion-easing: cubic-bezier(0.2, 0, 0, 1);
@@ -12,13 +13,19 @@
grid-column: 1; grid-column: 1;
grid-row: 1 / 3; grid-row: 1 / 3;
width: var(--sidebar-width); width: var(--sidebar-width);
/* Animate reserved main-content space, independently of the panel. */
transition: width var(--sidebar-motion-duration) var(--sidebar-motion-easing); transition: width var(--sidebar-motion-duration) var(--sidebar-motion-easing);
position: relative;
z-index: 40;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow: hidden; overflow: visible;
} }
.sidebar-frame .bevel.sidebar-frame__bevel { .sidebar-frame .bevel.sidebar-frame__bevel {
width: 100%; /* Keep the open panel's width identical in pinned and overlay modes. */
width: var(--sidebar-width);
transition: width var(--sidebar-motion-duration) var(--sidebar-motion-easing);
background: var(--bg);
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
} }
@@ -29,7 +36,7 @@
height: 100%; height: 100%;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
padding-block: var(--space-4); padding-top: var(--space-4);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.sidebar-frame { .sidebar-frame {
@@ -38,11 +45,17 @@
} }
} }
.sidebar-frame.folded { .sidebar-frame.folded {
width: calc(32px + var(--space-2) + var(--space-2) + 1px); width: var(--sidebar-rail-width);
overflow: hidden;
}
.sidebar-hover-region {
flex: 1 1 0;
min-width: 0;
min-height: 0;
overflow: hidden; overflow: hidden;
} }
.sidebar-frame-content { .sidebar-frame-content {
flex: 1 1 auto; height: 100%;
width: calc(var(--sidebar-width) - 1px); width: calc(var(--sidebar-width) - 1px);
transform: translateX(0); transform: translateX(0);
visibility: visible; visibility: visible;
@@ -58,6 +71,24 @@
visibility: hidden; visibility: hidden;
transition-delay: 0s, var(--sidebar-motion-duration); transition-delay: 0s, var(--sidebar-motion-duration);
} }
.sidebar-frame.hover-mode {
position: relative;
z-index: 40;
width: var(--sidebar-rail-width);
overflow: visible;
}
.sidebar-frame.hover-mode .bevel.sidebar-frame__bevel {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: var(--sidebar-width);
overflow: hidden;
background: var(--bg);
}
.sidebar-frame.hover-mode.folded .bevel.sidebar-frame__bevel {
width: var(--sidebar-rail-width);
}
.sidebar-frame-content, .sidebar-frame-content,
.global-sidebar, .global-sidebar,
.workspace-sidebar, .workspace-sidebar,
@@ -112,13 +143,21 @@
outline-offset: 1px; outline-offset: 1px;
} }
.sidebar-control-row { .sidebar-control-row {
position: relative;
display: flex; display: flex;
justify-content: flex-end; flex: 0 0 auto;
justify-content: flex-start;
gap: var(--space-1); gap: var(--space-1);
padding: var(--space-2);
} }
.sidebar-control-row { .sidebar-control-row::before {
margin-top: auto; content: "";
padding: var(--space-4) var(--space-2) 0; position: absolute;
top: 0;
inset-inline: var(--space-2);
height: 1px;
background: var(--line);
pointer-events: none;
} }
.workspace-label { .workspace-label {
flex: 1 1 auto; flex: 1 1 auto;
@@ -647,7 +686,8 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.sidebar-frame { .sidebar-frame,
.sidebar-frame.hover-mode {
--sidebar-width: 100%; --sidebar-width: 100%;
z-index: 20; z-index: 20;
grid-column: 1; grid-column: 1;
@@ -660,7 +700,8 @@
transition: transform var(--sidebar-motion-duration) var(--sidebar-motion-easing), transition: transform var(--sidebar-motion-duration) var(--sidebar-motion-easing),
visibility 0s; visibility 0s;
} }
.sidebar-frame.folded { .sidebar-frame.folded,
.sidebar-frame.hover-mode.folded {
width: 100%; width: 100%;
transform: translateX(-100%); transform: translateX(-100%);
visibility: hidden; visibility: hidden;
@@ -673,8 +714,11 @@
visibility: inherit; visibility: inherit;
transition: none; transition: none;
} }
.sidebar-control-row { .sidebar-frame.hover-mode .bevel.sidebar-frame__bevel,
display: none; .sidebar-frame.hover-mode.folded .bevel.sidebar-frame__bevel {
position: static;
width: 100%;
transition: none;
} }
} }
@@ -58,39 +58,6 @@
margin-top: var(--space-1); margin-top: var(--space-1);
} }
.workspace-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.workspace-action-card {
display: grid;
gap: var(--space-1);
padding: var(--space-4);
border: 1px solid var(--line);
background: var(--bg-raised);
color: inherit;
text-decoration: none;
}
.workspace-action-card:hover,
.workspace-action-card:focus-visible {
border-color: var(--accent);
}
.workspace-action-card span,
.workspace-action-card small {
color: var(--text-muted);
}
.workspace-action-card span {
font-size: var(--font-size-compact);
}
.workspace-action-card strong {
color: var(--text-strong);
}
@media (max-width: 720px) {
.workspace-actions {
grid-template-columns: 1fr;
}
}
.runtime-card { .runtime-card {
padding: 0; padding: 0;
} }
+42 -26
View File
@@ -11,6 +11,7 @@
} from '$lib/workspace/header/context'; } from '$lib/workspace/header/context';
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte'; import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte'; import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte';
import SidebarToggleIcon from '$lib/workspace/sidebar/SidebarToggleIcon.svelte';
import { SIDEBAR_CONTEXT, type SidebarController, type SidebarSnippet } from '$lib/workspace/sidebar/context'; import { SIDEBAR_CONTEXT, type SidebarController, type SidebarSnippet } from '$lib/workspace/sidebar/context';
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack'; import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
import '../app.css'; import '../app.css';
@@ -25,31 +26,42 @@
const headerOverrides = createOverrideStack<HeaderSnippet>((activeHeader) => { const headerOverrides = createOverrideStack<HeaderSnippet>((activeHeader) => {
header = activeHeader; header = activeHeader;
}); });
// Browser-wide layout preference; independent of authentication and Workspace. // Only the display mode persists; hover/touch previews never do.
const sidebarFoldStorageKey = 'yoi.sidebar.folded.v1'; const sidebarModeStorageKey = 'yoi.sidebar.mode.v1';
function loadSidebarFolded(): boolean { const legacySidebarStorageKey = 'yoi.sidebar.folded.v1';
if (typeof window === 'undefined') return false; function loadSidebarMode(): 'pinned' | 'hover' {
if (typeof window === 'undefined') return 'pinned';
try { try {
return window.localStorage.getItem(sidebarFoldStorageKey) === 'true'; const mode = window.localStorage.getItem(sidebarModeStorageKey);
if (mode === 'pinned' || mode === 'hover') return mode;
return window.localStorage.getItem(legacySidebarStorageKey) === 'true' ? 'hover' : 'pinned';
} catch { } catch {
return false; return 'pinned';
} }
} }
let sidebarFolded = $state(loadSidebarFolded()); let sidebarMode = $state(loadSidebarMode());
let sidebarTransientOpen = $state(false);
const mobileLayout = new MediaQuery('(max-width: 760px)'); const mobileLayout = new MediaQuery('(max-width: 760px)');
const sidebarOpen = $derived((!mobileLayout.current && sidebarMode === 'pinned') || sidebarTransientOpen);
$effect(() => { $effect(() => {
const folded = sidebarFolded; const mode = sidebarMode;
try { try {
window.localStorage.setItem(sidebarFoldStorageKey, String(folded)); window.localStorage.setItem(sidebarModeStorageKey, mode);
window.localStorage.removeItem(legacySidebarStorageKey);
} catch { } catch {
// Storage can be blocked or full; folding must still work for this page. // Blocked/full storage must not prevent mode changes in this page.
} }
}); });
$effect(() => {
mobileLayout.current;
sidebarTransientOpen = false;
});
function toggleSidebar() { function toggleSidebar() {
sidebarFolded = !sidebarFolded; sidebarTransientOpen = !sidebarOpen;
} }
provideHeaderController({ provideHeaderController({
@@ -62,8 +74,14 @@
<WorkspaceAlerts /> <WorkspaceAlerts />
<div class="app-shell" class:sidebar-open={!sidebarFolded}> <div class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}>
<SidebarFrame bind:folded={sidebarFolded}> <SidebarFrame
mode={sidebarMode}
open={sidebarOpen}
mobile={mobileLayout.current}
onModeChange={(mode) => { sidebarMode = mode; }}
onOpenChange={(open) => { sidebarTransientOpen = open; }}
>
<GlobalSidebar <GlobalSidebar
currentPath={page.url.pathname} currentPath={page.url.pathname}
content={sidebar} content={sidebar}
@@ -80,20 +98,12 @@
<button <button
class="app-shell__icon-button app-shell__mobile-sidebar-toggle" class="app-shell__icon-button app-shell__mobile-sidebar-toggle"
type="button" type="button"
aria-label={sidebarFolded ? 'Show sidebar' : 'Hide sidebar'} aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
aria-expanded={!sidebarFolded} aria-expanded={sidebarOpen}
title={sidebarFolded ? 'Show sidebar' : 'Hide sidebar'} title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
onclick={toggleSidebar} onclick={toggleSidebar}
> >
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24"> <SidebarToggleIcon open={sidebarOpen} />
{#if sidebarFolded}
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
{:else}
<path d="m11 17-5-5 5-5" />
<path d="m18 17-5-5 5-5" />
{/if}
</svg>
</button> </button>
<a class="app-shell__icon-button" href="/account" aria-label="Open Account" title="Account"> <a class="app-shell__icon-button" href="/account" aria-label="Open Account" title="Account">
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24"> <svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
@@ -104,7 +114,7 @@
</nav> </nav>
</header> </header>
</Bevel> </Bevel>
<main class="app-shell__main" inert={mobileLayout.current && !sidebarFolded}> <main class="app-shell__main" inert={mobileLayout.current && sidebarOpen}>
{@render children()} {@render children()}
</main> </main>
</div> </div>
@@ -203,6 +213,12 @@
padding: var(--space-4); padding: var(--space-4);
} }
@media (hover: none) {
.sidebar-hover-mode .app-shell__mobile-sidebar-toggle {
display: inline-flex;
}
}
@media (max-width: 760px) { @media (max-width: 760px) {
.app-shell { .app-shell {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -2,88 +2,226 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { createRawSnippet } from "svelte"; import { createRawSnippet, tick } from "svelte";
import { logout } from "$lib/workspace/auth/api"; import { logout } from "$lib/workspace/auth/api";
import Layout from "./+layout.svelte"; import Layout from "./+layout.svelte";
vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/") } })); vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/") } }));
const storageKey = "yoi.sidebar.mode.v1";
const storageKey = "yoi.sidebar.folded.v1"; const legacyKey = "yoi.sidebar.folded.v1";
const props = { const props = {
params: {}, params: {},
children: createRawSnippet(() => ({ render: () => "<p>Page content</p>" })), children: createRawSnippet(() => ({ render: () => "<p>Page content</p>" })),
data: { accessibleWorkspaces: [], workspaceCatalogError: null }, data: { accessibleWorkspaces: [], workspaceCatalogError: null },
}; };
const frame = () => screen.getByRole("complementary", { name: "Sidebar" });
const folded = () => frame().classList.contains("folded");
const hoverRegion = () => frame().querySelector<HTMLElement>(".sidebar-hover-region")!;
const enter = () => fireEvent.pointerEnter(hoverRegion(), { pointerType: "mouse" });
const leave = () => fireEvent.pointerLeave(hoverRegion(), { pointerType: "mouse" });
const delay = () => vi.advanceTimersByTimeAsync(200);
beforeEach(() => localStorage.clear()); beforeEach(() => { localStorage.clear(); vi.useFakeTimers(); });
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.useRealTimers();
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
localStorage.clear(); localStorage.clear();
}); });
test("sidebar starts unfolded and persists both desktop and mobile toggles", async () => { test("unpinning changes mode, and pointer departure closes the preview", async () => {
const view = render(Layout, { props }); render(Layout, { props });
expect(screen.getByRole("button", { name: "Fold sidebar" }).getAttribute("aria-expanded")).toBe("true"); expect(screen.getByRole("button", { name: "Unpin sidebar" }).getAttribute("aria-pressed")).toBe("true");
const content = view.container.querySelector<HTMLElement>(".sidebar-frame-content")!; await enter();
screen.getByRole("button", { name: "Unpin sidebar" }).focus();
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), { detail: 1 });
expect(localStorage.getItem(storageKey)).toBe("hover");
expect(folded()).toBe(false);
await leave();
await delay();
expect(folded()).toBe(true);
});
test("footer unpin closes in the same update as the mode change, without the hover delay", async () => {
render(Layout, { props });
const button = screen.getByRole("button", { name: "Unpin sidebar" });
button.focus();
await fireEvent.click(button, { detail: 1 });
expect(localStorage.getItem(storageKey)).toBe("hover");
expect(folded()).toBe(true);
await delay();
expect(folded()).toBe(true);
await fireEvent.click(button, { detail: 1 });
expect(localStorage.getItem(storageKey)).toBe("pinned");
expect(folded()).toBe(false);
});
test("footer pointer clicks release previous sidebar link focus before unpinning", async () => {
render(Layout, { props });
frame().querySelector<HTMLAnchorElement>("a")!.focus();
await tick();
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), { detail: 1 });
expect(folded()).toBe(true);
});
test("keyboard mode switching preserves the focused preview", async () => {
render(Layout, { props });
const button = screen.getByRole("button", { name: "Unpin sidebar" });
button.focus();
await fireEvent.click(button, { detail: 0 });
expect(localStorage.getItem(storageKey)).toBe("hover");
await delay();
expect(folded()).toBe(false);
});
test.each(["pinned", "hover"])("restores %s mode without persisting a transient open state", (mode) => {
localStorage.setItem(storageKey, mode);
render(Layout, { props });
expect(folded()).toBe(mode === "hover");
expect(localStorage.getItem(storageKey)).toBe(mode);
});
test.each([true, false])("migrates legacy folded=%s to a mode", (value) => {
localStorage.setItem(legacyKey, String(value));
render(Layout, { props });
expect(localStorage.getItem(storageKey)).toBe(value ? "hover" : "pinned");
expect(localStorage.getItem(legacyKey)).toBeNull();
expect(folded()).toBe(value);
});
test("a saved mode takes precedence over the legacy preference", () => {
localStorage.setItem(legacyKey, "true");
localStorage.setItem(storageKey, "pinned");
render(Layout, { props });
expect(folded()).toBe(false);
});
test("hover keeps the same content node, delays closing, and cancels closing on reentry", async () => {
localStorage.setItem(storageKey, "hover");
render(Layout, { props });
const content = frame().querySelector<HTMLElement>(".sidebar-frame-content")!;
expect(content.inert).toBe(true);
await enter();
expect(folded()).toBe(false);
expect(content.inert).toBe(false); expect(content.inert).toBe(false);
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" })); await leave();
expect(view.container.querySelector(".sidebar-frame-content")).toBe(content); await vi.advanceTimersByTimeAsync(100);
expect(folded()).toBe(false);
await enter();
await delay();
expect(folded()).toBe(false);
await leave();
await delay();
expect(folded()).toBe(true);
expect(frame().querySelector(".sidebar-frame-content")).toBe(content);
expect(content.inert).toBe(true); expect(content.inert).toBe(true);
expect(content.getAttribute("aria-hidden")).toBe("true"); expect(content.getAttribute("aria-hidden")).toBe("true");
expect(localStorage.getItem(storageKey)).toBe("true"); expect(localStorage.getItem(storageKey)).toBe("hover");
expect(view.container.querySelector(".app-shell")?.classList.contains("sidebar-open")).toBe(false);
await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" }));
expect(content.inert).toBe(false);
expect(content.getAttribute("aria-hidden")).toBe("false");
expect(localStorage.getItem(storageKey)).toBe("false");
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull();
await fireEvent.click(screen.getByRole("button", { name: "Hide sidebar" }));
expect(localStorage.getItem(storageKey)).toBe("true");
await fireEvent.click(screen.getByRole("button", { name: "Unfold sidebar" }));
expect(localStorage.getItem(storageKey)).toBe("false");
}); });
test.each([true, false])("restores stored folded=%s without overwriting it with the default", (folded) => { test("footer hover does not open or hold the preview, but its button still changes mode", async () => {
localStorage.setItem(storageKey, String(folded)); localStorage.setItem(storageKey, "hover");
const writes = vi.spyOn(Storage.prototype, "setItem"); render(Layout, { props });
const view = render(Layout, { props }); const footer = frame().querySelector<HTMLElement>(".sidebar-control-row")!;
expect(screen.getByRole("button", { name: folded ? "Unfold sidebar" : "Fold sidebar" }).getAttribute("aria-expanded")).toBe(String(!folded)); const button = screen.getByRole("button", { name: "Pin sidebar" });
expect(view.container.querySelector(".app-shell")?.classList.contains("sidebar-open")).toBe(!folded); expect(button.querySelector('[data-icon="panel-right-open"] rect')).not.toBeNull();
expect(writes.mock.calls.every(([key, value]) => key !== storageKey || value === String(folded))).toBe(true); await fireEvent.pointerEnter(footer, { pointerType: "mouse" });
await fireEvent.pointerEnter(button, { pointerType: "mouse" });
await delay();
expect(folded()).toBe(true);
await enter();
expect(folded()).toBe(false);
await leave();
await fireEvent.pointerEnter(footer, { pointerType: "mouse" });
await delay();
expect(folded()).toBe(true);
await fireEvent.click(button, { detail: 1 });
expect(folded()).toBe(false);
expect(button.querySelector('[data-icon="panel-right-close"] rect')).not.toBeNull();
}); });
test("fold preference survives logout and a fresh app mount", async () => { test("keyboard focus opens the preview and holds it until focus leaves", async () => {
localStorage.setItem(storageKey, "hover");
render(Layout, { props });
screen.getByRole("button", { name: "Pin sidebar" }).focus();
await tick();
expect(folded()).toBe(false);
await enter();
await leave();
await delay();
expect(folded()).toBe(false);
screen.getByRole("link", { name: "Open Account" }).focus();
await delay();
expect(folded()).toBe(true);
});
test("pinning a preview keeps it open after pointer and focus leave", async () => {
localStorage.setItem(storageKey, "hover");
render(Layout, { props });
await enter();
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }), { detail: 1 });
await leave();
await delay();
expect(folded()).toBe(false);
expect(localStorage.getItem(storageKey)).toBe("pinned");
});
test("mode survives logout and remount, but an open preview does not", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ status: "logged_out" }))); vi.stubGlobal("fetch", vi.fn(async () => Response.json({ status: "logged_out" })));
localStorage.setItem(storageKey, "hover");
const view = render(Layout, { props }); const view = render(Layout, { props });
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" })); await enter();
await logout(); await logout();
view.unmount(); view.unmount();
render(Layout, { props }); render(Layout, { props });
expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull(); expect(folded()).toBe(true);
expect(localStorage.getItem(storageKey)).toBe("true"); expect(localStorage.getItem(storageKey)).toBe("hover");
}); });
test("invalid storage falls back to unfolded", () => { test("touch does not hover; mobile header opens temporarily without changing mode", async () => {
localStorage.setItem(storageKey, "not-a-boolean"); const matchMedia = window.matchMedia.bind(window);
vi.spyOn(window, "matchMedia").mockImplementation((query) => {
const result = matchMedia(query);
Object.defineProperty(result, "matches", { value: query === "(max-width: 760px)" });
return result;
});
localStorage.setItem(storageKey, "hover");
const view = render(Layout, { props });
await fireEvent.pointerEnter(frame(), { pointerType: "touch" });
expect(folded()).toBe(true);
await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" }));
expect(folded()).toBe(false);
expect(view.container.querySelector<HTMLElement>("main")!.inert).toBe(true);
await fireEvent.pointerLeave(frame(), { pointerType: "touch" });
await delay();
expect(folded()).toBe(false);
await fireEvent.click(screen.getByRole("button", { name: "Hide sidebar" }));
expect(folded()).toBe(true);
expect(view.container.querySelector<HTMLElement>("main")!.inert).toBe(false);
expect(localStorage.getItem(storageKey)).toBe("hover");
});
test("invalid storage falls back to pinned", () => {
localStorage.setItem(storageKey, "not-a-mode");
render(Layout, { props }); render(Layout, { props });
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); expect(folded()).toBe(false);
}); });
test("unavailable storage does not prevent folding", async () => { test("unavailable storage does not prevent mode switching", async () => {
vi.spyOn(window, "localStorage", "get").mockImplementation(() => { throw new DOMException("Blocked", "SecurityError"); }); vi.spyOn(window, "localStorage", "get").mockImplementation(() => { throw new DOMException("Blocked", "SecurityError"); });
render(Layout, { props }); render(Layout, { props });
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" })); await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }));
expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull(); await delay();
await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" })); expect(folded()).toBe(true);
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }));
expect(folded()).toBe(false);
}); });
test("full storage still restores the saved value and allows toggles", async () => { test("full storage still restores the mode and allows changes", async () => {
localStorage.setItem(storageKey, "true"); localStorage.setItem(storageKey, "hover");
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); }); vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); });
render(Layout, { props }); render(Layout, { props });
await fireEvent.click(screen.getByRole("button", { name: "Unfold sidebar" })); await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }));
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull(); expect(folded()).toBe(false);
}); });
@@ -1,103 +1,53 @@
<script lang="ts"> <script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http'; import { invalidate } from '$app/navigation';
import DashboardSection from '$lib/workspace/home/DashboardSection.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? data.workspaceId); let refreshing = $state(false);
let ticketsHref = $derived(workspaceRoute(workspaceId, '/tickets')); let refreshError = $state<string | null>(null);
let runtimeSettingsHref = $derived(workspaceRoute(workspaceId, '/settings/runtimes')); async function refresh() {
let workersHref = $derived(workspaceRoute(workspaceId, '/workers')); if (refreshing) return;
refreshing = true;
refreshError = null;
try {
await invalidate('workspace:home');
await Promise.all(Object.values(data.dashboard));
} catch {
refreshError = 'Could not refresh. Try again.';
} finally {
refreshing = false;
}
}
</script> </script>
<svelte:head> <svelte:head><title>{data.workspace?.display_name ?? 'Workspace'} · Yoi</title></svelte:head>
<title>Yoi Workspace Control Plane</title>
<meta name="description" content="Local single-workspace Yoi control plane bootstrap" />
</svelte:head>
<section class="card"> <div class="workspace-home">
<h2>Workspace</h2> <header class="home-header">
{#if data.workspace} <h1>Activity</h1>
<dl> <button type="button" onclick={refresh} disabled={refreshing}>{refreshing ? 'Refreshing…' : 'Refresh'}</button>
<div> </header>
<dt>ID</dt> {#if refreshError}<p class="home-error" role="alert">{refreshError}</p>{/if}
<dd>{data.workspace.workspace_id}</dd> <div class="home-columns">
</div> <DashboardSection id="home-attention" title="Needs attention" empty="No open Merge Requests." result={data.dashboard.reviews} />
<div> <DashboardSection id="home-active" title="In progress" empty="No in-progress or queued tickets." result={data.dashboard.active} />
<dt>Name</dt> <DashboardSection id="home-recent" title="Recent updates" empty="No done / closed tickets or Objectives yet." result={data.dashboard.recent} />
<dd>{data.workspace.display_name}</dd> </div>
</div> </div>
<div>
<dt>Record authority</dt>
<dd>{data.workspace.record_authority}</dd>
</div>
<div>
<dt>Host / Worker bridge</dt>
<dd>{data.workspace.extension_points.host_worker_bridge.status}</dd>
</div>
</dl>
{:else if data.workspaceError}
<p class="error">{data.workspaceError}</p>
{:else}
<p>Waiting for <code>/api/workspace</code>…</p>
{/if}
</section>
<section class="workspace-actions" aria-label="Workspace sections"> <style>
<a class="workspace-action-card" href={ticketsHref}> .workspace-home { container-type: inline-size; min-width: 0; }
<span>Tickets</span> .home-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-bottom: var(--space-5); }
<strong>Browse workspace tickets</strong> h1 { margin: 0; font-size: var(--font-size-title); line-height: var(--line-height-title); color: var(--text-strong); }
<small>Read typed Ticket records</small> button { flex: 0 0 auto; padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-soft); color: var(--text); background: transparent; cursor: pointer; }
</a> button:hover:not(:disabled), button:focus-visible { background: var(--interactive-hover); }
<a class="workspace-action-card" href={runtimeSettingsHref}> button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
<span>Runtimes</span> button:active:not(:disabled) { background: var(--interactive-selected); }
<strong>Open admin Runtimes</strong> button:disabled { opacity: 0.6; cursor: wait; }
<small>{data.hosts?.items.length ?? 0} host{(data.hosts?.items.length ?? 0) === 1 ? '' : 's'} visible</small> .home-columns { display: grid; gap: var(--space-6); align-items: start; }
</a> .home-error { color: var(--danger); overflow-wrap: anywhere; }
<a class="workspace-action-card" href={workersHref}> @container (min-width: 52rem) {
<span>Workers</span> .home-columns { grid-template-columns: repeat(3, minmax(0, 1fr)); }
<strong>Open worker list</strong> }
<small>Inspect status and attach to consoles</small> </style>
</a>
</section>
<section class="card">
<h2>Hosts</h2>
{#if data.hosts}
{#if data.hosts.items.length === 0}
<p>No local Hosts are visible.</p>
{:else}
<div class="stack">
{#each data.hosts.items as host}
<article class="runtime-card">
<div class="runtime-heading">
<strong>{host.label}</strong>
<span class:warn={host.status !== 'available'}>{host.status}</span>
</div>
<dl>
<div>
<dt>ID</dt>
<dd><code>{host.host_id}</code></dd>
</div>
<div>
<dt>Kind</dt>
<dd>{host.kind}</dd>
</div>
<div>
<dt>Runtime</dt>
<dd><code>{host.runtime_id}</code></dd>
</div>
<div>
<dt>Platform</dt>
<dd>{host.os} / {host.arch}</dd>
</div>
</dl>
</article>
{/each}
</div>
{/if}
{:else if data.hostsError}
<p class="error">{data.hostsError}</p>
{:else}
<p>Waiting for <code>/api/hosts</code>…</p>
{/if}
</section>
@@ -1,28 +1,10 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadDashboard } from "$lib/workspace/home/dashboard";
import {
parseHostListResponse,
type HostListResponse,
} from "$lib/workspace/api/workspace-model";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
const HOST_API_LOAD_POLICY = { export const load: PageLoad = ({ fetch, params, depends }) => {
diagnosticLabel: "Host API", depends("workspace:home");
maxResponseBytes: 4 * 1024 * 1024,
} as const;
export const load: PageLoad = async ({ fetch, params }) => {
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path);
const hosts = await loadJson<HostListResponse>(
fetch,
apiPath("/hosts"),
undefined,
parseHostListResponse,
HOST_API_LOAD_POLICY,
);
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
hosts: hosts.data, dashboard: loadDashboard(fetch, params.workspaceId),
hostsError: hosts.error,
}; };
}; };
@@ -0,0 +1,266 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
within,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import type { ComponentProps } from "svelte";
import { invalidate } from "$app/navigation";
import Home from "./+page.svelte";
import { load } from "./+page";
import {
type Dashboard,
type DashboardFeed,
loadDashboard,
recentRows,
} from "$lib/workspace/home/dashboard";
import {
dashboardFixture,
fixtureDetail,
} from "$lib/workspace/home/dashboard.test-fixtures";
vi.mock("$app/navigation", () => ({ invalidate: vi.fn(async () => {}) }));
type Data = ComponentProps<typeof Home>["data"];
function fixtureFetch(failPath?: string) {
return vi.fn(async (path: RequestInfo | URL, _init?: RequestInit) => {
const url = new URL(String(path), "https://example.test");
const [, id, resource] = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/)!;
return resource === failPath
? Response.json({}, { status: 503 })
: Response.json(
dashboardFixture(resource, url.searchParams, decodeURIComponent(id)),
);
});
}
function data(id = "home-owner", fetchFn: typeof fetch = fixtureFetch()): Data {
return {
workspaceId: id,
workspace: { workspace_id: id, display_name: "Home review" },
dashboard: loadDashboard(fetchFn, id),
} as unknown as Data;
}
function mount(value = data()) {
return render(Home, {
props: { params: { workspaceId: value.workspaceId }, data: value },
});
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("Home shows actual reviews, active assignments/blockers, and recent records, not navigation tiles", async () => {
mount();
expect(await screen.findByText("Changes requested")).toBeTruthy();
expect(await screen.findByText("Blocked by T-99")).toBeTruthy();
expect(screen.getByText("W-7")).toBeTruthy();
expect(await screen.findByText("Improve daily Workspace operation"))
.toBeTruthy();
expect(screen.getByText("Review pending")).toBeTruthy();
expect(screen.getByText("Approved")).toBeTruthy();
expect(screen.queryByRole("navigation")).toBeNull();
expect(screen.queryByRole("heading", { name: "Settings" })).toBeNull();
expect(screen.getAllByRole("button")).toHaveLength(1);
expect(document.body.textContent).not.toMatch(
/internal-worker|internal-merge|internal-runtime|Record authority/,
);
for (const link of screen.getAllByRole("link")) {
expect(link.getAttribute("href")).toMatch(
/^\/w\/home-owner\/(tickets|objectives|merge-requests)\/[^/]+$/,
);
}
});
test("loader only uses bounded read-only supported endpoints and advertises explicit refresh dependency", async () => {
const fetch = fixtureFetch();
const depends = vi.fn();
const result = await load(
{
fetch,
params: { workspaceId: "home-owner" },
depends,
} as unknown as Parameters<typeof load>[0],
);
await Promise.all(Object.values(result!.dashboard));
expect(depends).toHaveBeenCalledWith("workspace:home");
expect(fetch).toHaveBeenCalledTimes(7);
for (const [path, init] of fetch.mock.calls) {
const url = new URL(String(path), "https://example.test");
expect(init?.method ?? "GET").toBe("GET");
expect(init?.signal).toBeTruthy();
expect(url.pathname).not.toMatch(/query|hosts|workers|settings/);
if (!url.pathname.includes("/tickets/T-")) {
expect(url.searchParams.get("limit")).toBe("5");
}
}
expect(fetch.mock.calls.some(([url]) => String(url).includes("states=done")))
.toBe(true);
expect(
fetch.mock.calls.some(([url]) => String(url).includes("states=closed")),
).toBe(true);
});
test("recent updates merge independently limited sources in actual update order, not state priority", async () => {
const feed = await data().dashboard.recent;
expect(feed.errors).toEqual([]);
expect(feed.rows.map((row) => row.reference)).toEqual([
"T-105",
"O-12",
"T-103",
"T-104",
]);
const rows = Array.from(
{ length: 10 },
(_, index) => ({
...feed.rows[0],
key: String(index),
updatedAt: index === 9 ? null : `2026-01-01T0${index}:00:00Z`,
}),
);
expect(
recentRows([...rows, { ...rows[8], updatedAt: rows[0].updatedAt }]).map((
row,
) => row.key),
).toEqual([
"8",
"7",
"6",
"5",
"4",
]);
});
test("partial failure is not an empty/all-clear state and leaves sibling sections usable", async () => {
mount(data("home-owner", fixtureFetch("/merge-requests")));
expect((await screen.findByRole("alert")).textContent).toContain(
"Merge Requests",
);
expect(screen.queryByText("No open Merge Requests.")).toBeNull();
expect(await screen.findByText("Blocked by T-99")).toBeTruthy();
expect(await screen.findByText("Improve daily Workspace operation"))
.toBeTruthy();
});
test("one recent source failure preserves the other records and does not assert an empty feed", async () => {
const value = data("home-owner", fixtureFetch("/objectives"));
mount(value);
expect((await value.dashboard.recent).rows.map((row) => row.reference))
.toEqual(["T-105", "T-103", "T-104"]);
expect((await screen.findByRole("alert")).textContent).toContain(
"Objectives",
);
expect(screen.queryByText("No done / closed tickets or Objectives yet."))
.toBeNull();
});
test("a failed Ticket detail preserves the summary without inventing no-assignee or unblocked state", async () => {
const feed = await data("home-owner", fixtureFetch("/tickets/T-101"))
.dashboard.active;
expect(feed.rows[0].detail).toBe("Assignment and blockers unavailable");
expect(feed.rows[0].workerKey).toBeUndefined();
expect(feed.rows[0].attention).toBeUndefined();
expect(feed.errors[0]).toContain("T-101");
});
test("a concurrent Ticket transition leaves the active scope with a refresh notice", async () => {
const fallback = fixtureFetch();
const fetchFn: typeof fetch = (path, init) =>
String(path).endsWith("/tickets/T-101")
? Promise.resolve(Response.json({ ...fixtureDetail(), state: "done" }))
: fallback(path, init);
const feed = await data("home-owner", fetchFn).dashboard.active;
expect(feed.rows.map((row) => row.reference)).toEqual(["T-102"]);
expect(feed.notice).toContain("Work changed");
});
test("empty resources have scoped empty states and no fabricated totals", async () => {
mount(data("home-empty"));
expect(await screen.findByText("No open Merge Requests.")).toBeTruthy();
expect(await screen.findByText("No in-progress or queued tickets."))
.toBeTruthy();
expect(await screen.findByText("No done / closed tickets or Objectives yet."))
.toBeTruthy();
expect(screen.queryAllByRole("link")).toHaveLength(0);
});
test("slow review loading does not hide the other completed sections", async () => {
const value = data();
value.dashboard.reviews = new Promise(() => {});
mount(value);
expect(
within(screen.getByRole("region", { name: "Needs attention" })).getByRole(
"status",
).textContent,
).toContain("Loading needs attention");
expect(await screen.findByText("Blocked by T-99")).toBeTruthy();
expect(await screen.findByText("Improve daily Workspace operation"))
.toBeTruthy();
});
test("route reuse drops pending old Workspace results and updates scoped links", async () => {
let finish!: (feed: DashboardFeed) => void;
const value = data();
value.dashboard.reviews = new Promise((resolve) => {
finish = resolve;
});
const view = mount(value);
await view.rerender({ data: data("space / 日本") });
await screen.findByText("Changes requested");
finish({ rows: [], errors: ["OLD WORKSPACE ERROR"] });
await Promise.resolve();
expect(screen.queryByText("OLD WORKSPACE ERROR")).toBeNull();
for (const link of screen.getAllByRole("link")) {
expect(link.getAttribute("href")).toContain(
"/w/space%20%2F%20%E6%97%A5%E6%9C%AC/",
);
}
});
test("malformed review responses are unavailable rather than empty", async () => {
const fallback = fixtureFetch();
const fetchFn: typeof fetch = (path, init) =>
String(path).includes("/merge-requests?")
? Promise.resolve(Response.json({ items: "invalid" }))
: fallback(path, init);
const feed = await data("home-owner", fetchFn).dashboard.reviews;
expect(feed.rows).toEqual([]);
expect(feed.errors).toEqual([
"Merge Requests unavailable. Refresh to retry.",
]);
});
test("unknown review states are preserved and branch text never becomes HTML", async () => {
const fallback = fixtureFetch();
const fetchFn: typeof fetch = async (path, init) => {
const response = await fallback(path, init);
if (!String(path).includes("/merge-requests?")) return response;
const body = await response.json();
body.items[0].summary.review_status = "future_status";
body.items[0].summary.selector_from = '<img src=x onerror="alert(1)">';
return Response.json(body);
};
const view = mount(data("home-owner", fetchFn));
expect(await screen.findByText("future_status")).toBeTruthy();
expect(view.container.querySelector("img")).toBeNull();
expect(await screen.findByText(/<img src=x/)).toBeTruthy();
});
test("unavailable timestamps are not presented as current activity", async () => {
const value = data();
const feed = await value.dashboard.recent;
feed.rows[0].updatedAt = "invalid";
value.dashboard.recent = Promise.resolve(feed);
mount(value);
expect(await screen.findByText("Update time unavailable")).toBeTruthy();
});
test("Refresh invalidates only the Home data dependency", async () => {
const value = data();
mount(value);
await Promise.all(Object.values(value.dashboard));
await fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
expect(invalidate).toHaveBeenCalledWith("workspace:home");
});
@@ -1,395 +1,17 @@
<script lang="ts"> <script lang="ts">
import type { import WorkspaceIdentitySettings from '$lib/workspace/settings/WorkspaceIdentitySettings.svelte';
Diagnostic,
WorkspaceDeletionOperationResponse,
WorkspaceDeletionPreflightResponse,
WorkspaceDeletionRequest,
WorkspaceMetadataSettingsResponse,
WorkspaceSigningIdentityResponse,
} from '$lib/generated/legacy-server-api';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
import { disposeWorkspaceWorkersStore } from '$lib/workspace/sidebar/worker-subscription';
import {
getWorkspaceDeletion,
preflightWorkspaceDeletion,
startWorkspaceDeletion,
} from '$lib/workspace/settings/workspace-deletion-api';
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
import {
fetchWorkspaceMetadata,
fetchWorkspaceSigningIdentity,
provisionWorkspaceSigningIdentity,
updateWorkspaceMetadata,
} from '$lib/workspace/settings/profile-api';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
let signingIdentity = $state<WorkspaceSigningIdentityResponse | null>(null);
let identityLoading = $state(true);
let identityError = $state<string | null>(null);
let provisioningIdentity = $state(false);
let identityCopied = $state(false);
let identityBundleText = $derived(
signingIdentity?.public_bundle ? JSON.stringify(signingIdentity.public_bundle, null, 2) : ''
);
let displayNameDraft = $state('');
let loading = $state(true);
let submitting = $state(false);
let message = $state<string | null>(null);
let diagnostics = $state<Diagnostic[]>([]);
let deletionOpen = $state(false);
let deletionLoading = $state(false);
let deletionSubmitting = $state(false);
let deletionConfirmation = $state('');
let deletionPreflight = $state<WorkspaceDeletionPreflightResponse | null>(null);
let deletionOperation = $state<WorkspaceDeletionOperationResponse | null>(null);
let deletionRequest = $state<WorkspaceDeletionRequest | null>(null);
let deletionError = $state<string | null>(null);
function deletionStorageKey(): string {
return `yoi:workspace-deletion:${workspaceId}`;
}
$effect(() => {
if (!workspaceId) {
loading = false;
return;
}
let cancelled = false;
async function load() {
loading = true;
message = null;
try {
const response = await fetchWorkspaceMetadata(workspaceId);
if (!cancelled) {
workspaceMetadata = response;
displayNameDraft = response.display_name;
diagnostics = response.diagnostics;
if (data.workspace?.permissions.delete_workspace) {
try {
signingIdentity = await fetchWorkspaceSigningIdentity(workspaceId);
} catch (err) {
identityError = err instanceof Error ? err.message : 'Workspace identity request failed';
} finally {
identityLoading = false;
}
} else {
identityLoading = false;
}
}
} catch (err) {
if (!cancelled) {
message = err instanceof Error ? err.message : 'workspace settings request failed';
}
} finally {
if (!cancelled) loading = false;
}
}
load();
return () => {
cancelled = true;
};
});
async function submitWorkspaceName() {
if (!workspaceMetadata) return;
submitting = true;
message = null;
try {
const response = await updateWorkspaceMetadata(workspaceId, {
display_name: displayNameDraft,
revision: workspaceMetadata.revision
});
workspaceMetadata = response.workspace;
displayNameDraft = response.workspace.display_name;
diagnostics = response.diagnostics.concat(response.workspace.diagnostics);
message = 'Workspace display name updated.';
} catch (err) {
message = err instanceof Error ? err.message : 'workspace update failed';
} finally {
submitting = false;
}
}
async function provisionIdentity() {
provisioningIdentity = true;
identityError = null;
try {
signingIdentity = await provisionWorkspaceSigningIdentity(workspaceId);
} catch (err) {
identityError = err instanceof Error ? err.message : 'Workspace identity provisioning failed';
} finally {
provisioningIdentity = false;
}
}
async function copyIdentityBundle() {
const bundle = signingIdentity?.public_bundle;
if (!bundle) return;
identityCopied = false;
try {
await navigator.clipboard.writeText(JSON.stringify(bundle, null, 2));
identityCopied = true;
} catch (err) {
identityError = err instanceof Error ? err.message : 'Workspace identity bundle copy failed';
}
}
async function openDeletionConfirmation() {
deletionOpen = true;
deletionLoading = true;
deletionError = null;
deletionOperation = null;
deletionRequest = null;
sessionStorage.removeItem(deletionStorageKey());
deletionConfirmation = '';
try {
deletionPreflight = await preflightWorkspaceDeletion(workspaceId);
} catch (err) {
deletionError = err instanceof Error ? err.message : 'Workspace deletion preflight failed';
} finally {
deletionLoading = false;
}
}
async function trackDeletion(operationId: string) {
let operation = await getWorkspaceDeletion(operationId);
deletionOperation = operation;
while (operation.state === 'queued' || operation.state === 'running') {
await new Promise((resolve) => setTimeout(resolve, 500));
operation = await getWorkspaceDeletion(operation.operation_id);
deletionOperation = operation;
}
if (operation.state === 'succeeded') {
sessionStorage.removeItem(deletionStorageKey());
disposeWorkspaceMultiplexer(workspaceId);
disposeWorkspaceWorkersStore(workspaceId);
await goto('/');
}
}
function storedDeletionRequest(): WorkspaceDeletionRequest | null {
try {
const value: unknown = JSON.parse(sessionStorage.getItem(deletionStorageKey()) ?? 'null');
if (typeof value !== 'object' || value === null) return null;
const record = value as Record<string, unknown>;
if (
Object.keys(record).sort().join(',') !== 'confirmation,expected_revision,operation_id' ||
typeof record.operation_id !== 'string' || record.operation_id.length === 0 || record.operation_id.length > 128 ||
!/^[A-Za-z0-9_-]+$/.test(record.operation_id) ||
typeof record.expected_revision !== 'string' || record.expected_revision.length > 128 ||
typeof record.confirmation !== 'string' || record.confirmation !== data.workspace?.display_name || record.confirmation.length > 256
) return null;
return {
operation_id: record.operation_id,
expected_revision: record.expected_revision,
confirmation: record.confirmation,
};
} catch {
return null;
}
}
onMount(() => {
if (!data.workspace?.permissions.delete_workspace) return;
const request = storedDeletionRequest();
if (!request) return;
deletionRequest = request;
deletionConfirmation = request.confirmation;
deletionOpen = true;
deletionSubmitting = true;
void trackDeletion(request.operation_id)
.catch((err) => {
deletionError = err instanceof Error ? err.message : 'Workspace deletion status failed';
})
.finally(() => {
deletionSubmitting = false;
});
});
async function deleteWorkspace() {
if (!deletionPreflight && !deletionRequest) return;
deletionSubmitting = true;
deletionError = null;
try {
const request = deletionRequest ?? {
operation_id: crypto.randomUUID(),
expected_revision: deletionPreflight!.expected_revision,
confirmation: deletionConfirmation,
};
deletionRequest = request;
sessionStorage.setItem(deletionStorageKey(), JSON.stringify(request));
const operation = await startWorkspaceDeletion(workspaceId, request);
deletionOperation = operation;
await trackDeletion(operation.operation_id);
} catch (err) {
deletionError = err instanceof Error ? err.message : 'Workspace deletion failed';
} finally {
deletionSubmitting = false;
}
}
</script> </script>
<svelte:head> <svelte:head><title>Workspace Identity · Settings · Yoi</title></svelte:head>
<title>Workspace settings · Yoi Workspace</title>
</svelte:head>
<section class="card settings-section" aria-labelledby="workspace-settings-title"> {#if data.workspace}
<header class="settings-section-header"> {#key `${data.workspace.workspace_id}:${data.workspace.permissions.delete_workspace}`}
<div> <WorkspaceIdentitySettings
<p class="eyebrow">editable</p> workspaceId={data.workspace.workspace_id}
<h2 id="workspace-settings-title">Workspace Identity</h2> workspaceName={data.workspace.display_name}
</div> canDelete={data.workspace.permissions.delete_workspace}
<span class="badge success">Backend scoped</span> />
</header> {/key}
{#if loading}
<p class="status-message">Loading workspace settings…</p>
{:else}
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void submitWorkspaceName(); }}>
<label>
<span>Display name</span>
<input bind:value={displayNameDraft} autocomplete="off" />
</label>
<p class="settings-note">Workspace id: <code>{workspaceMetadata?.workspace_id ?? workspaceId}</code></p>
<button type="submit" disabled={submitting || !workspaceMetadata}>{submitting ? 'Saving…' : 'Save workspace name'}</button>
</form>
<dl class="settings-identity-list">
<div>
<dt>Source</dt>
<dd>{workspaceMetadata?.source ?? 'unknown'}</dd>
</div>
<div>
<dt>Revision</dt>
<dd><code>{workspaceMetadata?.revision ?? 'unknown'}</code></dd>
</div>
</dl>
{/if}
{#if message}
<p class="status-message" class:error={message.includes('failed')}>{message}</p>
{/if}
<DiagnosticsList {diagnostics} />
</section>
{#if data.workspace?.permissions.delete_workspace}
<section class="settings-section" aria-labelledby="workspace-identity-title">
<div class="section-heading">
<div>
<h2 id="workspace-identity-title">Workspace public identity</h2>
<p>Use this public bundle when connecting a Runtime to this Workspace.</p>
</div>
{#if signingIdentity?.public_bundle}
<button type="button" onclick={() => void copyIdentityBundle()}>
{identityCopied ? 'Copied' : 'Copy bundle'}
</button>
{/if}
</div>
{#if identityError}
<p class="status-message error">{identityError}</p>
{/if}
{#if identityLoading}
<p>Loading identity…</p>
{:else if signingIdentity?.identity.state === 'pending_provisioning'}
<p>This existing Workspace needs one explicit signing identity provisioning operation.</p>
<button
type="button"
disabled={provisioningIdentity}
onclick={() => void provisionIdentity()}
>{provisioningIdentity ? 'Provisioning…' : 'Provision identity'}</button>
{:else if signingIdentity?.public_bundle}
<dl class="metadata-list">
<div>
<dt>Key</dt>
<dd><code>{signingIdentity.identity.key_id}</code></dd>
</div>
<div>
<dt>Fingerprint</dt>
<dd><code>{signingIdentity.identity.public_key_fingerprint}</code></dd>
</div>
<div>
<dt>Revision</dt>
<dd><code>{signingIdentity.identity.revision}</code></dd>
</div>
</dl>
<label class="identity-bundle">
<span>Public identity bundle</span>
<textarea readonly rows="9" value={identityBundleText}></textarea>
</label>
{/if}
</section>
<section class="settings-section danger-zone" aria-labelledby="workspace-danger-title">
<div>
<h2 id="workspace-danger-title">Danger zone</h2>
<p>Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data.</p>
</div>
<button class="danger-button" type="button" onclick={() => void openDeletionConfirmation()}>Delete Workspace</button>
</section>
{/if} {/if}
{#if deletionOpen}
<div class="modal-backdrop" role="presentation">
<div class="deletion-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-workspace-title">
<h2 id="delete-workspace-title">Delete {deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? 'Workspace'}?</h2>
{#if deletionLoading}
<p>Loading deletion impact…</p>
{:else if deletionPreflight}
<p>This operation cannot be undone. It will remove:</p>
<ul>
<li>{deletionPreflight.resources.workers} Workers</li>
<li>{deletionPreflight.resources.workdirs} Workdirs</li>
<li>{deletionPreflight.resources.repositories} repositories</li>
<li>{deletionPreflight.resources.runtime_bindings} Runtime bindings</li>
<li>{deletionPreflight.resources.secrets} secret records</li>
<li>{deletionPreflight.resources.artifacts} artifacts</li>
</ul>
{#each deletionPreflight.blockers as blocker}
<p class="status-message error">{blocker.message}</p>
{/each}
<label>
<span>Type <strong>{deletionPreflight.display_name}</strong> to confirm</span>
<input bind:value={deletionConfirmation} autocomplete="off" />
</label>
{/if}
{#if deletionOperation}
<p class="status-message">Deletion state: {deletionOperation.state}</p>
{#each deletionOperation.blockers as blocker}
<p class="status-message error">{blocker.message}</p>
{/each}
{/if}
{#if deletionError}<p class="status-message error">{deletionError}</p>{/if}
<div class="dialog-actions">
<button type="button" onclick={() => { deletionOpen = false; }} disabled={deletionSubmitting}>Cancel</button>
<button
class="danger-button"
type="button"
onclick={() => void deleteWorkspace()}
disabled={deletionSubmitting || (!deletionRequest && !deletionPreflight?.can_delete) || deletionConfirmation !== (deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? '')}
>{deletionSubmitting ? 'Deleting…' : 'Delete Workspace'}</button>
</div>
</div>
</div>
{/if}
<style>
.section-heading { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); }
.section-heading p { margin-block: var(--space-1) 0; }
.metadata-list { display: grid; gap: var(--space-2); }
.metadata-list div { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: var(--space-3); }
.metadata-list dd { margin: 0; overflow-wrap: anywhere; }
.identity-bundle { display: grid; gap: var(--space-2); margin-top: var(--space-4); }
.identity-bundle textarea { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: var(--font-size-compact); }
.danger-zone { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); border-top: 1px solid var(--color-danger, #b42318); }
.danger-zone p { max-width: 68ch; }
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: var(--space-4); background: rgb(0 0 0 / 0.55); }
.deletion-dialog { width: min(34rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; padding: var(--space-5); background: var(--color-surface, white); border: 1px solid var(--color-border); }
.deletion-dialog label { display: grid; gap: var(--space-2); margin-block: var(--space-4); }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-5); }
</style>
@@ -0,0 +1,532 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import type { ComponentProps } from "svelte";
import { goto, invalidate } from "$app/navigation";
import Settings from "./+page.svelte";
import {
deletionFixture,
identityFixture,
metadataFixture,
settingsFixtureHandler,
} from "$lib/workspace/settings/identity.test-fixtures";
vi.mock(
"$app/navigation",
() => ({ goto: vi.fn(async () => {}), invalidate: vi.fn(async () => {}) }),
);
vi.mock(
"$lib/workspace/multiplexer",
() => ({ disposeWorkspaceMultiplexer: vi.fn() }),
);
vi.mock(
"$lib/workspace/sidebar/worker-subscription",
() => ({ disposeWorkspaceWorkersStore: vi.fn() }),
);
type Data = ComponentProps<typeof Settings>["data"];
function data(id = "home-owner", owner = true): Data {
return {
workspace: {
workspace_id: id,
display_name: metadataFixture(id).display_name,
permissions: { delete_workspace: owner },
},
} as Data;
}
function mount(value = data()) {
return render(Settings, {
props: {
data: value,
params: { workspaceId: value.workspace!.workspace_id },
},
});
}
function mockApi(
override?: (request: Request) => Promise<Response | null> | Response | null,
) {
const handler = settingsFixtureHandler();
const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(
new URL(String(input), "https://fixture.test"),
init,
);
return await override?.(request) ?? await handler(request) ??
new Response("Unexpected fixture request", { status: 404 });
});
vi.stubGlobal("fetch", mock);
return mock;
}
async function openName() {
await fireEvent.click(
await screen.findByRole("button", { name: "Edit name" }),
);
return screen.getByLabelText("New display name") as HTMLInputElement;
}
async function openDeletion() {
await screen.findByText("Active", { exact: true });
await fireEvent.click(
screen.getByText("Delete Workspace", { selector: "summary" }),
);
await fireEvent.click(
screen.getByRole("button", { name: "Review deletion impact" }),
);
return await screen.findByLabelText(
`Type ${metadataFixture().display_name} to confirm`,
) as HTMLInputElement;
}
function operation(state = "succeeded", operationId = "op-test") {
return {
operation_id: operationId,
workspace_id: "home-owner",
display_name: metadataFixture().display_name,
state,
resources: deletionFixture().resources,
child_operation_ids: [],
blockers: [],
failure_category: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
completed_at: null,
};
}
afterEach(() => {
cleanup();
sessionStorage.clear();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
test("saved settings have no editable form, badges or raw data expanded; each technical scope is disclosed", async () => {
const api = mockApi();
mount();
await screen.findByText("Active", { exact: true });
expect(screen.getByText(metadataFixture().display_name)).toBeTruthy();
expect(document.querySelector("form")).toBeNull();
expect(document.querySelector("input, textarea:not([readonly])")).toBeNull();
// happy-dom does not implement native details visibility; Chromium verifies it.
expect(document.querySelector("textarea")?.closest("details")?.open).toBe(
false,
);
expect(document.querySelectorAll("details[open]")).toHaveLength(0);
expect(document.querySelector(".badge")).toBeNull();
expect(api.mock.calls).toHaveLength(2);
expect(api.mock.calls.some(([path]) => String(path).includes("deletion")))
.toBe(false);
});
test("Edit focuses the field; Cancel discards the draft and returns focus without mutation", async () => {
const api = mockApi();
mount();
const input = await openName();
expect(document.activeElement).toBe(input);
await fireEvent.input(input, { target: { value: "Discard this" } });
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "Edit name" }),
);
expect((await openName()).value).toBe(metadataFixture().display_name);
expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true);
});
test("save uses the current revision, publishes confirmed name and invalidates the Workspace header", async () => {
const api = mockApi();
mount();
await fireEvent.input(await openName(), {
target: { value: "Renamed Workspace" },
});
await fireEvent.click(screen.getByRole("button", { name: "Save name" }));
await screen.findByText("Workspace name saved.");
expect(screen.getByText("Renamed Workspace")).toBeTruthy();
expect(document.querySelector("form")).toBeNull();
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "Edit name" }),
);
expect(
JSON.parse(String(
api.mock.calls.find(([, init]) => init?.method === "PUT")![1]!.body,
)),
).toEqual({
display_name: "Renamed Workspace",
revision: metadataFixture().revision,
});
expect(invalidate).toHaveBeenCalledWith("/api/w/home-owner/workspace");
});
test("failed save preserves the draft and saved name; Reload saved name recovers from revision conflicts", async () => {
mockApi((request) =>
request.method === "PUT"
? new Response("Revision conflict", { status: 409 })
: null
);
mount();
const input = await openName();
await fireEvent.input(input, { target: { value: "Keep draft" } });
await fireEvent.click(screen.getByRole("button", { name: "Save name" }));
expect((await screen.findByRole("alert")).textContent).toBe(
"Revision conflict",
);
expect(input.value).toBe("Keep draft");
expect(screen.getByText(`Current: ${metadataFixture().display_name}`))
.toBeTruthy();
await fireEvent.click(
screen.getByRole("button", { name: "Reload saved name" }),
);
await screen.findByRole("button", { name: "Edit name" });
expect(screen.queryByRole("alert")).toBeNull();
expect((await openName()).value).toBe(metadataFixture().display_name);
});
test("pending save disables duplicate submit and cancel while preserving the current value", async () => {
let release!: (response: Response) => void;
const held = new Promise<Response>((resolve) => release = resolve);
const api = mockApi((request) => request.method === "PUT" ? held : null);
mount();
await fireEvent.input(await openName(), { target: { value: "New name" } });
await fireEvent.click(screen.getByRole("button", { name: "Save name" }));
expect(
(screen.getByRole("button", { name: "Saving…" }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement)
.disabled,
).toBe(true);
await fireEvent.submit(document.querySelector("form")!);
expect(api.mock.calls.filter(([, init]) => init?.method === "PUT"))
.toHaveLength(1);
release(
Response.json({
workspace: { ...metadataFixture(), display_name: "New name" },
diagnostics: [],
}),
);
await screen.findByText("Workspace name saved.");
});
test("metadata and public identity load independently and offer scoped retries", async () => {
let failName = true;
mockApi((request) =>
failName && new URL(request.url).pathname.endsWith("/settings")
? new Response("Name unavailable", { status: 503 })
: null
);
mount();
await screen.findByText("Active", { exact: true });
await screen.findByText("Name unavailable");
expect(screen.queryByRole("button", { name: "Edit name" })).toBeNull();
failName = false;
await fireEvent.click(screen.getByRole("button", { name: "Retry name" }));
await screen.findByRole("button", { name: "Edit name" });
});
test("identity failure does not block name editing or masquerade as pending provisioning", async () => {
let fail = true;
mockApi((request) =>
fail && request.url.endsWith("/signing-identity")
? new Response("Identity unavailable", { status: 503 })
: null
);
mount();
await screen.findByRole("button", { name: "Edit name" });
await screen.findByText("Identity unavailable");
expect(screen.queryByRole("button", { name: "Provision identity" }))
.toBeNull();
fail = false;
await fireEvent.click(screen.getByRole("button", { name: "Retry identity" }));
await screen.findByText("Active", { exact: true });
});
test("non-owner retains metadata editing but never fetches or shows owner-only identity/deletion", async () => {
const api = mockApi();
mount(data("home-member", false));
await screen.findByRole("button", { name: "Edit name" });
expect(screen.queryByRole("heading", { name: "Public identity" })).toBeNull();
expect(screen.queryByText("Delete Workspace")).toBeNull();
expect(api).toHaveBeenCalledTimes(1);
});
test("pending provisioning is explicit, only provisions on action and focuses the resulting copy action", async () => {
const api = mockApi();
mount(data("home-empty"));
await screen.findByText("Not provisioned");
expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true);
await fireEvent.click(
screen.getByRole("button", { name: "Provision identity" }),
);
const copy = await screen.findByRole("button", { name: "Copy bundle" });
expect(document.activeElement).toBe(copy);
expect(screen.getByText("Active", { exact: true })).toBeTruthy();
});
test("copy uses the exact public bundle; clipboard failure offers manual selection rather than losing data", async () => {
mockApi();
const writeText = vi.fn().mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("Denied"));
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
mount();
const button = await screen.findByRole("button", { name: "Copy bundle" });
await fireEvent.click(button);
await screen.findByText("Public bundle copied.");
expect(writeText).toHaveBeenCalledWith(
JSON.stringify(identityFixture().public_bundle, null, 2),
);
await fireEvent.click(button);
expect((await screen.findByRole("alert")).textContent).toContain(
"select the bundle manually",
);
expect(document.querySelector("textarea")!.value).toBe(
JSON.stringify(identityFixture().public_bundle, null, 2),
);
});
test("deletion is deferred, requires exact current name, and Cancel restores disclosure focus", async () => {
const api = mockApi();
mount();
const input = await openDeletion();
expect(document.activeElement).toBe(input);
const confirm = screen.getByRole("button", {
name: "Delete Workspace",
}) as HTMLButtonElement;
expect(confirm.disabled).toBe(true);
await fireEvent.input(input, { target: { value: "Wrong name" } });
expect(confirm.disabled).toBe(true);
await fireEvent.input(input, {
target: { value: metadataFixture().display_name },
});
expect(confirm.disabled).toBe(false);
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(document.activeElement?.textContent).toBe("Delete Workspace");
expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true);
});
test("deletion blockers and preflight failure do not permit a destructive request", async () => {
let fail = true;
const api = mockApi((request) =>
request.url.endsWith("/deletion")
? fail
? new Response("Preflight unavailable", { status: 503 })
: Response.json({
...deletionFixture(),
can_delete: false,
blockers: [{
kind: "worker_removal_blocked",
resource_kind: "worker",
resource_key: "W-7",
message: "Worker W-7 is running.",
}],
})
: null
);
mount();
await screen.findByText("Active", { exact: true });
await fireEvent.click(
screen.getByText("Delete Workspace", { selector: "summary" }),
);
await fireEvent.click(
screen.getByRole("button", { name: "Review deletion impact" }),
);
await screen.findByRole("button", { name: "Retry deletion check" });
expect(screen.queryByRole("button", { name: "Delete Workspace" })).toBeNull();
fail = false;
await fireEvent.click(
screen.getByRole("button", { name: "Retry deletion check" }),
);
await screen.findByText("Worker W-7 is running.");
expect(
(screen.getByRole("button", {
name: "Delete Workspace",
}) as HTMLButtonElement).disabled,
).toBe(true);
expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true);
});
test("deletion retry reuses the persisted operation id and confirmation, then redirects only after success", async () => {
let attempts = 0;
const api = mockApi(async (request) => {
if (request.method === "POST" && request.url.endsWith("/deletion")) {
const body = await request.json();
return ++attempts === 1
? new Response("Connection lost", { status: 503 })
: Response.json(operation("queued", body.operation_id));
}
if (request.url.includes("/workspace-deletions/")) {
return Response.json(
operation("succeeded", request.url.split("/").pop()),
);
}
return null;
});
mount();
await fireEvent.input(await openDeletion(), {
target: { value: metadataFixture().display_name },
});
await fireEvent.click(
screen.getByRole("button", { name: "Delete Workspace" }),
);
await screen.findByRole("button", { name: "Retry deletion" });
expect(goto).not.toHaveBeenCalled();
const stored = sessionStorage.getItem("yoi:workspace-deletion:home-owner");
expect(stored).toBeTruthy();
await fireEvent.click(screen.getByRole("button", { name: "Retry deletion" }));
await waitFor(() => expect(goto).toHaveBeenCalledWith("/"));
const requests = api.mock.calls.filter(([, init]) => init?.method === "POST");
expect(requests[0][1]!.body).toBe(requests[1][1]!.body);
expect(sessionStorage.getItem("yoi:workspace-deletion:home-owner"))
.toBeNull();
});
test("reload resumes persisted deletion without sending another POST", async () => {
sessionStorage.setItem(
"yoi:workspace-deletion:home-owner",
JSON.stringify({
operation_id: "op-test",
expected_revision: metadataFixture().revision,
confirmation: metadataFixture().display_name,
}),
);
const api = mockApi((request) =>
request.url.includes("/workspace-deletions/")
? Response.json(operation("failed"))
: null
);
mount();
await screen.findByText("Deletion state: failed");
expect(document.querySelector("details.deletion")?.hasAttribute("open")).toBe(
true,
);
expect(api.mock.calls.every(([, init]) => !init?.method)).toBe(true);
});
test("Workspace switch resets drafts and ignores old load/mutation completions", async () => {
let release!: (response: Response) => void;
const held = new Promise<Response>((resolve) => release = resolve);
mockApi((request) => request.method === "PUT" ? held : null);
const view = mount();
await fireEvent.input(await openName(), {
target: { value: "Old scope draft" },
});
await fireEvent.click(screen.getByRole("button", { name: "Save name" }));
await view.rerender({
data: data("home-long"),
params: { workspaceId: "home-long" },
});
await screen.findByText(metadataFixture("home-long").display_name);
release(
Response.json({
workspace: { ...metadataFixture(), display_name: "Old scope draft" },
diagnostics: [],
}),
);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(screen.queryByText("Old scope draft")).toBeNull();
expect(invalidate).not.toHaveBeenCalled();
expect(goto).not.toHaveBeenCalled();
});
test("slow identity keeps the name usable and old identity responses cannot leak across Workspaces", async () => {
let release!: (response: Response) => void;
const held = new Promise<Response>((resolve) => release = resolve);
mockApi((request) =>
request.url.endsWith("/home-owner/settings/signing-identity") ? held : null
);
const view = mount();
await screen.findByRole("button", { name: "Edit name" });
expect(screen.getByText("Loading identity…")).toBeTruthy();
await view.rerender({
data: data("home-empty"),
params: { workspaceId: "home-empty" },
});
await screen.findByText("Not provisioned");
release(Response.json(identityFixture()));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(screen.queryByText("Active", { exact: true })).toBeNull();
expect(screen.getByText("Not provisioned")).toBeTruthy();
});
test("a completed deletion request from an unmounted Workspace cannot navigate away from the new scope", async () => {
const request = {
operation_id: "op-test",
expected_revision: metadataFixture().revision,
confirmation: metadataFixture().display_name,
};
sessionStorage.setItem(
"yoi:workspace-deletion:home-owner",
JSON.stringify(request),
);
let release!: (response: Response) => void;
const held = new Promise<Response>((resolve) => release = resolve);
mockApi((request) =>
request.url.includes("/workspace-deletions/") ? held : null
);
const view = mount();
await screen.findByText("Deleting…");
await view.rerender({
data: data("home-member", false),
params: { workspaceId: "home-member" },
});
await screen.findByRole("button", { name: "Edit name" });
release(Response.json(operation()));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(goto).not.toHaveBeenCalled();
expect(sessionStorage.getItem("yoi:workspace-deletion:home-owner")).not
.toBeNull();
});
test("name editing and deletion confirmation cannot overlap", async () => {
mockApi();
mount();
await openName();
await fireEvent.click(
screen.getByText("Delete Workspace", { selector: "summary" }),
);
expect(
(screen.getByRole("button", {
name: "Review deletion impact",
}) as HTMLButtonElement).disabled,
).toBe(true);
await fireEvent.click(
screen.getByRole("button", { name: "Cancel" }),
);
await fireEvent.click(
screen.getByRole("button", { name: "Review deletion impact" }),
);
await screen.findByLabelText(
`Type ${metadataFixture().display_name} to confirm`,
);
expect(
(screen.getByRole("button", { name: "Edit name" }) as HTMLButtonElement)
.disabled,
).toBe(true);
await fireEvent.click(
screen.getByRole("button", { name: "Cancel" }),
);
expect(
(screen.getByRole("button", { name: "Edit name" }) as HTMLButtonElement)
.disabled,
).toBe(false);
});
test("untrusted names and errors remain text, not interpreted HTML", async () => {
const name = "<img src=x onerror=alert(1)>";
mockApi((request) =>
request.url.endsWith("/settings")
? Response.json({ ...metadataFixture(), display_name: name })
: request.url.endsWith("/signing-identity")
? new Response("<script>bad()</script>", { status: 503 })
: null
);
mount();
await screen.findByText(name);
expect((await screen.findByRole("alert")).textContent).toBe(
"<script>bad()</script>",
);
expect(document.querySelector("img, script")).toBeNull();
});
@@ -1892,10 +1892,10 @@
/> />
{#if pendingSubmissionItems.length > 0 || pendingSubmissions.notification_count > 0} {#if pendingSubmissionItems.length > 0 || pendingSubmissions.notification_count > 0}
<section class="pending-submissions" aria-label="Pending activations"> <section class="pending-submissions" class:has-both={pendingSubmissionItems.length > 0 && pendingSubmissions.notification_count > 0} aria-label="Pending activations">
<div class="pending-column pending-queue" role="group" aria-label="Queued inputs"> {#if pendingSubmissionItems.length > 0}
<h2 class="pending-submissions-label">{pendingSubmissionItems.length} Queued</h2> <div class="pending-column pending-queue" role="group" aria-label="Queued inputs">
{#if pendingSubmissionItems.length > 0} <h2 class="pending-submissions-label">{pendingSubmissionItems.length} Queued</h2>
<ol aria-label="Queued inputs"> <ol aria-label="Queued inputs">
{#each pendingSubmissionItems as submission, index (submission.submission_id)} {#each pendingSubmissionItems as submission, index (submission.submission_id)}
<li> <li>
@@ -1923,11 +1923,11 @@
</li> </li>
{/each} {/each}
</ol> </ol>
{/if} </div>
</div> {/if}
<div class="pending-column pending-notifications" role="group" aria-label="Notifications"> {#if pendingSubmissions.notification_count > 0}
<h2 class="pending-submissions-label">Notifications</h2> <div class="pending-column pending-notifications" role="group" aria-label="Notifications">
{#if pendingSubmissions.notification_count > 0} <h2 class="pending-submissions-label">Notifications</h2>
<ol aria-label="Pending notifications"> <ol aria-label="Pending notifications">
{#each pendingSubmissions.notification_previews ?? [] as preview} {#each pendingSubmissions.notification_previews ?? [] as preview}
<li> <li>
@@ -1941,8 +1941,8 @@
</li> </li>
{/each} {/each}
</ol> </ol>
{/if} </div>
</div> {/if}
</section> </section>
{/if} {/if}
@@ -2309,24 +2309,34 @@
.pending-submissions { .pending-submissions {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: minmax(0, 1fr);
gap: var(--space-3); gap: var(--space-3);
flex: 0 0 auto; flex: 0 0 auto;
min-width: 0; min-width: 0;
margin: 0 var(--space-3); margin: 0 var(--space-3);
color: var(--text-muted); color: var(--text-muted);
font-family: var(--font-mono);
font-size: var(--font-size-compact); font-size: var(--font-size-compact);
line-height: var(--line-height-compact); line-height: var(--line-height-compact);
} }
.pending-submissions.has-both {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pending-column { .pending-column {
display: grid;
align-content: start;
gap: var(--space-1);
min-width: 0; min-width: 0;
} }
.pending-submissions li { .pending-submissions li {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
min-height: var(--space-6); height: var(--line-height-compact);
margin: 0;
padding: 0;
align-items: center; align-items: center;
} }
@@ -2340,16 +2350,16 @@
} }
.pending-submissions-label { .pending-submissions-label {
margin: 0 0 var(--space-1); margin: 0;
font-size: var(--font-size-compact); padding: 0;
line-height: var(--line-height-compact); font: inherit;
font-weight: 500; color: inherit;
} }
.pending-submissions ol { .pending-submissions ol {
display: grid; display: grid;
gap: var(--space-1); gap: var(--space-1);
max-height: calc(var(--space-6) * 4); max-height: calc(var(--line-height-compact) * 4 + var(--space-1) * 3);
overflow-y: auto; overflow-y: auto;
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -2371,8 +2381,8 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: var(--space-6); width: var(--space-6);
height: var(--space-6); height: var(--line-height-compact);
padding: var(--space-2); padding: 0;
border: 0; border: 0;
border-radius: var(--radius-soft); border-radius: var(--radius-soft);
background: transparent; background: transparent;
@@ -369,7 +369,8 @@ test("notification-only state displays previews without actions and supports leg
session.pending_submissions.head_id = "notification-head"; session.pending_submissions.head_id = "notification-head";
latestListener().onFrame(subscribedFrame(session)); latestListener().onFrame(subscribedFrame(session));
const region = await screen.findByRole("region", { name: "Pending activations" }); const region = await screen.findByRole("region", { name: "Pending activations" });
expect(region.textContent).toContain("0 Queued"); expect(screen.queryByRole("group", { name: "Queued inputs" })).toBeNull();
expect(screen.queryByRole("heading", { name: "0 Queued" })).toBeNull();
expect(region.textContent).toContain("一件目の通知"); expect(region.textContent).toContain("一件目の通知");
expect(region.textContent).toContain("二件目の通知"); expect(region.textContent).toContain("二件目の通知");
expect(region.querySelectorAll("li")).toHaveLength(2); expect(region.querySelectorAll("li")).toHaveLength(2);
@@ -382,7 +383,7 @@ test("notification-only state displays previews without actions and supports leg
await waitFor(() => expect(screen.queryByRole("region", { name: "Pending activations" })).toBeNull()); await waitFor(() => expect(screen.queryByRole("region", { name: "Pending activations" })).toBeNull());
}); });
test("queue-only state keeps the notification column empty and disables cancellation when disconnected", async () => { test("queue-only state hides notifications and disables cancellation when disconnected", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" }))); vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
render(ConsolePage, { data: pageData() }); render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
@@ -390,7 +391,8 @@ test("queue-only state keeps the notification column empty and disables cancella
session.pending_submissions.submissions = [{ submission_id: "queue-1", preview: "送信内容", accepted_at_ms: 1, segment_count: 1, byte_len: 12 }]; session.pending_submissions.submissions = [{ submission_id: "queue-1", preview: "送信内容", accepted_at_ms: 1, segment_count: 1, byte_len: 12 }];
latestListener().onFrame(subscribedFrame(session)); latestListener().onFrame(subscribedFrame(session));
await screen.findByText("送信内容"); await screen.findByText("送信内容");
expect(screen.getByRole("group", { name: "Notifications" }).querySelector("ol")).toBeNull(); expect(screen.queryByRole("group", { name: "Notifications" })).toBeNull();
expect(screen.queryByRole("heading", { name: "Notifications" })).toBeNull();
latestListener().onStatus?.("closed", "connection lost"); latestListener().onStatus?.("closed", "connection lost");
await waitFor(() => expect((screen.getByRole("button", { name: "Cancel queued input 1" }) as HTMLButtonElement).disabled).toBe(true)); await waitFor(() => expect((screen.getByRole("button", { name: "Cancel queued input 1" }) as HTMLButtonElement).disabled).toBe(true));
}); });
@@ -1137,3 +1139,28 @@ function expectHistoryTurn(text: string) {
expect(screen.getAllByText(text)).toHaveLength(1); expect(screen.getAllByText(text)).toHaveLength(1);
expect(screen.getByRole("button", { name: new RegExp("^Turn \\d+: " + text + "$") })).not.toBeNull(); expect(screen.getByRole("button", { name: new RegExp("^Turn \\d+: " + text + "$") })).not.toBeNull();
} }
test("pending groups independently follow authoritative changes, including zero count with stale previews", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
render(ConsolePage, { data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
const session = emptySession();
session.pending_submissions.submissions = [{ submission_id: "queue-1", preview: "Queued", accepted_at_ms: 1, segment_count: 1, byte_len: 6 }];
session.pending_submissions.notification_count = 1;
session.pending_submissions.notification_previews = ["Notice"];
latestListener().onFrame(subscribedFrame(session));
await screen.findByRole("group", { name: "Queued inputs" });
expect(screen.getByRole("group", { name: "Notifications" })).toBeTruthy();
session.pending_submissions.notification_count = 0;
latestListener().onFrame(subscribedFrame(session));
await waitFor(() => expect(screen.queryByRole("group", { name: "Notifications" })).toBeNull());
expect(screen.getByRole("group", { name: "Queued inputs" })).toBeTruthy();
session.pending_submissions.submissions = [];
session.pending_submissions.notification_count = 1;
latestListener().onFrame(subscribedFrame(session));
await waitFor(() => expect(screen.queryByRole("group", { name: "Queued inputs" })).toBeNull());
expect(screen.getByRole("group", { name: "Notifications" })).toBeTruthy();
session.pending_submissions.notification_count = 0;
latestListener().onFrame(subscribedFrame(session));
await waitFor(() => expect(screen.queryByRole("region", { name: "Pending activations" })).toBeNull());
});
+19
View File
@@ -0,0 +1,19 @@
Lucide panel-right-close and panel-right-open
https://lucide.dev/icons/panel-right-close
https://lucide.dev/icons/panel-right-open
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
@@ -93,18 +93,18 @@ Deno.test("Shell regions use selected Bevel edges while open separators use Beve
assert( assert(
rootLayout.includes('class="app-shell__topbar-bevel"') && rootLayout.includes('class="app-shell__topbar-bevel"') &&
rootLayout.includes("top={false} right={false} left={false}") && rootLayout.includes("top={false} right={false} left={false}") &&
rootLayout.includes("bind:folded={sidebarFolded}") && rootLayout.includes("open={sidebarOpen}") &&
rootLayout.includes("app-shell__mobile-sidebar-toggle") && rootLayout.includes("app-shell__mobile-sidebar-toggle") &&
rootLayout.includes("class:sidebar-open={!sidebarFolded}") && rootLayout.includes("class:sidebar-open={sidebarOpen}") &&
!rootLayout.includes("depth=") && !rootLayout.includes("depth=") &&
!rootLayout.includes("BevelLine") && !rootLayout.includes("BevelLine") &&
sidebarFrame.includes('class="sidebar-frame__bevel"') && sidebarFrame.includes('class="sidebar-frame__bevel"') &&
sidebarFrame.includes("top={false} bottom={false} left={false}") && sidebarFrame.includes("top={false} bottom={false} left={false}") &&
sidebarFrame.includes("$bindable(false)") && sidebarFrame.includes("class:folded={!open}") &&
!sidebarFrame.includes("depth=") && !sidebarFrame.includes("depth=") &&
!sidebarFrame.includes("BevelLine") && !sidebarFrame.includes("BevelLine") &&
sidebarCss.includes(".sidebar-frame.folded") && sidebarCss.includes(".sidebar-frame.folded") &&
sidebarCss.includes("display: none") && sidebarCss.includes("transform: translateX(-100%)") &&
sidebarCss.includes("display: grid"), sidebarCss.includes("display: grid"),
"Desktop shell boundaries must use one selected border edge and Mobile must share Header fold state", "Desktop shell boundaries must use one selected border edge and Mobile must share Header fold state",
); );
+5 -5
View File
@@ -313,11 +313,11 @@ Deno.test("Workspace deletion DTOs fail closed and preserve durable operation st
}); });
Deno.test("Workspace settings exposes owner-gated typed destructive confirmation", async () => { Deno.test("Workspace settings exposes owner-gated typed destructive confirmation", async () => {
const source = await Deno.readTextFile( const source = (await Promise.all([
new URL( "../src/routes/w/[workspaceId]/settings/+page.svelte",
"../src/routes/w/[workspaceId]/settings/+page.svelte", "../src/lib/workspace/settings/WorkspaceIdentitySettings.svelte",
import.meta.url, ].map((path) => Deno.readTextFile(new URL(path, import.meta.url))))).join(
), "\n",
); );
for ( for (
const token of [ const token of [
+2
View File
@@ -19,7 +19,9 @@ export default defineConfig({
ignored: [ ignored: [
"**/.tmp*", "**/.tmp*",
"**/.tmp*/**", "**/.tmp*/**",
// Config bundling creates and immediately deletes these files.
"**/vite.config.*.timestamp-*.mjs", "**/vite.config.*.timestamp-*.mjs",
"**/vitest.config.*.timestamp-*.mjs",
], ],
}, },