Merge remote-tracking branch 'origin/develop' into work/T-668-subjektiv-memory-lifecycle
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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());
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+30
-3
@@ -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());
|
||||||
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user