diff --git a/Cargo.lock b/Cargo.lock index eb5651f6c..01722f689 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6274,8 +6274,10 @@ name = "yoi-e2e" version = "0.0.0" dependencies = [ "libc", + "reqwest", "serde", "serde_json", + "sha2 0.11.0", "tempfile", ] diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index f69c61927..f87e62dab 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -1,6 +1,6 @@ use reqwest::Method; -use serde::Serialize; use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; use server_api::{ BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, @@ -22,6 +22,21 @@ use crate::{BackendApiClient, BackendWorkspaceClientError}; const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000; +#[derive(Debug, Deserialize)] +struct BackendObjectiveListResponse { + workspace_id: String, + limit: usize, + items: Vec, + invalid_records: Vec, + record_authority: String, +} + +#[derive(Debug, Deserialize)] +struct BackendInvalidProjectRecord { + label: String, + reason: String, +} + /// Workspace-scoped Backend client for Ticket and Objective product state. /// /// Construction requires both the selected Backend URL and Workspace identity. @@ -177,7 +192,23 @@ impl BackendWorkspaceProductClient { &self, limit: usize, ) -> Result, BackendWorkspaceClientError> { - self.get_json(&format!("/objectives?limit={limit}")) + let response: BackendObjectiveListResponse = + self.get_json(&format!("/objectives?limit={limit}"))?; + Ok(ListResponse { + workspace_id: response.workspace_id, + limit: response.limit, + items: response.items, + source: response.record_authority, + diagnostics: response + .invalid_records + .into_iter() + .map(|invalid| server_api::Diagnostic { + code: "invalid_objective_record".to_string(), + severity: server_api::DiagnosticSeverity::Warning, + message: format!("{}: {}", invalid.label, invalid.reason), + }) + .collect(), + }) } pub fn show_objective(&self, id: &str) -> Result { @@ -794,7 +825,7 @@ mod tests { #[test] fn objective_list_uses_workspace_scoped_backend_route() { - let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#; + let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"invalid_records":[{"label":"broken.md","reason":"invalid frontmatter"}],"record_authority":"sqlite"}"#; let (base_url, request, handle) = one_response_server("200 OK", body); let client = BackendWorkspaceProductClient::new_with_access_token( base_url, @@ -806,6 +837,13 @@ mod tests { let response = client.list_objectives(1_000).unwrap(); assert!(response.items.is_empty()); + assert_eq!(response.source, "sqlite"); + assert_eq!(response.diagnostics.len(), 1); + assert_eq!(response.diagnostics[0].code, "invalid_objective_record"); + assert_eq!( + response.diagnostics[0].message, + "broken.md: invalid frontmatter" + ); let request = request.recv().unwrap(); assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 ")); assert!(request.contains("authorization: Bearer test-backend-token\r\n")); diff --git a/crates/tui/src/backend_dashboard.rs b/crates/tui/src/backend_dashboard.rs index 7fcdc36cb..fc8a88ae5 100644 --- a/crates/tui/src/backend_dashboard.rs +++ b/crates/tui/src/backend_dashboard.rs @@ -2,7 +2,7 @@ use std::io; use std::time::Duration; use client::{BackendWorkspaceProductClient, ObjectiveSummary}; -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::{ Frame, Terminal, backend::CrosstermBackend, @@ -79,8 +79,26 @@ async fn run_loop( app: &mut BackendDashboard, client: BackendWorkspaceProductClient, ) -> Result<(), Box> { + let mut first_frame = true; + let mut initial_snapshot_pending = false; loop { terminal.draw(|frame| draw(frame, app))?; + if first_frame { + first_frame = false; + #[cfg(feature = "e2e-test")] + { + crate::e2e_observer::emit( + "backend_dashboard", + "panel_ready", + serde_json::json!({ "workspace_id": app.workspace_id }), + ); + initial_snapshot_pending = true; + } + } else if initial_snapshot_pending { + #[cfg(feature = "e2e-test")] + emit_e2e_snapshot(app, "backend_dashboard_content_ready"); + initial_snapshot_pending = false; + } if !event::poll(Duration::from_millis(100))? { continue; } @@ -91,21 +109,50 @@ async fn run_loop( continue; } match key.code { - KeyCode::Char('q') | KeyCode::Esc => return Ok(()), + KeyCode::Char('q') | KeyCode::Esc => { + #[cfg(feature = "e2e-test")] + crate::e2e_observer::emit( + "backend_dashboard", + "quit_requested", + serde_json::json!({}), + ); + return Ok(()); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + #[cfg(feature = "e2e-test")] + crate::e2e_observer::emit( + "backend_dashboard", + "quit_requested", + serde_json::json!({}), + ); + return Ok(()); + } KeyCode::Tab | KeyCode::BackTab => { app.focus = match app.focus { Focus::Tickets => Focus::Objectives, Focus::Objectives => Focus::Tickets, }; + #[cfg(feature = "e2e-test")] + emit_e2e_snapshot(app, "backend_dashboard_selection_changed"); + } + KeyCode::Down | KeyCode::Char('j') => { + app.select_next(); + #[cfg(feature = "e2e-test")] + emit_e2e_snapshot(app, "backend_dashboard_selection_changed"); + } + KeyCode::Up | KeyCode::Char('k') => { + app.select_previous(); + #[cfg(feature = "e2e-test")] + emit_e2e_snapshot(app, "backend_dashboard_selection_changed"); } - KeyCode::Down | KeyCode::Char('j') => app.select_next(), - KeyCode::Up | KeyCode::Char('k') => app.select_previous(), KeyCode::Char('r') => match load(&client).await { Ok((tickets, objectives)) => { app.tickets = tickets; app.objectives = objectives; app.clamp_selection(); app.status = "Reloaded from Backend authority".to_string(); + #[cfg(feature = "e2e-test")] + emit_e2e_snapshot(app, "backend_dashboard_reloaded"); } Err(error) => app.status = format!("Backend reload failed: {error}"), }, @@ -144,6 +191,51 @@ async fn run_loop( } } +#[cfg(feature = "e2e-test")] +fn emit_e2e_snapshot(app: &BackendDashboard, event: &'static str) { + let focus = match app.focus { + Focus::Tickets => "tickets", + Focus::Objectives => "objectives", + }; + let tickets = app + .tickets + .iter() + .map(|ticket| { + serde_json::json!({ + "id": ticket.id, + "resource_key": ticket.resource_key, + "title": ticket.title, + "workflow_state": ticket.workflow_state.as_str(), + }) + }) + .collect::>(); + let objectives = app + .objectives + .iter() + .map(|objective| { + serde_json::json!({ + "id": objective.id, + "resource_key": objective.resource_key, + "title": objective.title, + "state": objective.state, + }) + }) + .collect::>(); + crate::e2e_observer::emit( + "backend_dashboard", + event, + serde_json::json!({ + "workspace_id": app.workspace_id, + "focus": focus, + "selected_ticket_id": app.selected_ticket_id(), + "selected_objective_id": app.objectives.get(app.selected_objective).map(|objective| &objective.id), + "tickets": tickets, + "objectives": objectives, + "status": app.status, + }), + ); +} + impl BackendDashboard { fn select_next(&mut self) { match self.focus { diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index a3459b7c7..35ea1438e 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -623,6 +623,16 @@ fn read_terminal_events(stop: Arc, tx: mpsc::UnboundedSender Result<(), Box> { + let mut terminal = enter_fullscreen()?; + let result = run_e2e_rewind_fixture(&mut terminal, worker_name).await; + let leave_result = leave_fullscreen(&mut terminal); + result?; + leave_result?; + Ok(()) +} + #[cfg(feature = "e2e-test")] async fn run_e2e_rewind_fixture( terminal: &mut ConsoleTerminal, @@ -637,6 +647,8 @@ async fn run_e2e_rewind_fixture( entries: Vec::new(), }, state: WorkerStatus::Idle.into(), + in_flight: protocol::InFlightSnapshot::default(), + internal_workers: Vec::new(), greeting: Greeting { worker_name: worker_name.clone(), cwd: workspace_root.display().to_string(), diff --git a/crates/tui/src/e2e_observer.rs b/crates/tui/src/e2e_observer.rs index 77eceb609..91a81a8e2 100644 --- a/crates/tui/src/e2e_observer.rs +++ b/crates/tui/src/e2e_observer.rs @@ -2,12 +2,11 @@ use std::fs::{File, OpenOptions}; use std::io::Write; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{SystemTime, UNIX_EPOCH}; use serde::Serialize; const EVENT_PATH_ENV: &str = "YOI_TUI_TEST_EVENTS"; -const HOLD_BACKGROUND_TASK_ENV: &str = "YOI_TUI_TEST_HOLD_BACKGROUND_TASK"; static EVENT_WRITER: OnceLock>> = OnceLock::new(); @@ -44,25 +43,6 @@ where } } -pub(crate) async fn hold_background_task_if_requested(task: &'static str) { - let requested = std::env::var(HOLD_BACKGROUND_TASK_ENV).unwrap_or_default(); - if !requested - .split(',') - .map(str::trim) - .any(|requested| requested == task) - { - return; - } - emit( - "panel", - "background_task_hold_started", - serde_json::json!({ "task": task }), - ); - loop { - tokio::time::sleep(Duration::from_millis(25)).await; - } -} - fn open_event_writer() -> Option> { let path = std::env::var_os(EVENT_PATH_ENV).map(PathBuf::from)?; if let Some(parent) = path.parent() { diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 437e554b2..c9c48bd8e 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -137,7 +137,51 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { } let mut terminal_mode = TerminalModeGuard::new(); - let result = match mode { + #[cfg(feature = "e2e-test")] + let result = if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() { + let worker_name = match &mode { + LaunchMode::Spawn { worker_name, .. } => worker_name + .clone() + .unwrap_or_else(|| "e2e-rewind".to_string()), + _ => "e2e-rewind".to_string(), + }; + console::run_e2e_rewind(worker_name).await + } else { + launch_mode(target.as_ref(), mode, &workspace_root).await + }; + #[cfg(not(feature = "e2e-test"))] + let result = launch_mode(target.as_ref(), mode, &workspace_root).await; + + // Always restore the terminal first so any pending eprintln below + // shows up cleanly in scrollback rather than inside an active + // alternate-screen buffer. + #[cfg(feature = "e2e-test")] + e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({})); + let _ = terminal_mode.restore(); + #[cfg(feature = "e2e-test")] + e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({})); + + match result { + Ok(()) => { + #[cfg(feature = "e2e-test")] + e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "success" })); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("yoi: {e}"); + #[cfg(feature = "e2e-test")] + e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" })); + ExitCode::FAILURE + } + } +} + +async fn launch_mode( + target: &dyn Target, + mode: LaunchMode, + workspace_root: &std::path::Path, +) -> Result<(), Box> { + match mode { LaunchMode::Spawn { worker_name, profile, @@ -145,7 +189,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { Ok(Some(selection)) => match target.spawn_worker() { Ok(spawn) => { console::run_standalone( - workspace_root.clone(), + workspace_root.to_path_buf(), spawn.state_dir, Some(selection.worker_name), Some(selection.profile), @@ -158,7 +202,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { Err(error) => Err(Box::new(error) as Box), }, LaunchMode::StandaloneResume { include_all } => { - match standalone_picker::pick(target.as_ref(), include_all) { + match standalone_picker::pick(target, include_all) { Ok(Some(intent)) => console::run_standalone_restore(intent).await, Ok(None) => Ok(()), Err(error) => Err(Box::new(error) as Box), @@ -195,28 +239,5 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { } Err(e) => Err(Box::new(e) as Box), }, - }; - - // Always restore the terminal first so any pending eprintln below - // shows up cleanly in scrollback rather than inside an active - // alternate-screen buffer. - #[cfg(feature = "e2e-test")] - e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({})); - let _ = terminal_mode.restore(); - #[cfg(feature = "e2e-test")] - e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({})); - - match result { - Ok(()) => { - #[cfg(feature = "e2e-test")] - e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "success" })); - ExitCode::SUCCESS - } - Err(e) => { - eprintln!("yoi: {e}"); - #[cfg(feature = "e2e-test")] - e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" })); - ExitCode::FAILURE - } } } diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index e07dde7ef..3f4a327c3 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -41,7 +41,7 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature ring.workspace = true tar.workspace = true thiserror = { workspace = true } -tokio = { workspace = true, features = ["net", "process", "rt", "sync", "time"] } +tokio = { workspace = true, features = ["net", "process", "rt", "signal", "sync", "time"] } tracing.workspace = true tracing-subscriber.workspace = true toml.workspace = true diff --git a/crates/worker-runtime/src/fs_store.rs b/crates/worker-runtime/src/fs_store.rs index ffb129bfa..b97101201 100644 --- a/crates/worker-runtime/src/fs_store.rs +++ b/crates/worker-runtime/src/fs_store.rs @@ -123,8 +123,8 @@ impl FsRuntimeStore { })?; let owner_lock = acquire_runtime_store_owner_lock(&root)?; recover_runtime_store_migration(&root)?; - let existed = root.exists(); - if existed && !root.is_dir() { + let root_existed = root.exists(); + if root_existed && !root.is_dir() { return Err(RuntimeError::StoreCorrupt { operation: "open runtime store", path: root, @@ -132,17 +132,22 @@ impl FsRuntimeStore { }); } - if !existed { + if !root_existed { fs::create_dir_all(&root).map_err(|source| RuntimeError::StoreIo { operation: "create runtime store root", path: root.clone(), source, })?; } + let snapshot_exists = root.join(RUNTIME_FILE).is_file(); + if root_existed && !snapshot_exists { + validate_fresh_runtime_store_layout(&root)?; + } - fs::create_dir_all(root.join(WORKERS_DIR)).map_err(|source| RuntimeError::StoreIo { + let workers_dir = root.join(WORKERS_DIR); + fs::create_dir_all(&workers_dir).map_err(|source| RuntimeError::StoreIo { operation: "create runtime store", - path: root.join(WORKERS_DIR), + path: workers_dir, source, })?; let legacy_events = root.join("events.jsonl"); @@ -158,14 +163,14 @@ impl FsRuntimeStore { } } - if existed { + if snapshot_exists { migrate_runtime_store(&root, runtime_id)?; } let store = Self { root, _owner_lock: Some(owner_lock), }; - let state = if existed { + let state = if snapshot_exists { Some(store.load_runtime_state()?) } else { None @@ -343,6 +348,38 @@ impl FsRuntimeStore { } } +fn validate_fresh_runtime_store_layout(root: &Path) -> Result<(), RuntimeError> { + let entries = fs::read_dir(root).map_err(|source| RuntimeError::StoreIo { + operation: "inspect uninitialized runtime store", + path: root.to_path_buf(), + source, + })?; + for entry in entries { + let entry = entry.map_err(|source| RuntimeError::StoreIo { + operation: "inspect uninitialized runtime store", + path: root.to_path_buf(), + source, + })?; + let file_type = entry.file_type().map_err(|source| RuntimeError::StoreIo { + operation: "inspect uninitialized runtime store entry", + path: entry.path(), + source, + })?; + if entry.file_name() == "auth.toml" && file_type.is_file() { + continue; + } + return Err(RuntimeError::StoreCorrupt { + operation: "open runtime store", + path: root.to_path_buf(), + message: format!( + "unexpected persistence `{}` exists without a Runtime snapshot", + entry.file_name().to_string_lossy() + ), + }); + } + Ok(()) +} + fn acquire_runtime_store_owner_lock( root: &Path, ) -> Result, RuntimeError> { @@ -2137,6 +2174,72 @@ mod tests { document["schema_version"].as_u64().unwrap() } + #[test] + fn auth_initialized_directory_without_snapshot_is_a_fresh_store() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("runtime"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("auth.toml"), b"fixture auth material").unwrap(); + + let opened = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap(); + + assert!(opened.state.is_none()); + assert!(root.join(WORKERS_DIR).is_dir()); + assert_eq!( + fs::read(root.join("auth.toml")).unwrap(), + b"fixture auth material" + ); + } + + #[test] + fn snapshotless_directory_rejects_non_auth_persistence() { + for unexpected in ["events.jsonl", "unknown.json"] { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("runtime"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("auth.toml"), b"fixture auth material").unwrap(); + fs::write(root.join(unexpected), b"must remain").unwrap(); + + let error = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap_err(); + + assert!(matches!( + error, + RuntimeError::StoreCorrupt { path, .. } if path == root + )); + assert_eq!(fs::read(root.join(unexpected)).unwrap(), b"must remain"); + assert!(!root.join(WORKERS_DIR).exists()); + } + } + + #[test] + fn orphan_archive_without_runtime_snapshot_is_rejected() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("runtime"); + fs::create_dir_all(root.join(ORPHANED_WORKERS_DIR).join("worker-a")).unwrap(); + + let error = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap_err(); + + assert!(matches!( + error, + RuntimeError::StoreCorrupt { path, .. } if path == root + )); + assert!(root.join(ORPHANED_WORKERS_DIR).join("worker-a").is_dir()); + } + + #[test] + fn worker_records_without_runtime_snapshot_are_rejected() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("runtime"); + fs::create_dir_all(root.join(WORKERS_DIR).join("orphan")).unwrap(); + + let error = FsRuntimeStore::open_or_create(root.clone(), "runtime-test").unwrap_err(); + + assert!(matches!( + error, + RuntimeError::StoreCorrupt { path, .. } if path == root + )); + } + #[test] fn runtime_store_owner_lock_child_probe() { let Some(root) = std::env::var_os("YOI_TEST_RUNTIME_STORE_LOCK_ROOT") else { diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 5462a09f5..1ffdae919 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -139,6 +139,22 @@ pub enum RuntimeHttpStoreSelection { }, } +pub async fn serve_runtime_http_with_shutdown( + runtime: Runtime, + listener: TcpListener, + local_token: Option, + shutdown: F, +) -> Result<(), RuntimeHttpServerError> +where + F: std::future::Future + Send + 'static, +{ + let local_token = local_token.ok_or(RuntimeHttpServerError::AuthRequired)?; + axum::serve(listener, runtime_http_router(runtime, local_token)) + .with_graceful_shutdown(shutdown) + .await?; + Ok(()) +} + /// Serve an existing Runtime on a pre-bound listener. pub async fn serve_runtime_http( runtime: Runtime, @@ -150,6 +166,25 @@ pub async fn serve_runtime_http( Ok(()) } +pub async fn serve_runtime_http_with_workspace_auth_shutdown( + runtime: Runtime, + listener: TcpListener, + local_token: Option, + workspace_auth: WorkspaceRuntimeHttpAuth, + shutdown: F, +) -> Result<(), RuntimeHttpServerError> +where + F: std::future::Future + Send + 'static, +{ + axum::serve( + listener, + runtime_http_router_with_optional_auth(runtime, local_token, Some(workspace_auth)), + ) + .with_graceful_shutdown(shutdown) + .await?; + Ok(()) +} + pub async fn serve_runtime_http_with_workspace_auth( runtime: Runtime, listener: TcpListener, diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index 835efde8b..c09b0f149 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -75,6 +75,23 @@ fn init_serve_tracing() { .try_init(); } +async fn shutdown_signal() { + #[cfg(unix)] + { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = terminate.recv() => {}, + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + fn run() -> Result<(), ProcessError> { let args = env::args().skip(1).collect::>(); if matches!(args.first().map(String::as_str), Some("migrate")) { @@ -104,11 +121,12 @@ fn run() -> Result<(), ProcessError> { "yoi-runtime listening on {local_addr}; intended client is a trusted backend/proxy, not a browser" ); let server = if let Some(workspace_auth) = workspace_http_auth { - worker_runtime::http_server::serve_runtime_http_with_workspace_auth( + worker_runtime::http_server::serve_runtime_http_with_workspace_auth_shutdown( worker_runtime, listener, config.http.local_token, workspace_auth, + shutdown_signal(), ) .await } else { @@ -117,10 +135,11 @@ fn run() -> Result<(), ProcessError> { "Runtime HTTP server requires Workspace issuer auth or --local-token".to_owned(), ) })?; - worker_runtime::http_server::serve_runtime_http( + worker_runtime::http_server::serve_runtime_http_with_shutdown( worker_runtime, listener, Some(local_token), + shutdown_signal(), ) .await }; diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 39b41e9c4..5b7330acc 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -38,7 +38,7 @@ thiserror.workspace = true ticket.workspace = true memory.workspace = true merge-request.workspace = true -tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] } +tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tower.workspace = true tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] } worker.workspace = true diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index a1ebc50e5..efab8ecbb 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -48,6 +48,7 @@ pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegist pub use server::{ AuthConfig, ServerConfig, WorkspaceApi, WorkspaceServerApi, build_router, build_workspace_server_router, serve, serve_workspace_catalog, + serve_workspace_catalog_with_shutdown, }; pub use server_api::{ GitCommitSummary, GitRemoteSummary, GitRepositorySummary, RepositorySummary, diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index 08a3d9c36..b712941e8 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -9,18 +9,19 @@ use yoi_workspace_server::hosts::{ EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig, is_loopback_runtime_origin, }; use yoi_workspace_server::store::{ - SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, - WorkspaceRuntimeBindingState, + AccountRecord, ApiTokenRecord, SqliteWorkspaceStore, UserRecord, + WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingState, }; use yoi_workspace_server::{ ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile, - WorkspaceRecord, serve_workspace_catalog, + WorkspaceRecord, serve_workspace_catalog_with_shutdown, }; #[derive(Debug)] enum Command { Serve(ServeOptions), Migrate(MigrateOptions), + BootstrapAuth(BootstrapAuthOptions), Skills(SkillsCommand), Help, } @@ -38,6 +39,13 @@ struct MigrateOptions { help: bool, } +#[derive(Debug)] +struct BootstrapAuthOptions { + handle: String, + display_name: Option, + label: Option, +} + #[derive(Debug)] struct SkillWorkspaceOptions { workspace_id: String, @@ -77,6 +85,7 @@ async fn run() -> Result<(), Box> { match parse_command(&args)? { Command::Serve(options) => run_serve(options).await, Command::Migrate(options) => run_migrate(options), + Command::BootstrapAuth(options) => run_bootstrap_auth(options), Command::Skills(command) => run_skills(command), Command::Help => Ok(()), } @@ -90,6 +99,7 @@ fn parse_command(args: &[String]) -> Result { match command.as_str() { "migrate" => parse_migrate_options(rest).map(Command::Migrate), + "bootstrap-auth" => parse_bootstrap_auth_options(rest).map(Command::BootstrapAuth), "skills" => parse_skills_command(rest), "serve" => { if rest.iter().any(|arg| arg == "--help" || arg == "-h") { @@ -103,7 +113,7 @@ fn parse_command(args: &[String]) -> Result { Ok(Command::Help) } other => Err(CliError(format!( - "unknown command `{other}`; expected `migrate`, `skills`, or `serve`" + "unknown command `{other}`; expected `bootstrap-auth`, `migrate`, `skills`, or `serve`" ))), } } @@ -214,6 +224,131 @@ fn run_migrate(options: MigrateOptions) -> Result<(), Box Ok(()) } +fn run_bootstrap_auth(options: BootstrapAuthOptions) -> Result<(), Box> { + let database_path = ServerConfig::default_server_database_path(); + if let Some(parent) = database_path.parent() { + std::fs::create_dir_all(parent)?; + } + let store = SqliteWorkspaceStore::open(&database_path)?; + if !store.list_workspaces()?.is_empty() { + return Err(Box::new(CliError( + "bootstrap-auth is available only before the first Workspace is created".to_string(), + ))); + } + + let handle = yoi_workspace_server::auth::normalize_handle(&options.handle)?; + let display_name = options + .display_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&handle) + .to_string(); + let now = yoi_workspace_server::auth::now_rfc3339(); + let user = match store.any_user()? { + Some(user) if user.handle == handle => user, + Some(_) => { + return Err(Box::new(CliError( + "bootstrap-auth refuses to replace an existing bootstrap user".to_string(), + ))); + } + None => { + let account_id = yoi_workspace_server::auth::new_id("acct-user"); + store.upsert_account(&AccountRecord { + account_id: account_id.clone(), + kind: "user".to_string(), + handle: handle.clone(), + display_name: display_name.clone(), + created_at: now.clone(), + updated_at: now.clone(), + })?; + let user = UserRecord { + user_id: yoi_workspace_server::auth::new_id("user"), + account_id, + handle, + display_name, + created_at: now.clone(), + updated_at: now.clone(), + }; + store.upsert_user(&user)?; + user + } + }; + + let access_token = yoi_workspace_server::auth::mint_secret("yoi_api"); + store.create_api_token(&ApiTokenRecord { + token_id: yoi_workspace_server::auth::new_id("api-token"), + token_hash: yoi_workspace_server::auth::token_hash(&access_token), + user_id: user.user_id.clone(), + label: options + .label + .unwrap_or_else(|| "initial host setup".to_string()), + created_at: now, + expires_at: None, + revoked_at: None, + last_used_at: None, + })?; + println!( + "{}", + serde_json::to_string(&serde_json::json!({ + "access_token": access_token, + "token_type": "bearer", + "user": { + "user_id": user.user_id, + "account_id": user.account_id, + "handle": user.handle, + "display_name": user.display_name, + } + }))? + ); + Ok(()) +} + +fn parse_bootstrap_auth_options(args: &[String]) -> Result { + let mut handle = None; + let mut display_name = None; + let mut label = None; + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + let (field, target) = match arg.as_str() { + "--handle" => ("--handle", &mut handle), + "--display-name" => ("--display-name", &mut display_name), + "--label" => ("--label", &mut label), + _ if arg.starts_with("--handle=") => { + handle = Some(value_after_equals(arg, "--handle")?.to_string()); + index += 1; + continue; + } + _ if arg.starts_with("--display-name=") => { + display_name = Some(value_after_equals(arg, "--display-name")?.to_string()); + index += 1; + continue; + } + _ if arg.starts_with("--label=") => { + label = Some(value_after_equals(arg, "--label")?.to_string()); + index += 1; + continue; + } + other => return Err(CliError(format!("unknown bootstrap-auth option `{other}`"))), + }; + index += 1; + let value = args + .get(index) + .ok_or_else(|| CliError(format!("{field} requires a value")))?; + if target.replace(value.clone()).is_some() { + return Err(CliError(format!("{field} must be provided only once"))); + } + index += 1; + } + let handle = handle.ok_or_else(|| CliError("bootstrap-auth requires --handle".to_string()))?; + Ok(BootstrapAuthOptions { + handle, + display_name, + label, + }) +} + fn init_serve_tracing() { let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); @@ -226,6 +361,23 @@ fn init_serve_tracing() { .try_init(); } +async fn shutdown_signal() { + #[cfg(unix)] + { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = terminate.recv() => {}, + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + async fn run_serve(options: ServeOptions) -> Result<(), Box> { init_serve_tracing(); let database_path = ServerConfig::default_server_database_path(); @@ -284,7 +436,8 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box Result { fn print_help() { println!( - "yoi-server\n\nUsage:\n yoi-server migrate [--dry-run] [--database ]\n yoi-server skills [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" + "yoi-server\n\nUsage:\n yoi-server bootstrap-auth --handle [--display-name ] [--label