Compare commits

...

9 Commits

32 changed files with 1360 additions and 751 deletions

View File

@ -21,7 +21,7 @@ pub use backend_auth::{
poll_device_login, start_device_login, wait_for_device_login, poll_device_login, start_device_login, wait_for_device_login,
}; };
pub use backend_runtime::{ pub use backend_runtime::{
BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse, BackendDiagnostic, BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse,
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget, BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary, BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,

View File

@ -54,11 +54,22 @@ impl BackendTarget {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerListRequest { pub struct WorkerListRequest {
pub runtime_id: Option<String>, pub runtime_id: Option<String>,
pub include_stopped: bool,
} }
impl WorkerListRequest { impl WorkerListRequest {
pub fn new(runtime_id: Option<String>) -> Self { pub fn new(runtime_id: Option<String>) -> Self {
Self { runtime_id } Self {
runtime_id,
include_stopped: false,
}
}
pub fn with_stopped(runtime_id: Option<String>) -> Self {
Self {
runtime_id,
include_stopped: true,
}
} }
} }
@ -99,7 +110,9 @@ pub struct Dashboard {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerList { pub struct WorkerList {
pub target: BackendRuntimeListTarget, pub local_runtime_command: Option<WorkerRuntimeCommand>,
pub backend_target: Option<BackendRuntimeListTarget>,
pub include_stopped: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@ -182,11 +195,18 @@ impl Target for LocalTarget {
}) })
} }
fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> { fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Err(TargetError::unsupported( if request.runtime_id.is_some() {
"Backend runtime worker listing", return Err(TargetError::unsupported(
"Explicit runtime id for local worker listing",
self.kind(), self.kind(),
)) ));
}
Ok(WorkerList {
local_runtime_command: Some(self.runtime_command()?),
backend_target: None,
include_stopped: request.include_stopped,
})
} }
fn connect_worker( fn connect_worker(
@ -226,11 +246,13 @@ impl Target for BackendTarget {
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> { fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Ok(WorkerList { Ok(WorkerList {
target: BackendRuntimeListTarget::new( local_runtime_command: None,
backend_target: Some(BackendRuntimeListTarget::new(
self.base_url.clone(), self.base_url.clone(),
self.workspace_id.clone(), self.workspace_id.clone(),
request.runtime_id, request.runtime_id,
), )),
include_stopped: request.include_stopped,
}) })
} }
@ -259,9 +281,28 @@ mod tests {
.list_workers(WorkerListRequest::new(Some("runtime-a".to_string()))) .list_workers(WorkerListRequest::new(Some("runtime-a".to_string())))
.unwrap(); .unwrap();
assert_eq!(workers.target.base_url, "http://127.0.0.1:8787"); assert_eq!(
assert_eq!(workers.target.workspace_id.as_deref(), Some("workspace-a")); workers.backend_target.as_ref().unwrap().base_url,
assert_eq!(workers.target.runtime_id.as_deref(), Some("runtime-a")); "http://127.0.0.1:8787"
);
assert_eq!(
workers
.backend_target
.as_ref()
.unwrap()
.workspace_id
.as_deref(),
Some("workspace-a")
);
assert_eq!(
workers
.backend_target
.as_ref()
.unwrap()
.runtime_id
.as_deref(),
Some("runtime-a")
);
} }
#[test] #[test]
@ -288,15 +329,14 @@ mod tests {
} }
#[test] #[test]
fn local_target_rejects_backend_worker_operations() { fn local_target_builds_local_worker_list() {
let target = LocalTarget::new(); let target = LocalTarget::new();
let err = target let workers = target
.list_workers(WorkerListRequest::new(None)) .list_workers(WorkerListRequest::with_stopped(None))
.unwrap_err(); .unwrap();
assert_eq!( assert!(workers.local_runtime_command.is_some());
err.to_string(), assert!(workers.backend_target.is_none());
"Backend runtime worker listing is not supported by local target" assert!(workers.include_stopped);
);
} }
} }

View File

@ -3539,10 +3539,7 @@ mod completion_flow_tests {
arguments: args.into(), arguments: args.into(),
}); });
} }
assert_eq!( assert!(app.task_store.tasks().is_empty());
app.task_store.tasks()[0].status,
crate::task::TaskStatus::Completed
);
} }
#[test] #[test]

View File

