objective: add mutation tools

This commit is contained in:
2026-07-29 21:58:01 +09:00
parent 0b64eab148
commit 7265041e55
4 changed files with 1064 additions and 69 deletions
+352 -35
View File
@@ -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<ProjectRecordList<ObjectiveSummary>>;
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 {
@@ -110,6 +132,75 @@ impl SqliteWorkspaceAuthority {
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 {
@@ -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<ObjectiveDetail> {
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<ObjectiveDetail> {
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::<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,
.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::<Vec<_>>();
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
.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<ObjectiveDetail> {
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<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<()> {
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<_>>(),
vec!["create", "edit", "state", "link_ticket", "unlink_ticket"]
);
}
#[test]
fn does_not_read_legacy_ticket_files_without_sqlite_import() {
let dir = tempfile::tempdir().unwrap();
+242 -9
View File
@@ -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<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)]
struct TranscriptQuery {
start: Option<usize>,
@@ -1831,10 +1891,78 @@ async fn scoped_list_objectives(
async fn scoped_get_objective(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
AxumPath(path): AxumPath<ScopedObjectivePath>,
) -> ApiResult<Json<ObjectiveDetail>> {
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(
@@ -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() }),
)
+98 -6
View File
@@ -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<String>,
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<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 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<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<()> {
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(),