feat: route product commands through backend targets
This commit is contained in:
Generated
+2
@@ -6142,6 +6142,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"ts-rs",
|
||||||
"workdir",
|
"workdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6215,6 +6216,7 @@ dependencies = [
|
|||||||
"toml",
|
"toml",
|
||||||
"tui",
|
"tui",
|
||||||
"worker",
|
"worker",
|
||||||
|
"workspace-api",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ protocol = { workspace = true }
|
|||||||
manifest = { workspace = true }
|
manifest = { workspace = true }
|
||||||
ticket = { workspace = true }
|
ticket = { workspace = true }
|
||||||
futures = { 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 = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub mod spawn;
|
|||||||
pub mod target;
|
pub mod target;
|
||||||
pub mod ticket_role;
|
pub mod ticket_role;
|
||||||
mod worker_client;
|
mod worker_client;
|
||||||
|
mod workspace_product;
|
||||||
|
|
||||||
pub use backend_auth::{
|
pub use backend_auth::{
|
||||||
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
||||||
@@ -36,9 +37,9 @@ pub use backend_workspace::{
|
|||||||
};
|
};
|
||||||
pub use runtime_command::WorkerRuntimeCommand;
|
pub use runtime_command::WorkerRuntimeCommand;
|
||||||
pub use target::{
|
pub use target::{
|
||||||
BackendTarget, Dashboard, LocalTarget, Target, TargetError, TargetKind, WorkerByName,
|
BackendTarget, Dashboard, LocalTarget, ResolvedTarget, Target, TargetError, TargetKind,
|
||||||
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerResume,
|
WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest,
|
||||||
WorkerSpawn,
|
WorkerResume, WorkerSpawn,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use spawn::{
|
pub use spawn::{
|
||||||
@@ -52,3 +53,5 @@ pub use ticket_role::{
|
|||||||
plan_ticket_role_launch_with_config,
|
plan_ticket_role_launch_with_config,
|
||||||
};
|
};
|
||||||
pub use worker_client::WorkerClient;
|
pub use worker_client::WorkerClient;
|
||||||
|
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary};
|
||||||
|
pub use workspace_product::BackendWorkspaceProductClient;
|
||||||
|
|||||||
+118
-4
@@ -8,6 +8,24 @@ pub enum TargetKind {
|
|||||||
Backend,
|
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 {
|
impl fmt::Display for TargetKind {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
@@ -104,8 +122,14 @@ pub struct WorkerResume {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Dashboard {
|
pub enum Dashboard {
|
||||||
pub runtime_command: WorkerRuntimeCommand,
|
Local {
|
||||||
|
runtime_command: WorkerRuntimeCommand,
|
||||||
|
},
|
||||||
|
Backend {
|
||||||
|
base_url: String,
|
||||||
|
workspace_id: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -156,6 +180,13 @@ impl std::error::Error for TargetError {}
|
|||||||
pub trait Target: fmt::Debug + Send + Sync {
|
pub trait Target: fmt::Debug + Send + Sync {
|
||||||
fn kind(&self) -> TargetKind;
|
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<ResolvedTarget, TargetError>;
|
||||||
|
|
||||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError>;
|
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError>;
|
||||||
|
|
||||||
fn worker_by_name(&self) -> Result<WorkerByName, TargetError>;
|
fn worker_by_name(&self) -> Result<WorkerByName, TargetError>;
|
||||||
@@ -177,6 +208,10 @@ impl Target for LocalTarget {
|
|||||||
TargetKind::Local
|
TargetKind::Local
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve(&self) -> Result<ResolvedTarget, TargetError> {
|
||||||
|
Ok(ResolvedTarget::Local)
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
||||||
Ok(WorkerSpawn {
|
Ok(WorkerSpawn {
|
||||||
runtime_command: self.runtime_command()?,
|
runtime_command: self.runtime_command()?,
|
||||||
@@ -196,7 +231,7 @@ impl Target for LocalTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
||||||
Ok(Dashboard {
|
Ok(Dashboard::Local {
|
||||||
runtime_command: self.runtime_command()?,
|
runtime_command: self.runtime_command()?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -231,6 +266,19 @@ impl Target for BackendTarget {
|
|||||||
TargetKind::Backend
|
TargetKind::Backend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve(&self) -> Result<ResolvedTarget, TargetError> {
|
||||||
|
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<WorkerSpawn, TargetError> {
|
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
||||||
Err(TargetError::unsupported("Worker spawn", self.kind()))
|
Err(TargetError::unsupported("Worker spawn", self.kind()))
|
||||||
}
|
}
|
||||||
@@ -247,7 +295,16 @@ impl Target for BackendTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
||||||
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<WorkerList, TargetError> {
|
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
||||||
@@ -287,6 +344,63 @@ impl Target for BackendTarget {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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::<String>);
|
||||||
|
|
||||||
|
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::<String>);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
target
|
||||||
|
.dashboard()
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("workspace selection is required")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_target_builds_worker_list() {
|
fn backend_target_builds_worker_list() {
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
||||||
|
|||||||
@@ -743,6 +743,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
status: WorkerStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
in_flight: protocol::InFlightSnapshot::default(),
|
in_flight: protocol::InFlightSnapshot::default(),
|
||||||
|
internal_workers: vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<BackendWorkerLaunchRuntime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<BackendCreateWorkerResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
workspace_id: impl Into<String>,
|
||||||
|
) -> Result<Self, BackendWorkspaceClientError> {
|
||||||
|
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<Vec<TicketSummary>, BackendWorkspaceClientError> {
|
||||||
|
let state = ticket_list_state_query(query);
|
||||||
|
self.get_json(&format!("/tickets/search?state={state}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_ticket(&self, id: &TicketIdOrSlug) -> Result<Ticket, BackendWorkspaceClientError> {
|
||||||
|
self.get_json(&format!(
|
||||||
|
"/tickets/{}/record",
|
||||||
|
encode_path_segment(&ticket_reference(id))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_ticket(
|
||||||
|
&self,
|
||||||
|
input: &NewTicket,
|
||||||
|
) -> Result<TicketRef, BackendWorkspaceClientError> {
|
||||||
|
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<TicketRelation, BackendWorkspaceClientError> {
|
||||||
|
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<TicketRelationKind>,
|
||||||
|
) -> Result<Vec<TicketRelation>, BackendWorkspaceClientError> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Query<'a> {
|
||||||
|
ticket: Option<&'a TicketIdOrSlug>,
|
||||||
|
kind: Option<TicketRelationKind>,
|
||||||
|
}
|
||||||
|
self.send_json(
|
||||||
|
Method::POST,
|
||||||
|
"/tickets/relations/search",
|
||||||
|
Some(&Query { ticket, kind }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ticket_doctor(&self) -> Result<TicketDoctorReport, BackendWorkspaceClientError> {
|
||||||
|
self.get_json("/tickets/doctor")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_objectives(
|
||||||
|
&self,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<ListResponse<ObjectiveSummary>, BackendWorkspaceClientError> {
|
||||||
|
self.get_json(&format!("/objectives?limit={limit}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show_objective(&self, id: &str) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
||||||
|
self.get_json(&format!("/objectives/{}", encode_path_segment(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_objective(
|
||||||
|
&self,
|
||||||
|
input: &ObjectiveCreateRequest,
|
||||||
|
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
||||||
|
self.send_json(Method::POST, "/objectives", Some(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edit_objective(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
input: &ObjectiveEditRequest,
|
||||||
|
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
||||||
|
self.send_json(
|
||||||
|
Method::PATCH,
|
||||||
|
&format!("/objectives/{}", encode_path_segment(id)),
|
||||||
|
Some(input),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_objective_state(
|
||||||
|
&self,
|
||||||
|
id: &str,
|
||||||
|
input: &ObjectiveStateRequest,
|
||||||
|
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
||||||
|
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<ObjectiveDetail, BackendWorkspaceClientError> {
|
||||||
|
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<ObjectiveDetail, BackendWorkspaceClientError> {
|
||||||
|
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<String, BackendWorkspaceClientError> {
|
||||||
|
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<String, BackendWorkspaceClientError> {
|
||||||
|
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<R: DeserializeOwned>(&self, path: &str) -> Result<R, BackendWorkspaceClientError> {
|
||||||
|
self.send_json::<(), R>(Method::GET, path, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_json<B: Serialize + ?Sized, R: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<&B>,
|
||||||
|
) -> Result<R, BackendWorkspaceClientError> {
|
||||||
|
let response = self.request(method, path, body)?.send()?;
|
||||||
|
let response = ensure_success(response)?;
|
||||||
|
response.json().map_err(BackendWorkspaceClientError::Http)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_unit<B: Serialize + ?Sized>(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<&B>,
|
||||||
|
) -> Result<(), BackendWorkspaceClientError> {
|
||||||
|
ensure_success(self.request(method, path, body)?.send()?)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request<B: Serialize + ?Sized>(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path: &str,
|
||||||
|
body: Option<&B>,
|
||||||
|
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
|
||||||
|
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<Vec<TicketSummary>> {
|
||||||
|
self.list_tickets(&filter).map_err(ticket_client_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show(&self, id: TicketIdOrSlug) -> ticket::Result<Ticket> {
|
||||||
|
self.show_ticket(&id).map_err(ticket_client_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create(&self, input: NewTicket) -> ticket::Result<TicketRef> {
|
||||||
|
self.create_ticket(&input).map_err(ticket_client_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> ticket::Result<Ticket> {
|
||||||
|
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<TicketDependencyCheck> {
|
||||||
|
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<Ticket> {
|
||||||
|
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<TicketRelation> {
|
||||||
|
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<TicketRelation> {
|
||||||
|
#[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<TicketIdOrSlug>,
|
||||||
|
kind: Option<TicketRelationKind>,
|
||||||
|
) -> ticket::Result<Vec<TicketRelation>> {
|
||||||
|
BackendWorkspaceProductClient::query_ticket_relations(self, ticket.as_ref(), kind)
|
||||||
|
.map_err(ticket_client_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relation_view(&self, id: TicketIdOrSlug) -> ticket::Result<TicketRelationView> {
|
||||||
|
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<OrchestrationPlanRecord> {
|
||||||
|
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<TicketIdOrSlug>,
|
||||||
|
kind: Option<OrchestrationPlanKind>,
|
||||||
|
) -> ticket::Result<Vec<OrchestrationPlanRecord>> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Query {
|
||||||
|
ticket: Option<TicketIdOrSlug>,
|
||||||
|
kind: Option<OrchestrationPlanKind>,
|
||||||
|
}
|
||||||
|
self.send_json(
|
||||||
|
Method::POST,
|
||||||
|
"/tickets/orchestration-plans/search",
|
||||||
|
Some(&Query { ticket, kind }),
|
||||||
|
)
|
||||||
|
.map_err(ticket_client_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doctor(&self) -> ticket::Result<TicketDoctorReport> {
|
||||||
|
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<reqwest::blocking::Response, BackendWorkspaceClientError> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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<String>, 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<String>, 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TicketSummary>,
|
||||||
|
objectives: Vec<ObjectiveSummary>,
|
||||||
|
focus: Focus,
|
||||||
|
selected_ticket: usize,
|
||||||
|
selected_objective: usize,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn launch(
|
||||||
|
base_url: String,
|
||||||
|
workspace_id: String,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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<TicketSummary>, Vec<ObjectiveSummary>), Box<dyn std::error::Error>> {
|
||||||
|
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<CrosstermBackend<io::Stdout>>,
|
||||||
|
app: &mut BackendDashboard,
|
||||||
|
client: BackendWorkspaceProductClient,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
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<String> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod app;
|
mod app;
|
||||||
|
mod backend_dashboard;
|
||||||
mod backend_worker_picker;
|
mod backend_worker_picker;
|
||||||
mod backend_workspace_picker;
|
mod backend_workspace_picker;
|
||||||
mod block;
|
mod block;
|
||||||
@@ -181,7 +182,13 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
|||||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||||
},
|
},
|
||||||
LaunchMode::Panel { include_stopped } => match target.dashboard() {
|
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<dyn std::error::Error>),
|
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,13 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
typescript = ["dep:ts-rs"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
ts-rs = { version = "12.0.1", optional = true }
|
||||||
workdir.workspace = true
|
workdir.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -32,6 +32,108 @@ pub struct ListResponse<T> {
|
|||||||
pub diagnostics: Vec<Diagnostic>,
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
pub sort: String,
|
||||||
|
pub source_limit: Option<usize>,
|
||||||
|
pub source_truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ObjectiveEventDetail {
|
||||||
|
pub event_ref: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub body: Option<String>,
|
||||||
|
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<String>,
|
||||||
|
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<String>,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
pub summary: String,
|
||||||
|
pub linked_tickets: Vec<String>,
|
||||||
|
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<String>,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
pub linked_tickets: Vec<String>,
|
||||||
|
pub linked_ticket_summaries: Vec<ObjectiveLinkedTicketSummary>,
|
||||||
|
pub resources: Vec<ObjectiveResourceSummary>,
|
||||||
|
pub body: String,
|
||||||
|
pub body_truncated: bool,
|
||||||
|
pub events: Vec<ObjectiveEventDetail>,
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_objective_state() -> String {
|
||||||
|
"active".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
pub struct ObjectiveEditRequest {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub old_string: Option<String>,
|
||||||
|
pub new_string: Option<String>,
|
||||||
|
#[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)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RuntimeSourceKind {
|
pub enum RuntimeSourceKind {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["typescript"]
|
default = ["typescript"]
|
||||||
typescript = ["dep:ts-rs"]
|
typescript = ["dep:ts-rs", "workspace-api/typescript"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
use project_record::validate_record_id;
|
use project_record::validate_record_id;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
pub use workspace_api::{
|
||||||
|
ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkedTicketSummary, ObjectiveResourceSummary,
|
||||||
|
ObjectiveSummary, QueryPage,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
@@ -226,18 +230,6 @@ impl From<ticket::TicketRelationView> 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<String>,
|
|
||||||
pub sort: String,
|
|
||||||
pub source_limit: Option<usize>,
|
|
||||||
pub source_truncated: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct ObjectiveLinkSummary {
|
pub struct ObjectiveLinkSummary {
|
||||||
@@ -418,63 +410,6 @@ pub struct ObjectiveShowRequest {
|
|||||||
pub event_cursor: Option<String>,
|
pub event_cursor: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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<String>,
|
|
||||||
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<String>,
|
|
||||||
pub updated_at: Option<String>,
|
|
||||||
pub summary: String,
|
|
||||||
pub linked_tickets: Vec<String>,
|
|
||||||
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<String>,
|
|
||||||
pub updated_at: Option<String>,
|
|
||||||
pub linked_tickets: Vec<String>,
|
|
||||||
pub linked_ticket_summaries: Vec<ObjectiveLinkedTicketSummary>,
|
|
||||||
pub resources: Vec<ObjectiveResourceSummary>,
|
|
||||||
pub body: String,
|
|
||||||
pub body_truncated: bool,
|
|
||||||
pub events: Vec<ObjectiveEventDetail>,
|
|
||||||
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<String>,
|
|
||||||
pub bytes: usize,
|
|
||||||
pub updated_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "typescript")]
|
#[cfg(feature = "typescript")]
|
||||||
pub fn ticket_api_typescript() -> String {
|
pub fn ticket_api_typescript() -> String {
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ use workdir::workspace::{
|
|||||||
use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjectRef};
|
use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjectRef};
|
||||||
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
||||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||||
|
use workspace_api::{
|
||||||
|
ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
AuthPublicConfig, AuthenticatedUser, RequestActor, auth_error, is_expired, mint_secret, new_id,
|
AuthPublicConfig, AuthenticatedUser, RequestActor, auth_error, is_expired, mint_secret, new_id,
|
||||||
@@ -2648,26 +2651,6 @@ struct ObjectiveListQuery {
|
|||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct ObjectiveCreateRequest {
|
|
||||||
title: String,
|
|
||||||
#[serde(default)]
|
|
||||||
body_md: String,
|
|
||||||
#[serde(default = "default_objective_state")]
|
|
||||||
state: String,
|
|
||||||
#[serde(default)]
|
|
||||||
linked_tickets: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct ObjectiveEditRequest {
|
|
||||||
title: Option<String>,
|
|
||||||
old_string: Option<String>,
|
|
||||||
new_string: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
replace_all: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct TicketListQuery {
|
struct TicketListQuery {
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
@@ -2676,16 +2659,6 @@ struct TicketListQuery {
|
|||||||
states: Option<String>,
|
states: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct ObjectiveStateRequest {
|
|
||||||
state: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct ObjectiveLinkTicketRequest {
|
|
||||||
ticket_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ScopedObjectivePath {
|
struct ScopedObjectivePath {
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
@@ -2699,10 +2672,6 @@ struct ScopedObjectiveTicketPath {
|
|||||||
ticket_id: String,
|
ticket_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_objective_state() -> String {
|
|
||||||
"active".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct TranscriptQuery {
|
struct TranscriptQuery {
|
||||||
start: Option<usize>,
|
start: Option<usize>,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ client = { workspace = true }
|
|||||||
memory = { workspace = true }
|
memory = { workspace = true }
|
||||||
manifest = { workspace = true }
|
manifest = { workspace = true }
|
||||||
worker = { workspace = true }
|
worker = { workspace = true }
|
||||||
|
workspace-api = { workspace = true }
|
||||||
session-store = { workspace = true }
|
session-store = { workspace = true }
|
||||||
session-analytics = { workspace = true }
|
session-analytics = { workspace = true }
|
||||||
ticket = { workspace = true }
|
ticket = { workspace = true }
|
||||||
|
|||||||
@@ -55,14 +55,14 @@ impl CliCommand {
|
|||||||
CliCommand::DefaultTui
|
CliCommand::DefaultTui
|
||||||
| CliCommand::Workers
|
| CliCommand::Workers
|
||||||
| CliCommand::Resume
|
| CliCommand::Resume
|
||||||
| CliCommand::Panel => CliConnectionRequirement::ConnectionAware,
|
| CliCommand::Panel
|
||||||
|
| CliCommand::Ticket
|
||||||
|
| CliCommand::Objective => CliConnectionRequirement::ConnectionAware,
|
||||||
CliCommand::Login => CliConnectionRequirement::BackendOnly,
|
CliCommand::Login => CliConnectionRequirement::BackendOnly,
|
||||||
CliCommand::Keys
|
CliCommand::Keys
|
||||||
| CliCommand::SetupModel
|
| CliCommand::SetupModel
|
||||||
| CliCommand::WorkerRuntime
|
| CliCommand::WorkerRuntime
|
||||||
| CliCommand::WorkerCleanup
|
| CliCommand::WorkerCleanup
|
||||||
| CliCommand::Ticket
|
|
||||||
| CliCommand::Objective
|
|
||||||
| CliCommand::Plugin
|
| CliCommand::Plugin
|
||||||
| CliCommand::Mcp
|
| CliCommand::Mcp
|
||||||
| CliCommand::MemoryLint
|
| CliCommand::MemoryLint
|
||||||
@@ -259,6 +259,8 @@ mod tests {
|
|||||||
CliCommand::Workers,
|
CliCommand::Workers,
|
||||||
CliCommand::Resume,
|
CliCommand::Resume,
|
||||||
CliCommand::Panel,
|
CliCommand::Panel,
|
||||||
|
CliCommand::Ticket,
|
||||||
|
CliCommand::Objective,
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
command.connection_requirement(),
|
command.connection_requirement(),
|
||||||
@@ -277,8 +279,6 @@ mod tests {
|
|||||||
CliCommand::SetupModel,
|
CliCommand::SetupModel,
|
||||||
CliCommand::WorkerRuntime,
|
CliCommand::WorkerRuntime,
|
||||||
CliCommand::WorkerCleanup,
|
CliCommand::WorkerCleanup,
|
||||||
CliCommand::Ticket,
|
|
||||||
CliCommand::Objective,
|
|
||||||
CliCommand::Plugin,
|
CliCommand::Plugin,
|
||||||
CliCommand::Mcp,
|
CliCommand::Mcp,
|
||||||
CliCommand::MemoryLint,
|
CliCommand::MemoryLint,
|
||||||
|
|||||||
+113
-20
@@ -34,10 +34,16 @@ enum Mode {
|
|||||||
MemoryLint(LintCliOptions),
|
MemoryLint(LintCliOptions),
|
||||||
Mcp(mcp_cli::McpCliCommand),
|
Mcp(mcp_cli::McpCliCommand),
|
||||||
Plugin(plugin_cli::PluginCliCommand),
|
Plugin(plugin_cli::PluginCliCommand),
|
||||||
Objective(objective_cli::ObjectiveCli),
|
Objective {
|
||||||
|
cli: objective_cli::ObjectiveCli,
|
||||||
|
target: client::ResolvedTarget,
|
||||||
|
},
|
||||||
Session(session_cli::SessionCli),
|
Session(session_cli::SessionCli),
|
||||||
WorkerCleanup(worker_cleanup_cli::WorkerCleanupCli),
|
WorkerCleanup(worker_cleanup_cli::WorkerCleanupCli),
|
||||||
Ticket(ticket_cli::TicketCli),
|
Ticket {
|
||||||
|
cli: ticket_cli::TicketCli,
|
||||||
|
target: client::ResolvedTarget,
|
||||||
|
},
|
||||||
Login {
|
Login {
|
||||||
backend_url: String,
|
backend_url: String,
|
||||||
no_wait: bool,
|
no_wait: bool,
|
||||||
@@ -119,19 +125,25 @@ async fn main() -> ExitCode {
|
|||||||
ExitCode::FAILURE
|
ExitCode::FAILURE
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Mode::Objective(cli) => match objective_cli::run(cli) {
|
Mode::Objective { cli, target } => {
|
||||||
Ok(output) => {
|
match tokio::task::spawn_blocking(move || objective_cli::run(cli, target)).await {
|
||||||
|
Ok(Ok(output)) => {
|
||||||
print!("{}", output.stdout);
|
print!("{}", output.stdout);
|
||||||
match output.status {
|
match output.status {
|
||||||
objective_cli::ObjectiveCliStatus::Success => ExitCode::SUCCESS,
|
objective_cli::ObjectiveCliStatus::Success => ExitCode::SUCCESS,
|
||||||
objective_cli::ObjectiveCliStatus::Failure => ExitCode::FAILURE,
|
objective_cli::ObjectiveCliStatus::Failure => ExitCode::FAILURE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Ok(Err(e)) => {
|
||||||
eprintln!("yoi objective: {e}");
|
eprintln!("yoi objective: {e}");
|
||||||
ExitCode::FAILURE
|
ExitCode::FAILURE
|
||||||
}
|
}
|
||||||
},
|
Err(e) => {
|
||||||
|
eprintln!("yoi objective: execution task failed: {e}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Mode::Session(cli) => match session_cli::run(cli) {
|
Mode::Session(cli) => match session_cli::run(cli) {
|
||||||
Ok(output) => {
|
Ok(output) => {
|
||||||
print!("{}", output.stdout);
|
print!("{}", output.stdout);
|
||||||
@@ -158,19 +170,25 @@ async fn main() -> ExitCode {
|
|||||||
ExitCode::FAILURE
|
ExitCode::FAILURE
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Mode::Ticket(cli) => match ticket_cli::run(cli) {
|
Mode::Ticket { cli, target } => {
|
||||||
Ok(output) => {
|
match tokio::task::spawn_blocking(move || ticket_cli::run(cli, target)).await {
|
||||||
|
Ok(Ok(output)) => {
|
||||||
print!("{}", output.stdout);
|
print!("{}", output.stdout);
|
||||||
match output.status {
|
match output.status {
|
||||||
ticket_cli::TicketCliStatus::Success => ExitCode::SUCCESS,
|
ticket_cli::TicketCliStatus::Success => ExitCode::SUCCESS,
|
||||||
ticket_cli::TicketCliStatus::Failure => ExitCode::FAILURE,
|
ticket_cli::TicketCliStatus::Failure => ExitCode::FAILURE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Ok(Err(e)) => {
|
||||||
eprintln!("yoi ticket: {e}");
|
eprintln!("yoi ticket: {e}");
|
||||||
ExitCode::FAILURE
|
ExitCode::FAILURE
|
||||||
}
|
}
|
||||||
},
|
Err(e) => {
|
||||||
|
eprintln!("yoi ticket: execution task failed: {e}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Mode::WorkerRuntime(args) => worker::entrypoint::run_cli_from("yoi worker", args).await,
|
Mode::WorkerRuntime(args) => worker::entrypoint::run_cli_from("yoi worker", args).await,
|
||||||
Mode::Keys => tui::keys::launch().await,
|
Mode::Keys => tui::keys::launch().await,
|
||||||
Mode::SetupModel => tui::setup_model::launch().await,
|
Mode::SetupModel => tui::setup_model::launch().await,
|
||||||
@@ -357,10 +375,18 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
|||||||
return Ok(Mode::WorkerRuntime(args[1..].to_vec()));
|
return Ok(Mode::WorkerRuntime(args[1..].to_vec()));
|
||||||
}
|
}
|
||||||
"objective" => {
|
"objective" => {
|
||||||
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Objective)?;
|
let workspace_root = current_dir()?;
|
||||||
let objective_cli = objective_cli::parse_objective_args(&args[1..])
|
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()))?;
|
.map_err(|e| ParseError(e.to_string()))?;
|
||||||
return Ok(Mode::Objective(objective_cli));
|
return Ok(Mode::Objective { cli, target });
|
||||||
}
|
}
|
||||||
"session" => {
|
"session" => {
|
||||||
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Session)?;
|
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Session)?;
|
||||||
@@ -369,10 +395,18 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
|
|||||||
return Ok(Mode::Session(session_cli));
|
return Ok(Mode::Session(session_cli));
|
||||||
}
|
}
|
||||||
"ticket" => {
|
"ticket" => {
|
||||||
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Ticket)?;
|
let workspace_root = current_dir()?;
|
||||||
let ticket_cli =
|
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()))?;
|
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" => {
|
"plugin" => {
|
||||||
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::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 [WORKER_OPTIONS] Run the local Worker runtime CLI
|
||||||
worker delete <NAME> Delete local Worker records
|
worker delete <NAME> Delete local Worker records
|
||||||
worker prune Prune old local Worker records
|
worker prune Prune old local Worker records
|
||||||
ticket <COMMAND> Manage Tickets through the local workspace authority
|
ticket <COMMAND> Manage Tickets through the selected target
|
||||||
objective <COMMAND> Manage Objectives through the local workspace authority
|
objective <COMMAND> Manage Objectives through the selected target
|
||||||
plugin <COMMAND> Build/check/list/show plugins
|
plugin <COMMAND> Build/check/list/show plugins
|
||||||
mcp <COMMAND> Inspect configured MCP servers
|
mcp <COMMAND> Inspect configured MCP servers
|
||||||
memory lint Lint local memory files
|
memory lint Lint local memory files
|
||||||
@@ -2105,11 +2139,67 @@ backend = "shared"
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_ticket_subcommand_uses_ticket_mode() {
|
fn parse_ticket_subcommand_uses_ticket_mode() {
|
||||||
match parse_args_from(["ticket", "doctor"]).unwrap() {
|
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"),
|
_ => 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]
|
#[test]
|
||||||
fn parse_session_analyze_uses_session_mode() {
|
fn parse_session_analyze_uses_session_mode() {
|
||||||
match parse_args_from(["session", "analyze", "/tmp/session.jsonl", "--json"]).unwrap() {
|
match parse_args_from(["session", "analyze", "/tmp/session.jsonl", "--json"]).unwrap() {
|
||||||
@@ -2124,7 +2214,10 @@ backend = "shared"
|
|||||||
#[test]
|
#[test]
|
||||||
fn parse_ticket_help_uses_ticket_mode() {
|
fn parse_ticket_help_uses_ticket_mode() {
|
||||||
match parse_args_from(["ticket", "--help"]).unwrap() {
|
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"),
|
_ => panic!("expected Ticket help mode"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::fs;
|
|||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use client::{BackendWorkspaceProductClient, ResolvedTarget};
|
||||||
use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_id};
|
use project_record::{allocate_record_id, unix_epoch_millis_now, validate_record_id};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use ticket::config::TicketConfig;
|
use ticket::config::TicketConfig;
|
||||||
@@ -167,12 +168,101 @@ pub fn parse_objective_args(args: &[String]) -> Result<ObjectiveCli, ObjectiveCl
|
|||||||
Ok(ObjectiveCli::Command(command))
|
Ok(ObjectiveCli::Command(command))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(cli: ObjectiveCli) -> Result<ObjectiveCliOutput, ObjectiveCliError> {
|
pub fn run(
|
||||||
|
cli: ObjectiveCli,
|
||||||
|
target: ResolvedTarget,
|
||||||
|
) -> Result<ObjectiveCliOutput, ObjectiveCliError> {
|
||||||
|
match target {
|
||||||
|
ResolvedTarget::Local => {
|
||||||
let workspace = std::env::current_dir().map_err(|error| {
|
let workspace = std::env::current_dir().map_err(|error| {
|
||||||
ObjectiveCliError::new(format!("failed to resolve current directory: {error}"))
|
ObjectiveCliError::new(format!("failed to resolve current directory: {error}"))
|
||||||
})?;
|
})?;
|
||||||
run_in_workspace(cli, &workspace)
|
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<ObjectiveCliOutput, ObjectiveCliError> {
|
||||||
|
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(
|
pub fn run_in_workspace(
|
||||||
cli: ObjectiveCli,
|
cli: ObjectiveCli,
|
||||||
@@ -453,15 +543,21 @@ fn list_state_matches(filter: ObjectiveListState, state: Option<ObjectiveState>)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
fn render_objective_item(title: &str, linked_tickets: &[String]) -> String {
|
||||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
let now = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
format!(
|
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(title),
|
||||||
yaml_string(ObjectiveState::Active.as_str()),
|
yaml_string(ObjectiveState::Active.as_str()),
|
||||||
yaml_string(&now),
|
yaml_string(&now),
|
||||||
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 {
|
fn help_text() -> &'static str {
|
||||||
"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\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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use std::io::Write;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use chrono::{SecondsFormat, Utc};
|
use chrono::{SecondsFormat, Utc};
|
||||||
|
use client::{BackendWorkspaceProductClient, ResolvedTarget};
|
||||||
use ticket::config::{
|
use ticket::config::{
|
||||||
TICKET_CONFIG_RELATIVE_PATH, TicketConfig, WORKSPACE_SETTINGS_RELATIVE_PATH,
|
TICKET_CONFIG_RELATIVE_PATH, TicketConfig, WORKSPACE_SETTINGS_RELATIVE_PATH,
|
||||||
ticket_config_scaffold,
|
ticket_config_scaffold,
|
||||||
@@ -205,12 +206,33 @@ pub fn parse_ticket_args(args: &[String]) -> Result<TicketCli, TicketCliError> {
|
|||||||
Ok(TicketCli::Command(command))
|
Ok(TicketCli::Command(command))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(cli: TicketCli) -> Result<TicketCliOutput, TicketCliError> {
|
pub fn run(cli: TicketCli, target: ResolvedTarget) -> Result<TicketCliOutput, TicketCliError> {
|
||||||
|
match target {
|
||||||
|
ResolvedTarget::Local => {
|
||||||
let workspace = std::env::current_dir().map_err(|error| {
|
let workspace = std::env::current_dir().map_err(|error| {
|
||||||
TicketCliError::new(format!("failed to resolve current directory: {error}"))
|
TicketCliError::new(format!("failed to resolve current directory: {error}"))
|
||||||
})?;
|
})?;
|
||||||
run_in_workspace(cli, &workspace)
|
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(
|
pub fn run_in_workspace(
|
||||||
cli: TicketCli,
|
cli: TicketCli,
|
||||||
@@ -234,20 +256,27 @@ fn run_command(
|
|||||||
TicketCommand::ImportLocal => import_local(workspace),
|
TicketCommand::ImportLocal => import_local(workspace),
|
||||||
command => {
|
command => {
|
||||||
let backend = backend_for_workspace(workspace)?;
|
let backend = backend_for_workspace(workspace)?;
|
||||||
|
run_backend_command(command, backend.as_ref())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_backend_command(
|
||||||
|
command: TicketCommand,
|
||||||
|
backend: &dyn TicketBackend,
|
||||||
|
) -> Result<TicketCliOutput, TicketCliError> {
|
||||||
match command {
|
match command {
|
||||||
TicketCommand::Create(options) => create(backend.as_ref(), options),
|
TicketCommand::Create(options) => create(backend, options),
|
||||||
TicketCommand::List(options) => list(backend.as_ref(), options),
|
TicketCommand::List(options) => list(backend, options),
|
||||||
TicketCommand::Show { query } => show(backend.as_ref(), query),
|
TicketCommand::Show { query } => show(backend, query),
|
||||||
TicketCommand::Comment(options) => comment(backend.as_ref(), options),
|
TicketCommand::Comment(options) => comment(backend, options),
|
||||||
TicketCommand::State(options) => state(backend.as_ref(), options),
|
TicketCommand::State(options) => state(backend, options),
|
||||||
TicketCommand::Close(options) => close(backend.as_ref(), options),
|
TicketCommand::Close(options) => close(backend, options),
|
||||||
TicketCommand::Relation(options) => relation(backend.as_ref(), options),
|
TicketCommand::Relation(options) => relation(backend, options),
|
||||||
TicketCommand::Doctor => doctor(backend.as_ref()),
|
TicketCommand::Doctor => doctor(backend),
|
||||||
TicketCommand::Init | TicketCommand::ImportLocal => {
|
TicketCommand::Init | TicketCommand::ImportLocal => Err(TicketCliError::new(
|
||||||
unreachable!("handled before backend setup")
|
"ticket init/import-local require an explicit local target",
|
||||||
}
|
)),
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1153,7 +1182,7 @@ fn default_author() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn help_text() -> &'static str {
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
|
|||||||
filter = sourceFilter;
|
filter = sourceFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
cargoHash = "sha256-R33Ty4414wGkqnwCt08zbDQbVu9ggMC5I3ZawgloNT0=";
|
cargoHash = "sha256-LjswbsalQA0Yny0DIBJg3FiqwdmTADZ1CnqNJ9oo9Kw=";
|
||||||
|
|
||||||
depsExtraArgs = {
|
depsExtraArgs = {
|
||||||
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
|
||||||
|
|||||||
Reference in New Issue
Block a user