diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index bbbf4799c..16f5b575a 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -342,6 +342,8 @@ impl_openapi_schema!( TextResponse, TicketDependencyCheckResponse, TicketDetail, + TicketEventDetail, + TicketPostEditRequest, TicketCaseUpdateRequest, TicketAssignmentTrackingUpdateRequest, TicketAssignmentTrackingRecord, @@ -3331,10 +3333,29 @@ pub trait ServerApi { #[patch("/api/w/{workspace_id}/tickets/{id}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] async fn ticket_edit( &self, + #[extension] context: ServerRequestContext, #[path] workspace_id: String, #[path] id: String, #[body] request: BrowserEditTicketRequest, ) -> Result; + #[get("/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_post_get( + &self, + #[path] workspace_id: String, + #[path] id: String, + #[path] event_ref: String, + ) -> Result; + + #[patch("/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_post_edit( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[path] event_ref: String, + #[body] request: TicketPostEditRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/{id}/show", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] async fn ticket_show( &self, @@ -3390,6 +3411,7 @@ pub trait ServerApi { #[post("/api/w/{workspace_id}/tickets/{id}/events", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] async fn ticket_event_append( &self, + #[extension] context: ServerRequestContext, #[path] workspace_id: String, #[path] id: String, #[body] request: BrowserAppendTicketEventRequest, @@ -7391,6 +7413,12 @@ pub struct TicketListResponse { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct TicketEventDetail { + pub is_initial: bool, + pub edited_at: Option, + pub edited_by: Option, + pub body_digest: String, + pub body_truncated: bool, + pub editable: bool, #[cfg_attr(feature = "typescript", ts(type = "number"))] #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub sequence: usize, @@ -7800,6 +7828,8 @@ pub struct TicketShowRequest { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct TicketDetail { + pub created_by: Option, + pub initial_post: TicketEventDetail, pub id: String, #[cfg_attr(feature = "typescript", ts(type = "Array"))] pub tags: Vec, @@ -8182,6 +8212,14 @@ impl From for BrowserTicketState { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct TicketPostEditRequest { + pub body: String, + pub expected_body_digest: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index 9c7e0bfff..67210a88f 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -494,6 +494,20 @@ fn validate_ticket_browser_operations( request: Some("BrowserEditTicketRequest"), successes: &[("200", "TicketDetail")], }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}", + method: "get", + operation_id: "ticket_post_get", + request: None, + successes: &[("200", "TicketEventDetail")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}", + method: "patch", + operation_id: "ticket_post_edit", + request: Some("TicketPostEditRequest"), + successes: &[("200", "TicketDetail")], + }, OperationContract { path: "/api/w/{workspace_id}/tickets/{id}/assignments/{role}", method: "put", diff --git a/crates/ticket/README.md b/crates/ticket/README.md index 3b58ee68c..f47f14a26 100644 --- a/crates/ticket/README.md +++ b/crates/ticket/README.md @@ -9,3 +9,43 @@ Does not search Repository documents or handle Git integration. - [`TicketBackend::update_case`]: reasoned close, reopen, and closure correction - [`ticket_content_digest`]: requirement-content comparison condition - [`tool`]: Worker-facing operation definitions + +## Posts and creation metadata + +The initial body is a real `Comment` in `typed_ticket_events`, with the same author, +creation time, persisted `event_ref`, and edit metadata as later comments. +`Ticket.document.body` is a derived convenience, not another storage authority. +`TicketMeta.created_at` and optional `created_by` belong to the Ticket itself; +unknown legacy authors remain unknown. + +`TicketBackend::edit_post_with_snapshots` edits a comment by its persisted identity +and exact expected body, returning the before/after Ticket snapshots atomically for +host audit and initial-post rescope handling. The host must supply the authenticated +editor, rather than trusting an author from Web input. Original post author and time +are retained; `edited_at` and `edited_by` record the edit. System events/judgments +are not editable posts. `edit_item_with_snapshots` still supports title/target and +initial-body authoring, without generating a discussion message. + +Transport operation `TicketBackendOperation::EditPost { id, edit }` returns +`TicketBackendOperationResult::TicketSnapshots { previous, current }` by calling +that atomic snapshot method. The native `TicketEditPost` tool accepts `ticket`, +`event_ref`, `expected_body`, and `body`; it does not accept an author. Its standalone +actor default must be replaced with the authenticated caller by Workspace hosts. +Generic Show always returns `initial_post`, even when the recent-event window omits +it, using the same post identity/authorship/edit metadata projection as discussion +entries. Truncated bodies are marked and must not be used as exact edit observations. + +Component migration 12 appends to the retained 10/11 migration history. It reserves +SQL event index `-1` for the initial comment and leaves all prior event indices, +references, attributes, and evidence unchanged. Create/item_edit records are archived +in place (`archived = 1`) outside `Ticket.events`, not deleted. New creates insert +only the real initial comment; new item edits do not append events. Existing posts +retain their published identity: a stored `event_id`, otherwise the old zero-based +`sequence:` computed before inserting or hiding rows. Duplicate identities +abort migration rather than being silently changed. `event_ref` is persisted and +must not subsequently be recomputed from display position; existing attributes are +not overwritten. Initial edit metadata comes only from the last known body-edit +record (`changes` includes `body`), never a later title-only edit. Missing or empty +creator identities remain explicitly nullable, not inferred from later editors. +The retained base SQL and frozen fixture schemas remain unchanged; fresh creation +composes the ordered post migration. diff --git a/crates/ticket/src/assignment_answer.rs b/crates/ticket/src/assignment_answer.rs index f321c9975..2f0f58ffc 100644 --- a/crates/ticket/src/assignment_answer.rs +++ b/crates/ticket/src/assignment_answer.rs @@ -94,7 +94,7 @@ pub fn sqlite_answer_ticket_assignment_in_transaction( } let index: i64 = conn.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![workspace_id,ticket_id],|r| r.get(0)).map_err(sqlite_err)?; let at = now_utc(); - conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,heading,body) VALUES(?1,?2,?3,'assignment_answer',?4,?5,'Assignment answer',?6)", params![workspace_id,ticket_id,index,actor,at,answer.body]).map_err(sqlite_err)?; + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,heading,body,event_ref) VALUES(?1,?2,?3,'assignment_answer',?4,?5,'Assignment answer',?6,?2 || ':' || ?3)", params![workspace_id,ticket_id,index,actor,at,answer.body]).map_err(sqlite_err)?; for (key, value) in [ ("orchestrator_assignment_id", answer.assignment_id.as_str()), ( diff --git a/crates/ticket/src/assignment_tracking_tests.rs b/crates/ticket/src/assignment_tracking_tests.rs index c5ea6d1c3..f432636ee 100644 --- a/crates/ticket/src/assignment_tracking_tests.rs +++ b/crates/ticket/src/assignment_tracking_tests.rs @@ -9,7 +9,19 @@ fn db() -> Connection { } fn seed_ticket(conn: &Connection, workspace: &str, ticket: &str) { - conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES (?1,?2,?2,'Title','open','task','P2','body','open',1)",params![workspace,ticket]).unwrap(); + let legacy: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM pragma_table_info('typed_tickets') WHERE name='body')", + [], + |r| r.get(0), + ) + .unwrap(); + if legacy { + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES (?1,?2,?2,'Title','open','task','P2','body','open',1)",params![workspace,ticket]).unwrap(); + } else { + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) VALUES (?1,?2,?2,'Title','open','task','P2','open',1)",params![workspace,ticket]).unwrap(); + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES (?1,?2,-1,?2 || ':-1','comment','body')",params![workspace,ticket]).unwrap(); + } } fn assign(conn: &Connection, id: &str, event: &str) { @@ -396,7 +408,7 @@ fn migration_preserves_original_records_and_only_reconciles_current_fenced_deleg |r| r.get::<_, i64>(0) ) .unwrap(), - 11 + 12 ); let value = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") .unwrap() @@ -753,15 +765,21 @@ fn assignment_answer_commits_one_audited_input_and_exact_receipt_without_trackin assert_eq!(result.input_event_id, "ticket-event:0"); assert_eq!(apply(&answer).unwrap(), result); assert_eq!( - conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r - .get::<_, i64>(0)) - .unwrap(), + conn.query_row( + "SELECT count(*) FROM typed_ticket_events WHERE event_index >= 0", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), 1 ); assert_eq!( - conn.query_row("SELECT body FROM typed_ticket_events", [], |r| r - .get::<_, String>(0)) - .unwrap(), + conn.query_row( + "SELECT body FROM typed_ticket_events WHERE event_index >= 0", + [], + |r| r.get::<_, String>(0) + ) + .unwrap(), answer.body ); assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE key='orchestrator_assignment_id'",[],|r| r.get::<_,String>(0)).unwrap(),"a"); @@ -823,9 +841,12 @@ fn assignment_answer_rejects_unbounded_body_and_malformed_identities_without_app conn.execute_batch("ROLLBACK").unwrap(); } assert_eq!( - conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r - .get::<_, i64>(0)) - .unwrap(), + conn.query_row( + "SELECT count(*) FROM typed_ticket_events WHERE event_index >= 0", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), 0 ); } diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index d3539bca8..62423f1f6 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -623,7 +623,7 @@ pub fn sqlite_ticket_content_digest( ) -> Result> { let item: Option<(String, String)> = connection .query_row( - "SELECT title,body FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", + "SELECT title,(SELECT body FROM typed_ticket_events e WHERE e.workspace_id=typed_tickets.workspace_id AND e.ticket_id=typed_tickets.ticket_id AND e.event_index=-1) FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", params![workspace_id, ticket_id], |row| Ok((row.get(0)?, row.get(1)?)), ) @@ -1020,13 +1020,6 @@ struct TicketBodyReplacementOutcome { replacement_count: usize, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TicketBodyEditAudit { - None, - WholeBody, - Partial { replacement_count: usize }, -} - impl TicketItemEdit { fn validate_body_edit_request(&self) -> Result<()> { if self.body.is_some() && self.body_replacement.is_some() { @@ -1595,6 +1588,8 @@ pub struct TicketMeta { pub priority: String, pub labels: Vec, pub created_at: Option, + #[serde(default)] + pub created_by: Option, pub updated_at: Option, pub assignee: Option, pub readiness: Option, @@ -1675,6 +1670,15 @@ pub struct TicketDocument { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketEvent { + /// Persisted identity, independent of display position and legacy event_id attributes. + #[serde(default)] + pub event_ref: String, + #[serde(default)] + pub is_initial: bool, + #[serde(default)] + pub edited_at: Option, + #[serde(default)] + pub edited_by: Option, pub kind: TicketEventKind, pub author: Option, pub at: Option, @@ -1759,7 +1763,25 @@ impl TicketDoctorReport { } } +/// A compare-and-swap edit of a real comment post. The host supplies authenticated author. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketPostEdit { + pub event_ref: String, + pub expected_body: MarkdownText, + pub body: MarkdownText, + pub author: String, +} + pub trait TicketBackend { + fn edit_post_with_snapshots( + &self, + id: TicketIdOrSlug, + edit: TicketPostEdit, + ) -> Result<(Ticket, Ticket)>; + fn edit_post(&self, id: TicketIdOrSlug, edit: TicketPostEdit) -> Result { + self.edit_post_with_snapshots(id, edit) + .map(|(_, current)| current) + } fn list_workspace_tags(&self, request: TagListRequest) -> Result>; fn mutate_workspace_tag(&self, request: WorkspaceTagMutation) -> Result; fn set_ticket_tags( @@ -1862,6 +1884,10 @@ pub enum TicketBackendOperation { id: TicketIdOrSlug, edit: TicketItemEdit, }, + EditPost { + id: TicketIdOrSlug, + edit: TicketPostEdit, + }, DependencyCheck { id: TicketIdOrSlug, }, @@ -1927,6 +1953,7 @@ pub enum TicketBackendOperationResult { Unit, Tickets(Vec), Ticket(Ticket), + TicketSnapshots { previous: Ticket, current: Ticket }, TicketRef(TicketRef), DependencyCheck(TicketDependencyCheck), QueueOutcome(TicketQueueOutcome), @@ -1972,6 +1999,10 @@ where TicketBackendOperation::EditItem { id, edit } => { TicketBackendOperationResult::Ticket(backend.edit_item(id, edit)?) } + TicketBackendOperation::EditPost { id, edit } => { + let (previous, current) = backend.edit_post_with_snapshots(id, edit)?; + TicketBackendOperationResult::TicketSnapshots { previous, current } + } TicketBackendOperation::DependencyCheck { id } => { TicketBackendOperationResult::DependencyCheck(backend.dependency_check(id)?) } @@ -2444,14 +2475,6 @@ impl SqliteTicketBackend { op(&conn) } - fn created_event_body(&self) -> &'static str { - if is_japanese_record_language(self.record_language()) { - "Ticket が作成されました。" - } else { - "Ticket created." - } - } - fn default_item_body(&self) -> &'static str { if is_japanese_record_language(self.record_language()) { "## 背景\n\n## 要件\n\n## 受け入れ条件\n" @@ -2564,15 +2587,20 @@ impl SqliteTicketBackend { } fn insert_event(&self, conn: &Connection, ticket_id: &str, event: &TicketEvent) -> Result<()> { - let next_index: i64 = conn.query_row( + let next_index: i64 = if event.is_initial { + -1 + } else { + conn.query_row( "SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id], |row| row.get(0), - ).map_err(sqlite_err)?; + ).map_err(sqlite_err)? + }; + let event_ref = format!("{ticket_id}:{next_index}"); conn.execute(r#"INSERT INTO typed_ticket_events - (workspace_id, ticket_id, event_index, kind, author, at, status, from_state, to_state, reason, state_field, heading, body) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)"#, - params![self.workspace_id, ticket_id, next_index, event.kind.as_str(), event.author, event.at, event.status, event.from, event.to, event.reason, event.state_field, event.heading, event.body.as_str()] + (workspace_id, ticket_id, event_index, kind, author, at, status, from_state, to_state, reason, state_field, heading, body, event_ref, archived) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#, + params![self.workspace_id, ticket_id, next_index, event.kind.as_str(), event.author, event.at, event.status, event.from, event.to, event.reason, event.state_field, event.heading, event.body.as_str(), event_ref, matches!(event.kind.as_str(), "create" | "item_edit")] ).map_err(sqlite_err)?; for (ordinal, reference) in event.references.iter().enumerate() { conn.execute("INSERT INTO typed_ticket_event_references (workspace_id, ticket_id, event_index, ordinal, kind, target) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", @@ -2611,9 +2639,9 @@ impl SqliteTicketBackend { 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) + (workspace_id, ticket_id, slug, title, status, kind, priority, created_by, 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())] + 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.meta.created_by, 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())] ).map_err(sqlite_err)?; self.replace_ticket_targets(conn, &ticket.meta.id, &ticket.meta.targets)?; self.insert_ordered_values( @@ -2735,6 +2763,7 @@ impl SqliteTicketBackend { priority: row.get(5)?, labels: Vec::new(), created_at: row.get(6)?, + created_by: row.get(16)?, updated_at: row.get(7)?, assignee: row.get(8)?, readiness: row.get(9)?, @@ -2751,7 +2780,7 @@ impl SqliteTicketBackend { fn load_ticket(&self, conn: &Connection, ticket_id: &str) -> Result { #[cfg(test)] self.full_ticket_load_count.fetch_add(1, Ordering::SeqCst); - 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, (SELECT body FROM typed_ticket_events e WHERE e.workspace_id = typed_tickets.workspace_id AND e.ticket_id = typed_tickets.ticket_id AND e.event_index = -1), resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, created_by 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.resource_key = Self::resource_key_for(conn, &self.workspace_id, ticket_id)?; meta.targets = self.load_ticket_targets(conn, ticket_id)?; @@ -2861,7 +2890,7 @@ impl SqliteTicketBackend { } fn load_events(&self, conn: &Connection, ticket_id: &str) -> Result> { - let mut stmt = conn.prepare(r#"SELECT event_index, kind, author, at, status, from_state, to_state, reason, state_field, heading, body FROM typed_ticket_events WHERE workspace_id = ?1 AND ticket_id = ?2 ORDER BY event_index ASC"#).map_err(sqlite_err)?; + let mut stmt = conn.prepare(r#"SELECT event_index, kind, author, at, status, from_state, to_state, reason, state_field, heading, body, event_ref, edited_at, edited_by FROM typed_ticket_events WHERE workspace_id = ?1 AND ticket_id = ?2 AND archived = 0 ORDER BY event_index ASC"#).map_err(sqlite_err)?; let rows = stmt .query_map(params![self.workspace_id, ticket_id], |row| { Ok(( @@ -2876,17 +2905,38 @@ impl SqliteTicketBackend { row.get::<_, Option>(8)?, row.get::<_, Option>(9)?, row.get::<_, String>(10)?, + row.get::<_, String>(11)?, + row.get::<_, Option>(12)?, + row.get::<_, Option>(13)?, )) }) .map_err(sqlite_err)?; let mut events = Vec::new(); for row in rows { - let (index, kind, author, at, status, from, to, reason, state_field, heading, body) = - row.map_err(sqlite_err)?; + let ( + index, + kind, + author, + at, + status, + from, + to, + reason, + state_field, + heading, + body, + event_ref, + edited_at, + edited_by, + ) = row.map_err(sqlite_err)?; let mut attributes = self.load_event_attributes(conn, ticket_id, index)?; - attributes.insert("event_id".to_string(), format!("{ticket_id}:{index}")); + attributes.insert("event_id".to_string(), event_ref.clone()); attributes.insert("event_sequence".to_string(), index.to_string()); events.push(TicketEvent { + event_ref, + is_initial: index == -1, + edited_at, + edited_by, kind: TicketEventKind::from(kind.as_str()), author, at, @@ -2961,7 +3011,7 @@ impl SqliteTicketBackend { 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, NULL, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, created_by 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)?; @@ -3048,6 +3098,36 @@ fn finish_sqlite_transaction(conn: &Connection, result: Result) -> Result< } impl TicketBackend for SqliteTicketBackend { + fn edit_post_with_snapshots( + &self, + id: TicketIdOrSlug, + edit: TicketPostEdit, + ) -> Result<(Ticket, Ticket)> { + self.with_write(|conn| { + validate_required_event_value("event_ref", &edit.event_ref)?; + validate_required_event_value("author", &edit.author)?; + let ticket_id = self.resolve_ticket_id(conn, id)?; + let previous = self.load_ticket(conn, &ticket_id)?; + let post = previous.events.iter().find(|event| event.event_ref == edit.event_ref) + .ok_or_else(|| TicketError::NotFound(edit.event_ref.clone()))?; + if post.kind != TicketEventKind::Comment { + return Err(TicketError::Conflict("only comment posts can be edited".into())); + } + if post.body != edit.expected_body { + return Err(TicketError::Conflict("stale Ticket post body".into())); + } + let now = now_utc(); + let changed = conn.execute( + "UPDATE typed_ticket_events SET body = ?4, edited_at = ?5, edited_by = ?6 WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_ref = ?3 AND body = ?7 AND kind = 'comment' AND archived = 0", + params![self.workspace_id, ticket_id, edit.event_ref, edit.body.as_str(), now, edit.author, edit.expected_body.as_str()], + ).map_err(sqlite_err)?; + if changed != 1 { return Err(TicketError::Conflict("stale Ticket post body".into())); } + self.touch_ticket(conn, &ticket_id, &now)?; + let current = self.load_ticket(conn, &ticket_id)?; + Ok((previous, current)) + }) + } + fn list_workspace_tags(&self, request: TagListRequest) -> Result> { self.list_tags(request) } @@ -3109,6 +3189,10 @@ impl TicketBackend for SqliteTicketBackend { } else { ExtensibleTicketStatus::Open }; + let created_by = input + .author + .clone() + .filter(|author| !author.trim().is_empty()); let meta = TicketMeta { id: id.clone(), resource_key: None, @@ -3119,6 +3203,7 @@ impl TicketBackend for SqliteTicketBackend { priority: input.priority, labels: input.labels, created_at: Some(now.clone()), + created_by: created_by.clone(), updated_at: Some(now.clone()), assignee: input.assignee, readiness: input.readiness, @@ -3137,6 +3222,7 @@ impl TicketBackend for SqliteTicketBackend { }; let author = input .author + .clone() .unwrap_or_else(|| "SqliteTicketBackend".to_string()); let ticket = Ticket { closure_operation_key: None, @@ -3146,20 +3232,24 @@ impl TicketBackend for SqliteTicketBackend { tags: Vec::new(), meta, document: TicketDocument { - body, + body: body.clone(), raw_frontmatter: BTreeMap::new(), }, events: vec![TicketEvent { - kind: TicketEventKind::Create, - author: Some(author.clone()), + event_ref: String::new(), + is_initial: true, + edited_at: None, + edited_by: None, + kind: TicketEventKind::Comment, + author: created_by, at: Some(now.clone()), status: None, from: None, to: None, reason: None, state_field: None, - heading: Some(TicketEventKind::Create.heading()), - body: MarkdownText::new(self.created_event_body()), + heading: None, + body, references: Vec::new(), attributes: BTreeMap::new(), }], @@ -3226,31 +3316,27 @@ impl TicketBackend for SqliteTicketBackend { let ticket_id = self.resolve_ticket_id(conn, id)?; let previous = self.load_ticket(conn, &ticket_id)?; let now = now_utc(); - let mut body_edit_audit = TicketBodyEditAudit::None; if let Some(title) = edit.title.as_ref() { conn.execute("UPDATE typed_tickets SET title = ?3, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, title, now]).map_err(sqlite_err)?; } let mut updated_body = None; if let Some(body) = edit.body.as_ref() { updated_body = Some(body.clone()); - body_edit_audit = TicketBodyEditAudit::WholeBody; } if let Some(replacement) = edit.body_replacement.as_ref() { let current_body: String = conn .query_row( - "SELECT body FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2", + "SELECT body FROM typed_ticket_events WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = -1", params![self.workspace_id, ticket_id], |row| row.get(0), ) .map_err(sqlite_err)?; let outcome = replacement.apply(current_body.as_str())?; updated_body = Some(outcome.body); - body_edit_audit = TicketBodyEditAudit::Partial { - replacement_count: outcome.replacement_count, - }; } 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)?; + conn.execute("UPDATE typed_ticket_events SET body = ?3, edited_at = ?4, edited_by = ?5 WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = -1", params![self.workspace_id, ticket_id, body.as_str(), now, edit.author]).map_err(sqlite_err)?; + self.touch_ticket(conn, &ticket_id, &now)?; } if let Some(targets) = edit.targets.as_ref() { let targets = match targets { @@ -3261,43 +3347,6 @@ impl TicketBackend for SqliteTicketBackend { self.touch_ticket(conn, &ticket_id, &now)?; } - let mut changes = Vec::new(); - if edit.title.is_some() { - changes.push("title"); - } - if !matches!(body_edit_audit, TicketBodyEditAudit::None) { - changes.push("body"); - } - if edit.targets.is_some() { - changes.push("targets"); - } - let mut attributes = BTreeMap::new(); - attributes.insert("changes".to_string(), changes.join(",")); - let body = match body_edit_audit { - TicketBodyEditAudit::None => { - MarkdownText::new(format!("Ticket item updated: {}.", changes.join(", "))) - } - TicketBodyEditAudit::WholeBody => { - attributes.insert("body_edit".to_string(), "whole".to_string()); - MarkdownText::new(format!( - "Ticket item updated: {}. Body was replaced as a whole.", - changes.join(", ") - )) - } - TicketBodyEditAudit::Partial { replacement_count } => { - attributes.insert("body_edit".to_string(), "partial".to_string()); - attributes.insert( - "replacement_count".to_string(), - replacement_count.to_string(), - ); - MarkdownText::new(format!( - "Ticket item updated: {}. Body replacement applied to {replacement_count} occurrence(s).", - changes.join(", ") - )) - } - }; - let event = TicketEvent { kind: TicketEventKind::Other("item_edit".to_string()), author: Some(edit.author.unwrap_or_else(default_author)), at: Some(now), status: None, from: None, to: None, reason: None, state_field: None, heading: Some("Item edit".to_string()), body, references: Vec::new(), attributes }; - self.insert_event(conn, &ticket_id, &event)?; let current = self.load_ticket(conn, &ticket_id)?; Ok((previous, current)) }) @@ -3335,6 +3384,10 @@ impl TicketBackend for SqliteTicketBackend { conn, &ticket_id, &TicketEvent { + event_ref: String::new(), + is_initial: false, + edited_at: None, + edited_by: None, kind: event.kind.clone(), author: event.author.or_else(|| Some(default_author())), at: Some(at.clone()), @@ -3368,6 +3421,10 @@ impl TicketBackend for SqliteTicketBackend { conn, &ticket_id, &TicketEvent { + event_ref: String::new(), + is_initial: false, + edited_at: None, + edited_by: None, kind: TicketEventKind::IntakeSummary, author: Some(summary.author.unwrap_or_else(default_author)), at: Some(at.clone()), @@ -3444,7 +3501,7 @@ impl TicketBackend for SqliteTicketBackend { self.insert_case_history(conn, &ticket_id, &entry, &fingerprint)?; conn.execute("UPDATE typed_tickets SET workflow_state = ?3, workflow_state_explicit = 1, status = ?3, updated_at = ?4, resolution = CASE WHEN ?3 = 'closed' THEN ?5 ELSE NULL END WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, state.as_str(), at, request.explanation]).map_err(sqlite_err)?; let kind = if matches!(request.action, TicketCaseAction::Close { .. }) { TicketEventKind::Close } else { TicketEventKind::StateChanged }; - self.insert_event(conn, &ticket_id, &TicketEvent { + self.insert_event(conn, &ticket_id, &TicketEvent { event_ref: String::new(), is_initial: false, edited_at: None, edited_by: None, heading: Some(kind.heading()), kind, author: Some(entry.author), at: Some(at), status: Some(state.to_string()), from: Some(previous.meta.workflow_state.to_string()), to: Some(state.to_string()), reason: Some(request.explanation.clone()), state_field: Some("case".into()), body: MarkdownText::new(&request.explanation), references: request.references.clone(), attributes: BTreeMap::from([("operation_key".into(), request.operation_key.clone()), ("fingerprint_version".into(), "case-update-v1".into()), ("request_fingerprint".into(), fingerprint.clone()), ("case_action".into(), serde_json::to_string(&request.action).map_err(sqlite_err)?), ("expected_content_digest".into(), request.expected_content_digest.clone())]), })?; @@ -3503,7 +3560,7 @@ impl TicketBackend for SqliteTicketBackend { self.insert_event( conn, &ticket_id, - &TicketEvent { + &TicketEvent { event_ref: String::new(), is_initial: false, edited_at: None, edited_by: None, kind: TicketEventKind::IntakeSummary, author: summary.author, at: None, @@ -3522,7 +3579,7 @@ impl TicketBackend for SqliteTicketBackend { self.insert_event( conn, &ticket_id, - &TicketEvent { + &TicketEvent { event_ref: String::new(), is_initial: false, edited_at: None, edited_by: None, kind: TicketEventKind::StateChanged, author: Some(request.author.unwrap_or_else(default_author)), at: Some(at.clone()), @@ -3588,7 +3645,7 @@ impl TicketBackend for SqliteTicketBackend { "UPDATE typed_tickets SET queued_by = ?3, queued_at = ?4, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, requested_ticket, queued_by, at], ).map_err(sqlite_err)?; - self.insert_event(conn, &requested_ticket, &TicketEvent { + self.insert_event(conn, &requested_ticket, &TicketEvent { event_ref: String::new(), is_initial: false, edited_at: None, edited_by: None, kind: TicketEventKind::StateChanged, author: Some(queued_by.to_owned()), at: Some(at), @@ -4397,15 +4454,11 @@ mod tests { ticket_target("docs", Some("main"), TicketTargetAccess::ReadOnly), ] ); - 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(&"targets".to_string()) + assert!( + !edited + .events + .iter() + .any(|event| event.kind.as_str() == "item_edit") ); let cleared = backend @@ -4443,21 +4496,11 @@ mod tests { edited.document.body.as_str().trim_start_matches('\n'), "ALPHA\nbeta\nALPHA\n" ); - 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("body_edit"), - Some(&"partial".to_string()) - ); - assert_eq!( - edit_event.attributes.get("replacement_count"), - Some(&"2".to_string()) - ); - assert!(edit_event.body.as_str().contains("2 occurrence")); + assert_eq!(edited.events.len(), 1); + assert_eq!(edited.events[0].kind, TicketEventKind::Comment); + assert_eq!(edited.events[0].body, edited.document.body); + assert_eq!(edited.events[0].edited_by.as_deref(), Some("tester")); + assert!(edited.events[0].edited_at.is_some()); let duplicate_err = backend .edit_item( @@ -5472,12 +5515,7 @@ mod tests { let ticket = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); assert!(ticket.document.body.as_str().contains("## 背景")); - assert!( - ticket.events[0] - .body - .as_str() - .contains("Ticket が作成されました。") - ); + assert!(ticket.events[0].body.as_str().contains("## 背景")); } #[test] diff --git a/crates/ticket/src/post_migration.sql b/crates/ticket/src/post_migration.sql new file mode 100644 index 000000000..6de79b0fc --- /dev/null +++ b/crates/ticket/src/post_migration.sql @@ -0,0 +1,50 @@ +-- Ordered component 11 -> 12. Existing event indices and child rows are never rewritten. +ALTER TABLE typed_tickets ADD COLUMN created_by TEXT; +ALTER TABLE typed_ticket_events ADD COLUMN event_ref TEXT NOT NULL DEFAULT ''; +ALTER TABLE typed_ticket_events ADD COLUMN edited_at TEXT; +ALTER TABLE typed_ticket_events ADD COLUMN edited_by TEXT; +ALTER TABLE typed_ticket_events ADD COLUMN archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1)); +-- The retained SQLite loader published ticket_id:event_index even when a raw +-- event_id attribute existed. Preserve that identity, not a display ordinal or +-- the unpublished attribute; leave the stored attributes untouched as evidence. +UPDATE typed_ticket_events SET event_ref = ticket_id || ':' || event_index; +-- A retained row at the reserved initial index fails the enclosing transaction; +-- never silently rename its published identity or move its child rows. +CREATE UNIQUE INDEX typed_ticket_events_ref ON typed_ticket_events(workspace_id, ticket_id, event_ref); +UPDATE typed_tickets SET created_by = COALESCE(( + SELECT CASE WHEN trim(author) = '' THEN NULL ELSE author END FROM typed_ticket_events e + WHERE e.workspace_id = typed_tickets.workspace_id AND e.ticket_id = typed_tickets.ticket_id + AND e.kind = 'create' ORDER BY e.event_index LIMIT 1 +), ( + SELECT CASE WHEN trim(value) = '' THEN NULL ELSE value END FROM typed_ticket_raw_frontmatter m + WHERE m.workspace_id = typed_tickets.workspace_id AND m.ticket_id = typed_tickets.ticket_id + AND m.key = 'created_by' +)); +INSERT INTO typed_ticket_events (workspace_id, ticket_id, event_index, event_ref, kind, author, at, body) +SELECT t.workspace_id, t.ticket_id, -1, t.ticket_id || ':-1', 'comment', t.created_by, + COALESCE((SELECT at FROM typed_ticket_events e + WHERE e.workspace_id = t.workspace_id AND e.ticket_id = t.ticket_id AND e.kind = 'create' + ORDER BY e.event_index LIMIT 1), t.created_at), t.body +FROM typed_tickets t; +-- A later title/target edit is not an edit of the current initial-post body. +-- Take both edit fields from the same last known body-edit record. Missing actor +-- or time stays unknown rather than falling back to the creator or an older edit. +WITH body_edits AS ( + SELECT e.workspace_id, e.ticket_id, e.at, e.author, + ROW_NUMBER() OVER ( + PARTITION BY e.workspace_id, e.ticket_id ORDER BY e.event_index DESC + ) AS recency + FROM typed_ticket_events e + JOIN typed_ticket_event_attributes a + ON a.workspace_id = e.workspace_id AND a.ticket_id = e.ticket_id + AND a.event_index = e.event_index AND a.key = 'changes' + WHERE e.kind = 'item_edit' AND instr(',' || a.value || ',', ',body,') > 0 +) +UPDATE typed_ticket_events SET (edited_at, edited_by) = ( + SELECT at, CASE WHEN trim(author) = '' THEN NULL ELSE author END FROM body_edits e + WHERE e.workspace_id = typed_ticket_events.workspace_id + AND e.ticket_id = typed_ticket_events.ticket_id AND e.recency = 1 +) WHERE event_index = -1; +-- Retain the original records, attributes, references and stable identities as archive history. +UPDATE typed_ticket_events SET archived = 1 WHERE kind IN ('create', 'item_edit'); +ALTER TABLE typed_tickets DROP COLUMN body; diff --git a/crates/ticket/src/sqlite_schema.rs b/crates/ticket/src/sqlite_schema.rs index 793920cf5..431f824d0 100644 --- a/crates/ticket/src/sqlite_schema.rs +++ b/crates/ticket/src/sqlite_schema.rs @@ -20,7 +20,7 @@ use crate::{Result, TicketError, sqlite_err}; const MIGRATION_TABLE: &str = "ticket_schema_migrations"; const MAX_SCHEMA_DIAGNOSTICS: usize = 32; -const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 11; +const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 12; const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline"; #[derive(Clone, Copy)] @@ -321,10 +321,10 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { } /// Composes retained 6/7, tags-only 8 or cases-only 9 through component 10, then -/// appends the 10→11 assignment migration without replacing the applied 10 marker. +/// appends the 10→11 assignment and 11→12 post migrations without replacing applied markers. /// The host owns the surrounding transaction (for example Workspace 91→92). /// A savepoint rolls back this component on failure, without committing host work. -/// Current component 11 is verified only; immutable judgments are never reapplied. +/// Current component 12 is verified only; immutable judgments are never reapplied. pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> Result<()> { if connection.is_autocommit() { return Err(TicketError::Sqlite( @@ -348,32 +348,12 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R create_latest_ticket_schema(connection)?; connection .execute( - "INSERT INTO ticket_schema_migrations VALUES (11, ?1, ?2)", + "INSERT INTO ticket_schema_migrations VALUES (12, ?1, ?2)", params![BASELINE_MIGRATION_NAME, chrono::Utc::now().to_rfc3339()], ) .map_err(sqlite_err)?; } else { - let version = if applied - == BTreeMap::from([ - (10, BASELINE_MIGRATION_NAME.to_owned()), - (11, "Ticket-owned assignment tracking".to_owned()), - ]) { - 11 - } else { - match applied.iter().next() { - Some((&version, name)) - if applied.len() == 1 - && name == BASELINE_MIGRATION_NAME - && matches!(version, 6..=11) => - { - version - } - _ => { - validate_applied_migrations(&applied)?; - unreachable!() - } - } - }; + let version = recognized_schema_version(&applied)?; if version == 6 { migrate_v6_to_v7(connection)?; connection @@ -392,7 +372,9 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R } TicketSchemaShape::Tags8 => migrate_legacy_cases(connection)?, TicketSchemaShape::Cases9 => add_tag_schema(connection)?, - TicketSchemaShape::Composed10 | TicketSchemaShape::Tracking11 => {} + TicketSchemaShape::Composed10 + | TicketSchemaShape::Tracking11 + | TicketSchemaShape::Posts12 => {} } if version < 10 { connection @@ -406,6 +388,10 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R crate::assignment_tracking::migrate_assignment_tracking(connection)?; connection.execute("INSERT INTO ticket_schema_migrations VALUES (11, 'Ticket-owned assignment tracking', ?1)", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?; } + if version < 12 { + migrate_posts(connection)?; + connection.execute("INSERT INTO ticket_schema_migrations VALUES (12, 'Canonical Ticket posts', ?1)", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?; + } } verify_sqlite_ticket_schema(connection) })(); @@ -429,6 +415,7 @@ enum TicketSchemaShape { Cases9, Composed10, Tracking11, + Posts12, } impl TicketSchemaShape { fn from_version(version: i64) -> Self { @@ -438,6 +425,7 @@ impl TicketSchemaShape { 9 => Self::Cases9, 10 => Self::Composed10, 11 => Self::Tracking11, + 12 => Self::Posts12, _ => unreachable!(), } } @@ -448,19 +436,26 @@ impl TicketSchemaShape { Self::Cases9 => 9, Self::Composed10 => 10, Self::Tracking11 => 11, + Self::Posts12 => 12, } } fn tags(self) -> bool { - matches!(self, Self::Tags8 | Self::Composed10 | Self::Tracking11) + matches!( + self, + Self::Tags8 | Self::Composed10 | Self::Tracking11 | Self::Posts12 + ) } fn cases(self) -> bool { - matches!(self, Self::Cases9 | Self::Composed10 | Self::Tracking11) + matches!( + self, + Self::Cases9 | Self::Composed10 | Self::Tracking11 | Self::Posts12 + ) } } /// Verifies the current Ticket-owned SQLite schema without executing DDL. pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { - verify_ticket_schema_shape(connection, TicketSchemaShape::Tracking11) + verify_ticket_schema_shape(connection, TicketSchemaShape::Posts12) } /// Retained Workspace schema-66 migration produces the pre-classification v7 @@ -496,21 +491,16 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape) match load_applied_migrations(connection) { Ok(applied) => { - let history = if applied - == BTreeMap::from([(shape.version(), BASELINE_MIGRATION_NAME.to_owned())]) - || (matches!(shape, TicketSchemaShape::Tracking11) - && applied - == BTreeMap::from([ - (10, BASELINE_MIGRATION_NAME.to_owned()), - (11, "Ticket-owned assignment tracking".to_owned()), - ])) { - Ok(()) - } else { - Err(TicketError::Sqlite(format!( - "Ticket schema migration history must contain only the canonical version {} baseline marker", - shape.version() - ))) - }; + let history = recognized_schema_version(&applied).and_then(|version| { + if version == shape.version() { + Ok(()) + } else { + Err(TicketError::Sqlite(format!( + "Ticket schema migration history must match version {}", + shape.version() + ))) + } + }); if let Err(error) = history { push_diagnostic(&mut diagnostics, error.to_string()); } @@ -578,7 +568,22 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape) { continue; } - collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics); + let mut columns = columns.to_vec(); + if matches!(shape, TicketSchemaShape::Posts12) { + if table == "typed_tickets" { + columns.retain(|c| c.name != "body"); + columns.push(column("created_by", "TEXT", false, 0)); + } + if table == "typed_ticket_events" { + columns.extend([ + column("event_ref", "TEXT", true, 0), + column("edited_at", "TEXT", false, 0), + column("edited_by", "TEXT", false, 0), + column("archived", "INTEGER", true, 0), + ]); + } + } + collect_table_diagnostics(connection, table, &columns, foreign_keys, &mut diagnostics); } if shape.tags() { @@ -803,13 +808,26 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape) collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics); } - if matches!(shape, TicketSchemaShape::Tracking11) { + if matches!( + shape, + TicketSchemaShape::Tracking11 | TicketSchemaShape::Posts12 + ) { if let Err(error) = crate::assignment_tracking::verify_assignment_tracking_schema(connection) { push_diagnostic(&mut diagnostics, error.to_string()); } } + if matches!(shape, TicketSchemaShape::Posts12) { + collect_index_diagnostics( + connection, + "typed_ticket_events", + Some("typed_ticket_events_ref"), + true, + &["workspace_id", "ticket_id", "event_ref"], + &mut diagnostics, + ); + } if diagnostics.is_empty() { Ok(()) } else { @@ -830,7 +848,14 @@ fn create_latest_ticket_schema(connection: &Connection) -> Result<()> { connection .execute_batch(include_str!("latest_schema.sql")) .map_err(sqlite_err)?; - crate::assignment_tracking::migrate_assignment_tracking(connection) + crate::assignment_tracking::migrate_assignment_tracking(connection)?; + migrate_posts(connection) +} + +fn migrate_posts(connection: &Connection) -> Result<()> { + connection + .execute_batch(include_str!("post_migration.sql")) + .map_err(sqlite_err) } /// Retained 7/8 case migration: leave tags and historical receipts untouched. @@ -1077,24 +1102,30 @@ fn load_applied_migrations(connection: &Connection) -> Result) -> Result<()> { - let expected = BTreeMap::from([( - LATEST_SQLITE_TICKET_SCHEMA_VERSION, - BASELINE_MIGRATION_NAME.to_string(), - )]); - if applied == &expected - || applied - == &BTreeMap::from([ - (10, BASELINE_MIGRATION_NAME.to_owned()), - (11, "Ticket-owned assignment tracking".to_owned()), - ]) - { - Ok(()) - } else { - Err(TicketError::Sqlite(format!( - "Ticket schema migration history must contain only the canonical version {LATEST_SQLITE_TICKET_SCHEMA_VERSION} baseline marker" - ))) +fn recognized_schema_version(applied: &BTreeMap) -> Result { + if applied.len() == 1 { + if let Some((&version, name)) = applied.first_key_value() { + if (6..=12).contains(&version) && name == BASELINE_MIGRATION_NAME { + return Ok(version); + } + } } + for baseline in [10, 11] { + let mut expected = BTreeMap::from([(baseline, BASELINE_MIGRATION_NAME.to_owned())]); + if baseline == 10 { + expected.insert(11, "Ticket-owned assignment tracking".to_owned()); + if applied == &expected { + return Ok(11); + } + } + expected.insert(12, "Canonical Ticket posts".to_owned()); + if applied == &expected { + return Ok(12); + } + } + Err(TicketError::Sqlite(format!( + "Ticket schema migration history must contain a canonical baseline and ordered migrations through {LATEST_SQLITE_TICKET_SCHEMA_VERSION}" + ))) } #[derive(Debug)] @@ -1591,7 +1622,8 @@ mod tests { load_applied_migrations(&connection).unwrap(), BTreeMap::from([ (10, BASELINE_MIGRATION_NAME.to_owned()), - (11, "Ticket-owned assignment tracking".to_owned()) + (11, "Ticket-owned assignment tracking".to_owned()), + (12, "Canonical Ticket posts".to_owned()) ]) ); } @@ -1688,8 +1720,8 @@ mod tests { .unwrap(); migrate_sqlite_ticket_schema(&connection).unwrap(); connection.execute_batch( - "INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) - VALUES ('one','ticket','ticket','Title','open','task','P2','body','open',1); + "INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) + VALUES ('one','ticket','ticket','Title','open','task','P2','open',1); INSERT INTO workspace_tags VALUES ('two','tag','Name','name','create',0);" ).unwrap(); assert!( @@ -1734,7 +1766,7 @@ mod tests { let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); assert!(error.to_string().contains( - "migration history must contain only the canonical version 11 baseline marker" + "migration history must contain a canonical baseline and ordered migrations through 12" )); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2); } @@ -1756,7 +1788,7 @@ mod tests { let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); assert!(error.to_string().contains( - "migration history must contain only the canonical version 11 baseline marker" + "migration history must contain a canonical baseline and ordered migrations through 12" )); assert!(!table_exists(&connection, "typed_tickets").unwrap()); } diff --git a/crates/ticket/src/tags.rs b/crates/ticket/src/tags.rs index a58ee09a9..4b991bacf 100644 --- a/crates/ticket/src/tags.rs +++ b/crates/ticket/src/tags.rs @@ -201,6 +201,10 @@ impl SqliteTicketBackend { conn, ticket_id, &TicketEvent { + event_ref: String::new(), + is_initial: false, + edited_at: None, + edited_by: None, kind: TicketEventKind::TagsChanged, author: Some(default_author()), at: Some(now_utc()), diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 216f3c7cf..ddd18002b 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -37,9 +37,10 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024; const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100; const MAX_DIAGNOSTIC_LIMIT: usize = 500; -pub const TICKET_BASE_TOOL_NAMES: [&str; 13] = [ +pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [ "TicketCreate", "TicketEditItem", + "TicketEditPost", "QueryTicket", "ShowTicket", "TicketComment", @@ -71,9 +72,10 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [ pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] = ["TicketRelationQuery", "TicketOrchestrationPlanQuery"]; -pub const TICKET_TOOL_NAMES: [&str; 19] = [ +pub const TICKET_TOOL_NAMES: [&str; 20] = [ "TicketCreate", "TicketEditItem", + "TicketEditPost", "QueryTicket", "ShowTicket", "TicketComment", @@ -102,9 +104,10 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [ "TicketOrchestrationPlanQuery", ]; -pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [ +pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [ "TicketCreate", "TicketEditItem", + "TicketEditPost", "TicketComment", "TicketPlan", "TicketDecision", @@ -121,13 +124,16 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [ const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \ `title` is required, `body` is Markdown, and the backend assigns the id."; const EDIT_ITEM_DESCRIPTION: &str = "Edit a Ticket item through the configured typed Ticket backend. \ -This updates the current item title/body and appends an audited item_edit event. Intended for \ +This updates the current item title and/or initial comment body without adding a discussion entry. Intended for \ User/Companion authoring surfaces, not Orchestrator implementation control."; +const EDIT_POST_DESCRIPTION: &str = "Edit an initial or discussion comment by stable event_ref and exact expected_body. \ +Preserves original author and time and records edit metadata without adding a discussion entry. \ +System events and case judgments cannot be edited. The Workspace backend supplies the authenticated editor."; const LIST_DESCRIPTION: &str = "Query Tickets from the configured typed Ticket backend as a bounded \ overview. Workspace-backed Workers replace this definition with the richer authoritative \ text/event/evidence/relation/Objective/time/attention query."; const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured typed \ -Ticket backend. Output includes bounded Markdown body, recent events, resolution, and artifact \ +Ticket backend. Output includes the initial comment and recent events with stable event_ref, author/time and edit metadata, bounded Markdown body, resolution, and artifact \ metadata; Workspace-backed Workers replace this definition with the richer authoritative evidence projection."; const COMMENT_DESCRIPTION: &str = "Append a typed Ticket comment event. `body` is Markdown."; const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Markdown."; @@ -166,6 +172,7 @@ fn base_tool_description(name: &str) -> &'static str { match name { "TicketCreate" => CREATE_DESCRIPTION, "TicketEditItem" => EDIT_ITEM_DESCRIPTION, + "TicketEditPost" => EDIT_POST_DESCRIPTION, "QueryTicket" => LIST_DESCRIPTION, "ShowTicket" => SHOW_DESCRIPTION, "TicketComment" => COMMENT_DESCRIPTION, @@ -246,6 +253,14 @@ impl From for TicketToolBackend { } impl TicketBackend for TicketToolBackend { + fn edit_post_with_snapshots( + &self, + id: TicketIdOrSlug, + edit: crate::TicketPostEdit, + ) -> TicketResult<(Ticket, Ticket)> { + self.backend.edit_post_with_snapshots(id, edit) + } + fn list_workspace_tags( &self, request: crate::TagListRequest, @@ -437,6 +452,19 @@ struct TicketEditItemParams { targets: Option, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +struct TicketEditPostParams { + /// Ticket reference. Prefer T-*; canonical internal ids remain accepted. + ticket: String, + /// Persisted post identity returned by ShowTicket, not a display ordinal. + event_ref: String, + /// Exact observed Markdown body. A stale or truncated body cannot authorize an edit. + expected_body: String, + /// Replacement Markdown body. + body: String, +} + #[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] enum TicketStateParam { @@ -798,6 +826,11 @@ struct TicketEditItemTool { backend: TicketToolBackend, } +#[derive(Clone)] +struct TicketEditPostTool { + backend: TicketToolBackend, +} + #[derive(Clone)] struct QueryTicketTool { backend: TicketToolBackend, @@ -982,6 +1015,38 @@ impl Tool for TicketEditItemTool { } } +#[async_trait] +impl Tool for TicketEditPostTool { + async fn execute( + &self, + input_json: &str, + _ctx: agen::tool::ToolExecutionContext, + ) -> Result { + let params: TicketEditPostParams = parse_input("TicketEditPost", input_json)?; + let ticket = self + .backend + .edit_post( + TicketIdOrSlug::from(params.ticket), + crate::TicketPostEdit { + event_ref: params.event_ref, + expected_body: MarkdownText::new(params.expected_body), + body: MarkdownText::new(params.body), + author: default_author(), + }, + ) + .map_err(|error| backend_error("TicketEditPost", error))?; + Ok(json_output( + format!("Edited Ticket post in {}", ticket.meta.id), + ticket_json( + &ticket, + DEFAULT_EVENT_LIMIT, + DEFAULT_ARTIFACT_LIMIT, + DEFAULT_BODY_MAX_BYTES, + ), + )) + } +} + #[async_trait] impl Tool for QueryTicketTool { async fn execute( @@ -1629,6 +1694,28 @@ fn ticket_relations_json(ticket: &Ticket) -> Value { }) } +fn ticket_event_json(event: &crate::TicketEvent, body_max_bytes: usize) -> Value { + json!({ + "event_ref": event.event_ref, + "is_initial": event.is_initial, + "kind": event.kind.as_str(), + "author": event.author, + "at": event.at, + "edited_at": event.edited_at, + "edited_by": event.edited_by, + "state": event.status, + "from": event.from, + "to": event.to, + "reason": event.reason, + "state_field": event.state_field, + "attributes": event.attributes, + "references": event.references, + "heading": event.heading, + "body": truncate_text(event.body.as_str(), body_max_bytes), + "body_truncated": event.body.as_str().len() > body_max_bytes, + }) +} + fn ticket_json( ticket: &Ticket, event_limit: usize, @@ -1640,21 +1727,7 @@ fn ticket_json( .events .iter() .skip(event_count.saturating_sub(event_limit)) - .map(|event| { - json!({ - "kind": event.kind.as_str(), - "author": event.author, - "at": event.at, - "state": event.status, - "from": event.from, - "to": event.to, - "reason": event.reason, - "state_field": event.state_field, - "attributes": event.attributes, - "heading": event.heading, - "body": truncate_text(event.body.as_str(), body_max_bytes), - }) - }) + .map(|event| ticket_event_json(event, body_max_bytes)) .collect(); let artifact_count = ticket.artifacts.len(); let artifacts: Vec<_> = ticket @@ -1669,6 +1742,7 @@ fn ticket_json( "title": ticket.meta.title, "state": ticket.meta.workflow_state.as_str(), "created_at": ticket.meta.created_at, + "created_by": ticket.meta.created_by, "updated_at": ticket.meta.updated_at, "assignee": ticket.meta.assignee, "readiness": ticket.meta.readiness, @@ -1682,6 +1756,7 @@ fn ticket_json( "current_closure": ticket.current_closure.as_ref().map(|j| json!({"operation_key": j.operation_key, "reason": j.reason, "explanation": truncate_text(&j.explanation, body_max_bytes), "author": j.author, "references": j.references, "at": j.at})), "case_history": { "count": ticket.case_history.len(), "items": ticket.case_history.iter().rev().take(event_limit).map(|j| json!({"operation_key": j.operation_key, "previous_closure_operation_key": j.previous_closure_operation_key, "action": j.action, "explanation": truncate_text(&j.explanation, body_max_bytes), "author": j.author, "references": j.references, "at": j.at})).collect::>() }, "legacy_workflow_snapshot": ticket.legacy_workflow_snapshot, + "initial_post": ticket.events.iter().find(|event| event.is_initial).map(|event| ticket_event_json(event, body_max_bytes)), "body": truncate_text(ticket.document.body.as_str(), body_max_bytes), "events": { "count": event_count, @@ -1784,6 +1859,7 @@ fn input_schema(name: &str) -> Value { match name { "TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)), "TicketEditItem" => serde_json::to_value(schemars::schema_for!(TicketEditItemParams)), + "TicketEditPost" => serde_json::to_value(schemars::schema_for!(TicketEditPostParams)), "QueryTicket" => serde_json::to_value(schemars::schema_for!(QueryTicketParams)), "ShowTicket" => serde_json::to_value(schemars::schema_for!(ShowTicketParams)), "TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => { @@ -1829,6 +1905,7 @@ macro_rules! impl_from_backend { impl_from_backend!(TicketCreateTool); impl_from_backend!(TicketEditItemTool); +impl_from_backend!(TicketEditPostTool); impl_from_backend!(QueryTicketTool); impl_from_backend!(ShowTicketTool); impl_from_backend!(TicketCommentTool); @@ -1853,6 +1930,7 @@ pub fn ticket_tools(backend: impl Into) -> Vec("TicketCreate", backend.clone()), tool_definition::("TicketEditItem", backend.clone()), + tool_definition::("TicketEditPost", backend.clone()), tool_definition::("QueryTicket", backend.clone()), tool_definition::("ShowTicket", backend.clone()), tool_definition::("TicketComment", backend.clone()), @@ -1948,6 +2026,131 @@ mod tests { .expect("tool exists") } + #[tokio::test] + async fn edit_post_tool_and_show_preserve_common_identity_and_metadata() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let mut input = NewTicket::new("Post tools"); + input.author = Some("creator".into()); + input.body = MarkdownText::new("Initial body"); + let reference = backend.create(input).unwrap(); + let comment = tool_by_name(backend.clone(), "TicketComment"); + comment + .execute( + &json!({"ticket": reference.id, "body": "Discussion body"}).to_string(), + Default::default(), + ) + .await + .unwrap(); + let before = backend.show(reference.id.clone().into()).unwrap(); + let edit = tool_by_name(backend.clone(), "TicketEditPost"); + let show = tool_by_name(backend.clone(), "ShowTicket"); + let schema = input_schema("TicketEditPost"); + assert_eq!(schema["additionalProperties"], false); + for key in ["ticket", "event_ref", "expected_body", "body"] { + assert!(schema["required"].as_array().unwrap().contains(&json!(key))); + } + assert!( + !schema["properties"] + .as_object() + .unwrap() + .contains_key("author") + ); + for post in &before.events { + let args = json!({"ticket": reference.id, "event_ref": post.event_ref, "expected_body": post.body.as_str(), "body": format!("Edited {}", post.body.as_str())}); + let output = edit + .execute(&args.to_string(), Default::default()) + .await + .unwrap(); + let value: Value = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(value["events"]["count"], 2); + let projected = value["events"]["items"] + .as_array() + .unwrap() + .iter() + .find(|e| e["event_ref"] == post.event_ref) + .unwrap(); + assert_eq!(projected["author"], json!(post.author)); + assert_eq!(projected["at"], json!(post.at)); + assert_eq!(projected["is_initial"], post.is_initial); + assert_eq!(projected["edited_by"], default_author()); + assert!(projected["edited_at"].is_string()); + assert_eq!(projected["body_truncated"], false); + assert!( + edit.execute(&args.to_string(), Default::default()) + .await + .is_err() + ); + let mut spoofed = args.clone(); + spoofed["author"] = json!("spoofed-author"); + assert!( + edit.execute(&spoofed.to_string(), Default::default()) + .await + .is_err() + ); + } + let output = show + .execute( + &json!({"id": reference.id, "event_limit": 1}).to_string(), + Default::default(), + ) + .await + .unwrap(); + let value: Value = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(value["events"]["returned"], 1); + assert_eq!(value["events"]["items"][0]["is_initial"], false); + assert_eq!(value["initial_post"]["is_initial"], true); + assert_eq!( + value["initial_post"]["event_ref"], + before.events[0].event_ref + ); + assert_eq!(value["initial_post"]["body"], value["body"]); + assert_eq!(value["meta"]["created_by"], "creator"); + assert_eq!(value["initial_post"]["author"], "creator"); + let initial_keys = value["initial_post"] + .as_object() + .unwrap() + .keys() + .collect::>(); + let comment_keys = value["events"]["items"][0] + .as_object() + .unwrap() + .keys() + .collect::>(); + assert_eq!(initial_keys, comment_keys); + let clipped = ticket_json(&backend.show(reference.id.clone().into()).unwrap(), 1, 0, 3); + assert_eq!(clipped["initial_post"]["body_truncated"], true); + assert_eq!( + clipped["initial_post"]["event_ref"], + before.events[0].event_ref + ); + assert_eq!(clipped["initial_post"]["author"], "creator"); + let decision = tool_by_name(backend.clone(), "TicketDecision"); + decision + .execute( + &json!({"ticket": reference.id, "body": "Immutable judgment"}).to_string(), + Default::default(), + ) + .await + .unwrap(); + let current = backend.show(reference.id.clone().into()).unwrap(); + let system = current.events.last().unwrap(); + assert!(edit.execute(&json!({"ticket": reference.id, "event_ref": system.event_ref, "expected_body": system.body.as_str(), "body": "Changed judgment"}).to_string(), Default::default()).await.is_err()); + assert_eq!(backend.show(reference.id.into()).unwrap(), current); + let (_, unknown) = backend + .create_with_snapshot(NewTicket::new("Unknown creator")) + .unwrap(); + let unknown_json = ticket_json(&unknown, 1, 0, 100); + assert!( + unknown_json["meta"] + .as_object() + .unwrap() + .contains_key("created_by") + ); + assert!(unknown_json["meta"]["created_by"].is_null()); + assert!(unknown_json["initial_post"]["author"].is_null()); + } + #[test] fn post_mutation_reference_failures_are_outcome_unknown() { let temp = TempDir::new().unwrap(); @@ -1978,6 +2181,7 @@ mod tests { [ "TicketCreate", "TicketEditItem", + "TicketEditPost", "TicketComment", "TicketPlan", "TicketDecision", @@ -2321,20 +2525,10 @@ mod tests { let record = backend .show(TicketIdOrSlug::Id(created.id.clone())) .unwrap(); - let event = record - .events - .iter() - .rev() - .find(|event| event.kind == TicketEventKind::Other("item_edit".to_string())) - .expect("item_edit event"); - assert_eq!( - event.attributes.get("body_edit"), - Some(&"partial".to_string()) - ); - assert_eq!( - event.attributes.get("replacement_count"), - Some(&"2".to_string()) - ); + assert_eq!(record.events.len(), 1); + assert_eq!(record.events[0].kind, TicketEventKind::Comment); + assert_eq!(record.events[0].body, record.document.body); + assert!(record.events[0].edited_at.is_some()); let error = edit .execute( @@ -2655,6 +2849,7 @@ mod tests { for name in [ "TicketCreate", "TicketEditItem", + "TicketEditPost", "TicketComment", "TicketPlan", "TicketDecision", diff --git a/crates/ticket/tests/migration.rs b/crates/ticket/tests/migration.rs index 2d9c312c5..5c8157c5d 100644 --- a/crates/ticket/tests/migration.rs +++ b/crates/ticket/tests/migration.rs @@ -51,7 +51,7 @@ fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_withou .unwrap(); } // Snapshot evidence before migration, including untyped `done` and existing MR references. - let historical_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::>().unwrap(); + let historical_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::>().unwrap(); let old_attributes: Vec<(String, String, String)> = connection .prepare( "SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key", @@ -79,7 +79,7 @@ fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_withou }) .unwrap(); assert_eq!(marker, 10); - let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::>().unwrap(); + let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::>().unwrap(); assert_eq!(after_events, historical_events); let store = SqliteTicketBackend::open_verified(&path, "workspace").unwrap(); for state in [ @@ -129,11 +129,11 @@ fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_withou assert_eq!(legacy.resolution.as_deref(), Some("Original decision text")); assert_eq!(ticket.meta.assignee.as_deref(), Some("retained-assignee")); assert_eq!(ticket.meta.targets[0].repository_key, "main"); - assert_eq!(ticket.events[0].from.as_deref(), Some("planning")); - assert_eq!(ticket.events[0].to.as_deref(), Some(state)); - assert_eq!(ticket.events[0].references[0].target, "MR-existing"); + assert_eq!(ticket.events[1].from.as_deref(), Some("planning")); + assert_eq!(ticket.events[1].to.as_deref(), Some(state)); + assert_eq!(ticket.events[1].references[0].target, "MR-existing"); assert_eq!( - ticket.events[0].attributes["request_fingerprint"], + ticket.events[1].attributes["request_fingerprint"], "legacy-fingerprint" ); if expected_state == TicketState::Closed { @@ -254,8 +254,26 @@ fn falsely_numbered_schema8_without_tags_is_rejected_without_writes() { // Read actual SQLite values, not merely counts: receipt JSON, original context, // name snapshots and event/reference/attribute rows must survive byte-for-byte. fn rows(connection: &Connection, table: &str) -> Vec> { + // Compare the retained v7-v11 payload, excluding the new initial post and metadata. + let modern: bool = connection.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info('typed_tickets') WHERE name='created_by')", [], |r| r.get(0)).unwrap(); + let projection = match table { + "typed_tickets" if modern => { + "workspace_id,ticket_id,slug,title,status,kind,priority,(SELECT body FROM typed_ticket_events e WHERE e.workspace_id=typed_tickets.workspace_id AND e.ticket_id=typed_tickets.ticket_id AND e.event_index=-1),created_at,updated_at,assignee,readiness,workflow_state,workflow_state_explicit,queued_by,queued_at,resolution" + } + "typed_ticket_events" => { + "workspace_id,ticket_id,event_index,kind,author,at,status,from_state,to_state,reason,state_field,heading,body" + } + _ => "*", + }; + let filter = if table.starts_with("typed_ticket_event") { + "WHERE event_index >= 0" + } else { + "" + }; let mut stmt = connection - .prepare(&format!("SELECT * FROM {table} ORDER BY rowid")) + .prepare(&format!( + "SELECT {projection} FROM {table} {filter} ORDER BY rowid" + )) .unwrap(); let count = stmt.column_count(); stmt.query_map([], |row| (0..count).map(|i| row.get(i)).collect()) @@ -272,11 +290,24 @@ fn copy_tables(connection: &Connection, source: &std::path::Path, tables: &[&str ) .unwrap(); for table in tables { - connection - .execute_batch(&format!( - "INSERT INTO {table} SELECT * FROM fixture_source.{table}" - )) + let columns = connection + .prepare(&format!("PRAGMA table_info({table})")) + .unwrap() + .query_map([], |r| r.get::<_, String>(1)) + .unwrap() + .collect::, _>>() .unwrap(); + let selection = columns.iter().map(|column| { + if *table == "typed_tickets" && column == "body" { + "(SELECT body FROM fixture_source.typed_ticket_events e WHERE e.workspace_id=t.workspace_id AND e.ticket_id=t.ticket_id AND e.event_index=-1)".to_owned() + } else { format!("t.{column}") } + }).collect::>().join(","); + let filter = if table.starts_with("typed_ticket_event") { + "WHERE t.event_index >= 0" + } else { + "" + }; + connection.execute_batch(&format!("INSERT INTO {table} ({}) SELECT {selection} FROM fixture_source.{table} t {filter}", columns.join(","))).unwrap(); } connection .execute_batch("DETACH DATABASE fixture_source") @@ -559,12 +590,23 @@ fn independent_case_schema9_adds_only_tags_without_reapplying_judgments_or_recei ), ) .unwrap(); - let reopened = source + let mut reopened = source .update_case( &id, case_request(&corrected, "reopen", TicketCaseAction::Reopen), ) .unwrap(); + reopened.events[0] + .attributes + .retain(|key, _| matches!(key.as_str(), "event_id" | "event_sequence")); + // The frozen v9 copy has no persisted event_id attributes. Its published + // fallback was a zero-based ordinal, not the source v12 persisted identity. + for (ordinal, event) in reopened.events.iter_mut().skip(1).enumerate() { + event.event_ref = format!("sequence:{ordinal}"); + event + .attributes + .insert("event_id".into(), event.event_ref.clone()); + } let path = temp.path().join("retained9.db"); let connection = Connection::open(&path).unwrap(); schema7(&connection); diff --git a/crates/ticket/tests/posts.rs b/crates/ticket/tests/posts.rs new file mode 100644 index 000000000..8d71d3049 --- /dev/null +++ b/crates/ticket/tests/posts.rs @@ -0,0 +1,525 @@ +use rusqlite::{Connection, params}; +use tempfile::TempDir; +use ticket::{ + MarkdownText, NewTicket, NewTicketEvent, SqliteTicketBackend, TicketBackend, TicketEventKind, + TicketItemEdit, TicketPostEdit, migrate_sqlite_ticket_schema, + migrate_sqlite_ticket_schema_in_transaction, ticket_content_digest, + verify_sqlite_ticket_schema, +}; + +fn comment(body: &str, author: &str) -> NewTicketEvent { + let mut event = NewTicketEvent::new(TicketEventKind::Comment, body); + event.author = Some(author.into()); + event +} + +#[test] +fn initial_and_discussion_comments_share_identity_authorship_and_cas_edits() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("posts.db"); + let backend = SqliteTicketBackend::open(&path, "workspace").unwrap(); + let mut input = NewTicket::new("Real first post"); + input.body = MarkdownText::new("First authored post"); + input.author = Some("creator".into()); + let (reference, created) = backend.create_with_snapshot(input).unwrap(); + assert_eq!(created.events.len(), 1); + let initial = &created.events[0]; + assert_eq!(initial.kind, TicketEventKind::Comment); + assert!(initial.is_initial); + assert_eq!(initial.body, created.document.body); + assert_eq!(initial.author.as_deref(), Some("creator")); + assert_eq!(initial.at, created.meta.created_at); + assert_eq!(created.meta.created_by.as_deref(), Some("creator")); + assert!(initial.edited_at.is_none()); + backend + .add_event( + reference.id.clone().into(), + comment("Discussion", "commenter"), + ) + .unwrap(); + let before = backend.show(reference.id.clone().into()).unwrap(); + let ordinary = before.events[1].clone(); + assert!(!ordinary.is_initial); + assert_eq!(ordinary.kind, initial.kind); + let initial_time = initial.at.clone(); + for original in [ordinary, initial.clone()] { + let edit = TicketPostEdit { + event_ref: original.event_ref.clone(), + expected_body: original.body.clone(), + body: MarkdownText::new(format!("Edited {}", original.event_ref)), + author: "editor".into(), + }; + let (previous, current) = backend + .edit_post_with_snapshots(reference.id.clone().into(), edit.clone()) + .unwrap(); + let post = current + .events + .iter() + .find(|p| p.event_ref == original.event_ref) + .unwrap(); + assert_eq!(post.author, original.author); + assert_eq!(post.at, original.at); + assert_eq!(post.edited_by.as_deref(), Some("editor")); + assert!(post.edited_at.is_some()); + assert_eq!(current.meta.created_by.as_deref(), Some("creator")); + assert_eq!(current.meta.created_at, initial_time); + assert_eq!(current.events.len(), 2); + if original.is_initial { + assert_eq!(current.document.body, edit.body); + assert_ne!( + ticket_content_digest(&previous), + ticket_content_digest(¤t) + ); + } else { + assert_eq!( + ticket_content_digest(&previous), + ticket_content_digest(¤t) + ); + } + assert!( + backend + .edit_post(reference.id.clone().into(), edit) + .is_err() + ); + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), current); + } + let previous = backend.show(reference.id.clone().into()).unwrap(); + let edited = backend + .edit_item( + reference.id.clone().into(), + TicketItemEdit { + title: Some("New title".into()), + body: Some(MarkdownText::new("Item authoring edits initial post")), + author: Some("item-editor".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(edited.events.len(), previous.events.len()); + assert_eq!(edited.events[0].author, previous.events[0].author); + assert_eq!(edited.events[0].at, previous.events[0].at); + assert_eq!(edited.events[0].edited_by.as_deref(), Some("item-editor")); + assert_eq!(edited.document.body, edited.events[0].body); + assert!( + edited + .events + .iter() + .all(|e| !matches!(e.kind.as_str(), "create" | "item_edit")) + ); + let reopened = SqliteTicketBackend::open(&path, "workspace").unwrap(); + assert_eq!(reopened.show(reference.id.into()).unwrap(), edited); + let conn = Connection::open(path).unwrap(); + assert_eq!( + conn.query_row( + "SELECT count(*) FROM pragma_table_info('typed_tickets') WHERE name='body'", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 0 + ); +} + +#[test] +fn editing_rejects_system_judgments_other_tickets_and_missing_identity() { + let temp = TempDir::new().unwrap(); + let backend = SqliteTicketBackend::open(temp.path().join("posts.db"), "workspace").unwrap(); + let first = backend.create(NewTicket::new("first")).unwrap(); + let second = backend.create(NewTicket::new("second")).unwrap(); + let mut event = comment("System judgment", "system"); + event.kind = TicketEventKind::Decision; + backend.add_event(first.id.clone().into(), event).unwrap(); + let ticket = backend.show(first.id.clone().into()).unwrap(); + let system = &ticket.events[1]; + let edit = TicketPostEdit { + event_ref: system.event_ref.clone(), + expected_body: system.body.clone(), + body: MarkdownText::new("changed"), + author: "editor".into(), + }; + assert!( + backend + .edit_post(first.id.clone().into(), edit.clone()) + .is_err() + ); + assert!(backend.edit_post(second.id.into(), edit).is_err()); + let initial = &ticket.events[0]; + assert!( + backend + .edit_post( + first.id.clone().into(), + TicketPostEdit { + event_ref: initial.event_ref.clone(), + expected_body: initial.body.clone(), + body: MarkdownText::new("changed"), + author: " ".into() + } + ) + .is_err() + ); + assert_eq!(backend.show(first.id.into()).unwrap(), ticket); +} + +fn schema11(conn: &Connection) { + // Build the actual retained component shape, not a current schema with its marker changed. + conn.execute_batch(include_str!("../src/latest_schema.sql")) + .unwrap(); + conn.execute_batch(include_str!("../src/assignment_schema.sql")) + .unwrap(); + conn.execute_batch(include_str!("../src/assignment_tracking_schema.sql")) + .unwrap(); + conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(11,'ticket schema baseline','original-marker');").unwrap(); +} + +fn seed_legacy(conn: &Connection, id: &str) { + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,created_at,workflow_state,workflow_state_explicit) VALUES('workspace',?1,?1,'Legacy','open','task','P2','Latest body','ticket-time','open',1)", [id]).unwrap(); +} + +#[test] +fn schema12_migrates_body_authors_archived_history_and_stable_references_then_replays() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("legacy.db"); + let conn = Connection::open(&path).unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON").unwrap(); + schema11(&conn); + for id in ["known", "unknown", "metadata"] { + seed_legacy(&conn, id); + } + conn.execute_batch("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body) VALUES + ('workspace','known',0,'create','original-author','original-time','Created'), + ('workspace','known',1,'comment','discussant','discussion-time','Discussion'), + ('workspace','known',2,'item_edit','legacy-editor','edit-time','Body changed'); + INSERT INTO typed_ticket_event_attributes VALUES('workspace','known',1,'event_id','external-original-id'); + INSERT INTO typed_ticket_event_attributes VALUES('workspace','known',2,'body_edit','whole'); + INSERT INTO typed_ticket_event_references VALUES('workspace','known',2,0,'commit','original-evidence'); + INSERT INTO typed_ticket_raw_frontmatter VALUES('workspace','metadata','created_by','metadata-author');").unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + verify_sqlite_ticket_schema(&conn).unwrap(); + let backend = SqliteTicketBackend::open_verified(&path, "workspace").unwrap(); + let known = backend.show("known".into()).unwrap(); + assert_eq!(known.meta.created_at.as_deref(), Some("ticket-time")); + assert_eq!(known.meta.created_by.as_deref(), Some("original-author")); + assert_eq!(known.events.len(), 2); + assert_eq!(known.events[0].author.as_deref(), Some("original-author")); + assert_eq!(known.events[0].at.as_deref(), Some("original-time")); + assert_eq!(known.document.body.as_str(), "Latest body"); + assert_eq!(known.events[0].body, known.document.body); + assert_eq!(known.events[1].event_ref, "known:1"); + assert_eq!(known.events[1].attributes["event_id"], "known:1"); + assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE ticket_id='known' AND event_index=1 AND key='event_id'", [], |r| r.get::<_,String>(0)).unwrap(), "external-original-id"); + assert_eq!(known.events[1].attributes["event_sequence"], "1"); + let unknown = backend.show("unknown".into()).unwrap(); + assert_eq!(unknown.meta.created_by, None); + assert_eq!(unknown.events[0].author, None); + assert_eq!(unknown.events[0].at.as_deref(), Some("ticket-time")); + assert_eq!( + backend.show("metadata".into()).unwrap().events[0] + .author + .as_deref(), + Some("metadata-author") + ); + let history = || { + conn.prepare("SELECT event_index,kind,author,at,body,archived FROM typed_ticket_events WHERE ticket_id='known' AND event_index>=0 ORDER BY event_index").unwrap().query_map([], |r| Ok((r.get::<_,i64>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,bool>(5)?))).unwrap().collect::>>().unwrap() + }; + let original_history = history(); + assert_eq!( + original_history[0], + ( + 0, + "create".into(), + "original-author".into(), + "original-time".into(), + "Created".into(), + true + ) + ); + assert_eq!( + original_history[2], + ( + 2, + "item_edit".into(), + "legacy-editor".into(), + "edit-time".into(), + "Body changed".into(), + true + ) + ); + assert_eq!(conn.query_row("SELECT target FROM typed_ticket_event_references WHERE ticket_id='known' AND event_index=2", [], |r| r.get::<_,String>(0)).unwrap(), "original-evidence"); + migrate_sqlite_ticket_schema(&conn).unwrap(); + assert_eq!(history(), original_history); + assert_eq!(backend.show("known".into()).unwrap(), known); + assert_eq!( + conn.query_row( + "SELECT applied_at FROM ticket_schema_migrations WHERE version=11", + [], + |r| r.get::<_, String>(0) + ) + .unwrap(), + "original-marker" + ); + assert_eq!( + conn.query_row( + "SELECT max(version) FROM ticket_schema_migrations", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 12 + ); + backend + .add_event("known".into(), comment("Next", "author")) + .unwrap(); + assert_eq!( + backend + .show("known".into()) + .unwrap() + .events + .last() + .unwrap() + .event_ref, + "known:3" + ); +} + +#[test] +fn schema12_reserved_index_collision_rolls_back_component_and_host_can_retry() { + let conn = Connection::open_in_memory().unwrap(); + schema11(&conn); + seed_legacy(&conn, "collision"); + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('workspace','collision',-1,'comment','retained negative index')", []).unwrap(); + conn.execute_batch("CREATE TABLE host_work(value TEXT); BEGIN IMMEDIATE; INSERT INTO host_work VALUES('pending');").unwrap(); + assert!(migrate_sqlite_ticket_schema_in_transaction(&conn).is_err()); + assert!(!conn.is_autocommit()); + assert_eq!( + conn.query_row( + "SELECT body FROM typed_tickets WHERE ticket_id='collision'", + [], + |r| r.get::<_, String>(0) + ) + .unwrap(), + "Latest body" + ); + assert_eq!( + conn.query_row( + "SELECT count(*) FROM pragma_table_info('typed_ticket_events') WHERE name='event_ref'", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 0 + ); + assert_eq!( + conn.query_row( + "SELECT max(version) FROM ticket_schema_migrations", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 11 + ); + conn.execute_batch("ROLLBACK").unwrap(); + assert_eq!( + conn.query_row("SELECT count(*) FROM host_work", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 0 + ); + // Remove the deliberately conflicting test row, then retry the unchanged migration. + conn.execute( + "DELETE FROM typed_ticket_events WHERE event_index=?1", + params![-1], + ) + .unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + verify_sqlite_ticket_schema(&conn).unwrap(); +} + +#[test] +fn migration_preserves_published_refs_before_archiving_even_with_sparse_indices() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("identities.db"); + let conn = Connection::open(&path).unwrap(); + schema11(&conn); + seed_legacy(&conn, "sparse"); + conn.execute_batch("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES + ('workspace','sparse',3,'create','Created'), + ('workspace','sparse',12,'item_edit','Edited'), + ('workspace','sparse',90,'comment','Existing comment'), + ('workspace','sparse',99,'comment','Explicit identity'); + INSERT INTO typed_ticket_event_attributes VALUES('workspace','sparse',99,'event_id','published-custom-id');").unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + let refs: Vec<(i64, String)> = conn.prepare("SELECT event_index,event_ref FROM typed_ticket_events WHERE ticket_id='sparse' ORDER BY event_index").unwrap().query_map([], |r| Ok((r.get(0)?, r.get(1)?))).unwrap().collect::>().unwrap(); + assert_eq!( + refs, + vec![ + (-1, "sparse:-1".into()), + (3, "sparse:3".into()), + (12, "sparse:12".into()), + (90, "sparse:90".into()), + (99, "sparse:99".into()) + ] + ); + let backend = SqliteTicketBackend::open_verified(&path, "workspace").unwrap(); + let ticket = backend.show("sparse".into()).unwrap(); + assert_eq!(ticket.events[1].event_ref, "sparse:90"); + assert_eq!(ticket.events[1].attributes["event_id"], "sparse:90"); + assert_eq!(ticket.events[1].attributes["event_sequence"], "90"); + let edited = backend + .edit_post( + "sparse".into(), + TicketPostEdit { + event_ref: "sparse:90".into(), + expected_body: MarkdownText::new("Existing comment"), + body: MarkdownText::new("Updated comment"), + author: "editor".into(), + }, + ) + .unwrap(); + assert_eq!(edited.events[1].event_ref, "sparse:90"); + migrate_sqlite_ticket_schema(&conn).unwrap(); + assert_eq!(backend.show("sparse".into()).unwrap(), edited); +} + +#[test] +fn migration_preserves_raw_identity_attributes_without_rebinding_published_refs() { + for event_ids in [["same", "same"], ["sequence:1", ""], ["collision:-1", ""]] { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("identity-attributes.db"); + let conn = Connection::open(&path).unwrap(); + schema11(&conn); + seed_legacy(&conn, "collision"); + for (index, event_id) in event_ids.iter().enumerate() { + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('workspace','collision',?1,'comment','retained')", [index as i64]).unwrap(); + if !event_id.is_empty() { + conn.execute("INSERT INTO typed_ticket_event_attributes VALUES('workspace','collision',?1,'event_id',?2)", params![index as i64,event_id]).unwrap(); + } + } + migrate_sqlite_ticket_schema(&conn).unwrap(); + let backend = SqliteTicketBackend::open_verified(&path, "workspace").unwrap(); + let migrated = backend.show("collision".into()).unwrap(); + assert_eq!( + migrated + .events + .iter() + .map(|e| e.event_ref.as_str()) + .collect::>(), + ["collision:-1", "collision:0", "collision:1"] + ); + for (index, event_id) in event_ids.iter().enumerate() { + let event = &migrated.events[index + 1]; + assert_eq!(event.attributes["event_id"], format!("collision:{index}")); + if !event_id.is_empty() { + assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE ticket_id='collision' AND event_index=?1 AND key='event_id'", [index as i64], |r| r.get::<_, String>(0)).unwrap(), *event_id); + } + } + migrate_sqlite_ticket_schema(&conn).unwrap(); + assert_eq!(backend.show("collision".into()).unwrap(), migrated); + } +} + +#[test] +fn migration_recovers_only_last_known_body_edit_and_never_invents_creation_author() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("edit-metadata.db"); + let conn = Connection::open(&path).unwrap(); + schema11(&conn); + for id in [ + "known", + "unknown-last-editor", + "title-only", + "empty-creator", + ] { + seed_legacy(&conn, id); + } + conn.execute_batch("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body) VALUES + ('workspace','known',0,'create','creator','original-time','Created'), + ('workspace','known',1,'item_edit','old-editor','old-time','old body edit'), + ('workspace','known',5,'item_edit','body-editor','body-time','latest body edit'), + ('workspace','known',6,'item_edit','title-editor','title-time','later title edit'), + ('workspace','unknown-last-editor',0,'item_edit','old-editor','old-time','old body edit'), + ('workspace','unknown-last-editor',1,'item_edit',NULL,NULL,'last body edit'), + ('workspace','title-only',0,'item_edit','title-editor','title-time','not a body edit'), + ('workspace','empty-creator',0,'create',' ','create-time','Created'); + INSERT INTO typed_ticket_event_attributes VALUES + ('workspace','known',1,'changes','body'), + ('workspace','known',5,'changes','title,body,targets'), + ('workspace','known',6,'changes','title'), + ('workspace','unknown-last-editor',0,'changes','body'), + ('workspace','unknown-last-editor',1,'changes','body'), + ('workspace','title-only',0,'changes','title,body_format'); + INSERT INTO typed_ticket_raw_frontmatter VALUES('workspace','empty-creator','created_by','');").unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + let backend = SqliteTicketBackend::open_verified(&path, "workspace").unwrap(); + let known = backend.show("known".into()).unwrap(); + let post = &known.events[0]; + assert_eq!(post.author.as_deref(), Some("creator")); + assert_eq!(post.at.as_deref(), Some("original-time")); + assert_eq!(post.edited_at.as_deref(), Some("body-time")); + assert_eq!(post.edited_by.as_deref(), Some("body-editor")); + assert_eq!(known.document.body.as_str(), "Latest body"); + for id in ["unknown-last-editor", "title-only", "empty-creator"] { + let ticket = backend.show(id.into()).unwrap(); + assert_eq!(ticket.events[0].edited_at, None, "{id}"); + assert_eq!(ticket.events[0].edited_by, None, "{id}"); + assert_eq!(ticket.events[0].author, None, "{id}"); + assert_eq!(ticket.meta.created_by, None, "{id}"); + let json = serde_json::to_value(ticket).unwrap(); + assert!(json["meta"].as_object().unwrap().contains_key("created_by")); + assert!(json["meta"]["created_by"].is_null()); + assert!(json["events"][0]["author"].is_null()); + } + migrate_sqlite_ticket_schema(&conn).unwrap(); + assert_eq!(backend.show("known".into()).unwrap(), known); + // Blank new creation identities use the same honest nullable contract. + let mut input = NewTicket::new("Unknown creator"); + input.author = Some(" ".into()); + let (_, created) = backend.create_with_snapshot(input).unwrap(); + assert_eq!(created.meta.created_by, None); + assert_eq!(created.events[0].author, None); +} + +#[test] +fn edit_post_dispatch_round_trips_atomic_ticket_snapshots() { + use ticket::{ + TicketBackendOperation, TicketBackendOperationResult, execute_ticket_backend_operation, + }; + let temp = TempDir::new().unwrap(); + let backend = SqliteTicketBackend::open(temp.path().join("dispatch.db"), "workspace").unwrap(); + let (reference, before) = backend + .create_with_snapshot(NewTicket::new("Dispatch")) + .unwrap(); + let request = TicketBackendOperation::EditPost { + id: reference.id.clone().into(), + edit: TicketPostEdit { + event_ref: before.events[0].event_ref.clone(), + expected_body: before.document.body.clone(), + body: MarkdownText::new("Rescoped requirements"), + author: "caller".into(), + }, + }; + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["operation"], "edit_post"); + let decoded = serde_json::from_value(json).unwrap(); + let result = execute_ticket_backend_operation(&backend, decoded).unwrap(); + let wire = serde_json::to_value(&result).unwrap(); + assert_eq!(wire["result"], "ticket_snapshots"); + assert_eq!( + wire["value"]["previous"]["document"]["body"], + before.document.body.as_str() + ); + assert_eq!( + wire["value"]["current"]["document"]["body"], + "Rescoped requirements" + ); + let TicketBackendOperationResult::TicketSnapshots { previous, current } = + serde_json::from_value(wire).unwrap() + else { + panic!("wrong result variant") + }; + assert_eq!(previous, before); + assert_eq!(backend.show(reference.id.into()).unwrap(), current); + assert_ne!( + ticket_content_digest(&previous), + ticket_content_digest(¤t) + ); + assert!(execute_ticket_backend_operation(&backend, request).is_err()); +} diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index 9d149688e..09966f26b 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -43,6 +43,9 @@ pub(super) struct ModelTicketDetail { content_digest: String, title: String, body: String, + body_truncated: bool, + initial_post: ModelTicketEvent, + created_by: Option, state: ticket::TicketState, closure_operation_key: Option, current_closure: Option, @@ -131,9 +134,17 @@ struct ModelTicketEvent { #[serde(skip_serializing_if = "Option::is_none")] tag_changes: Option, sequence: usize, + event_ref: String, + is_initial: bool, kind: String, body: Option, + body_truncated: bool, + body_digest: String, + editable: bool, + author: Option, created_at: Option, + edited_at: Option, + edited_by: Option, } #[derive(Debug, Serialize, Default)] @@ -335,6 +346,12 @@ pub(super) fn project_ticket_detail(value: Value) -> Result Result { Ok(ModelTicketEvent { tag_changes, sequence: usize_field(event, "sequence")?, + event_ref: string_field(event, "event_ref")?, + is_initial: bool_field(event, "is_initial")?, + body_truncated: bool_field(event, "body_truncated")?, + body_digest: string_field(event, "body_digest")?, + editable: bool_field(event, "editable")?, + author: optional_string(event, "author")?, + edited_at: optional_string(event, "edited_at")?, + edited_by: optional_string(event, "edited_by")?, kind: string_field(event, "kind")?, body: match event.get("body") { None | Some(Value::Null) => None, @@ -815,13 +840,24 @@ mod tests { use super::*; use serde_json::json; + fn post_fixture(initial: bool, body: &str) -> Value { + json!({ + "sequence": if initial { 0 } else { 1 }, + "event_ref": if initial { "post-initial" } else { "post-discussion" }, + "is_initial": initial, "kind": "comment", "body": body, + "body_truncated": false, "body_digest": "a".repeat(64), "editable": true, + "author": "workspace-user:original", "at": "2026-01-01T00:00:00Z", + "edited_at": "2026-01-02T00:00:00Z", "edited_by": "workspace-user:editor" + }) + } + #[test] fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() { for state in ["open", "closed"] { let projected = project_ticket_detail(json!({ - "resource_key": "T-718", "assignment_tracking": [], "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision", + "resource_key": "T-718", "assignment_tracking": [], "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision", "body_truncated": false, "initial_post": post_fixture(true, "Decision"), "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, - "state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}], + "state": state, "events": [post_fixture(false, "No Git needed")], "linked_objectives": [], "assignments": [], "implementation_reports": [], "merge_requests": [], "merge_request": null, "evidence": { @@ -832,7 +868,7 @@ mod tests { })).unwrap(); let projected = serde_json::to_value(projected).unwrap(); assert_eq!(projected["state"], state); - assert_eq!(projected["thread"][0]["kind"], "completed"); + assert_eq!(projected["thread"][0]["kind"], "comment"); assert_eq!(projected["evidence"]["integration_applicable"], false); assert_eq!(projected["evidence"]["review_status"], Value::Null); assert_eq!(projected["evidence"]["approved_current_subject"], false); @@ -862,7 +898,7 @@ mod tests { }); let response = json!({ "assignment_tracking": [], "tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}], - "resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements", + "resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements", "body_truncated": false, "initial_post": post_fixture(true, "Requirements"), "state": "open", "closure_operation_key": "close-1", "current_closure": null, "closure_history": history, "legacy_handoff": legacy, "events": [], "linked_objectives": [], "assignments": [], "implementation_reports": [], @@ -1013,7 +1049,7 @@ mod tests { "content_digest": "ticket-content-digest", "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "title": "Queued Submit", - "body": "Body", + "body": "Body", "body_truncated": false, "initial_post": post_fixture(true, "Body"), "state": "open", "readiness": null, "priority": "P2", @@ -1117,7 +1153,7 @@ mod tests { "content_digest": "current-ticket-content-digest", "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "title": "Source evidence", - "body": "Body", + "body": "Body", "body_truncated": false, "initial_post": post_fixture(true, "Body"), "state": "open", "events": [], "linked_objectives": [], @@ -1301,7 +1337,7 @@ mod tests { let closure = json!({"operation_key":"close-1", "reason":"not_planned", "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}); let history = json!([{"operation_key":"close-1", "previous_closure_operation_key":null, "action":{"action":"close", "reason":"not_planned"}, "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}]); let detail = json!({ - "resource_key":"T-739", "title":"Case", "body":"Requirements", "content_digest":"content-1", + "resource_key":"T-739", "title":"Case", "body":"Requirements", "body_truncated":false, "initial_post":post_fixture(true, "Requirements"), "created_by":"workspace-user:creator", "content_digest":"content-1", "state":"closed", "assignment_tracking": [tracking], "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure, "legacy_handoff":null, "closure_history":history, "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[] }); @@ -1348,6 +1384,41 @@ mod tests { } assert_eq!(projected_detail["closure_history"], history); assert_eq!(projected_detail["content_digest"], "content-1"); + assert_eq!(projected_detail["created_by"], "workspace-user:creator"); + assert_eq!(projected_detail["body_truncated"], false); + for initial in [true, false] { + let input = post_fixture(initial, "The same post contract"); + let output = serde_json::to_value(project_ticket_event(&input).unwrap()).unwrap(); + for field in [ + "event_ref", + "is_initial", + "author", + "edited_at", + "edited_by", + "body", + "body_digest", + "body_truncated", + "editable", + ] { + assert_eq!(output[field], input[field], "post metadata {field}"); + } + assert_eq!(output["created_at"], input["at"]); + } + assert_eq!( + projected_detail["initial_post"]["author"], + "workspace-user:original" + ); + assert_eq!( + projected_detail["initial_post"]["edited_by"], + "workspace-user:editor" + ); + let mut missing_post = detail.clone(); + missing_post.as_object_mut().unwrap().remove("initial_post"); + assert!( + project_ticket_detail(missing_post) + .unwrap_err() + .contains("initial_post") + ); // Unknown observations must not be replaced with empty/null values. for field in [ "tags", @@ -1410,11 +1481,14 @@ mod tests { #[test] fn ticket_tag_history_projection_preserves_observed_names_and_identities() { let tag = serde_json::json!({"workspace_id":"ws","id":"tag-1","name":"Old name","last_operation_key":"create","deleted":false}); - let event = serde_json::json!({"sequence":3,"kind":"tags_changed","body":"Ticket tags changed","at":"then","attributes":{"tags_before":serde_json::json!([tag.clone()]).to_string(),"tags_after":"[]"}}); + let mut event = post_fixture(false, "Ticket tags changed"); + event["kind"] = json!("tags_changed"); + event["editable"] = json!(false); + event["attributes"] = serde_json::json!({"tags_before":serde_json::json!([tag.clone()]).to_string(),"tags_after":"[]"}); let projected = serde_json::to_value(project_ticket_event(&event).unwrap()).unwrap(); assert_eq!(projected["tag_changes"]["before"], serde_json::json!([tag])); assert_eq!(projected["tag_changes"]["after"], serde_json::json!([])); - let ordinary = serde_json::json!({"sequence":0,"kind":"comment","body":"hello","at":null}); + let ordinary = post_fixture(false, "hello"); assert!( serde_json::to_value(project_ticket_event(&ordinary).unwrap()) .unwrap() diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 140e1ea62..ad917f22d 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -670,6 +670,7 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &[ const AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", "TicketEditItem", + "TicketEditPost", "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", @@ -681,6 +682,7 @@ const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"]; const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", "TicketEditItem", + "TicketEditPost", "QueryTicket", "ShowTicket", "TicketComment", @@ -1109,6 +1111,17 @@ impl WorkspaceHttpTicketBackend { )?; Ok(TicketBackendOperationResult::Ticket(ticket)) } + TicketBackendOperation::EditPost { id, edit } => { + let (previous, current): (Ticket, Ticket) = Self::request( + client, + WorkspaceRequestMethod::Patch, + format!("{base}/{}/posts", Self::ticket_path(&id)), + Some(serde_json::to_value(edit).map_err(|error| { + TicketError::Conflict(format!("serialize Ticket post edit: {error}")) + })?), + )?; + Ok(TicketBackendOperationResult::TicketSnapshots { previous, current }) + } TicketBackendOperation::DependencyCheck { id } => { let check = Self::request( client, @@ -1289,6 +1302,21 @@ macro_rules! expect_ticket_result { } impl TicketBackend for WorkspaceHttpTicketBackend { + fn edit_post_with_snapshots( + &self, + id: TicketIdOrSlug, + edit: ticket::TicketPostEdit, + ) -> TicketResult<(Ticket, Ticket)> { + match self.invoke(TicketBackendOperation::EditPost { id, edit })? { + TicketBackendOperationResult::TicketSnapshots { previous, current } => { + Ok((previous, current)) + } + other => Err(TicketError::Conflict(format!( + "unexpected ticket post response: {other:?}" + ))), + } + } + fn list_workspace_tags( &self, request: ticket::TagListRequest, @@ -1573,6 +1601,12 @@ fn native_ticket_operation(tool_name: &str) -> Option { identity_field: Some("ticket"), mutating: true, }, + "TicketEditPost" => NativeTicketOperation { + operation: "edit_post", + surface: NativeTicketSurface::Item, + identity_field: Some("ticket"), + mutating: true, + }, "TicketComment" => NativeTicketOperation { operation: "comment", surface: NativeTicketSurface::Item, @@ -2470,6 +2504,47 @@ mod tests { assert!(client.requests.lock().unwrap().is_empty()); } + #[test] + fn post_edit_backend_transports_atomic_snapshots_and_propagates_conflicts() { + let record: Ticket = + serde_json::from_str(&decision_client(200, TicketState::Open).response.body).unwrap(); + let input = json!({ + "event_ref": "post-reference", + "expected_body": "Observed post", + "body": "Updated post", + "author": "standalone" + }); + for status in [200, 409] { + let client = Arc::new(DecisionClient { + requests: Mutex::new(Vec::new()), + response: crate::worker::WorkspaceResponse { + status, + body: serde_json::to_string(&(record.clone(), record.clone())).unwrap(), + }, + }); + let backend = WorkspaceHttpTicketBackend::new(client.clone()); + let result = backend.edit_post_with_snapshots( + TicketIdOrSlug::from("T-739"), + serde_json::from_value(input.clone()).unwrap(), + ); + if status == 200 { + let (previous, current) = result.unwrap(); + assert_eq!(previous.meta.id, record.meta.id); + assert_eq!(current.meta.id, record.meta.id); + } else { + assert!(result.unwrap_err().to_string().contains("409")); + } + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, WorkspaceRequestMethod::Patch); + assert_eq!(requests[0].path, "/api/w/workspace/tickets/T-739/posts"); + assert_eq!( + serde_json::from_str::(requests[0].body.as_deref().unwrap()).unwrap(), + input, + ); + } + } + #[test] fn query_schema_allows_only_open_and_closed_case_states() { let client = decision_client(200, TicketState::Open); @@ -3242,6 +3317,7 @@ mod tests { .collect::>(); assert!(workspace_tools.contains(&"TicketCreate")); assert!(workspace_tools.contains(&"TicketEditItem")); + assert!(workspace_tools.contains(&"TicketEditPost")); assert!(!workspace_tools.contains(&"TicketQueue")); assert!(workspace_tools.contains(&"TicketCaseUpdate")); @@ -3257,6 +3333,7 @@ mod tests { assert!(orchestration_tools.contains(&"TicketRelationRecord")); assert!(orchestration_tools.contains(&"TicketOrchestrationPlanRecord")); assert!(!orchestration_tools.contains(&"TicketEditItem")); + assert!(!orchestration_tools.contains(&"TicketEditPost")); assert!(!orchestration_tools.contains(&"TicketQueue")); let work_report = workspace_feature(TicketFeatureAccess::work_report()); @@ -3315,6 +3392,18 @@ mod tests { assert!(native.iter().all(|tool| tool.name != name), "{name}"); assert!(native_ticket_operation(name).is_none(), "{name}"); } + assert_eq!( + normal + .iter() + .any(|definition| definition().0.name == "TicketEditPost"), + access.authoring, + ); + assert_eq!( + native + .iter() + .any(|tool| tool.projection.operation == "edit_post"), + access.authoring, + ); if access.authoring || access.thread || access.workflow { assert!( normal diff --git a/crates/workspace-server/src/assignment_tracking_tests.rs b/crates/workspace-server/src/assignment_tracking_tests.rs index 5255267c7..6df1bb407 100644 --- a/crates/workspace-server/src/assignment_tracking_tests.rs +++ b/crates/workspace-server/src/assignment_tracking_tests.rs @@ -78,7 +78,8 @@ async fn tracking_store_every_replay_fences_current_singleton_and_authenticated_ ); // Model the authority change after authenticated source capture, before // BEGIN IMMEDIATE on a second connection. Receipt presence cannot bypass it. - let second = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap(); + let second = + SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap(); second.with_conn_mut(|conn| { let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; match change { @@ -233,7 +234,8 @@ async fn tracking_store_same_principal_reissue_and_exact_retries_keep_original_r .is_err() ); drop(store); - let reopened = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap(); + let reopened = + SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap(); assert_eq!( reopened .get_ticket_assignment_tracking("w", "tracking", "a") @@ -300,10 +302,11 @@ fn tracking_schema91_fixture(path: &Path) -> Connection { // Genuine retained component-10 DDL, before assignment tracking existed. // The host structure is unchanged from Workspace 91: no tracking columns, // tracking tables/triggers, or component-11 marker are created here. - conn.execute_batch(include_str!("../../ticket/src/latest_schema.sql")) + conn.execute_batch(include_str!("frozen_ticket_schema_v10.sql")) .unwrap(); conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','original-component10-time'); PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;").unwrap(); - create_workspace_schema_structure(&conn).unwrap(); + create_workspace_schema_structure_from_sql(&conn, include_str!("frozen_schema_v93.sql"), false) + .unwrap(); conn.execute( "INSERT INTO __yoi_schema_migrations(version,name) VALUES(91,'workspace schema baseline')", [], @@ -439,12 +442,12 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip .iter() .map(|m| m.version) .collect::>(), - vec![92, 93] + vec![92, 93, 94] ); let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); store .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 93); + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); assert_eq!( conn.query_row( "SELECT applied_at FROM ticket_schema_migrations WHERE version=10", @@ -461,7 +464,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip ( 93, "Durable assignment deliveries and source-bound startup fences".into() - ) + ), + (94, "Canonical Ticket posts".into()) ] ); for (table, rows) in retained.iter().zip(original.iter()) { @@ -599,8 +603,8 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first .is_empty() ); store.with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 93); - assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations", [], |row| row.get::<_,i64>(0))?, 11); + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); + assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations", [], |row| row.get::<_,i64>(0))?, 12); assert_eq!(conn.query_row("SELECT workflow_state FROM typed_tickets WHERE ticket_id='historical-done'", [], |row| row.get::<_,String>(0))?, "closed"); let stored: (Option, Option) = conn.query_row("SELECT tracking_json,last_tracking_event_id FROM ticket_worker_assignments WHERE assignment_id='a-historical-done'", [], |row| Ok((row.get(0)?,row.get(1)?)))?; assert_eq!(stored, (None, None), "read baseline must not manufacture tracking storage"); @@ -1068,7 +1072,8 @@ async fn orchestrator_assignment_all_runtime_retries_fence_singleton_and_binding &source, ) .unwrap(); - let second = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap(); + let second = + SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap(); second.with_conn_mut(|conn| {let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;match change { "replace" => {tx.execute("UPDATE worker_singleton_owners SET worker_id='replacement',generation=2 WHERE workspace_id='w'",[])?;}, "revoke" => {tx.execute("UPDATE workspace_runtime_bindings SET revoked_at='revoked' WHERE workspace_id='w'",[])?;}, @@ -1364,7 +1369,7 @@ async fn tracking_history_pages_are_append_ordered_exact_scope_cursors_and_bound }) .await .unwrap(); - store.with_conn(|conn| {conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('other','tracking','tracking','Other','open','task','P2','','open',1)",[])?;Ok(())}).unwrap(); + store.with_conn(|conn| {conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) VALUES('other','tracking','tracking','Other','open','task','P2','open',1)",[])?;conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('other','tracking',-1,'tracking:-1','comment','')",[])?;Ok(())}).unwrap(); let other_workspace = TicketRoleAssignmentRecord { workspace_id: "other".into(), ..orchestrator_mutation_record("history-a") diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 491b30bda..395cb78d4 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -489,10 +489,10 @@ impl SqliteWorkspaceAuthority { if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) { let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase()))); predicates.push(format!( - "(lower(t.title) LIKE {p} OR lower(t.body) LIKE {p} OR EXISTS ( + "(lower(t.title) LIKE {p} OR lower((SELECT initial.body FROM typed_ticket_events initial WHERE initial.workspace_id=t.workspace_id AND initial.ticket_id=t.ticket_id AND initial.event_index=-1 AND initial.archived=0)) LIKE {p} OR EXISTS ( SELECT 1 FROM typed_ticket_events e WHERE e.workspace_id=t.workspace_id AND e.ticket_id=t.ticket_id - AND lower(e.body) LIKE {p}) OR EXISTS ( + AND e.event_index >= 0 AND e.archived=0 AND lower(e.body) LIKE {p}) OR EXISTS ( SELECT 1 FROM typed_ticket_case_history h WHERE h.workspace_id=t.workspace_id AND h.ticket_id=t.ticket_id AND lower(CAST(json_extract(h.judgment_json,'$.action.reason') AS TEXT)) LIKE {p}))", p = pattern @@ -542,7 +542,7 @@ impl SqliteWorkspaceAuthority { .iter() .map(|kind| bind(SqlValue::Text(kind.clone()))) .collect::>(); - predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND event.kind IN ({}))", event_kinds.join(","))); + predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND event.event_index >= 0 AND event.archived=0 AND event.kind IN ({}))", event_kinds.join(","))); } // SQL only narrows immutable Ticket metadata. Review/evidence filters // must use the same provider + complete linked-result projection as ShowTicket; @@ -560,7 +560,7 @@ impl SqliteWorkspaceAuthority { if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) { let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase()))); format!( - "CASE WHEN lower(t.title) LIKE {pattern} THEN 0 WHEN lower(t.body) LIKE {pattern} THEN 1 WHEN EXISTS (SELECT 1 FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND lower(event.body) LIKE {pattern}) THEN 2 ELSE 3 END" + "CASE WHEN lower(t.title) LIKE {pattern} THEN 0 WHEN lower((SELECT initial.body FROM typed_ticket_events initial WHERE initial.workspace_id=t.workspace_id AND initial.ticket_id=t.ticket_id AND initial.event_index=-1 AND initial.archived=0)) LIKE {pattern} THEN 1 WHEN EXISTS (SELECT 1 FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND event.event_index >= 0 AND event.archived=0 AND lower(event.body) LIKE {pattern}) THEN 2 ELSE 3 END" ) } else { "3".to_string() @@ -762,8 +762,12 @@ impl SqliteWorkspaceAuthority { let id = ticket.meta.id.as_str(); let dependency_check = dependency_backend.dependency_check(TicketIdOrSlug::Id(id.to_string()))?; - let (body, body_truncated) = - truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT); + let initial = ticket.events.iter().find(|event| event.is_initial) + .ok_or_else(|| Error::Store("Ticket initial post is missing".into()))?; + let initial_post = ticket_event_detail(0, initial); + let body = initial_post.body.clone().unwrap_or_default(); + let body_truncated = initial_post.body_truncated; + let subsequent_events = ticket.events.iter().filter(|event| !event.is_initial).collect::>(); let event_limit = request .event_limit .unwrap_or(TICKET_EVENT_LIMIT) @@ -773,10 +777,10 @@ impl SqliteWorkspaceAuthority { .as_deref() .map(|cursor| parse_offset_cursor(cursor, "event_cursor")) .transpose()? - .unwrap_or(ticket.events.len()) - .min(ticket.events.len()); + .unwrap_or(subsequent_events.len()) + .min(subsequent_events.len()); let event_start = event_end.saturating_sub(event_limit); - let events = ticket.events[event_start..event_end] + let events = subsequent_events[event_start..event_end] .iter() .enumerate() .map(|(index, event)| ticket_event_detail(event_start + index, event)) @@ -795,8 +799,7 @@ impl SqliteWorkspaceAuthority { }) }) .collect::>>()?; - let implementation_reports = ticket - .events + let implementation_reports = subsequent_events .iter() .enumerate() .filter(|(_, event)| event.kind.as_str() == "implementation_report") @@ -996,6 +999,8 @@ impl SqliteWorkspaceAuthority { .store .list_current_ticket_assignment_tracking(&self.workspace_id, &ticket.meta.id)?; Ok(TicketDetail { + initial_post, + created_by: ticket.meta.created_by, assignment_tracking, id: ticket.meta.id, resource_key, @@ -1017,7 +1022,7 @@ impl SqliteWorkspaceAuthority { tags: ticket.tags, body, body_truncated, - event_count: ticket.events.len(), + event_count: subsequent_events.len(), events, event_page: QueryPage { limit: event_limit, @@ -1770,6 +1775,9 @@ impl std::fmt::Display for ObjectiveQuerySort { } fn ticket_event_ref(sequence: usize, event: &TicketEvent) -> String { + if !event.event_ref.is_empty() { + return event.event_ref.clone(); + } event .attributes .get("event_id") @@ -1777,8 +1785,20 @@ fn ticket_event_ref(sequence: usize, event: &TicketEvent) -> String { .unwrap_or_else(|| format!("sequence:{sequence}")) } -fn ticket_event_detail(sequence: usize, event: &TicketEvent) -> TicketEventDetail { +pub(crate) fn ticket_post_body_digest(body: &str) -> String { + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(body.as_bytes())) +} + +pub(crate) fn ticket_event_detail(sequence: usize, event: &TicketEvent) -> TicketEventDetail { + let (body, body_truncated) = truncate_body(event.body.as_str(), if event.is_initial { DETAIL_BODY_LIMIT } else { TICKET_EVENT_BODY_LIMIT }); TicketEventDetail { + is_initial: event.is_initial, + edited_at: event.edited_at.clone(), + edited_by: event.edited_by.clone(), + editable: event.kind == ticket::TicketEventKind::Comment, + body_digest: ticket_post_body_digest(event.body.as_str()), + body_truncated, sequence, event_ref: ticket_event_ref(sequence, event), kind: event.kind.as_str().to_owned(), @@ -1790,8 +1810,7 @@ fn ticket_event_detail(sequence: usize, event: &TicketEvent) -> TicketEventDetai 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), + body: Some(body), attributes: event.attributes.clone(), references: event .references @@ -2468,6 +2487,7 @@ fn ticket_query_item( if let Some((sequence, event)) = authoritative_events .iter() .enumerate() + .filter(|(_, event)| !event.is_initial) .find(|(_, event)| event.body.as_str().to_lowercase().contains(&needle)) { matched_fields.push("event".to_string()); diff --git a/crates/workspace-server/src/frozen_schema_v93.sql b/crates/workspace-server/src/frozen_schema_v93.sql new file mode 100644 index 000000000..76578bde6 --- /dev/null +++ b/crates/workspace-server/src/frozen_schema_v93.sql @@ -0,0 +1,1702 @@ +-- Canonical composed Workspace Server schema. New databases are created directly at this generation. +-- Ticket creates its standalone schema first; replace those empty tables with the composed authority. +DROP TABLE typed_ticket_event_attributes; +DROP TABLE typed_ticket_event_references; +DROP TABLE typed_ticket_artifacts; +DROP TABLE typed_ticket_labels; +DROP TABLE typed_ticket_orchestration_plans; +DROP TABLE typed_ticket_raw_frontmatter; +DROP TABLE typed_ticket_relations; +DROP TABLE typed_ticket_risk_flags; +DROP TABLE typed_ticket_events; +DROP TABLE typed_tickets; +DROP TABLE workspace_resource_key_counters; +DROP TABLE workspace_resource_keys; +CREATE TABLE accounts ( + account_id TEXT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('user', 'organization')), + handle TEXT NOT NULL, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (kind, handle) +); +CREATE TABLE api_tokens ( + token_id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + label TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + revoked_at TEXT, + last_used_at TEXT +); +CREATE TABLE "artifacts" ( + workspace_id TEXT NOT NULL, + artifact_id TEXT NOT NULL, + kind TEXT NOT NULL, + uri TEXT NOT NULL, + media_type TEXT, + sha256 TEXT, + size_bytes INTEGER, + summary TEXT, + created_at TEXT NOT NULL, + created_by_kind TEXT NOT NULL, + created_by_key TEXT NOT NULL, + created_by_display TEXT NOT NULL, + created_by_source_kind TEXT, + created_by_source_key TEXT, + ticket_id TEXT, + objective_id TEXT, + event_id TEXT, + worker_ref_kind TEXT, + worker_ref_key TEXT, + worker_display TEXT, + repository_id TEXT, + source_kind TEXT, + source_reference TEXT, + PRIMARY KEY (workspace_id, artifact_id), + CHECK ((worker_ref_kind IS NULL) = (worker_ref_key IS NULL)), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT +); +CREATE TABLE audit_events ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + audit_event_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + actor_kind TEXT NOT NULL, + actor_key TEXT NOT NULL, + actor_display TEXT NOT NULL, + actor_source_kind TEXT, + actor_source_key TEXT, + action TEXT NOT NULL, + target_kind TEXT NOT NULL, + target_id TEXT, + outcome TEXT NOT NULL, + request_id TEXT, + summary TEXT +); +CREATE TABLE auth_challenges ( + challenge_id TEXT PRIMARY KEY, + ceremony TEXT NOT NULL CHECK (ceremony IN ('passkey_registration', 'passkey_login')), + challenge TEXT NOT NULL UNIQUE, + user_id TEXT REFERENCES users(user_id) ON DELETE CASCADE, + rp_id TEXT NOT NULL, + origin TEXT NOT NULL, + state_json TEXT, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + consumed_at TEXT +); +CREATE TABLE browser_sessions ( + session_id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT +); +CREATE TABLE device_login_flows ( + device_code TEXT PRIMARY KEY, + user_code TEXT NOT NULL UNIQUE, + verification_uri TEXT NOT NULL, + client_name TEXT, + user_id TEXT REFERENCES users(user_id) ON DELETE SET NULL, + api_token_id TEXT REFERENCES api_tokens(token_id) ON DELETE SET NULL, + issued_access_token TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + approved_at TEXT, + consumed_at TEXT +); +CREATE TABLE flow_source_contents ( + workspace_id TEXT NOT NULL, + flow_id TEXT NOT NULL, + content TEXT NOT NULL, + content_digest TEXT NOT NULL, + definition_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, flow_id, content_digest), + FOREIGN KEY (workspace_id, flow_id) + REFERENCES flow_sources(workspace_id, flow_id) ON DELETE CASCADE +); +CREATE TABLE flow_sources ( + workspace_id TEXT NOT NULL, + flow_id TEXT NOT NULL, + source_kind TEXT NOT NULL CHECK (source_kind IN ('builtin', 'workspace')), + name TEXT NOT NULL, + path TEXT NOT NULL, + content TEXT NOT NULL, + content_digest TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, flow_id), + UNIQUE (workspace_id, source_kind, name), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE memory_staging_records ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + candidate_id TEXT NOT NULL, + raw_json TEXT NOT NULL, + source_path TEXT, + imported_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, candidate_id) +); +CREATE TABLE memory_staging_resolutions ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + candidate_id TEXT NOT NULL, + action TEXT NOT NULL, + reason TEXT NOT NULL, + affected_refs_json TEXT NOT NULL, + staging_raw_json TEXT NOT NULL, + source_path TEXT, + imported_at TEXT NOT NULL, + resolved_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, candidate_id, resolved_at) +); +CREATE TABLE "objective_events" ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + event_id TEXT NOT NULL, + kind TEXT NOT NULL, + body_md TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, event_id), + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE +); +CREATE TABLE "objective_resources" ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + resource_path TEXT NOT NULL, + body TEXT NOT NULL, + media_type TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id, resource_path), + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE +); +CREATE TABLE "objective_ticket_links" ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + kind TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id, ticket_id, kind), + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE "objectives" ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL, + body_md TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE passkey_credentials ( + credential_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + public_key_cose TEXT NOT NULL, + transports_json TEXT, + sign_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + last_used_at TEXT +); +CREATE TABLE "repositories" ( + workspace_id TEXT NOT NULL, + repository_id TEXT NOT NULL PRIMARY KEY, + repository_key TEXT NOT NULL + CHECK(length(repository_key) BETWEEN 1 AND 64) + CHECK(repository_key NOT GLOB '*[^a-z0-9-]*') + CHECK(substr(repository_key, 1, 1) <> '-') + CHECK(substr(repository_key, -1, 1) <> '-'), + kind TEXT NOT NULL, + provider TEXT, + uri TEXT NOT NULL, + default_ref TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + source_kind TEXT NOT NULL, + source_uri TEXT NOT NULL, + source_fingerprint TEXT NOT NULL, + observed_status TEXT NOT NULL DEFAULT 'unverified', + observed_at TEXT, + UNIQUE(workspace_id, repository_key), + UNIQUE(workspace_id, repository_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE repository_secret_audit_events ( + workspace_id TEXT NOT NULL, + event_id TEXT NOT NULL, + kind TEXT NOT NULL, + resource_id TEXT NOT NULL, + operation_id TEXT NOT NULL CHECK (length(operation_id) > 0), + actor_account_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, event_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE repository_secret_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('credential', 'host_trust')), + resource_id TEXT NOT NULL, + result_operation_id TEXT NOT NULL CHECK (length(result_operation_id) > 0), + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_credential_keys ( + workspace_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + operation_id TEXT NOT NULL CHECK (length(operation_id) > 0), + public_key_algorithm TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, credential_id, operation_id), + UNIQUE (workspace_id, credential_id, operation_id, public_key_fingerprint), + FOREIGN KEY (workspace_id, credential_id) + REFERENCES repository_ssh_credentials(workspace_id, credential_id) + ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_credentials ( + workspace_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + name TEXT NOT NULL, + public_key_algorithm TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + current_operation_id TEXT NOT NULL CHECK (length(current_operation_id) > 0), + status TEXT NOT NULL CHECK (status IN ('active', 'revoked')), + created_at TEXT NOT NULL, + rotated_at TEXT, + PRIMARY KEY (workspace_id, credential_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_host_trust_keys ( + workspace_id TEXT NOT NULL, + host_trust_id TEXT NOT NULL, + operation_id TEXT NOT NULL CHECK (length(operation_id) > 0), + hostname TEXT NOT NULL, + port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), + key_algorithm TEXT NOT NULL, + host_key TEXT NOT NULL, + fingerprint TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, host_trust_id, operation_id), + FOREIGN KEY (workspace_id, host_trust_id) + REFERENCES repository_ssh_host_trusts(workspace_id, host_trust_id) + ON DELETE CASCADE + ); +CREATE TABLE repository_ssh_host_trusts ( + workspace_id TEXT NOT NULL, + host_trust_id TEXT NOT NULL, + hostname TEXT NOT NULL, + port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535), + key_algorithm TEXT NOT NULL, + host_key TEXT NOT NULL, + fingerprint TEXT NOT NULL, + current_operation_id TEXT NOT NULL CHECK (length(current_operation_id) > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, host_trust_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE server_secret_objects ( + workspace_id TEXT NOT NULL, + secret_id TEXT NOT NULL, + operation_id TEXT NOT NULL CHECK (length(operation_id) > 0), + purpose TEXT NOT NULL CHECK (purpose IN ('private_key', 'passphrase')), + encryption_algorithm TEXT NOT NULL CHECK (encryption_algorithm = 'aes-256-gcm-v1'), + nonce BLOB NOT NULL CHECK (length(nonce) = 12), + ciphertext BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, secret_id, operation_id, purpose), + FOREIGN KEY (workspace_id, secret_id, operation_id) + REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id) + ON DELETE CASCADE + ); +-- Ticket-owned assignment schema is composed by store.rs. +CREATE TABLE ticket_assignment_ticket_tombstones ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + deleted_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE ticket_assignment_worker_tombstones ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + deleted_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); + + + +CREATE TABLE workspace_runtime_bindings ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + display_name TEXT NOT NULL, + base_url TEXT NOT NULL, + public_key TEXT NOT NULL, + public_key_fingerprint TEXT NOT NULL, + binding_id TEXT NOT NULL CHECK (length(binding_id) > 0), + authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + revoked_at TEXT, + PRIMARY KEY (workspace_id, runtime_id), + UNIQUE (workspace_id, public_key_fingerprint), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT +); +-- Historical authentication metadata only, never current admission authority. +CREATE TABLE workspace_runtime_binding_verification_archive ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + binding_id TEXT NOT NULL, + state TEXT NOT NULL, + workspace_key_id TEXT, + workspace_public_key_fingerprint TEXT, + workspace_trust_id TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, binding_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE workspace_runtime_verification_archive ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + binding_id TEXT NOT NULL, + workspace_key_id TEXT NOT NULL, + workspace_public_key_fingerprint TEXT NOT NULL, + workspace_trust_id TEXT NOT NULL, + runtime_public_key_fingerprint TEXT NOT NULL, + challenge_id TEXT NOT NULL, + state TEXT NOT NULL, + last_outcome TEXT NOT NULL, + verified_at TEXT, + checked_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE workspace_runtime_binding_audit ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')), + old_fingerprint TEXT, + new_fingerprint TEXT, + binding_id TEXT NOT NULL CHECK (length(binding_id) > 0), + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, binding_id), + FOREIGN KEY(workspace_id, runtime_id) + REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT, + FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT +); +CREATE INDEX idx_workspace_runtime_binding_audit_recent + ON workspace_runtime_binding_audit(workspace_id, runtime_id, at DESC, binding_id DESC); +CREATE TABLE typed_ticket_artifacts ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, relative_path), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_event_attributes ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index, key), + FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_event_references ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal), + FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_events ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + event_index INTEGER NOT NULL, + kind TEXT NOT NULL, + author TEXT, + at TEXT, + status TEXT, + from_state TEXT, + to_state TEXT, + reason TEXT, + state_field TEXT, + heading TEXT, + body TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_labels ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_orchestration_plans ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + record_id TEXT NOT NULL, + kind TEXT NOT NULL, + related_ticket TEXT, + note TEXT, + accepted_summary TEXT, + accepted_branch TEXT, + accepted_worktree TEXT, + accepted_role_plan TEXT, + author TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, record_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_raw_frontmatter ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, key), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE "typed_ticket_relations" ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + kind TEXT NOT NULL, + target TEXT NOT NULL, + note TEXT, + author TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, kind, target), + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, target) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_risk_flags ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE "typed_tickets" ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + slug TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + kind TEXT NOT NULL, + priority TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT, + updated_at TEXT, + assignee TEXT, + readiness TEXT, + workflow_state TEXT NOT NULL, + workflow_state_explicit INTEGER NOT NULL, + queued_by TEXT, + queued_at TEXT, + resolution TEXT, + repository_id TEXT, + ref_selector TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT +); +CREATE TABLE users ( + user_id TEXT PRIMARY KEY, + account_id TEXT NOT NULL UNIQUE REFERENCES accounts(account_id) ON DELETE CASCADE, + handle TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE workdir_create_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + repository_id TEXT NOT NULL, + selector TEXT, + requested_runtime_id TEXT, + resolved_runtime_id TEXT NOT NULL, + config_projection_digest TEXT NOT NULL, + working_directory_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')), + failure TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_fingerprint TEXT, credential_id TEXT, credential_fingerprint TEXT, host_trust_id TEXT, host_trust_fingerprint TEXT, repository_access_mode TEXT, + PRIMARY KEY (workspace_id, operation_id), + UNIQUE (workspace_id, working_directory_id) + ); +CREATE TABLE workdir_create_credential_candidates ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2), + role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')), + credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128), + credential_fingerprint TEXT NOT NULL CHECK (length(credential_fingerprint) > 0), + PRIMARY KEY (workspace_id, operation_id, ordinal), + UNIQUE (workspace_id, operation_id, ordinal, credential_id, credential_fingerprint), + UNIQUE (workspace_id, operation_id, role), + UNIQUE (workspace_id, operation_id, credential_id), + FOREIGN KEY (workspace_id, operation_id) + REFERENCES workdir_create_operations(workspace_id, operation_id) + ON DELETE CASCADE +); +CREATE INDEX idx_workdir_create_credential_candidates_fingerprint + ON workdir_create_credential_candidates( + workspace_id, credential_id, credential_fingerprint + ); +CREATE TABLE workdir_create_credential_retentions ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + credential_id TEXT NOT NULL, + credential_fingerprint TEXT NOT NULL, + credential_operation_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, operation_id, ordinal), + FOREIGN KEY (workspace_id, operation_id, ordinal, credential_id, credential_fingerprint) + REFERENCES workdir_create_credential_candidates( + workspace_id, operation_id, ordinal, credential_id, credential_fingerprint + ) + ON DELETE CASCADE, + FOREIGN KEY (workspace_id, credential_id, credential_operation_id, credential_fingerprint) + REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id, public_key_fingerprint) + ON DELETE RESTRICT +); +CREATE TABLE "workdir_registry" ( + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + display_name TEXT, + source_kind TEXT NOT NULL CHECK (source_kind IN ('repository', 'external_grant', 'workspace_config')), + runtime_id TEXT, + repository_id TEXT, + external_grant_id TEXT, + creation_selector TEXT, + creation_ref TEXT, + materialization_status TEXT NOT NULL CHECK (materialization_status IN ('pending', 'present', 'not_found', 'corrupted', 'unknown', 'failed')), + cleanliness TEXT NOT NULL CHECK (cleanliness IN ('clean', 'dirty', 'unknown')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + current_selector TEXT, + current_ref TEXT, creation_tree TEXT, current_tree TEXT, observed_at_epoch_seconds INTEGER, + workspace_config_grant_id TEXT, + PRIMARY KEY (workspace_id, workdir_id), + CHECK ( + (source_kind = 'repository' AND runtime_id IS NOT NULL AND repository_id IS NOT NULL AND external_grant_id IS NULL AND workspace_config_grant_id IS NULL) + OR (source_kind = 'external_grant' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NOT NULL AND workspace_config_grant_id IS NULL) + OR (source_kind = 'workspace_config' AND runtime_id IS NULL AND repository_id IS NULL AND external_grant_id IS NULL AND workspace_config_grant_id IS NOT NULL) + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES "repositories"(workspace_id, repository_id), + FOREIGN KEY (workspace_id, external_grant_id) + REFERENCES external_workdir_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED, + FOREIGN KEY (workspace_id, workspace_config_grant_id) + REFERENCES workspace_config_grants(workspace_id, grant_id) DEFERRABLE INITIALLY DEFERRED +); +CREATE TABLE external_workdir_grants ( + grant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + provider_instance_id TEXT NOT NULL, + display_name TEXT NOT NULL, + permissions TEXT NOT NULL CHECK (permissions IN ( + 'read_only', 'read_write', 'command_only', + 'read_command', 'read_write_command' + )), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + generation INTEGER NOT NULL CHECK (generation > 0), + status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')), + updated_at TEXT NOT NULL, + cleanup_state TEXT NOT NULL DEFAULT 'not_required' CHECK (cleanup_state IN ('not_required', 'pending', 'retry_required', 'completed')), + cleanup_error TEXT, + cleanup_updated_at TEXT, + PRIMARY KEY (workspace_id, grant_id), + UNIQUE (workspace_id, workdir_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED +); +CREATE TABLE workdir_removal_operations ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + workdir_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + repository_id TEXT NOT NULL, + materialization_fingerprint TEXT NOT NULL, + source_actor TEXT NOT NULL, + reason TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'failed', 'completed')), + attempt_id TEXT, + retryable INTEGER NOT NULL CHECK (retryable IN (0, 1)), + disposition TEXT CHECK (disposition IN ('removed', 'retained', 'attention_required')), + failure_category TEXT, + attempt_owner_pid INTEGER CHECK (attempt_owner_pid > 0), + attempt_owner_start_marker INTEGER CHECK (attempt_owner_start_marker >= 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE "worker_control_grants" ( + grant_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + controller_runtime_id TEXT NOT NULL, + controller_worker_id TEXT NOT NULL, + subject_runtime_id TEXT NOT NULL, + subject_worker_id TEXT NOT NULL, + relation TEXT NOT NULL, + origin TEXT NOT NULL, + permissions_json TEXT NOT NULL, + operation_id TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked_at TEXT, + PRIMARY KEY (workspace_id, grant_id), + UNIQUE (workspace_id, controller_runtime_id, controller_worker_id, operation_id), + FOREIGN KEY (workspace_id, controller_runtime_id, controller_worker_id) + REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, subject_runtime_id, subject_worker_id) + REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE + ); +CREATE TABLE worker_create_reservations ( + workspace_id TEXT NOT NULL, + allocation_key TEXT NOT NULL, + worker_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + create_fingerprint TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('reserved', 'created', 'removed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, request_fingerprint TEXT, memory_language TEXT, + singleton_key TEXT, + singleton_generation INTEGER CHECK (singleton_generation IS NULL OR singleton_generation > 0), + PRIMARY KEY (workspace_id, allocation_key), + UNIQUE (workspace_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + CHECK ((singleton_key IS NULL) = (singleton_generation IS NULL)) + ); +CREATE TRIGGER worker_create_reservation_singleton_lease_insert +BEFORE INSERT ON worker_create_reservations +WHEN (NEW.singleton_key IS NULL AND NEW.singleton_generation IS NOT NULL) + OR (NEW.singleton_key IS NOT NULL AND NEW.singleton_generation IS NULL) +BEGIN + SELECT RAISE(ABORT, 'worker_singleton_lease_incomplete'); +END; +CREATE TRIGGER worker_create_reservation_singleton_lease_update +BEFORE UPDATE OF singleton_key, singleton_generation ON worker_create_reservations +WHEN (NEW.singleton_key IS NULL AND NEW.singleton_generation IS NOT NULL) + OR (NEW.singleton_key IS NOT NULL AND NEW.singleton_generation IS NULL) +BEGIN + SELECT RAISE(ABORT, 'worker_singleton_lease_incomplete'); +END; +CREATE TABLE worker_singleton_owners ( + workspace_id TEXT NOT NULL, + singleton_key TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + generation INTEGER NOT NULL CHECK (generation > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, singleton_key), + UNIQUE (workspace_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE worker_diagnostics_archives ( + operation_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, + committed_at TEXT NOT NULL, expires_at TEXT NOT NULL, + FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE worker_mutation_source_proof_jtis ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + jti TEXT NOT NULL, + expires_at INTEGER NOT NULL, + consumed_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, jti) + ); +CREATE TABLE worker_orphan_diagnostics ( + diagnostic_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + category TEXT NOT NULL, detail TEXT NOT NULL, observed_at TEXT NOT NULL, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE "worker_registry" ( + workspace_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + display_name TEXT NOT NULL, + profile TEXT, + retention_state TEXT NOT NULL CHECK (retention_state IN ('normal', 'pinned')), + transcript_ref TEXT, + session_ref TEXT, + summary_ref TEXT, + diagnostics_ref TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE backend_jobs ( + workspace_id TEXT NOT NULL, + job_id TEXT NOT NULL, + purpose TEXT NOT NULL, + input_digest TEXT NOT NULL, + input_ref TEXT NOT NULL, + resource_key TEXT, + request_json TEXT NOT NULL, + intent_fingerprint TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'completed', 'failed', 'unknown')), + current_attempt INTEGER NOT NULL CHECK (current_attempt > 0 AND current_attempt <= 3), + result_json TEXT, + result_digest TEXT, + failure_category TEXT, + failure_detail TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (workspace_id, job_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + CHECK ((result_json IS NULL) = (result_digest IS NULL)), + CHECK ((state = 'completed') = (result_json IS NOT NULL)) + ); +CREATE INDEX backend_jobs_active + ON backend_jobs(workspace_id, state, updated_at); +CREATE INDEX backend_jobs_resource + ON backend_jobs(workspace_id, resource_key, created_at); +CREATE TABLE backend_job_attempts ( + workspace_id TEXT NOT NULL, + job_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + attempt INTEGER NOT NULL CHECK (attempt > 0 AND attempt <= 3), + input_digest TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatching', 'dispatched', 'completed', 'failed', 'unknown')), + runtime_id TEXT, + worker_id TEXT, + runtime_run_id TEXT, + dispatched_at TEXT, + deadline_at TEXT NOT NULL, + result_json TEXT, + result_digest TEXT, + failure_category TEXT, + failure_detail TEXT, + worker_cleanup_state TEXT CHECK ( + worker_cleanup_state IN ('pending', 'executing', 'completed', 'failed') + ), + worker_cleanup_failure_category TEXT, + worker_cleanup_failure_detail TEXT, + worker_cleanup_updated_at TEXT, + worker_cleanup_completed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + PRIMARY KEY (workspace_id, job_id, attempt_id), + UNIQUE (workspace_id, job_id, attempt), + UNIQUE (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, job_id) + REFERENCES backend_jobs(workspace_id, job_id) ON DELETE CASCADE, + CHECK ((runtime_id IS NULL) = (worker_id IS NULL)), + CHECK ((result_json IS NULL) = (result_digest IS NULL)), + CHECK ((state = 'completed') = (result_json IS NOT NULL)) + ); +CREATE INDEX backend_job_attempts_recovery + ON backend_job_attempts(workspace_id, state, deadline_at); +CREATE INDEX backend_job_attempts_worker_cleanup + ON backend_job_attempts(workspace_id, worker_cleanup_state, updated_at); +CREATE TABLE backend_job_deliveries ( + workspace_id TEXT NOT NULL, + delivery_id TEXT NOT NULL, + job_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + target_runtime_id TEXT NOT NULL, + target_worker_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'sending', 'completed', 'failed', 'unknown')), + failure_category TEXT, + failure_detail TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + delivered_at TEXT, + PRIMARY KEY (workspace_id, delivery_id), + FOREIGN KEY (workspace_id, job_id, attempt_id) + REFERENCES backend_job_attempts(workspace_id, job_id, attempt_id) ON DELETE CASCADE + ); +CREATE INDEX backend_job_deliveries_pending + ON backend_job_deliveries(workspace_id, state, updated_at); +CREATE TABLE worker_registry_observations ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')), + worker_json TEXT, + observed_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, runtime_id, worker_id) + REFERENCES worker_registry(workspace_id, runtime_id, worker_id) ON DELETE CASCADE +); +CREATE TABLE worker_registry_projection_removals ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id) +); +CREATE TABLE worker_registry_projection_diagnostics ( + diagnostic_id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + reason TEXT NOT NULL, + observed_at TEXT NOT NULL +); +CREATE TABLE worker_removal_operations ( + operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL, + workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + worker_updated_at TEXT NOT NULL, + policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, + session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL, + archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER, + diagnostics_disposition TEXT NOT NULL, + diagnostics_retention_seconds INTEGER, archive_id TEXT UNIQUE, blockers_json TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('planned','blocked','executing','failed','stale','succeeded')), + reason TEXT NOT NULL, failure_category TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE worker_retention_audit_events ( + event_id TEXT PRIMARY KEY, operation_id TEXT NOT NULL, workspace_id TEXT NOT NULL, + event_kind TEXT NOT NULL, detail TEXT NOT NULL, created_at TEXT NOT NULL, + FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE worker_session_archives ( + archive_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + session_id TEXT NOT NULL, checksum_sha256 TEXT NOT NULL, content_bytes INTEGER NOT NULL, + policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE, + committed_at TEXT NOT NULL, expires_at TEXT, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id)); +CREATE TABLE worker_session_archive_observe_grants ( + workspace_id TEXT NOT NULL, archive_id TEXT NOT NULL, + controller_runtime_id TEXT NOT NULL, controller_worker_id TEXT NOT NULL, + subject_runtime_id TEXT NOT NULL, subject_worker_id TEXT NOT NULL, + source_grant_id TEXT NOT NULL, granted_at TEXT NOT NULL, revoked_at TEXT, + PRIMARY KEY(workspace_id,archive_id,controller_runtime_id,controller_worker_id,source_grant_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE); +CREATE INDEX worker_session_archive_observe_grants_controller + ON worker_session_archive_observe_grants( + workspace_id,controller_runtime_id,controller_worker_id,archive_id); +CREATE TABLE worker_tombstones ( + workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, + display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL, + archive_id TEXT, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE, + PRIMARY KEY(workspace_id,runtime_id,worker_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id), + FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id)); +CREATE TABLE "worker_workdir_attachment_reservations" ( + workspace_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + reservation_id TEXT NOT NULL, + reserved_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, workdir_id), + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES "workdir_registry"(workspace_id, workdir_id) ON DELETE CASCADE +); +CREATE TABLE "worker_workdir_links" ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + alias TEXT NOT NULL, + linked_at TEXT NOT NULL, + unlinked_at TEXT, + PRIMARY KEY (workspace_id, runtime_id, worker_id, workdir_id, alias), + FOREIGN KEY (workspace_id, runtime_id, worker_id) + REFERENCES "worker_registry"(workspace_id, runtime_id, worker_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, workdir_id) + REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_config_entries ( + workspace_id TEXT NOT NULL, + path TEXT NOT NULL, + content_type TEXT NOT NULL, + content TEXT NOT NULL, + content_digest TEXT NOT NULL, + PRIMARY KEY (workspace_id, path), + FOREIGN KEY (workspace_id) REFERENCES workspace_config_trees(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_config_tree_history ( + workspace_id TEXT NOT NULL, + content_digest TEXT NOT NULL, + toolchain_fingerprint TEXT NOT NULL, + projection_digest TEXT NOT NULL, + manifest_json TEXT NOT NULL, + created_at TEXT NOT NULL, schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}', + PRIMARY KEY (workspace_id, content_digest, toolchain_fingerprint, projection_digest), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_config_trees ( + workspace_id TEXT PRIMARY KEY, + content_digest TEXT NOT NULL, + schema_version INTEGER NOT NULL, + entrypoints_json TEXT NOT NULL, + decodal_version TEXT NOT NULL, + import_policy_version INTEGER NOT NULL, + toolchain_fingerprint TEXT NOT NULL, + projection_digest TEXT NOT NULL, + updated_at TEXT NOT NULL, schema_bundle_json TEXT NOT NULL DEFAULT '{"contributions":[],"source":"{}","fingerprint":""}', + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_create_operations ( + operation_key TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + workspace_id TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_signing_identities ( + workspace_id TEXT PRIMARY KEY, + key_id TEXT NOT NULL UNIQUE, + algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'), + public_key TEXT, + public_key_fingerprint TEXT, + private_material_ref TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')), + created_at TEXT NOT NULL, + provisioned_at TEXT, + updated_at TEXT NOT NULL, + CHECK ( + (state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL) + OR + (state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL) + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE workspace_signing_identity_provisioning_operations ( + operation_key TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')), + workspace_id TEXT NOT NULL UNIQUE, + key_id TEXT NOT NULL UNIQUE, + private_material_ref TEXT NOT NULL UNIQUE, + actor_account_id TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'completed')), + created_at TEXT NOT NULL, + completed_at TEXT + ); +CREATE TABLE workspace_signing_identity_audit ( + event_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + key_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('provisioned')), + public_key_fingerprint TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE INDEX workspace_signing_identity_audit_workspace_idx + ON workspace_signing_identity_audit(workspace_id, created_at DESC); +CREATE TABLE workspace_memory_documents ( + workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + body_md TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE workspace_memory_settings ( + workspace_id TEXT PRIMARY KEY NOT NULL, + language TEXT NOT NULL CHECK(length(trim(language)) > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE "workspace_resource_key_counters" ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + next_sequence INTEGER NOT NULL CHECK (next_sequence > 0), + PRIMARY KEY (workspace_id, resource_kind), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE "workspace_resource_keys" ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + resource_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence > 0), + resource_key TEXT NOT NULL, + allocated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, resource_kind, resource_id), + UNIQUE (workspace_id, resource_kind, sequence), + UNIQUE (workspace_id, resource_key), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE TABLE workspace_worker_retention_policies ( + workspace_id TEXT PRIMARY KEY, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL, updated_at TEXT NOT NULL, + FOREIGN KEY(workspace_id,policy_id,policy_digest) REFERENCES workspace_worker_retention_policy_snapshots(workspace_id,policy_id,policy_digest), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE workspace_worker_retention_policy_snapshots ( + workspace_id TEXT NOT NULL, policy_id TEXT NOT NULL, policy_digest TEXT NOT NULL CHECK(length(policy_digest)>0), + session_disposition TEXT NOT NULL CHECK(session_disposition IN ('archive','purge')), + metadata_disposition TEXT NOT NULL CHECK(metadata_disposition IN ('tombstone','purge')), + archive_retention_kind TEXT NOT NULL CHECK(archive_retention_kind IN ('forever','for_seconds')), + archive_retention_seconds INTEGER, + diagnostics_disposition TEXT NOT NULL CHECK(diagnostics_disposition IN ('purge','retain')), + diagnostics_retention_seconds INTEGER, created_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,policy_id,policy_digest), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE); +CREATE TABLE "workspaces" ( + workspace_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + owner_account_id TEXT NOT NULL, + FOREIGN KEY (owner_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT + ); +CREATE INDEX idx_api_tokens_token_hash ON api_tokens(token_hash); +CREATE INDEX idx_artifacts_workspace_created + ON artifacts(workspace_id, created_at DESC); +CREATE INDEX idx_browser_sessions_token_hash ON browser_sessions(token_hash); +CREATE INDEX idx_device_login_user_code ON device_login_flows(user_code); +CREATE INDEX idx_objective_events_workspace_created + ON objective_events(workspace_id, created_at DESC); +CREATE INDEX idx_objective_resources_workspace_objective + ON objective_resources(workspace_id, objective_id); +CREATE INDEX idx_objective_ticket_links_workspace_objective + ON objective_ticket_links(workspace_id, objective_id); +CREATE INDEX idx_objective_ticket_links_workspace_ticket + ON objective_ticket_links(workspace_id, ticket_id); +CREATE INDEX idx_objectives_workspace_updated + ON objectives(workspace_id, updated_at DESC); +CREATE INDEX idx_repository_secret_audit_workspace_created + ON repository_secret_audit_events(workspace_id, created_at, event_id); +CREATE INDEX idx_repository_ssh_credentials_workspace_status + ON repository_ssh_credentials(workspace_id, status, credential_id); +CREATE INDEX idx_repository_ssh_host_trusts_workspace_host + ON repository_ssh_host_trusts(workspace_id, hostname, port); + + + + +CREATE INDEX idx_workspace_runtime_bindings_workspace + ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id); +CREATE INDEX idx_typed_ticket_relations_workspace_target + ON typed_ticket_relations(workspace_id, target, at DESC); +CREATE INDEX idx_typed_tickets_workspace_state_updated + ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id); +CREATE INDEX idx_typed_tickets_workspace_updated + ON typed_tickets(workspace_id, updated_at DESC, ticket_id); +CREATE INDEX idx_workdir_registry_workspace_updated + ON workdir_registry(workspace_id, updated_at DESC); +CREATE INDEX idx_external_workdir_grants_status_expiry + ON external_workdir_grants(workspace_id, status, expires_at); +CREATE UNIQUE INDEX idx_workdir_removal_operations_one_pending + ON workdir_removal_operations(workspace_id, workdir_id) + WHERE state = 'pending'; +CREATE INDEX idx_workdir_removal_operations_recovery + ON workdir_removal_operations(workspace_id, state, retryable, updated_at); +CREATE INDEX idx_workdir_removal_operations_workdir + ON workdir_removal_operations(workspace_id, workdir_id, created_at DESC); +CREATE INDEX idx_worker_mutation_source_proof_jtis_expiry + ON worker_mutation_source_proof_jtis(expires_at); +CREATE UNIQUE INDEX idx_worker_registry_workspace_runtime_worker + ON worker_registry(workspace_id, runtime_id, worker_id); +CREATE INDEX idx_workspace_config_entries_prefix + ON workspace_config_entries(workspace_id, path); +CREATE INDEX idx_workspace_resource_keys_reverse + ON workspace_resource_keys(workspace_id, resource_kind, resource_key); +CREATE INDEX repositories_workspace_provider_idx + ON repositories(workspace_id, provider); + + +CREATE INDEX typed_ticket_events_workspace_kind_ticket + ON typed_ticket_events(workspace_id, kind, ticket_id, event_index); +CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id + ON worker_workdir_attachment_reservations(workspace_id, reservation_id); +CREATE INDEX worker_control_grants_controller + ON worker_control_grants( + workspace_id, controller_runtime_id, controller_worker_id, revoked_at + ); +CREATE INDEX worker_control_grants_subject + ON worker_control_grants( + workspace_id, subject_runtime_id, subject_worker_id, revoked_at + ); +CREATE INDEX worker_create_reservations_worker + ON worker_create_reservations(workspace_id, worker_id); +CREATE INDEX worker_registry_runtime + ON worker_registry(workspace_id, runtime_id, worker_id); +CREATE INDEX worker_removal_operations_worker_idx ON worker_removal_operations(workspace_id,runtime_id,worker_id,created_at); +CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique + ON worker_workdir_links(workspace_id, workdir_id) + WHERE unlinked_at IS NULL; +CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique + ON worker_workdir_links(workspace_id, runtime_id, worker_id, alias) + WHERE unlinked_at IS NULL; +CREATE INDEX worker_workdir_links_workdir + ON worker_workdir_links(workspace_id, workdir_id); +CREATE TABLE runtime_removal_operations ( + operation_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + expected_binding_id TEXT NOT NULL CHECK(length(expected_binding_id)>0), + config_digest TEXT NOT NULL CHECK(length(config_digest)>0), + state TEXT NOT NULL CHECK (state IN ('pending', 'cleanup_pending', 'succeeded', 'failed')), + failure_category TEXT, + binding_removed INTEGER NOT NULL CHECK (binding_removed IN (0, 1)), + runtime_registration_removed INTEGER CHECK (runtime_registration_removed IS NULL OR runtime_registration_removed IN (0, 1)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX runtime_removal_operations_one_active_runtime +ON runtime_removal_operations(runtime_id) +WHERE state IN ('pending', 'cleanup_pending'); + +CREATE INDEX runtime_removal_operations_workspace_state +ON runtime_removal_operations(workspace_id, state, updated_at); + +CREATE TRIGGER runtime_binding_insert_blocked_by_removal +BEFORE INSERT ON workspace_runtime_bindings +FOR EACH ROW +WHEN EXISTS ( + SELECT 1 + FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN + SELECT RAISE(ABORT, 'runtime_removal_in_progress'); +END; + +CREATE TRIGGER runtime_binding_update_blocked_by_removal +BEFORE UPDATE ON workspace_runtime_bindings +FOR EACH ROW +WHEN EXISTS ( + SELECT 1 + FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN + SELECT RAISE(ABORT, 'runtime_removal_in_progress'); +END; + +CREATE TRIGGER worker_registry_insert_blocked_by_runtime_removal +BEFORE INSERT ON worker_registry FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER worker_registry_update_blocked_by_runtime_removal +BEFORE UPDATE ON worker_registry FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_registry_insert_blocked_by_runtime_removal +BEFORE INSERT ON workdir_registry FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_registry_update_blocked_by_runtime_removal +BEFORE UPDATE ON workdir_registry FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER worker_assignment_insert_blocked_by_runtime_removal +BEFORE INSERT ON ticket_current_worker_assignments FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER worker_assignment_update_blocked_by_runtime_removal +BEFORE UPDATE ON ticket_current_worker_assignments FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal +BEFORE INSERT ON worker_workdir_links FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal +BEFORE UPDATE ON worker_workdir_links FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER worker_create_insert_blocked_by_runtime_removal +BEFORE INSERT ON worker_create_reservations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER worker_create_update_blocked_by_runtime_removal +BEFORE UPDATE ON worker_create_reservations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal +BEFORE INSERT ON workdir_create_operations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.resolved_runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_create_update_blocked_by_runtime_removal +BEFORE UPDATE ON workdir_create_operations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.resolved_runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER worker_removal_insert_blocked_by_runtime_removal +BEFORE INSERT ON worker_removal_operations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER worker_removal_update_blocked_by_runtime_removal +BEFORE UPDATE ON worker_removal_operations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_removal_insert_blocked_by_runtime_removal +BEFORE INSERT ON workdir_removal_operations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TRIGGER workdir_removal_update_blocked_by_runtime_removal +BEFORE UPDATE ON workdir_removal_operations FOR EACH ROW +WHEN EXISTS ( + SELECT 1 FROM runtime_removal_operations operation + WHERE operation.runtime_id = NEW.runtime_id + AND operation.state IN ('pending', 'cleanup_pending') +) +BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END; + +CREATE TABLE workspace_deletion_operations ( + operation_id TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + workspace_id TEXT NOT NULL, + workspace_display_name TEXT NOT NULL, + workspace_updated_at TEXT NOT NULL, + owner_account_id TEXT NOT NULL, + actor_account_id TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('queued', 'running', 'blocked', 'failed', 'succeeded')), + resource_counts_json TEXT NOT NULL, + child_operation_ids_json TEXT NOT NULL, + blockers_json TEXT NOT NULL, + failure_category TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + FOREIGN KEY(owner_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT, + FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT +); +CREATE INDEX workspace_deletion_operations_workspace_recent + ON workspace_deletion_operations(workspace_id, created_at DESC); + +CREATE TRIGGER seed_worker_retention_policy_after_workspace_insert AFTER INSERT ON workspaces BEGIN + INSERT INTO workspace_worker_retention_policy_snapshots + (workspace_id,policy_id,policy_digest,session_disposition,metadata_disposition,archive_retention_kind,archive_retention_seconds,diagnostics_disposition,diagnostics_retention_seconds,created_at) + VALUES(NEW.workspace_id,'workspace-default-conservative','sha256:30d45d5d58a10dadf95c3bf831e589ea10c81dc76da9575bfd9fb13942b3d198','archive','tombstone','forever',NULL,'purge',NULL,NEW.created_at); + INSERT INTO workspace_worker_retention_policies(workspace_id,policy_id,policy_digest,updated_at) + VALUES(NEW.workspace_id,'workspace-default-conservative','sha256:30d45d5d58a10dadf95c3bf831e589ea10c81dc76da9575bfd9fb13942b3d198',NEW.created_at); + END; +CREATE TRIGGER ticket_assignment_operations_validate_insert + BEFORE INSERT ON ticket_assignment_operations + WHEN NOT EXISTS ( + SELECT 1 FROM typed_tickets AS ticket + WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id + ) + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment operation must reference a Ticket in the same Workspace'); + END; +CREATE TRIGGER ticket_assignment_ticket_parent_tombstone + BEFORE DELETE ON typed_tickets + WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.ticket_id = OLD.ticket_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_ticket_tombstones ( + workspace_id, ticket_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.ticket_id, CURRENT_TIMESTAMP); + END; +CREATE TRIGGER ticket_assignment_user_principal_insert + BEFORE INSERT ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'user' + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + ) THEN RAISE(ABORT, 'Ticket assignment user principal is not a valid Account user') END; + END; +CREATE TRIGGER ticket_assignment_user_principal_update + BEFORE UPDATE ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'user' + BEGIN + SELECT CASE WHEN NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + ) THEN RAISE(ABORT, 'Ticket assignment user principal is not a valid Account user') END; + END; +CREATE TRIGGER ticket_assignment_worker_parent_tombstone_delete + BEFORE DELETE ON worker_registry + WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.principal_kind = 'worker' + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); + END; +CREATE TRIGGER ticket_assignment_worker_parent_tombstone_move + BEFORE UPDATE OF runtime_id ON worker_registry + WHEN OLD.runtime_id != NEW.runtime_id + AND EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.principal_kind = 'worker' + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id + ) + BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); + END; +CREATE TRIGGER ticket_assignment_workspace_agent_insert + BEFORE INSERT ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'workspace_agent' + BEGIN + SELECT CASE WHEN NEW.principal_id != 'workspace-orchestrator' + THEN RAISE(ABORT, 'Ticket assignment Workspace agent is not registered') END; + END; +CREATE TRIGGER ticket_assignment_workspace_agent_update + BEFORE UPDATE ON ticket_current_worker_assignments + WHEN NEW.principal_kind = 'workspace_agent' + BEGIN + SELECT CASE WHEN NEW.principal_id != 'workspace-orchestrator' + THEN RAISE(ABORT, 'Ticket assignment Workspace agent is not registered') END; + END; +CREATE TRIGGER ticket_worker_assignment_events_validate_insert + BEFORE INSERT ON ticket_worker_assignment_events + WHEN (NEW.assignment_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = NEW.workspace_id + AND assignment.ticket_id = NEW.ticket_id + AND assignment.role = NEW.role + AND assignment.assignment_id = NEW.assignment_id + )) + OR (NEW.previous_assignment_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = NEW.workspace_id + AND assignment.ticket_id = NEW.ticket_id + AND assignment.role = NEW.role + AND assignment.assignment_id = NEW.previous_assignment_id + )) + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment event must reference the same Ticket role'); + END; +CREATE TRIGGER ticket_worker_assignments_validate_insert + BEFORE INSERT ON ticket_worker_assignments + WHEN NOT EXISTS ( + SELECT 1 FROM typed_tickets AS ticket + WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id + ) + OR (NEW.principal_kind = 'worker' AND NOT EXISTS ( + SELECT 1 FROM worker_registry AS worker + WHERE worker.workspace_id = NEW.workspace_id + AND worker.runtime_id = NEW.runtime_id + AND worker.worker_id = NEW.worker_id + )) + OR (NEW.principal_kind = 'user' AND NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + )) + OR (NEW.principal_kind = 'workspace_agent' AND NEW.principal_id != 'workspace-orchestrator') + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment principal is not valid in this Workspace'); + END; +CREATE TRIGGER ticket_worker_assignments_validate_update + BEFORE UPDATE OF workspace_id, ticket_id, principal_kind, principal_id, runtime_id, worker_id + ON ticket_worker_assignments + WHEN NOT EXISTS ( + SELECT 1 FROM typed_tickets AS ticket + WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id + ) + OR (NEW.principal_kind = 'worker' AND NOT EXISTS ( + SELECT 1 FROM worker_registry AS worker + WHERE worker.workspace_id = NEW.workspace_id + AND worker.runtime_id = NEW.runtime_id + AND worker.worker_id = NEW.worker_id + )) + OR (NEW.principal_kind = 'user' AND NOT EXISTS ( + SELECT 1 FROM accounts + WHERE account_id = NEW.principal_id AND kind = 'user' + )) + OR (NEW.principal_kind = 'workspace_agent' AND NEW.principal_id != 'workspace-orchestrator') + BEGIN + SELECT RAISE(ABORT, 'Ticket assignment principal is not valid in this Workspace'); + END; + +CREATE TABLE workspace_config_grants ( + workspace_id TEXT NOT NULL, + grant_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + workdir_id TEXT NOT NULL, + access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')), + revoked INTEGER NOT NULL CHECK(revoked IN (0, 1)), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY(workspace_id, grant_id), + UNIQUE(workspace_id, workdir_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX workspace_config_grants_active_worker + ON workspace_config_grants(workspace_id, runtime_id, worker_id) WHERE revoked = 0; + +CREATE TABLE workspace_drive_grants ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + grant_id INTEGER PRIMARY KEY AUTOINCREMENT, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')), + revoked INTEGER NOT NULL DEFAULT 0 CHECK(revoked IN (0, 1)), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked_by TEXT, + revoked_at TEXT, + CHECK((revoked=0 AND revoked_by IS NULL AND revoked_at IS NULL) + OR (revoked=1 AND revoked_by IS NOT NULL AND revoked_at IS NOT NULL)) +); +CREATE UNIQUE INDEX workspace_drive_grants_active_worker + ON workspace_drive_grants(workspace_id, runtime_id, worker_id) WHERE revoked=0; +-- Retain grant audit across Worker deletion, but never carry its authority into +-- a later lifetime reusing the same Runtime/Worker identity. +CREATE TRIGGER workspace_drive_grants_worker_deleted BEFORE DELETE ON worker_registry BEGIN + UPDATE workspace_drive_grants SET revoked=1, revoked_by='server:worker-removal', + revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE workspace_id=OLD.workspace_id AND runtime_id=OLD.runtime_id + AND worker_id=OLD.worker_id AND revoked=0; +END; +-- Frozen evidence only. Neither table is a key, lease, or mutation authority. +CREATE TABLE workdir_create_legacy_ssh_archives ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + operation_json TEXT NOT NULL CHECK (json_valid(operation_json)), + candidates_json TEXT NOT NULL CHECK (json_valid(candidates_json)), + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id, operation_id) + REFERENCES workdir_create_operations(workspace_id, operation_id) ON DELETE CASCADE +); +CREATE TRIGGER workdir_create_legacy_archive_cannot_authorize_update +BEFORE UPDATE ON workdir_create_operations FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a + WHERE a.workspace_id=OLD.workspace_id AND a.operation_id=OLD.operation_id) +AND (NEW.state <> 'succeeded' OR NEW.credential_id IS NOT NULL + OR NEW.credential_fingerprint IS NOT NULL OR NEW.host_trust_id IS NOT NULL + OR NEW.host_trust_fingerprint IS NOT NULL OR NEW.repository_access_mode IS NOT NULL) +BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END; +CREATE TRIGGER workdir_create_legacy_archive_blocks_candidates +BEFORE INSERT ON workdir_create_credential_candidates FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a + WHERE a.workspace_id=NEW.workspace_id AND a.operation_id=NEW.operation_id) +BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END; +CREATE TRIGGER workdir_create_legacy_archive_blocks_candidate_updates +BEFORE UPDATE ON workdir_create_credential_candidates FOR EACH ROW +WHEN EXISTS (SELECT 1 FROM workdir_create_legacy_ssh_archives a + WHERE a.workspace_id=NEW.workspace_id AND a.operation_id=NEW.operation_id) +BEGIN SELECT RAISE(ABORT, 'archived_workdir_create_cannot_authorize'); END; +CREATE TABLE repository_secret_legacy_receipts ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + resource_kind TEXT NOT NULL, + resource_id TEXT NOT NULL, + legacy_result_counter INTEGER NOT NULL, + created_at TEXT NOT NULL, + mutation_kind TEXT, + legacy_expected_counter INTEGER, + expected_operation_id TEXT, + expected_key_fingerprint TEXT, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id, operation_id) + REFERENCES repository_secret_operations(workspace_id, operation_id) ON DELETE CASCADE +); + +-- T715: responsibility is retained; closing irreversibly releases an assignment identity. +CREATE TABLE IF NOT EXISTS ticket_assignment_work_releases ( + workspace_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + released_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, assignment_id), + FOREIGN KEY (workspace_id, assignment_id) + REFERENCES ticket_worker_assignments(workspace_id, assignment_id) ON DELETE CASCADE +); +-- Ticket case assignment work constraints (schema 91). +DROP VIEW IF EXISTS ticket_active_worker_assignments; +CREATE VIEW ticket_active_worker_assignments AS + SELECT current.* FROM ticket_current_worker_assignments AS current + JOIN typed_tickets AS ticket + ON ticket.workspace_id = current.workspace_id AND ticket.ticket_id = current.ticket_id + WHERE ticket.workflow_state = 'open' + AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases AS released + WHERE released.workspace_id = current.workspace_id + AND released.assignment_id = current.assignment_id); +DROP TRIGGER IF EXISTS ticket_terminal_releases_work; +CREATE TRIGGER ticket_terminal_releases_work +AFTER UPDATE OF workflow_state ON typed_tickets +WHEN NEW.workflow_state = 'closed' +BEGIN + INSERT OR IGNORE INTO ticket_assignment_work_releases + SELECT workspace_id, assignment_id, COALESCE(NEW.updated_at, updated_at) + FROM ticket_current_worker_assignments + WHERE workspace_id = NEW.workspace_id AND ticket_id = NEW.ticket_id; +END; +DROP TRIGGER IF EXISTS ticket_terminal_assignment_releases_work_insert; +CREATE TRIGGER ticket_terminal_assignment_releases_work_insert +AFTER INSERT ON ticket_current_worker_assignments +WHEN EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id + AND ticket_id = NEW.ticket_id AND workflow_state = 'closed') +BEGIN + INSERT OR IGNORE INTO ticket_assignment_work_releases + VALUES (NEW.workspace_id, NEW.assignment_id, NEW.updated_at); +END; +DROP TRIGGER IF EXISTS ticket_terminal_assignment_releases_work_update; +CREATE TRIGGER ticket_terminal_assignment_releases_work_update +AFTER UPDATE ON ticket_current_worker_assignments +WHEN EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id + AND ticket_id = NEW.ticket_id AND workflow_state = 'closed') +BEGIN + INSERT OR IGNORE INTO ticket_assignment_work_releases + VALUES (NEW.workspace_id, NEW.assignment_id, NEW.updated_at); +END; +DROP TRIGGER IF EXISTS ticket_active_worker_role_insert; +CREATE TRIGGER ticket_active_worker_role_insert +BEFORE INSERT ON ticket_current_worker_assignments +WHEN NEW.principal_kind = 'worker' + AND EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id + AND ticket_id = NEW.ticket_id AND workflow_state = 'open') + AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases + WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id) + AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id = NEW.workspace_id AND role = NEW.role + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id + AND assignment_id != NEW.assignment_id) +BEGIN SELECT RAISE(ABORT, 'Worker already has unfinished Ticket work in this role'); END; +DROP TRIGGER IF EXISTS ticket_active_worker_role_update; +CREATE TRIGGER ticket_active_worker_role_update +BEFORE UPDATE ON ticket_current_worker_assignments +WHEN NEW.principal_kind = 'worker' + AND EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id + AND ticket_id = NEW.ticket_id AND workflow_state = 'open') + AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases + WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id) + AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id = NEW.workspace_id AND role = NEW.role + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id + AND assignment_id != OLD.assignment_id) +BEGIN SELECT RAISE(ABORT, 'Worker already has unfinished Ticket work in this role'); END; +-- Keep archived responsibilities valid after Worker removal, but never allow +-- newly installed identities to refer to a removed / removal-reserved Worker. +DROP TRIGGER IF EXISTS ticket_active_worker_principal_insert; +CREATE TRIGGER ticket_active_worker_principal_insert +BEFORE INSERT ON ticket_current_worker_assignments +WHEN NEW.principal_kind = 'worker' AND ( + NOT EXISTS (SELECT 1 FROM worker_registry WHERE workspace_id = NEW.workspace_id + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id) + OR EXISTS (SELECT 1 FROM worker_removal_operations WHERE workspace_id = NEW.workspace_id + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id + AND state IN ('executing', 'failed', 'succeeded'))) +BEGIN SELECT RAISE(ABORT, 'Worker is missing or removal is reserved'); END; +DROP TRIGGER IF EXISTS ticket_active_worker_principal_update; +CREATE TRIGGER ticket_active_worker_principal_update +BEFORE UPDATE ON ticket_current_worker_assignments +WHEN NEW.principal_kind = 'worker' AND ( + NOT EXISTS (SELECT 1 FROM worker_registry WHERE workspace_id = NEW.workspace_id + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id) + OR EXISTS (SELECT 1 FROM worker_removal_operations WHERE workspace_id = NEW.workspace_id + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id + AND state IN ('executing', 'failed', 'succeeded'))) +BEGIN SELECT RAISE(ABORT, 'Worker is missing or removal is reserved'); END; +DROP TRIGGER IF EXISTS ticket_unfinished_work_blocks_worker_delete; +CREATE TRIGGER ticket_unfinished_work_blocks_worker_delete +BEFORE DELETE ON worker_registry +WHEN EXISTS (SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id = OLD.workspace_id AND runtime_id = OLD.runtime_id + AND worker_id = OLD.worker_id) +BEGIN SELECT RAISE(ABORT, 'Worker has unfinished Ticket work'); END; +DROP TRIGGER IF EXISTS ticket_unfinished_work_blocks_worker_move; +CREATE TRIGGER ticket_unfinished_work_blocks_worker_move +BEFORE UPDATE OF runtime_id, worker_id, workspace_id ON worker_registry +WHEN (OLD.runtime_id != NEW.runtime_id OR OLD.worker_id != NEW.worker_id OR OLD.workspace_id != NEW.workspace_id) + AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id = OLD.workspace_id AND runtime_id = OLD.runtime_id + AND worker_id = OLD.worker_id) +BEGIN SELECT RAISE(ABORT, 'Worker has unfinished Ticket work'); END; diff --git a/crates/workspace-server/src/frozen_ticket_schema_v10.sql b/crates/workspace-server/src/frozen_ticket_schema_v10.sql new file mode 100644 index 000000000..d62200154 --- /dev/null +++ b/crates/workspace-server/src/frozen_ticket_schema_v10.sql @@ -0,0 +1,203 @@ +-- Canonical standalone Ticket schema. Workspace Server composes stricter cross-domain authority. +CREATE TABLE typed_ticket_artifacts ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, relative_path), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_event_attributes ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index, key), + FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_event_references ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal), + FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_events ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + event_index INTEGER NOT NULL, + kind TEXT NOT NULL, + author TEXT, + at TEXT, + status TEXT, + from_state TEXT, + to_state TEXT, + reason TEXT, + state_field TEXT, + heading TEXT, + body TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_labels ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_orchestration_plans ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + record_id TEXT NOT NULL, + kind TEXT NOT NULL, + related_ticket TEXT, + note TEXT, + accepted_summary TEXT, + accepted_branch TEXT, + accepted_worktree TEXT, + accepted_role_plan TEXT, + author TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, record_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_raw_frontmatter ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, key), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_relations ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, note TEXT, author TEXT NOT NULL, at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, kind, target), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_risk_flags ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_tickets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + slug TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + kind TEXT NOT NULL, + priority TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT, + updated_at TEXT, + assignee TEXT, + readiness TEXT, + workflow_state TEXT NOT NULL, + workflow_state_explicit INTEGER NOT NULL, + queued_by TEXT, + queued_at TEXT, + resolution TEXT, + PRIMARY KEY (workspace_id, ticket_id) +); +CREATE TABLE typed_ticket_targets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + repository_key TEXT NOT NULL, + ref_selector TEXT, + access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')), + PRIMARY KEY (workspace_id, ticket_id, ordinal), + UNIQUE (workspace_id, ticket_id, repository_key), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE "workspace_resource_key_counters" ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + next_sequence INTEGER NOT NULL CHECK (next_sequence > 0), + PRIMARY KEY (workspace_id, resource_kind) + ); +CREATE TABLE "workspace_resource_keys" ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + resource_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence > 0), + resource_key TEXT NOT NULL, + allocated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, resource_kind, resource_id), + UNIQUE (workspace_id, resource_kind, sequence), + UNIQUE (workspace_id, resource_key) + ); +CREATE INDEX idx_workspace_resource_keys_reverse + ON workspace_resource_keys(workspace_id, resource_kind, resource_key); +CREATE INDEX typed_ticket_events_workspace_kind_ticket + ON typed_ticket_events(workspace_id, kind, ticket_id, event_index); +CREATE INDEX typed_ticket_relations_workspace_source_kind + ON typed_ticket_relations(workspace_id, ticket_id, kind, target); +CREATE INDEX typed_ticket_relations_workspace_target_kind + ON typed_ticket_relations(workspace_id, target, kind, ticket_id); +CREATE INDEX typed_tickets_workspace_created + ON typed_tickets(workspace_id, created_at DESC, ticket_id); +CREATE INDEX typed_tickets_workspace_state_updated + ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id); +CREATE INDEX typed_tickets_workspace_title + ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id); +CREATE INDEX typed_tickets_workspace_updated + ON typed_tickets(workspace_id, updated_at DESC, ticket_id); + +-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority. +CREATE TABLE typed_ticket_case_metadata ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_case_history ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, + operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, operation_key), + UNIQUE (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_legacy_workflow_snapshot ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; +CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; +-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade. +CREATE TABLE workspace_tags ( + workspace_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + last_operation_key TEXT NOT NULL, + deleted INTEGER NOT NULL CHECK (deleted IN (0, 1)), + PRIMARY KEY (workspace_id, tag_id) +); +CREATE UNIQUE INDEX workspace_tags_active_name + ON workspace_tags(workspace_id, normalized_name) WHERE deleted = 0; +CREATE TABLE typed_ticket_tags ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, tag_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, tag_id) REFERENCES workspace_tags(workspace_id, tag_id) ON DELETE RESTRICT +); +CREATE INDEX typed_ticket_tags_workspace_tag + ON typed_ticket_tags(workspace_id, tag_id, ticket_id); +-- Immutable operation receipt and Workspace audit: exact input includes actor +-- attributes; before/result preserve stable identities and display-name snapshots. +-- No foreign keys to mutable rows: history survives deletion of current links. +CREATE TABLE workspace_tag_operations ( + workspace_id TEXT NOT NULL, + operation_key TEXT NOT NULL, + kind TEXT NOT NULL, + target_id TEXT NOT NULL, + input_json TEXT NOT NULL, + source_context_json TEXT NOT NULL, + before_json TEXT NOT NULL, + result_json TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, operation_key) +); diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 76578bde6..be5790c66 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -437,6 +437,10 @@ CREATE TABLE typed_ticket_events ( state_field TEXT, heading TEXT, body TEXT NOT NULL, + event_ref TEXT NOT NULL DEFAULT '', + edited_at TEXT, + edited_by TEXT, + archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1)), PRIMARY KEY (workspace_id, ticket_id, event_index), FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE ); @@ -493,7 +497,7 @@ CREATE TABLE "typed_tickets" ( status TEXT NOT NULL, kind TEXT NOT NULL, priority TEXT NOT NULL, - body TEXT NOT NULL, + created_by TEXT, created_at TEXT, updated_at TEXT, assignee TEXT, @@ -1113,6 +1117,7 @@ CREATE INDEX repositories_workspace_provider_idx ON repositories(workspace_id, provider); +CREATE UNIQUE INDEX typed_ticket_events_ref ON typed_ticket_events(workspace_id, ticket_id, event_ref); CREATE INDEX typed_ticket_events_workspace_kind_ticket ON typed_ticket_events(workspace_id, kind, ticket_id, event_index); CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id diff --git a/crates/workspace-server/src/retention.rs b/crates/workspace-server/src/retention.rs index 4aa310885..9baed7a8f 100644 --- a/crates/workspace-server/src/retention.rs +++ b/crates/workspace-server/src/retention.rs @@ -1135,10 +1135,11 @@ mod tests { [worker_id().to_string()], )?; c.execute( - "INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) \ - VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', '', 'open', 1)", + "INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, workflow_state, workflow_state_explicit) \ + VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', 'open', 1)", [], )?; + c.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('w','ticket',-1,'ticket:-1','comment','')", [])?; Ok(()) }) .unwrap(); @@ -1398,7 +1399,7 @@ mod tests { }) .unwrap(); s.with_conn(|conn| { - conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','open',1)", [])?; + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','open',1)", [])?; conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','2026-08-24T00:00:00Z')", [])?; conn.execute("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at) VALUES('w','allocation-old',?1,'r','fingerprint','created','created','created')", [worker_id().to_string()])?; conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index f3dcac742..145a224c9 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -10285,14 +10285,13 @@ impl server_api::ServerApi for ServerApiContractService { workspace_id: String, request: server_api::CreateTicketRecordRequest, ) -> std::result::Result { - let Json(response) = scoped_create_ticket_record( - State(self.workspace_api()?.clone()), - AxumPath(ScopedWorkspacePath { workspace_id }), - contract_request_headers(&context)?, - Json(request.0), - ) - .await - .map_err(ApiError::into_repository_api_error)?; + let result = execute_ticket_rest_operation_with_context( + self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context), + TicketBackendOperation::Create { input: request.0 }, + ).await.map_err(ApiError::into_repository_api_error)?; + let Json(response) = ticket_rest_result(result, |result| match result { + TicketBackendOperationResult::TicketRef(ticket) => Some(ticket), _ => None, + }).map_err(ApiError::into_repository_api_error)?; Ok(server_api::TicketRecordRef(response)) } @@ -10432,14 +10431,13 @@ impl server_api::ServerApi for ServerApiContractService { id: String, request: server_api::EditTicketRecordItemRequest, ) -> std::result::Result { - let Json(response) = scoped_edit_ticket_record_item( - State(self.workspace_api()?.clone()), - AxumPath((workspace_id, id)), - contract_request_headers(&context)?, - Json(request.0), - ) - .await - .map_err(ApiError::into_repository_api_error)?; + let result = execute_ticket_rest_operation_with_context( + self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context), + TicketBackendOperation::EditItem { id: TicketIdOrSlug::Query(id), edit: request.0 }, + ).await.map_err(ApiError::into_repository_api_error)?; + let Json(response) = ticket_rest_result(result, |result| match result { + TicketBackendOperationResult::Ticket(ticket) => Some(ticket), _ => None, + }).map_err(ApiError::into_repository_api_error)?; Ok(server_api::TicketRecord(response)) } @@ -10469,14 +10467,11 @@ impl server_api::ServerApi for ServerApiContractService { id: String, request: server_api::TicketThreadEventRequest, ) -> std::result::Result<(), server_api::RepositoryApiError> { - scoped_add_ticket_thread_event( - State(self.workspace_api()?.clone()), - AxumPath((workspace_id, id)), - contract_request_headers(&context)?, - Json(request.0), - ) - .await - .map_err(ApiError::into_repository_api_error)?; + let result = execute_ticket_rest_operation_with_context( + self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context), + TicketBackendOperation::AddEvent { id: TicketIdOrSlug::Query(id), event: request.0 }, + ).await.map_err(ApiError::into_repository_api_error)?; + ticket_rest_unit(result).map_err(ApiError::into_repository_api_error)?; Ok(()) } @@ -10936,12 +10931,14 @@ impl server_api::ServerApi for ServerApiContractService { async fn ticket_edit( &self, + context: server_api::ServerRequestContext, workspace_id: String, id: String, request: server_api::BrowserEditTicketRequest, ) -> std::result::Result { let Json(response) = scoped_edit_ticket_item( State(self.workspace_api()?.clone()), + &context, AxumPath(ScopedRecordPath { workspace_id, id }), Json(request), ) @@ -10950,6 +10947,22 @@ impl server_api::ServerApi for ServerApiContractService { Ok(response) } + async fn ticket_post_get( + &self, workspace_id: String, id: String, event_ref: String, + ) -> std::result::Result { + scoped_get_ticket_post(self.workspace_api()?.clone(), workspace_id, id, event_ref) + .await.map_err(ApiError::into_repository_api_error) + } + + async fn ticket_post_edit( + &self, context: server_api::ServerRequestContext, workspace_id: String, id: String, + event_ref: String, request: server_api::TicketPostEditRequest, + ) -> std::result::Result { + let Json(detail) = scoped_edit_ticket_post(self.workspace_api()?.clone(), context, + workspace_id, id, event_ref, request).await.map_err(ApiError::into_repository_api_error)?; + Ok(detail) + } + async fn ticket_show( &self, workspace_id: String, @@ -11297,12 +11310,14 @@ impl server_api::ServerApi for ServerApiContractService { async fn ticket_event_append( &self, + context: server_api::ServerRequestContext, workspace_id: String, id: String, request: server_api::BrowserAppendTicketEventRequest, ) -> std::result::Result { let Json(response) = scoped_append_ticket_event( State(self.workspace_api()?.clone()), + &context, AxumPath(ScopedRecordPath { workspace_id, id }), Json(request), ) @@ -15414,84 +15429,122 @@ fn browser_ticket_detail(api: &WorkspaceApi, ticket_id: &str) -> ApiResult, + context: &server_api::ServerRequestContext, AxumPath(path): AxumPath, Json(request): Json, ) -> ApiResult> { - api.host_executors - .host()? - .run(move || { - validate_workspace_scope(&api, &path.workspace_id)?; - let mut targets = request.target.map(|target| match target { - server_api::BrowserTicketTargetEdit::Set { targets } => TicketTargetsEdit::Set { - targets: targets - .into_iter() - .map(|target| ticket::TicketTarget { - repository_key: target.repository_key, - ref_selector: target.ref_selector, - access: target.access, - }) - .collect(), - }, - server_api::BrowserTicketTargetEdit::Clear => TicketTargetsEdit::Clear, - }); - if let Some(TicketTargetsEdit::Set { targets }) = targets.as_mut() { - validate_ticket_target_repository_keys(&api, &path.workspace_id, targets)?; - } - 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", - )); - } - }, - targets, - author: request.author, - }, - ) - .map_err(Error::from)?; - browser_ticket_detail(&api, &path.id) - }) - .await + let targets = request.target.map(|target| match target { + server_api::BrowserTicketTargetEdit::Set { targets } => TicketTargetsEdit::Set { + targets: targets.into_iter().map(|target| ticket::TicketTarget { + repository_key: target.repository_key, ref_selector: target.ref_selector, access: target.access, + }).collect(), + }, + server_api::BrowserTicketTargetEdit::Clear => TicketTargetsEdit::Clear, + }); + let 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")), + }; + execute_ticket_rest_operation_with_context(&api, &path.workspace_id, + contract_request_headers(context).map_err(|error| Error::Config(error.message))?, Some(context), + TicketBackendOperation::EditItem { id: TicketIdOrSlug::Query(path.id.clone()), edit: TicketItemEdit { + title: request.title, body: request.body.map(MarkdownText::new), body_replacement, + targets, author: request.author, + } }).await?; + api.host_executors.host()?.run(move || browser_ticket_detail(&api, &path.id)).await } async fn scoped_append_ticket_event( State(api): State, + context: &server_api::ServerRequestContext, AxumPath(path): AxumPath, Json(request): Json, ) -> ApiResult> { - api.host_executors - .host()? - .run(move || { - validate_workspace_scope(&api, &path.workspace_id)?; - let role = match request.role { - server_api::BrowserTicketThreadRole::Comment => TicketEventKind::Comment, - server_api::BrowserTicketThreadRole::Plan => TicketEventKind::Plan, - server_api::BrowserTicketThreadRole::Decision => TicketEventKind::Decision, - server_api::BrowserTicketThreadRole::ImplementationReport => { - TicketEventKind::ImplementationReport - } - }; - let mut event = NewTicketEvent::new(role, 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) - }) - .await + let role = match request.role { + server_api::BrowserTicketThreadRole::Comment => TicketEventKind::Comment, + server_api::BrowserTicketThreadRole::Plan => TicketEventKind::Plan, + server_api::BrowserTicketThreadRole::Decision => TicketEventKind::Decision, + server_api::BrowserTicketThreadRole::ImplementationReport => TicketEventKind::ImplementationReport, + }; + let mut event = NewTicketEvent::new(role, request.body); + event.author = request.author; + execute_ticket_rest_operation_with_context(&api, &path.workspace_id, + contract_request_headers(context).map_err(|error| Error::Config(error.message))?, Some(context), + TicketBackendOperation::AddEvent { id: TicketIdOrSlug::Query(path.id.clone()), event }).await?; + api.host_executors.host()?.run(move || browser_ticket_detail(&api, &path.id)).await +} + +const TICKET_EDITABLE_POST_BODY_LIMIT: usize = 1024 * 1024; + +fn authenticated_ticket_post_author( + context: &server_api::ServerRequestContext, + source: Option<&WorkerMutationSource>, +) -> Result { + if let Some(source) = source { + return Ok(format!("worker:{}/{}", source.runtime_id, source.worker_id)); + } + let actor = context.actor.as_ref().ok_or_else(|| Error::WorkspacePermissionDenied( + "Ticket posts require an authenticated actor".into()))?; + let identity = serde_json::to_string(&(&actor.account_id, &actor.user_id)) + .map_err(|error| Error::Config(format!("failed to encode post actor: {error}")))?; + Ok(format!("workspace-user:{identity}")) +} + +async fn scoped_get_ticket_post( + api: WorkspaceApi, workspace_id: String, id: String, event_ref: String, +) -> ApiResult { + api.host_executors.host()?.run(move || { + validate_workspace_scope(&api, &workspace_id)?; + let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?; + let ticket = browser_ticket_backend(&api)?.show(TicketIdOrSlug::Id(id)).map_err(Error::from)?; + let (sequence, post) = ticket.events.iter().enumerate().find(|(_, post)| post.event_ref == event_ref) + .ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref)))?; + if post.body.as_str().len() > TICKET_EDITABLE_POST_BODY_LIMIT { + return Err(settings_bad_request("ticket_post_too_large", "Ticket post exceeds the full-post read limit")); + } + let mut detail = crate::authority::ticket_event_detail(sequence, post); + detail.body = Some(post.body.as_str().to_string()); + detail.body_truncated = false; + Ok(detail) + }).await +} + +async fn scoped_edit_ticket_post( + api: WorkspaceApi, context: server_api::ServerRequestContext, + workspace_id: String, id: String, event_ref: String, request: server_api::TicketPostEditRequest, +) -> ApiResult> { + let headers = contract_request_headers(&context).map_err(|error| Error::Config(error.message))?; + let source = authenticated_ticket_worker_source(&headers, &context)?; + let author = authenticated_ticket_post_author(&context, source.as_ref())?; + if request.body.len() > TICKET_EDITABLE_POST_BODY_LIMIT { + return Err(settings_bad_request("ticket_post_too_large", "Ticket post exceeds the edit limit")); + } + api.host_executors.host()?.run(move || { + validate_workspace_scope(&api, &workspace_id)?; + let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?; + let backend = browser_ticket_backend(&api)?; + let observed = backend.show(TicketIdOrSlug::Id(id.clone())).map_err(Error::from)?; + let post = observed.events.iter().find(|post| post.event_ref == event_ref) + .ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref.clone())))?; + if crate::authority::ticket_post_body_digest(post.body.as_str()) != request.expected_body_digest { + return Err(Error::Ticket(ticket::TicketError::Conflict("stale Ticket post body digest".into())).into()); + } + let is_initial = post.is_initial; + // The digest is a transport observation; storage compares its exact body in the write transaction. + let (previous, current) = backend.edit_post_with_snapshots(TicketIdOrSlug::Id(id.clone()), ticket::TicketPostEdit { + event_ref, expected_body: post.body.clone(), body: MarkdownText::new(request.body), author, + }).map_err(Error::from)?; + notify_ticket_recipients(&api, &workspace_id, &id, previous.meta.workflow_state.as_str(), + current.meta.workflow_state.as_str(), source.clone()); + if is_initial && let Some(source) = source { + schedule_ticket_item_check(&api, ¤t, TicketItemCheckEdit::Edit { + title_changed: false, body_changed: true, previous_title: None, + previous_body: Some(previous.document.body.as_str().to_string()), body_replacement: None, + }, source); + } + browser_ticket_detail(&api, &id) + }).await } async fn scoped_mark_ticket_ready_from_browser( @@ -15802,7 +15855,22 @@ fn execute_ticket_rest_operation_on_host( ) .into()); } - let source = verified_source.or(hinted_source); + let source = if let Some(context) = context { + authenticated_ticket_worker_source(&headers, context)? + } else { + verified_source.or(hinted_source) + }; + if matches!(operation, TicketBackendOperation::Create { .. } | TicketBackendOperation::EditItem { .. } | TicketBackendOperation::AddEvent { .. }) + && let Some(context) = context + { + let author = authenticated_ticket_post_author(context, source.as_ref())?; + match &mut operation { + TicketBackendOperation::Create { input } => input.author = Some(author), + TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author), + TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author), + _ => unreachable!(), + } + } validate_ticket_repository_operation(api, &operation)?; let before = target.as_ref().and_then(|id| backend.show(id.clone()).ok()); if let Some(source) = source.as_ref() @@ -47377,6 +47445,7 @@ mod tests { .unwrap(); let Json(_) = scoped_edit_ticket_item( State(api.clone()), + &server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()), AxumPath(ScopedRecordPath { workspace_id: TEST_WORKSPACE_ID.to_string(), id: ticket_ref.id.clone(), @@ -53470,6 +53539,7 @@ mod tests { let Json(edited) = scoped_edit_ticket_item( State(api.clone()), + &server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()), AxumPath(path()), Json(server_api::BrowserEditTicketRequest { title: Some("Browser Ticket API edited".to_string()), @@ -53507,6 +53577,7 @@ mod tests { let Json(commented) = scoped_append_ticket_event( State(api.clone()), + &server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()), AxumPath(path()), Json(server_api::BrowserAppendTicketEventRequest { role: server_api::BrowserTicketThreadRole::Comment, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index ed8e673a1..3d939a6a9 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -73,7 +73,7 @@ pub use ticket::{ }; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 93; +const LATEST_SCHEMA_VERSION: i64 = 94; const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; @@ -346,6 +346,11 @@ const MIGRATIONS: &[Migration] = &[ name: "Durable assignment deliveries and source-bound startup fences", apply: migrate_assignment_delivery_v92_to_v93, }, + Migration { + version: 94, + name: "Canonical Ticket posts", + apply: migrate_ticket_posts_v93_to_v94, + }, ]; #[derive(Clone, Copy)] @@ -8602,9 +8607,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { tx.execute( "INSERT INTO typed_ticket_events ( workspace_id, ticket_id, event_index, kind, author, at, - from_state, to_state, reason, state_field, heading, body + from_state, to_state, reason, state_field, heading, body, event_ref ) VALUES (?1, ?2, ?3, 'assignment', ?4, ?5, NULL, NULL, - 'manual Worker assignment accepted', NULL, 'Worker assigned', '')", + 'manual Worker assignment accepted', NULL, 'Worker assigned', '', ?2 || ':' || ?3)", params![record.workspace_id, record.ticket_id, event_index, record.assigned_by, record.assigned_at], )?; @@ -13990,6 +13995,33 @@ fn migrate_assignment_delivery_v92_to_v93(conn: &Connection) -> Result<()> { Ok(()) } +fn migrate_ticket_posts_v93_to_v94(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 93 { + return Err(Error::Store( + "Ticket posts migration requires schema 93".into(), + )); + } + let tx = conn.unchecked_transaction()?; + ticket::migrate_sqlite_ticket_schema_in_transaction(&tx) + .map_err(|error| Error::Store(format!("Ticket posts migration failed: {error}")))?; + tx.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(94,'Canonical Ticket posts')", + [], + )?; + verify_workspace_resource_constraints(&tx)?; + let violations: i64 = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; + if violations != 0 { + return Err(Error::Store(format!( + "Ticket posts migration left {violations} foreign-key violations" + ))); + } + tx.commit()?; + Ok(()) +} + fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { create_workspace_schema_structure(conn)?; assignment_delivery::install(conn)?; @@ -14000,10 +14032,17 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { }) } -// Workspace 92 changes only the Ticket-owned assignment component. Retained -// Workspace-91 fixtures compose this host structure with the genuine Ticket-10 -// schema, not a current database with lowered migration markers. +// Fresh composition uses the posts contract. Retained fixtures pass frozen host +// and component DDL to the shared cross-domain constraint composition below. fn create_workspace_schema_structure(conn: &Connection) -> Result<()> { + create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true) +} + +fn create_workspace_schema_structure_from_sql( + conn: &Connection, + schema_sql: &str, + ticket_posts: bool, +) -> Result<()> { // The composed Workspace parent FK rebuild must not discard domain-owned // case guards. Retain their SQL from the shared fresh Ticket schema. let ticket_parent_triggers = conn @@ -14021,12 +14060,11 @@ fn create_workspace_schema_structure(conn: &Connection) -> Result<()> { CREATE UNIQUE INDEX worker_restore_intent_pending_domain ON worker_restore_intents(workspace_id,runtime_id,worker_id,domain_key) WHERE settled=0;")?; conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?; - conn.execute_batch(&include_str!("latest_schema.sql").replace( + conn.execute_batch(&schema_sql.replace( "-- Ticket-owned assignment schema is composed by store.rs.", ticket::TICKET_ASSIGNMENT_SCHEMA_SQL, ))?; - conn.execute_batch( - r#" + let composed_sql = r#" ALTER TABLE typed_tickets RENAME TO typed_tickets_legacy_v66; CREATE TABLE typed_tickets ( workspace_id TEXT NOT NULL, @@ -14088,8 +14126,13 @@ fn create_workspace_schema_structure(conn: &Connection) -> Result<()> { CHECK (capabilities IN ( 'all', 'read_only', 'read_write', 'command_only', 'read_command' )); - "#, - )?; + "#; + let composed_sql = if ticket_posts { + composed_sql.replace("body TEXT NOT NULL,", "created_by TEXT,") + } else { + composed_sql.to_string() + }; + conn.execute_batch(&composed_sql)?; install_ticket_case_assignment_work_schema(conn)?; for sql in ticket_parent_triggers { conn.execute_batch(&sql)?; @@ -16849,6 +16892,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx include!("schema_equivalence_tests.rs"); include!("ticket_tag_upgrade_tests.rs"); + include!("ticket_post_upgrade_tests.rs"); include!("migration_entrypoint_tests.rs"); include!("legacy_evidence_column_tests.rs"); @@ -16869,7 +16913,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx configure_sqlite(&conn).unwrap(); // Retained fixtures start from genuine pre-tracking Ticket DDL; current // component 11 would introduce assignment objects before frozen host 85. - conn.execute_batch(include_str!("../../ticket/src/latest_schema.sql")) + conn.execute_batch(include_str!("frozen_ticket_schema_v10.sql")) .unwrap(); conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','fixture');").unwrap(); merge_request::migrate(&conn).unwrap(); @@ -17181,6 +17225,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx }, WorkspaceSchemaMigrationStep {version:92, name:"Ticket-owned assignment tracking".into()}, WorkspaceSchemaMigrationStep {version:93, name:"Durable assignment deliveries and source-bound startup fences".into()}, + WorkspaceSchemaMigrationStep {version:94, name:"Canonical Ticket posts".into()}, ] ); @@ -17284,6 +17329,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (91, "Ticket open/closed cases and retained routing evidence".to_string()), (92, "Ticket-owned assignment tracking".to_string()), (93, "Durable assignment deliveries and source-bound startup fences".to_string()), +(94, "Canonical Ticket posts".to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -19696,6 +19742,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx fn stored_fixture_rows(conn: &Connection, table: &str) -> Vec> { // Compare preserved original assignment evidence, not newly added tracking columns. + if table == "typed_ticket_events" { + return fixture_query_rows( + conn, + "SELECT workspace_id,ticket_id,event_index,kind,author,at,status,from_state,to_state,reason,state_field,heading,body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY rowid", + ); + } let columns = if table == "ticket_worker_assignments" { "workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,runtime_id,worker_id,assigned_by,assigned_at" } else { @@ -19881,9 +19933,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .map(|step| step.version) .collect::>(), if version == 89 { - vec![90, 91, 92, 93] + vec![90, 91, 92, 93, 94] } else { - vec![91, 92, 93] + vec![91, 92, 93, 94] } ); drop(conn); @@ -19899,7 +19951,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx vec![(90, "Runtime binding connection configuration and historical verification archive".into()), (91, "Ticket open/closed cases and retained routing evidence".into()), (92, "Ticket-owned assignment tracking".into()), - (93, "Durable assignment deliveries and source-bound startup fences".into())]); + (93, "Durable assignment deliveries and source-bound startup fences".into()), + (94, "Canonical Ticket posts".into())]); for (table, rows) in unchanged.iter().zip(before.iter()) { assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}"); } @@ -20295,11 +20348,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx VALUES('guard-owner','user','guard-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('guard-workspace','guard-owner','Guard','active','1','1'); - INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) - VALUES('guard-workspace','guard-ticket','guard-ticket','Guard','open','task','P2','','open',1);")?; + INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) + VALUES('guard-workspace','guard-ticket','guard-ticket','Guard','open','task','P2','open',1); + INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('guard-workspace','guard-ticket',-1,'guard-ticket:-1','comment','');")?; assert!(conn.execute("UPDATE typed_tickets SET workflow_state='done' WHERE ticket_id='guard-ticket'", []).is_err()); - assert!(conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) - VALUES('guard-workspace','bad-ticket','bad-ticket','Bad','open','task','P2','','planning',1)", []).is_err()); + assert!(conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) + VALUES('guard-workspace','bad-ticket','bad-ticket','Bad','open','task','P2','planning',1)", []).is_err()); assert!(conn.execute("INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at) VALUES('guard-workspace','bad-runtime','Bad','https://invalid.invalid','key','fingerprint','binding','invalid-mode','1','1')", []).is_err()); Ok(()) @@ -20519,8 +20573,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx fn seed_manual_start_ticket(store: &SqliteWorkspaceStore, id: &str, state: &str) { store.with_conn(|conn| { - conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,updated_at) - VALUES('w',?1,?1,'Ticket','open','task','normal','',?2,1,'1')", params![id,state])?; + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit,updated_at) + VALUES('w',?1,?1,'Ticket','open','task','normal',?2,1,'1')", params![id,state])?; + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('w',?1,-1,?1 || ':-1','comment','')", [id])?; Ok(()) }).unwrap(); } @@ -20932,7 +20987,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ); reopened.with_conn(|conn| { conn.execute_batch("UPDATE typed_tickets SET title='Changed requirements' WHERE workspace_id='w' AND ticket_id='first'; - INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('w','first',1,'item_edit','Ticket requirements changed');")?; Ok(()) + UPDATE typed_ticket_events SET body='Ticket requirements changed',edited_at='2',edited_by='fixture-editor' WHERE workspace_id='w' AND ticket_id='first' AND event_index=-1;")?; Ok(()) }).unwrap(); assert!( reopened diff --git a/crates/workspace-server/src/ticket_post_upgrade_tests.rs b/crates/workspace-server/src/ticket_post_upgrade_tests.rs new file mode 100644 index 000000000..b48cbc7c6 --- /dev/null +++ b/crates/workspace-server/src/ticket_post_upgrade_tests.rs @@ -0,0 +1,369 @@ +// Host/domain integration through real SQLite and public migration entrypoints. +// Retained fixtures use pre-posts DDL; no current schema is relabelled as old. +fn ticket_posts_schema93_fixture(path: &Path) -> Connection { + let conn = Connection::open(path).unwrap(); + configure_sqlite(&conn).unwrap(); + conn.execute_batch(include_str!("frozen_ticket_schema_v10.sql")) + .unwrap(); + conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); + INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','original-component10-time'); + PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;").unwrap(); + create_workspace_schema_structure_from_sql(&conn, include_str!("frozen_schema_v93.sql"), false) + .unwrap(); + conn.execute_batch(include_str!( + "../../ticket/src/assignment_tracking_schema.sql" + )) + .unwrap(); + conn.execute_batch("INSERT INTO ticket_schema_migrations VALUES(11,'Ticket-owned assignment tracking','original-component11-time'); + INSERT INTO __yoi_schema_migrations(version,name) VALUES(93,'workspace schema baseline'); + PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;").unwrap(); + assignment_delivery::install(&conn).unwrap(); + seed_migration_workspace(&conn); + conn +} + +fn seed_ticket_posts_legacy_content(conn: &Connection, workspace: &str) { + for (id, body) in [ + ("authored", "Current requirements after edits"), + ("unknown", "Unknown creator body"), + ] { + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,created_at,updated_at,workflow_state,workflow_state_explicit) + VALUES(?1,?2,?2,'Retained Ticket','open','task','P2',?3,'2026-01-01T00:00:00Z','2026-02-01T00:00:00Z','open',1)", params![workspace,id,body]).unwrap(); + } + for (index, kind, author, body) in [ + (0, "create", Some("creator"), "Original creation audit"), + (3, "comment", Some("commenter"), "A preserved comment"), + (7, "item_edit", Some("editor"), "Original edit audit"), + (9, "comment", None, "Anonymous historical comment"), + ] { + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body) VALUES(?1,'authored',?2,?3,?4,'2026-01-02T00:00:00Z',?5)",params![workspace,index,kind,author,body]).unwrap(); + conn.execute("INSERT INTO typed_ticket_event_attributes VALUES(?1,'authored',?2,'source_kind','original-source')",params![workspace,index]).unwrap(); + conn.execute("INSERT INTO typed_ticket_event_references VALUES(?1,'authored',?2,0,'ticket','T-original')",params![workspace,index]).unwrap(); + } +} + +#[test] +fn ticket_posts_schema93_preserves_initial_body_creator_comments_and_archived_audit() { + use ticket::TicketBackend; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("workspace.db"); + let conn = ticket_posts_schema93_fixture(&path); + seed_ticket_posts_legacy_content(&conn, "space"); + let old_events = stored_fixture_rows(&conn, "typed_ticket_events"); + let old_attributes = stored_fixture_rows(&conn, "typed_ticket_event_attributes"); + let old_references = stored_fixture_rows(&conn, "typed_ticket_event_references"); + let old_markers = stored_fixture_rows(&conn, "ticket_schema_migrations"); + drop(conn); + let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); + assert_eq!(plan.current_schema_version, 93); + assert_eq!( + plan.migrations + .iter() + .map(|step| step.version) + .collect::>(), + [94] + ); + let conn = Connection::open(&path).unwrap(); + assert!( + column_exists(&conn, "typed_tickets", "body").unwrap(), + "planning must not mutate its source" + ); + assert_eq!( + stored_fixture_rows(&conn, "typed_ticket_events"), + old_events + ); + drop(conn); + assert_eq!(SqliteWorkspaceStore::migrate_database(&path).unwrap(), plan); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + store.with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, 94); + assert!(!column_exists(conn,"typed_tickets","body")?); + assert_eq!(stored_fixture_rows(conn,"typed_ticket_events"),old_events); + assert_eq!(stored_fixture_rows(conn,"typed_ticket_event_attributes"),old_attributes); + assert_eq!(stored_fixture_rows(conn,"typed_ticket_event_references"),old_references); + let markers = stored_fixture_rows(conn,"ticket_schema_migrations"); + assert_eq!(&markers[..old_markers.len()],old_markers.as_slice()); + assert_eq!(markers.len(),old_markers.len()+1); + let initial: (String,Option,Option,String,String) = conn.query_row("SELECT e.body,t.created_by,e.author,e.kind,e.event_ref FROM typed_tickets t JOIN typed_ticket_events e USING(workspace_id,ticket_id) WHERE t.ticket_id='authored' AND e.event_index=-1",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?)))?; + assert_eq!(initial,("Current requirements after edits".into(),Some("creator".into()),Some("creator".into()),"comment".into(),"authored:-1".into())); + let archive: Vec<(i64,String,i64)> = conn.prepare("SELECT event_index,event_ref,archived FROM typed_ticket_events WHERE ticket_id='authored' ORDER BY event_index")?.query_map([],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?.collect::>()?; + assert_eq!(archive,vec![(-1,"authored:-1".into(),0),(0,"authored:0".into(),1),(3,"authored:3".into(),0),(7,"authored:7".into(),1),(9,"authored:9".into(),0)]); + let unknown: (Option,Option,String) = conn.query_row("SELECT t.created_by,e.author,e.body FROM typed_tickets t JOIN typed_ticket_events e USING(workspace_id,ticket_id) WHERE t.ticket_id='unknown' AND e.event_index=-1",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?)))?; + assert_eq!(unknown,(None,None,"Unknown creator body".into())); + assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0); + ticket::verify_sqlite_ticket_schema(conn).unwrap(); + Ok(()) + }).unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "space").unwrap(); + let loaded = backend.show("authored".into()).unwrap(); + assert_eq!(loaded.meta.created_by.as_deref(), Some("creator")); + assert_eq!( + loaded.document.body.as_str(), + "Current requirements after edits" + ); + assert_eq!( + loaded + .events + .iter() + .map(|event| event.event_ref.as_str()) + .collect::>(), + ["authored:-1", "authored:3", "authored:9"] + ); + assert!(loaded.events[0].is_initial); + assert_eq!(loaded.events[1].author.as_deref(), Some("commenter")); + assert_eq!(loaded.events[2].author, None); + assert!( + loaded + .events + .iter() + .all(|event| event.edited_at.is_none() && event.edited_by.is_none()) + ); + drop(store); + let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + reopened + .with_conn(|conn| { + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM typed_ticket_events WHERE event_index=-1", + [], + |r| r.get::<_, i64>(0) + )?, + 2 + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_schema_migrations WHERE version=12", + [], + |r| r.get::<_, i64>(0) + )?, + 1 + ); + Ok(()) + }) + .unwrap(); +} + +#[test] +fn ticket_posts_schema94_keeps_body_edit_metadata_and_published_refs_across_reopen() { + use ticket::TicketBackend; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("workspace.db"); + let conn = ticket_posts_schema93_fixture(&path); + seed_ticket_posts_legacy_content(&conn, "space"); + conn.execute_batch("INSERT INTO typed_ticket_event_attributes VALUES + ('space','authored',7,'changes','title,body'), + ('space','authored',3,'event_id','raw-identity'), + ('space','authored',9,'event_id','raw-identity'); + INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body) + VALUES('space','authored',15,'item_edit','title-editor','2026-03-01T00:00:00Z','Later title edit'); + INSERT INTO typed_ticket_event_attributes VALUES('space','authored',15,'changes','title');").unwrap(); + drop(conn); + SqliteWorkspaceStore::migrate_database(&path).unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "space").unwrap(); + let migrated = backend.show("authored".into()).unwrap(); + assert_eq!(migrated.meta.created_by.as_deref(), Some("creator")); + assert_eq!( + migrated.meta.created_at.as_deref(), + Some("2026-01-01T00:00:00Z") + ); + let initial = &migrated.events[0]; + assert_eq!(initial.author.as_deref(), Some("creator")); + assert_eq!(initial.at.as_deref(), Some("2026-01-02T00:00:00Z")); + assert_eq!(initial.edited_by.as_deref(), Some("editor")); + assert_eq!(initial.edited_at.as_deref(), Some("2026-01-02T00:00:00Z")); + assert_eq!( + migrated + .events + .iter() + .map(|e| e.event_ref.as_str()) + .collect::>(), + ["authored:-1", "authored:3", "authored:9"] + ); + assert_eq!(migrated.events[1].attributes["event_id"], "authored:3"); + assert_eq!(migrated.events[2].attributes["event_sequence"], "9"); + let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + reopened.with_conn(|conn| { + assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE ticket_id='authored' AND event_index=3 AND key='event_id'", [], |r| r.get::<_,String>(0))?, "raw-identity"); + assert_eq!(conn.query_row("SELECT archived FROM typed_ticket_events WHERE ticket_id='authored' AND event_index=15", [], |r| r.get::<_,i64>(0))?, 1); + Ok(()) + }).unwrap(); + assert_eq!(backend.show("authored".into()).unwrap(), migrated); + backend + .add_event( + "authored".into(), + ticket::NewTicketEvent::new(ticket::TicketEventKind::Comment, "Next discussion"), + ) + .unwrap(); + assert_eq!( + backend + .show("authored".into()) + .unwrap() + .events + .last() + .unwrap() + .event_ref, + "authored:16" + ); +} + +#[test] +fn ticket_posts_schema94_host_failure_rolls_back_domain_rows_ddl_and_markers() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("workspace.db"); + let conn = ticket_posts_schema93_fixture(&path); + seed_ticket_posts_legacy_content(&conn, "space"); + conn.execute_batch("CREATE TRIGGER reject_host94 BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=94 BEGIN SELECT RAISE(ABORT,'injected host94 failure'); END;").unwrap(); + let schema = fixture_query_rows( + &conn, + "SELECT type,name,sql FROM sqlite_schema ORDER BY type,name", + ); + let tables = [ + "typed_tickets", + "typed_ticket_events", + "typed_ticket_event_attributes", + "typed_ticket_event_references", + "ticket_schema_migrations", + "__yoi_schema_migrations", + ]; + let rows = tables + .map(|table| fixture_query_rows(&conn, &format!("SELECT * FROM {table} ORDER BY rowid"))); + drop(conn); + for entrypoint in 0..3 { + let error = match entrypoint { + 0 => SqliteWorkspaceStore::open_for_test(&path).err().unwrap(), + 1 => SqliteWorkspaceStore::migration_plan(&path).unwrap_err(), + _ => SqliteWorkspaceStore::migrate_database(&path).unwrap_err(), + }; + assert!( + error.to_string().contains("injected host94 failure"), + "{error}" + ); + let conn = Connection::open(&path).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 93); + assert_eq!( + fixture_query_rows( + &conn, + "SELECT type,name,sql FROM sqlite_schema ORDER BY type,name" + ), + schema + ); + for (table, before) in tables.iter().zip(&rows) { + assert_eq!( + &fixture_query_rows(&conn, &format!("SELECT * FROM {table} ORDER BY rowid")), + before, + "rollback {table}" + ); + } + } + let conn = Connection::open(&path).unwrap(); + conn.execute_batch("DROP TRIGGER reject_host94;").unwrap(); + drop(conn); + SqliteWorkspaceStore::open_for_test(&path).unwrap(); +} + +#[test] +fn ticket_posts_fresh_and_migrated_schema_constraints_match() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("workspace.db"); + drop(ticket_posts_schema93_fixture(&path)); + let upgraded = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap(); + for table in [ + "typed_tickets", + "typed_ticket_events", + "typed_ticket_event_attributes", + "typed_ticket_event_references", + ] { + for query in [ + format!( + "SELECT name,type,\"notnull\",dflt_value,pk FROM pragma_table_info('{table}') ORDER BY name" + ), + format!( + "SELECT \"table\",\"from\",\"to\",on_update,on_delete FROM pragma_foreign_key_list('{table}') ORDER BY 1,2,3" + ), + format!( + "SELECT i.\"unique\",i.partial,(SELECT group_concat(name, ',') FROM (SELECT name FROM pragma_index_info(i.name) ORDER BY seqno)) FROM pragma_index_list('{table}') i ORDER BY 1,2,3" + ), + format!( + "SELECT name,sql FROM sqlite_schema WHERE type='trigger' AND tbl_name='{table}' ORDER BY name" + ), + ] { + let expected = fresh + .with_conn(|conn| Ok(fixture_query_rows(conn, &query))) + .unwrap(); + let actual = upgraded + .with_conn(|conn| Ok(fixture_query_rows(conn, &query))) + .unwrap(); + assert_eq!(actual, expected, "{query}"); + } + } + for store in [&fresh, &upgraded] { + store.with_conn(|conn| { + seed_migration_workspace_if_missing(conn); + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) VALUES('space','guard','guard','Guard','open','task','P2','open',1)",[])?; + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('space','guard',-1,'guard:-1','comment','Initial')",[])?; + assert!(conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('space','guard',0,'guard:-1','comment','Duplicate ref')",[]).is_err()); + assert!(conn.execute("UPDATE typed_ticket_events SET archived=2 WHERE ticket_id='guard'",[]).is_err()); + assert!(conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('space','missing',0,'missing:0','comment','Orphan')",[]).is_err()); + Ok(()) + }).unwrap(); + } +} + +fn seed_migration_workspace_if_missing(conn: &Connection) { + if conn + .query_row( + "SELECT COUNT(*) FROM workspaces WHERE workspace_id='space'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap() + == 0 + { + seed_migration_workspace(conn); + } +} + +#[test] +fn ticket_posts_oldest_supported_host50_preserves_content_through_full_chain() { + use ticket::TicketBackend; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("workspace.db"); + prepare_schema_v50(&path, Some("workspace-a")); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + seed_ticket_posts_legacy_content(&conn, "workspace-a"); + conn.execute("UPDATE typed_tickets SET workflow_state='planning'", []) + .unwrap(); + drop(conn); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + store + .with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, 94); + ticket::verify_sqlite_ticket_schema(conn).unwrap(); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r + .get::<_, i64>( + 0 + ))?, + 0 + ); + Ok(()) + }) + .unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "workspace-a").unwrap(); + let loaded = backend.show("authored".into()).unwrap(); + assert_eq!( + loaded.document.body.as_str(), + "Current requirements after edits" + ); + assert_eq!(loaded.meta.created_by.as_deref(), Some("creator")); + assert_eq!( + loaded + .events + .iter() + .map(|event| event.event_ref.as_str()) + .collect::>(), + ["authored:-1", "authored:3", "authored:9"] + ); +} diff --git a/crates/workspace-server/src/ticket_tag_upgrade_tests.rs b/crates/workspace-server/src/ticket_tag_upgrade_tests.rs index 69190eb1f..4fb3836a0 100644 --- a/crates/workspace-server/src/ticket_tag_upgrade_tests.rs +++ b/crates/workspace-server/src/ticket_tag_upgrade_tests.rs @@ -265,8 +265,8 @@ fn assert_retained_classification_upgrade(workspace_version: i64, component: i64 drop(conn); let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); store.with_conn(|conn| { - assert_eq!(current_schema_version(conn)?,93); - assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,11); + assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION); + assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,12); assert_eq!(fixture_auth_rows(conn),auth_before); for (table, rows) in preserved_tables.iter().zip(&preserved) { let mut migrated = stored_fixture_rows(conn, table); @@ -493,14 +493,14 @@ fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); store .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?,93); + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); assert_eq!( conn.query_row( "SELECT MAX(version) FROM ticket_schema_migrations", [], |row| row.get::<_, i64>(0) )?, - 11 + 12 ); assert_eq!( stored_fixture_rows(conn, "typed_ticket_case_history"), diff --git a/openapi/server-api.json b/openapi/server-api.json index 2e06fac1e..27f2c890a 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -11620,6 +11620,12 @@ "null" ] }, + "created_by": { + "type": [ + "string", + "null" + ] + }, "current_closure": { "anyOf": [ { @@ -11667,6 +11673,9 @@ }, "type": "array" }, + "initial_post": { + "$ref": "#/components/schemas/TicketEventDetail" + }, "legacy_handoff": { "anyOf": [ { @@ -11768,6 +11777,7 @@ } }, "required": [ + "initial_post", "id", "tags", "resource_key", @@ -12053,6 +12063,25 @@ "body": { "$ref": "#/components/schemas/MarkdownText" }, + "edited_at": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "edited_by": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "event_ref": { + "default": "", + "description": "Persisted identity, independent of display position and legacy event_id attributes.", + "type": "string" + }, "from": { "type": [ "string", @@ -12065,6 +12094,10 @@ "null" ] }, + "is_initial": { + "default": false, + "type": "boolean" + }, "kind": { "$ref": "#/components/schemas/TicketEventKind" }, @@ -12134,6 +12167,27 @@ "null" ] }, + "body_digest": { + "type": "string" + }, + "body_truncated": { + "type": "boolean" + }, + "editable": { + "type": "boolean" + }, + "edited_at": { + "type": [ + "string", + "null" + ] + }, + "edited_by": { + "type": [ + "string", + "null" + ] + }, "event_ref": { "type": "string" }, @@ -12149,6 +12203,9 @@ "null" ] }, + "is_initial": { + "type": "boolean" + }, "kind": { "type": "string" }, @@ -12190,6 +12247,10 @@ } }, "required": [ + "is_initial", + "body_digest", + "body_truncated", + "editable", "sequence", "event_ref", "kind", @@ -12751,6 +12812,13 @@ "null" ] }, + "created_by": { + "default": null, + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, @@ -12873,6 +12941,22 @@ }, "type": "object" }, + "TicketPostEditRequest": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "expected_body_digest": { + "type": "string" + } + }, + "required": [ + "body", + "expected_body_digest" + ], + "type": "object" + }, "TicketQueryItem": { "additionalProperties": false, "properties": { @@ -17114,7 +17198,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb" + "x-yoi-source-digest": "sha256:1d6615147b8d7143484b9a2aaf163da6916fcabb7560c675c5b7bf4d94aa2214" }, "openapi": "3.1.0", "paths": { @@ -35904,6 +35988,238 @@ ] } }, + "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}": { + "get": { + "operationId": "ticket_post_get", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "event_ref", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketEventDetail" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "patch": { + "operationId": "ticket_post_edit", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "event_ref", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketPostEditRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketDetail" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/tickets/{id}/queue": { "post": { "operationId": "ticket_queue", diff --git a/tools/web-ux/browser-tests/ticket_fixture_server.ts b/tools/web-ux/browser-tests/ticket_fixture_server.ts index ea0822683..eeb809a1a 100644 --- a/tools/web-ux/browser-tests/ticket_fixture_server.ts +++ b/tools/web-ux/browser-tests/ticket_fixture_server.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { extname, join, normalize } from "jsr:@std/path@1.1.4"; import { fixtureDetail, + fixturePost, fixturePage, } from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts"; import type { @@ -305,6 +306,27 @@ function ticket(key: string) { }); } } + value.created_by = "Mika"; + value.initial_post = fixturePost(value.body, { at: value.created_at, body_digest: createHash("sha256").update(value.body).digest("hex") }); + if (key === "T-900") { + value.title = "Compare deployment options for the shared Workspace"; + value.initial_post.body = "We need a deployment plan that the team can maintain without interrupting active Workers.\n\nPlease compare **a scheduled maintenance window** with **rolling replacement**, including recovery steps and how we preserve ongoing work. Return the trade-offs before choosing an approach."; + value.events = [ + fixturePost("I checked the current deployment notes. A maintenance window is simpler to operate, but we would need to tell users when new work will pause.\n\nRolling replacement also needs a clear rule for existing sessions; replacing a process is not the same as completing its Ticket.", { + sequence: 1, event_ref: "discussion-rin", is_initial: false, author: "Rin", at: "2026-01-01T09:15:00Z", edited_at: "2026-01-01T10:05:00Z", edited_by: "Rin", + }), + fixturePost("Please include an explicit rollback checkpoint. We should be able to verify the new instance before moving traffic, and keep the original author and timestamp on these notes.\n\nThe final decision can wait until we have both estimates.", { + sequence: 2, event_ref: "discussion-mika", is_initial: false, author: "Mika", at: "2026-01-02T08:30:00Z", + }), + fixturePost("The comparison is ready for review. The two approaches differ mainly in operational complexity and the duration of the pause.\n\n- Maintenance window: lower complexity, visible interruption.\n- Rolling replacement: smaller interruption, more recovery coordination.\n\nNo deployment or case closure has been performed.", { + sequence: 3, event_ref: "discussion-worker", is_initial: false, author: "Workspace Orchestrator", at: "2026-01-02T11:45:00Z", + }), + ]; + value.body = value.initial_post.body!; + for (const post of [value.initial_post, ...value.events]) post.body_digest = createHash("sha256").update(post.body ?? "").digest("hex"); + value.event_count = value.events.length; + value.event_page = fixturePage(value.events.length); + } tickets.set(key, value); } const stored = tickets.get(key)!; @@ -345,6 +367,8 @@ function projectedTicket(key: string, workspaceId: string) { workspace_id: workspaceId, })); if (workspaceId === "tickets-readonly") { + value.initial_post.editable = false; + value.events = value.events.map((post) => ({ ...post, editable: false })); value.action_eligibility = { blockers: ["Read-only fixture"], can_assign_orchestrator: false, @@ -710,6 +734,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { created.action_eligibility.can_unassign_orchestrator = false; created.title = body.title; created.body = body.body; + created.initial_post = fixturePost(body.body, { at: created.created_at, body_digest: createHash("sha256").update(body.body).digest("hex") }); created.targets = body.targets ?? []; return Response.json({ id: created.id, @@ -751,6 +776,24 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const key = decodeURIComponent(tm[1]).match(/T-\d+/)?.[0] ?? "T-1"; const stored = tickets.get(key) ?? (ticket(key), tickets.get(key)!); const suffix = tm[2]; + if (suffix.startsWith("/posts/")) { + const eventRef = decodeURIComponent(suffix.slice("/posts/".length)); + const post = [stored.initial_post, ...stored.events].find((post) => post.event_ref === eventRef); + if (!post) return Response.json({ message: "Post not found" }, { status: 404 }); + if (request.method === "GET") return Response.json(post); + if (request.method !== "PATCH") return new Response(null, { status: 405 }); + if (id === "tickets-readonly" || !post.editable || post.kind !== "comment") return new Response(null, { status: 403 }); + const body = await request.json(); + bodies.push({ path, method: request.method, body }); + if (body.expected_body_digest !== post.body_digest) return Response.json({ message: "Post update conflict" }, { status: 409 }); + post.body = body.body; + post.body_digest = createHash("sha256").update(body.body).digest("hex"); + post.edited_at = "2026-01-04T12:30:00Z"; + post.edited_by = "Rin"; + if (post.is_initial) stored.body = post.body ?? ""; + return Response.json(projectedTicket(key, id)); + } + if ( request.method === "GET" && suffix === "/assignment-tracking/assignments" @@ -1021,6 +1064,8 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { }, { status: 409 }); } stored.events.push({ + is_initial: false, edited_at: null, edited_by: null, body_truncated: false, + body_digest: createHash("sha256").update(body.body).digest("hex"), editable: false, event_ref: `ticket-event:${stored.events.length}`, kind: "assignment_answer", body: body.body, @@ -1074,6 +1119,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { }); } const { action } = body; + const previousState = stored.state; stored.closure_history.push({ action, at: "2026-01-03T00:00:00Z", @@ -1098,6 +1144,25 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { references: body.references ?? [], }; } + stored.events.push({ + is_initial: false, edited_at: null, edited_by: null, body_truncated: false, + body_digest: createHash("sha256").update(body.explanation).digest("hex"), editable: false, + sequence: stored.events.length, + kind: action.action === "close" ? "close" : "state_changed", + heading: action.action === "close" ? "Closed" : "State changed", + author: "fixture-owner", + at: "2026-01-03T00:00:00Z", + from: previousState, + to: stored.state, + status: stored.state, + state_field: "case", + reason: body.explanation, + body: body.explanation, + references: body.references ?? [], + attributes: { operation_key: operationKey }, + event_ref: `event-${stored.events.length}`, + }); + stored.event_count = stored.events.length; stored.action_eligibility.can_start_manual_worker = stored.state === "open"; result = { @@ -1147,6 +1212,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { } if (tm[2] === "/events") { stored.events.push({ + is_initial: false, edited_at: null, edited_by: null, body_truncated: false, + body_digest: createHash("sha256").update(body.body).digest("hex"), editable: body.role === "comment", + author: "Mika", at: "2026-01-03T00:00:00Z", sequence: stored.events.length, kind: body.role, body: body.body, diff --git a/tools/web-ux/browser-tests/ticket_principal_test.ts b/tools/web-ux/browser-tests/ticket_principal_test.ts index a0144dc61..df7512e8a 100644 --- a/tools/web-ux/browser-tests/ticket_principal_test.ts +++ b/tools/web-ux/browser-tests/ticket_principal_test.ts @@ -1,4 +1,5 @@ import { assert, assertEquals } from "jsr:@std/assert@1.0.19"; +import { join } from "@std/path"; import { chromium } from "npm:playwright@1.59.1"; import { loadScenario } from "../src/scenario.ts"; import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; @@ -18,7 +19,7 @@ Deno.test("principal replacement snapshots the target and observed assignment fo const processes = await startOwnedProcesses( scenario.processes!, scenarioPath, - new URL(`../../../target/web-ux/t743-principal/${port}`, import.meta.url).pathname, + join(Deno.env.get("WEB_UX_TEST_ARTIFACTS") ?? new URL("../../../target/web-ux", import.meta.url).pathname, "t743-principal", String(port)), [], ); let browser: Awaited> | null = null; diff --git a/tools/web-ux/browser-tests/ticket_shell_test.ts b/tools/web-ux/browser-tests/ticket_shell_test.ts index 6440cf57c..15ad79bdd 100644 --- a/tools/web-ux/browser-tests/ticket_shell_test.ts +++ b/tools/web-ux/browser-tests/ticket_shell_test.ts @@ -1,8 +1,13 @@ import { assert, assertEquals } from "jsr:@std/assert@1.0.19"; +import { join } from "@std/path"; import { type Browser, chromium, type Page } from "npm:playwright@1.59.1"; import { loadScenario } from "../src/scenario.ts"; import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; +function artifactPath(path: string): string { + return join(Deno.env.get("WEB_UX_TEST_ARTIFACTS") ?? new URL("../../../target/web-ux", import.meta.url).pathname, "t743-browser", path); +} + // Explicit browser boundary: production static build + loopback API fixture. // These tests prove UI/request semantics, not real Backend delivery or model judgment. async function withFixture( @@ -16,8 +21,7 @@ async function withFixture( const baseUrl = `http://127.0.0.1:${port}`; scenario.processes![0].args![4] = String(port); scenario.processes![0].readyUrl = `${baseUrl}/health`; - const logs = new URL(`../../../target/web-ux/t743-browser/${port}/logs`, import.meta.url) - .pathname; + const logs = artifactPath(`${port}/logs`); const processes = await startOwnedProcesses( scenario.processes!, scenarioPath, @@ -335,6 +339,63 @@ Deno.test("assignee qualifiers send exact user and runtime-worker identities, no }); }); +async function assertThreadLayout(page: Page) { + const main = page.getByRole("main", { name: "Ticket thread", exact: true }); + const rail = page.getByRole("complementary", { name: "Ticket details" }); + assertEquals(await main.locator(":scope > *").count(), 1); + assertEquals(await main.locator(".ticket-thread > *").evaluateAll((elements) => + elements.map((element) => element.className) + ), ["ticket-timeline", "ticket-control-form ticket-thread-composer"]); + assertEquals(await main.locator(".ticket-timeline > :first-child").getAttribute("class"), "ticket-thread-event"); + assertEquals(await main.locator(".ticket-thread-event[data-initial=true] header strong").textContent(), "Mika"); + assert((await main.locator(".ticket-thread-event[data-initial=true] time").textContent())?.trim()); + assertEquals(await main.getByRole("heading", { name: /^(Intent|Timeline)$/ }).count(), 0); + assertEquals(await main.locator(".ticket-timeline > *").evaluateAll((elements) => + elements.every((element, index) => index === 0 + ? element.matches("article.ticket-thread-event[data-initial=true]") + : index % 2 === 1 ? element.matches(".bevel-line") : element.matches("article.ticket-thread-event")) + ), true); + assert(await main.locator(".ticket-thread-composer").evaluate((element) => + element.previousElementSibling?.matches(".ticket-timeline") && + element.firstElementChild?.matches(".bevel-line") + )); + const metadata = ".ticket-handling, .ticket-tags, .ticket-relations, .ticket-orchestrator-worker, .ticket-worker-observation, .ticket-worker-card, .ticket-resources"; + assertEquals(await main.locator(metadata).count(), 0); + assertEquals(await rail.locator(metadata).count(), 7); + assertEquals(await page.locator(".ticket-delivery").count(), 0); + assertEquals(await page.getByText("Delivery and execution", { exact: true }).count(), 0); + assertEquals(await rail.locator(".ticket-thread-event[data-initial=true], .ticket-thread-event, .ticket-thread-composer").count(), 0); + for (const selector of [".ticket-orchestrator-worker", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) { + const details = rail.locator(selector); + assertEquals(await details.evaluate((element) => element.tagName), "DETAILS"); + assertEquals(await details.getAttribute("open"), null); + assert(await details.locator(":scope > summary").isVisible()); + } + assertEquals(await rail.locator(".ticket-worker-card > summary").textContent(), "Role assignments and manual Workers"); + assert(/^Repository resources \(\d+\)$/.test((await rail.locator(".ticket-resources > summary").textContent())!)); + const evidence = rail.locator("details").filter({ has: page.locator("summary", { hasText: /^MR requirement evidence$/ }) }); + if (await evidence.count()) assertEquals(await evidence.getAttribute("open"), null); +} + +async function assertThreadDividers(page: Page) { + const lines = page.locator(".ticket-timeline > .bevel-line, .ticket-thread-composer > .bevel-line"); + assertEquals(await lines.count(), await page.locator(".ticket-thread-event").count()); + for (const line of await lines.all()) { + await line.scrollIntoViewIfNeeded(); + assert(await line.isVisible()); + assert(await line.evaluate((element) => { + const view = element.ownerDocument.defaultView!; + const style = view.getComputedStyle(element); + const bounds = element.getBoundingClientRect(); + const content = element.nextElementSibling!.getBoundingClientRect(); + return element.getAttribute("aria-hidden") === "true" && + style.borderTopStyle === "solid" && parseFloat(style.borderTopWidth) > 0 && + style.borderTopColor !== "rgba(0, 0, 0, 0)" && style.opacity !== "0" && + Math.abs(bounds.left - content.left) < 1 && Math.abs(bounds.right - content.right) < 1; + })); + } +} + async function boundedGate(promise: Promise) { let timer: number | undefined; try { @@ -399,15 +460,14 @@ Deno.test("waiting answers retain a delegation, done reissue retains results, an .waitFor(); const before = await detail(base, "T-2"); const row = before.assignment_tracking[0]; - await page.getByText("Record result or comment", { exact: true }).click(); - await page.getByLabel("Body", { exact: true }).fill( + await page.getByLabel("Comment", { exact: true }).fill( "Ordinary retained discussion, not an answer input", ); const eventResponse = page.waitForResponse((response) => response.url().endsWith("/events") && response.request().method() === "POST" ); - await page.getByRole("button", { name: "Append event", exact: true }) + await page.getByRole("button", { name: "Add comment", exact: true }) .click(); assertEquals((await eventResponse).request().postDataJSON(), { role: "comment", @@ -486,7 +546,7 @@ Deno.test("waiting answers retain a delegation, done reissue retains results, an await page.goto(`${base}/w/tickets/tickets/T-4`); await page.getByText("Self-reported handling: done", { exact: true }) .waitFor(); - await page.locator(".ticket-case-card").getByLabel("Explanation", { + await page.locator(".ticket-thread-composer").getByLabel("Comment", { exact: true, }).fill("Reconsider the case without delegating again"); const reopenResponse = page.waitForResponse((response) => @@ -498,20 +558,23 @@ Deno.test("waiting answers retain a delegation, done reissue retains results, an assertEquals(reopen.action, { action: "reopen" }); assertEquals(reopen.expected_state, "closed"); assertEquals(reopen.expected_closure_operation_key, "close-T-4"); - await page.getByRole("button", { name: "Close ticket", exact: true }) + await page.getByRole("button", { name: "Comment and close", exact: true }) .waitFor(); const reopened = await detail(base, "T-4"); assertEquals(reopened.state, "open"); assertEquals(reopened.assignment_tracking[0].tracking.status, "done"); assertEquals(reopened.closure_history.length, 2); - await page.locator(".ticket-case-card").getByLabel("Explanation", { + await page.locator(".ticket-thread-composer").getByLabel("Comment", { exact: true, }).fill("The full requested investigation is delivered"); - await page.locator(".ticket-case-card select").selectOption("completed"); + await page.locator(".ticket-close-options > summary").click(); + await page.locator(".ticket-thread-composer").getByRole("combobox", { + name: "Closure reason", exact: true, + }).selectOption("completed"); const closeResponse = page.waitForResponse((response) => response.url().endsWith("/case") && response.request().method() === "POST" ); - await page.getByRole("button", { name: "Close ticket", exact: true }) + await page.getByRole("button", { name: "Comment and close", exact: true }) .click(); const close = (await closeResponse).request().postDataJSON(); assertEquals(close.action, { action: "close", reason: "completed" }); @@ -544,12 +607,122 @@ Deno.test("waiting answers retain a delegation, done reissue retains results, an .isVisible(), ); const requests = await (await fetch(`${base}/fixture-state`)).json(); + assert(!requests.some((r: { path: string }) => r.path.includes("assignment-diagnostics"))); + assertEquals(await page.locator(".ticket-delivery").count(), 0); + assertEquals(await page.getByText("Delivery and execution", { exact: true }).count(), 0); assert( !requests.some((r: { path: string }) => /\/(queue|ready|state)$/.test(r.path)), ); }); }); +Deno.test("timeline footer comments and closes with one case request, renders the comment once and still accepts closed-case comments", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/tickets/T-1`); + const composer = page.locator(".ticket-thread-composer"); + await composer.waitFor(); + assertEquals(await page.locator("aside .ticket-thread-composer").count(), 0); + assertEquals(await page.locator(".ticket-case-card").count(), 0); + assert(await composer.evaluate((element) => { + const timeline = element.parentElement?.querySelector(".ticket-timeline"); + return !!timeline && !!element.closest("main") && + !!(timeline.compareDocumentPosition(element) & 4); + })); + const comment = composer.getByLabel("Comment", { exact: true }); + const add = composer.getByRole("button", { name: "Add comment", exact: true }); + const close = composer.getByRole("button", { name: "Comment and close", exact: true }); + assert(await add.isDisabled()); + assert(await close.isDisabled()); + await comment.fill(" \n "); + assert(await add.isDisabled()); + assert(await close.isDisabled()); + const text = "The options are recorded; close this discussion without choosing one."; + await comment.fill(text); + assert(await add.isEnabled()); + assert(await close.isEnabled()); + const before = await detail(base, "T-1"); + const response = page.waitForResponse((response) => + response.url().endsWith("/case") && response.request().method() === "POST" + ); + await close.click(); + const request = (await response).request().postDataJSON(); + assertEquals(request.explanation, text); + assertEquals(request.action, { action: "close", reason: "unknown" }); + assertEquals(request.expected_state, "open"); + assertEquals(request.expected_content_digest, before.content_digest); + assertEquals(request.expected_closure_operation_key, before.closure_operation_key); + assert(typeof request.operation_key === "string" && request.operation_key.length > 0); + await page.locator(".ticket-detail-header").getByText("Case: closed", { + exact: true, + }).waitFor(); + const rendered = page.locator(".ticket-timeline").getByText(text, { exact: true }); + await rendered.waitFor(); + assertEquals(await rendered.count(), 1); + const closed = await detail(base, "T-1"); + assertEquals(closed.state, "closed"); + assertEquals(closed.current_closure.explanation, text); + assertEquals(closed.events.length, before.events.length + 1); + assertEquals(closed.events.at(-1).kind, "close"); + assertEquals(closed.events.at(-1).body, text); + assertEquals(closed.events.at(-1).reason, text); + const requests = await (await fetch(`${base}/fixture-state`)).json(); + assertEquals(requests.filter((row: { path: string }) => row.path.endsWith("/case")).length, 1); + assertEquals(requests.filter((row: { path: string }) => row.path.endsWith("/events")).length, 0); + assertEquals(await comment.inputValue(), ""); + assert(await add.isDisabled()); + const reopen = composer.getByRole("button", { name: "Reopen ticket", exact: true }); + assert(await reopen.isDisabled()); + await comment.fill("A retained comment after closure does not reopen the case."); + const eventResponse = page.waitForResponse((response) => + response.url().endsWith("/events") && response.request().method() === "POST" + ); + await add.click(); + assertEquals((await eventResponse).request().postDataJSON(), { + role: "comment", + body: "A retained comment after closure does not reopen the case.", + }); + await page.locator(".ticket-timeline").getByText( + "A retained comment after closure does not reopen the case.", + { exact: true }, + ).waitFor(); + assertEquals((await detail(base, "T-1")).state, "closed"); + }); +}); + +Deno.test("ambiguous comment-and-close retries exact case JSON without a duplicate timeline event", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/tickets/T-1`); + const composer = page.locator(".ticket-thread-composer"); + await composer.waitFor(); + const text = "Retain this close comment exactly once, even if the response is lost."; + const before = await detail(base, "T-1"); + await composer.getByLabel("Comment", { exact: true }).fill(text); + await control(base, { failure: "unknown_saved" }); + await composer.getByRole("button", { name: "Comment and close", exact: true }).click(); + await page.getByRole("alert").filter({ hasText: /Outcome unknown/ }).waitFor(); + assertEquals(await composer.getByRole("button", { + name: "Refresh and reconcile case", exact: true, + }).count(), 1); + await composer.getByRole("button", { name: "Retry exact case decision", exact: true }).click(); + await page.locator(".ticket-detail-header").getByText("Case: closed", { + exact: true, + }).waitFor(); + const rendered = page.locator(".ticket-timeline").getByText(text, { exact: true }); + await rendered.waitFor(); + assertEquals(await rendered.count(), 1); + const requests = await (await fetch(`${base}/fixture-state`)).json(); + const attempts = requests.filter((row: { path: string }) => row.path.endsWith("/case")); + assertEquals(attempts.length, 2); + assertEquals(attempts[0].body, attempts[1].body); + assertEquals(requests.filter((row: { path: string }) => row.path.endsWith("/events")).length, 0); + const closed = await detail(base, "T-1"); + assertEquals(closed.events.length, before.events.length + 1); + assertEquals(closed.closure_history.length, before.closure_history.length + 1); + }); +}); + Deno.test("repository-free creation and canonical tag create/attach/rename/delete do not close or delegate the case", async () => { await withFixture(async (browser, base) => { const page = await browser.newPage(); @@ -762,10 +935,161 @@ Deno.test("ambiguous saved Assign retries exact JSON once, while stale and permi }); }); +Deno.test("initial and later posts use the same editor and preserve authorship while title edit remains metadata-only", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/tickets/T-1`); + await page.locator(".ticket-detail-header").waitFor(); + const composer = page.getByRole("form", { name: "Add a comment" }); + await composer.getByLabel("Comment", { exact: true }).fill("A later discussion post"); + await composer.getByRole("button", { name: "Add comment", exact: true }).click(); + await page.locator(".ticket-timeline").getByText("A later discussion post", { exact: true }).waitFor(); + for (const name of ["Initial post", "Discussion post"]) { + const article = page.getByRole("article", { name, exact: true }); + const originalTime = await article.locator("time").first().getAttribute("datetime"); + const originalAuthor = await article.locator("header strong").textContent(); + await article.getByRole("button", { name: "Edit post", exact: true }).click(); + await article.getByLabel("Post body", { exact: true }).fill(`Revised ${name}`); + await article.getByRole("button", { name: "Save post", exact: true }).click(); + await article.getByText(`Revised ${name}`, { exact: true }).waitFor(); + assertEquals(await article.locator("time").first().getAttribute("datetime"), originalTime); + assertEquals(await article.locator("header strong").textContent(), originalAuthor); + assert((await article.locator(".ticket-post-edited").textContent())?.includes("by Rin")); + assertEquals(await page.locator(".ticket-timeline article").count(), 2); + } + const rail = page.getByRole("complementary", { name: "Ticket details" }); + await rail.getByRole("button", { name: "Edit title", exact: true }).click(); + assertEquals(await rail.getByLabel("Post body", { exact: true }).count(), 0); + await rail.getByLabel("Title", { exact: true }).fill("A clearer Ticket title"); + await rail.getByRole("button", { name: "Save title", exact: true }).click(); + await page.getByRole("heading", { name: "A clearer Ticket title", exact: true }).waitFor(); + const requests = await (await fetch(`${base}/fixture-state`)).json(); + const edits = requests.filter((row: { path: string; method?: string }) => row.method === "PATCH" && row.path.includes("/posts/")); + assertEquals(edits.length, 2); + for (const edit of edits) assertEquals(Object.keys(edit.body).sort(), ["body", "expected_body_digest"]); + const metadata = requests.findLast((row: { body?: { title?: string } }) => row.body?.title === "A clearer Ticket title"); + assertEquals(metadata.body, { title: "A clearer Ticket title" }); + assertEquals(await page.locator(".ticket-timeline article").count(), 2); + await page.close(); + }); +}); + +Deno.test("canonical thread renders long initial post and chronological multiline events once with content-aligned dividers", async () => { + await withFixture(async (browser, base) => { + const output = artifactPath("thread"); + await Deno.mkdir(output, { recursive: true }); + const paragraphs = Array.from({ length: 8 }, (_, index) => + `Initial post paragraph ${index + 1}: ` + "The requested investigation preserves context before any discussion. ".repeat(5) + ); + const events = ["Initial findings", "Follow-up decision", "Final investigation result"].map((heading, index) => ({ + is_initial: false, edited_at: null, edited_by: null, body_digest: "d".repeat(64), body_truncated: false, editable: index !== 1, + sequence: index + 1, + kind: index === 1 ? "decision" : "comment", + heading, + author: `Thread author ${index + 1}`, + at: `2026-10-13T0${index + 1}:00:00Z`, + body: `${heading} first paragraph.\n\n${heading} second paragraph with enough detail to wrap on narrow screens.\n\n- ${heading} evidence one\n- ${heading} evidence two`, + reason: null, + from: null, + to: null, + references: [], + attributes: {}, + event_ref: `thread-fixture-${index + 1}`, + })); + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390]) { + const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme, reducedMotion: "reduce" }); + await page.route("**/api/w/tickets/tickets/T-1", async (route) => { + const response = await route.fetch(); + const ticket = await response.json(); + await route.fulfill({ response, json: { + ...ticket, + body: "Compatibility body must not render", + initial_post: { ...ticket.initial_post, body: paragraphs.join("\n\n"), at: "2026-10-12T12:00:00Z" }, + created_at: "2026-10-12T12:00:00Z", + updated_at: events.at(-1)!.at, + events, + event_count: events.length, + } }); + }); + await page.goto(`${base}/w/tickets/tickets/T-1`); + await page.locator(".ticket-detail-header").waitFor(); + await assertThreadLayout(page); + const timeline = page.locator(".ticket-timeline"); + assertEquals(await timeline.locator("article header strong").allTextContents(), ["Mika", ...events.map((event) => event.author)]); + const expectedDates = ["2026-10-12T12:00:00Z", ...events.map((event) => event.at)]; + assertEquals(await timeline.locator("article header time").allTextContents(), await page.evaluate((dates) => dates.map((date) => new Date(date).toLocaleString()), expectedDates)); + for (const paragraph of paragraphs) assertEquals(await timeline.getByText(paragraph, { exact: true }).count(), 1); + for (const event of events) { + const article = timeline.locator(".ticket-thread-event").filter({ has: page.locator("header strong", { hasText: event.author }) }); + assertEquals(await article.count(), 1); + assertEquals(await article.getByText(`${event.heading} first paragraph.`, { exact: true }).count(), 1); + assertEquals(await article.getByText(`${event.heading} second paragraph with enough detail to wrap on narrow screens.`, { exact: true }).count(), 1); + assertEquals(await article.locator("li").count(), 2); + } + await assertThreadDividers(page); + assertEquals(await page.evaluate("document.documentElement.scrollWidth > innerWidth"), false); + await page.screenshot({ path: `${output}/${colorScheme}-${width}-full.png`, fullPage: true }); + await page.locator(".ticket-thread-event").first().scrollIntoViewIfNeeded(); + await page.screenshot({ path: `${output}/${colorScheme}-${width}-events.png` }); + await page.locator(".ticket-thread-composer").scrollIntoViewIfNeeded(); + await page.screenshot({ path: `${output}/${colorScheme}-${width}-composer.png` }); + await timeline.locator("article").first().getByRole("button", { name: "Edit post", exact: true }).click(); + const description = timeline.locator(".ticket-thread-event[data-initial=true]"); + await description.locator(".ticket-editor").waitFor(); + assertEquals(await description.getByLabel("Post body", { exact: true }).inputValue(), paragraphs.join("\n\n")); + assertEquals(await page.locator("aside .ticket-editor").count(), 0); + await page.getByRole("button", { name: "Cancel post edit", exact: true }).click(); + await description.getByText(paragraphs[0], { exact: true }).waitFor(); + await page.close(); + } + } + }); +}); + +Deno.test("Ticket metadata stays in the rail with flat links, wrapping labels and keyboard navigation", async () => { + await withFixture(async (browser, base) => { + const output = artifactPath("metadata"); + await Deno.mkdir(output, { recursive: true }); + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390]) { + const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme }); + await page.route("**/api/w/tickets/tickets/T-1*", async (route) => { + const response = await route.fetch(); + const body = await response.json(); + if (body.resource_key === "T-1" && body.relations) { + body.tags[0].name = "Classification-with-a-long-name-".repeat(4); + body.relations.outgoing = [{ at: "2026-01-01T00:00:00Z", author: "fixture-owner", kind: "related", target: "internal-T-2", target_resource_key: "T-2", ticket_id: body.id, note: "Related investigation with a long note. ".repeat(5) }]; + } + await route.fulfill({ response, json: body }); + }); + await page.goto(`${base}/w/tickets/tickets/T-1`); + const rail = page.getByRole("complementary", { name: "Ticket details" }); + const link = rail.getByRole("link", { name: /T-2/ }); + await link.waitFor(); + assertEquals(await link.getAttribute("href"), "/w/tickets/tickets/T-2"); + assertEquals(await page.locator(".ticket-detail-main .ticket-tags, .ticket-detail-main .ticket-relations").count(), 0); + assertEquals(await page.evaluate("document.documentElement.scrollWidth > innerWidth"), false); + assert(await rail.locator(".ticket-tag-chip, .ticket-relations-list a").evaluateAll((elements) => elements.every((element) => { + const style = element.ownerDocument.defaultView!.getComputedStyle(element); + return style.borderTopWidth === "0px" && style.borderRadius === "0px" && style.backgroundColor === "rgba(0, 0, 0, 0)"; + }))); + await link.focus(); + assert(await link.evaluate((element) => element === element.ownerDocument.activeElement)); + assert(await link.evaluate((element) => element.ownerDocument.defaultView!.getComputedStyle(element).outlineStyle !== "none")); + await page.screenshot({ path: `${output}/${colorScheme}-${width}.png` }); + await link.press("Enter"); + await page.waitForURL(/\/tickets\/T-2/); + await page.getByRole("heading", { name: "Read-only investigation", exact: true }).waitFor(); + await page.close(); + } + } + }); +}); + Deno.test("case and handling matrix renders without overflow in both themes and empty/error/unknown states stay distinct", async () => { await withFixture(async (browser, base) => { - const output = new URL("../../../target/web-ux/t743-browser/matrix", import.meta.url) - .pathname; + const output = artifactPath("matrix"); await Deno.mkdir(output, { recursive: true }); for (const colorScheme of ["light", "dark"] as const) { for (const width of [1440, 768, 390]) { @@ -787,18 +1111,18 @@ Deno.test("case and handling matrix renders without overflow in both themes and ["tickets-unknown", "orchestrator", ".orchestrator-assignment"], ["tickets-error", "tickets", "[role=alert]"], ["tickets", "tickets/T-1", ".ticket-detail-header"], - ["tickets", "tickets/T-2", ".ticket-worker-observation strong"], - ["tickets", "tickets/T-3", ".ticket-worker-observation strong"], + ["tickets", "tickets/T-2", ".ticket-detail-header"], + ["tickets", "tickets/T-3", ".ticket-detail-header"], ["tickets", "tickets/T-4", ".ticket-detail-header"], [ "tickets-readonly", "tickets/T-2", - ".ticket-worker-observation strong", + ".ticket-detail-header", ], [ "tickets-running", "tickets/T-2", - ".ticket-worker-observation strong", + ".ticket-detail-header", ], ] ) { @@ -810,6 +1134,24 @@ Deno.test("case and handling matrix renders without overflow in both themes and ), false, ); + if (route.startsWith("tickets/")) { + await assertThreadLayout(page); + await assertThreadDividers(page); + const rail = page.getByRole("complementary", { name: "Ticket details" }); + assertEquals(await rail.locator(".ticket-tags").count(), 1); + assertEquals(await rail.getByRole("heading", { name: "Relations", exact: true }).count(), 1); + assertEquals(await page.locator(".ticket-detail-main .ticket-tags, .ticket-detail-main .ticket-relations").count(), 0); + const sections = page.locator(".ticket-detail-page .ticket-detail-section, .ticket-detail-page .ticket-control-card"); + assert(await sections.evaluateAll((elements) => elements.every((element) => { + const style = element.ownerDocument.defaultView!.getComputedStyle(element); + return [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth, style.borderRadius].every((value) => value === "0px") && style.backgroundColor === "rgba(0, 0, 0, 0)"; + }))); + const mainBounds = await page.locator(".ticket-detail-main").boundingBox(); + const railBounds = await rail.boundingBox(); + assert(mainBounds && railBounds); + if (width === 1440) assert(railBounds.x >= mainBounds.x + mainBounds.width); + else assert(railBounds.y >= mainBounds.y + mainBounds.height); + } if (workspace === "tickets-empty") { assert( await page.getByText( @@ -837,6 +1179,10 @@ Deno.test("case and handling matrix renders without overflow in both themes and 4, ); } + if (["tickets/T-2", "tickets/T-3"].includes(route)) { + await page.locator("aside .ticket-worker-observation > summary").click(); + await page.locator("aside .ticket-worker-observation strong").waitFor(); + } if (route === "tickets/T-2") { assert( await page.getByText("Self-reported handling: waiting", { @@ -858,6 +1204,8 @@ Deno.test("case and handling matrix renders without overflow in both themes and ); } if (workspace === "tickets-running") { + await page.locator("aside .ticket-orchestrator-worker > summary").click(); + await page.locator("aside .ticket-orchestrator-worker strong").waitFor(); assert( await page.locator(".ticket-orchestrator-worker").getByText( "Running", @@ -887,6 +1235,15 @@ Deno.test("case and handling matrix renders without overflow in both themes and }.png`, fullPage: true, }); + if (workspace === "tickets" && ["tickets/T-1", "tickets/T-4"].includes(route)) { + const composer = page.locator(".ticket-thread-composer"); + await composer.waitFor(); + assertEquals(await page.locator("aside .ticket-thread-composer").count(), 0); + await composer.scrollIntoViewIfNeeded(); + await page.screenshot({ + path: `${output}/${colorScheme}-${width}-${route.replaceAll("/", "-")}-footer.png`, + }); + } } assertEquals(errors, []); } finally { diff --git a/tools/web-ux/scenarios/tickets-dark.json b/tools/web-ux/scenarios/tickets-dark.json index 352e161e4..1caa413cd 100644 --- a/tools/web-ux/scenarios/tickets-dark.json +++ b/tools/web-ux/scenarios/tickets-dark.json @@ -32,6 +32,72 @@ } ], "routes": [ + { + "id": "posts", + "label": "Discussion with initial post and edited replies", + "path": "/w/tickets-posts/tickets/T-900", + "goal": "Read and edit any discussion post without changing Ticket metadata", + "dataState": "Four realistic posts, distinct authors, original and edited times", + "ready": { + "kind": "selector", + "selector": ".ticket-timeline article:nth-of-type(4)" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Thread and metadata", + "fullPage": true + }, + { + "id": "edit-initial", + "label": "Same post editor on initial post", + "interaction": [ + { + "action": "click", + "selector": ".ticket-timeline article:first-of-type button" + } + ], + "fullPage": true + }, + { + "id": "later-posts", + "label": "Later discussion and edited attribution", + "interaction": [ + { + "action": "click", + "selector": ".ticket-timeline article:first-of-type button[type=button]" + }, + { + "action": "click", + "selector": ".ticket-timeline article:last-of-type header strong" + } + ], + "fullPage": true + }, + { + "id": "composer", + "label": "Thread-only comment and close footer", + "interaction": [ + { + "action": "click", + "selector": ".ticket-thread-composer textarea" + } + ], + "fullPage": true + }, + { + "id": "metadata", + "label": "Title editing separate from post bodies", + "interaction": [ + { + "action": "click", + "selector": ".ticket-metadata button" + } + ], + "fullPage": true + } + ] + }, { "id": "options", "label": "Return options without conclusion", @@ -54,7 +120,7 @@ "interaction": [ { "action": "click", - "selector": ".ticket-case-card textarea" + "selector": ".ticket-thread-composer textarea" } ], "fullPage": true @@ -83,7 +149,7 @@ "interaction": [ { "action": "click", - "selector": ".ticket-case-card textarea" + "selector": ".ticket-thread-composer textarea" } ], "fullPage": true @@ -112,7 +178,7 @@ "interaction": [ { "action": "click", - "selector": ".ticket-case-card textarea" + "selector": ".ticket-thread-composer textarea" } ], "fullPage": true diff --git a/tools/web-ux/scenarios/tickets.json b/tools/web-ux/scenarios/tickets.json index c9b362fe1..9f65ef7ad 100644 --- a/tools/web-ux/scenarios/tickets.json +++ b/tools/web-ux/scenarios/tickets.json @@ -32,6 +32,72 @@ } ], "routes": [ + { + "id": "posts", + "label": "Discussion with initial post and edited replies", + "path": "/w/tickets-posts/tickets/T-900", + "goal": "Read and edit any discussion post without changing Ticket metadata", + "dataState": "Four realistic posts, distinct authors, original and edited times", + "ready": { + "kind": "selector", + "selector": ".ticket-timeline article:nth-of-type(4)" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Thread and metadata", + "fullPage": true + }, + { + "id": "edit-initial", + "label": "Same post editor on initial post", + "interaction": [ + { + "action": "click", + "selector": ".ticket-timeline article:first-of-type button" + } + ], + "fullPage": true + }, + { + "id": "later-posts", + "label": "Later discussion and edited attribution", + "interaction": [ + { + "action": "click", + "selector": ".ticket-timeline article:first-of-type button[type=button]" + }, + { + "action": "click", + "selector": ".ticket-timeline article:last-of-type header strong" + } + ], + "fullPage": true + }, + { + "id": "composer", + "label": "Thread-only comment and close footer", + "interaction": [ + { + "action": "click", + "selector": ".ticket-thread-composer textarea" + } + ], + "fullPage": true + }, + { + "id": "metadata", + "label": "Title editing separate from post bodies", + "interaction": [ + { + "action": "click", + "selector": ".ticket-metadata button" + } + ], + "fullPage": true + } + ] + }, { "id": "options", "label": "Return options without conclusion", @@ -54,7 +120,7 @@ "interaction": [ { "action": "click", - "selector": ".ticket-case-card textarea" + "selector": ".ticket-thread-composer textarea" } ], "fullPage": true @@ -83,7 +149,7 @@ "interaction": [ { "action": "click", - "selector": ".ticket-case-card textarea" + "selector": ".ticket-thread-composer textarea" } ], "fullPage": true @@ -112,7 +178,7 @@ "interaction": [ { "action": "click", - "selector": ".ticket-case-card textarea" + "selector": ".ticket-thread-composer textarea" } ], "fullPage": true diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index b7846bdab..701d49e01 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb) +// Input contract: 0.1.0 (sha256:1d6615147b8d7143484b9a2aaf163da6916fcabb7560c675c5b7bf4d94aa2214) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index d35ec20d0..50e7e2da7 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb) +// Input contract: 0.1.0 (sha256:1d6615147b8d7143484b9a2aaf163da6916fcabb7560c675c5b7bf4d94aa2214) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check @@ -137,7 +137,7 @@ export type TicketClosureJudgment = { at: string; author: string; explanation: s export type TicketClosureReason = "completed" | "not_planned" | "duplicate" | "unknown" | { other: string; }; -export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignment_tracking: Array; assignments: Array; body: string; body_truncated: boolean; closure_history: Array; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array; state: BrowserTicketState; tags: Array; targets: Array; title: string; updated_at?: string | null; }; +export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignment_tracking: Array; assignments: Array; body: string; body_truncated: boolean; closure_history: Array; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; created_by?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; initial_post: TicketEventDetail; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array; state: BrowserTicketState; tags: Array; targets: Array; title: string; updated_at?: string | null; }; export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; }; @@ -149,7 +149,7 @@ export type TicketDetailRelationNotice = { kind: string; message: string; relate export type TicketDetailRelationView = { blockers: Array; incoming: Array; notices: Array; outgoing: Array; }; -export type TicketEventDetail = { at?: string | null; attributes: { [key: string]: string; }; author?: string | null; body?: string | null; event_ref: string; from?: string | null; heading?: string | null; kind: string; reason?: string | null; references: Array; sequence: number; state_field?: string | null; status?: string | null; to?: string | null; }; +export type TicketEventDetail = { at?: string | null; attributes: { [key: string]: string; }; author?: string | null; body?: string | null; body_digest: string; body_truncated: boolean; editable: boolean; edited_at?: string | null; edited_by?: string | null; event_ref: string; from?: string | null; heading?: string | null; is_initial: boolean; kind: string; reason?: string | null; references: Array; sequence: number; state_field?: string | null; status?: string | null; to?: string | null; }; export type TicketEvidenceEvent = { at?: string | null; author?: string | null; event_ref: string; excerpt: string; kind: string; sequence: number; }; @@ -167,6 +167,8 @@ export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | nu export type TicketMergeRequestSummary = { current_subject_ref?: string | null; integration_evidence_error?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; source_ref_observation: TicketSourceRefObservation; state: string; updated_at: string; }; +export type TicketPostEditRequest = { body: string; expected_body_digest: string; }; + export type TicketQueryItem = { assignment_tracking: Array; blocker_count: number; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; evidence: TicketEvidenceSummary; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objective_ids: Array; linked_objective_keys: Array; matched_fields: Array; matching_event?: TicketEvidenceEvent | null; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; priority: string; readiness?: string | null; relation_count: number; resource_key: string; snippet?: string | null; state: BrowserTicketState; tags: Array; title: string; unresolved_blocker_count: number; unresolved_review_count: number; updated_at?: string | null; workspace_action_priority: string; }; export type TicketQueryRequest = { assigned_principal?: TicketAssignmentPrincipalSnapshot | null; attention?: Array; closure_reason?: TicketClosureReason | null; cursor?: string | null; event_kinds?: Array; evidence?: Array; handling_status?: TicketAssignmentTrackingStatus | null; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; linked_objective_id?: string | null; query?: string | null; related_ticket_id?: string | null; relation_kind?: string | null; review_status?: string | null; sort?: string | null; states?: Array; tag_ids?: Array; tag_ids_any?: Array; tag_ids_none?: Array; updated_after?: string | null; updated_before?: string | null; }; diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts index 23735a03b..e5c0c2cd6 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts @@ -2,7 +2,9 @@ declare const Deno: { test(name: string, fn: () => Promise | void): void; }; +import { fixturePost } from "../home/dashboard.test-fixtures.ts"; import { + parseTicketEvent, parseMergeRequestDetailResponse, parseMergeRequestListResponse, parseObjectiveListResponse, @@ -41,6 +43,33 @@ const page = { source_truncated: false, }; +Deno.test("Ticket post parser preserves author and edit metadata independently of pagination", () => { + const initial = fixturePost("Current initial post", { edited_at: "2026-10-13T10:00:00Z", edited_by: "Rin" }); + const parsed = parseTicketDetail({ ...ticketDetailFixture(), initial_post: initial, events: [], event_page: { ...page, returned: 0, has_more: true } }); + assertEquals(parsed.initial_post.author, initial.author); + assertEquals(parsed.initial_post.at, initial.at); + assertEquals(parsed.initial_post.edited_at, initial.edited_at); + assertEquals(parsed.initial_post.edited_by, "Rin"); + assertEquals(parsed.initial_post.body_digest, initial.body_digest); + assertEquals(parsed.initial_post.is_initial, true); + assertEquals(parsed.created_by, null); + assertEquals(parsed.events, []); +}); + +Deno.test("Ticket post parser rejects absent safety fields and unknown wire fields", () => { + for (const field of ["is_initial", "body_digest", "body_truncated", "editable"]) { + const wire: Record = { ...fixturePost("Post") }; + delete wire[field]; + assertThrows(() => parseTicketEvent(wire), field); + } + assertThrows(() => parseTicketEvent({ ...fixturePost("Post"), editable: "yes" }), "editable"); + assertThrows(() => parseTicketEvent({ ...fixturePost("Post"), edited_by: 7 }), "edited_by"); + assertThrows(() => parseTicketEvent({ ...fixturePost("Post"), surprise: true }), "unknown field surprise"); + const detail: Record = { ...ticketDetailFixture() }; + delete detail.initial_post; + assertThrows(() => parseTicketDetail(detail), "initial_post"); +}); + Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () => { const parsed = parseTicketListResponse({ workspace_id: "workspace-a", @@ -145,6 +174,8 @@ function ticketDetailFixture() { closure_operation_key: null, current_closure: null, legacy_handoff: null, + created_by: null, + initial_post: fixturePost("Body"), body: "Body", body_truncated: false, current_worker: null, diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.ts b/web/workspace/src/lib/workspace/api/ticket-browser.ts index ade684b98..b92635f4c 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.ts @@ -495,9 +495,15 @@ export function parseTicketListResponse(value: unknown): TicketListResponse { }; } -function parseTicketEvent(value: unknown, label: string): TicketEventDetail { +export function parseTicketEvent(value: unknown, label = "Ticket post"): TicketEventDetail { const item = object(value, label); exact(item, [ + "is_initial", + "edited_at", + "edited_by", + "body_digest", + "body_truncated", + "editable", "at", "attributes", "author", @@ -514,6 +520,12 @@ function parseTicketEvent(value: unknown, label: string): TicketEventDetail { "to", ], label); return { + is_initial: boolean(item.is_initial, `${label}.is_initial`), + edited_at: optionalNullableString(item.edited_at, `${label}.edited_at`), + edited_by: optionalNullableString(item.edited_by, `${label}.edited_by`), + body_digest: string(item.body_digest, `${label}.body_digest`), + body_truncated: boolean(item.body_truncated, `${label}.body_truncated`), + editable: boolean(item.editable, `${label}.editable`), at: optionalNullableString(item.at, `${label}.at`), attributes: stringMap(item.attributes, `${label}.attributes`), author: optionalNullableString(item.author, `${label}.author`), @@ -1098,6 +1110,8 @@ export function parseTicketDetail(value: unknown): TicketDetail { "closure_history", "body", "body_truncated", + "created_by", + "initial_post", "created_at", "current_worker", "event_count", @@ -1157,6 +1171,8 @@ export function parseTicketDetail(value: unknown): TicketDetail { ), body: string(item.body, `${label}.body`), body_truncated: boolean(item.body_truncated, `${label}.body_truncated`), + created_by: optionalNullableString(item.created_by, `${label}.created_by`), + initial_post: parseTicketEvent(item.initial_post, `${label}.initial_post`), created_at: optionalNullableString(item.created_at, `${label}.created_at`), current_worker: item.current_worker === undefined ? undefined diff --git a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts index 1818609cb..2954141bb 100644 --- a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts @@ -1,3 +1,15 @@ +import type { TicketEventDetail } from "../../generated/ticket-api.ts"; + +export function fixturePost(body: string, overrides: Partial = {}): TicketEventDetail { + return { + sequence: 0, event_ref: "post-initial", kind: "comment", is_initial: true, + author: "Mika", at: "2026-09-28T01:00:00Z", body, + edited_at: null, edited_by: null, body_digest: "b".repeat(64), + body_truncated: false, editable: true, attributes: {}, references: [], + ...overrides, + }; +} + // Deterministic read-only fixtures shared by Home component and browser tests. export const fixturePage = (returned: number, more = false) => ({ returned, @@ -42,6 +54,8 @@ export function fixtureDetail(key = "T-101") { closure_history: [], content_digest: "a".repeat(64), created_at: updated(1), + created_by: "Mika", + initial_post: fixturePost("Implementation in progress."), readiness: null, body: "Implementation in progress.", body_truncated: false, diff --git a/web/workspace/src/lib/workspace/styles/tickets.css b/web/workspace/src/lib/workspace/styles/tickets.css index cf6e83b02..64b8985b4 100644 --- a/web/workspace/src/lib/workspace/styles/tickets.css +++ b/web/workspace/src/lib/workspace/styles/tickets.css @@ -120,6 +120,7 @@ gap: var(--space-4); max-width: 88rem; } + .ticket-detail-header > button { flex-shrink: 0; } .ticket-detail-header h1 { margin: 0.45rem 0 0.25rem; color: var(--text-strong); @@ -173,6 +174,23 @@ background: var(--bg-raised); padding: var(--space-4); } + .ticket-detail-page > .ticket-detail-grid { + grid-template-columns: minmax(0, 1fr) 1px minmax(18rem, 21rem); + gap: var(--space-5); + } + .ticket-header-divider { margin-block: var(--space-4); } + .ticket-column-divider { align-self: stretch; } + .ticket-mobile-sidebar-divider { display: none; } + .ticket-detail-page :is(.ticket-detail-section, .ticket-control-card, .ticket-editor, .workspace-callout) { + border: 0; + border-radius: 0; + background: transparent; + padding: 0; + } + .ticket-detail-page :is(.ticket-detail-main, .ticket-control-rail) { + gap: var(--space-3); + overflow-wrap: anywhere; + } .ticket-section-heading, .ticket-control-card > header { justify-content: space-between; gap: var(--space-3); @@ -269,14 +287,22 @@ .ticket-relations-list a, .ticket-blocker-list a { display: grid; gap: 0.18rem; - border: 1px solid var(--line); - border-radius: 0.55rem; color: var(--text-strong); - padding: 0.6rem; + padding-block: var(--space-2); text-decoration: none; } - .ticket-blocker-list a { - border-color: var(--warning); + .ticket-relations-list a:hover, .ticket-blocker-list a:hover { + background: var(--interactive-hover); + } + .ticket-relations-list a:focus-visible, .ticket-blocker-list a:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .ticket-relations-list a:active, .ticket-blocker-list a:active { + background: var(--interactive-selected); + } + .ticket-blocker-list strong { + color: var(--warning); } .ticket-relations-list span, .ticket-relations-list small, @@ -284,56 +310,68 @@ color: var(--text-muted); font-size: var(--font-size-compact); } - .ticket-timeline { - display: grid; + .ticket-thread-composer { + margin-top: 0; + min-width: 0; } - .ticket-timeline article { - display: grid; - grid-template-columns: 0.65rem minmax(0, 1fr); - gap: 0.75rem; - min-height: 4rem; - } - .ticket-timeline-marker { - position: relative; - margin-top: 0.35rem; - width: 0.55rem; - height: 0.55rem; - border: 2px solid var(--accent); - border-radius: 50%; - } - .ticket-timeline-marker::after { - position: absolute; - top: 0.55rem; - bottom: -3.3rem; - left: 0.15rem; - width: 1px; - background: var(--line); - content: ""; - } - .ticket-timeline article:last-child .ticket-timeline-marker::after { - display: none; - } - .ticket-timeline header { + .ticket-comment-actions { display: flex; - justify-content: space-between; - gap: 0.75rem; + flex-wrap: wrap; + gap: var(--space-2); + } + .ticket-thread-composer details summary { + cursor: pointer; + color: var(--text-muted); + font-size: var(--font-size-compact); + } + .ticket-thread-composer details[open] > :not(summary) { + margin-top: var(--space-2); + } + .ticket-thread { min-width: 0; } + .ticket-timeline { display: grid; } + .ticket-timeline > article { min-width: 0; padding-block: var(--space-5); } + .ticket-timeline > article:first-child { padding-top: 0; } + .ticket-timeline header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: var(--space-2); + margin-bottom: var(--space-3); } .ticket-timeline time, .ticket-event-author { color: var(--text-muted); font-size: var(--font-size-compact); } - .ticket-event-author { - margin: 0.2rem 0; + .ticket-post-attribution { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); min-width: 0; overflow-wrap: anywhere; } + .ticket-post-edited { color: var(--text-muted); font-size: var(--font-size-compact); } + .ticket-metadata dl { display: grid; gap: var(--space-1); margin: 0 0 var(--space-3); } + .ticket-metadata dt { color: var(--text-muted); font-size: var(--font-size-compact); } + .ticket-metadata dd { margin: 0 0 var(--space-2); overflow-wrap: anywhere; } + .ticket-metadata .ticket-editor { margin-top: var(--space-3); } + .ticket-detail-page .ticket-control-rail summary { + cursor: pointer; + color: var(--text-strong); + font-size: var(--font-size-body); + font-weight: 600; + } + .ticket-control-rail details[open] > summary { margin-bottom: var(--space-3); } + .ticket-control-rail p { margin-block: var(--space-2); } + .ticket-control-rail .ticket-section-heading { flex-wrap: wrap; } + .ticket-control-rail .ticket-handling details { margin-block: var(--space-2); } + .ticket-detail-page .workspace-primary-button { + border-color: var(--text-strong); + background: var(--text-strong); + color: var(--bg); } @container ticket-detail (max-width: 48rem) { - .ticket-detail-grid { - grid-template-columns: minmax(0, 1fr); - } + .ticket-detail-page > .ticket-detail-grid { grid-template-columns: minmax(0, 1fr); } + .ticket-column-divider { display: none; } + .ticket-mobile-sidebar-divider { display: block; } } @media (max-width: 64rem) { - .ticket-detail-grid { - grid-template-columns: 1fr; - } + .ticket-detail-grid, .ticket-detail-page > .ticket-detail-grid { grid-template-columns: minmax(0, 1fr); } + .ticket-column-divider { display: none; } + .ticket-mobile-sidebar-divider { display: block; } } } diff --git a/web/workspace/src/lib/workspace/tickets/TicketHandling.browser.spec.ts b/web/workspace/src/lib/workspace/tickets/TicketHandling.browser.spec.ts new file mode 100644 index 000000000..59b249508 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/TicketHandling.browser.spec.ts @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import type { TicketAssignmentTrackingProjection, TicketDetail } from "#lib/generated/ticket-api.ts"; +import { fixtureDetail } from "#lib/workspace/home/dashboard.test-fixtures.ts"; +import TicketHandling from "./TicketHandling.svelte"; + +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); +const assignmentsPath = "/workspaces/team/tickets/internal-T-1/assignment-tracking/assignments?limit=20"; +const assignmentPath = "/api/w/team/tickets/internal-T-1/assignments/orchestrator"; +const answerPath = "/api/w/team/tickets/internal-T-1/assignment-answer"; +function assigned(status: "waiting" | "done" = "waiting"): TicketDetail { + const row: TicketAssignmentTrackingProjection = { + assignment_id: "assignment-1", role: "orchestrator", ticket_id: "internal-T-1", workspace_id: "team", + assigned_at: "2026-10-13T09:00:00Z", assigned_by: "Mika", current: true, + principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, last_event_id: "handling-1", + tracking: { status, waiting_reason: "Choose the policy", progress: "Compared options", next_action: "Confirm the choice", result: "Comparison delivered" }, + }; + return { ...fixtureDetail("T-1"), assignment_tracking: [row], + assignments: [{ assignment_id: row.assignment_id, role: row.role, principal: row.principal, assigned_at: row.assigned_at, assigned_by: row.assigned_by }], + action_eligibility: { ...fixtureDetail("T-1").action_eligibility, can_assign_orchestrator: true }, + }; +} +function installFetch(handle: (path: string, init?: RequestInit) => Response | Promise = () => { throw new Error("Unexpected request"); }) { + const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path === assignmentsPath) return Response.json({ items: [], next_assignment_id: null }); + return await handle(path, init); + }); + vi.stubGlobal("fetch", fetcher); + return fetcher; +} +function mount(ticket = assigned(), refresh = vi.fn(async () => {})) { + return { ...render(TicketHandling, { workspaceId: "team", ticket, orchestrator: null, refresh }), refresh }; +} +function expectNoDiagnostics(fetcher: ReturnType, view: ReturnType) { + expect(view.container.querySelector(".ticket-delivery")).toBeNull(); + expect(view.queryByText("Delivery and execution")).toBeNull(); + expect(view.queryByRole("button", { name: /delivery diagnostics/i })).toBeNull(); + expect(fetcher.mock.calls.map(([input]) => String(input)).some((path) => path.includes("assignment-diagnostics"))).toBe(false); +} + +test("mount retains assignment and self-reported handling, progress and result without delivery UI or diagnostics reads", async () => { + const fetcher = installFetch(); + const view = mount(); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + expect(view.getByText("Self-reported handling: waiting")).toBeTruthy(); + for (const text of ["Choose the policy", "Compared options", "Confirm the choice", "Comparison delivered", "Assignment details"]) expect(view.getByText(text)).toBeTruthy(); + expect(view.container.querySelector(".ticket-orchestrator-worker")).not.toBeNull(); + expectNoDiagnostics(fetcher, view); +}); + +test("assignment save refreshes assignment history without diagnostics or delivery/execution notice", async () => { + const fetcher = installFetch((path, init) => { + expect(path).toBe(assignmentPath); + expect(init?.method).toBe("PUT"); + return Response.json({ ticket_id: "internal-T-1", workspace_id: "team", assignment: { + assignment_id: "assignment-2", assigned_at: "2026-10-13T10:00:00Z", assigned_by: "Mika", role: "orchestrator", + ticket_id: "internal-T-1", workspace_id: "team", principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, + } }); + }); + const view = mount(assigned("done")); + await fireEvent.click(view.getByRole("button", { name: "Request again (new assignment)" })); + await waitFor(() => { + expect(fetcher).toHaveBeenCalledTimes(3); + expect(view.queryByText("Loading assignment history…")).toBeNull(); + }); + expect(view.refresh).toHaveBeenCalledTimes(1); + expect(view.getByRole("status").textContent).toBe("Assignment operation saved: assignment-2."); + expectNoDiagnostics(fetcher, view); +}); + +test("answer save keeps handling unchanged and refreshes history without diagnostics reads", async () => { + const fetcher = installFetch((path, init) => { + expect(path).toBe(answerPath); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toMatchObject({ assignment_id: "assignment-1", expected_last_event_id: "handling-1", body: "Use read-only comparison" }); + return Response.json({ assignment_id: "assignment-1", input_event_id: "input-1", event_index: 1 }); + }); + const view = mount(); + await fireEvent.input(view.getByRole("textbox", { name: "Answer / continue this handling" }), { target: { value: "Use read-only comparison" } }); + await fireEvent.click(view.getByRole("button", { name: "Send answer to this assignment" })); + await waitFor(() => { + expect(fetcher).toHaveBeenCalledTimes(3); + expect(view.queryByText("Loading assignment history…")).toBeNull(); + }); + expect(view.refresh).toHaveBeenCalledTimes(1); + expect(view.getByRole("status").textContent).toContain("Handling status is unchanged."); + expect(view.getByText("Self-reported handling: waiting")).toBeTruthy(); + expectNoDiagnostics(fetcher, view); +}); + +test("uncertain assignment reconciliation only refreshes the projection and assignment history", async () => { + const fetcher = installFetch(() => { throw new Error("Response lost"); }); + const view = mount(assigned("done")); + await fireEvent.click(view.getByRole("button", { name: "Request again (new assignment)" })); + await waitFor(() => expect(view.getByRole("alert").textContent).toContain("Outcome unknown")); + await fireEvent.click(view.getByRole("button", { name: "Refresh and reconcile assignment" })); + await waitFor(() => { + expect(fetcher).toHaveBeenCalledTimes(3); + expect(view.queryByText("Loading assignment history…")).toBeNull(); + }); + expect(view.refresh).toHaveBeenCalledTimes(1); + expect(view.queryByRole("alert")).toBeNull(); + expectNoDiagnostics(fetcher, view); +}); diff --git a/web/workspace/src/lib/workspace/tickets/TicketHandling.svelte b/web/workspace/src/lib/workspace/tickets/TicketHandling.svelte index de6df2070..9e5356378 100644 --- a/web/workspace/src/lib/workspace/tickets/TicketHandling.svelte +++ b/web/workspace/src/lib/workspace/tickets/TicketHandling.svelte @@ -1,10 +1,11 @@

Orchestrator handling

All handling
{#if current}

Self-reported handling: {current.tracking?.status ?? "unknown"}

-

Principal: {current.principal.kind === "workspace_agent" ? current.principal.agent_key : current.principal.kind === "worker" ? `${current.principal.runtime_id}/${current.principal.worker_id}` : current.principal.account_id}

-

Assignment {current.assignment_id} · assigned by {current.assigned_by} · {formatDate(current.assigned_at)}

{#if current.tracking}
{#if current.tracking.waiting_reason}
Waiting reason
{current.tracking.waiting_reason}
{/if} @@ -137,7 +114,10 @@ {#if current.updated_at}

Updated {formatDate(current.updated_at)}

{/if} {#if current.migration_diagnostic}

{current.migration_diagnostic}

{/if} {#if current.tracking?.status === "done"}

This handling was returned. Ticket {ticket.state} is unchanged.

{/if} - +
Assignment details +

Principal: {current.principal.kind === "workspace_agent" ? current.principal.agent_key : current.principal.kind === "worker" ? `${current.principal.runtime_id}/${current.principal.worker_id}` : current.principal.account_id}

+

Assignment {current.assignment_id} · assigned by {current.assigned_by} · {formatDate(current.assigned_at)}

+
{:else}

No current Orchestrator assignment.

{/if} {#if error}{/if} {#if savedNotice}

{savedNotice}

{/if} @@ -175,6 +155,7 @@ {/if}
Past assignment and release history + {#if current}{/if} {#if assignmentPageBusy}

Loading assignment history…

{/if} {#each assignments?.items ?? [] as row (row.assignment_id)}

{row.assignment_id}{#if row.release} · {row.release.action}{/if}{#if row.tracking?.result} · {row.tracking.result}{/if}

{/each} {#if assignments?.next_assignment_id}{/if} @@ -188,32 +169,17 @@ {#if history}{#each history.items as entry (entry.event_id)}

{entry.tracking.status} · {formatDate(entry.at)}

{#if entry.tracking.waiting_reason}

Waiting reason: {entry.tracking.waiting_reason}

{/if}{#if entry.tracking.progress}

Progress: {entry.tracking.progress}

{/if}{#if entry.tracking.next_action}

Next action: {entry.tracking.next_action}

{/if}{#if entry.tracking.result}

Result: {entry.tracking.result}

{/if}
{/each}{#if history.next_event_id}{/if}{/if}
-
-

Delivery and execution

-

Assignment persistence: {current ? "Saved: " + current.assignment_id : "No current assignment"}

- {#if current}{/if} - {#if diagnosticError}{/if} - {#if diagnosticBusy}

Loading delivery diagnostics…

{/if} - {#if diagnostic} -

Assignment {diagnostic.assignment_id} · {diagnostic.actionable ? "actionable input" : "retained history / not actionable"}

- {#if !diagnostic.input_origin_known}

Input origin unknown; not inferred from handling.

{/if} - {#each diagnostic.inputs as input (input.input_event_id)} -

Input {input.input_event_id}

Notification
{input.notification.replaceAll("_", " ")}
Runtime acceptance
{input.notification === "runtime_accepted" ? "Accepted immutable notification" : input.acceptance_outcome_unknown ? "Outcome unknown — may already be accepted" : "No acceptance acknowledgement observed"}
- {:else}

No delivery input records in this page.

{/each} -

Work start / admission evidence

- {#each diagnostic.starts as start (start.operation_id)}

{start.operation_id}: {start.claim_state} · {start.runtime_id ?? "runtime unknown"}/{start.worker_id ?? "worker unknown"}{#if start.actual_processing_unknown} · actual model processing not attested{/if}

{:else}

No work start records observed. This is not inferred from Worker state.

{/each} - {#if diagnostic.next_input_event_id || diagnostic.next_start_operation_id}{/if} - {:else}

Delivery / Runtime acceptance / work start not observed.

{/if} -
-
-

Orchestrator Worker

+ +
+ Orchestrator Worker {#if orchestrator?.worker}

{orchestrator.worker.runtime_id}/{orchestrator.worker.worker_id} · actual state: {orchestrator.worker.state} · {orchestrator.online ? "online" : "offline"}

Open Worker{:else}

Worker observation unavailable. This does not change handling status.

{/if} -
+ diff --git a/web/workspace/src/lib/workspace/tickets/TicketPost.browser.spec.ts b/web/workspace/src/lib/workspace/tickets/TicketPost.browser.spec.ts new file mode 100644 index 000000000..419e59501 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/TicketPost.browser.spec.ts @@ -0,0 +1,94 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import type { TicketEventDetail } from "#lib/generated/ticket-api.ts"; +import TicketPost from "./TicketPost.svelte"; + +afterEach(cleanup); +function post(overrides: Partial = {}): TicketEventDetail { + return { sequence: 0, event_ref: "post-1", kind: "comment", is_initial: true, + author: "Mika", at: "2026-10-12T09:00:00Z", body: "Original request", + editable: true, body_truncated: false, body_digest: "a".repeat(64), edited_at: null, edited_by: null, + attributes: {}, references: [], ...overrides }; +} + +test.each([true, false])("initial=%s has the same author, timestamp and body-only edit action", async (is_initial) => { + const value = post({ is_initial }); + const save = vi.fn(async () => {}); + const load = vi.fn(async () => value); + const view = render(TicketPost, { post: value, busy: false, load, save }); + expect(view.getByText("Mika")).toBeTruthy(); + expect(view.container.querySelector("time")?.dateTime).toBe(value.at); + await fireEvent.click(view.getByRole("button", { name: "Edit post" })); + expect(view.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", value.body); + expect(view.queryByRole("textbox", { name: "Title" })).toBeNull(); + await fireEvent.input(view.getByRole("textbox", { name: "Post body" }), { target: { value: "Revised request" } }); + await fireEvent.click(view.getByRole("button", { name: "Save post" })); + await waitFor(() => expect(save).toHaveBeenCalledWith(value, "Revised request")); + expect(load).not.toHaveBeenCalled(); +}); + +test("non-editable and unknown-author posts do not infer authorship or editing permission", () => { + const value = post({ editable: false, author: null, at: null }); + const view = render(TicketPost, { post: value, busy: false, load: async () => value, save: async () => {} }); + expect(view.getByText("Unknown author")).toBeTruthy(); + expect(view.getByText("Unknown time")).toBeTruthy(); + expect(view.queryByRole("button", { name: "Edit post" })).toBeNull(); +}); + +test("edited metadata is separate from the preserved original author and time", () => { + const value = post({ edited_at: "2026-10-13T11:30:00Z", edited_by: "Rin" }); + const view = render(TicketPost, { post: value, busy: false, load: async () => value, save: async () => {} }); + expect(view.getByText("Mika")).toBeTruthy(); + expect(view.container.querySelector(".ticket-post-edited")?.textContent).toContain("by Rin"); + expect(Array.from(view.container.querySelectorAll("time")).map((time) => time.dateTime)).toEqual([value.at, value.edited_at]); +}); + +test.each(["close", "state_changed", "assignment_answer", "plan", "decision", "implementation_report"])("immutable %s records never offer post editing", (kind) => { + const value = post({ kind, is_initial: false }); + const view = render(TicketPost, { post: value, busy: false, load: async () => value, save: async () => {} }); + expect(view.queryByRole("button", { name: "Edit post" })).toBeNull(); +}); + +test("truncated post fetches the full body and its observation before editing", async () => { + const preview = post({ body: "Short preview", body_truncated: true }); + const full = post({ body: "Complete post including the unseen ending", body_digest: "b".repeat(64) }); + const save = vi.fn(async () => {}); + const load = vi.fn(async () => full); + const view = render(TicketPost, { post: preview, busy: false, load, save }); + expect(view.queryByRole("textbox")).toBeNull(); + await fireEvent.click(view.getByRole("button", { name: "Edit post" })); + await waitFor(() => expect(view.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", full.body)); + await fireEvent.click(view.getByRole("button", { name: "Save post" })); + await waitFor(() => expect(save).toHaveBeenCalledWith(full, full.body)); + expect(load).toHaveBeenCalledWith(preview); +}); + +test("a failed or still-truncated full-post read cannot open a partial editor", async () => { + const preview = post({ body: "Short preview", body_truncated: true }); + const save = vi.fn(async () => {}); + const view = render(TicketPost, { post: preview, busy: false, load: async () => preview, save }); + await fireEvent.click(view.getByRole("button", { name: "Edit post" })); + await waitFor(() => expect(view.getByRole("alert").textContent).toContain("No partial text can be saved")); + expect(view.queryByRole("textbox")).toBeNull(); + expect(save).not.toHaveBeenCalled(); +}); + +test("conflicts retain the draft and stale digest until explicit discard and reload", async () => { + const value = post(); + const current = post({ body: "Another editor's update", body_digest: "c".repeat(64) }); + const save = vi.fn(async () => { throw new Error("HTTP (409)"); }); + const load = vi.fn(async () => current); + const view = render(TicketPost, { post: value, busy: false, load, save }); + await fireEvent.click(view.getByRole("button", { name: "Edit post" })); + await fireEvent.input(view.getByRole("textbox", { name: "Post body" }), { target: { value: "My draft" } }); + await fireEvent.click(view.getByRole("button", { name: "Save post" })); + await waitFor(() => expect(view.getByRole("alert").textContent).toContain("Post update conflict")); + expect(view.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", "My draft"); + expect(view.getByRole("button", { name: "Save post" })).toHaveProperty("disabled", true); + expect(save).toHaveBeenCalledWith(value, "My draft"); + expect(load).not.toHaveBeenCalled(); + await fireEvent.click(view.getByRole("button", { name: "Discard draft and load current post" })); + await waitFor(() => expect(view.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", current.body)); + expect(view.getByRole("button", { name: "Save post" })).toHaveProperty("disabled", false); +}); diff --git a/web/workspace/src/lib/workspace/tickets/TicketPost.svelte b/web/workspace/src/lib/workspace/tickets/TicketPost.svelte new file mode 100644 index 000000000..dffb933e6 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/TicketPost.svelte @@ -0,0 +1,121 @@ + + +
+
+
+ {displayed.author ?? "Unknown author"} + {#if displayed.kind !== "comment"}{displayed.heading ?? relationLabel(displayed.kind)}{/if} + + {#if displayed.edited_at} + Edited by {displayed.edited_by ?? "Unknown editor"} + {/if} +
+ {#if editable && !editing} + + {/if} +
+ {#if editing} +
+ +
+ + +
+ {#if reconcile}{/if} +
+ {:else} + {#if displayed.from || displayed.to}

{displayed.from ?? "—"} → {displayed.to ?? "—"}

{/if} + {#if displayed.reason && displayed.reason !== displayed.body}{/if} + {#if displayed.body}{:else if displayed.kind === "comment"}

Empty post.

{/if} + {#each displayed.references as reference}

{reference}

{/each} + {#if displayed.body_truncated} +

This post is shortened.

+ + {/if} + {/if} + {#if error}{/if} +
diff --git a/web/workspace/src/lib/workspace/tickets/TicketTags.svelte b/web/workspace/src/lib/workspace/tickets/TicketTags.svelte index c441e9014..7734f87db 100644 --- a/web/workspace/src/lib/workspace/tickets/TicketTags.svelte +++ b/web/workspace/src/lib/workspace/tickets/TicketTags.svelte @@ -92,8 +92,9 @@