feat: unify Ticket posts and simplify detail layout

Checkpoint requested before the targets read-tool correction. Includes canonical initial posts, creator/edit metadata, migration and UI work, and removal of delivery diagnostics from Ticket details.\n\nIntegration remains incomplete: the Client post-edit trait implementation and server post routes/dispatch are not yet connected; cargo check currently fails. This commit does not assert validation or delivery completion.
This commit is contained in:
2026-10-14 02:42:58 +09:00
parent 26ab6130e0
commit 8611078058
45 changed files with 5862 additions and 668 deletions
+38
View File
@@ -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<TicketDetail, RepositoryApiError>;
#[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<TicketEventDetail, RepositoryApiError>;
#[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<TicketDetail, RepositoryApiError>;
#[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<String>,
pub edited_by: Option<String>,
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<String>,
pub initial_post: TicketEventDetail,
pub id: String,
#[cfg_attr(feature = "typescript", ts(type = "Array<WorkspaceTag>"))]
pub tags: Vec<ticket::WorkspaceTag>,
@@ -8182,6 +8212,14 @@ impl From<ticket::TicketState> 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)]
@@ -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",
+40
View File
@@ -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:<ordinal>` 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.
+1 -1
View File
@@ -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()),
(
+32 -11
View File
@@ -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
);
}
+150 -112
View File
@@ -623,7 +623,7 @@ pub fn sqlite_ticket_content_digest(
) -> Result<Option<String>> {
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<String>,
pub created_at: Option<String>,
#[serde(default)]
pub created_by: Option<String>,
pub updated_at: Option<String>,
pub assignee: Option<String>,
pub readiness: Option<String>,
@@ -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<String>,
#[serde(default)]
pub edited_by: Option<String>,
pub kind: TicketEventKind,
pub author: Option<String>,
pub at: Option<String>,
@@ -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<Ticket> {
self.edit_post_with_snapshots(id, edit)
.map(|(_, current)| current)
}
fn list_workspace_tags(&self, request: TagListRequest) -> Result<Vec<WorkspaceTag>>;
fn mutate_workspace_tag(&self, request: WorkspaceTagMutation) -> Result<WorkspaceTag>;
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<TicketSummary>),
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<Ticket> {
#[cfg(test)]
self.full_ticket_load_count.fetch_add(1, Ordering::SeqCst);
let (mut meta, body, resolution): (TicketMeta, String, Option<String>) = 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<String>) = 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<Vec<TicketEvent>> {
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<String>>(8)?,
row.get::<_, Option<String>>(9)?,
row.get::<_, String>(10)?,
row.get::<_, String>(11)?,
row.get::<_, Option<String>>(12)?,
row.get::<_, Option<String>>(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<Vec<TicketSummary>> {
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<R>(conn: &Connection, result: Result<R>) -> 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<Vec<WorkspaceTag>> {
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]
+50
View File
@@ -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;
+101 -69
View File
@@ -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<BTreeMap<i64, Stri
Ok(applied)
}
fn validate_applied_migrations(applied: &BTreeMap<i64, String>) -> 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<i64, String>) -> Result<i64> {
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());
}
+4
View File
@@ -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()),
+229 -34
View File
@@ -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<crate::SqliteTicketBackend> 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<crate::TicketTargetsEdit>,
}
#[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<ToolOutput, ToolError> {
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::<Vec<_>>() },
"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<TicketToolBackend>) -> Vec<ToolDefinition
vec![
tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()),
tool_definition::<TicketEditItemTool>("TicketEditItem", backend.clone()),
tool_definition::<TicketEditPostTool>("TicketEditPost", backend.clone()),
tool_definition::<QueryTicketTool>("QueryTicket", backend.clone()),
tool_definition::<ShowTicketTool>("ShowTicket", backend.clone()),
tool_definition::<TicketCommentTool>("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::<Vec<_>>();
let comment_keys = value["events"]["items"][0]
.as_object()
.unwrap()
.keys()
.collect::<Vec<_>>();
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",
+54 -12
View File
@@ -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::<std::result::Result<_, _>>().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::<std::result::Result<_, _>>().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::<std::result::Result<_, _>>().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::<std::result::Result<_, _>>().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<Vec<rusqlite::types::Value>> {
// 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::<std::result::Result<Vec<_>, _>>()
.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::<Vec<_>>().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);
+525
View File
@@ -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(&current)
);
} else {
assert_eq!(
ticket_content_digest(&previous),
ticket_content_digest(&current)
);
}
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::<rusqlite::Result<Vec<_>>>().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::<rusqlite::Result<_>>().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::<Vec<_>>(),
["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(&current)
);
assert!(execute_ticket_backend_operation(&backend, request).is_err());
}
@@ -43,6 +43,9 @@ pub(super) struct ModelTicketDetail {
content_digest: String,
title: String,
body: String,
body_truncated: bool,
initial_post: ModelTicketEvent,
created_by: Option<String>,
state: ticket::TicketState,
closure_operation_key: Option<String>,
current_closure: Option<ticket::TicketClosureJudgment>,
@@ -131,9 +134,17 @@ struct ModelTicketEvent {
#[serde(skip_serializing_if = "Option::is_none")]
tag_changes: Option<ModelTicketTagChange>,
sequence: usize,
event_ref: String,
is_initial: bool,
kind: String,
body: Option<String>,
body_truncated: bool,
body_digest: String,
editable: bool,
author: Option<String>,
created_at: Option<String>,
edited_at: Option<String>,
edited_by: Option<String>,
}
#[derive(Debug, Serialize, Default)]
@@ -335,6 +346,12 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
content_digest: string_field(root, "content_digest")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
body_truncated: bool_field(root, "body_truncated")?,
initial_post: project_ticket_event(
root.get("initial_post")
.ok_or("Ticket detail response is missing initial_post")?,
)?,
created_by: optional_string(root, "created_by")?,
state: typed_field(root, "state")?,
closure_operation_key: typed_field(root, "closure_operation_key")?,
current_closure: typed_field(root, "current_closure")?,
@@ -471,6 +488,14 @@ fn project_ticket_event(value: &Value) -> Result<ModelTicketEvent, String> {
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()
@@ -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<NativeTicketOperation> {
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::<Value>(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::<Vec<_>>();
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
@@ -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<_>>(),
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<String>, Option<String>) = 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")
+35 -15
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<Result<Vec<_>>>()?;
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());
File diff suppressed because it is too large Load Diff
@@ -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)
);
@@ -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
+4 -3
View File
@@ -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')", [])?;
+164 -93
View File
@@ -10285,14 +10285,13 @@ impl server_api::ServerApi for ServerApiContractService {
workspace_id: String,
request: server_api::CreateTicketRecordRequest,
) -> std::result::Result<server_api::TicketRecordRef, server_api::RepositoryApiError> {
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<server_api::TicketRecord, server_api::RepositoryApiError> {
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<server_api::TicketDetail, server_api::RepositoryApiError> {
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<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)
}
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<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)?;
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<server_api::TicketDetail, server_api::RepositoryApiError> {
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<Json<
async fn scoped_edit_ticket_item(
State(api): State<WorkspaceApi>,
context: &server_api::ServerRequestContext,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(request): Json<server_api::BrowserEditTicketRequest>,
) -> ApiResult<Json<TicketDetail>> {
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<WorkspaceApi>,
context: &server_api::ServerRequestContext,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(request): Json<server_api::BrowserAppendTicketEventRequest>,
) -> ApiResult<Json<TicketDetail>> {
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<String> {
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<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
}
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<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())?;
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, &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
}
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,
+77 -22
View File
@@ -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<Vec<rusqlite::types::Value>> {
// 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::<Vec<_>>(),
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
@@ -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::<Vec<_>>(),
[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<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<_>>()?;
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<String>,Option<String>,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::<Vec<_>>(),
["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::<Vec<_>>(),
["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::<Vec<_>>(),
["authored:-1", "authored:3", "authored:9"]
);
}
@@ -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"),