@ -3,7 +3,8 @@ use std::io;
use std::time::Duration; use std::time::Duration;
use client::{ use client::{
BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary, list_backend_workers, BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary,
list_backend_stopped_workers, list_backend_workers, restore_backend_worker,
}; };
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
@ -18,13 +19,30 @@ use crate::console;
const MAX_ROWS: usize = 10; const MAX_ROWS: usize = 10;
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4; const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
pub(crate) async fn run(target: BackendRuntimeListTarget) -> Result<(), Box<dyn Error>> { pub(crate) async fn run(
let response = list_backend_workers(&target).await.map_err(|error| { target: BackendRuntimeListTarget,
include_stopped: bool,
) -> Result<(), Box<dyn Error>> {
let mut response = list_backend_workers(&target).await.map_err(|error| {
io::Error::other(format!( io::Error::other(format!(
"failed to list Backend runtime workers from {}: {error}", "failed to list Backend runtime workers from {}: {error}",
target.base_url target.base_url
)) ))
})?; })?;
if include_stopped {
match list_backend_stopped_workers(&target).await {
Ok(stopped) => {
response.items.extend(stopped.items);
response.diagnostics.extend(stopped.diagnostics);
}
Err(error) => response.diagnostics.push(client::BackendDiagnostic {
code: "backend_stopped_workers_list_failed".to_string(),
severity: Some("error".to_string()),
message: error.to_string(),
}),
}
}
dedup_workers(&mut response.items);
if response.items.is_empty() { if response.items.is_empty() {
let diagnostics = response let diagnostics = response
.diagnostics .diagnostics
@ -44,11 +62,36 @@ pub(crate) async fn run(target: BackendRuntimeListTarget) -> Result<(), Box<dyn
} }
let selected = pick_worker(target.clone(), response.items)?; let selected = pick_worker(target.clone(), response.items)?;
let worker = if selected.state == "stopped" {
let restore_target = BackendRuntimeTarget::new(
target.base_url.clone(),
selected.runtime_id.clone(),
selected.worker_id.clone(),
);
restore_backend_worker(&restore_target)
.await
.map_err(|error| {
io::Error::other(format!(
"failed to restore Backend worker {}/{}: {error}",
selected.runtime_id, selected.worker_id
))
})?
.result
.worker
.unwrap_or(selected)
} else {
selected
};
let attach_target = let attach_target =
BackendRuntimeTarget::new(target.base_url, selected.runtime_id, selected.worker_id); BackendRuntimeTarget::new(target.base_url, worker.runtime_id, worker.worker_id);
console::run_backend_runtime(attach_target).await console::run_backend_runtime(attach_target).await
} }
fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
let mut seen = std::collections::HashSet::new();
workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone())));
}
fn pick_worker( fn pick_worker(
target: BackendRuntimeListTarget, target: BackendRuntimeListTarget,
mut workers: Vec<BackendWorkerSummary>, mut workers: Vec<BackendWorkerSummary>,

View File

@ -331,6 +331,15 @@ pub(crate) async fn run_resume(
runtime_command: WorkerRuntimeCommand, runtime_command: WorkerRuntimeCommand,
workspace_root: PathBuf, workspace_root: PathBuf,
all: bool, all: bool,
) -> Result<(), Box<dyn std::error::Error>> {
run_worker_picker(runtime_command, workspace_root, all, true).await
}
pub(crate) async fn run_worker_picker(
runtime_command: WorkerRuntimeCommand,
workspace_root: PathBuf,
all: bool,
include_stopped: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
// Pick a Worker in its own inline viewport, dropping the viewport before // Pick a Worker in its own inline viewport, dropping the viewport before
// attaching/restoring so each phase gets fresh vertical room. // attaching/restoring so each phase gets fresh vertical room.
@ -338,7 +347,8 @@ pub(crate) async fn run_resume(
picker::PickerOptions::all() picker::PickerOptions::all()
} else { } else {
picker::PickerOptions::workspace(workspace_root) picker::PickerOptions::workspace(workspace_root)
}; }
.with_stopped(include_stopped);
let (worker_name, socket_override) = match picker::run(picker_options).await? { let (worker_name, socket_override) = match picker::run(picker_options).await? {
PickerOutcome::Picked { PickerOutcome::Picked {
worker_name, worker_name,

View File

@ -118,8 +118,11 @@ pub(crate) enum DashboardOutcome {
Open(OpenWorkerRequest), Open(OpenWorkerRequest),
} }
pub(crate) async fn launch(runtime_command: WorkerRuntimeCommand) -> Result<(), Box<dyn Error>> { pub(crate) async fn launch(
let mut app = load_app(runtime_command.clone()).await?; runtime_command: WorkerRuntimeCommand,
include_stopped: bool,
) -> Result<(), Box<dyn Error>> {
let mut app = load_app(runtime_command.clone(), include_stopped).await?;
let mut terminal = crate::console::enter_dashboard_fullscreen()?; let mut terminal = crate::console::enter_dashboard_fullscreen()?;
loop { loop {
match run_loop(&mut terminal, &mut app).await? { match run_loop(&mut terminal, &mut app).await? {
@ -174,8 +177,9 @@ pub(crate) struct OpenWorkerRequest {
pub(crate) async fn load_app( pub(crate) async fn load_app(
runtime_command: WorkerRuntimeCommand, runtime_command: WorkerRuntimeCommand,
include_stopped: bool,
) -> Result<DashboardApp, DashboardError> { ) -> Result<DashboardApp, DashboardError> {
Ok(DashboardApp::loading(runtime_command)) Ok(DashboardApp::loading(runtime_command, include_stopped))
} }
async fn run_loop( async fn run_loop(
@ -222,14 +226,14 @@ async fn run_loop(
} }
if let Some(mode) = deferred_enter_reload.take() { if let Some(mode) = deferred_enter_reload.take() {
if pending_reload.start(mode) { if pending_reload.start(mode, app.include_stopped) {
app.refreshing = true; app.refreshing = true;
} }
} }
let now = Instant::now(); let now = Instant::now();
if now >= next_poll { if now >= next_poll {
pending_reload.start(OrchestratorLifecycleMode::Observe); pending_reload.start(OrchestratorLifecycleMode::Observe, app.include_stopped);
next_poll = now + DASHBOARD_POLL_INTERVAL; next_poll = now + DASHBOARD_POLL_INTERVAL;
continue; continue;
} }
@ -275,7 +279,8 @@ async fn run_loop(
terminal.draw(|f| render::draw(f, app))?; terminal.draw(|f| render::draw(f, app))?;
let result = dispatch_ticket_action(request).await; let result = dispatch_ticket_action(request).await;
app.finish_ticket_action_dispatch(result); app.finish_ticket_action_dispatch(result);
if pending_reload.start(OrchestratorLifecycleMode::Observe) { if pending_reload.start(OrchestratorLifecycleMode::Observe, app.include_stopped)
{
app.refreshing = true; app.refreshing = true;
} }
next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL;
@ -311,7 +316,8 @@ async fn run_loop(
} }
Err(error) => app.finish_ready_ticket_planning_return_error(error), Err(error) => app.finish_ready_ticket_planning_return_error(error),
} }
if pending_reload.start(OrchestratorLifecycleMode::Observe) { if pending_reload.start(OrchestratorLifecycleMode::Observe, app.include_stopped)
{
app.refreshing = true; app.refreshing = true;
} }
next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL;
@ -328,7 +334,8 @@ async fn run_loop(
terminal.draw(|f| render::draw(f, app))?; terminal.draw(|f| render::draw(f, app))?;
let result = launch_intake_with_handoff(request).await; let result = launch_intake_with_handoff(request).await;
app.finish_intake_launch(result); app.finish_intake_launch(result);
if pending_reload.start(OrchestratorLifecycleMode::Observe) { if pending_reload.start(OrchestratorLifecycleMode::Observe, app.include_stopped)
{
app.refreshing = true; app.refreshing = true;
} }
next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL;
@ -345,7 +352,8 @@ async fn run_loop(
terminal.draw(|f| render::draw(f, app))?; terminal.draw(|f| render::draw(f, app))?;
let result = dispatch_companion_message(request).await; let result = dispatch_companion_message(request).await;
app.finish_companion_send(result); app.finish_companion_send(result);
if pending_reload.start(OrchestratorLifecycleMode::Observe) { if pending_reload.start(OrchestratorLifecycleMode::Observe, app.include_stopped)
{
app.refreshing = true; app.refreshing = true;
} }
next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL; next_poll = Instant::now() + DASHBOARD_POLL_INTERVAL;
@ -366,7 +374,7 @@ struct PendingReload {
} }
impl PendingReload { impl PendingReload {
fn start(&mut self, lifecycle_mode: OrchestratorLifecycleMode) -> bool { fn start(&mut self, lifecycle_mode: OrchestratorLifecycleMode, include_stopped: bool) -> bool {
if self.handle.is_some() { if self.handle.is_some() {
return false; return false;
} }
@ -382,7 +390,7 @@ impl PendingReload {
self.handle = Some(tokio::spawn(async move { self.handle = Some(tokio::spawn(async move {
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
crate::e2e_observer::hold_background_task_if_requested("reload").await; crate::e2e_observer::hold_background_task_if_requested("reload").await;
load_dashboard_snapshot(None, lifecycle_mode).await load_dashboard_snapshot(None, lifecycle_mode, include_stopped).await
})); }));
true true
} }
@ -1219,6 +1227,7 @@ pub(crate) struct DashboardApp {
refreshing: bool, refreshing: bool,
enter_reload: Option<OrchestratorLifecycleMode>, enter_reload: Option<OrchestratorLifecycleMode>,
runtime_command: WorkerRuntimeCommand, runtime_command: WorkerRuntimeCommand,
include_stopped: bool,
last_companion_lifecycle_failure: Option<CompanionPanelState>, last_companion_lifecycle_failure: Option<CompanionPanelState>,
last_orchestrator_lifecycle_failure: Option<OrchestratorPanelState>, last_orchestrator_lifecycle_failure: Option<OrchestratorPanelState>,
orchestrator_work_set: OrchestratorWorkSet, orchestrator_work_set: OrchestratorWorkSet,
@ -1228,7 +1237,7 @@ pub(crate) struct DashboardApp {
} }
impl DashboardApp { impl DashboardApp {
fn loading(runtime_command: WorkerRuntimeCommand) -> Self { fn loading(runtime_command: WorkerRuntimeCommand, include_stopped: bool) -> Self {
let workspace_root = current_workspace_root(); let workspace_root = current_workspace_root();
let mut panel = WorkspacePanelViewModel::empty(&workspace_root); let mut panel = WorkspacePanelViewModel::empty(&workspace_root);
panel panel
@ -1257,6 +1266,7 @@ impl DashboardApp {
runtime_command: runtime_command.clone(), runtime_command: runtime_command.clone(),
}), }),
runtime_command, runtime_command,
include_stopped,
last_companion_lifecycle_failure: None, last_companion_lifecycle_failure: None,
last_orchestrator_lifecycle_failure: None, last_orchestrator_lifecycle_failure: None,
orchestrator_work_set: OrchestratorWorkSet::default(), orchestrator_work_set: OrchestratorWorkSet::default(),
@ -2511,6 +2521,7 @@ enum OrchestratorLifecycleMode {
async fn load_dashboard_snapshot( async fn load_dashboard_snapshot(
selected_name: Option<String>, selected_name: Option<String>,
lifecycle_mode: OrchestratorLifecycleMode, lifecycle_mode: OrchestratorLifecycleMode,
include_stopped: bool,
) -> Result<DashboardSnapshot, DashboardError> { ) -> Result<DashboardSnapshot, DashboardError> {
let workspace_root = current_workspace_root(); let workspace_root = current_workspace_root();
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
@ -2524,7 +2535,8 @@ async fn load_dashboard_snapshot(
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
let source_started = Instant::now(); let source_started = Instant::now();
let mut list = load_worker_list(list_selected_name.clone(), MAX_ENTRIES).await?; let mut list =
load_worker_list(list_selected_name.clone(), MAX_ENTRIES, include_stopped).await?;
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming { source_timings.push(PanelE2eSourceTiming {
source: "pod_metadata_status_probe.initial", source: "pod_metadata_status_probe.initial",
@ -2564,7 +2576,7 @@ async fn load_dashboard_snapshot(
if companion.reload_workers { if companion.reload_workers {
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
let source_started = Instant::now(); let source_started = Instant::now();
list = load_worker_list(list_selected_name.clone(), MAX_ENTRIES).await?; list = load_worker_list(list_selected_name.clone(), MAX_ENTRIES, include_stopped).await?;
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming { source_timings.push(PanelE2eSourceTiming {
source: "pod_metadata_status_probe.after_companion_reload", source: "pod_metadata_status_probe.after_companion_reload",
@ -2622,7 +2634,7 @@ async fn load_dashboard_snapshot(
if orchestrator.reload_workers { if orchestrator.reload_workers {
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
let source_started = Instant::now(); let source_started = Instant::now();
list = load_worker_list(list_selected_name, MAX_ENTRIES).await?; list = load_worker_list(list_selected_name, MAX_ENTRIES, include_stopped).await?;
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
source_timings.push(PanelE2eSourceTiming { source_timings.push(PanelE2eSourceTiming {
source: "pod_metadata_status_probe.after_orchestrator_reload", source: "pod_metadata_status_probe.after_orchestrator_reload",
@ -3420,6 +3432,7 @@ fn existing_ticket_claim_notice(
async fn load_worker_list( async fn load_worker_list(
selected_name: Option<String>, selected_name: Option<String>,
max_entries: usize, max_entries: usize,
include_stopped: bool,
) -> Result<WorkerList, DashboardError> { ) -> Result<WorkerList, DashboardError> {
let store_dir = default_store_dir()?; let store_dir = default_store_dir()?;
let store = FsStore::new(&store_dir)?; let store = FsStore::new(&store_dir)?;
@ -3429,14 +3442,18 @@ async fn load_worker_list(
let live = read_reachable_live_worker_infos(&store) let live = read_reachable_live_worker_infos(&store)
.await .await
.unwrap_or_default(); .unwrap_or_default();
Ok(WorkerList::from_workspace_sources( let mut list = WorkerList::from_workspace_sources(
WorkerVisibilitySource::ResumePicker, WorkerVisibilitySource::ResumePicker,
stored, stored,
live, live,
selected_name, selected_name,
max_entries, max_entries,
&current_workspace_root(), &current_workspace_root(),
)) );
if !include_stopped {
list.retain_live_entries();
}
Ok(list)
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View File

@ -2329,7 +2329,7 @@ fn dashboard_open_failure_keeps_composer_and_sets_notice() {
#[test] #[test]
fn dashboard_loading_app_defers_initial_snapshot_to_enter_reload() { fn dashboard_loading_app_defers_initial_snapshot_to_enter_reload() {
let app = DashboardApp::loading(WorkerRuntimeCommand::for_executable("/tmp/yoi")); let app = DashboardApp::loading(WorkerRuntimeCommand::for_executable("/tmp/yoi"), false);
assert!(app.panel.rows.is_empty()); assert!(app.panel.rows.is_empty());
assert!( assert!(
@ -3241,6 +3241,7 @@ fn app_with_panel(list: WorkerList, panel: WorkspacePanelViewModel) -> Dashboard
refreshing: false, refreshing: false,
enter_reload: None, enter_reload: None,
runtime_command: WorkerRuntimeCommand::for_executable("/tmp/yoi"), runtime_command: WorkerRuntimeCommand::for_executable("/tmp/yoi"),
include_stopped: false,
last_companion_lifecycle_failure, last_companion_lifecycle_failure,
last_orchestrator_lifecycle_failure, last_orchestrator_lifecycle_failure,
orchestrator_work_set: OrchestratorWorkSet::default(), orchestrator_work_set: OrchestratorWorkSet::default(),
@ -3392,7 +3393,7 @@ fn section_names<'a>(list: &'a WorkerList, section: &DashboardSection) -> Vec<&'
#[test] #[test]
fn ticket_action_error_records_f2_diagnostic_details() { fn ticket_action_error_records_f2_diagnostic_details() {
let mut app = DashboardApp::loading(WorkerRuntimeCommand::for_executable("/tmp/yoi")); let mut app = DashboardApp::loading(WorkerRuntimeCommand::for_executable("/tmp/yoi"), false);
let long_error = "root-clean failed for Ticket 00001KTWPE3KQ at /home/hare/Projects/yoi: dirty file crates/tui/src/dashboard.rs"; let long_error = "root-clean failed for Ticket 00001KTWPE3KQ at /home/hare/Projects/yoi: dirty file crates/tui/src/dashboard.rs";
app.finish_ticket_action_dispatch(Err(TicketActionError::Stale(long_error.to_string()))); app.finish_ticket_action_dispatch(Err(TicketActionError::Stale(long_error.to_string())));

View File

@ -58,7 +58,11 @@ pub enum LaunchMode {
}, },
/// `yoi workers` / `yoi --backend <url>`: list workers through the selected /// `yoi workers` / `yoi --backend <url>`: list workers through the selected
/// connection target, then attach to the selected Worker. /// connection target, then attach to the selected Worker.
Workers { runtime_id: Option<String> }, Workers {
runtime_id: Option<String>,
include_stopped: bool,
all: bool,
},
/// `yoi --backend <url> --runtime-id <id> --worker-id <id>`: open one Worker /// `yoi --backend <url> --runtime-id <id> --worker-id <id>`: open one Worker
/// through the selected connection target. /// through the selected connection target.
OpenWorker { OpenWorker {
@ -76,7 +80,7 @@ pub enum LaunchMode {
worker_name: Option<String>, worker_name: Option<String>,
}, },
/// `yoi panel`: open the workspace Dashboard from the current workspace. /// `yoi panel`: open the workspace Dashboard from the current workspace.
Panel, Panel { include_stopped: bool },
} }
pub async fn launch(options: LaunchOptions) -> ExitCode { pub async fn launch(options: LaunchOptions) -> ExitCode {
@ -128,12 +132,34 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
} }
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>), Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
}, },
LaunchMode::Workers { runtime_id } => { LaunchMode::Workers {
match target.list_workers(WorkerListRequest::new(runtime_id)) { runtime_id,
Ok(worker_list) => backend_worker_picker::run(worker_list.target).await, include_stopped,
all,
} => match target.list_workers(if include_stopped {
WorkerListRequest::with_stopped(runtime_id)
} else {
WorkerListRequest::new(runtime_id)
}) {
Ok(worker_list) => {
if let Some(target) = worker_list.backend_target {
backend_worker_picker::run(target, worker_list.include_stopped).await
} else if let Some(runtime_command) = worker_list.local_runtime_command {
console::run_worker_picker(
runtime_command,
workspace_root.clone(),
all,
worker_list.include_stopped,
)
.await
} else {
Err(Box::new(io::Error::other(
"worker list target did not include a local or backend source",
)) as Box<dyn std::error::Error>)
}
}
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>), Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
} },
}
LaunchMode::OpenWorker { LaunchMode::OpenWorker {
runtime_id, runtime_id,
worker_id, worker_id,
@ -153,8 +179,8 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
} }
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>), Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
}, },
LaunchMode::Panel => match target.dashboard() { LaunchMode::Panel { include_stopped } => match target.dashboard() {
Ok(dashboard) => dashboard::launch(dashboard.runtime_command).await, Ok(dashboard) => dashboard::launch(dashboard.runtime_command, include_stopped).await,
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>), Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
}, },
}; };

View File

@ -80,20 +80,28 @@ pub enum PickerOutcome {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct PickerOptions { pub(crate) struct PickerOptions {
scope: PickerScope, scope: PickerScope,
include_stopped: bool,
} }
impl PickerOptions { impl PickerOptions {
pub(crate) fn workspace(workspace_root: PathBuf) -> Self { pub(crate) fn workspace(workspace_root: PathBuf) -> Self {
Self { Self {
scope: PickerScope::Workspace(workspace_root), scope: PickerScope::Workspace(workspace_root),
include_stopped: true,
} }
} }
pub(crate) fn all() -> Self { pub(crate) fn all() -> Self {
Self { Self {
scope: PickerScope::All, scope: PickerScope::All,
include_stopped: true,
} }
} }
pub(crate) fn with_stopped(mut self, include_stopped: bool) -> Self {
self.include_stopped = include_stopped;
self
}
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -134,6 +142,11 @@ fn list_for_options(
stored_workers: Vec<StoredWorkerInfo>, stored_workers: Vec<StoredWorkerInfo>,
live_workers: Vec<LiveWorkerInfo>, live_workers: Vec<LiveWorkerInfo>,
) -> WorkerList { ) -> WorkerList {
let stored_workers = if options.include_stopped {
stored_workers
} else {
Vec::new()
};
match &options.scope { match &options.scope {
PickerScope::Workspace(workspace_root) => WorkerList::from_workspace_sources( PickerScope::Workspace(workspace_root) => WorkerList::from_workspace_sources(
WorkerVisibilitySource::ResumePicker, WorkerVisibilitySource::ResumePicker,

View File

@ -27,6 +27,12 @@ pub enum TaskStatus {
Deleted, Deleted,
} }
impl TaskStatus {
fn is_active(self) -> bool {
matches!(self, Self::Pending | Self::Inprogress)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct TaskEntry { pub struct TaskEntry {
pub taskid: u64, pub taskid: u64,
@ -106,8 +112,10 @@ impl TaskStore {
} }
"TaskUpdate" => { "TaskUpdate" => {
if let Ok(p) = serde_json::from_str::<TaskUpdateParams>(arguments) if let Ok(p) = serde_json::from_str::<TaskUpdateParams>(arguments)
&& let Some(t) = self.tasks.iter_mut().find(|t| t.taskid == p.taskid) && let Some(task_position) =
self.tasks.iter().position(|t| t.taskid == p.taskid)
{ {
let t = &mut self.tasks[task_position];
if let Some(s) = p.status { if let Some(s) = p.status {
t.status = s; t.status = s;
} }
@ -117,6 +125,9 @@ impl TaskStore {
if let Some(d) = p.description { if let Some(d) = p.description {
t.description = d; t.description = d;
} }
if !t.status.is_active() {
self.tasks.remove(task_position);
}
} }
} }
_ => {} _ => {}
@ -132,6 +143,10 @@ impl TaskStore {
} }
fn replace_with(&mut self, tasks: Vec<TaskEntry>) { fn replace_with(&mut self, tasks: Vec<TaskEntry>) {
let tasks: Vec<_> = tasks
.into_iter()
.filter(|task| task.status.is_active())
.collect();
self.next_taskid = tasks self.next_taskid = tasks
.iter() .iter()
.map(|t| t.taskid) .map(|t| t.taskid)
@ -175,7 +190,13 @@ fn parse_snapshot_text(text: &str) -> Option<Vec<TaskEntry>> {
let rest = &text[start..]; let rest = &text[start..];
let end = rest.find(end_marker)?; let end = rest.find(end_marker)?;
let snapshot: TaskSnapshot = serde_json::from_str(&rest[..end]).ok()?; let snapshot: TaskSnapshot = serde_json::from_str(&rest[..end]).ok()?;
Some(snapshot.tasks) Some(
snapshot
.tasks
.into_iter()
.filter(|task| task.status.is_active())
.collect(),
)
} }
#[cfg(test)] #[cfg(test)]
@ -220,7 +241,7 @@ mod tests {
} }
#[test] #[test]
fn counts_classifies_each_status() { fn counts_tracks_only_active_tasks_after_completion() {
let mut s = TaskStore::new(); let mut s = TaskStore::new();
s.apply_tool_call("TaskCreate", r#"{"subject":"a","description":""}"#); s.apply_tool_call("TaskCreate", r#"{"subject":"a","description":""}"#);
s.apply_tool_call("TaskCreate", r#"{"subject":"b","description":""}"#); s.apply_tool_call("TaskCreate", r#"{"subject":"b","description":""}"#);
@ -230,9 +251,9 @@ mod tests {
let c = s.counts(); let c = s.counts();
assert_eq!(c.pending, 1); assert_eq!(c.pending, 1);
assert_eq!(c.inprogress, 1); assert_eq!(c.inprogress, 1);
assert_eq!(c.completed, 1); assert_eq!(c.completed, 0);
assert_eq!(c.deleted, 0); assert_eq!(c.deleted, 0);
assert_eq!(c.total(), 3); assert_eq!(c.total(), 2);
assert_eq!(c.active(), 2); assert_eq!(c.active(), 2);
} }
@ -242,7 +263,7 @@ mod tests {
fn wrap_snapshot(json_body: &str, overview: &str) -> String { fn wrap_snapshot(json_body: &str, overview: &str) -> String {
format!( format!(
"[Session TaskStore snapshot]\n\n{overview}\n\n```json\n{json_body}\n```\n\n\ "[Session TaskStore snapshot]\n\n{overview}\n\n```json\n{json_body}\n```\n\n\
This is the complete session task list preserved across compaction. \ This is the active session task list preserved across compaction. \
The following TaskList tool result presents the same state through the tool lane." The following TaskList tool result presents the same state through the tool lane."
) )
} }
@ -267,20 +288,19 @@ mod tests {
}"#; }"#;
let text = wrap_snapshot( let text = wrap_snapshot(
body, body,
"TaskStore: 2 task(s) (pending: 1, inprogress: 0, completed: 1, deleted: 0)", "TaskStore: 1 active task(s) (pending: 1, inprogress: 0)",
); );
let mut s = TaskStore::new(); let mut s = TaskStore::new();
s.apply_tool_call("TaskCreate", r#"{"subject":"stale","description":""}"#); s.apply_tool_call("TaskCreate", r#"{"subject":"stale","description":""}"#);
s.apply_system_message_text(&text); s.apply_system_message_text(&text);
let tasks = s.tasks(); let tasks = s.tasks();
assert_eq!(tasks.len(), 2); assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].taskid, 5); assert_eq!(tasks[0].taskid, 7);
assert_eq!(tasks[0].status, TaskStatus::Completed); assert_eq!(tasks[0].status, TaskStatus::Pending);
assert_eq!(tasks[1].taskid, 7); // Subsequent TaskCreate must continue beyond the highest active taskid
// Subsequent TaskCreate must continue beyond the highest taskid
// observed in the snapshot. // observed in the snapshot.
s.apply_tool_call("TaskCreate", r#"{"subject":"new","description":""}"#); s.apply_tool_call("TaskCreate", r#"{"subject":"new","description":""}"#);
assert_eq!(s.tasks()[2].taskid, 8); assert_eq!(s.tasks()[1].taskid, 8);
} }
#[test] #[test]
@ -304,7 +324,7 @@ mod tests {
} }
] ]
}"#; }"#;
let text = wrap_snapshot(body, "TaskStore: 1 task(s)"); let text = wrap_snapshot(body, "TaskStore: 1 active task(s)");
let mut s = TaskStore::new(); let mut s = TaskStore::new();
s.apply_system_message_text(&text); s.apply_system_message_text(&text);
let t = &s.tasks()[0]; let t = &s.tasks()[0];
@ -329,13 +349,13 @@ mod snapshot_format_contract {
fn wrap_pod_style(snapshot_text: &str) -> String { fn wrap_pod_style(snapshot_text: &str) -> String {
format!( format!(
"[Session TaskStore snapshot]\n\n{snapshot_text}\n\n\ "[Session TaskStore snapshot]\n\n{snapshot_text}\n\n\
This is the complete session task list preserved across compaction. \ This is the active session task list preserved across compaction. \
The following TaskList tool result presents the same state through the tool lane." The following TaskList tool result presents the same state through the tool lane."
) )
} }
fn snapshot_fixture() -> &'static str { fn snapshot_fixture() -> &'static str {
r#"TaskStore: 2 task(s) (pending: 0, inprogress: 1, completed: 1, deleted: 0) r#"TaskStore: 1 active task(s) (pending: 0, inprogress: 1)
```json ```json
{ {
@ -345,12 +365,6 @@ mod snapshot_format_contract {
"status": "inprogress", "status": "inprogress",
"subject": "first", "subject": "first",
"description": "first desc" "description": "first desc"
},
{
"taskid": 2,
"status": "completed",
"subject": "second",
"description": "second desc with\nnewline"
} }
] ]
} }
@ -358,7 +372,7 @@ mod snapshot_format_contract {
} }
fn empty_snapshot_fixture() -> &'static str { fn empty_snapshot_fixture() -> &'static str {
r#"TaskStore: 0 task(s) (pending: 0, inprogress: 0, completed: 0, deleted: 0) r#"TaskStore: 0 active task(s) (pending: 0, inprogress: 0)
```json ```json
{ {
@ -384,15 +398,11 @@ mod snapshot_format_contract {
downstream.apply_system_message_text(&envelope); downstream.apply_system_message_text(&envelope);
let tasks = downstream.tasks(); let tasks = downstream.tasks();
assert_eq!(tasks.len(), 2, "TUI parsed wrong number of tasks"); assert_eq!(tasks.len(), 1, "TUI parsed wrong number of tasks");
assert_eq!(tasks[0].taskid, 1); assert_eq!(tasks[0].taskid, 1);
assert_eq!(tasks[0].subject, "first"); assert_eq!(tasks[0].subject, "first");
assert_eq!(tasks[0].description, "first desc"); assert_eq!(tasks[0].description, "first desc");
assert_eq!(status_label(tasks[0].status), "inprogress"); assert_eq!(status_label(tasks[0].status), "inprogress");
assert_eq!(tasks[1].taskid, 2);
assert_eq!(tasks[1].subject, "second");
assert_eq!(tasks[1].description, "second desc with\nnewline");
assert_eq!(status_label(tasks[1].status), "completed");
} }
#[test] #[test]

View File

@ -126,6 +126,17 @@ impl WorkerList {
self.selected_name = self.entries.get(index).map(|entry| entry.name.clone()); self.selected_name = self.entries.get(index).map(|entry| entry.name.clone());
} }
pub(crate) fn retain_live_entries(&mut self) {
self.entries.retain(|entry| entry.live.is_some());
if !self
.selected_name
.as_ref()
.is_some_and(|selected| self.entries.iter().any(|entry| entry.name == *selected))
{
self.selected_name = self.entries.first().map(|entry| entry.name.clone());
}
}
pub(crate) fn selected_entry(&self) -> Option<&WorkerListEntry> { pub(crate) fn selected_entry(&self) -> Option<&WorkerListEntry> {
let index = self.selected_index(); let index = self.selected_index();
self.entries.get(index) self.entries.get(index)
@ -736,6 +747,28 @@ mod tests {
); );
} }
#[test]
fn retain_live_entries_removes_stored_only_rows_and_reselects() {
let dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
append_start(&store, session_id, segment_id, 10);
let mut list = WorkerList::from_sources(
SOURCE,
vec![metadata_info(&store, "stored", session_id, segment_id)],
vec![live_info("live", WorkerStatus::Idle)],
Some("stored".to_string()),
10,
);
list.retain_live_entries();
assert_eq!(list.entries.len(), 1);
assert_eq!(list.entries[0].name, "live");
assert_eq!(list.selected_entry().unwrap().name, "live");
}
#[test] #[test]
fn stored_only_row_can_restore_and_open_but_not_direct_send() { fn stored_only_row_can_restore_and_open_but_not_direct_send() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();

View File

@ -7,7 +7,7 @@ license.workspace = true
autobins = false autobins = false
[[bin]] [[bin]]
name = "worker-runtime-rest-server" name = "yoi-runtime"
path = "src/main.rs" path = "src/main.rs"
required-features = ["ws-server", "fs-store"] required-features = ["ws-server", "fs-store"]
@ -17,7 +17,7 @@ path = "src/worker_runtime_bin.rs"
required-features = ["ws-server", "fs-store"] required-features = ["ws-server", "fs-store"]
[features] [features]
default = [] default = ["ws-server", "fs-store"]
fs-store = [] fs-store = []
http-server = ["dep:axum", "dep:tower", "dep:reqwest"] http-server = ["dep:axum", "dep:tower", "dep:reqwest"]
ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"] ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]

View File

@ -8,8 +8,7 @@ From the repository root:
```bash ```bash
cargo run -p worker-runtime \ cargo run -p worker-runtime \
--features ws-server,fs-store \ --bin yoi-runtime \
--bin worker-runtime-rest-server \
-- --bind 127.0.0.1:38800 -- --bind 127.0.0.1:38800
``` ```
@ -23,8 +22,7 @@ To bind another address explicitly:
```bash ```bash
cargo run -p worker-runtime \ cargo run -p worker-runtime \
--features ws-server,fs-store \ --bin yoi-runtime \
--bin worker-runtime-rest-server \
-- --bind 0.0.0.0:38800 -- --bind 0.0.0.0:38800
``` ```

View File

@ -30,7 +30,7 @@ fn main() -> ExitCode {
match run() { match run() {
Ok(()) => ExitCode::SUCCESS, Ok(()) => ExitCode::SUCCESS,
Err(error) => { Err(error) => {
eprintln!("worker-runtime-rest-server: {error}"); eprintln!("yoi-runtime: {error}");
if let ProcessError::Usage(_) = error { if let ProcessError::Usage(_) = error {
eprintln!(); eprintln!();
eprintln!("{}", usage()); eprintln!("{}", usage());
@ -64,7 +64,7 @@ fn run() -> Result<(), ProcessError> {
let local_addr = listener.local_addr()?; let local_addr = listener.local_addr()?;
let worker_runtime = build_runtime(&config)?; let worker_runtime = build_runtime(&config)?;
eprintln!( eprintln!(
"worker-runtime REST server listening on {local_addr}; intended client is a trusted backend/proxy, not a browser" "yoi-runtime listening on {local_addr}; intended client is a trusted backend/proxy, not a browser"
); );
worker_runtime::http_server::serve_runtime_http_with_auth( worker_runtime::http_server::serve_runtime_http_with_auth(
worker_runtime, worker_runtime,
@ -796,7 +796,7 @@ fn run_trust_server_command(mut args: VecDeque<String>) -> Result<(), ProcessErr
} }
fn usage() -> &'static str { fn usage() -> &'static str {
r#"Usage: worker-runtime-rest-server [OPTIONS] r#"Usage: yoi-runtime [OPTIONS]
Starts a worker-backed Runtime REST command API for a trusted backend/proxy. Starts a worker-backed Runtime REST command API for a trusted backend/proxy.
Browsers must not connect to this Runtime process directly. Browsers must not connect to this Runtime process directly.

View File

@ -76,7 +76,7 @@ impl TaskFeature {
/// pointing at the same feature-owned store after rewind. /// pointing at the same feature-owned store after rewind.
pub fn restore_from_history(&self, history: &[Item]) { pub fn restore_from_history(&self, history: &[Item]) {
let restored = TaskStore::from_history(history); let restored = TaskStore::from_history(history);
self.state.task_store.replace_with(restored.list()); self.state.task_store.replace_with(restored.list_active());
} }
/// Feature-owned snapshot text used by compaction to preserve Task state. /// Feature-owned snapshot text used by compaction to preserve Task state.
@ -86,7 +86,7 @@ impl TaskFeature {
/// Feature-owned compact summary used for the synthetic TaskList result. /// Feature-owned compact summary used for the synthetic TaskList result.
pub fn snapshot_overview(&self) -> String { pub fn snapshot_overview(&self) -> String {
snapshot_overview(&self.state.task_store.list()) snapshot_overview(&self.state.task_store.list_active())
} }
#[cfg(test)] #[cfg(test)]

View File

@ -18,6 +18,12 @@ pub enum TaskStatus {
Deleted, Deleted,
} }
impl TaskStatus {
pub fn is_active(self) -> bool {
matches!(self, Self::Pending | Self::Inprogress)
}
}
impl std::fmt::Display for TaskStatus { impl std::fmt::Display for TaskStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self { let s = match self {
@ -54,6 +60,8 @@ pub struct TaskSnapshot {
pub tasks: Vec<TaskEntry>, pub tasks: Vec<TaskEntry>,
} }
pub const DEFAULT_TASK_LIST_LIMIT: usize = 20;
impl TaskStore { impl TaskStore {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@ -85,6 +93,13 @@ impl TaskStore {
.clone() .clone()
} }
pub fn list_active(&self) -> Vec<TaskEntry> {
self.list()
.into_iter()
.filter(|task| task.status.is_active())
.collect()
}
pub fn get(&self, taskid: u64) -> Option<TaskEntry> { pub fn get(&self, taskid: u64) -> Option<TaskEntry> {
self.inner self.inner
.lock() .lock()
@ -106,11 +121,12 @@ impl TaskStore {
return Err(TaskStoreError::NoFields); return Err(TaskStoreError::NoFields);
} }
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
let task = inner let task_position = inner
.tasks .tasks
.iter_mut() .iter()
.find(|t| t.taskid == taskid) .position(|t| t.taskid == taskid)
.ok_or(TaskStoreError::Missing(taskid))?; .ok_or(TaskStoreError::Missing(taskid))?;
let task = &mut inner.tasks[task_position];
if let Some(status) = status { if let Some(status) = status {
task.status = status; task.status = status;
} }
@ -120,11 +136,21 @@ impl TaskStore {
if let Some(description) = description { if let Some(description) = description {
task.description = description; task.description = description;
} }
Ok(task.clone()) let updated = task.clone();
if !updated.status.is_active() {
inner.tasks.remove(task_position);
}
Ok(updated)
} }
pub fn snapshot(&self) -> TaskSnapshot { pub fn snapshot(&self) -> TaskSnapshot {
TaskSnapshot { tasks: self.list() } self.snapshot_limited(DEFAULT_TASK_LIST_LIMIT)
}
pub fn snapshot_limited(&self, limit: usize) -> TaskSnapshot {
TaskSnapshot {
tasks: self.list_active().into_iter().take(limit).collect(),
}
} }
pub fn replay_history(&self, history: &[Item]) { pub fn replay_history(&self, history: &[Item]) {
@ -168,6 +194,10 @@ impl TaskStore {
} }
pub fn replace_with(&self, tasks: Vec<TaskEntry>) { pub fn replace_with(&self, tasks: Vec<TaskEntry>) {
let tasks: Vec<_> = tasks
.into_iter()
.filter(|task| task.status.is_active())
.collect();
let next_taskid = tasks let next_taskid = tasks
.iter() .iter()
.map(|t| t.taskid) .map(|t| t.taskid)
@ -237,27 +267,26 @@ pub fn snapshot_overview(tasks: &[TaskEntry]) -> String {
.iter() .iter()
.filter(|t| t.status == TaskStatus::Inprogress) .filter(|t| t.status == TaskStatus::Inprogress)
.count(); .count();
let completed = tasks let active = pending + inprogress;
.iter() format!("TaskStore: {active} active task(s) (pending: {pending}, inprogress: {inprogress})")
.filter(|t| t.status == TaskStatus::Completed)
.count();
let deleted = tasks
.iter()
.filter(|t| t.status == TaskStatus::Deleted)
.count();
format!(
"TaskStore: {} task(s) (pending: {pending}, inprogress: {inprogress}, completed: {completed}, deleted: {deleted})",
tasks.len()
)
} }
pub fn render_snapshot(tasks: &[TaskEntry]) -> String { pub fn render_snapshot(tasks: &[TaskEntry]) -> String {
let active_tasks: Vec<_> = tasks
.iter()
.filter(|task| task.status.is_active())
.cloned()
.collect();
let snapshot = TaskSnapshot { let snapshot = TaskSnapshot {
tasks: tasks.to_vec(), tasks: active_tasks.clone(),
}; };
let json = let json =
serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| String::from("{\"tasks\":[]}")); serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| String::from("{\"tasks\":[]}"));
format!("{}\n\n```json\n{}\n```\n", snapshot_overview(tasks), json) format!(
"{}\n\n```json\n{}\n```\n",
snapshot_overview(&active_tasks),
json
)
} }
pub(super) fn parse_compact_snapshot_text(text: &str) -> Option<Vec<TaskEntry>> { pub(super) fn parse_compact_snapshot_text(text: &str) -> Option<Vec<TaskEntry>> {
@ -270,7 +299,13 @@ pub(super) fn parse_compact_snapshot_text(text: &str) -> Option<Vec<TaskEntry>>
let rest = &text[start..]; let rest = &text[start..];
let end = rest.find(end_marker)?; let end = rest.find(end_marker)?;
let snapshot: TaskSnapshot = serde_json::from_str(&rest[..end]).ok()?; let snapshot: TaskSnapshot = serde_json::from_str(&rest[..end]).ok()?;
Some(snapshot.tasks) Some(
snapshot
.tasks
.into_iter()
.filter(|task| task.status.is_active())
.collect(),
)
} }
#[cfg(test)] #[cfg(test)]
@ -288,11 +323,9 @@ mod tests {
]; ];
let store = TaskStore::from_history(&history); let store = TaskStore::from_history(&history);
let tasks = store.list(); let tasks = store.list();
assert_eq!(tasks.len(), 2); assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].taskid, 1); assert_eq!(tasks[0].taskid, 1);
assert_eq!(tasks[0].status, TaskStatus::Pending); assert_eq!(tasks[0].status, TaskStatus::Pending);
assert_eq!(tasks[1].taskid, 2);
assert_eq!(tasks[1].status, TaskStatus::Completed);
} }
/// Wrap snapshot text the way `Worker::try_pre_run_compact` does, so tests /// Wrap snapshot text the way `Worker::try_pre_run_compact` does, so tests
@ -300,7 +333,7 @@ mod tests {
fn wrap_snapshot_system_message(snapshot: &str) -> String { fn wrap_snapshot_system_message(snapshot: &str) -> String {
format!( format!(
"[Session TaskStore snapshot]\n\n{snapshot}\n\n\ "[Session TaskStore snapshot]\n\n{snapshot}\n\n\
This is the complete session task list preserved across compaction. \ This is the active session task list preserved across compaction. \
The following TaskList tool result presents the same state through the tool lane." The following TaskList tool result presents the same state through the tool lane."
) )
} }
@ -322,11 +355,9 @@ mod tests {
]; ];
let store = TaskStore::from_history(&history); let store = TaskStore::from_history(&history);
let tasks = store.list(); let tasks = store.list();
assert_eq!(tasks.len(), 2); assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].taskid, 1); assert_eq!(tasks[0].taskid, 2);
assert_eq!(tasks[0].status, TaskStatus::Completed); assert_eq!(tasks[0].subject, "new");
assert_eq!(tasks[1].taskid, 2);
assert_eq!(tasks[1].subject, "new");
} }
#[test] #[test]
@ -365,15 +396,12 @@ mod tests {
]; ];
let store = TaskStore::from_history(&history); let store = TaskStore::from_history(&history);
let tasks = store.list(); let tasks = store.list();
assert_eq!(tasks.len(), 3); assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].taskid, 1); assert_eq!(tasks[0].taskid, 2);
assert_eq!(tasks[0].subject, "A"); assert_eq!(tasks[0].subject, "B");
assert_eq!(tasks[0].status, TaskStatus::Completed); assert_eq!(tasks[0].status, TaskStatus::Inprogress);
assert_eq!(tasks[1].taskid, 2); assert_eq!(tasks[1].taskid, 3);
assert_eq!(tasks[1].subject, "B"); assert_eq!(tasks[1].subject, "C");
assert_eq!(tasks[1].status, TaskStatus::Inprogress);
assert_eq!(tasks[2].taskid, 3);
assert_eq!(tasks[2].subject, "C");
} }
#[test] #[test]
@ -415,9 +443,10 @@ mod tests {
let snapshot_text = pre.snapshot_text(); let snapshot_text = pre.snapshot_text();
let system = Item::system_message(wrap_snapshot_system_message(&snapshot_text)); let system = Item::system_message(wrap_snapshot_system_message(&snapshot_text));
let call = Item::tool_call("compact-tasklist", "TaskList", "{}"); let call = Item::tool_call("compact-tasklist", "TaskList", "{}");
let active_tasks = pre.list_active();
let result = Item::tool_result_with_content( let result = Item::tool_result_with_content(
"compact-tasklist", "compact-tasklist",
snapshot_overview(&pre.list()), snapshot_overview(&active_tasks),
snapshot_text.clone(), snapshot_text.clone(),
); );
@ -426,7 +455,7 @@ mod tests {
.as_text() .as_text()
.and_then(parse_compact_snapshot_text) .and_then(parse_compact_snapshot_text)
.expect("system message should parse as snapshot"); .expect("system message should parse as snapshot");
assert_eq!(extracted, pre.list()); assert_eq!(extracted, active_tasks);
// The synthetic call/result pair shares one call_id and carries the // The synthetic call/result pair shares one call_id and carries the
// expected tool name + detailed content. // expected tool name + detailed content.
@ -453,6 +482,6 @@ mod tests {
// Replaying the full triple reconstructs the same TaskStore. // Replaying the full triple reconstructs the same TaskStore.
let store = TaskStore::from_history(&[system, call, result]); let store = TaskStore::from_history(&[system, call, result]);
assert_eq!(store.list(), pre.list()); assert_eq!(store.list(), active_tasks);
} }
} }

View File

@ -6,7 +6,7 @@ use async_trait::async_trait;
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use serde::Deserialize; use serde::Deserialize;
use super::store::{TaskEntry, TaskStatus, TaskStore, render_snapshot, snapshot_overview}; use super::store::{DEFAULT_TASK_LIST_LIMIT, TaskEntry, TaskStatus, TaskStore, snapshot_overview};
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TaskCreateParams { struct TaskCreateParams {
@ -17,7 +17,11 @@ struct TaskCreateParams {
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TaskListParams {} struct TaskListParams {
/// Maximum number of active tasks to return. Defaults to 20.
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TaskGetParams { struct TaskGetParams {
@ -58,9 +62,7 @@ coordination. Do not create a task just because a request has several steps, and
one for short questions, quick checks, single reviews, or one-off commands. Prefer updating an \ one for short questions, quick checks, single reviews, or one-off commands. Prefer updating an \
existing active task over creating a duplicate. Input only `subject` and `description`; `taskid` \ existing active task over creating a duplicate. Input only `subject` and `description`; `taskid` \
is assigned automatically and initial `status` is `pending`."; is assigned automatically and initial `status` is `pending`.";
const LIST_DESCRIPTION: &str = "List every session-lifetime task, including completed and \ const LIST_DESCRIPTION: &str = "List active session-lifetime tasks. Completed and deleted tasks are forgotten and omitted. Defaults to 20 tasks unless `limit` is provided.";
deleted entries. Tasks are user-visible real-time status for short-term current-work tracking. \
Takes an empty object as input.";
const GET_DESCRIPTION: &str = "Get one session-lifetime task by `taskid`. Tasks are \ const GET_DESCRIPTION: &str = "Get one session-lifetime task by `taskid`. Tasks are \
user-visible real-time status for short-term current-work tracking. Returns an error if the task \ user-visible real-time status for short-term current-work tracking. Returns an error if the task \
does not exist."; does not exist.";
@ -81,7 +83,7 @@ impl Tool for TaskCreateTool {
let params: TaskCreateParams = serde_json::from_str(input_json) let params: TaskCreateParams = serde_json::from_str(input_json)
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskCreate input: {e}")))?; .map_err(|e| ToolError::InvalidArgument(format!("invalid TaskCreate input: {e}")))?;
let created = self.store.create(params.subject, params.description); let created = self.store.create(params.subject, params.description);
let tasks = self.store.list(); let tasks = self.store.list_active();
Ok(task_output( Ok(task_output(
format!( format!(
"Created task {} ({})\n{}", "Created task {} ({})\n{}",
@ -90,7 +92,6 @@ impl Tool for TaskCreateTool {
snapshot_overview(&tasks) snapshot_overview(&tasks)
), ),
&created, &created,
&tasks,
)) ))
} }
} }
@ -102,12 +103,14 @@ impl Tool for TaskListTool {
input_json: &str, input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext, _ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let _: TaskListParams = serde_json::from_str(input_json) let params: TaskListParams = serde_json::from_str(input_json)
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskList input: {e}")))?; .map_err(|e| ToolError::InvalidArgument(format!("invalid TaskList input: {e}")))?;
let tasks = self.store.list(); let limit = params.limit.unwrap_or(DEFAULT_TASK_LIST_LIMIT);
let active_tasks = self.store.list_active();
let tasks: Vec<_> = active_tasks.iter().take(limit).cloned().collect();
Ok(ToolOutput { Ok(ToolOutput {
summary: snapshot_overview(&tasks), summary: list_overview(active_tasks.len(), tasks.len()),
content: Some(render_snapshot(&tasks)), content: Some(render_task_list(&tasks)),
}) })
} }
} }
@ -150,7 +153,7 @@ impl Tool for TaskUpdateTool {
params.description, params.description,
) )
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
let tasks = self.store.list(); let tasks = self.store.list_active();
Ok(task_output( Ok(task_output(
format!( format!(
"Updated task {} ({})\n{}", "Updated task {} ({})\n{}",
@ -159,21 +162,32 @@ impl Tool for TaskUpdateTool {
snapshot_overview(&tasks) snapshot_overview(&tasks)
), ),
&updated, &updated,
&tasks,
)) ))
} }
} }
fn task_output(summary: String, task: &TaskEntry, tasks: &[TaskEntry]) -> ToolOutput { fn task_output(summary: String, task: &TaskEntry) -> ToolOutput {
let content = serde_json::json!({
"task": task,
"snapshot": { "tasks": tasks },
});
ToolOutput { ToolOutput {
summary, summary,
content: Some(serde_json::to_string_pretty(&content).unwrap_or_default()), content: Some(serde_json::to_string_pretty(task).unwrap_or_default()),
} }
} }
fn list_overview(total_active: usize, returned: usize) -> String {
if returned < total_active {
format!(
"TaskStore: {returned} active task(s) shown; {} omitted.",
total_active - returned
)
} else {
format!("TaskStore: {returned} active task(s)")
}
}
fn render_task_list(tasks: &[TaskEntry]) -> String {
serde_json::to_string_pretty(tasks).unwrap_or_default()
}
fn task_create_tool(store: TaskStore) -> ToolDefinition { fn task_create_tool(store: TaskStore) -> ToolDefinition {
Arc::new(move || { Arc::new(move || {
let schema = schemars::schema_for!(TaskCreateParams); let schema = schemars::schema_for!(TaskCreateParams);
@ -264,6 +278,10 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert!(out.summary.contains("Created task 1")); assert!(out.summary.contains("Created task 1"));
let created_content = out.content.unwrap();
let created_json: serde_json::Value = serde_json::from_str(&created_content).unwrap();
assert_eq!(created_json["taskid"], 1);
assert!(created_json.get("task").is_none());
assert_eq!(store.get(1).unwrap().status, TaskStatus::Pending); assert_eq!(store.get(1).unwrap().status, TaskStatus::Pending);
let out = update let out = update
@ -286,10 +304,106 @@ mod tests {
assert!(out.content.unwrap().contains("implement tasks")); assert!(out.content.unwrap().contains("implement tasks"));
let out = list.execute("{}", Default::default()).await.unwrap(); let out = list.execute("{}", Default::default()).await.unwrap();
assert!(out.summary.contains("1 task(s)")); assert!(out.summary.contains("1 active task(s)"));
let content = out.content.unwrap(); let content = out.content.unwrap();
assert!(content.contains("\"taskid\": 1")); assert!(content.contains("\"taskid\": 1"));
assert!(content.contains("```json")); assert!(!content.contains("\"limit\""));
assert!(!content.contains("```json"));
}
#[tokio::test]
async fn task_list_omits_completed_deleted_and_defaults_to_twenty() {
let store = TaskStore::new();
let create = tool(task_create_tool(store.clone()));
let update = tool(task_update_tool(store.clone()));
let list = tool(task_list_tool(store.clone()));
for i in 0..25 {
create
.execute(
&format!(r#"{{"subject":"task {i}","description":"desc {i}"}}"#),
Default::default(),
)
.await
.unwrap();
}
update
.execute(r#"{"taskid":1,"status":"completed"}"#, Default::default())
.await
.unwrap();
update
.execute(r#"{"taskid":2,"status":"deleted"}"#, Default::default())
.await
.unwrap();
let out = list.execute("{}", Default::default()).await.unwrap();
assert_eq!(
out.summary,
"TaskStore: 20 active task(s) shown; 3 omitted."
);
let content = out.content.unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
let tasks = json.as_array().unwrap();
assert_eq!(tasks.len(), 20);
let ids: Vec<u64> = tasks
.iter()
.map(|task| task["taskid"].as_u64().unwrap())
.collect();
assert!(!ids.contains(&1));
assert!(!ids.contains(&2));
assert!(!content.contains("\"limit\""));
assert!(!content.contains("\"total_active\""));
assert!(!content.contains("\"truncated\""));
let out = list
.execute(r#"{"limit":3}"#, Default::default())
.await
.unwrap();
assert_eq!(
out.summary,
"TaskStore: 3 active task(s) shown; 20 omitted."
);
let content = out.content.unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(json.as_array().unwrap().len(), 3);
}
#[tokio::test]
async fn task_create_and_update_results_do_not_include_full_snapshot() {
let store = TaskStore::new();
let create = tool(task_create_tool(store.clone()));
let update = tool(task_update_tool(store.clone()));
create
.execute(
r#"{"subject":"done","description":"completed task"}"#,
Default::default(),
)
.await
.unwrap();
update
.execute(r#"{"taskid":1,"status":"completed"}"#, Default::default())
.await
.unwrap();
create
.execute(
r#"{"subject":"active","description":"active task"}"#,
Default::default(),
)
.await
.unwrap();
let out = update
.execute(r#"{"taskid":2,"status":"inprogress"}"#, Default::default())
.await
.unwrap();
let content = out.content.unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(json["taskid"], 2);
assert!(json.get("task").is_none());
assert!(json.get("snapshot").is_none());
assert!(!content.contains("\"taskid\": 1"));
assert!(!content.contains("completed task"));
} }
#[tokio::test] #[tokio::test]

View File

@ -2733,7 +2733,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
} }
let task_snapshot_message = Item::system_message(format!( let task_snapshot_message = Item::system_message(format!(
"[Session TaskStore snapshot]\n\n{task_snapshot_text}\n\n\ "[Session TaskStore snapshot]\n\n{task_snapshot_text}\n\n\
This is the complete session task list preserved across compaction. \ This is the active session task list preserved across compaction. \
The following TaskList tool result presents the same state through the tool lane." The following TaskList tool result presents the same state through the tool lane."
)); ));
compact_introduced_system_messages.push(task_snapshot_message.clone()); compact_introduced_system_messages.push(task_snapshot_message.clone());

View File

@ -4,6 +4,11 @@ version = "0.1.0"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
publish = false publish = false
autobins = false
[[bin]]
name = "yoi-server"
path = "src/main.rs"
[dependencies] [dependencies]
async-trait.workspace = true async-trait.workspace = true
@ -25,7 +30,7 @@ memory.workspace = true
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] } tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
tokio-tungstenite.workspace = true tokio-tungstenite.workspace = true
worker.workspace = true worker.workspace = true
worker-runtime = { workspace = true, features = ["ws-server", "fs-store"] } worker-runtime.workspace = true
toml.workspace = true toml.workspace = true
tracing.workspace = true tracing.workspace = true
url.workspace = true url.workspace = true

View File

@ -306,7 +306,7 @@ impl WorkspaceBackendConfigFile {
match fs::read_to_string(&path) { match fs::read_to_string(&path) {
Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)), Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!( Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!(
"workspace backend local config `{}` does not exist; run `yoi workspace init --workspace {}` first", "workspace backend local config `{}` does not exist; run `yoi-server init --workspace {}` first",
path.display(), path.display(),
workspace_root.display() workspace_root.display()
))), ))),

View File

@ -46,7 +46,7 @@ impl WorkspaceIdentity {
Ok(raw) => Self::parse_str(&raw, &path), Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == ErrorKind::NotFound => { Err(error) if error.kind() == ErrorKind::NotFound => {
Err(Error::WorkspaceIdentity(format!( Err(Error::WorkspaceIdentity(format!(
"workspace is not initialized at {}; run `yoi workspace init --workspace {}` first", "workspace is not initialized at {}; run `yoi-server init --workspace {}` first",
workspace_root.as_ref().display(), workspace_root.as_ref().display(),
workspace_root.as_ref().display() workspace_root.as_ref().display()
))) )))

View File

@ -65,7 +65,7 @@ async fn main() -> ExitCode {
match run().await { match run().await {
Ok(()) => ExitCode::SUCCESS, Ok(()) => ExitCode::SUCCESS,
Err(error) => { Err(error) => {
eprintln!("yoi-workspace-server: {error}"); eprintln!("yoi-server: {error}");
ExitCode::FAILURE ExitCode::FAILURE
} }
} }
@ -160,7 +160,7 @@ async fn run_init_with_database_path(
})?; })?;
eprintln!( eprintln!(
"yoi-workspace-server: initialized workspace `{}` ({}) in server DB `{}`", "yoi-server: initialized workspace `{}` ({}) in server DB `{}`",
options.workspace.display(), options.workspace.display(),
identity.workspace_id, identity.workspace_id,
database_path.display() database_path.display()
@ -572,7 +572,7 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
let listener = TcpListener::bind(resolved.listen).await?; let listener = TcpListener::bind(resolved.listen).await?;
eprintln!( eprintln!(
"yoi-workspace-server: serving workspace `{}` from server DB `{}` on http://{}", "yoi-server: serving workspace `{}` from server DB `{}` on http://{}",
workspace.workspace_id, workspace.workspace_id,
database_path.display(), database_path.display(),
listener.local_addr()? listener.local_addr()?
@ -588,7 +588,7 @@ fn append_trusted_runtime_sources(
let Some(server_identity) = read_server_identity_file(&server_identity_path())? else { let Some(server_identity) = read_server_identity_file(&server_identity_path())? else {
if !store.list_trusted_runtimes(false)?.is_empty() { if !store.list_trusted_runtimes(false)?.is_empty() {
return Err(Box::new(CliError( return Err(Box::new(CliError(
"trusted runtimes are registered but server identity is not initialized; run `yoi-workspace-server identity init`".to_string(), "trusted runtimes are registered but server identity is not initialized; run `yoi-server identity init`".to_string(),
))); )));
} }
return Ok(()); return Ok(());
@ -617,7 +617,8 @@ fn select_serve_workspace(store: &SqliteWorkspaceStore) -> Result<WorkspaceRecor
.map_err(|error| CliError(format!("failed to list workspaces from server DB: {error}")))?; .map_err(|error| CliError(format!("failed to list workspaces from server DB: {error}")))?;
match workspaces.as_slice() { match workspaces.as_slice() {
[] => Err(CliError( [] => Err(CliError(
"server DB has no workspace records; run `yoi-workspace-server init --workspace <PATH>`".to_string(), "server DB has no workspace records; run `yoi-server init --workspace <PATH>`"
.to_string(),
)), )),
[workspace] => Ok(workspace.clone()), [workspace] => Ok(workspace.clone()),
_ => Err(CliError(format!( _ => Err(CliError(format!(
@ -825,31 +826,31 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() { fn print_help() {
println!( println!(
"yoi-workspace-server\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n yoi-workspace-server config <COMMAND> [OPTIONS]\n yoi-workspace-server identity init --server-id <SERVER_ID> [--replace]\n yoi-workspace-server identity show [--json]\n yoi-workspace-server trust-runtime add --runtime-id <RUNTIME_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-workspace-server trust-runtime list [--json] [--include-revoked]\n yoi-workspace-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-workspace-server skills <COMMAND> [OPTIONS]\n yoi-workspace-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" "yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config <COMMAND> [OPTIONS]\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
); );
} }
fn print_init_help() { fn print_init_help() {
println!( println!(
"yoi-workspace-server init\n\nUsage:\n yoi-workspace-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity, copies the packaged Backend config template to .yoi/workspace-backend.local.toml, and registers the Workspace in the Yoi server DB.\n\nOptions:\n --workspace <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help" "yoi-server init\n\nUsage:\n yoi-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity, copies the packaged Backend config template to .yoi/workspace-backend.local.toml, and registers the Workspace in the Yoi server DB.\n\nOptions:\n --workspace <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help"
); );
} }
fn print_config_help() { fn print_config_help() {
println!( println!(
"yoi-workspace-server config\n\nUsage:\n yoi-workspace-server config default\n yoi-workspace-server config diff [OPTIONS]\n\nDescription:\n Prints the packaged Workspace Backend config template or compares it with the workspace-local config.\n\nOptions for diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help" "yoi-server config\n\nUsage:\n yoi-server config default\n yoi-server config diff [OPTIONS]\n\nDescription:\n Prints the packaged Workspace Backend config template or compares it with the workspace-local config.\n\nOptions for diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
); );
} }
fn print_skills_help() { fn print_skills_help() {
println!( println!(
"yoi-workspace-server skills\n\nUsage:\n yoi-workspace-server skills list [OPTIONS]\n yoi-workspace-server skills lint [OPTIONS]\n yoi-workspace-server skills show <NAME> [OPTIONS]\n\nDescription:\n Uses the Workspace backend Skill catalog/lint/detail authority. Catalog output is lightweight and omits full SKILL.md bodies; detail output includes the body. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help" "yoi-server skills\n\nUsage:\n yoi-server skills list [OPTIONS]\n yoi-server skills lint [OPTIONS]\n yoi-server skills show <NAME> [OPTIONS]\n\nDescription:\n Uses the Workspace backend Skill catalog/lint/detail authority. Catalog output is lightweight and omits full SKILL.md bodies; detail output includes the body. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
); );
} }
fn print_serve_help() { fn print_serve_help() {
println!( println!(
"yoi-workspace-server serve\n\nUsage:\n yoi-workspace-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help" "yoi-server serve\n\nUsage:\n yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help"
); );
} }

View File

@ -1,7 +1,7 @@
use client::{BackendTarget, LocalTarget, Target, TargetKind}; use client::{BackendTarget, LocalTarget, Target, TargetKind};
use serde::Deserialize; use serde::Deserialize;
use super::{ParseError, resolve_backend_url}; use super::{ParseError, read_client_default_connection, resolve_backend_url};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CliConnectionRequirement { pub(crate) enum CliConnectionRequirement {
@ -26,7 +26,6 @@ pub(crate) enum CliCommand {
Mcp, Mcp,
MemoryLint, MemoryLint,
Session, Session,
WorkspaceServer,
Login, Login,
} }
@ -47,18 +46,18 @@ impl CliCommand {
CliCommand::Mcp => "yoi mcp", CliCommand::Mcp => "yoi mcp",
CliCommand::MemoryLint => "yoi memory lint", CliCommand::MemoryLint => "yoi memory lint",
CliCommand::Session => "yoi session", CliCommand::Session => "yoi session",
CliCommand::WorkspaceServer => "yoi workspace/server",
CliCommand::Login => "yoi login", CliCommand::Login => "yoi login",
} }
} }
pub(crate) fn connection_requirement(self) -> CliConnectionRequirement { pub(crate) fn connection_requirement(self) -> CliConnectionRequirement {
match self { match self {
CliCommand::DefaultTui => CliConnectionRequirement::ConnectionAware, CliCommand::DefaultTui
CliCommand::Workers | CliCommand::Login => CliConnectionRequirement::BackendOnly, | CliCommand::Workers
CliCommand::Resume | CliCommand::Resume
| CliCommand::Panel | CliCommand::Panel => CliConnectionRequirement::ConnectionAware,
| CliCommand::Keys CliCommand::Login => CliConnectionRequirement::BackendOnly,
CliCommand::Keys
| CliCommand::SetupModel | CliCommand::SetupModel
| CliCommand::WorkerRuntime | CliCommand::WorkerRuntime
| CliCommand::WorkerCleanup | CliCommand::WorkerCleanup
@ -67,15 +66,13 @@ impl CliCommand {
| CliCommand::Plugin | CliCommand::Plugin
| CliCommand::Mcp | CliCommand::Mcp
| CliCommand::MemoryLint | CliCommand::MemoryLint
| CliCommand::Session | CliCommand::Session => CliConnectionRequirement::LocalOnly,
| CliCommand::WorkspaceServer => CliConnectionRequirement::LocalOnly,
} }
} }
} }
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
#[allow(dead_code)]
pub(crate) enum ClientDefaultConnection { pub(crate) enum ClientDefaultConnection {
Local, Local,
Backend, Backend,
@ -89,7 +86,10 @@ impl Default for ClientDefaultConnection {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CliConnectionInput<'a> { pub(crate) enum CliConnectionInput<'a> {
LocalDefault, DefaultTarget {
workspace_id: Option<&'a str>,
},
LocalTarget,
BackendTarget { BackendTarget {
explicit_backend_url: Option<String>, explicit_backend_url: Option<String>,
workspace_id: Option<&'a str>, workspace_id: Option<&'a str>,
@ -114,21 +114,29 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver {
input: CliConnectionInput<'_>, input: CliConnectionInput<'_>,
) -> Result<Box<dyn Target>, ParseError> { ) -> Result<Box<dyn Target>, ParseError> {
match (command.connection_requirement(), input) { match (command.connection_requirement(), input) {
(CliConnectionRequirement::LocalOnly, CliConnectionInput::LocalDefault) => { (
Ok(Box::new(LocalTarget::new())) CliConnectionRequirement::LocalOnly,
} CliConnectionInput::DefaultTarget { .. } | CliConnectionInput::LocalTarget,
) => Ok(Box::new(LocalTarget::new())),
(CliConnectionRequirement::LocalOnly, CliConnectionInput::BackendTarget { .. }) => { (CliConnectionRequirement::LocalOnly, CliConnectionInput::BackendTarget { .. }) => {
Err(ParseError(format!( Err(ParseError(format!(
"{} uses a local connection target and cannot accept Backend target options", "{} uses a local connection target and cannot accept Backend target options",
command.display_name() command.display_name()
))) )))
} }
(CliConnectionRequirement::BackendOnly, CliConnectionInput::LocalDefault) => { (CliConnectionRequirement::BackendOnly, CliConnectionInput::LocalTarget) => {
Err(ParseError(format!( Err(ParseError(format!(
"{} requires a Backend connection target", "{} requires a Backend connection target",
command.display_name() command.display_name()
))) )))
} }
(
CliConnectionRequirement::BackendOnly,
CliConnectionInput::DefaultTarget { workspace_id },
) => Ok(Box::new(BackendTarget::new(
resolve_backend_url(None, workspace_id)?,
workspace_id.map(str::to_string),
))),
( (
CliConnectionRequirement::BackendOnly | CliConnectionRequirement::ConnectionAware, CliConnectionRequirement::BackendOnly | CliConnectionRequirement::ConnectionAware,
CliConnectionInput::BackendTarget { CliConnectionInput::BackendTarget {
@ -139,9 +147,19 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver {
resolve_backend_url(explicit_backend_url, workspace_id)?, resolve_backend_url(explicit_backend_url, workspace_id)?,
workspace_id.map(str::to_string), workspace_id.map(str::to_string),
))), ))),
(CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalDefault) => { (CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalTarget) => {
Ok(Box::new(LocalTarget::new())) Ok(Box::new(LocalTarget::new()))
} }
(
CliConnectionRequirement::ConnectionAware,
CliConnectionInput::DefaultTarget { workspace_id },
) => match read_client_default_connection()? {
ClientDefaultConnection::Local => Ok(Box::new(LocalTarget::new())),
ClientDefaultConnection::Backend => Ok(Box::new(BackendTarget::new(
resolve_backend_url(None, workspace_id)?,
workspace_id.map(str::to_string),
))),
},
} }
} }
} }
@ -150,7 +168,7 @@ pub(crate) fn resolve_local_cli_connection<R: CliConnectionResolver + ?Sized>(
resolver: &R, resolver: &R,
command: CliCommand, command: CliCommand,
) -> Result<Box<dyn Target>, ParseError> { ) -> Result<Box<dyn Target>, ParseError> {
let target = resolver.resolve_connection(command, CliConnectionInput::LocalDefault)?; let target = resolver.resolve_connection(command, CliConnectionInput::LocalTarget)?;
match target.kind() { match target.kind() {
TargetKind::Local => Ok(target), TargetKind::Local => Ok(target),
TargetKind::Backend => Err(ParseError(format!( TargetKind::Backend => Err(ParseError(format!(
@ -182,6 +200,33 @@ pub(crate) fn resolve_backend_cli_connection<R: CliConnectionResolver + ?Sized>(
} }
} }
pub(crate) fn resolve_connection_aware_cli_connection<R: CliConnectionResolver + ?Sized>(
resolver: &R,
command: CliCommand,
explicit_local: bool,
explicit_backend_url: Option<String>,
workspace_id: Option<&str>,
) -> Result<Box<dyn Target>, ParseError> {
if explicit_local && explicit_backend_url.is_some() {
return Err(ParseError(
"--local and --backend are mutually exclusive".to_string(),
));
}
if explicit_local {
return resolver.resolve_connection(command, CliConnectionInput::LocalTarget);
}
if explicit_backend_url.is_some() {
return resolver.resolve_connection(
command,
CliConnectionInput::BackendTarget {
explicit_backend_url,
workspace_id,
},
);
}
resolver.resolve_connection(command, CliConnectionInput::DefaultTarget { workspace_id })
}
pub(crate) fn backend_target_option_error_for_local_command( pub(crate) fn backend_target_option_error_for_local_command(
command: CliCommand, command: CliCommand,
option: &str, option: &str,
@ -209,22 +254,25 @@ mod tests {
#[test] #[test]
fn cli_command_connection_requirements_are_explicit() { fn cli_command_connection_requirements_are_explicit() {
for command in [
CliCommand::DefaultTui,
CliCommand::Workers,
CliCommand::Resume,
CliCommand::Panel,
] {
assert_eq!( assert_eq!(
CliCommand::DefaultTui.connection_requirement(), command.connection_requirement(),
CliConnectionRequirement::ConnectionAware CliConnectionRequirement::ConnectionAware,
); "{} should be connection-aware",
assert_eq!( command.display_name()
CliCommand::Workers.connection_requirement(),
CliConnectionRequirement::BackendOnly
); );
}
assert_eq!( assert_eq!(
CliCommand::Login.connection_requirement(), CliCommand::Login.connection_requirement(),
CliConnectionRequirement::BackendOnly CliConnectionRequirement::BackendOnly
); );
for command in [ for command in [
CliCommand::Resume,
CliCommand::Panel,
CliCommand::Keys, CliCommand::Keys,
CliCommand::SetupModel, CliCommand::SetupModel,
CliCommand::WorkerRuntime, CliCommand::WorkerRuntime,
@ -235,7 +283,6 @@ mod tests {
CliCommand::Mcp, CliCommand::Mcp,
CliCommand::MemoryLint, CliCommand::MemoryLint,
CliCommand::Session, CliCommand::Session,
CliCommand::WorkspaceServer,
] { ] {
assert_eq!( assert_eq!(
command.connection_requirement(), command.connection_requirement(),
@ -251,7 +298,7 @@ mod tests {
let resolver = ClientConfigCliConnectionResolver; let resolver = ClientConfigCliConnectionResolver;
let err = resolver let err = resolver
.resolve_connection( .resolve_connection(
CliCommand::Resume, CliCommand::Keys,
CliConnectionInput::BackendTarget { CliConnectionInput::BackendTarget {
explicit_backend_url: Some("http://127.0.0.1:8787".to_string()), explicit_backend_url: Some("http://127.0.0.1:8787".to_string()),
workspace_id: None, workspace_id: None,
@ -261,20 +308,20 @@ mod tests {
assert_eq!( assert_eq!(
err.to_string(), err.to_string(),
"yoi resume uses a local connection target and cannot accept Backend target options" "yoi keys uses a local connection target and cannot accept Backend target options"
); );
} }
#[test] #[test]
fn cli_connection_resolver_rejects_backend_only_local_default() { fn cli_connection_resolver_rejects_backend_only_local_target() {
let resolver = ClientConfigCliConnectionResolver; let resolver = ClientConfigCliConnectionResolver;
let err = resolver let err = resolver
.resolve_connection(CliCommand::Workers, CliConnectionInput::LocalDefault) .resolve_connection(CliCommand::Login, CliConnectionInput::LocalTarget)
.unwrap_err(); .unwrap_err();
assert_eq!( assert_eq!(
err.to_string(), err.to_string(),
"yoi workers requires a Backend connection target" "yoi login requires a Backend connection target"
); );
} }
@ -293,7 +340,8 @@ mod tests {
let workers = target let workers = target
.list_workers(client::WorkerListRequest::new(None)) .list_workers(client::WorkerListRequest::new(None))
.unwrap(); .unwrap();
assert_eq!(workers.target.base_url, "http://127.0.0.1:8787"); let backend_target = workers.backend_target.as_ref().unwrap();
assert_eq!(workers.target.workspace_id, None); assert_eq!(backend_target.base_url, "http://127.0.0.1:8787");
assert_eq!(backend_target.workspace_id, None);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -231,7 +231,7 @@ in
tag = imageTag; tag = imageTag;
copyToRoot = runtimeRoot; copyToRoot = runtimeRoot;
config = { config = {
Entrypoint = [ "/bin/worker-runtime-rest-server" ]; Entrypoint = [ "/bin/yoi-runtime" ];
Cmd = [ Cmd = [
"--bind" "--bind"
"0.0.0.0:38800" "0.0.0.0:38800"
@ -259,7 +259,7 @@ in
tag = imageTag; tag = imageTag;
copyToRoot = serverRoot; copyToRoot = serverRoot;
config = { config = {
Entrypoint = [ "/bin/yoi-workspace-server" ]; Entrypoint = [ "/bin/yoi-server" ];
Cmd = [ Cmd = [
"serve" "serve"
"--listen" "--listen"

View File

@ -32,10 +32,10 @@ Current image roles:
```text ```text
yoi-runtime:latest yoi-runtime:latest
worker-runtime-rest-server yoi-runtime
yoi-server:latest yoi-server:latest
yoi-workspace-server yoi-server
yoi-webui:latest yoi-webui:latest
nginx serving built WebUI assets and proxying API requests nginx serving built WebUI assets and proxying API requests
@ -62,7 +62,7 @@ docker/workspace/.yoi/workspace.toml
docker/workspace/.yoi/workspace-backend.local.toml docker/workspace/.yoi/workspace-backend.local.toml
``` ```
The WebUI container serves static assets and proxies `/api` to the Backend Server. The Backend Server registers the Runtime container as a remote Runtime such as `docker-runtime`. The Runtime container runs `worker-runtime-rest-server` and owns Worker spawning/materialization for that runtime. The WebUI container serves static assets and proxies `/api` to the Backend Server. The Backend Server registers the Runtime container as a remote Runtime such as `docker-runtime`. The Runtime container runs `yoi-runtime` and owns Worker spawning/materialization for that runtime.
Container user and writable data directories matter: runtime/server images must be able to write their configured data directories and named volumes. The current local-image Compose setup avoids an image-level `User` override and sets data-directory permissions accordingly. Container user and writable data directories matter: runtime/server images must be able to write their configured data directories and named volumes. The current local-image Compose setup avoids an image-level `User` override and sets data-directory permissions accordingly.

View File

@ -28,13 +28,13 @@ RUNTIME_BASE_URL=http://127.0.0.1:38800
From the Workspace Server host: From the Workspace Server host:
```bash ```bash
yoi-workspace-server identity init --server-id server-main yoi-server identity init --server-id server-main
``` ```
Show the public identity and copy the `public_key` value: Show the public identity and copy the `public_key` value:
```bash ```bash
yoi-workspace-server identity show --json yoi-server identity show --json
``` ```
The Server private identity is stored in the Yoi data directory under the Server data root, currently: The Server private identity is stored in the Yoi data directory under the Server data root, currently:
@ -50,13 +50,13 @@ On Unix this file is written with `0600` permissions. Do not copy the private ke
From the Runtime host, using the same Runtime storage flags that the Runtime server process will use: From the Runtime host, using the same Runtime storage flags that the Runtime server process will use:
```bash ```bash
worker-runtime-rest-server identity init --runtime-id runtime-main yoi-runtime identity init --runtime-id runtime-main
``` ```
Show the public identity and copy the `public_key` value: Show the public identity and copy the `public_key` value:
```bash ```bash
worker-runtime-rest-server identity show --json yoi-runtime identity show --json
``` ```
By default, Runtime auth state is stored at: By default, Runtime auth state is stored at:
@ -70,21 +70,21 @@ If the Runtime process is launched with `--fs-root` or `--fs-runtime-dir`, pass
Example with explicit Runtime storage: Example with explicit Runtime storage:
```bash ```bash
worker-runtime-rest-server identity init \ yoi-runtime identity init \
--runtime-id runtime-main \ --runtime-id runtime-main \
--fs-root /var/lib/yoi-runtime --fs-root /var/lib/yoi-runtime
worker-runtime-rest-server identity show \ yoi-runtime identity show \
--json \ --json \
--fs-root /var/lib/yoi-runtime --fs-root /var/lib/yoi-runtime
``` ```
## 3. Register the Server public key on Runtime ## 3. Register the Server public key on Runtime
On the Runtime host, register the Server public key copied from `yoi-workspace-server identity show --json`: On the Runtime host, register the Server public key copied from `yoi-server identity show --json`:
```bash ```bash
worker-runtime-rest-server trust-server add \ yoi-runtime trust-server add \
--server-id server-main \ --server-id server-main \
--public-key '<SERVER_PUBLIC_KEY>' --public-key '<SERVER_PUBLIC_KEY>'
``` ```
@ -92,7 +92,7 @@ worker-runtime-rest-server trust-server add \
With explicit Runtime storage, keep using the same storage flags: With explicit Runtime storage, keep using the same storage flags:
```bash ```bash
worker-runtime-rest-server trust-server add \ yoi-runtime trust-server add \
--server-id server-main \ --server-id server-main \
--public-key '<SERVER_PUBLIC_KEY>' \ --public-key '<SERVER_PUBLIC_KEY>' \
--fs-root /var/lib/yoi-runtime --fs-root /var/lib/yoi-runtime
@ -101,27 +101,27 @@ worker-runtime-rest-server trust-server add \
Verify: Verify:
```bash ```bash
worker-runtime-rest-server trust-server list --json yoi-runtime trust-server list --json
``` ```
## 4. Register the Runtime public key and endpoint on Server ## 4. Register the Runtime public key and endpoint on Server
On the Workspace Server host, register the Runtime public key copied from `worker-runtime-rest-server identity show --json`: On the Workspace Server host, register the Runtime public key copied from `yoi-runtime identity show --json`:
```bash ```bash
yoi-workspace-server trust-runtime add \ yoi-server trust-runtime add \
--runtime-id runtime-main \ --runtime-id runtime-main \
--base-url http://127.0.0.1:38800 \ --base-url http://127.0.0.1:38800 \
--public-key '<RUNTIME_PUBLIC_KEY>' \ --public-key '<RUNTIME_PUBLIC_KEY>' \
--display-name 'Runtime main' --display-name 'Runtime main'
``` ```
This writes a trusted Runtime record to the Server DB. During `yoi-workspace-server serve`, active trusted Runtime records are loaded as remote Runtime sources and receive signed capability tokens. You do not need to duplicate the same Runtime in `runtimes.toml` for this trust-backed path. This writes a trusted Runtime record to the Server DB. During `yoi-server serve`, active trusted Runtime records are loaded as remote Runtime sources and receive signed capability tokens. You do not need to duplicate the same Runtime in `runtimes.toml` for this trust-backed path.
Verify: Verify:
```bash ```bash
yoi-workspace-server trust-runtime list --json yoi-server trust-runtime list --json
``` ```
## 5. Start Runtime and Workspace Server ## 5. Start Runtime and Workspace Server
@ -129,7 +129,7 @@ yoi-workspace-server trust-runtime list --json
Start Runtime with the same storage flags used during Runtime identity/trust setup: Start Runtime with the same storage flags used during Runtime identity/trust setup:
```bash ```bash
worker-runtime-rest-server \ yoi-runtime \
--bind 127.0.0.1:38800 --bind 127.0.0.1:38800
``` ```
@ -137,27 +137,26 @@ For repository builds, the equivalent cargo command is:
```bash ```bash
cargo run -p worker-runtime \ cargo run -p worker-runtime \
--features ws-server,fs-store \ --bin yoi-runtime \
--bin worker-runtime-rest-server \
-- --bind 127.0.0.1:38800 -- --bind 127.0.0.1:38800
``` ```
Start Workspace Server: Start Workspace Server:
```bash ```bash
yoi-workspace-server serve --listen 127.0.0.1:8787 yoi-server serve --listen 127.0.0.1:8787
``` ```
For repository builds: For repository builds:
```bash ```bash
cargo run -p yoi-workspace-server -- serve --listen 127.0.0.1:8787 cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787
``` ```
If the Server DB has no workspace record yet, initialize it first: If the Server DB has no workspace record yet, initialize it first:
```bash ```bash
yoi-workspace-server init --workspace <WORKSPACE_ROOT> yoi-server init --workspace <WORKSPACE_ROOT>
``` ```
## Smoke checks ## Smoke checks
@ -165,8 +164,8 @@ yoi-workspace-server init --workspace <WORKSPACE_ROOT>
Check both trust stores: Check both trust stores:
```bash ```bash
yoi-workspace-server trust-runtime list --json yoi-server trust-runtime list --json
worker-runtime-rest-server trust-server list --json yoi-runtime trust-server list --json
``` ```
Check that Workspace Server can see Runtime workers through the authenticated path. From the CLI: Check that Workspace Server can see Runtime workers through the authenticated path. From the CLI:
@ -186,13 +185,13 @@ Identity and trust records are intentionally not overwritten by default.
Rotate Server identity: Rotate Server identity:
```bash ```bash
yoi-workspace-server identity init --server-id server-main --replace yoi-server identity init --server-id server-main --replace
``` ```
After Server identity rotation, every Runtime that trusts that Server must be updated with the new Server public key: After Server identity rotation, every Runtime that trusts that Server must be updated with the new Server public key:
```bash ```bash
worker-runtime-rest-server trust-server add \ yoi-runtime trust-server add \
--server-id server-main \ --server-id server-main \
--public-key '<NEW_SERVER_PUBLIC_KEY>' \ --public-key '<NEW_SERVER_PUBLIC_KEY>' \
--replace --replace
@ -201,13 +200,13 @@ worker-runtime-rest-server trust-server add \
Rotate Runtime identity: Rotate Runtime identity:
```bash ```bash
worker-runtime-rest-server identity init --runtime-id runtime-main --replace yoi-runtime identity init --runtime-id runtime-main --replace
``` ```
After Runtime identity rotation, Server must be updated with the new Runtime public key: After Runtime identity rotation, Server must be updated with the new Runtime public key:
```bash ```bash
yoi-workspace-server trust-runtime add \ yoi-server trust-runtime add \
--runtime-id runtime-main \ --runtime-id runtime-main \
--base-url http://127.0.0.1:38800 \ --base-url http://127.0.0.1:38800 \
--public-key '<NEW_RUNTIME_PUBLIC_KEY>' \ --public-key '<NEW_RUNTIME_PUBLIC_KEY>' \
@ -219,13 +218,13 @@ yoi-workspace-server trust-runtime add \
Revoke a trusted Runtime on Server: Revoke a trusted Runtime on Server:
```bash ```bash
yoi-workspace-server trust-runtime revoke --runtime-id runtime-main yoi-server trust-runtime revoke --runtime-id runtime-main
``` ```
Remove a trusted Server from Runtime: Remove a trusted Server from Runtime:
```bash ```bash
worker-runtime-rest-server trust-server revoke --server-id server-main yoi-runtime trust-server revoke --server-id server-main
``` ```
## Troubleshooting ## Troubleshooting
@ -235,7 +234,7 @@ worker-runtime-rest-server trust-server revoke --server-id server-main
The Server DB contains trusted Runtime records, but the Server signing identity file does not exist. Run: The Server DB contains trusted Runtime records, but the Server signing identity file does not exist. Run:
```bash ```bash
yoi-workspace-server identity init --server-id server-main yoi-server identity init --server-id server-main
``` ```
If the identity was created in another environment, ensure the Server process is using the same Yoi data directory. If the identity was created in another environment, ensure the Server process is using the same Yoi data directory.
@ -245,8 +244,8 @@ If the identity was created in another environment, ensure the Server process is
Runtime only enables signed capability-token auth when both a Runtime identity and at least one trusted Server are present in its auth file. Check: Runtime only enables signed capability-token auth when both a Runtime identity and at least one trusted Server are present in its auth file. Check:
```bash ```bash
worker-runtime-rest-server identity show --json yoi-runtime identity show --json
worker-runtime-rest-server trust-server list --json yoi-runtime trust-server list --json
``` ```
Also confirm the Runtime process was started with the same `--fs-root` / `--fs-runtime-dir` used for setup. Also confirm the Runtime process was started with the same `--fs-root` / `--fs-runtime-dir` used for setup.
@ -256,8 +255,8 @@ Also confirm the Runtime process was started with the same `--fs-root` / `--fs-r
Confirm the `--runtime-id` registered on Server exactly matches the Runtime identity id: Confirm the `--runtime-id` registered on Server exactly matches the Runtime identity id:
```bash ```bash
worker-runtime-rest-server identity show --json yoi-runtime identity show --json
yoi-workspace-server trust-runtime list --json yoi-server trust-runtime list --json
``` ```
`RUNTIME_ID` is the token audience; mismatches are rejected by Runtime. `RUNTIME_ID` is the token audience; mismatches are rejected by Runtime.

View File

@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-iZaTREhL/aLixn67A1+Gi9opqh2j/yyFuGMyBBvuATM="; cargoHash = "sha256-R33Ty4414wGkqnwCt08zbDQbVu9ggMC5I3ZawgloNT0=";
depsExtraArgs = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,
@ -92,8 +92,8 @@ rustPlatform.buildRustPackage rec {
]; ];
postBuild = '' postBuild = ''
cargo build --offline --profile release -p yoi-workspace-server --bin yoi-workspace-server cargo build --offline --profile release -p yoi-workspace-server --bin yoi-server
cargo build --offline --profile release -p worker-runtime --bin worker-runtime-rest-server --features ws-server,fs-store cargo build --offline --profile release -p worker-runtime --bin yoi-runtime
''; '';
# The package check is a credential-free install smoke check below. Running the # The package check is a credential-free install smoke check below. Running the
@ -105,16 +105,16 @@ rustPlatform.buildRustPackage rec {
runHook preInstall runHook preInstall
yoi_bin=$(find . -type f -name yoi | head -n 1) yoi_bin=$(find . -type f -name yoi | head -n 1)
workspace_server_bin=$(find . -type f -name yoi-workspace-server | head -n 1) workspace_server_bin=$(find . -type f -name yoi-server | head -n 1)
worker_runtime_bin=$(find . -type f -name worker-runtime-rest-server | head -n 1) worker_runtime_bin=$(find . -type f -name yoi-runtime | head -n 1)
if [ -z "$yoi_bin" ] || [ -z "$workspace_server_bin" ] || [ -z "$worker_runtime_bin" ]; then if [ -z "$yoi_bin" ] || [ -z "$workspace_server_bin" ] || [ -z "$worker_runtime_bin" ]; then
echo "built binaries not found" >&2 echo "built binaries not found" >&2
find . -maxdepth 6 -type f \( -name yoi -o -name yoi-workspace-server -o -name worker-runtime-rest-server \) -print >&2 find . -maxdepth 6 -type f \( -name yoi -o -name yoi-server -o -name yoi-runtime \) -print >&2
exit 1 exit 1
fi fi
install -Dm755 "$yoi_bin" "$out/bin/yoi" install -Dm755 "$yoi_bin" "$out/bin/yoi"
install -Dm755 "$workspace_server_bin" "$out/bin/yoi-workspace-server" install -Dm755 "$workspace_server_bin" "$out/bin/yoi-server"
install -Dm755 "$worker_runtime_bin" "$out/bin/worker-runtime-rest-server" install -Dm755 "$worker_runtime_bin" "$out/bin/yoi-runtime"
runHook postInstall runHook postInstall
''; '';
@ -125,10 +125,10 @@ rustPlatform.buildRustPackage rec {
"$out/bin/yoi" worker --help >/dev/null "$out/bin/yoi" worker --help >/dev/null
test -x "$out/bin/yoi" test -x "$out/bin/yoi"
test -x "$out/bin/yoi-workspace-server" test -x "$out/bin/yoi-server"
test -x "$out/bin/worker-runtime-rest-server" test -x "$out/bin/yoi-runtime"
"$out/bin/yoi-workspace-server" --help >/dev/null "$out/bin/yoi-server" --help >/dev/null
"$out/bin/worker-runtime-rest-server" --help >/dev/null "$out/bin/yoi-runtime" --help >/dev/null
test ! -e "$out/bin/yoi-pod" test ! -e "$out/bin/yoi-pod"
test ! -e "$out/share/yoi/resources" test ! -e "$out/share/yoi/resources"
if "$out/bin/yoi" --session not-a-uuid 2>yoi.err; then if "$out/bin/yoi" --session not-a-uuid 2>yoi.err; then

View File

@ -1,14 +1,14 @@
# Workspace Backend local config template. # Workspace Backend local config template.
# #
# `yoi workspace init` copies this packaged template to # `yoi-server init` copies this packaged template to
# `.yoi/workspace-backend.local.toml` without overwriting an existing file. # `.yoi/workspace-backend.local.toml` without overwriting an existing file.
# The `.local` file is intentionally git-ignored. # The `.local` file is intentionally git-ignored.
# #
# Print the latest packaged template with: # Print the latest packaged template with:
# yoi workspace config default # yoi-server config default
# #
# Compare the local config with the latest packaged template with: # Compare the local config with the latest packaged template with:
# yoi workspace config diff # yoi-server config diff
# #
# Omit a key to use the built-in fallback. TOML has no `null`, so optional # Omit a key to use the built-in fallback. TOML has no `null`, so optional
# settings are represented by leaving the key commented out. # settings are represented by leaving the key commented out.

View File

@ -57,8 +57,8 @@ usage() {
Usage: $(basename "$0") <start|stop|restart|status> Usage: $(basename "$0") <start|stop|restart|status>
Manage the local Yoi development stack for this checkout: Manage the local Yoi development stack for this checkout:
runtime target/debug/worker-runtime-rest-server --bind $RUNTIME_BIND runtime target/debug/yoi-runtime --bind $RUNTIME_BIND
backend target/debug/yoi-workspace-server serve --listen $BACKEND_LISTEN backend target/debug/yoi-server serve --listen $BACKEND_LISTEN
frontend deno run -A npm:vite@7.2.7 dev --host $FRONTEND_HOST --port $FRONTEND_PORT (cwd: web/workspace) frontend deno run -A npm:vite@7.2.7 dev --host $FRONTEND_HOST --port $FRONTEND_PORT (cwd: web/workspace)
Actions: Actions:
@ -307,7 +307,7 @@ build_runtime_backend() {
log "building runtime binary" log "building runtime binary"
( (
cd "$ROOT_DIR" cd "$ROOT_DIR"
run_cargo build -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server run_cargo build -p worker-runtime --bin yoi-runtime
) )
else else
log "runtime disabled by YOI_DEV_RUNTIME_ENABLED=0; skipping runtime build" log "runtime disabled by YOI_DEV_RUNTIME_ENABLED=0; skipping runtime build"
@ -316,7 +316,7 @@ build_runtime_backend() {
log "building backend binary" log "building backend binary"
( (
cd "$ROOT_DIR" cd "$ROOT_DIR"
run_cargo build -p yoi-workspace-server --bin yoi-workspace-server run_cargo build -p yoi-workspace-server --bin yoi-server
) )
} }
@ -327,7 +327,7 @@ start_runtime() {
fi fi
local port runtime_bin local port runtime_bin
port="$(port_for_addr "$RUNTIME_BIND")" port="$(port_for_addr "$RUNTIME_BIND")"
runtime_bin="$ROOT_DIR/target/debug/worker-runtime-rest-server" runtime_bin="$ROOT_DIR/target/debug/yoi-runtime"
if [[ ! -x "$runtime_bin" ]]; then if [[ ! -x "$runtime_bin" ]]; then
printf 'runtime binary not found or not executable: %s\n' "$runtime_bin" >&2 printf 'runtime binary not found or not executable: %s\n' "$runtime_bin" >&2
return 1 return 1
@ -339,7 +339,7 @@ start_runtime() {
start_backend() { start_backend() {
local port backend_bin local port backend_bin
port="$(port_for_addr "$BACKEND_LISTEN")" port="$(port_for_addr "$BACKEND_LISTEN")"
backend_bin="$ROOT_DIR/target/debug/yoi-workspace-server" backend_bin="$ROOT_DIR/target/debug/yoi-server"
if [[ ! -x "$backend_bin" ]]; then if [[ ! -x "$backend_bin" ]]; then
printf 'backend binary not found or not executable: %s\n' "$backend_bin" >&2 printf 'backend binary not found or not executable: %s\n' "$backend_bin" >&2
return 1 return 1

View File

@ -3,7 +3,7 @@
SvelteKit static SPA for the Yoi workspace control plane. SvelteKit static SPA for the Yoi workspace control plane.
The frontend is intentionally static. Workspace authority, validation, and API The frontend is intentionally static. Workspace authority, validation, and API
behavior live in the Rust `yoi-workspace-server` backend. behavior live in the Rust `yoi-server` backend.
## Development ## Development
@ -30,12 +30,12 @@ printed by `deno task dev`.
If you want to run the backend from the repository root instead: If you want to run the backend from the repository root instead:
```bash ```bash
cargo run -p yoi-workspace-server -- serve --listen 127.0.0.1:8787 cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787
``` ```
The backend reads Workspace records from the Yoi server DB at The backend reads Workspace records from the Yoi server DB at
`<data_dir>/server/server.db`. Run `<data_dir>/server/server.db`. Run
`cargo run -p yoi-workspace-server -- init --workspace .` first when the server `cargo run -p yoi-workspace-server --bin yoi-server -- init --workspace .` first when the server
DB has not been initialized. DB has not been initialized.
## Static build ## Static build

View File

@ -4,7 +4,7 @@
"tasks": { "tasks": {
"install": "deno install", "install": "deno install",
"dev": "deno run -A npm:vite@7.2.7 dev", "dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.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/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts", "test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.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/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts",
"build": "deno run -A npm:vite@7.2.7 build", "build": "deno run -A npm:vite@7.2.7 build",