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:
@@ -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.
|
||||
|
||||
@@ -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()),
|
||||
(
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use tempfile::TempDir;
|
||||
use ticket::{
|
||||
MarkdownText, NewTicket, NewTicketEvent, SqliteTicketBackend, TicketBackend, TicketEventKind,
|
||||
TicketItemEdit, TicketPostEdit, migrate_sqlite_ticket_schema,
|
||||
migrate_sqlite_ticket_schema_in_transaction, ticket_content_digest,
|
||||
verify_sqlite_ticket_schema,
|
||||
};
|
||||
|
||||
fn comment(body: &str, author: &str) -> NewTicketEvent {
|
||||
let mut event = NewTicketEvent::new(TicketEventKind::Comment, body);
|
||||
event.author = Some(author.into());
|
||||
event
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_and_discussion_comments_share_identity_authorship_and_cas_edits() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let path = temp.path().join("posts.db");
|
||||
let backend = SqliteTicketBackend::open(&path, "workspace").unwrap();
|
||||
let mut input = NewTicket::new("Real first post");
|
||||
input.body = MarkdownText::new("First authored post");
|
||||
input.author = Some("creator".into());
|
||||
let (reference, created) = backend.create_with_snapshot(input).unwrap();
|
||||
assert_eq!(created.events.len(), 1);
|
||||
let initial = &created.events[0];
|
||||
assert_eq!(initial.kind, TicketEventKind::Comment);
|
||||
assert!(initial.is_initial);
|
||||
assert_eq!(initial.body, created.document.body);
|
||||
assert_eq!(initial.author.as_deref(), Some("creator"));
|
||||
assert_eq!(initial.at, created.meta.created_at);
|
||||
assert_eq!(created.meta.created_by.as_deref(), Some("creator"));
|
||||
assert!(initial.edited_at.is_none());
|
||||
backend
|
||||
.add_event(
|
||||
reference.id.clone().into(),
|
||||
comment("Discussion", "commenter"),
|
||||
)
|
||||
.unwrap();
|
||||
let before = backend.show(reference.id.clone().into()).unwrap();
|
||||
let ordinary = before.events[1].clone();
|
||||
assert!(!ordinary.is_initial);
|
||||
assert_eq!(ordinary.kind, initial.kind);
|
||||
let initial_time = initial.at.clone();
|
||||
for original in [ordinary, initial.clone()] {
|
||||
let edit = TicketPostEdit {
|
||||
event_ref: original.event_ref.clone(),
|
||||
expected_body: original.body.clone(),
|
||||
body: MarkdownText::new(format!("Edited {}", original.event_ref)),
|
||||
author: "editor".into(),
|
||||
};
|
||||
let (previous, current) = backend
|
||||
.edit_post_with_snapshots(reference.id.clone().into(), edit.clone())
|
||||
.unwrap();
|
||||
let post = current
|
||||
.events
|
||||
.iter()
|
||||
.find(|p| p.event_ref == original.event_ref)
|
||||
.unwrap();
|
||||
assert_eq!(post.author, original.author);
|
||||
assert_eq!(post.at, original.at);
|
||||
assert_eq!(post.edited_by.as_deref(), Some("editor"));
|
||||
assert!(post.edited_at.is_some());
|
||||
assert_eq!(current.meta.created_by.as_deref(), Some("creator"));
|
||||
assert_eq!(current.meta.created_at, initial_time);
|
||||
assert_eq!(current.events.len(), 2);
|
||||
if original.is_initial {
|
||||
assert_eq!(current.document.body, edit.body);
|
||||
assert_ne!(
|
||||
ticket_content_digest(&previous),
|
||||
ticket_content_digest(¤t)
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
ticket_content_digest(&previous),
|
||||
ticket_content_digest(¤t)
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
backend
|
||||
.edit_post(reference.id.clone().into(), edit)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), current);
|
||||
}
|
||||
let previous = backend.show(reference.id.clone().into()).unwrap();
|
||||
let edited = backend
|
||||
.edit_item(
|
||||
reference.id.clone().into(),
|
||||
TicketItemEdit {
|
||||
title: Some("New title".into()),
|
||||
body: Some(MarkdownText::new("Item authoring edits initial post")),
|
||||
author: Some("item-editor".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(edited.events.len(), previous.events.len());
|
||||
assert_eq!(edited.events[0].author, previous.events[0].author);
|
||||
assert_eq!(edited.events[0].at, previous.events[0].at);
|
||||
assert_eq!(edited.events[0].edited_by.as_deref(), Some("item-editor"));
|
||||
assert_eq!(edited.document.body, edited.events[0].body);
|
||||
assert!(
|
||||
edited
|
||||
.events
|
||||
.iter()
|
||||
.all(|e| !matches!(e.kind.as_str(), "create" | "item_edit"))
|
||||
);
|
||||
let reopened = SqliteTicketBackend::open(&path, "workspace").unwrap();
|
||||
assert_eq!(reopened.show(reference.id.into()).unwrap(), edited);
|
||||
let conn = Connection::open(path).unwrap();
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM pragma_table_info('typed_tickets') WHERE name='body'",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_rejects_system_judgments_other_tickets_and_missing_identity() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = SqliteTicketBackend::open(temp.path().join("posts.db"), "workspace").unwrap();
|
||||
let first = backend.create(NewTicket::new("first")).unwrap();
|
||||
let second = backend.create(NewTicket::new("second")).unwrap();
|
||||
let mut event = comment("System judgment", "system");
|
||||
event.kind = TicketEventKind::Decision;
|
||||
backend.add_event(first.id.clone().into(), event).unwrap();
|
||||
let ticket = backend.show(first.id.clone().into()).unwrap();
|
||||
let system = &ticket.events[1];
|
||||
let edit = TicketPostEdit {
|
||||
event_ref: system.event_ref.clone(),
|
||||
expected_body: system.body.clone(),
|
||||
body: MarkdownText::new("changed"),
|
||||
author: "editor".into(),
|
||||
};
|
||||
assert!(
|
||||
backend
|
||||
.edit_post(first.id.clone().into(), edit.clone())
|
||||
.is_err()
|
||||
);
|
||||
assert!(backend.edit_post(second.id.into(), edit).is_err());
|
||||
let initial = &ticket.events[0];
|
||||
assert!(
|
||||
backend
|
||||
.edit_post(
|
||||
first.id.clone().into(),
|
||||
TicketPostEdit {
|
||||
event_ref: initial.event_ref.clone(),
|
||||
expected_body: initial.body.clone(),
|
||||
body: MarkdownText::new("changed"),
|
||||
author: " ".into()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(backend.show(first.id.into()).unwrap(), ticket);
|
||||
}
|
||||
|
||||
fn schema11(conn: &Connection) {
|
||||
// Build the actual retained component shape, not a current schema with its marker changed.
|
||||
conn.execute_batch(include_str!("../src/latest_schema.sql"))
|
||||
.unwrap();
|
||||
conn.execute_batch(include_str!("../src/assignment_schema.sql"))
|
||||
.unwrap();
|
||||
conn.execute_batch(include_str!("../src/assignment_tracking_schema.sql"))
|
||||
.unwrap();
|
||||
conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(11,'ticket schema baseline','original-marker');").unwrap();
|
||||
}
|
||||
|
||||
fn seed_legacy(conn: &Connection, id: &str) {
|
||||
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,created_at,workflow_state,workflow_state_explicit) VALUES('workspace',?1,?1,'Legacy','open','task','P2','Latest body','ticket-time','open',1)", [id]).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema12_migrates_body_authors_archived_history_and_stable_references_then_replays() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let path = temp.path().join("legacy.db");
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
|
||||
schema11(&conn);
|
||||
for id in ["known", "unknown", "metadata"] {
|
||||
seed_legacy(&conn, id);
|
||||
}
|
||||
conn.execute_batch("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body) VALUES
|
||||
('workspace','known',0,'create','original-author','original-time','Created'),
|
||||
('workspace','known',1,'comment','discussant','discussion-time','Discussion'),
|
||||
('workspace','known',2,'item_edit','legacy-editor','edit-time','Body changed');
|
||||
INSERT INTO typed_ticket_event_attributes VALUES('workspace','known',1,'event_id','external-original-id');
|
||||
INSERT INTO typed_ticket_event_attributes VALUES('workspace','known',2,'body_edit','whole');
|
||||
INSERT INTO typed_ticket_event_references VALUES('workspace','known',2,0,'commit','original-evidence');
|
||||
INSERT INTO typed_ticket_raw_frontmatter VALUES('workspace','metadata','created_by','metadata-author');").unwrap();
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
let backend = SqliteTicketBackend::open_verified(&path, "workspace").unwrap();
|
||||
let known = backend.show("known".into()).unwrap();
|
||||
assert_eq!(known.meta.created_at.as_deref(), Some("ticket-time"));
|
||||
assert_eq!(known.meta.created_by.as_deref(), Some("original-author"));
|
||||
assert_eq!(known.events.len(), 2);
|
||||
assert_eq!(known.events[0].author.as_deref(), Some("original-author"));
|
||||
assert_eq!(known.events[0].at.as_deref(), Some("original-time"));
|
||||
assert_eq!(known.document.body.as_str(), "Latest body");
|
||||
assert_eq!(known.events[0].body, known.document.body);
|
||||
assert_eq!(known.events[1].event_ref, "known:1");
|
||||
assert_eq!(known.events[1].attributes["event_id"], "known:1");
|
||||
assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE ticket_id='known' AND event_index=1 AND key='event_id'", [], |r| r.get::<_,String>(0)).unwrap(), "external-original-id");
|
||||
assert_eq!(known.events[1].attributes["event_sequence"], "1");
|
||||
let unknown = backend.show("unknown".into()).unwrap();
|
||||
assert_eq!(unknown.meta.created_by, None);
|
||||
assert_eq!(unknown.events[0].author, None);
|
||||
assert_eq!(unknown.events[0].at.as_deref(), Some("ticket-time"));
|
||||
assert_eq!(
|
||||
backend.show("metadata".into()).unwrap().events[0]
|
||||
.author
|
||||
.as_deref(),
|
||||
Some("metadata-author")
|
||||
);
|
||||
let history = || {
|
||||
conn.prepare("SELECT event_index,kind,author,at,body,archived FROM typed_ticket_events WHERE ticket_id='known' AND event_index>=0 ORDER BY event_index").unwrap().query_map([], |r| Ok((r.get::<_,i64>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,bool>(5)?))).unwrap().collect::<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(¤t)
|
||||
);
|
||||
assert!(execute_ticket_backend_operation(&backend, request).is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user