feat(ticket): expose case tags and independent assignment handling
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
//! Continuing input is authored against an observed delegation, never a new Assign.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketAssignmentAnswer {
|
||||
pub assignment_id: String,
|
||||
pub expected_last_event_id: String,
|
||||
pub operation_key: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketAssignmentAnswerReceipt {
|
||||
pub assignment_id: String,
|
||||
pub input_event_id: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub event_index: u64,
|
||||
}
|
||||
|
||||
/// Host owns the IMMEDIATE transaction and authenticates/fences its source before
|
||||
/// calling, including on replay. Reuse Ticket's existing immutable operation
|
||||
/// receipts with a tagged input; these are not handling events or another ledger.
|
||||
pub fn sqlite_answer_ticket_assignment_in_transaction(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
answer: &TicketAssignmentAnswer,
|
||||
source_context: &serde_json::Value,
|
||||
actor: &str,
|
||||
) -> Result<TicketAssignmentAnswerReceipt> {
|
||||
for value in [
|
||||
&answer.assignment_id,
|
||||
&answer.expected_last_event_id,
|
||||
&answer.operation_key,
|
||||
actor,
|
||||
] {
|
||||
if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
|
||||
return Err(TicketError::Conflict(
|
||||
"Answer requires bounded nonempty identities".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if answer.body.trim().is_empty() || answer.body.len() > 16384 || answer.body.contains('\0') {
|
||||
return Err(TicketError::Conflict(
|
||||
"Answer body must be nonempty, at most 16384 bytes and without NUL".into(),
|
||||
));
|
||||
}
|
||||
let input =
|
||||
serde_json::to_string(&serde_json::json!({"kind":"assignment_answer", "answer":answer}))
|
||||
.map_err(sqlite_err)?;
|
||||
let source = serde_json::to_string(
|
||||
&serde_json::json!({"kind":"assignment_answer", "source":source_context, "actor":actor}),
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let receipt: Option<(String,String,String,String)> = conn.query_row(
|
||||
"SELECT ticket_id,input_json,source_context_json,result_json FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key=?2",
|
||||
params![workspace_id, answer.operation_key], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional().map_err(sqlite_err)?;
|
||||
if let Some((saved_ticket, saved_input, saved_source, saved_result)) = receipt {
|
||||
if saved_ticket != ticket_id || saved_input != input || saved_source != source {
|
||||
return Err(TicketError::OperationFingerprintMismatch {
|
||||
operation_key: answer.operation_key.clone(),
|
||||
});
|
||||
}
|
||||
return serde_json::from_str(&saved_result).map_err(sqlite_err);
|
||||
}
|
||||
let current =
|
||||
sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &answer.assignment_id)?
|
||||
.ok_or_else(|| {
|
||||
TicketError::Conflict("Answer assignment does not exist in this Ticket".into())
|
||||
})?;
|
||||
if !current.current
|
||||
|| current.last_event_id.as_deref() != Some(&answer.expected_last_event_id)
|
||||
|| !current.tracking.as_ref().is_some_and(|tracking| {
|
||||
matches!(
|
||||
tracking.status,
|
||||
TicketAssignmentTrackingStatus::Waiting
|
||||
| TicketAssignmentTrackingStatus::Inprogress
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(TicketError::Conflict("Answer observed assignment or handling event is stale; read current responsibility before authoring".into()));
|
||||
}
|
||||
let open: bool = conn.query_row("SELECT workflow_state='open' FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", params![workspace_id,ticket_id], |r| r.get(0)).map_err(sqlite_err)?;
|
||||
if !open {
|
||||
return Err(TicketError::Conflict(
|
||||
"A closed Ticket does not admit continuing work input".into(),
|
||||
));
|
||||
}
|
||||
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)?;
|
||||
for (key, value) in [
|
||||
("orchestrator_assignment_id", answer.assignment_id.as_str()),
|
||||
(
|
||||
"expected_last_event_id",
|
||||
answer.expected_last_event_id.as_str(),
|
||||
),
|
||||
("operation_key", answer.operation_key.as_str()),
|
||||
("authenticated_source", source.as_str()),
|
||||
] {
|
||||
conn.execute("INSERT INTO typed_ticket_event_attributes(workspace_id,ticket_id,event_index,key,value) VALUES(?1,?2,?3,?4,?5)",params![workspace_id,ticket_id,index,key,value]).map_err(sqlite_err)?;
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET updated_at=?3 WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![workspace_id, ticket_id, at],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let result = TicketAssignmentAnswerReceipt {
|
||||
assignment_id: answer.assignment_id.clone(),
|
||||
input_event_id: format!("ticket-event:{index}"),
|
||||
event_index: index as u64,
|
||||
};
|
||||
let result_json = serde_json::to_string(&result).map_err(sqlite_err)?;
|
||||
conn.execute("INSERT INTO typed_ticket_assignment_tracking_operations(workspace_id,operation_key,ticket_id,input_json,source_context_json,result_json) VALUES(?1,?2,?3,?4,?5,?6)",params![workspace_id,answer.operation_key,ticket_id,input,source,result_json]).map_err(sqlite_err)?;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -898,3 +898,53 @@ pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()>
|
||||
#[cfg(test)]
|
||||
#[path = "assignment_tracking_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketAssignmentsPage {
|
||||
pub items: Vec<TicketAssignmentTrackingProjection>,
|
||||
pub next_assignment_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Immutable append-order identities. The cursor must be an identity in this
|
||||
/// Ticket/Workspace; timestamps and lexical IDs are not insertion order.
|
||||
pub fn sqlite_ticket_assignments_page(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
after_assignment_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<TicketAssignmentsPage> {
|
||||
if !(1..=100).contains(&limit) {
|
||||
return Err(TicketError::Conflict(
|
||||
"Assignment page limit must be 1..100".into(),
|
||||
));
|
||||
}
|
||||
let after: i64 = match after_assignment_id {
|
||||
None => 0,
|
||||
Some(id) => conn.query_row("SELECT rowid FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND assignment_id=?3",params![workspace_id,ticket_id,id],|r| r.get(0)).optional().map_err(sqlite_err)?
|
||||
.ok_or_else(|| TicketError::Conflict("Assignment cursor does not belong to this Ticket".into()))?,
|
||||
};
|
||||
let mut stmt = conn.prepare("SELECT assignment_id FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND rowid>?3 ORDER BY rowid LIMIT ?4").map_err(sqlite_err)?;
|
||||
let mut ids = stmt
|
||||
.query_map(
|
||||
params![workspace_id, ticket_id, after, (limit + 1) as i64],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(sqlite_err)?;
|
||||
let has_more = ids.len() > limit;
|
||||
ids.truncate(limit);
|
||||
let next_assignment_id = if has_more { ids.last().cloned() } else { None };
|
||||
let items = ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, id)?
|
||||
.ok_or_else(|| TicketError::Conflict("Assignment disappeared while paging".into()))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(TicketAssignmentsPage {
|
||||
items,
|
||||
next_assignment_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -728,3 +728,104 @@ fn schema_verification_does_not_normalize_away_case_sensitive_authority_literals
|
||||
.contains("differs from canonical schema")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_answer_commits_one_audited_input_and_exact_receipt_without_tracking_mutation() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
let answer = TicketAssignmentAnswer {
|
||||
assignment_id: "a".into(),
|
||||
expected_last_event_id: "assigned-a".into(),
|
||||
operation_key: "answer-a".into(),
|
||||
body: "Continue with B".into(),
|
||||
};
|
||||
let source = serde_json::json!({"kind":"account","account_id":"account","user_id":"human"});
|
||||
let apply = |answer: &TicketAssignmentAnswer| {
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
let result = sqlite_answer_ticket_assignment_in_transaction(
|
||||
&conn, "w", "t", answer, &source, "human",
|
||||
);
|
||||
conn.execute_batch(if result.is_ok() { "COMMIT" } else { "ROLLBACK" })
|
||||
.unwrap();
|
||||
result
|
||||
};
|
||||
let result = apply(&answer).unwrap();
|
||||
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(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT body FROM typed_ticket_events", [], |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");
|
||||
assert_eq!(
|
||||
sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.last_event_id
|
||||
.as_deref(),
|
||||
Some("assigned-a")
|
||||
);
|
||||
let mut altered = answer.clone();
|
||||
altered.body = "Changed".into();
|
||||
assert!(apply(&altered).is_err());
|
||||
assign(&conn, "b", "assigned-b");
|
||||
assert_eq!(apply(&answer).unwrap(), result);
|
||||
altered.operation_key = "fresh-stale".into();
|
||||
assert!(apply(&altered).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_answer_rejects_unbounded_body_and_malformed_identities_without_appending_input() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
let original = TicketAssignmentAnswer {
|
||||
assignment_id: "a".into(),
|
||||
expected_last_event_id: "assigned-a".into(),
|
||||
operation_key: "answer-a".into(),
|
||||
body: "Answer".into(),
|
||||
};
|
||||
for field in [
|
||||
"body-large",
|
||||
"body-nul",
|
||||
"body-empty",
|
||||
"identity-control",
|
||||
"event-empty",
|
||||
] {
|
||||
let mut answer = original.clone();
|
||||
match field {
|
||||
"body-large" => answer.body = "a".repeat(16385),
|
||||
"body-nul" => answer.body = "answer\0hidden".into(),
|
||||
"body-empty" => answer.body = " ".into(),
|
||||
"identity-control" => answer.operation_key = "key\n".into(),
|
||||
_ => answer.expected_last_event_id = "".into(),
|
||||
}
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
assert!(
|
||||
sqlite_answer_ticket_assignment_in_transaction(
|
||||
&conn,
|
||||
"w",
|
||||
"t",
|
||||
&answer,
|
||||
&serde_json::json!({"actor":"source"}),
|
||||
"human"
|
||||
)
|
||||
.is_err(),
|
||||
"{field}"
|
||||
);
|
||||
conn.execute_batch("ROLLBACK").unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
|
||||
mod assignment_answer;
|
||||
pub use assignment_answer::*;
|
||||
mod assignment_tracking;
|
||||
pub mod config;
|
||||
mod sqlite_schema;
|
||||
|
||||
Reference in New Issue
Block a user