fix: complete Ticket post integration and expose targets

Preserve ordered targets in Workspace ShowTicket and native reads. Complete authenticated post-edit transport, shared dispatch, generated contracts and regression fixtures. Keep canonical content-edit delivery inputs transactional and separate from discussion posts.
This commit is contained in:
2026-10-14 14:49:20 +09:00
parent 8611078058
commit da378fc902
22 changed files with 1311 additions and 178 deletions
@@ -0,0 +1,57 @@
-- Schema 94 operational inputs: canonical edits no longer append Ticket events.
-- Record input identity in the content transaction, never reconstruct it from
-- mutable text/timestamps or turn an old migrated edit into a new delegation.
CREATE TABLE ticket_assignment_content_inputs (
input_sequence INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
assignment_id TEXT NOT NULL,
after_ticket_event_index INTEGER NOT NULL,
FOREIGN KEY(workspace_id,ticket_id,assignment_id)
REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id)
);
CREATE TRIGGER ticket_assignment_content_input_no_update
BEFORE UPDATE ON ticket_assignment_content_inputs
BEGIN SELECT RAISE(ABORT,'Assignment content input is immutable'); END;
CREATE TRIGGER ticket_assignment_content_input_no_delete
BEFORE DELETE ON ticket_assignment_content_inputs
BEGIN SELECT RAISE(ABORT,'Assignment content input is immutable'); END;
CREATE TRIGGER ticket_assignment_title_input
AFTER UPDATE OF title ON typed_tickets
WHEN NEW.title IS NOT OLD.title
BEGIN
INSERT INTO ticket_assignment_content_inputs
(workspace_id,ticket_id,assignment_id,after_ticket_event_index)
SELECT a.workspace_id,a.ticket_id,a.assignment_id,
COALESCE((SELECT MAX(event_index) FROM typed_ticket_events
WHERE workspace_id=a.workspace_id AND ticket_id=a.ticket_id),-1)
FROM ticket_current_worker_assignments c
JOIN ticket_worker_assignments a USING(workspace_id,ticket_id,assignment_id)
JOIN ticket_assignment_delivery_origins o USING(workspace_id,ticket_id,assignment_id)
WHERE a.workspace_id=NEW.workspace_id AND a.ticket_id=NEW.ticket_id
AND a.role='orchestrator' AND a.principal_kind='workspace_agent'
AND a.principal_id='workspace-orchestrator' AND NEW.workflow_state='open'
AND json_extract(a.tracking_json,'$.status') IN ('waiting','inprogress')
AND NOT EXISTS(SELECT 1 FROM ticket_assignment_work_releases r
WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id);
END;
CREATE TRIGGER ticket_assignment_initial_post_input
AFTER UPDATE OF body ON typed_ticket_events
WHEN NEW.event_index=-1 AND NEW.body IS NOT OLD.body
BEGIN
INSERT INTO ticket_assignment_content_inputs
(workspace_id,ticket_id,assignment_id,after_ticket_event_index)
SELECT a.workspace_id,a.ticket_id,a.assignment_id,
COALESCE((SELECT MAX(event_index) FROM typed_ticket_events
WHERE workspace_id=a.workspace_id AND ticket_id=a.ticket_id),-1)
FROM ticket_current_worker_assignments c
JOIN ticket_worker_assignments a USING(workspace_id,ticket_id,assignment_id)
JOIN ticket_assignment_delivery_origins o USING(workspace_id,ticket_id,assignment_id)
JOIN typed_tickets t USING(workspace_id,ticket_id)
WHERE a.workspace_id=NEW.workspace_id AND a.ticket_id=NEW.ticket_id
AND a.role='orchestrator' AND a.principal_kind='workspace_agent'
AND a.principal_id='workspace-orchestrator' AND t.workflow_state='open'
AND json_extract(a.tracking_json,'$.status') IN ('waiting','inprogress')
AND NOT EXISTS(SELECT 1 FROM ticket_assignment_work_releases r
WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id);
END;
@@ -126,6 +126,12 @@ WHERE json_extract(h.source_context_json,'$.kind')='migration'
Ok(())
}
/// Additive schema94 composition; keep the applied schema93 install unchanged.
pub(super) fn install_post_inputs(conn: &Connection) -> Result<()> {
conn.execute_batch(include_str!("assignment_content_inputs.sql"))?;
Ok(())
}
const INPUTS_SQL: &str = r#"
WITH eligible AS (
SELECT a.*,o.input_event_id,o.after_ticket_event_index
@@ -152,6 +158,10 @@ WITH eligible AS (
AND e.kind NOT LIKE 'assignment_delivery%'
AND COALESCE(e.state_field,'') NOT IN ('case','routing','assignment_tracking')
AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value<>a.assignment_id)
UNION ALL
SELECT a.ticket_id,a.assignment_id,'ticket-edit:' || printf('%020d',e.input_sequence),a.tracking_json,e.after_ticket_event_index+1
FROM eligible a JOIN ticket_assignment_content_inputs e
ON e.workspace_id=a.workspace_id AND e.ticket_id=a.ticket_id AND e.assignment_id=a.assignment_id
)
SELECT i.ticket_id,i.assignment_id,i.input_event_id,
json_extract(i.tracking_json,'$.waiting_reason'),json_extract(i.tracking_json,'$.next_action'),d.payload_json
@@ -180,7 +190,9 @@ fn inputs_in_tx(
assignment_id: Option<&str>,
input_event_id: Option<&str>,
) -> Result<Vec<TicketAssignmentDelivery>> {
let sql = format!("{INPUTS_SQL} ORDER BY i.ticket_id,i.assignment_id,i.input_order LIMIT ?2");
let sql = format!(
"{INPUTS_SQL} ORDER BY i.ticket_id,i.assignment_id,i.input_order,i.input_event_id LIMIT ?2"
);
let mut statement = conn.prepare(&sql)?;
let rows = statement.query_map(
params![workspace_id, limit as i64, assignment_id, input_event_id],
@@ -404,12 +416,15 @@ pub(super) fn save_operation_fence(
// Same input selection as delivery, plus immutable delivered history. Never
// SELECT payload/source/content into the public diagnostic projection.
const DIAGNOSTIC_INPUTS_SQL: &str = r#"
WITH inputs AS (
WITH raw_inputs AS (
SELECT o.input_event_id,-1 AS input_order FROM ticket_assignment_delivery_origins o
WHERE o.workspace_id=?1 AND o.assignment_id=?3
UNION
SELECT d.input_event_id,CASE WHEN d.input_event_id LIKE 'ticket-event:%' THEN CAST(substr(d.input_event_id,14) AS INTEGER) ELSE -1 END
FROM ticket_assignment_deliveries d WHERE d.workspace_id=?1 AND d.assignment_id=?3
SELECT d.input_event_id,COALESCE(e.after_ticket_event_index+1,CASE WHEN d.input_event_id LIKE 'ticket-event:%' THEN CAST(substr(d.input_event_id,14) AS INTEGER) ELSE -1 END)
FROM ticket_assignment_deliveries d
LEFT JOIN ticket_assignment_content_inputs e ON e.workspace_id=d.workspace_id AND e.assignment_id=d.assignment_id
AND d.input_event_id='ticket-edit:' || printf('%020d',e.input_sequence)
WHERE d.workspace_id=?1 AND d.assignment_id=?3
UNION
SELECT 'ticket-event:' || e.event_index,e.event_index
FROM ticket_assignment_delivery_origins o JOIN typed_ticket_events e ON e.workspace_id=o.workspace_id AND e.ticket_id=o.ticket_id
@@ -425,6 +440,11 @@ WITH inputs AS (
JOIN ticket_worker_assignments original ON original.workspace_id=o.workspace_id AND original.assignment_id=o.assignment_id
WHERE later.workspace_id=o.workspace_id AND later.ticket_id=o.ticket_id AND la.rowid>original.rowid),9223372036854775807)
AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value<>?3)
UNION
SELECT 'ticket-edit:' || printf('%020d',input_sequence),after_ticket_event_index+1
FROM ticket_assignment_content_inputs WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3
), inputs AS (
SELECT input_event_id,ROW_NUMBER() OVER (ORDER BY input_order,input_event_id) AS input_order FROM raw_inputs
)
"#;
@@ -6,7 +6,7 @@ fn delivery_store_external_event(
state_field: Option<&str>,
) {
store.with_conn(|conn| {
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,state_field,body) VALUES('w','tracking',?1,?2,'user','2026-10-12T00:00:00Z',?3,'Answer arrived')",params![index,kind,state_field])?;
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,author,at,state_field,body) VALUES('w','tracking',?1,'tracking:' || ?1,?2,'user','2026-10-12T00:00:00Z',?3,'Answer arrived')",params![index,kind,state_field])?;
Ok(())
}).unwrap();
}
@@ -651,7 +651,7 @@ fn assignment_delivery_schema92_upgrade_uses_reconciled_migration_evidence_not_l
);
store
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 93);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert_eq!(
stored_fixture_rows(conn, "ticket_worker_assignments"),
assignments
@@ -1033,3 +1033,120 @@ async fn assignment_diagnostic_start_cursor_pages_existing_claims_without_input_
let wire = serde_json::to_string(&first).unwrap();
assert!(!wire.contains("tracking-binding") && !wire.contains("External resource"));
}
#[tokio::test]
async fn assignment_content_edits_are_atomic_durable_inputs_without_appending_posts() {
use ticket::TicketBackend;
let (directory, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "a", "assign-a");
let source = tracking_store_source();
let initial = delivery_store_reserve_initial(&store, &source);
store.ack_ticket_assignment_delivery("w", &initial.delivery_id, &source).unwrap();
let path = directory.path().join("workspace.db");
let backend = ticket::SqliteTicketBackend::open_verified(&path, "w").unwrap();
let original = backend.show("tracking".into()).unwrap();
backend.edit_item("tracking".into(), ticket::TicketItemEdit {
title: Some("Changed title".into()), ..Default::default()
}).unwrap();
backend.edit_item("tracking".into(), ticket::TicketItemEdit {
title: Some("Changed title".into()), ..Default::default()
}).unwrap();
let mut latest = backend.show("tracking".into()).unwrap();
for body in ["Changed requirements", "Final requirements"] {
backend.edit_post("tracking".into(), ticket::TicketPostEdit {
event_ref: latest.events[0].event_ref.clone(),
expected_body: latest.document.body.clone(),
body: ticket::MarkdownText::new(body), author: "fixture-editor".into(),
}).unwrap();
latest = backend.show("tracking".into()).unwrap();
}
assert_eq!(latest.events.len(), original.events.len());
assert_eq!(latest.events[0].event_ref, original.events[0].event_ref);
backend.add_event("tracking".into(), ticket::NewTicketEvent::new(
ticket::TicketEventKind::Comment, "Discussion",
)).unwrap();
let comment = backend.show("tracking".into()).unwrap().events.last().unwrap().clone();
backend.edit_post("tracking".into(), ticket::TicketPostEdit {
event_ref: comment.event_ref, expected_body: comment.body,
body: ticket::MarkdownText::new("Edited discussion"), author: "fixture-editor".into(),
}).unwrap();
// Input insertion participates in the content write, including rollback.
store.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
tx.execute("UPDATE typed_tickets SET title='Rolled back' WHERE ticket_id='tracking'", [])?;
tx.rollback()?;
assert!(conn.execute("UPDATE ticket_assignment_content_inputs SET assignment_id='other'", []).is_err());
assert!(conn.execute("DELETE FROM ticket_assignment_content_inputs", []).is_err());
Ok(())
}).unwrap();
let pending = store.list_pending_ticket_assignment_deliveries("w", 100).unwrap();
assert_eq!(pending.len(), 3, "no-op and discussion edits are not new request inputs");
assert!(pending.iter().all(|input| input.input_event_id.starts_with("ticket-edit:")));
let mut cursor = None;
let mut diagnostics = Vec::new();
loop {
let page = store.page_ticket_assignment_diagnostics("w", "tracking", "a", cursor.as_deref(), None, 1).unwrap();
diagnostics.extend(page.inputs.into_iter().map(|input| input.input_event_id));
cursor = page.next_input_event_id;
if cursor.is_none() { break; }
}
assert_eq!(diagnostics, std::iter::once(initial.input_event_id.clone())
.chain(pending.iter().map(|input| input.input_event_id.clone())).collect::<Vec<_>>());
drop(store);
let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap();
assert_eq!(reopened.list_pending_ticket_assignment_deliveries("w", 100).unwrap(), pending);
for input in pending {
let reserved = reopened.reserve_ticket_assignment_delivery("w", "a", &input.input_event_id, &source).unwrap();
assert_eq!(reserved, input);
reopened.ack_ticket_assignment_delivery("w", &reserved.delivery_id, &source).unwrap();
}
assert!(reopened.list_pending_ticket_assignment_deliveries("w", 100).unwrap().is_empty());
}
#[tokio::test]
async fn assignment_content_edits_never_revive_done_closed_or_reissued_delegations() {
for change in ["done", "close-reopen", "reissue"] {
let (directory, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "a", "assign-a");
let source = tracking_store_source();
let initial = delivery_store_reserve_initial(&store, &source);
store.ack_ticket_assignment_delivery("w", &initial.delivery_id, &source).unwrap();
store.with_conn(|conn| {
conn.execute("UPDATE typed_tickets SET title='Input before authority change' WHERE ticket_id='tracking'", [])?;
Ok(())
}).unwrap();
let old = store.list_pending_ticket_assignment_deliveries("w", 100).unwrap().remove(0);
match change {
"done" => { store.update_ticket_assignment_tracking("w", "tracking", &tracking_store_input("a", "event-assign-a", "done-a"), &source).unwrap(); }
"close-reopen" => { store.with_conn(|conn| { conn.execute_batch("UPDATE typed_tickets SET workflow_state='closed' WHERE ticket_id='tracking'; UPDATE typed_tickets SET workflow_state='open' WHERE ticket_id='tracking';")?; Ok(()) }).unwrap(); }
_ => { tracking_store_assign(&store, "b", "assign-b"); }
}
use ticket::TicketBackend;
let backend = ticket::SqliteTicketBackend::open_verified(directory.path().join("workspace.db"), "w").unwrap();
backend.edit_item("tracking".into(), ticket::TicketItemEdit {
body: Some(ticket::MarkdownText::new("New requirements")), author: Some("fixture-editor".into()), ..Default::default()
}).unwrap();
let pending = store.list_pending_ticket_assignment_deliveries("w", 100).unwrap();
if change == "reissue" {
assert_eq!(pending.len(), 2, "new delegation and its content edit only");
assert!(pending.iter().all(|input| input.assignment_id == "b"));
} else { assert!(pending.is_empty(), "{change}"); }
assert!(store.reserve_ticket_assignment_delivery("w", "a", &old.input_event_id, &source).is_err());
let page = store.page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 100).unwrap();
assert!(!page.actionable);
assert_eq!(page.inputs.len(), 2, "retain old input evidence without creating new authority");
}
}
#[tokio::test]
async fn assignment_content_input_missing_guards_refuse_startup() {
for trigger in ["ticket_assignment_title_input", "ticket_assignment_initial_post_input", "ticket_assignment_content_input_no_update", "ticket_assignment_content_input_no_delete"] {
let (directory, store) = tracking_store_fixture().await;
store.with_conn(|conn| { conn.execute_batch(&format!("DROP TRIGGER {trigger}"))?; Ok(()) }).unwrap();
drop(store);
let error = match SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")) {
Ok(_) => panic!("missing {trigger} was accepted"), Err(error) => error,
};
assert!(error.to_string().contains(trigger), "{error}");
}
}
+79 -16
View File
@@ -762,12 +762,19 @@ impl SqliteWorkspaceAuthority {
let id = ticket.meta.id.as_str();
let dependency_check =
dependency_backend.dependency_check(TicketIdOrSlug::Id(id.to_string()))?;
let initial = ticket.events.iter().find(|event| event.is_initial)
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::<Vec<_>>();
let subsequent_events = ticket
.events
.iter()
.filter(|event| !event.is_initial)
.collect::<Vec<_>>();
let event_limit = request
.event_limit
.unwrap_or(TICKET_EVENT_LIMIT)
@@ -1787,11 +1794,21 @@ fn ticket_event_ref(sequence: usize, event: &TicketEvent) -> String {
pub(crate) fn ticket_post_body_digest(body: &str) -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(body.as_bytes()))
Sha256::digest(body.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
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 });
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(),
@@ -1946,6 +1963,11 @@ pub(crate) fn merge_request_summary(
}
fn substantive_item_edit(event: &TicketEvent) -> bool {
// Initial-comment edits replace requirements without appending an item_edit event.
// Discussion-comment edits do not change the Ticket's requirement scope.
if event.is_initial && event.edited_at.is_some() {
return true;
}
if event.kind.as_str() != "item_edit" {
return false;
}
@@ -3041,6 +3063,10 @@ mod tests {
attributes.insert("changes".to_string(), changes.to_string());
}
TicketEvent {
event_ref: format!("event:{kind}:{at}"),
is_initial: false,
edited_at: None,
edited_by: None,
kind: ticket::TicketEventKind::Other(kind.to_string()),
author: Some("coder".to_string()),
at: Some(at.to_string()),
@@ -3056,6 +3082,40 @@ mod tests {
}
}
#[test]
fn edited_initial_post_marks_rescope_but_discussion_edits_do_not() {
let evidence = TicketEvidenceSummary {
missing: vec!["review_after_rescope".into()],
..Default::default()
};
let mut post = ticket_event("comment", "2026-10-13T00:00:00Z", None);
post.kind = ticket::TicketEventKind::Comment;
post.is_initial = true;
assert!(!ticket_attention_matches(
"open",
&evidence,
false,
&[post.clone()],
"stale_after_rescope"
));
post.edited_at = Some("2026-10-13T01:00:00Z".into());
assert!(ticket_attention_matches(
"open",
&evidence,
false,
&[post.clone()],
"stale_after_rescope"
));
post.is_initial = false;
assert!(!ticket_attention_matches(
"open",
&evidence,
false,
&[post],
"stale_after_rescope"
));
}
fn unavailable_source(
code: &str,
) -> std::result::Result<String, server_api::MergeRequestRefDiagnostic> {
@@ -3615,30 +3675,29 @@ mod tests {
.execute_batch(
r#"
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
created_at, updated_at, workflow_state, workflow_state_explicit
) VALUES
('workspace-test', '00000000001J2', '00000000001J2', 'Read bridge', 'open', 'ticket', 'P2',
'Ticket body. Deep Ticket marker. This body is intentionally long enough for bounded projection tests.',
'2026-01-01T00:00:00Z', '2026-01-03T00:00:00Z', 'open', 1),
('workspace-test', '00000000001J5', '00000000001J5', 'Second ticket', 'open', 'ticket', 'P2',
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z', 'open', 1),
'2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z', 'open', 1),
('workspace-test', '00000000001J6', '00000000001J6', 'Third ticket', 'open', 'ticket', 'P2',
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 'open', 1);
'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 'open', 1);
INSERT INTO typed_ticket_events (
workspace_id, ticket_id, event_index, kind, author, at, heading, body
workspace_id, ticket_id, event_index, event_ref, kind, author, at, body
) VALUES
('workspace-test', '00000000001J2', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created'),
('workspace-test', '00000000001J5', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created'),
('workspace-test', '00000000001J6', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created');
('workspace-test', '00000000001J2', -1, '00000000001J2:-1', 'comment', 'test', '2026-01-01T00:00:00Z', 'Ticket body'),
('workspace-test', '00000000001J5', -1, '00000000001J5:-1', 'comment', 'test', '2026-01-01T00:00:00Z', 'Ticket body'),
('workspace-test', '00000000001J6', -1, '00000000001J6:-1', 'comment', 'test', '2026-01-01T00:00:00Z', 'Ticket body');
"#,
)
.unwrap();
rusqlite::Connection::open(&db_path)
.unwrap()
.execute(
"UPDATE typed_tickets SET body = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2",
"UPDATE typed_ticket_events SET body = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = -1",
rusqlite::params![
"workspace-test",
"00000000001J2",
@@ -4044,11 +4103,15 @@ VALUES ('workspace-test', 'ticket', 4);
.execute_batch(
r#"
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
workflow_state, workflow_state_explicit
) VALUES
('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'open', 1),
('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'open', 1);
('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', 'open', 1),
('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', 'open', 1);
INSERT INTO typed_ticket_events (workspace_id, ticket_id, event_index, event_ref, kind, body)
VALUES
('workspace-test', '00000000001J2', -1, '00000000001J2:-1', 'comment', ''),
('workspace-test', '00000000001J3', -1, '00000000001J3:-1', 'comment', '');
INSERT INTO workspace_resource_keys (
workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at
) VALUES
+592 -94
View File
@@ -6948,6 +6948,15 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::ticket_orchestration_plan_record(service.clone()))
.merge(server_api::server_api_axum::ticket_get(service.clone()))
.merge(server_api::server_api_axum::ticket_edit(service.clone()))
.merge(server_api::server_api_axum::ticket_post_get(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_post_edit(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_record_post_edit(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_show(service.clone()))
.merge(server_api::server_api_axum::ticket_assignment_list(
service.clone(),
@@ -10286,12 +10295,19 @@ impl server_api::ServerApi for ServerApiContractService {
request: server_api::CreateTicketRecordRequest,
) -> std::result::Result<server_api::TicketRecordRef, server_api::RepositoryApiError> {
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&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)?;
)
.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)?;
TicketBackendOperationResult::TicketRef(ticket) => Some(ticket),
_ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::TicketRecordRef(response))
}
@@ -10432,15 +10448,54 @@ impl server_api::ServerApi for ServerApiContractService {
request: server_api::EditTicketRecordItemRequest,
) -> std::result::Result<server_api::TicketRecord, server_api::RepositoryApiError> {
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)?;
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)?;
TicketBackendOperationResult::Ticket(ticket) => Some(ticket),
_ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::TicketRecord(response))
}
async fn ticket_record_post_edit(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
request: server_api::EditTicketPostRequest,
) -> std::result::Result<server_api::TicketPostSnapshots, server_api::RepositoryApiError> {
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?,
&workspace_id,
contract_request_headers(&context)?,
Some(&context),
TicketBackendOperation::EditPost {
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::TicketSnapshots { previous, current } => {
Some((previous, current))
}
_ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::TicketPostSnapshots(response))
}
async fn ticket_dependency_check(
&self,
context: server_api::ServerRequestContext,
@@ -10468,9 +10523,17 @@ impl server_api::ServerApi for ServerApiContractService {
request: server_api::TicketThreadEventRequest,
) -> std::result::Result<(), server_api::RepositoryApiError> {
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)?;
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(())
}
@@ -10948,18 +11011,34 @@ impl server_api::ServerApi for ServerApiContractService {
}
async fn ticket_post_get(
&self, workspace_id: String, id: String, event_ref: String,
&self,
workspace_id: String,
id: String,
event_ref: String,
) -> std::result::Result<server_api::TicketEventDetail, server_api::RepositoryApiError> {
scoped_get_ticket_post(self.workspace_api()?.clone(), workspace_id, id, event_ref)
.await.map_err(ApiError::into_repository_api_error)
.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,
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
event_ref: String,
request: server_api::TicketPostEditRequest,
) -> std::result::Result<TicketDetail, server_api::RepositoryApiError> {
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)?;
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)
}
@@ -15435,24 +15514,52 @@ async fn scoped_edit_ticket_item(
) -> ApiResult<Json<TicketDetail>> {
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(),
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 }),
(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")),
_ => {
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
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(
@@ -15465,14 +15572,27 @@ async fn scoped_append_ticket_event(
server_api::BrowserTicketThreadRole::Comment => TicketEventKind::Comment,
server_api::BrowserTicketThreadRole::Plan => TicketEventKind::Plan,
server_api::BrowserTicketThreadRole::Decision => TicketEventKind::Decision,
server_api::BrowserTicketThreadRole::ImplementationReport => TicketEventKind::ImplementationReport,
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
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;
@@ -15484,67 +15604,106 @@ fn authenticated_ticket_post_author(
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 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,
api: WorkspaceApi,
workspace_id: String,
id: String,
event_ref: String,
) -> ApiResult<server_api::TicketEventDetail> {
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
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,
api: WorkspaceApi,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
event_ref: String,
request: server_api::TicketPostEditRequest,
) -> ApiResult<Json<TicketDetail>> {
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())?;
let headers =
contract_request_headers(&context).map_err(|error| Error::Config(error.message))?;
if request.body.len() > TICKET_EDITABLE_POST_BODY_LIMIT {
return Err(settings_bad_request("ticket_post_too_large", "Ticket post exceeds the edit 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, &current, 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
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());
}
// The digest is a transport observation; storage compares its exact body in the write transaction.
let expected_body = post.body.clone();
execute_ticket_rest_operation_on_host(
&api,
&workspace_id,
headers,
Some(&context),
TicketBackendOperation::EditPost {
id: TicketIdOrSlug::Id(id.clone()),
edit: ticket::TicketPostEdit {
event_ref,
expected_body,
body: MarkdownText::new(request.body),
author: String::new(),
},
},
)?;
browser_ticket_detail(&api, &id)
})
.await
}
async fn scoped_mark_ticket_ready_from_browser(
@@ -15860,13 +16019,19 @@ fn execute_ticket_rest_operation_on_host(
} else {
verified_source.or(hinted_source)
};
if matches!(operation, TicketBackendOperation::Create { .. } | TicketBackendOperation::EditItem { .. } | TicketBackendOperation::AddEvent { .. })
&& let Some(context) = context
if matches!(
operation,
TicketBackendOperation::Create { .. }
| TicketBackendOperation::EditItem { .. }
| TicketBackendOperation::EditPost { .. }
| 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::EditPost { edit, .. } => edit.author = author,
TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author),
_ => unreachable!(),
}
@@ -15987,6 +16152,35 @@ fn execute_ticket_rest_operation_on_host(
}
(TicketBackendOperationResult::Ticket(ticket), None)
}
TicketBackendOperation::EditPost { id, edit } => {
let event_ref = edit.event_ref.clone();
let (previous, current) = backend
.edit_post_with_snapshots(id, edit)
.map_err(Error::from)?;
// Check only an initial post, using the transaction's actual preimage.
ticket_item_check = source.clone().and_then(|source| {
previous
.events
.iter()
.find(|post| post.event_ref == event_ref && post.is_initial)
.map(|_| {
(
source,
TicketItemCheckEdit::Edit {
title_changed: false,
body_changed: true,
previous_title: None,
previous_body: Some(previous.document.body.as_str().to_string()),
body_replacement: None,
},
)
})
});
(
TicketBackendOperationResult::TicketSnapshots { previous, current },
None,
)
}
operation => (
execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?,
None,
@@ -16005,6 +16199,16 @@ fn execute_ticket_rest_operation_on_host(
source.clone(),
);
}
} else if let TicketBackendOperationResult::TicketSnapshots { previous, current } = &result
{
notify_ticket_recipients(
api,
workspace_id,
&current.meta.id,
previous.meta.workflow_state.as_str(),
current.meta.workflow_state.as_str(),
source.clone(),
);
} else if let Some(target) = target
&& let Ok(ticket) = backend.show(target)
{
@@ -16021,6 +16225,7 @@ fn execute_ticket_rest_operation_on_host(
if let Some((source_worker, edit)) = ticket_item_check {
let checked_ticket = created_ticket.or_else(|| match &result {
TicketBackendOperationResult::Ticket(ticket) => Some(ticket.clone()),
TicketBackendOperationResult::TicketSnapshots { current, .. } => Some(current.clone()),
_ => None,
});
if let Some(ticket) = checked_ticket {
@@ -18310,6 +18515,7 @@ type WorkerMutationSource = RuntimeWorkerRef;
fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketIdOrSlug> {
match operation {
TicketBackendOperation::EditItem { id, .. }
| TicketBackendOperation::EditPost { id, .. }
| TicketBackendOperation::AddEvent { id, .. }
| TicketBackendOperation::AddStateChanged { id, .. }
| TicketBackendOperation::AddIntakeSummary { id, .. }
@@ -18349,6 +18555,7 @@ fn bind_worker_ticket_operation_source(
match operation {
TicketBackendOperation::Create { input } => input.author = Some(author),
TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author),
TicketBackendOperation::EditPost { edit, .. } => edit.author = author,
TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author),
TicketBackendOperation::AddStateChanged { change, .. } => change.author = Some(author),
TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author),
@@ -18373,6 +18580,7 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
match operation {
TicketBackendOperation::Create { .. } => "create",
TicketBackendOperation::EditItem { .. } => "edit_item",
TicketBackendOperation::EditPost { .. } => "edit_post",
TicketBackendOperation::AddEvent { .. } => "add_event",
TicketBackendOperation::AddStateChanged { .. } => "add_state_changed",
TicketBackendOperation::UpdateCase { .. } => "case_update",
@@ -47445,7 +47653,13 @@ 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()),
&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(),
@@ -52595,6 +52809,14 @@ mod tests {
axum::http::HeaderValue::from_str(&source_worker.worker.worker_id).unwrap(),
);
let response = build_inner_router(api.clone())
.layer(Extension(
crate::worker_source::VerifiedRuntimeRequestSource {
authentication_binding_id: "fixture-binding".into(),
token_id_hash: None,
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.into(),
worker_id: Some(source_worker.worker.worker_id.clone()),
},
))
.oneshot(
Request::builder()
.method("POST")
@@ -53470,6 +53692,263 @@ mod tests {
);
}
#[tokio::test]
async fn ticket_post_native_route_binds_actor_and_returns_atomic_snapshots() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let mut input = ticket::NewTicket::new("Native post edit");
input.body = MarkdownText::new("Original initial post");
input.author = Some("original-author".into());
let (reference, original) = backend.create_with_snapshot(input).unwrap();
let app = build_inner_router(api.clone()).layer(Extension(test_browser_request_actor()));
let uri = format!(
"/api/w/{TEST_WORKSPACE_ID}/tickets/{}/posts",
reference.resource_key.as_ref().unwrap()
);
let edit = json!({
"event_ref": original.events[0].event_ref,
"expected_body": original.document.body,
"body": "Changed initial post",
"author": "forged-editor"
});
let response = request_json(
app.clone(),
"PATCH",
&uri,
Some(edit.clone()),
StatusCode::OK,
)
.await;
let (previous, current): (ticket::Ticket, ticket::Ticket) =
serde_json::from_value(response).unwrap();
assert_eq!(previous, original);
assert_eq!(current, backend.show(reference.id.clone().into()).unwrap());
assert_eq!(current.document.body.as_str(), "Changed initial post");
assert_ne!(
ticket::ticket_content_digest(&previous),
ticket::ticket_content_digest(&current)
);
assert_eq!(current.events.len(), original.events.len());
assert_eq!(current.events[0].author, original.events[0].author);
assert_eq!(current.events[0].at, original.events[0].at);
let context = server_request_context(
Some(test_browser_request_actor()),
None,
None,
None,
&HeaderMap::new(),
);
let author = authenticated_ticket_post_author(&context, None).unwrap();
assert_eq!(
current.events[0].edited_by.as_deref(),
Some(author.as_str())
);
assert!(current.events[0].edited_at.is_some());
assert!(ticket_check_jobs_for_snapshot(&api, &current).is_empty());
request_json(app, "PATCH", &uri, Some(edit), StatusCode::CONFLICT).await;
assert_eq!(backend.show(reference.id.into()).unwrap(), current);
}
#[tokio::test]
async fn ticket_post_native_worker_route_checks_only_initial_with_atomic_preimage() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let source = spawn_ticket_check_source(&api, "post-editor");
let backend = browser_ticket_backend(&api).unwrap();
let mut input = ticket::NewTicket::new("Worker post edit");
input.body = MarkdownText::new("Initial requirements");
input.author = Some("creator".into());
let reference = backend.create(input).unwrap();
let mut comment = NewTicketEvent::new(TicketEventKind::Comment, "Discussion");
comment.author = Some("commenter".into());
backend
.add_event(reference.id.clone().into(), comment)
.unwrap();
let original = backend.show(reference.id.clone().into()).unwrap();
// Below proof middleware, inject the verified Worker subject; no identity hints required.
let app = build_inner_router(api.clone()).layer(Extension(
crate::worker_source::VerifiedRuntimeRequestSource {
authentication_binding_id: "fixture-binding".into(),
token_id_hash: None,
runtime_id: source.runtime_id.clone(),
worker_id: Some(source.worker_id.clone()),
},
));
let uri = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/posts", reference.id);
let editor = format!("worker:{}/{}", source.runtime_id, source.worker_id);
let mut latest = original.clone();
for (index, body) in [
(1, "Changed discussion"),
(0, "Changed requirements"),
(0, "Final requirements"),
] {
let edit = json!({ "event_ref": latest.events[index].event_ref,
"expected_body": latest.events[index].body, "body": body, "author": "forged-editor" });
let response = request_json(
app.clone(),
"PATCH",
&uri,
Some(edit.clone()),
StatusCode::OK,
)
.await;
let (previous, current): (ticket::Ticket, ticket::Ticket) =
serde_json::from_value(response).unwrap();
assert_eq!(previous, latest);
assert_eq!(
current.events[index].edited_by.as_deref(),
Some(editor.as_str())
);
assert_eq!(current.events[index].author, original.events[index].author);
assert_eq!(current.events[index].at, original.events[index].at);
assert_eq!(current.events.len(), original.events.len());
if index == 0 {
let jobs = ticket_check_jobs_for_snapshot(&api, &current);
assert_eq!(jobs.len(), 1);
let checked = ticket_item_checker::parse_input(&jobs[0].request.input).unwrap();
assert_eq!(checked.body, current.document.body.as_str());
assert_eq!(
checked.content_digest,
ticket_item_checker::content_digest(&current)
);
assert!(matches!(checked.edit, TicketItemCheckEdit::Edit {
title_changed: false, body_changed: true, previous_body: Some(ref body), ..
} if body == previous.document.body.as_str()));
} else {
assert_eq!(
ticket::ticket_content_digest(&previous),
ticket::ticket_content_digest(&current)
);
assert!(ticket_check_jobs_for_snapshot(&api, &current).is_empty());
}
request_json(app.clone(), "PATCH", &uri, Some(edit), StatusCode::CONFLICT).await;
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), current);
latest = current;
}
tokio::time::timeout(std::time::Duration::from_secs(5), async {
api.host_executors.host().unwrap().drain().await.unwrap();
api.host_executors.cleanup().unwrap().drain().await.unwrap();
api.worker_projection.shutdown_async().await;
api.store.db_executor().drain().await.unwrap();
})
.await
.expect("post fixture must drain accepted work");
}
#[tokio::test]
async fn ticket_post_browser_routes_read_full_body_and_cas_initial_and_comment() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let mut input = ticket::NewTicket::new("Browser posts");
input.body = MarkdownText::new("Initial post");
input.author = Some("creator".into());
let reference = backend.create(input).unwrap();
let full_body = "長い comment\n".repeat(4000);
let mut comment = NewTicketEvent::new(TicketEventKind::Comment, full_body.clone());
comment.author = Some("commenter".into());
backend
.add_event(reference.id.clone().into(), comment)
.unwrap();
backend
.add_event(
reference.id.clone().into(),
NewTicketEvent::new(TicketEventKind::Decision, "Immutable decision"),
)
.unwrap();
let original = backend.show(reference.id.clone().into()).unwrap();
let app = build_inner_router(api.clone()).layer(Extension(test_browser_request_actor()));
let base = format!(
"/api/w/{TEST_WORKSPACE_ID}/tickets/{}/posts",
reference.resource_key.as_ref().unwrap()
);
let mut latest = original.clone();
for (index, replacement) in [(1, "Changed discussion"), (0, "Changed initial")] {
let uri = format!("{base}/{}", latest.events[index].event_ref);
let read = request_json(app.clone(), "GET", &uri, None, StatusCode::OK).await;
let post: server_api::TicketEventDetail = serde_json::from_value(read).unwrap();
assert_eq!(
post.body.as_deref(),
Some(latest.events[index].body.as_str())
);
assert!(!post.body_truncated);
assert!(post.editable);
assert_eq!(
post.body_digest,
crate::authority::ticket_post_body_digest(latest.events[index].body.as_str())
);
let edit = json!({ "expected_body_digest": post.body_digest, "body": replacement });
let response = request_json(
app.clone(),
"PATCH",
&uri,
Some(edit.clone()),
StatusCode::OK,
)
.await;
let detail: TicketDetail = serde_json::from_value(response).unwrap();
let current = backend.show(reference.id.clone().into()).unwrap();
assert_eq!(detail.id, reference.id);
assert_eq!(current.events.len(), original.events.len());
assert_eq!(current.events[index].author, original.events[index].author);
assert_eq!(current.events[index].at, original.events[index].at);
assert!(
current.events[index]
.edited_by
.as_ref()
.unwrap()
.starts_with("workspace-user:")
);
assert!(current.events[index].edited_at.is_some());
assert_eq!(current.events[index].body.as_str(), replacement);
if index == 0 {
assert_eq!(current.document.body.as_str(), replacement);
assert_ne!(
ticket::ticket_content_digest(&latest),
ticket::ticket_content_digest(&current)
);
} else {
assert_eq!(current.document.body, original.document.body);
assert_eq!(
ticket::ticket_content_digest(&latest),
ticket::ticket_content_digest(&current)
);
}
request_json(app.clone(), "PATCH", &uri, Some(edit), StatusCode::CONFLICT).await;
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), current);
latest = current;
}
let system_uri = format!("{base}/{}", original.events[2].event_ref);
let system = request_json(app.clone(), "GET", &system_uri, None, StatusCode::OK).await;
assert_eq!(system["editable"], false);
request_json(
app.clone(),
"PATCH",
&system_uri,
Some(json!({"expected_body_digest": system["body_digest"], "body": "forged"})),
StatusCode::CONFLICT,
)
.await;
request_json(
app.clone(),
"GET",
&format!("{base}/missing"),
None,
StatusCode::NOT_FOUND,
)
.await;
let other = backend
.create(ticket::NewTicket::new("Other ticket"))
.unwrap();
let foreign = format!(
"/api/w/{TEST_WORKSPACE_ID}/tickets/{}/posts/{}",
other.id, original.events[0].event_ref
);
request_json(app, "GET", &foreign, None, StatusCode::NOT_FOUND).await;
assert_eq!(backend.show(reference.id.into()).unwrap(), latest);
}
#[tokio::test]
async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() {
let dir = tempfile::tempdir().unwrap();
@@ -53539,7 +54018,13 @@ 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()),
&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()),
@@ -53577,7 +54062,13 @@ 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()),
&server_request_context(
Some(test_browser_request_actor()),
None,
None,
None,
&HeaderMap::new(),
),
AxumPath(path()),
Json(server_api::BrowserAppendTicketEventRequest {
role: server_api::BrowserTicketThreadRole::Comment,
@@ -55228,9 +55719,14 @@ mod tests {
crate::store::configure_sqlite(&conn).unwrap();
conn.execute(
"INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
workflow_state, workflow_state_explicit
) VALUES (?1, ?2, ?2, ?2, 'open', 'task', 'normal', '', 'open', 1)",
) VALUES (?1, ?2, ?2, ?2, 'open', 'task', 'normal', 'open', 1)",
rusqlite::params![api.config.workspace_id, ticket_id],
)
.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','')",
rusqlite::params![api.config.workspace_id, ticket_id],
)
.unwrap();
@@ -60402,7 +60898,7 @@ mod tests {
{
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let app = build_inner_router(api);
let app = build_inner_router(api).layer(Extension(test_browser_request_actor()));
let response = app
.clone()
@@ -63348,11 +63844,13 @@ mod tests {
.execute_batch(
r#"
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
workflow_state, workflow_state_explicit
) VALUES
('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'open', 1),
('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'open', 1);
('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', 'open', 1),
('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', 'open', 1);
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body)
SELECT workspace_id,ticket_id,-1,ticket_id || ':-1','comment','' FROM typed_tickets WHERE workspace_id='0192f0e8-4d84-7d6e-a000-000000000001';
INSERT INTO workspace_resource_keys (
workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at
) VALUES
+43 -13
View File
@@ -14004,6 +14004,7 @@ fn migrate_ticket_posts_v93_to_v94(conn: &Connection) -> Result<()> {
let tx = conn.unchecked_transaction()?;
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
.map_err(|error| Error::Store(format!("Ticket posts migration failed: {error}")))?;
assignment_delivery::install_post_inputs(&tx)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(94,'Canonical Ticket posts')",
[],
@@ -14025,6 +14026,7 @@ fn migrate_ticket_posts_v93_to_v94(conn: &Connection) -> Result<()> {
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
create_workspace_schema_structure(conn)?;
assignment_delivery::install(conn)?;
assignment_delivery::install_post_inputs(conn)?;
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
Error::Store(format!(
"fresh Workspace Ticket schema verification failed: {error}"
@@ -14260,6 +14262,26 @@ fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> {
}
}
}
if current_schema_version(conn)? >= 94 {
for (kind, name) in [
("table", "ticket_assignment_content_inputs"),
("trigger", "ticket_assignment_content_input_no_update"),
("trigger", "ticket_assignment_content_input_no_delete"),
("trigger", "ticket_assignment_title_input"),
("trigger", "ticket_assignment_initial_post_input"),
] {
let present: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type=?1 AND name=?2)",
params![kind, name],
|row| row.get(0),
)?;
if !present {
return Err(Error::Store(format!(
"Assignment content input constraint {name} is missing"
)));
}
}
}
Ok(())
}
@@ -20146,16 +20168,18 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
let (_dir, path) = legacy_case_workspace(90);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
// Keep the collision table valid for dependent triggers during the shared
// Ticket table rebuild. Only Workspace phase two must fail on DROP VIEW.
conn.execute_batch("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
VALUES('case-workspace','terminal','terminal','Terminal','closed','task','P2','','done',1);
DROP VIEW ticket_active_worker_assignments;
CREATE TABLE ticket_active_worker_assignments(collision TEXT);").unwrap();
CREATE TABLE ticket_active_worker_assignments AS SELECT * FROM ticket_current_worker_assignments WHERE 0;").unwrap();
install_retained_ticket_tags8(&conn);
let _tags = seed_retained_classification(&conn, "case-workspace", "terminal");
let tags_before = classification_rows(&conn);
let auth_before = fixture_auth_rows(&conn);
let error = migrate_ticket_cases_v90_to_v91(&conn).unwrap_err();
assert!(error.to_string().contains("DROP TABLE"));
assert!(error.to_string().contains("DROP TABLE"), "{error}");
assert_eq!(current_schema_version(&conn).unwrap(), 90);
assert_eq!(fixture_auth_rows(&conn), auth_before);
assert_eq!(classification_rows(&conn), tags_before);
@@ -21142,10 +21166,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
FROM typed_tickets WHERE ticket_id='ticket'", [],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))?;
if state == "closed" {
assert_eq!((persisted.as_str(), events, assignments, operations), (state,0,0,0));
assert_eq!((persisted.as_str(), events, assignments, operations), (state,1,0,0));
} else {
assert_eq!((persisted.as_str(), events, assignments, operations), ("open",1,1,1));
let transition: (Option<String>,Option<String>,String) = conn.query_row("SELECT from_state,to_state,kind FROM typed_ticket_events", [], |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?)))?;
assert_eq!((persisted.as_str(), events, assignments, operations), ("open",2,1,1));
let transition: (Option<String>,Option<String>,String) = conn.query_row("SELECT from_state,to_state,kind FROM typed_ticket_events WHERE kind='assignment'", [], |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?)))?;
assert_eq!(transition, (None, None, "assignment".into()));
assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE key='assignment_role'", [], |row| row.get::<_,String>(0))?, "worker");
}
@@ -21404,12 +21428,14 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
conn.execute_batch(
r#"
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
workflow_state, workflow_state_explicit
) VALUES
('workspace-a', 'ticket-1', 'ticket-1', 'Ticket 1', 'open', 'task', 'normal', '', 'open', 1),
('workspace-a', 'ticket-2', 'ticket-2', 'Ticket 2', 'open', 'task', 'normal', '', 'open', 1),
('workspace-a', 'ticket-3', 'ticket-3', 'Ticket 3', 'open', 'task', 'normal', '', 'open', 1);
('workspace-a', 'ticket-1', 'ticket-1', 'Ticket 1', 'open', 'task', 'normal', 'open', 1),
('workspace-a', 'ticket-2', 'ticket-2', 'Ticket 2', 'open', 'task', 'normal', 'open', 1),
('workspace-a', 'ticket-3', 'ticket-3', 'Ticket 3', 'open', 'task', 'normal', 'open', 1);
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body)
SELECT workspace_id,ticket_id,-1,ticket_id || ':-1','comment','' FROM typed_tickets WHERE workspace_id='workspace-a';
INSERT INTO worker_registry (
workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at
) VALUES
@@ -22032,9 +22058,11 @@ INSERT INTO workspace_signing_identities (
'2026-01-01', NULL, '2026-01-01'
);
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
workflow_state, workflow_state_explicit
) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', 'open', 1);
) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', 'open', 1);
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body)
VALUES ('workspace-a','ticket-a',-1,'ticket-a:-1','comment','');
INSERT INTO worker_registry (
workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at
) VALUES (
@@ -22062,9 +22090,11 @@ INSERT INTO workspace_signing_identities (
'2026-01-01', NULL, '2026-01-01'
);
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
workflow_state, workflow_state_explicit
) VALUES ('workspace-b', 'ticket-a', 'ticket-a-b', 'B', 'open', 'task', 'normal', '', 'open', 1);
) VALUES ('workspace-b', 'ticket-a', 'ticket-a-b', 'B', 'open', 'task', 'normal', 'open', 1);
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body)
VALUES ('workspace-b','ticket-a',-1,'ticket-a:-1','comment','');
INSERT INTO worker_registry (
workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at
) VALUES (
@@ -84,6 +84,7 @@ fn ticket_posts_schema93_preserves_initial_body_creator_comments_and_archived_au
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);
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs",[],|r|r.get::<_,i64>(0))?,0,"migration does not turn historical edits into new inputs");
let initial: (String,Option<String>,Option<String>,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::<rusqlite::Result<_>>()?;
@@ -273,6 +274,7 @@ fn ticket_posts_fresh_and_migrated_schema_constraints_match() {
"typed_ticket_events",
"typed_ticket_event_attributes",
"typed_ticket_event_references",
"ticket_assignment_content_inputs",
] {
for query in [
format!(
@@ -1298,10 +1298,14 @@ mod tests {
)?;
conn.execute(
"INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
workflow_state, workflow_state_explicit
) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open',
'task', 'normal', '', 'open', 1)",
'task', 'normal', 'open', 1)",
[],
)?;
conn.execute(
"INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('workspace-a','ticket-a',-1,'ticket-a:-1','comment','')",
[],
)?;
conn.execute(
@@ -48,6 +48,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"ticket_assignment_delivery_acknowledgements",
"ticket_assignment_delivery_requests",
"ticket_assignment_deliveries",
"ticket_assignment_content_inputs",
"ticket_assignment_delivery_origins",
"ticket_assignment_operation_fences",
"ticket_assignment_operations",
@@ -488,6 +489,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
("ticket_assignment_delivery_requests", "ticket_assignment_delivery_request_no_delete"),
("ticket_assignment_delivery_acknowledgements", "ticket_assignment_delivery_ack_no_delete"),
("ticket_assignment_delivery_origins", "ticket_assignment_delivery_origin_no_delete"),
("ticket_assignment_content_inputs", "ticket_assignment_content_input_no_delete"),
("ticket_assignment_operation_fences", "ticket_assignment_operation_fence_no_delete"),
("typed_ticket_assignment_tracking_history", "typed_ticket_assignment_tracking_history_no_delete"),
("typed_ticket_assignment_tracking_operations", "typed_ticket_assignment_tracking_operations_no_delete"),
@@ -1016,6 +1018,15 @@ mod tests {
false,
)
.unwrap();
let initial = backend
.edit_item(
initial.meta.id.clone().into(),
ticket::TicketItemEdit {
title: Some("Changed case evidence".into()),
..Default::default()
},
)
.unwrap();
// Seed domain handling evidence under a transaction. This fixture
// exercises owner purge, not singleton authentication or delivery.
store
@@ -1091,7 +1102,7 @@ mod tests {
}
let assert_immutable = |workspace: &str| {
store.with_conn(|conn| {
for table in ["typed_ticket_case_history", "typed_ticket_legacy_workflow_snapshot", "ticket_worker_assignment_events", "typed_ticket_assignment_tracking_history", "typed_ticket_assignment_tracking_operations", "ticket_assignment_operations"] {
for table in ["typed_ticket_case_history", "typed_ticket_legacy_workflow_snapshot", "ticket_worker_assignment_events", "typed_ticket_assignment_tracking_history", "typed_ticket_assignment_tracking_operations", "ticket_assignment_operations", "ticket_assignment_content_inputs"] {
assert!(conn.execute(&format!("DELETE FROM {table} WHERE workspace_id=?1"), [workspace]).is_err());
assert!(conn.execute(&format!("UPDATE {table} SET ticket_id=ticket_id WHERE workspace_id=?1"), [workspace]).is_err());
}
@@ -1176,6 +1187,7 @@ mod tests {
};
assert_eq!(count, expected, "rollback must restore {table}");
}
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs WHERE workspace_id='workspace-a'", [], |row| row.get::<_,i64>(0))?, 1, "rollback restores content inputs");
conn.execute_batch("DROP TRIGGER reject_workspace_purge;")?;
Ok(())
})
@@ -1202,6 +1214,8 @@ mod tests {
assert_immutable("workspace-b");
store
.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs WHERE workspace_id='workspace-a'", [], |row| row.get::<_,i64>(0))?, 0);
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs WHERE workspace_id='workspace-b'", [], |row| row.get::<_,i64>(0))?, 1);
for (table, before) in tables.iter().zip(&other_before) {
let count: i64 = conn.query_row(
&format!(