diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 05f38972..999be609 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -495,6 +495,8 @@ pub struct NewTicket { pub workflow_state: Option, pub queued_by: Option, pub queued_at: Option, + pub repository_id: Option, + pub ref_selector: Option, } impl NewTicket { @@ -513,10 +515,51 @@ impl NewTicket { workflow_state: None, queued_by: None, queued_at: None, + repository_id: None, + ref_selector: None, } } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum TicketTargetEdit { + Set { + repository_id: String, + ref_selector: Option, + }, + Clear, +} + +impl TicketTargetEdit { + fn validate(&self) -> Result<()> { + if let Self::Set { + repository_id, + ref_selector, + } = self + { + validate_ticket_target(Some(repository_id), ref_selector.as_deref())?; + } + Ok(()) + } +} + +fn validate_ticket_target(repository_id: Option<&str>, ref_selector: Option<&str>) -> Result<()> { + let Some(repository_id) = repository_id else { + if ref_selector.is_some() { + return Err(TicketError::Conflict( + "ref_selector requires repository_id".to_string(), + )); + } + return Ok(()); + }; + validate_required_event_value("repository_id", repository_id)?; + if let Some(ref_selector) = ref_selector { + validate_required_event_value("ref_selector", ref_selector)?; + } + Ok(()) +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketItemEdit { pub title: Option, @@ -525,6 +568,7 @@ pub struct TicketItemEdit { pub body: Option, #[serde(default)] pub body_replacement: Option, + pub target: Option, pub author: Option, } @@ -560,12 +604,18 @@ impl TicketItemEdit { if let Some(replacement) = &self.body_replacement { replacement.validate()?; } + if let Some(target) = &self.target { + target.validate()?; + } Ok(()) } fn has_changes(&self) -> bool { - self.title.is_some() || self.body.is_some() || self.body_replacement.is_some() + self.title.is_some() + || self.body.is_some() + || self.body_replacement.is_some() + || self.target.is_some() } } @@ -1373,6 +1423,8 @@ pub struct TicketMeta { pub workflow_state_explicit: bool, pub queued_by: Option, pub queued_at: Option, + pub repository_id: Option, + pub ref_selector: Option, pub raw: BTreeMap, } @@ -2298,6 +2350,8 @@ CREATE TABLE IF NOT EXISTS typed_tickets ( queued_by TEXT, queued_at TEXT, resolution TEXT, + repository_id TEXT, + ref_selector TEXT, PRIMARY KEY (workspace_id, ticket_id) ); CREATE TABLE IF NOT EXISTS typed_ticket_labels ( @@ -2368,7 +2422,11 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts ( PRIMARY KEY (workspace_id, ticket_id, relative_path), FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE ); -"#).map_err(sqlite_err) +"#) + .map_err(sqlite_err)?; + ensure_sqlite_ticket_column(conn, "repository_id", "TEXT")?; + ensure_sqlite_ticket_column(conn, "ref_selector", "TEXT")?; + Ok(()) } fn with_write(&self, op: impl FnOnce(&Connection) -> Result) -> Result { @@ -2475,9 +2533,9 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts ( fn insert_ticket(&self, conn: &Connection, ticket: &Ticket) -> Result<()> { conn.execute(r#"INSERT INTO typed_tickets - (workspace_id, ticket_id, slug, title, status, kind, priority, body, created_at, updated_at, assignee, readiness, workflow_state, workflow_state_explicit, queued_by, queued_at, resolution) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)"#, - params![self.workspace_id, ticket.meta.id, ticket.meta.slug, ticket.meta.title, ticket.meta.status.as_str(), ticket.meta.kind, ticket.meta.priority, ticket.document.body.as_str(), ticket.meta.created_at, ticket.meta.updated_at, ticket.meta.assignee, ticket.meta.readiness, ticket.meta.workflow_state.as_str(), if ticket.meta.workflow_state_explicit { 1 } else { 0 }, ticket.meta.queued_by, ticket.meta.queued_at, ticket.resolution.as_ref().map(|body| body.as_str())] + (workspace_id, ticket_id, slug, title, status, kind, priority, body, created_at, updated_at, assignee, readiness, workflow_state, workflow_state_explicit, queued_by, queued_at, resolution, repository_id, ref_selector) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)"#, + params![self.workspace_id, ticket.meta.id, ticket.meta.slug, ticket.meta.title, ticket.meta.status.as_str(), ticket.meta.kind, ticket.meta.priority, ticket.document.body.as_str(), ticket.meta.created_at, ticket.meta.updated_at, ticket.meta.assignee, ticket.meta.readiness, ticket.meta.workflow_state.as_str(), if ticket.meta.workflow_state_explicit { 1 } else { 0 }, ticket.meta.queued_by, ticket.meta.queued_at, ticket.resolution.as_ref().map(|body| body.as_str()), ticket.meta.repository_id, ticket.meta.ref_selector] ).map_err(sqlite_err)?; self.insert_ordered_values( conn, @@ -2565,12 +2623,14 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts ( workflow_state_explicit: row.get::<_, i64>(13)? != 0, queued_by: row.get(14)?, queued_at: row.get(15)?, + repository_id: row.get(16)?, + ref_selector: row.get(17)?, raw: BTreeMap::new(), }) } fn load_ticket(&self, conn: &Connection, ticket_id: &str) -> Result { - let (mut meta, body, resolution): (TicketMeta, String, Option) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#, + let (mut meta, body, resolution): (TicketMeta, String, Option) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#, params![self.workspace_id, ticket_id], |row| Ok((Self::ticket_meta_from_row(row)?, row.get(10)?, row.get(11)?))).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(ticket_id.to_string()))?; meta.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", ticket_id)?; meta.risk_flags = @@ -2733,7 +2793,7 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts ( conn: &Connection, filter: TicketListQuery, ) -> Result> { - let mut stmt = conn.prepare(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at FROM typed_tickets WHERE workspace_id = ?1 ORDER BY ticket_id ASC"#).map_err(sqlite_err)?; + let mut stmt = conn.prepare(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, repository_id, ref_selector FROM typed_tickets WHERE workspace_id = ?1 ORDER BY ticket_id ASC"#).map_err(sqlite_err)?; let rows = stmt .query_map(params![self.workspace_id], Self::ticket_meta_from_row) .map_err(sqlite_err)?; @@ -2806,6 +2866,30 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts ( } } +fn ensure_sqlite_ticket_column( + conn: &rusqlite::Connection, + name: &str, + sql_type: &str, +) -> Result<()> { + let mut statement = conn + .prepare("PRAGMA table_info(typed_tickets)") + .map_err(sqlite_err)?; + let columns = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(sqlite_err)?; + for column in columns { + if column.map_err(sqlite_err)? == name { + return Ok(()); + } + } + conn.execute( + format!("ALTER TABLE typed_tickets ADD COLUMN {name} {sql_type}").as_str(), + [], + ) + .map_err(sqlite_err)?; + Ok(()) +} + fn finish_sqlite_transaction(conn: &Connection, result: Result) -> Result { match result { Ok(output) => { @@ -2865,6 +2949,10 @@ impl TicketBackend for SqliteTicketBackend { "ticket title must not be empty".to_string(), )); } + validate_ticket_target( + input.repository_id.as_deref(), + input.ref_selector.as_deref(), + )?; let base_millis = unix_epoch_millis_now().map_err(|err| { TicketError::Conflict(format!("failed to read ticket id timestamp: {err}")) })?; @@ -2900,6 +2988,8 @@ impl TicketBackend for SqliteTicketBackend { workflow_state_explicit: true, queued_by: input.queued_by, queued_at: input.queued_at, + repository_id: input.repository_id, + ref_selector: input.ref_selector, raw: BTreeMap::new(), }; let body = if input.body.as_str() == DEFAULT_TICKET_BODY { @@ -2948,7 +3038,7 @@ impl TicketBackend for SqliteTicketBackend { edit.validate_body_edit_request()?; if !edit.has_changes() { return Err(TicketError::Conflict( - "TicketEditItem requires at least one of title, body, or body_replacement" + "TicketEditItem requires at least one of title, body, body_replacement, or target" .to_string(), )); } @@ -2986,6 +3076,20 @@ impl TicketBackend for SqliteTicketBackend { if let Some(body) = updated_body.as_ref() { conn.execute("UPDATE typed_tickets SET body = ?3, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, body.as_str(), now]).map_err(sqlite_err)?; } + if let Some(target) = edit.target.as_ref() { + let (repository_id, ref_selector) = match target { + TicketTargetEdit::Set { + repository_id, + ref_selector, + } => (Some(repository_id.as_str()), ref_selector.as_deref()), + TicketTargetEdit::Clear => (None, None), + }; + conn.execute( + "UPDATE typed_tickets SET repository_id = ?3, ref_selector = ?4, updated_at = ?5 WHERE workspace_id = ?1 AND ticket_id = ?2", + params![self.workspace_id, ticket_id, repository_id, ref_selector, now], + ) + .map_err(sqlite_err)?; + } let mut changes = Vec::new(); if edit.title.is_some() { @@ -2994,6 +3098,9 @@ impl TicketBackend for SqliteTicketBackend { if !matches!(body_edit_audit, TicketBodyEditAudit::None) { changes.push("body"); } + if edit.target.is_some() { + changes.push("target"); + } let mut attributes = BTreeMap::new(); attributes.insert("changes".to_string(), changes.join(",")); let body = match body_edit_audit { @@ -3305,6 +3412,10 @@ impl TicketBackend for LocalTicketBackend { "ticket title must not be empty".to_string(), )); } + validate_ticket_target( + input.repository_id.as_deref(), + input.ref_selector.as_deref(), + )?; let base_millis = unix_epoch_millis_now().map_err(|err| { TicketError::Conflict(format!("failed to read ticket id timestamp: {err}")) })?; @@ -3375,6 +3486,18 @@ impl TicketBackend for LocalTicketBackend { format_yaml_string_scalar(queued_at.as_str()), )); } + if let Some(repository_id) = input.repository_id { + fields.push(( + "repository_id".to_string(), + format_yaml_string_scalar(repository_id.as_str()), + )); + } + if let Some(ref_selector) = input.ref_selector { + fields.push(( + "ref_selector".to_string(), + format_yaml_string_scalar(ref_selector.as_str()), + )); + } let item_body = if input.body.as_str() == DEFAULT_TICKET_BODY { self.generated_default_body() } else { @@ -3426,6 +3549,48 @@ impl TicketBackend for LocalTicketBackend { } })?; } + if let Some(target) = edit.target.as_ref() { + match target { + TicketTargetEdit::Set { + repository_id, + ref_selector, + } => { + content = replace_frontmatter_fields( + &content, + &[("repository_id", repository_id.as_str())], + ) + .map_err(|message| TicketError::Parse { + path: item.clone(), + message, + })?; + if let Some(ref_selector) = ref_selector { + content = replace_frontmatter_fields( + &content, + &[("ref_selector", ref_selector.as_str())], + ) + .map_err(|message| TicketError::Parse { + path: item.clone(), + message, + })?; + } else { + content = remove_frontmatter_fields(&content, &["ref_selector"]).map_err( + |message| TicketError::Parse { + path: item.clone(), + message, + }, + )?; + } + } + TicketTargetEdit::Clear => { + content = + remove_frontmatter_fields(&content, &["repository_id", "ref_selector"]) + .map_err(|message| TicketError::Parse { + path: item.clone(), + message, + })?; + } + } + } if let Some(body) = edit.body.as_ref() { content = replace_item_body(&content, body.as_str()).map_err(|message| { TicketError::Parse { @@ -3461,6 +3626,9 @@ impl TicketBackend for LocalTicketBackend { if !matches!(body_edit_audit, TicketBodyEditAudit::None) { changes.push("body"); } + if edit.target.is_some() { + changes.push("target"); + } let mut attrs = Vec::new(); attrs.push(("changes", changes.join(","))); let body = match body_edit_audit { @@ -4104,6 +4272,8 @@ struct TicketItemFrontmatter { state_explicit: bool, queued_by: Option, queued_at: Option, + repository_id: Option, + ref_selector: Option, raw: BTreeMap, } @@ -4211,6 +4381,8 @@ fn parse_ticket_frontmatter(content: &str) -> std::result::Result TicketMeta { workflow_state_explicit: frontmatter.state_explicit, queued_by: frontmatter.queued_by, queued_at: frontmatter.queued_at, + repository_id: frontmatter.repository_id, + ref_selector: frontmatter.ref_selector, raw: frontmatter.raw, } } @@ -5032,6 +5206,37 @@ fn replace_frontmatter_fields( Ok(out) } +fn remove_frontmatter_fields( + content: &str, + fields: &[&str], +) -> std::result::Result { + let mut lines: Vec = content.lines().map(ToOwned::to_owned).collect(); + if lines.first().map(String::as_str) != Some("---") { + return Err("item.md missing frontmatter opener".to_string()); + } + let Some(end) = lines + .iter() + .enumerate() + .skip(1) + .find_map(|(idx, line)| (line == "---").then_some(idx)) + else { + return Err("item.md missing frontmatter closer".to_string()); + }; + for index in (1..end).rev() { + let should_remove = lines[index] + .split_once(':') + .is_some_and(|(key, _)| fields.contains(&key.trim())); + if should_remove { + lines.remove(index); + } + } + let mut out = lines.join("\n"); + if content.ends_with('\n') { + out.push('\n'); + } + Ok(out) +} + fn replace_item_body(content: &str, body: &str) -> std::result::Result { let mut lines = content.lines(); if lines.next() != Some("---") { @@ -5692,6 +5897,56 @@ mod tests { LocalTicketBackend::new(dir.path().join("tickets")) } + fn assert_ticket_target_edit_semantics(backend: &B) { + let mut input = NewTicket::new("Target Ticket"); + input.repository_id = Some("main".to_string()); + input.ref_selector = Some("feature/api".to_string()); + let created = backend.create(input).unwrap(); + let ticket = backend + .show(TicketIdOrSlug::Id(created.id.clone())) + .unwrap(); + assert_eq!(ticket.meta.repository_id.as_deref(), Some("main")); + assert_eq!(ticket.meta.ref_selector.as_deref(), Some("feature/api")); + + let edited = backend + .edit_item( + TicketIdOrSlug::Id(created.id.clone()), + TicketItemEdit { + target: Some(TicketTargetEdit::Set { + repository_id: "secondary".to_string(), + ref_selector: None, + }), + author: Some("tester".to_string()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(edited.meta.repository_id.as_deref(), Some("secondary")); + assert_eq!(edited.meta.ref_selector, None); + let edit_event = edited + .events + .iter() + .rev() + .find(|event| event.kind == TicketEventKind::Other("item_edit".to_string())) + .expect("item_edit event"); + assert_eq!( + edit_event.attributes.get("changes"), + Some(&"target".to_string()) + ); + + let cleared = backend + .edit_item( + TicketIdOrSlug::Id(created.id), + TicketItemEdit { + target: Some(TicketTargetEdit::Clear), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(cleared.meta.repository_id, None); + assert_eq!(cleared.meta.ref_selector, None); + } + fn assert_partial_body_replacement_semantics(backend: &B) { let mut input = NewTicket::new("Body Edit Ticket"); input.body = MarkdownText::new("alpha\nbeta\nalpha\n"); @@ -6092,6 +6347,38 @@ state: planning assert!(report.is_ok(), "{:?}", report.diagnostics); } + #[test] + fn local_backend_persists_and_edits_ticket_target() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + assert_ticket_target_edit_semantics(&backend); + } + + #[test] + fn sqlite_backend_persists_and_edits_ticket_target() { + let tmp = TempDir::new().unwrap(); + let backend = SqliteTicketBackend::new(tmp.path().join("workspace.db"), "workspace-test"); + assert_ticket_target_edit_semantics(&backend); + } + + #[test] + fn sqlite_ticket_target_columns_are_added_to_existing_table() { + let tmp = TempDir::new().unwrap(); + let conn = rusqlite::Connection::open(tmp.path().join("workspace.db")).unwrap(); + conn.execute_batch("CREATE TABLE typed_tickets (ticket_id TEXT PRIMARY KEY);") + .unwrap(); + ensure_sqlite_ticket_column(&conn, "repository_id", "TEXT").unwrap(); + ensure_sqlite_ticket_column(&conn, "ref_selector", "TEXT").unwrap(); + let mut statement = conn.prepare("PRAGMA table_info(typed_tickets)").unwrap(); + let columns = statement + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap(); + assert!(columns.iter().any(|column| column == "repository_id")); + assert!(columns.iter().any(|column| column == "ref_selector")); + } + #[test] fn local_backend_edit_item_supports_partial_body_replacement() { let tmp = TempDir::new().unwrap(); diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 6477a05d..fb88153e 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -382,6 +382,12 @@ struct TicketCreateParams { /// Optional queued_at frontmatter value. #[serde(default)] queued_at: Option, + /// Optional target Workspace repository id. + #[serde(default)] + repository_id: Option, + /// Optional target Git ref selector. Requires `repository_id`. + #[serde(default)] + ref_selector: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -403,6 +409,9 @@ struct TicketEditItemParams { /// Replace every occurrence of `old_string`; by default exactly one occurrence is required. #[serde(default)] replace_all: bool, + /// Optional target repository/ref update. + #[serde(default)] + target: Option, /// Optional thread author for the audited item_edit event. #[serde(default)] author: Option, @@ -916,6 +925,8 @@ impl Tool for TicketCreateTool { input.workflow_state = params.state.map(TicketWorkflowStateParam::into_state); input.queued_by = params.queued_by; input.queued_at = params.queued_at; + input.repository_id = params.repository_id; + input.ref_selector = params.ref_selector; let created = self .backend @@ -959,6 +970,7 @@ impl Tool for TicketEditItemTool { title: params.title, body: params.body.map(MarkdownText::new), body_replacement, + target: params.target, author: params.author, }; let ticket = self diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index bd4f7f1e..2105cfa0 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -10,7 +10,7 @@ use ticket::{ use crate::records::{ ObjectiveDetail, ObjectiveResourceSummary, ObjectiveSummary, ProjectRecordList, TicketDetail, - TicketSummary, summarize_body, truncate_body, validate_project_id, + TicketEventDetail, TicketSummary, summarize_body, truncate_body, validate_project_id, }; use crate::store::{ ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, @@ -19,6 +19,8 @@ use crate::store::{ use crate::{Error, Result}; const DETAIL_BODY_LIMIT: usize = 64 * 1024; +const TICKET_EVENT_LIMIT: usize = 100; +const TICKET_EVENT_BODY_LIMIT: usize = 16 * 1024; const DEFAULT_MEMORY_DOCUMENT_BODY: &str = "# Memory\n\n"; const RECORD_SOURCE_WORKSPACE_SQLITE: &str = "workspace-sqlite"; @@ -244,6 +246,25 @@ impl TicketAuthority for SqliteWorkspaceAuthority { .show(TicketIdOrSlug::Id(id.to_string()))?; let (body, body_truncated) = truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT); + let event_start = ticket.events.len().saturating_sub(TICKET_EVENT_LIMIT); + let events = ticket.events[event_start..] + .iter() + .enumerate() + .map(|(index, event)| TicketEventDetail { + sequence: event_start + index, + kind: event.kind.as_str().to_owned(), + author: event.author.clone(), + at: event.at.clone(), + status: event.status.clone(), + from: event.from.clone(), + to: event.to.clone(), + reason: event.reason.clone(), + state_field: event.state_field.clone(), + heading: event.heading.clone(), + body: (!event.body.as_str().is_empty()) + .then(|| truncate_body(event.body.as_str(), TICKET_EVENT_BODY_LIMIT).0), + }) + .collect(); Ok(TicketDetail { id: ticket.meta.id, title: ticket.meta.title, @@ -253,11 +274,22 @@ impl TicketAuthority for SqliteWorkspaceAuthority { updated_at: ticket.meta.updated_at, queued_by: ticket.meta.queued_by, queued_at: ticket.meta.queued_at, + repository_id: ticket.meta.repository_id, + ref_selector: ticket.meta.ref_selector, risk_flags: ticket.meta.risk_flags, body, body_truncated, event_count: ticket.events.len(), + events, artifact_count: ticket.artifacts.len(), + artifacts: ticket + .artifacts + .into_iter() + .map(|artifact| artifact.relative_path.display().to_string()) + .collect(), + resolution: ticket + .resolution + .map(|resolution| resolution.as_str().to_string()), record_source: "sqlite_yoi_ticket".to_string(), }) } diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index 121741e7..830a8a66 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -41,14 +41,34 @@ pub struct TicketDetail { pub updated_at: Option, pub queued_by: Option, pub queued_at: Option, + pub repository_id: Option, + pub ref_selector: Option, pub risk_flags: Vec, pub body: String, pub body_truncated: bool, pub event_count: usize, + pub events: Vec, pub artifact_count: usize, + pub artifacts: Vec, + pub resolution: Option, pub record_source: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TicketEventDetail { + pub sequence: usize, + pub kind: String, + pub author: Option, + pub at: Option, + pub status: Option, + pub from: Option, + pub to: Option, + pub reason: Option, + pub state_field: Option, + pub heading: Option, + pub body: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ObjectiveSummary { pub id: String, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 3ee955db..736591c0 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -19,6 +19,11 @@ use memory::backend::{ use protocol::stream::{decode_method, encode_event}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use ticket::{ + MarkdownText, NewTicketEvent, TicketBackend, TicketBodyReplacement, TicketEventKind, + TicketIdOrSlug, TicketItemEdit, TicketReview, TicketReviewResult, TicketStateChange, + TicketTargetEdit, TicketWorkflowState, +}; use ticket::{ SqliteTicketBackend, TicketBackendHttpResponse, TicketBackendOperation, execute_ticket_backend_operation, @@ -511,7 +516,30 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/skills/{name}/activate", get(scoped_activate_skill), ) - .route("/api/w/{workspace_id}/tickets/{id}", get(scoped_get_ticket)) + .route( + "/api/w/{workspace_id}/tickets/{id}", + get(scoped_get_ticket).patch(scoped_edit_ticket_item), + ) + .route( + "/api/w/{workspace_id}/tickets/{id}/state", + post(scoped_transition_ticket_state), + ) + .route( + "/api/w/{workspace_id}/tickets/{id}/events", + post(scoped_append_ticket_event), + ) + .route( + "/api/w/{workspace_id}/tickets/{id}/reviews", + post(scoped_review_ticket), + ) + .route( + "/api/w/{workspace_id}/tickets/{id}/queue", + post(scoped_queue_ticket), + ) + .route( + "/api/w/{workspace_id}/tickets/{id}/close", + post(scoped_close_ticket), + ) .route("/api/objectives", get(list_objectives)) .route( "/api/w/{workspace_id}/objectives", @@ -1526,6 +1554,220 @@ async fn scoped_get_ticket( get_ticket(State(api), AxumPath(path.id)).await } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BrowserEditTicketRequest { + title: Option, + body: Option, + old_string: Option, + new_string: Option, + #[serde(default)] + replace_all: bool, + target: Option, + author: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BrowserTransitionTicketStateRequest { + state: TicketWorkflowState, + reason: Option, + body: Option, + author: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum BrowserTicketThreadRole { + Comment, + Plan, + Decision, + ImplementationReport, +} + +impl From for TicketEventKind { + fn from(role: BrowserTicketThreadRole) -> Self { + match role { + BrowserTicketThreadRole::Comment => Self::Comment, + BrowserTicketThreadRole::Plan => Self::Plan, + BrowserTicketThreadRole::Decision => Self::Decision, + BrowserTicketThreadRole::ImplementationReport => Self::ImplementationReport, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BrowserAppendTicketEventRequest { + role: BrowserTicketThreadRole, + body: String, + author: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BrowserReviewTicketRequest { + result: TicketReviewResult, + body: String, + author: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BrowserQueueTicketRequest { + queued_by: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BrowserCloseTicketRequest { + resolution: String, +} + +fn browser_ticket_backend(api: &WorkspaceApi) -> Result { + let config = ticket::config::TicketConfig::load_workspace(&api.config.workspace_root) + .map_err(|error| Error::Config(format!("load Ticket workspace settings: {error}")))?; + Ok(SqliteTicketBackend::new( + api.config.database_path.clone(), + api.config.workspace_id.clone(), + ) + .with_record_language(config.ticket_record_language())) +} + +fn browser_ticket_detail(api: &WorkspaceApi, ticket_id: &str) -> ApiResult> { + Ok(Json(api.authority.ticket(ticket_id)?)) +} + +async fn scoped_edit_ticket_item( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + if let Some(TicketTargetEdit::Set { repository_id, .. }) = request.target.as_ref() { + if !api + .store + .list_repositories(&api.config.workspace_id)? + .iter() + .any(|repository| repository.repository_id == *repository_id) + { + return Err(settings_bad_request( + "unknown_ticket_repository", + "repository_id must identify a repository registered in this Workspace", + )); + } + } + browser_ticket_backend(&api)? + .edit_item( + TicketIdOrSlug::Id(path.id.clone()), + TicketItemEdit { + title: request.title, + body: request.body.map(MarkdownText::new), + body_replacement: match (request.old_string, request.new_string) { + (Some(old_string), Some(new_string)) => Some(TicketBodyReplacement { + old_string, + new_string, + replace_all: request.replace_all, + }), + (None, None) => None, + _ => { + return Err(settings_bad_request( + "invalid_ticket_edit_replacement", + "old_string and new_string must be provided together", + )); + } + }, + target: request.target, + author: request.author, + }, + ) + .map_err(Error::from)?; + browser_ticket_detail(&api, &path.id) +} + +async fn scoped_transition_ticket_state( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let current = api.authority.ticket(&path.id)?; + let mut change = TicketStateChange::new( + current.state, + request.state.as_str(), + request + .reason + .unwrap_or_else(|| "state changed from Web Ticket API".to_owned()), + request.body.unwrap_or_default(), + ); + change.author = request.author; + browser_ticket_backend(&api)? + .set_workflow_state(TicketIdOrSlug::Id(path.id.clone()), change) + .map_err(Error::from)?; + browser_ticket_detail(&api, &path.id) +} + +async fn scoped_append_ticket_event( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let mut event = NewTicketEvent::new(request.role.into(), request.body); + event.author = request.author; + browser_ticket_backend(&api)? + .add_event(TicketIdOrSlug::Id(path.id.clone()), event) + .map_err(Error::from)?; + browser_ticket_detail(&api, &path.id) +} + +async fn scoped_review_ticket( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + browser_ticket_backend(&api)? + .review( + TicketIdOrSlug::Id(path.id.clone()), + TicketReview { + result: request.result, + body: MarkdownText::new(request.body), + author: request.author, + }, + ) + .map_err(Error::from)?; + browser_ticket_detail(&api, &path.id) +} + +async fn scoped_queue_ticket( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let queued_by = request.queued_by.as_deref().unwrap_or("web"); + browser_ticket_backend(&api)? + .queue_ready(TicketIdOrSlug::Id(path.id.clone()), queued_by) + .map_err(Error::from)?; + browser_ticket_detail(&api, &path.id) +} + +async fn scoped_close_ticket( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + browser_ticket_backend(&api)? + .close( + TicketIdOrSlug::Id(path.id.clone()), + MarkdownText::new(request.resolution), + ) + .map_err(Error::from)?; + browser_ticket_detail(&api, &path.id) +} + async fn scoped_ticket_backend_operation( State(api): State, AxumPath(path): AxumPath, @@ -4329,19 +4571,35 @@ async fn create_workspace_worker( }, ) .map_err(|err| err.into_error())?; + Ok(Json(record_browser_worker_spawn( + &api, + request.runtime_id, + display_name, + selected_working_directory_id, + result, + )?)) +} + +fn record_browser_worker_spawn( + api: &WorkspaceApi, + requested_runtime_id: String, + display_name: String, + selected_working_directory_id: Option, + result: WorkerSpawnResult, +) -> ApiResult { if result.state != WorkerOperationState::Accepted { return Err(worker_create_not_accepted_error( - request.runtime_id.clone(), + requested_runtime_id.clone(), result.diagnostics, )); } let worker = result.worker.ok_or_else(|| Error::RuntimeOperationFailed { - runtime_id: request.runtime_id.clone(), + runtime_id: requested_runtime_id, code: "workspace_worker_create_missing_summary".to_string(), message: "Runtime completed worker creation without returning a Worker summary".to_string(), })?; let worker_record = record_worker_summary( - &api, + api, &worker, display_name.as_str(), worker.profile.clone(), @@ -4349,13 +4607,9 @@ async fn create_workspace_worker( )?; if let Some(working_directory) = worker.working_directory.as_ref() { let workdir_record = - workdir_record_from_summary(&api, worker.runtime_id.as_str(), working_directory); + workdir_record_from_summary(api, worker.runtime_id.as_str(), working_directory); api.store.upsert_workdir_registry(&workdir_record)?; - link_worker_to_workdir( - &api, - &worker_record, - &working_directory.working_directory_id, - )?; + link_worker_to_workdir(api, &worker_record, &working_directory.working_directory_id)?; } if let Some(workdir_id) = selected_working_directory_id.as_deref() { if api @@ -4370,7 +4624,7 @@ async fn create_workspace_worker( { if let Some(status) = result.working_directory { let record = workdir_record_from_summary( - &api, + api, worker.runtime_id.as_str(), &status.summary, ); @@ -4383,7 +4637,7 @@ async fn create_workspace_worker( .get_workdir_registry(&api.config.workspace_id, workdir_id)? .is_some() { - link_worker_to_workdir(&api, &worker_record, workdir_id)?; + link_worker_to_workdir(api, &worker_record, workdir_id)?; } } let runtime_id = worker.runtime_id.clone(); @@ -4395,14 +4649,14 @@ async fn create_workspace_worker( encode_path_segment(&runtime_id), encode_path_segment(&worker_id) ); - Ok(Json(BrowserCreateWorkerResponse { + Ok(BrowserCreateWorkerResponse { workspace_id, runtime_id, worker_id, console_href, worker, diagnostics: result.diagnostics, - })) + }) } async fn post_internal_runtime_resource_fetch( @@ -6749,6 +7003,7 @@ fn workspace_id_mismatch_error() -> ApiError { ) } +#[derive(Debug)] struct ApiError { error: Error, diagnostics: Vec, @@ -6762,6 +7017,22 @@ impl From for ApiError { severity: DiagnosticSeverity::Error, message: sanitize_backend_error(message), }], + Error::Ticket(ticket_error) => vec![RuntimeDiagnostic { + code: match ticket_error { + ticket::TicketError::NotFound(_) => "ticket_not_found", + ticket::TicketError::Ambiguous { .. } => "ticket_ambiguous", + ticket::TicketError::Locked { .. } => "ticket_locked", + ticket::TicketError::Conflict(_) => "ticket_conflict", + ticket::TicketError::InvalidPathComponent(_) + | ticket::TicketError::PathEscapesRoot { .. } => "invalid_ticket_request", + ticket::TicketError::Io { .. } + | ticket::TicketError::Parse { .. } + | ticket::TicketError::Sqlite(_) => "ticket_backend_error", + } + .to_string(), + severity: DiagnosticSeverity::Error, + message: sanitize_backend_error(&ticket_error.to_string()), + }], _ => Vec::new(), }; Self { error, diagnostics } @@ -6778,6 +7049,16 @@ impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match &self.error { Error::InvalidRuntimeIdentifier { .. } => StatusCode::BAD_REQUEST, + Error::Ticket(ticket::TicketError::NotFound(_)) => StatusCode::NOT_FOUND, + Error::Ticket( + ticket::TicketError::Ambiguous { .. } + | ticket::TicketError::Locked { .. } + | ticket::TicketError::Conflict(_), + ) => StatusCode::CONFLICT, + Error::Ticket( + ticket::TicketError::InvalidPathComponent(_) + | ticket::TicketError::PathEscapesRoot { .. }, + ) => StatusCode::BAD_REQUEST, Error::InvalidRecordId(_) | Error::MissingFrontmatter(_) | Error::UnknownHost(_) @@ -6896,6 +7177,21 @@ mod tests { const TEST_REPOSITORY_ID: &str = "main"; const TEST_CREATED_AT: &str = "2026-06-23T06:43:28Z"; + #[test] + fn ticket_api_errors_preserve_http_status() { + let not_found = ApiError::from(Error::Ticket(ticket::TicketError::NotFound( + "0000000000000".to_string(), + ))) + .into_response(); + assert_eq!(not_found.status(), StatusCode::NOT_FOUND); + + let conflict = ApiError::from(Error::Ticket(ticket::TicketError::Conflict( + "invalid transition".to_string(), + ))) + .into_response(); + assert_eq!(conflict.status(), StatusCode::CONFLICT); + } + #[test] fn backend_worker_projection_preserves_missing_rows_links_and_redacts_paths() { let worker = WorkerRegistryRecord { @@ -7669,6 +7965,127 @@ mod tests { } } + #[tokio::test] + async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let Json(created) = scoped_ticket_backend_operation( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Json(TicketBackendOperation::Create { + input: ticket::NewTicket::new("Browser Ticket API"), + }), + ) + .await + .unwrap(); + let ticket_id = match created { + TicketBackendHttpResponse::Ok { + result: ticket::TicketBackendOperationResult::TicketRef(ticket_ref), + } => ticket_ref.id, + other => panic!("unexpected create response: {other:?}"), + }; + let path = || ScopedRecordPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + id: ticket_id.clone(), + }; + + let Json(edited) = scoped_edit_ticket_item( + State(api.clone()), + AxumPath(path()), + Json(BrowserEditTicketRequest { + title: Some("Browser Ticket API edited".to_string()), + body: Some("Updated from the Browser API.".to_string()), + old_string: None, + new_string: None, + replace_all: false, + target: Some(TicketTargetEdit::Set { + repository_id: "main".to_string(), + ref_selector: Some("feature/api".to_string()), + }), + author: Some("browser-user".to_string()), + }), + ) + .await + .unwrap(); + assert_eq!(edited.title, "Browser Ticket API edited"); + assert_eq!(edited.body, "Updated from the Browser API."); + assert_eq!(edited.repository_id.as_deref(), Some("main")); + assert_eq!(edited.ref_selector.as_deref(), Some("feature/api")); + + let Json(commented) = scoped_append_ticket_event( + State(api.clone()), + AxumPath(path()), + Json(BrowserAppendTicketEventRequest { + role: BrowserTicketThreadRole::Comment, + body: "API comment".to_string(), + author: Some("browser-user".to_string()), + }), + ) + .await + .unwrap(); + assert!(commented.events.iter().any(|event| { + event.kind == "comment" && event.body.as_deref() == Some("API comment") + })); + + let Json(ready) = scoped_transition_ticket_state( + State(api.clone()), + AxumPath(path()), + Json(BrowserTransitionTicketStateRequest { + state: TicketWorkflowState::Ready, + reason: Some("intake complete".to_string()), + body: Some("Ready for queue".to_string()), + author: Some("browser-user".to_string()), + }), + ) + .await + .unwrap(); + assert_eq!(ready.state, "ready"); + + let Json(queued) = scoped_queue_ticket( + State(api.clone()), + AxumPath(path()), + Json(BrowserQueueTicketRequest { + queued_by: Some("browser-user".to_string()), + }), + ) + .await + .unwrap(); + assert_eq!(queued.state, "queued"); + assert_eq!(queued.queued_by.as_deref(), Some("browser-user")); + + let Json(reviewed) = scoped_review_ticket( + State(api.clone()), + AxumPath(path()), + Json(BrowserReviewTicketRequest { + result: TicketReviewResult::Approve, + body: "API review".to_string(), + author: Some("reviewer".to_string()), + }), + ) + .await + .unwrap(); + assert!(reviewed.events.iter().any(|event| { + event.kind == "review" && event.body.as_deref() == Some("API review") + })); + + let Json(closed) = scoped_close_ticket( + State(api), + AxumPath(path()), + Json(BrowserCloseTicketRequest { + resolution: "Closed through the Browser API.".to_string(), + }), + ) + .await + .unwrap(); + assert_eq!(closed.state, "closed"); + assert_eq!( + closed.resolution.as_deref(), + Some("Closed through the Browser API.") + ); + } + #[tokio::test] async fn ticket_backend_endpoint_uses_workspace_sqlite_backend() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index f94949c7..65ae6711 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -82,6 +82,11 @@ const MIGRATIONS: &[Migration] = &[ name: "objective mutation audit events", apply: create_objective_event_tables, }, + Migration { + version: 14, + name: "remove unused control-plane Ticket tables", + apply: remove_unused_control_plane_ticket_tables, + }, ]; struct Migration { @@ -2156,6 +2161,20 @@ CREATE TABLE IF NOT EXISTS memory_staging_resolutions ( Ok(()) } +fn remove_unused_control_plane_ticket_tables(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" +DROP TABLE IF EXISTS ticket_target_paths; +DROP TABLE IF EXISTS ticket_worker_links; +DROP TABLE IF EXISTS ticket_targets; +DROP TABLE IF EXISTS ticket_relations; +DROP TABLE IF EXISTS ticket_events; +DROP TABLE IF EXISTS tickets; +"#, + )?; + Ok(()) +} + fn create_objective_event_tables(conn: &Connection) -> Result<()> { conn.execute_batch( r#" @@ -2649,68 +2668,6 @@ CREATE TABLE IF NOT EXISTS workspaces ( updated_at TEXT NOT NULL ); -CREATE TABLE IF NOT EXISTS tickets ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - ticket_id TEXT PRIMARY KEY, - title TEXT NOT NULL, - state TEXT NOT NULL, - priority TEXT, - assignee_kind TEXT, - assignee_key TEXT, - assignee_display TEXT, - body_md TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - closed_at TEXT, - resolution_event_id TEXT -); - -CREATE TABLE IF NOT EXISTS ticket_events ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - event_id TEXT PRIMARY KEY, - ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, - event_seq INTEGER NOT NULL, - kind TEXT NOT NULL, - activity_id TEXT, - author_kind TEXT NOT NULL, - author_key TEXT NOT NULL, - author_display TEXT NOT NULL, - author_source_kind TEXT, - author_source_key TEXT, - created_at TEXT NOT NULL, - body_md TEXT, - subject_kind TEXT, - subject_id TEXT, - previous_state TEXT, - new_state TEXT, - status TEXT, - artifact_id TEXT, - worker_ref_kind TEXT, - worker_ref_key TEXT, - worker_display TEXT, - host_ref_kind TEXT, - host_ref_key TEXT, - host_display TEXT, - repository_id TEXT, - caused_by_event_id TEXT, - UNIQUE (ticket_id, event_seq) -); - -CREATE TABLE IF NOT EXISTS ticket_relations ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - source_ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, - target_ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, - kind TEXT NOT NULL, - created_at TEXT NOT NULL, - author_kind TEXT NOT NULL, - author_key TEXT NOT NULL, - author_display TEXT NOT NULL, - author_source_kind TEXT, - author_source_key TEXT, - note TEXT, - PRIMARY KEY (source_ticket_id, target_ticket_id, kind) -); - CREATE TABLE IF NOT EXISTS objectives ( workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, objective_id TEXT PRIMARY KEY, @@ -2793,43 +2750,6 @@ CREATE TABLE IF NOT EXISTS repositories ( updated_at TEXT NOT NULL ); -CREATE TABLE IF NOT EXISTS ticket_targets ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, - target_id TEXT NOT NULL, - repository_id TEXT NOT NULL REFERENCES repositories(repository_id) ON DELETE CASCADE, - role TEXT NOT NULL, - intent TEXT NOT NULL, - ref_selector TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (ticket_id, target_id) -); - -CREATE TABLE IF NOT EXISTS ticket_target_paths ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - ticket_id TEXT NOT NULL, - target_id TEXT NOT NULL, - path TEXT NOT NULL, - PRIMARY KEY (ticket_id, target_id, path), - FOREIGN KEY (ticket_id, target_id) REFERENCES ticket_targets(ticket_id, target_id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS ticket_worker_links ( - workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - ticket_id TEXT NOT NULL REFERENCES tickets(ticket_id) ON DELETE CASCADE, - worker_ref_kind TEXT NOT NULL, - worker_ref_key TEXT NOT NULL, - worker_display TEXT, - role TEXT NOT NULL, - status TEXT NOT NULL, - activity_id TEXT, - assigned_at TEXT, - released_at TEXT, - last_event_id TEXT, - PRIMARY KEY (ticket_id, worker_ref_kind, worker_ref_key, role) -); - CREATE TABLE IF NOT EXISTS artifacts ( workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, artifact_id TEXT PRIMARY KEY, @@ -2904,13 +2824,40 @@ mod tests { use super::*; use std::collections::BTreeSet; + #[test] + fn removes_unused_control_plane_ticket_tables() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + r#" +CREATE TABLE tickets (ticket_id TEXT PRIMARY KEY); +CREATE TABLE ticket_events (event_id TEXT PRIMARY KEY, ticket_id TEXT REFERENCES tickets(ticket_id)); +CREATE TABLE ticket_relations (source_ticket_id TEXT, target_ticket_id TEXT); +CREATE TABLE ticket_targets (ticket_id TEXT, target_id TEXT, PRIMARY KEY (ticket_id, target_id)); +CREATE TABLE ticket_target_paths (ticket_id TEXT, target_id TEXT, path TEXT); +CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); +"#, + ) + .unwrap(); + remove_unused_control_plane_ticket_tables(&conn).unwrap(); + for table in [ + "tickets", + "ticket_events", + "ticket_relations", + "ticket_targets", + "ticket_target_paths", + "ticket_worker_links", + ] { + assert!(!table_exists(&conn, table).unwrap(), "{table} still exists"); + } + } + #[tokio::test] async fn migrates_sqlite_and_preserves_workspace_record() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 13); + assert_eq!(store.schema_version().await.unwrap(), 14); let record = WorkspaceRecord { workspace_id: "local-dev".to_string(), @@ -2923,7 +2870,7 @@ mod tests { store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 13); + assert_eq!(reopened.schema_version().await.unwrap(), 14); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -2939,9 +2886,6 @@ mod tests { let tables = table_names(&conn); for expected in [ "workspaces", - "tickets", - "ticket_events", - "ticket_relations", "objectives", "objective_ticket_links", "objective_resources", @@ -2949,9 +2893,6 @@ mod tests { "workspace_memory_documents", "memory_staging_resolutions", "repositories", - "ticket_targets", - "ticket_target_paths", - "ticket_worker_links", "artifacts", "audit_events", "worker_registry", @@ -2977,6 +2918,12 @@ mod tests { "actors", "validation_results", "ci_results", + "tickets", + "ticket_events", + "ticket_relations", + "ticket_targets", + "ticket_target_paths", + "ticket_worker_links", ] { assert!( !tables.contains(forbidden), @@ -3017,39 +2964,6 @@ mod tests { "updated_at", ], ); - assert_columns( - &conn, - "ticket_events", - [ - "workspace_id", - "event_id", - "ticket_id", - "event_seq", - "kind", - "activity_id", - "author_kind", - "author_key", - "author_display", - "author_source_kind", - "author_source_key", - "created_at", - "body_md", - "subject_kind", - "subject_id", - "previous_state", - "new_state", - "status", - "artifact_id", - "worker_ref_kind", - "worker_ref_key", - "worker_display", - "host_ref_kind", - "host_ref_key", - "host_display", - "repository_id", - "caused_by_event_id", - ], - ); assert_columns( &conn, "worker_registry", @@ -3098,7 +3012,7 @@ mod tests { ], ); - for table in ["workspaces", "repositories", "ticket_events", "artifacts"] { + for table in ["workspaces", "repositories", "artifacts"] { let columns = table_columns(&conn, table).unwrap(); for forbidden_column in [ "payload", @@ -3144,7 +3058,7 @@ mod tests { .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 13); + assert_eq!(store.schema_version().await.unwrap(), 14); store .with_conn(|conn| { @@ -3152,9 +3066,6 @@ mod tests { for expected in [ "workspaces", "repositories", - "tickets", - "ticket_events", - "ticket_worker_links", "artifacts", "audit_events", "workspace_memory_documents", @@ -3172,7 +3083,19 @@ mod tests { "missing {expected} after upgrade" ); } - for forbidden in ["runs", "hosts", "workers", "actors", "validation_results"] { + for forbidden in [ + "runs", + "hosts", + "workers", + "actors", + "validation_results", + "tickets", + "ticket_events", + "ticket_relations", + "ticket_targets", + "ticket_target_paths", + "ticket_worker_links", + ] { assert!( !tables.contains(forbidden), "upgraded schema must not retain forbidden canonical table {forbidden}" @@ -3238,7 +3161,7 @@ mod tests { #[tokio::test] async fn repository_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 13); + assert_eq!(store.schema_version().await.unwrap(), 14); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -3276,7 +3199,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(), 13); + assert_eq!(store.schema_version().await.unwrap(), 14); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -3450,7 +3373,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(), 13); + assert_eq!(store.schema_version().await.unwrap(), 14); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(),