feat: connect Subject Ticket assignments across public execution paths
This commit is contained in:
@@ -8092,6 +8092,9 @@ pub struct TicketAssignmentSummary {
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
#[cfg_attr(feature = "typescript", ts(tag = "kind", rename_all = "snake_case"))]
|
||||
pub enum TicketAssignmentPrincipal {
|
||||
Subject {
|
||||
subject_id: String,
|
||||
},
|
||||
User {
|
||||
account_id: String,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
use server_api::{SetTicketRoleAssignmentRequest, TicketAssignmentPrincipal, TicketQueryRequest};
|
||||
|
||||
#[test]
|
||||
fn subject_assignment_and_query_use_the_same_exact_principal_identity() {
|
||||
for subject_id in ["subject-a", "subject-b"] {
|
||||
let principal = serde_json::json!({ "kind": "subject", "subject_id": subject_id });
|
||||
let assign: SetTicketRoleAssignmentRequest = serde_json::from_value(serde_json::json!({
|
||||
"operation_id": "assign-subject", "expected_assignment_id": null, "principal": principal
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(assign.principal, TicketAssignmentPrincipal::Subject { subject_id: ref id } if id == subject_id)
|
||||
);
|
||||
let query: TicketQueryRequest = serde_json::from_value(serde_json::json!({
|
||||
"assigned_principal": principal
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(query.assigned_principal).unwrap(),
|
||||
principal
|
||||
);
|
||||
assert_eq!(serde_json::to_value(assign.principal).unwrap(), principal);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_principal_keeps_legacy_audit_and_rejects_subject_identity_spoofing() {
|
||||
for principal in [
|
||||
serde_json::json!({ "kind": "user", "account_id": "alice" }),
|
||||
serde_json::json!({ "kind": "worker", "runtime_id": "runtime", "worker_id": "worker" }),
|
||||
serde_json::json!({ "kind": "workspace_agent", "agent_key": "workspace-orchestrator" }),
|
||||
] {
|
||||
let retained: TicketAssignmentPrincipal =
|
||||
serde_json::from_value(principal.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(retained).unwrap(), principal);
|
||||
}
|
||||
for malformed in [
|
||||
serde_json::json!({ "kind": "subject" }),
|
||||
serde_json::json!({ "kind": "subject", "subject_id": 1 }),
|
||||
serde_json::json!({ "kind": "subject", "subject_id": "a", "worker_id": "old-body" }),
|
||||
serde_json::json!({ "kind": "subject", "subject_id": "a", "agent_key": "workspace-orchestrator" }),
|
||||
] {
|
||||
assert!(serde_json::from_value::<TicketAssignmentPrincipal>(malformed).is_err());
|
||||
}
|
||||
}
|
||||
@@ -49,3 +49,38 @@ record (`changes` includes `body`), never a later title-only edit. Missing or em
|
||||
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.
|
||||
|
||||
## Subject responsibility schema
|
||||
|
||||
Component migration 14 owns the Subject principal CHECKs and assignment/current,
|
||||
operation, and legacy tracking/history guards. Fresh standalone stores and Workspace
|
||||
hosts use the same `migrate_sqlite_ticket_schema_in_transaction(&Connection)` startup
|
||||
composition; the host owns its surrounding transaction, authentication, live Subject
|
||||
body bindings, delivery/start fences, and notification routing. Request paths only
|
||||
verify the owning schema and never install a host-dependent fixture or fallback.
|
||||
|
||||
Retained host startup migrations must instead call
|
||||
`migrate_historical_sqlite_ticket_schema_in_transaction(&Connection, HistoricalTicketSchemaTarget)`
|
||||
with the explicit `Cases10`, `Tracking11`, `Posts12`, or `Content13` boundary. This
|
||||
bounded API shares the latest composer's migration engine; it cannot select arbitrary
|
||||
versions or downgrade a current store. If the applied version is already newer, it
|
||||
verifies that actual contract without replaying old steps or relabelling receipts.
|
||||
Original baseline receipts are retained, including v6–9 baselines; their ordered
|
||||
v7/10 composition receipts are appended instead of overwriting the baseline. Previously
|
||||
retained normalized baseline10 histories remain supported. Neither composer is a
|
||||
request-path API or a grant of legacy execution authority.
|
||||
|
||||
The additive migration leaves applied core/tracking SQL unchanged. It preflights
|
||||
complete known table generations (standalone or Workspace-parent FKs), widens only
|
||||
the three principal CHECKs with exact-SQL CAS, restores `writable_schema` on failure,
|
||||
and checks integrity before accepting the migration. This avoids rebuilding immutable
|
||||
parents and their host-owned FK graph inside a host transaction. Unsupported/mixed
|
||||
tables and weakened guards fail closed; a trigger name alone never selects a schema
|
||||
generation. Migration history selects the exact verified contract.
|
||||
|
||||
Old `workspace_agent` relationships, handling/results, audit source JSON, and receipts
|
||||
remain readable as historical data, with a nonoperational diagnostic. They are not
|
||||
converted to an arbitrary Subject or replayed as new input. Newly authorized Subject
|
||||
Assign/reissue creates a distinct identity. Tracking sources use the Rust/JSON field
|
||||
`subject_id`; historical `agent_key` audit JSON remains an unmodified JSON value,
|
||||
not an alias accepted as current Subject authority.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! Close/reopen does not change handling; current responsibility can report after close
|
||||
//! without restoring execution grants. Retained unknown handling is not inferred waiting/done.
|
||||
//!
|
||||
//! The host authenticates the current workspace-agent Worker and its actual Runtime
|
||||
//! The host authenticates the current Subject body Worker and its actual Runtime
|
||||
//! binding, including retries, inside the same write transaction as receipt comparison,
|
||||
//! current-assignment/event CAS, audit, and result. Never split that transaction around
|
||||
//! awaits. A replayed receipt is not a fresh event observation: reread before a new update,
|
||||
@@ -48,7 +48,7 @@ pub struct TicketAssignmentTrackingUpdate {
|
||||
pub tracking: TicketAssignmentTracking,
|
||||
}
|
||||
|
||||
/// Backend-owned authenticated context, never decoded from model input. Agent
|
||||
/// Backend-owned authenticated context, never decoded from model input. Subject
|
||||
/// principal remains stable across Worker exchange; exact replay binds to the
|
||||
/// authenticated concrete sender and its binding, not merely a claimed role.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
@@ -56,7 +56,7 @@ pub struct TicketAssignmentTrackingSourceContext {
|
||||
pub actor: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub agent_key: String,
|
||||
pub subject_id: String,
|
||||
pub authentication_binding_id: String,
|
||||
}
|
||||
|
||||
@@ -87,6 +87,9 @@ pub struct TicketAssignmentTrackingProjection {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum TicketAssignmentPrincipalSnapshot {
|
||||
Subject {
|
||||
subject_id: String,
|
||||
},
|
||||
User {
|
||||
account_id: String,
|
||||
},
|
||||
@@ -156,11 +159,11 @@ fn validate_update(
|
||||
]
|
||||
.into_iter()
|
||||
.any(|value| !valid_identity(value))
|
||||
|| source.agent_key != "workspace-orchestrator"
|
||||
|| !valid_identity(&source.subject_id)
|
||||
|| source.subject_id == "workspace-orchestrator"
|
||||
{
|
||||
return Err(TicketError::Conflict(
|
||||
"tracking requires bounded identities and authenticated workspace-orchestrator source"
|
||||
.into(),
|
||||
"tracking requires bounded identities and authenticated Subject source".into(),
|
||||
));
|
||||
}
|
||||
for text in [
|
||||
@@ -216,7 +219,8 @@ pub fn sqlite_ticket_assignment_tracking(
|
||||
.transpose()?;
|
||||
// Legacy migration may retain current responsibility without evidence of
|
||||
// handling status. Its real Assign audit is still an immutable observation
|
||||
// against which the first explicit self-report can CAS, even after close.
|
||||
// for a later explicitly authorized operation, even after close. This does
|
||||
// not authorize a legacy agent relationship to report as a Subject.
|
||||
// Never manufacture a tracking event/status or repair a missing audit. Once
|
||||
// tracking exists, only its event identity is a valid observation.
|
||||
let last_event_id = if current && tracking.is_none() && last_event_id.is_none() {
|
||||
@@ -238,6 +242,9 @@ pub fn sqlite_ticket_assignment_tracking(
|
||||
let missing =
|
||||
|| TicketError::Conflict("stored assignment principal identity is incomplete".into());
|
||||
let principal = match kind.as_str() {
|
||||
"subject" => TicketAssignmentPrincipalSnapshot::Subject {
|
||||
subject_id: principal_id.ok_or_else(missing)?,
|
||||
},
|
||||
"workspace_agent" => TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
|
||||
agent_key: principal_id.ok_or_else(missing)?,
|
||||
},
|
||||
@@ -441,10 +448,10 @@ pub fn sqlite_update_ticket_assignment_tracking_in_transaction(
|
||||
"tracking assignment is removed/replaced or last event changed".into(),
|
||||
));
|
||||
}
|
||||
let principal_matches:bool=conn.query_row("SELECT principal_kind='workspace_agent' AND principal_id=?3 FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,update.assignment_id,source.agent_key],|r|r.get(0)).map_err(sqlite_err)?;
|
||||
let principal_matches:bool=conn.query_row("SELECT principal_kind='subject' AND principal_id=?3 FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,update.assignment_id,source.subject_id],|r|r.get(0)).map_err(sqlite_err)?;
|
||||
if !principal_matches {
|
||||
return Err(TicketError::Conflict(
|
||||
"authenticated agent does not own this assignment".into(),
|
||||
"authenticated Subject does not own this assignment".into(),
|
||||
));
|
||||
}
|
||||
let event_id=format!("tracking-{}",allocate_record_id(unix_epoch_millis_now().unwrap_or(0), |candidate| conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_assignment_tracking_history WHERE workspace_id=?1 AND event_id=?2)",params![workspace_id,format!("tracking-{candidate}")],|r|r.get::<_,bool>(0)).unwrap_or(true)).map_err(sqlite_err)?);
|
||||
@@ -572,7 +579,10 @@ fn reconcile_legacy_tracking(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()> {
|
||||
pub(crate) fn verify_assignment_tracking_schema(
|
||||
conn: &Connection,
|
||||
subject_contract: bool,
|
||||
) -> Result<()> {
|
||||
// Object names alone would accept same-named no-op triggers after drift.
|
||||
let canonical = Connection::open_in_memory().map_err(sqlite_err)?;
|
||||
canonical
|
||||
@@ -603,6 +613,13 @@ pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()>
|
||||
}
|
||||
result.replace("ifnotexists", "")
|
||||
};
|
||||
// Generation is selected by the owning migration ledger, never by a trigger name.
|
||||
if subject_contract {
|
||||
canonical
|
||||
.execute_batch(crate::subject_assignment::SUBJECT_GUARDS)
|
||||
.map_err(sqlite_err)?;
|
||||
crate::subject_assignment::verify(conn)?;
|
||||
}
|
||||
for name in [
|
||||
"typed_ticket_assignment_tracking_history",
|
||||
"typed_ticket_assignment_tracking_operations",
|
||||
@@ -811,6 +828,14 @@ pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()>
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let expected_sql = if subject_contract {
|
||||
expected_sql.replace(
|
||||
"CHECK(principal_kind IN ('user', 'worker', 'workspace_agent'))",
|
||||
"CHECK(principal_kind IN ('user', 'worker', 'workspace_agent', 'subject'))",
|
||||
)
|
||||
} else {
|
||||
expected_sql
|
||||
};
|
||||
let expected_sql = normalize(expected_sql);
|
||||
let actual_sql = normalize(actual_sql);
|
||||
let mut remainder = expected_sql.as_str();
|
||||
|
||||
@@ -25,11 +25,20 @@ fn seed_ticket(conn: &Connection, workspace: &str, ticket: &str) {
|
||||
}
|
||||
|
||||
fn assign(conn: &Connection, id: &str, event: &str) {
|
||||
assign_principal(conn, id, event, "subject", "subject-a");
|
||||
}
|
||||
|
||||
// Historical schema fixture only: never infer an authority fallback from DB shape.
|
||||
fn assign_legacy(conn: &Connection, id: &str, event: &str) {
|
||||
assign_principal(conn, id, event, "workspace_agent", "workspace-orchestrator");
|
||||
}
|
||||
|
||||
fn assign_principal(conn: &Connection, id: &str, event: &str, kind: &str, principal: &str) {
|
||||
let previous: Option<String> = conn.query_row("SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id='w' AND ticket_id='t' AND role='orchestrator'",[],|r|r.get(0)).optional().unwrap();
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t',?1,'orchestrator','workspace_agent','workspace-orchestrator','author','2026-10-10T00:00:00Z')",[id]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t',?1,'orchestrator',?2,?3,'author','2026-10-10T00:00:00Z')",params![id,kind,principal]).unwrap();
|
||||
conn.execute("DELETE FROM ticket_current_worker_assignments WHERE workspace_id='w' AND ticket_id='t' AND role='orchestrator'",[]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES ('w','t',?1,'orchestrator','workspace_agent','workspace-orchestrator','now')",[id]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES ('w','t',?1,'orchestrator',?2,?3,'now')",params![id,kind,principal]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,previous_assignment_id,actor,created_at,operation_id) VALUES ('w','t','orchestrator',?2,?4,?1,?3,'author','now',?2)",params![id,event,previous,if previous.is_some(){"reassigned"}else{"assigned"}]).unwrap();
|
||||
conn.execute_batch("COMMIT").unwrap();
|
||||
}
|
||||
@@ -39,7 +48,7 @@ fn source() -> TicketAssignmentTrackingSourceContext {
|
||||
actor: "orchestrator".into(),
|
||||
runtime_id: "runtime".into(),
|
||||
worker_id: "worker".into(),
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
subject_id: "subject-a".into(),
|
||||
authentication_binding_id: "binding".into(),
|
||||
}
|
||||
}
|
||||
@@ -92,7 +101,7 @@ fn assignment_initializes_waiting_and_audit_atomically() {
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].event_id, "assigned-a");
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t','rolled-back','orchestrator','workspace_agent','workspace-orchestrator','author','now')",[]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t','rolled-back','orchestrator','subject','subject-a','author','now')",[]).unwrap();
|
||||
conn.execute_batch("ROLLBACK").unwrap();
|
||||
assert!(
|
||||
sqlite_ticket_assignment_tracking(&conn, "w", "t", "rolled-back")
|
||||
@@ -222,13 +231,13 @@ fn tracking_cas_and_receipts_bind_full_payload_and_authenticated_source() {
|
||||
1 => sender.runtime_id = "other".into(),
|
||||
2 => sender.worker_id = "replacement".into(),
|
||||
3 => sender.authentication_binding_id = "replacement-binding".into(),
|
||||
_ => sender.agent_key = "other-agent".into(),
|
||||
_ => sender.subject_id = "subject-b".into(),
|
||||
};
|
||||
assert!(apply(&conn, &input, &sender).is_err());
|
||||
}
|
||||
let mut other = source();
|
||||
other.worker_id = "replacement".into();
|
||||
// Backend authenticated the replacement as the current stable agent. A new
|
||||
// Backend authenticated the replacement as the current Subject body. A new
|
||||
// operation can proceed on the same assignment without starting a new row.
|
||||
let next = update(
|
||||
"a",
|
||||
@@ -323,7 +332,7 @@ fn legacy(original: &str, fenced: bool, later_case: bool, reassigned: bool) -> C
|
||||
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-time');").unwrap();
|
||||
install_assignment_core(&conn).unwrap();
|
||||
seed_ticket(&conn, "w", "t");
|
||||
assign(&conn, "a", "assigned-a");
|
||||
assign_legacy(&conn, "a", "assigned-a");
|
||||
let snapshot = TicketLegacyWorkflowSnapshot {
|
||||
workflow_state: original.into(),
|
||||
workflow_state_explicit: true,
|
||||
@@ -347,7 +356,7 @@ fn legacy(original: &str, fenced: bool, later_case: bool, reassigned: bool) -> C
|
||||
conn.execute("INSERT INTO typed_ticket_case_history VALUES('w','t',0,'closed-later','case-fingerprint','{}')",[]).unwrap();
|
||||
}
|
||||
if reassigned {
|
||||
assign(&conn, "b", "assigned-b");
|
||||
assign_legacy(&conn, "b", "assigned-b");
|
||||
}
|
||||
conn
|
||||
}
|
||||
@@ -408,7 +417,7 @@ fn migration_preserves_original_records_and_only_reconciles_current_fenced_deleg
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
13
|
||||
14
|
||||
);
|
||||
let value = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
@@ -459,8 +468,8 @@ fn migration_preserves_original_records_and_only_reconciles_current_fenced_deleg
|
||||
fn tracking_schema_rejects_drift_and_duplicate_current_singleton_roles() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','author','now')",[]).unwrap();
|
||||
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','now')",[]).is_err());
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','b','orchestrator','subject','subject-a','author','now')",[]).unwrap();
|
||||
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','subject','subject-a','now')",[]).is_err());
|
||||
conn.execute_batch("DROP TRIGGER typed_ticket_assignment_tracking_history_no_update; CREATE TRIGGER typed_ticket_assignment_tracking_history_no_update BEFORE UPDATE ON typed_ticket_assignment_tracking_history BEGIN SELECT 1; END;").unwrap();
|
||||
assert!(
|
||||
verify_sqlite_ticket_schema(&conn)
|
||||
@@ -493,7 +502,7 @@ fn standalone_assignment_identity_audit_and_terminal_receipts_are_immutable_but_
|
||||
{
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint) VALUES('w','assigned-op','assign','t','orchestrator','workspace_agent','workspace-orchestrator','a','original-time','original-fingerprint')",[]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint) VALUES('w','assigned-op','assign','t','orchestrator','subject','subject-a','a','original-time','original-fingerprint')",[]).unwrap();
|
||||
for sql in [
|
||||
"UPDATE ticket_worker_assignments SET assignment_id='changed' WHERE assignment_id='a'",
|
||||
"UPDATE ticket_worker_assignments SET assigned_by='forged' WHERE assignment_id='a'",
|
||||
@@ -518,7 +527,7 @@ fn standalone_assignment_identity_audit_and_terminal_receipts_are_immutable_but_
|
||||
// current row, even in a standalone Ticket store without host constraints.
|
||||
conn.execute("DELETE FROM ticket_current_worker_assignments", [])
|
||||
.unwrap();
|
||||
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','a','orchestrator','workspace_agent','different-agent','now')",[]).is_err());
|
||||
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','a','orchestrator','subject','subject-b','now')",[]).is_err());
|
||||
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,runtime_id,worker_id,created_at,request_fingerprint,claim_state) VALUES('w','pending','assign','t','worker','worker','runtime','pending-worker','claim-time','claim-fingerprint','pending')",[]).unwrap();
|
||||
conn.execute("UPDATE ticket_assignment_operations SET worker_id='resolved-worker',binding_recovery_json='{}' WHERE operation_id='pending'",[]).unwrap();
|
||||
conn.execute("UPDATE ticket_assignment_operations SET claim_state='failed',failure_reason='Admission failed' WHERE operation_id='pending'",[]).unwrap();
|
||||
@@ -573,7 +582,7 @@ fn release_projection_preserves_actor_time_reason_and_optional_legacy_tracking()
|
||||
TicketAssignmentTrackingStatus::Waiting
|
||||
);
|
||||
assert!(conn.execute("UPDATE ticket_worker_assignments SET tracking_json='{\"status\":\"done\"}' WHERE assignment_id='b'",[]).is_err());
|
||||
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','now')",[]).is_err());
|
||||
assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','subject','subject-a','now')",[]).is_err());
|
||||
let legacy = legacy("done", true, false, false);
|
||||
migrate_sqlite_ticket_schema(&legacy).unwrap();
|
||||
legacy
|
||||
@@ -694,15 +703,15 @@ fn standalone_assignment_constraints_reject_cross_ticket_principals_or_orphan_au
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
for sql in [
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('other','t','cross-workspace','orchestrator','workspace_agent','workspace-orchestrator','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('other','t','cross-workspace','orchestrator','subject','subject-a','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','bad-orchestrator','orchestrator','user','user','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','t','bad-owner','owner','worker','r','worker','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','bad-worker','worker','workspace_agent','workspace-orchestrator','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','bad-worker','worker','subject','subject-a','author','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,assigned_by,assigned_at) VALUES('w','t','missing-worker-identity','worker','worker','author','now')",
|
||||
"INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at) VALUES('other','t','orchestrator','cross-audit','assigned','a','author','now')",
|
||||
"INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at) VALUES('w','missing','orchestrator','cross-ticket-audit','assigned','a','author','now')",
|
||||
"INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,created_at) VALUES('other','orphan-operation','assign','t','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at,tracking_json) VALUES('w','t','bad-status','orchestrator','workspace_agent','workspace-orchestrator','author','now','{\"status\":\"running\"}')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at,tracking_json) VALUES('w','t','bad-status','orchestrator','subject','subject-a','author','now','{\"status\":\"running\"}')",
|
||||
] {
|
||||
assert!(conn.execute(sql, []).is_err(), "reject {sql}");
|
||||
}
|
||||
@@ -850,3 +859,269 @@ fn assignment_answer_rejects_unbounded_body_and_malformed_identities_without_app
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
// Frozen component13 composition, not a latest-schema fixture relabelled as old.
|
||||
fn legacy_v13() -> Connection {
|
||||
let conn = legacy("no-snapshot", false, false, false);
|
||||
migrate_assignment_tracking(&conn).unwrap();
|
||||
conn.execute_batch(include_str!("post_migration.sql"))
|
||||
.unwrap();
|
||||
conn.execute_batch(include_str!("content_changes.sql"))
|
||||
.unwrap();
|
||||
conn.execute_batch(
|
||||
"INSERT INTO ticket_schema_migrations VALUES
|
||||
(11,'Ticket-owned assignment tracking','v11-time'),
|
||||
(12,'Canonical Ticket posts','v12-time'),
|
||||
(13,'Ticket content change facts','v13-time');",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_migration_preserves_legacy_audit_and_receipts_without_inventing_subject_authority() {
|
||||
let conn = legacy_v13();
|
||||
let tracking = r#"{"status":"done","waiting_reason":null,"progress":null,"next_action":null,"result":"Historical result"}"#;
|
||||
let old_source = r#"{"actor":"old-actor","runtime_id":"old-runtime","worker_id":"old-worker","agent_key":"workspace-orchestrator","authentication_binding_id":"old-binding"}"#;
|
||||
conn.execute("UPDATE ticket_worker_assignments SET tracking_json=?1,last_tracking_event_id='old-event',tracking_updated_at='old-time' WHERE assignment_id='a'",[tracking]).unwrap();
|
||||
conn.execute("INSERT INTO typed_ticket_assignment_tracking_history VALUES('w','t','a','old-event','old-op',?1,?2,'old-time')",params![tracking,old_source]).unwrap();
|
||||
let before = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let history = sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a").unwrap();
|
||||
// Pre-Subject saved results remain byte-identical and are not new requests.
|
||||
let mut old_input = update(
|
||||
"a",
|
||||
"assigned-a",
|
||||
"old-op",
|
||||
TicketAssignmentTrackingStatus::Done,
|
||||
);
|
||||
old_input.tracking = before.tracking.clone().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO typed_ticket_assignment_tracking_operations VALUES('w','old-op','t',?1,?2,?3)",
|
||||
params![
|
||||
serde_json::to_string(&("t", &old_input)).unwrap(),
|
||||
old_source,
|
||||
serde_json::to_string(&before).unwrap()
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint) VALUES('w','legacy-assign','assign','t','orchestrator','workspace_agent','workspace-orchestrator','a','old-time','old-fingerprint')",[]).unwrap();
|
||||
let receipts = |db: &Connection| -> (String, String) {
|
||||
db.query_row("SELECT source_context_json,result_json FROM typed_ticket_assignment_tracking_operations WHERE operation_key='old-op'",[],|r|Ok((r.get(0)?,r.get(1)?))).unwrap()
|
||||
};
|
||||
let saved_receipt = receipts(&conn);
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
let after = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
after.principal,
|
||||
TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(after.tracking, before.tracking);
|
||||
assert_eq!(after.last_event_id, before.last_event_id);
|
||||
assert_eq!(after.updated_at, before.updated_at);
|
||||
assert!(after.current);
|
||||
assert!(
|
||||
after
|
||||
.migration_diagnostic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("nonoperational")
|
||||
);
|
||||
assert_eq!(
|
||||
sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a").unwrap(),
|
||||
history
|
||||
);
|
||||
assert_eq!(
|
||||
history.last().unwrap().source_context["agent_key"],
|
||||
"workspace-orchestrator"
|
||||
);
|
||||
assert_eq!(receipts(&conn), saved_receipt);
|
||||
assert_eq!(conn.query_row("SELECT principal_kind FROM ticket_assignment_operations WHERE operation_id='legacy-assign'",[],|r|r.get::<_,String>(0)).unwrap(),"workspace_agent");
|
||||
// Neither a random Subject nor spelling the old singleton as a Subject owns it.
|
||||
for subject in ["subject-a", "workspace-orchestrator"] {
|
||||
let mut sender = source();
|
||||
sender.actor = "old-actor".into();
|
||||
sender.runtime_id = "old-runtime".into();
|
||||
sender.worker_id = "old-worker".into();
|
||||
sender.authentication_binding_id = "old-binding".into();
|
||||
sender.subject_id = subject.into();
|
||||
assert!(
|
||||
apply(
|
||||
&conn,
|
||||
&update(
|
||||
"a",
|
||||
"old-event",
|
||||
"new-op",
|
||||
TicketAssignmentTrackingStatus::Inprogress
|
||||
),
|
||||
&sender
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
// Exact historical input and concrete sender still cannot reinterpret
|
||||
// old agent_key JSON as a new Subject-authenticated receipt.
|
||||
assert!(apply(&conn, &old_input, &sender).is_err());
|
||||
}
|
||||
for sql in [
|
||||
"UPDATE ticket_worker_assignments SET tracking_updated_at='new-time' WHERE assignment_id='a'",
|
||||
"INSERT INTO typed_ticket_assignment_tracking_history VALUES('w','t','a','new-event','new-op','{\"status\":\"done\"}','{}','now')",
|
||||
"INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','legacy-new','orchestrator','workspace_agent','workspace-orchestrator','actor','now')",
|
||||
] {
|
||||
assert!(conn.execute(sql, []).is_err(), "reject {sql}");
|
||||
}
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
assert_eq!(
|
||||
sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
after
|
||||
);
|
||||
// Explicit reissue is a new Subject identity, not a conversion of old audit.
|
||||
assign(&conn, "subject-reissue", "subject-reissue-event");
|
||||
apply(
|
||||
&conn,
|
||||
&update(
|
||||
"subject-reissue",
|
||||
"subject-reissue-event",
|
||||
"subject-update",
|
||||
TicketAssignmentTrackingStatus::Done,
|
||||
),
|
||||
&source(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(receipts(&conn), saved_receipt);
|
||||
assert!(
|
||||
!sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.current
|
||||
);
|
||||
let wire = serde_json::to_value(source()).unwrap();
|
||||
assert_eq!(wire["subject_id"], "subject-a");
|
||||
assert!(wire.get("agent_key").is_none());
|
||||
// Old serialized principal snapshots and audit JSON are still readable as old types.
|
||||
let old: TicketAssignmentTrackingProjection = serde_json::from_str(&saved_receipt.1).unwrap();
|
||||
assert_eq!(old.principal, before.principal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_owner_is_required_for_new_operations_and_worker_replacement_is_not_reassignment() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
let input = update(
|
||||
"a",
|
||||
"assigned-a",
|
||||
"fresh",
|
||||
TicketAssignmentTrackingStatus::Inprogress,
|
||||
);
|
||||
let mut wrong_subject = source();
|
||||
wrong_subject.subject_id = "subject-b".into();
|
||||
assert!(apply(&conn, &input, &wrong_subject).is_err());
|
||||
let projection = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
projection.principal,
|
||||
TicketAssignmentPrincipalSnapshot::Subject {
|
||||
subject_id: "subject-a".into()
|
||||
}
|
||||
);
|
||||
let mut replacement = source();
|
||||
replacement.worker_id = "new-body".into();
|
||||
replacement.authentication_binding_id = "new-binding".into();
|
||||
let result = apply(&conn, &input, &replacement).unwrap();
|
||||
assert_eq!(result.assignment_id, "a");
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM typed_ticket_assignment_tracking_operations",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_subject_migration_rolls_back_schema_and_receipt_but_preserves_host_transaction() {
|
||||
let conn = legacy_v13();
|
||||
let original: String = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name='ticket_worker_assignments'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute_batch("CREATE TRIGGER host_reject_subject_migration BEFORE UPDATE OF tracking_migration_diagnostic ON ticket_worker_assignments BEGIN SELECT RAISE(ABORT,'simulated failure after CHECK widening'); END;").unwrap();
|
||||
conn.execute_batch("BEGIN IMMEDIATE; CREATE TABLE host_pending(value TEXT); INSERT INTO host_pending VALUES('keep');").unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema_in_transaction(&conn).is_err());
|
||||
assert!(!conn.is_autocommit());
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT value FROM host_pending", [], |r| r
|
||||
.get::<_, String>(0))
|
||||
.unwrap(),
|
||||
"keep"
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT MAX(version) FROM ticket_schema_migrations",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
13
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name='ticket_worker_assignments'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
original
|
||||
);
|
||||
assert!(
|
||||
!conn
|
||||
.pragma_query_value(None, "writable_schema", |r| r.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_schema WHERE name='ticket_subject_current_insert'",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
conn.execute_batch("DROP TRIGGER host_reject_subject_migration;")
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema_in_transaction(&conn).unwrap();
|
||||
conn.execute_batch("COMMIT").unwrap();
|
||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_generation_is_ledger_selected_and_never_repaired_by_a_marker_trigger() {
|
||||
let conn = legacy_v13();
|
||||
conn.execute_batch("CREATE TRIGGER ticket_subject_current_insert BEFORE INSERT ON ticket_current_worker_assignments BEGIN SELECT 1; END;").unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema(&conn).is_err());
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT MAX(version) FROM ticket_schema_migrations",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
13
|
||||
);
|
||||
let conn = db();
|
||||
conn.execute_batch("DROP TRIGGER ticket_subject_current_insert; CREATE TRIGGER ticket_subject_current_insert BEFORE INSERT ON ticket_current_worker_assignments BEGIN SELECT 1; END;").unwrap();
|
||||
assert!(verify_sqlite_ticket_schema(&conn).is_err());
|
||||
assert!(migrate_sqlite_ticket_schema(&conn).is_err());
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ mod assignment_tracking;
|
||||
pub mod config;
|
||||
mod content_changes;
|
||||
mod sqlite_schema;
|
||||
mod subject_assignment;
|
||||
mod tags;
|
||||
pub use assignment_tracking::*;
|
||||
pub use tags::{
|
||||
@@ -39,6 +40,7 @@ pub use tags::{
|
||||
pub mod tool;
|
||||
|
||||
pub use sqlite_schema::{
|
||||
HistoricalTicketSchemaTarget, migrate_historical_sqlite_ticket_schema_in_transaction,
|
||||
migrate_sqlite_ticket_schema, migrate_sqlite_ticket_schema_in_transaction,
|
||||
verify_legacy_sqlite_ticket_schema, verify_pre_classification_ticket_schema,
|
||||
verify_sqlite_ticket_schema,
|
||||
|
||||
@@ -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 = 13;
|
||||
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 14;
|
||||
const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -320,11 +320,60 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends assignment, post, and content-change fact migrations without replacing applied markers.
|
||||
/// The host owns the surrounding transaction.
|
||||
/// A savepoint rolls back this component on failure, without committing host work.
|
||||
/// Current component 13 is verified only; historical edits are never backfilled.
|
||||
/// Startup-only historical composition targets retained by Workspace migrations.
|
||||
/// These are frozen component boundaries, not selectable request schemas or a
|
||||
/// way to restore legacy execution authority. Current stores must use the latest
|
||||
/// composer and request paths must use read-only verification instead.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HistoricalTicketSchemaTarget {
|
||||
/// Two-state cases plus tags; no owning assignment tracking yet.
|
||||
Cases10,
|
||||
/// Owning assignment tracking, before canonical posts.
|
||||
Tracking11,
|
||||
/// Canonical posts, before content-change facts.
|
||||
Posts12,
|
||||
/// Content-change facts, before Subject responsibility constraints.
|
||||
Content13,
|
||||
}
|
||||
impl HistoricalTicketSchemaTarget {
|
||||
fn shape(self) -> TicketSchemaShape {
|
||||
match self {
|
||||
Self::Cases10 => TicketSchemaShape::Composed10,
|
||||
Self::Tracking11 => TicketSchemaShape::Tracking11,
|
||||
Self::Posts12 => TicketSchemaShape::Posts12,
|
||||
Self::Content13 => TicketSchemaShape::ContentChanges13,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compose one explicit retained host-migration boundary in the host's startup
|
||||
/// transaction. This is NOT a request-path API or a downgrade/repair operation.
|
||||
///
|
||||
/// A recognized older preimage is verified before composition. Applied receipts
|
||||
/// are retained; only missing migration receipts are appended. If the stored
|
||||
/// version is already at or above the target, its ACTUAL version and complete
|
||||
/// shape are verified without downgrading, relabelling, or replaying old steps.
|
||||
/// Unknown history and weakened constraints fail closed. The component savepoint
|
||||
/// rolls back failure without committing or discarding the caller's other work.
|
||||
pub fn migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
connection: &Connection,
|
||||
target: HistoricalTicketSchemaTarget,
|
||||
) -> Result<()> {
|
||||
migrate_ticket_schema_to_in_transaction(connection, target.shape())
|
||||
}
|
||||
|
||||
/// Appends owning component migrations through the latest contract without
|
||||
/// replacing applied receipts. Startup only; the host owns the transaction.
|
||||
/// A savepoint rolls back this component on failure without committing host work.
|
||||
/// Already-current schemas are verified, never repaired or backfilled.
|
||||
pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> Result<()> {
|
||||
migrate_ticket_schema_to_in_transaction(connection, TicketSchemaShape::Subjects14)
|
||||
}
|
||||
|
||||
fn migrate_ticket_schema_to_in_transaction(
|
||||
connection: &Connection,
|
||||
target: TicketSchemaShape,
|
||||
) -> Result<()> {
|
||||
if connection.is_autocommit() {
|
||||
return Err(TicketError::Sqlite(
|
||||
"Ticket component migration requires a host transaction".into(),
|
||||
@@ -344,26 +393,36 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R
|
||||
verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?;
|
||||
let applied = load_applied_migrations(connection)?;
|
||||
if applied.is_empty() {
|
||||
create_latest_ticket_schema(connection)?;
|
||||
connection
|
||||
.execute_batch(include_str!("latest_schema.sql"))
|
||||
.map_err(sqlite_err)?;
|
||||
for version in 11..=target.version() {
|
||||
apply_component_migration(connection, version)?;
|
||||
}
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO ticket_schema_migrations VALUES (13, ?1, ?2)",
|
||||
params![BASELINE_MIGRATION_NAME, chrono::Utc::now().to_rfc3339()],
|
||||
"INSERT INTO ticket_schema_migrations VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
target.version(),
|
||||
BASELINE_MIGRATION_NAME,
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
} else {
|
||||
let version = recognized_schema_version(&applied)?;
|
||||
let mut version = recognized_schema_version(&applied)?;
|
||||
if version == 6 {
|
||||
// Retained v6 conversion establishes the verified v7 preimage.
|
||||
// Preserve its original baseline receipt instead of renumbering it.
|
||||
migrate_v6_to_v7(connection)?;
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE ticket_schema_migrations SET version = 7 WHERE version = 6",
|
||||
[],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
append_component_receipt(connection, 7)?;
|
||||
version = 7;
|
||||
}
|
||||
let shape = TicketSchemaShape::from_version(if version == 6 { 7 } else { version });
|
||||
let shape = TicketSchemaShape::from_version(version);
|
||||
verify_ticket_schema_shape(connection, shape)?;
|
||||
if version >= target.version() {
|
||||
return Ok(());
|
||||
}
|
||||
match shape {
|
||||
TicketSchemaShape::Legacy7 => {
|
||||
add_tag_schema(connection)?;
|
||||
@@ -374,30 +433,19 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R
|
||||
TicketSchemaShape::Composed10
|
||||
| TicketSchemaShape::Tracking11
|
||||
| TicketSchemaShape::Posts12
|
||||
| TicketSchemaShape::ContentChanges13 => {}
|
||||
| TicketSchemaShape::ContentChanges13
|
||||
| TicketSchemaShape::Subjects14 => {}
|
||||
}
|
||||
if version < 10 {
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE ticket_schema_migrations SET version = 10, applied_at = ?1",
|
||||
params![chrono::Utc::now().to_rfc3339()],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
append_component_receipt(connection, 10)?;
|
||||
version = 10;
|
||||
}
|
||||
if version < 11 {
|
||||
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)?;
|
||||
}
|
||||
if version < 13 {
|
||||
migrate_content_changes(connection)?;
|
||||
connection.execute("INSERT INTO ticket_schema_migrations VALUES (13, 'Ticket content change facts', ?1)", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?;
|
||||
for next in version + 1..=target.version() {
|
||||
apply_component_migration(connection, next)?;
|
||||
append_component_receipt(connection, next)?;
|
||||
}
|
||||
}
|
||||
verify_sqlite_ticket_schema(connection)
|
||||
verify_ticket_schema_shape(connection, target)
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => connection
|
||||
@@ -421,6 +469,7 @@ enum TicketSchemaShape {
|
||||
Tracking11,
|
||||
Posts12,
|
||||
ContentChanges13,
|
||||
Subjects14,
|
||||
}
|
||||
impl TicketSchemaShape {
|
||||
fn from_version(version: i64) -> Self {
|
||||
@@ -432,6 +481,7 @@ impl TicketSchemaShape {
|
||||
11 => Self::Tracking11,
|
||||
12 => Self::Posts12,
|
||||
13 => Self::ContentChanges13,
|
||||
14 => Self::Subjects14,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -444,6 +494,7 @@ impl TicketSchemaShape {
|
||||
Self::Tracking11 => 11,
|
||||
Self::Posts12 => 12,
|
||||
Self::ContentChanges13 => 13,
|
||||
Self::Subjects14 => 14,
|
||||
}
|
||||
}
|
||||
fn tags(self) -> bool {
|
||||
@@ -454,6 +505,7 @@ impl TicketSchemaShape {
|
||||
| Self::Tracking11
|
||||
| Self::Posts12
|
||||
| Self::ContentChanges13
|
||||
| Self::Subjects14
|
||||
)
|
||||
}
|
||||
fn cases(self) -> bool {
|
||||
@@ -464,13 +516,14 @@ impl TicketSchemaShape {
|
||||
| Self::Tracking11
|
||||
| Self::Posts12
|
||||
| Self::ContentChanges13
|
||||
| Self::Subjects14
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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::ContentChanges13)
|
||||
verify_ticket_schema_shape(connection, TicketSchemaShape::Subjects14)
|
||||
}
|
||||
|
||||
/// Retained Workspace schema-66 migration produces the pre-classification v7
|
||||
@@ -484,7 +537,7 @@ pub fn verify_pre_classification_ticket_schema(connection: &Connection) -> Resul
|
||||
/// treating historical workflow values as the current two-state case authority.
|
||||
pub fn verify_legacy_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
let applied = load_applied_migrations(connection)?;
|
||||
let version = applied.keys().next().copied().unwrap_or_default();
|
||||
let version = recognized_schema_version(&applied)?;
|
||||
if !matches!(version, 7 | 8) {
|
||||
return Err(TicketError::Sqlite(
|
||||
"legacy Ticket schema requires component 7 or 8".into(),
|
||||
@@ -586,7 +639,9 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
|
||||
let mut columns = columns.to_vec();
|
||||
if matches!(
|
||||
shape,
|
||||
TicketSchemaShape::Posts12 | TicketSchemaShape::ContentChanges13
|
||||
TicketSchemaShape::Posts12
|
||||
| TicketSchemaShape::ContentChanges13
|
||||
| TicketSchemaShape::Subjects14
|
||||
) {
|
||||
if table == "typed_tickets" {
|
||||
columns.retain(|c| c.name != "body");
|
||||
@@ -831,16 +886,20 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
|
||||
TicketSchemaShape::Tracking11
|
||||
| TicketSchemaShape::Posts12
|
||||
| TicketSchemaShape::ContentChanges13
|
||||
| TicketSchemaShape::Subjects14
|
||||
) {
|
||||
if let Err(error) =
|
||||
crate::assignment_tracking::verify_assignment_tracking_schema(connection)
|
||||
{
|
||||
if let Err(error) = crate::assignment_tracking::verify_assignment_tracking_schema(
|
||||
connection,
|
||||
matches!(shape, TicketSchemaShape::Subjects14),
|
||||
) {
|
||||
push_diagnostic(&mut diagnostics, error.to_string());
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
shape,
|
||||
TicketSchemaShape::Posts12 | TicketSchemaShape::ContentChanges13
|
||||
TicketSchemaShape::Posts12
|
||||
| TicketSchemaShape::ContentChanges13
|
||||
| TicketSchemaShape::Subjects14
|
||||
) {
|
||||
collect_index_diagnostics(
|
||||
connection,
|
||||
@@ -851,7 +910,10 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
|
||||
&mut diagnostics,
|
||||
);
|
||||
}
|
||||
if matches!(shape, TicketSchemaShape::ContentChanges13) {
|
||||
if matches!(
|
||||
shape,
|
||||
TicketSchemaShape::ContentChanges13 | TicketSchemaShape::Subjects14
|
||||
) {
|
||||
collect_content_change_diagnostics(connection, &mut diagnostics)?;
|
||||
}
|
||||
if diagnostics.is_empty() {
|
||||
@@ -870,13 +932,41 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
|
||||
}
|
||||
}
|
||||
|
||||
fn create_latest_ticket_schema(connection: &Connection) -> Result<()> {
|
||||
// Both fresh and retained composition use this single ordered step dispatcher.
|
||||
fn apply_component_migration(connection: &Connection, version: i64) -> Result<()> {
|
||||
match version {
|
||||
11 => crate::assignment_tracking::migrate_assignment_tracking(connection),
|
||||
12 => migrate_posts(connection),
|
||||
13 => migrate_content_changes(connection),
|
||||
14 => crate::subject_assignment::migrate(connection),
|
||||
_ => unreachable!("bounded component migration"),
|
||||
}
|
||||
}
|
||||
|
||||
fn component_migration_name(version: i64) -> &'static str {
|
||||
match version {
|
||||
7 => "Ordered Ticket targets",
|
||||
10 => "Ticket case and tag composition",
|
||||
11 => "Ticket-owned assignment tracking",
|
||||
12 => "Canonical Ticket posts",
|
||||
13 => "Ticket content change facts",
|
||||
14 => "Subject Ticket responsibility",
|
||||
_ => unreachable!("bounded component receipt"),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_component_receipt(connection: &Connection, version: i64) -> Result<()> {
|
||||
connection
|
||||
.execute_batch(include_str!("latest_schema.sql"))
|
||||
.execute(
|
||||
"INSERT INTO ticket_schema_migrations VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
version,
|
||||
component_migration_name(version),
|
||||
chrono::Utc::now().to_rfc3339()
|
||||
],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
crate::assignment_tracking::migrate_assignment_tracking(connection)?;
|
||||
migrate_posts(connection)?;
|
||||
migrate_content_changes(connection)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_content_changes(connection: &Connection) -> Result<()> {
|
||||
@@ -1212,26 +1302,24 @@ fn load_applied_migrations(connection: &Connection) -> Result<BTreeMap<i64, Stri
|
||||
}
|
||||
|
||||
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..=13).contains(&version) && name == BASELINE_MIGRATION_NAME {
|
||||
return Ok(version);
|
||||
}
|
||||
}
|
||||
}
|
||||
for baseline in [10, 11, 12] {
|
||||
let mut expected = BTreeMap::from([(baseline, BASELINE_MIGRATION_NAME.to_owned())]);
|
||||
for (version, name) in [
|
||||
(11, "Ticket-owned assignment tracking"),
|
||||
(12, "Canonical Ticket posts"),
|
||||
(13, "Ticket content change facts"),
|
||||
] {
|
||||
if version <= baseline {
|
||||
continue;
|
||||
}
|
||||
expected.insert(version, name.to_owned());
|
||||
if let Some((&baseline, name)) = applied.first_key_value() {
|
||||
if (6..=LATEST_SQLITE_TICKET_SCHEMA_VERSION).contains(&baseline)
|
||||
&& name == BASELINE_MIGRATION_NAME
|
||||
{
|
||||
let mut expected = BTreeMap::from([(baseline, name.clone())]);
|
||||
if applied == &expected {
|
||||
return Ok(version);
|
||||
return Ok(baseline);
|
||||
}
|
||||
// Versions 8 and 9 were independent baseline generations, not
|
||||
// ordered upgrades. Both compose at10; v6 additionally needs v7.
|
||||
for version in [7, 10, 11, 12, 13, 14] {
|
||||
if version <= baseline || (version == 7 && baseline != 6) {
|
||||
continue;
|
||||
}
|
||||
expected.insert(version, component_migration_name(version).to_owned());
|
||||
if applied == &expected {
|
||||
return Ok(version);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1733,10 +1821,13 @@ mod tests {
|
||||
assert_eq!(
|
||||
load_applied_migrations(&connection).unwrap(),
|
||||
BTreeMap::from([
|
||||
(10, BASELINE_MIGRATION_NAME.to_owned()),
|
||||
(6, BASELINE_MIGRATION_NAME.to_owned()),
|
||||
(7, "Ordered Ticket targets".to_owned()),
|
||||
(10, "Ticket case and tag composition".to_owned()),
|
||||
(11, "Ticket-owned assignment tracking".to_owned()),
|
||||
(12, "Canonical Ticket posts".to_owned()),
|
||||
(13, "Ticket content change facts".to_owned())
|
||||
(13, "Ticket content change facts".to_owned()),
|
||||
(14, "Subject Ticket responsibility".to_owned())
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -1879,7 +1970,7 @@ mod tests {
|
||||
|
||||
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
|
||||
assert!(error.to_string().contains(
|
||||
"migration history must contain a canonical baseline and ordered migrations through 13"
|
||||
"migration history must contain a canonical baseline and ordered migrations through 14"
|
||||
));
|
||||
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
|
||||
}
|
||||
@@ -1901,7 +1992,7 @@ mod tests {
|
||||
|
||||
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
|
||||
assert!(error.to_string().contains(
|
||||
"migration history must contain a canonical baseline and ordered migrations through 13"
|
||||
"migration history must contain a canonical baseline and ordered migrations through 14"
|
||||
));
|
||||
assert!(!table_exists(&connection, "typed_tickets").unwrap());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Component 14 owns Subject assignment table constraints and guards. It never
|
||||
//! maps a legacy agent to a Subject, starts work, or rewrites historical receipts.
|
||||
//! The host separately owns authenticated live Subject bindings, delivery and startup fences.
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
const PRINCIPAL_TABLES: [&str; 3] = [
|
||||
"ticket_assignment_operations",
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_worker_assignments",
|
||||
];
|
||||
const OLD_KINDS: &str = "CHECK(principal_kind IN ('user', 'worker', 'workspace_agent'))";
|
||||
const SUBJECT_KINDS: &str =
|
||||
"CHECK(principal_kind IN ('user', 'worker', 'workspace_agent', 'subject'))";
|
||||
pub(crate) const SUBJECT_GUARDS: &str = include_str!("subject_assignment_schema.sql");
|
||||
const LEGACY_DIAGNOSTIC: &str = "Legacy Orchestrator identity retained without a trustworthy historical Subject mapping; nonoperational, explicit authorized Subject assignment required. Saved history and receipts do not grant execution authority.";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum MigrationError {
|
||||
#[error("{0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
#[error("{0}")]
|
||||
Contract(String),
|
||||
}
|
||||
type Result<T> = std::result::Result<T, MigrationError>;
|
||||
|
||||
/// Called only by the owning startup migration, within its savepoint. The caller
|
||||
/// rolls back on error. No independent ledger, transaction, or authority fallback.
|
||||
pub(crate) fn migrate(conn: &Connection) -> crate::Result<()> {
|
||||
let result = (|| -> Result<()> {
|
||||
if conn.is_autocommit() {
|
||||
return Err(MigrationError::Contract(
|
||||
"Subject assignment migration requires a caller-owned transaction".into(),
|
||||
));
|
||||
}
|
||||
widen_principal_checks(conn)?;
|
||||
conn.execute_batch(SUBJECT_GUARDS)?;
|
||||
conn.execute(
|
||||
"UPDATE ticket_worker_assignments
|
||||
SET tracking_migration_diagnostic = CASE
|
||||
WHEN tracking_migration_diagnostic IS NULL OR tracking_migration_diagnostic=''
|
||||
THEN ?1 ELSE tracking_migration_diagnostic || char(10) || ?1 END
|
||||
WHERE role='orchestrator' AND principal_kind!='subject'",
|
||||
[LEGACY_DIAGNOSTIC],
|
||||
)?;
|
||||
verify_inner(conn)?;
|
||||
verify_integrity(conn)
|
||||
})();
|
||||
result.map_err(super::sqlite_err)
|
||||
}
|
||||
|
||||
pub(crate) fn verify(conn: &Connection) -> crate::Result<()> {
|
||||
verify_inner(conn).map_err(super::sqlite_err)
|
||||
}
|
||||
|
||||
fn verify_inner(conn: &Connection) -> Result<()> {
|
||||
require_schema_writes_closed(conn)?;
|
||||
let canonical = known_table_shape(conn, true)?;
|
||||
canonical.execute_batch(SUBJECT_GUARDS)?;
|
||||
let mut statement =
|
||||
canonical.prepare("SELECT name FROM sqlite_schema WHERE type='trigger' ORDER BY name")?;
|
||||
for name in statement.query_map([], |row| row.get::<_, String>(0))? {
|
||||
require_same_sql(conn, &canonical, "trigger", &name?)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_schema_writes_closed(conn: &Connection) -> Result<()> {
|
||||
for setting in ["writable_schema", "ignore_check_constraints"] {
|
||||
if conn.pragma_query_value(None, setting, |row| row.get::<_, bool>(0))? {
|
||||
return Err(MigrationError::Contract(format!(
|
||||
"Subject assignment schema requires {setting} OFF"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn schema_sql(conn: &Connection, kind: &str, name: &str) -> Result<String> {
|
||||
conn.query_row(
|
||||
"SELECT sql FROM main.sqlite_schema WHERE type=?1 AND name=?2",
|
||||
params![kind, name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(|| MigrationError::Contract(format!("Missing Subject assignment {kind} {name}")))
|
||||
}
|
||||
|
||||
fn require_same_sql(
|
||||
conn: &Connection,
|
||||
canonical: &Connection,
|
||||
kind: &str,
|
||||
name: &str,
|
||||
) -> Result<()> {
|
||||
if normalize_sql(&schema_sql(conn, kind, name)?)
|
||||
!= normalize_sql(&schema_sql(canonical, kind, name)?)
|
||||
{
|
||||
return Err(MigrationError::Contract(format!(
|
||||
"Subject assignment {kind} {name} differs from canonical schema"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Token boundaries and quoted literal bytes are significant. Only whitespace,
|
||||
/// unquoted ASCII case and SQLite's ALTER-generated quoting of these three known
|
||||
/// table identifiers are normalized. In particular, do not globally strip quotes,
|
||||
/// comments, IF NOT EXISTS, whitespace in literals, or merge adjacent SQL tokens.
|
||||
fn normalize_sql(sql: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = sql.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
if matches!(ch, '\'' | '"' | '`' | '[') {
|
||||
let end = if ch == '[' { ']' } else { ch };
|
||||
let mut token = String::from(ch);
|
||||
while let Some(next) = chars.next() {
|
||||
token.push(next);
|
||||
if next == end {
|
||||
if chars.peek() == Some(&end) && end != ']' {
|
||||
token.push(chars.next().expect("peeked quote"));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ch == '"'
|
||||
&& PRINCIPAL_TABLES
|
||||
.iter()
|
||||
.any(|table| token == format!("\"{table}\""))
|
||||
{
|
||||
token = token[1..token.len() - 1].to_owned();
|
||||
}
|
||||
tokens.push(token);
|
||||
} else if ch.is_alphanumeric() || matches!(ch, '_' | '$') {
|
||||
let mut token = ch.to_string();
|
||||
while chars
|
||||
.peek()
|
||||
.is_some_and(|next| next.is_alphanumeric() || matches!(*next, '_' | '$'))
|
||||
{
|
||||
token.push(chars.next().expect("peeked token"));
|
||||
}
|
||||
tokens.push(token.to_ascii_lowercase());
|
||||
} else {
|
||||
tokens.push(ch.to_string());
|
||||
}
|
||||
}
|
||||
tokens
|
||||
}
|
||||
|
||||
/// Reconstruct both explicitly supported historical generations from the immutable
|
||||
/// Ticket-owned core and tracking DDL. Only Workspace-parent FK presence differs.
|
||||
fn canonical_tables(subject: bool, workspace_parent: bool) -> Result<Connection> {
|
||||
let canonical = Connection::open_in_memory()?;
|
||||
let historical = super::TICKET_ASSIGNMENT_SCHEMA_SQL;
|
||||
let mut sql = if workspace_parent {
|
||||
historical.to_owned()
|
||||
} else {
|
||||
historical.replace("FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,", "")
|
||||
.replace(",\n FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE\n", "\n")
|
||||
};
|
||||
if subject {
|
||||
sql = sql.replace(OLD_KINDS, SUBJECT_KINDS);
|
||||
}
|
||||
canonical.execute_batch(&sql)?;
|
||||
canonical.execute_batch(include_str!("assignment_tracking_schema.sql"))?;
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
/// Match a whole published three-table generation. The operation table selects
|
||||
/// the known Workspace-FK variant; the other two must match that SAME variant.
|
||||
/// Mixed principal generations or one-off dropped constraints are not accepted.
|
||||
fn known_table_shape(conn: &Connection, subject: bool) -> Result<Connection> {
|
||||
let operation = normalize_sql(&schema_sql(conn, "table", PRINCIPAL_TABLES[0])?);
|
||||
for workspace_parent in [true, false] {
|
||||
let canonical = canonical_tables(subject, workspace_parent)?;
|
||||
if operation != normalize_sql(&schema_sql(&canonical, "table", PRINCIPAL_TABLES[0])?) {
|
||||
continue;
|
||||
}
|
||||
for table in PRINCIPAL_TABLES {
|
||||
if normalize_sql(&schema_sql(conn, "table", table)?)
|
||||
!= normalize_sql(&schema_sql(&canonical, "table", table)?)
|
||||
{
|
||||
return Err(MigrationError::Contract(format!(
|
||||
"Unsupported complete assignment table SQL in {table}; refusing unknown or mixed schema"
|
||||
)));
|
||||
}
|
||||
}
|
||||
return Ok(canonical);
|
||||
}
|
||||
Err(MigrationError::Contract(format!(
|
||||
"Unsupported complete assignment table SQL in {}; refusing unknown schema",
|
||||
PRINCIPAL_TABLES[0]
|
||||
)))
|
||||
}
|
||||
|
||||
/// SQLite's documented CHECK-only schema-change procedure avoids rebuilding
|
||||
/// immutable assignment parents and their many FK dependents. No columns,
|
||||
/// rootpages, rowids, index definitions, or data bytes change. The *entire* known
|
||||
/// old/new table SQL is preflighted before schema writes. The UPDATE targets are
|
||||
/// exactly the three named main-schema table rows and use exact-SQL CAS.
|
||||
///
|
||||
/// A normal DROP/rebuild needs foreign_keys=OFF *before* the caller transaction:
|
||||
/// defer_foreign_keys does not defer CASCADE actions or immutable child-delete
|
||||
/// triggers. Renaming with FK enforcement rewrites inbound references. Rebuilding
|
||||
/// the complete FK graph would include immutable tracking, receipts, work releases,
|
||||
/// origins, deliveries, startup fences and historical implementation delegations.
|
||||
/// That would require a different migration boundary and much broader data churn.
|
||||
/// For this CHECK-only change under the agreed caller transaction, the documented
|
||||
/// bounded schema edit is safer than an improvised graph rebuild. If table layout
|
||||
/// ever changes, this helper must reject it; it is not a general migration engine.
|
||||
fn widen_principal_checks(conn: &Connection) -> Result<()> {
|
||||
require_schema_writes_closed(conn)?;
|
||||
let old = known_table_shape(conn, false)?;
|
||||
let mut changes = Vec::new();
|
||||
for table in PRINCIPAL_TABLES {
|
||||
let sql = schema_sql(conn, "table", table)?;
|
||||
if sql.matches(OLD_KINDS).count() != 1 {
|
||||
return Err(MigrationError::Contract(format!(
|
||||
"Unsupported principal CHECK spelling in {table}"
|
||||
)));
|
||||
}
|
||||
changes.push((table, sql.replace(OLD_KINDS, SUBJECT_KINDS), sql));
|
||||
}
|
||||
// Do not silently repair same-named weakened principal triggers on upgrade.
|
||||
for name in [
|
||||
"typed_ticket_assignment_orchestrator_principal_insert",
|
||||
"typed_ticket_assignment_role_principal_insert",
|
||||
] {
|
||||
require_same_sql(conn, &old, "trigger", name)?;
|
||||
}
|
||||
let cookie: i64 = conn.pragma_query_value(None, "schema_version", |row| row.get(0))?;
|
||||
if !(0..i64::from(i32::MAX)).contains(&cookie) {
|
||||
return Err(MigrationError::Contract(
|
||||
"SQLite schema cookie cannot be advanced".into(),
|
||||
));
|
||||
}
|
||||
conn.pragma_update(None, "writable_schema", true)?;
|
||||
let change_result = (|| {
|
||||
let enabled: bool = conn.pragma_query_value(None, "writable_schema", |row| row.get(0))?;
|
||||
if !enabled {
|
||||
return Err(MigrationError::Contract(
|
||||
"SQLite refused the CHECK-only Subject schema migration".into(),
|
||||
));
|
||||
}
|
||||
for (table, new_sql, old_sql) in changes {
|
||||
let changed = conn.execute(
|
||||
"UPDATE main.sqlite_schema SET sql=?2 WHERE type='table' AND name=?1 AND tbl_name=?1 AND sql=?3",
|
||||
params![table, new_sql, old_sql],
|
||||
)?;
|
||||
if changed != 1 {
|
||||
return Err(MigrationError::Contract(format!(
|
||||
"Principal CHECK changed concurrently in {table}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
// Engine schema cache generation, NOT the application migration ledger.
|
||||
conn.pragma_update(None, "schema_version", cookie + 1)?;
|
||||
Ok(())
|
||||
})();
|
||||
// writable_schema is connection state, not transactional: restore it even
|
||||
// on failure, before returning control to the caller's rollback path.
|
||||
let restore_result = conn.pragma_update(None, "writable_schema", false);
|
||||
restore_result?;
|
||||
require_schema_writes_closed(conn)?;
|
||||
change_result?;
|
||||
conn.flush_prepared_statement_cache();
|
||||
// Force a complete reparse with writable_schema OFF. Malformed schema must
|
||||
// fail here rather than leave an apparently successful migration receipt.
|
||||
for table in PRINCIPAL_TABLES {
|
||||
conn.prepare(&format!("SELECT * FROM {table} LIMIT 0"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn verify_integrity(conn: &Connection) -> Result<()> {
|
||||
let integrity: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
|
||||
if integrity != "ok" {
|
||||
return Err(MigrationError::Contract(format!(
|
||||
"Subject assignment migration integrity check failed: {integrity}"
|
||||
)));
|
||||
}
|
||||
let violations: i64 =
|
||||
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
if violations != 0 {
|
||||
return Err(MigrationError::Contract(
|
||||
"Subject assignment migration foreign-key violation".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Component 14: additive Subject responsibility guards. Historical identities remain readable.
|
||||
|
||||
DROP TRIGGER typed_ticket_assignment_orchestrator_principal_insert;
|
||||
CREATE TRIGGER typed_ticket_assignment_orchestrator_principal_insert
|
||||
BEFORE INSERT ON ticket_worker_assignments
|
||||
WHEN NEW.role='orchestrator' AND NEW.principal_kind!='subject'
|
||||
BEGIN SELECT RAISE(ABORT,'New Orchestrator responsibility requires a Subject principal'); END;
|
||||
|
||||
DROP TRIGGER typed_ticket_assignment_role_principal_insert;
|
||||
CREATE TRIGGER typed_ticket_assignment_role_principal_insert
|
||||
BEFORE INSERT ON ticket_worker_assignments
|
||||
WHEN (NEW.role='owner' AND NEW.principal_kind!='user')
|
||||
OR (NEW.role='worker' AND NEW.principal_kind IN ('workspace_agent','subject'))
|
||||
OR (NEW.principal_kind='workspace_agent' AND NEW.principal_id!='workspace-orchestrator')
|
||||
OR (NEW.principal_kind='subject' AND NEW.role!='orchestrator')
|
||||
BEGIN SELECT RAISE(ABORT,'Assignment role and principal are incompatible'); END;
|
||||
|
||||
CREATE TRIGGER ticket_subject_current_insert
|
||||
BEFORE INSERT ON ticket_current_worker_assignments
|
||||
WHEN (NEW.role='orchestrator' AND NEW.principal_kind!='subject')
|
||||
OR (NEW.principal_kind='subject' AND NEW.role!='orchestrator')
|
||||
BEGIN SELECT RAISE(ABORT,'Current Orchestrator responsibility requires a Subject principal'); END;
|
||||
CREATE TRIGGER ticket_subject_current_update
|
||||
BEFORE UPDATE OF workspace_id,ticket_id,role,assignment_id,principal_kind,principal_id,runtime_id,worker_id
|
||||
ON ticket_current_worker_assignments
|
||||
WHEN (NEW.role='orchestrator' AND NEW.principal_kind!='subject')
|
||||
OR (NEW.principal_kind='subject' AND NEW.role!='orchestrator')
|
||||
BEGIN SELECT RAISE(ABORT,'Current Orchestrator responsibility requires a Subject principal'); END;
|
||||
|
||||
CREATE TRIGGER ticket_subject_operation_insert
|
||||
BEFORE INSERT ON ticket_assignment_operations
|
||||
WHEN (NEW.role='orchestrator' AND NEW.action!='unassign' AND NEW.principal_kind!='subject')
|
||||
OR (NEW.principal_kind='subject' AND (NEW.role!='orchestrator'
|
||||
OR NEW.principal_id IS NULL OR length(trim(NEW.principal_id))=0
|
||||
OR NEW.runtime_id IS NOT NULL OR NEW.worker_id IS NOT NULL))
|
||||
BEGIN SELECT RAISE(ABORT,'New Orchestrator assignment operation requires a Subject principal'); END;
|
||||
CREATE TRIGGER ticket_subject_operation_update
|
||||
BEFORE UPDATE ON ticket_assignment_operations
|
||||
WHEN (NEW.role='orchestrator' AND NEW.action!='unassign' AND NEW.principal_kind!='subject')
|
||||
OR (NEW.principal_kind='subject' AND (NEW.role!='orchestrator'
|
||||
OR NEW.principal_id IS NULL OR length(trim(NEW.principal_id))=0
|
||||
OR NEW.runtime_id IS NOT NULL OR NEW.worker_id IS NOT NULL))
|
||||
BEGIN SELECT RAISE(ABORT,'New Orchestrator assignment operation requires a Subject principal'); END;
|
||||
|
||||
CREATE TRIGGER ticket_legacy_orchestrator_tracking_no_update
|
||||
BEFORE UPDATE OF tracking_json,last_tracking_event_id,tracking_updated_at ON ticket_worker_assignments
|
||||
WHEN OLD.role='orchestrator' AND OLD.principal_kind!='subject'
|
||||
BEGIN SELECT RAISE(ABORT,'Legacy Orchestrator responsibility is nonoperational'); END;
|
||||
CREATE TRIGGER ticket_legacy_orchestrator_history_no_insert
|
||||
BEFORE INSERT ON typed_ticket_assignment_tracking_history
|
||||
WHEN EXISTS(SELECT 1 FROM ticket_worker_assignments a
|
||||
WHERE a.workspace_id=NEW.workspace_id AND a.assignment_id=NEW.assignment_id
|
||||
AND a.role='orchestrator' AND a.principal_kind!='subject')
|
||||
BEGIN SELECT RAISE(ABORT,'Legacy Orchestrator responsibility is nonoperational'); END;
|
||||
@@ -78,7 +78,17 @@ fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_withou
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(marker, 10);
|
||||
assert_eq!(marker, 7);
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT applied_at FROM ticket_schema_migrations WHERE version=7",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
"legacy"
|
||||
);
|
||||
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();
|
||||
@@ -442,7 +452,7 @@ fn tagged_schema8_composes_cases_preserving_tombstones_membership_context_receip
|
||||
verify_sqlite_ticket_schema(&connection).unwrap();
|
||||
assert_eq!(
|
||||
rows(&connection, "ticket_schema_migrations")[0][0],
|
||||
rusqlite::types::Value::Integer(10)
|
||||
rusqlite::types::Value::Integer(8)
|
||||
);
|
||||
for (table, expected) in preserved.iter().zip(&before) {
|
||||
assert_eq!(&rows(&connection, table), expected, "changed {table}");
|
||||
@@ -852,3 +862,218 @@ fn retained_workspace_foreign_keys_survive_composition_without_rebuilding_ticket
|
||||
.unwrap();
|
||||
assert_eq!(failures, 0);
|
||||
}
|
||||
|
||||
fn compose_historical(connection: &Connection, target: HistoricalTicketSchemaTarget) {
|
||||
connection.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
migrate_historical_sqlite_ticket_schema_in_transaction(connection, target).unwrap();
|
||||
assert!(!connection.is_autocommit());
|
||||
connection.execute_batch("COMMIT").unwrap();
|
||||
}
|
||||
|
||||
fn max_component(connection: &Connection) -> i64 {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT MAX(version) FROM ticket_schema_migrations",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_composition_stops_at_each_explicit_boundary_and_preserves_applied_receipts() {
|
||||
use HistoricalTicketSchemaTarget::{Cases10, Content13, Posts12, Tracking11};
|
||||
// Both empty stores and real old schema7 preimages must stop at the chosen
|
||||
// contract, rather than installing the latest Subject schema prematurely.
|
||||
for retained in [false, true] {
|
||||
for (target, version) in [
|
||||
(Cases10, 10),
|
||||
(Tracking11, 11),
|
||||
(Posts12, 12),
|
||||
(Content13, 13),
|
||||
] {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
|
||||
if retained {
|
||||
schema7(&conn);
|
||||
}
|
||||
assert!(migrate_historical_sqlite_ticket_schema_in_transaction(&conn, target).is_err());
|
||||
compose_historical(&conn, target);
|
||||
assert_eq!(max_component(&conn), version);
|
||||
let exists = |kind: &str, name: &str| -> bool {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type=?1 AND name=?2)",
|
||||
params![kind, name],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
exists("table", "typed_ticket_assignment_tracking_history"),
|
||||
version >= 11
|
||||
);
|
||||
assert_eq!(exists("index", "typed_ticket_events_ref"), version >= 12);
|
||||
assert_eq!(
|
||||
exists("table", "typed_ticket_content_changes"),
|
||||
version >= 13
|
||||
);
|
||||
assert!(!exists("trigger", "ticket_subject_current_insert"));
|
||||
if retained {
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT name,applied_at FROM ticket_schema_migrations WHERE version=7",
|
||||
[],
|
||||
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
|
||||
)
|
||||
.unwrap(),
|
||||
("ticket schema baseline".into(), "legacy".into())
|
||||
);
|
||||
}
|
||||
let receipts = rows(&conn, "ticket_schema_migrations");
|
||||
compose_historical(&conn, target);
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
|
||||
// Older host hooks verify the actual newer historical generation;
|
||||
// they neither replay older steps nor change its receipts.
|
||||
compose_historical(&conn, Cases10);
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
|
||||
assert!(verify_sqlite_ticket_schema(&conn).is_err());
|
||||
}
|
||||
}
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
schema7(&conn);
|
||||
let mut prior = rows(&conn, "ticket_schema_migrations");
|
||||
for target in [Cases10, Tracking11, Posts12, Content13] {
|
||||
compose_historical(&conn, target);
|
||||
let after = rows(&conn, "ticket_schema_migrations");
|
||||
assert_eq!(&after[..prior.len()], prior.as_slice());
|
||||
prior = after;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_composition_verifies_actual_current_contract_without_downgrade_or_repair() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
let receipts = rows(&conn, "ticket_schema_migrations");
|
||||
for target in [
|
||||
HistoricalTicketSchemaTarget::Cases10,
|
||||
HistoricalTicketSchemaTarget::Tracking11,
|
||||
HistoricalTicketSchemaTarget::Posts12,
|
||||
HistoricalTicketSchemaTarget::Content13,
|
||||
] {
|
||||
compose_historical(&conn, target);
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
|
||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
}
|
||||
// A lower requested target cannot hide a weakened actual14 guard.
|
||||
conn.execute_batch("DROP TRIGGER ticket_subject_current_insert; CREATE TRIGGER ticket_subject_current_insert BEFORE INSERT ON ticket_current_worker_assignments BEGIN SELECT 1; END;").unwrap();
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
assert!(
|
||||
migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
&conn,
|
||||
HistoricalTicketSchemaTarget::Cases10
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!conn.is_autocommit());
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
|
||||
conn.execute_batch("ROLLBACK").unwrap();
|
||||
|
||||
// A claimed13 marker over actual10 must also be rejected, not backfilled.
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
compose_historical(&conn, HistoricalTicketSchemaTarget::Cases10);
|
||||
conn.execute("UPDATE ticket_schema_migrations SET version=13", [])
|
||||
.unwrap();
|
||||
let before = rows(&conn, "ticket_schema_migrations");
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
assert!(
|
||||
migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
&conn,
|
||||
HistoricalTicketSchemaTarget::Cases10
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), before);
|
||||
conn.execute_batch("ROLLBACK").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_content13_reopens_after_failed_subject_upgrade_then_migrates_atomically_to14() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let path = temp.path().join("retained-content13.db");
|
||||
let (receipts, history, old_table);
|
||||
{
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
|
||||
compose_historical(&conn, HistoricalTicketSchemaTarget::Content13);
|
||||
// Actual retained13 accepts its historical stable-agent contract. It
|
||||
// has not been created at14 and then merely relabelled as old.
|
||||
conn.execute_batch("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit) VALUES('workspace','t','t','Title','open','task','P2','open',1);
|
||||
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('workspace','t',-1,'t:-1','comment','Body');
|
||||
INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('workspace','t','old','orchestrator','workspace_agent','workspace-orchestrator','old-author','old-time');
|
||||
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('workspace','t','old','orchestrator','workspace_agent','workspace-orchestrator','old-time');
|
||||
INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at,operation_id) VALUES('workspace','t','orchestrator','old-assigned','assigned','old','old-author','old-time','old-op');
|
||||
CREATE TRIGGER host_reject_subject_migration BEFORE UPDATE OF tracking_migration_diagnostic ON ticket_worker_assignments BEGIN SELECT RAISE(ABORT,'forced post-widen failure'); END;").unwrap();
|
||||
receipts = rows(&conn, "ticket_schema_migrations");
|
||||
history = rows(&conn, "typed_ticket_assignment_tracking_history");
|
||||
old_table = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name='ticket_worker_assignments'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute_batch("BEGIN IMMEDIATE; CREATE TABLE host_work(value TEXT); INSERT INTO host_work VALUES('keep');").unwrap();
|
||||
assert!(migrate_sqlite_ticket_schema_in_transaction(&conn).is_err());
|
||||
assert!(!conn.is_autocommit());
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
|
||||
assert_eq!(
|
||||
rows(&conn, "typed_ticket_assignment_tracking_history"),
|
||||
history
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name='ticket_worker_assignments'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
old_table
|
||||
);
|
||||
assert!(
|
||||
!conn
|
||||
.pragma_query_value(None, "writable_schema", |r| r.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
conn.execute_batch("COMMIT").unwrap();
|
||||
}
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
|
||||
compose_historical(&conn, HistoricalTicketSchemaTarget::Content13);
|
||||
assert_eq!(max_component(&conn), 13);
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), receipts);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT value FROM host_work", [], |r| r.get::<_, String>(0))
|
||||
.unwrap(),
|
||||
"keep"
|
||||
);
|
||||
conn.execute_batch("DROP TRIGGER host_reject_subject_migration")
|
||||
.unwrap();
|
||||
migrate_sqlite_ticket_schema(&conn).unwrap();
|
||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
assert_eq!(max_component(&conn), 14);
|
||||
assert_eq!(
|
||||
&rows(&conn, "ticket_schema_migrations")[..receipts.len()],
|
||||
receipts.as_slice()
|
||||
);
|
||||
assert_eq!(
|
||||
rows(&conn, "typed_ticket_assignment_tracking_history"),
|
||||
history
|
||||
);
|
||||
drop(conn);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
let applied14 = rows(&conn, "ticket_schema_migrations");
|
||||
compose_historical(&conn, HistoricalTicketSchemaTarget::Content13);
|
||||
assert_eq!(rows(&conn, "ticket_schema_migrations"), applied14);
|
||||
verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ fn schema12_migrates_body_authors_archived_history_and_stable_references_then_re
|
||||
|r| r.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
13
|
||||
14
|
||||
);
|
||||
backend
|
||||
.add_event("known".into(), comment("Next", "author"))
|
||||
|
||||
@@ -597,9 +597,9 @@ fn verifier_rejects_weakened_fact_constraints_trigger_and_migration_history() {
|
||||
assert!(migrate_sqlite_ticket_schema(&f.conn).is_err(), "{from}");
|
||||
}
|
||||
for mutation in [
|
||||
"UPDATE ticket_schema_migrations SET name='unrecognized' WHERE version=13",
|
||||
"UPDATE ticket_schema_migrations SET name='unrecognized' WHERE version=14",
|
||||
"INSERT INTO ticket_schema_migrations VALUES(12,'Canonical Ticket posts','invalid extra history')",
|
||||
"UPDATE ticket_schema_migrations SET version=14 WHERE version=13",
|
||||
"UPDATE ticket_schema_migrations SET version=15 WHERE version=14",
|
||||
] {
|
||||
let f = Fixture::new();
|
||||
f.conn.execute_batch(mutation).unwrap();
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::feature::{
|
||||
|
||||
const FEATURE_ID: &str = "orchestration";
|
||||
const TOOL_NAME: &str = "SpawnTicketWorker";
|
||||
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); repository/Workdir attachments are optional. For builtin:coder (or coder), omitted flow defaults to builtin:coder-review so the Coder owns the Reviewer SubWorker cycle and MR verdict. Explicit flow: null disables Flow; a string selects another Flow. Other profiles get no default Flow. Results and return choices follow user intent, not a universal approval or MR gate. Orchestrator starts must supply the observed orchestrator_assignment_id; stale delegations cannot start new work. Assign is enough (no Queue/ready); unresolved dependencies are diagnostics for partial work and never start implicitly. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
|
||||
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); repository/Workdir attachments are optional. For builtin:coder (or coder), omitted flow defaults to builtin:coder-review so the Coder owns the Reviewer SubWorker cycle and MR verdict. Explicit flow: null disables Flow; a string selects another Flow. Other profiles get no default Flow. Results and return choices follow user intent, not a universal approval or MR gate. Assigned Subject starts must supply the observed orchestrator_assignment_id; stale delegations cannot start new work. Assign is enough (no Queue/ready); unresolved dependencies are diagnostics for partial work and never start implicitly. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OrchestrationFeature;
|
||||
@@ -84,7 +84,7 @@ struct SpawnTicketWorkerWorkdirInput {
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SpawnTicketWorkerInput {
|
||||
ticket_id: String,
|
||||
/// Observed current delegation identity. Required by Backend for an Orchestrator start; manual callers omit it.
|
||||
/// Observed current delegation identity. Required by Backend for an assigned Subject start; manual callers omit it.
|
||||
#[serde(default)]
|
||||
orchestrator_assignment_id: Option<String>,
|
||||
runtime_id: String,
|
||||
@@ -103,6 +103,10 @@ struct SpawnTicketWorkerInput {
|
||||
/// Workdirs. Backend derives effective capabilities; callers cannot request them.
|
||||
#[serde(default)]
|
||||
workdir_attachments: Vec<SpawnTicketWorkerWorkdirInput>,
|
||||
/// Existing general delegation contract. Backend validates the live Subject
|
||||
/// ceiling and requested Profile; Ticket assignment grants no capabilities.
|
||||
#[serde(default)]
|
||||
execution_grant: Option<server_api::authorization::WorkerDelegationRequest>,
|
||||
}
|
||||
|
||||
// Serde's ordinary Option would collapse explicit null and an absent field.
|
||||
@@ -170,7 +174,7 @@ impl Tool for SpawnTicketWorkerTool {
|
||||
operation_id: Some(format!("spawn-ticket-worker:{}:{call_id}", ticket.id)),
|
||||
display_name: format!("Worker · {}", ticket.resource_key),
|
||||
initial_submit,
|
||||
execution_grant: None,
|
||||
execution_grant: input.execution_grant,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
@@ -339,6 +343,12 @@ mod tests {
|
||||
let mut input = input();
|
||||
input["flow"] = serde_json::json!("project:investigation");
|
||||
input["orchestrator_assignment_id"] = serde_json::json!("delegation-742");
|
||||
input["execution_grant"] = serde_json::json!({
|
||||
"permissions": [{"operation":"ticket.read","resource":{"kind":"ticket","workspace_id":"workspace-1","ticket_id":"T-719"}}],
|
||||
"redelegation": []
|
||||
});
|
||||
let expected_grant: server_api::authorization::WorkerDelegationRequest =
|
||||
serde_json::from_value(input["execution_grant"].clone()).unwrap();
|
||||
input["workdir_attachments"] = serde_json::json!([
|
||||
{
|
||||
"alias": "checkout",
|
||||
@@ -359,6 +369,7 @@ mod tests {
|
||||
assert_eq!(requests.len(), 1);
|
||||
let request = &requests[0];
|
||||
assert_eq!(request.runtime_id, "runtime-1");
|
||||
assert_eq!(request.execution_grant.as_ref(), Some(&expected_grant));
|
||||
assert_eq!(
|
||||
request.orchestrator_assignment_id.as_deref(),
|
||||
Some("delegation-742")
|
||||
|
||||
@@ -145,13 +145,13 @@ impl PublicAssignmentKind {
|
||||
pub(super) fn description(name: &str) -> &'static str {
|
||||
match name {
|
||||
"TicketOrchestratorAssign" => {
|
||||
"Explicitly delegate or reissue this Ticket to a target Orchestrator principal using the existing role-assignment API. Query current assignment first; provide observed expected_assignment_id (explicit null only when unassigned), target principal and stable operation_id. Assign persists initial waiting without ready/Queue. Explicit reissue after done creates a new row even for the same principal and preserves the old result. Exact retry keeps the same operation_id and full input; never automatically generate a new key after conflict or unknown outcome. Reread and reconcile stale responsibility. Saved Assign and notification failure are separate outcomes, not a reason to reassign. Backend authenticates the actor and validates role/principal; target principal is not an authority claim. This does not start/stop Workers, close the case, or authorize review/merge."
|
||||
"Explicitly delegate or reissue this Ticket to a target Subject principal using the existing role-assignment API. Query current assignment first; provide observed expected_assignment_id (explicit null only when unassigned), target principal and stable operation_id. Assign persists initial waiting without ready/Queue. Explicit reissue after done creates a new row even for the same principal and preserves the old result. Exact retry keeps the same operation_id and full input; never automatically generate a new key after conflict or unknown outcome. Reread and reconcile stale responsibility. Saved Assign and notification failure are separate outcomes, not a reason to reassign. Backend authenticates the actor and validates role/principal; target principal is not an authority claim. This does not start/stop Workers, close the case, or authorize review/merge."
|
||||
}
|
||||
"TicketOrchestratorClear" => {
|
||||
"Release the observed current Orchestrator delegation using assignment_id and stable operation_id. Query first; exact retry retains the same full input/key, stale conflict requires rereading, and unknown outcome is not automatic success/failure or a new operation. Preserve last handling/result and release history; do not turn release into done, Worker stop, work transfer or resource cleanup. Done/closed do not require clear. Actor authority is authenticated by Backend, never supplied by the model."
|
||||
"Release the observed current Subject delegation using assignment_id and stable operation_id. Query first; exact retry retains the same full input/key, stale conflict requires rereading, and unknown outcome is not automatic success/failure or a new operation. Preserve last handling/result and release history; do not turn release into done, Worker stop, work transfer or resource cleanup. Done/closed do not require clear. Actor authority is authenticated by Backend, never supplied by the model."
|
||||
}
|
||||
"TicketAssignmentsQuery" => {
|
||||
"Read a bounded page of retained Orchestrator assignment identities for this Ticket, role/principal, handling and independent release/replacement history. Use after_assignment_id from the returned next_assignment_id, limit 1..100. Historical done/results are not new work or execution authority. Use current tracking query before a new mutation; a replay receipt is not a fresh observation."
|
||||
"Read a bounded page of retained Subject assignment identities for this Ticket, role/principal, handling and independent release/replacement history. Use after_assignment_id from the returned next_assignment_id, limit 1..100. Historical done/results are not new work or execution authority. Use current tracking query before a new mutation; a replay receipt is not a fresh observation."
|
||||
}
|
||||
"TicketAssignmentDiagnostics" => {
|
||||
"Read bounded sanitized existing delivery/acceptance/start evidence for an assignment_id, with independent after_input_event_id and after_start_operation_id cursors (limit 1..100). Saved delegation, notification persistence, Runtime acceptance and actual processing are distinct; absent acknowledgement may mean unknown outcome, not failed Assign. History/unknown are not pending work. No rendered content or binding credentials are exposed. Never infer or update self-reported handling from these diagnostics, Worker liveness or MR state."
|
||||
@@ -343,19 +343,8 @@ async fn execute_value(
|
||||
identity("operation_id", &input.operation_id)?;
|
||||
optional_identity("expected_assignment_id", &input.expected_assignment_id)?;
|
||||
match &input.principal {
|
||||
server_api::TicketAssignmentPrincipal::User { account_id } => {
|
||||
identity("account_id", account_id)?
|
||||
}
|
||||
server_api::TicketAssignmentPrincipal::Worker {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => {
|
||||
identity("runtime_id", runtime_id)?;
|
||||
identity("worker_id", worker_id)?;
|
||||
}
|
||||
server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
|
||||
identity("agent_key", agent_key)?
|
||||
}
|
||||
server_api::TicketAssignmentPrincipal::Subject { subject_id } => identity("subject_id", subject_id)?,
|
||||
_ => return Err(ToolError::InvalidArgument("New Ticket responsibility requires an explicit Subject principal; legacy principals are audit-only".into())),
|
||||
}
|
||||
WorkspaceServerOperation::TicketAssignmentSet {
|
||||
ticket_id: input.ticket,
|
||||
|
||||
@@ -46,7 +46,7 @@ pub(super) fn cases() -> Vec<(PublicAssignmentKind, Value, Value)> {
|
||||
vec![
|
||||
(
|
||||
PublicAssignmentKind::Assign,
|
||||
json!({"ticket":"T-743", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}, "expected_assignment_id":null, "operation_id":"assign-1"}),
|
||||
json!({"ticket":"T-743", "principal":{"kind":"subject", "subject_id":"subject-a"}, "expected_assignment_id":null, "operation_id":"assign-1"}),
|
||||
json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}),
|
||||
),
|
||||
(
|
||||
@@ -164,6 +164,26 @@ async fn public_assignment_schemas_require_observations_and_exclude_model_actor_
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn new_assignment_rejects_retained_legacy_principals_before_transport() {
|
||||
for principal in [
|
||||
json!({"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}),
|
||||
json!({"kind":"user", "account_id":"owner"}),
|
||||
json!({"kind":"worker", "runtime_id":"r", "worker_id":"old-body"}),
|
||||
] {
|
||||
let client = transport(200, json!({}));
|
||||
let (_, tool) = definition(client.clone(), PublicAssignmentKind::Assign)();
|
||||
let input = json!({"ticket":"T-743", "principal":principal,
|
||||
"expected_assignment_id":null, "operation_id":"legacy-is-not-new-authority"});
|
||||
assert!(matches!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await,
|
||||
Err(ToolError::InvalidArgument(_))
|
||||
));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assign_reissue_and_clear_forward_observed_identity_and_exact_retry_without_new_key() {
|
||||
for (kind, mut input, response) in cases().into_iter().filter(|(kind, _, _)| {
|
||||
|
||||
@@ -79,7 +79,7 @@ pub(super) fn description(name: &str) -> &'static str {
|
||||
"Read bounded immutable history for one assignment_id, including released/replaced delegations. Continue with the returned event identity. Historical results do not grant execution rights or create a new request."
|
||||
}
|
||||
"TicketAssignmentTrackingUpdate" => {
|
||||
"Record the current Workspace Orchestrator's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Answers during waiting continue the same row; explicit reissue after done requires a new Assign row, not overwriting the previous result. Send the complete tracking payload. Exact retry uses the same operation_key and full input; different input with that key is rejected. On CAS/stale conflict reread current authority and reconcile; unknown outcome is not success or failure and must not become a fresh operation automatically. Backend authenticates the current registered Orchestrator; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations."
|
||||
"Record the current assigned Subject's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Answers during waiting continue the same row; explicit reissue after done requires a new Assign row, not overwriting the previous result. Send the complete tracking payload. Exact retry uses the same operation_key and full input; different input with that key is rejected. On CAS/stale conflict reread current authority and reconcile; unknown outcome is not success or failure and must not become a fresh operation automatically. Backend authenticates the current Subject body; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations."
|
||||
}
|
||||
_ => unreachable!("unknown assignment tracking tool"),
|
||||
}
|
||||
|
||||
@@ -99,6 +99,10 @@ fn principal() -> Shape {
|
||||
union(
|
||||
"kind",
|
||||
vec![
|
||||
case(
|
||||
"subject",
|
||||
Some(record(vec![p("subject_id", true, Shape::String)])),
|
||||
),
|
||||
case(
|
||||
"user",
|
||||
Some(record(vec![p("account_id", true, Shape::String)])),
|
||||
@@ -117,6 +121,15 @@ fn principal() -> Shape {
|
||||
],
|
||||
)
|
||||
}
|
||||
fn assignment_principal() -> Shape {
|
||||
union(
|
||||
"kind",
|
||||
vec![case(
|
||||
"subject",
|
||||
Some(record(vec![p("subject_id", true, Shape::String)])),
|
||||
)],
|
||||
)
|
||||
}
|
||||
fn reason() -> Shape {
|
||||
// An externally tagged domain enum is encoded by this single native union definition.
|
||||
Shape::ExternalUnion {
|
||||
@@ -387,7 +400,7 @@ pub(super) fn contract(op: &str) -> Contract {
|
||||
"orchestrator_assign",
|
||||
"Delegate or explicitly reissue responsibility; no Worker starts. Observed null means unassigned, not unknown. Exact retries retain operation identity and full input.",
|
||||
vec![
|
||||
p("principal", true, principal()),
|
||||
p("principal", true, assignment_principal()),
|
||||
group(
|
||||
"observed",
|
||||
true,
|
||||
|
||||
@@ -198,12 +198,32 @@ fn native_entry_inputs_use_allocated_namespace_and_never_subject_argument_ids()
|
||||
|
||||
#[test]
|
||||
fn assignment_principal_and_cas_are_declared_compounds_not_opaque_json() {
|
||||
let value = json!({"principal":{"$case":"worker","value":{"runtime_id":"r","worker_id":"w"}},"observed":{"assignment_id":{"$case":"null"}},"operation_id":"assign-1"});
|
||||
let value = json!({"principal":{"$case":"subject","value":{"subject_id":"subject-a"}},"observed":{"assignment_id":{"$case":"null"}},"operation_id":"assign-1"});
|
||||
let command = converted("orchestrator_assign", value.clone(), Some("T-42"));
|
||||
assert_eq!(
|
||||
command,
|
||||
json!({"ticket":"T-42","principal":{"kind":"worker","runtime_id":"r","worker_id":"w"},"expected_assignment_id":null,"operation_id":"assign-1"})
|
||||
json!({"ticket":"T-42","principal":{"kind":"subject","subject_id":"subject-a"},"expected_assignment_id":null,"operation_id":"assign-1"})
|
||||
);
|
||||
for legacy in [
|
||||
json!({"$case":"worker","value":{"runtime_id":"r","worker_id":"w"}}),
|
||||
json!({"$case":"user","value":{"account_id":"a"}}),
|
||||
json!({"$case":"workspace_agent","value":{"agent_key":"workspace-orchestrator"}}),
|
||||
] {
|
||||
let mut assignment = value.clone();
|
||||
assignment["principal"] = legacy;
|
||||
assert!(
|
||||
input(
|
||||
"orchestrator_assign",
|
||||
&arguments(assignment),
|
||||
Some("T-42"),
|
||||
Some("ticket"),
|
||||
"/cases",
|
||||
"/changes",
|
||||
"/goals"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for invalid in [
|
||||
{
|
||||
let mut v = value.clone();
|
||||
|
||||
@@ -606,7 +606,7 @@ mod tests {
|
||||
"only `open`",
|
||||
"stable tag IDs, not display names",
|
||||
"no ready step or separate Queue is required",
|
||||
"Orchestrator's self-reported handling",
|
||||
"assigned Subject's self-reported handling",
|
||||
"Answers during waiting or ongoing handling continue the same assignment row",
|
||||
"explicit reissue creates a new row/assignment_id",
|
||||
"Respect an explicit user approval condition",
|
||||
@@ -684,9 +684,9 @@ mod tests {
|
||||
)));
|
||||
for (policy, name, input) in [
|
||||
(
|
||||
"Use `TicketOrchestratorAssign` for explicit initial delegation or reissue",
|
||||
"Use `TicketOrchestratorAssign` for explicit initial assignment, reassignment or reissue to the selected Subject principal",
|
||||
"TicketOrchestratorAssign",
|
||||
serde_json::json!({"ticket":"T-743","principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"expected_assignment_id":null,"operation_id":"delegate-research"}),
|
||||
serde_json::json!({"ticket":"T-743","principal":{"kind":"subject","subject_id":"subject-a"},"expected_assignment_id":null,"operation_id":"delegate-research"}),
|
||||
),
|
||||
(
|
||||
"research returned to a human can be `open` + handling `done`",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! Retained migration SQL establishes origins only for reconciled assignment-specific legacy
|
||||
//! Queue fences; migration does not reinterpret historical comments as new requests.
|
||||
//!
|
||||
//! Reserve and prepare recheck delegation, singleton and live binding under
|
||||
//! Reserve and prepare recheck delegation, Subject body and live binding under
|
||||
//! IMMEDIATE ownership. ACK instead verifies the target authorized before I/O;
|
||||
//! a later responsibility change must not erase Runtime acceptance evidence.
|
||||
//! Persist the entire rendered request before Runtime I/O, then reuse
|
||||
@@ -199,7 +199,7 @@ fn source_guard(
|
||||
workspace_id,
|
||||
&source.runtime_id,
|
||||
&source.worker_id,
|
||||
&source.agent_key,
|
||||
&source.subject_id,
|
||||
&source.authentication_binding_id,
|
||||
)
|
||||
}
|
||||
@@ -226,7 +226,31 @@ pub(super) fn require_current(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Call only after source_guard and require_current in the reservation/preparation
|
||||
// Runtime source authentication alone is insufficient: two live Subject bodies can
|
||||
// coexist in one Workspace. Fence the recipient's Subject before any receipt replay
|
||||
// or target authorization, in the same write transaction as the delivery/start.
|
||||
fn require_assignment_target(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
subject_id: &str,
|
||||
) -> Result<()> {
|
||||
require_current(conn, workspace_id, ticket_id, assignment_id)?;
|
||||
let matches: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 AND role='orchestrator' AND principal_kind='subject' AND principal_id=?4)",
|
||||
params![workspace_id, ticket_id, assignment_id, subject_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !matches {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Delivery/start target Subject differs from the current assignment".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Call only after source_guard and require_assignment_target in the reservation/preparation
|
||||
// transaction. A replacement target gets its own evidence for the same delivery,
|
||||
// not a new request or authority to revive a completed/released assignment.
|
||||
fn save_authorized_target(
|
||||
@@ -242,13 +266,30 @@ fn save_authorized_target(
|
||||
delivery,
|
||||
source.runtime_id,
|
||||
source.worker_id,
|
||||
source.agent_key,
|
||||
source.subject_id,
|
||||
source.authentication_binding_id
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_acknowledged(conn: &Connection, workspace: &str, delivery: &str) -> Result<bool> {
|
||||
Ok(conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2)",
|
||||
params![workspace, delivery], |row| row.get(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
fn require_unacknowledged(conn: &Connection, workspace: &str, delivery: &str) -> Result<()> {
|
||||
if is_acknowledged(conn, workspace, delivery)? {
|
||||
return Err(Error::TicketAssignmentConflict(
|
||||
"Assignment delivery already has Runtime acceptance; new send admission is closed"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn reserve(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
@@ -260,9 +301,9 @@ pub(super) fn reserve(
|
||||
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
source_guard(&tx,workspace_id,source)?;
|
||||
let ticket_id:String=tx.query_row("SELECT ticket_id FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,assignment_id],|r|r.get(0))?;
|
||||
require_current(&tx,workspace_id,&ticket_id,assignment_id)?;
|
||||
require_assignment_target(&tx,workspace_id,&ticket_id,assignment_id,&source.subject_id)?;
|
||||
let saved:Option<String>=tx.query_row("SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND assignment_id=?2 AND input_event_id=?3",params![workspace_id,assignment_id,input_event_id],|r|r.get(0)).optional()?;
|
||||
if let Some(saved)=saved {let result=decode_payload(&saved)?;save_authorized_target(&tx,workspace_id,&result.delivery_id,source)?;tx.commit()?;return Ok(result);}
|
||||
if let Some(saved)=saved {let result=decode_payload(&saved)?;require_unacknowledged(&tx,workspace_id,&result.delivery_id)?;save_authorized_target(&tx,workspace_id,&result.delivery_id,source)?;tx.commit()?;return Ok(result);}
|
||||
// A caller cannot fabricate an input. Restrict the same common query by
|
||||
// exact assignment/input identity rather than trusting a scan snapshot.
|
||||
let candidates=inputs_in_tx(&tx,workspace_id,1,Some(assignment_id),Some(input_event_id))?;
|
||||
@@ -295,7 +336,8 @@ pub(super) fn prepare_content(
|
||||
source_guard(&tx,workspace_id,source)?;
|
||||
let payload:String=tx.query_row("SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0))?;
|
||||
let delivery=decode_payload(&payload)?;
|
||||
require_current(&tx,workspace_id,&delivery.ticket_id,&delivery.assignment_id)?;
|
||||
require_assignment_target(&tx,workspace_id,&delivery.ticket_id,&delivery.assignment_id,&source.subject_id)?;
|
||||
require_unacknowledged(&tx,workspace_id,delivery_id)?;
|
||||
save_authorized_target(&tx,workspace_id,delivery_id,source)?;
|
||||
let saved:Option<String>=tx.query_row("SELECT content FROM ticket_assignment_delivery_requests WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0)).optional()?;
|
||||
let result=if let Some(saved)=saved {saved} else {
|
||||
@@ -307,6 +349,46 @@ pub(super) fn prepare_content(
|
||||
})
|
||||
}
|
||||
|
||||
/// Final send admission is distinct from reading a receipt or replaying an ACK.
|
||||
/// Recheck the current Subject/assignment and prepared target, then return only
|
||||
/// the pinned bytes. A known ACK closes admission; an unknown outcome keeps the
|
||||
/// same request eligible. The caller still authorizes live policy immediately
|
||||
/// before Runtime I/O; the transaction does not serialize external acceptance.
|
||||
pub(super) fn admit_send(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
delivery_id: &str,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<Option<String>> {
|
||||
store.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
source_guard(&tx, workspace_id, source)?;
|
||||
let payload: String = tx.query_row(
|
||||
"SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND delivery_id=?2",
|
||||
params![workspace_id, delivery_id], |row| row.get(0),
|
||||
)?;
|
||||
let delivery = decode_payload(&payload)?;
|
||||
require_assignment_target(&tx, workspace_id, &delivery.ticket_id, &delivery.assignment_id, &source.subject_id)?;
|
||||
let authorized: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM notification_assignment_targets WHERE workspace_id=?1 AND delivery_id=?2 AND runtime_id=?3 AND worker_id=?4 AND agent_key=?5 AND authentication_binding_id=?6)",
|
||||
params![workspace_id, delivery_id, source.runtime_id, source.worker_id, source.subject_id, source.authentication_binding_id], |row| row.get(0),
|
||||
)?;
|
||||
if !authorized {
|
||||
return Err(Error::WorkspacePermissionDenied("Assignment send target was not prepared or reserved for this delivery".into()));
|
||||
}
|
||||
let content = if is_acknowledged(&tx, workspace_id, delivery_id)? {
|
||||
None
|
||||
} else {
|
||||
Some(tx.query_row(
|
||||
"SELECT content FROM ticket_assignment_delivery_requests WHERE workspace_id=?1 AND delivery_id=?2",
|
||||
params![workspace_id, delivery_id], |row| row.get(0),
|
||||
).optional()?.ok_or_else(|| Error::TicketAssignmentConflict("Assignment send requires a persisted request".into()))?)
|
||||
};
|
||||
tx.commit()?;
|
||||
Ok(content)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn ack(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace_id: &str,
|
||||
@@ -319,7 +401,7 @@ pub(super) fn ack(
|
||||
// authenticated target identity fields against evidence saved while current.
|
||||
// Actor is audit attribution; it is not target authority. Existing ACK bytes
|
||||
// still have to match on replay and are never replaced.
|
||||
let authorized:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM notification_assignment_targets WHERE workspace_id=?1 AND delivery_id=?2 AND runtime_id=?3 AND worker_id=?4 AND agent_key=?5 AND authentication_binding_id=?6)",params![workspace_id,delivery_id,source.runtime_id,source.worker_id,source.agent_key,source.authentication_binding_id],|r|r.get(0))?;
|
||||
let authorized:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM notification_assignment_targets WHERE workspace_id=?1 AND delivery_id=?2 AND runtime_id=?3 AND worker_id=?4 AND agent_key=?5 AND authentication_binding_id=?6)",params![workspace_id,delivery_id,source.runtime_id,source.worker_id,source.subject_id,source.authentication_binding_id],|r|r.get(0))?;
|
||||
if !authorized {return Err(Error::WorkspacePermissionDenied("Acknowledgement target was not authorized for this delivery".into()));}
|
||||
let source_json=serde_json::to_string(source).map_err(|e|Error::Store(e.to_string()))?;
|
||||
let saved:Option<String>=tx.query_row("SELECT source_json FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0)).optional()?;
|
||||
@@ -339,16 +421,16 @@ pub(super) fn operation_guard(
|
||||
operation_id: &str,
|
||||
) -> Result<()> {
|
||||
let fence=conn.query_row("SELECT o.ticket_id,f.orchestrator_assignment_id,f.runtime_id,f.worker_id,f.agent_key,f.authentication_binding_id FROM ticket_assignment_operation_fences f JOIN ticket_assignment_operations o ON o.workspace_id=f.workspace_id AND o.operation_id=f.operation_id WHERE f.workspace_id=?1 AND f.operation_id=?2",params![workspace_id,operation_id],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,String>(5)?))).optional()?;
|
||||
if let Some((ticket_id, assignment_id, runtime_id, worker_id, agent_key, binding_id)) = fence {
|
||||
if let Some((ticket_id, assignment_id, runtime_id, worker_id, subject_id, binding_id)) = fence {
|
||||
assignment_tracking::require_runtime_source(
|
||||
conn,
|
||||
workspace_id,
|
||||
&runtime_id,
|
||||
&worker_id,
|
||||
&agent_key,
|
||||
&subject_id,
|
||||
&binding_id,
|
||||
)?;
|
||||
require_current(conn, workspace_id, &ticket_id, &assignment_id)?;
|
||||
require_assignment_target(conn, workspace_id, &ticket_id, &assignment_id, &subject_id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -363,7 +445,13 @@ pub(super) fn save_operation_fence(
|
||||
existing: bool,
|
||||
) -> Result<()> {
|
||||
source_guard(conn, workspace_id, source)?;
|
||||
require_current(conn, workspace_id, ticket_id, assignment_id)?;
|
||||
require_assignment_target(
|
||||
conn,
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
assignment_id,
|
||||
&source.subject_id,
|
||||
)?;
|
||||
let source_json = serde_json::to_string(source).map_err(|e| Error::Store(e.to_string()))?;
|
||||
let saved:Option<(String,String)>=conn.query_row("SELECT orchestrator_assignment_id,source_json FROM ticket_assignment_operation_fences WHERE workspace_id=?1 AND operation_id=?2",params![workspace_id,operation_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()?;
|
||||
if let Some(saved) = saved {
|
||||
@@ -386,7 +474,7 @@ pub(super) fn save_operation_fence(
|
||||
assignment_id,
|
||||
source.runtime_id,
|
||||
source.worker_id,
|
||||
source.agent_key,
|
||||
source.subject_id,
|
||||
source.authentication_binding_id,
|
||||
source_json
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ pub enum TicketOrchestratorAssignmentSourceContext {
|
||||
Runtime {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
#[serde(rename = "subject_id")]
|
||||
agent_key: String,
|
||||
authentication_binding_id: String,
|
||||
},
|
||||
@@ -46,9 +47,25 @@ pub(super) fn require_runtime_source(
|
||||
agent_key: &str,
|
||||
binding_id: &str,
|
||||
) -> Result<()> {
|
||||
let current_source:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM worker_singleton_owners singleton JOIN workspace_runtime_bindings binding ON binding.workspace_id=singleton.workspace_id AND binding.runtime_id=singleton.runtime_id WHERE singleton.workspace_id=?1 AND singleton.singleton_key=?2 AND singleton.runtime_id=?3 AND singleton.worker_id=?4 AND binding.binding_id=?5 AND binding.revoked_at IS NULL)",params![workspace_id,crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY,runtime_id,worker_id,binding_id],|row|row.get(0))?;
|
||||
if agent_key != "workspace-orchestrator" || binding_id.trim().is_empty() || !current_source {
|
||||
return Err(Error::WorkspacePermissionDenied("Assignment source is not the current registered Orchestrator with its authenticated Runtime binding".into()));
|
||||
// The legacy field name is retained in immutable source receipts, but only a
|
||||
// Subject identity can authenticate this self-report/delivery boundary.
|
||||
// In particular, never reinterpret the old workspace-agent identity.
|
||||
if agent_key == "workspace-orchestrator" || agent_key.trim().is_empty() {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Legacy assignment sources are audit-only".into(),
|
||||
));
|
||||
}
|
||||
let key = crate::subjektiv::subject_worker_singleton_key(agent_key)
|
||||
.map_err(|error| Error::WorkspacePermissionDenied(error.to_string()))?;
|
||||
let current_source: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM worker_singleton_owners s
|
||||
JOIN worker_create_reservations r ON r.workspace_id=s.workspace_id AND r.runtime_id=s.runtime_id AND r.worker_id=s.worker_id AND r.singleton_key=s.singleton_key AND r.singleton_generation=s.generation AND r.state!='removed'
|
||||
JOIN workspace_runtime_bindings b ON b.workspace_id=s.workspace_id AND b.runtime_id=s.runtime_id
|
||||
WHERE s.workspace_id=?1 AND s.singleton_key=?2 AND s.runtime_id=?3 AND s.worker_id=?4 AND b.binding_id=?5 AND b.revoked_at IS NULL
|
||||
AND NOT EXISTS(SELECT 1 FROM subject_deletions d WHERE d.workspace_id=s.workspace_id AND d.subject_id=?6))",
|
||||
params![workspace_id, key, runtime_id, worker_id, binding_id, agent_key], |row| row.get(0))?;
|
||||
if binding_id.trim().is_empty() || !current_source {
|
||||
return Err(Error::WorkspacePermissionDenied("Assignment source is not the current Subject body with its authenticated Runtime binding and generation".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -64,14 +81,25 @@ pub(super) fn require_assignment_source(
|
||||
worker_id,
|
||||
agent_key,
|
||||
authentication_binding_id,
|
||||
} => require_runtime_source(
|
||||
conn,
|
||||
workspace_id,
|
||||
runtime_id,
|
||||
worker_id,
|
||||
agent_key,
|
||||
authentication_binding_id,
|
||||
),
|
||||
} => {
|
||||
let live: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM workspace_runtime_bindings b JOIN worker_registry w ON w.workspace_id=b.workspace_id AND w.runtime_id=b.runtime_id WHERE b.workspace_id=?1 AND b.runtime_id=?2 AND b.binding_id=?3 AND b.revoked_at IS NULL AND w.worker_id=?4)",params![workspace_id,runtime_id,authentication_binding_id,worker_id], |r|r.get(0))?;
|
||||
if !live || agent_key == "workspace-orchestrator" {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"Assignment source binding is no longer live".into(),
|
||||
));
|
||||
}
|
||||
if !agent_key.is_empty() {
|
||||
require_runtime_source(
|
||||
conn,
|
||||
workspace_id,
|
||||
runtime_id,
|
||||
worker_id,
|
||||
agent_key,
|
||||
authentication_binding_id,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
TicketOrchestratorAssignmentSourceContext::Account {
|
||||
account_id,
|
||||
user_id,
|
||||
@@ -110,7 +138,7 @@ pub(super) fn update(
|
||||
workspace_id,
|
||||
&source.runtime_id,
|
||||
&source.worker_id,
|
||||
&source.agent_key,
|
||||
&source.subject_id,
|
||||
&source.authentication_binding_id,
|
||||
)?;
|
||||
let result = ticket::sqlite_update_ticket_assignment_tracking_in_transaction(
|
||||
|
||||
@@ -4,7 +4,7 @@ fn tracking_store_source() -> TicketAssignmentTrackingSourceContext {
|
||||
actor: "authenticated-worker:r/worker".into(),
|
||||
runtime_id: "r".into(),
|
||||
worker_id: "worker".into(),
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
subject_id: "tracking-subject".into(),
|
||||
authentication_binding_id: "tracking-binding".into(),
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,9 @@ async fn tracking_store_fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) {
|
||||
seed_manual_start_ticket(&store, "tracking", "open");
|
||||
store.with_conn(|conn| {
|
||||
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('w','r','Runtime','https://runtime.invalid','verified-fixture-key','verified-fixture-fingerprint','tracking-binding','workspace_identity','1','1')",[])?;
|
||||
conn.execute("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) VALUES('w',?1,'r','worker',1,'1','1')",[crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY])?;
|
||||
conn.execute("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) VALUES('w',?1,'r','worker',1,'1','1')",["subjektiv:tracking-subject"])?;
|
||||
conn.execute("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES('w','r','replacement','Replacement','normal','1','1')",[])?;
|
||||
conn.execute("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at,singleton_key,singleton_generation) VALUES('w','tracking-body','worker','r','tracking-body','created','1','1','subjektiv:tracking-subject',1)",[])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
(directory, store)
|
||||
@@ -29,8 +30,8 @@ fn tracking_store_assign(
|
||||
let assignment = TicketRoleAssignmentRecord {
|
||||
assignment_id: id.into(),
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
principal: TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "tracking-subject".into(),
|
||||
},
|
||||
..manual_worker_assignment("tracking", id)
|
||||
};
|
||||
@@ -62,7 +63,15 @@ fn tracking_store_input(id: &str, event: &str, key: &str) -> TicketAssignmentTra
|
||||
#[tokio::test]
|
||||
async fn tracking_store_every_replay_fences_current_singleton_and_authenticated_binding_under_write_lock()
|
||||
{
|
||||
for change in ["replacement", "revoke", "binding-change", "remove"] {
|
||||
for change in [
|
||||
"replacement",
|
||||
"revoke",
|
||||
"binding-change",
|
||||
"generation",
|
||||
"reservation-removed",
|
||||
"subject-deleted",
|
||||
"remove",
|
||||
] {
|
||||
let (directory, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store, "a", "assign-a");
|
||||
let source = tracking_store_source();
|
||||
@@ -86,6 +95,9 @@ async fn tracking_store_every_replay_fences_current_singleton_and_authenticated_
|
||||
"replacement" => {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'",[])?;},
|
||||
"binding-change" => {tx.execute("UPDATE workspace_runtime_bindings SET binding_id='new-binding' WHERE workspace_id='w'",[])?;},
|
||||
"generation" => {tx.execute("UPDATE worker_create_reservations SET singleton_generation=2 WHERE workspace_id='w'",[])?;},
|
||||
"reservation-removed" => {tx.execute("UPDATE worker_create_reservations SET state='removed' WHERE workspace_id='w'",[])?;},
|
||||
"subject-deleted" => {tx.execute("INSERT INTO subject_deletions(workspace_id,subject_id,operation_id) VALUES('w','tracking-subject','delete-subject')",[])?;},
|
||||
_ => {tx.execute("DELETE FROM worker_singleton_owners WHERE workspace_id='w'",[])?;},
|
||||
}
|
||||
tx.commit()?;Ok(())
|
||||
@@ -470,17 +482,37 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
|
||||
95,
|
||||
"Common notification routing and durable Worker delivery".into()
|
||||
),
|
||||
(96,"Backend Job common notification routing".into()),
|
||||
(97, "MR notifications and authenticated implementation results".into()),
|
||||
(96, "Backend Job common notification routing".into()),
|
||||
(
|
||||
97,
|
||||
"MR notifications and authenticated implementation results".into()
|
||||
),
|
||||
(98, "Assignment-independent implementation reports".into()),
|
||||
(99, "Explicit hosting administrators and self-registration policy".into()),
|
||||
(100, "Provisional users and scoped passkey activation".into()),
|
||||
(
|
||||
99,
|
||||
"Explicit hosting administrators and self-registration policy".into()
|
||||
),
|
||||
(
|
||||
100,
|
||||
"Provisional users and scoped passkey activation".into()
|
||||
),
|
||||
(101, "Durable explicit Subject body release".into()),
|
||||
(102, "Durable failed Subject creation recovery".into()),
|
||||
(103, "Passkey metadata and atomic addition results".into()),
|
||||
(104, "Converge Passkey results and Job input retention".into()),
|
||||
(105, "Durable Subject deletion snapshots and Job admission fences".into()),
|
||||
(106, crate::plugin_catalog::MIGRATION_NAME.into())
|
||||
(
|
||||
104,
|
||||
"Converge Passkey results and Job input retention".into()
|
||||
),
|
||||
(
|
||||
105,
|
||||
"Durable Subject deletion snapshots and Job admission fences".into()
|
||||
),
|
||||
(106, crate::plugin_catalog::MIGRATION_NAME.into()),
|
||||
(
|
||||
107,
|
||||
"Subject Ticket responsibility and current-body notification routing"
|
||||
.into()
|
||||
)
|
||||
]
|
||||
);
|
||||
for (table, rows) in retained.iter().zip(original.iter()) {
|
||||
@@ -554,10 +586,10 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first_self_report() {
|
||||
fn tracking_store_schema91_closed_unknown_assignment_retains_assign_audit_but_legacy_cannot_self_report()
|
||||
{
|
||||
use ticket::TicketBackend;
|
||||
|
||||
for report_after_reopen in [false, true] {
|
||||
for reopen_before_report in [false, true] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("closed-unknown91.db");
|
||||
let conn = tracking_schema91_fixture(&path);
|
||||
@@ -568,27 +600,8 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first
|
||||
let grants_before = stored_fixture_rows(&conn, "worker_control_grants");
|
||||
let releases_before = stored_fixture_rows(&conn, "ticket_assignment_work_releases");
|
||||
drop(conn);
|
||||
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
let backend = ticket::SqliteTicketBackend::open_verified(&path, "w").unwrap();
|
||||
let reopen = || {
|
||||
let observed = backend.show("historical-done".into()).unwrap();
|
||||
backend
|
||||
.update_case(
|
||||
"historical-done",
|
||||
ticket::TicketCaseUpdate {
|
||||
expected_content_digest: ticket::ticket_content_digest(&observed),
|
||||
expected_state: ticket::TicketState::Closed,
|
||||
expected_closure_operation_key: observed.closure_operation_key.clone(),
|
||||
operation_key: "explicit-reopen".into(),
|
||||
action: ticket::TicketCaseAction::Reopen,
|
||||
explanation: "Reopen without redelegation or restoring old work".into(),
|
||||
references: vec![],
|
||||
author: Some("account-author".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
let unknown = store
|
||||
.get_ticket_assignment_tracking("w", "historical-done", "a-historical-done")
|
||||
.unwrap()
|
||||
@@ -602,11 +615,59 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first
|
||||
Some("assigned-historical-done")
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_current_ticket_assignment_tracking("w", "historical-done")
|
||||
.unwrap(),
|
||||
vec![unknown.clone()]
|
||||
unknown.principal,
|
||||
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into()
|
||||
}
|
||||
);
|
||||
if reopen_before_report {
|
||||
let observed = backend.show("historical-done".into()).unwrap();
|
||||
backend
|
||||
.update_case(
|
||||
"historical-done",
|
||||
ticket::TicketCaseUpdate {
|
||||
expected_content_digest: ticket::ticket_content_digest(&observed),
|
||||
expected_state: ticket::TicketState::Closed,
|
||||
expected_closure_operation_key: observed.closure_operation_key,
|
||||
operation_key: "explicit-reopen".into(),
|
||||
action: ticket::TicketCaseAction::Reopen,
|
||||
explanation: "Reopen is not Subject assignment or Worker restore".into(),
|
||||
references: vec![],
|
||||
author: Some("account-author".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut legacy = tracking_store_source();
|
||||
legacy.subject_id = "workspace-orchestrator".into();
|
||||
let input = tracking_store_input(
|
||||
"a-historical-done",
|
||||
unknown.last_event_id.as_deref().unwrap(),
|
||||
"legacy-result",
|
||||
);
|
||||
// Both the old identity and an unrelated Subject must fail even when
|
||||
// they possess the authentic historical Assign observation.
|
||||
let second = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
for source in [&legacy, &tracking_store_source()] {
|
||||
for observer in [&store, &second] {
|
||||
assert!(matches!(
|
||||
observer.update_ticket_assignment_tracking(
|
||||
"w",
|
||||
"historical-done",
|
||||
&input,
|
||||
source
|
||||
),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
));
|
||||
assert_eq!(
|
||||
observer
|
||||
.get_ticket_assignment_tracking("w", "historical-done", "a-historical-done")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
unknown
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
store
|
||||
.list_ticket_assignment_tracking_history(
|
||||
@@ -617,129 +678,6 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
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))?, 13);
|
||||
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");
|
||||
assert_eq!(stored_fixture_rows(conn, "ticket_worker_assignment_events"), audit_before);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
if report_after_reopen {
|
||||
reopen();
|
||||
}
|
||||
let source = tracking_store_source();
|
||||
let input = tracking_store_input(
|
||||
"a-historical-done",
|
||||
unknown.last_event_id.as_deref().unwrap(),
|
||||
"first-explicit-result",
|
||||
);
|
||||
let mut wrong_source = source.clone();
|
||||
wrong_source.worker_id = "not-current-orchestrator".into();
|
||||
assert!(matches!(
|
||||
store.update_ticket_assignment_tracking("w", "historical-done", &input, &wrong_source),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
));
|
||||
for (workspace, ticket) in [("other-workspace", "historical-done"), ("w", "pending")] {
|
||||
assert!(
|
||||
store
|
||||
.update_ticket_assignment_tracking(workspace, ticket, &input, &source)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
store
|
||||
.update_ticket_assignment_tracking(
|
||||
"w",
|
||||
"replaced",
|
||||
&tracking_store_input(
|
||||
"a-replaced",
|
||||
"assigned-replaced",
|
||||
"wrong-old-assignment"
|
||||
),
|
||||
&source
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
// A second observer holds the same Assign baseline. Only the first
|
||||
// explicit writer may consume it; BEGIN IMMEDIATE serializes the CAS.
|
||||
let second = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
assert_eq!(
|
||||
second
|
||||
.get_ticket_assignment_tracking("w", "historical-done", "a-historical-done")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
unknown
|
||||
);
|
||||
let done = store
|
||||
.update_ticket_assignment_tracking("w", "historical-done", &input, &source)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
done.tracking.as_ref().unwrap().status,
|
||||
TicketAssignmentTrackingStatus::Done
|
||||
);
|
||||
assert_eq!(done.assignment_id, unknown.assignment_id);
|
||||
assert_ne!(done.last_event_id, unknown.last_event_id);
|
||||
assert_eq!(
|
||||
store
|
||||
.update_ticket_assignment_tracking("w", "historical-done", &input, &source)
|
||||
.unwrap(),
|
||||
done
|
||||
);
|
||||
assert!(matches!(
|
||||
second.update_ticket_assignment_tracking(
|
||||
"w",
|
||||
"historical-done",
|
||||
&tracking_store_input(
|
||||
"a-historical-done",
|
||||
"assigned-historical-done",
|
||||
"stale-second-observer"
|
||||
),
|
||||
&source
|
||||
),
|
||||
Err(Error::TicketAssignmentConflict(_))
|
||||
));
|
||||
assert_eq!(
|
||||
backend
|
||||
.show("historical-done".into())
|
||||
.unwrap()
|
||||
.meta
|
||||
.workflow_state,
|
||||
if report_after_reopen {
|
||||
ticket::TicketState::Open
|
||||
} else {
|
||||
ticket::TicketState::Closed
|
||||
}
|
||||
);
|
||||
if !report_after_reopen {
|
||||
reopen();
|
||||
}
|
||||
let after_reopen = store
|
||||
.update_ticket_assignment_tracking(
|
||||
"w",
|
||||
"historical-done",
|
||||
&tracking_store_input(
|
||||
"a-historical-done",
|
||||
done.last_event_id.as_deref().unwrap(),
|
||||
"explicit-result-after-reopen",
|
||||
),
|
||||
&source,
|
||||
)
|
||||
.unwrap();
|
||||
let history = store
|
||||
.list_ticket_assignment_tracking_history("w", "historical-done", "a-historical-done")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
history.len(),
|
||||
2,
|
||||
"only explicit self-reports create handling events"
|
||||
);
|
||||
assert_eq!(history[0].event_id, done.last_event_id.clone().unwrap());
|
||||
assert_eq!(
|
||||
history[1].event_id,
|
||||
after_reopen.last_event_id.clone().unwrap()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_active_ticket_role_assignment(
|
||||
@@ -751,56 +689,40 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first
|
||||
.is_none()
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments WHERE workspace_id='w' AND ticket_id='historical-done'", [], |row| row.get::<_,i64>(0))?, 1);
|
||||
assert_eq!(stored_fixture_rows(conn, "ticket_worker_assignment_events"), audit_before);
|
||||
assert_eq!(stored_fixture_rows(conn, "worker_control_grants"), grants_before);
|
||||
assert_eq!(stored_fixture_rows(conn, "ticket_assignment_work_releases"), releases_before);
|
||||
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), "Assign audit baseline must not manufacture handling or authority");
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| row.get::<_,i64>(0))?, 0);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert_eq!(
|
||||
backend
|
||||
.show("historical-done".into())
|
||||
.unwrap()
|
||||
.meta
|
||||
.workflow_state,
|
||||
if reopen_before_report {
|
||||
ticket::TicketState::Open
|
||||
} else {
|
||||
ticket::TicketState::Closed
|
||||
}
|
||||
);
|
||||
drop(second);
|
||||
drop(store);
|
||||
let reopened_store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
assert_eq!(
|
||||
reopened_store
|
||||
reopened
|
||||
.get_ticket_assignment_tracking("w", "historical-done", "a-historical-done")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
after_reopen
|
||||
);
|
||||
assert!(
|
||||
reopened_store
|
||||
.clear_current_ticket_orchestrator_assignment(
|
||||
"w",
|
||||
"historical-done",
|
||||
"a-historical-done",
|
||||
"explicit-release-event",
|
||||
"explicit-release",
|
||||
"human",
|
||||
"3",
|
||||
Some("Return responsibility"),
|
||||
&orchestrator_mutation_runtime_source()
|
||||
)
|
||||
.unwrap()
|
||||
unknown
|
||||
);
|
||||
assert!(matches!(
|
||||
reopened_store.update_ticket_assignment_tracking(
|
||||
"w",
|
||||
"historical-done",
|
||||
&tracking_store_input(
|
||||
"a-historical-done",
|
||||
after_reopen.last_event_id.as_deref().unwrap(),
|
||||
"new-report-after-release"
|
||||
),
|
||||
&source
|
||||
),
|
||||
Err(Error::TicketAssignmentConflict(_))
|
||||
reopened.update_ticket_assignment_tracking("w", "historical-done", &input, &legacy),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
));
|
||||
let released = reopened_store
|
||||
.get_ticket_assignment_tracking("w", "historical-done", "a-historical-done")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!released.current);
|
||||
assert_eq!(released.tracking, after_reopen.tracking);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,7 +780,7 @@ fn orchestrator_mutation_runtime_source() -> TicketOrchestratorAssignmentSourceC
|
||||
TicketOrchestratorAssignmentSourceContext::Runtime {
|
||||
runtime_id: "r".into(),
|
||||
worker_id: "worker".into(),
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
agent_key: "tracking-subject".into(),
|
||||
authentication_binding_id: "tracking-binding".into(),
|
||||
}
|
||||
}
|
||||
@@ -866,8 +788,8 @@ fn orchestrator_mutation_record(id: &str) -> TicketRoleAssignmentRecord {
|
||||
TicketRoleAssignmentRecord {
|
||||
assignment_id: id.into(),
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
principal: TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "tracking-subject".into(),
|
||||
},
|
||||
assigned_by: "readable Backend attribution".into(),
|
||||
..manual_worker_assignment("tracking", id)
|
||||
@@ -984,7 +906,7 @@ async fn orchestrator_assignment_receipts_bind_verified_source_even_when_readabl
|
||||
authentication_binding_id: "replacement-binding".into(),
|
||||
runtime_id: "r".into(),
|
||||
worker_id: "worker".into(),
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
agent_key: "tracking-subject".into(),
|
||||
};
|
||||
assert!(matches!(
|
||||
store.set_current_ticket_orchestrator_assignment(
|
||||
@@ -1349,8 +1271,8 @@ async fn tracking_history_pages_are_append_ordered_exact_scope_cursors_and_bound
|
||||
seed_manual_start_ticket(&store, "another-ticket", "open");
|
||||
let other_ticket = TicketRoleAssignmentRecord {
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
principal: TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "tracking-subject".into(),
|
||||
},
|
||||
..manual_worker_assignment("another-ticket", "other-ticket-assignment")
|
||||
};
|
||||
@@ -1521,7 +1443,7 @@ fn assignment_answer_source() -> TicketOrchestratorAssignmentSourceContext {
|
||||
TicketOrchestratorAssignmentSourceContext::Runtime {
|
||||
runtime_id: "r".into(),
|
||||
worker_id: "worker".into(),
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
agent_key: "tracking-subject".into(),
|
||||
authentication_binding_id: "tracking-binding".into(),
|
||||
}
|
||||
}
|
||||
@@ -1711,8 +1633,8 @@ async fn assignment_answer_new_input_rejects_replaced_or_changed_handling_but_ex
|
||||
);
|
||||
let mut replacement = manual_worker_assignment("tracking", "answer-b");
|
||||
replacement.role = TicketAssignmentRole::Orchestrator;
|
||||
replacement.principal = TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
replacement.principal = TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "tracking-subject".into(),
|
||||
};
|
||||
store
|
||||
.set_current_ticket_role_assignment(
|
||||
@@ -1775,8 +1697,8 @@ async fn assignment_identity_pages_keep_release_and_last_status_separate_and_rej
|
||||
tracking_store_assign(&store, "z-first", "first");
|
||||
let mut replacement = manual_worker_assignment("tracking", "a-second");
|
||||
replacement.role = TicketAssignmentRole::Orchestrator;
|
||||
replacement.principal = TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
replacement.principal = TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "tracking-subject".into(),
|
||||
};
|
||||
store
|
||||
.set_current_ticket_role_assignment(
|
||||
|
||||
@@ -458,6 +458,12 @@ impl SqliteWorkspaceAuthority {
|
||||
];
|
||||
if let Some(principal) = &query.assigned_principal {
|
||||
match principal {
|
||||
ticket::TicketAssignmentPrincipalSnapshot::Subject { subject_id } => {
|
||||
let value = bind(SqlValue::Text(subject_id.clone()));
|
||||
assignment.push(format!(
|
||||
"a.principal_kind='subject' AND a.principal_id={value}"
|
||||
));
|
||||
}
|
||||
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { agent_key } => {
|
||||
let value = bind(SqlValue::Text(agent_key.clone()));
|
||||
assignment.push(format!(
|
||||
@@ -854,6 +860,9 @@ impl SqliteWorkspaceAuthority {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
},
|
||||
TicketAssignmentPrincipal::Subject { subject_id } => {
|
||||
TicketAssignmentPrincipalSummary::Subject { subject_id }
|
||||
}
|
||||
TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
|
||||
TicketAssignmentPrincipalSummary::WorkspaceAgent { agent_key }
|
||||
}
|
||||
@@ -2136,6 +2145,7 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
|
||||
}
|
||||
if let Some(principal) = &query.assigned_principal {
|
||||
let ids: Vec<&str> = match principal {
|
||||
ticket::TicketAssignmentPrincipalSnapshot::Subject { subject_id } => vec![subject_id],
|
||||
ticket::TicketAssignmentPrincipalSnapshot::User { account_id } => vec![account_id],
|
||||
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { agent_key } => {
|
||||
vec![agent_key]
|
||||
@@ -3614,8 +3624,8 @@ mod tests {
|
||||
let orchestrator = crate::store::TicketRoleAssignmentRecord {
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
assignment_id: "orchestrator".into(),
|
||||
principal: TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "eligibility-subject".into(),
|
||||
},
|
||||
..record.clone()
|
||||
};
|
||||
|
||||
@@ -9,7 +9,9 @@ fn implementation_result_input() -> server_api::TicketImplementationResultReques
|
||||
}
|
||||
}
|
||||
|
||||
fn implementation_result_business_state(conn: &Connection) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
|
||||
fn implementation_result_business_state(
|
||||
conn: &Connection,
|
||||
) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
|
||||
[
|
||||
"typed_tickets",
|
||||
"typed_ticket_events",
|
||||
@@ -63,21 +65,50 @@ async fn implementation_result_without_any_assignment_is_authenticated_and_never
|
||||
let receipt = store
|
||||
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
|
||||
.unwrap();
|
||||
assert_eq!(store.with_conn(|conn| Ok(implementation_result_business_state(conn))).unwrap(), before);
|
||||
assert_eq!(store.implementation_result("w", "tracking", &receipt.result_id).unwrap(), receipt);
|
||||
assert_eq!(
|
||||
store
|
||||
.with_conn(|conn| Ok(implementation_result_business_state(conn)))
|
||||
.unwrap(),
|
||||
before
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.implementation_result("w", "tracking", &receipt.result_id)
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
// A second, independently authenticated Worker cannot reuse this Workspace operation key.
|
||||
let other = RuntimeWorkerRef::new("r", "worker");
|
||||
assert!(store.record_implementation_result("w", "tracking", &input, &other, "tracking-binding").is_err());
|
||||
assert!(
|
||||
store
|
||||
.record_implementation_result("w", "tracking", &input, &other, "tracking-binding")
|
||||
.is_err()
|
||||
);
|
||||
let mut other_input = input.clone();
|
||||
other_input.operation_key = "other-worker-result".into();
|
||||
store.record_implementation_result("w", "tracking", &other_input, &other, "tracking-binding").unwrap();
|
||||
store
|
||||
.record_implementation_result("w", "tracking", &other_input, &other, "tracking-binding")
|
||||
.unwrap();
|
||||
seed_manual_start_ticket(&store, "other-ticket", "open");
|
||||
assert!(store.record_implementation_result("w", "other-ticket", &input, &worker, "tracking-binding").is_err());
|
||||
assert!(
|
||||
store
|
||||
.record_implementation_result("w", "other-ticket", &input, &worker, "tracking-binding")
|
||||
.is_err()
|
||||
);
|
||||
let mut missing = input.clone();
|
||||
missing.operation_key = "missing-ticket-result".into();
|
||||
assert!(store.record_implementation_result("w", "missing", &missing, &worker, "tracking-binding").is_err());
|
||||
assert!(!store.list_pending_ticket_assignment_deliveries("w", 100).unwrap().iter()
|
||||
.any(|p| p.input_event_id.starts_with("implementation-result:")));
|
||||
assert!(
|
||||
store
|
||||
.record_implementation_result("w", "missing", &missing, &worker, "tracking-binding")
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p.input_event_id.starts_with("implementation-result:"))
|
||||
);
|
||||
let evidence = store.with_conn(|conn| {
|
||||
let source: (String, String, String) = conn.query_row(
|
||||
"SELECT s.assignment_id,d.disposition,d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1",
|
||||
@@ -90,9 +121,19 @@ async fn implementation_result_without_any_assignment_is_authenticated_and_never
|
||||
}).unwrap();
|
||||
// A later Orchestrator must not acquire previously recipient-less reports.
|
||||
tracking_store_assign(&store, "late-delegation", "assign-late");
|
||||
assert_eq!(store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap(), receipt);
|
||||
assert!(!store.list_pending_ticket_assignment_deliveries("w", 100).unwrap().iter()
|
||||
.any(|p| p.input_event_id.starts_with("implementation-result:")));
|
||||
assert_eq!(
|
||||
store
|
||||
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
assert!(
|
||||
!store
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p.input_event_id.starts_with("implementation-result:"))
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE kind='implementation_result'", [], |r| r.get::<_, i64>(0))?, 2);
|
||||
// Assignment creation has its own evidence; old report evidence stays byte-for-byte intact.
|
||||
@@ -101,22 +142,46 @@ async fn implementation_result_without_any_assignment_is_authenticated_and_never
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(dir.path().join("workspace.db")).unwrap();
|
||||
assert_eq!(reopened.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap(), receipt);
|
||||
assert_eq!(reopened.implementation_result("w", "tracking", &receipt.result_id).unwrap(), receipt);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.implementation_result("w", "tracking", &receipt.result_id)
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn implementation_result_requires_live_binding_and_registered_worker_even_for_replay() {
|
||||
for change in ["revoked", "binding-changed", "unregistered", "foreign-worker", "foreign-binding"] {
|
||||
for change in [
|
||||
"revoked",
|
||||
"binding-changed",
|
||||
"unregistered",
|
||||
"foreign-worker",
|
||||
"foreign-binding",
|
||||
] {
|
||||
let (_dir, store) = tracking_store_fixture().await;
|
||||
store.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: "other".into(), owner_account_id: "owner-account".into(),
|
||||
display_name: "Other".into(), state: "active".into(),
|
||||
created_at: "1".into(), updated_at: "1".into(),
|
||||
}).await.unwrap();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: "other".into(),
|
||||
owner_account_id: "owner-account".into(),
|
||||
display_name: "Other".into(),
|
||||
state: "active".into(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let worker = RuntimeWorkerRef::new("r", "replacement");
|
||||
let input = implementation_result_input();
|
||||
store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap();
|
||||
store
|
||||
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
|
||||
.unwrap();
|
||||
store.with_conn(|conn| {
|
||||
match change {
|
||||
"revoked" => { conn.execute("UPDATE workspace_runtime_bindings SET revoked_at='2' WHERE workspace_id='w'", [])?; }
|
||||
@@ -131,8 +196,19 @@ async fn implementation_result_requires_live_binding_and_registered_worker_even_
|
||||
let mut fresh = input.clone();
|
||||
fresh.operation_key = "fresh-after-auth-change".into();
|
||||
for request in [&input, &fresh] {
|
||||
assert!(matches!(store.record_implementation_result("w", "tracking", request, &worker, "tracking-binding"),
|
||||
Err(Error::WorkspacePermissionDenied(_))), "{change}: {request:?}");
|
||||
assert!(
|
||||
matches!(
|
||||
store.record_implementation_result(
|
||||
"w",
|
||||
"tracking",
|
||||
request,
|
||||
&worker,
|
||||
"tracking-binding"
|
||||
),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
),
|
||||
"{change}: {request:?}"
|
||||
);
|
||||
}
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM ticket_implementation_results", [], |r| r.get::<_, i64>(0))?, 1);
|
||||
@@ -144,7 +220,14 @@ async fn implementation_result_requires_live_binding_and_registered_worker_even_
|
||||
|
||||
#[tokio::test]
|
||||
async fn implementation_result_late_reports_do_not_change_ticket_assignment_or_handling_state() {
|
||||
for situation in ["never-assigned", "released", "reassigned", "closed", "no-orchestrator", "assigned-before-orchestrator"] {
|
||||
for situation in [
|
||||
"never-assigned",
|
||||
"released",
|
||||
"reassigned",
|
||||
"closed",
|
||||
"no-orchestrator",
|
||||
"assigned-before-orchestrator",
|
||||
] {
|
||||
let (_dir, store) = tracking_store_fixture().await;
|
||||
if situation != "assigned-before-orchestrator" {
|
||||
tracking_store_assign(&store, "delegation", "assign");
|
||||
@@ -157,12 +240,32 @@ async fn implementation_result_late_reports_do_not_change_ticket_assignment_or_h
|
||||
match situation {
|
||||
"never-assigned" => {}
|
||||
"released" => {
|
||||
assert!(store.clear_current_ticket_role_assignment("w", "tracking", TicketAssignmentRole::Worker,
|
||||
"implementation", "release-event", "release-op", "human", "3", Some("Released")).unwrap());
|
||||
assert!(
|
||||
store
|
||||
.clear_current_ticket_role_assignment(
|
||||
"w",
|
||||
"tracking",
|
||||
TicketAssignmentRole::Worker,
|
||||
"implementation",
|
||||
"release-event",
|
||||
"release-op",
|
||||
"human",
|
||||
"3",
|
||||
Some("Released")
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
"reassigned" => {
|
||||
store.set_current_ticket_role_assignment(&manual_worker_assignment("tracking", "replacement-implementation"),
|
||||
Some("implementation"), "reassign-event", "reassign-op", true).unwrap();
|
||||
store
|
||||
.set_current_ticket_role_assignment(
|
||||
&manual_worker_assignment("tracking", "replacement-implementation"),
|
||||
Some("implementation"),
|
||||
"reassign-event",
|
||||
"reassign-op",
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
"closed" => {
|
||||
store.with_conn(|conn| {
|
||||
@@ -171,16 +274,40 @@ async fn implementation_result_late_reports_do_not_change_ticket_assignment_or_h
|
||||
}).unwrap();
|
||||
}
|
||||
"no-orchestrator" => {
|
||||
assert!(store.clear_current_ticket_role_assignment("w", "tracking", TicketAssignmentRole::Orchestrator,
|
||||
"delegation", "release-orchestrator-event", "release-orchestrator-op", "human", "3", Some("Returned to human")).unwrap());
|
||||
assert!(
|
||||
store
|
||||
.clear_current_ticket_role_assignment(
|
||||
"w",
|
||||
"tracking",
|
||||
TicketAssignmentRole::Orchestrator,
|
||||
"delegation",
|
||||
"release-orchestrator-event",
|
||||
"release-orchestrator-op",
|
||||
"human",
|
||||
"3",
|
||||
Some("Returned to human")
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
"assigned-before-orchestrator" => {
|
||||
tracking_store_assign(&store, "late-delegation", "assign-late");
|
||||
}
|
||||
"assigned-before-orchestrator" => { tracking_store_assign(&store, "late-delegation", "assign-late"); }
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let before = store.with_conn(|conn| Ok(implementation_result_business_state(conn))).unwrap();
|
||||
let before = store
|
||||
.with_conn(|conn| Ok(implementation_result_business_state(conn)))
|
||||
.unwrap();
|
||||
let input = implementation_result_input();
|
||||
let receipt = store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap();
|
||||
assert_eq!(store.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding").unwrap(), receipt);
|
||||
let receipt = store
|
||||
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
|
||||
.unwrap(),
|
||||
receipt
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(implementation_result_business_state(conn), before, "{situation}");
|
||||
let expected = match situation {
|
||||
@@ -239,9 +366,17 @@ async fn implementation_result_replay_is_atomic_and_cannot_be_retargeted() {
|
||||
);
|
||||
let mut changed_kind = input.clone();
|
||||
changed_kind.kind = server_api::TicketImplementationResultKind::Blocker;
|
||||
assert!(store
|
||||
.record_implementation_result("w", "tracking", &changed_kind, &worker, "tracking-binding")
|
||||
.is_err());
|
||||
assert!(
|
||||
store
|
||||
.record_implementation_result(
|
||||
"w",
|
||||
"tracking",
|
||||
&changed_kind,
|
||||
&worker,
|
||||
"tracking-binding"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
for (workspace, ticket, source, binding) in [
|
||||
("other", "tracking", worker.clone(), "tracking-binding"),
|
||||
("w", "other", worker.clone(), "tracking-binding"),
|
||||
@@ -322,11 +457,13 @@ async fn implementation_result_replay_is_atomic_and_cannot_be_retargeted() {
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert!(store
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p.input_event_id == format!("implementation-result:{}", late.result_id)));
|
||||
assert!(
|
||||
store
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| p.input_event_id == format!("implementation-result:{}", late.result_id))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -367,131 +504,120 @@ async fn implementation_result_rejects_forged_evidence_and_rolls_back_without_no
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Preserve seeded assignment evidence while rebuilding the actual pre-business
|
||||
// capture composition. Changing only a receipt leaves current admission fences
|
||||
// and erasure guards in an otherwise legacy fixture.
|
||||
fn reconstruct_implementation_schema96(conn: &Connection) -> Result<()> {
|
||||
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
|
||||
conn.execute_batch("DROP TABLE plugin_references; DROP TABLE plugin_operations;
|
||||
DROP TABLE plugin_uploads; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations;
|
||||
UPDATE __yoi_schema_migrations SET version=105 WHERE version=106;")?;
|
||||
// Historical migration fixtures start from the retained Workspace96/component13
|
||||
// composition. Wrapping the actual connection is test-only SQL access, not a
|
||||
// current-schema open or any claim of authenticated legacy execution authority.
|
||||
fn implementation_legacy96_fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("retained96.db");
|
||||
let path = directory.path().join("workspace.db");
|
||||
prepare_retained_schema(&path, 96);
|
||||
let retained = Connection::open(path)?;
|
||||
for trigger in [
|
||||
"notification_sources_no_delete",
|
||||
"notification_decisions_no_delete",
|
||||
"notification_requests_no_delete",
|
||||
"notification_acknowledgements_no_delete",
|
||||
"notification_failures_no_delete",
|
||||
"notification_job_targets_no_delete",
|
||||
"notification_job_legacy_no_delete",
|
||||
"notification_source_preparation_failures_no_delete",
|
||||
] {
|
||||
let definition: String = retained.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE type='trigger' AND name=?1",
|
||||
[trigger],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
conn.execute_batch(&format!("DROP TRIGGER {trigger}; {definition};"))?;
|
||||
}
|
||||
for trigger in [
|
||||
"subject_deleted_job_insert",
|
||||
"subject_deleted_job_result",
|
||||
"subject_deleted_attempt_insert",
|
||||
"subject_deleted_attempt_result",
|
||||
"subject_deleted_worker_reservation",
|
||||
"subject_deleted_notification_source",
|
||||
"subject_deleted_notification_payload",
|
||||
"subject_deleted_notification_send",
|
||||
] {
|
||||
conn.execute_batch(&format!("DROP TRIGGER {trigger};"))?;
|
||||
}
|
||||
// Children before parents; dropping the tables also drops snapshot guards.
|
||||
conn.execute_batch(
|
||||
"DROP TABLE subject_deletion_removals;
|
||||
DROP TABLE subject_deletion_allocations;
|
||||
DROP TABLE subject_deletion_jobs;
|
||||
DROP TABLE subject_deletion_workers;
|
||||
DROP TABLE subject_deletions;
|
||||
UPDATE __yoi_schema_migrations SET version=104 WHERE version=105;",
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 96);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT max(version) FROM ticket_schema_migrations",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
13
|
||||
);
|
||||
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('legacy-owner','user','legacy-owner','Legacy owner','1','1');
|
||||
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('w','legacy-owner','Legacy workspace','active','1','1');
|
||||
INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES('w','r','worker','Legacy body','normal','1','1'),('w','r','replacement','Implementation','normal','1','1');
|
||||
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('w','r','Runtime','https://runtime.invalid','retained-key','retained-fingerprint','tracking-binding','workspace_identity','1','1');
|
||||
INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) VALUES('w','workspace-orchestrator','r','worker',1,'1','1');
|
||||
INSERT INTO repositories(workspace_id,repository_id,repository_key,kind,provider,uri,created_at,updated_at,source_kind,source_uri,source_fingerprint) VALUES('w','main','main','git','git','file:///main','1','1','file','file:///main','sha256:fixture');").unwrap();
|
||||
let store = SqliteWorkspaceStore {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
db_executor: crate::db_executor::DbExecutor::new(
|
||||
crate::db_executor::DbExecutorLimits::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
};
|
||||
seed_manual_start_ticket(&store, "tracking", "open");
|
||||
store.with_conn(|conn| {
|
||||
// The old principal and normal assignment triggers are genuine retained
|
||||
// data. Current assignment APIs intentionally cannot create this identity.
|
||||
conn.execute_batch("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','tracking','delegation','orchestrator','workspace_agent','workspace-orchestrator','legacy-owner','2');
|
||||
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','tracking','delegation','orchestrator','workspace_agent','workspace-orchestrator','2');
|
||||
INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at,operation_id) VALUES('w','tracking','orchestrator','legacy-assigned-event','assigned','delegation','legacy-owner','2','legacy-assign');")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
(directory, store)
|
||||
}
|
||||
|
||||
// Seed accepted PRE-upgrade notification evidence as historical SQL data. The
|
||||
// current router must never authorize workspace-orchestrator to recreate it.
|
||||
fn implementation_seed_legacy_result_delivery(conn: &Connection, index: usize) -> Result<()> {
|
||||
let event = format!("implementation-result:legacy-result-{index}");
|
||||
let delivery_id = format!("legacy-result-delivery-{index}");
|
||||
let input = crate::store::TicketAssignmentDelivery {
|
||||
ticket_id: "tracking".into(),
|
||||
assignment_id: "delegation".into(),
|
||||
input_event_id: event.clone(),
|
||||
delivery_id: delivery_id.clone(),
|
||||
request_id: delivery_id.clone(),
|
||||
waiting_reason: None,
|
||||
next_action: None,
|
||||
};
|
||||
let source = serde_json::json!({"actor":"backend:assignment-delivery", "runtime_id":"r", "worker_id":"worker", "agent_key":"workspace-orchestrator", "authentication_binding_id":"tracking-binding"}).to_string();
|
||||
conn.execute("INSERT INTO notification_decisions(source_sequence,workspace_id,delivery_id,disposition,reason,purpose) SELECT sequence,'w',?1,'deliver','result','result' FROM notification_sources WHERE input_event_id=?2 AND role='orchestrator'", params![delivery_id,event])?;
|
||||
conn.execute(
|
||||
"INSERT INTO ticket_assignment_deliveries VALUES('w','delegation',?1,?2,?3,?4)",
|
||||
params![
|
||||
event,
|
||||
delivery_id,
|
||||
serde_json::to_string(&input).unwrap(),
|
||||
source
|
||||
],
|
||||
)?;
|
||||
conn.execute_batch(
|
||||
"DROP TRIGGER notification_mr_event;
|
||||
DROP TRIGGER notification_mr_source;
|
||||
DROP TRIGGER notification_implementation_result;
|
||||
DROP TRIGGER IF EXISTS implementation_delegation_capture;
|
||||
DROP TABLE ticket_implementation_results;
|
||||
DROP TABLE ticket_implementation_delegations;
|
||||
DROP TABLE merge_request_source_observations;
|
||||
DROP TRIGGER passkey_metadata_insert;
|
||||
DROP TABLE passkey_addition_receipts;
|
||||
DROP TABLE passkey_metadata;
|
||||
DROP TRIGGER backend_job_input_retirement;
|
||||
DROP TRIGGER backend_job_identity_immutable;
|
||||
ALTER TABLE backend_jobs DROP COLUMN input_disposition;
|
||||
UPDATE __yoi_schema_migrations SET version=102 WHERE version=104;
|
||||
DROP TABLE subject_create_recoveries;
|
||||
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
|
||||
DROP TABLE subject_worker_releases;
|
||||
UPDATE __yoi_schema_migrations SET version=100 WHERE version=101;
|
||||
DROP TABLE user_activations;
|
||||
UPDATE __yoi_schema_migrations SET version=99 WHERE version=100;
|
||||
DROP TABLE hosting_administrators; DROP TABLE hosting_settings;
|
||||
UPDATE __yoi_schema_migrations SET version=98 WHERE version=99;
|
||||
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;",
|
||||
conn.execute("INSERT INTO ticket_assignment_delivery_requests VALUES('w',?1,'Retained legacy report notification')", [&delivery_id])?;
|
||||
conn.execute(
|
||||
"INSERT INTO ticket_assignment_delivery_acknowledgements VALUES('w',?1,?2)",
|
||||
params![delivery_id, source],
|
||||
)?;
|
||||
assert_eq!(current_schema_version(conn)?, 96);
|
||||
assert_eq!(conn.query_row(
|
||||
"SELECT count(*) FROM sqlite_schema WHERE name LIKE 'subject_delet%' OR sql LIKE '%subject_deletion%'",
|
||||
[], |row| row.get::<_, i64>(0),
|
||||
)?, 0);
|
||||
for table in [
|
||||
"notification_sources",
|
||||
"notification_decisions",
|
||||
"notification_requests",
|
||||
"notification_acknowledgements",
|
||||
"notification_failures",
|
||||
"notification_job_targets",
|
||||
"notification_job_legacy_receipts",
|
||||
"notification_source_preparation_failures",
|
||||
"backend_jobs",
|
||||
"backend_job_attempts",
|
||||
"worker_create_reservations",
|
||||
] {
|
||||
assert_eq!(
|
||||
state_contract_schema_signature(conn, table),
|
||||
state_contract_schema_signature(&retained, table),
|
||||
"schema96 {table}"
|
||||
);
|
||||
}
|
||||
conn.execute("INSERT INTO notification_assignment_targets VALUES('w',?1,'r','worker','workspace-orchestrator','tracking-binding')", [&delivery_id])?;
|
||||
conn.execute(
|
||||
"INSERT INTO notification_requests VALUES('w',?1,'Retained legacy report notification',?2)",
|
||||
params![delivery_id, source],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO notification_acknowledgements VALUES('w',?1,?2)",
|
||||
params![delivery_id, source],
|
||||
)?;
|
||||
conn.execute("INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w',?1,'runtime_rejected','4')", [&delivery_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_notification_evidence() {
|
||||
let (dir, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store, "delegation", "assign");
|
||||
async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_notification_evidence()
|
||||
{
|
||||
let (dir, store) = implementation_legacy96_fixture();
|
||||
let worker = routing_worker_assign(&store, "implementation").worker;
|
||||
// Reconstruct schema97 from a fresh current-schema fixture, including its historical capture.
|
||||
store.with_conn(|conn| {
|
||||
reconstruct_implementation_schema96(conn)?;
|
||||
migrate_business_notifications_v96_to_v97(conn)?;
|
||||
assert_eq!(current_schema_version(conn)?, 97);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
// Apply the real forward 96 -> 97 migration before seeding schema97 reports.
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
migrate_business_notifications_v96_to_v97(conn)?;
|
||||
assert_eq!(current_schema_version(conn)?, 97);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let input = implementation_result_input();
|
||||
let mut second = input.clone();
|
||||
second.operation_key = "legacy-second-operation".into();
|
||||
second.body = "A second retained report".into();
|
||||
let requests = [input.clone(), second];
|
||||
let raw_requests = requests.iter().map(|request| {
|
||||
let mut legacy = serde_json::to_value(request).unwrap();
|
||||
legacy["assignment_id"] = serde_json::json!("implementation");
|
||||
legacy["orchestrator_assignment_id"] = serde_json::json!("delegation");
|
||||
format!(" \n{}\n ", serde_json::to_string_pretty(&legacy).unwrap())
|
||||
}).collect::<Vec<_>>();
|
||||
let raw_requests = requests
|
||||
.iter()
|
||||
.map(|request| {
|
||||
let mut legacy = serde_json::to_value(request).unwrap();
|
||||
legacy["assignment_id"] = serde_json::json!("implementation");
|
||||
legacy["orchestrator_assignment_id"] = serde_json::json!("delegation");
|
||||
format!(" \n{}\n ", serde_json::to_string_pretty(&legacy).unwrap())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
store.with_conn(|conn| {
|
||||
for (index, request) in requests.iter().enumerate() {
|
||||
conn.execute("INSERT INTO ticket_implementation_results(workspace_id,ticket_id,operation_key,result_id,assignment_id,orchestrator_assignment_id,runtime_id,worker_id,request_json,created_at)
|
||||
@@ -500,23 +626,16 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
// Populate persisted request, target, ACK and failure evidence, not just source rows.
|
||||
let pending = store.list_pending_ticket_assignment_deliveries("w", 100).unwrap();
|
||||
for index in 0..requests.len() {
|
||||
let input_event = format!("implementation-result:legacy-result-{index}");
|
||||
assert!(pending.iter().any(|p| p.input_event_id == input_event));
|
||||
let reserved = store.reserve_ticket_assignment_delivery("w", "delegation", &input_event, &tracking_store_source()).unwrap();
|
||||
store.prepare_ticket_assignment_delivery_content("w", &reserved.delivery_id, "Retained legacy report notification", &tracking_store_source()).unwrap();
|
||||
store.ack_ticket_assignment_delivery("w", &reserved.delivery_id, &tracking_store_source()).unwrap();
|
||||
store.with_conn(|conn| {
|
||||
conn.execute("INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w',?1,'legacy-failure','4')", [&reserved.delivery_id])?;
|
||||
// Persist real historical row shapes without calling current Subject-only
|
||||
// send/reserve APIs as though the legacy identity were live authority.
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
for index in 0..requests.len() {
|
||||
implementation_seed_legacy_result_delivery(conn, index)?;
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
routing_ack_worker_pending(&store, &TicketWorkerNotificationTarget {
|
||||
worker: worker.clone(),
|
||||
authentication_binding_id: "tracking-binding".into(),
|
||||
});
|
||||
})
|
||||
.unwrap();
|
||||
let (results, business, evidence) = store.with_conn(|conn| {
|
||||
Ok((
|
||||
fixture_query_rows(conn, "SELECT workspace_id,ticket_id,operation_key,result_id,runtime_id,worker_id,request_json,created_at FROM ticket_implementation_results ORDER BY rowid"),
|
||||
@@ -524,7 +643,10 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
|
||||
implementation_result_notification_evidence(conn),
|
||||
))
|
||||
}).unwrap();
|
||||
assert!(evidence.iter().all(|rows| !rows.is_empty()), "fixture must retain nonempty source, decision, request, target, ACK and failure evidence");
|
||||
assert!(
|
||||
evidence.iter().all(|rows| !rows.is_empty()),
|
||||
"fixture must retain nonempty source, decision, request, target, ACK and failure evidence"
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
// The migration must roll back its trigger drops and leave every byte replayable.
|
||||
conn.execute_batch("CREATE TABLE ticket_implementation_results_v98(collision INTEGER);")?;
|
||||
@@ -557,52 +679,114 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(dir.path().join("workspace.db")).unwrap();
|
||||
for (index, request) in requests.iter().enumerate() {
|
||||
let expected = server_api::TicketImplementationResultReceipt {
|
||||
result_id: format!("legacy-result-{index}"), ticket_id: "tracking".into(),
|
||||
request: request.clone(), created_at: "4".into(),
|
||||
result_id: format!("legacy-result-{index}"),
|
||||
ticket_id: "tracking".into(),
|
||||
request: request.clone(),
|
||||
created_at: "4".into(),
|
||||
};
|
||||
assert_eq!(reopened.implementation_result("w", "tracking", &expected.result_id).unwrap(), expected);
|
||||
assert_eq!(reopened.record_implementation_result("w", "tracking", request, &worker, "tracking-binding").unwrap(), expected);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.implementation_result("w", "tracking", &expected.result_id)
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.record_implementation_result("w", "tracking", request, &worker, "tracking-binding")
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
reopened.with_conn(|conn| {
|
||||
assert_eq!(fixture_query_rows(conn, "SELECT * FROM ticket_implementation_results ORDER BY rowid"), results);
|
||||
assert_eq!(implementation_result_notification_evidence(conn), evidence, "reading and replaying legacy JSON must never recapture notifications");
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
reopened
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(
|
||||
fixture_query_rows(
|
||||
conn,
|
||||
"SELECT * FROM ticket_implementation_results ORDER BY rowid"
|
||||
),
|
||||
results
|
||||
);
|
||||
assert_eq!(
|
||||
implementation_result_notification_evidence(conn),
|
||||
evidence,
|
||||
"reading and replaying legacy JSON must never recapture notifications"
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
tracking_store_assign(&reopened, "new-delegation", "assign-new");
|
||||
routing_worker_assign(&reopened, "new-implementation");
|
||||
reopened.with_conn(|conn| {
|
||||
assert_eq!(stored_fixture_rows(conn, "ticket_implementation_delegations"), business[9], "historical delegation evidence must not grow");
|
||||
for index in 0..requests.len() {
|
||||
assert_eq!(conn.query_row("SELECT assignment_id FROM notification_sources WHERE input_event_id=?1", [format!("implementation-result:legacy-result-{index}")], |r| r.get::<_, String>(0))?, "delegation");
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
reopened
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(
|
||||
stored_fixture_rows(conn, "ticket_implementation_delegations"),
|
||||
business[9],
|
||||
"historical delegation evidence must not grow"
|
||||
);
|
||||
for index in 0..requests.len() {
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT assignment_id FROM notification_sources WHERE input_event_id=?1",
|
||||
[format!("implementation-result:legacy-result-{index}")],
|
||||
|r| r.get::<_, String>(0)
|
||||
)?,
|
||||
"delegation"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
for request in &requests {
|
||||
reopened.record_implementation_result("w", "tracking", request, &worker, "tracking-binding").unwrap();
|
||||
reopened
|
||||
.record_implementation_result("w", "tracking", request, &worker, "tracking-binding")
|
||||
.unwrap();
|
||||
}
|
||||
let mut fresh = input;
|
||||
fresh.operation_key = "new-schema98-report".into();
|
||||
let receipt = reopened.record_implementation_result("w", "tracking", &fresh, &worker, "tracking-binding").unwrap();
|
||||
reopened.with_conn(|conn| {
|
||||
assert_eq!(conn.query_row("SELECT assignment_id FROM notification_sources WHERE input_event_id=?1", [format!("implementation-result:{}", receipt.result_id)], |r| r.get::<_, String>(0))?, "new-delegation");
|
||||
for index in 0..requests.len() {
|
||||
assert_eq!(conn.query_row("SELECT request_json FROM ticket_implementation_results WHERE result_id=?1", [format!("legacy-result-{index}")], |r| r.get::<_, String>(0))?, raw_requests[index]);
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE input_event_id=?1", [format!("implementation-result:legacy-result-{index}")], |r| r.get::<_, i64>(0))?, 1);
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
let receipt = reopened
|
||||
.record_implementation_result("w", "tracking", &fresh, &worker, "tracking-binding")
|
||||
.unwrap();
|
||||
reopened
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT assignment_id FROM notification_sources WHERE input_event_id=?1",
|
||||
[format!("implementation-result:{}", receipt.result_id)],
|
||||
|r| r.get::<_, String>(0)
|
||||
)?,
|
||||
"new-delegation"
|
||||
);
|
||||
for index in 0..requests.len() {
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT request_json FROM ticket_implementation_results WHERE result_id=?1",
|
||||
[format!("legacy-result-{index}")],
|
||||
|r| r.get::<_, String>(0)
|
||||
)?,
|
||||
raw_requests[index]
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM notification_sources WHERE input_event_id=?1",
|
||||
[format!("implementation-result:legacy-result-{index}")],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)?,
|
||||
1
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknown_legacy_results() {
|
||||
let (_dir, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store, "delegation", "assign");
|
||||
let (_dir, store) = implementation_legacy96_fixture();
|
||||
routing_worker_assign(&store, "implementation");
|
||||
routing_worker_assign(&store, "known-implementation");
|
||||
store.with_conn(|conn| {
|
||||
conn.execute("INSERT INTO ticket_assignment_operation_fences VALUES('w','operation-known-implementation','delegation','r','worker','workspace-orchestrator','tracking-binding',?1)",[serde_json::to_string(&tracking_store_source()).unwrap()])?;
|
||||
// Reconstruct the prior composition before its new business capture existed.
|
||||
reconstruct_implementation_schema96(conn)?;
|
||||
let legacy_source = serde_json::json!({"actor":"authenticated-worker:r/worker", "runtime_id":"r", "worker_id":"worker", "agent_key":"workspace-orchestrator", "authentication_binding_id":"tracking-binding"});
|
||||
conn.execute("INSERT INTO ticket_assignment_operation_fences VALUES('w','operation-known-implementation','delegation','r','worker','workspace-orchestrator','tracking-binding',?1)",[legacy_source.to_string()])?;
|
||||
conn.execute_batch("INSERT INTO merge_requests VALUES('w','historical-mr','main','open','refs/heads/work','refs/heads/main','3','3');
|
||||
INSERT INTO merge_request_ticket_relations VALUES('w','historical-mr','tracking','implements','3');
|
||||
INSERT INTO merge_request_thread_events VALUES('w','historical-mr','approved-event',1,'review','{\"subject_ref\":\"source-a\",\"decision\":\"approve\"}',NULL,'3');
|
||||
@@ -629,8 +813,14 @@ async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknow
|
||||
assert!(
|
||||
inputs
|
||||
.iter()
|
||||
.any(|i| i.input_event_id == "mr-event:historical-mr:approved-event")
|
||||
.all(|i| i.input_event_id != "mr-event:historical-mr:approved-event"),
|
||||
"retained legacy capture must not become current Subject delivery authority"
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
let decision: (String, String) = conn.query_row("SELECT d.disposition,d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id='mr-event:historical-mr:approved-event' AND s.role='orchestrator'", [], |row| Ok((row.get(0)?,row.get(1)?)))?;
|
||||
assert_eq!(decision, ("suppress".into(),"unsupported_recipient".into()));
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
for _ in 0..2 {
|
||||
store
|
||||
.observe_merge_request_source("w", "main", "refs/heads/work", "source-b")
|
||||
@@ -681,7 +871,14 @@ async fn implementation_result_notification_failure_rolls_back_and_concurrent_re
|
||||
0
|
||||
);
|
||||
assert_eq!(implementation_result_business_state(conn), before);
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE kind='implementation_result'", [], |r| r.get::<_, i64>(0))?, 0);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM notification_sources WHERE kind='implementation_result'",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)?,
|
||||
0
|
||||
);
|
||||
conn.execute_batch("DROP TRIGGER reject_result_notification;")?;
|
||||
Ok(())
|
||||
})
|
||||
@@ -742,20 +939,37 @@ async fn implementation_result_notification_failure_rolls_back_and_concurrent_re
|
||||
#[tokio::test]
|
||||
async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
|
||||
for historical in [false, true] {
|
||||
let (_dir, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store, "delegation", "assign");
|
||||
let (_dir, store) = if historical {
|
||||
implementation_legacy96_fixture()
|
||||
} else {
|
||||
let fixture = tracking_store_fixture().await;
|
||||
tracking_store_assign(&fixture.1, "delegation", "assign");
|
||||
fixture
|
||||
};
|
||||
routing_worker_assign(&store, "implementation");
|
||||
let events = [
|
||||
("review_requested", Some("requested_by"), Some("replacement"), Some("worker")),
|
||||
(
|
||||
"review_requested",
|
||||
Some("requested_by"),
|
||||
Some("replacement"),
|
||||
Some("worker"),
|
||||
),
|
||||
("review", Some("reviewer"), Some("reviewer-child"), None),
|
||||
("review_revoked", Some("revoked_by"), Some("replacement"), Some("worker")),
|
||||
("merge", Some("merged_by"), Some("worker"), Some("orchestrator")),
|
||||
(
|
||||
"review_revoked",
|
||||
Some("revoked_by"),
|
||||
Some("replacement"),
|
||||
Some("worker"),
|
||||
),
|
||||
(
|
||||
"merge",
|
||||
Some("merged_by"),
|
||||
Some("worker"),
|
||||
Some("orchestrator"),
|
||||
),
|
||||
("review_cancelled", None, None, None),
|
||||
];
|
||||
store.with_conn(|conn| {
|
||||
if historical {
|
||||
reconstruct_implementation_schema96(conn)?;
|
||||
}
|
||||
conn.execute_batch("INSERT INTO merge_requests VALUES('w','actor-mr','main','open','work','main','3','3');
|
||||
INSERT INTO merge_request_ticket_relations VALUES('w','actor-mr','tracking','implements','3');")?;
|
||||
for (index, (kind, field, worker, _)) in events.iter().enumerate() {
|
||||
@@ -777,7 +991,10 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
|
||||
"SELECT s.actor,d.disposition,d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1 AND s.role=?2",
|
||||
params![format!("mr-event:actor-mr:{kind}"),role],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?;
|
||||
assert_eq!(actor,worker.map(|w|format!("worker:r/{w}")),"{kind}, historical={historical}");
|
||||
if suppressed_role == Some(role) {
|
||||
if historical && role == "orchestrator" {
|
||||
assert_eq!((disposition.as_str(),reason.as_str()),("suppress","unsupported_recipient"),
|
||||
"legacy capture preserves actors but never restores Subject routing authority");
|
||||
} else if suppressed_role == Some(role) {
|
||||
assert_eq!((disposition.as_str(),reason.as_str()),("suppress","self_notification"),"{kind}, historical={historical}");
|
||||
} else {
|
||||
assert_eq!((disposition.as_str(),reason.as_str()),("deliver","attention"),"{kind}, {role}, historical={historical}");
|
||||
@@ -792,9 +1009,9 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
|
||||
#[tokio::test]
|
||||
async fn implementation_result_accepts_only_observations_with_matching_mr_ticket_workspace() {
|
||||
let (_dir, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store,"delegation","assign");
|
||||
let worker = routing_worker_assign(&store,"implementation").worker;
|
||||
seed_manual_start_ticket(&store,"unrelated","open");
|
||||
tracking_store_assign(&store, "delegation", "assign");
|
||||
let worker = routing_worker_assign(&store, "implementation").worker;
|
||||
seed_manual_start_ticket(&store, "unrelated", "open");
|
||||
store.with_conn(|conn| {
|
||||
conn.execute_batch("INSERT INTO merge_requests VALUES('w','observed-mr','main','open','work','main','3','3');
|
||||
INSERT INTO merge_request_ticket_relations VALUES('w','observed-mr','tracking','implements','3');
|
||||
@@ -802,23 +1019,56 @@ async fn implementation_result_accepts_only_observations_with_matching_mr_ticket
|
||||
INSERT INTO merge_request_ticket_relations VALUES('w','other-mr','unrelated','implements','3');")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
store.observe_merge_request_source("w","main","work","source-b").unwrap();
|
||||
store.observe_merge_request_source("w","main","other","other-source").unwrap();
|
||||
for (workspace,mr,subject,accepted) in [
|
||||
("w","observed-mr","forged",false),
|
||||
("w","observed-mr","other-source",false),
|
||||
("w","other-mr","other-source",false),
|
||||
("other","observed-mr","source-b",false),
|
||||
("w","observed-mr","source-b",true),
|
||||
store
|
||||
.observe_merge_request_source("w", "main", "work", "source-b")
|
||||
.unwrap();
|
||||
store
|
||||
.observe_merge_request_source("w", "main", "other", "other-source")
|
||||
.unwrap();
|
||||
for (workspace, mr, subject, accepted) in [
|
||||
("w", "observed-mr", "forged", false),
|
||||
("w", "observed-mr", "other-source", false),
|
||||
("w", "other-mr", "other-source", false),
|
||||
("other", "observed-mr", "source-b", false),
|
||||
("w", "observed-mr", "source-b", true),
|
||||
] {
|
||||
let mut input = implementation_result_input();
|
||||
input.merge_request = Some(server_api::MergeRequestReviewSubject {merge_request_id:mr.into(),subject_ref:subject.into()});
|
||||
let result = store.record_implementation_result(workspace,"tracking",&input,&worker,"tracking-binding");
|
||||
assert_eq!(result.is_ok(),accepted,"{workspace}/{mr}/{subject}: {result:?}");
|
||||
input.merge_request = Some(server_api::MergeRequestReviewSubject {
|
||||
merge_request_id: mr.into(),
|
||||
subject_ref: subject.into(),
|
||||
});
|
||||
let result = store.record_implementation_result(
|
||||
workspace,
|
||||
"tracking",
|
||||
&input,
|
||||
&worker,
|
||||
"tracking-binding",
|
||||
);
|
||||
assert_eq!(
|
||||
result.is_ok(),
|
||||
accepted,
|
||||
"{workspace}/{mr}/{subject}: {result:?}"
|
||||
);
|
||||
}
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM merge_request_thread_events",[],|r|r.get::<_,i64>(0))?,0);
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM ticket_implementation_results",[],|r|r.get::<_,i64>(0))?,1);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM merge_request_thread_events",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)?,
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM ticket_implementation_results",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0)
|
||||
)?,
|
||||
1
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -154,6 +154,23 @@ fn fixed_worker_target(
|
||||
})
|
||||
}
|
||||
|
||||
fn authorize_worker_attention(
|
||||
api: &WorkspaceApi,
|
||||
input: &TicketWorkerNotification,
|
||||
) -> ApiResult<()> {
|
||||
subject_authorization::authorize(
|
||||
api,
|
||||
&input.worker,
|
||||
&server_api::authorization::OperationAuthorizationRequest {
|
||||
operation: "ticket.read".into(),
|
||||
resource: server_api::authorization::PermissionResource::Ticket {
|
||||
workspace_id: api.workspace_id().into(),
|
||||
ticket_id: input.ticket_id.clone(),
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_ticket_worker_notifications(api: &WorkspaceApi) {
|
||||
let Some(_permit) = api.notification_delivery_lifecycle.begin() else {
|
||||
return;
|
||||
@@ -182,6 +199,10 @@ pub(super) fn dispatch_ticket_worker_notifications(api: &WorkspaceApi) {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if authorize_worker_attention(api, &input).is_err() {
|
||||
record_notification_failure(api, &key, NotificationFailureCode::PreparationFailed);
|
||||
continue;
|
||||
}
|
||||
let resource_key = match api.store.resource_key(
|
||||
&api.config.workspace_id,
|
||||
WorkspaceResourceKind::Ticket,
|
||||
@@ -199,13 +220,17 @@ pub(super) fn dispatch_ticket_worker_notifications(api: &WorkspaceApi) {
|
||||
"Ticket {resource_key} has committed activity (input {}). Reread the current Ticket and assignment before acting. This notification does not grant or extend execution authority.",
|
||||
input.input_event_id,
|
||||
);
|
||||
let content = match api.store.prepare_ticket_worker_notification_content(
|
||||
if authorize_worker_attention(api, &input).is_err() {
|
||||
record_notification_failure(api, &key, NotificationFailureCode::PreparationFailed);
|
||||
continue;
|
||||
}
|
||||
match api.store.prepare_ticket_worker_notification_content(
|
||||
&api.config.workspace_id,
|
||||
&input.delivery_id,
|
||||
&content,
|
||||
&target,
|
||||
) {
|
||||
Ok(content) => content,
|
||||
Ok(_) => (),
|
||||
Err(error) => {
|
||||
let code = match error {
|
||||
Error::TicketAssignmentConflict(_) => {
|
||||
@@ -217,6 +242,22 @@ pub(super) fn dispatch_ticket_worker_notifications(api: &WorkspaceApi) {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let content = match api.store.admit_ticket_worker_notification_send(
|
||||
api.workspace_id(),
|
||||
&input.delivery_id,
|
||||
&target,
|
||||
) {
|
||||
Ok(Some(content)) => content,
|
||||
Ok(None) => continue,
|
||||
Err(_) => {
|
||||
record_notification_failure(api, &key, NotificationFailureCode::PreparationFailed);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if authorize_worker_attention(api, &input).is_err() {
|
||||
record_notification_failure(api, &key, NotificationFailureCode::PreparationFailed);
|
||||
continue;
|
||||
}
|
||||
let result = api.runtime.send_input(
|
||||
&input.worker,
|
||||
WorkerInputRequest {
|
||||
@@ -274,26 +315,35 @@ pub(super) fn recover_ticket_notifications(api: &WorkspaceApi) {
|
||||
};
|
||||
recover_job_notifications(api);
|
||||
dispatch_ticket_worker_notifications(api);
|
||||
if let Some(orchestrator) = find_workspace_orchestrator(api) {
|
||||
dispatch_orchestrator_assignment_attention(api, &orchestrator.worker);
|
||||
} else {
|
||||
// Still reconcile committed sources and explain target absence even when
|
||||
// there is no Runtime Worker. Do not start or restore one as a side effect.
|
||||
match api
|
||||
.store
|
||||
.list_pending_ticket_assignment_deliveries(&api.config.workspace_id, 20)
|
||||
{
|
||||
Ok(inputs) => {
|
||||
for input in inputs {
|
||||
record_notification_failure(
|
||||
api,
|
||||
&assignment_delivery_key(&input),
|
||||
NotificationFailureCode::TargetUnavailable,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => tracing::warn!(%error, "assignment notification scan failed"),
|
||||
// Resolve each current assignment independently. A missing body is not an implicit restore.
|
||||
let inputs = match api
|
||||
.store
|
||||
.list_pending_ticket_assignment_deliveries(api.workspace_id(), 100)
|
||||
{
|
||||
Ok(inputs) => inputs,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "assignment notification scan failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut recipients = std::collections::BTreeSet::new();
|
||||
for input in inputs {
|
||||
match assigned_subject_body(api, &input.ticket_id, &input.assignment_id) {
|
||||
Ok(Some(worker)) => {
|
||||
recipients.insert((worker.runtime_id, worker.worker_id));
|
||||
}
|
||||
_ => record_notification_failure(
|
||||
api,
|
||||
&assignment_delivery_key(&input),
|
||||
NotificationFailureCode::TargetUnavailable,
|
||||
),
|
||||
}
|
||||
}
|
||||
for (runtime_id, worker_id) in recipients {
|
||||
dispatch_orchestrator_assignment_attention(
|
||||
api,
|
||||
&RuntimeWorkerRef::new(runtime_id, worker_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,13 @@ async fn notification_diagnostics_preserves_unknown_acceptance_and_marks_replace
|
||||
assert_eq!(read().stage, S::AcceptanceUnknown);
|
||||
assert!(read().acceptance_outcome_unknown);
|
||||
assert_eq!(read().last_failure_code.as_deref(), Some("target_stopped"));
|
||||
store.with_conn(|conn|{conn.execute("UPDATE worker_singleton_owners SET worker_id='replacement' WHERE workspace_id='w'",[])?;Ok(())}).unwrap();
|
||||
store.with_conn(|conn| {
|
||||
conn.execute_batch("UPDATE worker_create_reservations SET state='removed' WHERE workspace_id='w' AND singleton_key='subjektiv:tracking-subject';
|
||||
UPDATE worker_singleton_owners SET worker_id='replacement',generation=2 WHERE workspace_id='w' AND singleton_key='subjektiv:tracking-subject';
|
||||
INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at,singleton_key,singleton_generation)
|
||||
VALUES('w','replacement-body','replacement','r','replacement-body','created','2','2','subjektiv:tracking-subject',2);")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
let replacement = TicketAssignmentTrackingSourceContext {
|
||||
worker_id: "replacement".into(),
|
||||
..source
|
||||
@@ -182,8 +188,8 @@ async fn notification_diagnostics_detects_unconnected_origin_and_retains_history
|
||||
#[tokio::test]
|
||||
async fn notification_diagnostics_is_readonly_paginated_and_workspace_scoped() {
|
||||
use server_api::{NotificationDiagnosticStage as S, NotificationDiagnosticsQuery as Q};
|
||||
let (_dir, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store, "a", "assign-a");
|
||||
let (_dir, store) = delivery_tracking_fixture().await;
|
||||
delivery_tracking_assign(&store, "a", "assign-a");
|
||||
delivery_store_answer(&store, "answer-a");
|
||||
let before = store
|
||||
.with_conn(|conn| {
|
||||
@@ -377,8 +383,18 @@ async fn notification_source_metadata_resolves_hashed_and_legacy_identities_with
|
||||
use server_api::authorization::PermissionResource;
|
||||
let (_dir, store) = tracking_store_fixture().await;
|
||||
let (_, source) = job_notice_fixture(&store, "metadata-only", false);
|
||||
let job = store.get_backend_job("w", "metadata-only").unwrap().unwrap();
|
||||
let retired = store.reclaim_backend_job_input("w", "metadata-only", &job.input_digest, crate::backend_job::JobInputRetirementReason::ExplicitErasure).unwrap();
|
||||
let job = store
|
||||
.get_backend_job("w", "metadata-only")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let retired = store
|
||||
.reclaim_backend_job_input(
|
||||
"w",
|
||||
"metadata-only",
|
||||
&job.input_digest,
|
||||
crate::backend_job::JobInputRetirementReason::ExplicitErasure,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(retired.load_executable_request().is_err());
|
||||
store.with_conn(|conn| {
|
||||
// Retired protected input cannot execute, but notification ownership
|
||||
@@ -402,7 +418,13 @@ async fn notification_source_metadata_resolves_hashed_and_legacy_identities_with
|
||||
);
|
||||
let other = store.notification_source_resource("other", event).unwrap();
|
||||
if event == "mr-event:legacy-job:e" {
|
||||
assert_eq!(other, Some(PermissionResource::MergeRequest { workspace_id: "other".into(), merge_request_id: "legacy-job".into() }));
|
||||
assert_eq!(
|
||||
other,
|
||||
Some(PermissionResource::MergeRequest {
|
||||
workspace_id: "other".into(),
|
||||
merge_request_id: "legacy-job".into()
|
||||
})
|
||||
);
|
||||
} else {
|
||||
assert!(other.is_none());
|
||||
}
|
||||
|
||||
@@ -179,8 +179,11 @@ fn policy(
|
||||
return ("suppress", R::NoRecipient);
|
||||
}
|
||||
let supported = if role == "orchestrator" {
|
||||
facts.principal_kind.as_deref() == Some("workspace_agent")
|
||||
&& facts.principal_id.as_deref() == Some("workspace-orchestrator")
|
||||
facts.principal_kind.as_deref() == Some("subject")
|
||||
&& facts
|
||||
.principal_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| !id.trim().is_empty())
|
||||
} else {
|
||||
role == "worker" && facts.principal_kind.as_deref() == Some("worker")
|
||||
};
|
||||
@@ -231,6 +234,56 @@ fn policy(
|
||||
|
||||
const FACTS_SQL: &str = "json_object('principal_kind',a.principal_kind,'principal_id',a.principal_id,'runtime_id',COALESCE(a.runtime_id,(SELECT runtime_id FROM worker_singleton_owners WHERE workspace_id=a.workspace_id AND singleton_key=a.principal_id AND a.principal_kind='workspace_agent')),'worker_id',COALESCE(a.worker_id,(SELECT worker_id FROM worker_singleton_owners WHERE workspace_id=a.workspace_id AND singleton_key=a.principal_id AND a.principal_kind='workspace_agent')),'status',json_extract(a.tracking_json,'$.status'),'case_state',t.workflow_state,'released',json(CASE WHEN EXISTS(SELECT 1 FROM ticket_assignment_work_releases r WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id) THEN 'true' ELSE 'false' END))";
|
||||
|
||||
// FACTS_SQL above is retained verbatim for applied schema95/97/98 migrations.
|
||||
// Current capture resolves the existing Subject singleton owner, never a second registry.
|
||||
fn subject_facts_sql() -> String {
|
||||
let prefix = crate::subjektiv::SUBJECT_WORKER_SINGLETON_PREFIX.replace('\'', "''");
|
||||
let owner = |column: &str| {
|
||||
format!(
|
||||
"CASE WHEN a.principal_kind='subject' THEN (SELECT {column} FROM worker_singleton_owners WHERE workspace_id=a.workspace_id AND singleton_key='{prefix}' || a.principal_id) ELSE a.{column} END"
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"json_object('principal_kind',a.principal_kind,'principal_id',a.principal_id,'runtime_id',{},'worker_id',{},'status',json_extract(a.tracking_json,'$.status'),'case_state',t.workflow_state,'released',json(CASE WHEN EXISTS(SELECT 1 FROM ticket_assignment_work_releases r WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id) THEN 'true' ELSE 'false' END))",
|
||||
owner("runtime_id"),
|
||||
owner("worker_id")
|
||||
)
|
||||
}
|
||||
|
||||
/// Additive upgrade of future capture only. Old recipient snapshots and receipts remain
|
||||
/// evidence; an unresolved legacy principal is not mapped to an arbitrary Subject.
|
||||
/// The caller owns the Workspace schema transaction and runs this after assignment migration.
|
||||
pub(super) fn install_subject_recipients(conn: &Connection) -> Result<()> {
|
||||
let facts = subject_facts_sql();
|
||||
for trigger in [
|
||||
"notification_ticket_event",
|
||||
"notification_assignment_history",
|
||||
"notification_assignment_change",
|
||||
"notification_assignment_release",
|
||||
"notification_content_input",
|
||||
"notification_unassigned_content",
|
||||
"notification_mr_event",
|
||||
"notification_mr_source",
|
||||
"notification_implementation_result",
|
||||
] {
|
||||
let sql: String = conn.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE type='trigger' AND name=?1",
|
||||
[trigger],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !sql.contains(FACTS_SQL) {
|
||||
return Err(Error::Store(format!(
|
||||
"Notification capture {trigger} does not match the prior schema"
|
||||
)));
|
||||
}
|
||||
conn.execute_batch(&format!(
|
||||
"DROP TRIGGER {trigger}; {}",
|
||||
sql.replace(FACTS_SQL, &facts)
|
||||
))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn capture(input: &str, kind: &str, actor: &str, event_index: &str) -> String {
|
||||
format!("INSERT OR IGNORE INTO notification_sources(workspace_id,ticket_id,input_event_id,role,assignment_id,kind,actor,event_index,facts_json)
|
||||
SELECT NEW.workspace_id,NEW.ticket_id,{input},roles.role,COALESCE(a.assignment_id,''),{kind},{actor},{event_index},{FACTS_SQL}
|
||||
@@ -694,7 +747,8 @@ pub(super) fn require_current(
|
||||
assignment: &str,
|
||||
role: &str,
|
||||
) -> Result<()> {
|
||||
let facts:Option<String>=conn.query_row(&format!("SELECT {FACTS_SQL} FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id WHERE c.workspace_id=?1 AND c.ticket_id=?2 AND c.assignment_id=?3 AND c.role=?4"),params![workspace,ticket,assignment,role],|r|r.get(0)).optional()?;
|
||||
let facts_sql = subject_facts_sql();
|
||||
let facts:Option<String>=conn.query_row(&format!("SELECT {facts_sql} FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id WHERE c.workspace_id=?1 AND c.ticket_id=?2 AND c.assignment_id=?3 AND c.role=?4"),params![workspace,ticket,assignment,role],|r|r.get(0)).optional()?;
|
||||
if let Some(facts) = facts {
|
||||
let facts: Facts = decode(&facts)?;
|
||||
if policy(
|
||||
@@ -819,6 +873,13 @@ fn worker_guard(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn worker_acknowledged(conn: &Connection, workspace: &str, id: &str) -> Result<bool> {
|
||||
Ok(conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM notification_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2)",
|
||||
params![workspace, id], |row| row.get(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub(super) fn prepare_worker(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace: &str,
|
||||
@@ -833,6 +894,7 @@ pub(super) fn prepare_worker(
|
||||
}
|
||||
store.with_conn_mut(|conn| {let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
worker_guard(&tx,workspace,id,target)?;
|
||||
if worker_acknowledged(&tx,workspace,id)? { return Err(Error::TicketAssignmentConflict("Worker notification already has Runtime acceptance; new send admission is closed".into())); }
|
||||
let saved:Option<(String,String)>=tx.query_row("SELECT content,target_json FROM notification_requests WHERE workspace_id=?1 AND delivery_id=?2",params![workspace,id],|r|Ok((r.get(0)?,r.get(1)?))).optional()?;
|
||||
let result=if let Some((saved,binding))=saved {
|
||||
if binding!=json(target)? { return Err(Error::TicketAssignmentConflict("Prepared notification target binding changed".into())); }
|
||||
@@ -841,6 +903,31 @@ pub(super) fn prepare_worker(
|
||||
tx.commit()?;Ok(result)
|
||||
})
|
||||
}
|
||||
/// Authorize a new Runtime send, not a historical receipt read. The Host must
|
||||
/// additionally check live recipient policy before supply and immediately before
|
||||
/// I/O. An ACK closes admission without erasing the pinned request or receipt.
|
||||
pub(super) fn admit_worker_send(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace: &str,
|
||||
id: &str,
|
||||
target: &TicketWorkerNotificationTarget,
|
||||
) -> Result<Option<String>> {
|
||||
store.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
worker_guard(&tx, workspace, id, target)?;
|
||||
let (content, saved_target): (String, String) = tx.query_row(
|
||||
"SELECT content,target_json FROM notification_requests WHERE workspace_id=?1 AND delivery_id=?2",
|
||||
params![workspace, id], |row| Ok((row.get(0)?, row.get(1)?)),
|
||||
).optional()?.ok_or_else(|| Error::TicketAssignmentConflict("Worker notification send requires a persisted request".into()))?;
|
||||
if saved_target != json(target)? {
|
||||
return Err(Error::TicketAssignmentConflict("Worker notification send target differs from prepared request".into()));
|
||||
}
|
||||
let content = if worker_acknowledged(&tx, workspace, id)? { None } else { Some(content) };
|
||||
tx.commit()?;
|
||||
Ok(content)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn ack_worker(
|
||||
store: &SqliteWorkspaceStore,
|
||||
workspace: &str,
|
||||
@@ -866,8 +953,8 @@ mod tests {
|
||||
#[test]
|
||||
fn notification_routing_policy_distinguishes_requests_attention_results_and_suppression() {
|
||||
let mut facts = Facts {
|
||||
principal_kind: Some("workspace_agent".into()),
|
||||
principal_id: Some("workspace-orchestrator".into()),
|
||||
principal_kind: Some("subject".into()),
|
||||
principal_id: Some("subject-a".into()),
|
||||
runtime_id: None,
|
||||
worker_id: None,
|
||||
status: Some("waiting".into()),
|
||||
|
||||
@@ -49,7 +49,7 @@ fn routing_ack_worker_pending(
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_diagnostics_preview_unrouted_requests_readonly_beyond_global_batch() {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
seed_manual_start_ticket(&store, "other-ticket", "open");
|
||||
store.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction()?;
|
||||
@@ -59,7 +59,7 @@ async fn notification_routing_diagnostics_preview_unrouted_requests_readonly_bey
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
tracking_store_assign(&store, "a", "assign-a");
|
||||
delivery_tracking_assign(&store, "a", "assign-a");
|
||||
delivery_store_answer(&store, "preview-answer");
|
||||
// An answer without the assignment fence must be suppressed by the same Rust policy.
|
||||
delivery_store_external_event(&store, 1, "assignment_answer", None);
|
||||
@@ -183,9 +183,9 @@ async fn notification_routing_orchestrator_ack_retains_authorized_target_after_r
|
||||
"worker-replaced",
|
||||
] {
|
||||
for prepare in [false, true] {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store, "a", "assign-a");
|
||||
let source = tracking_store_source();
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
delivery_tracking_assign(&store, "a", "assign-a");
|
||||
let source = delivery_tracking_source();
|
||||
let delivery = delivery_store_reserve_initial(&store, &source);
|
||||
if prepare {
|
||||
store
|
||||
@@ -200,7 +200,7 @@ async fn notification_routing_orchestrator_ack_retains_authorized_target_after_r
|
||||
match change {
|
||||
"done" => { store.update_ticket_assignment_tracking("w", "tracking", &tracking_store_input("a", "event-assign-a", "done-a"), &source).unwrap(); }
|
||||
"release" => { store.clear_current_ticket_role_assignment("w", "tracking", TicketAssignmentRole::Orchestrator, "a", "clear-a", "clear-a", "human", "now", None).unwrap(); }
|
||||
"reassign" => { tracking_store_assign(&store, "b", "assign-b"); }
|
||||
"reassign" => { delivery_tracking_assign(&store, "b", "assign-b"); }
|
||||
_ => store.with_conn(|conn| {
|
||||
match change {
|
||||
"revoked" => conn.execute("UPDATE workspace_runtime_bindings SET revoked_at='revoked' WHERE workspace_id='w'", [])?,
|
||||
@@ -215,7 +215,7 @@ async fn notification_routing_orchestrator_ack_retains_authorized_target_after_r
|
||||
match field {
|
||||
"runtime" => forged.runtime_id = "forged".into(),
|
||||
"worker" => forged.worker_id = "replacement".into(),
|
||||
"agent" => forged.agent_key = "forged".into(),
|
||||
"agent" => forged.subject_id = "forged".into(),
|
||||
_ => forged.authentication_binding_id = "replacement-binding".into(),
|
||||
}
|
||||
assert!(
|
||||
@@ -280,9 +280,9 @@ async fn notification_routing_orchestrator_ack_retains_authorized_target_after_r
|
||||
#[tokio::test]
|
||||
async fn notification_routing_replacement_target_requires_explicit_authorized_reserve_or_prepare() {
|
||||
for prepare in [false, true] {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store, "a", "assign-a");
|
||||
let source = tracking_store_source();
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
delivery_tracking_assign(&store, "a", "assign-a");
|
||||
let source = delivery_tracking_source();
|
||||
let delivery = delivery_store_reserve_initial(&store, &source);
|
||||
store
|
||||
.prepare_ticket_assignment_delivery_content(
|
||||
@@ -292,10 +292,7 @@ async fn notification_routing_replacement_target_requires_explicit_authorized_re
|
||||
&source,
|
||||
)
|
||||
.unwrap();
|
||||
store.with_conn(|conn| {
|
||||
conn.execute("UPDATE worker_singleton_owners SET worker_id='replacement' WHERE workspace_id='w'", [])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
delivery_tracking_replace_body(&store);
|
||||
let replacement = TicketAssignmentTrackingSourceContext {
|
||||
worker_id: "replacement".into(),
|
||||
..source.clone()
|
||||
@@ -376,7 +373,7 @@ async fn notification_routing_replacement_target_requires_explicit_authorized_re
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_commit_without_callback_recovers_and_rollback_has_no_source() {
|
||||
let (directory, store) = tracking_store_fixture().await;
|
||||
let (directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
routing_ack_worker_pending(&store, &target);
|
||||
store.with_conn_mut(|conn| {
|
||||
@@ -408,7 +405,7 @@ async fn notification_routing_commit_without_callback_recovers_and_rollback_has_
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_parallel_scan_and_render_reuse_request_after_response_loss() {
|
||||
let (directory, store) = tracking_store_fixture().await;
|
||||
let (directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
routing_ack_worker_pending(&store, &target);
|
||||
delivery_store_external_event(&store, 0, "comment", None);
|
||||
@@ -497,7 +494,7 @@ async fn notification_routing_parallel_scan_and_render_reuse_request_after_respo
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_assignment_change_between_commit_and_scan_never_transfers_input() {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
routing_ack_worker_pending(&store, &target);
|
||||
delivery_store_external_event(&store, 0, "comment", None);
|
||||
@@ -517,7 +514,7 @@ async fn notification_routing_assignment_change_between_commit_and_scan_never_tr
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_ack_retains_acceptance_after_responsibility_changes() {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
routing_ack_worker_pending(&store, &target);
|
||||
delivery_store_external_event(&store, 0, "comment", None);
|
||||
@@ -557,7 +554,7 @@ async fn notification_routing_ack_retains_acceptance_after_responsibility_change
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_editor_is_not_original_author_and_unknown_events_are_deferred() {
|
||||
let (directory, store) = tracking_store_fixture().await;
|
||||
let (directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
routing_ack_worker_pending(&store, &target);
|
||||
use ticket::TicketBackend;
|
||||
@@ -625,7 +622,7 @@ async fn notification_routing_editor_is_not_original_author_and_unknown_events_a
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_failures_do_not_starve_other_inputs_or_replace_requests() {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
routing_ack_worker_pending(&store, &target);
|
||||
delivery_store_external_event(&store, 0, "comment", None);
|
||||
@@ -674,7 +671,7 @@ async fn notification_routing_failures_do_not_starve_other_inputs_or_replace_req
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_target_binding_and_workspace_are_rechecked_before_prepare() {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
let input = store
|
||||
.list_pending_ticket_worker_notifications("w", 100)
|
||||
@@ -733,23 +730,27 @@ fn notification_routing_migration_preserves_receipts_does_not_replay_comments_an
|
||||
waiting_reason: None,
|
||||
next_action: None,
|
||||
};
|
||||
// Retained94 stores an agent-key context, not today's Subject context.
|
||||
let reserved_source_json = r#"{"actor":"authenticated-worker:r/worker","runtime_id":"r","worker_id":"worker","agent_key":"workspace-orchestrator","authentication_binding_id":"tracking-binding"}"#;
|
||||
let acknowledged_source_json = r#"{"actor":"authenticated-worker:r/worker","runtime_id":"r","worker_id":"old-replacement","agent_key":"workspace-orchestrator","authentication_binding_id":"tracking-binding"}"#;
|
||||
conn.execute(
|
||||
"INSERT INTO ticket_assignment_deliveries VALUES('w','a-pending',?1,'preserved-id',?2,?3)",
|
||||
params![
|
||||
payload.input_event_id,
|
||||
serde_json::to_string(&payload).unwrap(),
|
||||
serde_json::to_string(&tracking_store_source()).unwrap()
|
||||
reserved_source_json
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO ticket_assignment_delivery_requests VALUES('w','preserved-id','immutable request')",[]).unwrap();
|
||||
let acknowledged_target = TicketAssignmentTrackingSourceContext {
|
||||
worker_id: "old-replacement".into(),
|
||||
..tracking_store_source()
|
||||
subject_id: "workspace-orchestrator".into(),
|
||||
..delivery_tracking_source()
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO ticket_assignment_delivery_acknowledgements VALUES('w','preserved-id',?1)",
|
||||
[serde_json::to_string(&acknowledged_target).unwrap()],
|
||||
[acknowledged_source_json],
|
||||
)
|
||||
.unwrap();
|
||||
let requests_before = stored_fixture_rows(&conn, "ticket_assignment_delivery_requests");
|
||||
@@ -806,9 +807,18 @@ fn notification_routing_migration_preserves_receipts_does_not_replay_comments_an
|
||||
!pending.iter().any(|p| p.assignment_id == "a-pending"),
|
||||
"saved ACK remains authoritative even with non-derived legacy delivery ID"
|
||||
);
|
||||
store
|
||||
.ack_ticket_assignment_delivery("w", "preserved-id", &acknowledged_target)
|
||||
.unwrap();
|
||||
// A new Subject-shaped context must not reinterpret the immutable agent ACK.
|
||||
assert!(matches!(
|
||||
store.ack_ticket_assignment_delivery("w", "preserved-id", &acknowledged_target),
|
||||
Err(Error::TicketAssignmentConflict(_))
|
||||
));
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(stored_fixture_rows(conn, "ticket_assignment_delivery_requests"), requests_before);
|
||||
assert_eq!(stored_fixture_rows(conn, "ticket_assignment_delivery_acknowledgements"), acks_before);
|
||||
assert_eq!(stored_fixture_rows(conn, "ticket_assignment_deliveries"), before);
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM notification_assignment_targets WHERE delivery_id='preserved-id' AND agent_key='workspace-orchestrator'", [], |row| row.get::<_, i64>(0))?, 2);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -817,7 +827,7 @@ async fn notification_routing_missing_authorized_target_guard_refuses_startup()
|
||||
"notification_assignment_targets_no_update",
|
||||
"notification_assignment_targets_no_delete",
|
||||
] {
|
||||
let (directory, store) = tracking_store_fixture().await;
|
||||
let (directory, store) = delivery_tracking_fixture().await;
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch(&format!("DROP TRIGGER {guard}"))?;
|
||||
@@ -859,7 +869,7 @@ async fn notification_routing_fresh_and_upgraded_schema_and_guards_match() {
|
||||
Ok(snapshot(conn))
|
||||
})
|
||||
.unwrap();
|
||||
let (fresh_directory, fresh) = tracking_store_fixture().await;
|
||||
let (fresh_directory, fresh) = delivery_tracking_fixture().await;
|
||||
fresh
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(snapshot(conn), upgraded);
|
||||
@@ -883,7 +893,7 @@ async fn notification_routing_fresh_and_upgraded_schema_and_guards_match() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_content_without_recipient_records_reason_and_domain_reference() {
|
||||
let (directory, store) = tracking_store_fixture().await;
|
||||
let (directory, store) = delivery_tracking_fixture().await;
|
||||
use ticket::TicketBackend;
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open_verified(directory.path().join("workspace.db"), "w")
|
||||
@@ -912,7 +922,7 @@ async fn notification_routing_content_without_recipient_records_reason_and_domai
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_clear_retains_original_assignment_and_suppression_reason() {
|
||||
let (_directory, store) = tracking_store_fixture().await;
|
||||
let (_directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
routing_ack_worker_pending(&store, &target);
|
||||
store
|
||||
@@ -939,3 +949,297 @@ async fn notification_routing_clear_retains_original_assignment_and_suppression_
|
||||
assert_eq!(row,("worker-a".into(),"historical_only".into()));Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_notification_capture_uses_each_subject_current_body_and_results_never_retarget() {
|
||||
let (_directory, store) = subject_notification_store().await;
|
||||
subject_notification_assign(&store, "subject-ticket-a", "assigned-a", "subject-a");
|
||||
subject_notification_assign(&store, "subject-ticket-b", "assigned-b", "subject-b");
|
||||
let report = |operation: &str| server_api::TicketImplementationResultRequest {
|
||||
operation_key: operation.into(),
|
||||
kind: server_api::TicketImplementationResultKind::Result,
|
||||
body: "Saved background result".into(),
|
||||
merge_request: None,
|
||||
};
|
||||
// A Subject body's own result is not sent back to itself. The same Worker can
|
||||
// report to another Subject without a global singleton stealing the recipient.
|
||||
let body_a = RuntimeWorkerRef::new("r", "worker");
|
||||
let own = store
|
||||
.record_implementation_result(
|
||||
"w",
|
||||
"subject-ticket-a",
|
||||
&report("own-a"),
|
||||
&body_a,
|
||||
"subject-binding",
|
||||
)
|
||||
.unwrap();
|
||||
let other = store
|
||||
.record_implementation_result(
|
||||
"w",
|
||||
"subject-ticket-b",
|
||||
&report("a-to-b"),
|
||||
&body_a,
|
||||
"subject-binding",
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap();
|
||||
store.with_conn(|conn| {
|
||||
let own_reason: String = conn.query_row("SELECT d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1", [format!("implementation-result:{}", own.result_id)], |row| row.get(0))?;
|
||||
assert_eq!(own_reason, "self_notification");
|
||||
let recipient: (String, String, String, String) = conn.query_row("SELECT s.assignment_id,json_extract(s.facts_json,'$.principal_id'),json_extract(s.facts_json,'$.worker_id'),d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1", [format!("implementation-result:{}", other.result_id)], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))?;
|
||||
assert_eq!(recipient, ("assigned-b".into(), "subject-b".into(), "body-b".into(), "result".into()));
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
subject_notification_replace_body(&store, "subject-b");
|
||||
let replacement = RuntimeWorkerRef::new("r", "body-replacement");
|
||||
let self_after_swap = store
|
||||
.record_implementation_result(
|
||||
"w",
|
||||
"subject-ticket-b",
|
||||
&report("new-body-self"),
|
||||
&replacement,
|
||||
"subject-binding",
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap();
|
||||
store.with_conn(|conn| {
|
||||
let self_reason: String = conn.query_row("SELECT d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id=?1", [format!("implementation-result:{}", self_after_swap.result_id)], |row| row.get(0))?;
|
||||
assert_eq!(self_reason, "self_notification");
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
subject_notification_assign(&store, "subject-ticket-b", "new-a-assignment", "subject-a");
|
||||
assert!(
|
||||
!store
|
||||
.list_pending_ticket_assignment_deliveries("w", 100)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|input| input.input_event_id
|
||||
== format!("implementation-result:{}", other.result_id))
|
||||
);
|
||||
// Replaying the saved report is still evidence about the original assignment.
|
||||
assert_eq!(
|
||||
store
|
||||
.record_implementation_result(
|
||||
"w",
|
||||
"subject-ticket-b",
|
||||
&report("a-to-b"),
|
||||
&body_a,
|
||||
"subject-binding"
|
||||
)
|
||||
.unwrap(),
|
||||
other
|
||||
);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
let recipient: String = conn.query_row(
|
||||
"SELECT assignment_id FROM notification_sources WHERE input_event_id=?1",
|
||||
[format!("implementation-result:{}", other.result_id)],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(recipient, "assigned-b");
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_notification_upgrade_preserves_legacy_sources_requests_and_receipts_and_rolls_back() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let conn = tracking_schema91_fixture(&directory.path().join("workspace.db"));
|
||||
migrate_ticket_tracking_v91_to_v92(&conn).unwrap();
|
||||
migrate_assignment_delivery_v92_to_v93(&conn).unwrap();
|
||||
migrate_ticket_posts_v93_to_v94(&conn).unwrap();
|
||||
migrate_notification_routing_v94_to_v95(&conn).unwrap();
|
||||
migrate_job_notifications_v95_to_v96(&conn).unwrap();
|
||||
migrate_business_notifications_v96_to_v97(&conn).unwrap();
|
||||
migrate_result_reports_v97_to_v98(&conn).unwrap();
|
||||
let legacy_source = TicketAssignmentTrackingSourceContext {
|
||||
actor: "legacy-delivery".into(),
|
||||
runtime_id: "old-runtime".into(),
|
||||
worker_id: "old-worker".into(),
|
||||
subject_id: "workspace-orchestrator".into(),
|
||||
authentication_binding_id: "old-binding".into(),
|
||||
};
|
||||
let delivery = TicketAssignmentDelivery {
|
||||
ticket_id: "pending".into(),
|
||||
assignment_id: "a-pending".into(),
|
||||
input_event_id: "assignment-event:migration-tracking:a-pending".into(),
|
||||
delivery_id: "legacy-delivery".into(),
|
||||
request_id: "legacy-request".into(),
|
||||
waiting_reason: None,
|
||||
next_action: None,
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO ticket_assignment_deliveries VALUES('w','a-pending',?1,?2,?3,?4)",
|
||||
params![
|
||||
delivery.input_event_id,
|
||||
delivery.delivery_id,
|
||||
serde_json::to_string(&delivery).unwrap(),
|
||||
serde_json::to_string(&legacy_source).unwrap()
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute("INSERT INTO ticket_assignment_delivery_requests VALUES('w','legacy-delivery','legacy bytes')", []).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO ticket_assignment_delivery_acknowledgements VALUES('w','legacy-delivery',?1)",
|
||||
[serde_json::to_string(&legacy_source).unwrap()],
|
||||
)
|
||||
.unwrap();
|
||||
let tables = [
|
||||
"ticket_worker_assignments",
|
||||
"notification_sources",
|
||||
"notification_decisions",
|
||||
"ticket_assignment_deliveries",
|
||||
"ticket_assignment_delivery_requests",
|
||||
"ticket_assignment_delivery_acknowledgements",
|
||||
];
|
||||
let before: Vec<_> = tables
|
||||
.iter()
|
||||
.map(|table| stored_fixture_rows(&conn, table))
|
||||
.collect();
|
||||
let triggers_before = fixture_query_rows(
|
||||
&conn,
|
||||
"SELECT name,sql FROM sqlite_schema WHERE type='trigger' ORDER BY name",
|
||||
);
|
||||
// Fail after some triggers were refreshed. The caller's transaction restores
|
||||
// the entire previous capture schema, not a half-upgraded notification path.
|
||||
{
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
tx.execute_batch("DROP TRIGGER notification_mr_event; CREATE TRIGGER notification_mr_event AFTER INSERT ON merge_request_thread_events BEGIN SELECT 1; END;").unwrap();
|
||||
assert!(notification_routing::install_subject_recipients(&tx).is_err());
|
||||
}
|
||||
assert_eq!(
|
||||
fixture_query_rows(
|
||||
&conn,
|
||||
"SELECT name,sql FROM sqlite_schema WHERE type='trigger' ORDER BY name"
|
||||
),
|
||||
triggers_before
|
||||
);
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
notification_routing::install_subject_recipients(&tx).unwrap();
|
||||
tx.commit().unwrap();
|
||||
let after: Vec<_> = tables
|
||||
.iter()
|
||||
.map(|table| stored_fixture_rows(&conn, table))
|
||||
.collect();
|
||||
assert_eq!(after, before, "migration updates future capture only");
|
||||
for trigger in [
|
||||
"notification_ticket_event",
|
||||
"notification_assignment_history",
|
||||
"notification_assignment_change",
|
||||
"notification_assignment_release",
|
||||
"notification_content_input",
|
||||
"notification_unassigned_content",
|
||||
"notification_mr_event",
|
||||
"notification_mr_source",
|
||||
"notification_implementation_result",
|
||||
] {
|
||||
let sql: String = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name=?1",
|
||||
[trigger],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(sql.contains("a.principal_kind='subject'"), "{trigger}");
|
||||
assert!(
|
||||
!sql.contains("a.principal_kind='workspace_agent'"),
|
||||
"{trigger}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
notification_routing::pending_sources(&conn, "w", "orchestrator", 100)
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"old principals and receipts are evidence, not new Subject requests"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_routing_cached_worker_attention_stops_after_ack_not_after_unknown_acceptance()
|
||||
{
|
||||
let (directory, store) = delivery_tracking_fixture().await;
|
||||
let target = routing_worker_assign(&store, "worker-a");
|
||||
let cached = store
|
||||
.list_pending_ticket_worker_notifications("w", 100)
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
assert!(
|
||||
notification_routing::admit_worker_send(&store, "w", &cached.delivery_id, &target).is_err()
|
||||
);
|
||||
store
|
||||
.prepare_ticket_worker_notification_content(
|
||||
"w",
|
||||
&cached.delivery_id,
|
||||
"pinned attention",
|
||||
&target,
|
||||
)
|
||||
.unwrap();
|
||||
let mut sends = Vec::new();
|
||||
let mut dispatch_cached = |store: &SqliteWorkspaceStore| {
|
||||
if let Some(content) =
|
||||
notification_routing::admit_worker_send(store, "w", &cached.delivery_id, &target)
|
||||
.unwrap()
|
||||
{
|
||||
sends.push((cached.request_id.clone(), content));
|
||||
}
|
||||
};
|
||||
dispatch_cached(&store);
|
||||
store
|
||||
.record_notification_delivery_failure(
|
||||
"w",
|
||||
&NotificationDeliveryKey::WorkerNotification {
|
||||
delivery_id: cached.delivery_id.clone(),
|
||||
},
|
||||
NotificationFailureCode::AcceptanceUnknown,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.prepare_ticket_worker_notification_content(
|
||||
"w",
|
||||
&cached.delivery_id,
|
||||
"different rendering",
|
||||
&target
|
||||
)
|
||||
.unwrap(),
|
||||
"pinned attention"
|
||||
);
|
||||
dispatch_cached(&store);
|
||||
let other = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
|
||||
other
|
||||
.ack_ticket_worker_notification("w", &cached.delivery_id, &target)
|
||||
.unwrap();
|
||||
dispatch_cached(&store);
|
||||
assert_eq!(
|
||||
sends,
|
||||
vec![(cached.request_id.clone(), "pinned attention".into()); 2]
|
||||
);
|
||||
assert!(matches!(
|
||||
store.prepare_ticket_worker_notification_content(
|
||||
"w",
|
||||
&cached.delivery_id,
|
||||
"late render",
|
||||
&target
|
||||
),
|
||||
Err(Error::TicketAssignmentConflict(_))
|
||||
));
|
||||
store
|
||||
.ack_ticket_worker_notification("w", &cached.delivery_id, &target)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.list_pending_ticket_worker_notifications("w", 100)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
store.with_conn(|conn| {
|
||||
let content: String = conn.query_row("SELECT content FROM notification_requests WHERE workspace_id='w' AND delivery_id=?1", [&cached.delivery_id], |row| row.get(0))?;
|
||||
assert_eq!(content, "pinned attention", "historical request bytes remain readable");
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
|
||||
@@ -27,7 +27,11 @@ fn plugin_catalog_migration_matches_fresh_schema_from_oldest_retained_and_105()
|
||||
assert_eq!(catalog_schema(c),canonical);
|
||||
let after=workspace_schema_migration_history(c)?;
|
||||
assert!(after.starts_with(&before));
|
||||
assert_eq!(after.last().unwrap(),&(106,crate::plugin_catalog::MIGRATION_NAME.into()));
|
||||
// Plugin catalog remains migration 106 even when startup applies later steps.
|
||||
assert_eq!(after.iter().filter(|(version, _)| *version == 106).cloned().collect::<Vec<_>>(),
|
||||
vec![(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
|
||||
assert_eq!(after.last().unwrap(), &(107, "Subject Ticket responsibility and current-body notification routing".into()));
|
||||
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
|
||||
assert_eq!(c.query_row("SELECT display_name FROM workspaces WHERE workspace_id='plugin-workspace'",[],|r|r.get::<_,String>(0))?,"Plugins");
|
||||
assert_eq!(c.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
|
||||
assert!(c.execute("INSERT INTO plugin_installations VALUES('absent','i','alias','p','operation',0)",[]).is_err());
|
||||
@@ -57,7 +61,14 @@ fn plugin_catalog_migration_marker_failure_rolls_back_then_retries_without_chang
|
||||
let migrated = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
migrated
|
||||
.with_conn(|c| {
|
||||
assert_eq!(current_schema_version(c)?, 106);
|
||||
assert_eq!(current_schema_version(c)?, LATEST_SCHEMA_VERSION);
|
||||
let mut expected = before.clone();
|
||||
expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into()));
|
||||
expected.push((
|
||||
107,
|
||||
"Subject Ticket responsibility and current-body notification routing".into(),
|
||||
));
|
||||
assert_eq!(workspace_schema_migration_history(c)?, expected);
|
||||
crate::plugin_catalog::verify(c)?;
|
||||
Ok(())
|
||||
})
|
||||
|
||||
@@ -104,6 +104,10 @@ mod schema_convergence {
|
||||
version: 106,
|
||||
name: crate::plugin_catalog::MIGRATION_NAME.into(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 107,
|
||||
name: "Subject Ticket responsibility and current-body notification routing".into(),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -121,6 +125,7 @@ mod schema_convergence {
|
||||
expected.push((104, "Converge Passkey results and Job input retention".into()));
|
||||
expected.push((105, "Durable Subject deletion snapshots and Job admission fences".into()));
|
||||
expected.push((106, crate::plugin_catalog::MIGRATION_NAME.into()));
|
||||
expected.push((107, "Subject Ticket responsibility and current-body notification routing".into()));
|
||||
assert_eq!(workspace_schema_migration_history(conn)?, expected);
|
||||
for table in ["passkey_credentials", "passkey_metadata", "passkey_addition_receipts", "backend_jobs", "backend_job_attempts"] {
|
||||
assert_eq!(state_contract_schema_signature(conn, table), fresh.with_conn(|c| Ok(state_contract_schema_signature(c, table)))?, "{table}");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ async fn attention_test_api() -> (
|
||||
) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
let (api, execution) = test_api_with_recording_backend(dir.path()).await;
|
||||
let (api, execution) = assignment_test_api_with_recording_backend(dir.path()).await;
|
||||
attention_assign_ticket(&api, "Attention backlog");
|
||||
(dir, api, execution)
|
||||
}
|
||||
@@ -25,18 +25,214 @@ fn attention_pending(api: &WorkspaceApi) -> Vec<crate::store::TicketAssignmentDe
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn start_attention_test_orchestrator(
|
||||
// Operational fixtures use Subject Feature storage, never a legacy agent alias.
|
||||
fn ensure_test_assignment_subject(api: &WorkspaceApi) -> String {
|
||||
let store = open_subjektiv_store(api).unwrap();
|
||||
if let Some(subject) = store
|
||||
.list_subjects(100)
|
||||
.unwrap()
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|subject| subject.role.as_str() == "assignment-fixture")
|
||||
{
|
||||
return subject.id;
|
||||
}
|
||||
store
|
||||
.create_subject_with_behavior(
|
||||
crate::subjektiv::SubjectRole::new("assignment-fixture").unwrap(),
|
||||
"Handle only the assigned Ticket; distinguish delivery from processing.".into(),
|
||||
)
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
|
||||
fn test_assignment_principal(api: &WorkspaceApi) -> serde_json::Value {
|
||||
serde_json::json!({"kind":"subject", "subject_id":ensure_test_assignment_subject(api)})
|
||||
}
|
||||
|
||||
fn grant_test_assignment_ticket(api: &WorkspaceApi, subject_id: &str, ticket_id: &str) {
|
||||
grant_test_ticket_operations(
|
||||
api,
|
||||
subject_id,
|
||||
ticket_id,
|
||||
&[
|
||||
"ticket.read",
|
||||
"ticket.assignment.query",
|
||||
"ticket.assignment.history",
|
||||
"ticket.implementation_result.write",
|
||||
"ticket.assignment.assign",
|
||||
"ticket.assignment.clear",
|
||||
"ticket.assignment.answer",
|
||||
"ticket.assignment.update",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn grant_test_ticket_operations(
|
||||
api: &WorkspaceApi,
|
||||
) -> BrowserWorkspaceOrchestratorResponse {
|
||||
scoped_start_workspace_orchestrator(
|
||||
subject_id: &str,
|
||||
ticket_id: &str,
|
||||
operations: &[&str],
|
||||
) {
|
||||
use server_api::authorization::{
|
||||
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
|
||||
};
|
||||
let store = open_subjektiv_store(api).unwrap();
|
||||
let previous = store.subject_permissions(subject_id).unwrap();
|
||||
let mut execution = previous
|
||||
.as_ref()
|
||||
.map(|p| p.execution.clone())
|
||||
.unwrap_or_default();
|
||||
for operation in operations {
|
||||
let grant = ScopedPermission {
|
||||
operation: (*operation).into(),
|
||||
resource: PermissionResource::Ticket {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
ticket_id: ticket_id.into(),
|
||||
},
|
||||
};
|
||||
if !execution.contains(&grant) {
|
||||
execution.push(grant);
|
||||
}
|
||||
}
|
||||
if previous.as_ref().is_some_and(|p| p.execution == execution) {
|
||||
return;
|
||||
}
|
||||
store
|
||||
.set_subject_permissions(
|
||||
subject_id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: Uuid::now_v7().to_string(),
|
||||
expected_change_id: previous.as_ref().map(|p| p.change_id.clone()),
|
||||
execution,
|
||||
delegation: previous.map(|p| p.delegation).unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn create_test_assignment_body(
|
||||
api: &WorkspaceApi,
|
||||
runtime_id: &str,
|
||||
subject_id: &str,
|
||||
) -> RuntimeWorkerRef {
|
||||
let Json(created) = create_workspace_worker(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
HeaderMap::new(),
|
||||
Json(CreateWorkspaceWorkerRequest {
|
||||
runtime_id: runtime_id.into(),
|
||||
display_name: "Assignment Subject".into(),
|
||||
singleton_key: None,
|
||||
profile: Some("builtin:companion".into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: vec![],
|
||||
workdir_attachments: vec![],
|
||||
feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest {
|
||||
subjektiv: Some(server_api::SubjektivWorkerConnectionRequest {
|
||||
subject_id: subject_id.into(),
|
||||
}),
|
||||
},
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.0
|
||||
.unwrap();
|
||||
RuntimeWorkerRef::new(created.runtime_id, created.worker_id)
|
||||
}
|
||||
|
||||
fn test_assignment_body(api: &WorkspaceApi) -> Option<InternalWorkerSummary> {
|
||||
let key = crate::subjektiv::subject_worker_singleton_key(&ensure_test_assignment_subject(api))
|
||||
.unwrap();
|
||||
let owner = api
|
||||
.store
|
||||
.current_worker_singleton_owner(TEST_WORKSPACE_ID, &key)
|
||||
.unwrap()?;
|
||||
api.runtime.worker(&owner.worker).ok()
|
||||
}
|
||||
|
||||
async fn start_attention_test_orchestrator(
|
||||
api: &WorkspaceApi,
|
||||
) -> BrowserWorkspaceOrchestratorResponse {
|
||||
// Serialize fixture callers, but exercise normal Subject create/restore.
|
||||
// There is no legacy launch, synthetic singleton or alternate executor.
|
||||
let api = api.clone();
|
||||
api.host_executors
|
||||
.host()
|
||||
.unwrap()
|
||||
.run(move || {
|
||||
let guard = api.orchestrator_spawn_lock.lock().unwrap();
|
||||
seed_test_delivery_binding(&api);
|
||||
let subject_id = ensure_test_assignment_subject(&api);
|
||||
let (worker, disposition) = if let Some(existing) = test_assignment_body(&api) {
|
||||
if workspace_orchestrator_is_online(&existing) {
|
||||
(existing, "existing")
|
||||
} else {
|
||||
let restored =
|
||||
restore_internal_observed_worker(&api, &existing, "subject-restore".into())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restored.state,
|
||||
server_api::WorkerRestoreState::Accepted,
|
||||
"{:?}",
|
||||
restored.diagnostics
|
||||
);
|
||||
(api.runtime.worker(&existing.worker).unwrap(), "restored")
|
||||
}
|
||||
} else {
|
||||
let Json(created) =
|
||||
futures::executor::block_on(create_workspace_worker_authenticated_on_host(
|
||||
api.clone(),
|
||||
HeaderMap::new(),
|
||||
CreateWorkspaceWorkerRequest {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.into(),
|
||||
display_name: "Assignment Subject".into(),
|
||||
singleton_key: None,
|
||||
profile: Some("builtin:companion".into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: vec![],
|
||||
workdir_attachments: vec![],
|
||||
feature_connections:
|
||||
server_api::WorkspaceWorkerFeatureConnectionsRequest {
|
||||
subjektiv: Some(server_api::SubjektivWorkerConnectionRequest {
|
||||
subject_id,
|
||||
}),
|
||||
},
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
(
|
||||
api.runtime
|
||||
.worker(&RuntimeWorkerRef::new(
|
||||
created.runtime_id,
|
||||
created.worker_id,
|
||||
))
|
||||
.unwrap(),
|
||||
"created",
|
||||
)
|
||||
};
|
||||
if worker.worker.runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
|
||||
// Bind the canonical Session before dispatch. The deterministic
|
||||
// executor uses pending metadata; scripted Controllers retain
|
||||
// their already-created active Session, never a replacement ID.
|
||||
bootstrap_test_assignment_session(&api, &worker.worker);
|
||||
}
|
||||
drop(guard);
|
||||
dispatch_orchestrator_assignment_attention(&api, &worker.worker);
|
||||
Ok::<_, ApiError>(BrowserWorkspaceOrchestratorResponse {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
online: workspace_orchestrator_is_online(&worker),
|
||||
disposition: disposition.into(),
|
||||
worker: Some(worker_launch_worker_summary(worker)),
|
||||
diagnostics: vec![],
|
||||
})
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -191,6 +387,7 @@ async fn orchestrator_attention_input_rechecks_reserved_noop_before_and_after_co
|
||||
std::thread::scope(|scope| {
|
||||
let api_for_caller = api.clone();
|
||||
let worker_for_caller = worker.clone();
|
||||
let acknowledged_for_caller = acknowledged.request_id.clone();
|
||||
let caller = scope.spawn(move || {
|
||||
let mut checks = 0;
|
||||
with_orchestrator_attention_reservation(
|
||||
@@ -215,7 +412,7 @@ async fn orchestrator_attention_input_rechecks_reserved_noop_before_and_after_co
|
||||
// but the exact same production reservation still exists.
|
||||
assert_eq!(
|
||||
state.lock().unwrap().accepted.as_ref().unwrap().request_id,
|
||||
acknowledged.request_id
|
||||
acknowledged_for_caller
|
||||
);
|
||||
entered_tx.send(()).unwrap();
|
||||
release_rx
|
||||
@@ -235,19 +432,31 @@ async fn orchestrator_attention_input_rechecks_reserved_noop_before_and_after_co
|
||||
let restored = futures::executor::block_on(start_attention_test_orchestrator(&api));
|
||||
assert_eq!(restored.disposition, "restored");
|
||||
let deferred = state.lock().unwrap().input_recheck;
|
||||
let old_retired = state.lock().unwrap().accepted.is_none();
|
||||
let acknowledged_id = state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.accepted
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.request_id
|
||||
.clone();
|
||||
let inputs_before_release = execution.take_inputs();
|
||||
release_tx.send(()).unwrap();
|
||||
caller.join().unwrap();
|
||||
assert!(deferred);
|
||||
assert!(old_retired);
|
||||
// Generic Subject restore preserves the local success cache; the
|
||||
// durable delivery ACK, not cache retirement, prevents a resend.
|
||||
assert_eq!(acknowledged_id, acknowledged.request_id);
|
||||
assert!(inputs_before_release.is_empty());
|
||||
});
|
||||
assert!(execution.take_inputs().is_empty());
|
||||
assert!(execution.take_input_request_ids().is_empty());
|
||||
assert!(attention_pending(&api).is_empty());
|
||||
let state = state.lock().unwrap();
|
||||
assert!(state.accepted.is_none());
|
||||
assert_eq!(
|
||||
state.accepted.as_ref().unwrap().request_id,
|
||||
acknowledged.request_id
|
||||
);
|
||||
assert!(!state.sending);
|
||||
assert!(!state.input_recheck);
|
||||
}
|
||||
@@ -307,8 +516,38 @@ async fn orchestrator_attention_unknown_response_retry_keeps_id_and_payload() {
|
||||
let pending = state.lock().unwrap().pending.clone().unwrap();
|
||||
let attempts = execution.take_inputs();
|
||||
let ids = execution.take_input_request_ids();
|
||||
assert_eq!(attempts.len(), 2);
|
||||
assert_eq!(ids, vec![Some(pending.request_id.clone()); 2]);
|
||||
// Subject creation can trigger several ordinary delivery scans. Every
|
||||
// unknown-outcome attempt must preserve the exact request and payload.
|
||||
assert!(!attempts.is_empty());
|
||||
assert!(attempts.iter().all(|attempt| attempt.1 == pending.content));
|
||||
assert_eq!(ids, vec![Some(pending.request_id.clone()); attempts.len()]);
|
||||
dispatch_orchestrator_assignment_attention(&api, &worker);
|
||||
let explicit_retry = execution.take_inputs();
|
||||
assert!(!explicit_retry.is_empty());
|
||||
assert!(
|
||||
explicit_retry
|
||||
.iter()
|
||||
.all(|attempt| attempt.1 == pending.content)
|
||||
);
|
||||
assert_eq!(
|
||||
execution.take_input_request_ids(),
|
||||
vec![Some(pending.request_id.clone()); explicit_retry.len()]
|
||||
);
|
||||
// Complement the known-ACK cached resend regression below. An uncertain
|
||||
// transport outcome has NO durable acceptance, so the existing request must
|
||||
// remain retryable. Persistent Runtime receipt recovery is exercised by the
|
||||
// notification delivery fixture rather than reimplemented by this adapter.
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
let pending_acks = || {
|
||||
conn.query_row("SELECT count(*) FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2",
|
||||
rusqlite::params![TEST_WORKSPACE_ID, pending.input.delivery_id], |row| row.get::<_, i64>(0)).unwrap()
|
||||
};
|
||||
let pinned_bytes = || {
|
||||
conn.query_row("SELECT content FROM ticket_assignment_delivery_requests WHERE workspace_id=?1 AND delivery_id=?2",
|
||||
rusqlite::params![TEST_WORKSPACE_ID, pending.input.delivery_id], |row| row.get::<_, String>(0)).unwrap()
|
||||
};
|
||||
assert_eq!(pending_acks(), 0);
|
||||
assert_eq!(pinned_bytes(), pending.content);
|
||||
// Change the backlog before retry: uncertain acceptance must never reuse
|
||||
// the ID for a newly rendered payload.
|
||||
attention_assign_ticket(&api, "Changed backlog");
|
||||
@@ -327,7 +566,9 @@ async fn orchestrator_attention_unknown_response_retry_keeps_id_and_payload() {
|
||||
assert!(retry[1].1.contains("Changed backlog"));
|
||||
let ids = execution.take_input_request_ids();
|
||||
assert_eq!(ids.len(), 2);
|
||||
assert_eq!(ids[0], Some(pending.request_id));
|
||||
assert_eq!(ids[0], Some(pending.request_id.clone()));
|
||||
assert_eq!(pending_acks(), 1);
|
||||
assert_eq!(pinned_bytes(), pending.content);
|
||||
assert_ne!(ids[1], ids[0]);
|
||||
assert!(state.lock().unwrap().pending.is_none());
|
||||
assert!(state.lock().unwrap().accepted.is_some());
|
||||
@@ -414,7 +655,7 @@ impl AttentionWorkerFactory {
|
||||
worker_id: String,
|
||||
restore: bool,
|
||||
) -> std::result::Result<worker_runtime::worker_backend::RuntimeWorkerController, String> {
|
||||
let name = format!("attention-{worker_id}");
|
||||
let name = format!("worker-runtime-{worker_id}");
|
||||
let root = self.root.join("workers").join(&worker_id);
|
||||
let store = session_store::CombinedStore::new(
|
||||
session_store::WorkerSessionStore::new(root.join("session"))
|
||||
@@ -789,12 +1030,16 @@ async fn real_attention_worker_start_restore(lose_response: bool) {
|
||||
.unwrap();
|
||||
let restored = start_attention_test_orchestrator(&api).await;
|
||||
assert_eq!(restored.disposition, "restored");
|
||||
assert!(
|
||||
assert_eq!(
|
||||
orchestrator_attention_state(&api, &worker)
|
||||
.lock()
|
||||
.unwrap()
|
||||
.accepted
|
||||
.is_none()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.request_id,
|
||||
first_id,
|
||||
"generic Subject restore preserves the acknowledged delivery cache"
|
||||
);
|
||||
assert!(attention_pending(&api).is_empty());
|
||||
assert_eq!(
|
||||
@@ -803,8 +1048,13 @@ async fn real_attention_worker_start_restore(lose_response: bool) {
|
||||
);
|
||||
assert_eq!(receipts().len(), 1);
|
||||
assert_eq!(commits(), 1);
|
||||
// Retry directly at the real Controller boundary: the same Worker's
|
||||
// persisted receipt survives restore even after server memory is retired.
|
||||
// Explicitly simulate loss of the server's optional success cache. Retry
|
||||
// directly at the real Controller boundary: its persisted receipt survives
|
||||
// restore independently of server memory.
|
||||
orchestrator_attention_state(&api, &worker)
|
||||
.lock()
|
||||
.unwrap()
|
||||
.accepted = None;
|
||||
let retry = api
|
||||
.runtime
|
||||
.send_input(
|
||||
@@ -918,15 +1168,9 @@ async fn orchestrator_attention_restore_and_replacement_do_not_resend_acknowledg
|
||||
"existing"
|
||||
);
|
||||
assert!(execution.take_inputs().is_empty());
|
||||
// This is a fixture Worker, not the running dogfooding Orchestrator.
|
||||
api.runtime.delete_worker(&worker).unwrap();
|
||||
api.worker_projection
|
||||
.publish_ordered(|| {
|
||||
api.store
|
||||
.delete_worker_registry(TEST_WORKSPACE_ID, &worker)
|
||||
.map(|commit| ((), commit))
|
||||
})
|
||||
.unwrap();
|
||||
// Connected Subject occupancy is released only by the normal Host protocol,
|
||||
// even though this fixture substitutes the deterministic Controller.
|
||||
remove_test_assignment_body(&api, &worker).await;
|
||||
let replacement = start_attention_test_orchestrator(&api).await;
|
||||
let replacement_id = replacement.worker.unwrap().worker_id;
|
||||
assert_ne!(replacement_id, worker.worker_id);
|
||||
@@ -1020,8 +1264,8 @@ async fn orchestrator_attention_close_reopen_does_not_revive_old_input_but_reass
|
||||
ticket_id: original.input.ticket_id.clone(),
|
||||
assignment_id: "attention-reassigned".into(),
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
principal: TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: ensure_test_assignment_subject(&api),
|
||||
},
|
||||
assigned_by: "test-user".into(),
|
||||
assigned_at: "2026-09-02T00:00:00Z".into(),
|
||||
@@ -1149,3 +1393,219 @@ async fn orchestrator_running_to_idle_recovers_unreceived_input_without_notifica
|
||||
"foreign_workspace_ticket_projection"
|
||||
);
|
||||
}
|
||||
|
||||
// Lookup-only fault adapter around the existing fixture Runtime. It is installed
|
||||
// after normal Subject body creation and owns no launch/delegation implementation.
|
||||
// All observations and sends retain the existing adapter's behavior; only one
|
||||
// selected Runtime.worker() return runs the deterministic policy-change callback.
|
||||
struct AssignmentLookupHookRuntime {
|
||||
inner: WorkdirlessFixtureRuntime,
|
||||
hook: Mutex<Option<Box<dyn FnOnce() + Send>>>,
|
||||
}
|
||||
|
||||
impl crate::hosts::WorkspaceWorkerRuntime for AssignmentLookupHookRuntime {
|
||||
fn runtime_id(&self) -> &str {
|
||||
crate::hosts::WorkspaceWorkerRuntime::runtime_id(&self.inner)
|
||||
}
|
||||
fn runtime_summary(&self, limit: usize) -> crate::hosts::InternalRuntimeSummary {
|
||||
crate::hosts::WorkspaceWorkerRuntime::runtime_summary(&self.inner, limit)
|
||||
}
|
||||
fn list_hosts(
|
||||
&self,
|
||||
limit: usize,
|
||||
) -> crate::hosts::RuntimeList<crate::hosts::InternalHostSummary> {
|
||||
crate::hosts::WorkspaceWorkerRuntime::list_hosts(&self.inner, limit)
|
||||
}
|
||||
fn list_workers(&self, limit: usize) -> crate::hosts::RuntimeList<InternalWorkerSummary> {
|
||||
crate::hosts::WorkspaceWorkerRuntime::list_workers(&self.inner, limit)
|
||||
}
|
||||
fn worker(&self, worker_id: &str) -> crate::hosts::WorkerLookupResult {
|
||||
let observed = crate::hosts::WorkspaceWorkerRuntime::worker(&self.inner, worker_id);
|
||||
let hook = self.hook.lock().unwrap().take();
|
||||
if let Some(hook) = hook {
|
||||
hook();
|
||||
}
|
||||
observed
|
||||
}
|
||||
fn send_input(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
request: WorkerInputRequest,
|
||||
) -> crate::hosts::WorkerInputResult {
|
||||
crate::hosts::WorkspaceWorkerRuntime::send_input(&self.inner, worker_id, request)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_assignment_cached_send_rechecks_policy_after_runtime_target_lookup() {
|
||||
use server_api::authorization::SubjectPermissionsUpdate;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await;
|
||||
let runtime = WorkdirlessFixtureRuntime::default();
|
||||
api.runtime.register_or_replace(runtime.clone());
|
||||
let subject_id = ensure_test_assignment_subject(&api);
|
||||
let body =
|
||||
create_test_assignment_body(&api, WorkdirlessFixtureRuntime::RUNTIME_ID, &subject_id).await;
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new("Private assignment input"))
|
||||
.unwrap();
|
||||
grant_test_assignment_ticket(&api, &subject_id, &ticket.id);
|
||||
api.store
|
||||
.set_current_ticket_role_assignment(
|
||||
&TicketRoleAssignmentRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
ticket_id: ticket.id.clone(),
|
||||
assignment_id: "policy-lookup-assignment".into(),
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: subject_id.clone(),
|
||||
},
|
||||
assigned_by: "fixture-owner".into(),
|
||||
assigned_at: TEST_CREATED_AT.into(),
|
||||
},
|
||||
None,
|
||||
"policy-lookup-event",
|
||||
"policy-lookup-assign",
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let input = api
|
||||
.store
|
||||
.list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 20)
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
let target = orchestrator_delivery_target(&api, &body).unwrap();
|
||||
let input = api
|
||||
.store
|
||||
.reserve_ticket_assignment_delivery(
|
||||
TEST_WORKSPACE_ID,
|
||||
&input.assignment_id,
|
||||
&input.input_event_id,
|
||||
&target,
|
||||
)
|
||||
.unwrap();
|
||||
let content = api
|
||||
.store
|
||||
.prepare_ticket_assignment_delivery_content(
|
||||
TEST_WORKSPACE_ID,
|
||||
&input.delivery_id,
|
||||
"Pinned private Ticket context",
|
||||
&target,
|
||||
)
|
||||
.unwrap();
|
||||
let delivery = OrchestratorAttentionDelivery {
|
||||
fingerprint: input.delivery_id.clone(),
|
||||
request_id: input.request_id.clone(),
|
||||
content,
|
||||
input,
|
||||
};
|
||||
let state = orchestrator_attention_state(&api, &body);
|
||||
state.lock().unwrap().pending = Some(delivery.clone());
|
||||
let callback_ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let called = callback_ran.clone();
|
||||
let revoking_api = api.clone();
|
||||
let revoking_body = body.clone();
|
||||
let revoking_ticket = ticket.id.clone();
|
||||
let revoking_subject = subject_id.clone();
|
||||
api.runtime
|
||||
.register_or_replace(AssignmentLookupHookRuntime {
|
||||
inner: runtime.clone(),
|
||||
hook: Mutex::new(Some(Box::new(move || {
|
||||
// The first authorization already passed. Policy is still live at
|
||||
// this exact target lookup, then changes before the final send.
|
||||
authorize_assignment_delivery(&revoking_api, &revoking_body, &revoking_ticket)
|
||||
.unwrap();
|
||||
let store = open_subjektiv_store(&revoking_api).unwrap();
|
||||
let previous = store
|
||||
.subject_permissions(&revoking_subject)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
store
|
||||
.set_subject_permissions(
|
||||
&revoking_subject,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "revoke-at-target-lookup".into(),
|
||||
expected_change_id: Some(previous.change_id),
|
||||
execution: vec![],
|
||||
delegation: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
called.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}))),
|
||||
});
|
||||
assert!(runtime.input_requests.lock().unwrap().is_empty());
|
||||
// Invoke the real cached-send path. Its next Runtime.worker() call is the
|
||||
// target observation between initial authorization and DB/send admission.
|
||||
send_orchestrator_attention(&api, &body, &state, delivery.clone());
|
||||
assert!(callback_ran.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert!(authorize_assignment_delivery(&api, &body, &ticket.id).is_err());
|
||||
assert!(
|
||||
runtime.input_requests.lock().unwrap().is_empty(),
|
||||
"revoked context reached Runtime send_input"
|
||||
);
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
let acknowledgements = || {
|
||||
conn.query_row("SELECT count(*) FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2",
|
||||
rusqlite::params![TEST_WORKSPACE_ID, delivery.input.delivery_id], |row| row.get::<_, i64>(0)).unwrap()
|
||||
};
|
||||
assert_eq!(acknowledgements(), 0);
|
||||
assert!(
|
||||
state.lock().unwrap().pending.is_some(),
|
||||
"denied send must not manufacture acceptance"
|
||||
);
|
||||
assert!(state.lock().unwrap().accepted.is_none());
|
||||
// Positive control: explicit policy restoration can admit the SAME pinned
|
||||
// request. The target/reservation was valid; no new delegation was needed.
|
||||
grant_test_assignment_ticket(&api, &subject_id, &ticket.id);
|
||||
send_orchestrator_attention(&api, &body, &state, delivery.clone());
|
||||
let inputs = runtime.input_requests.lock().unwrap();
|
||||
assert_eq!(inputs.len(), 1);
|
||||
assert_eq!(
|
||||
inputs[0].submission_request_id.as_deref(),
|
||||
Some(delivery.request_id.as_str())
|
||||
);
|
||||
assert_eq!(inputs[0].content, delivery.content);
|
||||
assert_eq!(acknowledgements(), 1);
|
||||
assert!(state.lock().unwrap().pending.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_assignment_known_ack_closes_cached_pending_resend_admission() {
|
||||
let (_dir, api, execution) = attention_test_api().await;
|
||||
let started = start_attention_test_orchestrator(&api).await;
|
||||
let body = RuntimeWorkerRef::new(
|
||||
EMBEDDED_WORKER_RUNTIME_ID,
|
||||
started.worker.unwrap().worker_id,
|
||||
);
|
||||
let state = orchestrator_attention_state(&api, &body);
|
||||
let acknowledged = state.lock().unwrap().accepted.clone().unwrap();
|
||||
assert!(attention_pending(&api).is_empty());
|
||||
execution.take_inputs();
|
||||
execution.take_input_request_ids();
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
let acks = || {
|
||||
conn.query_row("SELECT count(*) FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2",
|
||||
rusqlite::params![TEST_WORKSPACE_ID, acknowledged.input.delivery_id], |row| row.get::<_, i64>(0)).unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
acks(),
|
||||
1,
|
||||
"positive Runtime acceptance must already be durable"
|
||||
);
|
||||
// Model a stale local pending slot after another sender committed its ACK.
|
||||
// Memory does not regain send authority merely by holding the original bytes.
|
||||
state.lock().unwrap().pending = Some(acknowledged.clone());
|
||||
send_orchestrator_attention(&api, &body, &state, acknowledged.clone());
|
||||
assert!(execution.take_inputs().is_empty());
|
||||
assert!(execution.take_input_request_ids().is_empty());
|
||||
assert!(state.lock().unwrap().pending.is_none());
|
||||
assert_eq!(acks(), 1);
|
||||
assert!(attention_pending(&api).is_empty());
|
||||
assert_eq!(
|
||||
state.lock().unwrap().accepted.as_ref().unwrap().request_id,
|
||||
acknowledged.request_id
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,13 +87,14 @@ async fn delivery_http(
|
||||
}
|
||||
|
||||
async fn delivery_assign(api: &WorkspaceApi, ticket: &str, key: &str) -> Value {
|
||||
grant_test_assignment_ticket(api, &ensure_test_assignment_subject(api), ticket);
|
||||
delivery_http(
|
||||
api,
|
||||
"PUT",
|
||||
&delivery_assignment_path(ticket),
|
||||
Some(json!({
|
||||
"operation_id": key,
|
||||
"principal": {"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}
|
||||
"principal": test_assignment_principal(&api)
|
||||
})),
|
||||
StatusCode::OK,
|
||||
)
|
||||
@@ -152,7 +153,7 @@ async fn delivery_recording_fixture(
|
||||
RuntimeWorkerRef,
|
||||
RuntimeIdentityMaterial,
|
||||
) {
|
||||
let (mut api, execution) = test_api_with_recording_backend(root).await;
|
||||
let (mut api, execution) = assignment_test_api_with_recording_backend(root).await;
|
||||
seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
|
||||
let worker = delivery_worker(start_attention_test_orchestrator(&api).await);
|
||||
let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap();
|
||||
@@ -617,7 +618,7 @@ async fn assignment_clear_and_reassign_discard_old_unknown_delivery_without_stop
|
||||
"clear/reissue must not rewrite the old status to done"
|
||||
);
|
||||
assert_eq!(
|
||||
find_workspace_orchestrator(&api).unwrap().worker,
|
||||
test_assignment_body(&api).unwrap().worker,
|
||||
worker,
|
||||
"clear/reassign are not Worker stop or replacement"
|
||||
);
|
||||
@@ -782,34 +783,46 @@ struct DeliveryWorkerFactory {
|
||||
}
|
||||
|
||||
impl DeliveryWorkerFactory {
|
||||
fn manifest(
|
||||
worker_id: &str,
|
||||
profile: &worker_runtime::catalog::ProfileSelector,
|
||||
bundle: Option<&worker_runtime::config_bundle::ConfigBundle>,
|
||||
) -> std::result::Result<manifest::WorkerManifest, String> {
|
||||
let selector = match profile {
|
||||
worker_runtime::catalog::ProfileSelector::Builtin(value)
|
||||
| worker_runtime::catalog::ProfileSelector::Named(value) => value,
|
||||
};
|
||||
let mut manifest = crate::profile_settings::resolve_profile_manifest_from_config_bundle(
|
||||
bundle.ok_or("delivery fixture requires the normal resolved Config bundle")?,
|
||||
selector,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
manifest.worker.name = format!("worker-runtime-{worker_id}");
|
||||
// These notification fixtures attach no Workdirs. Apply that zero-file
|
||||
// launch ceiling rather than resolving Profile scope against the test cwd.
|
||||
manifest.scope.allow.clear();
|
||||
// Retain the resolved Profile; bootstrap saves the actual execution policy,
|
||||
// including that ceiling, through its normal metadata writer.
|
||||
assert!(manifest.requires_persisted_execution_snapshot());
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
async fn controller(
|
||||
&self,
|
||||
worker_id: String,
|
||||
restore: bool,
|
||||
manifest: manifest::WorkerManifest,
|
||||
) -> std::result::Result<worker_runtime::worker_backend::RuntimeWorkerController, String> {
|
||||
let name = format!("delivery-{worker_id}");
|
||||
let name = format!("worker-runtime-{worker_id}");
|
||||
let root = self.root.join("workers").join(&worker_id);
|
||||
let store = session_store::CombinedStore::new(
|
||||
WorkerSessionStore::new(root.join("session")).map_err(|e| e.to_string())?,
|
||||
session_store::WorkerAggregateStore::new(&root, &name).map_err(|e| e.to_string())?,
|
||||
);
|
||||
let manifest = manifest::WorkerManifest::from_toml(&format!(
|
||||
r#"
|
||||
[worker]
|
||||
name = "{name}"
|
||||
pwd = "./"
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "scripted-delivery"
|
||||
auth = {{ kind = "none" }}
|
||||
[engine]
|
||||
max_tokens = 64
|
||||
[scope]
|
||||
allow = []
|
||||
"#
|
||||
))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let context = worker::WorkerWorkspaceContext::local_filesystem(None);
|
||||
|
||||
let context = worker::WorkerWorkspaceContext::local_filesystem(Some(
|
||||
worker::WorkspaceId::new(TEST_WORKSPACE_ID).unwrap(),
|
||||
));
|
||||
let workspace_client = context.client_handle();
|
||||
let bootstrap = worker::WorkerBootstrap::new(
|
||||
manifest,
|
||||
@@ -827,7 +840,7 @@ impl DeliveryWorkerFactory {
|
||||
} else {
|
||||
bootstrap.prepare().await
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let started = prepared.start().await.map_err(|e| e.to_string())?;
|
||||
Ok(worker_runtime::worker_backend::RuntimeWorkerController {
|
||||
handle: started.handle,
|
||||
@@ -845,8 +858,13 @@ impl worker_runtime::worker_backend::RuntimeWorkerFactory for DeliveryWorkerFact
|
||||
&self,
|
||||
request: delivery_execution::WorkerExecutionSpawnRequest,
|
||||
) -> std::result::Result<worker_runtime::worker_backend::RuntimeWorkerController, String> {
|
||||
self.controller(request.worker_ref.worker_id.to_string(), false)
|
||||
.await
|
||||
let worker_id = request.worker_ref.worker_id.to_string();
|
||||
let manifest = Self::manifest(
|
||||
&worker_id,
|
||||
&request.request.profile,
|
||||
request.config_bundle.as_ref(),
|
||||
)?;
|
||||
self.controller(worker_id, false, manifest).await
|
||||
}
|
||||
async fn prepare_restore(
|
||||
&self,
|
||||
@@ -855,13 +873,27 @@ impl worker_runtime::worker_backend::RuntimeWorkerFactory for DeliveryWorkerFact
|
||||
Box<dyn worker_runtime::worker_backend::RuntimeRestoreCandidate>,
|
||||
worker_runtime::worker_backend::RuntimeRestoreFailure,
|
||||
> {
|
||||
let worker_id = request.worker_ref.worker_id.to_string();
|
||||
// Restore must read the saved execution snapshot, not reselect today's
|
||||
// Config/Profile (a reopened Runtime need not have a cached bundle).
|
||||
// Workspace-bound prepare_restored rejects a missing saved snapshot.
|
||||
let mut fallback = manifest::WorkerManifestConfig::resolution_defaults();
|
||||
fallback.worker.name = Some(format!("worker-runtime-{worker_id}"));
|
||||
let manifest = manifest::WorkerManifest::try_from(fallback).map_err(|error| {
|
||||
eprintln!("delivery fixture restore defaults failed: {error}");
|
||||
worker_runtime::worker_backend::RuntimeRestoreFailure::refused(
|
||||
worker_runtime::worker_backend::RuntimeRestoreStage::Reconstruction,
|
||||
"delivery fixture restore defaults failed",
|
||||
)
|
||||
})?;
|
||||
Ok(Box::new(DeliveryRestoreCandidate {
|
||||
inputs: Some((
|
||||
DeliveryWorkerFactory {
|
||||
root: self.root.clone(),
|
||||
client: self.client.clone(),
|
||||
},
|
||||
request.worker_ref.worker_id.to_string(),
|
||||
worker_id,
|
||||
manifest,
|
||||
)),
|
||||
}))
|
||||
}
|
||||
@@ -870,7 +902,7 @@ impl worker_runtime::worker_backend::RuntimeWorkerFactory for DeliveryWorkerFact
|
||||
// Retain the same scripted client/receipt store without opening either store or
|
||||
// starting a Controller until activation. Discard drops only these owned inputs.
|
||||
struct DeliveryRestoreCandidate {
|
||||
inputs: Option<(DeliveryWorkerFactory, String)>,
|
||||
inputs: Option<(DeliveryWorkerFactory, String, manifest::WorkerManifest)>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -885,18 +917,22 @@ impl worker_runtime::worker_backend::RuntimeRestoreCandidate for DeliveryRestore
|
||||
worker_runtime::worker_backend::RuntimeWorkerController,
|
||||
worker_runtime::worker_backend::RuntimeRestoreFailure,
|
||||
> {
|
||||
let (factory, worker_id) = self.inputs.take().ok_or_else(|| {
|
||||
let (factory, worker_id, manifest) = self.inputs.take().ok_or_else(|| {
|
||||
worker_runtime::worker_backend::RuntimeRestoreFailure::refused(
|
||||
worker_runtime::worker_backend::RuntimeRestoreStage::Controller,
|
||||
"delivery fixture candidate already consumed",
|
||||
)
|
||||
})?;
|
||||
factory.controller(worker_id, true).await.map_err(|_| {
|
||||
worker_runtime::worker_backend::RuntimeRestoreFailure::unproven(
|
||||
worker_runtime::worker_backend::RuntimeRestoreStage::Reconstruction,
|
||||
"delivery fixture reconstruction failed",
|
||||
)
|
||||
})
|
||||
factory
|
||||
.controller(worker_id, true, manifest)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
eprintln!("delivery fixture reconstruction failed: {error}");
|
||||
worker_runtime::worker_backend::RuntimeRestoreFailure::unproven(
|
||||
worker_runtime::worker_backend::RuntimeRestoreStage::Reconstruction,
|
||||
"delivery fixture reconstruction failed",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn discard(
|
||||
@@ -919,6 +955,12 @@ impl delivery_execution::WorkerExecutionBackend for DeliveryResponseLossBackend
|
||||
fn backend_id(&self) -> &str {
|
||||
self.inner.backend_id()
|
||||
}
|
||||
fn subject_release(
|
||||
&self,
|
||||
request: &runtime_api::SubjectReleaseRequest,
|
||||
) -> std::result::Result<runtime_api::SubjectReleaseResponse, String> {
|
||||
self.inner.subject_release(request)
|
||||
}
|
||||
fn spawn_worker(
|
||||
&self,
|
||||
r: delivery_execution::WorkerExecutionSpawnRequest,
|
||||
@@ -1364,17 +1406,24 @@ async fn assignment_worker_replacement_receives_pending_input_without_recreating
|
||||
let initial = delivery_tracking(&api, &handled.id, assignment);
|
||||
let old_worker = delivery_worker(start_attention_test_orchestrator(&api).await);
|
||||
delivery_wait_idle(&api, &old_worker, 1).await;
|
||||
delivery_stop(&api, &old_worker);
|
||||
// Delete only this isolated fixture's stopped identity. A new live Worker
|
||||
// has a new session; it is not a restore and cannot reuse old session receipts.
|
||||
api.runtime.delete_worker(&old_worker).unwrap();
|
||||
api.worker_projection
|
||||
.publish_ordered(|| {
|
||||
api.store
|
||||
.delete_worker_registry(TEST_WORKSPACE_ID, &old_worker)
|
||||
.map(|commit| ((), commit))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(execution.attempts.lock().unwrap().len(), 1);
|
||||
// The factory has no Journal tool wiring. Drive real Host admission and the
|
||||
// dispatched Journal Job's sequential services over the actual saved source,
|
||||
// rather than bypassing the normal release coverage invariant.
|
||||
reflect_test_assignment_session(
|
||||
&api,
|
||||
&old_worker,
|
||||
"Read the delegation; returning without starting a Coder.",
|
||||
)
|
||||
.await;
|
||||
let reflected_attempts = execution.attempts.lock().unwrap().len();
|
||||
assert_eq!(
|
||||
reflected_attempts, 2,
|
||||
"one delivery plus one actual Journal Job"
|
||||
);
|
||||
// Release while the actual Controller is Idle, before stopping/deleting its
|
||||
// retained identity. Deletion itself cannot release connected Subject occupancy.
|
||||
remove_test_assignment_body(&api, &old_worker).await;
|
||||
let pending = delivery_ticket(&api, "Pending input for replacement");
|
||||
let new_assign = delivery_assign(&api, &pending.id, "assign-pending-replacement").await;
|
||||
let pending_assignment = new_assign["assignment"]["assignment_id"].as_str().unwrap();
|
||||
@@ -1391,7 +1440,10 @@ async fn assignment_worker_replacement_receives_pending_input_without_recreating
|
||||
!commits[0].contains(handled.resource_key.as_deref().unwrap()),
|
||||
"replacement must not execute acknowledged history"
|
||||
);
|
||||
assert_eq!(execution.attempts.lock().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
execution.attempts.lock().unwrap().len(),
|
||||
reflected_attempts + 1
|
||||
);
|
||||
assert_eq!(
|
||||
delivery_tracking(&api, &handled.id, assignment),
|
||||
initial,
|
||||
@@ -1402,7 +1454,10 @@ async fn assignment_worker_replacement_receives_pending_input_without_recreating
|
||||
pending_initial
|
||||
);
|
||||
delivery_scan(&api, &replacement);
|
||||
assert_eq!(execution.attempts.lock().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
execution.attempts.lock().unwrap().len(),
|
||||
reflected_attempts + 1
|
||||
);
|
||||
delivery_stop(&api, &replacement);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,64 +7,56 @@ use super::*;
|
||||
struct StartSource {
|
||||
identity: RuntimeIdentityMaterial,
|
||||
worker: RuntimeWorkerRef,
|
||||
delegation: Vec<server_api::authorization::ScopedPermission>,
|
||||
}
|
||||
|
||||
async fn start_source(api: &mut WorkspaceApi, runtime: &WorkdirlessFixtureRuntime) -> StartSource {
|
||||
let runtime_id = WorkdirlessFixtureRuntime::RUNTIME_ID;
|
||||
async fn start_source(api: &mut WorkspaceApi, _runtime: &WorkdirlessFixtureRuntime) -> StartSource {
|
||||
start_body_source(api, WorkdirlessFixtureRuntime::RUNTIME_ID).await
|
||||
}
|
||||
|
||||
async fn start_body_source(api: &mut WorkspaceApi, runtime_id: &str) -> StartSource {
|
||||
let identity = RuntimeIdentityMaterial::generate(runtime_id).unwrap();
|
||||
configure_runtime_request_auth(api, &identity, runtime_id);
|
||||
let memory = api
|
||||
.config_store
|
||||
.get_workspace_memory_settings(TEST_WORKSPACE_ID)
|
||||
.unwrap();
|
||||
let reservation = api
|
||||
.config_store
|
||||
.reserve_worker_create(
|
||||
TEST_WORKSPACE_ID,
|
||||
runtime_id,
|
||||
"start-orchestrator",
|
||||
"sha256:start-orchestrator",
|
||||
Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY),
|
||||
&memory,
|
||||
let subject_id = ensure_test_assignment_subject(api);
|
||||
let worker = create_test_assignment_body(api, runtime_id, &subject_id).await;
|
||||
for ticket in api.authority.list_tickets(100).unwrap().items {
|
||||
grant_test_assignment_ticket(api, &subject_id, &ticket.id);
|
||||
}
|
||||
use server_api::authorization::{
|
||||
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
|
||||
};
|
||||
let delegation: Vec<_> = api
|
||||
.store
|
||||
.list_workdir_registry(TEST_WORKSPACE_ID, 100)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|workdir| ScopedPermission {
|
||||
operation: "filesystem.read".into(),
|
||||
resource: PermissionResource::Workdir {
|
||||
working_directory_id: workdir.workdir_id,
|
||||
path: ".".into(),
|
||||
recursive: true,
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
let store = open_subjektiv_store(api).unwrap();
|
||||
let previous = store.subject_permissions(&subject_id).unwrap();
|
||||
store
|
||||
.set_subject_permissions(
|
||||
&subject_id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "start-source-delegation".into(),
|
||||
expected_change_id: previous.as_ref().map(|policy| policy.change_id.clone()),
|
||||
execution: previous.map(|policy| policy.execution).unwrap_or_default(),
|
||||
delegation: delegation.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let mut summary = WorkdirlessFixtureRuntime::worker_summary(
|
||||
WorkerCreateBinding {
|
||||
worker_id: reservation.worker_id,
|
||||
create_fingerprint: reservation.create_fingerprint,
|
||||
},
|
||||
&WorkerSpawnRequest {
|
||||
requested_worker_name: Some("Start Orchestrator".into()),
|
||||
singleton_key: Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.into()),
|
||||
intent: WorkerSpawnIntent::WorkspaceCompanion,
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
|
||||
expected_segments: 0,
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:orchestrator".into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: Vec::new(),
|
||||
workdir_attachment_requests: Vec::new(),
|
||||
resolved_workdir_attachment_requests: Vec::new(),
|
||||
resolved_workdir_attachments: Vec::new(),
|
||||
resolved_config_bundle: None,
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
resolved_subjektiv_attached: false,
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
},
|
||||
);
|
||||
summary.tags = vec!["orchestrator".into(), "singleton".into()];
|
||||
let worker = summary.worker.clone();
|
||||
runtime.workers.lock().unwrap().push(summary);
|
||||
seed_worker_source_member(api, runtime_id, &worker.worker_id);
|
||||
api.config_store
|
||||
.complete_worker_create_reservation(TEST_WORKSPACE_ID, worker.worker_id.parse().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(find_workspace_orchestrator(api).unwrap().worker, worker);
|
||||
StartSource { identity, worker }
|
||||
StartSource {
|
||||
identity,
|
||||
worker,
|
||||
delegation,
|
||||
}
|
||||
}
|
||||
|
||||
fn add_unfinished_start_dependency(api: &WorkspaceApi, ticket_id: &str) -> String {
|
||||
@@ -91,8 +83,37 @@ fn start_http_request(
|
||||
token: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
body: Value,
|
||||
mut body: Value,
|
||||
) -> Request<Body> {
|
||||
if let Some(source) =
|
||||
source.filter(|_| method == "POST" && path.ends_with("/worker-control/workers"))
|
||||
{
|
||||
// Supply the normal explicit least-authority child request. Only the
|
||||
// selected reference Workdirs receive read; no write/command is issued.
|
||||
let attachments = body["workdir_attachments"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let permissions: Vec<_> = source
|
||||
.delegation
|
||||
.iter()
|
||||
.filter(|permission| {
|
||||
let server_api::authorization::PermissionResource::Workdir {
|
||||
working_directory_id,
|
||||
..
|
||||
} = &permission.resource
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
attachments.iter().any(|attachment| {
|
||||
attachment["working_directory_id"].as_str()
|
||||
== Some(working_directory_id.as_str())
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
body["execution_grant"] = serde_json::json!({"permissions":permissions, "redelegation":[]});
|
||||
}
|
||||
let body = serde_json::to_vec(&body).unwrap();
|
||||
match source {
|
||||
Some(source) => runtime_source_request(
|
||||
@@ -182,6 +203,54 @@ fn assert_start_dependency_diagnostic_only(api: &WorkspaceApi, ticket: &str, dep
|
||||
assert!(prerequisite.assignment_tracking.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_start_rechecks_changed_target_after_allocation_before_runtime_dispatch() {
|
||||
for change in ["access='read_only'", "ref_selector='different-branch'"] {
|
||||
let fixture = guarded_spawn_fixture().await;
|
||||
let conn = rusqlite::Connection::open(&fixture.api.config.database_path).unwrap();
|
||||
// Deterministic interleaving at a real persistence boundary: the initial
|
||||
// target check has finished, but no Runtime spawn has been dispatched.
|
||||
conn.execute_batch(&format!(
|
||||
"CREATE TRIGGER narrow_target_after_allocation AFTER INSERT ON worker_create_reservations
|
||||
WHEN NEW.runtime_id='workdirless-runtime'
|
||||
BEGIN UPDATE typed_ticket_targets SET {change}
|
||||
WHERE ticket_id='{}' AND repository_key='test-repository'; END;",
|
||||
fixture.ticket_id,
|
||||
)).unwrap();
|
||||
let body = guarded_spawn_payload(&fixture, "target-changed-before-dispatch");
|
||||
let response = post_guarded_spawn(&fixture, body).await;
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert!(
|
||||
matches!(status, StatusCode::FORBIDDEN | StatusCode::BAD_REQUEST),
|
||||
"{change}: {status}: {}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
);
|
||||
assert!(
|
||||
fixture.runtime.spawn_requests().is_empty(),
|
||||
"{change}: no Runtime effect"
|
||||
);
|
||||
assert!(
|
||||
fixture
|
||||
.api
|
||||
.store
|
||||
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id,)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM worker_workdir_attachment_reservations",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap(),
|
||||
0,
|
||||
"failed admission must release newly reserved attachments"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unfinished_dependencies_allow_manual_and_orchestrator_resource_free_start_and_assign() {
|
||||
for orchestrator in [false, true] {
|
||||
@@ -590,7 +659,7 @@ async fn orchestrator_start_and_assign_reject_missing_stale_cleared_or_reissued_
|
||||
ticket.id
|
||||
);
|
||||
start_http_response(&api, start_http_request(None, &token, "PUT", &path,
|
||||
serde_json::json!({"operation_id":"reissue-observed", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})), StatusCode::OK).await;
|
||||
serde_json::json!({"operation_id":"reissue-observed", "principal":test_assignment_principal(&api)})), StatusCode::OK).await;
|
||||
assert_ne!(
|
||||
observed_start_delegation(&api, &ticket.id, Some(&source)).unwrap(),
|
||||
observed
|
||||
@@ -664,11 +733,12 @@ async fn orchestrator_start_and_assign_reject_missing_stale_cleared_or_reissued_
|
||||
async fn replaced_orchestrator_cannot_start_or_assign_under_retained_current_delegation() {
|
||||
for existing in [false, true] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut api = test_api(directory.path()).await;
|
||||
let (mut api, _) = assignment_test_api_with_recording_backend(directory.path()).await;
|
||||
register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await;
|
||||
let runtime = WorkdirlessFixtureRuntime::default();
|
||||
api.runtime.register_or_replace(runtime.clone());
|
||||
let source = start_source(&mut api, &runtime).await;
|
||||
let source = start_body_source(&mut api, EMBEDDED_WORKER_RUNTIME_ID).await;
|
||||
bootstrap_test_assignment_session(&api, &source.worker);
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "obsolete-worker-owner").await;
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
@@ -697,21 +767,16 @@ async fn replaced_orchestrator_cannot_start_or_assign_under_retained_current_del
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Backend ownership changes independently of the retained assignment row.
|
||||
let replacement =
|
||||
RuntimeWorkerRef::new(&source.worker.runtime_id, WorkerId::now_v7().to_string());
|
||||
seed_worker_source_member(&api, &replacement.runtime_id, &replacement.worker_id);
|
||||
let mut summary = runtime.workers.lock().unwrap()[0].clone();
|
||||
summary.worker = replacement.clone();
|
||||
runtime.workers.lock().unwrap().push(summary);
|
||||
rusqlite::Connection::open(&api.config.database_path).unwrap().execute(
|
||||
"UPDATE worker_singleton_owners SET worker_id=?1,generation=generation+1 WHERE workspace_id=?2 AND singleton_key=?3",
|
||||
rusqlite::params![replacement.worker_id, TEST_WORKSPACE_ID, crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY],
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
find_workspace_orchestrator(&api).unwrap().worker,
|
||||
replacement
|
||||
);
|
||||
// Body replacement preserves Subject assignment, but rotates the real
|
||||
// create reservation/singleton generation through normal Subject launch.
|
||||
remove_test_assignment_body(&api, &source.worker).await;
|
||||
let replacement = create_test_assignment_body(
|
||||
&api,
|
||||
&source.worker.runtime_id,
|
||||
&ensure_test_assignment_subject(&api),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(test_assignment_body(&api).unwrap().worker, replacement);
|
||||
assert_eq!(
|
||||
observed_start_delegation(&api, &ticket.id, Some(&source)).unwrap(),
|
||||
delegation
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
// Test-only Subject lifetime support. Session metadata and Host writer binding
|
||||
// are real; the deterministic executor substitutes only the Controller protocol.
|
||||
// Tests requiring real Controller release use the connected Runtime suites.
|
||||
async fn assignment_test_api_with_recording_backend(
|
||||
root: impl Into<PathBuf>,
|
||||
) -> (WorkspaceApi, Arc<DeterministicExecutionBackend>) {
|
||||
let (api, execution) = test_api_with_recording_backend(root).await;
|
||||
execution
|
||||
.assignment_release_enabled
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
(api, execution)
|
||||
}
|
||||
|
||||
fn bootstrap_test_assignment_session(api: &WorkspaceApi, worker: &RuntimeWorkerRef) {
|
||||
use server_api::authorization::{
|
||||
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
|
||||
};
|
||||
use session_store::WorkerMetadataStore;
|
||||
assert_eq!(worker.runtime_id, EMBEDDED_WORKER_RUNTIME_ID);
|
||||
let lease = api
|
||||
.store
|
||||
.require_current_worker_singleton_owner(api.workspace_id(), worker)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let subject = lease.key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX).unwrap();
|
||||
let store = open_subjektiv_store(api).unwrap();
|
||||
let previous = store.subject_permissions(subject).unwrap();
|
||||
let mut execution = previous
|
||||
.as_ref()
|
||||
.map(|p| p.execution.clone())
|
||||
.unwrap_or_default();
|
||||
let before = execution.clone();
|
||||
for operation in ["session.record", "journal.recording.request"] {
|
||||
let permission = ScopedPermission {
|
||||
operation: operation.into(),
|
||||
resource: PermissionResource::Subject {
|
||||
workspace_id: api.workspace_id().into(),
|
||||
subject_id: subject.into(),
|
||||
},
|
||||
};
|
||||
if !execution.contains(&permission) {
|
||||
execution.push(permission);
|
||||
}
|
||||
}
|
||||
if before != execution {
|
||||
store
|
||||
.set_subject_permissions(
|
||||
subject,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: format!("assignment-session-{}", worker.worker_id),
|
||||
expected_change_id: previous.as_ref().map(|p| p.change_id.clone()),
|
||||
execution,
|
||||
delegation: previous.map(|p| p.delegation).unwrap_or_default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let name = format!("worker-runtime-{}", worker.worker_id);
|
||||
let aggregate = session_store::worker_metadata::WorkerAggregateStore::new(
|
||||
api.config
|
||||
.embedded_runtime_store_root
|
||||
.join("workers")
|
||||
.join(&worker.worker_id),
|
||||
&name,
|
||||
)
|
||||
.unwrap();
|
||||
let metadata = aggregate.read_read_only().unwrap();
|
||||
let session = if let Some(active) = metadata.as_ref().and_then(|m| m.active.as_ref()) {
|
||||
active.session_id
|
||||
} else {
|
||||
let session = session_store::new_session_id();
|
||||
let mut metadata =
|
||||
metadata.unwrap_or_else(|| session_store::WorkerMetadata::new(&name, None));
|
||||
metadata.active = Some(session_store::WorkerActiveSegmentRef::pending_segment(
|
||||
session,
|
||||
));
|
||||
aggregate
|
||||
.write(&metadata.with_workspace_id(api.workspace_id()))
|
||||
.unwrap();
|
||||
session
|
||||
};
|
||||
subject_authorization::authorize(
|
||||
api,
|
||||
worker,
|
||||
&server_api::authorization::OperationAuthorizationRequest {
|
||||
operation: "session.record".into(),
|
||||
resource: PermissionResource::Subject {
|
||||
workspace_id: api.workspace_id().into(),
|
||||
subject_id: subject.into(),
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
subjektiv_journal::bind_host_session(
|
||||
api,
|
||||
crate::subjektiv::SubjectSessionAttribution::new(
|
||||
subject,
|
||||
&worker.runtime_id,
|
||||
&worker.worker_id,
|
||||
session.to_string(),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// The delivery factory persists real canonical Session bytes, but its scripted model
|
||||
// has no Journal tools. Substitute only the sequential model/tool calls here: normal
|
||||
// Host admission selects the saved source, dispatches the immutable BackendJob input
|
||||
// and attests actual Runtime acceptance. No fixture source or delivery proof is seeded.
|
||||
async fn reflect_test_assignment_session(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
expected_public_output: &str,
|
||||
) {
|
||||
use server_api::authorization::{
|
||||
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
|
||||
};
|
||||
use subjektiv::journal::transport::{JournalJobRequest, JournalJobResponse};
|
||||
use subjektiv::journal::{JournalAction, JournalEntryState, JournalOperation};
|
||||
|
||||
let lease = api
|
||||
.store
|
||||
.require_current_worker_singleton_owner(api.workspace_id(), worker)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let subject = lease.key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX).unwrap();
|
||||
let store = open_subjektiv_store(api).unwrap();
|
||||
let previous = store.subject_permissions(subject).unwrap().unwrap();
|
||||
let mut execution = previous.execution.clone();
|
||||
for operation in [
|
||||
"journal.job.input.read",
|
||||
"journal.job.input.supply",
|
||||
"journal.state.read",
|
||||
"journal.entry.create",
|
||||
"journal.position.update",
|
||||
"journal.checkpoint",
|
||||
"journal.finish",
|
||||
] {
|
||||
let permission = ScopedPermission {
|
||||
operation: operation.into(),
|
||||
resource: PermissionResource::Subject {
|
||||
workspace_id: api.workspace_id().into(),
|
||||
subject_id: subject.into(),
|
||||
},
|
||||
};
|
||||
if !execution.contains(&permission) {
|
||||
execution.push(permission);
|
||||
}
|
||||
}
|
||||
store
|
||||
.set_subject_permissions(
|
||||
subject,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: format!("assignment-journal-{}", worker.worker_id),
|
||||
expected_change_id: Some(previous.change_id.clone()),
|
||||
execution,
|
||||
delegation: previous.delegation.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let saved_permissions = store.subject_permissions(subject).unwrap().unwrap();
|
||||
assert!(
|
||||
previous
|
||||
.execution
|
||||
.iter()
|
||||
.all(|permission| saved_permissions.execution.contains(permission))
|
||||
);
|
||||
assert_eq!(saved_permissions.delegation, previous.delegation);
|
||||
let attrs = store
|
||||
.subject_session_attributions(subject)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|a| a.runtime_id == worker.runtime_id && a.worker_id == worker.worker_id)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(attrs.len(), 1, "fixture expects one actual saved Session");
|
||||
let attr = &attrs[0];
|
||||
subjektiv_journal::reconcile_created_worker(api, worker).unwrap();
|
||||
let (job, attempt) = tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
loop {
|
||||
let jobs = api
|
||||
.store
|
||||
.list_backend_journal_jobs(api.workspace_id(), &attr.session_id, None, 100)
|
||||
.unwrap();
|
||||
assert!(
|
||||
jobs.len() <= 1,
|
||||
"unexpected duplicate Journal intent: {jobs:?}"
|
||||
);
|
||||
if let Some(job) = jobs.into_iter().next() {
|
||||
let attempt = api
|
||||
.store
|
||||
.get_backend_job_attempt(
|
||||
api.workspace_id(),
|
||||
&job.request.job_id,
|
||||
&crate::backend_job::attempt_id(&job.request.job_id, job.current_attempt),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
!attempt.state.terminal(),
|
||||
"Journal dispatch failed before reflection: {attempt:?}"
|
||||
);
|
||||
if attempt.state == BackendJobAttemptState::Dispatched {
|
||||
break (job, attempt);
|
||||
}
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("actual Journal BackendJob was not dispatched");
|
||||
let journal_worker = attempt.worker.as_ref().unwrap();
|
||||
assert_ne!(journal_worker, worker);
|
||||
let input = subjektiv::journal::recording::JournalInput::decode(&job.request.input).unwrap();
|
||||
let canonical = subjektiv_journal::capture_input(api, attr, 0, 0, 48 * 1024).unwrap();
|
||||
assert_eq!(input.capture.entries, canonical.entries);
|
||||
assert!(!input.selected_ranges.is_empty());
|
||||
let captured_text = input
|
||||
.capture
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| match &entry.body {
|
||||
session_store::capture::CaptureBody::CanonicalPublic { text, .. } => text.as_str(),
|
||||
_ => panic!("only actual canonical public material is eligible"),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
// System notifications are not canonical public recording material. Reflect
|
||||
// the actual assistant output, not a reconstructed copy of its notification.
|
||||
assert!(captured_text.contains(expected_public_output));
|
||||
let JournalJobResponse::State(state) =
|
||||
subjektiv_journal::execute(api, journal_worker, JournalJobRequest::ReadState).unwrap()
|
||||
else {
|
||||
panic!("bound Journal state required")
|
||||
};
|
||||
assert_eq!(state.selected_ranges, input.selected_ranges);
|
||||
assert_eq!(state.initial_journal.state_token, input.initial_state_token);
|
||||
assert!(state.current_open.is_none());
|
||||
let record_id = format!("record:{}", state.generation_id);
|
||||
let mut token = state.current_journal.state_token;
|
||||
for (request_id, action, covered_ranges) in [
|
||||
(
|
||||
record_id.clone(),
|
||||
JournalAction::Create {
|
||||
title: "Assignment delivery before body replacement".into(),
|
||||
body: "Received the Ticket delegation. The scripted body returned without starting a Coder; no implementation result is asserted.".into(),
|
||||
ongoing: "Keep the Subject assignment; handle future pending Ticket input in the replacement body.".into(),
|
||||
state: JournalEntryState::Open,
|
||||
source_end: None,
|
||||
correction_refs: vec![],
|
||||
},
|
||||
input.selected_ranges.clone(),
|
||||
),
|
||||
(
|
||||
format!("position:{}", state.generation_id),
|
||||
JournalAction::UpdatePosition {
|
||||
current_position: "Delegation received; handling remains waiting. Release this body before replacement; do not replay acknowledged input.".into(),
|
||||
},
|
||||
vec![],
|
||||
),
|
||||
(
|
||||
format!("checkpoint:{}", state.generation_id),
|
||||
JournalAction::Checkpoint { record_operation_ids: vec![record_id] },
|
||||
input.selected_ranges,
|
||||
),
|
||||
] {
|
||||
let JournalJobResponse::Saved(receipt) = subjektiv_journal::execute(
|
||||
api,
|
||||
journal_worker,
|
||||
JournalJobRequest::Execute {
|
||||
operation: JournalOperation { request_id, expected_state: token, action, covered_ranges },
|
||||
},
|
||||
).unwrap() else {
|
||||
panic!("saved sequential Journal receipt required")
|
||||
};
|
||||
token = receipt.state_token;
|
||||
}
|
||||
let completion = subjektiv_journal::finish(api, journal_worker).unwrap();
|
||||
assert_eq!(completion.state_token, token);
|
||||
let accepted = api
|
||||
.accept_backend_job_result(
|
||||
journal_worker,
|
||||
&BackendJobResultSubmission {
|
||||
job_id: job.request.job_id.clone(),
|
||||
attempt_id: attempt.attempt_id.clone(),
|
||||
input_digest: attempt.input_digest,
|
||||
result: serde_json::to_value(completion).unwrap(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(accepted.attempt.state, BackendJobAttemptState::Completed);
|
||||
wait_for_backend_job_worker_cleanup(
|
||||
api,
|
||||
&job.request.job_id,
|
||||
&attempt.attempt_id,
|
||||
BackendJobWorkerCleanupState::Completed,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
fn release_test_assignment_body(api: &WorkspaceApi, worker: &RuntimeWorkerRef) {
|
||||
let response = subjektiv_journal::release_subject(
|
||||
api,
|
||||
serde_json::from_value(json!({
|
||||
"worker":worker, "operation_id":format!("assignment-release-{}", worker.worker_id),
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.state,
|
||||
runtime_api::SubjectReleaseState::Disconnected
|
||||
);
|
||||
let stopped = api
|
||||
.runtime
|
||||
.stop_worker(worker, WorkerLifecycleRequest::default())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
stopped.state,
|
||||
InternalWorkerOperationState::Accepted,
|
||||
"{:?}",
|
||||
stopped.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
async fn remove_test_assignment_body(api: &WorkspaceApi, worker: &RuntimeWorkerRef) {
|
||||
release_test_assignment_body(api, worker);
|
||||
let removed = WorkerRemovalService::new(api)
|
||||
.execute_target_removal(
|
||||
api.runtime.as_ref(),
|
||||
worker,
|
||||
"replace assignment test body",
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(removed.status, StatusCode::OK.as_u16(), "{}", removed.body);
|
||||
}
|
||||
|
||||
impl DeterministicExecutionBackend {
|
||||
fn release_assignment_controller(
|
||||
&self,
|
||||
request: &runtime_api::SubjectReleaseRequest,
|
||||
) -> std::result::Result<runtime_api::SubjectReleaseResponse, String> {
|
||||
use runtime_api::{SubjectReleaseAction as A, SubjectReleaseState as S};
|
||||
if !self
|
||||
.assignment_release_enabled
|
||||
.load(std::sync::atomic::Ordering::SeqCst)
|
||||
{
|
||||
return Err(
|
||||
"deterministic backend has no actual Subject Controller release support".into(),
|
||||
);
|
||||
}
|
||||
let worker = worker_runtime::identity::WorkerRef::new(request.worker_id);
|
||||
let execution = self
|
||||
.contexts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&worker)
|
||||
.cloned()
|
||||
.ok_or("Subject execution is absent; no disconnect proof")?;
|
||||
let mut saved = execution.subject_release.lock().unwrap();
|
||||
if let Some((prior, state)) = saved.as_ref() {
|
||||
if *state != S::Cancelled
|
||||
&& (prior.binding_id != request.binding_id
|
||||
|| prior.operation_id != request.operation_id
|
||||
|| prior.workspace_id != request.workspace_id)
|
||||
{
|
||||
return Err("another Subject release owns this execution".into());
|
||||
}
|
||||
}
|
||||
let state = match request.action {
|
||||
A::Reserve => {
|
||||
if saved.as_ref().is_some_and(|(p, s)| {
|
||||
p.operation_id == request.operation_id && *s == S::Cancelled
|
||||
}) {
|
||||
return Err("cancelled Subject release cannot rearm".into());
|
||||
}
|
||||
if execution.state.lock().unwrap().state != protocol::WorkerState::Idle
|
||||
|| execution.method_rx.lock().unwrap().is_none()
|
||||
{
|
||||
return Err("mock execution cannot reserve its live Idle receiver".into());
|
||||
}
|
||||
S::Reserved
|
||||
}
|
||||
A::Cancel => S::Cancelled,
|
||||
A::Disconnect => {
|
||||
if !saved.as_ref().is_some_and(|(p, s)| {
|
||||
p.operation_id == request.operation_id
|
||||
&& p.binding_id == request.binding_id
|
||||
&& matches!(s, S::Reserved | S::Disconnected)
|
||||
}) {
|
||||
return Err("Subject disconnect has no exact receiver reservation".into());
|
||||
}
|
||||
execution.method_rx.lock().unwrap().take();
|
||||
S::Disconnected
|
||||
}
|
||||
};
|
||||
*saved = Some((request.clone(), state));
|
||||
Ok(runtime_api::SubjectReleaseResponse {
|
||||
binding_id: request.binding_id.clone(),
|
||||
operation_id: request.operation_id.clone(),
|
||||
state,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_
|
||||
let input = |key: &str| {
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"operation_id": key,
|
||||
"principal": {"kind":"workspace_agent","agent_key":"workspace-orchestrator"}
|
||||
"principal": test_assignment_principal(&api)
|
||||
}))
|
||||
.unwrap()
|
||||
};
|
||||
@@ -175,6 +175,7 @@ fn tracking_path(ticket: &str) -> String {
|
||||
}
|
||||
|
||||
fn assignment_request(
|
||||
api: &WorkspaceApi,
|
||||
identity: &RuntimeIdentityMaterial,
|
||||
worker: &str,
|
||||
ticket: &str,
|
||||
@@ -182,7 +183,7 @@ fn assignment_request(
|
||||
) -> Request<Body> {
|
||||
runtime_source_request(identity, Some(worker), "PUT",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"),
|
||||
serde_json::to_vec(&serde_json::json!({"operation_id":operation,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap())
|
||||
serde_json::to_vec(&serde_json::json!({"operation_id":operation,"principal":test_assignment_principal(&api)})).unwrap())
|
||||
}
|
||||
|
||||
fn assignment_clear_path(ticket: &str, assignment: &str, operation: &str) -> String {
|
||||
@@ -191,22 +192,40 @@ fn assignment_clear_path(ticket: &str, assignment: &str, operation: &str) -> Str
|
||||
)
|
||||
}
|
||||
|
||||
fn replace_tracking_orchestrator(
|
||||
async fn tracking_embedded_fixture(
|
||||
root: &Path,
|
||||
) -> (
|
||||
WorkspaceApi,
|
||||
RuntimeIdentityMaterial,
|
||||
RuntimeWorkerRef,
|
||||
String,
|
||||
) {
|
||||
let (mut api, _) = assignment_test_api_with_recording_backend(root).await;
|
||||
let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, EMBEDDED_WORKER_RUNTIME_ID);
|
||||
let subject_id = ensure_test_assignment_subject(&api);
|
||||
let source = create_test_assignment_body(&api, EMBEDDED_WORKER_RUNTIME_ID, &subject_id).await;
|
||||
bootstrap_test_assignment_session(&api, &source);
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new("Subject body replacement"))
|
||||
.unwrap();
|
||||
grant_test_assignment_ticket(&api, &subject_id, &ticket.id);
|
||||
(api, identity, source, ticket.id)
|
||||
}
|
||||
|
||||
async fn replace_tracking_orchestrator(
|
||||
api: &WorkspaceApi,
|
||||
runtime: &WorkdirlessFixtureRuntime,
|
||||
source: &RuntimeWorkerRef,
|
||||
) -> RuntimeWorkerRef {
|
||||
let replacement = RuntimeWorkerRef::new(&source.runtime_id, WorkerId::now_v7().to_string());
|
||||
seed_worker_source_member(api, &replacement.runtime_id, &replacement.worker_id);
|
||||
let mut summary = runtime.workers.lock().unwrap()[0].clone();
|
||||
summary.worker = replacement.clone();
|
||||
runtime.workers.lock().unwrap().push(summary);
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
conn.execute("UPDATE worker_singleton_owners SET worker_id=?1,generation=generation+1 WHERE workspace_id=?2 AND singleton_key=?3", rusqlite::params![replacement.worker_id, TEST_WORKSPACE_ID, crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY]).unwrap();
|
||||
assert_eq!(
|
||||
find_workspace_orchestrator(api).unwrap().worker,
|
||||
replacement
|
||||
);
|
||||
// Normal Subject release owns writer revocation and the singleton transition.
|
||||
// This fixture substitutes the Controller receiver, not the lifetime protocol.
|
||||
remove_test_assignment_body(api, source).await;
|
||||
let subject_id = ensure_test_assignment_subject(api);
|
||||
let replacement = create_test_assignment_body(api, &source.runtime_id, &subject_id).await;
|
||||
bootstrap_test_assignment_session(api, &replacement);
|
||||
assert_ne!(*source, replacement);
|
||||
assert_eq!(test_assignment_body(api).unwrap().worker, replacement);
|
||||
replacement
|
||||
}
|
||||
|
||||
@@ -238,7 +257,13 @@ async fn revoked_runtime_cannot_retry_assign_clear_or_record_tracking() {
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
conn.execute("UPDATE workspace_runtime_bindings SET revoked_at='2026-10-11T00:00:00Z' WHERE workspace_id=?1 AND runtime_id=?2", rusqlite::params![TEST_WORKSPACE_ID, source.runtime_id]).unwrap();
|
||||
for request in [
|
||||
assignment_request(&identity, &source.worker_id, &ticket, "assign-tracking"),
|
||||
assignment_request(
|
||||
&api,
|
||||
&identity,
|
||||
&source.worker_id,
|
||||
&ticket,
|
||||
"assign-tracking",
|
||||
),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(&source.worker_id),
|
||||
@@ -267,7 +292,7 @@ async fn revoked_runtime_cannot_retry_assign_clear_or_record_tracking() {
|
||||
#[tokio::test]
|
||||
async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_old_receipts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await;
|
||||
let (api, identity, source, ticket) = tracking_embedded_fixture(dir.path()).await;
|
||||
let current = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
let app = build_router(api.clone());
|
||||
let clear_path =
|
||||
@@ -284,7 +309,7 @@ async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_ol
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let replacement = replace_tracking_orchestrator(&api, &runtime, &source);
|
||||
let replacement = replace_tracking_orchestrator(&api, &source).await;
|
||||
for worker in [&source.worker_id, &replacement.worker_id] {
|
||||
let expected = if worker == &source.worker_id {
|
||||
StatusCode::UNAUTHORIZED
|
||||
@@ -293,7 +318,7 @@ async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_ol
|
||||
};
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
assignment_request(&identity, worker, &ticket, "assign-tracking"),
|
||||
assignment_request(&api, &identity, worker, &ticket, "assign-tracking"),
|
||||
expected,
|
||||
)
|
||||
.await;
|
||||
@@ -313,6 +338,7 @@ async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_ol
|
||||
tracking_response(
|
||||
app,
|
||||
assignment_request(
|
||||
&api,
|
||||
&identity,
|
||||
&replacement.worker_id,
|
||||
&ticket,
|
||||
@@ -330,19 +356,30 @@ async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_ol
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_orchestrator_tags_without_backend_singleton_do_not_grant_assignment_authority() {
|
||||
async fn runtime_orchestrator_tags_without_operation_policy_do_not_grant_assignment_authority() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await;
|
||||
let current = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
let forged = RuntimeWorkerRef::new(&source.runtime_id, WorkerId::now_v7().to_string());
|
||||
seed_worker_source_member(&api, &forged.runtime_id, &forged.worker_id);
|
||||
let mut summary = runtime.workers.lock().unwrap()[0].clone();
|
||||
summary.worker = forged.clone();
|
||||
let unprivileged = open_subjektiv_store(&api)
|
||||
.unwrap()
|
||||
.create_subject(
|
||||
crate::subjektiv::SubjectRole::new("unprivileged-assignment-source").unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let forged = create_test_assignment_body(&api, &source.runtime_id, &unprivileged.id).await;
|
||||
// Runtime display metadata cannot mint operation policy. Backend ownership
|
||||
// remains this real, unprivileged Subject, even if the adapter advertises tags.
|
||||
let mut workers = runtime.workers.lock().unwrap();
|
||||
let summary = workers
|
||||
.iter_mut()
|
||||
.find(|worker| worker.worker == forged)
|
||||
.unwrap();
|
||||
summary.tags = vec!["orchestrator".into(), "singleton".into()];
|
||||
summary.singleton_key = Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.into());
|
||||
runtime.workers.lock().unwrap().push(summary);
|
||||
drop(workers);
|
||||
let app = build_router(api.clone());
|
||||
for request in [
|
||||
assignment_request(&identity, &forged.worker_id, &ticket, "forged-assign"),
|
||||
assignment_request(&api, &identity, &forged.worker_id, &ticket, "forged-assign"),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(&forged.worker_id),
|
||||
@@ -376,7 +413,7 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "assignment-account-owner").await;
|
||||
let other = seed_test_api_token(api.store.as_ref(), "assignment-account-other");
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"account-assign", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap();
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"account-assign", "principal":test_assignment_principal(&api)})).unwrap();
|
||||
let request = |token: &str, method: &str, path: &str, body: Vec<u8>| {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
@@ -459,7 +496,7 @@ async fn assign_tracking(
|
||||
let value = tracking_response(build_router(api.clone()), runtime_source_request(
|
||||
identity, Some(&source.worker_id), "PUT",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"),
|
||||
serde_json::to_vec(&serde_json::json!({ "operation_id": "assign-tracking", "principal": { "kind": "workspace_agent", "agent_key": "workspace-orchestrator" } })).unwrap(),
|
||||
serde_json::to_vec(&serde_json::json!({ "operation_id": "assign-tracking", "principal": test_assignment_principal(&api) })).unwrap(),
|
||||
), StatusCode::OK).await;
|
||||
assert_eq!(
|
||||
value["assignment"]["assigned_by"],
|
||||
@@ -755,61 +792,14 @@ async fn tracking_fixture(
|
||||
api.runtime.register_or_replace(runtime.clone());
|
||||
let identity = RuntimeIdentityMaterial::generate(runtime_id).unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, runtime_id);
|
||||
let memory = api
|
||||
.config_store
|
||||
.get_workspace_memory_settings(TEST_WORKSPACE_ID)
|
||||
.unwrap();
|
||||
let reservation = api
|
||||
.config_store
|
||||
.reserve_worker_create(
|
||||
TEST_WORKSPACE_ID,
|
||||
runtime_id,
|
||||
"tracking-orchestrator",
|
||||
"sha256:tracking-orchestrator",
|
||||
Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY),
|
||||
&memory,
|
||||
)
|
||||
.unwrap();
|
||||
let mut summary = WorkdirlessFixtureRuntime::worker_summary(
|
||||
WorkerCreateBinding {
|
||||
worker_id: reservation.worker_id,
|
||||
create_fingerprint: reservation.create_fingerprint,
|
||||
},
|
||||
&WorkerSpawnRequest {
|
||||
requested_worker_name: Some("Tracking Orchestrator".into()),
|
||||
singleton_key: Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.into()),
|
||||
intent: WorkerSpawnIntent::WorkspaceCompanion,
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
|
||||
expected_segments: 0,
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:orchestrator".into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: Vec::new(),
|
||||
workdir_attachment_requests: Vec::new(),
|
||||
resolved_workdir_attachment_requests: Vec::new(),
|
||||
resolved_workdir_attachments: Vec::new(),
|
||||
resolved_config_bundle: None,
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
resolved_subjektiv_attached: false,
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
},
|
||||
);
|
||||
summary.tags = vec!["orchestrator".into(), "singleton".into()];
|
||||
let source = summary.worker.clone();
|
||||
runtime.workers.lock().unwrap().push(summary);
|
||||
seed_worker_source_member(&api, runtime_id, &source.worker_id);
|
||||
api.config_store
|
||||
.complete_worker_create_reservation(TEST_WORKSPACE_ID, source.worker_id.parse().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(find_workspace_orchestrator(&api).unwrap().worker, source);
|
||||
let subject_id = ensure_test_assignment_subject(&api);
|
||||
let source = create_test_assignment_body(&api, runtime_id, &subject_id).await;
|
||||
assert_eq!(test_assignment_body(&api).unwrap().worker, source);
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new("Tracking authority"))
|
||||
.unwrap();
|
||||
grant_test_assignment_ticket(&api, &subject_id, &ticket.id);
|
||||
(api, runtime, identity, source, ticket.id)
|
||||
}
|
||||
|
||||
@@ -922,7 +912,7 @@ async fn tracking_unassign_and_reassign_keep_results_and_fence_old_assignment()
|
||||
.await,
|
||||
result
|
||||
);
|
||||
tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"assign-again", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap()), StatusCode::OK).await;
|
||||
tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"assign-again", "principal":test_assignment_principal(&api)})).unwrap()), StatusCode::OK).await;
|
||||
let next = api
|
||||
.store
|
||||
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket)
|
||||
@@ -1003,7 +993,7 @@ async fn tracking_other_workspace_routes_and_unsigned_reads_are_rejected() {
|
||||
#[tokio::test]
|
||||
async fn replaced_orchestrator_cannot_update_or_replay_and_current_replacement_inherits_tracking() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await;
|
||||
let (api, identity, source, ticket) = tracking_embedded_fixture(dir.path()).await;
|
||||
let current = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
let update = tracking_update(¤t, "old-orchestrator-result");
|
||||
let app = build_router(api.clone());
|
||||
@@ -1013,18 +1003,7 @@ async fn replaced_orchestrator_cannot_update_or_replay_and_current_replacement_i
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let replacement = RuntimeWorkerRef::new(&source.runtime_id, WorkerId::now_v7().to_string());
|
||||
seed_worker_source_member(&api, &replacement.runtime_id, &replacement.worker_id);
|
||||
let mut summary = runtime.workers.lock().unwrap()[0].clone();
|
||||
summary.worker = replacement.clone();
|
||||
runtime.workers.lock().unwrap().push(summary);
|
||||
// Seed the authoritative replacement, not merely a Runtime tag or model role.
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
conn.execute("UPDATE worker_singleton_owners SET worker_id=?1,generation=generation+1 WHERE workspace_id=?2 AND singleton_key=?3", rusqlite::params![replacement.worker_id, TEST_WORKSPACE_ID, crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY]).unwrap();
|
||||
assert_eq!(
|
||||
find_workspace_orchestrator(&api).unwrap().worker,
|
||||
replacement
|
||||
);
|
||||
let replacement = replace_tracking_orchestrator(&api, &source).await;
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update),
|
||||
@@ -1111,7 +1090,7 @@ async fn assignment_retry_is_idempotent_but_another_authenticated_actor_cannot_r
|
||||
assert_eq!(replay, current);
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "assignment-replay-user").await;
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap();
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":test_assignment_principal(&api)})).unwrap();
|
||||
tracking_response(
|
||||
build_router(api.clone()),
|
||||
Request::builder()
|
||||
@@ -1261,7 +1240,7 @@ async fn assignment_receipt_cannot_be_replayed_under_a_new_runtime_binding() {
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
conn.execute("UPDATE workspace_runtime_bindings SET binding_id='new-assignment-binding' WHERE workspace_id=?1 AND runtime_id=?2", rusqlite::params![TEST_WORKSPACE_ID, source.runtime_id]).unwrap();
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap();
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":test_assignment_principal(&api)})).unwrap();
|
||||
tracking_response(
|
||||
build_router(api.clone()),
|
||||
runtime_source_request(&identity, Some(&source.worker_id), "PUT", &path, body),
|
||||
@@ -1377,7 +1356,7 @@ async fn released_assignment_query_retains_principal_assign_audit_and_last_track
|
||||
assert_eq!(released["role"], "orchestrator");
|
||||
assert_eq!(
|
||||
released["principal"],
|
||||
serde_json::json!({"kind":"workspace_agent","agent_key":"workspace-orchestrator"})
|
||||
serde_json::json!(test_assignment_principal(&api))
|
||||
);
|
||||
assert_eq!(released["assigned_by"], current.assigned_by);
|
||||
assert_eq!(released["assigned_at"], current.assigned_at);
|
||||
@@ -1407,6 +1386,11 @@ async fn released_assignment_query_retains_principal_assign_audit_and_last_track
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new("Different Ticket"))
|
||||
.unwrap();
|
||||
grant_test_assignment_ticket(
|
||||
&api,
|
||||
&ensure_test_assignment_subject(&api),
|
||||
&other_ticket.id,
|
||||
);
|
||||
assert_eq!(
|
||||
tracking_response(
|
||||
app,
|
||||
@@ -1441,7 +1425,7 @@ async fn same_principal_reassignment_retains_old_result_and_projects_replacement
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let assignment = tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"request-again", "expected_assignment_id":current.assignment_id,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap()), StatusCode::OK).await;
|
||||
let assignment = tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"request-again", "expected_assignment_id":current.assignment_id,"principal":test_assignment_principal(&api)})).unwrap()), StatusCode::OK).await;
|
||||
let next_id = assignment["assignment"]["assignment_id"].as_str().unwrap();
|
||||
assert_ne!(next_id, current.assignment_id);
|
||||
let old = tracking_response(
|
||||
@@ -1494,7 +1478,7 @@ async fn assignment_signed_runtime_without_worker_cannot_self_assign_service_rol
|
||||
let (api, _, identity, _, ticket) = tracking_fixture(dir.path()).await;
|
||||
let input = serde_json::json!({
|
||||
"operation_id": "runtime-only-assign",
|
||||
"principal": { "kind": "workspace_agent", "agent_key": "workspace-orchestrator" }
|
||||
"principal": test_assignment_principal(&api)
|
||||
});
|
||||
tracking_response(
|
||||
build_router(api.clone()),
|
||||
@@ -1595,7 +1579,7 @@ async fn public_assignment_answer_authenticates_source_replays_saved_result_and_
|
||||
.await;
|
||||
let assignment_path =
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
|
||||
tracking_response(app.clone(),runtime_source_request(&identity,Some(&source.worker_id),"PUT",&assignment_path,serde_json::to_vec(&serde_json::json!({"operation_id":"replace-after-read","expected_assignment_id":current.assignment_id,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap()),StatusCode::OK).await;
|
||||
tracking_response(app.clone(),runtime_source_request(&identity,Some(&source.worker_id),"PUT",&assignment_path,serde_json::to_vec(&serde_json::json!({"operation_id":"replace-after-read","expected_assignment_id":current.assignment_id,"principal":test_assignment_principal(&api)})).unwrap()),StatusCode::OK).await;
|
||||
let mut stale = body.clone();
|
||||
stale["operation_key"] = "fresh-stale-answer".into();
|
||||
tracking_response(app.clone(), account_request(&stale), StatusCode::CONFLICT).await;
|
||||
@@ -1709,11 +1693,11 @@ async fn ticket_query_principal_and_handling_filter_before_single_item_page_with
|
||||
.create(ticket::NewTicket::new(format!("Unassigned newer {n}")))
|
||||
.unwrap();
|
||||
}
|
||||
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"done","assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"limit":1})).unwrap();
|
||||
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"done","assigned_principal":test_assignment_principal(&api),"limit":1})).unwrap();
|
||||
let found = api.authority.query_tickets(query).unwrap();
|
||||
assert_eq!(found.items.len(), 1);
|
||||
assert_eq!(found.items[0].id, ticket);
|
||||
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"waiting","assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"limit":1})).unwrap();
|
||||
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"waiting","assigned_principal":test_assignment_principal(&api),"limit":1})).unwrap();
|
||||
assert!(api.authority.query_tickets(query).unwrap().items.is_empty());
|
||||
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"assigned_principal":{"kind":"worker","runtime_id":source.runtime_id,"worker_id":source.worker_id},"limit":1})).unwrap();
|
||||
assert!(api.authority.query_tickets(query).unwrap().items.is_empty());
|
||||
@@ -2062,3 +2046,491 @@ async fn closure_reason_filter_matches_current_judgment_before_paging_and_text_s
|
||||
reference.id
|
||||
);
|
||||
}
|
||||
|
||||
// FeatureStorage policy, body singleton ownership and signed Runtime HTTP are real.
|
||||
// Bodies are Workdir-free. Connected concurrent delegate/tool execution is tested
|
||||
// separately in subject_authorization_tests, not claimed by this identity test.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn two_subjects_signed_assignment_isolates_current_subjects_and_stale_receipts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(dir.path());
|
||||
for branch in ["subject-a-work", "subject-b-work"] {
|
||||
assert!(
|
||||
std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir.path())
|
||||
.args(["branch", branch])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
}
|
||||
let (mut api, _execution) = test_api_with_recording_backend(dir.path()).await;
|
||||
let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, EMBEDDED_WORKER_RUNTIME_ID);
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "parallel-subject-owner").await;
|
||||
let app = build_router(api.clone());
|
||||
let mut cases = Vec::new();
|
||||
for (name, branch) in [
|
||||
("subject-a", "subject-a-work"),
|
||||
("subject-b", "subject-b-work"),
|
||||
] {
|
||||
let subject = open_subjektiv_store(&api)
|
||||
.unwrap()
|
||||
.create_subject_with_behavior(
|
||||
crate::subjektiv::SubjectRole::new(name).unwrap(),
|
||||
format!("Own only {name}'s Ticket."),
|
||||
)
|
||||
.unwrap();
|
||||
let mut input = ticket::NewTicket::new(name);
|
||||
set_test_ticket_target(&mut input, "test-repository", branch);
|
||||
let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap();
|
||||
grant_test_assignment_ticket(&api, &subject.id, &ticket.id);
|
||||
let created = request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),
|
||||
Some(
|
||||
json!({"runtime_id":EMBEDDED_WORKER_RUNTIME_ID, "display_name":name,
|
||||
"profile":"builtin:companion", "initial_submit":[],
|
||||
"feature_connections":{"subjektiv":{"subject_id":subject.id}},
|
||||
"workdir_attachments":[]}),
|
||||
),
|
||||
&token,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let body = RuntimeWorkerRef::new(
|
||||
EMBEDDED_WORKER_RUNTIME_ID,
|
||||
created["worker_id"].as_str().unwrap(),
|
||||
);
|
||||
let assign_path = format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/orchestrator",
|
||||
ticket.id
|
||||
);
|
||||
let assign = json!({"operation_id":format!("assign-{name}"),
|
||||
"principal":{"kind":"subject", "subject_id":subject.id}});
|
||||
let receipt = tracking_response(
|
||||
app.clone(),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(&body.worker_id),
|
||||
"PUT",
|
||||
&assign_path,
|
||||
serde_json::to_vec(&assign).unwrap(),
|
||||
),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(receipt["assignment"]["principal"], assign["principal"]);
|
||||
assert_eq!(receipt["assignment"]["role"], "orchestrator");
|
||||
let current = api
|
||||
.store
|
||||
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket.id)
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
assert_eq!(
|
||||
current.tracking.as_ref().unwrap().status,
|
||||
ticket::TicketAssignmentTrackingStatus::Waiting
|
||||
);
|
||||
let links = api
|
||||
.store
|
||||
.list_worker_workdir_links(TEST_WORKSPACE_ID, &body)
|
||||
.unwrap();
|
||||
assert!(links.is_empty());
|
||||
let key = crate::subjektiv::subject_worker_singleton_key(&subject.id).unwrap();
|
||||
assert_eq!(
|
||||
api.store
|
||||
.current_worker_singleton_owner(TEST_WORKSPACE_ID, &key)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.worker,
|
||||
body
|
||||
);
|
||||
cases.push((subject.id, ticket.id, body, current));
|
||||
}
|
||||
assert_ne!(cases[0].0, cases[1].0);
|
||||
assert_ne!(cases[0].2, cases[1].2);
|
||||
assert!(
|
||||
api.store
|
||||
.current_worker_singleton_owner(
|
||||
TEST_WORKSPACE_ID,
|
||||
crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let (subject_a, ticket_a, body_a, current_a) = &cases[0];
|
||||
let (subject_b, ticket_b, body_b, current_b) = &cases[1];
|
||||
let update_a = tracking_update(current_a, "a-result");
|
||||
// Exact operation grant is required even for a live body of another Subject.
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(&identity, Some(&body_b.worker_id), ticket_a, &update_a),
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
grant_test_assignment_ticket(&api, subject_b, ticket_a);
|
||||
// Granting the operation still cannot impersonate the assigned Subject.
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(&identity, Some(&body_b.worker_id), ticket_a, &update_a),
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
let saved = tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(&identity, Some(&body_a.worker_id), ticket_a, &update_a),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
api.store
|
||||
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket_b, ¤t_b.assignment_id)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
*current_b
|
||||
);
|
||||
// Same Subject body may author reassignment if granted, but ownership of A
|
||||
// immediately moves to B. A's old done receipt remains evidence, not authority.
|
||||
let reassign = json!({"operation_id":"a-to-b", "expected_assignment_id":current_a.assignment_id,
|
||||
"principal":{"kind":"subject", "subject_id":subject_b}});
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket_a}/assignments/orchestrator");
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(&body_a.worker_id),
|
||||
"PUT",
|
||||
&path,
|
||||
serde_json::to_vec(&reassign).unwrap(),
|
||||
),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let next = api
|
||||
.store
|
||||
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket_a)
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
assert_ne!(next.assignment_id, current_a.assignment_id);
|
||||
let fresh = tracking_update(&next, "old-body-new-delegation");
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(&identity, Some(&body_a.worker_id), ticket_a, &fresh),
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
let mut forged_receipt = update_a.clone();
|
||||
forged_receipt.assignment_id = next.assignment_id.clone();
|
||||
forged_receipt.expected_last_event_id = next.last_event_id.clone().unwrap();
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(
|
||||
&identity,
|
||||
Some(&body_b.worker_id),
|
||||
ticket_a,
|
||||
&forged_receipt,
|
||||
),
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
// A retained self-report receipt is never rewritten to point at the new row.
|
||||
assert_eq!(saved["assignment_id"], current_a.assignment_id);
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(&body_b.worker_id),
|
||||
"DELETE",
|
||||
&assignment_clear_path(ticket_a, &next.assignment_id, "clear-a-from-b"),
|
||||
Vec::new(),
|
||||
),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let after_clear = tracking_update(&next, "after-clear");
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(&identity, Some(&body_b.worker_id), ticket_a, &after_clear),
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
api.store
|
||||
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket_a)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket_b, ¤t_b.assignment_id)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
*current_b
|
||||
);
|
||||
assert_eq!(
|
||||
api.authority.ticket(ticket_a).unwrap().state,
|
||||
server_api::BrowserTicketState::Open
|
||||
);
|
||||
assert_eq!(
|
||||
api.authority.ticket(ticket_b).unwrap().state,
|
||||
server_api::BrowserTicketState::Open
|
||||
);
|
||||
assert_eq!(
|
||||
open_subjektiv_store(&api)
|
||||
.unwrap()
|
||||
.subject(subject_a)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.behavior_md,
|
||||
"Own only subject-a's Ticket."
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subject_operation_policy_revocation_rejects_saved_mutation_receipts_without_releasing_assignment()
|
||||
{
|
||||
use server_api::authorization::SubjectPermissionsUpdate;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await;
|
||||
let current = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
let input = tracking_update(¤t, "result-before-policy-revocation");
|
||||
let app = build_router(api.clone());
|
||||
let saved = tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &input),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let before = api
|
||||
.store
|
||||
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let subject_id = ensure_test_assignment_subject(&api);
|
||||
let store = open_subjektiv_store(&api).unwrap();
|
||||
let policy = store.subject_permissions(&subject_id).unwrap().unwrap();
|
||||
store
|
||||
.set_subject_permissions(
|
||||
&subject_id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "revoke-assignment-operations".into(),
|
||||
expected_change_id: Some(policy.change_id),
|
||||
execution: vec![],
|
||||
delegation: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let answer = json!({"assignment_id":current.assignment_id,
|
||||
"expected_last_event_id":saved["last_event_id"], "operation_key":"answer-after-revoke", "body":"No authority"});
|
||||
let answer_path = format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer");
|
||||
for request in [
|
||||
assignment_request(
|
||||
&api,
|
||||
&identity,
|
||||
&source.worker_id,
|
||||
&ticket,
|
||||
"assign-tracking",
|
||||
),
|
||||
signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &input),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(&source.worker_id),
|
||||
"DELETE",
|
||||
&assignment_clear_path(&ticket, ¤t.assignment_id, "clear-after-revoke"),
|
||||
Vec::new(),
|
||||
),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(&source.worker_id),
|
||||
"POST",
|
||||
&answer_path,
|
||||
serde_json::to_vec(&answer).unwrap(),
|
||||
),
|
||||
] {
|
||||
tracking_response(app.clone(), request, StatusCode::FORBIDDEN).await;
|
||||
}
|
||||
assert_eq!(
|
||||
api.store
|
||||
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(before.current);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.list_ticket_assignment_tracking_history(
|
||||
TEST_WORKSPACE_ID,
|
||||
&ticket,
|
||||
¤t.assignment_id
|
||||
)
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
api.authority.ticket(&ticket).unwrap().state,
|
||||
server_api::BrowserTicketState::Open
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generic_delegated_worker_authors_with_operation_grants_but_cannot_self_report_subject_handling()
|
||||
{
|
||||
use server_api::authorization::{
|
||||
PermissionResource, ScopedPermission, SubjectPermissionsUpdate,
|
||||
};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, _, identity, body, ticket) = tracking_fixture(dir.path()).await;
|
||||
let subject_id = ensure_test_assignment_subject(&api);
|
||||
let store = open_subjektiv_store(&api).unwrap();
|
||||
let policy = store.subject_permissions(&subject_id).unwrap().unwrap();
|
||||
let permissions: Vec<_> = [
|
||||
"ticket.assignment.assign",
|
||||
"ticket.assignment.clear",
|
||||
"ticket.assignment.answer",
|
||||
"ticket.assignment.update",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|operation| ScopedPermission {
|
||||
operation: operation.into(),
|
||||
resource: PermissionResource::Ticket {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
ticket_id: ticket.clone(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
let policy = store
|
||||
.set_subject_permissions(
|
||||
&subject_id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "allow-generic-assignment-author".into(),
|
||||
expected_change_id: Some(policy.change_id),
|
||||
execution: policy.execution,
|
||||
delegation: permissions.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let app = build_router(api.clone());
|
||||
// Existing Worker control/FeatureStorage grant path, not a Ticket executor
|
||||
// or a role fixture. No implementation start or T-803 integration is claimed.
|
||||
let child = tracking_response(app.clone(), runtime_source_request(&identity, Some(&body.worker_id), "POST",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"),
|
||||
serde_json::to_vec(&json!({"runtime_id":body.runtime_id, "display_name":"Generic assignment author",
|
||||
"profile":"builtin:intake", "initial_submit":[], "workdir_attachments":[], "feature_connections":{},
|
||||
"control_operation_id":"spawn-generic-author",
|
||||
"execution_grant":{"permissions":permissions, "redelegation":[]}})).unwrap()), StatusCode::OK).await;
|
||||
let worker_id = child["worker_id"].as_str().unwrap();
|
||||
assert!(child["worker"]["singleton_key"].is_null());
|
||||
assert_eq!(child["worker"]["profile"], "builtin:intake");
|
||||
let saved = tracking_response(
|
||||
app.clone(),
|
||||
assignment_request(&api, &identity, worker_id, &ticket, "generic-assign"),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let current = api
|
||||
.store
|
||||
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket)
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
assert_eq!(
|
||||
saved["assignment"]["principal"],
|
||||
test_assignment_principal(&api)
|
||||
);
|
||||
assert_eq!(
|
||||
saved["assignment"]["assigned_by"],
|
||||
format!("worker:{}/{worker_id}", body.runtime_id)
|
||||
);
|
||||
// Operation policy alone never makes a background Worker the Subject body.
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
signed_tracking_update(
|
||||
&identity,
|
||||
Some(worker_id),
|
||||
&ticket,
|
||||
&tracking_update(¤t, "child-cannot-report"),
|
||||
),
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
let answer = json!({"assignment_id":current.assignment_id,
|
||||
"expected_last_event_id":current.last_event_id, "operation_key":"generic-answer", "body":"Additional authorized input"});
|
||||
let answer_path = format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer");
|
||||
let receipt = tracking_response(
|
||||
app.clone(),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(worker_id),
|
||||
"POST",
|
||||
&answer_path,
|
||||
serde_json::to_vec(&answer).unwrap(),
|
||||
),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(worker_id),
|
||||
"POST",
|
||||
&answer_path,
|
||||
serde_json::to_vec(&answer).unwrap()
|
||||
),
|
||||
StatusCode::OK
|
||||
)
|
||||
.await,
|
||||
receipt
|
||||
);
|
||||
let clear = assignment_clear_path(&ticket, ¤t.assignment_id, "generic-clear");
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
runtime_source_request(&identity, Some(worker_id), "DELETE", &clear, Vec::new()),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
store
|
||||
.set_subject_permissions(
|
||||
&subject_id,
|
||||
&SubjectPermissionsUpdate {
|
||||
operation_id: "revoke-generic-author".into(),
|
||||
expected_change_id: Some(policy.change_id),
|
||||
execution: policy.execution,
|
||||
delegation: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
for request in [
|
||||
assignment_request(&api, &identity, worker_id, &ticket, "generic-assign"),
|
||||
runtime_source_request(&identity, Some(worker_id), "DELETE", &clear, Vec::new()),
|
||||
runtime_source_request(
|
||||
&identity,
|
||||
Some(worker_id),
|
||||
"POST",
|
||||
&answer_path,
|
||||
serde_json::to_vec(&answer).unwrap(),
|
||||
),
|
||||
] {
|
||||
tracking_response(app.clone(), request, StatusCode::FORBIDDEN).await;
|
||||
}
|
||||
let retained = api
|
||||
.store
|
||||
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!retained.current);
|
||||
assert_eq!(
|
||||
retained.tracking.unwrap().status,
|
||||
ticket::TicketAssignmentTrackingStatus::Waiting
|
||||
);
|
||||
assert!(
|
||||
api.store
|
||||
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,11 @@ async fn result_signed_http(
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
async fn assert_result_diagnostics(api: &WorkspaceApi, recipient: &RuntimeWorkerRef, result_id: &str) {
|
||||
async fn assert_result_diagnostics(
|
||||
api: &WorkspaceApi,
|
||||
recipient: &RuntimeWorkerRef,
|
||||
result_id: &str,
|
||||
) {
|
||||
let page = delivery_http(api, "GET", &format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?source_event_id=implementation-result:{result_id}"), None, StatusCode::OK).await;
|
||||
let rows = page["items"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
@@ -43,7 +47,8 @@ async fn assert_result_diagnostics(api: &WorkspaceApi, recipient: &RuntimeWorker
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn implementation_result_signed_http_reaches_persistent_orchestrator_without_assignment_or_mr() {
|
||||
async fn implementation_result_signed_http_reaches_persistent_orchestrator_without_assignment_or_mr()
|
||||
{
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut api, execution, _) = delivery_real_fixture(dir.path(), false).await;
|
||||
let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap();
|
||||
@@ -73,7 +78,15 @@ async fn implementation_result_signed_http_reaches_persistent_orchestrator_witho
|
||||
let receipt =
|
||||
result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK).await;
|
||||
// Another authenticated Worker may report, but cannot replay this Workers receipt.
|
||||
result_signed_http(&api, &identity, &orchestrator, &ticket.id, &input, StatusCode::CONFLICT).await;
|
||||
result_signed_http(
|
||||
&api,
|
||||
&identity,
|
||||
&orchestrator,
|
||||
&ticket.id,
|
||||
&input,
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
delivery_wait_idle(&api, &orchestrator, 2).await;
|
||||
assert_eq!(
|
||||
result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK).await,
|
||||
@@ -224,9 +237,22 @@ async fn mr_result_recovery_and_handoff_are_distinct_persistent_inputs() {
|
||||
})
|
||||
.unwrap();
|
||||
// No mutation callback: a diagnostic read must expose, but not route, saved MR sources.
|
||||
let pending = delivery_http(&api, "GET", &format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?merge_request_id=result-mr"), None, StatusCode::OK).await;
|
||||
let pending = delivery_http(
|
||||
&api,
|
||||
"GET",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?merge_request_id=result-mr"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(pending["items"].as_array().unwrap().len(), 4);
|
||||
assert!(pending["items"].as_array().unwrap().iter().all(|row| row["stage"] == "awaiting_routing"));
|
||||
assert!(
|
||||
pending["items"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|row| row["stage"] == "awaiting_routing")
|
||||
);
|
||||
assert_eq!(pending["source_checks"][0]["origin_present"], true);
|
||||
assert_eq!(pending["source_checks"][0]["captured"], true);
|
||||
recover_ticket_notifications(&api);
|
||||
@@ -325,10 +351,26 @@ async fn mr_result_recovery_and_handoff_are_distinct_persistent_inputs() {
|
||||
assert_eq!(execution.attempts.lock().unwrap().len(), attempts);
|
||||
assert_eq!(delivery_receipts(&api, &orchestrator).len(), 10);
|
||||
assert_result_diagnostics(&api, &orchestrator, receipt["result_id"].as_str().unwrap()).await;
|
||||
let diagnostics = delivery_http(&api, "GET", &format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?merge_request_id=result-mr"), None, StatusCode::OK).await;
|
||||
let diagnostics = delivery_http(
|
||||
&api,
|
||||
"GET",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?merge_request_id=result-mr"),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let rows = diagnostics["items"].as_array().unwrap();
|
||||
for kind in ["mr_review_requested", "mr_review", "mr_review_revoked", "mr_review_cancelled", "mr_source_changed"] {
|
||||
let matching: Vec<_> = rows.iter().filter(|r| r["event_kind"] == kind && r["recipient_role"] == "orchestrator").collect();
|
||||
for kind in [
|
||||
"mr_review_requested",
|
||||
"mr_review",
|
||||
"mr_review_revoked",
|
||||
"mr_review_cancelled",
|
||||
"mr_source_changed",
|
||||
] {
|
||||
let matching: Vec<_> = rows
|
||||
.iter()
|
||||
.filter(|r| r["event_kind"] == kind && r["recipient_role"] == "orchestrator")
|
||||
.collect();
|
||||
assert!(!matching.is_empty(), "missing diagnostic for {kind}");
|
||||
for row in matching {
|
||||
assert_eq!(row["stage"], "runtime_accepted");
|
||||
@@ -337,9 +379,16 @@ async fn mr_result_recovery_and_handoff_are_distinct_persistent_inputs() {
|
||||
}
|
||||
}
|
||||
for kind in ["mr_review_requested", "mr_review_revoked"] {
|
||||
assert!(rows.iter().any(|r| r["event_kind"] == kind && r["recipient_role"] == "worker" && r["stage"] == "suppressed" && r["reason"] == "self_notification"));
|
||||
assert!(rows.iter().any(|r| r["event_kind"] == kind
|
||||
&& r["recipient_role"] == "worker"
|
||||
&& r["stage"] == "suppressed"
|
||||
&& r["reason"] == "self_notification"));
|
||||
}
|
||||
assert_eq!(execution.attempts.lock().unwrap().len(), attempts, "diagnostic reads must not dispatch");
|
||||
assert_eq!(
|
||||
execution.attempts.lock().unwrap().len(),
|
||||
attempts,
|
||||
"diagnostic reads must not dispatch"
|
||||
);
|
||||
assert_eq!(
|
||||
api.authority.ticket(&ticket.id).unwrap().state,
|
||||
server_api::BrowserTicketState::Open
|
||||
|
||||
@@ -715,7 +715,7 @@ async fn worker_control_source_lookup_waits_off_tokio_and_retains_finite_host_ow
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free() {
|
||||
async fn ticket_tracking_authority_and_case_audit_use_local_state_without_runtime_lookup() {
|
||||
for operation in ["tracking", "case"] {
|
||||
let fixture = fixture().await;
|
||||
let ticket = browser_ticket_backend(&fixture.api)
|
||||
@@ -767,6 +767,9 @@ async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free
|
||||
};
|
||||
let backend = browser_ticket_backend(&fixture.api).unwrap();
|
||||
let before = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap();
|
||||
// Tracking authority and case attribution are now local Subject/assignment
|
||||
// reads, not observations of a singleton Runtime Worker. Keep a held
|
||||
// Runtime endpoint to detect an accidental return to network authority.
|
||||
let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let api = fixture.api.clone();
|
||||
let ticket_id = ticket.id.clone();
|
||||
@@ -819,8 +822,24 @@ async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free
|
||||
},
|
||||
));
|
||||
assert!(futures::poll!(&mut decision).is_pending());
|
||||
let request = observed(reply.entered.take().unwrap()).await.unwrap();
|
||||
assert!(request.contains("/v1/workers/"), "{operation}: {request}");
|
||||
let outcome = observed(decision.as_mut()).await;
|
||||
if operation == "tracking" {
|
||||
let error = outcome.unwrap_err();
|
||||
assert!(
|
||||
error.error.to_string().contains("Subject body"),
|
||||
"{error:?}"
|
||||
);
|
||||
} else {
|
||||
outcome.unwrap();
|
||||
}
|
||||
assert_eq!(leases.load(Ordering::SeqCst), 0);
|
||||
assert!(
|
||||
matches!(
|
||||
reply.entered.as_mut().unwrap().try_recv(),
|
||||
Err(tokio::sync::oneshot::error::TryRecvError::Empty)
|
||||
),
|
||||
"local assignment authority/audit must not observe Runtime"
|
||||
);
|
||||
let store = fixture.store.clone();
|
||||
observed(
|
||||
fixture
|
||||
@@ -836,7 +855,6 @@ async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free
|
||||
observed(tokio::time::sleep(StdDuration::from_millis(1))).await;
|
||||
assert!(!reply.upstream_released.load(Ordering::SeqCst));
|
||||
drop(decision);
|
||||
assert_eq!(leases.load(Ordering::SeqCst), 1);
|
||||
reply.finish();
|
||||
observed(fixture.host_executor.drain()).await.unwrap();
|
||||
observed(fixture.executor.drain()).await.unwrap();
|
||||
@@ -848,8 +866,8 @@ async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free
|
||||
"tracking still requires live assignment authority"
|
||||
);
|
||||
} else {
|
||||
// Profile-authorized case changes do not depend on a live
|
||||
// Orchestrator lookup. Its failure only affects audit role context.
|
||||
// Case changes do not depend on a remote singleton lookup.
|
||||
// The local assignment-only audit falls back to an ordinary Worker.
|
||||
assert_eq!(after.meta.workflow_state, ticket::TicketState::Closed);
|
||||
assert_eq!(
|
||||
after.events.last().unwrap().attributes["source_actor_role"],
|
||||
|
||||
@@ -208,7 +208,8 @@ impl TicketSurface {
|
||||
.await?;
|
||||
if name == "ShowTicket" {
|
||||
let mut page = serde_json::Map::new();
|
||||
for (tool_field, native_field) in [("event_cursor", "cursor"), ("event_limit", "limit")] {
|
||||
for (tool_field, native_field) in [("event_cursor", "cursor"), ("event_limit", "limit")]
|
||||
{
|
||||
if let Some(value) = input.as_object_mut().unwrap().remove(tool_field) {
|
||||
page.insert(native_field.into(), value);
|
||||
}
|
||||
@@ -451,12 +452,29 @@ async fn reviewer_reads_saved_related_tickets_by_key_and_id_and_pages_their_thre
|
||||
let related_before = backend.show(related.id.clone().into()).unwrap();
|
||||
let bound_key = bound.meta.resource_key.as_deref().unwrap();
|
||||
let related_key = related.resource_key.as_deref().unwrap();
|
||||
// A relation and Reviewer profile do not grant access to its target.
|
||||
// Exercise the signed denial first, then grant only the related read.
|
||||
let transport = ticket_client(&api, identity, &source);
|
||||
// One client must retain its review binding while following related Tickets.
|
||||
let client = reviewer_client(transport.clone(), &bound_id);
|
||||
let surface =
|
||||
TicketSurface::with_access(dir.path(), client, wip, TicketFeatureAccess::review())
|
||||
.await;
|
||||
let denied = surface
|
||||
.call(
|
||||
"ShowTicket",
|
||||
serde_json::json!({"id": related_key}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(denied.to_string().contains("403"), "{denied}");
|
||||
grant_test_ticket_operations(
|
||||
&api,
|
||||
&ensure_test_assignment_subject(&api),
|
||||
&related.id,
|
||||
&["ticket.read"],
|
||||
);
|
||||
for (reference, expected_key, expected_title, expected_body) in [
|
||||
(
|
||||
bound_key,
|
||||
@@ -684,13 +702,26 @@ async fn implementation_result_tool_and_wip_keep_host_owned_retry_identity_witho
|
||||
);
|
||||
assert_eq!(receipt["request"]["body"], input["body"]);
|
||||
assert_eq!(report_counts(&api), (1, 1));
|
||||
let queried = surface
|
||||
let query = serde_json::json!({"ticket":ticket,"result_id":receipt["result_id"]});
|
||||
let denied = surface
|
||||
.call(
|
||||
"TicketImplementationResultQuery",
|
||||
serde_json::json!({"ticket":ticket,"result_id":receipt["result_id"]}),
|
||||
context("query"),
|
||||
query.clone(),
|
||||
context("query-denied"),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(denied.to_string().contains("403"), "{denied}");
|
||||
// Writing a result does not implicitly authorize reading it.
|
||||
grant_test_ticket_operations(
|
||||
&api,
|
||||
&ensure_test_assignment_subject(&api),
|
||||
&ticket,
|
||||
&["ticket.implementation_result.read"],
|
||||
);
|
||||
let queried = surface
|
||||
.call("TicketImplementationResultQuery", query, context("query"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(queried, receipt);
|
||||
let mut changed = input.clone();
|
||||
|
||||
@@ -118,6 +118,8 @@ pub use notification_routing::{
|
||||
mod assignment_tracking;
|
||||
#[path = "implementation_result.rs"]
|
||||
mod implementation_result;
|
||||
#[path = "subject_assignment_migration.rs"]
|
||||
mod subject_assignment_migration;
|
||||
pub use assignment_delivery::TicketAssignmentDelivery;
|
||||
pub use assignment_tracking::TicketOrchestratorAssignmentSourceContext;
|
||||
pub use ticket::{
|
||||
@@ -127,7 +129,7 @@ pub use ticket::{
|
||||
};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 106;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 107;
|
||||
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";
|
||||
@@ -465,6 +467,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: crate::plugin_catalog::MIGRATION_NAME,
|
||||
apply: crate::plugin_catalog::migrate,
|
||||
},
|
||||
Migration {
|
||||
version: 107,
|
||||
name: "Subject Ticket responsibility and current-body notification routing",
|
||||
apply: migrate_subject_assignments_v106_to_v107,
|
||||
},
|
||||
];
|
||||
|
||||
// Both schema-103 identities shipped. Keep the alternate implementation and accept
|
||||
@@ -964,6 +971,9 @@ impl TicketAssignmentRole {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum TicketAssignmentPrincipal {
|
||||
Subject {
|
||||
subject_id: String,
|
||||
},
|
||||
User {
|
||||
account_id: String,
|
||||
},
|
||||
@@ -979,6 +989,7 @@ pub enum TicketAssignmentPrincipal {
|
||||
impl TicketAssignmentPrincipal {
|
||||
fn kind(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Subject { .. } => "subject",
|
||||
Self::User { .. } => "user",
|
||||
Self::Worker { .. } => "worker",
|
||||
Self::WorkspaceAgent { .. } => "workspace_agent",
|
||||
@@ -2267,6 +2278,14 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
content: &str,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<String>;
|
||||
/// Recheck the current target and ACK immediately before sending pinned bytes.
|
||||
/// None means a positive ACK is already saved, not an unknown acceptance.
|
||||
fn admit_ticket_assignment_delivery_send(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
delivery_id: &str,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<Option<String>>;
|
||||
fn ack_ticket_assignment_delivery(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -2297,6 +2316,12 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
content: &str,
|
||||
target: &TicketWorkerNotificationTarget,
|
||||
) -> Result<String>;
|
||||
fn admit_ticket_worker_notification_send(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
delivery_id: &str,
|
||||
target: &TicketWorkerNotificationTarget,
|
||||
) -> Result<Option<String>>;
|
||||
fn ack_ticket_worker_notification(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -9359,6 +9384,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
) -> Result<String> {
|
||||
notification_routing::prepare_worker(self, workspace_id, delivery_id, content, target)
|
||||
}
|
||||
fn admit_ticket_worker_notification_send(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
delivery_id: &str,
|
||||
target: &TicketWorkerNotificationTarget,
|
||||
) -> Result<Option<String>> {
|
||||
notification_routing::admit_worker_send(self, workspace_id, delivery_id, target)
|
||||
}
|
||||
fn ack_ticket_worker_notification(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -9445,6 +9478,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
) -> Result<String> {
|
||||
assignment_delivery::prepare_content(self, workspace_id, delivery_id, content, source)
|
||||
}
|
||||
fn admit_ticket_assignment_delivery_send(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
delivery_id: &str,
|
||||
source: &TicketAssignmentTrackingSourceContext,
|
||||
) -> Result<Option<String>> {
|
||||
assignment_delivery::admit_send(self, workspace_id, delivery_id, source)
|
||||
}
|
||||
fn ack_ticket_assignment_delivery(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -9840,7 +9881,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())),
|
||||
TicketAssignmentPrincipal::WorkspaceAgent { .. } => {
|
||||
TicketAssignmentPrincipal::WorkspaceAgent { .. }
|
||||
| TicketAssignmentPrincipal::Subject { .. } => {
|
||||
return Err(Error::TicketAssignmentConflict(
|
||||
"Workspace agent principal cannot occupy the Worker role".to_string(),
|
||||
));
|
||||
@@ -13258,7 +13300,7 @@ impl SqliteWorkspaceStore {
|
||||
)));
|
||||
}
|
||||
if let Some((assignment_id,source))=fence {
|
||||
assignment_tracking::require_runtime_source(&tx,workspace_id,&source.runtime_id,&source.worker_id,&source.agent_key,&source.authentication_binding_id)?;
|
||||
assignment_tracking::require_runtime_source(&tx,workspace_id,&source.runtime_id,&source.worker_id,&source.subject_id,&source.authentication_binding_id)?;
|
||||
assignment_delivery::require_current(&tx,workspace_id,ticket_id,assignment_id)?;
|
||||
}
|
||||
let worker = worker_id.map(|id| RuntimeWorkerRef::new(runtime_id, id));
|
||||
@@ -13382,6 +13424,9 @@ impl SqliteWorkspaceStore {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())),
|
||||
TicketAssignmentPrincipal::Subject { subject_id } => {
|
||||
(Some(subject_id.as_str()), None, None)
|
||||
}
|
||||
TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
|
||||
(Some(agent_key.as_str()), None, None)
|
||||
}
|
||||
@@ -13588,6 +13633,9 @@ fn read_ticket_role_assignment_record(
|
||||
runtime_id: runtime_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?,
|
||||
worker_id: worker_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?,
|
||||
},
|
||||
"subject" => TicketAssignmentPrincipal::Subject {
|
||||
subject_id: principal_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?,
|
||||
},
|
||||
"workspace_agent" => TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: principal_id.ok_or_else(|| rusqlite::Error::InvalidQuery)?,
|
||||
},
|
||||
@@ -13656,8 +13704,8 @@ fn validate_ticket_assignment_role_principal(
|
||||
let valid = match role {
|
||||
TicketAssignmentRole::Orchestrator => matches!(
|
||||
principal,
|
||||
TicketAssignmentPrincipal::WorkspaceAgent { agent_key }
|
||||
if agent_key == "workspace-orchestrator"
|
||||
TicketAssignmentPrincipal::Subject { subject_id }
|
||||
if !subject_id.trim().is_empty() && subject_id.len() <= 1024 && !subject_id.chars().any(char::is_control)
|
||||
),
|
||||
TicketAssignmentRole::Worker => {
|
||||
matches!(principal, TicketAssignmentPrincipal::Worker { .. })
|
||||
@@ -13760,6 +13808,9 @@ fn clear_current_ticket_role_assignment_with_source_in_tx(
|
||||
runtime_id,
|
||||
worker_id,
|
||||
} => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())),
|
||||
TicketAssignmentPrincipal::Subject { subject_id } => {
|
||||
(Some(subject_id.as_str()), None, None)
|
||||
}
|
||||
TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
|
||||
(Some(agent_key.as_str()), None, None)
|
||||
}
|
||||
@@ -15361,8 +15412,13 @@ fn migrate_ticket_cases_v90_to_v91(conn: &Connection) -> Result<()> {
|
||||
// closure reasons and immutable case history. It opens its own transaction.
|
||||
// Leave the Workspace marker at 90 until our composed constraints commit;
|
||||
// a failed second phase is resumable through the idempotent domain migrator.
|
||||
ticket::migrate_sqlite_ticket_schema(conn)
|
||||
.map_err(|error| Error::Store(format!("Ticket case migration failed: {error}")))?;
|
||||
let component = conn.unchecked_transaction()?;
|
||||
ticket::migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
&component,
|
||||
ticket::HistoricalTicketSchemaTarget::Cases10,
|
||||
)
|
||||
.map_err(|error| Error::Store(format!("Ticket case migration failed: {error}")))?;
|
||||
component.commit()?;
|
||||
let invalid: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM typed_tickets WHERE workflow_state NOT IN ('open', 'closed')",
|
||||
[],
|
||||
@@ -15408,8 +15464,11 @@ fn migrate_ticket_tracking_v91_to_v92(conn: &Connection) -> Result<()> {
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
|
||||
.map_err(|error| Error::Store(error.to_string()))?;
|
||||
ticket::migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
&tx,
|
||||
ticket::HistoricalTicketSchemaTarget::Tracking11,
|
||||
)
|
||||
.map_err(|error| Error::Store(error.to_string()))?;
|
||||
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (92, 'Ticket-owned assignment tracking')", [])?;
|
||||
verify_workspace_resource_constraints(&tx)?;
|
||||
tx.commit()?;
|
||||
@@ -15436,8 +15495,11 @@ fn migrate_ticket_posts_v93_to_v94(conn: &Connection) -> Result<()> {
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
|
||||
.map_err(|error| Error::Store(format!("Ticket posts migration failed: {error}")))?;
|
||||
ticket::migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
&tx,
|
||||
ticket::HistoricalTicketSchemaTarget::Posts12,
|
||||
)
|
||||
.map_err(|error| Error::Store(format!("Ticket posts migration failed: {error}")))?;
|
||||
assignment_delivery::install_post_inputs(&tx)?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(94,'Canonical Ticket posts')",
|
||||
@@ -15464,8 +15526,11 @@ fn migrate_notification_routing_v94_to_v95(conn: &Connection) -> Result<()> {
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
|
||||
.map_err(|e| Error::Store(format!("Ticket content facts migration failed: {e}")))?;
|
||||
ticket::migrate_historical_sqlite_ticket_schema_in_transaction(
|
||||
&tx,
|
||||
ticket::HistoricalTicketSchemaTarget::Content13,
|
||||
)
|
||||
.map_err(|e| Error::Store(format!("Ticket content facts migration failed: {e}")))?;
|
||||
notification_routing::install(&tx)?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(95,?1)",
|
||||
@@ -15737,6 +15802,23 @@ fn migrate_hosting_policy_v98_to_v99(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_subject_assignments_v106_to_v107(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 106 {
|
||||
return Err(Error::Store(
|
||||
"Subject assignment migration requires schema 106".into(),
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
subject_assignment_migration::install(&tx)?;
|
||||
notification_routing::install_subject_recipients(&tx)?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(107,?1)",
|
||||
["Subject Ticket responsibility and current-body notification routing"],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
create_workspace_schema_structure(conn)?;
|
||||
assignment_delivery::install(conn)?;
|
||||
@@ -15746,6 +15828,8 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
notification_routing::install_business_events(conn)?;
|
||||
notification_routing::upgrade_result_reports(conn)?;
|
||||
subject_deletion_store::install_notification_erasure(conn)?;
|
||||
subject_assignment_migration::install(conn)?;
|
||||
notification_routing::install_subject_recipients(conn)?;
|
||||
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
|
||||
Error::Store(format!(
|
||||
"fresh Workspace Ticket schema verification failed: {error}"
|
||||
@@ -16800,6 +16884,7 @@ fn apply_migrations_with_secret_source(
|
||||
|
||||
verify_schema_history(conn, LATEST_SCHEMA_VERSION)?;
|
||||
crate::plugin_catalog::verify(conn)?;
|
||||
subject_assignment_migration::verify(conn)?;
|
||||
verify_workspace_runtime_binding_schema(conn)?;
|
||||
verify_workspace_runtime_verification_schema(conn)?;
|
||||
verify_workspace_deletion_schema(conn)?;
|
||||
@@ -16836,6 +16921,7 @@ mod test_support;
|
||||
mod tests {
|
||||
include!("assignment_tracking_tests.rs");
|
||||
include!("assignment_delivery_tests.rs");
|
||||
include!("subject_assignment_migration_tests.rs");
|
||||
include!("notification_routing_tests.rs");
|
||||
include!("notification_diagnostics_tests.rs");
|
||||
include!("job_notification_tests.rs");
|
||||
@@ -19494,6 +19580,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
WorkspaceSchemaMigrationStep {version:104, name:"Converge Passkey results and Job input retention".into()},
|
||||
WorkspaceSchemaMigrationStep {version:105, name:"Durable Subject deletion snapshots and Job admission fences".into()},
|
||||
WorkspaceSchemaMigrationStep {version:106, name:crate::plugin_catalog::MIGRATION_NAME.into()},
|
||||
WorkspaceSchemaMigrationStep {version:107, name:"Subject Ticket responsibility and current-body notification routing".into()},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -19610,6 +19697,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(104, "Converge Passkey results and Job input retention".to_string()),
|
||||
(105, "Durable Subject deletion snapshots and Job admission fences".to_string()),
|
||||
(106, crate::plugin_catalog::MIGRATION_NAME.to_string()),
|
||||
(107, "Subject Ticket responsibility and current-body notification routing".to_string()),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -22897,7 +22985,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(103, "Passkey metadata and atomic addition results".into()),
|
||||
(104, "Converge Passkey results and Job input retention".into()),
|
||||
(105, "Durable Subject deletion snapshots and Job admission fences".into()),
|
||||
(106, crate::plugin_catalog::MIGRATION_NAME.into())]);
|
||||
(106, crate::plugin_catalog::MIGRATION_NAME.into()),
|
||||
(107, "Subject Ticket responsibility and current-body notification routing".into())]);
|
||||
for (table, rows) in unchanged.iter().zip(before.iter()) {
|
||||
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
|
||||
}
|
||||
@@ -23628,8 +23717,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
let first = manual_worker_assignment("first", "first-assignment");
|
||||
let orchestrator = TicketRoleAssignmentRecord {
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
principal: TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "fixture-subject".into(),
|
||||
},
|
||||
..first.clone()
|
||||
};
|
||||
@@ -24713,8 +24802,8 @@ INSERT INTO worker_registry (
|
||||
ticket_id: ticket.id.clone(),
|
||||
assignment_id: "orchestrator-1".to_string(),
|
||||
role: TicketAssignmentRole::Orchestrator,
|
||||
principal: TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".to_string(),
|
||||
principal: TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "fixture-subject".to_string(),
|
||||
},
|
||||
assigned_by: "user".to_string(),
|
||||
assigned_at: "2026-09-01T00:00:00Z".to_string(),
|
||||
@@ -27083,32 +27172,54 @@ INSERT INTO worker_registry (
|
||||
use crate::backend_job::{JobInputDisposition, JobInputRetirementReason};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("migration.sqlite");
|
||||
let store = SqliteWorkspaceStore::open_api_fixture(&path).unwrap();
|
||||
// Build the actual retained boundary, never latest DDL relabelled as 102.
|
||||
prepare_retained_schema(&path, 102);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
// Obtain a canonical pending Job through the current API, then copy only
|
||||
// columns that existed at the retained boundary into the historical file.
|
||||
let store = SqliteWorkspaceStore::in_memory_fixture().unwrap();
|
||||
seed_resource_job_workspace(&store).await;
|
||||
let original = resource_job_request("upgrade", "subject", "candidate");
|
||||
let before = store
|
||||
.reserve_backend_job("resource-workspace", &original, RESOURCE_JOB_NOW)
|
||||
.unwrap();
|
||||
store.with_conn(|conn| {
|
||||
// This fixture deliberately reconstructs schema 102 from fresh.
|
||||
conn.execute_batch("DROP TABLE plugin_references; DROP TABLE plugin_artifacts; DROP TABLE plugin_installations; DROP TABLE plugin_uploads; DROP TABLE plugin_operations;")?;
|
||||
// Remove the newer Subject fence composition, not just its receipt.
|
||||
let triggers=conn.prepare("SELECT name,tbl_name FROM sqlite_schema WHERE type='trigger' AND tbl_name LIKE 'notification_%' AND name NOT LIKE 'subject_deleted_%' AND sql LIKE '%subject_deletion_jobs%'")?
|
||||
.query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
for (name,table) in triggers {conn.execute_batch(&format!("DROP TRIGGER {name}; CREATE TRIGGER {name} BEFORE DELETE ON {table} BEGIN SELECT RAISE(ABORT,'Notification evidence is immutable'); END;"))?;}
|
||||
for name in ["subject_deleted_job_insert","subject_deleted_job_result","subject_deleted_attempt_insert","subject_deleted_attempt_result","subject_deleted_worker_reservation","subject_deleted_notification_source","subject_deleted_notification_payload","subject_deleted_notification_send","subject_deletion_worker_prior_insert","subject_deletion_worker_identity_immutable"] {conn.execute_batch(&format!("DROP TRIGGER {name}"))?;}
|
||||
conn.execute_batch("DROP TABLE subject_deletion_removals; DROP TABLE subject_deletion_allocations; DROP TABLE subject_deletion_jobs; DROP TABLE subject_deletion_workers; DROP TABLE subject_deletions;")?;
|
||||
conn.execute_batch("DROP TRIGGER backend_job_input_retirement; DROP TRIGGER backend_job_identity_immutable;
|
||||
ALTER TABLE backend_jobs DROP COLUMN input_disposition;
|
||||
DROP TRIGGER passkey_metadata_insert;
|
||||
DROP TABLE passkey_addition_receipts;
|
||||
DROP TABLE passkey_metadata;
|
||||
UPDATE __yoi_schema_migrations SET version=102 WHERE version=106;
|
||||
CREATE TRIGGER backend_job_input_retirement BEFORE UPDATE ON backend_job_attempts BEGIN SELECT RAISE(ABORT,'collision'); END;")?; Ok(())
|
||||
}).unwrap();
|
||||
for table in [
|
||||
"accounts",
|
||||
"workspaces",
|
||||
"backend_jobs",
|
||||
"backend_job_attempts",
|
||||
] {
|
||||
let columns = conn
|
||||
.prepare(&format!("PRAGMA table_info({table})"))
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
let names = columns.join(",");
|
||||
let rows = store
|
||||
.with_conn(|source| {
|
||||
Ok(fixture_query_rows(
|
||||
source,
|
||||
&format!("SELECT {names} FROM {table}"),
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
let placeholders = vec!["?"; columns.len()].join(",");
|
||||
for row in rows {
|
||||
conn.execute(
|
||||
&format!("INSERT INTO {table} ({names}) VALUES ({placeholders})"),
|
||||
rusqlite::params_from_iter(row),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
drop(store);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 102);
|
||||
verify_schema_history(&conn, 102).unwrap();
|
||||
assert!(!column_exists(&conn, "backend_jobs", "input_disposition").unwrap());
|
||||
conn.execute_batch("CREATE TRIGGER backend_job_input_retirement BEFORE UPDATE ON backend_job_attempts BEGIN SELECT RAISE(ABORT,'collision'); END;").unwrap();
|
||||
assert!(migrate_job_input_retention_v102_to_v103(&conn).is_err());
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 102);
|
||||
assert!(!column_exists(&conn, "backend_jobs", "input_disposition").unwrap());
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Workspace composition of Ticket-owned Subject responsibility (component 14).
|
||||
//!
|
||||
//! Ticket alone owns principal tables/CHECKs, domain assignment guards and the
|
||||
//! component migration ledger. This adapter composes that migration in the host
|
||||
//! transaction, then installs only Workspace delivery/start guards. It never edits
|
||||
//! sqlite_schema, rebuilds Ticket tables, or reinstalls Ticket-owned triggers.
|
||||
//!
|
||||
//! There is no trustworthy conversion of `workspace-orchestrator` to a Subject.
|
||||
//! A current `subjektiv:<id>` body or child execution grant cannot attest ownership
|
||||
//! of a historical stable-agent assignment. Original principals/current pointers,
|
||||
//! tracking, inputs, results, receipt fingerprints and source JSON stay evidence,
|
||||
//! not execution authority. Newly authorized Subject assignment is explicit.
|
||||
//!
|
||||
//! No Runtime/FeatureStorage calls, routing, grants, restore or receipt replay
|
||||
//! occur here. The caller owns the surrounding transaction and Workspace version;
|
||||
//! on any error it must roll back the whole transaction, including Ticket work.
|
||||
use super::*;
|
||||
|
||||
pub(super) const LEGACY_DIAGNOSTIC: &str = "Legacy Orchestrator identity retained without a trustworthy historical Subject mapping; nonoperational, explicit authorized Subject assignment required. Saved history and receipts do not grant execution authority.";
|
||||
|
||||
const HOST_GUARDS: [&str; 2] = [
|
||||
"ticket_legacy_orchestrator_delivery_no_insert",
|
||||
"ticket_legacy_orchestrator_start_no_insert",
|
||||
];
|
||||
|
||||
/// Parent may already have composed component 14 in this same transaction; the
|
||||
/// Ticket entrypoint then verifies its current schema without applying it again.
|
||||
/// Host guard installation is a single migration, not an operational repair API.
|
||||
pub(super) fn install(conn: &Connection) -> Result<()> {
|
||||
if conn.is_autocommit() {
|
||||
return Err(Error::Store(
|
||||
"Subject assignment migration requires a caller-owned transaction".into(),
|
||||
));
|
||||
}
|
||||
ticket::migrate_sqlite_ticket_schema_in_transaction(conn).map_err(|error| {
|
||||
Error::Store(format!(
|
||||
"Ticket Subject component composition failed: {error}"
|
||||
))
|
||||
})?;
|
||||
conn.execute_batch(HOST_GUARDS_SQL)?;
|
||||
// Component 14 currently supplies this same diagnostic. Ensure it exists for
|
||||
// retained legacy rows without duplicating its line or touching their CAS,
|
||||
// tracking values, historical source JSON, events, inputs or receipts.
|
||||
conn.execute(
|
||||
"UPDATE ticket_worker_assignments
|
||||
SET tracking_migration_diagnostic = CASE
|
||||
WHEN tracking_migration_diagnostic IS NULL OR tracking_migration_diagnostic=''
|
||||
THEN ?1 ELSE tracking_migration_diagnostic || char(10) || ?1 END
|
||||
WHERE role='orchestrator' AND principal_kind!='subject'
|
||||
AND instr(char(10) || COALESCE(tracking_migration_diagnostic,'') || char(10),
|
||||
char(10) || ?1 || char(10))=0",
|
||||
[LEGACY_DIAGNOSTIC],
|
||||
)?;
|
||||
verify(conn)
|
||||
}
|
||||
|
||||
/// Read-only verification delegates the owning table/domain-guard contract to
|
||||
/// Ticket. Workspace verifies only its two complete trigger definitions/targets,
|
||||
/// followed by host database integrity and FK validation. No repair is performed.
|
||||
pub(super) fn verify(conn: &Connection) -> Result<()> {
|
||||
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
|
||||
Error::Store(format!(
|
||||
"Ticket Subject schema verification failed: {error}"
|
||||
))
|
||||
})?;
|
||||
let canonical = Connection::open_in_memory()?;
|
||||
canonical.execute_batch("CREATE TABLE ticket_assignment_deliveries(workspace_id TEXT,assignment_id TEXT);
|
||||
CREATE TABLE ticket_assignment_operation_fences(workspace_id TEXT,orchestrator_assignment_id TEXT);")?;
|
||||
canonical.execute_batch(HOST_GUARDS_SQL)?;
|
||||
for name in HOST_GUARDS {
|
||||
let expected = trigger_definition(&canonical, name)?;
|
||||
let actual = trigger_definition(conn, name)?;
|
||||
if actual != expected {
|
||||
return Err(Error::Store(format!(
|
||||
"Workspace Subject guard {name} differs from canonical SQL or target"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let integrity: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
|
||||
if integrity != "ok" {
|
||||
return Err(Error::Store(format!(
|
||||
"Workspace Subject migration integrity check failed: {integrity}"
|
||||
)));
|
||||
}
|
||||
let violations: i64 =
|
||||
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
if violations != 0 {
|
||||
return Err(Error::Store(
|
||||
"Workspace Subject migration foreign-key violation".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trigger_definition(conn: &Connection, name: &str) -> Result<(String, String)> {
|
||||
conn.query_row(
|
||||
"SELECT tbl_name,sql FROM main.sqlite_schema WHERE type='trigger' AND name=?1",
|
||||
[name],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(|| Error::Store(format!("Missing Workspace Subject guard {name}")))
|
||||
}
|
||||
|
||||
const HOST_GUARDS_SQL: &str = r#"
|
||||
CREATE TRIGGER ticket_legacy_orchestrator_delivery_no_insert
|
||||
BEFORE INSERT ON ticket_assignment_deliveries
|
||||
WHEN EXISTS(SELECT 1 FROM ticket_worker_assignments a
|
||||
WHERE a.workspace_id=NEW.workspace_id AND a.assignment_id=NEW.assignment_id
|
||||
AND a.role='orchestrator' AND a.principal_kind!='subject')
|
||||
BEGIN SELECT RAISE(ABORT,'Legacy Orchestrator responsibility is nonoperational'); END;
|
||||
CREATE TRIGGER ticket_legacy_orchestrator_start_no_insert
|
||||
BEFORE INSERT ON ticket_assignment_operation_fences
|
||||
WHEN NOT EXISTS(SELECT 1 FROM ticket_worker_assignments a
|
||||
WHERE a.workspace_id=NEW.workspace_id AND a.assignment_id=NEW.orchestrator_assignment_id
|
||||
AND a.role='orchestrator' AND a.principal_kind='subject')
|
||||
BEGIN SELECT RAISE(ABORT,'Ticket startup fence requires Subject responsibility'); END;
|
||||
"#;
|
||||
@@ -0,0 +1,842 @@
|
||||
// Included in store::tests; requires the production module as a store child.
|
||||
// Component14 owns principal migration and its ledger; these tests exercise the
|
||||
// real owner API through host composition, not a copied table/guard installer.
|
||||
// Run: cargo test -p yoi-workspace-server --lib subject_assignment_migration
|
||||
mod subject_assignment_migration_tests {
|
||||
use super::*;
|
||||
use crate::store::subject_assignment_migration::{LEGACY_DIAGNOSTIC, install, verify};
|
||||
|
||||
fn fixture(baseline_only: bool) -> (tempfile::TempDir, std::path::PathBuf, Connection) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("subject-assignment.sqlite");
|
||||
// Real retained migrations, not latest DDL with its version relabelled.
|
||||
prepare_retained_schema(&path, 106);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT max(version) FROM ticket_schema_migrations",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
13,
|
||||
"retained106 must be a real pre-Subject component13 fixture; historical host migrations must not consume component14 early"
|
||||
);
|
||||
if baseline_only {
|
||||
conn.execute_batch("DELETE FROM __yoi_schema_migrations;
|
||||
INSERT INTO __yoi_schema_migrations(version,name) VALUES(106,'workspace schema baseline');").unwrap();
|
||||
}
|
||||
seed_workspace(&conn);
|
||||
for status in ["waiting", "inprogress", "done", "unknown", "released"] {
|
||||
seed_ticket(&conn, status);
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at)
|
||||
VALUES('w',?1,?1,'orchestrator','workspace_agent','workspace-orchestrator','original actor','original time')",[status]).unwrap();
|
||||
if status == "unknown" {
|
||||
conn.execute("UPDATE ticket_worker_assignments SET tracking_json=NULL,tracking_migration_diagnostic='Existing ambiguity' WHERE assignment_id=?1",[status]).unwrap();
|
||||
} else if status != "released" {
|
||||
conn.execute(
|
||||
"UPDATE ticket_worker_assignments SET tracking_json=?2 WHERE assignment_id=?1",
|
||||
params![
|
||||
status,
|
||||
format!(
|
||||
"{{\"status\":\"{status}\", \"result\":\"original result bytes\"}}"
|
||||
)
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,principal_id,updated_at)
|
||||
VALUES('w',?1,'orchestrator',?1,'workspace_agent','workspace-orchestrator','original time')",[status]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at,operation_id)
|
||||
VALUES('w',?1,'orchestrator',?1,'assigned',?1,'original actor','original time',?1)", [status]).unwrap();
|
||||
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint,binding_recovery_json)
|
||||
VALUES('w',?1,'assign',?1,'orchestrator','workspace_agent','workspace-orchestrator',?1,'original time','unchanged-fingerprint','{ \"original\" : true }')",[status]).unwrap();
|
||||
}
|
||||
conn.execute_batch("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at,operation_id)
|
||||
VALUES('w','released','orchestrator','release-event','unassigned','released','original releaser','release time','clear-op');
|
||||
DELETE FROM ticket_current_worker_assignments WHERE assignment_id='released';
|
||||
INSERT INTO typed_ticket_assignment_tracking_operations
|
||||
VALUES('w','saved-answer','waiting','{ \"old_input\" : true }','{ \"old_source\" : true }','{ \"old_result\" : true }');
|
||||
INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,runtime_id,worker_id,created_at,request_fingerprint,claim_state,binding_recovery_json)
|
||||
VALUES('w','pending-start','assign','waiting','worker','worker','r','worker','claim time','pending fingerprint','pending','{ \"reservation\" : true }');
|
||||
INSERT INTO ticket_assignment_operation_fences
|
||||
VALUES('w','pending-start','waiting','r','worker','workspace-orchestrator','old-binding','{ \"saved_fence\" : true }');
|
||||
INSERT INTO ticket_assignment_deliveries VALUES('w','waiting','pending-input','pending-delivery','{ \"payload\" : true }','{ \"source\" : true }');
|
||||
INSERT INTO ticket_assignment_deliveries VALUES('w','waiting','accepted-input','accepted-delivery','{ \"payload\" : false }','{ \"source\" : false }');
|
||||
INSERT INTO ticket_assignment_delivery_requests VALUES('w','accepted-delivery','original rendered request');
|
||||
INSERT INTO ticket_assignment_delivery_acknowledgements VALUES('w','accepted-delivery','{ \"accepted\" : true }');
|
||||
INSERT INTO ticket_assignment_content_inputs(workspace_id,ticket_id,assignment_id,after_ticket_event_index,actor,kind)
|
||||
VALUES('w','waiting','waiting',-1,'original editor','content_edit');
|
||||
INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at)
|
||||
VALUES('w','waiting','implementation','worker','worker','r','worker','original assigner','original time');
|
||||
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,runtime_id,worker_id,updated_at)
|
||||
VALUES('w','waiting','worker','implementation','worker','r','worker','original time');
|
||||
INSERT INTO ticket_implementation_delegations VALUES('w','implementation','waiting');
|
||||
INSERT INTO ticket_implementation_results VALUES('w','waiting','result-op','saved-result','r','worker','{ \"body\" : \"original result\", \"kind\" : \"result\" }','result time');
|
||||
INSERT INTO notification_decisions(source_sequence,workspace_id,delivery_id,disposition,reason,purpose)
|
||||
SELECT sequence,'w','saved-notification','deliver','original reason','attention' FROM notification_sources WHERE input_event_id='implementation-result:saved-result';
|
||||
INSERT INTO notification_requests VALUES('w','saved-notification','original common request','{ \"target\" : \"old body\" }');
|
||||
INSERT INTO notification_acknowledgements VALUES('w','saved-notification','{ \"accepted\" : \"old body\" }');
|
||||
INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w','saved-notification','old-unknown-outcome','original time');
|
||||
INSERT INTO notification_assignment_targets VALUES('w','accepted-delivery','r','worker','workspace-orchestrator','old-binding');").unwrap();
|
||||
// A live body candidate and the old singleton exist simultaneously. A
|
||||
// migration must not infer stable-agent responsibility from either one.
|
||||
conn.execute_batch("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at)
|
||||
VALUES('w','workspace-orchestrator','r','worker',1,'1','2');
|
||||
INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at)
|
||||
VALUES('w','r','body','Body','normal','1','2');
|
||||
INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at)
|
||||
VALUES('w','subjektiv:tempting-subject','r','body',1,'1','2');").unwrap();
|
||||
(directory, path, conn)
|
||||
}
|
||||
|
||||
fn seed_workspace(conn: &Connection) {
|
||||
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at)
|
||||
VALUES('owner','user','owner','Owner','1','2');
|
||||
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at)
|
||||
VALUES('w','owner','Workspace','active','1','2');
|
||||
INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at)
|
||||
VALUES('w','r','worker','Worker','normal','1','2');").unwrap();
|
||||
}
|
||||
|
||||
fn seed_ticket(conn: &Connection, id: &str) {
|
||||
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,workflow_state,workflow_state_explicit)
|
||||
VALUES('w',?1,?1,'Title','open','task','P2','open',1)",[id]).unwrap();
|
||||
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body)
|
||||
VALUES('w',?1,-1,?2,'comment','Original body')",params![id,format!("{id}:-1")]).unwrap();
|
||||
}
|
||||
|
||||
fn migrate(conn: &Connection) {
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
install(&tx).unwrap();
|
||||
tx.commit().unwrap();
|
||||
}
|
||||
|
||||
// Rowids matter: history pagination uses append ordering. Include all old
|
||||
// bytes except the one explicitly enriched diagnostic column.
|
||||
fn evidence(conn: &Connection) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
|
||||
let mut rows = Vec::new();
|
||||
rows.push(fixture_query_rows(
|
||||
conn,
|
||||
"SELECT rowid,* FROM ticket_schema_migrations WHERE version<=13 ORDER BY version",
|
||||
));
|
||||
rows.push(fixture_query_rows(conn,"SELECT rowid,workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,runtime_id,worker_id,assigned_by,assigned_at,tracking_json,last_tracking_event_id,tracking_updated_at FROM ticket_worker_assignments ORDER BY rowid"));
|
||||
for table in [
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_worker_assignment_events",
|
||||
"ticket_assignment_operations",
|
||||
"typed_ticket_assignment_tracking_history",
|
||||
"typed_ticket_assignment_tracking_operations",
|
||||
"ticket_assignment_delivery_origins",
|
||||
"ticket_assignment_deliveries",
|
||||
"ticket_assignment_delivery_requests",
|
||||
"ticket_assignment_delivery_acknowledgements",
|
||||
"ticket_assignment_operation_fences",
|
||||
"ticket_assignment_content_inputs",
|
||||
"ticket_assignment_work_releases",
|
||||
"ticket_implementation_delegations",
|
||||
"ticket_implementation_results",
|
||||
"notification_sources",
|
||||
"notification_decisions",
|
||||
"notification_requests",
|
||||
"notification_acknowledgements",
|
||||
"notification_failures",
|
||||
"notification_assignment_targets",
|
||||
"worker_registry",
|
||||
"worker_singleton_owners",
|
||||
"worker_create_reservations",
|
||||
"__yoi_schema_migrations",
|
||||
// Ticket's owning ledger legitimately appends component14; assert
|
||||
// its preserved prefix and new receipt separately below.
|
||||
] {
|
||||
rows.push(fixture_query_rows(
|
||||
conn,
|
||||
&format!("SELECT rowid,* FROM {table} ORDER BY rowid"),
|
||||
));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn component_history(conn: &Connection) -> Vec<Vec<rusqlite::types::Value>> {
|
||||
fixture_query_rows(
|
||||
conn,
|
||||
"SELECT rowid,* FROM ticket_schema_migrations ORDER BY version",
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_component13(conn: &Connection) {
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT max(version) FROM ticket_schema_migrations",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
13,
|
||||
"failed owning/host migration must not retain a component14 receipt"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_component14_append(conn: &Connection, before: &[Vec<rusqlite::types::Value>]) {
|
||||
let history = component_history(conn);
|
||||
assert_eq!(history.len(), before.len() + 1);
|
||||
assert_eq!(
|
||||
&history[..before.len()],
|
||||
before,
|
||||
"owning migration preserves all old component receipt bytes/rowids"
|
||||
);
|
||||
let latest: (i64,String,String) = conn.query_row("SELECT version,name,applied_at FROM ticket_schema_migrations ORDER BY version DESC LIMIT 1", [],
|
||||
|row| Ok((row.get(0)?,row.get(1)?,row.get(2)?))).unwrap();
|
||||
assert_eq!(latest.0, 14);
|
||||
assert_eq!(latest.1, "Subject Ticket responsibility");
|
||||
assert!(!latest.2.is_empty());
|
||||
}
|
||||
|
||||
fn schema(conn: &Connection) -> Vec<Vec<rusqlite::types::Value>> {
|
||||
fixture_query_rows(
|
||||
conn,
|
||||
"SELECT type,name,tbl_name,rootpage,sql FROM sqlite_schema ORDER BY type,name",
|
||||
)
|
||||
}
|
||||
|
||||
fn preserved_schema_objects(conn: &Connection) -> Vec<Vec<rusqlite::types::Value>> {
|
||||
fixture_query_rows(conn, "SELECT type,name,tbl_name,rootpage,sql FROM sqlite_schema
|
||||
WHERE name NOT IN ('ticket_assignment_operations','ticket_current_worker_assignments','ticket_worker_assignments',
|
||||
'typed_ticket_assignment_orchestrator_principal_insert','typed_ticket_assignment_role_principal_insert')
|
||||
AND name NOT LIKE 'ticket_subject_%' AND name NOT LIKE 'ticket_legacy_orchestrator_%'
|
||||
ORDER BY type,name")
|
||||
}
|
||||
|
||||
fn principal_table_contracts(conn: &Connection) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
|
||||
let mut contracts = Vec::new();
|
||||
for table in [
|
||||
"ticket_assignment_operations",
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_worker_assignments",
|
||||
] {
|
||||
contracts.push(fixture_query_rows(
|
||||
conn,
|
||||
&format!("SELECT * FROM pragma_table_info('{table}') ORDER BY cid"),
|
||||
));
|
||||
contracts.push(fixture_query_rows(
|
||||
conn,
|
||||
&format!("SELECT * FROM pragma_foreign_key_list('{table}') ORDER BY id,seq"),
|
||||
));
|
||||
contracts.push(fixture_query_rows(
|
||||
conn,
|
||||
&format!("SELECT * FROM pragma_index_list('{table}') ORDER BY name"),
|
||||
));
|
||||
}
|
||||
contracts
|
||||
}
|
||||
|
||||
fn cookie(conn: &Connection) -> i64 {
|
||||
conn.pragma_query_value(None, "schema_version", |row| row.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn assert_closed_schema_writes(conn: &Connection) {
|
||||
assert!(
|
||||
!conn
|
||||
.pragma_query_value(None, "writable_schema", |row| row.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!conn
|
||||
.pragma_query_value(None, "ignore_check_constraints", |row| row
|
||||
.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_assignment_migration_preserves_legacy_bytes_rowids_receipts_and_pending_inputs() {
|
||||
for baseline_only in [false, true] {
|
||||
let (_directory, path, conn) = fixture(baseline_only);
|
||||
let before = evidence(&conn);
|
||||
let component_before = component_history(&conn);
|
||||
let preserved_schema = preserved_schema_objects(&conn);
|
||||
let contracts = principal_table_contracts(&conn);
|
||||
let table_sql_query = "SELECT name,sql FROM sqlite_schema WHERE type='table' AND name IN ('ticket_assignment_operations','ticket_current_worker_assignments','ticket_worker_assignments') ORDER BY name";
|
||||
let mut table_sql = fixture_query_rows(&conn, table_sql_query);
|
||||
for row in &mut table_sql {
|
||||
if let rusqlite::types::Value::Text(sql) = &mut row[1] {
|
||||
*sql = sql.replace(
|
||||
"CHECK(principal_kind IN ('user', 'worker', 'workspace_agent'))",
|
||||
"CHECK(principal_kind IN ('user', 'worker', 'workspace_agent', 'subject'))",
|
||||
);
|
||||
} else {
|
||||
panic!("table SQL must be text");
|
||||
}
|
||||
}
|
||||
let rootpages = fixture_query_rows(
|
||||
&conn,
|
||||
"SELECT name,rootpage FROM sqlite_schema WHERE type='table' ORDER BY name",
|
||||
);
|
||||
let content_capture = fixture_query_rows(
|
||||
&conn,
|
||||
"SELECT name,sql FROM sqlite_schema WHERE name IN ('ticket_assignment_title_input','ticket_assignment_initial_post_input') ORDER BY name",
|
||||
);
|
||||
migrate(&conn);
|
||||
assert_component14_append(&conn, &component_before);
|
||||
verify(&conn).unwrap();
|
||||
assert_eq!(preserved_schema_objects(&conn), preserved_schema);
|
||||
assert_eq!(principal_table_contracts(&conn), contracts);
|
||||
assert_eq!(fixture_query_rows(&conn, table_sql_query), table_sql);
|
||||
assert_eq!(evidence(&conn), before);
|
||||
assert_eq!(
|
||||
fixture_query_rows(
|
||||
&conn,
|
||||
"SELECT name,rootpage FROM sqlite_schema WHERE type='table' ORDER BY name"
|
||||
),
|
||||
rootpages
|
||||
);
|
||||
assert_eq!(
|
||||
fixture_query_rows(
|
||||
&conn,
|
||||
"SELECT name,sql FROM sqlite_schema WHERE name IN ('ticket_assignment_title_input','ticket_assignment_initial_post_input') ORDER BY name"
|
||||
),
|
||||
content_capture
|
||||
);
|
||||
let diagnostics: Vec<(String,String)> = conn.prepare("SELECT assignment_id,tracking_migration_diagnostic FROM ticket_worker_assignments WHERE role='orchestrator' ORDER BY assignment_id").unwrap()
|
||||
.query_map([],|row|Ok((row.get(0)?,row.get(1)?))).unwrap().collect::<rusqlite::Result<_>>().unwrap();
|
||||
assert_eq!(diagnostics.len(), 5);
|
||||
for (id, diagnostic) in diagnostics {
|
||||
if id == "unknown" {
|
||||
assert_eq!(
|
||||
diagnostic,
|
||||
format!("Existing ambiguity\n{LEGACY_DIAGNOSTIC}")
|
||||
);
|
||||
} else {
|
||||
assert_eq!(diagnostic, LEGACY_DIAGNOSTIC);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM ticket_worker_assignments WHERE principal_kind='subject'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_closed_schema_writes(&conn);
|
||||
drop(conn);
|
||||
let reopened = Connection::open(path).unwrap();
|
||||
configure_sqlite(&reopened).unwrap();
|
||||
assert_eq!(evidence(&reopened), before);
|
||||
assert_component14_append(&reopened, &component_before);
|
||||
assert_eq!(preserved_schema_objects(&reopened), preserved_schema);
|
||||
assert_eq!(principal_table_contracts(&reopened), contracts);
|
||||
verify(&reopened).unwrap();
|
||||
assert_closed_schema_writes(&reopened);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_subject_contract(conn: &Connection) {
|
||||
seed_ticket(conn, "new-ticket");
|
||||
conn.execute_batch("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint)
|
||||
VALUES('w','new-op','assign','new-ticket','orchestrator','subject','subject-a','new-assignment','3','subject fingerprint');
|
||||
INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at)
|
||||
VALUES('w','new-ticket','new-assignment','orchestrator','subject','subject-a','authorized actor','3');
|
||||
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,principal_id,updated_at)
|
||||
VALUES('w','new-ticket','orchestrator','new-assignment','subject','subject-a','3');
|
||||
INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at)
|
||||
VALUES('w','new-ticket','orchestrator','new-event','assigned','new-assignment','authorized actor','3');").unwrap();
|
||||
assert_eq!(conn.query_row("SELECT principal_id FROM ticket_current_worker_assignments WHERE assignment_id='new-assignment'",[],|row|row.get::<_,String>(0)).unwrap(),"subject-a");
|
||||
assert_eq!(conn.query_row("SELECT json_extract(tracking_json,'$.status') FROM ticket_worker_assignments WHERE assignment_id='new-assignment'",[],|row|row.get::<_,String>(0)).unwrap(),"waiting");
|
||||
for (role, kind, id, runtime, worker) in [
|
||||
(
|
||||
"orchestrator",
|
||||
"workspace_agent",
|
||||
Some("workspace-orchestrator"),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
("orchestrator", "subject", Some(" "), None, None),
|
||||
("orchestrator", "subject", None, None, None),
|
||||
(
|
||||
"orchestrator",
|
||||
"subject",
|
||||
Some("subject-a"),
|
||||
Some("r"),
|
||||
Some("worker"),
|
||||
),
|
||||
("orchestrator", "unsupported", Some("subject-a"), None, None),
|
||||
("worker", "subject", Some("subject-a"), None, None),
|
||||
("owner", "subject", Some("subject-a"), None, None),
|
||||
("contributor", "subject", Some("subject-a"), None, None),
|
||||
] {
|
||||
assert!(conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,runtime_id,worker_id,assigned_by,assigned_at)
|
||||
VALUES('w','new-ticket','bad',?1,?2,?3,?4,?5,'actor','4')",params![role,kind,id,runtime,worker]).is_err(),"{role}/{kind}/{id:?}");
|
||||
}
|
||||
assert!(conn.execute("UPDATE ticket_worker_assignments SET principal_id='other-subject' WHERE assignment_id='new-assignment'",[]).is_err());
|
||||
assert!(conn.execute("UPDATE ticket_current_worker_assignments SET principal_id='other-subject' WHERE assignment_id='new-assignment'",[]).is_err());
|
||||
assert!(conn.execute("UPDATE ticket_assignment_operations SET request_fingerprint='changed' WHERE operation_id='new-op'",[]).is_err());
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_assignment_migration_baseline_and_retained_match_fresh_constraints() {
|
||||
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
for baseline_only in [false, true] {
|
||||
let (_directory, _path, conn) = fixture(baseline_only);
|
||||
migrate(&conn);
|
||||
fresh.with_conn(|new| {
|
||||
assert!(new.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='ticket_subject_current_insert')",[],|row|row.get::<_,bool>(0))?, "fresh composition must install the Subject assignment contract");
|
||||
for table in ["ticket_assignment_operations","ticket_worker_assignments","ticket_current_worker_assignments"] {
|
||||
// Fresh106 installed Ticket core before workspaces existed;
|
||||
// retained83 has two additional direct Workspace parent FKs.
|
||||
// Verify both complete known targets, preserve their original
|
||||
// FKs, and compare all other contract signatures/behavior.
|
||||
verify(&conn)?;
|
||||
verify(new)?;
|
||||
let workspace_parents = |db: &Connection| -> Result<i64> {
|
||||
db.query_row(&format!("SELECT count(*) FROM pragma_foreign_key_list('{table}') WHERE \"table\"='workspaces'"), [], |row| row.get(0)).map_err(Error::from)
|
||||
};
|
||||
assert_eq!(workspace_parents(new)?, 0, "fresh standalone-first preimage: {table}");
|
||||
assert_eq!(workspace_parents(&conn)?, if table=="ticket_current_worker_assignments" {0} else {1}, "retained Workspace preimage: {table}");
|
||||
let contract = |db: &Connection| state_contract_schema_signature(db,table).into_iter()
|
||||
.filter(|row| !row.first().is_some_and(|name| name=="workspaces"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(contract(&conn),contract(new),"{table}");
|
||||
for db in [&conn,new] {
|
||||
let ddl:String=db.query_row("SELECT sql FROM sqlite_schema WHERE type='table' AND name=?1",[table],|row|row.get(0))?;
|
||||
assert!(ddl.contains("CHECK(principal_kind IN ('user', 'worker', 'workspace_agent', 'subject'))"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert_subject_contract(&conn);
|
||||
}
|
||||
fresh
|
||||
.with_conn(|conn| {
|
||||
seed_workspace(conn);
|
||||
assert_subject_contract(conn);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_assignment_migration_legacy_is_readable_but_never_new_authority() {
|
||||
let (_directory, _path, conn) = fixture(false);
|
||||
migrate(&conn);
|
||||
for sql in [
|
||||
"UPDATE ticket_worker_assignments SET tracking_json='{\"status\":\"inprogress\"}' WHERE assignment_id='waiting'",
|
||||
"UPDATE ticket_worker_assignments SET last_tracking_event_id='new' WHERE assignment_id='waiting'",
|
||||
"INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,principal_id,updated_at) VALUES('w','released','orchestrator','released','workspace_agent','workspace-orchestrator','new')",
|
||||
"INSERT INTO typed_ticket_assignment_tracking_history VALUES('w','waiting','waiting','forged','forged','{\"status\":\"waiting\"}','{}','new')",
|
||||
"INSERT INTO ticket_assignment_deliveries VALUES('w','waiting','fresh-input','fresh-delivery','{}','{}')",
|
||||
"INSERT INTO ticket_assignment_operation_fences VALUES('w','waiting','waiting','r','worker','workspace-orchestrator','old-binding','{}')",
|
||||
"UPDATE ticket_assignment_operations SET request_fingerprint='changed' WHERE operation_id='waiting'",
|
||||
"UPDATE typed_ticket_assignment_tracking_operations SET result_json='{}' WHERE operation_key='saved-answer'",
|
||||
] {
|
||||
assert!(conn.execute(sql, []).is_err(), "{sql}");
|
||||
}
|
||||
// Explicit clear may preserve a legacy principal in its new receipt; it
|
||||
// ends responsibility and does not resurrect an assignment or Worker.
|
||||
conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at)
|
||||
VALUES('w','explicit-clear','unassign','waiting','orchestrator','workspace_agent','workspace-orchestrator','waiting','new')",[]).unwrap();
|
||||
assert_subject_contract(&conn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_assignment_migration_failure_rolls_back_schema_cookie_rows_and_allows_retry() {
|
||||
let (_directory, path, conn) = fixture(false);
|
||||
conn.execute_batch("CREATE TRIGGER reject_subject_diagnostic BEFORE UPDATE OF tracking_migration_diagnostic ON ticket_worker_assignments BEGIN SELECT RAISE(ABORT,'injected Subject migration failure'); END;").unwrap();
|
||||
let before_rows = evidence(&conn);
|
||||
let component_before = component_history(&conn);
|
||||
let before_schema = schema(&conn);
|
||||
let before_cookie = cookie(&conn);
|
||||
{
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
let error = install(&tx).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("injected Subject migration failure"),
|
||||
"{error}"
|
||||
);
|
||||
assert_closed_schema_writes(&tx);
|
||||
assert_component13(&tx); // Ticket's savepoint already rolled back.
|
||||
// Caller rolls back its whole transaction, including ledger writes.
|
||||
}
|
||||
assert_eq!(schema(&conn), before_schema);
|
||||
assert_eq!(cookie(&conn), before_cookie);
|
||||
assert_component13(&conn);
|
||||
assert_eq!(evidence(&conn), before_rows);
|
||||
assert_closed_schema_writes(&conn);
|
||||
drop(conn);
|
||||
let conn = Connection::open(path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
assert_eq!(schema(&conn), before_schema);
|
||||
assert_eq!(evidence(&conn), before_rows);
|
||||
conn.execute_batch("DROP TRIGGER reject_subject_diagnostic")
|
||||
.unwrap();
|
||||
migrate(&conn);
|
||||
assert_eq!(evidence(&conn), before_rows);
|
||||
assert_component14_append(&conn, &component_before);
|
||||
assert_subject_contract(&conn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_assignment_migration_rejects_autocommit_and_unknown_schema_without_changes() {
|
||||
let (_directory, _path, conn) = fixture(false);
|
||||
let before = schema(&conn);
|
||||
assert!(
|
||||
install(&conn)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("caller-owned transaction")
|
||||
);
|
||||
assert_eq!(schema(&conn), before);
|
||||
conn.execute_batch("ALTER TABLE ticket_assignment_operations RENAME COLUMN principal_kind TO unexpected_kind;").unwrap();
|
||||
let before = schema(&conn);
|
||||
let before_cookie = cookie(&conn);
|
||||
{
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
let error = install(&tx).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Ticket Subject component composition failed"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
assert_eq!(schema(&conn), before);
|
||||
assert_eq!(cookie(&conn), before_cookie);
|
||||
assert_component13(&conn);
|
||||
assert_closed_schema_writes(&conn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_assignment_migration_rejects_whole_table_drift_and_rolls_back_unknown_schema() {
|
||||
for table in [
|
||||
"ticket_assignment_operations",
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_worker_assignments",
|
||||
] {
|
||||
let (_directory, path, conn) = fixture(false);
|
||||
// The formerly matched principal CHECK remains exactly present.
|
||||
// Unknown layout/default/constraint additions must not be accepted.
|
||||
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN unexpected_metadata TEXT DEFAULT 'not canonical' CHECK(unexpected_metadata IS NOT NULL);")).unwrap();
|
||||
let before = schema(&conn);
|
||||
let rows = evidence(&conn);
|
||||
let before_cookie = cookie(&conn);
|
||||
let ddl: String = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name=?1",
|
||||
[table],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(ddl.contains("CHECK(principal_kind IN ('user', 'worker', 'workspace_agent'))"));
|
||||
{
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
let error = install(&tx).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Ticket Subject component composition failed")
|
||||
&& error.to_string().contains(table),
|
||||
"{error}"
|
||||
);
|
||||
assert_closed_schema_writes(&tx);
|
||||
}
|
||||
assert_eq!(schema(&conn), before);
|
||||
assert_eq!(cookie(&conn), before_cookie);
|
||||
assert_component13(&conn);
|
||||
assert_eq!(evidence(&conn), rows);
|
||||
drop(conn);
|
||||
let reopened = Connection::open(path).unwrap();
|
||||
configure_sqlite(&reopened).unwrap();
|
||||
assert_eq!(schema(&reopened), before);
|
||||
assert_eq!(evidence(&reopened), rows);
|
||||
assert_closed_schema_writes(&reopened);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_assignment_migration_rejects_weakened_principal_preimage_without_repair() {
|
||||
let (_directory, _path, conn) = fixture(false);
|
||||
conn.execute_batch("DROP TRIGGER typed_ticket_assignment_orchestrator_principal_insert;
|
||||
CREATE TRIGGER typed_ticket_assignment_orchestrator_principal_insert BEFORE INSERT ON ticket_worker_assignments BEGIN SELECT 1; END;").unwrap();
|
||||
let before = schema(&conn);
|
||||
let rows = evidence(&conn);
|
||||
{
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
let error = install(&tx).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("typed_ticket_assignment_orchestrator_principal_insert")
|
||||
&& error.to_string().contains("canonical"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
assert_eq!(schema(&conn), before);
|
||||
assert_eq!(evidence(&conn), rows);
|
||||
assert_component13(&conn);
|
||||
assert_closed_schema_writes(&conn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_assignment_migration_readonly_owner_failure_preserves_settings_and_retries() {
|
||||
let (_directory, _path, conn) = fixture(false);
|
||||
let before = schema(&conn);
|
||||
let rows = evidence(&conn);
|
||||
let before_cookie = cookie(&conn);
|
||||
{
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
// Failure at the delegated owning migration boundary must not leak
|
||||
// connection settings or install host guards. Ticket owns injection
|
||||
// details inside its principal-table migration, not this adapter.
|
||||
tx.pragma_update(None, "query_only", true).unwrap();
|
||||
let error = install(&tx).unwrap_err();
|
||||
assert!(error.to_string().contains("readonly"), "{error}");
|
||||
assert_closed_schema_writes(&tx);
|
||||
tx.pragma_update(None, "query_only", false).unwrap();
|
||||
}
|
||||
assert_eq!(schema(&conn), before);
|
||||
assert_eq!(cookie(&conn), before_cookie);
|
||||
assert_component13(&conn);
|
||||
assert_eq!(evidence(&conn), rows);
|
||||
assert_closed_schema_writes(&conn);
|
||||
migrate(&conn);
|
||||
verify(&conn).unwrap();
|
||||
assert_subject_contract(&conn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_assignment_migration_verifier_checks_every_complete_trigger_and_table() {
|
||||
let (_directory, _path, conn) = fixture(false);
|
||||
migrate(&conn);
|
||||
verify(&conn).unwrap();
|
||||
let guards: Vec<(String, String)> = conn.prepare("SELECT name,tbl_name FROM sqlite_schema
|
||||
WHERE type='trigger' AND (name LIKE 'ticket_subject_%' OR name LIKE 'ticket_legacy_orchestrator_%'
|
||||
OR name IN ('typed_ticket_assignment_orchestrator_principal_insert','typed_ticket_assignment_role_principal_insert'))
|
||||
ORDER BY name").unwrap().query_map([], |row| Ok((row.get(0)?,row.get(1)?))).unwrap().collect::<rusqlite::Result<_>>().unwrap();
|
||||
assert_eq!(guards.len(), 10);
|
||||
for (name, table) in guards {
|
||||
conn.execute_batch("SAVEPOINT weakened_guard").unwrap();
|
||||
conn.execute_batch(&format!("DROP TRIGGER {name}; CREATE TRIGGER {name} BEFORE INSERT ON {table} BEGIN SELECT 1; END;")).unwrap();
|
||||
let drift = schema(&conn);
|
||||
let error = verify(&conn).unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains(&name),
|
||||
"owning or host verifier must reject {name}: {error}"
|
||||
);
|
||||
if name == "ticket_legacy_orchestrator_delivery_no_insert"
|
||||
|| name == "ticket_legacy_orchestrator_start_no_insert"
|
||||
{
|
||||
assert!(
|
||||
error.to_string().contains("Workspace Subject guard"),
|
||||
"{error}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Ticket Subject schema verification failed"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
assert_eq!(schema(&conn), drift, "verification must not repair a guard");
|
||||
conn.execute_batch("ROLLBACK TO weakened_guard; RELEASE weakened_guard")
|
||||
.unwrap();
|
||||
verify(&conn).unwrap();
|
||||
}
|
||||
for table in [
|
||||
"ticket_assignment_operations",
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_worker_assignments",
|
||||
] {
|
||||
conn.execute_batch("SAVEPOINT weakened_table").unwrap();
|
||||
conn.execute_batch(&format!("ALTER TABLE {table} ADD COLUMN unexpected TEXT;"))
|
||||
.unwrap();
|
||||
let error = verify(&conn).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("Ticket Subject schema verification failed")
|
||||
&& error.to_string().contains(table),
|
||||
"{error}"
|
||||
);
|
||||
conn.execute_batch("ROLLBACK TO weakened_table; RELEASE weakened_table")
|
||||
.unwrap();
|
||||
}
|
||||
for setting in ["writable_schema", "ignore_check_constraints"] {
|
||||
conn.pragma_update(None, setting, true).unwrap();
|
||||
let error = verify(&conn).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains(&format!("requires {setting} OFF")),
|
||||
"{error}"
|
||||
);
|
||||
conn.pragma_update(None, setting, false).unwrap();
|
||||
}
|
||||
verify(&conn).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_assignment_migration_startup_rejects_guard_drift_without_repair() {
|
||||
for guard in [
|
||||
"ticket_legacy_orchestrator_delivery_no_insert",
|
||||
"ticket_legacy_orchestrator_start_no_insert",
|
||||
] {
|
||||
let (_directory, path, conn) = fixture(false);
|
||||
apply_migrations(&conn).unwrap();
|
||||
conn.execute_batch(&format!("DROP TRIGGER {guard}"))
|
||||
.unwrap();
|
||||
let before_schema = schema(&conn);
|
||||
let before_rows = evidence(&conn);
|
||||
let before_cookie = cookie(&conn);
|
||||
let before_components = component_history(&conn);
|
||||
drop(conn);
|
||||
|
||||
let conn = Connection::open(path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
let error = apply_migrations(&conn).unwrap_err();
|
||||
assert!(error.to_string().contains(guard), "{error}");
|
||||
assert_eq!(schema(&conn), before_schema);
|
||||
assert_eq!(evidence(&conn), before_rows);
|
||||
assert_eq!(cookie(&conn), before_cookie);
|
||||
assert_eq!(component_history(&conn), before_components);
|
||||
assert_closed_schema_writes(&conn);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_assignment_migration_host_failure_rolls_back_owning_component_and_retries() {
|
||||
let (_directory, path, conn) = fixture(false);
|
||||
// Force failure on the SECOND host guard, after Ticket14 and the first
|
||||
// host guard have succeeded. The outer host transaction owns rollback.
|
||||
conn.execute_batch(
|
||||
"CREATE TRIGGER ticket_legacy_orchestrator_start_no_insert
|
||||
BEFORE INSERT ON ticket_assignment_operation_fences BEGIN SELECT 1; END;",
|
||||
)
|
||||
.unwrap();
|
||||
let before_schema = schema(&conn);
|
||||
let before_rows = evidence(&conn);
|
||||
let component_before = component_history(&conn);
|
||||
let before_cookie = cookie(&conn);
|
||||
{
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
let error = install(&tx).unwrap_err();
|
||||
assert!(error.to_string().contains("already exists"), "{error}");
|
||||
assert_component14_append(&tx, &component_before);
|
||||
assert!(tx.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='ticket_legacy_orchestrator_delivery_no_insert')", [], |row| row.get::<_,bool>(0)).unwrap());
|
||||
assert!(!tx.is_autocommit());
|
||||
assert_closed_schema_writes(&tx);
|
||||
}
|
||||
assert_eq!(schema(&conn), before_schema);
|
||||
assert_eq!(evidence(&conn), before_rows);
|
||||
assert_eq!(component_history(&conn), component_before);
|
||||
assert_eq!(cookie(&conn), before_cookie);
|
||||
assert_component13(&conn);
|
||||
drop(conn);
|
||||
let conn = Connection::open(path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
assert_eq!(schema(&conn), before_schema);
|
||||
assert_eq!(component_history(&conn), component_before);
|
||||
conn.execute_batch("DROP TRIGGER ticket_legacy_orchestrator_start_no_insert")
|
||||
.unwrap();
|
||||
migrate(&conn);
|
||||
assert_component14_append(&conn, &component_before);
|
||||
verify(&conn).unwrap();
|
||||
assert_subject_contract(&conn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_assignment_migration_precomposed_owner_is_not_reinstalled_or_rediagnosed() {
|
||||
let (_directory, _path, conn) = fixture(false);
|
||||
let old_components = component_history(&conn);
|
||||
{
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx).unwrap();
|
||||
tx.commit().unwrap();
|
||||
}
|
||||
assert_component14_append(&conn, &old_components);
|
||||
ticket::verify_sqlite_ticket_schema(&conn).unwrap();
|
||||
// The host must not append the owning diagnostic again or issue even a
|
||||
// redundant diagnostic UPDATE for any already-diagnosed legacy row.
|
||||
conn.execute_batch(
|
||||
"CREATE TRIGGER forbid_duplicate_subject_diagnostic
|
||||
BEFORE UPDATE OF tracking_migration_diagnostic ON ticket_worker_assignments
|
||||
BEGIN SELECT RAISE(ABORT,'host duplicated owning diagnostic'); END;",
|
||||
)
|
||||
.unwrap();
|
||||
let owner_schema = schema(&conn);
|
||||
let owner_assignments = fixture_query_rows(
|
||||
&conn,
|
||||
"SELECT rowid,* FROM ticket_worker_assignments ORDER BY rowid",
|
||||
);
|
||||
let owner_receipts = component_history(&conn);
|
||||
let before_rows = evidence(&conn);
|
||||
let before_cookie = cookie(&conn);
|
||||
migrate(&conn);
|
||||
assert_eq!(component_history(&conn), owner_receipts);
|
||||
assert_eq!(
|
||||
fixture_query_rows(
|
||||
&conn,
|
||||
"SELECT rowid,* FROM ticket_worker_assignments ORDER BY rowid"
|
||||
),
|
||||
owner_assignments
|
||||
);
|
||||
assert_eq!(evidence(&conn), before_rows);
|
||||
let after_without_host = fixture_query_rows(&conn,"SELECT type,name,tbl_name,rootpage,sql FROM sqlite_schema
|
||||
WHERE name NOT IN ('ticket_legacy_orchestrator_delivery_no_insert','ticket_legacy_orchestrator_start_no_insert') ORDER BY type,name");
|
||||
assert_eq!(
|
||||
after_without_host, owner_schema,
|
||||
"host must not rebuild tables or replace any owning guard"
|
||||
);
|
||||
assert_eq!(
|
||||
cookie(&conn),
|
||||
before_cookie + 2,
|
||||
"only the two host CREATE TRIGGER statements change the schema"
|
||||
);
|
||||
verify(&conn).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_assignment_migration_reloads_prepared_constraints_and_reopen_cache() {
|
||||
let (_directory, path, conn) = fixture(false);
|
||||
let mut cached = conn.prepare_cached("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint)
|
||||
VALUES('w','cached-subject','assign','waiting','orchestrator','subject','subject-a','cached-assignment','3','cached fingerprint')").unwrap();
|
||||
assert!(cached.execute([]).is_err(), "old CHECK rejects Subject");
|
||||
{
|
||||
let tx = conn.unchecked_transaction().unwrap();
|
||||
install(&tx).unwrap();
|
||||
// Drop the whole migration transaction; a prepared statement must
|
||||
// not retain broadened constraints after rollback.
|
||||
}
|
||||
assert_component13(&conn);
|
||||
assert!(
|
||||
cached.execute([]).is_err(),
|
||||
"rollback restores old CHECK and guards"
|
||||
);
|
||||
migrate(&conn);
|
||||
cached.execute([]).unwrap();
|
||||
drop(cached);
|
||||
verify(&conn).unwrap();
|
||||
drop(conn);
|
||||
let conn = Connection::open(path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
verify(&conn).unwrap();
|
||||
assert_eq!(conn.query_row("SELECT request_fingerprint FROM ticket_assignment_operations WHERE operation_id='cached-subject'", [], |row| row.get::<_, String>(0)).unwrap(), "cached fingerprint");
|
||||
assert_subject_contract(&conn);
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,10 @@ fn page_sql(
|
||||
if let Some(principal) = &query.assigned_principal {
|
||||
use ticket::TicketAssignmentPrincipalSnapshot::*;
|
||||
terms.push(match principal {
|
||||
Subject { subject_id } => format!(
|
||||
"a.principal_kind='subject' AND a.principal_id={}",
|
||||
bind(subject_id.clone())
|
||||
),
|
||||
WorkspaceAgent { agent_key } => format!(
|
||||
"a.principal_kind='workspace_agent' AND a.principal_id={}",
|
||||
bind(agent_key.clone())
|
||||
@@ -342,6 +346,7 @@ fn enrich_page(
|
||||
let mut statement = conn.prepare(&format!("SELECT a.ticket_id,json_object(
|
||||
'workspace_id',a.workspace_id,'ticket_id',a.ticket_id,'assignment_id',a.assignment_id,'role',a.role,
|
||||
'principal',json(CASE a.principal_kind
|
||||
WHEN 'subject' THEN json_object('kind','subject','subject_id',a.principal_id)
|
||||
WHEN 'workspace_agent' THEN json_object('kind','workspace_agent','agent_key',a.principal_id)
|
||||
WHEN 'user' THEN json_object('kind','user','account_id',a.principal_id)
|
||||
WHEN 'worker' THEN json_object('kind','worker','runtime_id',a.runtime_id,'worker_id',a.worker_id) END),
|
||||
|
||||
@@ -78,12 +78,12 @@ impl Fixture {
|
||||
fn assign(&self, id: &str, assignment: &str, status: Option<&str>, current: bool) {
|
||||
self.authority.store.with_conn(|conn| {
|
||||
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at)
|
||||
VALUES('w',?1,?2,'orchestrator','workspace_agent','workspace-orchestrator','owner-account','1')",params![id,assignment])?;
|
||||
VALUES('w',?1,?2,'orchestrator','subject','subject-a','owner-account','1')",params![id,assignment])?;
|
||||
conn.execute("UPDATE ticket_worker_assignments SET tracking_json=?1,tracking_updated_at=?2 WHERE workspace_id='w' AND assignment_id=?3",
|
||||
params![status.map(|s| serde_json::json!({"status":s,"waiting_reason":null,"progress":null,"next_action":null,"result":null}).to_string()),status.map(|_|"1"),assignment])?;
|
||||
if current {
|
||||
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,role,assignment_id,principal_kind,principal_id,updated_at)
|
||||
VALUES('w',?1,'orchestrator',?2,'workspace_agent','workspace-orchestrator','1')",params![id,assignment])?;
|
||||
VALUES('w',?1,'orchestrator',?2,'subject','subject-a','1')",params![id,assignment])?;
|
||||
}
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
@@ -100,9 +100,9 @@ impl Fixture {
|
||||
}).unwrap();
|
||||
}
|
||||
}
|
||||
fn agent() -> ticket::TicketAssignmentPrincipalSnapshot {
|
||||
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
fn assigned_subject() -> ticket::TicketAssignmentPrincipalSnapshot {
|
||||
ticket::TicketAssignmentPrincipalSnapshot::Subject {
|
||||
subject_id: "subject-a".into(),
|
||||
}
|
||||
}
|
||||
fn ids(response: &TicketBrowseQueryResponse) -> Vec<String> {
|
||||
@@ -264,7 +264,7 @@ async fn ticket_browse_all_filters_are_applied_before_limit_and_batch_fields_mat
|
||||
query: Some("NEEDLE".into()),
|
||||
states: vec![TicketState::Closed],
|
||||
tag_ids: vec!["b".into(), "a".into(), "a".into()],
|
||||
assigned_principal: Some(agent()),
|
||||
assigned_principal: Some(assigned_subject()),
|
||||
handling_status: Some(ticket::TicketAssignmentTrackingStatus::Done),
|
||||
closure_reason: Some(TicketClosureReason::Other("custom reason".into())),
|
||||
limit: Some(1),
|
||||
@@ -372,7 +372,7 @@ async fn ticket_browse_assignment_current_missing_unknown_and_cleared_semantics(
|
||||
f.authority.ticket_assignment_tracking(&unknown).unwrap()
|
||||
);
|
||||
let mut assigned = ids(&f.run(TicketBrowseQueryRequest {
|
||||
assigned_principal: Some(agent()),
|
||||
assigned_principal: Some(assigned_subject()),
|
||||
..Default::default()
|
||||
}));
|
||||
assigned.sort();
|
||||
|
||||
@@ -83,7 +83,7 @@ fn ticket_posts_schema93_preserves_initial_body_creator_comments_and_archived_au
|
||||
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()+2);
|
||||
assert_eq!(markers.len(),old_markers.len()+3);
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM typed_ticket_content_changes",[],|r|r.get::<_,i64>(0))?,0,"migration does not invent content change facts");
|
||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs",[],|r|r.get::<_,i64>(0))?,0,"migration does not turn historical edits into new inputs");
|
||||
let initial: (String,Option<String>,Option<String>,String,String) = conn.query_row("SELECT e.body,t.created_by,e.author,e.kind,e.event_ref FROM typed_tickets t JOIN typed_ticket_events e USING(workspace_id,ticket_id) WHERE t.ticket_id='authored' AND e.event_index=-1",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?)))?;
|
||||
|
||||
@@ -266,7 +266,7 @@ fn assert_retained_classification_upgrade(workspace_version: i64, component: i64
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
store.with_conn(|conn| {
|
||||
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))?,13);
|
||||
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,14);
|
||||
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);
|
||||
@@ -500,7 +500,7 @@ fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)?,
|
||||
13
|
||||
14
|
||||
);
|
||||
assert_eq!(
|
||||
stored_fixture_rows(conn, "typed_ticket_case_history"),
|
||||
|
||||
@@ -1049,8 +1049,8 @@ mod tests {
|
||||
ticket_id: initial.meta.id.clone(),
|
||||
assignment_id: assignment_id.clone(),
|
||||
role: crate::store::TicketAssignmentRole::Orchestrator,
|
||||
principal: crate::store::TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
principal: crate::store::TicketAssignmentPrincipal::Subject {
|
||||
subject_id: "deletion-fixture-subject".into(),
|
||||
},
|
||||
assigned_by: "owner".into(),
|
||||
assigned_at: "2026-01-01T00:00:00Z".into(),
|
||||
@@ -1094,7 +1094,7 @@ mod tests {
|
||||
actor: "orchestrator".into(),
|
||||
runtime_id: "fixture-runtime".into(),
|
||||
worker_id: "fixture-worker".into(),
|
||||
agent_key: "workspace-orchestrator".into(),
|
||||
subject_id: "deletion-fixture-subject".into(),
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
},
|
||||
)?;
|
||||
|
||||
Reference in New Issue
Block a user