objective: add mutation tools
This commit is contained in:
parent
0b64eab148
commit
7265041e55
|
|
@ -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
|
//! Objectives are project-level planning context. Runtime Workers may not know
|
||||||
//! local `.yoi/objectives` paths, so model-visible Objective tools go through
|
//! local `.yoi/objectives` paths, so model-visible Objective tools go through
|
||||||
|
|
@ -43,23 +44,119 @@ impl WorkspaceHttpObjectiveBackend {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn show(&self, input: ObjectiveShowInput) -> Result<ToolOutput, ToolError> {
|
async fn show(&self, input: ObjectiveShowInput) -> Result<ToolOutput, ToolError> {
|
||||||
let id = input.id.trim();
|
let id = validate_id(&input.id, "ObjectiveShow")?;
|
||||||
if id.is_empty() || id.contains('/') {
|
let url = self.objective_url(id);
|
||||||
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 response = get_json::<ObjectiveDetail>(&url)
|
let response = get_json::<ObjectiveDetail>(&url)
|
||||||
.await
|
.await
|
||||||
.map_err(backend_error)?;
|
.map_err(backend_error)?;
|
||||||
Ok(ToolOutput {
|
Ok(objective_output(
|
||||||
summary: format!("Read objective {}", response.id),
|
format!("Read objective {}", response.id),
|
||||||
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
|
response,
|
||||||
})
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create(&self, input: ObjectiveCreateInput) -> Result<ToolOutput, ToolError> {
|
||||||
|
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::<ObjectiveCreateInput, ObjectiveDetail>(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<ToolOutput, ToolError> {
|
||||||
|
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::<ObjectiveEditRequest, ObjectiveDetail>(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<ToolOutput, ToolError> {
|
||||||
|
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::<ObjectiveSetStateRequest, ObjectiveDetail>(
|
||||||
|
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<ToolOutput, ToolError> {
|
||||||
|
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::<ObjectiveLinkTicketRequest, ObjectiveDetail>(
|
||||||
|
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<ToolOutput, ToolError> {
|
||||||
|
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::<ObjectiveDetail>(&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<T: for<'de> Deserialize<'de>>(
|
||||||
url: &str,
|
url: &str,
|
||||||
) -> Result<T, WorkspaceObjectiveBackendError> {
|
) -> Result<T, WorkspaceObjectiveBackendError> {
|
||||||
let response = reqwest::Client::new().get(url).send().await?;
|
let response = reqwest::Client::new().get(url).send().await?;
|
||||||
|
decode_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_json<B: Serialize, T: for<'de> Deserialize<'de>>(
|
||||||
|
method: reqwest::Method,
|
||||||
|
url: &str,
|
||||||
|
body: &B,
|
||||||
|
) -> Result<T, WorkspaceObjectiveBackendError> {
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.request(method, url)
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_json<T: for<'de> Deserialize<'de>>(
|
||||||
|
url: &str,
|
||||||
|
) -> Result<T, WorkspaceObjectiveBackendError> {
|
||||||
|
let response = reqwest::Client::new().delete(url).send().await?;
|
||||||
|
decode_response(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn decode_response<T: for<'de> Deserialize<'de>>(
|
||||||
|
response: reqwest::Response,
|
||||||
|
) -> Result<T, WorkspaceObjectiveBackendError> {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await?;
|
let body = response.text().await?;
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
|
|
@ -96,6 +219,23 @@ async fn get_json<T: for<'de> Deserialize<'de>>(
|
||||||
serde_json::from_str(&body).map_err(Into::into)
|
serde_json::from_str(&body).map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
|
||||||
|
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(
|
pub fn workspace_http_objective_tools(
|
||||||
workspace_id: impl Into<String>,
|
workspace_id: impl Into<String>,
|
||||||
base_url: impl Into<String>,
|
base_url: impl Into<String>,
|
||||||
|
|
@ -113,9 +253,44 @@ pub fn workspace_http_objective_tools(
|
||||||
"ObjectiveShow",
|
"ObjectiveShow",
|
||||||
SHOW_DESCRIPTION,
|
SHOW_DESCRIPTION,
|
||||||
show_schema(),
|
show_schema(),
|
||||||
backend,
|
backend.clone(),
|
||||||
ObjectiveOperation::Show,
|
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 {
|
enum ObjectiveOperation {
|
||||||
List,
|
List,
|
||||||
Show,
|
Show,
|
||||||
|
Create,
|
||||||
|
Edit,
|
||||||
|
SetState,
|
||||||
|
LinkTicket,
|
||||||
|
UnlinkTicket,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn objective_tool(
|
fn objective_tool(
|
||||||
|
|
@ -167,6 +347,26 @@ impl Tool for WorkspaceHttpObjectiveTool {
|
||||||
let input = parse_input::<ObjectiveShowInput>(input_json)?;
|
let input = parse_input::<ObjectiveShowInput>(input_json)?;
|
||||||
self.backend.show(input).await
|
self.backend.show(input).await
|
||||||
}
|
}
|
||||||
|
ObjectiveOperation::Create => {
|
||||||
|
let input = parse_input::<ObjectiveCreateInput>(input_json)?;
|
||||||
|
self.backend.create(input).await
|
||||||
|
}
|
||||||
|
ObjectiveOperation::Edit => {
|
||||||
|
let input = parse_input::<ObjectiveEditInput>(input_json)?;
|
||||||
|
self.backend.edit(input).await
|
||||||
|
}
|
||||||
|
ObjectiveOperation::SetState => {
|
||||||
|
let input = parse_input::<ObjectiveSetStateInput>(input_json)?;
|
||||||
|
self.backend.set_state(input).await
|
||||||
|
}
|
||||||
|
ObjectiveOperation::LinkTicket => {
|
||||||
|
let input = parse_input::<ObjectiveLinkTicketInput>(input_json)?;
|
||||||
|
self.backend.link_ticket(input).await
|
||||||
|
}
|
||||||
|
ObjectiveOperation::UnlinkTicket => {
|
||||||
|
let input = parse_input::<ObjectiveUnlinkTicketInput>(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.";
|
"List Objective records through Backend Workspace API authority as bounded summaries.";
|
||||||
const SHOW_DESCRIPTION: &str =
|
const SHOW_DESCRIPTION: &str =
|
||||||
"Show one Objective record by canonical id through Backend Workspace API authority.";
|
"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 {
|
fn list_schema() -> serde_json::Value {
|
||||||
json!({
|
json!({
|
||||||
|
|
@ -191,16 +401,81 @@ fn list_schema() -> serde_json::Value {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn show_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!({
|
json!({
|
||||||
"type":"object",
|
"type":"object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required":["id"],
|
"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":{
|
"properties":{
|
||||||
"id":{"type":"string"}
|
"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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ObjectiveListInput {
|
struct ObjectiveListInput {
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
|
|
@ -211,6 +486,67 @@ struct ObjectiveShowInput {
|
||||||
id: String,
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ObjectiveEditInput {
|
||||||
|
id: String,
|
||||||
|
title: Option<String>,
|
||||||
|
old_string: Option<String>,
|
||||||
|
new_string: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
replace_all: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ObjectiveEditRequest {
|
||||||
|
title: Option<String>,
|
||||||
|
old_string: Option<String>,
|
||||||
|
new_string: Option<String>,
|
||||||
|
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)]
|
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
struct ObjectiveListResponse {
|
struct ObjectiveListResponse {
|
||||||
items: Vec<ObjectiveSummary>,
|
items: Vec<ObjectiveSummary>,
|
||||||
|
|
@ -263,20 +599,37 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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(
|
let names = tool_names(workspace_http_objective_tools(
|
||||||
"workspace".to_string(),
|
"workspace".to_string(),
|
||||||
"http://backend".to_string(),
|
"http://backend".to_string(),
|
||||||
));
|
));
|
||||||
|
|
||||||
assert_eq!(names, vec!["ObjectiveList", "ObjectiveShow"]);
|
assert_eq!(
|
||||||
|
names,
|
||||||
|
vec![
|
||||||
|
"ObjectiveCreate",
|
||||||
|
"ObjectiveEdit",
|
||||||
|
"ObjectiveLinkTicket",
|
||||||
|
"ObjectiveList",
|
||||||
|
"ObjectiveSetState",
|
||||||
|
"ObjectiveShow",
|
||||||
|
"ObjectiveUnlinkTicket",
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn objective_tool_schemas_are_bounded_and_read_only() {
|
fn objective_tool_schemas_are_bounded_and_mutation_scoped() {
|
||||||
let list = list_schema();
|
let list = list_schema();
|
||||||
assert_eq!(list["properties"]["limit"]["maximum"], 1000);
|
assert_eq!(list["properties"]["limit"]["maximum"], 1000);
|
||||||
let show = show_schema();
|
let show = show_schema();
|
||||||
assert_eq!(show["required"][0], "id");
|
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"]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use project_record::{allocate_record_id, unix_epoch_millis_now};
|
||||||
|
|
||||||
use ticket::{
|
use ticket::{
|
||||||
SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery,
|
SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery,
|
||||||
|
|
@ -13,7 +14,7 @@ use crate::records::{
|
||||||
};
|
};
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
|
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
|
||||||
SqliteWorkspaceStore,
|
ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore,
|
||||||
};
|
};
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
|
|
@ -38,6 +39,27 @@ pub trait TicketAuthority {
|
||||||
pub trait ObjectiveAuthority {
|
pub trait ObjectiveAuthority {
|
||||||
fn list_objectives(&self, limit: usize) -> Result<ProjectRecordList<ObjectiveSummary>>;
|
fn list_objectives(&self, limit: usize) -> Result<ProjectRecordList<ObjectiveSummary>>;
|
||||||
fn objective(&self, id: &str) -> Result<ObjectiveDetail>;
|
fn objective(&self, id: &str) -> Result<ObjectiveDetail>;
|
||||||
|
fn create_objective(&self, input: ObjectiveCreateInput) -> Result<ObjectiveDetail>;
|
||||||
|
fn edit_objective(&self, id: &str, input: ObjectiveEditInput) -> Result<ObjectiveDetail>;
|
||||||
|
fn set_objective_state(&self, id: &str, state: &str) -> Result<ObjectiveDetail>;
|
||||||
|
fn link_objective_ticket(&self, id: &str, ticket_id: &str) -> Result<ObjectiveDetail>;
|
||||||
|
fn unlink_objective_ticket(&self, id: &str, ticket_id: &str) -> Result<ObjectiveDetail>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ObjectiveCreateInput {
|
||||||
|
pub title: String,
|
||||||
|
pub body_md: String,
|
||||||
|
pub state: String,
|
||||||
|
pub linked_tickets: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct ObjectiveEditInput {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub old_string: Option<String>,
|
||||||
|
pub new_string: Option<String>,
|
||||||
|
pub replace_all: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait MemoryAuthority {
|
pub trait MemoryAuthority {
|
||||||
|
|
@ -110,6 +132,75 @@ impl SqliteWorkspaceAuthority {
|
||||||
ticket_backend: SqliteTicketBackend::new(database_path, workspace_id),
|
ticket_backend: SqliteTicketBackend::new(database_path, workspace_id),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn objective_record(&self, id: &str) -> Result<ObjectiveRecord> {
|
||||||
|
self.store
|
||||||
|
.get_objective(&self.workspace_id, id)?
|
||||||
|
.ok_or_else(|| unknown_objective_error(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn objective_detail_from_record(&self, record: ObjectiveRecord) -> Result<ObjectiveDetail> {
|
||||||
|
let linked_tickets = self
|
||||||
|
.store
|
||||||
|
.list_objective_ticket_links(&self.workspace_id, &record.objective_id)?
|
||||||
|
.into_iter()
|
||||||
|
.map(|link| link.ticket_id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
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 {
|
impl TicketAuthority for SqliteWorkspaceAuthority {
|
||||||
|
|
@ -189,52 +280,172 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
|
||||||
updated_at: Some(record.updated_at),
|
updated_at: Some(record.updated_at),
|
||||||
summary: summarize_body(&record.body_md),
|
summary: summarize_body(&record.body_md),
|
||||||
linked_tickets,
|
linked_tickets,
|
||||||
record_source: "workspace-sqlite".to_string(),
|
record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(ProjectRecordList {
|
Ok(ProjectRecordList {
|
||||||
items,
|
items,
|
||||||
invalid_records: Vec::new(),
|
invalid_records: Vec::new(),
|
||||||
record_authority: "workspace-sqlite".to_string(),
|
record_authority: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn objective(&self, id: &str) -> Result<ObjectiveDetail> {
|
fn objective(&self, id: &str) -> Result<ObjectiveDetail> {
|
||||||
validate_project_id(id)?;
|
validate_project_id(id)?;
|
||||||
let record = self
|
let record = self.objective_record(id)?;
|
||||||
.store
|
self.objective_detail_from_record(record)
|
||||||
.get_objective(&self.workspace_id, id)?
|
}
|
||||||
.ok_or_else(|| Error::Store(format!("unknown objective `{id}`")))?;
|
|
||||||
let linked_tickets = self
|
fn create_objective(&self, input: ObjectiveCreateInput) -> Result<ObjectiveDetail> {
|
||||||
.store
|
validate_objective_title(&input.title)?;
|
||||||
.list_objective_ticket_links(&self.workspace_id, &record.objective_id)?
|
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()
|
.into_iter()
|
||||||
.map(|link| link.ticket_id)
|
.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::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let resources = self
|
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<ObjectiveDetail> {
|
||||||
|
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<ObjectiveDetail> {
|
||||||
|
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<ObjectiveDetail> {
|
||||||
|
validate_project_id(id)?;
|
||||||
|
validate_project_id(ticket_id)?;
|
||||||
|
let _record = self.objective_record(id)?;
|
||||||
|
let now = now_rfc3339();
|
||||||
|
let mut links = self
|
||||||
.store
|
.store
|
||||||
.list_objective_resources(&self.workspace_id, &record.objective_id)?
|
.list_objective_ticket_links(&self.workspace_id, id)?;
|
||||||
.into_iter()
|
if !links.iter().any(|link| link.ticket_id == ticket_id) {
|
||||||
.map(|resource| ObjectiveResourceSummary {
|
links.push(ObjectiveTicketLinkRecord {
|
||||||
path: resource.resource_path,
|
workspace_id: self.workspace_id.clone(),
|
||||||
media_type: resource.media_type,
|
objective_id: id.to_string(),
|
||||||
bytes: resource.body.len(),
|
ticket_id: ticket_id.to_string(),
|
||||||
updated_at: resource.updated_at,
|
kind: "linked".to_string(),
|
||||||
})
|
created_at: now,
|
||||||
.collect();
|
});
|
||||||
let (body, body_truncated) = truncate_body(&record.body_md, DETAIL_BODY_LIMIT);
|
self.store
|
||||||
Ok(ObjectiveDetail {
|
.replace_objective_ticket_links(&self.workspace_id, id, &links)?;
|
||||||
id: record.objective_id,
|
self.insert_objective_event(id, "link_ticket", Some(ticket_id))?;
|
||||||
title: record.title,
|
}
|
||||||
state: record.state,
|
self.objective(id)
|
||||||
created_at: Some(record.created_at),
|
}
|
||||||
updated_at: Some(record.updated_at),
|
|
||||||
linked_tickets,
|
fn unlink_objective_ticket(&self, id: &str, ticket_id: &str) -> Result<ObjectiveDetail> {
|
||||||
resources,
|
validate_project_id(id)?;
|
||||||
body,
|
validate_project_id(ticket_id)?;
|
||||||
body_truncated,
|
let _record = self.objective_record(id)?;
|
||||||
record_source: "workspace-sqlite".to_string(),
|
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<String>) -> 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<()> {
|
fn validate_json_object(raw_json: &str, field: &str) -> Result<()> {
|
||||||
let value: serde_json::Value = serde_json::from_str(raw_json)
|
let value: serde_json::Value = serde_json::from_str(raw_json)
|
||||||
.map_err(|err| Error::Store(format!("memory {field} must be valid JSON: {err}")))?;
|
.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<_>>(),
|
||||||
|
vec!["create", "edit", "state", "link_ticket", "unlink_ticket"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn does_not_read_legacy_ticket_files_without_sqlite_import() {
|
fn does_not_read_legacy_ticket_files_without_sqlite_import() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,8 @@ use crate::auth::{
|
||||||
session_set_cookie, token_hash,
|
session_set_cookie, token_hash,
|
||||||
};
|
};
|
||||||
use crate::authority::{
|
use crate::authority::{
|
||||||
MemoryAuthority, ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority,
|
MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput,
|
||||||
|
SqliteWorkspaceAuthority, TicketAuthority,
|
||||||
};
|
};
|
||||||
use crate::companion::{
|
use crate::companion::{
|
||||||
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
|
||||||
|
|
@ -514,12 +515,24 @@ pub fn build_router(api: WorkspaceApi) -> Router {
|
||||||
.route("/api/objectives", get(list_objectives))
|
.route("/api/objectives", get(list_objectives))
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/objectives",
|
"/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/objectives/{id}", get(get_objective))
|
||||||
.route(
|
.route(
|
||||||
"/api/w/{workspace_id}/objectives/{id}",
|
"/api/w/{workspace_id}/objectives/{objective_id}",
|
||||||
get(scoped_get_objective),
|
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("/api/repositories", get(list_repositories))
|
||||||
.route(
|
.route(
|
||||||
|
|
@ -1195,6 +1208,53 @@ 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)]
|
||||||
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct TranscriptQuery {
|
struct TranscriptQuery {
|
||||||
start: Option<usize>,
|
start: Option<usize>,
|
||||||
|
|
@ -1831,10 +1891,78 @@ async fn scoped_list_objectives(
|
||||||
|
|
||||||
async fn scoped_get_objective(
|
async fn scoped_get_objective(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedRecordPath>,
|
AxumPath(path): AxumPath<ScopedObjectivePath>,
|
||||||
) -> ApiResult<Json<ObjectiveDetail>> {
|
) -> ApiResult<Json<ObjectiveDetail>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
|
Json(request): Json<ObjectiveCreateRequest>,
|
||||||
|
) -> ApiResult<Json<ObjectiveDetail>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedObjectivePath>,
|
||||||
|
Json(request): Json<ObjectiveEditRequest>,
|
||||||
|
) -> ApiResult<Json<ObjectiveDetail>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedObjectivePath>,
|
||||||
|
Json(request): Json<ObjectiveStateRequest>,
|
||||||
|
) -> ApiResult<Json<ObjectiveDetail>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedObjectivePath>,
|
||||||
|
Json(request): Json<ObjectiveLinkTicketRequest>,
|
||||||
|
) -> ApiResult<Json<ObjectiveDetail>> {
|
||||||
|
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<WorkspaceApi>,
|
||||||
|
AxumPath(path): AxumPath<ScopedObjectiveTicketPath>,
|
||||||
|
) -> ApiResult<Json<ObjectiveDetail>> {
|
||||||
|
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(
|
async fn scoped_list_repositories(
|
||||||
|
|
@ -6690,7 +6818,9 @@ impl IntoResponse for ApiError {
|
||||||
StatusCode::CONFLICT
|
StatusCode::CONFLICT
|
||||||
}
|
}
|
||||||
Error::RuntimeOperationFailed { code, .. }
|
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
|
StatusCode::NOT_FOUND
|
||||||
}
|
}
|
||||||
|
|
@ -6759,7 +6889,7 @@ mod tests {
|
||||||
};
|
};
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
MemoryDocumentRecord, MemoryStagingRecord, ObjectiveRecord, ObjectiveResourceRecord,
|
MemoryDocumentRecord, MemoryStagingRecord, ObjectiveRecord, ObjectiveResourceRecord,
|
||||||
ObjectiveTicketLinkRecord, SqliteWorkspaceStore,
|
ObjectiveTicketLinkRecord, SqliteWorkspaceStore, WorkspaceRecord,
|
||||||
};
|
};
|
||||||
|
|
||||||
const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
|
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);
|
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 {
|
async fn get_json(app: Router, uri: &str) -> Value {
|
||||||
let response = app
|
let response = app
|
||||||
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
||||||
|
|
@ -9702,8 +9929,14 @@ mod tests {
|
||||||
.oneshot(builder.body(request_body).unwrap())
|
.oneshot(builder.body(request_body).unwrap())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(response.status(), expected_status, "{method} {uri}");
|
let status = response.status();
|
||||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
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::from_slice(&bytes).unwrap_or_else(
|
||||||
|_| serde_json::json!({ "message": String::from_utf8_lossy(&bytes).to_string() }),
|
|_| serde_json::json!({ "message": String::from_utf8_lossy(&bytes).to_string() }),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||||
name: "trusted remote runtime registry",
|
name: "trusted remote runtime registry",
|
||||||
apply: create_trusted_runtime_registry_tables,
|
apply: create_trusted_runtime_registry_tables,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 13,
|
||||||
|
name: "objective mutation audit events",
|
||||||
|
apply: create_objective_event_tables,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
struct Migration {
|
struct Migration {
|
||||||
|
|
@ -267,6 +272,16 @@ pub struct ObjectiveTicketLinkRecord {
|
||||||
pub created_at: String,
|
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<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ObjectiveResourceRecord {
|
pub struct ObjectiveResourceRecord {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
|
|
@ -335,6 +350,12 @@ pub trait ControlPlaneStore: Send + Sync {
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
objective_id: &str,
|
objective_id: &str,
|
||||||
) -> Result<Vec<ObjectiveTicketLinkRecord>>;
|
) -> Result<Vec<ObjectiveTicketLinkRecord>>;
|
||||||
|
fn insert_objective_event(&self, record: &ObjectiveEventRecord) -> Result<()>;
|
||||||
|
fn list_objective_events(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
objective_id: &str,
|
||||||
|
) -> Result<Vec<ObjectiveEventRecord>>;
|
||||||
fn upsert_objective_resource(&self, record: &ObjectiveResourceRecord) -> Result<()>;
|
fn upsert_objective_resource(&self, record: &ObjectiveResourceRecord) -> Result<()>;
|
||||||
fn list_objective_resources(
|
fn list_objective_resources(
|
||||||
&self,
|
&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<Vec<ObjectiveEventRecord>> {
|
||||||
|
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::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn upsert_objective_resource(&self, record: &ObjectiveResourceRecord) -> Result<()> {
|
fn upsert_objective_resource(&self, record: &ObjectiveResourceRecord) -> Result<()> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|
@ -2089,6 +2156,22 @@ CREATE TABLE IF NOT EXISTS memory_staging_resolutions (
|
||||||
Ok(())
|
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<()> {
|
fn create_trusted_runtime_registry_tables(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -2647,6 +2730,15 @@ CREATE TABLE IF NOT EXISTS objective_ticket_links (
|
||||||
PRIMARY KEY (objective_id, ticket_id, kind)
|
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 (
|
CREATE TABLE IF NOT EXISTS objective_resources (
|
||||||
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
objective_id TEXT NOT NULL REFERENCES objectives(objective_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 db = dir.path().join("control-plane.sqlite");
|
||||||
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
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 {
|
let record = WorkspaceRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
|
|
@ -2831,7 +2923,7 @@ mod tests {
|
||||||
store.upsert_workspace(&record).await.unwrap();
|
store.upsert_workspace(&record).await.unwrap();
|
||||||
|
|
||||||
let reopened = SqliteWorkspaceStore::open(&db).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!(
|
assert_eq!(
|
||||||
reopened.get_workspace("local-dev").await.unwrap(),
|
reopened.get_workspace("local-dev").await.unwrap(),
|
||||||
Some(record)
|
Some(record)
|
||||||
|
|
@ -3052,7 +3144,7 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let store = SqliteWorkspaceStore::from_connection(conn).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
|
store
|
||||||
.with_conn(|conn| {
|
.with_conn(|conn| {
|
||||||
|
|
@ -3146,7 +3238,7 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn repository_records_round_trip() {
|
async fn repository_records_round_trip() {
|
||||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
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 {
|
let workspace = WorkspaceRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
owner_account_id: None,
|
owner_account_id: None,
|
||||||
|
|
@ -3184,7 +3276,7 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn memory_authority_records_round_trip_and_close_staging() {
|
async fn memory_authority_records_round_trip_and_close_staging() {
|
||||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
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 {
|
let workspace = WorkspaceRecord {
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
owner_account_id: None,
|
owner_account_id: None,
|
||||||
|
|
@ -3358,7 +3450,7 @@ mod tests {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn account_and_login_records_round_trip() {
|
async fn account_and_login_records_round_trip() {
|
||||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
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 now = "2026-07-22T00:00:00Z".to_string();
|
||||||
let account = AccountRecord {
|
let account = AccountRecord {
|
||||||
account_id: "acct-user-alice".to_string(),
|
account_id: "acct-user-alice".to_string(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user