diff --git a/Cargo.lock b/Cargo.lock index 2dd520bc..8894e0be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6142,6 +6142,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "ts-rs", "workdir", ] @@ -6215,6 +6216,7 @@ dependencies = [ "toml", "tui", "worker", + "workspace-api", ] [[package]] diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index b92e79ab..bb5f92a3 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -9,7 +9,7 @@ protocol = { workspace = true } manifest = { workspace = true } ticket = { workspace = true } futures = { workspace = true } -reqwest = { version = "0.13", default-features = false, features = ["json", "native-tls"] } +reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index c2efc1bd..4ea7a11a 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -16,6 +16,7 @@ pub mod spawn; pub mod target; pub mod ticket_role; mod worker_client; +mod workspace_product; pub use backend_auth::{ BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse, @@ -36,9 +37,9 @@ pub use backend_workspace::{ }; pub use runtime_command::WorkerRuntimeCommand; pub use target::{ - BackendTarget, Dashboard, LocalTarget, Target, TargetError, TargetKind, WorkerByName, - WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerResume, - WorkerSpawn, + BackendTarget, Dashboard, LocalTarget, ResolvedTarget, Target, TargetError, TargetKind, + WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, + WorkerResume, WorkerSpawn, }; pub use spawn::{ @@ -52,3 +53,5 @@ pub use ticket_role::{ plan_ticket_role_launch_with_config, }; pub use worker_client::WorkerClient; +pub use workspace_api::{ObjectiveDetail, ObjectiveSummary}; +pub use workspace_product::BackendWorkspaceProductClient; diff --git a/crates/client/src/target.rs b/crates/client/src/target.rs index 1234d459..1b98819f 100644 --- a/crates/client/src/target.rs +++ b/crates/client/src/target.rs @@ -8,6 +8,24 @@ pub enum TargetKind { Backend, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedTarget { + Local, + Backend { + base_url: String, + workspace_id: String, + }, +} + +impl ResolvedTarget { + pub fn kind(&self) -> TargetKind { + match self { + Self::Local => TargetKind::Local, + Self::Backend { .. } => TargetKind::Backend, + } + } +} + impl fmt::Display for TargetKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -104,8 +122,14 @@ pub struct WorkerResume { } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Dashboard { - pub runtime_command: WorkerRuntimeCommand, +pub enum Dashboard { + Local { + runtime_command: WorkerRuntimeCommand, + }, + Backend { + base_url: String, + workspace_id: String, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -156,6 +180,13 @@ impl std::error::Error for TargetError {} pub trait Target: fmt::Debug + Send + Sync { fn kind(&self) -> TargetKind; + /// Resolve the target once for Workspace product-state operations. + /// + /// Backend targets must carry an explicit Workspace identity. Callers use + /// this value instead of rediscovering Backend/local authority from cwd or + /// process configuration after command dispatch. + fn resolve(&self) -> Result; + fn spawn_worker(&self) -> Result; fn worker_by_name(&self) -> Result; @@ -177,6 +208,10 @@ impl Target for LocalTarget { TargetKind::Local } + fn resolve(&self) -> Result { + Ok(ResolvedTarget::Local) + } + fn spawn_worker(&self) -> Result { Ok(WorkerSpawn { runtime_command: self.runtime_command()?, @@ -196,7 +231,7 @@ impl Target for LocalTarget { } fn dashboard(&self) -> Result { - Ok(Dashboard { + Ok(Dashboard::Local { runtime_command: self.runtime_command()?, }) } @@ -231,6 +266,19 @@ impl Target for BackendTarget { TargetKind::Backend } + fn resolve(&self) -> Result { + let workspace_id = self.workspace_id.clone().ok_or_else(|| { + TargetError::invalid( + self.kind(), + "workspace selection is required for Backend product-state operations", + ) + })?; + Ok(ResolvedTarget::Backend { + base_url: self.base_url.clone(), + workspace_id, + }) + } + fn spawn_worker(&self) -> Result { Err(TargetError::unsupported("Worker spawn", self.kind())) } @@ -247,7 +295,16 @@ impl Target for BackendTarget { } fn dashboard(&self) -> Result { - Err(TargetError::unsupported("Dashboard", self.kind())) + match self.resolve()? { + ResolvedTarget::Backend { + base_url, + workspace_id, + } => Ok(Dashboard::Backend { + base_url, + workspace_id, + }), + ResolvedTarget::Local => unreachable!("BackendTarget cannot resolve as Local"), + } } fn list_workers(&self, request: WorkerListRequest) -> Result { @@ -287,6 +344,63 @@ impl Target for BackendTarget { mod tests { use super::*; + #[test] + fn backend_target_resolves_workspace_scoped_product_state_authority() { + let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a")); + + assert_eq!( + target.resolve().unwrap(), + ResolvedTarget::Backend { + base_url: "http://127.0.0.1:8787".to_string(), + workspace_id: "workspace-a".to_string(), + } + ); + } + + #[test] + fn backend_target_rejects_product_state_resolution_without_workspace() { + let target = BackendTarget::new("http://127.0.0.1:8787", None::); + + assert!( + target + .resolve() + .unwrap_err() + .to_string() + .contains("workspace selection is required") + ); + } + + #[test] + fn local_target_resolves_local_product_state_authority() { + assert_eq!(LocalTarget::new().resolve().unwrap(), ResolvedTarget::Local); + } + + #[test] + fn backend_target_builds_workspace_scoped_dashboard() { + let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a")); + + assert_eq!( + target.dashboard().unwrap(), + Dashboard::Backend { + base_url: "http://127.0.0.1:8787".to_string(), + workspace_id: "workspace-a".to_string(), + } + ); + } + + #[test] + fn backend_target_rejects_dashboard_without_workspace_selection() { + let target = BackendTarget::new("http://127.0.0.1:8787", None::); + + assert!( + target + .dashboard() + .unwrap_err() + .to_string() + .contains("workspace selection is required") + ); + } + #[test] fn backend_target_builds_worker_list() { let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a")); diff --git a/crates/client/src/ticket_role.rs b/crates/client/src/ticket_role.rs index 21f80f19..687800cc 100644 --- a/crates/client/src/ticket_role.rs +++ b/crates/client/src/ticket_role.rs @@ -743,6 +743,7 @@ mod tests { }, status: WorkerStatus::Idle, in_flight: protocol::InFlightSnapshot::default(), + internal_workers: vec![], } } diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs new file mode 100644 index 00000000..d5dff296 --- /dev/null +++ b/crates/client/src/workspace_product.rs @@ -0,0 +1,811 @@ +use reqwest::Method; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use ticket::{ + MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, + OrchestrationPlanKind, OrchestrationPlanRecord, Ticket, TicketBackend, TicketDependencyCheck, + TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketItemEdit, + TicketListQuery, TicketListState, TicketMarkReady, TicketRef, TicketRelation, + TicketRelationKind, TicketRelationView, TicketStateChange, TicketStateSelector, TicketSummary, +}; +use workspace_api::{ + ListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, + ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary, +}; + +use crate::BackendWorkspaceClientError; + +const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000; + +#[derive(Debug, Deserialize)] +struct BackendWorkerLaunchOptions { + runtimes: Vec, +} + +#[derive(Debug, Deserialize)] +struct BackendWorkerLaunchRuntime { + runtime_id: String, + can_spawn_worker: bool, + working_directory_required: bool, +} + +#[derive(Debug, Deserialize)] +struct BackendCreateWorkerResponse { + runtime_id: String, + worker_id: String, +} + +#[derive(Debug, Deserialize)] +struct BackendWorkspaceOrchestratorResponse { + disposition: String, + worker: Option, +} + +/// Workspace-scoped Backend client for Ticket and Objective product state. +/// +/// Construction requires both the selected Backend URL and Workspace identity. +/// Callers should derive these once from `Target::resolve()` and must not retry +/// failed requests against repository-local state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendWorkspaceProductClient { + base_url: String, + workspace_id: String, +} + +impl BackendWorkspaceProductClient { + pub fn new( + base_url: impl Into, + workspace_id: impl Into, + ) -> Result { + let base_url = base_url.into().trim_end_matches('/').to_string(); + if base_url.is_empty() { + return Err(BackendWorkspaceClientError::InvalidTarget( + "Backend base URL must not be empty".into(), + )); + } + let workspace_id = workspace_id.into(); + if workspace_id.trim().is_empty() { + return Err(BackendWorkspaceClientError::InvalidTarget( + "Backend Workspace identity must not be empty".into(), + )); + } + Ok(Self { + base_url, + workspace_id, + }) + } + + pub fn workspace_id(&self) -> &str { + &self.workspace_id + } + + pub fn list_tickets( + &self, + query: &TicketListQuery, + ) -> Result, BackendWorkspaceClientError> { + let state = ticket_list_state_query(query); + self.get_json(&format!("/tickets/search?state={state}")) + } + + pub fn show_ticket(&self, id: &TicketIdOrSlug) -> Result { + self.get_json(&format!( + "/tickets/{}/record", + encode_path_segment(&ticket_reference(id)) + )) + } + + pub fn create_ticket( + &self, + input: &NewTicket, + ) -> Result { + self.send_json(Method::POST, "/tickets", Some(input)) + } + + pub fn add_ticket_event( + &self, + id: &TicketIdOrSlug, + event: &NewTicketEvent, + ) -> Result<(), BackendWorkspaceClientError> { + self.send_unit( + Method::POST, + &format!( + "/tickets/{}/thread-events", + encode_path_segment(&ticket_reference(id)) + ), + Some(event), + ) + } + + pub fn set_ticket_workflow_state( + &self, + id: &TicketIdOrSlug, + change: &TicketStateChange, + ) -> Result<(), BackendWorkspaceClientError> { + self.send_unit( + Method::POST, + &format!( + "/tickets/{}/workflow-state", + encode_path_segment(&ticket_reference(id)) + ), + Some(change), + ) + } + + pub fn close_ticket( + &self, + id: &TicketIdOrSlug, + resolution: &MarkdownText, + ) -> Result<(), BackendWorkspaceClientError> { + self.send_unit( + Method::POST, + &format!( + "/tickets/{}/workflow/close", + encode_path_segment(&ticket_reference(id)) + ), + Some(resolution), + ) + } + + pub fn add_ticket_relation( + &self, + id: &TicketIdOrSlug, + relation: &NewTicketRelation, + ) -> Result { + self.send_json( + Method::POST, + &format!( + "/tickets/{}/relations", + encode_path_segment(&ticket_reference(id)) + ), + Some(relation), + ) + } + + pub fn query_ticket_relations( + &self, + ticket: Option<&TicketIdOrSlug>, + kind: Option, + ) -> Result, BackendWorkspaceClientError> { + #[derive(Serialize)] + struct Query<'a> { + ticket: Option<&'a TicketIdOrSlug>, + kind: Option, + } + self.send_json( + Method::POST, + "/tickets/relations/search", + Some(&Query { ticket, kind }), + ) + } + + pub fn ticket_doctor(&self) -> Result { + self.get_json("/tickets/doctor") + } + + pub fn list_objectives( + &self, + limit: usize, + ) -> Result, BackendWorkspaceClientError> { + self.get_json(&format!("/objectives?limit={limit}")) + } + + pub fn show_objective(&self, id: &str) -> Result { + self.get_json(&format!("/objectives/{}", encode_path_segment(id))) + } + + pub fn create_objective( + &self, + input: &ObjectiveCreateRequest, + ) -> Result { + self.send_json(Method::POST, "/objectives", Some(input)) + } + + pub fn edit_objective( + &self, + id: &str, + input: &ObjectiveEditRequest, + ) -> Result { + self.send_json( + Method::PATCH, + &format!("/objectives/{}", encode_path_segment(id)), + Some(input), + ) + } + + pub fn set_objective_state( + &self, + id: &str, + input: &ObjectiveStateRequest, + ) -> Result { + self.send_json( + Method::POST, + &format!("/objectives/{}/state", encode_path_segment(id)), + Some(input), + ) + } + + pub fn link_objective_ticket( + &self, + id: &str, + input: &ObjectiveLinkTicketRequest, + ) -> Result { + self.send_json( + Method::POST, + &format!("/objectives/{}/ticket-links", encode_path_segment(id)), + Some(input), + ) + } + + pub fn unlink_objective_ticket( + &self, + id: &str, + ticket_id: &str, + ) -> Result { + self.send_json::<(), _>( + Method::DELETE, + &format!( + "/objectives/{}/ticket-links/{}", + encode_path_segment(id), + encode_path_segment(ticket_id) + ), + None, + ) + } + + pub fn launch_ticket_intake( + &self, + ticket_id: &str, + ) -> Result { + let options: BackendWorkerLaunchOptions = self.get_json("/workers/launch-options")?; + let runtime = options + .runtimes + .iter() + .find(|runtime| runtime.can_spawn_worker && !runtime.working_directory_required) + .ok_or_else(|| { + BackendWorkspaceClientError::InvalidTarget( + "Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker" + .to_string(), + ) + })?; + let response: BackendCreateWorkerResponse = self.send_json( + Method::POST, + "/workers", + Some(&serde_json::json!({ + "runtime_id": runtime.runtime_id, + "display_name": format!("intake-{ticket_id}"), + "profile": "builtin:intake", + "initial_submit": [{ + "kind": "text", + "content": format!("Please handle intake for Ticket {ticket_id}.") + }] + })), + )?; + Ok(format!( + "Started Intake Worker {}/{} for Ticket {ticket_id}", + response.runtime_id, response.worker_id + )) + } + + pub fn start_workspace_orchestrator(&self) -> Result { + let response: BackendWorkspaceOrchestratorResponse = + self.send_json::<(), _>(Method::POST, "/orchestrator", None)?; + let worker = response.worker.ok_or_else(|| { + BackendWorkspaceClientError::InvalidTarget( + "Backend accepted the Orchestrator request without returning a Worker".to_string(), + ) + })?; + Ok(format!( + "Workspace Orchestrator {} at {}/{}", + response.disposition, worker.runtime_id, worker.worker_id + )) + } + + pub fn default_product_list_limit() -> usize { + DEFAULT_PRODUCT_LIST_LIMIT + } + + fn get_json(&self, path: &str) -> Result { + self.send_json::<(), R>(Method::GET, path, None) + } + + fn send_json( + &self, + method: Method, + path: &str, + body: Option<&B>, + ) -> Result { + let response = self.request(method, path, body)?.send()?; + let response = ensure_success(response)?; + response.json().map_err(BackendWorkspaceClientError::Http) + } + + fn send_unit( + &self, + method: Method, + path: &str, + body: Option<&B>, + ) -> Result<(), BackendWorkspaceClientError> { + ensure_success(self.request(method, path, body)?.send()?)?; + Ok(()) + } + + fn request( + &self, + method: Method, + path: &str, + body: Option<&B>, + ) -> Result { + let client = reqwest::blocking::Client::builder().build()?; + let url = format!( + "{}/api/w/{}/{}", + self.base_url, + encode_path_segment(&self.workspace_id), + path.trim_start_matches('/') + ); + let request = client.request(method, url); + Ok(match body { + Some(body) => request.json(body), + None => request, + }) + } +} + +impl TicketBackend for BackendWorkspaceProductClient { + fn default_intake_ready_state_change_body(&self, from: &str) -> String { + #[derive(Serialize)] + struct Request<'a> { + from: &'a str, + } + self.send_json( + Method::POST, + "/tickets/default-intake-ready-body", + Some(&Request { from }), + ) + .unwrap_or_else(|error| error.to_string()) + } + + fn list(&self, filter: TicketListQuery) -> ticket::Result> { + self.list_tickets(&filter).map_err(ticket_client_error) + } + + fn show(&self, id: TicketIdOrSlug) -> ticket::Result { + self.show_ticket(&id).map_err(ticket_client_error) + } + + fn create(&self, input: NewTicket) -> ticket::Result { + self.create_ticket(&input).map_err(ticket_client_error) + } + + fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> ticket::Result { + self.send_json( + Method::PATCH, + &format!( + "/tickets/{}/item", + encode_path_segment(&ticket_reference(&id)) + ), + Some(&edit), + ) + .map_err(ticket_client_error) + } + + fn dependency_check(&self, id: TicketIdOrSlug) -> ticket::Result { + self.get_json(&format!( + "/tickets/{}/dependency-check", + encode_path_segment(&ticket_reference(&id)) + )) + .map_err(ticket_client_error) + } + + fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> ticket::Result<()> { + self.add_ticket_event(&id, &event) + .map_err(ticket_client_error) + } + + fn add_state_changed( + &self, + id: TicketIdOrSlug, + change: TicketStateChange, + ) -> ticket::Result<()> { + self.send_unit( + Method::POST, + &format!( + "/tickets/{}/state-changes", + encode_path_segment(&ticket_reference(&id)) + ), + Some(&change), + ) + .map_err(ticket_client_error) + } + + fn add_intake_summary( + &self, + id: TicketIdOrSlug, + summary: TicketIntakeSummary, + ) -> ticket::Result<()> { + self.send_unit( + Method::POST, + &format!( + "/tickets/{}/intake-summaries", + encode_path_segment(&ticket_reference(&id)) + ), + Some(&summary), + ) + .map_err(ticket_client_error) + } + + fn set_state_field( + &self, + id: TicketIdOrSlug, + field: &str, + change: TicketStateChange, + ) -> ticket::Result<()> { + self.send_unit( + Method::POST, + &format!( + "/tickets/{}/state-fields/{}", + encode_path_segment(&ticket_reference(&id)), + encode_path_segment(field) + ), + Some(&change), + ) + .map_err(ticket_client_error) + } + + fn set_workflow_state( + &self, + id: TicketIdOrSlug, + change: TicketStateChange, + ) -> ticket::Result<()> { + self.set_ticket_workflow_state(&id, &change) + .map_err(ticket_client_error) + } + + fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> ticket::Result { + self.send_json( + Method::POST, + &format!( + "/tickets/{}/workflow/mark-ready", + encode_path_segment(&ticket_reference(&id)) + ), + Some(&request), + ) + .map_err(ticket_client_error) + } + + fn queue_ready(&self, id: TicketIdOrSlug, _queued_by: &str) -> ticket::Result<()> { + self.send_unit::<()>( + Method::POST, + &format!( + "/tickets/{}/workflow/queue", + encode_path_segment(&ticket_reference(&id)) + ), + None, + ) + .map_err(ticket_client_error) + } + + fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> ticket::Result<()> { + self.close_ticket(&id, &resolution) + .map_err(ticket_client_error) + } + + fn add_ticket_relation( + &self, + id: TicketIdOrSlug, + relation: NewTicketRelation, + ) -> ticket::Result { + BackendWorkspaceProductClient::add_ticket_relation(self, &id, &relation) + .map_err(ticket_client_error) + } + + fn remove_ticket_relation( + &self, + id: TicketIdOrSlug, + kind: TicketRelationKind, + target: TicketIdOrSlug, + ) -> ticket::Result { + #[derive(Serialize)] + struct Request { + kind: TicketRelationKind, + target: String, + } + self.send_json( + Method::DELETE, + &format!( + "/tickets/{}/relations", + encode_path_segment(&ticket_reference(&id)) + ), + Some(&Request { + kind, + target: ticket_reference(&target), + }), + ) + .map_err(ticket_client_error) + } + + fn query_ticket_relations( + &self, + ticket: Option, + kind: Option, + ) -> ticket::Result> { + BackendWorkspaceProductClient::query_ticket_relations(self, ticket.as_ref(), kind) + .map_err(ticket_client_error) + } + + fn relation_view(&self, id: TicketIdOrSlug) -> ticket::Result { + self.get_json(&format!( + "/tickets/{}/relation-view", + encode_path_segment(&ticket_reference(&id)) + )) + .map_err(ticket_client_error) + } + + fn add_orchestration_plan_record( + &self, + id: TicketIdOrSlug, + record: NewOrchestrationPlanRecord, + ) -> ticket::Result { + self.send_json( + Method::POST, + &format!( + "/tickets/{}/orchestration-plans", + encode_path_segment(&ticket_reference(&id)) + ), + Some(&record), + ) + .map_err(ticket_client_error) + } + + fn query_orchestration_plan_records( + &self, + ticket: Option, + kind: Option, + ) -> ticket::Result> { + #[derive(Serialize)] + struct Query { + ticket: Option, + kind: Option, + } + self.send_json( + Method::POST, + "/tickets/orchestration-plans/search", + Some(&Query { ticket, kind }), + ) + .map_err(ticket_client_error) + } + + fn doctor(&self) -> ticket::Result { + self.ticket_doctor().map_err(ticket_client_error) + } +} + +fn ticket_client_error(error: BackendWorkspaceClientError) -> TicketError { + TicketError::Sqlite(format!("Backend request failed: {error}")) +} + +fn ensure_success( + response: reqwest::blocking::Response, +) -> Result { + if response.status().is_success() { + return Ok(response); + } + let status = response.status().as_u16(); + let message = response + .text() + .unwrap_or_else(|_| "Backend request failed".to_string()); + Err(BackendWorkspaceClientError::RequestFailed { status, message }) +} + +fn ticket_reference(id: &TicketIdOrSlug) -> String { + match id { + TicketIdOrSlug::Id(id) => id.to_string(), + TicketIdOrSlug::Slug(slug) | TicketIdOrSlug::Query(slug) => slug.clone(), + } +} + +fn ticket_list_state_query(query: &TicketListQuery) -> String { + match &query.state { + TicketStateSelector::Active => "active".to_string(), + TicketStateSelector::All => "all".to_string(), + TicketStateSelector::States(states) => states + .iter() + .copied() + .map(TicketListState::as_str) + .collect::>() + .join(","), + } +} + +fn encode_path_segment(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + use std::fmt::Write as _; + write!(&mut encoded, "%{byte:02X}").expect("writing to String cannot fail"); + } + } + encoded +} + +#[cfg(test)] +mod tests { + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::sync::mpsc; + use std::thread; + + use super::*; + + fn one_response_server( + status: &str, + body: &str, + ) -> (String, mpsc::Receiver, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let status = status.to_string(); + let body = body.to_string(); + let (sender, receiver) = mpsc::channel(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = vec![0_u8; 8_192]; + let bytes = stream.read(&mut request).unwrap(); + sender + .send(String::from_utf8_lossy(&request[..bytes]).to_string()) + .unwrap(); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + }); + (format!("http://{address}"), receiver, handle) + } + + fn response_sequence_server( + responses: Vec<(&'static str, &'static str)>, + ) -> (String, mpsc::Receiver, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + let handle = thread::spawn(move || { + for (status, body) in responses { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = vec![0_u8; 16_384]; + let bytes = stream.read(&mut request).unwrap(); + sender + .send(String::from_utf8_lossy(&request[..bytes]).to_string()) + .unwrap(); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .unwrap(); + } + }); + (format!("http://{address}"), receiver, handle) + } + + #[test] + fn objective_list_uses_workspace_scoped_backend_route() { + let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#; + let (base_url, request, handle) = one_response_server("200 OK", body); + let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); + + let response = client.list_objectives(1_000).unwrap(); + + assert!(response.items.is_empty()); + assert!( + request + .recv() + .unwrap() + .starts_with("GET /api/w/workspace-a/objectives?limit=1000 ") + ); + handle.join().unwrap(); + } + + #[test] + fn backend_mutation_failure_is_returned_without_local_fallback() { + let (base_url, request, handle) = one_response_server("403 Forbidden", "denied"); + let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); + + let error = client + .create_objective(&ObjectiveCreateRequest { + title: "Objective".to_string(), + body_md: "body".to_string(), + state: "active".to_string(), + linked_tickets: Vec::new(), + }) + .unwrap_err(); + + assert!(error.to_string().contains("403")); + assert!( + request + .recv() + .unwrap() + .starts_with("POST /api/w/workspace-a/objectives ") + ); + handle.join().unwrap(); + } + + #[test] + fn ticket_intake_launch_uses_backend_options_and_workspace_worker_route() { + let (base_url, requests, handle) = response_sequence_server(vec![ + ( + "200 OK", + r#"{"runtimes":[{"runtime_id":"embedded","can_spawn_worker":true,"working_directory_required":false}]}"#, + ), + ( + "200 OK", + r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#, + ), + ]); + let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); + + let status = client.launch_ticket_intake("T-1").unwrap(); + + assert!(status.contains("embedded/worker-1")); + assert!( + requests + .recv() + .unwrap() + .starts_with("GET /api/w/workspace-a/workers/launch-options ") + ); + let create_request = requests.recv().unwrap(); + assert!(create_request.starts_with("POST /api/w/workspace-a/workers ")); + assert!(create_request.contains("\"profile\":\"builtin:intake\"")); + assert!(create_request.contains("Ticket T-1")); + handle.join().unwrap(); + } + + #[test] + fn workspace_orchestrator_launch_uses_scoped_backend_route() { + let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#; + let (base_url, request, handle) = one_response_server("200 OK", body); + let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); + + let status = client.start_workspace_orchestrator().unwrap(); + + assert!(status.contains("created at embedded/worker-2")); + assert!( + request + .recv() + .unwrap() + .starts_with("POST /api/w/workspace-a/orchestrator ") + ); + handle.join().unwrap(); + } + + #[test] + fn product_client_requires_workspace_identity() { + let error = BackendWorkspaceProductClient::new("http://127.0.0.1:8787", "").unwrap_err(); + assert!(error.to_string().contains("Workspace identity")); + } + + #[test] + fn ticket_state_query_preserves_local_filter_semantics() { + assert_eq!( + ticket_list_state_query(&TicketListQuery::active()), + "active" + ); + assert_eq!(ticket_list_state_query(&TicketListQuery::all()), "all"); + assert_eq!( + ticket_list_state_query(&TicketListQuery { + state: TicketStateSelector::States( + [TicketListState::Ready, TicketListState::InProgress] + .into_iter() + .collect(), + ), + }), + "ready,inprogress" + ); + } + + #[test] + fn ticket_and_objective_references_are_path_encoded() { + assert_eq!(encode_path_segment("T-1/a"), "T-1%2Fa"); + } +} diff --git a/crates/tui/src/backend_dashboard.rs b/crates/tui/src/backend_dashboard.rs new file mode 100644 index 00000000..7fcdc36c --- /dev/null +++ b/crates/tui/src/backend_dashboard.rs @@ -0,0 +1,302 @@ +use std::io; +use std::time::Duration; + +use client::{BackendWorkspaceProductClient, ObjectiveSummary}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use ratatui::{ + Frame, Terminal, + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::Line, + widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}, +}; +use ticket::{TicketListQuery, TicketSummary}; + +use crate::console::{enter_dashboard_fullscreen, leave_dashboard_fullscreen}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Focus { + Tickets, + Objectives, +} + +struct BackendDashboard { + workspace_id: String, + tickets: Vec, + objectives: Vec, + focus: Focus, + selected_ticket: usize, + selected_objective: usize, + status: String, +} + +pub async fn launch( + base_url: String, + workspace_id: String, +) -> Result<(), Box> { + let client = BackendWorkspaceProductClient::new(base_url, workspace_id.clone())?; + let (tickets, objectives) = load(&client).await?; + let mut app = BackendDashboard { + workspace_id, + tickets, + objectives, + focus: Focus::Tickets, + selected_ticket: 0, + selected_objective: 0, + status: "Backend Ticket and Objective authority selected".to_string(), + }; + + let mut terminal = enter_dashboard_fullscreen()?; + let result = run_loop(&mut terminal, &mut app, client).await; + let restore_result = leave_dashboard_fullscreen(&mut terminal); + result?; + restore_result?; + Ok(()) +} + +async fn load( + client: &BackendWorkspaceProductClient, +) -> Result<(Vec, Vec), Box> { + let client = client.clone(); + tokio::task::spawn_blocking(move || { + let tickets = client + .list_tickets(&TicketListQuery::active()) + .map_err(|error| error.to_string())?; + let objectives = client + .list_objectives(BackendWorkspaceProductClient::default_product_list_limit()) + .map_err(|error| error.to_string())? + .items; + Ok::<_, String>((tickets, objectives)) + }) + .await + .map_err(|error| format!("Backend dashboard loading task failed: {error}"))? + .map_err(Into::into) +} + +async fn run_loop( + terminal: &mut Terminal>, + app: &mut BackendDashboard, + client: BackendWorkspaceProductClient, +) -> Result<(), Box> { + loop { + terminal.draw(|frame| draw(frame, app))?; + if !event::poll(Duration::from_millis(100))? { + continue; + } + let Event::Key(key) = event::read()? else { + continue; + }; + if key.kind != KeyEventKind::Press { + continue; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => return Ok(()), + KeyCode::Tab | KeyCode::BackTab => { + app.focus = match app.focus { + Focus::Tickets => Focus::Objectives, + Focus::Objectives => Focus::Tickets, + }; + } + 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(); + } + Err(error) => app.status = format!("Backend reload failed: {error}"), + }, + KeyCode::Char('i') if app.focus == Focus::Tickets => { + let Some(ticket_id) = app.selected_ticket_id() else { + app.status = "Select a Ticket before launching Intake".to_string(); + continue; + }; + let client = client.clone(); + match tokio::task::spawn_blocking(move || client.launch_ticket_intake(&ticket_id)) + .await + { + Ok(Ok(status)) => app.status = status, + Ok(Err(error)) => app.status = format!("Backend Intake launch failed: {error}"), + Err(error) => { + app.status = format!("Backend Intake launch task failed: {error}") + } + } + } + KeyCode::Char('o') => { + let client = client.clone(); + match tokio::task::spawn_blocking(move || client.start_workspace_orchestrator()) + .await + { + Ok(Ok(status)) => app.status = status, + Ok(Err(error)) => { + app.status = format!("Backend Orchestrator launch failed: {error}") + } + Err(error) => { + app.status = format!("Backend Orchestrator launch task failed: {error}") + } + } + } + _ => {} + } + } +} + +impl BackendDashboard { + fn select_next(&mut self) { + match self.focus { + Focus::Tickets if !self.tickets.is_empty() => { + self.selected_ticket = (self.selected_ticket + 1).min(self.tickets.len() - 1); + } + Focus::Objectives if !self.objectives.is_empty() => { + self.selected_objective = + (self.selected_objective + 1).min(self.objectives.len() - 1); + } + _ => {} + } + } + + fn select_previous(&mut self) { + match self.focus { + Focus::Tickets => self.selected_ticket = self.selected_ticket.saturating_sub(1), + Focus::Objectives => { + self.selected_objective = self.selected_objective.saturating_sub(1) + } + } + } + + fn selected_ticket_id(&self) -> Option { + self.tickets + .get(self.selected_ticket) + .map(|ticket| ticket.id.clone()) + } + + fn clamp_selection(&mut self) { + self.selected_ticket = self + .selected_ticket + .min(self.tickets.len().saturating_sub(1)); + self.selected_objective = self + .selected_objective + .min(self.objectives.len().saturating_sub(1)); + } +} + +fn draw(frame: &mut Frame<'_>, app: &BackendDashboard) { + let areas = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(4), + ]) + .split(frame.area()); + frame.render_widget( + Paragraph::new(format!( + "Workspace {} · Backend product state", + app.workspace_id + )) + .block(Block::default().borders(Borders::ALL).title("Panel")), + areas[0], + ); + + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(areas[1]); + draw_tickets(frame, columns[0], app); + draw_objectives(frame, columns[1], app); + + frame.render_widget( + Paragraph::new(vec![ + Line::from("Tab switch · j/k move · r reload · i Intake · o Orchestrator · q quit"), + Line::from(app.status.as_str()), + ]) + .wrap(Wrap { trim: true }) + .block(Block::default().borders(Borders::ALL).title("Status")), + areas[2], + ); +} + +fn draw_tickets(frame: &mut Frame<'_>, area: Rect, app: &BackendDashboard) { + let items = app.tickets.iter().enumerate().map(|(index, ticket)| { + let marker = if index == app.selected_ticket { + ">" + } else { + " " + }; + ListItem::new(format!( + "{marker} {} [{}] {}", + ticket.resource_key.as_deref().unwrap_or(&ticket.id), + ticket.workflow_state.as_str(), + ticket.title + )) + }); + let style = focus_style(app.focus == Focus::Tickets); + frame.render_widget( + List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(style) + .title(format!("Tickets ({})", app.tickets.len())), + ), + area, + ); +} + +fn draw_objectives(frame: &mut Frame<'_>, area: Rect, app: &BackendDashboard) { + let items = app.objectives.iter().enumerate().map(|(index, objective)| { + let marker = if index == app.selected_objective { + ">" + } else { + " " + }; + ListItem::new(format!( + "{marker} {} [{}] {}", + objective.resource_key, objective.state, objective.title + )) + }); + let style = focus_style(app.focus == Focus::Objectives); + frame.render_widget( + List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(style) + .title(format!("Objectives ({})", app.objectives.len())), + ), + area, + ); +} + +fn focus_style(focused: bool) -> Style { + if focused { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backend_dashboard_navigation_is_bounded() { + let mut app = BackendDashboard { + workspace_id: "workspace-a".to_string(), + tickets: Vec::new(), + objectives: Vec::new(), + focus: Focus::Tickets, + selected_ticket: 0, + selected_objective: 0, + status: String::new(), + }; + app.select_next(); + app.select_previous(); + assert_eq!(app.selected_ticket, 0); + assert_eq!(app.selected_objective, 0); + } +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index fdc25345..e7b7ec5b 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -1,4 +1,5 @@ mod app; +mod backend_dashboard; mod backend_worker_picker; mod backend_workspace_picker; mod block; @@ -181,7 +182,13 @@ pub async fn launch(options: LaunchOptions) -> ExitCode { Err(e) => Err(Box::new(e) as Box), }, LaunchMode::Panel { include_stopped } => match target.dashboard() { - Ok(dashboard) => dashboard::launch(dashboard.runtime_command, include_stopped).await, + Ok(client::Dashboard::Local { runtime_command }) => { + dashboard::launch(runtime_command, include_stopped).await + } + Ok(client::Dashboard::Backend { + base_url, + workspace_id, + }) => backend_dashboard::launch(base_url, workspace_id).await, Err(e) => Err(Box::new(e) as Box), }, }; diff --git a/crates/workspace-api/Cargo.toml b/crates/workspace-api/Cargo.toml index f5c05977..ab4ef23e 100644 --- a/crates/workspace-api/Cargo.toml +++ b/crates/workspace-api/Cargo.toml @@ -5,8 +5,13 @@ edition.workspace = true license.workspace = true publish = false +[features] +default = [] +typescript = ["dep:ts-rs"] + [dependencies] serde = { workspace = true, features = ["derive"] } +ts-rs = { version = "12.0.1", optional = true } workdir.workspace = true [dev-dependencies] diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index f52eea8e..2b633533 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -32,6 +32,108 @@ pub struct ListResponse { pub diagnostics: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct QueryPage { + pub limit: usize, + pub returned: usize, + pub has_more: bool, + pub next_cursor: Option, + pub sort: String, + pub source_limit: Option, + pub source_truncated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObjectiveEventDetail { + pub event_ref: String, + pub kind: String, + pub body: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObjectiveLinkedTicketSummary { + pub id: String, + pub resource_key: String, + pub title: String, + pub state: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObjectiveResourceSummary { + pub path: String, + pub media_type: Option, + pub bytes: usize, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObjectiveSummary { + pub id: String, + pub resource_key: String, + pub title: String, + pub state: String, + pub created_at: Option, + pub updated_at: Option, + pub summary: String, + pub linked_tickets: Vec, + pub record_source: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObjectiveDetail { + pub id: String, + pub resource_key: String, + pub title: String, + pub state: String, + pub revision: String, + pub created_at: Option, + pub updated_at: Option, + pub linked_tickets: Vec, + pub linked_ticket_summaries: Vec, + pub resources: Vec, + pub body: String, + pub body_truncated: bool, + pub events: Vec, + pub event_page: QueryPage, + pub record_source: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObjectiveCreateRequest { + pub title: String, + #[serde(default)] + pub body_md: String, + #[serde(default = "default_objective_state")] + pub state: String, + #[serde(default)] + pub linked_tickets: Vec, +} + +fn default_objective_state() -> String { + "active".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ObjectiveEditRequest { + pub title: Option, + pub old_string: Option, + pub new_string: Option, + #[serde(default)] + pub replace_all: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObjectiveStateRequest { + pub state: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObjectiveLinkTicketRequest { + pub ticket_id: String, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum RuntimeSourceKind { diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index dd870795..ec9d8fb1 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -12,7 +12,7 @@ path = "src/main.rs" [features] default = ["typescript"] -typescript = ["dep:ts-rs"] +typescript = ["dep:ts-rs", "workspace-api/typescript"] [dependencies] async-trait.workspace = true diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index 26e15cda..0c18fd1f 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -1,5 +1,9 @@ use project_record::validate_record_id; use serde::{Deserialize, Serialize}; +pub use workspace_api::{ + ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkedTicketSummary, ObjectiveResourceSummary, + ObjectiveSummary, QueryPage, +}; use crate::{Error, Result}; @@ -226,18 +230,6 @@ impl From for TicketRelationView { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct QueryPage { - pub limit: usize, - pub returned: usize, - pub has_more: bool, - pub next_cursor: Option, - pub sort: String, - pub source_limit: Option, - pub source_truncated: bool, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct ObjectiveLinkSummary { @@ -418,63 +410,6 @@ pub struct ObjectiveShowRequest { pub event_cursor: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -pub struct ObjectiveEventDetail { - pub event_ref: String, - pub kind: String, - pub body: Option, - pub created_at: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ObjectiveLinkedTicketSummary { - pub id: String, - pub resource_key: String, - pub title: String, - pub state: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ObjectiveSummary { - pub id: String, - pub resource_key: String, - pub title: String, - pub state: String, - pub created_at: Option, - pub updated_at: Option, - pub summary: String, - pub linked_tickets: Vec, - pub record_source: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ObjectiveDetail { - pub id: String, - pub resource_key: String, - pub title: String, - pub state: String, - pub revision: String, - pub created_at: Option, - pub updated_at: Option, - pub linked_tickets: Vec, - pub linked_ticket_summaries: Vec, - pub resources: Vec, - pub body: String, - pub body_truncated: bool, - pub events: Vec, - pub event_page: QueryPage, - pub record_source: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ObjectiveResourceSummary { - pub path: String, - pub media_type: Option, - pub bytes: usize, - pub updated_at: String, -} - #[cfg(feature = "typescript")] pub fn ticket_api_typescript() -> String { use ts_rs::TS; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 2d9d9931..ff981554 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -57,6 +57,9 @@ use workdir::workspace::{ use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjectRef}; use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; +use workspace_api::{ + ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, +}; use crate::auth::{ AuthPublicConfig, AuthenticatedUser, RequestActor, auth_error, is_expired, mint_secret, new_id, @@ -2648,26 +2651,6 @@ struct ObjectiveListQuery { limit: Option, } -#[derive(Debug, Deserialize)] -struct ObjectiveCreateRequest { - title: String, - #[serde(default)] - body_md: String, - #[serde(default = "default_objective_state")] - state: String, - #[serde(default)] - linked_tickets: Vec, -} - -#[derive(Debug, Deserialize)] -struct ObjectiveEditRequest { - title: Option, - old_string: Option, - new_string: Option, - #[serde(default)] - replace_all: bool, -} - #[derive(Debug, Deserialize)] struct TicketListQuery { limit: Option, @@ -2676,16 +2659,6 @@ struct TicketListQuery { states: Option, } -#[derive(Debug, Deserialize)] -struct ObjectiveStateRequest { - state: String, -} - -#[derive(Debug, Deserialize)] -struct ObjectiveLinkTicketRequest { - ticket_id: String, -} - #[derive(Debug, Deserialize)] struct ScopedObjectivePath { workspace_id: String, @@ -2699,10 +2672,6 @@ struct ScopedObjectiveTicketPath { ticket_id: String, } -fn default_objective_state() -> String { - "active".to_string() -} - #[derive(Debug, Deserialize)] struct TranscriptQuery { start: Option, diff --git a/crates/yoi/Cargo.toml b/crates/yoi/Cargo.toml index 95b37c56..c449eda2 100644 --- a/crates/yoi/Cargo.toml +++ b/crates/yoi/Cargo.toml @@ -15,6 +15,7 @@ client = { workspace = true } memory = { workspace = true } manifest = { workspace = true } worker = { workspace = true } +workspace-api = { workspace = true } session-store = { workspace = true } session-analytics = { workspace = true } ticket = { workspace = true } diff --git a/crates/yoi/src/cli_connection.rs b/crates/yoi/src/cli_connection.rs index 352637ed..4d5bd379 100644 --- a/crates/yoi/src/cli_connection.rs +++ b/crates/yoi/src/cli_connection.rs @@ -55,14 +55,14 @@ impl CliCommand { CliCommand::DefaultTui | CliCommand::Workers | CliCommand::Resume - | CliCommand::Panel => CliConnectionRequirement::ConnectionAware, + | CliCommand::Panel + | CliCommand::Ticket + | CliCommand::Objective => CliConnectionRequirement::ConnectionAware, CliCommand::Login => CliConnectionRequirement::BackendOnly, CliCommand::Keys | CliCommand::SetupModel | CliCommand::WorkerRuntime | CliCommand::WorkerCleanup - | CliCommand::Ticket - | CliCommand::Objective | CliCommand::Plugin | CliCommand::Mcp | CliCommand::MemoryLint @@ -259,6 +259,8 @@ mod tests { CliCommand::Workers, CliCommand::Resume, CliCommand::Panel, + CliCommand::Ticket, + CliCommand::Objective, ] { assert_eq!( command.connection_requirement(), @@ -277,8 +279,6 @@ mod tests { CliCommand::SetupModel, CliCommand::WorkerRuntime, CliCommand::WorkerCleanup, - CliCommand::Ticket, - CliCommand::Objective, CliCommand::Plugin, CliCommand::Mcp, CliCommand::MemoryLint, diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 61469028..2a5e119f 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -34,10 +34,16 @@ enum Mode { MemoryLint(LintCliOptions), Mcp(mcp_cli::McpCliCommand), Plugin(plugin_cli::PluginCliCommand), - Objective(objective_cli::ObjectiveCli), + Objective { + cli: objective_cli::ObjectiveCli, + target: client::ResolvedTarget, + }, Session(session_cli::SessionCli), WorkerCleanup(worker_cleanup_cli::WorkerCleanupCli), - Ticket(ticket_cli::TicketCli), + Ticket { + cli: ticket_cli::TicketCli, + target: client::ResolvedTarget, + }, Login { backend_url: String, no_wait: bool, @@ -119,19 +125,25 @@ async fn main() -> ExitCode { ExitCode::FAILURE } }, - Mode::Objective(cli) => match objective_cli::run(cli) { - Ok(output) => { - print!("{}", output.stdout); - match output.status { - objective_cli::ObjectiveCliStatus::Success => ExitCode::SUCCESS, - objective_cli::ObjectiveCliStatus::Failure => ExitCode::FAILURE, + Mode::Objective { cli, target } => { + match tokio::task::spawn_blocking(move || objective_cli::run(cli, target)).await { + Ok(Ok(output)) => { + print!("{}", output.stdout); + match output.status { + objective_cli::ObjectiveCliStatus::Success => ExitCode::SUCCESS, + objective_cli::ObjectiveCliStatus::Failure => ExitCode::FAILURE, + } + } + Ok(Err(e)) => { + eprintln!("yoi objective: {e}"); + ExitCode::FAILURE + } + Err(e) => { + eprintln!("yoi objective: execution task failed: {e}"); + ExitCode::FAILURE } } - Err(e) => { - eprintln!("yoi objective: {e}"); - ExitCode::FAILURE - } - }, + } Mode::Session(cli) => match session_cli::run(cli) { Ok(output) => { print!("{}", output.stdout); @@ -158,19 +170,25 @@ async fn main() -> ExitCode { ExitCode::FAILURE } }, - Mode::Ticket(cli) => match ticket_cli::run(cli) { - Ok(output) => { - print!("{}", output.stdout); - match output.status { - ticket_cli::TicketCliStatus::Success => ExitCode::SUCCESS, - ticket_cli::TicketCliStatus::Failure => ExitCode::FAILURE, + Mode::Ticket { cli, target } => { + match tokio::task::spawn_blocking(move || ticket_cli::run(cli, target)).await { + Ok(Ok(output)) => { + print!("{}", output.stdout); + match output.status { + ticket_cli::TicketCliStatus::Success => ExitCode::SUCCESS, + ticket_cli::TicketCliStatus::Failure => ExitCode::FAILURE, + } + } + Ok(Err(e)) => { + eprintln!("yoi ticket: {e}"); + ExitCode::FAILURE + } + Err(e) => { + eprintln!("yoi ticket: execution task failed: {e}"); + ExitCode::FAILURE } } - Err(e) => { - eprintln!("yoi ticket: {e}"); - ExitCode::FAILURE - } - }, + } Mode::WorkerRuntime(args) => worker::entrypoint::run_cli_from("yoi worker", args).await, Mode::Keys => tui::keys::launch().await, Mode::SetupModel => tui::setup_model::launch().await, @@ -357,10 +375,18 @@ fn parse_args_slice_with_connection_resolver( return Ok(Mode::WorkerRuntime(args[1..].to_vec())); } "objective" => { - let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Objective)?; - let objective_cli = objective_cli::parse_objective_args(&args[1..]) + let workspace_root = current_dir()?; + let target = resolve_tui_target( + connection_resolver, + CliCommand::Objective, + &target_selection, + &workspace_root, + )? + .resolve() + .map_err(|error| ParseError(error.to_string()))?; + let cli = objective_cli::parse_objective_args(&args[1..]) .map_err(|e| ParseError(e.to_string()))?; - return Ok(Mode::Objective(objective_cli)); + return Ok(Mode::Objective { cli, target }); } "session" => { let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Session)?; @@ -369,10 +395,18 @@ fn parse_args_slice_with_connection_resolver( return Ok(Mode::Session(session_cli)); } "ticket" => { - let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Ticket)?; - let ticket_cli = + let workspace_root = current_dir()?; + let target = resolve_tui_target( + connection_resolver, + CliCommand::Ticket, + &target_selection, + &workspace_root, + )? + .resolve() + .map_err(|error| ParseError(error.to_string()))?; + let cli = ticket_cli::parse_ticket_args(&args[1..]).map_err(|e| ParseError(e.to_string()))?; - return Ok(Mode::Ticket(ticket_cli)); + return Ok(Mode::Ticket { cli, target }); } "plugin" => { let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Plugin)?; @@ -1660,8 +1694,8 @@ Local commands: worker [WORKER_OPTIONS] Run the local Worker runtime CLI worker delete Delete local Worker records worker prune Prune old local Worker records - ticket Manage Tickets through the local workspace authority - objective Manage Objectives through the local workspace authority + ticket Manage Tickets through the selected target + objective Manage Objectives through the selected target plugin Build/check/list/show plugins mcp Inspect configured MCP servers memory lint Lint local memory files @@ -2105,11 +2139,67 @@ backend = "shared" #[test] fn parse_ticket_subcommand_uses_ticket_mode() { match parse_args_from(["ticket", "doctor"]).unwrap() { - Mode::Ticket(ticket_cli::TicketCli::Command(ticket_cli::TicketCommand::Doctor)) => {} + Mode::Ticket { + cli: ticket_cli::TicketCli::Command(ticket_cli::TicketCommand::Doctor), + target: client::ResolvedTarget::Local, + } => {} _ => panic!("expected Ticket doctor mode"), } } + #[test] + fn parse_backend_ticket_keeps_resolved_workspace_target() { + let resolver = FixedCliConnectionResolver { + backend_url: "http://fake-backend.example", + }; + let args = vec![ + "--backend".to_string(), + "http://ignored-by-fixed-resolver.example".to_string(), + "--workspace-id".to_string(), + "workspace-a".to_string(), + "ticket".to_string(), + "doctor".to_string(), + ]; + + match parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() { + Mode::Ticket { + cli: ticket_cli::TicketCli::Command(ticket_cli::TicketCommand::Doctor), + target: + client::ResolvedTarget::Backend { + base_url, + workspace_id, + }, + } => { + assert_eq!(base_url, "http://fake-backend.example"); + assert_eq!(workspace_id, "workspace-a"); + } + other => panic!("expected Backend Ticket mode, got {other:?}"), + } + } + + #[test] + fn parse_backend_objective_keeps_resolved_workspace_target() { + let resolver = FixedCliConnectionResolver { + backend_url: "http://fake-backend.example", + }; + let args = vec![ + "--backend".to_string(), + "http://ignored-by-fixed-resolver.example".to_string(), + "--workspace-id".to_string(), + "workspace-a".to_string(), + "objective".to_string(), + "doctor".to_string(), + ]; + + match parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() { + Mode::Objective { + cli: objective_cli::ObjectiveCli::Command(objective_cli::ObjectiveCommand::Doctor), + target: client::ResolvedTarget::Backend { workspace_id, .. }, + } => assert_eq!(workspace_id, "workspace-a"), + other => panic!("expected Backend Objective mode, got {other:?}"), + } + } + #[test] fn parse_session_analyze_uses_session_mode() { match parse_args_from(["session", "analyze", "/tmp/session.jsonl", "--json"]).unwrap() { @@ -2124,7 +2214,10 @@ backend = "shared" #[test] fn parse_ticket_help_uses_ticket_mode() { match parse_args_from(["ticket", "--help"]).unwrap() { - Mode::Ticket(ticket_cli::TicketCli::Help) => {} + Mode::Ticket { + cli: ticket_cli::TicketCli::Help, + target: client::ResolvedTarget::Local, + } => {} _ => panic!("expected Ticket help mode"), } } diff --git a/crates/yoi/src/objective_cli.rs b/crates/yoi/src/objective_cli.rs index c2f68dfb..844f5305 100644 --- a/crates/yoi/src/objective_cli.rs +++ b/crates/yoi/src/objective_cli.rs @@ -3,6 +3,7 @@ use std::fs; use std::path::{Component, Path, PathBuf}; use chrono::Utc; +use client::{BackendWorkspaceProductClient, ResolvedTarget}; use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_id}; use serde::Deserialize; use ticket::config::TicketConfig; @@ -167,11 +168,100 @@ pub fn parse_objective_args(args: &[String]) -> Result Result { - let workspace = std::env::current_dir().map_err(|error| { - ObjectiveCliError::new(format!("failed to resolve current directory: {error}")) - })?; - run_in_workspace(cli, &workspace) +pub fn run( + cli: ObjectiveCli, + target: ResolvedTarget, +) -> Result { + match target { + ResolvedTarget::Local => { + let workspace = std::env::current_dir().map_err(|error| { + ObjectiveCliError::new(format!("failed to resolve current directory: {error}")) + })?; + run_in_workspace(cli, &workspace) + } + ResolvedTarget::Backend { + base_url, + workspace_id, + } => { + let backend = BackendWorkspaceProductClient::new(base_url, workspace_id) + .map_err(|error| ObjectiveCliError::new(error.to_string()))?; + run_with_backend(cli, &backend) + } + } +} + +fn run_with_backend( + cli: ObjectiveCli, + backend: &BackendWorkspaceProductClient, +) -> Result { + match cli { + ObjectiveCli::Help => Ok(success(help_text().to_string())), + ObjectiveCli::Command(ObjectiveCommand::Create(options)) => { + let title = options.title.trim(); + if title.is_empty() { + return Err(ObjectiveCliError::new("create --title must not be empty")); + } + let objective = backend + .create_objective(&workspace_api::ObjectiveCreateRequest { + title: title.to_string(), + body_md: objective_body_template(), + state: "active".to_string(), + linked_tickets: options.linked_tickets, + }) + .map_err(|error| ObjectiveCliError::new(error.to_string()))?; + Ok(success(format!("created\t{}\n", objective.id))) + } + ObjectiveCli::Command(ObjectiveCommand::List(options)) => { + let response = backend + .list_objectives(BackendWorkspaceProductClient::default_product_list_limit()) + .map_err(|error| ObjectiveCliError::new(error.to_string()))?; + let mut stdout = String::from("state\tid\ttitle\tupdated_at\tlinked_tickets\n"); + for objective in response.items { + let state = ObjectiveState::parse(&objective.state); + if !list_state_matches(options.state, state) { + continue; + } + stdout.push_str(&format!( + "{}\t{}\t{}\t{}\t{}\n", + objective.state, + objective.id, + objective.title, + objective.updated_at.unwrap_or_default(), + objective.linked_tickets.join(",") + )); + } + Ok(success(stdout)) + } + ObjectiveCli::Command(ObjectiveCommand::Show { id }) => { + let objective = backend + .show_objective(&id) + .map_err(|error| ObjectiveCliError::new(error.to_string()))?; + let mut stdout = String::new(); + stdout.push_str(&format!("# {}\n\n", objective.title)); + stdout.push_str(&format!("State: {}\n", objective.state)); + stdout.push_str(&format!("ID: {}\n", objective.id)); + stdout.push_str(&format!( + "Updated: {}\n\n## item.md\n\n", + objective.updated_at.unwrap_or_default() + )); + stdout.push_str(&objective.body); + if !stdout.ends_with('\n') { + stdout.push('\n'); + } + Ok(success(stdout)) + } + ObjectiveCli::Command(ObjectiveCommand::Doctor) => { + let response = backend + .list_objectives(BackendWorkspaceProductClient::default_product_list_limit()) + .map_err(|error| ObjectiveCliError::new(error.to_string()))?; + for objective in response.items { + backend + .show_objective(&objective.id) + .map_err(|error| ObjectiveCliError::new(error.to_string()))?; + } + Ok(success("doctor: ok\n".to_string())) + } + } } pub fn run_in_workspace( @@ -453,15 +543,21 @@ fn list_state_matches(filter: ObjectiveListState, state: Option) } } +fn objective_body_template() -> String { + "## Goal\n\nTBD\n\n## Motivation / background\n\nTBD\n\n## Strategy / design direction\n\nTBD\n\n## Success criteria / exit conditions\n\n- TBD\n\n## Decision context\n\n- TBD\n" + .to_string() +} + fn render_objective_item(title: &str, linked_tickets: &[String]) -> String { let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); format!( - "---\ntitle: {}\nstate: {}\ncreated_at: {}\nupdated_at: {}\nlinked_tickets: {}\n---\n\n## Goal\n\nTBD\n\n## Motivation / background\n\nTBD\n\n## Strategy / design direction\n\nTBD\n\n## Success criteria / exit conditions\n\n- TBD\n\n## Decision context\n\n- TBD\n\n", + "---\ntitle: {}\nstate: {}\ncreated_at: {}\nupdated_at: {}\nlinked_tickets: {}\n---\n\n{}\n", yaml_string(title), yaml_string(ObjectiveState::Active.as_str()), yaml_string(&now), yaml_string(&now), - yaml_string_array(linked_tickets) + yaml_string_array(linked_tickets), + objective_body_template() ) } @@ -587,7 +683,7 @@ fn success(stdout: String) -> ObjectiveCliOutput { } fn help_text() -> &'static str { - "yoi objective\n\nUsage:\n yoi objective create --title [--ticket <TICKET_ID> ...]\n yoi objective list [--state active|paused|done|archived|all]\n yoi objective show <OBJECTIVE_ID>\n yoi objective doctor\n\nObjective records are lightweight project records stored as .yoi/objectives/<objective-id>/item.md. Linked Tickets must be canonical opaque Ticket IDs; Objective links are non-blocking context, not Ticket dependencies.\n" + "yoi objective\n\nUsage:\n yoi objective create --title <TITLE> [--ticket <TICKET_ID> ...]\n yoi objective list [--state active|paused|done|archived|all]\n yoi objective show <OBJECTIVE_ID>\n yoi objective doctor\n\nBackend targets use the Workspace-scoped Objective API selected by the shared client Target. Explicit local targets preserve the repository-file Objective backend. Linked Tickets must be canonical opaque Ticket IDs; Objective links are non-blocking context, not Ticket dependencies.\n" } #[cfg(test)] diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index 62d9f1ad..dbf115b3 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -5,6 +5,7 @@ use std::io::Write; use std::path::{Path, PathBuf}; use chrono::{SecondsFormat, Utc}; +use client::{BackendWorkspaceProductClient, ResolvedTarget}; use ticket::config::{ TICKET_CONFIG_RELATIVE_PATH, TicketConfig, WORKSPACE_SETTINGS_RELATIVE_PATH, ticket_config_scaffold, @@ -205,11 +206,32 @@ pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> { Ok(TicketCli::Command(command)) } -pub fn run(cli: TicketCli) -> Result<TicketCliOutput, TicketCliError> { - let workspace = std::env::current_dir().map_err(|error| { - TicketCliError::new(format!("failed to resolve current directory: {error}")) - })?; - run_in_workspace(cli, &workspace) +pub fn run(cli: TicketCli, target: ResolvedTarget) -> Result<TicketCliOutput, TicketCliError> { + match target { + ResolvedTarget::Local => { + let workspace = std::env::current_dir().map_err(|error| { + TicketCliError::new(format!("failed to resolve current directory: {error}")) + })?; + run_in_workspace(cli, &workspace) + } + ResolvedTarget::Backend { + base_url, + workspace_id, + } => match cli { + TicketCli::Help => Ok(TicketCliOutput { + status: TicketCliStatus::Success, + stdout: help_text().to_string(), + }), + TicketCli::Command(TicketCommand::Init | TicketCommand::ImportLocal) => Err( + TicketCliError::new("ticket init/import-local require an explicit local target"), + ), + TicketCli::Command(command) => { + let backend = BackendWorkspaceProductClient::new(base_url, workspace_id) + .map_err(|error| TicketCliError::new(error.to_string()))?; + run_backend_command(command, &backend) + } + }, + } } pub fn run_in_workspace( @@ -234,23 +256,30 @@ fn run_command( TicketCommand::ImportLocal => import_local(workspace), command => { let backend = backend_for_workspace(workspace)?; - match command { - TicketCommand::Create(options) => create(backend.as_ref(), options), - TicketCommand::List(options) => list(backend.as_ref(), options), - TicketCommand::Show { query } => show(backend.as_ref(), query), - TicketCommand::Comment(options) => comment(backend.as_ref(), options), - TicketCommand::State(options) => state(backend.as_ref(), options), - TicketCommand::Close(options) => close(backend.as_ref(), options), - TicketCommand::Relation(options) => relation(backend.as_ref(), options), - TicketCommand::Doctor => doctor(backend.as_ref()), - TicketCommand::Init | TicketCommand::ImportLocal => { - unreachable!("handled before backend setup") - } - } + run_backend_command(command, backend.as_ref()) } } } +fn run_backend_command( + command: TicketCommand, + backend: &dyn TicketBackend, +) -> Result<TicketCliOutput, TicketCliError> { + match command { + TicketCommand::Create(options) => create(backend, options), + TicketCommand::List(options) => list(backend, options), + TicketCommand::Show { query } => show(backend, query), + TicketCommand::Comment(options) => comment(backend, options), + TicketCommand::State(options) => state(backend, options), + TicketCommand::Close(options) => close(backend, options), + TicketCommand::Relation(options) => relation(backend, options), + TicketCommand::Doctor => doctor(backend), + TicketCommand::Init | TicketCommand::ImportLocal => Err(TicketCliError::new( + "ticket init/import-local require an explicit local target", + )), + } +} + fn init(workspace: &Path) -> Result<TicketCliOutput, TicketCliError> { let legacy_config_path = workspace.join(TICKET_CONFIG_RELATIVE_PATH); if legacy_config_path.exists() { @@ -1153,7 +1182,7 @@ fn default_author() -> String { } fn help_text() -> &'static str { - "yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nBackend:\n Tickets are stored in the workspace SQLite DB under the Yoi data directory.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n" + "yoi ticket\n\nUsage:\n yoi ticket init\n yoi ticket import-local\n yoi ticket create --title <title>\n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nTargets:\n Backend targets use the Workspace-scoped Ticket API selected by the shared client Target.\n Explicit local targets use the workspace SQLite backend. `init` and `import-local` are local-only.\n `yoi ticket import-local` imports the legacy .yoi/tickets backend root configured in .yoi/workspace.toml.\n `yoi ticket init` writes explicit fixed role profiles and optional [ticket].language into .yoi/workspace.toml, but does not create .yoi/tickets.\n" } #[cfg(test)] diff --git a/package.nix b/package.nix index 6850ae31..5e0d4a00 100644 --- a/package.nix +++ b/package.nix @@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-R33Ty4414wGkqnwCt08zbDQbVu9ggMC5I3ZawgloNT0="; + cargoHash = "sha256-LjswbsalQA0Yny0DIBJg3FiqwdmTADZ1CnqNJ9oo9Kw="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint,