From 7265041e55dab8b485d203512c7323efe59f7b1a Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 29 Jul 2026 21:58:01 +0900 Subject: [PATCH] objective: add mutation tools --- .../worker/src/feature/builtin/objective.rs | 391 +++++++++++++++++- crates/workspace-server/src/authority.rs | 387 +++++++++++++++-- crates/workspace-server/src/server.rs | 251 ++++++++++- crates/workspace-server/src/store.rs | 104 ++++- 4 files changed, 1064 insertions(+), 69 deletions(-) diff --git a/crates/worker/src/feature/builtin/objective.rs b/crates/worker/src/feature/builtin/objective.rs index 9e1b14a8..9c1cbc9e 100644 --- a/crates/worker/src/feature/builtin/objective.rs +++ b/crates/worker/src/feature/builtin/objective.rs @@ -1,4 +1,5 @@ -//! Backend Workspace API backed Objective read tools. +//! +//! Objective tool registration backed by Workspace API authority. //! //! Objectives are project-level planning context. Runtime Workers may not know //! local `.yoi/objectives` paths, so model-visible Objective tools go through @@ -43,23 +44,119 @@ impl WorkspaceHttpObjectiveBackend { } async fn show(&self, input: ObjectiveShowInput) -> Result { - let id = input.id.trim(); - if id.is_empty() || id.contains('/') { - return Err(ToolError::InvalidArgument( - "ObjectiveShow requires non-empty canonical id without '/'".to_string(), - )); - } - let url = format!( - "{}/api/w/{}/objectives/{}", - self.base_url, self.workspace_id, id - ); + let id = validate_id(&input.id, "ObjectiveShow")?; + let url = self.objective_url(id); let response = get_json::(&url) .await .map_err(backend_error)?; - Ok(ToolOutput { - summary: format!("Read objective {}", response.id), - content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), - }) + Ok(objective_output( + format!("Read objective {}", response.id), + response, + )?) + } + + async fn create(&self, input: ObjectiveCreateInput) -> Result { + if input.title.trim().is_empty() { + return Err(ToolError::InvalidArgument( + "ObjectiveCreate requires non-empty title".to_string(), + )); + } + let url = format!("{}/api/w/{}/objectives", self.base_url, self.workspace_id); + let response = + send_json::(reqwest::Method::POST, &url, &input) + .await + .map_err(backend_error)?; + Ok(objective_output( + format!("Created objective {}", response.id), + response, + )?) + } + + async fn edit(&self, input: ObjectiveEditInput) -> Result { + let id = validate_id(&input.id, "ObjectiveEdit")?; + if input.title.is_none() && input.old_string.is_none() && input.new_string.is_none() { + return Err(ToolError::InvalidArgument( + "ObjectiveEdit requires title or old_string/new_string".to_string(), + )); + } + let url = self.objective_url(id); + let body = ObjectiveEditRequest { + title: input.title, + old_string: input.old_string, + new_string: input.new_string, + replace_all: input.replace_all, + }; + let response = + send_json::(reqwest::Method::PATCH, &url, &body) + .await + .map_err(backend_error)?; + Ok(objective_output( + format!("Edited objective {}", response.id), + response, + )?) + } + + async fn set_state(&self, input: ObjectiveSetStateInput) -> Result { + let id = validate_id(&input.id, "ObjectiveSetState")?; + if input.state.trim().is_empty() { + return Err(ToolError::InvalidArgument( + "ObjectiveSetState requires non-empty state".to_string(), + )); + } + let url = format!("{}/state", self.objective_url(id)); + let response = send_json::( + reqwest::Method::POST, + &url, + &ObjectiveSetStateRequest { state: input.state }, + ) + .await + .map_err(backend_error)?; + Ok(objective_output( + format!("Updated objective {} state", response.id), + response, + )?) + } + + async fn link_ticket(&self, input: ObjectiveLinkTicketInput) -> Result { + let id = validate_id(&input.id, "ObjectiveLinkTicket")?; + let ticket_id = validate_id(&input.ticket_id, "ObjectiveLinkTicket")?; + let url = format!("{}/ticket-links", self.objective_url(id)); + let response = send_json::( + reqwest::Method::POST, + &url, + &ObjectiveLinkTicketRequest { + ticket_id: ticket_id.to_string(), + }, + ) + .await + .map_err(backend_error)?; + Ok(objective_output( + format!("Linked ticket {ticket_id} to objective {}", response.id), + response, + )?) + } + + async fn unlink_ticket( + &self, + input: ObjectiveUnlinkTicketInput, + ) -> Result { + let id = validate_id(&input.id, "ObjectiveUnlinkTicket")?; + let ticket_id = validate_id(&input.ticket_id, "ObjectiveUnlinkTicket")?; + let url = format!("{}/ticket-links/{}", self.objective_url(id), ticket_id); + let response = delete_json::(&url) + .await + .map_err(backend_error)?; + Ok(objective_output( + format!("Unlinked ticket {ticket_id} from objective {}", response.id), + response, + )?) + } + + fn objective_url(&self, id: &str) -> String { + format!( + "{}/api/w/{}/objectives/{}", + self.base_url, self.workspace_id, id + ) } } @@ -88,6 +185,32 @@ async fn get_json Deserialize<'de>>( url: &str, ) -> Result { let response = reqwest::Client::new().get(url).send().await?; + decode_response(response).await +} + +async fn send_json Deserialize<'de>>( + method: reqwest::Method, + url: &str, + body: &B, +) -> Result { + let response = reqwest::Client::new() + .request(method, url) + .json(body) + .send() + .await?; + decode_response(response).await +} + +async fn delete_json Deserialize<'de>>( + url: &str, +) -> Result { + let response = reqwest::Client::new().delete(url).send().await?; + decode_response(response).await +} + +async fn decode_response Deserialize<'de>>( + response: reqwest::Response, +) -> Result { let status = response.status(); let body = response.text().await?; if !status.is_success() { @@ -96,6 +219,23 @@ async fn get_json Deserialize<'de>>( serde_json::from_str(&body).map_err(Into::into) } +fn objective_output(summary: String, response: ObjectiveDetail) -> Result { + Ok(ToolOutput { + summary, + content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), + }) +} + +fn validate_id<'a>(id: &'a str, tool_name: &str) -> Result<&'a str, ToolError> { + let id = id.trim(); + if id.is_empty() || id.contains('/') { + return Err(ToolError::InvalidArgument(format!( + "{tool_name} requires non-empty canonical id without '/'" + ))); + } + Ok(id) +} + pub fn workspace_http_objective_tools( workspace_id: impl Into, base_url: impl Into, @@ -113,9 +253,44 @@ pub fn workspace_http_objective_tools( "ObjectiveShow", SHOW_DESCRIPTION, show_schema(), - backend, + backend.clone(), ObjectiveOperation::Show, ), + objective_tool( + "ObjectiveCreate", + CREATE_DESCRIPTION, + create_schema(), + backend.clone(), + ObjectiveOperation::Create, + ), + objective_tool( + "ObjectiveEdit", + EDIT_DESCRIPTION, + edit_schema(), + backend.clone(), + ObjectiveOperation::Edit, + ), + objective_tool( + "ObjectiveSetState", + SET_STATE_DESCRIPTION, + set_state_schema(), + backend.clone(), + ObjectiveOperation::SetState, + ), + objective_tool( + "ObjectiveLinkTicket", + LINK_TICKET_DESCRIPTION, + link_ticket_schema(), + backend.clone(), + ObjectiveOperation::LinkTicket, + ), + objective_tool( + "ObjectiveUnlinkTicket", + UNLINK_TICKET_DESCRIPTION, + unlink_ticket_schema(), + backend, + ObjectiveOperation::UnlinkTicket, + ), ] } @@ -123,6 +298,11 @@ pub fn workspace_http_objective_tools( enum ObjectiveOperation { List, Show, + Create, + Edit, + SetState, + LinkTicket, + UnlinkTicket, } fn objective_tool( @@ -167,6 +347,26 @@ impl Tool for WorkspaceHttpObjectiveTool { let input = parse_input::(input_json)?; self.backend.show(input).await } + ObjectiveOperation::Create => { + let input = parse_input::(input_json)?; + self.backend.create(input).await + } + ObjectiveOperation::Edit => { + let input = parse_input::(input_json)?; + self.backend.edit(input).await + } + ObjectiveOperation::SetState => { + let input = parse_input::(input_json)?; + self.backend.set_state(input).await + } + ObjectiveOperation::LinkTicket => { + let input = parse_input::(input_json)?; + self.backend.link_ticket(input).await + } + ObjectiveOperation::UnlinkTicket => { + let input = parse_input::(input_json)?; + self.backend.unlink_ticket(input).await + } } } } @@ -179,6 +379,16 @@ const LIST_DESCRIPTION: &str = "List Objective records through Backend Workspace API authority as bounded summaries."; const SHOW_DESCRIPTION: &str = "Show one Objective record by canonical id through Backend Workspace API authority."; +const CREATE_DESCRIPTION: &str = + "Create an Objective record through Backend Workspace API authority."; +const EDIT_DESCRIPTION: &str = + "Partially edit an Objective title and/or body through Backend Workspace API authority."; +const SET_STATE_DESCRIPTION: &str = + "Set an Objective state through Backend Workspace API authority."; +const LINK_TICKET_DESCRIPTION: &str = + "Link a Ticket id to an Objective through Backend Workspace API authority."; +const UNLINK_TICKET_DESCRIPTION: &str = + "Unlink a Ticket id from an Objective through Backend Workspace API authority."; fn list_schema() -> serde_json::Value { json!({ @@ -191,16 +401,81 @@ fn list_schema() -> serde_json::Value { } fn show_schema() -> serde_json::Value { + id_schema(&["id"]) +} + +fn create_schema() -> serde_json::Value { + json!({ + "type":"object", + "additionalProperties": false, + "required":["title"], + "properties":{ + "title":{"type":"string","minLength":1}, + "body_md":{"type":"string"}, + "state":{"type":"string","default":"active"}, + "linked_tickets":{"type":"array","items":{"type":"string"}} + } + }) +} + +fn edit_schema() -> serde_json::Value { json!({ "type":"object", "additionalProperties": false, "required":["id"], + "properties":{ + "id":{"type":"string"}, + "title":{"type":["string","null"]}, + "old_string":{"type":["string","null"]}, + "new_string":{"type":["string","null"]}, + "replace_all":{"type":"boolean","default":false} + } + }) +} + +fn set_state_schema() -> serde_json::Value { + json!({ + "type":"object", + "additionalProperties": false, + "required":["id","state"], + "properties":{ + "id":{"type":"string"}, + "state":{"type":"string","minLength":1} + } + }) +} + +fn link_ticket_schema() -> serde_json::Value { + id_ticket_schema(&["id", "ticket_id"]) +} + +fn unlink_ticket_schema() -> serde_json::Value { + id_ticket_schema(&["id", "ticket_id"]) +} + +fn id_schema(required: &[&str]) -> serde_json::Value { + json!({ + "type":"object", + "additionalProperties": false, + "required": required, "properties":{ "id":{"type":"string"} } }) } +fn id_ticket_schema(required: &[&str]) -> serde_json::Value { + json!({ + "type":"object", + "additionalProperties": false, + "required": required, + "properties":{ + "id":{"type":"string"}, + "ticket_id":{"type":"string"} + } + }) +} + #[derive(Debug, Deserialize)] struct ObjectiveListInput { limit: Option, @@ -211,6 +486,67 @@ struct ObjectiveShowInput { id: String, } +#[derive(Debug, Serialize, Deserialize)] +struct ObjectiveCreateInput { + title: String, + #[serde(default)] + body_md: String, + #[serde(default = "default_state")] + state: String, + #[serde(default)] + linked_tickets: Vec, +} + +#[derive(Debug, Deserialize)] +struct ObjectiveEditInput { + id: String, + title: Option, + old_string: Option, + new_string: Option, + #[serde(default)] + replace_all: bool, +} + +#[derive(Debug, Serialize)] +struct ObjectiveEditRequest { + title: Option, + old_string: Option, + new_string: Option, + replace_all: bool, +} + +#[derive(Debug, Deserialize)] +struct ObjectiveSetStateInput { + id: String, + state: String, +} + +#[derive(Debug, Serialize)] +struct ObjectiveSetStateRequest { + state: String, +} + +#[derive(Debug, Deserialize)] +struct ObjectiveLinkTicketInput { + id: String, + ticket_id: String, +} + +#[derive(Debug, Deserialize)] +struct ObjectiveUnlinkTicketInput { + id: String, + ticket_id: String, +} + +#[derive(Debug, Serialize)] +struct ObjectiveLinkTicketRequest { + ticket_id: String, +} + +fn default_state() -> String { + "active".to_string() +} + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] struct ObjectiveListResponse { items: Vec, @@ -263,20 +599,37 @@ mod tests { } #[test] - fn workspace_http_objective_tools_include_read_only_objective_tools() { + fn workspace_http_objective_tools_include_objective_crud_tools() { let names = tool_names(workspace_http_objective_tools( "workspace".to_string(), "http://backend".to_string(), )); - assert_eq!(names, vec!["ObjectiveList", "ObjectiveShow"]); + assert_eq!( + names, + vec![ + "ObjectiveCreate", + "ObjectiveEdit", + "ObjectiveLinkTicket", + "ObjectiveList", + "ObjectiveSetState", + "ObjectiveShow", + "ObjectiveUnlinkTicket", + ] + ); } #[test] - fn objective_tool_schemas_are_bounded_and_read_only() { + fn objective_tool_schemas_are_bounded_and_mutation_scoped() { let list = list_schema(); assert_eq!(list["properties"]["limit"]["maximum"], 1000); let show = show_schema(); assert_eq!(show["required"][0], "id"); + let create = create_schema(); + assert_eq!(create["required"][0], "title"); + let edit = edit_schema(); + assert_eq!(edit["required"][0], "id"); + let link = link_ticket_schema(); + assert_eq!(link["required"], json!(["id", "ticket_id"])); } } diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 2434d06b..bd4f7f1e 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use chrono::Utc; +use project_record::{allocate_record_id, unix_epoch_millis_now}; use ticket::{ SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery, @@ -13,7 +14,7 @@ use crate::records::{ }; use crate::store::{ ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, - SqliteWorkspaceStore, + ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore, }; use crate::{Error, Result}; @@ -38,6 +39,27 @@ pub trait TicketAuthority { pub trait ObjectiveAuthority { fn list_objectives(&self, limit: usize) -> Result>; fn objective(&self, id: &str) -> Result; + fn create_objective(&self, input: ObjectiveCreateInput) -> Result; + fn edit_objective(&self, id: &str, input: ObjectiveEditInput) -> Result; + fn set_objective_state(&self, id: &str, state: &str) -> Result; + fn link_objective_ticket(&self, id: &str, ticket_id: &str) -> Result; + fn unlink_objective_ticket(&self, id: &str, ticket_id: &str) -> Result; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObjectiveCreateInput { + pub title: String, + pub body_md: String, + pub state: String, + pub linked_tickets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ObjectiveEditInput { + pub title: Option, + pub old_string: Option, + pub new_string: Option, + pub replace_all: bool, } pub trait MemoryAuthority { @@ -110,6 +132,75 @@ impl SqliteWorkspaceAuthority { ticket_backend: SqliteTicketBackend::new(database_path, workspace_id), }) } + + fn objective_record(&self, id: &str) -> Result { + self.store + .get_objective(&self.workspace_id, id)? + .ok_or_else(|| unknown_objective_error(id)) + } + + fn objective_detail_from_record(&self, record: ObjectiveRecord) -> Result { + let linked_tickets = self + .store + .list_objective_ticket_links(&self.workspace_id, &record.objective_id)? + .into_iter() + .map(|link| link.ticket_id) + .collect::>(); + let resources = self + .store + .list_objective_resources(&self.workspace_id, &record.objective_id)? + .into_iter() + .map(|resource| ObjectiveResourceSummary { + path: resource.resource_path, + media_type: resource.media_type, + bytes: resource.body.len(), + updated_at: resource.updated_at, + }) + .collect(); + let (body, body_truncated) = truncate_body(&record.body_md, DETAIL_BODY_LIMIT); + Ok(ObjectiveDetail { + id: record.objective_id, + title: record.title, + state: record.state, + created_at: Some(record.created_at), + updated_at: Some(record.updated_at), + linked_tickets, + resources, + body, + body_truncated, + record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), + }) + } + + fn insert_objective_event( + &self, + objective_id: &str, + kind: &str, + body_md: Option<&str>, + ) -> Result<()> { + let event_id = allocate_record_id( + unix_epoch_millis_now().map_err(|err| { + invalid_objective_error(format!("failed to read objective event clock: {err}")) + })?, + |candidate| { + self.store + .list_objective_events(&self.workspace_id, objective_id) + .map(|events| events.iter().any(|event| event.event_id == candidate)) + .unwrap_or(true) + }, + ) + .map_err(|err| { + invalid_objective_error(format!("failed to allocate objective event id: {err}")) + })?; + self.store.insert_objective_event(&ObjectiveEventRecord { + workspace_id: self.workspace_id.clone(), + objective_id: objective_id.to_string(), + event_id, + kind: kind.to_string(), + body_md: body_md.map(str::to_string), + created_at: now_rfc3339(), + }) + } } impl TicketAuthority for SqliteWorkspaceAuthority { @@ -189,52 +280,172 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority { updated_at: Some(record.updated_at), summary: summarize_body(&record.body_md), linked_tickets, - record_source: "workspace-sqlite".to_string(), + record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), }); } Ok(ProjectRecordList { items, invalid_records: Vec::new(), - record_authority: "workspace-sqlite".to_string(), + record_authority: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), }) } fn objective(&self, id: &str) -> Result { validate_project_id(id)?; - let record = self - .store - .get_objective(&self.workspace_id, id)? - .ok_or_else(|| Error::Store(format!("unknown objective `{id}`")))?; - let linked_tickets = self - .store - .list_objective_ticket_links(&self.workspace_id, &record.objective_id)? + let record = self.objective_record(id)?; + self.objective_detail_from_record(record) + } + + fn create_objective(&self, input: ObjectiveCreateInput) -> Result { + validate_objective_title(&input.title)?; + validate_objective_state(&input.state)?; + for ticket_id in &input.linked_tickets { + validate_project_id(ticket_id)?; + } + let now = now_rfc3339(); + let objective_id = allocate_record_id( + unix_epoch_millis_now().map_err(|err| { + invalid_objective_error(format!("failed to read objective clock: {err}")) + })?, + |candidate| { + self.store + .get_objective(&self.workspace_id, candidate) + .map(|record| record.is_some()) + .unwrap_or(true) + }, + ) + .map_err(|err| { + invalid_objective_error(format!("failed to allocate objective id: {err}")) + })?; + let record = ObjectiveRecord { + workspace_id: self.workspace_id.clone(), + objective_id: objective_id.clone(), + title: input.title.trim().to_string(), + state: input.state.trim().to_string(), + body_md: input.body_md, + created_at: now.clone(), + updated_at: now.clone(), + }; + self.store.upsert_objective(&record)?; + let links = input + .linked_tickets .into_iter() - .map(|link| link.ticket_id) - .collect::>(); - let resources = self - .store - .list_objective_resources(&self.workspace_id, &record.objective_id)? - .into_iter() - .map(|resource| ObjectiveResourceSummary { - path: resource.resource_path, - media_type: resource.media_type, - bytes: resource.body.len(), - updated_at: resource.updated_at, + .map(|ticket_id| ObjectiveTicketLinkRecord { + workspace_id: self.workspace_id.clone(), + objective_id: objective_id.clone(), + ticket_id, + kind: "linked".to_string(), + created_at: now.clone(), }) - .collect(); - let (body, body_truncated) = truncate_body(&record.body_md, DETAIL_BODY_LIMIT); - Ok(ObjectiveDetail { - id: record.objective_id, - title: record.title, - state: record.state, - created_at: Some(record.created_at), - updated_at: Some(record.updated_at), - linked_tickets, - resources, - body, - body_truncated, - record_source: "workspace-sqlite".to_string(), - }) + .collect::>(); + self.store + .replace_objective_ticket_links(&self.workspace_id, &objective_id, &links)?; + self.insert_objective_event(&objective_id, "create", Some(&record.body_md))?; + self.objective(&objective_id) + } + + fn edit_objective(&self, id: &str, input: ObjectiveEditInput) -> Result { + validate_project_id(id)?; + let mut record = self.objective_record(id)?; + let mut changed = false; + if let Some(title) = input.title { + validate_objective_title(&title)?; + let title = title.trim().to_string(); + if title != record.title { + record.title = title; + changed = true; + } + } + match (input.old_string, input.new_string) { + (Some(old_string), Some(new_string)) => { + if old_string.is_empty() { + return Err(invalid_objective_error("old_string must not be empty")); + } + let matches = record.body_md.matches(&old_string).count(); + if matches == 0 { + return Err(invalid_objective_error( + "old_string was not found in objective body", + )); + } + if matches > 1 && !input.replace_all { + return Err(invalid_objective_error(format!( + "old_string matched {matches} times; set replace_all = true or provide a unique string" + ))); + } + record.body_md = if input.replace_all { + record.body_md.replace(&old_string, &new_string) + } else { + record.body_md.replacen(&old_string, &new_string, 1) + }; + changed = true; + } + (None, None) => {} + _ => { + return Err(invalid_objective_error( + "old_string and new_string must be provided together", + )); + } + } + if !changed { + return Err(invalid_objective_error( + "objective edit must change title or body", + )); + } + record.updated_at = now_rfc3339(); + self.store.upsert_objective(&record)?; + self.insert_objective_event(id, "edit", None)?; + self.objective(id) + } + + fn set_objective_state(&self, id: &str, state: &str) -> Result { + validate_project_id(id)?; + validate_objective_state(state)?; + let mut record = self.objective_record(id)?; + record.state = state.trim().to_string(); + record.updated_at = now_rfc3339(); + self.store.upsert_objective(&record)?; + self.insert_objective_event(id, "state", Some(&record.state))?; + self.objective(id) + } + + fn link_objective_ticket(&self, id: &str, ticket_id: &str) -> Result { + validate_project_id(id)?; + validate_project_id(ticket_id)?; + let _record = self.objective_record(id)?; + let now = now_rfc3339(); + let mut links = self + .store + .list_objective_ticket_links(&self.workspace_id, id)?; + if !links.iter().any(|link| link.ticket_id == ticket_id) { + links.push(ObjectiveTicketLinkRecord { + workspace_id: self.workspace_id.clone(), + objective_id: id.to_string(), + ticket_id: ticket_id.to_string(), + kind: "linked".to_string(), + created_at: now, + }); + self.store + .replace_objective_ticket_links(&self.workspace_id, id, &links)?; + self.insert_objective_event(id, "link_ticket", Some(ticket_id))?; + } + self.objective(id) + } + + fn unlink_objective_ticket(&self, id: &str, ticket_id: &str) -> Result { + validate_project_id(id)?; + validate_project_id(ticket_id)?; + let _record = self.objective_record(id)?; + let mut links = self + .store + .list_objective_ticket_links(&self.workspace_id, id)?; + let original_len = links.len(); + links.retain(|link| link.ticket_id != ticket_id); + if links.len() != original_len { + self.store + .replace_objective_ticket_links(&self.workspace_id, id, &links)?; + self.insert_objective_event(id, "unlink_ticket", Some(ticket_id))?; + } + self.objective(id) } } @@ -372,6 +583,38 @@ fn validate_non_empty(value: &str, field: &str) -> Result<()> { } } +fn validate_objective_title(title: &str) -> Result<()> { + if title.trim().is_empty() { + Err(invalid_objective_error("objective title must not be empty")) + } else { + Ok(()) + } +} + +fn validate_objective_state(state: &str) -> Result<()> { + if state.trim().is_empty() { + Err(invalid_objective_error("objective state must not be empty")) + } else { + Ok(()) + } +} + +fn invalid_objective_error(message: impl Into) -> Error { + Error::RuntimeOperationFailed { + runtime_id: "workspace-authority".to_string(), + code: "invalid_objective_request".to_string(), + message: message.into(), + } +} + +fn unknown_objective_error(id: &str) -> Error { + Error::RuntimeOperationFailed { + runtime_id: "workspace-authority".to_string(), + code: "unknown_objective".to_string(), + message: format!("unknown objective `{id}`"), + } +} + fn validate_json_object(raw_json: &str, field: &str) -> Result<()> { let value: serde_json::Value = serde_json::from_str(raw_json) .map_err(|err| Error::Store(format!("memory {field} must be valid JSON: {err}")))?; @@ -551,6 +794,80 @@ mod tests { ); } + #[tokio::test] + async fn objective_mutations_write_sqlite_records_and_audit_events() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("workspace.db"); + let store = SqliteWorkspaceStore::open(&db_path).unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "workspace-test".to_string(), + owner_account_id: None, + display_name: "Workspace Test".to_string(), + state: "active".to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + }) + .await + .unwrap(); + let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace-test").unwrap(); + + let created = authority + .create_objective(ObjectiveCreateInput { + title: "Create Objective".to_string(), + body_md: "Alpha body".to_string(), + state: "active".to_string(), + linked_tickets: vec!["00000000001J2".to_string()], + }) + .unwrap(); + assert_eq!(created.title, "Create Objective"); + assert_eq!(created.linked_tickets, vec!["00000000001J2"]); + + let edited = authority + .edit_objective( + &created.id, + ObjectiveEditInput { + title: Some("Edited Objective".to_string()), + old_string: Some("Alpha".to_string()), + new_string: Some("Beta".to_string()), + replace_all: false, + }, + ) + .unwrap(); + assert_eq!(edited.title, "Edited Objective"); + assert_eq!(edited.body, "Beta body"); + + let state = authority + .set_objective_state(&created.id, "paused") + .unwrap(); + assert_eq!(state.state, "paused"); + assert_eq!( + authority + .link_objective_ticket(&created.id, "00000000001J3") + .unwrap() + .linked_tickets, + vec!["00000000001J2", "00000000001J3"] + ); + assert_eq!( + authority + .unlink_objective_ticket(&created.id, "00000000001J2") + .unwrap() + .linked_tickets, + vec!["00000000001J3"] + ); + + let events = store + .list_objective_events("workspace-test", &created.id) + .unwrap(); + assert_eq!( + events + .iter() + .map(|event| event.kind.as_str()) + .collect::>(), + vec!["create", "edit", "state", "link_ticket", "unlink_ticket"] + ); + } + #[test] fn does_not_read_legacy_ticket_files_without_sqlite_import() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 3853452c..3ee955db 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -43,7 +43,8 @@ use crate::auth::{ session_set_cookie, token_hash, }; use crate::authority::{ - MemoryAuthority, ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, + MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput, + SqliteWorkspaceAuthority, TicketAuthority, }; use crate::companion::{ CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, @@ -514,12 +515,24 @@ pub fn build_router(api: WorkspaceApi) -> Router { .route("/api/objectives", get(list_objectives)) .route( "/api/w/{workspace_id}/objectives", - get(scoped_list_objectives), + get(scoped_list_objectives).post(scoped_create_objective), ) .route("/api/objectives/{id}", get(get_objective)) .route( - "/api/w/{workspace_id}/objectives/{id}", - get(scoped_get_objective), + "/api/w/{workspace_id}/objectives/{objective_id}", + get(scoped_get_objective).patch(scoped_edit_objective), + ) + .route( + "/api/w/{workspace_id}/objectives/{objective_id}/state", + post(scoped_set_objective_state), + ) + .route( + "/api/w/{workspace_id}/objectives/{objective_id}/ticket-links", + post(scoped_link_objective_ticket), + ) + .route( + "/api/w/{workspace_id}/objectives/{objective_id}/ticket-links/{ticket_id}", + delete(scoped_unlink_objective_ticket), ) .route("/api/repositories", get(list_repositories)) .route( @@ -1195,6 +1208,53 @@ struct ObjectiveListQuery { limit: Option, } +#[derive(Debug, Deserialize)] +struct ObjectiveCreateRequest { + title: String, + #[serde(default)] + body_md: String, + #[serde(default = "default_objective_state")] + state: String, + #[serde(default)] + linked_tickets: Vec, +} + +#[derive(Debug, Deserialize)] +struct ObjectiveEditRequest { + title: Option, + old_string: Option, + new_string: Option, + #[serde(default)] + replace_all: bool, +} + +#[derive(Debug, Deserialize)] +struct ObjectiveStateRequest { + state: String, +} + +#[derive(Debug, Deserialize)] +struct ObjectiveLinkTicketRequest { + ticket_id: String, +} + +#[derive(Debug, Deserialize)] +struct ScopedObjectivePath { + workspace_id: String, + objective_id: String, +} + +#[derive(Debug, Deserialize)] +struct ScopedObjectiveTicketPath { + workspace_id: String, + objective_id: String, + ticket_id: String, +} + +fn default_objective_state() -> String { + "active".to_string() +} + #[derive(Debug, Deserialize)] struct TranscriptQuery { start: Option, @@ -1831,10 +1891,78 @@ async fn scoped_list_objectives( async fn scoped_get_objective( State(api): State, - AxumPath(path): AxumPath, + AxumPath(path): AxumPath, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - get_objective(State(api), AxumPath(path.id)).await + get_objective(State(api), AxumPath(path.objective_id)).await +} + +async fn scoped_create_objective( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.create_objective( + ObjectiveCreateInput { + title: request.title, + body_md: request.body_md, + state: request.state, + linked_tickets: request.linked_tickets, + }, + )?)) +} + +async fn scoped_edit_objective( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.edit_objective( + &path.objective_id, + ObjectiveEditInput { + title: request.title, + old_string: request.old_string, + new_string: request.new_string, + replace_all: request.replace_all, + }, + )?)) +} + +async fn scoped_set_objective_state( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json( + api.authority + .set_objective_state(&path.objective_id, &request.state)?, + )) +} + +async fn scoped_link_objective_ticket( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.link_objective_ticket( + &path.objective_id, + &request.ticket_id, + )?)) +} + +async fn scoped_unlink_objective_ticket( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.unlink_objective_ticket( + &path.objective_id, + &path.ticket_id, + )?)) } async fn scoped_list_repositories( @@ -6690,7 +6818,9 @@ impl IntoResponse for ApiError { StatusCode::CONFLICT } Error::RuntimeOperationFailed { code, .. } - if code == "unknown_profile_source" || code == "unknown_profile_selector" => + if code == "unknown_profile_source" + || code == "unknown_profile_selector" + || code == "unknown_objective" => { StatusCode::NOT_FOUND } @@ -6759,7 +6889,7 @@ mod tests { }; use crate::store::{ MemoryDocumentRecord, MemoryStagingRecord, ObjectiveRecord, ObjectiveResourceRecord, - ObjectiveTicketLinkRecord, SqliteWorkspaceStore, + ObjectiveTicketLinkRecord, SqliteWorkspaceStore, WorkspaceRecord, }; const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001"; @@ -9674,6 +9804,103 @@ mod tests { assert_ne!(login_without_registered_passkey.status(), StatusCode::OK); } + #[tokio::test] + async fn objective_mutation_endpoints_round_trip_through_workspace_authority() { + let dir = tempfile::tempdir().unwrap(); + let config = test_server_config(dir.path()); + let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + owner_account_id: None, + display_name: "Test Workspace".to_string(), + state: "active".to_string(), + created_at: TEST_CREATED_AT.to_string(), + updated_at: TEST_CREATED_AT.to_string(), + }) + .await + .unwrap(); + let api = WorkspaceApi::new_with_execution_backend( + config, + store, + Arc::new(DeterministicExecutionBackend::default()), + ) + .await + .unwrap(); + let app = build_router(api); + let objectives_path = format!("/api/w/{TEST_WORKSPACE_ID}/objectives"); + + let created = request_json( + app.clone(), + "POST", + &objectives_path, + Some(json!({ + "title": "Objective CRUD", + "body_md": "First body", + "state": "active", + "linked_tickets": ["00000000001J2"] + })), + StatusCode::OK, + ) + .await; + let id = created["id"].as_str().unwrap().to_string(); + assert_eq!(created["title"], "Objective CRUD"); + assert_eq!(created["linked_tickets"], json!(["00000000001J2"])); + + let edited = request_json( + app.clone(), + "PATCH", + &format!("{objectives_path}/{id}"), + Some(json!({ + "title": "Objective CRUD updated", + "old_string": "First", + "new_string": "Updated" + })), + StatusCode::OK, + ) + .await; + assert_eq!(edited["title"], "Objective CRUD updated"); + assert_eq!(edited["body"], "Updated body"); + + let state = request_json( + app.clone(), + "POST", + &format!("{objectives_path}/{id}/state"), + Some(json!({ "state": "paused" })), + StatusCode::OK, + ) + .await; + assert_eq!(state["state"], "paused"); + + let linked = request_json( + app.clone(), + "POST", + &format!("{objectives_path}/{id}/ticket-links"), + Some(json!({ "ticket_id": "00000000001J3" })), + StatusCode::OK, + ) + .await; + assert_eq!( + linked["linked_tickets"], + json!(["00000000001J2", "00000000001J3"]) + ); + + let unlinked = request_json( + app.clone(), + "DELETE", + &format!("{objectives_path}/{id}/ticket-links/00000000001J2"), + None, + StatusCode::OK, + ) + .await; + assert_eq!(unlinked["linked_tickets"], json!(["00000000001J3"])); + + let shown = get_json(app, &format!("{objectives_path}/{id}")).await; + assert_eq!(shown["title"], "Objective CRUD updated"); + assert_eq!(shown["state"], "paused"); + assert_eq!(shown["linked_tickets"], json!(["00000000001J3"])); + } + async fn get_json(app: Router, uri: &str) -> Value { let response = app .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) @@ -9702,8 +9929,14 @@ mod tests { .oneshot(builder.body(request_body).unwrap()) .await .unwrap(); - assert_eq!(response.status(), expected_status, "{method} {uri}"); + let status = response.status(); let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + expected_status, + "{method} {uri}: {}", + String::from_utf8_lossy(&bytes) + ); serde_json::from_slice(&bytes).unwrap_or_else( |_| serde_json::json!({ "message": String::from_utf8_lossy(&bytes).to_string() }), ) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index f4e89e65..f94949c7 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -77,6 +77,11 @@ const MIGRATIONS: &[Migration] = &[ name: "trusted remote runtime registry", apply: create_trusted_runtime_registry_tables, }, + Migration { + version: 13, + name: "objective mutation audit events", + apply: create_objective_event_tables, + }, ]; struct Migration { @@ -267,6 +272,16 @@ pub struct ObjectiveTicketLinkRecord { pub created_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ObjectiveEventRecord { + pub workspace_id: String, + pub objective_id: String, + pub event_id: String, + pub kind: String, + pub body_md: Option, + pub created_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveResourceRecord { pub workspace_id: String, @@ -335,6 +350,12 @@ pub trait ControlPlaneStore: Send + Sync { workspace_id: &str, objective_id: &str, ) -> Result>; + fn insert_objective_event(&self, record: &ObjectiveEventRecord) -> Result<()>; + fn list_objective_events( + &self, + workspace_id: &str, + objective_id: &str, + ) -> Result>; fn upsert_objective_resource(&self, record: &ObjectiveResourceRecord) -> Result<()>; fn list_objective_resources( &self, @@ -792,6 +813,52 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn insert_objective_event(&self, record: &ObjectiveEventRecord) -> Result<()> { + self.with_conn(|conn| { + conn.execute( + r#"INSERT INTO objective_events ( + workspace_id, objective_id, event_id, kind, body_md, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, + params![ + record.workspace_id, + record.objective_id, + record.event_id, + record.kind, + record.body_md, + record.created_at, + ], + )?; + Ok(()) + }) + } + + fn list_objective_events( + &self, + workspace_id: &str, + objective_id: &str, + ) -> Result> { + self.with_conn(|conn| { + let mut stmt = conn.prepare( + r#"SELECT workspace_id, objective_id, event_id, kind, body_md, created_at + FROM objective_events + WHERE workspace_id = ?1 AND objective_id = ?2 + ORDER BY created_at ASC, event_id ASC"#, + )?; + let rows = stmt.query_map(params![workspace_id, objective_id], |row| { + Ok(ObjectiveEventRecord { + workspace_id: row.get(0)?, + objective_id: row.get(1)?, + event_id: row.get(2)?, + kind: row.get(3)?, + body_md: row.get(4)?, + created_at: row.get(5)?, + }) + })?; + rows.collect::, _>>() + .map_err(Error::from) + }) + } + fn upsert_objective_resource(&self, record: &ObjectiveResourceRecord) -> Result<()> { self.with_conn(|conn| { conn.execute( @@ -2089,6 +2156,22 @@ CREATE TABLE IF NOT EXISTS memory_staging_resolutions ( Ok(()) } +fn create_objective_event_tables(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" +CREATE TABLE IF NOT EXISTS objective_events ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE, + event_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + body_md TEXT, + created_at TEXT NOT NULL +); +"#, + )?; + Ok(()) +} + fn create_trusted_runtime_registry_tables(conn: &Connection) -> Result<()> { conn.execute_batch( r#" @@ -2647,6 +2730,15 @@ CREATE TABLE IF NOT EXISTS objective_ticket_links ( PRIMARY KEY (objective_id, ticket_id, kind) ); +CREATE TABLE IF NOT EXISTS objective_events ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE, + event_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + body_md TEXT, + created_at TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS objective_resources ( workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE, @@ -2818,7 +2910,7 @@ mod tests { let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 11); + assert_eq!(store.schema_version().await.unwrap(), 13); let record = WorkspaceRecord { workspace_id: "local-dev".to_string(), @@ -2831,7 +2923,7 @@ mod tests { store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 11); + assert_eq!(reopened.schema_version().await.unwrap(), 13); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -3052,7 +3144,7 @@ mod tests { .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 11); + assert_eq!(store.schema_version().await.unwrap(), 13); store .with_conn(|conn| { @@ -3146,7 +3238,7 @@ mod tests { #[tokio::test] async fn repository_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 11); + assert_eq!(store.schema_version().await.unwrap(), 13); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -3184,7 +3276,7 @@ mod tests { #[tokio::test] async fn memory_authority_records_round_trip_and_close_staging() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 11); + assert_eq!(store.schema_version().await.unwrap(), 13); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -3358,7 +3450,7 @@ mod tests { #[tokio::test] async fn account_and_login_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 11); + assert_eq!(store.schema_version().await.unwrap(), 13); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(),