Compare commits
16
Commits
72cae33ea6
...
4a276b0af0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a276b0af0 | ||
|
|
c2b82a2591 | ||
|
|
99daaf31b6 | ||
|
|
94e51ea6d1 | ||
|
|
f2d2ab0102 | ||
|
|
6f42f23d2b | ||
|
|
8f54fa2a00 | ||
|
|
c110965911 | ||
|
|
2a48dfc41a | ||
|
|
0451142d41 | ||
|
|
65f18b0cdb | ||
|
|
ea31c7ca81 | ||
|
|
dd47dba5b0 | ||
|
|
cbedc76d06 | ||
|
|
a4aa1a1848 | ||
|
|
ee8ee360ef |
Generated
+11
@@ -540,6 +540,7 @@ dependencies = [
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"uuid",
|
||||
"workdir",
|
||||
"workspace-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6135,6 +6136,15 @@ dependencies = [
|
||||
"worker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "workspace-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"workdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
@@ -6265,6 +6275,7 @@ dependencies = [
|
||||
"workdir",
|
||||
"worker",
|
||||
"worker-runtime",
|
||||
"workspace-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -27,6 +27,7 @@ members = [
|
||||
"crates/ticket",
|
||||
"crates/merge-request",
|
||||
"crates/project-record",
|
||||
"crates/workspace-api",
|
||||
"crates/workspace-server",
|
||||
"tests/e2e",
|
||||
]
|
||||
@@ -57,6 +58,7 @@ default-members = [
|
||||
"crates/ticket",
|
||||
"crates/merge-request",
|
||||
"crates/project-record",
|
||||
"crates/workspace-api",
|
||||
"crates/workspace-server",
|
||||
]
|
||||
|
||||
@@ -78,6 +80,7 @@ ticket = { path = "crates/ticket" }
|
||||
project-record = { path = "crates/project-record" }
|
||||
worker = { path = "crates/worker" }
|
||||
worker-runtime = { path = "crates/worker-runtime" }
|
||||
workspace-api = { path = "crates/workspace-api" }
|
||||
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
||||
yoi = { path = "crates/yoi" }
|
||||
protocol = { path = "crates/protocol" }
|
||||
|
||||
@@ -16,6 +16,7 @@ thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
workspace-api.workspace = true
|
||||
workdir = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use protocol::stream::{decode_event, encode_method};
|
||||
use protocol::{ErrorCode, Event, Method};
|
||||
use serde::Deserialize;
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
|
||||
pub use workspace_api::{
|
||||
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
|
||||
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary,
|
||||
WorkerCapabilitySummary as BackendWorkerCapabilitySummary,
|
||||
WorkerImplementationSummary as BackendWorkerImplementationSummary,
|
||||
WorkerRestoreResponse as BackendWorkerRestoreResponse,
|
||||
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
|
||||
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackendRuntimeTarget {
|
||||
@@ -93,94 +101,6 @@ impl BackendRuntimeListTarget {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct BackendRuntimeListResponse<T> {
|
||||
pub workspace_id: String,
|
||||
pub limit: usize,
|
||||
pub items: Vec<T>,
|
||||
pub source: String,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<BackendDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendRuntimeSummary {
|
||||
pub runtime_id: String,
|
||||
pub label: String,
|
||||
pub kind: String,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub host_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<BackendDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendWorkerWorkspaceSummary {
|
||||
pub visibility: String,
|
||||
pub identity: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendWorkerImplementationSummary {
|
||||
pub kind: String,
|
||||
pub display_hint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendWorkerCapabilitySummary {
|
||||
pub can_stop: bool,
|
||||
pub can_spawn_followup: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendWorkerSummary {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub resource_key: String,
|
||||
pub host_id: String,
|
||||
#[serde(default)]
|
||||
pub display_name: String,
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub profile: Option<String>,
|
||||
#[serde(default)]
|
||||
pub singleton_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
pub workspace: BackendWorkerWorkspaceSummary,
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub last_seen_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pinned: bool,
|
||||
#[serde(default)]
|
||||
pub retention_state: String,
|
||||
pub implementation: BackendWorkerImplementationSummary,
|
||||
pub capabilities: BackendWorkerCapabilitySummary,
|
||||
#[serde(default)]
|
||||
pub working_directory: Option<BackendWorkingDirectorySummary>,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<BackendDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendWorkerRestoreResult {
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub worker: Option<BackendWorkerSummary>,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<BackendDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendWorkerRestoreResponse {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub result: BackendWorkerRestoreResult,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BackendRuntimeClient {
|
||||
target: BackendRuntimeTarget,
|
||||
@@ -277,7 +197,7 @@ pub async fn list_backend_workers(
|
||||
}
|
||||
Err(error) => diagnostics.push(BackendDiagnostic {
|
||||
code: "runtime_worker_list_failed".to_string(),
|
||||
severity: Some("error".to_string()),
|
||||
severity: BackendDiagnosticSeverity::Error,
|
||||
message: format!(
|
||||
"failed to list workers for runtime {}: {error}",
|
||||
runtime.runtime_id
|
||||
@@ -619,14 +539,6 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
|
||||
encoded
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendDiagnostic {
|
||||
pub code: String,
|
||||
#[serde(default)]
|
||||
pub severity: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -22,9 +22,9 @@ pub use backend_auth::{
|
||||
poll_device_login, start_device_login, wait_for_device_login,
|
||||
};
|
||||
pub use backend_runtime::{
|
||||
BackendDiagnostic, BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse,
|
||||
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
||||
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
||||
BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClient, BackendRuntimeClientError,
|
||||
BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary,
|
||||
BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
||||
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,
|
||||
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers,
|
||||
list_backend_workers, restore_backend_worker,
|
||||
|
||||
@@ -46,7 +46,7 @@ pub(crate) async fn run(
|
||||
}
|
||||
Err(error) => response.diagnostics.push(client::BackendDiagnostic {
|
||||
code: "backend_stopped_workers_list_failed".to_string(),
|
||||
severity: Some("error".to_string()),
|
||||
severity: client::BackendDiagnosticSeverity::Error,
|
||||
message: error.to_string(),
|
||||
}),
|
||||
}
|
||||
@@ -396,6 +396,7 @@ mod tests {
|
||||
workspace: BackendWorkerWorkspaceSummary {
|
||||
visibility: "workspace".to_string(),
|
||||
identity: "ws".to_string(),
|
||||
workspace_id: Some("ws".to_string()),
|
||||
},
|
||||
state: "running".to_string(),
|
||||
last_seen_at: None,
|
||||
|
||||
@@ -8,11 +8,12 @@ use fs_operation::{
|
||||
EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest,
|
||||
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||
};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::{
|
||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, Workdir,
|
||||
WorkdirError, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability,
|
||||
WorkdirSessionHandle,
|
||||
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
|
||||
CommandSnapshot, CommandStatus, Workdir, WorkdirError, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
@@ -520,6 +521,22 @@ impl WorkdirSession for DelegatingWorkdirSession {
|
||||
self.source.cancel_command(handle).await
|
||||
}
|
||||
|
||||
fn subscribe_command_events(&self) -> Option<broadcast::Receiver<CommandEvent>> {
|
||||
self.ensure_capability(WorkdirSessionCapability::Command, "command observation")
|
||||
.ok()?;
|
||||
self.source.subscribe_command_events()
|
||||
}
|
||||
|
||||
fn command_snapshot(&self) -> Vec<CommandSnapshot> {
|
||||
if self
|
||||
.ensure_capability(WorkdirSessionCapability::Command, "command observation")
|
||||
.is_err()
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
self.source.command_snapshot()
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), WorkdirError> {
|
||||
self.validity.active.store(false, Ordering::Release);
|
||||
if self.closes_source {
|
||||
@@ -732,6 +749,53 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegation_capable_session_forwards_command_telemetry() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let parent = session(root.path());
|
||||
let mut events = parent
|
||||
.subscribe_command_events()
|
||||
.expect("delegation wrapper must preserve command observation");
|
||||
let handle = parent
|
||||
.start_command(CommandRequest {
|
||||
command: "printf ready; sleep 0.2; printf done".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
tool_call_id: Some("tool-delegated".into()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let first_output = loop {
|
||||
let event = tokio::time::timeout(std::time::Duration::from_secs(2), events.recv())
|
||||
.await
|
||||
.expect("delegated command telemetry should not stall")
|
||||
.unwrap();
|
||||
if let CommandEvent::Output { content, .. } = event {
|
||||
break content;
|
||||
}
|
||||
};
|
||||
assert_eq!(first_output, "ready");
|
||||
let snapshots = parent.command_snapshot();
|
||||
assert_eq!(snapshots.len(), 1);
|
||||
assert_eq!(snapshots[0].command_id, handle.0);
|
||||
assert_eq!(snapshots[0].status, CommandStatus::Running);
|
||||
assert_eq!(snapshots[0].stdout.content, "ready");
|
||||
|
||||
let output = parent
|
||||
.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.status, CommandStatus::Completed);
|
||||
assert_eq!(output.content, "readydone");
|
||||
assert!(parent.command_snapshot().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_recursive_rule_covers_target_and_direct_children_only() {
|
||||
let rule = WorkdirDelegationRule {
|
||||
@@ -776,6 +840,8 @@ mod tests {
|
||||
.capabilities
|
||||
.supports(WorkdirSessionCapability::Command)
|
||||
);
|
||||
assert!(child.scoped_session.subscribe_command_events().is_none());
|
||||
assert!(child.scoped_session.command_snapshot().is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "workspace-api"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
workdir.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Shared Workspace HTTP resource contracts.
|
||||
//!
|
||||
//! This crate owns transport DTOs exposed by the Workspace Server and consumed
|
||||
//! by Rust clients. Runtime-internal projections remain in their owning crates;
|
||||
//! callers must explicitly construct these Workspace-authoritative resources.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use workdir::workspace::WorkingDirectorySummary;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DiagnosticSeverity {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Diagnostic {
|
||||
pub code: String,
|
||||
pub severity: DiagnosticSeverity,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ListResponse<T> {
|
||||
pub workspace_id: String,
|
||||
pub limit: usize,
|
||||
pub items: Vec<T>,
|
||||
pub source: String,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeSourceKind {
|
||||
EmbeddedWorkerRuntime,
|
||||
RemoteHttp,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeSourceStatus {
|
||||
Active,
|
||||
Reserved,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeIdentityAuthority {
|
||||
RuntimeRegistryProjection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RuntimeSourceSummary {
|
||||
pub kind: RuntimeSourceKind,
|
||||
pub status: RuntimeSourceStatus,
|
||||
pub identity_authority: RuntimeIdentityAuthority,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RuntimeCapabilitySummary {
|
||||
pub can_list_hosts: bool,
|
||||
pub can_list_workers: bool,
|
||||
pub can_get_worker: bool,
|
||||
pub can_spawn_worker: bool,
|
||||
pub can_stop_worker: bool,
|
||||
pub has_workspace_fs: bool,
|
||||
pub has_shell: bool,
|
||||
pub has_git: bool,
|
||||
pub supports_worktrees: bool,
|
||||
pub supports_backend_internal_tools: bool,
|
||||
pub workspace_scope: String,
|
||||
pub max_workers: usize,
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RuntimeSummary {
|
||||
pub runtime_id: String,
|
||||
pub label: String,
|
||||
pub kind: String,
|
||||
pub status: String,
|
||||
pub source: RuntimeSourceSummary,
|
||||
#[serde(default)]
|
||||
pub host_ids: Vec<String>,
|
||||
pub capabilities: RuntimeCapabilitySummary,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerWorkspaceSummary {
|
||||
pub visibility: String,
|
||||
pub identity: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerImplementationSummary {
|
||||
pub kind: String,
|
||||
pub display_hint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerCapabilitySummary {
|
||||
pub can_stop: bool,
|
||||
pub can_spawn_followup: bool,
|
||||
}
|
||||
|
||||
/// Workspace-authoritative Worker projection.
|
||||
///
|
||||
/// `resource_key` is required here even though Runtime-internal Worker summaries
|
||||
/// do not carry one. The Workspace Server must resolve it from Workspace
|
||||
/// authority before constructing this response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerSummary {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub resource_key: String,
|
||||
pub host_id: String,
|
||||
#[serde(default)]
|
||||
pub display_name: String,
|
||||
pub label: String,
|
||||
pub profile: Option<String>,
|
||||
pub singleton_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
pub workspace: WorkerWorkspaceSummary,
|
||||
pub state: String,
|
||||
pub last_seen_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pinned: bool,
|
||||
#[serde(default)]
|
||||
pub retention_state: String,
|
||||
pub implementation: WorkerImplementationSummary,
|
||||
pub capabilities: WorkerCapabilitySummary,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub working_directory: Option<WorkingDirectorySummary>,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerOperationState {
|
||||
Accepted,
|
||||
Unsupported,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerRestoreResult {
|
||||
pub state: WorkerOperationState,
|
||||
pub worker: Option<WorkerSummary>,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerRestoreResponse {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub result: WorkerRestoreResult,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn worker_resource_key_is_required() {
|
||||
let payload = serde_json::json!({
|
||||
"runtime_id": "arcadia",
|
||||
"worker_id": "worker-1",
|
||||
"host_id": "host",
|
||||
"display_name": "Coder",
|
||||
"label": "Coder",
|
||||
"workspace": {
|
||||
"visibility": "workspace",
|
||||
"identity": "workspace-test"
|
||||
},
|
||||
"state": "idle",
|
||||
"implementation": {"kind": "worker", "display_hint": "Coder"},
|
||||
"capabilities": {"can_stop": true, "can_spawn_followup": false}
|
||||
});
|
||||
|
||||
assert!(serde_json::from_value::<WorkerSummary>(payload).is_err());
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread"
|
||||
tower.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
worker.workspace = true
|
||||
workspace-api.workspace = true
|
||||
workdir = { workspace = true, features = ["http-client"] }
|
||||
worker-runtime.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
@@ -246,8 +246,6 @@ pub struct WorkerCapabilitySummary {
|
||||
pub struct WorkerSummary {
|
||||
#[serde(flatten)]
|
||||
pub worker: RuntimeWorkerRef,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resource_key: Option<String>,
|
||||
pub host_id: String,
|
||||
/// Human-readable display name. This is not identity and may be duplicated.
|
||||
pub display_name: String,
|
||||
@@ -271,6 +269,119 @@ pub struct WorkerSummary {
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
impl From<RuntimeDiagnostic> for workspace_api::Diagnostic {
|
||||
fn from(diagnostic: RuntimeDiagnostic) -> Self {
|
||||
let severity = match diagnostic.severity {
|
||||
DiagnosticSeverity::Info => workspace_api::DiagnosticSeverity::Info,
|
||||
DiagnosticSeverity::Warning => workspace_api::DiagnosticSeverity::Warning,
|
||||
DiagnosticSeverity::Error => workspace_api::DiagnosticSeverity::Error,
|
||||
};
|
||||
Self {
|
||||
code: diagnostic.code,
|
||||
severity,
|
||||
message: diagnostic.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RuntimeSourceSummary> for workspace_api::RuntimeSourceSummary {
|
||||
fn from(source: RuntimeSourceSummary) -> Self {
|
||||
let kind = match source.kind {
|
||||
RuntimeSourceKind::EmbeddedWorkerRuntime => {
|
||||
workspace_api::RuntimeSourceKind::EmbeddedWorkerRuntime
|
||||
}
|
||||
RuntimeSourceKind::RemoteHttp => workspace_api::RuntimeSourceKind::RemoteHttp,
|
||||
};
|
||||
let status = match source.status {
|
||||
RuntimeSourceStatus::Active => workspace_api::RuntimeSourceStatus::Active,
|
||||
RuntimeSourceStatus::Reserved => workspace_api::RuntimeSourceStatus::Reserved,
|
||||
};
|
||||
let identity_authority = match source.identity_authority {
|
||||
RuntimeIdentityAuthority::RuntimeRegistryProjection => {
|
||||
workspace_api::RuntimeIdentityAuthority::RuntimeRegistryProjection
|
||||
}
|
||||
};
|
||||
Self {
|
||||
kind,
|
||||
status,
|
||||
identity_authority,
|
||||
note: source.note,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RuntimeCapabilitySummary> for workspace_api::RuntimeCapabilitySummary {
|
||||
fn from(capabilities: RuntimeCapabilitySummary) -> Self {
|
||||
Self {
|
||||
can_list_hosts: capabilities.can_list_hosts,
|
||||
can_list_workers: capabilities.can_list_workers,
|
||||
can_get_worker: capabilities.can_get_worker,
|
||||
can_spawn_worker: capabilities.can_spawn_worker,
|
||||
can_stop_worker: capabilities.can_stop_worker,
|
||||
has_workspace_fs: capabilities.has_workspace_fs,
|
||||
has_shell: capabilities.has_shell,
|
||||
has_git: capabilities.has_git,
|
||||
supports_worktrees: capabilities.supports_worktrees,
|
||||
supports_backend_internal_tools: capabilities.supports_backend_internal_tools,
|
||||
workspace_scope: capabilities.workspace_scope,
|
||||
max_workers: capabilities.max_workers,
|
||||
os: capabilities.os,
|
||||
arch: capabilities.arch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
|
||||
fn from(runtime: RuntimeSummary) -> Self {
|
||||
Self {
|
||||
runtime_id: runtime.runtime_id,
|
||||
label: runtime.label,
|
||||
kind: runtime.kind,
|
||||
status: runtime.status,
|
||||
source: runtime.source.into(),
|
||||
host_ids: runtime.host_ids,
|
||||
capabilities: runtime.capabilities.into(),
|
||||
diagnostics: runtime.diagnostics.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_worker_summary(
|
||||
summary: WorkerSummary,
|
||||
resource_key: String,
|
||||
) -> workspace_api::WorkerSummary {
|
||||
workspace_api::WorkerSummary {
|
||||
runtime_id: summary.worker.runtime_id,
|
||||
worker_id: summary.worker.worker_id,
|
||||
resource_key,
|
||||
host_id: summary.host_id,
|
||||
display_name: summary.display_name,
|
||||
label: summary.label,
|
||||
profile: summary.profile,
|
||||
singleton_key: summary.singleton_key,
|
||||
tags: summary.tags,
|
||||
workspace: workspace_api::WorkerWorkspaceSummary {
|
||||
visibility: summary.workspace.visibility,
|
||||
identity: summary.workspace.identity,
|
||||
workspace_id: summary.workspace.workspace_id,
|
||||
},
|
||||
state: summary.state,
|
||||
last_seen_at: summary.last_seen_at,
|
||||
pinned: summary.pinned,
|
||||
retention_state: summary.retention_state,
|
||||
implementation: workspace_api::WorkerImplementationSummary {
|
||||
kind: summary.implementation.kind,
|
||||
display_hint: summary.implementation.display_hint,
|
||||
},
|
||||
capabilities: workspace_api::WorkerCapabilitySummary {
|
||||
can_stop: summary.capabilities.can_stop,
|
||||
can_spawn_followup: summary.capabilities.can_spawn_followup,
|
||||
},
|
||||
working_directory: summary.working_directory,
|
||||
diagnostics: summary.diagnostics.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerRestoreResult {
|
||||
pub state: WorkerOperationState,
|
||||
@@ -509,6 +620,16 @@ pub enum WorkerOperationState {
|
||||
Rejected,
|
||||
}
|
||||
|
||||
impl From<WorkerOperationState> for workspace_api::WorkerOperationState {
|
||||
fn from(state: WorkerOperationState) -> Self {
|
||||
match state {
|
||||
WorkerOperationState::Accepted => Self::Accepted,
|
||||
WorkerOperationState::Unsupported => Self::Unsupported,
|
||||
WorkerOperationState::Rejected => Self::Rejected,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerSpawnAcceptanceEvidence {
|
||||
pub kind: String,
|
||||
@@ -1680,7 +1801,6 @@ impl EmbeddedWorkerRuntime {
|
||||
);
|
||||
WorkerSummary {
|
||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
||||
resource_key: None,
|
||||
host_id: self.host_id.clone(),
|
||||
display_name: display.display_name.clone(),
|
||||
label: display.display_name,
|
||||
@@ -1720,7 +1840,6 @@ impl EmbeddedWorkerRuntime {
|
||||
);
|
||||
WorkerSummary {
|
||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
||||
resource_key: None,
|
||||
host_id: self.host_id.clone(),
|
||||
display_name: display.display_name.clone(),
|
||||
label: display.display_name,
|
||||
@@ -2806,7 +2925,6 @@ impl RemoteWorkerRuntime {
|
||||
);
|
||||
WorkerSummary {
|
||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
||||
resource_key: None,
|
||||
host_id: self.host_id.clone(),
|
||||
display_name: display.display_name.clone(),
|
||||
label: display.display_name,
|
||||
@@ -2850,7 +2968,6 @@ impl RemoteWorkerRuntime {
|
||||
);
|
||||
WorkerSummary {
|
||||
worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id.clone()),
|
||||
resource_key: None,
|
||||
host_id: self.host_id.clone(),
|
||||
display_name: display.display_name.clone(),
|
||||
label: display.display_name,
|
||||
@@ -4222,7 +4339,6 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
|
||||
let host_id = host_id.into();
|
||||
WorkerSummary {
|
||||
worker: RuntimeWorkerRef::new("placeholder", "worker-placeholder"),
|
||||
resource_key: None,
|
||||
host_id,
|
||||
display_name: "Worker runtime actions are not implemented".to_string(),
|
||||
label: "Worker runtime actions are not implemented".to_string(),
|
||||
@@ -4616,7 +4732,6 @@ mod tests {
|
||||
host_id: host_id.to_string(),
|
||||
workers: vec![WorkerSummary {
|
||||
worker: RuntimeWorkerRef::new(runtime_id, worker_id),
|
||||
resource_key: None,
|
||||
host_id: host_id.to_string(),
|
||||
display_name: label.to_string(),
|
||||
label: label.to_string(),
|
||||
|
||||
@@ -77,13 +77,13 @@ use crate::hosts::{
|
||||
ConfigBundleCheckResult, ConfigBundleSyncResult, DiagnosticSeverity, EMBEDDED_RUNTIME_ID,
|
||||
EmbeddedWorkerRuntime, HostSummary, RemoteRuntimeConfig, RemoteWorkerRuntime,
|
||||
RuntimeDiagnostic, RuntimeRegistry, RuntimeRegistryError, RuntimeRegistryUnregisterResult,
|
||||
RuntimeSummary, TicketWorkerRole, WorkerCapabilitySummary, WorkerCompletionsRequest,
|
||||
WorkerCompletionsResult, WorkerControlOperation, WorkerCreateBinding,
|
||||
WorkerImplementationSummary, WorkerInputKind, WorkerInputRequest, WorkerInputResult,
|
||||
WorkerLifecycleRequest, WorkerLifecycleResult, WorkerOperationState, WorkerRestoreResult,
|
||||
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult,
|
||||
WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerTicketAssignmentRequest,
|
||||
WorkerWorkspaceSummary, worker_spawn_create_fingerprint,
|
||||
TicketWorkerRole, WorkerCapabilitySummary, WorkerCompletionsRequest, WorkerCompletionsResult,
|
||||
WorkerControlOperation, WorkerCreateBinding, WorkerImplementationSummary, WorkerInputKind,
|
||||
WorkerInputRequest, WorkerInputResult, WorkerLifecycleRequest, WorkerLifecycleResult,
|
||||
WorkerOperationState, WorkerRestoreResult, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent,
|
||||
WorkerSpawnRequest, WorkerSpawnResult, WorkerSpawnWorkingDirectoryRequest, WorkerSummary,
|
||||
WorkerTicketAssignmentRequest, WorkerWorkspaceSummary, worker_spawn_create_fingerprint,
|
||||
workspace_worker_summary,
|
||||
};
|
||||
use crate::identity::WorkspaceIdentity;
|
||||
use crate::memory_backend::execute_memory_backend_operation_with_authority;
|
||||
@@ -2264,14 +2264,6 @@ enum RuntimeWorkersStatusFilter {
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WorkerRestoreResponse {
|
||||
pub workspace_id: String,
|
||||
#[serde(flatten)]
|
||||
pub worker_ref: RuntimeWorkerRef,
|
||||
pub result: WorkerRestoreResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CleanupTargetKind {
|
||||
@@ -6534,7 +6526,7 @@ async fn scoped_get_profile_source_archive(
|
||||
async fn scoped_list_runtimes(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
) -> ApiResult<Json<RuntimeListResponse<RuntimeSummary>>> {
|
||||
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::RuntimeSummary>>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
list_runtimes(State(api)).await
|
||||
}
|
||||
@@ -6722,7 +6714,7 @@ async fn scoped_worker_remove_source_boundary(
|
||||
async fn scoped_get_workspace_worker(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspaceWorkerReferencePath>,
|
||||
) -> ApiResult<Json<WorkerSummary>> {
|
||||
) -> ApiResult<Json<workspace_api::WorkerSummary>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let worker_id = api
|
||||
.store
|
||||
@@ -6738,7 +6730,7 @@ async fn scoped_get_workspace_worker(
|
||||
workers
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|worker| worker.worker.worker_id == worker_id)
|
||||
.find(|worker| worker.worker_id == worker_id)
|
||||
.map(Json)
|
||||
.ok_or_else(|| {
|
||||
Error::UnknownWorker {
|
||||
@@ -6751,7 +6743,7 @@ async fn scoped_get_workspace_worker(
|
||||
async fn scoped_list_workers(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
|
||||
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::WorkerSummary>>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
list_workers(State(api)).await
|
||||
}
|
||||
@@ -7010,7 +7002,7 @@ async fn restore_known_worker(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRuntimeWorkerPath>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<Json<WorkerRestoreResponse>> {
|
||||
) -> ApiResult<Json<workspace_api::WorkerRestoreResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?;
|
||||
let subject = path.worker.clone();
|
||||
@@ -7590,7 +7582,7 @@ fn build_runtime_cleanup_plan(
|
||||
.items
|
||||
.iter()
|
||||
.filter(|worker| worker.state == "running")
|
||||
.map(|worker| worker.worker.clone())
|
||||
.map(|worker| RuntimeWorkerRef::new(&worker.runtime_id, &worker.worker_id))
|
||||
.collect();
|
||||
let (workdir_summaries, mut diagnostics) =
|
||||
match runtime_working_directory_summaries(api, runtime_id) {
|
||||
@@ -8107,7 +8099,7 @@ async fn scoped_list_runtime_workers(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||
Query(query): Query<RuntimeWorkersQuery>,
|
||||
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
|
||||
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::WorkerSummary>>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
list_runtime_workers(State(api), AxumPath(path.runtime_id), Query(query)).await
|
||||
}
|
||||
@@ -8166,7 +8158,7 @@ async fn scoped_restore_runtime_worker(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRuntimeWorkerPath>,
|
||||
Query(query): Query<RestoreTicketAssignmentQuery>,
|
||||
) -> ApiResult<Json<WorkerRestoreResponse>> {
|
||||
) -> ApiResult<Json<workspace_api::WorkerRestoreResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let workspace_id = path.workspace_id.clone();
|
||||
let runtime_id = path.worker.runtime_id.clone();
|
||||
@@ -8219,11 +8211,13 @@ async fn scoped_restore_runtime_worker(
|
||||
.into());
|
||||
}
|
||||
assign_ticket_worker_from_lifecycle(&api, assignment, &runtime_id, &worker_id)?;
|
||||
return Ok(Json(WorkerRestoreResponse {
|
||||
let worker = project_workspace_worker(&api, worker)?;
|
||||
return Ok(Json(workspace_api::WorkerRestoreResponse {
|
||||
workspace_id,
|
||||
worker_ref: RuntimeWorkerRef::new(&runtime_id, &worker_id),
|
||||
result: crate::hosts::WorkerRestoreResult {
|
||||
state: WorkerOperationState::Accepted,
|
||||
runtime_id: runtime_id.clone(),
|
||||
worker_id: worker_id.clone(),
|
||||
result: workspace_api::WorkerRestoreResult {
|
||||
state: workspace_api::WorkerOperationState::Accepted,
|
||||
worker: Some(worker),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
@@ -8352,7 +8346,7 @@ async fn scoped_worker_protocol_ws(
|
||||
async fn scoped_list_host_workers(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedHostPath>,
|
||||
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
|
||||
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::WorkerSummary>>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
list_host_workers(State(api), AxumPath(path.host_id)).await
|
||||
}
|
||||
@@ -9304,21 +9298,21 @@ async fn list_hosts(
|
||||
|
||||
async fn list_runtimes(
|
||||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<RuntimeListResponse<RuntimeSummary>>> {
|
||||
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::RuntimeSummary>>> {
|
||||
let limit = api.config.max_records.min(200);
|
||||
let runtimes = api.runtime.list_runtimes(limit);
|
||||
Ok(Json(RuntimeListResponse {
|
||||
Ok(Json(workspace_api::ListResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
limit,
|
||||
items: runtimes.items,
|
||||
items: runtimes.items.into_iter().map(Into::into).collect(),
|
||||
source: "worker_runtime_registry".to_string(),
|
||||
diagnostics: runtimes.diagnostics,
|
||||
diagnostics: runtimes.diagnostics.into_iter().map(Into::into).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_workers(
|
||||
State(api): State<WorkspaceApi>,
|
||||
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
|
||||
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::WorkerSummary>>> {
|
||||
workers_response(api).map(Json)
|
||||
}
|
||||
|
||||
@@ -10025,7 +10019,7 @@ async fn post_companion_cancel(
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WorkerShowProjection {
|
||||
#[serde(flatten)]
|
||||
worker: WorkerSummary,
|
||||
worker: workspace_api::WorkerSummary,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
@@ -10071,31 +10065,18 @@ async fn get_runtime_worker(
|
||||
.store
|
||||
.list_workdir_registry(&api.config.workspace_id, 500)?;
|
||||
let updated_at = record.updated_at.clone();
|
||||
let mut worker = merge_worker_registry_projection(Some(&worker), &record, links, &workdirs);
|
||||
worker.resource_key = Some(
|
||||
api.store
|
||||
.resource_key(
|
||||
&api.config.workspace_id,
|
||||
WorkspaceResourceKind::Worker,
|
||||
&worker_ref.worker_id,
|
||||
)?
|
||||
.ok_or_else(|| {
|
||||
Error::Store(format!(
|
||||
"Workspace Worker `{}` has no resource key",
|
||||
worker_ref.worker_id
|
||||
))
|
||||
})?,
|
||||
);
|
||||
let worker = merge_worker_registry_projection(Some(&worker), &record, links, &workdirs);
|
||||
let worker = project_workspace_worker(&api, worker)?;
|
||||
Ok(Json(WorkerShowProjection { worker, updated_at }))
|
||||
}
|
||||
|
||||
async fn restore_runtime_worker(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>,
|
||||
) -> ApiResult<Json<WorkerRestoreResponse>> {
|
||||
) -> ApiResult<Json<workspace_api::WorkerRestoreResponse>> {
|
||||
let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?;
|
||||
let mut result = api.restore_workspace_worker(&worker)?;
|
||||
if let Some(worker) = result.worker.as_ref() {
|
||||
let result = api.restore_workspace_worker(&worker)?;
|
||||
let projected_worker = if let Some(worker) = result.worker.as_ref() {
|
||||
let record = sync_worker_observation(&api, worker)?;
|
||||
let links = api
|
||||
.store
|
||||
@@ -10103,27 +10084,20 @@ async fn restore_runtime_worker(
|
||||
let workdirs = api
|
||||
.store
|
||||
.list_workdir_registry(&api.config.workspace_id, 500)?;
|
||||
let mut summary = merge_worker_registry_projection(Some(worker), &record, links, &workdirs);
|
||||
summary.resource_key = Some(
|
||||
api.store
|
||||
.resource_key(
|
||||
&api.config.workspace_id,
|
||||
WorkspaceResourceKind::Worker,
|
||||
&record.worker.worker_id,
|
||||
)?
|
||||
.ok_or_else(|| {
|
||||
Error::Store(format!(
|
||||
"Workspace Worker `{}` has no resource key",
|
||||
record.worker.worker_id
|
||||
))
|
||||
})?,
|
||||
);
|
||||
result.worker = Some(summary);
|
||||
}
|
||||
Ok(Json(WorkerRestoreResponse {
|
||||
let summary = merge_worker_registry_projection(Some(worker), &record, links, &workdirs);
|
||||
Some(project_workspace_worker(&api, summary)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Json(workspace_api::WorkerRestoreResponse {
|
||||
workspace_id: api.workspace_id().to_string(),
|
||||
worker_ref: RuntimeWorkerRef::new(&runtime_id, &worker_id),
|
||||
result,
|
||||
runtime_id: runtime_id.clone(),
|
||||
worker_id: worker_id.clone(),
|
||||
result: workspace_api::WorkerRestoreResult {
|
||||
state: result.state.into(),
|
||||
worker: projected_worker,
|
||||
diagnostics: result.diagnostics.into_iter().map(Into::into).collect(),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -10179,28 +10153,33 @@ async fn list_runtime_workers(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(runtime_id): AxumPath<String>,
|
||||
Query(query): Query<RuntimeWorkersQuery>,
|
||||
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
|
||||
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::WorkerSummary>>> {
|
||||
let limit = api.config.max_records.min(200);
|
||||
let (worker_list, source) = match query.status {
|
||||
let (runtime_workers, source) = match query.status {
|
||||
Some(RuntimeWorkersStatusFilter::Stopped) => (
|
||||
api.runtime
|
||||
.list_stopped_workers_for_runtime(&runtime_id, limit)
|
||||
.map_err(|err| err.into_error())?,
|
||||
.map_err(|error| error.into_error())?,
|
||||
"runtime_registry_stopped",
|
||||
),
|
||||
None => (
|
||||
api.runtime
|
||||
.list_workers_for_runtime(&runtime_id, limit)
|
||||
.map_err(|err| err.into_error())?,
|
||||
.map_err(|error| error.into_error())?,
|
||||
"runtime_registry",
|
||||
),
|
||||
};
|
||||
Ok(Json(RuntimeListResponse {
|
||||
let items = project_observed_workspace_workers(&api, runtime_workers.items)?;
|
||||
Ok(Json(workspace_api::ListResponse {
|
||||
workspace_id: api.workspace_id().to_string(),
|
||||
limit,
|
||||
items: worker_list.items,
|
||||
items,
|
||||
source: source.to_string(),
|
||||
diagnostics: worker_list.diagnostics,
|
||||
diagnostics: runtime_workers
|
||||
.diagnostics
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -11142,22 +11121,70 @@ fn protocol_error_event(message: impl Into<String>) -> protocol::Event {
|
||||
async fn list_host_workers(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(host_id): AxumPath<String>,
|
||||
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
|
||||
) -> ApiResult<Json<workspace_api::ListResponse<workspace_api::WorkerSummary>>> {
|
||||
let limit = api.config.max_records.min(200);
|
||||
let runtime_workers = api
|
||||
.runtime
|
||||
.list_workers_for_host(&host_id, limit)
|
||||
.map_err(|err| err.into_error())?;
|
||||
Ok(Json(RuntimeListResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
let items = project_observed_workspace_workers(&api, runtime_workers.items)?;
|
||||
Ok(Json(workspace_api::ListResponse {
|
||||
workspace_id: api.workspace_id().to_string(),
|
||||
limit,
|
||||
items: runtime_workers.items,
|
||||
items,
|
||||
source: "worker_runtime_registry".to_string(),
|
||||
diagnostics: runtime_workers.diagnostics,
|
||||
diagnostics: runtime_workers
|
||||
.diagnostics
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSummary>> {
|
||||
fn project_workspace_worker(
|
||||
api: &WorkspaceApi,
|
||||
summary: WorkerSummary,
|
||||
) -> ApiResult<workspace_api::WorkerSummary> {
|
||||
let resource_key = api
|
||||
.store
|
||||
.resource_key(
|
||||
&api.config.workspace_id,
|
||||
WorkspaceResourceKind::Worker,
|
||||
&summary.worker.worker_id,
|
||||
)?
|
||||
.ok_or_else(|| {
|
||||
Error::Store(format!(
|
||||
"Workspace Worker `{}` has no resource key",
|
||||
summary.worker.worker_id
|
||||
))
|
||||
})?;
|
||||
Ok(workspace_worker_summary(summary, resource_key))
|
||||
}
|
||||
|
||||
fn project_observed_workspace_workers(
|
||||
api: &WorkspaceApi,
|
||||
workers: Vec<WorkerSummary>,
|
||||
) -> ApiResult<Vec<workspace_api::WorkerSummary>> {
|
||||
let workdirs = api
|
||||
.store
|
||||
.list_workdir_registry(&api.config.workspace_id, 500)?;
|
||||
workers
|
||||
.into_iter()
|
||||
.map(|worker| {
|
||||
let record = sync_worker_observation(api, &worker)?;
|
||||
let links = api
|
||||
.store
|
||||
.list_worker_workdir_links(&api.config.workspace_id, &record.worker)?;
|
||||
let summary =
|
||||
merge_worker_registry_projection(Some(&worker), &record, links, &workdirs);
|
||||
project_workspace_worker(api, summary)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn workers_response(
|
||||
api: WorkspaceApi,
|
||||
) -> ApiResult<workspace_api::ListResponse<workspace_api::WorkerSummary>> {
|
||||
let limit = api.config.max_records.min(200);
|
||||
let runtime_workers = api.runtime.list_workers(limit);
|
||||
let mut observed = std::collections::BTreeMap::new();
|
||||
@@ -11196,34 +11223,20 @@ fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSu
|
||||
let links = api
|
||||
.store
|
||||
.list_worker_workdir_links(&api.config.workspace_id, &record.worker)?;
|
||||
let mut summary = merge_worker_registry_projection(
|
||||
let summary = merge_worker_registry_projection(
|
||||
observed.get(&record.worker),
|
||||
&record,
|
||||
links,
|
||||
&workdir_records,
|
||||
);
|
||||
summary.resource_key = Some(
|
||||
api.store
|
||||
.resource_key(
|
||||
&api.config.workspace_id,
|
||||
WorkspaceResourceKind::Worker,
|
||||
&record.worker.worker_id,
|
||||
)?
|
||||
.ok_or_else(|| {
|
||||
Error::Store(format!(
|
||||
"Workspace Worker `{}` has no resource key",
|
||||
record.worker.worker_id
|
||||
))
|
||||
})?,
|
||||
);
|
||||
items.push(summary);
|
||||
items.push(project_workspace_worker(&api, summary)?);
|
||||
}
|
||||
Ok(RuntimeListResponse {
|
||||
Ok(workspace_api::ListResponse {
|
||||
workspace_id: api.config.workspace_id,
|
||||
limit,
|
||||
items,
|
||||
source: "backend_worker_registry".to_string(),
|
||||
diagnostics,
|
||||
diagnostics: diagnostics.into_iter().map(Into::into).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12084,7 +12097,6 @@ fn record_worker_summary(
|
||||
fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary {
|
||||
WorkerSummary {
|
||||
worker: record.worker.clone(),
|
||||
resource_key: None,
|
||||
host_id: "backend-registry".to_string(),
|
||||
display_name: record.display_name.clone(),
|
||||
label: record.display_name.clone(),
|
||||
@@ -15979,11 +15991,11 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(retried_restore.worker_id, first_worker.worker.worker_id);
|
||||
assert_eq!(
|
||||
retried_restore.worker_ref.worker_id,
|
||||
first_worker.worker.worker_id
|
||||
retried_restore.result.state,
|
||||
workspace_api::WorkerOperationState::Accepted
|
||||
);
|
||||
assert_eq!(retried_restore.result.state, WorkerOperationState::Accepted);
|
||||
let restored_assignment = api
|
||||
.store
|
||||
.get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &second_ticket.id)
|
||||
@@ -18159,6 +18171,23 @@ mod tests {
|
||||
assert_eq!(worker["profile"], "builtin:companion");
|
||||
assert!(worker.get("role").is_none());
|
||||
assert_eq!(worker["worker_id"], created["worker_id"]);
|
||||
let resource_key = worker["resource_key"]
|
||||
.as_str()
|
||||
.expect("Workspace Worker list must project a resource key");
|
||||
assert!(resource_key.starts_with("W-"));
|
||||
|
||||
let runtime_workers =
|
||||
get_json(app.clone(), "/api/runtimes/embedded-worker-runtime/workers").await;
|
||||
let runtime_workers = serde_json::from_value::<
|
||||
workspace_api::ListResponse<workspace_api::WorkerSummary>,
|
||||
>(runtime_workers)
|
||||
.expect("Runtime-scoped Worker list must use the shared Workspace API contract");
|
||||
assert!(
|
||||
runtime_workers
|
||||
.items
|
||||
.iter()
|
||||
.any(|worker| worker.resource_key == resource_key)
|
||||
);
|
||||
let detail_path = format!(
|
||||
"/api/runtimes/{}/workers/{}",
|
||||
created["runtime_id"].as_str().unwrap(),
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { ansiSegments } from "./ansi.ts";
|
||||
|
||||
let { text }: { text: string } = $props();
|
||||
let segments = $derived(ansiSegments(text));
|
||||
</script>
|
||||
|
||||
{#each segments as segment}
|
||||
<span
|
||||
class:ansi-bold={segment.bold}
|
||||
class:ansi-dim={segment.dim}
|
||||
class:ansi-italic={segment.italic}
|
||||
class:ansi-underline={segment.underline}
|
||||
class:ansi-strikethrough={segment.strikethrough}
|
||||
class:ansi-concealed={segment.concealed}
|
||||
style:color={segment.foreground}
|
||||
style:background-color={segment.background}
|
||||
>{segment.text}</span>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.ansi-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ansi-dim {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.ansi-italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ansi-underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
|
||||
.ansi-strikethrough {
|
||||
text-decoration-line: line-through;
|
||||
}
|
||||
|
||||
.ansi-underline.ansi-strikethrough {
|
||||
text-decoration-line: underline line-through;
|
||||
}
|
||||
|
||||
.ansi-concealed {
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import AnsiText from '$lib/workspace/console/AnsiText.svelte';
|
||||
import RichMarkdown from '$lib/workspace/console/RichMarkdown.svelte';
|
||||
import type { ConsoleLine } from '$lib/workspace/console/model';
|
||||
|
||||
@@ -18,6 +19,10 @@
|
||||
return [name ? `tool-${name}` : '', `tool-state-${state}`].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function isBashTool(line: ConsoleLine): boolean {
|
||||
return line.toolCall?.name?.toLowerCase() === 'bash';
|
||||
}
|
||||
|
||||
function shouldRenderHeading(line: ConsoleLine): boolean {
|
||||
return line.kind !== 'assistant' && line.kind !== 'user' && line.kind !== 'tool' &&
|
||||
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
|
||||
@@ -60,7 +65,13 @@
|
||||
{/if}
|
||||
{#if item.kind === 'tool'}
|
||||
{#if bodyTextAfterToolSummary(item)}
|
||||
<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>
|
||||
<p class="console-plain-text">
|
||||
{#if isBashTool(item)}
|
||||
<AnsiText text={bodyTextAfterToolSummary(item)} />
|
||||
{:else}
|
||||
{bodyTextAfterToolSummary(item)}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{:else if item.kind === 'user'}
|
||||
<div class="user-message">
|
||||
@@ -130,6 +141,10 @@
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.activity-summary {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.task-reminder-summary {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -184,7 +199,10 @@
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.1;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
@@ -193,6 +211,10 @@
|
||||
color: var(--tui-gray);
|
||||
}
|
||||
|
||||
.console-line.tool.tool-bash .console-plain-text {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tool-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
.slice(0, 3),
|
||||
);
|
||||
|
||||
function taskNoun(count: number): "task" | "tasks" {
|
||||
return count === 1 ? "task" : "tasks";
|
||||
}
|
||||
|
||||
function mark(status: ConsoleTask["status"]): string {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
@@ -54,7 +58,7 @@
|
||||
{/each}
|
||||
<div class="task-summary-row">
|
||||
<span class="task-summary">
|
||||
{counts.total} task(s) — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}, deleted: {counts.deleted}
|
||||
{counts.total} {taskNoun(counts.total)} — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}
|
||||
</span>
|
||||
{#if workerViews.length > 1}
|
||||
<span class="worker-view-tabs" role="group" aria-label="Worker transcript view">
|
||||
@@ -119,7 +123,6 @@
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
margin-bottom: -0.75rem;
|
||||
padding-inline: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
export type AnsiSegment = {
|
||||
text: string;
|
||||
foreground?: string;
|
||||
background?: string;
|
||||
bold: boolean;
|
||||
dim: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
strikethrough: boolean;
|
||||
concealed: boolean;
|
||||
};
|
||||
|
||||
type AnsiState = Omit<AnsiSegment, "text"> & { inverse: boolean };
|
||||
|
||||
const ANSI_PALETTE = [
|
||||
"#1e1e1e",
|
||||
"#cd3131",
|
||||
"#0dbc79",
|
||||
"#e5e510",
|
||||
"#2472c8",
|
||||
"#bc3fbc",
|
||||
"#11a8cd",
|
||||
"#e5e5e5",
|
||||
"#666666",
|
||||
"#f14c4c",
|
||||
"#23d18b",
|
||||
"#f5f543",
|
||||
"#3b8eea",
|
||||
"#d670d6",
|
||||
"#29b8db",
|
||||
"#ffffff",
|
||||
] as const;
|
||||
|
||||
export function ansiSegments(input: string): AnsiSegment[] {
|
||||
const segments: AnsiSegment[] = [];
|
||||
let state = defaultState();
|
||||
let text = "";
|
||||
|
||||
const flush = () => {
|
||||
if (!text) return;
|
||||
segments.push(segmentFromState(text, state));
|
||||
text = "";
|
||||
};
|
||||
|
||||
for (let index = 0; index < input.length;) {
|
||||
const code = input.charCodeAt(index);
|
||||
if (code !== 0x1b) {
|
||||
if (code >= 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) {
|
||||
text += input[index];
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
flush();
|
||||
const next = input[index + 1];
|
||||
if (next === "[") {
|
||||
const finalIndex = findCsiFinal(input, index + 2);
|
||||
if (finalIndex < 0) break;
|
||||
if (input[finalIndex] === "m") {
|
||||
state = applySgr(state, input.slice(index + 2, finalIndex));
|
||||
}
|
||||
index = finalIndex + 1;
|
||||
continue;
|
||||
}
|
||||
if (next === "]") {
|
||||
const finalIndex = findOscFinal(input, index + 2);
|
||||
if (finalIndex < 0) break;
|
||||
index = finalIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
index += next === undefined ? 1 : 2;
|
||||
}
|
||||
|
||||
flush();
|
||||
return segments;
|
||||
}
|
||||
|
||||
function defaultState(): AnsiState {
|
||||
return {
|
||||
foreground: undefined,
|
||||
background: undefined,
|
||||
bold: false,
|
||||
dim: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
concealed: false,
|
||||
inverse: false,
|
||||
};
|
||||
}
|
||||
|
||||
function segmentFromState(text: string, state: AnsiState): AnsiSegment {
|
||||
const foreground = state.inverse
|
||||
? state.background ?? "var(--bg)"
|
||||
: state.foreground;
|
||||
const background = state.inverse
|
||||
? state.foreground ?? "var(--text)"
|
||||
: state.background;
|
||||
return {
|
||||
text,
|
||||
foreground,
|
||||
background,
|
||||
bold: state.bold,
|
||||
dim: state.dim,
|
||||
italic: state.italic,
|
||||
underline: state.underline,
|
||||
strikethrough: state.strikethrough,
|
||||
concealed: state.concealed,
|
||||
};
|
||||
}
|
||||
|
||||
function findCsiFinal(input: string, start: number): number {
|
||||
for (let index = start; index < input.length; index += 1) {
|
||||
const code = input.charCodeAt(index);
|
||||
if (code >= 0x40 && code <= 0x7e) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findOscFinal(input: string, start: number): number {
|
||||
for (let index = start; index < input.length; index += 1) {
|
||||
if (input.charCodeAt(index) === 0x07) return index + 1;
|
||||
if (input.charCodeAt(index) === 0x1b && input[index + 1] === "\\") {
|
||||
return index + 2;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function applySgr(current: AnsiState, raw: string): AnsiState {
|
||||
const state = { ...current };
|
||||
const values = raw === "" ? [0] : raw.split(";").map(parseSgrValue);
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const code = values[index];
|
||||
if (code === null) continue;
|
||||
if (code === 0) Object.assign(state, defaultState());
|
||||
else if (code === 1) state.bold = true;
|
||||
else if (code === 2) state.dim = true;
|
||||
else if (code === 3) state.italic = true;
|
||||
else if (code === 4) state.underline = true;
|
||||
else if (code === 7) state.inverse = true;
|
||||
else if (code === 8) state.concealed = true;
|
||||
else if (code === 9) state.strikethrough = true;
|
||||
else if (code === 22) {
|
||||
state.bold = false;
|
||||
state.dim = false;
|
||||
} else if (code === 23) state.italic = false;
|
||||
else if (code === 24) state.underline = false;
|
||||
else if (code === 27) state.inverse = false;
|
||||
else if (code === 28) state.concealed = false;
|
||||
else if (code === 29) state.strikethrough = false;
|
||||
else if (code >= 30 && code <= 37) {
|
||||
state.foreground = ANSI_PALETTE[code - 30];
|
||||
} else if (code === 38 || code === 48) {
|
||||
const parsed = parseExtendedColor(values, index + 1);
|
||||
if (parsed) {
|
||||
if (code === 38) state.foreground = parsed.color;
|
||||
else state.background = parsed.color;
|
||||
index += parsed.consumed;
|
||||
}
|
||||
} else if (code === 39) state.foreground = undefined;
|
||||
else if (code >= 40 && code <= 47) {
|
||||
state.background = ANSI_PALETTE[code - 40];
|
||||
} else if (code === 49) state.background = undefined;
|
||||
else if (code >= 90 && code <= 97) {
|
||||
state.foreground = ANSI_PALETTE[code - 82];
|
||||
} else if (code >= 100 && code <= 107) {
|
||||
state.background = ANSI_PALETTE[code - 92];
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function parseSgrValue(value: string): number | null {
|
||||
if (!/^\d+$/.test(value)) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseExtendedColor(
|
||||
values: Array<number | null>,
|
||||
start: number,
|
||||
): { color: string; consumed: number } | null {
|
||||
const mode = values[start];
|
||||
const first = values[start + 1];
|
||||
if (mode === 5 && isByte(first)) {
|
||||
return { color: palette256(first), consumed: 2 };
|
||||
}
|
||||
const second = values[start + 2];
|
||||
const third = values[start + 3];
|
||||
if (mode === 2 && isByte(first) && isByte(second) && isByte(third)) {
|
||||
return {
|
||||
color: `rgb(${first}, ${second}, ${third})`,
|
||||
consumed: 4,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isByte(value: number | null | undefined): value is number {
|
||||
return value !== null && value !== undefined && value >= 0 && value <= 255;
|
||||
}
|
||||
|
||||
function palette256(index: number): string {
|
||||
if (index < 16) return ANSI_PALETTE[index];
|
||||
if (index < 232) {
|
||||
const offset = index - 16;
|
||||
const red = Math.floor(offset / 36);
|
||||
const green = Math.floor((offset % 36) / 6);
|
||||
const blue = offset % 6;
|
||||
return `rgb(${colorCube(red)}, ${colorCube(green)}, ${colorCube(blue)})`;
|
||||
}
|
||||
const gray = 8 + (index - 232) * 10;
|
||||
return `rgb(${gray}, ${gray}, ${gray})`;
|
||||
}
|
||||
|
||||
function colorCube(value: number): number {
|
||||
return value === 0 ? 0 : 55 + value * 40;
|
||||
}
|
||||
@@ -422,9 +422,11 @@ Deno.test("projectConsole streams distinct Bash stdout and stderr through termin
|
||||
|
||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||
assert(line.body.includes("Bash — failed (exit 7)"), line.body);
|
||||
assert(line.body.includes("elapsed 300ms"), line.body);
|
||||
assert(line.body.includes("stdout:\nready\n"), line.body);
|
||||
assert(!line.body.includes("elapsed"), line.body);
|
||||
assert(!line.body.includes("stdout:"), line.body);
|
||||
assert(line.body.includes("ready\n"), line.body);
|
||||
assert(line.body.includes("stderr:\nwarn\n"), line.body);
|
||||
assert(line.detail?.includes("command: elapsed 300ms"), line.detail ?? "");
|
||||
assertEquals(line.streaming, false);
|
||||
assertEquals(line.error, true);
|
||||
});
|
||||
@@ -462,12 +464,13 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
|
||||
const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
|
||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||
assert(line.body.includes("Bash — running…"), line.body);
|
||||
assert(!line.body.includes("elapsed"), line.body);
|
||||
assert(!line.body.includes("stdout:"), line.body);
|
||||
assert(line.body.includes("[… earlier stdout omitted]\ntail\n"), line.body);
|
||||
assert(
|
||||
line.body.includes("elapsed 250ms · last output at +200ms"),
|
||||
line.body,
|
||||
line.detail?.includes("command: elapsed 250ms · last output at +200ms"),
|
||||
line.detail ?? "",
|
||||
);
|
||||
assert(line.body.includes("[stdout tail; earlier output omitted]"), line.body);
|
||||
assert(line.body.includes("stdout:\ntail\n"), line.body);
|
||||
assertEquals(line.streaming, true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1505,7 +1505,6 @@ function renderBashTool(toolCall: ToolCallView): string {
|
||||
return compactLines([
|
||||
`Bash — ${commandStateSuffix(toolCall)}`,
|
||||
command ? `$ ${command}` : argsText(toolCall),
|
||||
commandTiming(toolCall.command),
|
||||
["done", "error"].includes(toolCall.state)
|
||||
? cappedDisplaySection(resultText(toolCall), 10)
|
||||
: renderLiveCommandOutput(toolCall.command),
|
||||
@@ -1549,11 +1548,17 @@ function durationLabel(milliseconds: number): string {
|
||||
|
||||
function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined {
|
||||
if (!command) return undefined;
|
||||
const stdout = compactLines([
|
||||
command.stdout.truncated ? "[… earlier stdout omitted]" : undefined,
|
||||
command.stdout.content,
|
||||
]);
|
||||
const stderr = compactLines([
|
||||
command.stderr.truncated ? "[… earlier stderr omitted]" : undefined,
|
||||
command.stderr.content,
|
||||
]);
|
||||
return compactLines([
|
||||
command.stdout.truncated ? "[stdout tail; earlier output omitted]" : undefined,
|
||||
command.stdout.content ? `stdout:\n${command.stdout.content}` : undefined,
|
||||
command.stderr.truncated ? "[stderr tail; earlier output omitted]" : undefined,
|
||||
command.stderr.content ? `stderr:\n${command.stderr.content}` : undefined,
|
||||
stdout,
|
||||
stderr ? `stderr:\n${stderr}` : undefined,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1569,6 +1574,7 @@ function toolCallDetail(toolCall: ToolCallView): string {
|
||||
return compactLines([
|
||||
`id: ${toolCall.id}`,
|
||||
`state: ${stateSuffix(toolCall.state)}`,
|
||||
toolCall.command ? `command: ${commandTiming(toolCall.command)}` : undefined,
|
||||
toolCall.summary
|
||||
? `summary: ${
|
||||
normalizeKnownToolResult(toolCall.name, toolCall.summary, toolCall.cwd)
|
||||
|
||||
@@ -357,6 +357,38 @@ Deno.test("Worker Console uses protocol observation events without transcript fe
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console owns its narrower centered shell width", async () => {
|
||||
const page = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const rootLayout = await Deno.readTextFile(
|
||||
new URL("./../../../routes/+layout.svelte", import.meta.url),
|
||||
);
|
||||
|
||||
assert(
|
||||
page.includes(".console-shell {") &&
|
||||
page.includes("max-width: 920px;") &&
|
||||
page.includes("margin-inline: auto;") &&
|
||||
rootLayout.includes("max-width: 1280px;"),
|
||||
"Root content should allow 1280px while Worker Console remains centered at 920px",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console overview activity summaries use 14px text", async () => {
|
||||
const consoleLine = await Deno.readTextFile(
|
||||
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
||||
);
|
||||
|
||||
assert(
|
||||
consoleLine.includes(".activity-summary {") &&
|
||||
consoleLine.includes("font-size: 14px;"),
|
||||
"Overview activity summaries such as ran command counts should render at 14px",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console renders markdown only for message rows", async () => {
|
||||
const consoleLine = await Deno.readTextFile(
|
||||
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
||||
@@ -365,12 +397,22 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
|
||||
assert(
|
||||
consoleLine.includes("function shouldRenderMarkdown") &&
|
||||
consoleLine.includes("item.kind === 'tool'") &&
|
||||
consoleLine.includes("{#if isBashTool(item)}") &&
|
||||
consoleLine.includes(
|
||||
'<p class="console-plain-text">{bodyTextAfterToolSummary(item)}</p>',
|
||||
"<AnsiText text={bodyTextAfterToolSummary(item)} />",
|
||||
) &&
|
||||
consoleLine.includes(
|
||||
".console-line.tool-bash .console-plain-text",
|
||||
) &&
|
||||
consoleLine.includes(
|
||||
".console-line.tool.tool-bash .console-plain-text",
|
||||
) &&
|
||||
consoleLine.includes("font-size: 12px;") &&
|
||||
consoleLine.includes("line-height: 1.1;") &&
|
||||
consoleLine.includes("{:else if shouldRenderMarkdown(item)}") &&
|
||||
consoleLine.includes("<RichMarkdown text={item.body || '—'} />"),
|
||||
"Console should keep markdown rendering to user/assistant/system message bodies and render tool text literally",
|
||||
consoleLine.includes("<RichMarkdown text={item.body || '—'} />") &&
|
||||
!consoleLine.includes("{@html"),
|
||||
"Console should keep markdown rendering to message bodies, safely project Bash ANSI, and render other tool text literally",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -425,6 +467,35 @@ Deno.test("Worker Console exposes a foldable timeline beside the scroll body", a
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console removes redundant chrome and uses shared alerts", async () => {
|
||||
const page = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const tasks = await Deno.readTextFile(
|
||||
new URL("./ConsoleTasks.svelte", import.meta.url),
|
||||
);
|
||||
|
||||
assert(
|
||||
page.includes('<form class="console-composer"') &&
|
||||
!page.includes('class="console-composer card"') &&
|
||||
!page.includes(
|
||||
"padding: var(--space-3) var(--space-6) var(--space-4)",
|
||||
) &&
|
||||
!page.includes("margin-inline: calc(-1 * var(--space-6))") &&
|
||||
page.includes('import { pushWorkspaceAlert }') &&
|
||||
page.includes('title: "Worker control"') &&
|
||||
page.includes('title: "Rewind targets"') &&
|
||||
page.includes('pushWorkspaceAlert("error"') &&
|
||||
!page.includes("controlNotice") &&
|
||||
!page.includes("console-notice") &&
|
||||
!tasks.includes("margin-bottom: -0.75rem"),
|
||||
"Console should remove redundant card/spacing chrome and route control notices through workspace alerts",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console composer fits to content without manual resize", async () => {
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL(
|
||||
@@ -806,9 +877,10 @@ Deno.test("Web Console renders the client-projected Worker task store", async ()
|
||||
tasksComponent.includes("[~]") &&
|
||||
tasksComponent.includes("[x]") &&
|
||||
tasksComponent.includes("[-]") &&
|
||||
tasksComponent.includes("task(s) — pending:") &&
|
||||
tasksComponent.includes('return count === 1 ? "task" : "tasks"') &&
|
||||
!tasksComponent.includes(", deleted: {counts.deleted}") &&
|
||||
tasksComponent.includes("task.description"),
|
||||
"Tasks UI should mirror the TUI status marks, summary, and descriptions",
|
||||
"Tasks UI should mirror the TUI status marks, pluralize its summary, omit the deleted count, and show descriptions",
|
||||
);
|
||||
assert(
|
||||
tasksModel.includes('name === "TaskCreate"') &&
|
||||
|
||||
@@ -260,6 +260,7 @@
|
||||
text-align: center;
|
||||
}
|
||||
.ticket-detail-page {
|
||||
container: ticket-detail / inline-size;
|
||||
gap: var(--space-4);
|
||||
max-width: 88rem;
|
||||
}
|
||||
@@ -296,9 +297,14 @@
|
||||
}
|
||||
.ticket-detail-main, .ticket-control-rail {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.ticket-detail-main {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ticket-detail-section, .ticket-control-card, .ticket-editor {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.8rem;
|
||||
background: var(--bg-raised);
|
||||
@@ -434,6 +440,15 @@
|
||||
.ticket-event-author {
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
@container ticket-detail (max-width: 48rem) {
|
||||
.ticket-detail-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.ticket-control-rail {
|
||||
grid-row: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 64rem) {
|
||||
.ticket-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -11,7 +11,8 @@ import type {
|
||||
} from "../../generated/ticket-api.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
readTextFile(path: URL): Promise<string>;
|
||||
};
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T): void {
|
||||
@@ -108,3 +109,20 @@ Deno.test("ticket worker launch uses the common Worker route and bounded Ticket
|
||||
"Work on Ticket 00001KYRRDVH9 as its reviewer.",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("ticket detail keeps the operation rail outside main content", async () => {
|
||||
const css = await Deno.readTextFile(
|
||||
new URL("../styles/tickets.css", import.meta.url),
|
||||
);
|
||||
|
||||
assertEquals(css.includes("container: ticket-detail / inline-size;"), true);
|
||||
assertEquals(
|
||||
css.includes(".ticket-detail-main {\n overflow-wrap: anywhere;"),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
css.includes("@container ticket-detail (max-width: 48rem)"),
|
||||
true,
|
||||
);
|
||||
assertEquals(css.includes("min-width: 0;"), true);
|
||||
});
|
||||
|
||||
@@ -126,6 +126,9 @@
|
||||
gap: var(--space-6);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
margin-inline: auto;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
+27
-19
@@ -33,6 +33,7 @@
|
||||
type ConsoleViewScroll,
|
||||
} from "$lib/workspace/console/model";
|
||||
import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol";
|
||||
import { pushWorkspaceAlert } from "$lib/workspace/alerts/store";
|
||||
import { workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import { workspaceMultiplexer, type WorkspaceMultiplexerSubscription } from "$lib/workspace/multiplexer";
|
||||
import type {
|
||||
@@ -111,7 +112,6 @@
|
||||
let sendError = $state<string | null>(null);
|
||||
let rewindTargets = $state<RewindTarget[]>([]);
|
||||
let rewindHeadEntries = $state(0);
|
||||
let controlNotice = $state<string | null>(null);
|
||||
let composerNotice = $state<string | null>(null);
|
||||
let protocolState = $state<"connecting" | "open" | "closed" | "error">(
|
||||
"connecting",
|
||||
@@ -161,6 +161,9 @@
|
||||
};
|
||||
|
||||
const consoleTarget = $derived({ workspaceId, runtimeId, workerId });
|
||||
const controlAlertId = $derived(
|
||||
`worker-console-control:${runtimeId}:${workerId}`,
|
||||
);
|
||||
|
||||
const workerViews = $derived(consoleWorkerViews(consoleProjection));
|
||||
const selectedWorkerView = $derived(
|
||||
@@ -418,10 +421,18 @@
|
||||
function sendControl(method: ProtocolMethod, label: string) {
|
||||
try {
|
||||
sendProtocolMethod(method);
|
||||
controlNotice = `${label} sent through Worker protocol.`;
|
||||
pushWorkspaceAlert(
|
||||
"info",
|
||||
`${label} sent through Worker protocol.`,
|
||||
{ id: controlAlertId, title: "Worker control" },
|
||||
);
|
||||
} catch (error) {
|
||||
controlNotice = null;
|
||||
sendError = error instanceof Error ? error.message : String(error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
sendError = message;
|
||||
pushWorkspaceAlert("error", message, {
|
||||
id: controlAlertId,
|
||||
title: "Worker control failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,10 +685,13 @@
|
||||
if (event.event === "rewind_targets") {
|
||||
rewindHeadEntries = event.data.head_entries;
|
||||
rewindTargets = event.data.targets;
|
||||
controlNotice =
|
||||
pushWorkspaceAlert(
|
||||
"info",
|
||||
event.data.targets.length === 0
|
||||
? "No rewind targets are available."
|
||||
: `Loaded ${event.data.targets.length} rewind target(s).`;
|
||||
: `Loaded ${event.data.targets.length} rewind target(s).`,
|
||||
{ id: controlAlertId, title: "Rewind targets" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (event.event === "error") {
|
||||
@@ -1324,10 +1338,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if controlNotice}
|
||||
<p class="console-notice">{controlNotice}</p>
|
||||
{/if}
|
||||
|
||||
{#if rewindTargets.length > 0}
|
||||
<section class="card rewind-targets" aria-label="Rewind targets">
|
||||
<h3>Rewind targets</h3>
|
||||
@@ -1509,7 +1519,7 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<form class="console-composer card" onsubmit={sendMessage}>
|
||||
<form class="console-composer" onsubmit={sendMessage}>
|
||||
<div class="composer-input-shell">
|
||||
<textarea
|
||||
id="worker-console-message"
|
||||
@@ -1586,6 +1596,12 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.console-shell {
|
||||
width: 100%;
|
||||
max-width: 920px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.worker-console-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1653,12 +1669,6 @@
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.console-notice {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.rewind-targets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1831,8 +1841,6 @@
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin-inline: calc(-1 * var(--space-6));
|
||||
padding: var(--space-3) var(--space-6) var(--space-4);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ansiSegments } from "../../src/lib/workspace/console/ansi.ts";
|
||||
|
||||
type TestRegistrar = (name: string, body: () => void) => void;
|
||||
|
||||
const test =
|
||||
(globalThis as unknown as { Deno: { test: TestRegistrar } }).Deno.test;
|
||||
|
||||
function assert(
|
||||
condition: boolean,
|
||||
message = "assertion failed",
|
||||
): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) {
|
||||
throw new Error(
|
||||
`values differ:\nactual: ${actualJson}\nexpected: ${expectedJson}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test("ansiSegments projects standard colors and reset", () => {
|
||||
const segments = ansiSegments("plain \x1b[31mred\x1b[0m normal");
|
||||
|
||||
assertEquals(
|
||||
segments.map(({ text, foreground }) => ({ text, foreground })),
|
||||
[
|
||||
{ text: "plain ", foreground: undefined },
|
||||
{ text: "red", foreground: "#cd3131" },
|
||||
{ text: " normal", foreground: undefined },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("ansiSegments supports terminal styles, 256 colors, and truecolor", () => {
|
||||
const segments = ansiSegments(
|
||||
"\x1b[1;4;38;5;202mindexed\x1b[22;24;48;2;1;2;3mbackground\x1b[0m",
|
||||
);
|
||||
|
||||
assertEquals(segments[0], {
|
||||
text: "indexed",
|
||||
foreground: "rgb(255, 95, 0)",
|
||||
background: undefined,
|
||||
bold: true,
|
||||
dim: false,
|
||||
italic: false,
|
||||
underline: true,
|
||||
strikethrough: false,
|
||||
concealed: false,
|
||||
});
|
||||
assertEquals(segments[1], {
|
||||
text: "background",
|
||||
foreground: "rgb(255, 95, 0)",
|
||||
background: "rgb(1, 2, 3)",
|
||||
bold: false,
|
||||
dim: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strikethrough: false,
|
||||
concealed: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("ansiSegments keeps output as text and strips terminal control sequences", () => {
|
||||
const input =
|
||||
"\x1b]8;;https://example.invalid\x07<script>alert(1)</script>\x1b]8;;\x07" +
|
||||
"\x1b[2Ksafe\x00\x1b[31";
|
||||
const segments = ansiSegments(input);
|
||||
|
||||
assertEquals(
|
||||
segments.map((segment) => segment.text).join(""),
|
||||
"<script>alert(1)</script>safe",
|
||||
);
|
||||
assert(!segments.some((segment) => segment.text.includes("\x1b")));
|
||||
});
|
||||
Reference in New Issue
Block a user