feat: project SubWorker activity in sidebar
This commit is contained in:
@@ -554,6 +554,8 @@ pub struct SubscriptionWorker {
|
|||||||
/// Producer-owned monotonic revision for this Worker subject.
|
/// Producer-owned monotonic revision for this Worker subject.
|
||||||
pub subject_revision: u64,
|
pub subject_revision: u64,
|
||||||
pub state: SubscriptionWorkerState,
|
pub state: SubscriptionWorkerState,
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_running_internal_workers: bool,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub workspace_id: Option<String>,
|
pub workspace_id: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -796,6 +798,7 @@ mod tests {
|
|||||||
runtime_id: None,
|
runtime_id: None,
|
||||||
subject_revision: 0,
|
subject_revision: 0,
|
||||||
state: SubscriptionWorkerState::Idle,
|
state: SubscriptionWorkerState::Idle,
|
||||||
|
has_running_internal_workers: false,
|
||||||
workspace_id: Some("workspace-1".to_string()),
|
workspace_id: Some("workspace-1".to_string()),
|
||||||
display_name: Some(format!("Worker {value}")),
|
display_name: Some(format!("Worker {value}")),
|
||||||
profile: Some("builtin:coder".to_string()),
|
profile: Some("builtin:coder".to_string()),
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ impl Runtime {
|
|||||||
for (worker_id, worker) in &mut state.workers {
|
for (worker_id, worker) in &mut state.workers {
|
||||||
if worker.status.is_active() {
|
if worker.status.is_active() {
|
||||||
worker.status = WorkerStatus::Stopped;
|
worker.status = WorkerStatus::Stopped;
|
||||||
|
worker.internal_workers.clear();
|
||||||
stopped.push(*worker_id);
|
stopped.push(*worker_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -574,6 +575,7 @@ impl Runtime {
|
|||||||
run_generation: 1,
|
run_generation: 1,
|
||||||
working_directory: None,
|
working_directory: None,
|
||||||
execution_handle: None,
|
execution_handle: None,
|
||||||
|
internal_workers: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
state.workers.insert(worker_id, record);
|
state.workers.insert(worker_id, record);
|
||||||
state.persist_runtime_snapshot()?;
|
state.persist_runtime_snapshot()?;
|
||||||
@@ -1471,7 +1473,8 @@ impl Runtime {
|
|||||||
let mut state = self.lock()?;
|
let mut state = self.lock()?;
|
||||||
state.ensure_worker_ref(worker_ref)?;
|
state.ensure_worker_ref(worker_ref)?;
|
||||||
let status_changed = state.project_protocol_event_to_status(worker_ref, &payload);
|
let status_changed = state.project_protocol_event_to_status(worker_ref, &payload);
|
||||||
if status_changed {
|
let activity_changed = state.project_internal_worker_activity(worker_ref, &payload);
|
||||||
|
if status_changed || activity_changed {
|
||||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||||
}
|
}
|
||||||
let event = state.push_worker_observation_event(worker_ref.clone(), payload);
|
let event = state.push_worker_observation_event(worker_ref.clone(), payload);
|
||||||
@@ -1528,6 +1531,7 @@ impl Runtime {
|
|||||||
let worker = state.worker_mut(worker_ref)?;
|
let worker = state.worker_mut(worker_ref)?;
|
||||||
worker.status = status;
|
worker.status = status;
|
||||||
worker.execution_handle = None;
|
worker.execution_handle = None;
|
||||||
|
worker.internal_workers.clear();
|
||||||
let status = worker.status;
|
let status = worker.status;
|
||||||
state.publish_worker_upsert(worker_ref.worker_id)?;
|
state.publish_worker_upsert(worker_ref.worker_id)?;
|
||||||
state.persist_runtime_snapshot()?;
|
state.persist_runtime_snapshot()?;
|
||||||
@@ -1940,6 +1944,7 @@ impl RuntimeState {
|
|||||||
run_generation: worker.run_generation,
|
run_generation: worker.run_generation,
|
||||||
working_directory: worker.working_directory,
|
working_directory: worker.working_directory,
|
||||||
execution_handle: None,
|
execution_handle: None,
|
||||||
|
internal_workers: BTreeMap::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2260,6 +2265,10 @@ impl RuntimeState {
|
|||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
state: subscription_worker_state(worker.status),
|
state: subscription_worker_state(worker.status),
|
||||||
|
has_running_internal_workers: worker
|
||||||
|
.internal_workers
|
||||||
|
.values()
|
||||||
|
.any(|worker| worker.status == protocol::WorkerStatus::Running),
|
||||||
workspace_id: worker.workspace_id.clone(),
|
workspace_id: worker.workspace_id.clone(),
|
||||||
display_name: worker.request.display_name.clone(),
|
display_name: worker.request.display_name.clone(),
|
||||||
profile,
|
profile,
|
||||||
@@ -2402,6 +2411,7 @@ impl RuntimeState {
|
|||||||
let worker = self.worker_mut(worker_ref)?;
|
let worker = self.worker_mut(worker_ref)?;
|
||||||
worker.execution_handle = None;
|
worker.execution_handle = None;
|
||||||
worker.status = WorkerStatus::Stopped;
|
worker.status = WorkerStatus::Stopped;
|
||||||
|
worker.internal_workers.clear();
|
||||||
self.publish_worker_upsert(worker_ref.worker_id)?;
|
self.publish_worker_upsert(worker_ref.worker_id)?;
|
||||||
self.persist_runtime_snapshot()?;
|
self.persist_runtime_snapshot()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2455,7 +2465,134 @@ impl RuntimeState {
|
|||||||
event
|
event
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "ws-server")]
|
fn internal_worker_snapshot_statuses(
|
||||||
|
statuses: &mut BTreeMap<String, InternalWorkerActivity>,
|
||||||
|
snapshot: &protocol::InternalWorkerSnapshot,
|
||||||
|
) {
|
||||||
|
statuses.insert(
|
||||||
|
snapshot.worker.session_id.clone(),
|
||||||
|
InternalWorkerActivity {
|
||||||
|
status: snapshot.status,
|
||||||
|
parent_session_id: snapshot.worker.parent_session_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for child in &snapshot.internal_workers {
|
||||||
|
Self::internal_worker_snapshot_statuses(statuses, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_internal_worker_subtree(
|
||||||
|
statuses: &mut BTreeMap<String, InternalWorkerActivity>,
|
||||||
|
root_session_id: &str,
|
||||||
|
) {
|
||||||
|
let mut removed = vec![root_session_id.to_string()];
|
||||||
|
while let Some(parent_session_id) = removed.pop() {
|
||||||
|
let children = statuses
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(session_id, worker)| {
|
||||||
|
(worker.parent_session_id.as_deref() == Some(parent_session_id.as_str()))
|
||||||
|
.then(|| session_id.clone())
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
statuses.remove(&parent_session_id);
|
||||||
|
removed.extend(children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_internal_worker_event(
|
||||||
|
statuses: &mut BTreeMap<String, InternalWorkerActivity>,
|
||||||
|
worker: &protocol::InternalWorkerRef,
|
||||||
|
event: &protocol::Event,
|
||||||
|
) {
|
||||||
|
match event {
|
||||||
|
protocol::Event::Snapshot {
|
||||||
|
status,
|
||||||
|
internal_workers,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
Self::remove_internal_worker_subtree(statuses, &worker.session_id);
|
||||||
|
statuses.insert(
|
||||||
|
worker.session_id.clone(),
|
||||||
|
InternalWorkerActivity {
|
||||||
|
status: *status,
|
||||||
|
parent_session_id: worker.parent_session_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for child in internal_workers {
|
||||||
|
Self::internal_worker_snapshot_statuses(statuses, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protocol::Event::InternalWorker {
|
||||||
|
worker: nested_worker,
|
||||||
|
event,
|
||||||
|
..
|
||||||
|
} => Self::project_internal_worker_event(statuses, nested_worker, event),
|
||||||
|
protocol::Event::Status { status } => {
|
||||||
|
statuses.insert(
|
||||||
|
worker.session_id.clone(),
|
||||||
|
InternalWorkerActivity {
|
||||||
|
status: *status,
|
||||||
|
parent_session_id: worker.parent_session_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
protocol::Event::RunEnd { result } => {
|
||||||
|
let status = match result {
|
||||||
|
protocol::RunResult::Paused => protocol::WorkerStatus::Paused,
|
||||||
|
protocol::RunResult::Finished
|
||||||
|
| protocol::RunResult::LimitReached
|
||||||
|
| protocol::RunResult::RolledBack => protocol::WorkerStatus::Idle,
|
||||||
|
};
|
||||||
|
statuses.insert(
|
||||||
|
worker.session_id.clone(),
|
||||||
|
InternalWorkerActivity {
|
||||||
|
status,
|
||||||
|
parent_session_id: worker.parent_session_id.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_internal_worker_activity(
|
||||||
|
statuses: &mut BTreeMap<String, InternalWorkerActivity>,
|
||||||
|
event: &protocol::Event,
|
||||||
|
) -> bool {
|
||||||
|
let was_running = statuses
|
||||||
|
.values()
|
||||||
|
.any(|worker| worker.status == protocol::WorkerStatus::Running);
|
||||||
|
match event {
|
||||||
|
protocol::Event::Snapshot {
|
||||||
|
internal_workers, ..
|
||||||
|
} => {
|
||||||
|
statuses.clear();
|
||||||
|
for child in internal_workers {
|
||||||
|
Self::internal_worker_snapshot_statuses(statuses, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protocol::Event::InternalWorker { worker, event, .. } => {
|
||||||
|
Self::project_internal_worker_event(statuses, worker, event);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let is_running = statuses
|
||||||
|
.values()
|
||||||
|
.any(|worker| worker.status == protocol::WorkerStatus::Running);
|
||||||
|
was_running != is_running
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_internal_worker_activity(
|
||||||
|
&mut self,
|
||||||
|
worker_ref: &WorkerRef,
|
||||||
|
event: &protocol::Event,
|
||||||
|
) -> bool {
|
||||||
|
let Some(worker) = self.workers.get_mut(&worker_ref.worker_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
Self::update_internal_worker_activity(&mut worker.internal_workers, event)
|
||||||
|
}
|
||||||
|
|
||||||
fn project_protocol_event_to_status(
|
fn project_protocol_event_to_status(
|
||||||
&mut self,
|
&mut self,
|
||||||
worker_ref: &WorkerRef,
|
worker_ref: &WorkerRef,
|
||||||
@@ -2498,6 +2635,12 @@ impl RuntimeState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct InternalWorkerActivity {
|
||||||
|
status: protocol::WorkerStatus,
|
||||||
|
parent_session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct WorkerRecord {
|
struct WorkerRecord {
|
||||||
worker_ref: WorkerRef,
|
worker_ref: WorkerRef,
|
||||||
@@ -2508,6 +2651,7 @@ struct WorkerRecord {
|
|||||||
run_generation: u64,
|
run_generation: u64,
|
||||||
working_directory: Option<CatalogWorkingDirectoryStatus>,
|
working_directory: Option<CatalogWorkingDirectoryStatus>,
|
||||||
execution_handle: Option<WorkerExecutionHandle>,
|
execution_handle: Option<WorkerExecutionHandle>,
|
||||||
|
internal_workers: BTreeMap<String, InternalWorkerActivity>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerRecord {
|
impl WorkerRecord {
|
||||||
@@ -2730,6 +2874,126 @@ mod tests {
|
|||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
fn internal_worker_ref(
|
||||||
|
session_id: &str,
|
||||||
|
parent_session_id: Option<&str>,
|
||||||
|
) -> protocol::InternalWorkerRef {
|
||||||
|
protocol::InternalWorkerRef {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
parent_session_id: parent_session_id.map(str::to_string),
|
||||||
|
name: session_id.to_string(),
|
||||||
|
kind: protocol::InternalWorkerKind::SubWorker,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_worker_status_event(
|
||||||
|
worker: protocol::InternalWorkerRef,
|
||||||
|
status: protocol::WorkerStatus,
|
||||||
|
) -> protocol::Event {
|
||||||
|
protocol::Event::InternalWorker {
|
||||||
|
worker,
|
||||||
|
revision: 1,
|
||||||
|
event: Box::new(protocol::Event::Status { status }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn internal_worker_activity_tracks_running_children_independently() {
|
||||||
|
let mut activity = BTreeMap::new();
|
||||||
|
assert!(RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-a", None),
|
||||||
|
protocol::WorkerStatus::Running,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
assert!(!RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-b", None),
|
||||||
|
protocol::WorkerStatus::Running,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
assert!(!RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-a", None),
|
||||||
|
protocol::WorkerStatus::Idle,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
assert!(RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-b", None),
|
||||||
|
protocol::WorkerStatus::Idle,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_internal_worker_activity_reaches_the_parent_projection() {
|
||||||
|
let mut activity = BTreeMap::new();
|
||||||
|
let direct_child = internal_worker_ref("child", None);
|
||||||
|
let nested_running = protocol::Event::InternalWorker {
|
||||||
|
worker: direct_child.clone(),
|
||||||
|
revision: 1,
|
||||||
|
event: Box::new(internal_worker_status_event(
|
||||||
|
internal_worker_ref("grandchild", Some("child")),
|
||||||
|
protocol::WorkerStatus::Running,
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
assert!(RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&nested_running,
|
||||||
|
));
|
||||||
|
|
||||||
|
let nested_idle = protocol::Event::InternalWorker {
|
||||||
|
worker: direct_child,
|
||||||
|
revision: 2,
|
||||||
|
event: Box::new(internal_worker_status_event(
|
||||||
|
internal_worker_ref("grandchild", Some("child")),
|
||||||
|
protocol::WorkerStatus::Idle,
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
assert!(RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&nested_idle,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parent_snapshot_replaces_stale_internal_worker_activity() {
|
||||||
|
let mut activity = BTreeMap::new();
|
||||||
|
RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-a", None),
|
||||||
|
protocol::WorkerStatus::Running,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let snapshot = protocol::Event::Snapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
greeting: protocol::Greeting {
|
||||||
|
worker_name: "parent".to_string(),
|
||||||
|
cwd: "/tmp".to_string(),
|
||||||
|
provider: "test".to_string(),
|
||||||
|
model: "test".to_string(),
|
||||||
|
scope_summary: String::new(),
|
||||||
|
tools: Vec::new(),
|
||||||
|
context_window: 0,
|
||||||
|
context_tokens: 0,
|
||||||
|
},
|
||||||
|
status: protocol::WorkerStatus::Idle,
|
||||||
|
in_flight: protocol::InFlightSnapshot::default(),
|
||||||
|
internal_workers: Vec::new(),
|
||||||
|
};
|
||||||
|
assert!(RuntimeState::update_internal_worker_activity(
|
||||||
|
&mut activity,
|
||||||
|
&snapshot,
|
||||||
|
));
|
||||||
|
assert!(activity.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_identity_binding_is_immutable_and_host_owned() {
|
fn runtime_identity_binding_is_immutable_and_host_owned() {
|
||||||
let runtime = Runtime::new_memory();
|
let runtime = Runtime::new_memory();
|
||||||
@@ -3083,16 +3347,75 @@ mod tests {
|
|||||||
payload => panic!("unexpected subscription payload: {payload:?}"),
|
payload => panic!("unexpected subscription payload: {payload:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.stop_runtime().unwrap();
|
runtime
|
||||||
|
.observe_worker_event(
|
||||||
|
&created.worker_ref,
|
||||||
|
internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-live", None),
|
||||||
|
protocol::WorkerStatus::Running,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let update = receive_subscription_update(&mut subscription).unwrap();
|
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||||
assert_eq!(update.subject_revision, 2);
|
assert_eq!(update.subject_revision, 2);
|
||||||
match update.payload {
|
match update.payload {
|
||||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||||
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string());
|
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||||
assert_eq!(worker.state, SubscriptionWorkerState::Stopped);
|
assert!(worker.has_running_internal_workers);
|
||||||
}
|
}
|
||||||
payload => panic!("unexpected subscription payload: {payload:?}"),
|
payload => panic!("unexpected subscription payload: {payload:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtime
|
||||||
|
.observe_worker_event(
|
||||||
|
&created.worker_ref,
|
||||||
|
internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-live", None),
|
||||||
|
protocol::WorkerStatus::Idle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||||
|
assert_eq!(update.subject_revision, 3);
|
||||||
|
match update.payload {
|
||||||
|
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||||
|
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||||
|
assert!(!worker.has_running_internal_workers);
|
||||||
|
}
|
||||||
|
payload => panic!("unexpected subscription payload: {payload:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime
|
||||||
|
.observe_worker_event(
|
||||||
|
&created.worker_ref,
|
||||||
|
internal_worker_status_event(
|
||||||
|
internal_worker_ref("child-live", None),
|
||||||
|
protocol::WorkerStatus::Running,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||||
|
assert_eq!(update.subject_revision, 4);
|
||||||
|
match update.payload {
|
||||||
|
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||||
|
assert_eq!(worker.state, SubscriptionWorkerState::Idle);
|
||||||
|
assert!(worker.has_running_internal_workers);
|
||||||
|
}
|
||||||
|
payload => panic!("unexpected subscription payload: {payload:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.stop_worker(&created.worker_ref, None).unwrap();
|
||||||
|
let update = receive_subscription_update(&mut subscription).unwrap();
|
||||||
|
assert_eq!(update.subject_revision, 5);
|
||||||
|
match update.payload {
|
||||||
|
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||||
|
assert_eq!(worker.worker_id.as_str(), created.worker_id.to_string());
|
||||||
|
assert_eq!(worker.state, SubscriptionWorkerState::Stopped);
|
||||||
|
assert!(!worker.has_running_internal_workers);
|
||||||
|
}
|
||||||
|
payload => panic!("unexpected subscription payload: {payload:?}"),
|
||||||
|
}
|
||||||
|
runtime.stop_runtime().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ runtime_id?: string | null,
|
|||||||
/**
|
/**
|
||||||
* Producer-owned monotonic revision for this Worker subject.
|
* Producer-owned monotonic revision for this Worker subject.
|
||||||
*/
|
*/
|
||||||
subject_revision: number, state: SubscriptionWorkerState, workspace_id?: string | null, display_name?: string | null, profile?: string | null, repository_id?: string | null, working_directory_id?: SubscriptionWorkdirId | null, };
|
subject_revision: number, state: SubscriptionWorkerState, has_running_internal_workers: boolean, workspace_id?: string | null, display_name?: string | null, profile?: string | null, repository_id?: string | null, working_directory_id?: SubscriptionWorkdirId | null, };
|
||||||
|
|
||||||
export type SubscriptionWorkdir = { working_directory_id: SubscriptionWorkdirId, repository_id: string, state: string, primary_worker_id?: SubscriptionWorkerId | null, };
|
export type SubscriptionWorkdir = { working_directory_id: SubscriptionWorkdirId, repository_id: string, state: string, primary_worker_id?: SubscriptionWorkerId | null, };
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.spinner {
|
.spinner {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
color: var(--accent);
|
color: var(--spinner-color, var(--accent));
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Spinner from '$lib/workspace/console/Spinner.svelte';
|
||||||
import { workerConsoleHref } from '$lib/workspace/console/model';
|
import { workerConsoleHref } from '$lib/workspace/console/model';
|
||||||
import {
|
import {
|
||||||
workspaceWorkersStore,
|
workspaceWorkersStore,
|
||||||
@@ -76,10 +77,12 @@
|
|||||||
aria-current={currentPath === href ? 'page' : undefined}
|
aria-current={currentPath === href ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
<span class="worker-status-indicator">
|
<span class="worker-status-indicator">
|
||||||
{#if worker.state === 'idle'}
|
{#if worker.state === 'running'}
|
||||||
|
<span class="worker-status-spinner"><Spinner label="Running" /></span>
|
||||||
|
{:else if worker.has_running_internal_workers}
|
||||||
|
<span class="worker-status-spinner is-subworker"><Spinner label="SubWorker running" /></span>
|
||||||
|
{:else if worker.state === 'idle'}
|
||||||
<span class="worker-status-dot" aria-label="Idle"></span>
|
<span class="worker-status-dot" aria-label="Idle"></span>
|
||||||
{:else if worker.state === 'running'}
|
|
||||||
<span class="worker-status-spinner" aria-label="Running"></span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="worker-nav-label">{worker.display_name || worker.label}</span>
|
<span class="worker-nav-label">{worker.display_name || worker.label}</span>
|
||||||
|
|||||||
@@ -267,12 +267,17 @@
|
|||||||
background: var(--success);
|
background: var(--success);
|
||||||
}
|
}
|
||||||
.worker-status-spinner {
|
.worker-status-spinner {
|
||||||
width: 0.625rem;
|
--spinner-color: var(--success);
|
||||||
height: 0.625rem;
|
|
||||||
border: 0.125rem solid color-mix(in oklch, var(--accent) 25%, transparent);
|
display: inline-flex;
|
||||||
border-top-color: var(--accent);
|
align-items: center;
|
||||||
border-radius: 50%;
|
justify-content: center;
|
||||||
animation: worker-status-spin 0.8s linear infinite;
|
width: 0.75rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.worker-status-spinner.is-subworker {
|
||||||
|
--spinner-color: var(--tui-magenta);
|
||||||
}
|
}
|
||||||
.worker-nav-label {
|
.worker-nav-label {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
@@ -339,16 +344,6 @@
|
|||||||
.worker-overflow-toggle[aria-expanded="true"] .worker-overflow-chevron {
|
.worker-overflow-toggle[aria-expanded="true"] .worker-overflow-chevron {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
@keyframes worker-status-spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.worker-status-spinner {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.sidebar-frame,
|
.sidebar-frame,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function worker(runtimeId: string, workerId: string, revision: number): Subscrip
|
|||||||
runtime_id: runtimeId,
|
runtime_id: runtimeId,
|
||||||
subject_revision: revision,
|
subject_revision: revision,
|
||||||
state: 'idle',
|
state: 'idle',
|
||||||
|
has_running_internal_workers: false,
|
||||||
workspace_id: 'workspace-test',
|
workspace_id: 'workspace-test',
|
||||||
display_name: null,
|
display_name: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { Worker } from './types';
|
|||||||
export type SidebarWorker = Worker & {
|
export type SidebarWorker = Worker & {
|
||||||
repository_id: string | null;
|
repository_id: string | null;
|
||||||
working_directory_id: string | null;
|
working_directory_id: string | null;
|
||||||
|
has_running_internal_workers: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkspaceWorkersState = {
|
export type WorkspaceWorkersState = {
|
||||||
@@ -96,6 +97,7 @@ function projectWorker(worker: SubscriptionWorker): SidebarWorker {
|
|||||||
},
|
},
|
||||||
repository_id: worker.repository_id ?? null,
|
repository_id: worker.repository_id ?? null,
|
||||||
working_directory_id: worker.working_directory_id ?? null,
|
working_directory_id: worker.working_directory_id ?? null,
|
||||||
|
has_running_internal_workers: worker.has_running_internal_workers,
|
||||||
working_directory: null,
|
working_directory: null,
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,62 @@ Deno.test("Console spinner wraps a reusable timed sequence loop", async () => {
|
|||||||
assert(spinner.includes("SequenceLoop"), "Spinner should wrap SequenceLoop");
|
assert(spinner.includes("SequenceLoop"), "Spinner should wrap SequenceLoop");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("sidebar running status reuses the green symbol spinner", async () => {
|
||||||
|
const sidebar = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../src/lib/workspace/sidebar/WorkersNavSection.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const sidebarCss = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../src/lib/workspace/sidebar/sidebar.css",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
sidebar.includes(
|
||||||
|
"import Spinner from '$lib/workspace/console/Spinner.svelte'",
|
||||||
|
),
|
||||||
|
"Workers sidebar should import the reusable symbol Spinner",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sidebar.includes('<Spinner label="Running" />'),
|
||||||
|
"running Workers should render the reusable symbol Spinner",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sidebarCss.includes("--spinner-color: var(--success)"),
|
||||||
|
"sidebar spinner should use the green success token",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sidebar.indexOf("worker.state === 'running'") <
|
||||||
|
sidebar.indexOf("worker.has_running_internal_workers"),
|
||||||
|
"parent running state should keep the green Spinner priority",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sidebar.indexOf("worker.has_running_internal_workers") <
|
||||||
|
sidebar.indexOf("worker.state === 'idle'"),
|
||||||
|
"SubWorker activity should replace the idle dot with the purple Spinner",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sidebar.includes("worker.has_running_internal_workers"),
|
||||||
|
"idle parents should render SubWorker activity from the Workspace projection",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sidebar.includes('<Spinner label="SubWorker running" />'),
|
||||||
|
"running SubWorkers should use the reusable symbol Spinner",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
sidebarCss.includes("--spinner-color: var(--tui-magenta)"),
|
||||||
|
"SubWorker spinner should use the purple TUI token",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!sidebarCss.includes("@keyframes worker-status-spin"),
|
||||||
|
"legacy rotating ring spinner should be removed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("running status is Composer-side above mini Tasks", async () => {
|
Deno.test("running status is Composer-side above mini Tasks", async () => {
|
||||||
const page = await Deno.readTextFile(
|
const page = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
|
|||||||
Reference in New Issue
Block a user