feat: unify Ticket posts and simplify detail layout

Checkpoint requested before the targets read-tool correction. Includes canonical initial posts, creator/edit metadata, migration and UI work, and removal of delivery diagnostics from Ticket details.\n\nIntegration remains incomplete: the Client post-edit trait implementation and server post routes/dispatch are not yet connected; cargo check currently fails. This commit does not assert validation or delivery completion.
This commit is contained in:
2026-10-14 02:42:58 +09:00
parent 26ab6130e0
commit 8611078058
45 changed files with 5862 additions and 668 deletions
+38
View File
@@ -342,6 +342,8 @@ impl_openapi_schema!(
TextResponse,
TicketDependencyCheckResponse,
TicketDetail,
TicketEventDetail,
TicketPostEditRequest,
TicketCaseUpdateRequest,
TicketAssignmentTrackingUpdateRequest,
TicketAssignmentTrackingRecord,
@@ -3331,10 +3333,29 @@ pub trait ServerApi {
#[patch("/api/w/{workspace_id}/tickets/{id}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_edit(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: BrowserEditTicketRequest,
) -> Result<TicketDetail, RepositoryApiError>;
#[get("/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_post_get(
&self,
#[path] workspace_id: String,
#[path] id: String,
#[path] event_ref: String,
) -> Result<TicketEventDetail, RepositoryApiError>;
#[patch("/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_post_edit(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[path] event_ref: String,
#[body] request: TicketPostEditRequest,
) -> Result<TicketDetail, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/show", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_show(
&self,
@@ -3390,6 +3411,7 @@ pub trait ServerApi {
#[post("/api/w/{workspace_id}/tickets/{id}/events", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_event_append(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: BrowserAppendTicketEventRequest,
@@ -7391,6 +7413,12 @@ pub struct TicketListResponse {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct TicketEventDetail {
pub is_initial: bool,
pub edited_at: Option<String>,
pub edited_by: Option<String>,
pub body_digest: String,
pub body_truncated: bool,
pub editable: bool,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
pub sequence: usize,
@@ -7800,6 +7828,8 @@ pub struct TicketShowRequest {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct TicketDetail {
pub created_by: Option<String>,
pub initial_post: TicketEventDetail,
pub id: String,
#[cfg_attr(feature = "typescript", ts(type = "Array<WorkspaceTag>"))]
pub tags: Vec<ticket::WorkspaceTag>,
@@ -8182,6 +8212,14 @@ impl From<ticket::TicketState> for BrowserTicketState {
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct TicketPostEditRequest {
pub body: String,
pub expected_body_digest: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -494,6 +494,20 @@ fn validate_ticket_browser_operations(
request: Some("BrowserEditTicketRequest"),
successes: &[("200", "TicketDetail")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}",
method: "get",
operation_id: "ticket_post_get",
request: None,
successes: &[("200", "TicketEventDetail")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}",
method: "patch",
operation_id: "ticket_post_edit",
request: Some("TicketPostEditRequest"),
successes: &[("200", "TicketDetail")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/assignments/{role}",
method: "put",
+40
View File
@@ -9,3 +9,43 @@ Does not search Repository documents or handle Git integration.
- [`TicketBackend::update_case`]: reasoned close, reopen, and closure correction
- [`ticket_content_digest`]: requirement-content comparison condition
- [`tool`]: Worker-facing operation definitions
## Posts and creation metadata
The initial body is a real `Comment` in `typed_ticket_events`, with the same author,
creation time, persisted `event_ref`, and edit metadata as later comments.
`Ticket.document.body` is a derived convenience, not another storage authority.
`TicketMeta.created_at` and optional `created_by` belong to the Ticket itself;
unknown legacy authors remain unknown.
`TicketBackend::edit_post_with_snapshots` edits a comment by its persisted identity
and exact expected body, returning the before/after Ticket snapshots atomically for
host audit and initial-post rescope handling. The host must supply the authenticated
editor, rather than trusting an author from Web input. Original post author and time
are retained; `edited_at` and `edited_by` record the edit. System events/judgments
are not editable posts. `edit_item_with_snapshots` still supports title/target and
initial-body authoring, without generating a discussion message.
Transport operation `TicketBackendOperation::EditPost { id, edit }` returns
`TicketBackendOperationResult::TicketSnapshots { previous, current }` by calling
that atomic snapshot method. The native `TicketEditPost` tool accepts `ticket`,
`event_ref`, `expected_body`, and `body`; it does not accept an author. Its standalone
actor default must be replaced with the authenticated caller by Workspace hosts.
Generic Show always returns `initial_post`, even when the recent-event window omits
it, using the same post identity/authorship/edit metadata projection as discussion
entries. Truncated bodies are marked and must not be used as exact edit observations.
Component migration 12 appends to the retained 10/11 migration history. It reserves
SQL event index `-1` for the initial comment and leaves all prior event indices,
references, attributes, and evidence unchanged. Create/item_edit records are archived
in place (`archived = 1`) outside `Ticket.events`, not deleted. New creates insert
only the real initial comment; new item edits do not append events. Existing posts
retain their published identity: a stored `event_id`, otherwise the old zero-based
`sequence:<ordinal>` computed before inserting or hiding rows. Duplicate identities
abort migration rather than being silently changed. `event_ref` is persisted and
must not subsequently be recomputed from display position; existing attributes are
not overwritten. Initial edit metadata comes only from the last known body-edit
record (`changes` includes `body`), never a later title-only edit. Missing or empty
creator identities remain explicitly nullable, not inferred from later editors.
The retained base SQL and frozen fixture schemas remain unchanged; fresh creation
composes the ordered post migration.
+1 -1
View File
@@ -94,7 +94,7 @@ pub fn sqlite_answer_ticket_assignment_in_transaction(
}
let index: i64 = conn.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![workspace_id,ticket_id],|r| r.get(0)).map_err(sqlite_err)?;
let at = now_utc();
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,heading,body) VALUES(?1,?2,?3,'assignment_answer',?4,?5,'Assignment answer',?6)", params![workspace_id,ticket_id,index,actor,at,answer.body]).map_err(sqlite_err)?;
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,heading,body,event_ref) VALUES(?1,?2,?3,'assignment_answer',?4,?5,'Assignment answer',?6,?2 || ':' || ?3)", params![workspace_id,ticket_id,index,actor,at,answer.body]).map_err(sqlite_err)?;
for (key, value) in [
("orchestrator_assignment_id", answer.assignment_id.as_str()),
(
+32 -11
View File
@@ -9,7 +9,19 @@ fn db() -> Connection {
}
fn seed_ticket(conn: &Connection, workspace: &str, ticket: &str) {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES (?1,?2,?2,'Title','open','task','P2','body','open',1)",params![workspace,ticket]).unwrap();
let legacy: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('typed_tickets') WHERE name='body')",
[],
|r| r.get(0),
)
.unwrap();
if legacy {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES (?1,?2,?2,'Title','open','task','P2','body','open',1)",params![workspace,ticket]).unwrap();
} else {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) VALUES (?1,?2,?2,'Title','open','task','P2','open',1)",params![workspace,ticket]).unwrap();
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES (?1,?2,-1,?2 || ':-1','comment','body')",params![workspace,ticket]).unwrap();
}
}
fn assign(conn: &Connection, id: &str, event: &str) {
@@ -396,7 +408,7 @@ fn migration_preserves_original_records_and_only_reconciles_current_fenced_deleg
|r| r.get::<_, i64>(0)
)
.unwrap(),
11
12
);
let value = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
.unwrap()
@@ -753,15 +765,21 @@ fn assignment_answer_commits_one_audited_input_and_exact_receipt_without_trackin
assert_eq!(result.input_event_id, "ticket-event:0");
assert_eq!(apply(&answer).unwrap(), result);
assert_eq!(
conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r
.get::<_, i64>(0))
.unwrap(),
conn.query_row(
"SELECT count(*) FROM typed_ticket_events WHERE event_index >= 0",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
1
);
assert_eq!(
conn.query_row("SELECT body FROM typed_ticket_events", [], |r| r
.get::<_, String>(0))
.unwrap(),
conn.query_row(
"SELECT body FROM typed_ticket_events WHERE event_index >= 0",
[],
|r| r.get::<_, String>(0)
)
.unwrap(),
answer.body
);
assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE key='orchestrator_assignment_id'",[],|r| r.get::<_,String>(0)).unwrap(),"a");
@@ -823,9 +841,12 @@ fn assignment_answer_rejects_unbounded_body_and_malformed_identities_without_app
conn.execute_batch("ROLLBACK").unwrap();
}
assert_eq!(
conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r
.get::<_, i64>(0))
.unwrap(),
conn.query_row(
"SELECT count(*) FROM typed_ticket_events WHERE event_index >= 0",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
0
);
}
+150 -112
View File
@@ -623,7 +623,7 @@ pub fn sqlite_ticket_content_digest(
) -> Result<Option<String>> {
let item: Option<(String, String)> = connection
.query_row(
"SELECT title,body FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
"SELECT title,(SELECT body FROM typed_ticket_events e WHERE e.workspace_id=typed_tickets.workspace_id AND e.ticket_id=typed_tickets.ticket_id AND e.event_index=-1) FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
params![workspace_id, ticket_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
@@ -1020,13 +1020,6 @@ struct TicketBodyReplacementOutcome {
replacement_count: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TicketBodyEditAudit {
None,
WholeBody,
Partial { replacement_count: usize },
}
impl TicketItemEdit {
fn validate_body_edit_request(&self) -> Result<()> {
if self.body.is_some() && self.body_replacement.is_some() {
@@ -1595,6 +1588,8 @@ pub struct TicketMeta {
pub priority: String,
pub labels: Vec<String>,
pub created_at: Option<String>,
#[serde(default)]
pub created_by: Option<String>,
pub updated_at: Option<String>,
pub assignee: Option<String>,
pub readiness: Option<String>,
@@ -1675,6 +1670,15 @@ pub struct TicketDocument {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TicketEvent {
/// Persisted identity, independent of display position and legacy event_id attributes.
#[serde(default)]
pub event_ref: String,
#[serde(default)]
pub is_initial: bool,
#[serde(default)]
pub edited_at: Option<String>,
#[serde(default)]
pub edited_by: Option<String>,
pub kind: TicketEventKind,
pub author: Option<String>,
pub at: Option<String>,
@@ -1759,7 +1763,25 @@ impl TicketDoctorReport {
}
}
/// A compare-and-swap edit of a real comment post. The host supplies authenticated author.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TicketPostEdit {
pub event_ref: String,
pub expected_body: MarkdownText,
pub body: MarkdownText,
pub author: String,
}
pub trait TicketBackend {
fn edit_post_with_snapshots(
&self,
id: TicketIdOrSlug,
edit: TicketPostEdit,
) -> Result<(Ticket, Ticket)>;
fn edit_post(&self, id: TicketIdOrSlug, edit: TicketPostEdit) -> Result<Ticket> {
self.edit_post_with_snapshots(id, edit)
.map(|(_, current)| current)
}
fn list_workspace_tags(&self, request: TagListRequest) -> Result<Vec<WorkspaceTag>>;
fn mutate_workspace_tag(&self, request: WorkspaceTagMutation) -> Result<WorkspaceTag>;
fn set_ticket_tags(
@@ -1862,6 +1884,10 @@ pub enum TicketBackendOperation {
id: TicketIdOrSlug,
edit: TicketItemEdit,
},
EditPost {
id: TicketIdOrSlug,
edit: TicketPostEdit,
},
DependencyCheck {
id: TicketIdOrSlug,
},
@@ -1927,6 +1953,7 @@ pub enum TicketBackendOperationResult {
Unit,
Tickets(Vec<TicketSummary>),
Ticket(Ticket),
TicketSnapshots { previous: Ticket, current: Ticket },
TicketRef(TicketRef),
DependencyCheck(TicketDependencyCheck),
QueueOutcome(TicketQueueOutcome),
@@ -1972,6 +1999,10 @@ where
TicketBackendOperation::EditItem { id, edit } => {
TicketBackendOperationResult::Ticket(backend.edit_item(id, edit)?)
}
TicketBackendOperation::EditPost { id, edit } => {
let (previous, current) = backend.edit_post_with_snapshots(id, edit)?;
TicketBackendOperationResult::TicketSnapshots { previous, current }
}
TicketBackendOperation::DependencyCheck { id } => {
TicketBackendOperationResult::DependencyCheck(backend.dependency_check(id)?)
}
@@ -2444,14 +2475,6 @@ impl SqliteTicketBackend {
op(&conn)
}
fn created_event_body(&self) -> &'static str {
if is_japanese_record_language(self.record_language()) {
"Ticket が作成されました。"
} else {
"Ticket created."
}
}
fn default_item_body(&self) -> &'static str {
if is_japanese_record_language(self.record_language()) {
"## 背景\n\n## 要件\n\n## 受け入れ条件\n"
@@ -2564,15 +2587,20 @@ impl SqliteTicketBackend {
}
fn insert_event(&self, conn: &Connection, ticket_id: &str, event: &TicketEvent) -> Result<()> {
let next_index: i64 = conn.query_row(
let next_index: i64 = if event.is_initial {
-1
} else {
conn.query_row(
"SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events WHERE workspace_id = ?1 AND ticket_id = ?2",
params![self.workspace_id, ticket_id],
|row| row.get(0),
).map_err(sqlite_err)?;
).map_err(sqlite_err)?
};
let event_ref = format!("{ticket_id}:{next_index}");
conn.execute(r#"INSERT INTO typed_ticket_events
(workspace_id, ticket_id, event_index, kind, author, at, status, from_state, to_state, reason, state_field, heading, body)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)"#,
params![self.workspace_id, ticket_id, next_index, event.kind.as_str(), event.author, event.at, event.status, event.from, event.to, event.reason, event.state_field, event.heading, event.body.as_str()]
(workspace_id, ticket_id, event_index, kind, author, at, status, from_state, to_state, reason, state_field, heading, body, event_ref, archived)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#,
params![self.workspace_id, ticket_id, next_index, event.kind.as_str(), event.author, event.at, event.status, event.from, event.to, event.reason, event.state_field, event.heading, event.body.as_str(), event_ref, matches!(event.kind.as_str(), "create" | "item_edit")]
).map_err(sqlite_err)?;
for (ordinal, reference) in event.references.iter().enumerate() {
conn.execute("INSERT INTO typed_ticket_event_references (workspace_id, ticket_id, event_index, ordinal, kind, target) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
@@ -2611,9 +2639,9 @@ impl SqliteTicketBackend {
fn insert_ticket(&self, conn: &Connection, ticket: &Ticket) -> Result<()> {
conn.execute(r#"INSERT INTO typed_tickets
(workspace_id, ticket_id, slug, title, status, kind, priority, body, created_at, updated_at, assignee, readiness, workflow_state, workflow_state_explicit, queued_by, queued_at, resolution)
(workspace_id, ticket_id, slug, title, status, kind, priority, created_by, created_at, updated_at, assignee, readiness, workflow_state, workflow_state_explicit, queued_by, queued_at, resolution)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)"#,
params![self.workspace_id, ticket.meta.id, ticket.meta.slug, ticket.meta.title, ticket.meta.status.as_str(), ticket.meta.kind, ticket.meta.priority, ticket.document.body.as_str(), ticket.meta.created_at, ticket.meta.updated_at, ticket.meta.assignee, ticket.meta.readiness, ticket.meta.workflow_state.as_str(), if ticket.meta.workflow_state_explicit { 1 } else { 0 }, ticket.meta.queued_by, ticket.meta.queued_at, ticket.resolution.as_ref().map(|body| body.as_str())]
params![self.workspace_id, ticket.meta.id, ticket.meta.slug, ticket.meta.title, ticket.meta.status.as_str(), ticket.meta.kind, ticket.meta.priority, ticket.meta.created_by, ticket.meta.created_at, ticket.meta.updated_at, ticket.meta.assignee, ticket.meta.readiness, ticket.meta.workflow_state.as_str(), if ticket.meta.workflow_state_explicit { 1 } else { 0 }, ticket.meta.queued_by, ticket.meta.queued_at, ticket.resolution.as_ref().map(|body| body.as_str())]
).map_err(sqlite_err)?;
self.replace_ticket_targets(conn, &ticket.meta.id, &ticket.meta.targets)?;
self.insert_ordered_values(
@@ -2735,6 +2763,7 @@ impl SqliteTicketBackend {
priority: row.get(5)?,
labels: Vec::new(),
created_at: row.get(6)?,
created_by: row.get(16)?,
updated_at: row.get(7)?,
assignee: row.get(8)?,
readiness: row.get(9)?,
@@ -2751,7 +2780,7 @@ impl SqliteTicketBackend {
fn load_ticket(&self, conn: &Connection, ticket_id: &str) -> Result<Ticket> {
#[cfg(test)]
self.full_ticket_load_count.fetch_add(1, Ordering::SeqCst);
let (mut meta, body, resolution): (TicketMeta, String, Option<String>) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#,
let (mut meta, body, resolution): (TicketMeta, String, Option<String>) = conn.query_row(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, (SELECT body FROM typed_ticket_events e WHERE e.workspace_id = typed_tickets.workspace_id AND e.ticket_id = typed_tickets.ticket_id AND e.event_index = -1), resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, created_by FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2"#,
params![self.workspace_id, ticket_id], |row| Ok((Self::ticket_meta_from_row(row)?, row.get(10)?, row.get(11)?))).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(ticket_id.to_string()))?;
meta.resource_key = Self::resource_key_for(conn, &self.workspace_id, ticket_id)?;
meta.targets = self.load_ticket_targets(conn, ticket_id)?;
@@ -2861,7 +2890,7 @@ impl SqliteTicketBackend {
}
fn load_events(&self, conn: &Connection, ticket_id: &str) -> Result<Vec<TicketEvent>> {
let mut stmt = conn.prepare(r#"SELECT event_index, kind, author, at, status, from_state, to_state, reason, state_field, heading, body FROM typed_ticket_events WHERE workspace_id = ?1 AND ticket_id = ?2 ORDER BY event_index ASC"#).map_err(sqlite_err)?;
let mut stmt = conn.prepare(r#"SELECT event_index, kind, author, at, status, from_state, to_state, reason, state_field, heading, body, event_ref, edited_at, edited_by FROM typed_ticket_events WHERE workspace_id = ?1 AND ticket_id = ?2 AND archived = 0 ORDER BY event_index ASC"#).map_err(sqlite_err)?;
let rows = stmt
.query_map(params![self.workspace_id, ticket_id], |row| {
Ok((
@@ -2876,17 +2905,38 @@ impl SqliteTicketBackend {
row.get::<_, Option<String>>(8)?,
row.get::<_, Option<String>>(9)?,
row.get::<_, String>(10)?,
row.get::<_, String>(11)?,
row.get::<_, Option<String>>(12)?,
row.get::<_, Option<String>>(13)?,
))
})
.map_err(sqlite_err)?;
let mut events = Vec::new();
for row in rows {
let (index, kind, author, at, status, from, to, reason, state_field, heading, body) =
row.map_err(sqlite_err)?;
let (
index,
kind,
author,
at,
status,
from,
to,
reason,
state_field,
heading,
body,
event_ref,
edited_at,
edited_by,
) = row.map_err(sqlite_err)?;
let mut attributes = self.load_event_attributes(conn, ticket_id, index)?;
attributes.insert("event_id".to_string(), format!("{ticket_id}:{index}"));
attributes.insert("event_id".to_string(), event_ref.clone());
attributes.insert("event_sequence".to_string(), index.to_string());
events.push(TicketEvent {
event_ref,
is_initial: index == -1,
edited_at,
edited_by,
kind: TicketEventKind::from(kind.as_str()),
author,
at,
@@ -2961,7 +3011,7 @@ impl SqliteTicketBackend {
conn: &Connection,
filter: TicketListQuery,
) -> Result<Vec<TicketSummary>> {
let mut stmt = conn.prepare(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, body, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at FROM typed_tickets WHERE workspace_id = ?1 ORDER BY ticket_id ASC"#).map_err(sqlite_err)?;
let mut stmt = conn.prepare(r#"SELECT ticket_id, slug, title, status, kind, priority, created_at, updated_at, assignee, readiness, NULL, resolution, workflow_state, workflow_state_explicit, queued_by, queued_at, created_by FROM typed_tickets WHERE workspace_id = ?1 ORDER BY ticket_id ASC"#).map_err(sqlite_err)?;
let rows = stmt
.query_map(params![self.workspace_id], Self::ticket_meta_from_row)
.map_err(sqlite_err)?;
@@ -3048,6 +3098,36 @@ fn finish_sqlite_transaction<R>(conn: &Connection, result: Result<R>) -> Result<
}
impl TicketBackend for SqliteTicketBackend {
fn edit_post_with_snapshots(
&self,
id: TicketIdOrSlug,
edit: TicketPostEdit,
) -> Result<(Ticket, Ticket)> {
self.with_write(|conn| {
validate_required_event_value("event_ref", &edit.event_ref)?;
validate_required_event_value("author", &edit.author)?;
let ticket_id = self.resolve_ticket_id(conn, id)?;
let previous = self.load_ticket(conn, &ticket_id)?;
let post = previous.events.iter().find(|event| event.event_ref == edit.event_ref)
.ok_or_else(|| TicketError::NotFound(edit.event_ref.clone()))?;
if post.kind != TicketEventKind::Comment {
return Err(TicketError::Conflict("only comment posts can be edited".into()));
}
if post.body != edit.expected_body {
return Err(TicketError::Conflict("stale Ticket post body".into()));
}
let now = now_utc();
let changed = conn.execute(
"UPDATE typed_ticket_events SET body = ?4, edited_at = ?5, edited_by = ?6 WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_ref = ?3 AND body = ?7 AND kind = 'comment' AND archived = 0",
params![self.workspace_id, ticket_id, edit.event_ref, edit.body.as_str(), now, edit.author, edit.expected_body.as_str()],
).map_err(sqlite_err)?;
if changed != 1 { return Err(TicketError::Conflict("stale Ticket post body".into())); }
self.touch_ticket(conn, &ticket_id, &now)?;
let current = self.load_ticket(conn, &ticket_id)?;
Ok((previous, current))
})
}
fn list_workspace_tags(&self, request: TagListRequest) -> Result<Vec<WorkspaceTag>> {
self.list_tags(request)
}
@@ -3109,6 +3189,10 @@ impl TicketBackend for SqliteTicketBackend {
} else {
ExtensibleTicketStatus::Open
};
let created_by = input
.author
.clone()
.filter(|author| !author.trim().is_empty());
let meta = TicketMeta {
id: id.clone(),
resource_key: None,
@@ -3119,6 +3203,7 @@ impl TicketBackend for SqliteTicketBackend {
priority: input.priority,
labels: input.labels,
created_at: Some(now.clone()),
created_by: created_by.clone(),
updated_at: Some(now.clone()),
assignee: input.assignee,
readiness: input.readiness,
@@ -3137,6 +3222,7 @@ impl TicketBackend for SqliteTicketBackend {
};
let author = input
.author
.clone()
.unwrap_or_else(|| "SqliteTicketBackend".to_string());
let ticket = Ticket {
closure_operation_key: None,
@@ -3146,20 +3232,24 @@ impl TicketBackend for SqliteTicketBackend {
tags: Vec::new(),
meta,
document: TicketDocument {
body,
body: body.clone(),
raw_frontmatter: BTreeMap::new(),
},
events: vec![TicketEvent {
kind: TicketEventKind::Create,
author: Some(author.clone()),
event_ref: String::new(),
is_initial: true,
edited_at: None,
edited_by: None,
kind: TicketEventKind::Comment,
author: created_by,
at: Some(now.clone()),
status: None,
from: None,
to: None,
reason: None,
state_field: None,
heading: Some(TicketEventKind::Create.heading()),
body: MarkdownText::new(self.created_event_body()),
heading: None,
body,
references: Vec::new(),
attributes: BTreeMap::new(),
}],
@@ -3226,31 +3316,27 @@ impl TicketBackend for SqliteTicketBackend {
let ticket_id = self.resolve_ticket_id(conn, id)?;
let previous = self.load_ticket(conn, &ticket_id)?;
let now = now_utc();
let mut body_edit_audit = TicketBodyEditAudit::None;
if let Some(title) = edit.title.as_ref() {
conn.execute("UPDATE typed_tickets SET title = ?3, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, title, now]).map_err(sqlite_err)?;
}
let mut updated_body = None;
if let Some(body) = edit.body.as_ref() {
updated_body = Some(body.clone());
body_edit_audit = TicketBodyEditAudit::WholeBody;
}
if let Some(replacement) = edit.body_replacement.as_ref() {
let current_body: String = conn
.query_row(
"SELECT body FROM typed_tickets WHERE workspace_id = ?1 AND ticket_id = ?2",
"SELECT body FROM typed_ticket_events WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = -1",
params![self.workspace_id, ticket_id],
|row| row.get(0),
)
.map_err(sqlite_err)?;
let outcome = replacement.apply(current_body.as_str())?;
updated_body = Some(outcome.body);
body_edit_audit = TicketBodyEditAudit::Partial {
replacement_count: outcome.replacement_count,
};
}
if let Some(body) = updated_body.as_ref() {
conn.execute("UPDATE typed_tickets SET body = ?3, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, body.as_str(), now]).map_err(sqlite_err)?;
conn.execute("UPDATE typed_ticket_events SET body = ?3, edited_at = ?4, edited_by = ?5 WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = -1", params![self.workspace_id, ticket_id, body.as_str(), now, edit.author]).map_err(sqlite_err)?;
self.touch_ticket(conn, &ticket_id, &now)?;
}
if let Some(targets) = edit.targets.as_ref() {
let targets = match targets {
@@ -3261,43 +3347,6 @@ impl TicketBackend for SqliteTicketBackend {
self.touch_ticket(conn, &ticket_id, &now)?;
}
let mut changes = Vec::new();
if edit.title.is_some() {
changes.push("title");
}
if !matches!(body_edit_audit, TicketBodyEditAudit::None) {
changes.push("body");
}
if edit.targets.is_some() {
changes.push("targets");
}
let mut attributes = BTreeMap::new();
attributes.insert("changes".to_string(), changes.join(","));
let body = match body_edit_audit {
TicketBodyEditAudit::None => {
MarkdownText::new(format!("Ticket item updated: {}.", changes.join(", ")))
}
TicketBodyEditAudit::WholeBody => {
attributes.insert("body_edit".to_string(), "whole".to_string());
MarkdownText::new(format!(
"Ticket item updated: {}. Body was replaced as a whole.",
changes.join(", ")
))
}
TicketBodyEditAudit::Partial { replacement_count } => {
attributes.insert("body_edit".to_string(), "partial".to_string());
attributes.insert(
"replacement_count".to_string(),
replacement_count.to_string(),
);
MarkdownText::new(format!(
"Ticket item updated: {}. Body replacement applied to {replacement_count} occurrence(s).",
changes.join(", ")
))
}
};
let event = TicketEvent { kind: TicketEventKind::Other("item_edit".to_string()), author: Some(edit.author.unwrap_or_else(default_author)), at: Some(now), status: None, from: None, to: None, reason: None, state_field: None, heading: Some("Item edit".to_string()), body, references: Vec::new(), attributes };
self.insert_event(conn, &ticket_id, &event)?;
let current = self.load_ticket(conn, &ticket_id)?;
Ok((previous, current))
})
@@ -3335,6 +3384,10 @@ impl TicketBackend for SqliteTicketBackend {
conn,
&ticket_id,
&TicketEvent {
event_ref: String::new(),
is_initial: false,
edited_at: None,
edited_by: None,
kind: event.kind.clone(),
author: event.author.or_else(|| Some(default_author())),
at: Some(at.clone()),
@@ -3368,6 +3421,10 @@ impl TicketBackend for SqliteTicketBackend {
conn,
&ticket_id,
&TicketEvent {
event_ref: String::new(),
is_initial: false,
edited_at: None,
edited_by: None,
kind: TicketEventKind::IntakeSummary,
author: Some(summary.author.unwrap_or_else(default_author)),
at: Some(at.clone()),
@@ -3444,7 +3501,7 @@ impl TicketBackend for SqliteTicketBackend {
self.insert_case_history(conn, &ticket_id, &entry, &fingerprint)?;
conn.execute("UPDATE typed_tickets SET workflow_state = ?3, workflow_state_explicit = 1, status = ?3, updated_at = ?4, resolution = CASE WHEN ?3 = 'closed' THEN ?5 ELSE NULL END WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, state.as_str(), at, request.explanation]).map_err(sqlite_err)?;
let kind = if matches!(request.action, TicketCaseAction::Close { .. }) { TicketEventKind::Close } else { TicketEventKind::StateChanged };
self.insert_event(conn, &ticket_id, &TicketEvent {
self.insert_event(conn, &ticket_id, &TicketEvent { event_ref: String::new(), is_initial: false, edited_at: None, edited_by: None,
heading: Some(kind.heading()), kind, author: Some(entry.author), at: Some(at), status: Some(state.to_string()), from: Some(previous.meta.workflow_state.to_string()), to: Some(state.to_string()), reason: Some(request.explanation.clone()), state_field: Some("case".into()), body: MarkdownText::new(&request.explanation), references: request.references.clone(),
attributes: BTreeMap::from([("operation_key".into(), request.operation_key.clone()), ("fingerprint_version".into(), "case-update-v1".into()), ("request_fingerprint".into(), fingerprint.clone()), ("case_action".into(), serde_json::to_string(&request.action).map_err(sqlite_err)?), ("expected_content_digest".into(), request.expected_content_digest.clone())]),
})?;
@@ -3503,7 +3560,7 @@ impl TicketBackend for SqliteTicketBackend {
self.insert_event(
conn,
&ticket_id,
&TicketEvent {
&TicketEvent { event_ref: String::new(), is_initial: false, edited_at: None, edited_by: None,
kind: TicketEventKind::IntakeSummary,
author: summary.author,
at: None,
@@ -3522,7 +3579,7 @@ impl TicketBackend for SqliteTicketBackend {
self.insert_event(
conn,
&ticket_id,
&TicketEvent {
&TicketEvent { event_ref: String::new(), is_initial: false, edited_at: None, edited_by: None,
kind: TicketEventKind::StateChanged,
author: Some(request.author.unwrap_or_else(default_author)),
at: Some(at.clone()),
@@ -3588,7 +3645,7 @@ impl TicketBackend for SqliteTicketBackend {
"UPDATE typed_tickets SET queued_by = ?3, queued_at = ?4, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2",
params![self.workspace_id, requested_ticket, queued_by, at],
).map_err(sqlite_err)?;
self.insert_event(conn, &requested_ticket, &TicketEvent {
self.insert_event(conn, &requested_ticket, &TicketEvent { event_ref: String::new(), is_initial: false, edited_at: None, edited_by: None,
kind: TicketEventKind::StateChanged,
author: Some(queued_by.to_owned()),
at: Some(at),
@@ -4397,15 +4454,11 @@ mod tests {
ticket_target("docs", Some("main"), TicketTargetAccess::ReadOnly),
]
);
let edit_event = edited
.events
.iter()
.rev()
.find(|event| event.kind == TicketEventKind::Other("item_edit".to_string()))
.expect("item_edit event");
assert_eq!(
edit_event.attributes.get("changes"),
Some(&"targets".to_string())
assert!(
!edited
.events
.iter()
.any(|event| event.kind.as_str() == "item_edit")
);
let cleared = backend
@@ -4443,21 +4496,11 @@ mod tests {
edited.document.body.as_str().trim_start_matches('\n'),
"ALPHA\nbeta\nALPHA\n"
);
let edit_event = edited
.events
.iter()
.rev()
.find(|event| event.kind == TicketEventKind::Other("item_edit".to_string()))
.expect("item_edit event");
assert_eq!(
edit_event.attributes.get("body_edit"),
Some(&"partial".to_string())
);
assert_eq!(
edit_event.attributes.get("replacement_count"),
Some(&"2".to_string())
);
assert!(edit_event.body.as_str().contains("2 occurrence"));
assert_eq!(edited.events.len(), 1);
assert_eq!(edited.events[0].kind, TicketEventKind::Comment);
assert_eq!(edited.events[0].body, edited.document.body);
assert_eq!(edited.events[0].edited_by.as_deref(), Some("tester"));
assert!(edited.events[0].edited_at.is_some());
let duplicate_err = backend
.edit_item(
@@ -5472,12 +5515,7 @@ mod tests {
let ticket = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
assert!(ticket.document.body.as_str().contains("## 背景"));
assert!(
ticket.events[0]
.body
.as_str()
.contains("Ticket が作成されました。")
);
assert!(ticket.events[0].body.as_str().contains("## 背景"));
}
#[test]
+50
View File
@@ -0,0 +1,50 @@
-- Ordered component 11 -> 12. Existing event indices and child rows are never rewritten.
ALTER TABLE typed_tickets ADD COLUMN created_by TEXT;
ALTER TABLE typed_ticket_events ADD COLUMN event_ref TEXT NOT NULL DEFAULT '';
ALTER TABLE typed_ticket_events ADD COLUMN edited_at TEXT;
ALTER TABLE typed_ticket_events ADD COLUMN edited_by TEXT;
ALTER TABLE typed_ticket_events ADD COLUMN archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1));
-- The retained SQLite loader published ticket_id:event_index even when a raw
-- event_id attribute existed. Preserve that identity, not a display ordinal or
-- the unpublished attribute; leave the stored attributes untouched as evidence.
UPDATE typed_ticket_events SET event_ref = ticket_id || ':' || event_index;
-- A retained row at the reserved initial index fails the enclosing transaction;
-- never silently rename its published identity or move its child rows.
CREATE UNIQUE INDEX typed_ticket_events_ref ON typed_ticket_events(workspace_id, ticket_id, event_ref);
UPDATE typed_tickets SET created_by = COALESCE((
SELECT CASE WHEN trim(author) = '' THEN NULL ELSE author END FROM typed_ticket_events e
WHERE e.workspace_id = typed_tickets.workspace_id AND e.ticket_id = typed_tickets.ticket_id
AND e.kind = 'create' ORDER BY e.event_index LIMIT 1
), (
SELECT CASE WHEN trim(value) = '' THEN NULL ELSE value END FROM typed_ticket_raw_frontmatter m
WHERE m.workspace_id = typed_tickets.workspace_id AND m.ticket_id = typed_tickets.ticket_id
AND m.key = 'created_by'
));
INSERT INTO typed_ticket_events (workspace_id, ticket_id, event_index, event_ref, kind, author, at, body)
SELECT t.workspace_id, t.ticket_id, -1, t.ticket_id || ':-1', 'comment', t.created_by,
COALESCE((SELECT at FROM typed_ticket_events e
WHERE e.workspace_id = t.workspace_id AND e.ticket_id = t.ticket_id AND e.kind = 'create'
ORDER BY e.event_index LIMIT 1), t.created_at), t.body
FROM typed_tickets t;
-- A later title/target edit is not an edit of the current initial-post body.
-- Take both edit fields from the same last known body-edit record. Missing actor
-- or time stays unknown rather than falling back to the creator or an older edit.
WITH body_edits AS (
SELECT e.workspace_id, e.ticket_id, e.at, e.author,
ROW_NUMBER() OVER (
PARTITION BY e.workspace_id, e.ticket_id ORDER BY e.event_index DESC
) AS recency
FROM typed_ticket_events e
JOIN typed_ticket_event_attributes a
ON a.workspace_id = e.workspace_id AND a.ticket_id = e.ticket_id
AND a.event_index = e.event_index AND a.key = 'changes'
WHERE e.kind = 'item_edit' AND instr(',' || a.value || ',', ',body,') > 0
)
UPDATE typed_ticket_events SET (edited_at, edited_by) = (
SELECT at, CASE WHEN trim(author) = '' THEN NULL ELSE author END FROM body_edits e
WHERE e.workspace_id = typed_ticket_events.workspace_id
AND e.ticket_id = typed_ticket_events.ticket_id AND e.recency = 1
) WHERE event_index = -1;
-- Retain the original records, attributes, references and stable identities as archive history.
UPDATE typed_ticket_events SET archived = 1 WHERE kind IN ('create', 'item_edit');
ALTER TABLE typed_tickets DROP COLUMN body;
+101 -69
View File
@@ -20,7 +20,7 @@ use crate::{Result, TicketError, sqlite_err};
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 11;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 12;
const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline";
#[derive(Clone, Copy)]
@@ -321,10 +321,10 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
}
/// Composes retained 6/7, tags-only 8 or cases-only 9 through component 10, then
/// appends the 10→11 assignment migration without replacing the applied 10 marker.
/// appends the 10→11 assignment and 11→12 post migrations without replacing applied markers.
/// The host owns the surrounding transaction (for example Workspace 91→92).
/// A savepoint rolls back this component on failure, without committing host work.
/// Current component 11 is verified only; immutable judgments are never reapplied.
/// Current component 12 is verified only; immutable judgments are never reapplied.
pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> Result<()> {
if connection.is_autocommit() {
return Err(TicketError::Sqlite(
@@ -348,32 +348,12 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R
create_latest_ticket_schema(connection)?;
connection
.execute(
"INSERT INTO ticket_schema_migrations VALUES (11, ?1, ?2)",
"INSERT INTO ticket_schema_migrations VALUES (12, ?1, ?2)",
params![BASELINE_MIGRATION_NAME, chrono::Utc::now().to_rfc3339()],
)
.map_err(sqlite_err)?;
} else {
let version = if applied
== BTreeMap::from([
(10, BASELINE_MIGRATION_NAME.to_owned()),
(11, "Ticket-owned assignment tracking".to_owned()),
]) {
11
} else {
match applied.iter().next() {
Some((&version, name))
if applied.len() == 1
&& name == BASELINE_MIGRATION_NAME
&& matches!(version, 6..=11) =>
{
version
}
_ => {
validate_applied_migrations(&applied)?;
unreachable!()
}
}
};
let version = recognized_schema_version(&applied)?;
if version == 6 {
migrate_v6_to_v7(connection)?;
connection
@@ -392,7 +372,9 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R
}
TicketSchemaShape::Tags8 => migrate_legacy_cases(connection)?,
TicketSchemaShape::Cases9 => add_tag_schema(connection)?,
TicketSchemaShape::Composed10 | TicketSchemaShape::Tracking11 => {}
TicketSchemaShape::Composed10
| TicketSchemaShape::Tracking11
| TicketSchemaShape::Posts12 => {}
}
if version < 10 {
connection
@@ -406,6 +388,10 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R
crate::assignment_tracking::migrate_assignment_tracking(connection)?;
connection.execute("INSERT INTO ticket_schema_migrations VALUES (11, 'Ticket-owned assignment tracking', ?1)", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?;
}
if version < 12 {
migrate_posts(connection)?;
connection.execute("INSERT INTO ticket_schema_migrations VALUES (12, 'Canonical Ticket posts', ?1)", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?;
}
}
verify_sqlite_ticket_schema(connection)
})();
@@ -429,6 +415,7 @@ enum TicketSchemaShape {
Cases9,
Composed10,
Tracking11,
Posts12,
}
impl TicketSchemaShape {
fn from_version(version: i64) -> Self {
@@ -438,6 +425,7 @@ impl TicketSchemaShape {
9 => Self::Cases9,
10 => Self::Composed10,
11 => Self::Tracking11,
12 => Self::Posts12,
_ => unreachable!(),
}
}
@@ -448,19 +436,26 @@ impl TicketSchemaShape {
Self::Cases9 => 9,
Self::Composed10 => 10,
Self::Tracking11 => 11,
Self::Posts12 => 12,
}
}
fn tags(self) -> bool {
matches!(self, Self::Tags8 | Self::Composed10 | Self::Tracking11)
matches!(
self,
Self::Tags8 | Self::Composed10 | Self::Tracking11 | Self::Posts12
)
}
fn cases(self) -> bool {
matches!(self, Self::Cases9 | Self::Composed10 | Self::Tracking11)
matches!(
self,
Self::Cases9 | Self::Composed10 | Self::Tracking11 | Self::Posts12
)
}
}
/// Verifies the current Ticket-owned SQLite schema without executing DDL.
pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
verify_ticket_schema_shape(connection, TicketSchemaShape::Tracking11)
verify_ticket_schema_shape(connection, TicketSchemaShape::Posts12)
}
/// Retained Workspace schema-66 migration produces the pre-classification v7
@@ -496,21 +491,16 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
match load_applied_migrations(connection) {
Ok(applied) => {
let history = if applied
== BTreeMap::from([(shape.version(), BASELINE_MIGRATION_NAME.to_owned())])
|| (matches!(shape, TicketSchemaShape::Tracking11)
&& applied
== BTreeMap::from([
(10, BASELINE_MIGRATION_NAME.to_owned()),
(11, "Ticket-owned assignment tracking".to_owned()),
])) {
Ok(())
} else {
Err(TicketError::Sqlite(format!(
"Ticket schema migration history must contain only the canonical version {} baseline marker",
shape.version()
)))
};
let history = recognized_schema_version(&applied).and_then(|version| {
if version == shape.version() {
Ok(())
} else {
Err(TicketError::Sqlite(format!(
"Ticket schema migration history must match version {}",
shape.version()
)))
}
});
if let Err(error) = history {
push_diagnostic(&mut diagnostics, error.to_string());
}
@@ -578,7 +568,22 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
{
continue;
}
collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics);
let mut columns = columns.to_vec();
if matches!(shape, TicketSchemaShape::Posts12) {
if table == "typed_tickets" {
columns.retain(|c| c.name != "body");
columns.push(column("created_by", "TEXT", false, 0));
}
if table == "typed_ticket_events" {
columns.extend([
column("event_ref", "TEXT", true, 0),
column("edited_at", "TEXT", false, 0),
column("edited_by", "TEXT", false, 0),
column("archived", "INTEGER", true, 0),
]);
}
}
collect_table_diagnostics(connection, table, &columns, foreign_keys, &mut diagnostics);
}
if shape.tags() {
@@ -803,13 +808,26 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics);
}
if matches!(shape, TicketSchemaShape::Tracking11) {
if matches!(
shape,
TicketSchemaShape::Tracking11 | TicketSchemaShape::Posts12
) {
if let Err(error) =
crate::assignment_tracking::verify_assignment_tracking_schema(connection)
{
push_diagnostic(&mut diagnostics, error.to_string());
}
}
if matches!(shape, TicketSchemaShape::Posts12) {
collect_index_diagnostics(
connection,
"typed_ticket_events",
Some("typed_ticket_events_ref"),
true,
&["workspace_id", "ticket_id", "event_ref"],
&mut diagnostics,
);
}
if diagnostics.is_empty() {
Ok(())
} else {
@@ -830,7 +848,14 @@ fn create_latest_ticket_schema(connection: &Connection) -> Result<()> {
connection
.execute_batch(include_str!("latest_schema.sql"))
.map_err(sqlite_err)?;
crate::assignment_tracking::migrate_assignment_tracking(connection)
crate::assignment_tracking::migrate_assignment_tracking(connection)?;
migrate_posts(connection)
}
fn migrate_posts(connection: &Connection) -> Result<()> {
connection
.execute_batch(include_str!("post_migration.sql"))
.map_err(sqlite_err)
}
/// Retained 7/8 case migration: leave tags and historical receipts untouched.
@@ -1077,24 +1102,30 @@ fn load_applied_migrations(connection: &Connection) -> Result<BTreeMap<i64, Stri
Ok(applied)
}
fn validate_applied_migrations(applied: &BTreeMap<i64, String>) -> Result<()> {
let expected = BTreeMap::from([(
LATEST_SQLITE_TICKET_SCHEMA_VERSION,
BASELINE_MIGRATION_NAME.to_string(),
)]);
if applied == &expected
|| applied
== &BTreeMap::from([
(10, BASELINE_MIGRATION_NAME.to_owned()),
(11, "Ticket-owned assignment tracking".to_owned()),
])
{
Ok(())
} else {
Err(TicketError::Sqlite(format!(
"Ticket schema migration history must contain only the canonical version {LATEST_SQLITE_TICKET_SCHEMA_VERSION} baseline marker"
)))
fn recognized_schema_version(applied: &BTreeMap<i64, String>) -> Result<i64> {
if applied.len() == 1 {
if let Some((&version, name)) = applied.first_key_value() {
if (6..=12).contains(&version) && name == BASELINE_MIGRATION_NAME {
return Ok(version);
}
}
}
for baseline in [10, 11] {
let mut expected = BTreeMap::from([(baseline, BASELINE_MIGRATION_NAME.to_owned())]);
if baseline == 10 {
expected.insert(11, "Ticket-owned assignment tracking".to_owned());
if applied == &expected {
return Ok(11);
}
}
expected.insert(12, "Canonical Ticket posts".to_owned());
if applied == &expected {
return Ok(12);
}
}
Err(TicketError::Sqlite(format!(
"Ticket schema migration history must contain a canonical baseline and ordered migrations through {LATEST_SQLITE_TICKET_SCHEMA_VERSION}"
)))
}
#[derive(Debug)]
@@ -1591,7 +1622,8 @@ mod tests {
load_applied_migrations(&connection).unwrap(),
BTreeMap::from([
(10, BASELINE_MIGRATION_NAME.to_owned()),
(11, "Ticket-owned assignment tracking".to_owned())
(11, "Ticket-owned assignment tracking".to_owned()),
(12, "Canonical Ticket posts".to_owned())
])
);
}
@@ -1688,8 +1720,8 @@ mod tests {
.unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
connection.execute_batch(
"INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
VALUES ('one','ticket','ticket','Title','open','task','P2','body','open',1);
"INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit)
VALUES ('one','ticket','ticket','Title','open','task','P2','open',1);
INSERT INTO workspace_tags VALUES ('two','tag','Name','name','create',0);"
).unwrap();
assert!(
@@ -1734,7 +1766,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain only the canonical version 11 baseline marker"
"migration history must contain a canonical baseline and ordered migrations through 12"
));
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
}
@@ -1756,7 +1788,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain only the canonical version 11 baseline marker"
"migration history must contain a canonical baseline and ordered migrations through 12"
));
assert!(!table_exists(&connection, "typed_tickets").unwrap());
}
+4
View File
@@ -201,6 +201,10 @@ impl SqliteTicketBackend {
conn,
ticket_id,
&TicketEvent {
event_ref: String::new(),
is_initial: false,
edited_at: None,
edited_by: None,
kind: TicketEventKind::TagsChanged,
author: Some(default_author()),
at: Some(now_utc()),
+229 -34
View File
@@ -37,9 +37,10 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
pub const TICKET_BASE_TOOL_NAMES: [&str; 13] = [
pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [
"TicketCreate",
"TicketEditItem",
"TicketEditPost",
"QueryTicket",
"ShowTicket",
"TicketComment",
@@ -71,9 +72,10 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
pub const TICKET_TOOL_NAMES: [&str; 19] = [
pub const TICKET_TOOL_NAMES: [&str; 20] = [
"TicketCreate",
"TicketEditItem",
"TicketEditPost",
"QueryTicket",
"ShowTicket",
"TicketComment",
@@ -102,9 +104,10 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
"TicketOrchestrationPlanQuery",
];
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [
"TicketCreate",
"TicketEditItem",
"TicketEditPost",
"TicketComment",
"TicketPlan",
"TicketDecision",
@@ -121,13 +124,16 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \
`title` is required, `body` is Markdown, and the backend assigns the id.";
const EDIT_ITEM_DESCRIPTION: &str = "Edit a Ticket item through the configured typed Ticket backend. \
This updates the current item title/body and appends an audited item_edit event. Intended for \
This updates the current item title and/or initial comment body without adding a discussion entry. Intended for \
User/Companion authoring surfaces, not Orchestrator implementation control.";
const EDIT_POST_DESCRIPTION: &str = "Edit an initial or discussion comment by stable event_ref and exact expected_body. \
Preserves original author and time and records edit metadata without adding a discussion entry. \
System events and case judgments cannot be edited. The Workspace backend supplies the authenticated editor.";
const LIST_DESCRIPTION: &str = "Query Tickets from the configured typed Ticket backend as a bounded \
overview. Workspace-backed Workers replace this definition with the richer authoritative \
text/event/evidence/relation/Objective/time/attention query.";
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured typed \
Ticket backend. Output includes bounded Markdown body, recent events, resolution, and artifact \
Ticket backend. Output includes the initial comment and recent events with stable event_ref, author/time and edit metadata, bounded Markdown body, resolution, and artifact \
metadata; Workspace-backed Workers replace this definition with the richer authoritative evidence projection.";
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket comment event. `body` is Markdown.";
const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Markdown.";
@@ -166,6 +172,7 @@ fn base_tool_description(name: &str) -> &'static str {
match name {
"TicketCreate" => CREATE_DESCRIPTION,
"TicketEditItem" => EDIT_ITEM_DESCRIPTION,
"TicketEditPost" => EDIT_POST_DESCRIPTION,
"QueryTicket" => LIST_DESCRIPTION,
"ShowTicket" => SHOW_DESCRIPTION,
"TicketComment" => COMMENT_DESCRIPTION,
@@ -246,6 +253,14 @@ impl From<crate::SqliteTicketBackend> for TicketToolBackend {
}
impl TicketBackend for TicketToolBackend {
fn edit_post_with_snapshots(
&self,
id: TicketIdOrSlug,
edit: crate::TicketPostEdit,
) -> TicketResult<(Ticket, Ticket)> {
self.backend.edit_post_with_snapshots(id, edit)
}
fn list_workspace_tags(
&self,
request: crate::TagListRequest,
@@ -437,6 +452,19 @@ struct TicketEditItemParams {
targets: Option<crate::TicketTargetsEdit>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
struct TicketEditPostParams {
/// Ticket reference. Prefer T-*; canonical internal ids remain accepted.
ticket: String,
/// Persisted post identity returned by ShowTicket, not a display ordinal.
event_ref: String,
/// Exact observed Markdown body. A stale or truncated body cannot authorize an edit.
expected_body: String,
/// Replacement Markdown body.
body: String,
}
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TicketStateParam {
@@ -798,6 +826,11 @@ struct TicketEditItemTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct TicketEditPostTool {
backend: TicketToolBackend,
}
#[derive(Clone)]
struct QueryTicketTool {
backend: TicketToolBackend,
@@ -982,6 +1015,38 @@ impl Tool for TicketEditItemTool {
}
}
#[async_trait]
impl Tool for TicketEditPostTool {
async fn execute(
&self,
input_json: &str,
_ctx: agen::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: TicketEditPostParams = parse_input("TicketEditPost", input_json)?;
let ticket = self
.backend
.edit_post(
TicketIdOrSlug::from(params.ticket),
crate::TicketPostEdit {
event_ref: params.event_ref,
expected_body: MarkdownText::new(params.expected_body),
body: MarkdownText::new(params.body),
author: default_author(),
},
)
.map_err(|error| backend_error("TicketEditPost", error))?;
Ok(json_output(
format!("Edited Ticket post in {}", ticket.meta.id),
ticket_json(
&ticket,
DEFAULT_EVENT_LIMIT,
DEFAULT_ARTIFACT_LIMIT,
DEFAULT_BODY_MAX_BYTES,
),
))
}
}
#[async_trait]
impl Tool for QueryTicketTool {
async fn execute(
@@ -1629,6 +1694,28 @@ fn ticket_relations_json(ticket: &Ticket) -> Value {
})
}
fn ticket_event_json(event: &crate::TicketEvent, body_max_bytes: usize) -> Value {
json!({
"event_ref": event.event_ref,
"is_initial": event.is_initial,
"kind": event.kind.as_str(),
"author": event.author,
"at": event.at,
"edited_at": event.edited_at,
"edited_by": event.edited_by,
"state": event.status,
"from": event.from,
"to": event.to,
"reason": event.reason,
"state_field": event.state_field,
"attributes": event.attributes,
"references": event.references,
"heading": event.heading,
"body": truncate_text(event.body.as_str(), body_max_bytes),
"body_truncated": event.body.as_str().len() > body_max_bytes,
})
}
fn ticket_json(
ticket: &Ticket,
event_limit: usize,
@@ -1640,21 +1727,7 @@ fn ticket_json(
.events
.iter()
.skip(event_count.saturating_sub(event_limit))
.map(|event| {
json!({
"kind": event.kind.as_str(),
"author": event.author,
"at": event.at,
"state": event.status,
"from": event.from,
"to": event.to,
"reason": event.reason,
"state_field": event.state_field,
"attributes": event.attributes,
"heading": event.heading,
"body": truncate_text(event.body.as_str(), body_max_bytes),
})
})
.map(|event| ticket_event_json(event, body_max_bytes))
.collect();
let artifact_count = ticket.artifacts.len();
let artifacts: Vec<_> = ticket
@@ -1669,6 +1742,7 @@ fn ticket_json(
"title": ticket.meta.title,
"state": ticket.meta.workflow_state.as_str(),
"created_at": ticket.meta.created_at,
"created_by": ticket.meta.created_by,
"updated_at": ticket.meta.updated_at,
"assignee": ticket.meta.assignee,
"readiness": ticket.meta.readiness,
@@ -1682,6 +1756,7 @@ fn ticket_json(
"current_closure": ticket.current_closure.as_ref().map(|j| json!({"operation_key": j.operation_key, "reason": j.reason, "explanation": truncate_text(&j.explanation, body_max_bytes), "author": j.author, "references": j.references, "at": j.at})),
"case_history": { "count": ticket.case_history.len(), "items": ticket.case_history.iter().rev().take(event_limit).map(|j| json!({"operation_key": j.operation_key, "previous_closure_operation_key": j.previous_closure_operation_key, "action": j.action, "explanation": truncate_text(&j.explanation, body_max_bytes), "author": j.author, "references": j.references, "at": j.at})).collect::<Vec<_>>() },
"legacy_workflow_snapshot": ticket.legacy_workflow_snapshot,
"initial_post": ticket.events.iter().find(|event| event.is_initial).map(|event| ticket_event_json(event, body_max_bytes)),
"body": truncate_text(ticket.document.body.as_str(), body_max_bytes),
"events": {
"count": event_count,
@@ -1784,6 +1859,7 @@ fn input_schema(name: &str) -> Value {
match name {
"TicketCreate" => serde_json::to_value(schemars::schema_for!(TicketCreateParams)),
"TicketEditItem" => serde_json::to_value(schemars::schema_for!(TicketEditItemParams)),
"TicketEditPost" => serde_json::to_value(schemars::schema_for!(TicketEditPostParams)),
"QueryTicket" => serde_json::to_value(schemars::schema_for!(QueryTicketParams)),
"ShowTicket" => serde_json::to_value(schemars::schema_for!(ShowTicketParams)),
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
@@ -1829,6 +1905,7 @@ macro_rules! impl_from_backend {
impl_from_backend!(TicketCreateTool);
impl_from_backend!(TicketEditItemTool);
impl_from_backend!(TicketEditPostTool);
impl_from_backend!(QueryTicketTool);
impl_from_backend!(ShowTicketTool);
impl_from_backend!(TicketCommentTool);
@@ -1853,6 +1930,7 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
vec![
tool_definition::<TicketCreateTool>("TicketCreate", backend.clone()),
tool_definition::<TicketEditItemTool>("TicketEditItem", backend.clone()),
tool_definition::<TicketEditPostTool>("TicketEditPost", backend.clone()),
tool_definition::<QueryTicketTool>("QueryTicket", backend.clone()),
tool_definition::<ShowTicketTool>("ShowTicket", backend.clone()),
tool_definition::<TicketCommentTool>("TicketComment", backend.clone()),
@@ -1948,6 +2026,131 @@ mod tests {
.expect("tool exists")
}
#[tokio::test]
async fn edit_post_tool_and_show_preserve_common_identity_and_metadata() {
let temp = TempDir::new().unwrap();
let backend = backend(&temp);
let mut input = NewTicket::new("Post tools");
input.author = Some("creator".into());
input.body = MarkdownText::new("Initial body");
let reference = backend.create(input).unwrap();
let comment = tool_by_name(backend.clone(), "TicketComment");
comment
.execute(
&json!({"ticket": reference.id, "body": "Discussion body"}).to_string(),
Default::default(),
)
.await
.unwrap();
let before = backend.show(reference.id.clone().into()).unwrap();
let edit = tool_by_name(backend.clone(), "TicketEditPost");
let show = tool_by_name(backend.clone(), "ShowTicket");
let schema = input_schema("TicketEditPost");
assert_eq!(schema["additionalProperties"], false);
for key in ["ticket", "event_ref", "expected_body", "body"] {
assert!(schema["required"].as_array().unwrap().contains(&json!(key)));
}
assert!(
!schema["properties"]
.as_object()
.unwrap()
.contains_key("author")
);
for post in &before.events {
let args = json!({"ticket": reference.id, "event_ref": post.event_ref, "expected_body": post.body.as_str(), "body": format!("Edited {}", post.body.as_str())});
let output = edit
.execute(&args.to_string(), Default::default())
.await
.unwrap();
let value: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
assert_eq!(value["events"]["count"], 2);
let projected = value["events"]["items"]
.as_array()
.unwrap()
.iter()
.find(|e| e["event_ref"] == post.event_ref)
.unwrap();
assert_eq!(projected["author"], json!(post.author));
assert_eq!(projected["at"], json!(post.at));
assert_eq!(projected["is_initial"], post.is_initial);
assert_eq!(projected["edited_by"], default_author());
assert!(projected["edited_at"].is_string());
assert_eq!(projected["body_truncated"], false);
assert!(
edit.execute(&args.to_string(), Default::default())
.await
.is_err()
);
let mut spoofed = args.clone();
spoofed["author"] = json!("spoofed-author");
assert!(
edit.execute(&spoofed.to_string(), Default::default())
.await
.is_err()
);
}
let output = show
.execute(
&json!({"id": reference.id, "event_limit": 1}).to_string(),
Default::default(),
)
.await
.unwrap();
let value: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
assert_eq!(value["events"]["returned"], 1);
assert_eq!(value["events"]["items"][0]["is_initial"], false);
assert_eq!(value["initial_post"]["is_initial"], true);
assert_eq!(
value["initial_post"]["event_ref"],
before.events[0].event_ref
);
assert_eq!(value["initial_post"]["body"], value["body"]);
assert_eq!(value["meta"]["created_by"], "creator");
assert_eq!(value["initial_post"]["author"], "creator");
let initial_keys = value["initial_post"]
.as_object()
.unwrap()
.keys()
.collect::<Vec<_>>();
let comment_keys = value["events"]["items"][0]
.as_object()
.unwrap()
.keys()
.collect::<Vec<_>>();
assert_eq!(initial_keys, comment_keys);
let clipped = ticket_json(&backend.show(reference.id.clone().into()).unwrap(), 1, 0, 3);
assert_eq!(clipped["initial_post"]["body_truncated"], true);
assert_eq!(
clipped["initial_post"]["event_ref"],
before.events[0].event_ref
);
assert_eq!(clipped["initial_post"]["author"], "creator");
let decision = tool_by_name(backend.clone(), "TicketDecision");
decision
.execute(
&json!({"ticket": reference.id, "body": "Immutable judgment"}).to_string(),
Default::default(),
)
.await
.unwrap();
let current = backend.show(reference.id.clone().into()).unwrap();
let system = current.events.last().unwrap();
assert!(edit.execute(&json!({"ticket": reference.id, "event_ref": system.event_ref, "expected_body": system.body.as_str(), "body": "Changed judgment"}).to_string(), Default::default()).await.is_err());
assert_eq!(backend.show(reference.id.into()).unwrap(), current);
let (_, unknown) = backend
.create_with_snapshot(NewTicket::new("Unknown creator"))
.unwrap();
let unknown_json = ticket_json(&unknown, 1, 0, 100);
assert!(
unknown_json["meta"]
.as_object()
.unwrap()
.contains_key("created_by")
);
assert!(unknown_json["meta"]["created_by"].is_null());
assert!(unknown_json["initial_post"]["author"].is_null());
}
#[test]
fn post_mutation_reference_failures_are_outcome_unknown() {
let temp = TempDir::new().unwrap();
@@ -1978,6 +2181,7 @@ mod tests {
[
"TicketCreate",
"TicketEditItem",
"TicketEditPost",
"TicketComment",
"TicketPlan",
"TicketDecision",
@@ -2321,20 +2525,10 @@ mod tests {
let record = backend
.show(TicketIdOrSlug::Id(created.id.clone()))
.unwrap();
let event = record
.events
.iter()
.rev()
.find(|event| event.kind == TicketEventKind::Other("item_edit".to_string()))
.expect("item_edit event");
assert_eq!(
event.attributes.get("body_edit"),
Some(&"partial".to_string())
);
assert_eq!(
event.attributes.get("replacement_count"),
Some(&"2".to_string())
);
assert_eq!(record.events.len(), 1);
assert_eq!(record.events[0].kind, TicketEventKind::Comment);
assert_eq!(record.events[0].body, record.document.body);
assert!(record.events[0].edited_at.is_some());
let error = edit
.execute(
@@ -2655,6 +2849,7 @@ mod tests {
for name in [
"TicketCreate",
"TicketEditItem",
"TicketEditPost",
"TicketComment",
"TicketPlan",
"TicketDecision",
+54 -12
View File
@@ -51,7 +51,7 @@ fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_withou
.unwrap();
}
// Snapshot evidence before migration, including untyped `done` and existing MR references.
let historical_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
let historical_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
let old_attributes: Vec<(String, String, String)> = connection
.prepare(
"SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key",
@@ -79,7 +79,7 @@ fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_withou
})
.unwrap();
assert_eq!(marker, 10);
let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
assert_eq!(after_events, historical_events);
let store = SqliteTicketBackend::open_verified(&path, "workspace").unwrap();
for state in [
@@ -129,11 +129,11 @@ fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_withou
assert_eq!(legacy.resolution.as_deref(), Some("Original decision text"));
assert_eq!(ticket.meta.assignee.as_deref(), Some("retained-assignee"));
assert_eq!(ticket.meta.targets[0].repository_key, "main");
assert_eq!(ticket.events[0].from.as_deref(), Some("planning"));
assert_eq!(ticket.events[0].to.as_deref(), Some(state));
assert_eq!(ticket.events[0].references[0].target, "MR-existing");
assert_eq!(ticket.events[1].from.as_deref(), Some("planning"));
assert_eq!(ticket.events[1].to.as_deref(), Some(state));
assert_eq!(ticket.events[1].references[0].target, "MR-existing");
assert_eq!(
ticket.events[0].attributes["request_fingerprint"],
ticket.events[1].attributes["request_fingerprint"],
"legacy-fingerprint"
);
if expected_state == TicketState::Closed {
@@ -254,8 +254,26 @@ fn falsely_numbered_schema8_without_tags_is_rejected_without_writes() {
// Read actual SQLite values, not merely counts: receipt JSON, original context,
// name snapshots and event/reference/attribute rows must survive byte-for-byte.
fn rows(connection: &Connection, table: &str) -> Vec<Vec<rusqlite::types::Value>> {
// Compare the retained v7-v11 payload, excluding the new initial post and metadata.
let modern: bool = connection.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info('typed_tickets') WHERE name='created_by')", [], |r| r.get(0)).unwrap();
let projection = match table {
"typed_tickets" if modern => {
"workspace_id,ticket_id,slug,title,status,kind,priority,(SELECT body FROM typed_ticket_events e WHERE e.workspace_id=typed_tickets.workspace_id AND e.ticket_id=typed_tickets.ticket_id AND e.event_index=-1),created_at,updated_at,assignee,readiness,workflow_state,workflow_state_explicit,queued_by,queued_at,resolution"
}
"typed_ticket_events" => {
"workspace_id,ticket_id,event_index,kind,author,at,status,from_state,to_state,reason,state_field,heading,body"
}
_ => "*",
};
let filter = if table.starts_with("typed_ticket_event") {
"WHERE event_index >= 0"
} else {
""
};
let mut stmt = connection
.prepare(&format!("SELECT * FROM {table} ORDER BY rowid"))
.prepare(&format!(
"SELECT {projection} FROM {table} {filter} ORDER BY rowid"
))
.unwrap();
let count = stmt.column_count();
stmt.query_map([], |row| (0..count).map(|i| row.get(i)).collect())
@@ -272,11 +290,24 @@ fn copy_tables(connection: &Connection, source: &std::path::Path, tables: &[&str
)
.unwrap();
for table in tables {
connection
.execute_batch(&format!(
"INSERT INTO {table} SELECT * FROM fixture_source.{table}"
))
let columns = connection
.prepare(&format!("PRAGMA table_info({table})"))
.unwrap()
.query_map([], |r| r.get::<_, String>(1))
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap();
let selection = columns.iter().map(|column| {
if *table == "typed_tickets" && column == "body" {
"(SELECT body FROM fixture_source.typed_ticket_events e WHERE e.workspace_id=t.workspace_id AND e.ticket_id=t.ticket_id AND e.event_index=-1)".to_owned()
} else { format!("t.{column}") }
}).collect::<Vec<_>>().join(",");
let filter = if table.starts_with("typed_ticket_event") {
"WHERE t.event_index >= 0"
} else {
""
};
connection.execute_batch(&format!("INSERT INTO {table} ({}) SELECT {selection} FROM fixture_source.{table} t {filter}", columns.join(","))).unwrap();
}
connection
.execute_batch("DETACH DATABASE fixture_source")
@@ -559,12 +590,23 @@ fn independent_case_schema9_adds_only_tags_without_reapplying_judgments_or_recei
),
)
.unwrap();
let reopened = source
let mut reopened = source
.update_case(
&id,
case_request(&corrected, "reopen", TicketCaseAction::Reopen),
)
.unwrap();
reopened.events[0]
.attributes
.retain(|key, _| matches!(key.as_str(), "event_id" | "event_sequence"));
// The frozen v9 copy has no persisted event_id attributes. Its published
// fallback was a zero-based ordinal, not the source v12 persisted identity.
for (ordinal, event) in reopened.events.iter_mut().skip(1).enumerate() {
event.event_ref = format!("sequence:{ordinal}");
event
.attributes
.insert("event_id".into(), event.event_ref.clone());
}
let path = temp.path().join("retained9.db");
let connection = Connection::open(&path).unwrap();
schema7(&connection);
+525
View File
@@ -0,0 +1,525 @@
use rusqlite::{Connection, params};
use tempfile::TempDir;
use ticket::{
MarkdownText, NewTicket, NewTicketEvent, SqliteTicketBackend, TicketBackend, TicketEventKind,
TicketItemEdit, TicketPostEdit, migrate_sqlite_ticket_schema,
migrate_sqlite_ticket_schema_in_transaction, ticket_content_digest,
verify_sqlite_ticket_schema,
};
fn comment(body: &str, author: &str) -> NewTicketEvent {
let mut event = NewTicketEvent::new(TicketEventKind::Comment, body);
event.author = Some(author.into());
event
}
#[test]
fn initial_and_discussion_comments_share_identity_authorship_and_cas_edits() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("posts.db");
let backend = SqliteTicketBackend::open(&path, "workspace").unwrap();
let mut input = NewTicket::new("Real first post");
input.body = MarkdownText::new("First authored post");
input.author = Some("creator".into());
let (reference, created) = backend.create_with_snapshot(input).unwrap();
assert_eq!(created.events.len(), 1);
let initial = &created.events[0];
assert_eq!(initial.kind, TicketEventKind::Comment);
assert!(initial.is_initial);
assert_eq!(initial.body, created.document.body);
assert_eq!(initial.author.as_deref(), Some("creator"));
assert_eq!(initial.at, created.meta.created_at);
assert_eq!(created.meta.created_by.as_deref(), Some("creator"));
assert!(initial.edited_at.is_none());
backend
.add_event(
reference.id.clone().into(),
comment("Discussion", "commenter"),
)
.unwrap();
let before = backend.show(reference.id.clone().into()).unwrap();
let ordinary = before.events[1].clone();
assert!(!ordinary.is_initial);
assert_eq!(ordinary.kind, initial.kind);
let initial_time = initial.at.clone();
for original in [ordinary, initial.clone()] {
let edit = TicketPostEdit {
event_ref: original.event_ref.clone(),
expected_body: original.body.clone(),
body: MarkdownText::new(format!("Edited {}", original.event_ref)),
author: "editor".into(),
};
let (previous, current) = backend
.edit_post_with_snapshots(reference.id.clone().into(), edit.clone())
.unwrap();
let post = current
.events
.iter()
.find(|p| p.event_ref == original.event_ref)
.unwrap();
assert_eq!(post.author, original.author);
assert_eq!(post.at, original.at);
assert_eq!(post.edited_by.as_deref(), Some("editor"));
assert!(post.edited_at.is_some());
assert_eq!(current.meta.created_by.as_deref(), Some("creator"));
assert_eq!(current.meta.created_at, initial_time);
assert_eq!(current.events.len(), 2);
if original.is_initial {
assert_eq!(current.document.body, edit.body);
assert_ne!(
ticket_content_digest(&previous),
ticket_content_digest(&current)
);
} else {
assert_eq!(
ticket_content_digest(&previous),
ticket_content_digest(&current)
);
}
assert!(
backend
.edit_post(reference.id.clone().into(), edit)
.is_err()
);
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), current);
}
let previous = backend.show(reference.id.clone().into()).unwrap();
let edited = backend
.edit_item(
reference.id.clone().into(),
TicketItemEdit {
title: Some("New title".into()),
body: Some(MarkdownText::new("Item authoring edits initial post")),
author: Some("item-editor".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(edited.events.len(), previous.events.len());
assert_eq!(edited.events[0].author, previous.events[0].author);
assert_eq!(edited.events[0].at, previous.events[0].at);
assert_eq!(edited.events[0].edited_by.as_deref(), Some("item-editor"));
assert_eq!(edited.document.body, edited.events[0].body);
assert!(
edited
.events
.iter()
.all(|e| !matches!(e.kind.as_str(), "create" | "item_edit"))
);
let reopened = SqliteTicketBackend::open(&path, "workspace").unwrap();
assert_eq!(reopened.show(reference.id.into()).unwrap(), edited);
let conn = Connection::open(path).unwrap();
assert_eq!(
conn.query_row(
"SELECT count(*) FROM pragma_table_info('typed_tickets') WHERE name='body'",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
0
);
}
#[test]
fn editing_rejects_system_judgments_other_tickets_and_missing_identity() {
let temp = TempDir::new().unwrap();
let backend = SqliteTicketBackend::open(temp.path().join("posts.db"), "workspace").unwrap();
let first = backend.create(NewTicket::new("first")).unwrap();
let second = backend.create(NewTicket::new("second")).unwrap();
let mut event = comment("System judgment", "system");
event.kind = TicketEventKind::Decision;
backend.add_event(first.id.clone().into(), event).unwrap();
let ticket = backend.show(first.id.clone().into()).unwrap();
let system = &ticket.events[1];
let edit = TicketPostEdit {
event_ref: system.event_ref.clone(),
expected_body: system.body.clone(),
body: MarkdownText::new("changed"),
author: "editor".into(),
};
assert!(
backend
.edit_post(first.id.clone().into(), edit.clone())
.is_err()
);
assert!(backend.edit_post(second.id.into(), edit).is_err());
let initial = &ticket.events[0];
assert!(
backend
.edit_post(
first.id.clone().into(),
TicketPostEdit {
event_ref: initial.event_ref.clone(),
expected_body: initial.body.clone(),
body: MarkdownText::new("changed"),
author: " ".into()
}
)
.is_err()
);
assert_eq!(backend.show(first.id.into()).unwrap(), ticket);
}
fn schema11(conn: &Connection) {
// Build the actual retained component shape, not a current schema with its marker changed.
conn.execute_batch(include_str!("../src/latest_schema.sql"))
.unwrap();
conn.execute_batch(include_str!("../src/assignment_schema.sql"))
.unwrap();
conn.execute_batch(include_str!("../src/assignment_tracking_schema.sql"))
.unwrap();
conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(11,'ticket schema baseline','original-marker');").unwrap();
}
fn seed_legacy(conn: &Connection, id: &str) {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,created_at,workflow_state,workflow_state_explicit) VALUES('workspace',?1,?1,'Legacy','open','task','P2','Latest body','ticket-time','open',1)", [id]).unwrap();
}
#[test]
fn schema12_migrates_body_authors_archived_history_and_stable_references_then_replays() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("legacy.db");
let conn = Connection::open(&path).unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
schema11(&conn);
for id in ["known", "unknown", "metadata"] {
seed_legacy(&conn, id);
}
conn.execute_batch("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body) VALUES
('workspace','known',0,'create','original-author','original-time','Created'),
('workspace','known',1,'comment','discussant','discussion-time','Discussion'),
('workspace','known',2,'item_edit','legacy-editor','edit-time','Body changed');
INSERT INTO typed_ticket_event_attributes VALUES('workspace','known',1,'event_id','external-original-id');
INSERT INTO typed_ticket_event_attributes VALUES('workspace','known',2,'body_edit','whole');
INSERT INTO typed_ticket_event_references VALUES('workspace','known',2,0,'commit','original-evidence');
INSERT INTO typed_ticket_raw_frontmatter VALUES('workspace','metadata','created_by','metadata-author');").unwrap();
migrate_sqlite_ticket_schema(&conn).unwrap();
verify_sqlite_ticket_schema(&conn).unwrap();
let backend = SqliteTicketBackend::open_verified(&path, "workspace").unwrap();
let known = backend.show("known".into()).unwrap();
assert_eq!(known.meta.created_at.as_deref(), Some("ticket-time"));
assert_eq!(known.meta.created_by.as_deref(), Some("original-author"));
assert_eq!(known.events.len(), 2);
assert_eq!(known.events[0].author.as_deref(), Some("original-author"));
assert_eq!(known.events[0].at.as_deref(), Some("original-time"));
assert_eq!(known.document.body.as_str(), "Latest body");
assert_eq!(known.events[0].body, known.document.body);
assert_eq!(known.events[1].event_ref, "known:1");
assert_eq!(known.events[1].attributes["event_id"], "known:1");
assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE ticket_id='known' AND event_index=1 AND key='event_id'", [], |r| r.get::<_,String>(0)).unwrap(), "external-original-id");
assert_eq!(known.events[1].attributes["event_sequence"], "1");
let unknown = backend.show("unknown".into()).unwrap();
assert_eq!(unknown.meta.created_by, None);
assert_eq!(unknown.events[0].author, None);
assert_eq!(unknown.events[0].at.as_deref(), Some("ticket-time"));
assert_eq!(
backend.show("metadata".into()).unwrap().events[0]
.author
.as_deref(),
Some("metadata-author")
);
let history = || {
conn.prepare("SELECT event_index,kind,author,at,body,archived FROM typed_ticket_events WHERE ticket_id='known' AND event_index>=0 ORDER BY event_index").unwrap().query_map([], |r| Ok((r.get::<_,i64>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,bool>(5)?))).unwrap().collect::<rusqlite::Result<Vec<_>>>().unwrap()
};
let original_history = history();
assert_eq!(
original_history[0],
(
0,
"create".into(),
"original-author".into(),
"original-time".into(),
"Created".into(),
true
)
);
assert_eq!(
original_history[2],
(
2,
"item_edit".into(),
"legacy-editor".into(),
"edit-time".into(),
"Body changed".into(),
true
)
);
assert_eq!(conn.query_row("SELECT target FROM typed_ticket_event_references WHERE ticket_id='known' AND event_index=2", [], |r| r.get::<_,String>(0)).unwrap(), "original-evidence");
migrate_sqlite_ticket_schema(&conn).unwrap();
assert_eq!(history(), original_history);
assert_eq!(backend.show("known".into()).unwrap(), known);
assert_eq!(
conn.query_row(
"SELECT applied_at FROM ticket_schema_migrations WHERE version=11",
[],
|r| r.get::<_, String>(0)
)
.unwrap(),
"original-marker"
);
assert_eq!(
conn.query_row(
"SELECT max(version) FROM ticket_schema_migrations",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
12
);
backend
.add_event("known".into(), comment("Next", "author"))
.unwrap();
assert_eq!(
backend
.show("known".into())
.unwrap()
.events
.last()
.unwrap()
.event_ref,
"known:3"
);
}
#[test]
fn schema12_reserved_index_collision_rolls_back_component_and_host_can_retry() {
let conn = Connection::open_in_memory().unwrap();
schema11(&conn);
seed_legacy(&conn, "collision");
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('workspace','collision',-1,'comment','retained negative index')", []).unwrap();
conn.execute_batch("CREATE TABLE host_work(value TEXT); BEGIN IMMEDIATE; INSERT INTO host_work VALUES('pending');").unwrap();
assert!(migrate_sqlite_ticket_schema_in_transaction(&conn).is_err());
assert!(!conn.is_autocommit());
assert_eq!(
conn.query_row(
"SELECT body FROM typed_tickets WHERE ticket_id='collision'",
[],
|r| r.get::<_, String>(0)
)
.unwrap(),
"Latest body"
);
assert_eq!(
conn.query_row(
"SELECT count(*) FROM pragma_table_info('typed_ticket_events') WHERE name='event_ref'",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
0
);
assert_eq!(
conn.query_row(
"SELECT max(version) FROM ticket_schema_migrations",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
11
);
conn.execute_batch("ROLLBACK").unwrap();
assert_eq!(
conn.query_row("SELECT count(*) FROM host_work", [], |r| r.get::<_, i64>(0))
.unwrap(),
0
);
// Remove the deliberately conflicting test row, then retry the unchanged migration.
conn.execute(
"DELETE FROM typed_ticket_events WHERE event_index=?1",
params![-1],
)
.unwrap();
migrate_sqlite_ticket_schema(&conn).unwrap();
verify_sqlite_ticket_schema(&conn).unwrap();
}
#[test]
fn migration_preserves_published_refs_before_archiving_even_with_sparse_indices() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("identities.db");
let conn = Connection::open(&path).unwrap();
schema11(&conn);
seed_legacy(&conn, "sparse");
conn.execute_batch("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES
('workspace','sparse',3,'create','Created'),
('workspace','sparse',12,'item_edit','Edited'),
('workspace','sparse',90,'comment','Existing comment'),
('workspace','sparse',99,'comment','Explicit identity');
INSERT INTO typed_ticket_event_attributes VALUES('workspace','sparse',99,'event_id','published-custom-id');").unwrap();
migrate_sqlite_ticket_schema(&conn).unwrap();
let refs: Vec<(i64, String)> = conn.prepare("SELECT event_index,event_ref FROM typed_ticket_events WHERE ticket_id='sparse' ORDER BY event_index").unwrap().query_map([], |r| Ok((r.get(0)?, r.get(1)?))).unwrap().collect::<rusqlite::Result<_>>().unwrap();
assert_eq!(
refs,
vec![
(-1, "sparse:-1".into()),
(3, "sparse:3".into()),
(12, "sparse:12".into()),
(90, "sparse:90".into()),
(99, "sparse:99".into())
]
);
let backend = SqliteTicketBackend::open_verified(&path, "workspace").unwrap();
let ticket = backend.show("sparse".into()).unwrap();
assert_eq!(ticket.events[1].event_ref, "sparse:90");
assert_eq!(ticket.events[1].attributes["event_id"], "sparse:90");
assert_eq!(ticket.events[1].attributes["event_sequence"], "90");
let edited = backend
.edit_post(
"sparse".into(),
TicketPostEdit {
event_ref: "sparse:90".into(),
expected_body: MarkdownText::new("Existing comment"),
body: MarkdownText::new("Updated comment"),
author: "editor".into(),
},
)
.unwrap();
assert_eq!(edited.events[1].event_ref, "sparse:90");
migrate_sqlite_ticket_schema(&conn).unwrap();
assert_eq!(backend.show("sparse".into()).unwrap(), edited);
}
#[test]
fn migration_preserves_raw_identity_attributes_without_rebinding_published_refs() {
for event_ids in [["same", "same"], ["sequence:1", ""], ["collision:-1", ""]] {
let temp = TempDir::new().unwrap();
let path = temp.path().join("identity-attributes.db");
let conn = Connection::open(&path).unwrap();
schema11(&conn);
seed_legacy(&conn, "collision");
for (index, event_id) in event_ids.iter().enumerate() {
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('workspace','collision',?1,'comment','retained')", [index as i64]).unwrap();
if !event_id.is_empty() {
conn.execute("INSERT INTO typed_ticket_event_attributes VALUES('workspace','collision',?1,'event_id',?2)", params![index as i64,event_id]).unwrap();
}
}
migrate_sqlite_ticket_schema(&conn).unwrap();
let backend = SqliteTicketBackend::open_verified(&path, "workspace").unwrap();
let migrated = backend.show("collision".into()).unwrap();
assert_eq!(
migrated
.events
.iter()
.map(|e| e.event_ref.as_str())
.collect::<Vec<_>>(),
["collision:-1", "collision:0", "collision:1"]
);
for (index, event_id) in event_ids.iter().enumerate() {
let event = &migrated.events[index + 1];
assert_eq!(event.attributes["event_id"], format!("collision:{index}"));
if !event_id.is_empty() {
assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE ticket_id='collision' AND event_index=?1 AND key='event_id'", [index as i64], |r| r.get::<_, String>(0)).unwrap(), *event_id);
}
}
migrate_sqlite_ticket_schema(&conn).unwrap();
assert_eq!(backend.show("collision".into()).unwrap(), migrated);
}
}
#[test]
fn migration_recovers_only_last_known_body_edit_and_never_invents_creation_author() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("edit-metadata.db");
let conn = Connection::open(&path).unwrap();
schema11(&conn);
for id in [
"known",
"unknown-last-editor",
"title-only",
"empty-creator",
] {
seed_legacy(&conn, id);
}
conn.execute_batch("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body) VALUES
('workspace','known',0,'create','creator','original-time','Created'),
('workspace','known',1,'item_edit','old-editor','old-time','old body edit'),
('workspace','known',5,'item_edit','body-editor','body-time','latest body edit'),
('workspace','known',6,'item_edit','title-editor','title-time','later title edit'),
('workspace','unknown-last-editor',0,'item_edit','old-editor','old-time','old body edit'),
('workspace','unknown-last-editor',1,'item_edit',NULL,NULL,'last body edit'),
('workspace','title-only',0,'item_edit','title-editor','title-time','not a body edit'),
('workspace','empty-creator',0,'create',' ','create-time','Created');
INSERT INTO typed_ticket_event_attributes VALUES
('workspace','known',1,'changes','body'),
('workspace','known',5,'changes','title,body,targets'),
('workspace','known',6,'changes','title'),
('workspace','unknown-last-editor',0,'changes','body'),
('workspace','unknown-last-editor',1,'changes','body'),
('workspace','title-only',0,'changes','title,body_format');
INSERT INTO typed_ticket_raw_frontmatter VALUES('workspace','empty-creator','created_by','');").unwrap();
migrate_sqlite_ticket_schema(&conn).unwrap();
let backend = SqliteTicketBackend::open_verified(&path, "workspace").unwrap();
let known = backend.show("known".into()).unwrap();
let post = &known.events[0];
assert_eq!(post.author.as_deref(), Some("creator"));
assert_eq!(post.at.as_deref(), Some("original-time"));
assert_eq!(post.edited_at.as_deref(), Some("body-time"));
assert_eq!(post.edited_by.as_deref(), Some("body-editor"));
assert_eq!(known.document.body.as_str(), "Latest body");
for id in ["unknown-last-editor", "title-only", "empty-creator"] {
let ticket = backend.show(id.into()).unwrap();
assert_eq!(ticket.events[0].edited_at, None, "{id}");
assert_eq!(ticket.events[0].edited_by, None, "{id}");
assert_eq!(ticket.events[0].author, None, "{id}");
assert_eq!(ticket.meta.created_by, None, "{id}");
let json = serde_json::to_value(ticket).unwrap();
assert!(json["meta"].as_object().unwrap().contains_key("created_by"));
assert!(json["meta"]["created_by"].is_null());
assert!(json["events"][0]["author"].is_null());
}
migrate_sqlite_ticket_schema(&conn).unwrap();
assert_eq!(backend.show("known".into()).unwrap(), known);
// Blank new creation identities use the same honest nullable contract.
let mut input = NewTicket::new("Unknown creator");
input.author = Some(" ".into());
let (_, created) = backend.create_with_snapshot(input).unwrap();
assert_eq!(created.meta.created_by, None);
assert_eq!(created.events[0].author, None);
}
#[test]
fn edit_post_dispatch_round_trips_atomic_ticket_snapshots() {
use ticket::{
TicketBackendOperation, TicketBackendOperationResult, execute_ticket_backend_operation,
};
let temp = TempDir::new().unwrap();
let backend = SqliteTicketBackend::open(temp.path().join("dispatch.db"), "workspace").unwrap();
let (reference, before) = backend
.create_with_snapshot(NewTicket::new("Dispatch"))
.unwrap();
let request = TicketBackendOperation::EditPost {
id: reference.id.clone().into(),
edit: TicketPostEdit {
event_ref: before.events[0].event_ref.clone(),
expected_body: before.document.body.clone(),
body: MarkdownText::new("Rescoped requirements"),
author: "caller".into(),
},
};
let json = serde_json::to_value(&request).unwrap();
assert_eq!(json["operation"], "edit_post");
let decoded = serde_json::from_value(json).unwrap();
let result = execute_ticket_backend_operation(&backend, decoded).unwrap();
let wire = serde_json::to_value(&result).unwrap();
assert_eq!(wire["result"], "ticket_snapshots");
assert_eq!(
wire["value"]["previous"]["document"]["body"],
before.document.body.as_str()
);
assert_eq!(
wire["value"]["current"]["document"]["body"],
"Rescoped requirements"
);
let TicketBackendOperationResult::TicketSnapshots { previous, current } =
serde_json::from_value(wire).unwrap()
else {
panic!("wrong result variant")
};
assert_eq!(previous, before);
assert_eq!(backend.show(reference.id.into()).unwrap(), current);
assert_ne!(
ticket_content_digest(&previous),
ticket_content_digest(&current)
);
assert!(execute_ticket_backend_operation(&backend, request).is_err());
}
@@ -43,6 +43,9 @@ pub(super) struct ModelTicketDetail {
content_digest: String,
title: String,
body: String,
body_truncated: bool,
initial_post: ModelTicketEvent,
created_by: Option<String>,
state: ticket::TicketState,
closure_operation_key: Option<String>,
current_closure: Option<ticket::TicketClosureJudgment>,
@@ -131,9 +134,17 @@ struct ModelTicketEvent {
#[serde(skip_serializing_if = "Option::is_none")]
tag_changes: Option<ModelTicketTagChange>,
sequence: usize,
event_ref: String,
is_initial: bool,
kind: String,
body: Option<String>,
body_truncated: bool,
body_digest: String,
editable: bool,
author: Option<String>,
created_at: Option<String>,
edited_at: Option<String>,
edited_by: Option<String>,
}
#[derive(Debug, Serialize, Default)]
@@ -335,6 +346,12 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
content_digest: string_field(root, "content_digest")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
body_truncated: bool_field(root, "body_truncated")?,
initial_post: project_ticket_event(
root.get("initial_post")
.ok_or("Ticket detail response is missing initial_post")?,
)?,
created_by: optional_string(root, "created_by")?,
state: typed_field(root, "state")?,
closure_operation_key: typed_field(root, "closure_operation_key")?,
current_closure: typed_field(root, "current_closure")?,
@@ -471,6 +488,14 @@ fn project_ticket_event(value: &Value) -> Result<ModelTicketEvent, String> {
Ok(ModelTicketEvent {
tag_changes,
sequence: usize_field(event, "sequence")?,
event_ref: string_field(event, "event_ref")?,
is_initial: bool_field(event, "is_initial")?,
body_truncated: bool_field(event, "body_truncated")?,
body_digest: string_field(event, "body_digest")?,
editable: bool_field(event, "editable")?,
author: optional_string(event, "author")?,
edited_at: optional_string(event, "edited_at")?,
edited_by: optional_string(event, "edited_by")?,
kind: string_field(event, "kind")?,
body: match event.get("body") {
None | Some(Value::Null) => None,
@@ -815,13 +840,24 @@ mod tests {
use super::*;
use serde_json::json;
fn post_fixture(initial: bool, body: &str) -> Value {
json!({
"sequence": if initial { 0 } else { 1 },
"event_ref": if initial { "post-initial" } else { "post-discussion" },
"is_initial": initial, "kind": "comment", "body": body,
"body_truncated": false, "body_digest": "a".repeat(64), "editable": true,
"author": "workspace-user:original", "at": "2026-01-01T00:00:00Z",
"edited_at": "2026-01-02T00:00:00Z", "edited_by": "workspace-user:editor"
})
}
#[test]
fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() {
for state in ["open", "closed"] {
let projected = project_ticket_detail(json!({
"resource_key": "T-718", "assignment_tracking": [], "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
"resource_key": "T-718", "assignment_tracking": [], "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision", "body_truncated": false, "initial_post": post_fixture(true, "Decision"),
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}],
"state": state, "events": [post_fixture(false, "No Git needed")],
"linked_objectives": [], "assignments": [], "implementation_reports": [],
"merge_requests": [], "merge_request": null,
"evidence": {
@@ -832,7 +868,7 @@ mod tests {
})).unwrap();
let projected = serde_json::to_value(projected).unwrap();
assert_eq!(projected["state"], state);
assert_eq!(projected["thread"][0]["kind"], "completed");
assert_eq!(projected["thread"][0]["kind"], "comment");
assert_eq!(projected["evidence"]["integration_applicable"], false);
assert_eq!(projected["evidence"]["review_status"], Value::Null);
assert_eq!(projected["evidence"]["approved_current_subject"], false);
@@ -862,7 +898,7 @@ mod tests {
});
let response = json!({
"assignment_tracking": [], "tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}],
"resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements",
"resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements", "body_truncated": false, "initial_post": post_fixture(true, "Requirements"),
"state": "open", "closure_operation_key": "close-1", "current_closure": null,
"closure_history": history, "legacy_handoff": legacy,
"events": [], "linked_objectives": [], "assignments": [], "implementation_reports": [],
@@ -1013,7 +1049,7 @@ mod tests {
"content_digest": "ticket-content-digest",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"title": "Queued Submit",
"body": "Body",
"body": "Body", "body_truncated": false, "initial_post": post_fixture(true, "Body"),
"state": "open",
"readiness": null,
"priority": "P2",
@@ -1117,7 +1153,7 @@ mod tests {
"content_digest": "current-ticket-content-digest",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"title": "Source evidence",
"body": "Body",
"body": "Body", "body_truncated": false, "initial_post": post_fixture(true, "Body"),
"state": "open",
"events": [],
"linked_objectives": [],
@@ -1301,7 +1337,7 @@ mod tests {
let closure = json!({"operation_key":"close-1", "reason":"not_planned", "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"});
let history = json!([{"operation_key":"close-1", "previous_closure_operation_key":null, "action":{"action":"close", "reason":"not_planned"}, "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}]);
let detail = json!({
"resource_key":"T-739", "title":"Case", "body":"Requirements", "content_digest":"content-1",
"resource_key":"T-739", "title":"Case", "body":"Requirements", "body_truncated":false, "initial_post":post_fixture(true, "Requirements"), "created_by":"workspace-user:creator", "content_digest":"content-1",
"state":"closed", "assignment_tracking": [tracking], "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure,
"legacy_handoff":null, "closure_history":history, "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]
});
@@ -1348,6 +1384,41 @@ mod tests {
}
assert_eq!(projected_detail["closure_history"], history);
assert_eq!(projected_detail["content_digest"], "content-1");
assert_eq!(projected_detail["created_by"], "workspace-user:creator");
assert_eq!(projected_detail["body_truncated"], false);
for initial in [true, false] {
let input = post_fixture(initial, "The same post contract");
let output = serde_json::to_value(project_ticket_event(&input).unwrap()).unwrap();
for field in [
"event_ref",
"is_initial",
"author",
"edited_at",
"edited_by",
"body",
"body_digest",
"body_truncated",
"editable",
] {
assert_eq!(output[field], input[field], "post metadata {field}");
}
assert_eq!(output["created_at"], input["at"]);
}
assert_eq!(
projected_detail["initial_post"]["author"],
"workspace-user:original"
);
assert_eq!(
projected_detail["initial_post"]["edited_by"],
"workspace-user:editor"
);
let mut missing_post = detail.clone();
missing_post.as_object_mut().unwrap().remove("initial_post");
assert!(
project_ticket_detail(missing_post)
.unwrap_err()
.contains("initial_post")
);
// Unknown observations must not be replaced with empty/null values.
for field in [
"tags",
@@ -1410,11 +1481,14 @@ mod tests {
#[test]
fn ticket_tag_history_projection_preserves_observed_names_and_identities() {
let tag = serde_json::json!({"workspace_id":"ws","id":"tag-1","name":"Old name","last_operation_key":"create","deleted":false});
let event = serde_json::json!({"sequence":3,"kind":"tags_changed","body":"Ticket tags changed","at":"then","attributes":{"tags_before":serde_json::json!([tag.clone()]).to_string(),"tags_after":"[]"}});
let mut event = post_fixture(false, "Ticket tags changed");
event["kind"] = json!("tags_changed");
event["editable"] = json!(false);
event["attributes"] = serde_json::json!({"tags_before":serde_json::json!([tag.clone()]).to_string(),"tags_after":"[]"});
let projected = serde_json::to_value(project_ticket_event(&event).unwrap()).unwrap();
assert_eq!(projected["tag_changes"]["before"], serde_json::json!([tag]));
assert_eq!(projected["tag_changes"]["after"], serde_json::json!([]));
let ordinary = serde_json::json!({"sequence":0,"kind":"comment","body":"hello","at":null});
let ordinary = post_fixture(false, "hello");
assert!(
serde_json::to_value(project_ticket_event(&ordinary).unwrap())
.unwrap()
@@ -670,6 +670,7 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &[
const AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate",
"TicketEditItem",
"TicketEditPost",
"TicketCaseUpdate",
"TicketRelationRecord",
"TicketRelationRemove",
@@ -681,6 +682,7 @@ const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate",
"TicketEditItem",
"TicketEditPost",
"QueryTicket",
"ShowTicket",
"TicketComment",
@@ -1109,6 +1111,17 @@ impl WorkspaceHttpTicketBackend {
)?;
Ok(TicketBackendOperationResult::Ticket(ticket))
}
TicketBackendOperation::EditPost { id, edit } => {
let (previous, current): (Ticket, Ticket) = Self::request(
client,
WorkspaceRequestMethod::Patch,
format!("{base}/{}/posts", Self::ticket_path(&id)),
Some(serde_json::to_value(edit).map_err(|error| {
TicketError::Conflict(format!("serialize Ticket post edit: {error}"))
})?),
)?;
Ok(TicketBackendOperationResult::TicketSnapshots { previous, current })
}
TicketBackendOperation::DependencyCheck { id } => {
let check = Self::request(
client,
@@ -1289,6 +1302,21 @@ macro_rules! expect_ticket_result {
}
impl TicketBackend for WorkspaceHttpTicketBackend {
fn edit_post_with_snapshots(
&self,
id: TicketIdOrSlug,
edit: ticket::TicketPostEdit,
) -> TicketResult<(Ticket, Ticket)> {
match self.invoke(TicketBackendOperation::EditPost { id, edit })? {
TicketBackendOperationResult::TicketSnapshots { previous, current } => {
Ok((previous, current))
}
other => Err(TicketError::Conflict(format!(
"unexpected ticket post response: {other:?}"
))),
}
}
fn list_workspace_tags(
&self,
request: ticket::TagListRequest,
@@ -1573,6 +1601,12 @@ fn native_ticket_operation(tool_name: &str) -> Option<NativeTicketOperation> {
identity_field: Some("ticket"),
mutating: true,
},
"TicketEditPost" => NativeTicketOperation {
operation: "edit_post",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: true,
},
"TicketComment" => NativeTicketOperation {
operation: "comment",
surface: NativeTicketSurface::Item,
@@ -2470,6 +2504,47 @@ mod tests {
assert!(client.requests.lock().unwrap().is_empty());
}
#[test]
fn post_edit_backend_transports_atomic_snapshots_and_propagates_conflicts() {
let record: Ticket =
serde_json::from_str(&decision_client(200, TicketState::Open).response.body).unwrap();
let input = json!({
"event_ref": "post-reference",
"expected_body": "Observed post",
"body": "Updated post",
"author": "standalone"
});
for status in [200, 409] {
let client = Arc::new(DecisionClient {
requests: Mutex::new(Vec::new()),
response: crate::worker::WorkspaceResponse {
status,
body: serde_json::to_string(&(record.clone(), record.clone())).unwrap(),
},
});
let backend = WorkspaceHttpTicketBackend::new(client.clone());
let result = backend.edit_post_with_snapshots(
TicketIdOrSlug::from("T-739"),
serde_json::from_value(input.clone()).unwrap(),
);
if status == 200 {
let (previous, current) = result.unwrap();
assert_eq!(previous.meta.id, record.meta.id);
assert_eq!(current.meta.id, record.meta.id);
} else {
assert!(result.unwrap_err().to_string().contains("409"));
}
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, WorkspaceRequestMethod::Patch);
assert_eq!(requests[0].path, "/api/w/workspace/tickets/T-739/posts");
assert_eq!(
serde_json::from_str::<Value>(requests[0].body.as_deref().unwrap()).unwrap(),
input,
);
}
}
#[test]
fn query_schema_allows_only_open_and_closed_case_states() {
let client = decision_client(200, TicketState::Open);
@@ -3242,6 +3317,7 @@ mod tests {
.collect::<Vec<_>>();
assert!(workspace_tools.contains(&"TicketCreate"));
assert!(workspace_tools.contains(&"TicketEditItem"));
assert!(workspace_tools.contains(&"TicketEditPost"));
assert!(!workspace_tools.contains(&"TicketQueue"));
assert!(workspace_tools.contains(&"TicketCaseUpdate"));
@@ -3257,6 +3333,7 @@ mod tests {
assert!(orchestration_tools.contains(&"TicketRelationRecord"));
assert!(orchestration_tools.contains(&"TicketOrchestrationPlanRecord"));
assert!(!orchestration_tools.contains(&"TicketEditItem"));
assert!(!orchestration_tools.contains(&"TicketEditPost"));
assert!(!orchestration_tools.contains(&"TicketQueue"));
let work_report = workspace_feature(TicketFeatureAccess::work_report());
@@ -3315,6 +3392,18 @@ mod tests {
assert!(native.iter().all(|tool| tool.name != name), "{name}");
assert!(native_ticket_operation(name).is_none(), "{name}");
}
assert_eq!(
normal
.iter()
.any(|definition| definition().0.name == "TicketEditPost"),
access.authoring,
);
assert_eq!(
native
.iter()
.any(|tool| tool.projection.operation == "edit_post"),
access.authoring,
);
if access.authoring || access.thread || access.workflow {
assert!(
normal
@@ -78,7 +78,8 @@ async fn tracking_store_every_replay_fences_current_singleton_and_authenticated_
);
// Model the authority change after authenticated source capture, before
// BEGIN IMMEDIATE on a second connection. Receipt presence cannot bypass it.
let second = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
let second =
SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
second.with_conn_mut(|conn| {
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
match change {
@@ -233,7 +234,8 @@ async fn tracking_store_same_principal_reissue_and_exact_retries_keep_original_r
.is_err()
);
drop(store);
let reopened = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
let reopened =
SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
assert_eq!(
reopened
.get_ticket_assignment_tracking("w", "tracking", "a")
@@ -300,10 +302,11 @@ fn tracking_schema91_fixture(path: &Path) -> Connection {
// Genuine retained component-10 DDL, before assignment tracking existed.
// The host structure is unchanged from Workspace 91: no tracking columns,
// tracking tables/triggers, or component-11 marker are created here.
conn.execute_batch(include_str!("../../ticket/src/latest_schema.sql"))
conn.execute_batch(include_str!("frozen_ticket_schema_v10.sql"))
.unwrap();
conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','original-component10-time'); PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;").unwrap();
create_workspace_schema_structure(&conn).unwrap();
create_workspace_schema_structure_from_sql(&conn, include_str!("frozen_schema_v93.sql"), false)
.unwrap();
conn.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(91,'workspace schema baseline')",
[],
@@ -439,12 +442,12 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
.iter()
.map(|m| m.version)
.collect::<Vec<_>>(),
vec![92, 93]
vec![92, 93, 94]
);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 93);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert_eq!(
conn.query_row(
"SELECT applied_at FROM ticket_schema_migrations WHERE version=10",
@@ -461,7 +464,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
(
93,
"Durable assignment deliveries and source-bound startup fences".into()
)
),
(94, "Canonical Ticket posts".into())
]
);
for (table, rows) in retained.iter().zip(original.iter()) {
@@ -599,8 +603,8 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first
.is_empty()
);
store.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 93);
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations", [], |row| row.get::<_,i64>(0))?, 11);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations", [], |row| row.get::<_,i64>(0))?, 12);
assert_eq!(conn.query_row("SELECT workflow_state FROM typed_tickets WHERE ticket_id='historical-done'", [], |row| row.get::<_,String>(0))?, "closed");
let stored: (Option<String>, Option<String>) = conn.query_row("SELECT tracking_json,last_tracking_event_id FROM ticket_worker_assignments WHERE assignment_id='a-historical-done'", [], |row| Ok((row.get(0)?,row.get(1)?)))?;
assert_eq!(stored, (None, None), "read baseline must not manufacture tracking storage");
@@ -1068,7 +1072,8 @@ async fn orchestrator_assignment_all_runtime_retries_fence_singleton_and_binding
&source,
)
.unwrap();
let second = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
let second =
SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
second.with_conn_mut(|conn| {let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;match change {
"replace" => {tx.execute("UPDATE worker_singleton_owners SET worker_id='replacement',generation=2 WHERE workspace_id='w'",[])?;},
"revoke" => {tx.execute("UPDATE workspace_runtime_bindings SET revoked_at='revoked' WHERE workspace_id='w'",[])?;},
@@ -1364,7 +1369,7 @@ async fn tracking_history_pages_are_append_ordered_exact_scope_cursors_and_bound
})
.await
.unwrap();
store.with_conn(|conn| {conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('other','tracking','tracking','Other','open','task','P2','','open',1)",[])?;Ok(())}).unwrap();
store.with_conn(|conn| {conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) VALUES('other','tracking','tracking','Other','open','task','P2','open',1)",[])?;conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('other','tracking',-1,'tracking:-1','comment','')",[])?;Ok(())}).unwrap();
let other_workspace = TicketRoleAssignmentRecord {
workspace_id: "other".into(),
..orchestrator_mutation_record("history-a")
+35 -15
View File
@@ -489,10 +489,10 @@ impl SqliteWorkspaceAuthority {
if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) {
let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase())));
predicates.push(format!(
"(lower(t.title) LIKE {p} OR lower(t.body) LIKE {p} OR EXISTS (
"(lower(t.title) LIKE {p} OR lower((SELECT initial.body FROM typed_ticket_events initial WHERE initial.workspace_id=t.workspace_id AND initial.ticket_id=t.ticket_id AND initial.event_index=-1 AND initial.archived=0)) LIKE {p} OR EXISTS (
SELECT 1 FROM typed_ticket_events e
WHERE e.workspace_id=t.workspace_id AND e.ticket_id=t.ticket_id
AND lower(e.body) LIKE {p}) OR EXISTS (
AND e.event_index >= 0 AND e.archived=0 AND lower(e.body) LIKE {p}) OR EXISTS (
SELECT 1 FROM typed_ticket_case_history h WHERE h.workspace_id=t.workspace_id AND h.ticket_id=t.ticket_id
AND lower(CAST(json_extract(h.judgment_json,'$.action.reason') AS TEXT)) LIKE {p}))",
p = pattern
@@ -542,7 +542,7 @@ impl SqliteWorkspaceAuthority {
.iter()
.map(|kind| bind(SqlValue::Text(kind.clone())))
.collect::<Vec<_>>();
predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND event.kind IN ({}))", event_kinds.join(",")));
predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND event.event_index >= 0 AND event.archived=0 AND event.kind IN ({}))", event_kinds.join(",")));
}
// SQL only narrows immutable Ticket metadata. Review/evidence filters
// must use the same provider + complete linked-result projection as ShowTicket;
@@ -560,7 +560,7 @@ impl SqliteWorkspaceAuthority {
if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) {
let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase())));
format!(
"CASE WHEN lower(t.title) LIKE {pattern} THEN 0 WHEN lower(t.body) LIKE {pattern} THEN 1 WHEN EXISTS (SELECT 1 FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND lower(event.body) LIKE {pattern}) THEN 2 ELSE 3 END"
"CASE WHEN lower(t.title) LIKE {pattern} THEN 0 WHEN lower((SELECT initial.body FROM typed_ticket_events initial WHERE initial.workspace_id=t.workspace_id AND initial.ticket_id=t.ticket_id AND initial.event_index=-1 AND initial.archived=0)) LIKE {pattern} THEN 1 WHEN EXISTS (SELECT 1 FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND event.event_index >= 0 AND event.archived=0 AND lower(event.body) LIKE {pattern}) THEN 2 ELSE 3 END"
)
} else {
"3".to_string()
@@ -762,8 +762,12 @@ impl SqliteWorkspaceAuthority {
let id = ticket.meta.id.as_str();
let dependency_check =
dependency_backend.dependency_check(TicketIdOrSlug::Id(id.to_string()))?;
let (body, body_truncated) =
truncate_body(ticket.document.body.as_str(), DETAIL_BODY_LIMIT);
let initial = ticket.events.iter().find(|event| event.is_initial)
.ok_or_else(|| Error::Store("Ticket initial post is missing".into()))?;
let initial_post = ticket_event_detail(0, initial);
let body = initial_post.body.clone().unwrap_or_default();
let body_truncated = initial_post.body_truncated;
let subsequent_events = ticket.events.iter().filter(|event| !event.is_initial).collect::<Vec<_>>();
let event_limit = request
.event_limit
.unwrap_or(TICKET_EVENT_LIMIT)
@@ -773,10 +777,10 @@ impl SqliteWorkspaceAuthority {
.as_deref()
.map(|cursor| parse_offset_cursor(cursor, "event_cursor"))
.transpose()?
.unwrap_or(ticket.events.len())
.min(ticket.events.len());
.unwrap_or(subsequent_events.len())
.min(subsequent_events.len());
let event_start = event_end.saturating_sub(event_limit);
let events = ticket.events[event_start..event_end]
let events = subsequent_events[event_start..event_end]
.iter()
.enumerate()
.map(|(index, event)| ticket_event_detail(event_start + index, event))
@@ -795,8 +799,7 @@ impl SqliteWorkspaceAuthority {
})
})
.collect::<Result<Vec<_>>>()?;
let implementation_reports = ticket
.events
let implementation_reports = subsequent_events
.iter()
.enumerate()
.filter(|(_, event)| event.kind.as_str() == "implementation_report")
@@ -996,6 +999,8 @@ impl SqliteWorkspaceAuthority {
.store
.list_current_ticket_assignment_tracking(&self.workspace_id, &ticket.meta.id)?;
Ok(TicketDetail {
initial_post,
created_by: ticket.meta.created_by,
assignment_tracking,
id: ticket.meta.id,
resource_key,
@@ -1017,7 +1022,7 @@ impl SqliteWorkspaceAuthority {
tags: ticket.tags,
body,
body_truncated,
event_count: ticket.events.len(),
event_count: subsequent_events.len(),
events,
event_page: QueryPage {
limit: event_limit,
@@ -1770,6 +1775,9 @@ impl std::fmt::Display for ObjectiveQuerySort {
}
fn ticket_event_ref(sequence: usize, event: &TicketEvent) -> String {
if !event.event_ref.is_empty() {
return event.event_ref.clone();
}
event
.attributes
.get("event_id")
@@ -1777,8 +1785,20 @@ fn ticket_event_ref(sequence: usize, event: &TicketEvent) -> String {
.unwrap_or_else(|| format!("sequence:{sequence}"))
}
fn ticket_event_detail(sequence: usize, event: &TicketEvent) -> TicketEventDetail {
pub(crate) fn ticket_post_body_digest(body: &str) -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(body.as_bytes()))
}
pub(crate) fn ticket_event_detail(sequence: usize, event: &TicketEvent) -> TicketEventDetail {
let (body, body_truncated) = truncate_body(event.body.as_str(), if event.is_initial { DETAIL_BODY_LIMIT } else { TICKET_EVENT_BODY_LIMIT });
TicketEventDetail {
is_initial: event.is_initial,
edited_at: event.edited_at.clone(),
edited_by: event.edited_by.clone(),
editable: event.kind == ticket::TicketEventKind::Comment,
body_digest: ticket_post_body_digest(event.body.as_str()),
body_truncated,
sequence,
event_ref: ticket_event_ref(sequence, event),
kind: event.kind.as_str().to_owned(),
@@ -1790,8 +1810,7 @@ fn ticket_event_detail(sequence: usize, event: &TicketEvent) -> TicketEventDetai
reason: event.reason.clone(),
state_field: event.state_field.clone(),
heading: event.heading.clone(),
body: (!event.body.as_str().is_empty())
.then(|| truncate_body(event.body.as_str(), TICKET_EVENT_BODY_LIMIT).0),
body: Some(body),
attributes: event.attributes.clone(),
references: event
.references
@@ -2468,6 +2487,7 @@ fn ticket_query_item(
if let Some((sequence, event)) = authoritative_events
.iter()
.enumerate()
.filter(|(_, event)| !event.is_initial)
.find(|(_, event)| event.body.as_str().to_lowercase().contains(&needle))
{
matched_fields.push("event".to_string());
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,203 @@
-- Canonical standalone Ticket schema. Workspace Server composes stricter cross-domain authority.
CREATE TABLE typed_ticket_artifacts (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, relative_path),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_event_attributes (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index, key),
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_event_references (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal),
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_events (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
event_index INTEGER NOT NULL,
kind TEXT NOT NULL,
author TEXT,
at TEXT,
status TEXT,
from_state TEXT,
to_state TEXT,
reason TEXT,
state_field TEXT,
heading TEXT,
body TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_labels (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_orchestration_plans (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
record_id TEXT NOT NULL,
kind TEXT NOT NULL,
related_ticket TEXT,
note TEXT,
accepted_summary TEXT,
accepted_branch TEXT,
accepted_worktree TEXT,
accepted_role_plan TEXT,
author TEXT NOT NULL,
at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, record_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_raw_frontmatter (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, key),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_relations (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, note TEXT, author TEXT NOT NULL, at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, kind, target),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_risk_flags (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_tickets (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
slug TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL,
kind TEXT NOT NULL,
priority TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT,
updated_at TEXT,
assignee TEXT,
readiness TEXT,
workflow_state TEXT NOT NULL,
workflow_state_explicit INTEGER NOT NULL,
queued_by TEXT,
queued_at TEXT,
resolution TEXT,
PRIMARY KEY (workspace_id, ticket_id)
);
CREATE TABLE typed_ticket_targets (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
repository_key TEXT NOT NULL,
ref_selector TEXT,
access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')),
PRIMARY KEY (workspace_id, ticket_id, ordinal),
UNIQUE (workspace_id, ticket_id, repository_key),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE "workspace_resource_key_counters" (
workspace_id TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
next_sequence INTEGER NOT NULL CHECK (next_sequence > 0),
PRIMARY KEY (workspace_id, resource_kind)
);
CREATE TABLE "workspace_resource_keys" (
workspace_id TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
resource_id TEXT NOT NULL,
sequence INTEGER NOT NULL CHECK (sequence > 0),
resource_key TEXT NOT NULL,
allocated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, resource_kind, resource_id),
UNIQUE (workspace_id, resource_kind, sequence),
UNIQUE (workspace_id, resource_key)
);
CREATE INDEX idx_workspace_resource_keys_reverse
ON workspace_resource_keys(workspace_id, resource_kind, resource_key);
CREATE INDEX typed_ticket_events_workspace_kind_ticket
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
CREATE INDEX typed_ticket_relations_workspace_source_kind
ON typed_ticket_relations(workspace_id, ticket_id, kind, target);
CREATE INDEX typed_ticket_relations_workspace_target_kind
ON typed_ticket_relations(workspace_id, target, kind, ticket_id);
CREATE INDEX typed_tickets_workspace_created
ON typed_tickets(workspace_id, created_at DESC, ticket_id);
CREATE INDEX typed_tickets_workspace_state_updated
ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id);
CREATE INDEX typed_tickets_workspace_title
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
CREATE INDEX typed_tickets_workspace_updated
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority.
CREATE TABLE typed_ticket_case_metadata (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT,
PRIMARY KEY (workspace_id, ticket_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_case_history (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL,
operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, operation_key),
UNIQUE (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_legacy_workflow_snapshot (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets
WHEN NEW.workflow_state NOT IN ('open', 'closed')
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets
WHEN NEW.workflow_state NOT IN ('open', 'closed')
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.
CREATE TABLE workspace_tags (
workspace_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
name TEXT NOT NULL,
normalized_name TEXT NOT NULL,
last_operation_key TEXT NOT NULL,
deleted INTEGER NOT NULL CHECK (deleted IN (0, 1)),
PRIMARY KEY (workspace_id, tag_id)
);
CREATE UNIQUE INDEX workspace_tags_active_name
ON workspace_tags(workspace_id, normalized_name) WHERE deleted = 0;
CREATE TABLE typed_ticket_tags (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, tag_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE,
FOREIGN KEY (workspace_id, tag_id) REFERENCES workspace_tags(workspace_id, tag_id) ON DELETE RESTRICT
);
CREATE INDEX typed_ticket_tags_workspace_tag
ON typed_ticket_tags(workspace_id, tag_id, ticket_id);
-- Immutable operation receipt and Workspace audit: exact input includes actor
-- attributes; before/result preserve stable identities and display-name snapshots.
-- No foreign keys to mutable rows: history survives deletion of current links.
CREATE TABLE workspace_tag_operations (
workspace_id TEXT NOT NULL,
operation_key TEXT NOT NULL,
kind TEXT NOT NULL,
target_id TEXT NOT NULL,
input_json TEXT NOT NULL,
source_context_json TEXT NOT NULL,
before_json TEXT NOT NULL,
result_json TEXT NOT NULL,
at TEXT NOT NULL,
PRIMARY KEY (workspace_id, operation_key)
);
@@ -437,6 +437,10 @@ CREATE TABLE typed_ticket_events (
state_field TEXT,
heading TEXT,
body TEXT NOT NULL,
event_ref TEXT NOT NULL DEFAULT '',
edited_at TEXT,
edited_by TEXT,
archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1)),
PRIMARY KEY (workspace_id, ticket_id, event_index),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
@@ -493,7 +497,7 @@ CREATE TABLE "typed_tickets" (
status TEXT NOT NULL,
kind TEXT NOT NULL,
priority TEXT NOT NULL,
body TEXT NOT NULL,
created_by TEXT,
created_at TEXT,
updated_at TEXT,
assignee TEXT,
@@ -1113,6 +1117,7 @@ CREATE INDEX repositories_workspace_provider_idx
ON repositories(workspace_id, provider);
CREATE UNIQUE INDEX typed_ticket_events_ref ON typed_ticket_events(workspace_id, ticket_id, event_ref);
CREATE INDEX typed_ticket_events_workspace_kind_ticket
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id
+4 -3
View File
@@ -1135,10 +1135,11 @@ mod tests {
[worker_id().to_string()],
)?;
c.execute(
"INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) \
VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', '', 'open', 1)",
"INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, workflow_state, workflow_state_explicit) \
VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', 'open', 1)",
[],
)?;
c.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('w','ticket',-1,'ticket:-1','comment','')", [])?;
Ok(())
})
.unwrap();
@@ -1398,7 +1399,7 @@ mod tests {
})
.unwrap();
s.with_conn(|conn| {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','open',1)", [])?;
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','open',1)", [])?;
conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','2026-08-24T00:00:00Z')", [])?;
conn.execute("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at) VALUES('w','allocation-old',?1,'r','fingerprint','created','created','created')", [worker_id().to_string()])?;
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?;
+164 -93
View File
@@ -10285,14 +10285,13 @@ impl server_api::ServerApi for ServerApiContractService {
workspace_id: String,
request: server_api::CreateTicketRecordRequest,
) -> std::result::Result<server_api::TicketRecordRef, server_api::RepositoryApiError> {
let Json(response) = scoped_create_ticket_record(
State(self.workspace_api()?.clone()),
AxumPath(ScopedWorkspacePath { workspace_id }),
contract_request_headers(&context)?,
Json(request.0),
)
.await
.map_err(ApiError::into_repository_api_error)?;
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context),
TicketBackendOperation::Create { input: request.0 },
).await.map_err(ApiError::into_repository_api_error)?;
let Json(response) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::TicketRef(ticket) => Some(ticket), _ => None,
}).map_err(ApiError::into_repository_api_error)?;
Ok(server_api::TicketRecordRef(response))
}
@@ -10432,14 +10431,13 @@ impl server_api::ServerApi for ServerApiContractService {
id: String,
request: server_api::EditTicketRecordItemRequest,
) -> std::result::Result<server_api::TicketRecord, server_api::RepositoryApiError> {
let Json(response) = scoped_edit_ticket_record_item(
State(self.workspace_api()?.clone()),
AxumPath((workspace_id, id)),
contract_request_headers(&context)?,
Json(request.0),
)
.await
.map_err(ApiError::into_repository_api_error)?;
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context),
TicketBackendOperation::EditItem { id: TicketIdOrSlug::Query(id), edit: request.0 },
).await.map_err(ApiError::into_repository_api_error)?;
let Json(response) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::Ticket(ticket) => Some(ticket), _ => None,
}).map_err(ApiError::into_repository_api_error)?;
Ok(server_api::TicketRecord(response))
}
@@ -10469,14 +10467,11 @@ impl server_api::ServerApi for ServerApiContractService {
id: String,
request: server_api::TicketThreadEventRequest,
) -> std::result::Result<(), server_api::RepositoryApiError> {
scoped_add_ticket_thread_event(
State(self.workspace_api()?.clone()),
AxumPath((workspace_id, id)),
contract_request_headers(&context)?,
Json(request.0),
)
.await
.map_err(ApiError::into_repository_api_error)?;
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context),
TicketBackendOperation::AddEvent { id: TicketIdOrSlug::Query(id), event: request.0 },
).await.map_err(ApiError::into_repository_api_error)?;
ticket_rest_unit(result).map_err(ApiError::into_repository_api_error)?;
Ok(())
}
@@ -10936,12 +10931,14 @@ impl server_api::ServerApi for ServerApiContractService {
async fn ticket_edit(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
request: server_api::BrowserEditTicketRequest,
) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> {
let Json(response) = scoped_edit_ticket_item(
State(self.workspace_api()?.clone()),
&context,
AxumPath(ScopedRecordPath { workspace_id, id }),
Json(request),
)
@@ -10950,6 +10947,22 @@ impl server_api::ServerApi for ServerApiContractService {
Ok(response)
}
async fn ticket_post_get(
&self, workspace_id: String, id: String, event_ref: String,
) -> std::result::Result<server_api::TicketEventDetail, server_api::RepositoryApiError> {
scoped_get_ticket_post(self.workspace_api()?.clone(), workspace_id, id, event_ref)
.await.map_err(ApiError::into_repository_api_error)
}
async fn ticket_post_edit(
&self, context: server_api::ServerRequestContext, workspace_id: String, id: String,
event_ref: String, request: server_api::TicketPostEditRequest,
) -> std::result::Result<TicketDetail, server_api::RepositoryApiError> {
let Json(detail) = scoped_edit_ticket_post(self.workspace_api()?.clone(), context,
workspace_id, id, event_ref, request).await.map_err(ApiError::into_repository_api_error)?;
Ok(detail)
}
async fn ticket_show(
&self,
workspace_id: String,
@@ -11297,12 +11310,14 @@ impl server_api::ServerApi for ServerApiContractService {
async fn ticket_event_append(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
request: server_api::BrowserAppendTicketEventRequest,
) -> std::result::Result<server_api::TicketDetail, server_api::RepositoryApiError> {
let Json(response) = scoped_append_ticket_event(
State(self.workspace_api()?.clone()),
&context,
AxumPath(ScopedRecordPath { workspace_id, id }),
Json(request),
)
@@ -15414,84 +15429,122 @@ fn browser_ticket_detail(api: &WorkspaceApi, ticket_id: &str) -> ApiResult<Json<
async fn scoped_edit_ticket_item(
State(api): State<WorkspaceApi>,
context: &server_api::ServerRequestContext,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(request): Json<server_api::BrowserEditTicketRequest>,
) -> ApiResult<Json<TicketDetail>> {
api.host_executors
.host()?
.run(move || {
validate_workspace_scope(&api, &path.workspace_id)?;
let mut targets = request.target.map(|target| match target {
server_api::BrowserTicketTargetEdit::Set { targets } => TicketTargetsEdit::Set {
targets: targets
.into_iter()
.map(|target| ticket::TicketTarget {
repository_key: target.repository_key,
ref_selector: target.ref_selector,
access: target.access,
})
.collect(),
},
server_api::BrowserTicketTargetEdit::Clear => TicketTargetsEdit::Clear,
});
if let Some(TicketTargetsEdit::Set { targets }) = targets.as_mut() {
validate_ticket_target_repository_keys(&api, &path.workspace_id, targets)?;
}
browser_ticket_backend(&api)?
.edit_item(
TicketIdOrSlug::Id(path.id.clone()),
TicketItemEdit {
title: request.title,
body: request.body.map(MarkdownText::new),
body_replacement: match (request.old_string, request.new_string) {
(Some(old_string), Some(new_string)) => Some(TicketBodyReplacement {
old_string,
new_string,
replace_all: request.replace_all,
}),
(None, None) => None,
_ => {
return Err(settings_bad_request(
"invalid_ticket_edit_replacement",
"old_string and new_string must be provided together",
));
}
},
targets,
author: request.author,
},
)
.map_err(Error::from)?;
browser_ticket_detail(&api, &path.id)
})
.await
let targets = request.target.map(|target| match target {
server_api::BrowserTicketTargetEdit::Set { targets } => TicketTargetsEdit::Set {
targets: targets.into_iter().map(|target| ticket::TicketTarget {
repository_key: target.repository_key, ref_selector: target.ref_selector, access: target.access,
}).collect(),
},
server_api::BrowserTicketTargetEdit::Clear => TicketTargetsEdit::Clear,
});
let body_replacement = match (request.old_string, request.new_string) {
(Some(old_string), Some(new_string)) => Some(TicketBodyReplacement { old_string, new_string, replace_all: request.replace_all }),
(None, None) => None,
_ => return Err(settings_bad_request("invalid_ticket_edit_replacement", "old_string and new_string must be provided together")),
};
execute_ticket_rest_operation_with_context(&api, &path.workspace_id,
contract_request_headers(context).map_err(|error| Error::Config(error.message))?, Some(context),
TicketBackendOperation::EditItem { id: TicketIdOrSlug::Query(path.id.clone()), edit: TicketItemEdit {
title: request.title, body: request.body.map(MarkdownText::new), body_replacement,
targets, author: request.author,
} }).await?;
api.host_executors.host()?.run(move || browser_ticket_detail(&api, &path.id)).await
}
async fn scoped_append_ticket_event(
State(api): State<WorkspaceApi>,
context: &server_api::ServerRequestContext,
AxumPath(path): AxumPath<ScopedRecordPath>,
Json(request): Json<server_api::BrowserAppendTicketEventRequest>,
) -> ApiResult<Json<TicketDetail>> {
api.host_executors
.host()?
.run(move || {
validate_workspace_scope(&api, &path.workspace_id)?;
let role = match request.role {
server_api::BrowserTicketThreadRole::Comment => TicketEventKind::Comment,
server_api::BrowserTicketThreadRole::Plan => TicketEventKind::Plan,
server_api::BrowserTicketThreadRole::Decision => TicketEventKind::Decision,
server_api::BrowserTicketThreadRole::ImplementationReport => {
TicketEventKind::ImplementationReport
}
};
let mut event = NewTicketEvent::new(role, request.body);
event.author = request.author;
browser_ticket_backend(&api)?
.add_event(TicketIdOrSlug::Id(path.id.clone()), event)
.map_err(Error::from)?;
browser_ticket_detail(&api, &path.id)
})
.await
let role = match request.role {
server_api::BrowserTicketThreadRole::Comment => TicketEventKind::Comment,
server_api::BrowserTicketThreadRole::Plan => TicketEventKind::Plan,
server_api::BrowserTicketThreadRole::Decision => TicketEventKind::Decision,
server_api::BrowserTicketThreadRole::ImplementationReport => TicketEventKind::ImplementationReport,
};
let mut event = NewTicketEvent::new(role, request.body);
event.author = request.author;
execute_ticket_rest_operation_with_context(&api, &path.workspace_id,
contract_request_headers(context).map_err(|error| Error::Config(error.message))?, Some(context),
TicketBackendOperation::AddEvent { id: TicketIdOrSlug::Query(path.id.clone()), event }).await?;
api.host_executors.host()?.run(move || browser_ticket_detail(&api, &path.id)).await
}
const TICKET_EDITABLE_POST_BODY_LIMIT: usize = 1024 * 1024;
fn authenticated_ticket_post_author(
context: &server_api::ServerRequestContext,
source: Option<&WorkerMutationSource>,
) -> Result<String> {
if let Some(source) = source {
return Ok(format!("worker:{}/{}", source.runtime_id, source.worker_id));
}
let actor = context.actor.as_ref().ok_or_else(|| Error::WorkspacePermissionDenied(
"Ticket posts require an authenticated actor".into()))?;
let identity = serde_json::to_string(&(&actor.account_id, &actor.user_id))
.map_err(|error| Error::Config(format!("failed to encode post actor: {error}")))?;
Ok(format!("workspace-user:{identity}"))
}
async fn scoped_get_ticket_post(
api: WorkspaceApi, workspace_id: String, id: String, event_ref: String,
) -> ApiResult<server_api::TicketEventDetail> {
api.host_executors.host()?.run(move || {
validate_workspace_scope(&api, &workspace_id)?;
let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?;
let ticket = browser_ticket_backend(&api)?.show(TicketIdOrSlug::Id(id)).map_err(Error::from)?;
let (sequence, post) = ticket.events.iter().enumerate().find(|(_, post)| post.event_ref == event_ref)
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref)))?;
if post.body.as_str().len() > TICKET_EDITABLE_POST_BODY_LIMIT {
return Err(settings_bad_request("ticket_post_too_large", "Ticket post exceeds the full-post read limit"));
}
let mut detail = crate::authority::ticket_event_detail(sequence, post);
detail.body = Some(post.body.as_str().to_string());
detail.body_truncated = false;
Ok(detail)
}).await
}
async fn scoped_edit_ticket_post(
api: WorkspaceApi, context: server_api::ServerRequestContext,
workspace_id: String, id: String, event_ref: String, request: server_api::TicketPostEditRequest,
) -> ApiResult<Json<TicketDetail>> {
let headers = contract_request_headers(&context).map_err(|error| Error::Config(error.message))?;
let source = authenticated_ticket_worker_source(&headers, &context)?;
let author = authenticated_ticket_post_author(&context, source.as_ref())?;
if request.body.len() > TICKET_EDITABLE_POST_BODY_LIMIT {
return Err(settings_bad_request("ticket_post_too_large", "Ticket post exceeds the edit limit"));
}
api.host_executors.host()?.run(move || {
validate_workspace_scope(&api, &workspace_id)?;
let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?;
let backend = browser_ticket_backend(&api)?;
let observed = backend.show(TicketIdOrSlug::Id(id.clone())).map_err(Error::from)?;
let post = observed.events.iter().find(|post| post.event_ref == event_ref)
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref.clone())))?;
if crate::authority::ticket_post_body_digest(post.body.as_str()) != request.expected_body_digest {
return Err(Error::Ticket(ticket::TicketError::Conflict("stale Ticket post body digest".into())).into());
}
let is_initial = post.is_initial;
// The digest is a transport observation; storage compares its exact body in the write transaction.
let (previous, current) = backend.edit_post_with_snapshots(TicketIdOrSlug::Id(id.clone()), ticket::TicketPostEdit {
event_ref, expected_body: post.body.clone(), body: MarkdownText::new(request.body), author,
}).map_err(Error::from)?;
notify_ticket_recipients(&api, &workspace_id, &id, previous.meta.workflow_state.as_str(),
current.meta.workflow_state.as_str(), source.clone());
if is_initial && let Some(source) = source {
schedule_ticket_item_check(&api, &current, TicketItemCheckEdit::Edit {
title_changed: false, body_changed: true, previous_title: None,
previous_body: Some(previous.document.body.as_str().to_string()), body_replacement: None,
}, source);
}
browser_ticket_detail(&api, &id)
}).await
}
async fn scoped_mark_ticket_ready_from_browser(
@@ -15802,7 +15855,22 @@ fn execute_ticket_rest_operation_on_host(
)
.into());
}
let source = verified_source.or(hinted_source);
let source = if let Some(context) = context {
authenticated_ticket_worker_source(&headers, context)?
} else {
verified_source.or(hinted_source)
};
if matches!(operation, TicketBackendOperation::Create { .. } | TicketBackendOperation::EditItem { .. } | TicketBackendOperation::AddEvent { .. })
&& let Some(context) = context
{
let author = authenticated_ticket_post_author(context, source.as_ref())?;
match &mut operation {
TicketBackendOperation::Create { input } => input.author = Some(author),
TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author),
TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author),
_ => unreachable!(),
}
}
validate_ticket_repository_operation(api, &operation)?;
let before = target.as_ref().and_then(|id| backend.show(id.clone()).ok());
if let Some(source) = source.as_ref()
@@ -47377,6 +47445,7 @@ mod tests {
.unwrap();
let Json(_) = scoped_edit_ticket_item(
State(api.clone()),
&server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()),
AxumPath(ScopedRecordPath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
id: ticket_ref.id.clone(),
@@ -53470,6 +53539,7 @@ mod tests {
let Json(edited) = scoped_edit_ticket_item(
State(api.clone()),
&server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()),
AxumPath(path()),
Json(server_api::BrowserEditTicketRequest {
title: Some("Browser Ticket API edited".to_string()),
@@ -53507,6 +53577,7 @@ mod tests {
let Json(commented) = scoped_append_ticket_event(
State(api.clone()),
&server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()),
AxumPath(path()),
Json(server_api::BrowserAppendTicketEventRequest {
role: server_api::BrowserTicketThreadRole::Comment,
+77 -22
View File
@@ -73,7 +73,7 @@ pub use ticket::{
};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 93;
const LATEST_SCHEMA_VERSION: i64 = 94;
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -346,6 +346,11 @@ const MIGRATIONS: &[Migration] = &[
name: "Durable assignment deliveries and source-bound startup fences",
apply: migrate_assignment_delivery_v92_to_v93,
},
Migration {
version: 94,
name: "Canonical Ticket posts",
apply: migrate_ticket_posts_v93_to_v94,
},
];
#[derive(Clone, Copy)]
@@ -8602,9 +8607,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
tx.execute(
"INSERT INTO typed_ticket_events (
workspace_id, ticket_id, event_index, kind, author, at,
from_state, to_state, reason, state_field, heading, body
from_state, to_state, reason, state_field, heading, body, event_ref
) VALUES (?1, ?2, ?3, 'assignment', ?4, ?5, NULL, NULL,
'manual Worker assignment accepted', NULL, 'Worker assigned', '')",
'manual Worker assignment accepted', NULL, 'Worker assigned', '', ?2 || ':' || ?3)",
params![record.workspace_id, record.ticket_id, event_index,
record.assigned_by, record.assigned_at],
)?;
@@ -13990,6 +13995,33 @@ fn migrate_assignment_delivery_v92_to_v93(conn: &Connection) -> Result<()> {
Ok(())
}
fn migrate_ticket_posts_v93_to_v94(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 93 {
return Err(Error::Store(
"Ticket posts migration requires schema 93".into(),
));
}
let tx = conn.unchecked_transaction()?;
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
.map_err(|error| Error::Store(format!("Ticket posts migration failed: {error}")))?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(94,'Canonical Ticket posts')",
[],
)?;
verify_workspace_resource_constraints(&tx)?;
let violations: i64 =
tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
row.get(0)
})?;
if violations != 0 {
return Err(Error::Store(format!(
"Ticket posts migration left {violations} foreign-key violations"
)));
}
tx.commit()?;
Ok(())
}
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
create_workspace_schema_structure(conn)?;
assignment_delivery::install(conn)?;
@@ -14000,10 +14032,17 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
})
}
// Workspace 92 changes only the Ticket-owned assignment component. Retained
// Workspace-91 fixtures compose this host structure with the genuine Ticket-10
// schema, not a current database with lowered migration markers.
// Fresh composition uses the posts contract. Retained fixtures pass frozen host
// and component DDL to the shared cross-domain constraint composition below.
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)
}
fn create_workspace_schema_structure_from_sql(
conn: &Connection,
schema_sql: &str,
ticket_posts: bool,
) -> Result<()> {
// The composed Workspace parent FK rebuild must not discard domain-owned
// case guards. Retain their SQL from the shared fresh Ticket schema.
let ticket_parent_triggers = conn
@@ -14021,12 +14060,11 @@ fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
CREATE UNIQUE INDEX worker_restore_intent_pending_domain ON worker_restore_intents(workspace_id,runtime_id,worker_id,domain_key) WHERE settled=0;")?;
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
conn.execute_batch(&include_str!("latest_schema.sql").replace(
conn.execute_batch(&schema_sql.replace(
"-- Ticket-owned assignment schema is composed by store.rs.",
ticket::TICKET_ASSIGNMENT_SCHEMA_SQL,
))?;
conn.execute_batch(
r#"
let composed_sql = r#"
ALTER TABLE typed_tickets RENAME TO typed_tickets_legacy_v66;
CREATE TABLE typed_tickets (
workspace_id TEXT NOT NULL,
@@ -14088,8 +14126,13 @@ fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
CHECK (capabilities IN (
'all', 'read_only', 'read_write', 'command_only', 'read_command'
));
"#,
)?;
"#;
let composed_sql = if ticket_posts {
composed_sql.replace("body TEXT NOT NULL,", "created_by TEXT,")
} else {
composed_sql.to_string()
};
conn.execute_batch(&composed_sql)?;
install_ticket_case_assignment_work_schema(conn)?;
for sql in ticket_parent_triggers {
conn.execute_batch(&sql)?;
@@ -16849,6 +16892,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
include!("schema_equivalence_tests.rs");
include!("ticket_tag_upgrade_tests.rs");
include!("ticket_post_upgrade_tests.rs");
include!("migration_entrypoint_tests.rs");
include!("legacy_evidence_column_tests.rs");
@@ -16869,7 +16913,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
configure_sqlite(&conn).unwrap();
// Retained fixtures start from genuine pre-tracking Ticket DDL; current
// component 11 would introduce assignment objects before frozen host 85.
conn.execute_batch(include_str!("../../ticket/src/latest_schema.sql"))
conn.execute_batch(include_str!("frozen_ticket_schema_v10.sql"))
.unwrap();
conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','fixture');").unwrap();
merge_request::migrate(&conn).unwrap();
@@ -17181,6 +17225,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
},
WorkspaceSchemaMigrationStep {version:92, name:"Ticket-owned assignment tracking".into()},
WorkspaceSchemaMigrationStep {version:93, name:"Durable assignment deliveries and source-bound startup fences".into()},
WorkspaceSchemaMigrationStep {version:94, name:"Canonical Ticket posts".into()},
]
);
@@ -17284,6 +17329,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(91, "Ticket open/closed cases and retained routing evidence".to_string()),
(92, "Ticket-owned assignment tracking".to_string()),
(93, "Durable assignment deliveries and source-bound startup fences".to_string()),
(94, "Canonical Ticket posts".to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -19696,6 +19742,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
fn stored_fixture_rows(conn: &Connection, table: &str) -> Vec<Vec<rusqlite::types::Value>> {
// Compare preserved original assignment evidence, not newly added tracking columns.
if table == "typed_ticket_events" {
return fixture_query_rows(
conn,
"SELECT workspace_id,ticket_id,event_index,kind,author,at,status,from_state,to_state,reason,state_field,heading,body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY rowid",
);
}
let columns = if table == "ticket_worker_assignments" {
"workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,runtime_id,worker_id,assigned_by,assigned_at"
} else {
@@ -19881,9 +19933,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
.map(|step| step.version)
.collect::<Vec<_>>(),
if version == 89 {
vec![90, 91, 92, 93]
vec![90, 91, 92, 93, 94]
} else {
vec![91, 92, 93]
vec![91, 92, 93, 94]
}
);
drop(conn);
@@ -19899,7 +19951,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
vec![(90, "Runtime binding connection configuration and historical verification archive".into()),
(91, "Ticket open/closed cases and retained routing evidence".into()),
(92, "Ticket-owned assignment tracking".into()),
(93, "Durable assignment deliveries and source-bound startup fences".into())]);
(93, "Durable assignment deliveries and source-bound startup fences".into()),
(94, "Canonical Ticket posts".into())]);
for (table, rows) in unchanged.iter().zip(before.iter()) {
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
}
@@ -20295,11 +20348,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
VALUES('guard-owner','user','guard-owner','Owner','1','1');
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at)
VALUES('guard-workspace','guard-owner','Guard','active','1','1');
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
VALUES('guard-workspace','guard-ticket','guard-ticket','Guard','open','task','P2','','open',1);")?;
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit)
VALUES('guard-workspace','guard-ticket','guard-ticket','Guard','open','task','P2','open',1);
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('guard-workspace','guard-ticket',-1,'guard-ticket:-1','comment','');")?;
assert!(conn.execute("UPDATE typed_tickets SET workflow_state='done' WHERE ticket_id='guard-ticket'", []).is_err());
assert!(conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
VALUES('guard-workspace','bad-ticket','bad-ticket','Bad','open','task','P2','','planning',1)", []).is_err());
assert!(conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit)
VALUES('guard-workspace','bad-ticket','bad-ticket','Bad','open','task','P2','planning',1)", []).is_err());
assert!(conn.execute("INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at)
VALUES('guard-workspace','bad-runtime','Bad','https://invalid.invalid','key','fingerprint','binding','invalid-mode','1','1')", []).is_err());
Ok(())
@@ -20519,8 +20573,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
fn seed_manual_start_ticket(store: &SqliteWorkspaceStore, id: &str, state: &str) {
store.with_conn(|conn| {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,updated_at)
VALUES('w',?1,?1,'Ticket','open','task','normal','',?2,1,'1')", params![id,state])?;
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit,updated_at)
VALUES('w',?1,?1,'Ticket','open','task','normal',?2,1,'1')", params![id,state])?;
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('w',?1,-1,?1 || ':-1','comment','')", [id])?;
Ok(())
}).unwrap();
}
@@ -20932,7 +20987,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
);
reopened.with_conn(|conn| {
conn.execute_batch("UPDATE typed_tickets SET title='Changed requirements' WHERE workspace_id='w' AND ticket_id='first';
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('w','first',1,'item_edit','Ticket requirements changed');")?; Ok(())
UPDATE typed_ticket_events SET body='Ticket requirements changed',edited_at='2',edited_by='fixture-editor' WHERE workspace_id='w' AND ticket_id='first' AND event_index=-1;")?; Ok(())
}).unwrap();
assert!(
reopened
@@ -0,0 +1,369 @@
// Host/domain integration through real SQLite and public migration entrypoints.
// Retained fixtures use pre-posts DDL; no current schema is relabelled as old.
fn ticket_posts_schema93_fixture(path: &Path) -> Connection {
let conn = Connection::open(path).unwrap();
configure_sqlite(&conn).unwrap();
conn.execute_batch(include_str!("frozen_ticket_schema_v10.sql"))
.unwrap();
conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL);
INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','original-component10-time');
PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;").unwrap();
create_workspace_schema_structure_from_sql(&conn, include_str!("frozen_schema_v93.sql"), false)
.unwrap();
conn.execute_batch(include_str!(
"../../ticket/src/assignment_tracking_schema.sql"
))
.unwrap();
conn.execute_batch("INSERT INTO ticket_schema_migrations VALUES(11,'Ticket-owned assignment tracking','original-component11-time');
INSERT INTO __yoi_schema_migrations(version,name) VALUES(93,'workspace schema baseline');
PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;").unwrap();
assignment_delivery::install(&conn).unwrap();
seed_migration_workspace(&conn);
conn
}
fn seed_ticket_posts_legacy_content(conn: &Connection, workspace: &str) {
for (id, body) in [
("authored", "Current requirements after edits"),
("unknown", "Unknown creator body"),
] {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,created_at,updated_at,workflow_state,workflow_state_explicit)
VALUES(?1,?2,?2,'Retained Ticket','open','task','P2',?3,'2026-01-01T00:00:00Z','2026-02-01T00:00:00Z','open',1)", params![workspace,id,body]).unwrap();
}
for (index, kind, author, body) in [
(0, "create", Some("creator"), "Original creation audit"),
(3, "comment", Some("commenter"), "A preserved comment"),
(7, "item_edit", Some("editor"), "Original edit audit"),
(9, "comment", None, "Anonymous historical comment"),
] {
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body) VALUES(?1,'authored',?2,?3,?4,'2026-01-02T00:00:00Z',?5)",params![workspace,index,kind,author,body]).unwrap();
conn.execute("INSERT INTO typed_ticket_event_attributes VALUES(?1,'authored',?2,'source_kind','original-source')",params![workspace,index]).unwrap();
conn.execute("INSERT INTO typed_ticket_event_references VALUES(?1,'authored',?2,0,'ticket','T-original')",params![workspace,index]).unwrap();
}
}
#[test]
fn ticket_posts_schema93_preserves_initial_body_creator_comments_and_archived_audit() {
use ticket::TicketBackend;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("workspace.db");
let conn = ticket_posts_schema93_fixture(&path);
seed_ticket_posts_legacy_content(&conn, "space");
let old_events = stored_fixture_rows(&conn, "typed_ticket_events");
let old_attributes = stored_fixture_rows(&conn, "typed_ticket_event_attributes");
let old_references = stored_fixture_rows(&conn, "typed_ticket_event_references");
let old_markers = stored_fixture_rows(&conn, "ticket_schema_migrations");
drop(conn);
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
assert_eq!(plan.current_schema_version, 93);
assert_eq!(
plan.migrations
.iter()
.map(|step| step.version)
.collect::<Vec<_>>(),
[94]
);
let conn = Connection::open(&path).unwrap();
assert!(
column_exists(&conn, "typed_tickets", "body").unwrap(),
"planning must not mutate its source"
);
assert_eq!(
stored_fixture_rows(&conn, "typed_ticket_events"),
old_events
);
drop(conn);
assert_eq!(SqliteWorkspaceStore::migrate_database(&path).unwrap(), plan);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 94);
assert!(!column_exists(conn,"typed_tickets","body")?);
assert_eq!(stored_fixture_rows(conn,"typed_ticket_events"),old_events);
assert_eq!(stored_fixture_rows(conn,"typed_ticket_event_attributes"),old_attributes);
assert_eq!(stored_fixture_rows(conn,"typed_ticket_event_references"),old_references);
let markers = stored_fixture_rows(conn,"ticket_schema_migrations");
assert_eq!(&markers[..old_markers.len()],old_markers.as_slice());
assert_eq!(markers.len(),old_markers.len()+1);
let initial: (String,Option<String>,Option<String>,String,String) = conn.query_row("SELECT e.body,t.created_by,e.author,e.kind,e.event_ref FROM typed_tickets t JOIN typed_ticket_events e USING(workspace_id,ticket_id) WHERE t.ticket_id='authored' AND e.event_index=-1",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?)))?;
assert_eq!(initial,("Current requirements after edits".into(),Some("creator".into()),Some("creator".into()),"comment".into(),"authored:-1".into()));
let archive: Vec<(i64,String,i64)> = conn.prepare("SELECT event_index,event_ref,archived FROM typed_ticket_events WHERE ticket_id='authored' ORDER BY event_index")?.query_map([],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?.collect::<rusqlite::Result<_>>()?;
assert_eq!(archive,vec![(-1,"authored:-1".into(),0),(0,"authored:0".into(),1),(3,"authored:3".into(),0),(7,"authored:7".into(),1),(9,"authored:9".into(),0)]);
let unknown: (Option<String>,Option<String>,String) = conn.query_row("SELECT t.created_by,e.author,e.body FROM typed_tickets t JOIN typed_ticket_events e USING(workspace_id,ticket_id) WHERE t.ticket_id='unknown' AND e.event_index=-1",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?)))?;
assert_eq!(unknown,(None,None,"Unknown creator body".into()));
assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
ticket::verify_sqlite_ticket_schema(conn).unwrap();
Ok(())
}).unwrap();
let backend = ticket::SqliteTicketBackend::open_verified(&path, "space").unwrap();
let loaded = backend.show("authored".into()).unwrap();
assert_eq!(loaded.meta.created_by.as_deref(), Some("creator"));
assert_eq!(
loaded.document.body.as_str(),
"Current requirements after edits"
);
assert_eq!(
loaded
.events
.iter()
.map(|event| event.event_ref.as_str())
.collect::<Vec<_>>(),
["authored:-1", "authored:3", "authored:9"]
);
assert!(loaded.events[0].is_initial);
assert_eq!(loaded.events[1].author.as_deref(), Some("commenter"));
assert_eq!(loaded.events[2].author, None);
assert!(
loaded
.events
.iter()
.all(|event| event.edited_at.is_none() && event.edited_by.is_none())
);
drop(store);
let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap();
reopened
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM typed_ticket_events WHERE event_index=-1",
[],
|r| r.get::<_, i64>(0)
)?,
2
);
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM ticket_schema_migrations WHERE version=12",
[],
|r| r.get::<_, i64>(0)
)?,
1
);
Ok(())
})
.unwrap();
}
#[test]
fn ticket_posts_schema94_keeps_body_edit_metadata_and_published_refs_across_reopen() {
use ticket::TicketBackend;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("workspace.db");
let conn = ticket_posts_schema93_fixture(&path);
seed_ticket_posts_legacy_content(&conn, "space");
conn.execute_batch("INSERT INTO typed_ticket_event_attributes VALUES
('space','authored',7,'changes','title,body'),
('space','authored',3,'event_id','raw-identity'),
('space','authored',9,'event_id','raw-identity');
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body)
VALUES('space','authored',15,'item_edit','title-editor','2026-03-01T00:00:00Z','Later title edit');
INSERT INTO typed_ticket_event_attributes VALUES('space','authored',15,'changes','title');").unwrap();
drop(conn);
SqliteWorkspaceStore::migrate_database(&path).unwrap();
let backend = ticket::SqliteTicketBackend::open_verified(&path, "space").unwrap();
let migrated = backend.show("authored".into()).unwrap();
assert_eq!(migrated.meta.created_by.as_deref(), Some("creator"));
assert_eq!(
migrated.meta.created_at.as_deref(),
Some("2026-01-01T00:00:00Z")
);
let initial = &migrated.events[0];
assert_eq!(initial.author.as_deref(), Some("creator"));
assert_eq!(initial.at.as_deref(), Some("2026-01-02T00:00:00Z"));
assert_eq!(initial.edited_by.as_deref(), Some("editor"));
assert_eq!(initial.edited_at.as_deref(), Some("2026-01-02T00:00:00Z"));
assert_eq!(
migrated
.events
.iter()
.map(|e| e.event_ref.as_str())
.collect::<Vec<_>>(),
["authored:-1", "authored:3", "authored:9"]
);
assert_eq!(migrated.events[1].attributes["event_id"], "authored:3");
assert_eq!(migrated.events[2].attributes["event_sequence"], "9");
let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap();
reopened.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE ticket_id='authored' AND event_index=3 AND key='event_id'", [], |r| r.get::<_,String>(0))?, "raw-identity");
assert_eq!(conn.query_row("SELECT archived FROM typed_ticket_events WHERE ticket_id='authored' AND event_index=15", [], |r| r.get::<_,i64>(0))?, 1);
Ok(())
}).unwrap();
assert_eq!(backend.show("authored".into()).unwrap(), migrated);
backend
.add_event(
"authored".into(),
ticket::NewTicketEvent::new(ticket::TicketEventKind::Comment, "Next discussion"),
)
.unwrap();
assert_eq!(
backend
.show("authored".into())
.unwrap()
.events
.last()
.unwrap()
.event_ref,
"authored:16"
);
}
#[test]
fn ticket_posts_schema94_host_failure_rolls_back_domain_rows_ddl_and_markers() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("workspace.db");
let conn = ticket_posts_schema93_fixture(&path);
seed_ticket_posts_legacy_content(&conn, "space");
conn.execute_batch("CREATE TRIGGER reject_host94 BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=94 BEGIN SELECT RAISE(ABORT,'injected host94 failure'); END;").unwrap();
let schema = fixture_query_rows(
&conn,
"SELECT type,name,sql FROM sqlite_schema ORDER BY type,name",
);
let tables = [
"typed_tickets",
"typed_ticket_events",
"typed_ticket_event_attributes",
"typed_ticket_event_references",
"ticket_schema_migrations",
"__yoi_schema_migrations",
];
let rows = tables
.map(|table| fixture_query_rows(&conn, &format!("SELECT * FROM {table} ORDER BY rowid")));
drop(conn);
for entrypoint in 0..3 {
let error = match entrypoint {
0 => SqliteWorkspaceStore::open_for_test(&path).err().unwrap(),
1 => SqliteWorkspaceStore::migration_plan(&path).unwrap_err(),
_ => SqliteWorkspaceStore::migrate_database(&path).unwrap_err(),
};
assert!(
error.to_string().contains("injected host94 failure"),
"{error}"
);
let conn = Connection::open(&path).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 93);
assert_eq!(
fixture_query_rows(
&conn,
"SELECT type,name,sql FROM sqlite_schema ORDER BY type,name"
),
schema
);
for (table, before) in tables.iter().zip(&rows) {
assert_eq!(
&fixture_query_rows(&conn, &format!("SELECT * FROM {table} ORDER BY rowid")),
before,
"rollback {table}"
);
}
}
let conn = Connection::open(&path).unwrap();
conn.execute_batch("DROP TRIGGER reject_host94;").unwrap();
drop(conn);
SqliteWorkspaceStore::open_for_test(&path).unwrap();
}
#[test]
fn ticket_posts_fresh_and_migrated_schema_constraints_match() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("workspace.db");
drop(ticket_posts_schema93_fixture(&path));
let upgraded = SqliteWorkspaceStore::open_for_test(&path).unwrap();
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
for table in [
"typed_tickets",
"typed_ticket_events",
"typed_ticket_event_attributes",
"typed_ticket_event_references",
] {
for query in [
format!(
"SELECT name,type,\"notnull\",dflt_value,pk FROM pragma_table_info('{table}') ORDER BY name"
),
format!(
"SELECT \"table\",\"from\",\"to\",on_update,on_delete FROM pragma_foreign_key_list('{table}') ORDER BY 1,2,3"
),
format!(
"SELECT i.\"unique\",i.partial,(SELECT group_concat(name, ',') FROM (SELECT name FROM pragma_index_info(i.name) ORDER BY seqno)) FROM pragma_index_list('{table}') i ORDER BY 1,2,3"
),
format!(
"SELECT name,sql FROM sqlite_schema WHERE type='trigger' AND tbl_name='{table}' ORDER BY name"
),
] {
let expected = fresh
.with_conn(|conn| Ok(fixture_query_rows(conn, &query)))
.unwrap();
let actual = upgraded
.with_conn(|conn| Ok(fixture_query_rows(conn, &query)))
.unwrap();
assert_eq!(actual, expected, "{query}");
}
}
for store in [&fresh, &upgraded] {
store.with_conn(|conn| {
seed_migration_workspace_if_missing(conn);
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) VALUES('space','guard','guard','Guard','open','task','P2','open',1)",[])?;
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('space','guard',-1,'guard:-1','comment','Initial')",[])?;
assert!(conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('space','guard',0,'guard:-1','comment','Duplicate ref')",[]).is_err());
assert!(conn.execute("UPDATE typed_ticket_events SET archived=2 WHERE ticket_id='guard'",[]).is_err());
assert!(conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('space','missing',0,'missing:0','comment','Orphan')",[]).is_err());
Ok(())
}).unwrap();
}
}
fn seed_migration_workspace_if_missing(conn: &Connection) {
if conn
.query_row(
"SELECT COUNT(*) FROM workspaces WHERE workspace_id='space'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap()
== 0
{
seed_migration_workspace(conn);
}
}
#[test]
fn ticket_posts_oldest_supported_host50_preserves_content_through_full_chain() {
use ticket::TicketBackend;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("workspace.db");
prepare_schema_v50(&path, Some("workspace-a"));
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
seed_ticket_posts_legacy_content(&conn, "workspace-a");
conn.execute("UPDATE typed_tickets SET workflow_state='planning'", [])
.unwrap();
drop(conn);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 94);
ticket::verify_sqlite_ticket_schema(conn).unwrap();
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r
.get::<_, i64>(
0
))?,
0
);
Ok(())
})
.unwrap();
let backend = ticket::SqliteTicketBackend::open_verified(&path, "workspace-a").unwrap();
let loaded = backend.show("authored".into()).unwrap();
assert_eq!(
loaded.document.body.as_str(),
"Current requirements after edits"
);
assert_eq!(loaded.meta.created_by.as_deref(), Some("creator"));
assert_eq!(
loaded
.events
.iter()
.map(|event| event.event_ref.as_str())
.collect::<Vec<_>>(),
["authored:-1", "authored:3", "authored:9"]
);
}
@@ -265,8 +265,8 @@ fn assert_retained_classification_upgrade(workspace_version: i64, component: i64
drop(conn);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?,93);
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,11);
assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION);
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,12);
assert_eq!(fixture_auth_rows(conn),auth_before);
for (table, rows) in preserved_tables.iter().zip(&preserved) {
let mut migrated = stored_fixture_rows(conn, table);
@@ -493,14 +493,14 @@ fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?,93);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert_eq!(
conn.query_row(
"SELECT MAX(version) FROM ticket_schema_migrations",
[],
|row| row.get::<_, i64>(0)
)?,
11
12
);
assert_eq!(
stored_fixture_rows(conn, "typed_ticket_case_history"),
+317 -1
View File
@@ -11620,6 +11620,12 @@
"null"
]
},
"created_by": {
"type": [
"string",
"null"
]
},
"current_closure": {
"anyOf": [
{
@@ -11667,6 +11673,9 @@
},
"type": "array"
},
"initial_post": {
"$ref": "#/components/schemas/TicketEventDetail"
},
"legacy_handoff": {
"anyOf": [
{
@@ -11768,6 +11777,7 @@
}
},
"required": [
"initial_post",
"id",
"tags",
"resource_key",
@@ -12053,6 +12063,25 @@
"body": {
"$ref": "#/components/schemas/MarkdownText"
},
"edited_at": {
"default": null,
"type": [
"string",
"null"
]
},
"edited_by": {
"default": null,
"type": [
"string",
"null"
]
},
"event_ref": {
"default": "",
"description": "Persisted identity, independent of display position and legacy event_id attributes.",
"type": "string"
},
"from": {
"type": [
"string",
@@ -12065,6 +12094,10 @@
"null"
]
},
"is_initial": {
"default": false,
"type": "boolean"
},
"kind": {
"$ref": "#/components/schemas/TicketEventKind"
},
@@ -12134,6 +12167,27 @@
"null"
]
},
"body_digest": {
"type": "string"
},
"body_truncated": {
"type": "boolean"
},
"editable": {
"type": "boolean"
},
"edited_at": {
"type": [
"string",
"null"
]
},
"edited_by": {
"type": [
"string",
"null"
]
},
"event_ref": {
"type": "string"
},
@@ -12149,6 +12203,9 @@
"null"
]
},
"is_initial": {
"type": "boolean"
},
"kind": {
"type": "string"
},
@@ -12190,6 +12247,10 @@
}
},
"required": [
"is_initial",
"body_digest",
"body_truncated",
"editable",
"sequence",
"event_ref",
"kind",
@@ -12751,6 +12812,13 @@
"null"
]
},
"created_by": {
"default": null,
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -12873,6 +12941,22 @@
},
"type": "object"
},
"TicketPostEditRequest": {
"additionalProperties": false,
"properties": {
"body": {
"type": "string"
},
"expected_body_digest": {
"type": "string"
}
},
"required": [
"body",
"expected_body_digest"
],
"type": "object"
},
"TicketQueryItem": {
"additionalProperties": false,
"properties": {
@@ -17114,7 +17198,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb"
"x-yoi-source-digest": "sha256:1d6615147b8d7143484b9a2aaf163da6916fcabb7560c675c5b7bf4d94aa2214"
},
"openapi": "3.1.0",
"paths": {
@@ -35904,6 +35988,238 @@
]
}
},
"/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}": {
"get": {
"operationId": "ticket_post_get",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "event_ref",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketEventDetail"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
},
"patch": {
"operationId": "ticket_post_edit",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "event_ref",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketPostEditRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketDetail"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/tickets/{id}/queue": {
"post": {
"operationId": "ticket_queue",
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
import { extname, join, normalize } from "jsr:@std/path@1.1.4";
import {
fixtureDetail,
fixturePost,
fixturePage,
} from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts";
import type {
@@ -305,6 +306,27 @@ function ticket(key: string) {
});
}
}
value.created_by = "Mika";
value.initial_post = fixturePost(value.body, { at: value.created_at, body_digest: createHash("sha256").update(value.body).digest("hex") });
if (key === "T-900") {
value.title = "Compare deployment options for the shared Workspace";
value.initial_post.body = "We need a deployment plan that the team can maintain without interrupting active Workers.\n\nPlease compare **a scheduled maintenance window** with **rolling replacement**, including recovery steps and how we preserve ongoing work. Return the trade-offs before choosing an approach.";
value.events = [
fixturePost("I checked the current deployment notes. A maintenance window is simpler to operate, but we would need to tell users when new work will pause.\n\nRolling replacement also needs a clear rule for existing sessions; replacing a process is not the same as completing its Ticket.", {
sequence: 1, event_ref: "discussion-rin", is_initial: false, author: "Rin", at: "2026-01-01T09:15:00Z", edited_at: "2026-01-01T10:05:00Z", edited_by: "Rin",
}),
fixturePost("Please include an explicit rollback checkpoint. We should be able to verify the new instance before moving traffic, and keep the original author and timestamp on these notes.\n\nThe final decision can wait until we have both estimates.", {
sequence: 2, event_ref: "discussion-mika", is_initial: false, author: "Mika", at: "2026-01-02T08:30:00Z",
}),
fixturePost("The comparison is ready for review. The two approaches differ mainly in operational complexity and the duration of the pause.\n\n- Maintenance window: lower complexity, visible interruption.\n- Rolling replacement: smaller interruption, more recovery coordination.\n\nNo deployment or case closure has been performed.", {
sequence: 3, event_ref: "discussion-worker", is_initial: false, author: "Workspace Orchestrator", at: "2026-01-02T11:45:00Z",
}),
];
value.body = value.initial_post.body!;
for (const post of [value.initial_post, ...value.events]) post.body_digest = createHash("sha256").update(post.body ?? "").digest("hex");
value.event_count = value.events.length;
value.event_page = fixturePage(value.events.length);
}
tickets.set(key, value);
}
const stored = tickets.get(key)!;
@@ -345,6 +367,8 @@ function projectedTicket(key: string, workspaceId: string) {
workspace_id: workspaceId,
}));
if (workspaceId === "tickets-readonly") {
value.initial_post.editable = false;
value.events = value.events.map((post) => ({ ...post, editable: false }));
value.action_eligibility = {
blockers: ["Read-only fixture"],
can_assign_orchestrator: false,
@@ -710,6 +734,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
created.action_eligibility.can_unassign_orchestrator = false;
created.title = body.title;
created.body = body.body;
created.initial_post = fixturePost(body.body, { at: created.created_at, body_digest: createHash("sha256").update(body.body).digest("hex") });
created.targets = body.targets ?? [];
return Response.json({
id: created.id,
@@ -751,6 +776,24 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const key = decodeURIComponent(tm[1]).match(/T-\d+/)?.[0] ?? "T-1";
const stored = tickets.get(key) ?? (ticket(key), tickets.get(key)!);
const suffix = tm[2];
if (suffix.startsWith("/posts/")) {
const eventRef = decodeURIComponent(suffix.slice("/posts/".length));
const post = [stored.initial_post, ...stored.events].find((post) => post.event_ref === eventRef);
if (!post) return Response.json({ message: "Post not found" }, { status: 404 });
if (request.method === "GET") return Response.json(post);
if (request.method !== "PATCH") return new Response(null, { status: 405 });
if (id === "tickets-readonly" || !post.editable || post.kind !== "comment") return new Response(null, { status: 403 });
const body = await request.json();
bodies.push({ path, method: request.method, body });
if (body.expected_body_digest !== post.body_digest) return Response.json({ message: "Post update conflict" }, { status: 409 });
post.body = body.body;
post.body_digest = createHash("sha256").update(body.body).digest("hex");
post.edited_at = "2026-01-04T12:30:00Z";
post.edited_by = "Rin";
if (post.is_initial) stored.body = post.body ?? "";
return Response.json(projectedTicket(key, id));
}
if (
request.method === "GET" &&
suffix === "/assignment-tracking/assignments"
@@ -1021,6 +1064,8 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
}, { status: 409 });
}
stored.events.push({
is_initial: false, edited_at: null, edited_by: null, body_truncated: false,
body_digest: createHash("sha256").update(body.body).digest("hex"), editable: false,
event_ref: `ticket-event:${stored.events.length}`,
kind: "assignment_answer",
body: body.body,
@@ -1074,6 +1119,7 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
});
}
const { action } = body;
const previousState = stored.state;
stored.closure_history.push({
action,
at: "2026-01-03T00:00:00Z",
@@ -1098,6 +1144,25 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
references: body.references ?? [],
};
}
stored.events.push({
is_initial: false, edited_at: null, edited_by: null, body_truncated: false,
body_digest: createHash("sha256").update(body.explanation).digest("hex"), editable: false,
sequence: stored.events.length,
kind: action.action === "close" ? "close" : "state_changed",
heading: action.action === "close" ? "Closed" : "State changed",
author: "fixture-owner",
at: "2026-01-03T00:00:00Z",
from: previousState,
to: stored.state,
status: stored.state,
state_field: "case",
reason: body.explanation,
body: body.explanation,
references: body.references ?? [],
attributes: { operation_key: operationKey },
event_ref: `event-${stored.events.length}`,
});
stored.event_count = stored.events.length;
stored.action_eligibility.can_start_manual_worker =
stored.state === "open";
result = {
@@ -1147,6 +1212,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
}
if (tm[2] === "/events") {
stored.events.push({
is_initial: false, edited_at: null, edited_by: null, body_truncated: false,
body_digest: createHash("sha256").update(body.body).digest("hex"), editable: body.role === "comment",
author: "Mika", at: "2026-01-03T00:00:00Z",
sequence: stored.events.length,
kind: body.role,
body: body.body,
@@ -1,4 +1,5 @@
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
import { join } from "@std/path";
import { chromium } from "npm:playwright@1.59.1";
import { loadScenario } from "../src/scenario.ts";
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
@@ -18,7 +19,7 @@ Deno.test("principal replacement snapshots the target and observed assignment fo
const processes = await startOwnedProcesses(
scenario.processes!,
scenarioPath,
new URL(`../../../target/web-ux/t743-principal/${port}`, import.meta.url).pathname,
join(Deno.env.get("WEB_UX_TEST_ARTIFACTS") ?? new URL("../../../target/web-ux", import.meta.url).pathname, "t743-principal", String(port)),
[],
);
let browser: Awaited<ReturnType<typeof chromium.launch>> | null = null;
+373 -16
View File
@@ -1,8 +1,13 @@
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
import { join } from "@std/path";
import { type Browser, chromium, type Page } from "npm:playwright@1.59.1";
import { loadScenario } from "../src/scenario.ts";
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
function artifactPath(path: string): string {
return join(Deno.env.get("WEB_UX_TEST_ARTIFACTS") ?? new URL("../../../target/web-ux", import.meta.url).pathname, "t743-browser", path);
}
// Explicit browser boundary: production static build + loopback API fixture.
// These tests prove UI/request semantics, not real Backend delivery or model judgment.
async function withFixture(
@@ -16,8 +21,7 @@ async function withFixture(
const baseUrl = `http://127.0.0.1:${port}`;
scenario.processes![0].args![4] = String(port);
scenario.processes![0].readyUrl = `${baseUrl}/health`;
const logs = new URL(`../../../target/web-ux/t743-browser/${port}/logs`, import.meta.url)
.pathname;
const logs = artifactPath(`${port}/logs`);
const processes = await startOwnedProcesses(
scenario.processes!,
scenarioPath,
@@ -335,6 +339,63 @@ Deno.test("assignee qualifiers send exact user and runtime-worker identities, no
});
});
async function assertThreadLayout(page: Page) {
const main = page.getByRole("main", { name: "Ticket thread", exact: true });
const rail = page.getByRole("complementary", { name: "Ticket details" });
assertEquals(await main.locator(":scope > *").count(), 1);
assertEquals(await main.locator(".ticket-thread > *").evaluateAll((elements) =>
elements.map((element) => element.className)
), ["ticket-timeline", "ticket-control-form ticket-thread-composer"]);
assertEquals(await main.locator(".ticket-timeline > :first-child").getAttribute("class"), "ticket-thread-event");
assertEquals(await main.locator(".ticket-thread-event[data-initial=true] header strong").textContent(), "Mika");
assert((await main.locator(".ticket-thread-event[data-initial=true] time").textContent())?.trim());
assertEquals(await main.getByRole("heading", { name: /^(Intent|Timeline)$/ }).count(), 0);
assertEquals(await main.locator(".ticket-timeline > *").evaluateAll((elements) =>
elements.every((element, index) => index === 0
? element.matches("article.ticket-thread-event[data-initial=true]")
: index % 2 === 1 ? element.matches(".bevel-line") : element.matches("article.ticket-thread-event"))
), true);
assert(await main.locator(".ticket-thread-composer").evaluate((element) =>
element.previousElementSibling?.matches(".ticket-timeline") &&
element.firstElementChild?.matches(".bevel-line")
));
const metadata = ".ticket-handling, .ticket-tags, .ticket-relations, .ticket-orchestrator-worker, .ticket-worker-observation, .ticket-worker-card, .ticket-resources";
assertEquals(await main.locator(metadata).count(), 0);
assertEquals(await rail.locator(metadata).count(), 7);
assertEquals(await page.locator(".ticket-delivery").count(), 0);
assertEquals(await page.getByText("Delivery and execution", { exact: true }).count(), 0);
assertEquals(await rail.locator(".ticket-thread-event[data-initial=true], .ticket-thread-event, .ticket-thread-composer").count(), 0);
for (const selector of [".ticket-orchestrator-worker", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) {
const details = rail.locator(selector);
assertEquals(await details.evaluate((element) => element.tagName), "DETAILS");
assertEquals(await details.getAttribute("open"), null);
assert(await details.locator(":scope > summary").isVisible());
}
assertEquals(await rail.locator(".ticket-worker-card > summary").textContent(), "Role assignments and manual Workers");
assert(/^Repository resources \(\d+\)$/.test((await rail.locator(".ticket-resources > summary").textContent())!));
const evidence = rail.locator("details").filter({ has: page.locator("summary", { hasText: /^MR requirement evidence$/ }) });
if (await evidence.count()) assertEquals(await evidence.getAttribute("open"), null);
}
async function assertThreadDividers(page: Page) {
const lines = page.locator(".ticket-timeline > .bevel-line, .ticket-thread-composer > .bevel-line");
assertEquals(await lines.count(), await page.locator(".ticket-thread-event").count());
for (const line of await lines.all()) {
await line.scrollIntoViewIfNeeded();
assert(await line.isVisible());
assert(await line.evaluate((element) => {
const view = element.ownerDocument.defaultView!;
const style = view.getComputedStyle(element);
const bounds = element.getBoundingClientRect();
const content = element.nextElementSibling!.getBoundingClientRect();
return element.getAttribute("aria-hidden") === "true" &&
style.borderTopStyle === "solid" && parseFloat(style.borderTopWidth) > 0 &&
style.borderTopColor !== "rgba(0, 0, 0, 0)" && style.opacity !== "0" &&
Math.abs(bounds.left - content.left) < 1 && Math.abs(bounds.right - content.right) < 1;
}));
}
}
async function boundedGate(promise: Promise<void>) {
let timer: number | undefined;
try {
@@ -399,15 +460,14 @@ Deno.test("waiting answers retain a delegation, done reissue retains results, an
.waitFor();
const before = await detail(base, "T-2");
const row = before.assignment_tracking[0];
await page.getByText("Record result or comment", { exact: true }).click();
await page.getByLabel("Body", { exact: true }).fill(
await page.getByLabel("Comment", { exact: true }).fill(
"Ordinary retained discussion, not an answer input",
);
const eventResponse = page.waitForResponse((response) =>
response.url().endsWith("/events") &&
response.request().method() === "POST"
);
await page.getByRole("button", { name: "Append event", exact: true })
await page.getByRole("button", { name: "Add comment", exact: true })
.click();
assertEquals((await eventResponse).request().postDataJSON(), {
role: "comment",
@@ -486,7 +546,7 @@ Deno.test("waiting answers retain a delegation, done reissue retains results, an
await page.goto(`${base}/w/tickets/tickets/T-4`);
await page.getByText("Self-reported handling: done", { exact: true })
.waitFor();
await page.locator(".ticket-case-card").getByLabel("Explanation", {
await page.locator(".ticket-thread-composer").getByLabel("Comment", {
exact: true,
}).fill("Reconsider the case without delegating again");
const reopenResponse = page.waitForResponse((response) =>
@@ -498,20 +558,23 @@ Deno.test("waiting answers retain a delegation, done reissue retains results, an
assertEquals(reopen.action, { action: "reopen" });
assertEquals(reopen.expected_state, "closed");
assertEquals(reopen.expected_closure_operation_key, "close-T-4");
await page.getByRole("button", { name: "Close ticket", exact: true })
await page.getByRole("button", { name: "Comment and close", exact: true })
.waitFor();
const reopened = await detail(base, "T-4");
assertEquals(reopened.state, "open");
assertEquals(reopened.assignment_tracking[0].tracking.status, "done");
assertEquals(reopened.closure_history.length, 2);
await page.locator(".ticket-case-card").getByLabel("Explanation", {
await page.locator(".ticket-thread-composer").getByLabel("Comment", {
exact: true,
}).fill("The full requested investigation is delivered");
await page.locator(".ticket-case-card select").selectOption("completed");
await page.locator(".ticket-close-options > summary").click();
await page.locator(".ticket-thread-composer").getByRole("combobox", {
name: "Closure reason", exact: true,
}).selectOption("completed");
const closeResponse = page.waitForResponse((response) =>
response.url().endsWith("/case") && response.request().method() === "POST"
);
await page.getByRole("button", { name: "Close ticket", exact: true })
await page.getByRole("button", { name: "Comment and close", exact: true })
.click();
const close = (await closeResponse).request().postDataJSON();
assertEquals(close.action, { action: "close", reason: "completed" });
@@ -544,12 +607,122 @@ Deno.test("waiting answers retain a delegation, done reissue retains results, an
.isVisible(),
);
const requests = await (await fetch(`${base}/fixture-state`)).json();
assert(!requests.some((r: { path: string }) => r.path.includes("assignment-diagnostics")));
assertEquals(await page.locator(".ticket-delivery").count(), 0);
assertEquals(await page.getByText("Delivery and execution", { exact: true }).count(), 0);
assert(
!requests.some((r: { path: string }) => /\/(queue|ready|state)$/.test(r.path)),
);
});
});
Deno.test("timeline footer comments and closes with one case request, renders the comment once and still accepts closed-case comments", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets/tickets/T-1`);
const composer = page.locator(".ticket-thread-composer");
await composer.waitFor();
assertEquals(await page.locator("aside .ticket-thread-composer").count(), 0);
assertEquals(await page.locator(".ticket-case-card").count(), 0);
assert(await composer.evaluate((element) => {
const timeline = element.parentElement?.querySelector(".ticket-timeline");
return !!timeline && !!element.closest("main") &&
!!(timeline.compareDocumentPosition(element) & 4);
}));
const comment = composer.getByLabel("Comment", { exact: true });
const add = composer.getByRole("button", { name: "Add comment", exact: true });
const close = composer.getByRole("button", { name: "Comment and close", exact: true });
assert(await add.isDisabled());
assert(await close.isDisabled());
await comment.fill(" \n ");
assert(await add.isDisabled());
assert(await close.isDisabled());
const text = "The options are recorded; close this discussion without choosing one.";
await comment.fill(text);
assert(await add.isEnabled());
assert(await close.isEnabled());
const before = await detail(base, "T-1");
const response = page.waitForResponse((response) =>
response.url().endsWith("/case") && response.request().method() === "POST"
);
await close.click();
const request = (await response).request().postDataJSON();
assertEquals(request.explanation, text);
assertEquals(request.action, { action: "close", reason: "unknown" });
assertEquals(request.expected_state, "open");
assertEquals(request.expected_content_digest, before.content_digest);
assertEquals(request.expected_closure_operation_key, before.closure_operation_key);
assert(typeof request.operation_key === "string" && request.operation_key.length > 0);
await page.locator(".ticket-detail-header").getByText("Case: closed", {
exact: true,
}).waitFor();
const rendered = page.locator(".ticket-timeline").getByText(text, { exact: true });
await rendered.waitFor();
assertEquals(await rendered.count(), 1);
const closed = await detail(base, "T-1");
assertEquals(closed.state, "closed");
assertEquals(closed.current_closure.explanation, text);
assertEquals(closed.events.length, before.events.length + 1);
assertEquals(closed.events.at(-1).kind, "close");
assertEquals(closed.events.at(-1).body, text);
assertEquals(closed.events.at(-1).reason, text);
const requests = await (await fetch(`${base}/fixture-state`)).json();
assertEquals(requests.filter((row: { path: string }) => row.path.endsWith("/case")).length, 1);
assertEquals(requests.filter((row: { path: string }) => row.path.endsWith("/events")).length, 0);
assertEquals(await comment.inputValue(), "");
assert(await add.isDisabled());
const reopen = composer.getByRole("button", { name: "Reopen ticket", exact: true });
assert(await reopen.isDisabled());
await comment.fill("A retained comment after closure does not reopen the case.");
const eventResponse = page.waitForResponse((response) =>
response.url().endsWith("/events") && response.request().method() === "POST"
);
await add.click();
assertEquals((await eventResponse).request().postDataJSON(), {
role: "comment",
body: "A retained comment after closure does not reopen the case.",
});
await page.locator(".ticket-timeline").getByText(
"A retained comment after closure does not reopen the case.",
{ exact: true },
).waitFor();
assertEquals((await detail(base, "T-1")).state, "closed");
});
});
Deno.test("ambiguous comment-and-close retries exact case JSON without a duplicate timeline event", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets/tickets/T-1`);
const composer = page.locator(".ticket-thread-composer");
await composer.waitFor();
const text = "Retain this close comment exactly once, even if the response is lost.";
const before = await detail(base, "T-1");
await composer.getByLabel("Comment", { exact: true }).fill(text);
await control(base, { failure: "unknown_saved" });
await composer.getByRole("button", { name: "Comment and close", exact: true }).click();
await page.getByRole("alert").filter({ hasText: /Outcome unknown/ }).waitFor();
assertEquals(await composer.getByRole("button", {
name: "Refresh and reconcile case", exact: true,
}).count(), 1);
await composer.getByRole("button", { name: "Retry exact case decision", exact: true }).click();
await page.locator(".ticket-detail-header").getByText("Case: closed", {
exact: true,
}).waitFor();
const rendered = page.locator(".ticket-timeline").getByText(text, { exact: true });
await rendered.waitFor();
assertEquals(await rendered.count(), 1);
const requests = await (await fetch(`${base}/fixture-state`)).json();
const attempts = requests.filter((row: { path: string }) => row.path.endsWith("/case"));
assertEquals(attempts.length, 2);
assertEquals(attempts[0].body, attempts[1].body);
assertEquals(requests.filter((row: { path: string }) => row.path.endsWith("/events")).length, 0);
const closed = await detail(base, "T-1");
assertEquals(closed.events.length, before.events.length + 1);
assertEquals(closed.closure_history.length, before.closure_history.length + 1);
});
});
Deno.test("repository-free creation and canonical tag create/attach/rename/delete do not close or delegate the case", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
@@ -762,10 +935,161 @@ Deno.test("ambiguous saved Assign retries exact JSON once, while stale and permi
});
});
Deno.test("initial and later posts use the same editor and preserve authorship while title edit remains metadata-only", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
await page.goto(`${base}/w/tickets/tickets/T-1`);
await page.locator(".ticket-detail-header").waitFor();
const composer = page.getByRole("form", { name: "Add a comment" });
await composer.getByLabel("Comment", { exact: true }).fill("A later discussion post");
await composer.getByRole("button", { name: "Add comment", exact: true }).click();
await page.locator(".ticket-timeline").getByText("A later discussion post", { exact: true }).waitFor();
for (const name of ["Initial post", "Discussion post"]) {
const article = page.getByRole("article", { name, exact: true });
const originalTime = await article.locator("time").first().getAttribute("datetime");
const originalAuthor = await article.locator("header strong").textContent();
await article.getByRole("button", { name: "Edit post", exact: true }).click();
await article.getByLabel("Post body", { exact: true }).fill(`Revised ${name}`);
await article.getByRole("button", { name: "Save post", exact: true }).click();
await article.getByText(`Revised ${name}`, { exact: true }).waitFor();
assertEquals(await article.locator("time").first().getAttribute("datetime"), originalTime);
assertEquals(await article.locator("header strong").textContent(), originalAuthor);
assert((await article.locator(".ticket-post-edited").textContent())?.includes("by Rin"));
assertEquals(await page.locator(".ticket-timeline article").count(), 2);
}
const rail = page.getByRole("complementary", { name: "Ticket details" });
await rail.getByRole("button", { name: "Edit title", exact: true }).click();
assertEquals(await rail.getByLabel("Post body", { exact: true }).count(), 0);
await rail.getByLabel("Title", { exact: true }).fill("A clearer Ticket title");
await rail.getByRole("button", { name: "Save title", exact: true }).click();
await page.getByRole("heading", { name: "A clearer Ticket title", exact: true }).waitFor();
const requests = await (await fetch(`${base}/fixture-state`)).json();
const edits = requests.filter((row: { path: string; method?: string }) => row.method === "PATCH" && row.path.includes("/posts/"));
assertEquals(edits.length, 2);
for (const edit of edits) assertEquals(Object.keys(edit.body).sort(), ["body", "expected_body_digest"]);
const metadata = requests.findLast((row: { body?: { title?: string } }) => row.body?.title === "A clearer Ticket title");
assertEquals(metadata.body, { title: "A clearer Ticket title" });
assertEquals(await page.locator(".ticket-timeline article").count(), 2);
await page.close();
});
});
Deno.test("canonical thread renders long initial post and chronological multiline events once with content-aligned dividers", async () => {
await withFixture(async (browser, base) => {
const output = artifactPath("thread");
await Deno.mkdir(output, { recursive: true });
const paragraphs = Array.from({ length: 8 }, (_, index) =>
`Initial post paragraph ${index + 1}: ` + "The requested investigation preserves context before any discussion. ".repeat(5)
);
const events = ["Initial findings", "Follow-up decision", "Final investigation result"].map((heading, index) => ({
is_initial: false, edited_at: null, edited_by: null, body_digest: "d".repeat(64), body_truncated: false, editable: index !== 1,
sequence: index + 1,
kind: index === 1 ? "decision" : "comment",
heading,
author: `Thread author ${index + 1}`,
at: `2026-10-13T0${index + 1}:00:00Z`,
body: `${heading} first paragraph.\n\n${heading} second paragraph with enough detail to wrap on narrow screens.\n\n- ${heading} evidence one\n- ${heading} evidence two`,
reason: null,
from: null,
to: null,
references: [],
attributes: {},
event_ref: `thread-fixture-${index + 1}`,
}));
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme, reducedMotion: "reduce" });
await page.route("**/api/w/tickets/tickets/T-1", async (route) => {
const response = await route.fetch();
const ticket = await response.json();
await route.fulfill({ response, json: {
...ticket,
body: "Compatibility body must not render",
initial_post: { ...ticket.initial_post, body: paragraphs.join("\n\n"), at: "2026-10-12T12:00:00Z" },
created_at: "2026-10-12T12:00:00Z",
updated_at: events.at(-1)!.at,
events,
event_count: events.length,
} });
});
await page.goto(`${base}/w/tickets/tickets/T-1`);
await page.locator(".ticket-detail-header").waitFor();
await assertThreadLayout(page);
const timeline = page.locator(".ticket-timeline");
assertEquals(await timeline.locator("article header strong").allTextContents(), ["Mika", ...events.map((event) => event.author)]);
const expectedDates = ["2026-10-12T12:00:00Z", ...events.map((event) => event.at)];
assertEquals(await timeline.locator("article header time").allTextContents(), await page.evaluate((dates) => dates.map((date) => new Date(date).toLocaleString()), expectedDates));
for (const paragraph of paragraphs) assertEquals(await timeline.getByText(paragraph, { exact: true }).count(), 1);
for (const event of events) {
const article = timeline.locator(".ticket-thread-event").filter({ has: page.locator("header strong", { hasText: event.author }) });
assertEquals(await article.count(), 1);
assertEquals(await article.getByText(`${event.heading} first paragraph.`, { exact: true }).count(), 1);
assertEquals(await article.getByText(`${event.heading} second paragraph with enough detail to wrap on narrow screens.`, { exact: true }).count(), 1);
assertEquals(await article.locator("li").count(), 2);
}
await assertThreadDividers(page);
assertEquals(await page.evaluate("document.documentElement.scrollWidth > innerWidth"), false);
await page.screenshot({ path: `${output}/${colorScheme}-${width}-full.png`, fullPage: true });
await page.locator(".ticket-thread-event").first().scrollIntoViewIfNeeded();
await page.screenshot({ path: `${output}/${colorScheme}-${width}-events.png` });
await page.locator(".ticket-thread-composer").scrollIntoViewIfNeeded();
await page.screenshot({ path: `${output}/${colorScheme}-${width}-composer.png` });
await timeline.locator("article").first().getByRole("button", { name: "Edit post", exact: true }).click();
const description = timeline.locator(".ticket-thread-event[data-initial=true]");
await description.locator(".ticket-editor").waitFor();
assertEquals(await description.getByLabel("Post body", { exact: true }).inputValue(), paragraphs.join("\n\n"));
assertEquals(await page.locator("aside .ticket-editor").count(), 0);
await page.getByRole("button", { name: "Cancel post edit", exact: true }).click();
await description.getByText(paragraphs[0], { exact: true }).waitFor();
await page.close();
}
}
});
});
Deno.test("Ticket metadata stays in the rail with flat links, wrapping labels and keyboard navigation", async () => {
await withFixture(async (browser, base) => {
const output = artifactPath("metadata");
await Deno.mkdir(output, { recursive: true });
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme });
await page.route("**/api/w/tickets/tickets/T-1*", async (route) => {
const response = await route.fetch();
const body = await response.json();
if (body.resource_key === "T-1" && body.relations) {
body.tags[0].name = "Classification-with-a-long-name-".repeat(4);
body.relations.outgoing = [{ at: "2026-01-01T00:00:00Z", author: "fixture-owner", kind: "related", target: "internal-T-2", target_resource_key: "T-2", ticket_id: body.id, note: "Related investigation with a long note. ".repeat(5) }];
}
await route.fulfill({ response, json: body });
});
await page.goto(`${base}/w/tickets/tickets/T-1`);
const rail = page.getByRole("complementary", { name: "Ticket details" });
const link = rail.getByRole("link", { name: /T-2/ });
await link.waitFor();
assertEquals(await link.getAttribute("href"), "/w/tickets/tickets/T-2");
assertEquals(await page.locator(".ticket-detail-main .ticket-tags, .ticket-detail-main .ticket-relations").count(), 0);
assertEquals(await page.evaluate("document.documentElement.scrollWidth > innerWidth"), false);
assert(await rail.locator(".ticket-tag-chip, .ticket-relations-list a").evaluateAll((elements) => elements.every((element) => {
const style = element.ownerDocument.defaultView!.getComputedStyle(element);
return style.borderTopWidth === "0px" && style.borderRadius === "0px" && style.backgroundColor === "rgba(0, 0, 0, 0)";
})));
await link.focus();
assert(await link.evaluate((element) => element === element.ownerDocument.activeElement));
assert(await link.evaluate((element) => element.ownerDocument.defaultView!.getComputedStyle(element).outlineStyle !== "none"));
await page.screenshot({ path: `${output}/${colorScheme}-${width}.png` });
await link.press("Enter");
await page.waitForURL(/\/tickets\/T-2/);
await page.getByRole("heading", { name: "Read-only investigation", exact: true }).waitFor();
await page.close();
}
}
});
});
Deno.test("case and handling matrix renders without overflow in both themes and empty/error/unknown states stay distinct", async () => {
await withFixture(async (browser, base) => {
const output = new URL("../../../target/web-ux/t743-browser/matrix", import.meta.url)
.pathname;
const output = artifactPath("matrix");
await Deno.mkdir(output, { recursive: true });
for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
@@ -787,18 +1111,18 @@ Deno.test("case and handling matrix renders without overflow in both themes and
["tickets-unknown", "orchestrator", ".orchestrator-assignment"],
["tickets-error", "tickets", "[role=alert]"],
["tickets", "tickets/T-1", ".ticket-detail-header"],
["tickets", "tickets/T-2", ".ticket-worker-observation strong"],
["tickets", "tickets/T-3", ".ticket-worker-observation strong"],
["tickets", "tickets/T-2", ".ticket-detail-header"],
["tickets", "tickets/T-3", ".ticket-detail-header"],
["tickets", "tickets/T-4", ".ticket-detail-header"],
[
"tickets-readonly",
"tickets/T-2",
".ticket-worker-observation strong",
".ticket-detail-header",
],
[
"tickets-running",
"tickets/T-2",
".ticket-worker-observation strong",
".ticket-detail-header",
],
]
) {
@@ -810,6 +1134,24 @@ Deno.test("case and handling matrix renders without overflow in both themes and
),
false,
);
if (route.startsWith("tickets/")) {
await assertThreadLayout(page);
await assertThreadDividers(page);
const rail = page.getByRole("complementary", { name: "Ticket details" });
assertEquals(await rail.locator(".ticket-tags").count(), 1);
assertEquals(await rail.getByRole("heading", { name: "Relations", exact: true }).count(), 1);
assertEquals(await page.locator(".ticket-detail-main .ticket-tags, .ticket-detail-main .ticket-relations").count(), 0);
const sections = page.locator(".ticket-detail-page .ticket-detail-section, .ticket-detail-page .ticket-control-card");
assert(await sections.evaluateAll((elements) => elements.every((element) => {
const style = element.ownerDocument.defaultView!.getComputedStyle(element);
return [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth, style.borderRadius].every((value) => value === "0px") && style.backgroundColor === "rgba(0, 0, 0, 0)";
})));
const mainBounds = await page.locator(".ticket-detail-main").boundingBox();
const railBounds = await rail.boundingBox();
assert(mainBounds && railBounds);
if (width === 1440) assert(railBounds.x >= mainBounds.x + mainBounds.width);
else assert(railBounds.y >= mainBounds.y + mainBounds.height);
}
if (workspace === "tickets-empty") {
assert(
await page.getByText(
@@ -837,6 +1179,10 @@ Deno.test("case and handling matrix renders without overflow in both themes and
4,
);
}
if (["tickets/T-2", "tickets/T-3"].includes(route)) {
await page.locator("aside .ticket-worker-observation > summary").click();
await page.locator("aside .ticket-worker-observation strong").waitFor();
}
if (route === "tickets/T-2") {
assert(
await page.getByText("Self-reported handling: waiting", {
@@ -858,6 +1204,8 @@ Deno.test("case and handling matrix renders without overflow in both themes and
);
}
if (workspace === "tickets-running") {
await page.locator("aside .ticket-orchestrator-worker > summary").click();
await page.locator("aside .ticket-orchestrator-worker strong").waitFor();
assert(
await page.locator(".ticket-orchestrator-worker").getByText(
"Running",
@@ -887,6 +1235,15 @@ Deno.test("case and handling matrix renders without overflow in both themes and
}.png`,
fullPage: true,
});
if (workspace === "tickets" && ["tickets/T-1", "tickets/T-4"].includes(route)) {
const composer = page.locator(".ticket-thread-composer");
await composer.waitFor();
assertEquals(await page.locator("aside .ticket-thread-composer").count(), 0);
await composer.scrollIntoViewIfNeeded();
await page.screenshot({
path: `${output}/${colorScheme}-${width}-${route.replaceAll("/", "-")}-footer.png`,
});
}
}
assertEquals(errors, []);
} finally {
+69 -3
View File
@@ -32,6 +32,72 @@
}
],
"routes": [
{
"id": "posts",
"label": "Discussion with initial post and edited replies",
"path": "/w/tickets-posts/tickets/T-900",
"goal": "Read and edit any discussion post without changing Ticket metadata",
"dataState": "Four realistic posts, distinct authors, original and edited times",
"ready": {
"kind": "selector",
"selector": ".ticket-timeline article:nth-of-type(4)"
},
"capturePoints": [
{
"id": "initial",
"label": "Thread and metadata",
"fullPage": true
},
{
"id": "edit-initial",
"label": "Same post editor on initial post",
"interaction": [
{
"action": "click",
"selector": ".ticket-timeline article:first-of-type button"
}
],
"fullPage": true
},
{
"id": "later-posts",
"label": "Later discussion and edited attribution",
"interaction": [
{
"action": "click",
"selector": ".ticket-timeline article:first-of-type button[type=button]"
},
{
"action": "click",
"selector": ".ticket-timeline article:last-of-type header strong"
}
],
"fullPage": true
},
{
"id": "composer",
"label": "Thread-only comment and close footer",
"interaction": [
{
"action": "click",
"selector": ".ticket-thread-composer textarea"
}
],
"fullPage": true
},
{
"id": "metadata",
"label": "Title editing separate from post bodies",
"interaction": [
{
"action": "click",
"selector": ".ticket-metadata button"
}
],
"fullPage": true
}
]
},
{
"id": "options",
"label": "Return options without conclusion",
@@ -54,7 +120,7 @@
"interaction": [
{
"action": "click",
"selector": ".ticket-case-card textarea"
"selector": ".ticket-thread-composer textarea"
}
],
"fullPage": true
@@ -83,7 +149,7 @@
"interaction": [
{
"action": "click",
"selector": ".ticket-case-card textarea"
"selector": ".ticket-thread-composer textarea"
}
],
"fullPage": true
@@ -112,7 +178,7 @@
"interaction": [
{
"action": "click",
"selector": ".ticket-case-card textarea"
"selector": ".ticket-thread-composer textarea"
}
],
"fullPage": true
+69 -3
View File
@@ -32,6 +32,72 @@
}
],
"routes": [
{
"id": "posts",
"label": "Discussion with initial post and edited replies",
"path": "/w/tickets-posts/tickets/T-900",
"goal": "Read and edit any discussion post without changing Ticket metadata",
"dataState": "Four realistic posts, distinct authors, original and edited times",
"ready": {
"kind": "selector",
"selector": ".ticket-timeline article:nth-of-type(4)"
},
"capturePoints": [
{
"id": "initial",
"label": "Thread and metadata",
"fullPage": true
},
{
"id": "edit-initial",
"label": "Same post editor on initial post",
"interaction": [
{
"action": "click",
"selector": ".ticket-timeline article:first-of-type button"
}
],
"fullPage": true
},
{
"id": "later-posts",
"label": "Later discussion and edited attribution",
"interaction": [
{
"action": "click",
"selector": ".ticket-timeline article:first-of-type button[type=button]"
},
{
"action": "click",
"selector": ".ticket-timeline article:last-of-type header strong"
}
],
"fullPage": true
},
{
"id": "composer",
"label": "Thread-only comment and close footer",
"interaction": [
{
"action": "click",
"selector": ".ticket-thread-composer textarea"
}
],
"fullPage": true
},
{
"id": "metadata",
"label": "Title editing separate from post bodies",
"interaction": [
{
"action": "click",
"selector": ".ticket-metadata button"
}
],
"fullPage": true
}
]
},
{
"id": "options",
"label": "Return options without conclusion",
@@ -54,7 +120,7 @@
"interaction": [
{
"action": "click",
"selector": ".ticket-case-card textarea"
"selector": ".ticket-thread-composer textarea"
}
],
"fullPage": true
@@ -83,7 +149,7 @@
"interaction": [
{
"action": "click",
"selector": ".ticket-case-card textarea"
"selector": ".ticket-thread-composer textarea"
}
],
"fullPage": true
@@ -112,7 +178,7 @@
"interaction": [
{
"action": "click",
"selector": ".ticket-case-card textarea"
"selector": ".ticket-thread-composer textarea"
}
],
"fullPage": true
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb)
// Input contract: 0.1.0 (sha256:1d6615147b8d7143484b9a2aaf163da6916fcabb7560c675c5b7bf4d94aa2214)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb)
// Input contract: 0.1.0 (sha256:1d6615147b8d7143484b9a2aaf163da6916fcabb7560c675c5b7bf4d94aa2214)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -137,7 +137,7 @@ export type TicketClosureJudgment = { at: string; author: string; explanation: s
export type TicketClosureReason = "completed" | "not_planned" | "duplicate" | "unknown" | { other: string; };
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignment_tracking: Array<TicketAssignmentTrackingProjection>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; closure_history: Array<TicketCaseHistoryEntry>; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: BrowserTicketState; tags: Array<WorkspaceTag>; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignment_tracking: Array<TicketAssignmentTrackingProjection>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; closure_history: Array<TicketCaseHistoryEntry>; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; created_by?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; initial_post: TicketEventDetail; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: BrowserTicketState; tags: Array<WorkspaceTag>; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; };
@@ -149,7 +149,7 @@ export type TicketDetailRelationNotice = { kind: string; message: string; relate
export type TicketDetailRelationView = { blockers: Array<TicketDetailRelationBlocker>; incoming: Array<TicketDetailDerivedRelation>; notices: Array<TicketDetailRelationNotice>; outgoing: Array<TicketDetailRelation>; };
export type TicketEventDetail = { at?: string | null; attributes: { [key: string]: string; }; author?: string | null; body?: string | null; event_ref: string; from?: string | null; heading?: string | null; kind: string; reason?: string | null; references: Array<string>; sequence: number; state_field?: string | null; status?: string | null; to?: string | null; };
export type TicketEventDetail = { at?: string | null; attributes: { [key: string]: string; }; author?: string | null; body?: string | null; body_digest: string; body_truncated: boolean; editable: boolean; edited_at?: string | null; edited_by?: string | null; event_ref: string; from?: string | null; heading?: string | null; is_initial: boolean; kind: string; reason?: string | null; references: Array<string>; sequence: number; state_field?: string | null; status?: string | null; to?: string | null; };
export type TicketEvidenceEvent = { at?: string | null; author?: string | null; event_ref: string; excerpt: string; kind: string; sequence: number; };
@@ -167,6 +167,8 @@ export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | nu
export type TicketMergeRequestSummary = { current_subject_ref?: string | null; integration_evidence_error?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; source_ref_observation: TicketSourceRefObservation; state: string; updated_at: string; };
export type TicketPostEditRequest = { body: string; expected_body_digest: string; };
export type TicketQueryItem = { assignment_tracking: Array<TicketAssignmentTrackingProjection>; blocker_count: number; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; evidence: TicketEvidenceSummary; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objective_ids: Array<string>; linked_objective_keys: Array<string>; matched_fields: Array<string>; matching_event?: TicketEvidenceEvent | null; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; readiness?: string | null; relation_count: number; resource_key: string; snippet?: string | null; state: BrowserTicketState; tags: Array<WorkspaceTag>; title: string; unresolved_blocker_count: number; unresolved_review_count: number; updated_at?: string | null; workspace_action_priority: string; };
export type TicketQueryRequest = { assigned_principal?: TicketAssignmentPrincipalSnapshot | null; attention?: Array<TicketAttention>; closure_reason?: TicketClosureReason | null; cursor?: string | null; event_kinds?: Array<string>; evidence?: Array<string>; handling_status?: TicketAssignmentTrackingStatus | null; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; linked_objective_id?: string | null; query?: string | null; related_ticket_id?: string | null; relation_kind?: string | null; review_status?: string | null; sort?: string | null; states?: Array<TicketState>; tag_ids?: Array<string>; tag_ids_any?: Array<string>; tag_ids_none?: Array<string>; updated_after?: string | null; updated_before?: string | null; };
@@ -2,7 +2,9 @@ declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
};
import { fixturePost } from "../home/dashboard.test-fixtures.ts";
import {
parseTicketEvent,
parseMergeRequestDetailResponse,
parseMergeRequestListResponse,
parseObjectiveListResponse,
@@ -41,6 +43,33 @@ const page = {
source_truncated: false,
};
Deno.test("Ticket post parser preserves author and edit metadata independently of pagination", () => {
const initial = fixturePost("Current initial post", { edited_at: "2026-10-13T10:00:00Z", edited_by: "Rin" });
const parsed = parseTicketDetail({ ...ticketDetailFixture(), initial_post: initial, events: [], event_page: { ...page, returned: 0, has_more: true } });
assertEquals(parsed.initial_post.author, initial.author);
assertEquals(parsed.initial_post.at, initial.at);
assertEquals(parsed.initial_post.edited_at, initial.edited_at);
assertEquals(parsed.initial_post.edited_by, "Rin");
assertEquals(parsed.initial_post.body_digest, initial.body_digest);
assertEquals(parsed.initial_post.is_initial, true);
assertEquals(parsed.created_by, null);
assertEquals(parsed.events, []);
});
Deno.test("Ticket post parser rejects absent safety fields and unknown wire fields", () => {
for (const field of ["is_initial", "body_digest", "body_truncated", "editable"]) {
const wire: Record<string, unknown> = { ...fixturePost("Post") };
delete wire[field];
assertThrows(() => parseTicketEvent(wire), field);
}
assertThrows(() => parseTicketEvent({ ...fixturePost("Post"), editable: "yes" }), "editable");
assertThrows(() => parseTicketEvent({ ...fixturePost("Post"), edited_by: 7 }), "edited_by");
assertThrows(() => parseTicketEvent({ ...fixturePost("Post"), surprise: true }), "unknown field surprise");
const detail: Record<string, unknown> = { ...ticketDetailFixture() };
delete detail.initial_post;
assertThrows(() => parseTicketDetail(detail), "initial_post");
});
Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () => {
const parsed = parseTicketListResponse({
workspace_id: "workspace-a",
@@ -145,6 +174,8 @@ function ticketDetailFixture() {
closure_operation_key: null,
current_closure: null,
legacy_handoff: null,
created_by: null,
initial_post: fixturePost("Body"),
body: "Body",
body_truncated: false,
current_worker: null,
@@ -495,9 +495,15 @@ export function parseTicketListResponse(value: unknown): TicketListResponse {
};
}
function parseTicketEvent(value: unknown, label: string): TicketEventDetail {
export function parseTicketEvent(value: unknown, label = "Ticket post"): TicketEventDetail {
const item = object(value, label);
exact(item, [
"is_initial",
"edited_at",
"edited_by",
"body_digest",
"body_truncated",
"editable",
"at",
"attributes",
"author",
@@ -514,6 +520,12 @@ function parseTicketEvent(value: unknown, label: string): TicketEventDetail {
"to",
], label);
return {
is_initial: boolean(item.is_initial, `${label}.is_initial`),
edited_at: optionalNullableString(item.edited_at, `${label}.edited_at`),
edited_by: optionalNullableString(item.edited_by, `${label}.edited_by`),
body_digest: string(item.body_digest, `${label}.body_digest`),
body_truncated: boolean(item.body_truncated, `${label}.body_truncated`),
editable: boolean(item.editable, `${label}.editable`),
at: optionalNullableString(item.at, `${label}.at`),
attributes: stringMap(item.attributes, `${label}.attributes`),
author: optionalNullableString(item.author, `${label}.author`),
@@ -1098,6 +1110,8 @@ export function parseTicketDetail(value: unknown): TicketDetail {
"closure_history",
"body",
"body_truncated",
"created_by",
"initial_post",
"created_at",
"current_worker",
"event_count",
@@ -1157,6 +1171,8 @@ export function parseTicketDetail(value: unknown): TicketDetail {
),
body: string(item.body, `${label}.body`),
body_truncated: boolean(item.body_truncated, `${label}.body_truncated`),
created_by: optionalNullableString(item.created_by, `${label}.created_by`),
initial_post: parseTicketEvent(item.initial_post, `${label}.initial_post`),
created_at: optionalNullableString(item.created_at, `${label}.created_at`),
current_worker: item.current_worker === undefined
? undefined
@@ -1,3 +1,15 @@
import type { TicketEventDetail } from "../../generated/ticket-api.ts";
export function fixturePost(body: string, overrides: Partial<TicketEventDetail> = {}): TicketEventDetail {
return {
sequence: 0, event_ref: "post-initial", kind: "comment", is_initial: true,
author: "Mika", at: "2026-09-28T01:00:00Z", body,
edited_at: null, edited_by: null, body_digest: "b".repeat(64),
body_truncated: false, editable: true, attributes: {}, references: [],
...overrides,
};
}
// Deterministic read-only fixtures shared by Home component and browser tests.
export const fixturePage = (returned: number, more = false) => ({
returned,
@@ -42,6 +54,8 @@ export function fixtureDetail(key = "T-101") {
closure_history: [],
content_digest: "a".repeat(64),
created_at: updated(1),
created_by: "Mika",
initial_post: fixturePost("Implementation in progress."),
readiness: null,
body: "Implementation in progress.",
body_truncated: false,
@@ -120,6 +120,7 @@
gap: var(--space-4);
max-width: 88rem;
}
.ticket-detail-header > button { flex-shrink: 0; }
.ticket-detail-header h1 {
margin: 0.45rem 0 0.25rem;
color: var(--text-strong);
@@ -173,6 +174,23 @@
background: var(--bg-raised);
padding: var(--space-4);
}
.ticket-detail-page > .ticket-detail-grid {
grid-template-columns: minmax(0, 1fr) 1px minmax(18rem, 21rem);
gap: var(--space-5);
}
.ticket-header-divider { margin-block: var(--space-4); }
.ticket-column-divider { align-self: stretch; }
.ticket-mobile-sidebar-divider { display: none; }
.ticket-detail-page :is(.ticket-detail-section, .ticket-control-card, .ticket-editor, .workspace-callout) {
border: 0;
border-radius: 0;
background: transparent;
padding: 0;
}
.ticket-detail-page :is(.ticket-detail-main, .ticket-control-rail) {
gap: var(--space-3);
overflow-wrap: anywhere;
}
.ticket-section-heading, .ticket-control-card > header {
justify-content: space-between;
gap: var(--space-3);
@@ -269,14 +287,22 @@
.ticket-relations-list a, .ticket-blocker-list a {
display: grid;
gap: 0.18rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
color: var(--text-strong);
padding: 0.6rem;
padding-block: var(--space-2);
text-decoration: none;
}
.ticket-blocker-list a {
border-color: var(--warning);
.ticket-relations-list a:hover, .ticket-blocker-list a:hover {
background: var(--interactive-hover);
}
.ticket-relations-list a:focus-visible, .ticket-blocker-list a:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.ticket-relations-list a:active, .ticket-blocker-list a:active {
background: var(--interactive-selected);
}
.ticket-blocker-list strong {
color: var(--warning);
}
.ticket-relations-list span,
.ticket-relations-list small,
@@ -284,56 +310,68 @@
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.ticket-timeline {
display: grid;
.ticket-thread-composer {
margin-top: 0;
min-width: 0;
}
.ticket-timeline article {
display: grid;
grid-template-columns: 0.65rem minmax(0, 1fr);
gap: 0.75rem;
min-height: 4rem;
}
.ticket-timeline-marker {
position: relative;
margin-top: 0.35rem;
width: 0.55rem;
height: 0.55rem;
border: 2px solid var(--accent);
border-radius: 50%;
}
.ticket-timeline-marker::after {
position: absolute;
top: 0.55rem;
bottom: -3.3rem;
left: 0.15rem;
width: 1px;
background: var(--line);
content: "";
}
.ticket-timeline article:last-child .ticket-timeline-marker::after {
display: none;
}
.ticket-timeline header {
.ticket-comment-actions {
display: flex;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
gap: var(--space-2);
}
.ticket-thread-composer details summary {
cursor: pointer;
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.ticket-thread-composer details[open] > :not(summary) {
margin-top: var(--space-2);
}
.ticket-thread { min-width: 0; }
.ticket-timeline { display: grid; }
.ticket-timeline > article { min-width: 0; padding-block: var(--space-5); }
.ticket-timeline > article:first-child { padding-top: 0; }
.ticket-timeline header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.ticket-timeline time, .ticket-event-author {
color: var(--text-muted);
font-size: var(--font-size-compact);
}
.ticket-event-author {
margin: 0.2rem 0;
.ticket-post-attribution { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); min-width: 0; overflow-wrap: anywhere; }
.ticket-post-edited { color: var(--text-muted); font-size: var(--font-size-compact); }
.ticket-metadata dl { display: grid; gap: var(--space-1); margin: 0 0 var(--space-3); }
.ticket-metadata dt { color: var(--text-muted); font-size: var(--font-size-compact); }
.ticket-metadata dd { margin: 0 0 var(--space-2); overflow-wrap: anywhere; }
.ticket-metadata .ticket-editor { margin-top: var(--space-3); }
.ticket-detail-page .ticket-control-rail summary {
cursor: pointer;
color: var(--text-strong);
font-size: var(--font-size-body);
font-weight: 600;
}
.ticket-control-rail details[open] > summary { margin-bottom: var(--space-3); }
.ticket-control-rail p { margin-block: var(--space-2); }
.ticket-control-rail .ticket-section-heading { flex-wrap: wrap; }
.ticket-control-rail .ticket-handling details { margin-block: var(--space-2); }
.ticket-detail-page .workspace-primary-button {
border-color: var(--text-strong);
background: var(--text-strong);
color: var(--bg);
}
@container ticket-detail (max-width: 48rem) {
.ticket-detail-grid {
grid-template-columns: minmax(0, 1fr);
}
.ticket-detail-page > .ticket-detail-grid { grid-template-columns: minmax(0, 1fr); }
.ticket-column-divider { display: none; }
.ticket-mobile-sidebar-divider { display: block; }
}
@media (max-width: 64rem) {
.ticket-detail-grid {
grid-template-columns: 1fr;
}
.ticket-detail-grid, .ticket-detail-page > .ticket-detail-grid { grid-template-columns: minmax(0, 1fr); }
.ticket-column-divider { display: none; }
.ticket-mobile-sidebar-divider { display: block; }
}
}
@@ -0,0 +1,106 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import type { TicketAssignmentTrackingProjection, TicketDetail } from "#lib/generated/ticket-api.ts";
import { fixtureDetail } from "#lib/workspace/home/dashboard.test-fixtures.ts";
import TicketHandling from "./TicketHandling.svelte";
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
const assignmentsPath = "/workspaces/team/tickets/internal-T-1/assignment-tracking/assignments?limit=20";
const assignmentPath = "/api/w/team/tickets/internal-T-1/assignments/orchestrator";
const answerPath = "/api/w/team/tickets/internal-T-1/assignment-answer";
function assigned(status: "waiting" | "done" = "waiting"): TicketDetail {
const row: TicketAssignmentTrackingProjection = {
assignment_id: "assignment-1", role: "orchestrator", ticket_id: "internal-T-1", workspace_id: "team",
assigned_at: "2026-10-13T09:00:00Z", assigned_by: "Mika", current: true,
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, last_event_id: "handling-1",
tracking: { status, waiting_reason: "Choose the policy", progress: "Compared options", next_action: "Confirm the choice", result: "Comparison delivered" },
};
return { ...fixtureDetail("T-1"), assignment_tracking: [row],
assignments: [{ assignment_id: row.assignment_id, role: row.role, principal: row.principal, assigned_at: row.assigned_at, assigned_by: row.assigned_by }],
action_eligibility: { ...fixtureDetail("T-1").action_eligibility, can_assign_orchestrator: true },
};
}
function installFetch(handle: (path: string, init?: RequestInit) => Response | Promise<Response> = () => { throw new Error("Unexpected request"); }) {
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = String(input);
if (path === assignmentsPath) return Response.json({ items: [], next_assignment_id: null });
return await handle(path, init);
});
vi.stubGlobal("fetch", fetcher);
return fetcher;
}
function mount(ticket = assigned(), refresh = vi.fn(async () => {})) {
return { ...render(TicketHandling, { workspaceId: "team", ticket, orchestrator: null, refresh }), refresh };
}
function expectNoDiagnostics(fetcher: ReturnType<typeof installFetch>, view: ReturnType<typeof mount>) {
expect(view.container.querySelector(".ticket-delivery")).toBeNull();
expect(view.queryByText("Delivery and execution")).toBeNull();
expect(view.queryByRole("button", { name: /delivery diagnostics/i })).toBeNull();
expect(fetcher.mock.calls.map(([input]) => String(input)).some((path) => path.includes("assignment-diagnostics"))).toBe(false);
}
test("mount retains assignment and self-reported handling, progress and result without delivery UI or diagnostics reads", async () => {
const fetcher = installFetch();
const view = mount();
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
expect(view.getByText("Self-reported handling: waiting")).toBeTruthy();
for (const text of ["Choose the policy", "Compared options", "Confirm the choice", "Comparison delivered", "Assignment details"]) expect(view.getByText(text)).toBeTruthy();
expect(view.container.querySelector(".ticket-orchestrator-worker")).not.toBeNull();
expectNoDiagnostics(fetcher, view);
});
test("assignment save refreshes assignment history without diagnostics or delivery/execution notice", async () => {
const fetcher = installFetch((path, init) => {
expect(path).toBe(assignmentPath);
expect(init?.method).toBe("PUT");
return Response.json({ ticket_id: "internal-T-1", workspace_id: "team", assignment: {
assignment_id: "assignment-2", assigned_at: "2026-10-13T10:00:00Z", assigned_by: "Mika", role: "orchestrator",
ticket_id: "internal-T-1", workspace_id: "team", principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
} });
});
const view = mount(assigned("done"));
await fireEvent.click(view.getByRole("button", { name: "Request again (new assignment)" }));
await waitFor(() => {
expect(fetcher).toHaveBeenCalledTimes(3);
expect(view.queryByText("Loading assignment history…")).toBeNull();
});
expect(view.refresh).toHaveBeenCalledTimes(1);
expect(view.getByRole("status").textContent).toBe("Assignment operation saved: assignment-2.");
expectNoDiagnostics(fetcher, view);
});
test("answer save keeps handling unchanged and refreshes history without diagnostics reads", async () => {
const fetcher = installFetch((path, init) => {
expect(path).toBe(answerPath);
expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toMatchObject({ assignment_id: "assignment-1", expected_last_event_id: "handling-1", body: "Use read-only comparison" });
return Response.json({ assignment_id: "assignment-1", input_event_id: "input-1", event_index: 1 });
});
const view = mount();
await fireEvent.input(view.getByRole("textbox", { name: "Answer / continue this handling" }), { target: { value: "Use read-only comparison" } });
await fireEvent.click(view.getByRole("button", { name: "Send answer to this assignment" }));
await waitFor(() => {
expect(fetcher).toHaveBeenCalledTimes(3);
expect(view.queryByText("Loading assignment history…")).toBeNull();
});
expect(view.refresh).toHaveBeenCalledTimes(1);
expect(view.getByRole("status").textContent).toContain("Handling status is unchanged.");
expect(view.getByText("Self-reported handling: waiting")).toBeTruthy();
expectNoDiagnostics(fetcher, view);
});
test("uncertain assignment reconciliation only refreshes the projection and assignment history", async () => {
const fetcher = installFetch(() => { throw new Error("Response lost"); });
const view = mount(assigned("done"));
await fireEvent.click(view.getByRole("button", { name: "Request again (new assignment)" }));
await waitFor(() => expect(view.getByRole("alert").textContent).toContain("Outcome unknown"));
await fireEvent.click(view.getByRole("button", { name: "Refresh and reconcile assignment" }));
await waitFor(() => {
expect(fetcher).toHaveBeenCalledTimes(3);
expect(view.queryByText("Loading assignment history…")).toBeNull();
});
expect(view.refresh).toHaveBeenCalledTimes(1);
expect(view.queryByRole("alert")).toBeNull();
expectNoDiagnostics(fetcher, view);
});
@@ -1,10 +1,11 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import type { TicketDetail, TicketAssignmentTrackingProjection, TicketAssignmentTrackingHistoryPage, TicketAssignmentsPage, TicketAssignmentDiagnosticsPage, TicketAssignmentPrincipalSnapshot } from "#lib/generated/ticket-api.ts";
import BevelLine from "#lib/workspace/ui/BevelLine.svelte";
import type { TicketDetail, TicketAssignmentTrackingProjection, TicketAssignmentTrackingHistoryPage, TicketAssignmentsPage, TicketAssignmentPrincipalSnapshot } from "#lib/generated/ticket-api.ts";
import type { WorkspaceOrchestratorStatus } from "./ticket-panel.ts";
import { workspaceApiJson, workspaceApiJsonWithBody, workspaceApiPath, formatDate } from "#lib/workspace/api/http.ts";
import { workspaceApiJson, workspaceApiJsonWithBody, formatDate } from "#lib/workspace/api/http.ts";
import { parseTicketRoleAssignmentMutationResponse, TICKET_BROWSER_API_MAX_RESPONSE_BYTES } from "#lib/workspace/api/ticket-browser.ts";
import { assignmentOperation, answerOperation, parseAnswerReceipt, parseAssignmentsPage, parseAssignmentDiagnostics, operationError, parseTracking, parseTrackingHistory, trackingPath, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
import { assignmentOperation, answerOperation, parseAnswerReceipt, parseAssignmentsPage, operationError, parseTracking, parseTrackingHistory, trackingPath, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
let { workspaceId, ticket, orchestrator, refresh }: { workspaceId: string; ticket: TicketDetail; orchestrator: WorkspaceOrchestratorStatus | null; refresh: () => Promise<void> } = $props();
const current = $derived(ticket.assignment_tracking.find((row) => row.current && row.role === "orchestrator"));
let busy = $state(false);
@@ -14,10 +15,6 @@
let pendingKind = $state<"assignment" | "answer">("assignment");
let assignments = $state<TicketAssignmentsPage | null>(null);
let assignmentPageBusy = $state(false);
let diagnostic = $state<TicketAssignmentDiagnosticsPage | null>(null);
let diagnosticError = $state<string | null>(null);
let diagnosticBusy = $state(false);
let diagnosticRead = 0;
let answer = $state("");
let principalKind = $state<"workspace_agent" | "user" | "worker">("workspace_agent");
let accountId = $state("");
@@ -48,12 +45,11 @@
} else {
const result = await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseTicketRoleAssignmentMutationResponse, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (!alive) return;
savedNotice = `Assignment operation saved: ${result.assignment?.assignment_id ?? "released"}. Delivery and execution are separate.`;
savedNotice = `Assignment operation saved: ${result.assignment?.assignment_id ?? "released"}.`;
}
pending = null;
await refresh();
await readAssignments();
if (current) await readDiagnostics(current.assignment_id);
} catch (cause) { if (alive) error = savedNotice ? `Operation saved; latest projection unavailable. ${operationError(cause)}` : operationError(cause); }
finally { busy = false; }
}
@@ -64,7 +60,7 @@
async function reconcile() {
if (busy) return;
busy = true;
try { await refresh(); if (!alive) return; pending = null; error = null; savedNotice = null; await readAssignments(); if (current) await readDiagnostics(current.assignment_id); }
try { await refresh(); if (!alive) return; pending = null; error = null; savedNotice = null; await readAssignments(); }
catch (cause) { if (alive) error = operationError(cause); }
finally { busy = false; }
}
@@ -96,36 +92,17 @@
} catch (cause) { historyError = operationError(cause); }
finally { assignmentPageBusy = false; }
}
async function readDiagnostics(id: string, more = false) {
const read = ++diagnosticRead;
diagnosticBusy = true; diagnosticError = null;
try {
const query = new URLSearchParams({ assignment_id: id, limit: "20" });
if (more && diagnostic?.assignment_id === id) {
const inputCursor = diagnostic.next_input_event_id ?? diagnostic.inputs.at(-1)?.input_event_id;
const startCursor = diagnostic.next_start_operation_id ?? diagnostic.starts.at(-1)?.operation_id;
if (inputCursor) query.set("after_input_event_id", inputCursor);
if (startCursor) query.set("after_start_operation_id", startCursor);
}
const page = await workspaceApiJson(workspaceApiPath(workspaceId, "/tickets/" + encodeURIComponent(ticket.id) + "/assignment-diagnostics?") + query, parseAssignmentDiagnostics, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (!alive || read !== diagnosticRead) return;
diagnostic = more && diagnostic?.assignment_id === id ? { ...page, inputs: [...diagnostic.inputs, ...page.inputs], starts: [...diagnostic.starts, ...page.starts] } : page;
} catch (cause) { if (alive && read === diagnosticRead) diagnosticError = operationError(cause); }
finally { if (read === diagnosticRead) diagnosticBusy = false; }
}
function submitAnswer(event: SubmitEvent) {
event.preventDefault();
if (!current || pending || !answer.trim()) return;
void run(answerOperation(workspaceId, ticket.id, current, answer, crypto.randomUUID()), "answer");
}
onMount(() => { void readAssignments(); if (current) void readDiagnostics(current.assignment_id); });
onMount(() => { void readAssignments(); });
</script>
<section class="ticket-detail-section ticket-handling">
<div class="ticket-section-heading"><h2>Orchestrator handling</h2><a href={`/w/${encodeURIComponent(workspaceId)}/orchestrator`}>All handling</a></div>
{#if current}
<p><strong>Self-reported handling: {current.tracking?.status ?? "unknown"}</strong></p>
<p>Principal: {current.principal.kind === "workspace_agent" ? current.principal.agent_key : current.principal.kind === "worker" ? `${current.principal.runtime_id}/${current.principal.worker_id}` : current.principal.account_id}</p>
<p>Assignment <code>{current.assignment_id}</code> · assigned by {current.assigned_by} · {formatDate(current.assigned_at)}</p>
{#if current.tracking}
<dl>
{#if current.tracking.waiting_reason}<dt>Waiting reason</dt><dd>{current.tracking.waiting_reason}</dd>{/if}
@@ -137,7 +114,10 @@
{#if current.updated_at}<p>Updated {formatDate(current.updated_at)}</p>{/if}
{#if current.migration_diagnostic}<p class="workspace-callout">{current.migration_diagnostic}</p>{/if}
{#if current.tracking?.status === "done"}<p>This handling was returned. Ticket {ticket.state} is unchanged.</p>{/if}
<button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(current.assignment_id)}>View handling history</button>
<details class="ticket-assignment-details"><summary>Assignment details</summary>
<p>Principal: {current.principal.kind === "workspace_agent" ? current.principal.agent_key : current.principal.kind === "worker" ? `${current.principal.runtime_id}/${current.principal.worker_id}` : current.principal.account_id}</p>
<p>Assignment <code>{current.assignment_id}</code> · assigned by {current.assigned_by} · {formatDate(current.assigned_at)}</p>
</details>
{:else}<p class="workspace-empty-copy">No current Orchestrator assignment.</p>{/if}
{#if error}<p class="workspace-callout is-error" role="alert">{error}</p>{/if}
{#if savedNotice}<p role="status">{savedNotice}</p>{/if}
@@ -175,6 +155,7 @@
</form>
{/if}
<details class="ticket-assignment-history"><summary>Past assignment and release history</summary>
{#if current}<button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(current.assignment_id)}>View handling history</button>{/if}
{#if assignmentPageBusy}<p role="status">Loading assignment history…</p>{/if}
{#each assignments?.items ?? [] as row (row.assignment_id)}<p><button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(row.assignment_id)}>{row.current ? "Current" : "Past"} · {row.tracking?.status ?? "unknown"} · {formatDate(row.assigned_at)}</button> <code>{row.assignment_id}</code>{#if row.release} · {row.release.action}{/if}{#if row.tracking?.result} · {row.tracking.result}{/if}</p>{/each}
{#if assignments?.next_assignment_id}<button class="workspace-secondary-button" disabled={assignmentPageBusy} onclick={() => void readAssignments(true)}>More assignments</button>{/if}
@@ -188,32 +169,17 @@
{#if history}{#each history.items as entry (entry.event_id)}<article><p>{entry.tracking.status} · {formatDate(entry.at)}</p>{#if entry.tracking.waiting_reason}<p>Waiting reason: {entry.tracking.waiting_reason}</p>{/if}{#if entry.tracking.progress}<p>Progress: {entry.tracking.progress}</p>{/if}{#if entry.tracking.next_action}<p>Next action: {entry.tracking.next_action}</p>{/if}{#if entry.tracking.result}<p>Result: {entry.tracking.result}</p>{/if}</article>{/each}{#if history.next_event_id}<button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(historyId, true)}>More handling history</button>{/if}{/if}
</details>
</section>
<section class="ticket-detail-section ticket-delivery">
<div class="ticket-section-heading"><h2>Delivery and execution</h2></div>
<p>Assignment persistence: {current ? "Saved: " + current.assignment_id : "No current assignment"}</p>
{#if current}<button class="workspace-secondary-button" disabled={diagnosticBusy} onclick={() => current && void readDiagnostics(current.assignment_id)}>Refresh delivery diagnostics</button>{/if}
{#if diagnosticError}<p class="workspace-callout is-error" role="alert">{diagnosticError}</p>{/if}
{#if diagnosticBusy}<p role="status">Loading delivery diagnostics…</p>{/if}
{#if diagnostic}
<p>Assignment <code>{diagnostic.assignment_id}</code> · {diagnostic.actionable ? "actionable input" : "retained history / not actionable"}</p>
{#if !diagnostic.input_origin_known}<p>Input origin unknown; not inferred from handling.</p>{/if}
{#each diagnostic.inputs as input (input.input_event_id)}
<article><p>Input <code>{input.input_event_id}</code></p><dl><dt>Notification</dt><dd>{input.notification.replaceAll("_", " ")}</dd><dt>Runtime acceptance</dt><dd>{input.notification === "runtime_accepted" ? "Accepted immutable notification" : input.acceptance_outcome_unknown ? "Outcome unknown — may already be accepted" : "No acceptance acknowledgement observed"}</dd></dl></article>
{:else}<p>No delivery input records in this page.</p>{/each}
<h3>Work start / admission evidence</h3>
{#each diagnostic.starts as start (start.operation_id)}<p><code>{start.operation_id}</code>: {start.claim_state} · {start.runtime_id ?? "runtime unknown"}/{start.worker_id ?? "worker unknown"}{#if start.actual_processing_unknown} · actual model processing not attested{/if}</p>{:else}<p>No work start records observed. This is not inferred from Worker state.</p>{/each}
{#if diagnostic.next_input_event_id || diagnostic.next_start_operation_id}<button class="workspace-secondary-button" disabled={diagnosticBusy} onclick={() => diagnostic && void readDiagnostics(diagnostic.assignment_id, true)}>More delivery / start records</button>{/if}
{:else}<p>Delivery / Runtime acceptance / work start not observed.</p>{/if}
</section>
<section class="ticket-detail-section ticket-orchestrator-worker">
<div class="ticket-section-heading"><h2>Orchestrator Worker</h2></div>
<BevelLine decorative />
<details class="ticket-detail-section ticket-orchestrator-worker">
<summary>Orchestrator Worker</summary>
{#if orchestrator?.worker}<p>{orchestrator.worker.runtime_id}/{orchestrator.worker.worker_id} · actual state: <strong>{orchestrator.worker.state}</strong> · {orchestrator.online ? "online" : "offline"}</p><a href={`/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(orchestrator.worker.worker_id)}?runtimeId=${encodeURIComponent(orchestrator.worker.runtime_id)}`}>Open Worker</a>{:else}<p>Worker observation unavailable. This does not change handling status.</p>{/if}
</section>
</details>
<style>
.assignment-principal fieldset { min-width: 0; margin: var(--space-3, 0.75rem) 0 0; padding: 0; border: 0; }
dl { display: grid; grid-template-columns: minmax(7rem, 1fr) minmax(0, 3fr); gap: 0.5rem 1rem; }
dl { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-1); }
dd + dt { margin-top: var(--space-2); }
dt { color: var(--text-muted); } dd { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
code { overflow-wrap: anywhere; } .handling-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-block: 1rem; }
article + article { margin-top: 1rem; }
@media (max-width: 540px) { dl { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,94 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import type { TicketEventDetail } from "#lib/generated/ticket-api.ts";
import TicketPost from "./TicketPost.svelte";
afterEach(cleanup);
function post(overrides: Partial<TicketEventDetail> = {}): TicketEventDetail {
return { sequence: 0, event_ref: "post-1", kind: "comment", is_initial: true,
author: "Mika", at: "2026-10-12T09:00:00Z", body: "Original request",
editable: true, body_truncated: false, body_digest: "a".repeat(64), edited_at: null, edited_by: null,
attributes: {}, references: [], ...overrides };
}
test.each([true, false])("initial=%s has the same author, timestamp and body-only edit action", async (is_initial) => {
const value = post({ is_initial });
const save = vi.fn(async () => {});
const load = vi.fn(async () => value);
const view = render(TicketPost, { post: value, busy: false, load, save });
expect(view.getByText("Mika")).toBeTruthy();
expect(view.container.querySelector("time")?.dateTime).toBe(value.at);
await fireEvent.click(view.getByRole("button", { name: "Edit post" }));
expect(view.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", value.body);
expect(view.queryByRole("textbox", { name: "Title" })).toBeNull();
await fireEvent.input(view.getByRole("textbox", { name: "Post body" }), { target: { value: "Revised request" } });
await fireEvent.click(view.getByRole("button", { name: "Save post" }));
await waitFor(() => expect(save).toHaveBeenCalledWith(value, "Revised request"));
expect(load).not.toHaveBeenCalled();
});
test("non-editable and unknown-author posts do not infer authorship or editing permission", () => {
const value = post({ editable: false, author: null, at: null });
const view = render(TicketPost, { post: value, busy: false, load: async () => value, save: async () => {} });
expect(view.getByText("Unknown author")).toBeTruthy();
expect(view.getByText("Unknown time")).toBeTruthy();
expect(view.queryByRole("button", { name: "Edit post" })).toBeNull();
});
test("edited metadata is separate from the preserved original author and time", () => {
const value = post({ edited_at: "2026-10-13T11:30:00Z", edited_by: "Rin" });
const view = render(TicketPost, { post: value, busy: false, load: async () => value, save: async () => {} });
expect(view.getByText("Mika")).toBeTruthy();
expect(view.container.querySelector(".ticket-post-edited")?.textContent).toContain("by Rin");
expect(Array.from(view.container.querySelectorAll("time")).map((time) => time.dateTime)).toEqual([value.at, value.edited_at]);
});
test.each(["close", "state_changed", "assignment_answer", "plan", "decision", "implementation_report"])("immutable %s records never offer post editing", (kind) => {
const value = post({ kind, is_initial: false });
const view = render(TicketPost, { post: value, busy: false, load: async () => value, save: async () => {} });
expect(view.queryByRole("button", { name: "Edit post" })).toBeNull();
});
test("truncated post fetches the full body and its observation before editing", async () => {
const preview = post({ body: "Short preview", body_truncated: true });
const full = post({ body: "Complete post including the unseen ending", body_digest: "b".repeat(64) });
const save = vi.fn(async () => {});
const load = vi.fn(async () => full);
const view = render(TicketPost, { post: preview, busy: false, load, save });
expect(view.queryByRole("textbox")).toBeNull();
await fireEvent.click(view.getByRole("button", { name: "Edit post" }));
await waitFor(() => expect(view.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", full.body));
await fireEvent.click(view.getByRole("button", { name: "Save post" }));
await waitFor(() => expect(save).toHaveBeenCalledWith(full, full.body));
expect(load).toHaveBeenCalledWith(preview);
});
test("a failed or still-truncated full-post read cannot open a partial editor", async () => {
const preview = post({ body: "Short preview", body_truncated: true });
const save = vi.fn(async () => {});
const view = render(TicketPost, { post: preview, busy: false, load: async () => preview, save });
await fireEvent.click(view.getByRole("button", { name: "Edit post" }));
await waitFor(() => expect(view.getByRole("alert").textContent).toContain("No partial text can be saved"));
expect(view.queryByRole("textbox")).toBeNull();
expect(save).not.toHaveBeenCalled();
});
test("conflicts retain the draft and stale digest until explicit discard and reload", async () => {
const value = post();
const current = post({ body: "Another editor's update", body_digest: "c".repeat(64) });
const save = vi.fn(async () => { throw new Error("HTTP (409)"); });
const load = vi.fn(async () => current);
const view = render(TicketPost, { post: value, busy: false, load, save });
await fireEvent.click(view.getByRole("button", { name: "Edit post" }));
await fireEvent.input(view.getByRole("textbox", { name: "Post body" }), { target: { value: "My draft" } });
await fireEvent.click(view.getByRole("button", { name: "Save post" }));
await waitFor(() => expect(view.getByRole("alert").textContent).toContain("Post update conflict"));
expect(view.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", "My draft");
expect(view.getByRole("button", { name: "Save post" })).toHaveProperty("disabled", true);
expect(save).toHaveBeenCalledWith(value, "My draft");
expect(load).not.toHaveBeenCalled();
await fireEvent.click(view.getByRole("button", { name: "Discard draft and load current post" }));
await waitFor(() => expect(view.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", current.body));
expect(view.getByRole("button", { name: "Save post" })).toHaveProperty("disabled", false);
});
@@ -0,0 +1,121 @@
<script lang="ts">
import type { TicketEventDetail } from "#lib/generated/ticket-api.ts";
import RichMarkdown from "#lib/workspace/console/RichMarkdown.svelte";
import { relationLabel } from "./ticket-panel.ts";
const { post, busy, load, save } = $props<{
post: TicketEventDetail;
busy: boolean;
load: (post: TicketEventDetail) => Promise<TicketEventDetail>;
save: (post: TicketEventDetail, body: string) => Promise<void>;
}>();
let editing = $state(false);
let loading = $state(false);
let saving = $state(false);
let observed = $state<TicketEventDetail | null>(null);
let fullPost = $state<TicketEventDetail | null>(null);
let fullPostSourceDigest = $state<string | null>(null);
let draft = $state("");
let error = $state<string | null>(null);
let reconcile = $state(false);
const displayed = $derived(fullPost && fullPostSourceDigest === post.body_digest ? fullPost : post);
const editable = $derived(post.editable && post.kind === "comment");
function prettyDate(value?: string | null): string {
if (!value) return "Unknown time";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
async function readFull(edit: boolean): Promise<void> {
if (busy || loading || saving) return;
const sourceDigest = post.body_digest;
loading = true;
error = null;
try {
const current = await load(post);
if (current.event_ref !== post.event_ref || current.body_truncated) {
throw new Error("The full post could not be loaded. No partial text can be saved.");
}
fullPost = current;
fullPostSourceDigest = sourceDigest;
if (edit) {
if (!current.editable || current.kind !== "comment") throw new Error("This event is not an editable post.");
observed = current;
draft = current.body ?? "";
editing = true;
reconcile = false;
}
} catch (cause) {
error = cause instanceof Error ? cause.message : String(cause);
} finally {
loading = false;
}
}
function beginEdit(): void {
if (post.body_truncated) { void readFull(true); return; }
observed = displayed;
draft = displayed.body ?? "";
error = null;
reconcile = false;
editing = true;
}
async function savePost(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (busy || saving || !observed || observed.body_truncated || reconcile) return;
saving = true;
error = null;
try {
await save(observed, draft);
editing = false;
fullPost = null;
observed = null;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
error = /\(409\)/.test(message)
? `Post update conflict. Your draft is preserved. Load the current post before editing again. ${message}`
: `Post save was not confirmed. Your draft is preserved; read the current post before retrying. ${message}`;
reconcile = true;
} finally {
saving = false;
}
}
</script>
<article class="ticket-thread-event" data-initial={post.is_initial} aria-label={post.is_initial ? "Initial post" : post.kind === "comment" ? "Discussion post" : relationLabel(post.kind)}>
<header>
<div class="ticket-post-attribution">
<strong>{displayed.author ?? "Unknown author"}</strong>
{#if displayed.kind !== "comment"}<span>{displayed.heading ?? relationLabel(displayed.kind)}</span>{/if}
<time datetime={displayed.at ?? undefined}>{prettyDate(displayed.at)}</time>
{#if displayed.edited_at}
<span class="ticket-post-edited">Edited <time datetime={displayed.edited_at}>{prettyDate(displayed.edited_at)}</time> by {displayed.edited_by ?? "Unknown editor"}</span>
{/if}
</div>
{#if editable && !editing}
<button class="workspace-secondary-button" type="button" disabled={busy || loading} onclick={beginEdit}>{loading ? "Loading post…" : "Edit post"}</button>
{/if}
</header>
{#if editing}
<form class="ticket-editor" aria-label="Edit post" onsubmit={savePost}>
<label>Post body<textarea bind:value={draft} rows="8" disabled={saving}></textarea></label>
<div class="ticket-comment-actions">
<button class="workspace-primary-button" type="submit" disabled={busy || saving || reconcile}>{saving ? "Saving…" : "Save post"}</button>
<button class="workspace-secondary-button" type="button" disabled={saving} onclick={() => { editing = false; error = null; }}>Cancel post edit</button>
</div>
{#if reconcile}<button class="workspace-secondary-button" type="button" disabled={busy || loading} onclick={() => void readFull(true)}>Discard draft and load current post</button>{/if}
</form>
{:else}
{#if displayed.from || displayed.to}<p>{displayed.from ?? "—"} → {displayed.to ?? "—"}</p>{/if}
{#if displayed.reason && displayed.reason !== displayed.body}<RichMarkdown text={displayed.reason} />{/if}
{#if displayed.body}<RichMarkdown text={displayed.body} />{:else if displayed.kind === "comment"}<p class="workspace-empty-copy">Empty post.</p>{/if}
{#each displayed.references as reference}<p>{reference}</p>{/each}
{#if displayed.body_truncated}
<p class="workspace-empty-copy">This post is shortened.</p>
<button class="workspace-secondary-button" type="button" disabled={busy || loading} onclick={() => void readFull(false)}>{loading ? "Loading post…" : "Read full post"}</button>
{/if}
{/if}
{#if error}<p class="workspace-callout is-error" role="alert">{error}</p>{/if}
</article>
@@ -92,8 +92,9 @@
</details>
</section>
<style>
.ticket-tag-chips { display: flex; flex-wrap: wrap; gap: var(--space-2, 0.5rem); margin-block: 0.75rem; }
.ticket-tag-chip { padding: 0.2rem 0.5rem; background: var(--bg-subtle); border-radius: var(--radius-soft); overflow-wrap: anywhere; }
.ticket-tag-chips { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-block: var(--space-3); }
.ticket-tag-chip { overflow-wrap: anywhere; }
fieldset { min-width: 0; border: 0; padding: 0; margin: var(--space-3) 0; }
fieldset label { display: flex; align-items: center; gap: 0.5rem; }
fieldset input { width: auto; }
code { overflow-wrap: anywhere; }
@@ -27,11 +27,12 @@
}
$effect(() => { void ticket.current_worker?.worker_resource_key; untrack(() => { void load(); }); });
</script>
<section class="ticket-detail-section ticket-worker-observation">
<div class="ticket-section-heading"><h2>Related Worker — actual state</h2><button class="workspace-secondary-button" disabled={loading} onclick={() => void load()}>Refresh Worker observation</button></div>
<details class="ticket-detail-section ticket-worker-observation">
<summary>Related Worker — actual state</summary>
<button class="workspace-secondary-button" disabled={loading} onclick={() => void load()}>Refresh Worker observation</button>
{#if loading}<p role="status">Loading Worker observation…</p>{/if}
{#if error}<p role="alert" class="workspace-callout is-error">Worker observation unavailable: {error}</p>{/if}
{#if worker}<p>{worker.runtime_id}/{worker.worker_id}: <strong>{worker.state}</strong> · availability {worker.availability}</p><a href={workerHref(workspaceId, { resource_key: worker.resource_key, display_name: worker.display_name ?? worker.label })}>Open Worker</a>
{:else}<p class="workspace-empty-copy">{ticket.current_worker ? "Retained Worker identity; live state not observed." : "No related Worker recorded."}</p>{/if}
<p class="workspace-empty-copy">Worker state does not determine Ticket case state or self-reported handling.</p>
</section>
</details>
@@ -1,10 +1,12 @@
<script lang="ts">
import { untrack } from "svelte";
import TicketPost from "#lib/workspace/tickets/TicketPost.svelte";
import TicketTags from "#lib/workspace/tickets/TicketTags.svelte";
import TicketWorkerObservation from "#lib/workspace/tickets/TicketWorkerObservation.svelte";
import TicketHandling from "#lib/workspace/tickets/TicketHandling.svelte";
import { observeOperation, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
import RichMarkdown from "#lib/workspace/console/RichMarkdown.svelte";
import BevelLine from "#lib/workspace/ui/BevelLine.svelte";
import {
workspaceApiJson,
workspaceApiJsonWithBody,
@@ -14,6 +16,7 @@
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
parseTicketSummary,
parseTicketDetail,
parseTicketEvent,
parseTicketRoleAssignmentMutationResponse,
} from "#lib/workspace/api/ticket-browser.ts";
import { ticketCaseRequest } from "#lib/workspace/api/ticket-case.ts";
@@ -25,6 +28,8 @@
} from "#lib/workspace/tickets/ticket-panel.ts";
import type {
TicketCaseAction,
TicketEventDetail,
TicketPostEditRequest,
TicketClosureReason,
TicketTarget,
TicketTargetAccess,
@@ -72,11 +77,9 @@
const mergeRequests = $derived(ticket.merge_requests);
let editing = $state(false);
let editTitle = $state(loadedTicket.title);
let editBody = $state(loadedTicket.body);
let targetDrafts = $state<EditableTicketTarget[]>(
editableTargets(loadedTicket.targets),
);
let transitionReason = $state("");
let closureReason = $state("unknown");
let otherClosureReason = $state("");
let threadRole = $state("comment");
@@ -85,6 +88,8 @@
let errorMessage = $state<string | null>(null);
let caseOperation = $state<ObservedOperation | null>(null);
let caseSaved = $state(false);
let caseComment = $state<string | null>(null);
let commentError = $state<string | null>(null);
let manualRuntimeId = $state("");
let manualWorkerId = $state("");
let manualBindings = $state<{ alias: string; working_directory_id: string; connection_id: string }[]>([]);
@@ -134,14 +139,14 @@
function applyTicket(updatedTicket: TicketDetail): void {
ticket = updatedTicket;
editTitle = updatedTicket.title;
editBody = updatedTicket.body;
targetDrafts = editableTargets(updatedTicket.targets);
}
function resetTicketView(updatedTicket: TicketDetail): void {
applyTicket(updatedTicket);
editing = false;
transitionReason = "";
caseComment = null;
commentError = null;
closureReason = "unknown";
otherClosureReason = "";
threadRole = "comment";
@@ -299,7 +304,6 @@
if (
await mutate("edit", "", {
title: editTitle.trim(),
body: editBody,
}, "PATCH")
) editing = false;
}
@@ -331,8 +335,11 @@
}
async function caseDecision(action: TicketCaseAction): Promise<void> {
if (busy || caseOperation || !transitionReason.trim()) return;
const request = ticketCaseRequest(ticket, action, transitionReason, crypto.randomUUID());
if (busy || caseOperation || threadRole !== "comment" || !threadBody.trim()) return;
if (action.action !== "reopen" && closureReason === "other" && !otherClosureReason.trim()) return;
// Closeの本文と案件状態は既存APIが同時に保存する。別のコメントPOSTで二重記録しない。
const request = ticketCaseRequest(ticket, action, threadBody, crypto.randomUUID());
caseComment = threadBody;
const path = `/workspaces/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(data.ticketId)}/case`;
await runCaseDecision(observeOperation(path, "POST", request));
}
@@ -343,41 +350,82 @@
const path = ticketPath;
busy = "case";
caseOperation = operation;
errorMessage = null;
commentError = null;
caseSaved = false;
try {
await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseTicketSummary, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
const saved = await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseTicketSummary, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
caseOperation = null;
caseSaved = true;
ticket = { ...ticket, state: saved.workflow_state };
if (threadBody === caseComment) threadBody = "";
caseComment = null;
const updated = await workspaceApiJson(path, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
applyTicket(updated);
transitionReason = "";
} catch (error) {
if (generation === routeGeneration) errorMessage = ticketMutationError(error);
if (generation === routeGeneration) commentError = caseSaved
? "Comment and case change saved, but the timeline could not be refreshed. Refresh the thread."
: ticketMutationError(error);
} finally {
if (generation === routeGeneration) busy = null;
}
}
async function refreshCommentThread(): Promise<void> {
const generation = routeGeneration;
await refreshTicket();
if (generation !== routeGeneration) return;
commentError = errorMessage;
errorMessage = null;
}
async function reconcileCase(): Promise<void> {
if (busy) return;
const generation = routeGeneration;
await refreshCommentThread();
if (generation !== routeGeneration) return;
if (!commentError) { caseOperation = null; caseComment = null; }
}
function closureReasonLabel(reason: TicketClosureReason): string {
return typeof reason === "string" ? relationLabel(reason) : reason.other;
}
async function appendThread(event: SubmitEvent) {
event.preventDefault();
if (!threadBody.trim()) return;
if (
await mutate("thread", "/events", {
role: threadRole,
body: threadBody.trim(),
})
) threadBody = "";
if (busy || caseOperation || !threadBody.trim()) return;
const generation = routeGeneration;
const comment = threadBody;
commentError = null;
caseSaved = false;
const saved = await mutate("thread", "/events", {
role: threadRole,
body: comment.trim(),
});
if (generation !== routeGeneration) return;
if (saved && threadBody === comment) threadBody = "";
if (!saved) { commentError = errorMessage; errorMessage = null; }
}
function eventTitle(kind: string): string {
return relationLabel(kind);
async function loadPost(post: TicketEventDetail): Promise<TicketEventDetail> {
return await workspaceApiJson(`${ticketPath}/posts/${encodeURIComponent(post.event_ref)}`, parseTicketEvent, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
}
async function savePost(post: TicketEventDetail, body: string): Promise<void> {
if (busy || post.body_truncated || !post.editable || post.kind !== "comment") throw new Error("Post cannot be saved now.");
const generation = routeGeneration;
const request: TicketPostEditRequest = { body, expected_body_digest: post.body_digest };
busy = "post";
try {
const updated = await workspaceApiJsonWithBody(`${ticketPath}/posts/${encodeURIComponent(post.event_ref)}`, {
method: "PATCH", body: JSON.stringify(request),
}, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
if (generation !== routeGeneration) return;
applyTicket(updated);
} finally {
if (generation === routeGeneration) busy = null;
}
}
function prettyDate(value?: string | null): string {
@@ -398,11 +446,7 @@
</div>
<h1>{ticket.title}</h1>
{#if ticket.state === "closed" && ticket.current_closure}<p><strong>Closure reason:</strong> {closureReasonLabel(ticket.current_closure.reason)}</p>{/if}
<p>Updated {prettyDate(ticket.updated_at)}</p>
</div>
<button class="workspace-secondary-button" type="button" onclick={() => editing = !editing}>
{editing ? "Cancel edit" : "Edit ticket"}
</button>
</header>
{#if errorMessage}
@@ -410,34 +454,104 @@
{/if}
{#if editing}
<form class="ticket-editor" onsubmit={saveEdit}>
<label>Title<input bind:value={editTitle} required /></label>
<label>Body<textarea bind:value={editBody} rows="12"></textarea></label>
<button class="workspace-primary-button" type="submit" disabled={busy !== null || !editTitle.trim()}>
{busy === "edit" ? "Saving…" : "Save changes"}
</button>
</form>
{/if}
<BevelLine decorative class="ticket-header-divider" />
<div class="ticket-detail-grid">
<main class="ticket-detail-main">
<section class="ticket-detail-section">
<div class="ticket-section-heading"><h2>Intent</h2></div>
{#if ticket.body}
<RichMarkdown text={ticket.body} />
{:else}
<p class="workspace-empty-copy">No body has been recorded.</p>
<main class="ticket-detail-main" aria-label="Ticket thread">
<section class="ticket-thread">
<div class="ticket-timeline">
{#key JSON.stringify([data.workspaceId, ticket.id])}
{#each [ticket.initial_post, ...ticket.events] as post, index (post.event_ref)}
{#if index > 0}<BevelLine decorative />{/if}
<TicketPost {post} busy={busy !== null} load={loadPost} save={savePost} />
{/each}
{/key}
</div>
<form class="ticket-control-form ticket-thread-composer" aria-label="Add a comment" onsubmit={appendThread}>
<BevelLine decorative />
<label>Comment<textarea bind:value={threadBody} rows="5" required disabled={busy !== null}></textarea></label>
{#if commentError}
<div class="workspace-callout is-error" role="alert">
<p>{commentError}</p>
{#if !caseOperation}<button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={refreshCommentThread}>Refresh thread</button>{/if}
</div>
{/if}
{#if caseSaved}<p role="status">Comment and case change saved.</p>{/if}
{#if caseOperation}
<p role="status">The comment and case change may already be saved. Retry the same request or refresh before continuing.</p>
<div class="ticket-comment-actions">
<button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={() => caseOperation && void runCaseDecision(caseOperation)}>Retry exact case decision</button>
<button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={reconcileCase}>Refresh and reconcile case</button>
</div>
{/if}
<div class="ticket-comment-actions">
<button class="workspace-primary-button" type="submit" disabled={busy !== null || caseOperation !== null || !threadBody.trim()}>{busy === "thread" ? "Saving…" : threadRole === "comment" ? "Add comment" : "Append event"}</button>
{#if threadRole === "comment"}
<button class="workspace-secondary-button" type="button" disabled={busy !== null || caseOperation !== null || !threadBody.trim() || (ticket.state === "open" && closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision(ticket.state === "open" ? { action: "close", reason: selectedClosureReason() } : { action: "reopen" })}>
{busy === "case" ? "Saving…" : ticket.state === "open" ? "Comment and close" : "Reopen ticket"}
</button>
{/if}
</div>
{#if threadRole === "comment"}
<details class="ticket-close-options">
<summary>{ticket.state === "open" ? `Close reason: ${closureReasonLabel(selectedClosureReason())}` : "Correct closure"}</summary>
<label>Closure reason<select bind:value={closureReason} disabled={busy !== null || caseOperation !== null}>
<option value="completed">Completed</option>
<option value="not_planned">Not planned</option>
<option value="duplicate">Duplicate</option>
<option value="unknown">Unknown</option>
<option value="other">Other</option>
</select></label>
{#if closureReason === "other"}<label>Other reason<input bind:value={otherClosureReason} disabled={busy !== null || caseOperation !== null} /></label>{/if}
{#if ticket.state === "closed" && ticket.current_closure}
<button class="workspace-secondary-button" type="button" disabled={busy !== null || caseOperation !== null || !threadBody.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button>
{/if}
</details>
{/if}
<details class="ticket-record-type"><summary>Record type</summary>
<label>Role<select bind:value={threadRole} disabled={busy !== null || caseOperation !== null}>
<option value="comment">Comment</option>
<option value="plan">Plan</option>
<option value="decision">Decision</option>
<option value="implementation_report">Implementation report</option>
</select></label>
</details>
</form>
</section>
</main>
<div class="ticket-column-divider"><BevelLine direction="y" length="100%" decorative /></div>
<aside class="ticket-control-rail" aria-label="Ticket details">
<div class="ticket-mobile-sidebar-divider"><BevelLine decorative /></div>
<section class="ticket-control-card ticket-metadata" aria-label="Ticket metadata">
<dl>
<dt>Created by</dt><dd>{ticket.created_by ?? "Unknown author"}</dd>
<dt>Created</dt><dd><time datetime={ticket.created_at ?? undefined}>{prettyDate(ticket.created_at)}</time></dd>
<dt>Updated</dt><dd><time datetime={ticket.updated_at ?? undefined}>{prettyDate(ticket.updated_at)}</time></dd>
</dl>
<button class="workspace-secondary-button" type="button" onclick={() => { editTitle = ticket.title; editing = !editing; }}>
{editing ? "Cancel title edit" : "Edit title"}
</button>
{#if editing}
<form class="ticket-editor" aria-label="Edit ticket title" onsubmit={saveEdit}>
<label>Title<input bind:value={editTitle} required disabled={busy !== null} /></label>
<button class="workspace-primary-button" type="submit" disabled={busy !== null || !editTitle.trim()}>
{busy === "edit" ? "Saving…" : "Save title"}
</button>
</form>
{/if}
</section>
<BevelLine decorative />
{#each ticket.action_eligibility.blockers as blocker}<p class="workspace-callout">{blocker}</p>{/each}
{#key JSON.stringify([data.workspaceId, ticket.id])}
<TicketTags workspaceId={data.workspaceId} {ticket} refresh={refreshOperationTicket} />
<TicketHandling workspaceId={data.workspaceId} {ticket} orchestrator={data.orchestrator.data} refresh={refreshOperationTicket} />
<TicketWorkerObservation workspaceId={data.workspaceId} {ticket} />
<BevelLine decorative />
<TicketTags workspaceId={data.workspaceId} {ticket} refresh={refreshOperationTicket} />
{/key}
<section class="ticket-detail-section">
<BevelLine decorative />
<section class="ticket-detail-section ticket-relations">
<div class="ticket-section-heading">
<h2>Relations</h2>
<span>{ticket.relations.outgoing.length + ticket.relations.incoming.length}</span>
@@ -488,67 +602,13 @@
</div>
</section>
<section class="ticket-detail-section">
<div class="ticket-section-heading">
<h2>Timeline</h2><span>{ticket.event_count}</span>
</div>
<div class="ticket-timeline">
{#each ticket.events as event (event.sequence)}
<article>
<div class="ticket-timeline-marker"></div>
<div>
<header>
<strong>{event.heading ?? eventTitle(event.kind)}</strong>
<time>{prettyDate(event.at)}</time>
</header>
{#if event.author}<p class="ticket-event-author">{event.author}</p>{/if}
{#if event.from || event.to}<p>{event.from ?? "—"} → {event.to ?? "—"}</p>{/if}
{#if event.reason}<RichMarkdown text={event.reason} />{/if}
{#if event.body}<RichMarkdown text={event.body} />{/if}
{#each event.references as reference}<p>{reference}</p>{/each}
</div>
</article>
{:else}
<p class="workspace-empty-copy">No timeline events.</p>
{/each}
</div>
</section>
</main>
<aside class="ticket-control-rail">
<section class="ticket-control-card ticket-case-card">
<header><h2>Case decision</h2></header>
<p class="workspace-empty-copy">Open means the case remains under consideration; closed does not necessarily mean completed. Case decisions do not start or stop Workers, grant access, approve review, merge code, or change tags. Reopening does not resume an old Worker.</p>
{#if caseSaved}<p role="status">Case decision saved. This is not handling Done or MR approval.</p>{/if}
{#if caseOperation}
<p>The case decision may already be saved. The exact observed input and operation key are retained.</p>
<button class="workspace-secondary-button" disabled={busy !== null} onclick={() => caseOperation && void runCaseDecision(caseOperation)}>Retry exact case decision</button>
<button class="workspace-secondary-button" disabled={busy !== null} onclick={async () => { await refreshTicket(); if (!errorMessage) caseOperation = null; }}>Refresh and reconcile case</button>
{/if}
<form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); void caseDecision(ticket.state === "open" ? { action: "close", reason: selectedClosureReason() } : { action: "reopen" }); }}>
<label>Explanation<textarea bind:value={transitionReason} rows="3" required></textarea></label>
<label>Closure reason
<select bind:value={closureReason}>
<option value="completed">Completed</option>
<option value="not_planned">Not planned</option>
<option value="duplicate">Duplicate</option>
<option value="unknown">Unknown</option>
<option value="other">Other</option>
</select>
</label>
{#if closureReason === "other"}<label>Other reason<input bind:value={otherClosureReason} required={ticket.state === "open"} /></label>{/if}
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || caseOperation !== null || !transitionReason.trim() || (ticket.state === "open" && closureReason === "other" && !otherClosureReason.trim())}>
{ticket.state === "open" ? "Close ticket" : "Reopen ticket"}
</button>
{#if ticket.state === "closed" && ticket.current_closure}
<button class="workspace-secondary-button" type="button" disabled={busy !== null || caseOperation !== null || !transitionReason.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button>
{/if}
</form>
{#each ticket.action_eligibility.blockers as blocker}<p class="workspace-callout">{blocker}</p>{/each}
</section>
<section class="ticket-control-card ticket-worker-card">
<header><h2>Role assignments</h2><span>Retained responsibility</span></header>
<BevelLine decorative />
{#key JSON.stringify([data.workspaceId, ticket.id])}
<TicketWorkerObservation workspaceId={data.workspaceId} {ticket} />
{/key}
<BevelLine decorative />
<details class="ticket-control-card ticket-worker-card">
<summary>Role assignments and manual Workers</summary>
<p class="workspace-empty-copy">Responsibility is retained after work ends and Worker removal; it does not indicate a running Worker or active work authority.</p>
{#if ticket.assignments.length > 0}
<ul class="ticket-assignment-list">
@@ -619,10 +679,11 @@
<p class="workspace-callout">{diagnostic}</p>
{/each}
{/if}
</section>
</details>
<section class="ticket-control-card">
<header><h2>Repository resources (optional)</h2><span>{targetDrafts.length}</span></header>
<BevelLine decorative />
<details class="ticket-control-card ticket-resources">
<summary>Repository resources ({ticket.targets.length})</summary>
<p class="workspace-empty-copy">Targets describe permitted resources, not connected attachments. Editing targets or the body does not expand live permissions. Changed resources require explicit reconnection; stale or unavailable connections are rejected by Backend.</p>
{#if data.repositories.error}<p class="workspace-callout is-error">Repository catalog unavailable: {data.repositories.error}</p>{/if}
<form class="ticket-control-form" onsubmit={saveTarget}>
@@ -661,27 +722,12 @@
{busy === "target" ? "Saving…" : "Save targets"}
</button>
</form>
</section>
<details class="ticket-control-card">
<summary>Record result or comment</summary>
<form class="ticket-control-form" onsubmit={appendThread}>
<label>Role<select bind:value={threadRole}>
<option value="comment">Comment</option>
<option value="plan">Plan</option>
<option value="decision">Decision</option>
<option value="implementation_report">Implementation report</option>
</select></label>
<label>Body<textarea bind:value={threadBody} rows="5" required></textarea></label>
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !threadBody.trim()}>Append event</button>
</form>
</details>
{#if mergeRequests.length > 0}
<section class="ticket-control-card">
<header><h2>MR requirement evidence</h2></header>
<BevelLine decorative />
<details class="ticket-control-card">
<summary>MR requirement evidence</summary>
<p><strong>Current requirements:</strong> {ticket.evidence.complete_for_integration ? "satisfied for integration" : "not satisfied for integration"}.</p>
<p><strong>Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}.</p>
{#if ticket.evidence.missing.length > 0}
@@ -693,9 +739,9 @@
{#if ticket.state === "closed"}
<p class="workspace-empty-copy">This case remains closed. Incomplete MR evidence does not alter the recorded case judgment.</p>
{/if}
</section>
</details>
{/if}
<BevelLine decorative />
<section class="ticket-control-card">
<header><h2>Merge Requests</h2></header>
<p class="workspace-empty-copy">Ticket closure is not MR approval or integration.</p>
@@ -729,6 +775,7 @@
</section>
{#if ticket.current_closure}
<BevelLine decorative />
<section class="ticket-control-card"><header><h2>Current closure</h2></header>
<p>{closureReasonLabel(ticket.current_closure.reason)} · {ticket.current_closure.author} · {prettyDate(ticket.current_closure.at)}</p>
<RichMarkdown text={ticket.current_closure.explanation} />
@@ -736,6 +783,7 @@
</section>
{/if}
{#if ticket.closure_history.length > 0}
<BevelLine decorative />
<details class="ticket-control-card"><summary>Case history ({ticket.closure_history.length})</summary>
{#each ticket.closure_history as judgment (judgment.operation_key)}
<article>
@@ -747,6 +795,7 @@
</details>
{/if}
{#if ticket.legacy_handoff}
<BevelLine decorative />
<details class="ticket-control-card"><summary>Legacy routing handoff</summary>
<p>Historical state: {ticket.legacy_handoff.workflow_state}</p>
{#if ticket.legacy_handoff.resolution}<RichMarkdown text={ticket.legacy_handoff.resolution} />{/if}
@@ -0,0 +1,290 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, waitFor, within } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import type { ComponentProps } from "svelte";
import type { TicketDetail } from "#lib/generated/ticket-api.ts";
import { fixtureDetail, fixturePost } from "#lib/workspace/home/dashboard.test-fixtures.ts";
import PageContent from "./PageContent.svelte";
// 本番の詳細画面とrequest組立てを接続し、HTTP境界だけを代替する。
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
const detailPath = "/api/w/team/tickets/T-1";
const casePath = "/workspaces/team/tickets/T-1/case";
function pageData(ticket: TicketDetail) {
return { workspaceId: "team", ticketId: "T-1", ticket: { data: ticket, error: null },
repositories: { data: { items: [] }, error: null }, orchestrator: { data: null, error: null },
} as unknown as ComponentProps<typeof PageContent>["data"];
}
function initial(): TicketDetail { return fixtureDetail("T-1"); }
function commentEvent(body: string, kind = "comment") {
return { sequence: 1, kind, heading: kind === "close" ? "Closed" : "Comment", author: "User",
at: "2026-10-13T00:00:00Z", body, reason: kind === "close" ? body : null,
from: kind === "close" ? "open" : null, to: kind === "close" ? "closed" : null, references: [],
attributes: {}, event_ref: "event-1", is_initial: false, body_truncated: false,
body_digest: "d".repeat(64), editable: kind === "comment", edited_at: null, edited_by: null,
};
}
function closed(ticket: TicketDetail, body: string): TicketDetail {
return { ...ticket, state: "closed", closure_operation_key: "saved-close",
events: [commentEvent(body, "close")], event_count: 1 };
}
function savedSummary(ticket: TicketDetail) {
return { id: ticket.id, resource_key: ticket.resource_key, slug: "fixture", kind: "task",
labels: [], tags: ticket.tags, title: ticket.title, priority: ticket.priority,
status: "Closed", workflow_state: "closed", workflow_state_explicit: true };
}
function installFetch(handle: (path: string, init?: RequestInit) => Response | Promise<Response>) {
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = String(input);
if (path.endsWith("/assignment-tracking/assignments?limit=20")) return Response.json({ items: [], next_assignment_id: null });
if (path.endsWith("/tags")) return Response.json([]);
return await handle(path, init);
});
vi.stubGlobal("fetch", fetcher);
return fetcher;
}
function mount(ticket = initial()) {
const view = render(PageContent, { data: pageData(ticket) });
const form = view.getByRole("form", { name: "Add a comment" });
return { ...view, form, composer: within(form) };
}
async function typeComment(view: ReturnType<typeof mount>, body: string) {
await fireEvent.input(view.composer.getByRole("textbox", { name: "Comment" }), { target: { value: body } });
}
test("canonical initial post and discussion share markup and editing while all metadata stays in the sidebar", async () => {
installFetch(() => { throw new Error("Unexpected request"); });
const ticket = { ...initial(), body: "Compatibility body must not render", initial_post: fixturePost("Original user request"),
events: [commentEvent("Later discussion"), { ...commentEvent("Immutable closure", "close"), event_ref: "close-2", sequence: 2 }], event_count: 2 };
const view = mount(ticket);
const main = view.getByRole("main", { name: "Ticket thread" });
const rail = view.getByRole("complementary", { name: "Ticket details" });
const timeline = main.querySelector(".ticket-timeline")!;
const first = within(main).getByRole("article", { name: "Initial post" });
const later = within(main).getByRole("article", { name: "Discussion post" });
expect(timeline.firstElementChild).toBe(first);
expect(first.className).toBe(later.className);
expect(within(first).getByText("Original user request")).toBeTruthy();
expect(view.queryByText("Compatibility body must not render")).toBeNull();
expect(within(first).getByText("Mika")).toBeTruthy();
expect(first.querySelector("time")?.dateTime).toBe(ticket.initial_post.at);
expect(within(main).getAllByRole("button", { name: "Edit post" })).toHaveLength(2);
expect(Array.from(timeline.children).map((element) => element.matches(".bevel-line") ? "divider" : element.className))
.toEqual(["ticket-thread-event", "divider", "ticket-thread-event", "divider", "ticket-thread-event"]);
expect(timeline.nextElementSibling).toBe(view.form);
expect(view.form.firstElementChild?.matches(".bevel-line")).toBe(true);
for (const selector of [".ticket-metadata", ".ticket-handling", ".ticket-tags", ".ticket-relations", ".ticket-orchestrator-worker", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) {
expect(main.querySelector(selector)).toBeNull();
expect(rail.querySelector(selector)).not.toBeNull();
}
expect(view.container.querySelector(".ticket-delivery")).toBeNull();
expect(view.queryByText("Delivery and execution")).toBeNull();
for (const selector of [".ticket-orchestrator-worker", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) {
expect(rail.querySelector(selector)?.hasAttribute("open")).toBe(false);
}
expect(within(rail).getByText("Created by")).toBeTruthy();
expect(within(rail).getByText("Mika")).toBeTruthy();
await fireEvent.click(within(first).getByRole("button", { name: "Edit post" }));
expect(within(first).getByRole("textbox", { name: "Post body" })).toHaveProperty("value", ticket.initial_post.body);
expect(within(first).queryByRole("textbox", { name: "Title" })).toBeNull();
});
test("title editing only patches metadata and leaves the initial post untouched", async () => {
const ticket = initial();
const requests: unknown[] = [];
installFetch((path, init) => {
requests.push({ path, method: init?.method, body: JSON.parse(String(init?.body)) });
return Response.json({ ...ticket, title: "A clearer title" });
});
const view = mount(ticket);
const rail = view.getByRole("complementary", { name: "Ticket details" });
await fireEvent.click(within(rail).getByRole("button", { name: "Edit title" }));
await fireEvent.input(within(rail).getByRole("textbox", { name: "Title" }), { target: { value: "A clearer title" } });
expect(within(rail).queryByRole("textbox", { name: "Post body" })).toBeNull();
await fireEvent.click(within(rail).getByRole("button", { name: "Save title" }));
await waitFor(() => expect(view.getByRole("heading", { name: "A clearer title" })).toBeTruthy());
expect(requests).toEqual([{ path: detailPath, method: "PATCH", body: { title: "A clearer title" } }]);
expect(within(view.getByRole("article", { name: "Initial post" })).getByText(ticket.initial_post.body!)).toBeTruthy();
});
test.each([true, false])("initial=%s edit uses the post identity and observed body digest, not item edit", async (isInitial) => {
const ticket = { ...initial(), events: [commentEvent("Discussion")], event_count: 1 };
const post = isInitial ? ticket.initial_post : ticket.events[0];
const requests: unknown[] = [];
installFetch((path, init) => {
requests.push({ path, method: init?.method, body: JSON.parse(String(init?.body)) });
const updated = { ...post, body: "Revised post", edited_by: "Rin", edited_at: "2026-10-13T12:00:00Z", body_digest: "e".repeat(64) };
return Response.json({ ...ticket, initial_post: isInitial ? updated : ticket.initial_post, events: isInitial ? ticket.events : [updated] });
});
const view = mount(ticket);
const article = view.getByRole("article", { name: isInitial ? "Initial post" : "Discussion post" });
await fireEvent.click(within(article).getByRole("button", { name: "Edit post" }));
await fireEvent.input(within(article).getByRole("textbox", { name: "Post body" }), { target: { value: "Revised post" } });
await fireEvent.click(within(article).getByRole("button", { name: "Save post" }));
await waitFor(() => expect(within(article).getByText("Revised post")).toBeTruthy());
expect(requests).toEqual([{ path: `${detailPath}/posts/${post.event_ref}`, method: "PATCH", body: { body: "Revised post", expected_body_digest: post.body_digest } }]);
expect(within(article).getByText(post.author!)).toBeTruthy();
expect(article.querySelector("time")?.dateTime).toBe(post.at);
expect(article.querySelector(".ticket-post-edited")?.textContent).toContain("by Rin");
expect(view.container.querySelector(".ticket-timeline")?.textContent).not.toMatch(/Created|Item edit/);
});
test("truncated initial post uses the full-read route and never saves preview text", async () => {
const ticket = initial();
ticket.initial_post = { ...ticket.initial_post, body: "Preview", body_truncated: true };
const full = { ...ticket.initial_post, body: "The full initial post with its ending", body_truncated: false, body_digest: "f".repeat(64) };
const writes: unknown[] = [];
installFetch((path, init) => {
expect(path).toBe(`${detailPath}/posts/${ticket.initial_post.event_ref}`);
if (init?.method === "PATCH") {
writes.push(JSON.parse(String(init.body)));
return Response.json({ ...ticket, initial_post: full });
}
return Response.json(full);
});
const view = mount(ticket);
const article = within(view.getByRole("article", { name: "Initial post" }));
await fireEvent.click(article.getByRole("button", { name: "Edit post" }));
await waitFor(() => expect(article.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", full.body));
await fireEvent.click(article.getByRole("button", { name: "Save post" }));
await waitFor(() => expect(writes).toEqual([{ body: full.body, expected_body_digest: full.body_digest }]));
});
test("HTTP conflict does not append an edit event, overwrite the displayed post, or lose the draft", async () => {
const ticket = initial();
let writes = 0;
installFetch((path, init) => {
expect(path).toBe(`${detailPath}/posts/${ticket.initial_post.event_ref}`);
expect(init?.method).toBe("PATCH");
writes++;
return new Response("The body has changed", { status: 409 });
});
const view = mount(ticket);
const article = within(view.getByRole("article", { name: "Initial post" }));
await fireEvent.click(article.getByRole("button", { name: "Edit post" }));
await fireEvent.input(article.getByRole("textbox", { name: "Post body" }), { target: { value: "Keep my changes" } });
await fireEvent.click(article.getByRole("button", { name: "Save post" }));
await waitFor(() => expect(article.getByRole("alert").textContent).toContain("Post update conflict"));
expect(article.getByRole("textbox", { name: "Post body" })).toHaveProperty("value", "Keep my changes");
expect(article.getByRole("button", { name: "Save post" })).toHaveProperty("disabled", true);
expect(view.container.querySelectorAll(".ticket-timeline article")).toHaveLength(1);
expect(writes).toBe(1);
});
test("comment composer follows the timeline, and Add comment does not close the Ticket", async () => {
const posts: { path: string; body: unknown }[] = [];
const ticket = initial();
installFetch((path, init) => {
posts.push({ path, body: JSON.parse(String(init?.body)) });
expect(path).toBe(`${detailPath}/events`);
return Response.json({ ...ticket, events: [commentEvent("Still investigating")], event_count: 1 });
});
const view = mount(ticket);
const timeline = view.container.querySelector(".ticket-timeline")!;
expect(timeline.nextElementSibling).toBe(view.form);
expect(view.container.querySelector("aside .ticket-thread-composer")).toBeNull();
expect(view.queryByText("Case decision", { exact: true })).toBeNull();
expect(view.composer.getByRole("button", { name: "Add comment" })).toHaveProperty("disabled", true);
expect(view.composer.getByRole("button", { name: "Comment and close" })).toHaveProperty("disabled", true);
await typeComment(view, "Still investigating");
await fireEvent.click(view.composer.getByRole("button", { name: "Add comment" }));
await waitFor(() => expect(within(timeline as HTMLElement).getByText("Still investigating")).toBeTruthy());
expect(posts).toEqual([{ path: `${detailPath}/events`, body: { role: "comment", body: "Still investigating" } }]);
expect(view.getByText("Case: open")).toBeTruthy();
expect(view.composer.getByRole("textbox", { name: "Comment" })).toHaveProperty("value", "");
});
test("Comment and close saves one explanation with the case change and renders it once in the timeline", async () => {
const ticket = initial();
const writes: { path: string; body: Record<string, unknown> }[] = [];
installFetch((path, init) => {
if (path === casePath) {
writes.push({ path, body: JSON.parse(String(init?.body)) });
return Response.json(savedSummary(ticket));
}
expect(path).toBe(detailPath);
return Response.json(closed(ticket, "Delivered the findings"));
});
const view = mount(ticket);
await typeComment(view, "Delivered the findings");
await fireEvent.change(view.composer.getByLabelText("Closure reason"), { target: { value: "completed" } });
await fireEvent.click(view.composer.getByRole("button", { name: "Comment and close" }));
await waitFor(() => expect(view.getByText("Case: closed")).toBeTruthy());
await waitFor(() => expect(view.container.querySelector(".ticket-timeline")?.textContent).toContain("Delivered the findings"));
expect(writes).toHaveLength(1);
expect(writes[0]).toEqual({ path: casePath, body: {
action: { action: "close", reason: "completed" }, explanation: "Delivered the findings",
expected_content_digest: ticket.content_digest, expected_state: "open", expected_closure_operation_key: null,
operation_key: expect.any(String), references: [],
} });
expect(within(view.container.querySelector(".ticket-timeline") as HTMLElement).getAllByText("Delivered the findings")).toHaveLength(1);
expect(view.composer.queryByRole("button", { name: "Comment and close" })).toBeNull();
expect(view.composer.getByRole("button", { name: "Reopen ticket" })).toBeTruthy();
});
test("lost Close response retains the exact request and preserves later edits on retry", async () => {
const ticket = initial();
const requests: string[] = [];
installFetch((path, init) => {
if (path === casePath) {
requests.push(String(init?.body));
if (requests.length === 1) throw new Error("Response lost");
return Response.json(savedSummary(ticket));
}
expect(path).toBe(detailPath);
return Response.json(closed(ticket, "Original close comment"));
});
const view = mount(ticket);
await typeComment(view, "Original close comment");
await fireEvent.click(view.composer.getByRole("button", { name: "Comment and close" }));
await waitFor(() => expect(view.composer.getByRole("alert").textContent).toContain("Outcome unknown"));
expect(view.composer.getByRole("button", { name: "Add comment" })).toHaveProperty("disabled", true);
await typeComment(view, "A later draft");
await fireEvent.click(view.composer.getByRole("button", { name: "Retry exact case decision" }));
await waitFor(() => expect(view.getByText("Case: closed")).toBeTruthy());
expect(requests).toHaveLength(2);
expect(requests[1]).toBe(requests[0]);
expect(view.composer.getByRole("textbox", { name: "Comment" })).toHaveProperty("value", "A later draft");
});
test("saved Close followed by a failed refresh reports success separately and retries only the read", async () => {
const ticket = initial();
let writes = 0, reads = 0;
installFetch((path) => {
if (path === casePath) { writes++; return Response.json(savedSummary(ticket)); }
expect(path).toBe(detailPath);
if (++reads === 1) throw new Error("Read failed");
return Response.json(closed(ticket, "Saved once"));
});
const view = mount(ticket);
await typeComment(view, "Saved once");
await fireEvent.click(view.composer.getByRole("button", { name: "Comment and close" }));
await waitFor(() => expect(view.composer.getByRole("alert").textContent).toContain("saved, but the timeline"));
expect(view.getByText("Case: closed")).toBeTruthy();
expect(view.composer.queryByRole("button", { name: "Retry exact case decision" })).toBeNull();
expect(view.composer.getByRole("textbox", { name: "Comment" })).toHaveProperty("value", "");
await fireEvent.click(view.composer.getByRole("button", { name: "Refresh thread" }));
await waitFor(() => expect(view.composer.queryByRole("alert")).toBeNull());
expect(writes).toBe(1);
expect(reads).toBe(2);
});
test("rejected Close keeps the draft and requires reconciliation rather than silently replacing its observation", async () => {
const ticket = initial();
let writes = 0;
installFetch((path) => {
if (path === casePath) { writes++; return new Response("stale", { status: 409 }); }
expect(path).toBe(detailPath);
return Response.json(ticket);
});
const view = mount(ticket);
await typeComment(view, "Keep this draft");
await fireEvent.click(view.composer.getByRole("button", { name: "Comment and close" }));
await waitFor(() => expect(view.composer.getByRole("alert").textContent).toContain("Update conflict"));
expect(view.getByText("Case: open")).toBeTruthy();
expect(view.composer.getByRole("textbox", { name: "Comment" })).toHaveProperty("value", "Keep this draft");
expect(view.composer.getByRole("button", { name: "Comment and close" })).toHaveProperty("disabled", true);
await fireEvent.click(view.composer.getByRole("button", { name: "Refresh and reconcile case" }));
await waitFor(() => expect(view.composer.getByRole("button", { name: "Comment and close" })).toHaveProperty("disabled", false));
expect(writes).toBe(1);
});
@@ -26,17 +26,15 @@ Deno.test("ticket detail synchronizes reused route data", () => {
});
Deno.test("ticket child operations reset across Workspaces even when the retained Ticket id matches", () => {
assertStringIncludes(
pageSource,
"{#key JSON.stringify([data.workspaceId, ticket.id])}",
);
const children = pageSource.slice(
pageSource.indexOf("{#key JSON.stringify([data.workspaceId, ticket.id])}"),
pageSource.indexOf("{/key}"),
);
assertStringIncludes(children, "<TicketTags");
assertStringIncludes(children, "<TicketHandling");
assertStringIncludes(children, "<TicketWorkerObservation");
const keyedChildren = [...pageSource.matchAll(
/\{#key JSON\.stringify\(\[data\.workspaceId, ticket\.id\]\)\}([\s\S]*?)\{\/key\}/g,
)].map((match) => match[1]);
for (const child of ["TicketTags", "TicketHandling", "TicketWorkerObservation"]) {
assert(
keyedChildren.some((block) => block.includes(`<${child}`)),
`${child} must reset for the Workspace and Ticket, whether in main or rail`,
);
}
});
Deno.test("ticket detail fences stale mutation responses", () => {
@@ -64,7 +62,7 @@ Deno.test("ticket detail fences stale mutation responses", () => {
});
Deno.test("ticket responsibility display does not claim Worker liveness or work authority", () => {
assertStringIncludes(pageSource, "Retained responsibility");
assertStringIncludes(pageSource, "Role assignments and manual Workers");
assertStringIncludes(pageSource, "after work ends and Worker removal");
assertStringIncludes(
pageSource,