21 Commits
Author SHA1 Message Date
Hare 885e8045fe docs: map notification routing and verification boundaries 2026-10-15 19:41:40 +09:00
Hare 7b52bea23b feat: integrate MR notifications and implementation results 2026-10-15 19:40:29 +09:00
Hare a31f002a0a fix: retain MR source observations and notification actors 2026-10-15 19:13:03 +09:00
Hare 8ab7ee05db chore: retain committed Ticket sidebar changes during notification integration 2026-10-15 18:51:14 +09:00
Hare 62d71ed17e fix: retain preparation diagnostics and source-owned cleanup 2026-10-15 18:51:00 +09:00
Hare cafa2fa6ea fix: integrate recoverable and fair Job notification retries 2026-10-15 18:44:20 +09:00
Hare 95ed9e8719 fix: fairly schedule undecided Job notification retries 2026-10-15 18:43:23 +09:00
Hare 84ea23c2e6 feat: route MR events and authenticated implementation results 2026-10-15 18:38:27 +09:00
Hare ac49740a50 fix: simplify Ticket sidebar and surface repository targets 2026-10-15 18:35:39 +09:00
Hare bdc7c73143 fix: retry Job notification source preparation failures 2026-10-15 18:33:23 +09:00
Hare 011dbb9a98 fix: align MR evidence fixtures with canonical Ticket posts 2026-10-15 18:29:40 +09:00
Hare b9e7d5675b fix: project Job receipts and pinned notification recipients 2026-10-15 18:28:11 +09:00
Hare c155259c8e chore: integrate backend Job notifications with diagnostics 2026-10-15 18:16:29 +09:00
Hare e33729647c fix: preserve notification uncertainty and target provenance 2026-10-15 18:15:47 +09:00
Hare e71177900e feat: route backend job results through common notifications 2026-10-15 18:12:07 +09:00
Hare f50fa47a48 fix: await DB cleanup in cancelled Runtime metadata test 2026-10-15 17:55:54 +09:00
Hare b71fc9ee54 feat: expose read-only notification routing and delivery diagnostics 2026-10-15 17:55:54 +09:00
Hare e70cba4618 chore: integrate case permission fixes for notification work 2026-10-15 16:58:21 +09:00
Hare d115819f51 fix: honor Profile case permissions and expose Ticket API errors 2026-10-15 16:55:09 +09:00
Hare 822a084b31 fix: centralize durable Ticket notification routing 2026-10-15 02:20:40 +09:00
Hare 65ead5fffd fix: keep page errors local and preserve Workspace error details 2026-10-14 21:19:42 +09:00
77 changed files with 11025 additions and 1095 deletions
+12
View File
@@ -693,6 +693,17 @@ impl MergeRequestStore {
pub fn open_merge_request(
&self,
i: OpenMergeRequest,
) -> Result<MergeRequest, MergeRequestError> {
self.open_merge_request_with_evidence(i, |_| Ok(()))
}
/// Retain caller-owned evidence atomically with creation. The callback runs
/// after the MR and Ticket relation exist, under this store's transaction;
/// it must use that transaction rather than reacquire the store connection.
pub fn open_merge_request_with_evidence(
&self,
i: OpenMergeRequest,
retain_evidence: impl FnOnce(&Transaction<'_>) -> Result<(), MergeRequestError>,
) -> Result<MergeRequest, MergeRequestError> {
bounded_body("summary", &i.summary)?;
for (n, v) in [
@@ -756,6 +767,7 @@ impl MergeRequestStore {
None,
)?
}
retain_evidence(&t)?;
t.commit()?;
drop(c);
self.get_by_id(&i.auth.workspace_id, &merge_request_id)
+2
View File
@@ -44,6 +44,7 @@ fn content_digest(title: &str) -> String {
[title],
)
.unwrap();
connection.execute_batch("CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,body TEXT); INSERT INTO typed_ticket_events VALUES('W','T',-1,'Requirements');").unwrap();
ticket::sqlite_ticket_content_digest(&connection, "W", "T")
.unwrap()
.unwrap()
@@ -53,6 +54,7 @@ fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
let p = d.path().join("db");
let c = Connection::open(&p).unwrap();
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,title TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t','Title','Requirements');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap();
c.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('W','T',-1,'comment','Requirements')",[]).unwrap();
drop(c);
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
assignment_id: "A".into(),
+69
View File
@@ -7,6 +7,8 @@ pub use api_macros::axum as server_support;
pub use api_macros::reqwest as client_support;
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
mod drive;
mod notification;
pub use notification::*;
pub mod repository_openapi_typescript;
pub use drive::*;
mod workspace_config;
@@ -366,6 +368,10 @@ impl_openapi_schema!(
TicketAssignmentsPage,
TicketAssignmentAnswerRequest,
TicketAssignmentAnswerResponse,
NotificationDiagnosticsQuery,
NotificationDiagnosticsPage,
TicketImplementationResultRequest,
TicketImplementationResultReceipt,
TicketAssignmentDiagnosticsQuery,
TicketAssignmentDiagnosticsPage,
TicketQueryRequest,
@@ -3241,6 +3247,14 @@ pub trait ServerApi {
#[query] query: TicketAssignmentsQuery,
) -> Result<TicketAssignmentsPage, RepositoryApiError>;
#[get("/api/w/{workspace_id}/notification-diagnostics", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn notification_diagnostics(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[query] query: NotificationDiagnosticsQuery,
) -> Result<NotificationDiagnosticsPage, RepositoryApiError>;
#[get("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_assignment_diagnostics(
&self,
@@ -3277,6 +3291,33 @@ pub trait ServerApi {
#[body] request: TicketAssignmentAnswerRequest,
) -> Result<TicketAssignmentAnswerResponse, RepositoryApiError>;
#[post("/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_implementation_result(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] ticket_id: String,
#[body] request: TicketImplementationResultRequest,
) -> Result<TicketImplementationResultReceipt, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{ticket_id}/implementation-results", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_implementation_result_browser(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] ticket_id: String,
#[body] request: TicketImplementationResultRequest,
) -> Result<TicketImplementationResultReceipt, RepositoryApiError>;
#[get("/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results/{result_id}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_implementation_result_get(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] ticket_id: String,
#[path] result_id: String,
) -> Result<TicketImplementationResultReceipt, RepositoryApiError>;
#[get("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_assignment_tracking_get(
&self,
@@ -8006,6 +8047,34 @@ transparent_ticket_dto!(
ticket::TicketAssignmentAnswerReceipt
);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TicketImplementationResultKind {
Handoff,
Result,
Blocker,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketImplementationResultRequest {
pub operation_key: String,
pub assignment_id: String,
pub orchestrator_assignment_id: String,
pub kind: TicketImplementationResultKind,
pub body: String,
pub merge_request: Option<MergeRequestReviewSubject>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketImplementationResultReceipt {
pub result_id: String,
pub ticket_id: String,
pub request: TicketImplementationResultRequest,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketAssignmentDiagnosticsQuery {
+99
View File
@@ -0,0 +1,99 @@
//! Read-only notification evidence. Acceptance is not model processing or execution authority.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NotificationDiagnosticsQuery {
pub ticket: Option<String>,
pub merge_request_id: Option<String>,
pub job_id: Option<String>,
pub source_event_id: Option<String>,
pub assignment_id: Option<String>,
pub delivery_id: Option<String>,
/// Exact source identity for a single-record lookup.
pub source_id: Option<String>,
pub cursor: Option<String>,
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum NotificationDiagnosticStage {
Historical,
AwaitingRouting,
Suppressed,
Deferred,
Pending,
Reserved,
RequestPersisted,
RuntimeRejected,
AcceptanceUnknown,
DeliveryFailed,
RuntimeAccepted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum NotificationTargetResolution {
Unresolved,
Reserved,
RequestPersisted,
RuntimeAccepted,
MultipleAuthorizedTargets,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NotificationDiagnostic {
pub source_id: String,
pub source_event_id: String,
pub event_kind: String,
pub ticket: Option<String>,
pub merge_request_id: Option<String>,
pub job_id: Option<String>,
pub recipient_role: String,
pub assignment_id: Option<String>,
/// Current assignment identity only; null for fixed-worker or absent recipients.
pub recipient_current: Option<bool>,
pub disposition: Option<String>,
pub reason: Option<String>,
pub purpose: Option<String>,
pub delivery_id: Option<String>,
pub stage: NotificationDiagnosticStage,
/// A retained pre-cutover Job receipt contributes to this projection.
pub legacy_evidence: bool,
/// Conservative: a persisted request without ACK is enough; this does not prove a send occurred.
/// Can remain true alongside a later preflight failure, rejection or historical recipient.
pub acceptance_outcome_unknown: bool,
/// Failure records do not identify a particular authorized replacement target.
pub target_resolution: NotificationTargetResolution,
pub target_runtime_id: Option<String>,
pub target_worker_id: Option<String>,
pub last_failure_code: Option<String>,
pub last_failure_at: Option<String>,
/// Saved delivery candidate, NOT permission to send. Live recipient and binding checks remain required.
pub retry_candidate: bool,
/// No model-processing evidence is currently collected by this read contract.
pub model_processing_confirmed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NotificationSourceCheck {
pub resource_kind: String,
pub reference: String,
/// Whether an exact durable origin is present; null means that reference form was not verified.
pub origin_present: Option<bool>,
/// Whether the origin has any captured source (independent of pagination/recipient filters).
/// A present origin with false here is unconnected, not intentionally suppressed.
pub captured: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct NotificationDiagnosticsPage {
pub items: Vec<NotificationDiagnostic>,
pub next_cursor: Option<String>,
pub source_checks: Vec<NotificationSourceCheck>,
}
@@ -536,6 +536,13 @@ fn validate_ticket_browser_operations(
request: None,
successes: &[("200", "TicketAssignmentsPage")],
},
OperationContract {
path: "/api/w/{workspace_id}/notification-diagnostics",
method: "get",
operation_id: "notification_diagnostics",
request: None,
successes: &[("200", "NotificationDiagnosticsPage")],
},
OperationContract {
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics",
method: "get",
@@ -543,6 +550,27 @@ fn validate_ticket_browser_operations(
request: None,
successes: &[("200", "TicketAssignmentDiagnosticsPage")],
},
OperationContract {
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results",
method: "post",
operation_id: "ticket_implementation_result",
request: Some("TicketImplementationResultRequest"),
successes: &[("200", "TicketImplementationResultReceipt")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{ticket_id}/implementation-results",
method: "post",
operation_id: "ticket_implementation_result_browser",
request: Some("TicketImplementationResultRequest"),
successes: &[("200", "TicketImplementationResultReceipt")],
},
OperationContract {
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results/{result_id}",
method: "get",
operation_id: "ticket_implementation_result_get",
request: None,
successes: &[("200", "TicketImplementationResultReceipt")],
},
OperationContract {
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-answer",
method: "post",
@@ -408,7 +408,7 @@ fn migration_preserves_original_records_and_only_reconciles_current_fenced_deleg
|r| r.get::<_, i64>(0)
)
.unwrap(),
12
13
);
let value = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
.unwrap()
+62
View File
@@ -0,0 +1,62 @@
//! Immutable facts of committed edits, not notification decisions. The caller's write
//! transaction owns both the edit and this INSERT; hosts may capture assignment identity
//! with an INSERT trigger. No text snapshots or processing state are retained here.
use rusqlite::{Connection, params};
use crate::{Result, sqlite_err};
pub(crate) enum ContentChange<'a> {
ContentEdit {
title_changed: bool,
body_event_ref: Option<&'a str>,
targets_changed: bool,
},
CommentEdit {
event_ref: &'a str,
},
}
impl ContentChange<'_> {
pub(crate) fn insert(
self,
conn: &Connection,
workspace_id: &str,
ticket_id: &str,
actor: Option<&str>,
at: &str,
) -> Result<()> {
let (kind, title_changed, source_event_ref, targets_changed) = match self {
Self::ContentEdit {
title_changed,
body_event_ref,
targets_changed,
} => (
"content_edit",
title_changed,
body_event_ref,
targets_changed,
),
Self::CommentEdit { event_ref } => ("comment_edit", false, Some(event_ref), false),
};
conn.execute(
"INSERT INTO typed_ticket_content_changes
(change_id, workspace_id, ticket_id, kind, actor, at,
title_changed, body_changed, targets_changed, source_event_ref)
VALUES (lower(hex(randomblob(16))), ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
workspace_id,
ticket_id,
kind,
actor,
at,
title_changed,
source_event_ref.is_some(),
targets_changed,
source_event_ref
],
)
.map_err(sqlite_err)?;
Ok(())
}
}
+20
View File
@@ -0,0 +1,20 @@
-- Ordered component 12 -> 13. Facts have no delivery policy or mutable processing state.
CREATE TABLE typed_ticket_content_changes (
change_id TEXT NOT NULL PRIMARY KEY CHECK (length(trim(change_id)) > 0),
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('content_edit', 'comment_edit')),
actor TEXT CHECK (actor IS NULL OR length(trim(actor)) > 0),
at TEXT NOT NULL,
title_changed INTEGER NOT NULL CHECK (title_changed IN (0, 1)),
body_changed INTEGER NOT NULL CHECK (body_changed IN (0, 1)),
targets_changed INTEGER NOT NULL CHECK (targets_changed IN (0, 1)),
source_event_ref TEXT,
CHECK (title_changed + body_changed + targets_changed > 0),
CHECK ((body_changed = 0 AND source_event_ref IS NULL) OR
(body_changed = 1 AND source_event_ref IS NOT NULL AND length(trim(source_event_ref)) > 0)),
CHECK (kind != 'comment_edit' OR (title_changed = 0 AND body_changed = 1 AND targets_changed = 0)),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TRIGGER typed_ticket_content_changes_no_update BEFORE UPDATE ON typed_ticket_content_changes
BEGIN SELECT RAISE(ABORT, 'Ticket content change facts are immutable'); END;
+45 -27
View File
@@ -29,6 +29,7 @@ mod assignment_answer;
pub use assignment_answer::*;
mod assignment_tracking;
pub mod config;
mod content_changes;
mod sqlite_schema;
mod tags;
pub use assignment_tracking::*;
@@ -3116,6 +3117,9 @@ impl TicketBackend for SqliteTicketBackend {
if post.body != edit.expected_body {
return Err(TicketError::Conflict("stale Ticket post body".into()));
}
if post.body == edit.body {
return Ok((previous.clone(), previous));
}
let now = now_utc();
let changed = conn.execute(
"UPDATE typed_ticket_events SET body = ?4, edited_at = ?5, edited_by = ?6 WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_ref = ?3 AND body = ?7 AND kind = 'comment' AND archived = 0",
@@ -3123,6 +3127,16 @@ impl TicketBackend for SqliteTicketBackend {
).map_err(sqlite_err)?;
if changed != 1 { return Err(TicketError::Conflict("stale Ticket post body".into())); }
self.touch_ticket(conn, &ticket_id, &now)?;
let fact = if post.is_initial {
content_changes::ContentChange::ContentEdit {
title_changed: false,
body_event_ref: Some(&post.event_ref),
targets_changed: false,
}
} else {
content_changes::ContentChange::CommentEdit { event_ref: &post.event_ref }
};
fact.insert(conn, &self.workspace_id, &ticket_id, Some(&edit.author), &now)?;
let current = self.load_ticket(conn, &ticket_id)?;
Ok((previous, current))
})
@@ -3315,37 +3329,41 @@ impl TicketBackend for SqliteTicketBackend {
}
let ticket_id = self.resolve_ticket_id(conn, id)?;
let previous = self.load_ticket(conn, &ticket_id)?;
let now = now_utc();
if let Some(title) = edit.title.as_ref() {
conn.execute("UPDATE typed_tickets SET title = ?3, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, title, now]).map_err(sqlite_err)?;
}
let mut updated_body = None;
if let Some(body) = edit.body.as_ref() {
updated_body = Some(body.clone());
}
let mut updated_body = edit.body.clone();
if let Some(replacement) = edit.body_replacement.as_ref() {
let current_body: String = conn
.query_row(
"SELECT body FROM typed_ticket_events WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = -1",
params![self.workspace_id, ticket_id],
|row| row.get(0),
)
.map_err(sqlite_err)?;
let outcome = replacement.apply(current_body.as_str())?;
updated_body = Some(outcome.body);
updated_body = Some(replacement.apply(previous.document.body.as_str())?.body);
}
if let Some(body) = updated_body.as_ref() {
conn.execute("UPDATE typed_ticket_events SET body = ?3, edited_at = ?4, edited_by = ?5 WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = -1", params![self.workspace_id, ticket_id, body.as_str(), now, edit.author]).map_err(sqlite_err)?;
self.touch_ticket(conn, &ticket_id, &now)?;
let targets = edit.targets.as_ref().map(|targets| match targets {
TicketTargetsEdit::Set { targets } => targets.as_slice(),
TicketTargetsEdit::Clear => &[],
});
let title_changed = edit.title.as_ref().is_some_and(|title| *title != previous.meta.title);
let body_changed = updated_body.as_ref().is_some_and(|body| *body != previous.document.body);
let targets_changed = targets.is_some_and(|targets| targets != previous.meta.targets);
if !title_changed && !body_changed && !targets_changed {
return Ok((previous.clone(), previous));
}
if let Some(targets) = edit.targets.as_ref() {
let targets = match targets {
TicketTargetsEdit::Set { targets } => targets.as_slice(),
TicketTargetsEdit::Clear => &[],
};
self.replace_ticket_targets(conn, &ticket_id, targets)?;
self.touch_ticket(conn, &ticket_id, &now)?;
let now = now_utc();
if title_changed {
conn.execute("UPDATE typed_tickets SET title = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, edit.title]).map_err(sqlite_err)?;
}
let body_event_ref = if body_changed {
let initial = previous.events.iter().find(|event| event.is_initial)
.ok_or_else(|| TicketError::Conflict("Ticket initial post is missing".into()))?;
conn.execute("UPDATE typed_ticket_events SET body = ?3, edited_at = ?4, edited_by = ?5 WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = -1", params![self.workspace_id, ticket_id, updated_body.as_ref().expect("changed body").as_str(), now, edit.author]).map_err(sqlite_err)?;
Some(initial.event_ref.as_str())
} else {
None
};
if targets_changed {
self.replace_ticket_targets(conn, &ticket_id, targets.expect("changed targets"))?;
}
self.touch_ticket(conn, &ticket_id, &now)?;
content_changes::ContentChange::ContentEdit {
title_changed,
body_event_ref,
targets_changed,
}.insert(conn, &self.workspace_id, &ticket_id, edit.author.as_deref(), &now)?;
let current = self.load_ticket(conn, &ticket_id)?;
Ok((previous, current))
+139 -26
View File
@@ -20,7 +20,7 @@ use crate::{Result, TicketError, sqlite_err};
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 12;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 13;
const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline";
#[derive(Clone, Copy)]
@@ -320,11 +320,10 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
}
}
/// Composes retained 6/7, tags-only 8 or cases-only 9 through component 10, then
/// appends the 10→11 assignment and 11→12 post migrations without replacing applied markers.
/// The host owns the surrounding transaction (for example Workspace 91→92).
/// 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 12 is verified only; immutable judgments are never reapplied.
/// Current component 13 is verified only; historical edits are never backfilled.
pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> Result<()> {
if connection.is_autocommit() {
return Err(TicketError::Sqlite(
@@ -348,7 +347,7 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R
create_latest_ticket_schema(connection)?;
connection
.execute(
"INSERT INTO ticket_schema_migrations VALUES (12, ?1, ?2)",
"INSERT INTO ticket_schema_migrations VALUES (13, ?1, ?2)",
params![BASELINE_MIGRATION_NAME, chrono::Utc::now().to_rfc3339()],
)
.map_err(sqlite_err)?;
@@ -374,7 +373,8 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R
TicketSchemaShape::Cases9 => add_tag_schema(connection)?,
TicketSchemaShape::Composed10
| TicketSchemaShape::Tracking11
| TicketSchemaShape::Posts12 => {}
| TicketSchemaShape::Posts12
| TicketSchemaShape::ContentChanges13 => {}
}
if version < 10 {
connection
@@ -392,6 +392,10 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R
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)?;
}
}
verify_sqlite_ticket_schema(connection)
})();
@@ -416,6 +420,7 @@ enum TicketSchemaShape {
Composed10,
Tracking11,
Posts12,
ContentChanges13,
}
impl TicketSchemaShape {
fn from_version(version: i64) -> Self {
@@ -426,6 +431,7 @@ impl TicketSchemaShape {
10 => Self::Composed10,
11 => Self::Tracking11,
12 => Self::Posts12,
13 => Self::ContentChanges13,
_ => unreachable!(),
}
}
@@ -437,25 +443,34 @@ impl TicketSchemaShape {
Self::Composed10 => 10,
Self::Tracking11 => 11,
Self::Posts12 => 12,
Self::ContentChanges13 => 13,
}
}
fn tags(self) -> bool {
matches!(
self,
Self::Tags8 | Self::Composed10 | Self::Tracking11 | Self::Posts12
Self::Tags8
| Self::Composed10
| Self::Tracking11
| Self::Posts12
| Self::ContentChanges13
)
}
fn cases(self) -> bool {
matches!(
self,
Self::Cases9 | Self::Composed10 | Self::Tracking11 | Self::Posts12
Self::Cases9
| Self::Composed10
| Self::Tracking11
| Self::Posts12
| Self::ContentChanges13
)
}
}
/// 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::Posts12)
verify_ticket_schema_shape(connection, TicketSchemaShape::ContentChanges13)
}
/// Retained Workspace schema-66 migration produces the pre-classification v7
@@ -569,7 +584,10 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
continue;
}
let mut columns = columns.to_vec();
if matches!(shape, TicketSchemaShape::Posts12) {
if matches!(
shape,
TicketSchemaShape::Posts12 | TicketSchemaShape::ContentChanges13
) {
if table == "typed_tickets" {
columns.retain(|c| c.name != "body");
columns.push(column("created_by", "TEXT", false, 0));
@@ -810,7 +828,9 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
if matches!(
shape,
TicketSchemaShape::Tracking11 | TicketSchemaShape::Posts12
TicketSchemaShape::Tracking11
| TicketSchemaShape::Posts12
| TicketSchemaShape::ContentChanges13
) {
if let Err(error) =
crate::assignment_tracking::verify_assignment_tracking_schema(connection)
@@ -818,7 +838,10 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
push_diagnostic(&mut diagnostics, error.to_string());
}
}
if matches!(shape, TicketSchemaShape::Posts12) {
if matches!(
shape,
TicketSchemaShape::Posts12 | TicketSchemaShape::ContentChanges13
) {
collect_index_diagnostics(
connection,
"typed_ticket_events",
@@ -828,6 +851,9 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape)
&mut diagnostics,
);
}
if matches!(shape, TicketSchemaShape::ContentChanges13) {
collect_content_change_diagnostics(connection, &mut diagnostics)?;
}
if diagnostics.is_empty() {
Ok(())
} else {
@@ -849,7 +875,90 @@ fn create_latest_ticket_schema(connection: &Connection) -> Result<()> {
.execute_batch(include_str!("latest_schema.sql"))
.map_err(sqlite_err)?;
crate::assignment_tracking::migrate_assignment_tracking(connection)?;
migrate_posts(connection)
migrate_posts(connection)?;
migrate_content_changes(connection)
}
fn migrate_content_changes(connection: &Connection) -> Result<()> {
connection
.execute_batch(include_str!("content_changes.sql"))
.map_err(sqlite_err)
}
fn collect_content_change_diagnostics(
connection: &Connection,
diagnostics: &mut Vec<String>,
) -> Result<()> {
const TABLE: &str = "typed_ticket_content_changes";
collect_table_diagnostics(
connection,
TABLE,
&[
column("change_id", "TEXT", true, 1),
column("workspace_id", "TEXT", true, 0),
column("ticket_id", "TEXT", true, 0),
column("kind", "TEXT", true, 0),
column("actor", "TEXT", false, 0),
column("at", "TEXT", true, 0),
column("title_changed", "INTEGER", true, 0),
column("body_changed", "INTEGER", true, 0),
column("targets_changed", "INTEGER", true, 0),
column("source_event_ref", "TEXT", false, 0),
],
CHILD_FOREIGN_KEYS,
diagnostics,
);
// These new objects are wholly Ticket-owned. Compare their complete definitions,
// including CHECK predicates and the composite FK, not just column affinities.
// Additional host triggers are independent and remain permitted.
let ddl = include_str!("content_changes.sql");
let table_sql = format!(
"CREATE TABLE {}",
ddl.split_once("CREATE TABLE ")
.expect("table DDL")
.1
.split_once(";\n")
.expect("table terminator")
.0
);
let trigger_sql = format!(
"CREATE TRIGGER {}",
ddl.split_once("CREATE TRIGGER ")
.expect("trigger DDL")
.1
.trim()
);
for (kind, name, expected) in [
("table", TABLE, table_sql),
(
"trigger",
"typed_ticket_content_changes_no_update",
trigger_sql,
),
] {
let actual: Option<String> = connection
.query_row(
"SELECT sql FROM sqlite_master WHERE type = ?1 AND name = ?2",
params![kind, name],
|row| row.get(0),
)
.optional()
.map_err(sqlite_err)?;
let normalize = |sql: &str| {
sql.trim()
.trim_end_matches(';')
.split_whitespace()
.collect::<String>()
};
if actual.as_deref().map(normalize) != Some(normalize(&expected)) {
push_diagnostic(
diagnostics,
format!("invalid Ticket content change {kind} {name}"),
);
}
}
collect_foreign_key_check_diagnostics(connection, TABLE, diagnostics);
Ok(())
}
fn migrate_posts(connection: &Connection) -> Result<()> {
@@ -1105,22 +1214,25 @@ 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..=12).contains(&version) && name == BASELINE_MIGRATION_NAME {
if (6..=13).contains(&version) && name == BASELINE_MIGRATION_NAME {
return Ok(version);
}
}
}
for baseline in [10, 11] {
for baseline in [10, 11, 12] {
let mut expected = BTreeMap::from([(baseline, BASELINE_MIGRATION_NAME.to_owned())]);
if baseline == 10 {
expected.insert(11, "Ticket-owned assignment tracking".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 applied == &expected {
return Ok(11);
return Ok(version);
}
}
expected.insert(12, "Canonical Ticket posts".to_owned());
if applied == &expected {
return Ok(12);
}
}
Err(TicketError::Sqlite(format!(
@@ -1623,7 +1735,8 @@ mod tests {
BTreeMap::from([
(10, BASELINE_MIGRATION_NAME.to_owned()),
(11, "Ticket-owned assignment tracking".to_owned()),
(12, "Canonical Ticket posts".to_owned())
(12, "Canonical Ticket posts".to_owned()),
(13, "Ticket content change facts".to_owned())
])
);
}
@@ -1766,7 +1879,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 12"
"migration history must contain a canonical baseline and ordered migrations through 13"
));
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
}
@@ -1788,7 +1901,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 12"
"migration history must contain a canonical baseline and ordered migrations through 13"
));
assert!(!table_exists(&connection, "typed_tickets").unwrap());
}
+7 -1
View File
@@ -1,3 +1,9 @@
//! Real SQLite component tests through TicketBackend, including durable edit facts.
//! Host capture is represented only by test triggers; Workspace routing is not tested here.
#[path = "posts/content_changes.rs"]
mod content_changes;
use rusqlite::{Connection, params};
use tempfile::TempDir;
use ticket::{
@@ -264,7 +270,7 @@ fn schema12_migrates_body_authors_archived_history_and_stable_references_then_re
|r| r.get::<_, i64>(0)
)
.unwrap(),
12
13
);
backend
.add_event("known".into(), comment("Next", "author"))
@@ -0,0 +1,609 @@
use super::*;
use ticket::{TicketBodyReplacement, TicketTarget, TicketTargetAccess, TicketTargetsEdit};
struct Fixture {
_temp: TempDir,
path: std::path::PathBuf,
backend: SqliteTicketBackend,
conn: Connection,
id: String,
}
impl Fixture {
fn new() -> Self {
let temp = TempDir::new().unwrap();
let path = temp.path().join("facts.db");
let backend = SqliteTicketBackend::open(&path, "workspace").unwrap();
let mut input = NewTicket::new("Original title");
input.body = MarkdownText::new("Private original body");
input.author = Some("creator".into());
let id = backend.create(input).unwrap().id;
let conn = Connection::open(&path).unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON").unwrap();
Self {
_temp: temp,
path,
backend,
conn,
id,
}
}
fn ticket(&self) -> ticket::Ticket {
self.backend.show(self.id.clone().into()).unwrap()
}
fn edit(&self, edit: TicketItemEdit) -> ticket::Ticket {
self.backend
.edit_item(self.id.clone().into(), edit)
.unwrap()
}
fn facts(&self) -> Vec<Fact> {
self.conn.prepare("SELECT change_id,workspace_id,ticket_id,kind,actor,at,title_changed,body_changed,targets_changed,source_event_ref FROM typed_ticket_content_changes ORDER BY rowid").unwrap()
.query_map([], |r| Ok(Fact {
id: r.get(0)?, workspace: r.get(1)?, ticket: r.get(2)?, kind: r.get(3)?, actor: r.get(4)?, at: r.get(5)?,
title: r.get(6)?, body: r.get(7)?, targets: r.get(8)?, source: r.get(9)?,
})).unwrap().collect::<rusqlite::Result<_>>().unwrap()
}
}
#[derive(Debug, PartialEq, Eq)]
struct Fact {
id: String,
workspace: String,
ticket: String,
kind: String,
actor: Option<String>,
at: String,
title: bool,
body: bool,
targets: bool,
source: Option<String>,
}
fn targets(names: &[&str]) -> TicketTargetsEdit {
TicketTargetsEdit::Set {
targets: names
.iter()
.map(|name| TicketTarget {
repository_key: (*name).into(),
ref_selector: None,
access: TicketTargetAccess::ReadWrite,
})
.collect(),
}
}
#[test]
fn item_edits_record_actual_fields_and_contemporaneous_actor_once_without_text() {
let f = Fixture::new();
assert!(f.facts().is_empty());
let original = f.ticket();
let edits = [
(
TicketItemEdit {
title: Some("Changed title".into()),
..Default::default()
},
(true, false, false),
),
(
TicketItemEdit {
targets: Some(targets(&["main", "other"])),
..Default::default()
},
(false, false, true),
),
(
TicketItemEdit {
targets: Some(targets(&["other", "main"])),
..Default::default()
},
(false, false, true),
),
(
TicketItemEdit {
title: Some("Combined title".into()),
body: Some(MarkdownText::new("Private replacement body")),
targets: Some(TicketTargetsEdit::Clear),
..Default::default()
},
(true, true, true),
),
(
TicketItemEdit {
title: Some("Combined title".into()),
body_replacement: Some(TicketBodyReplacement {
old_string: "replacement".into(),
new_string: "updated".into(),
replace_all: false,
}),
..Default::default()
},
(false, true, false),
),
];
for (index, (mut edit, flags)) in edits.into_iter().enumerate() {
let actor = format!("editor-{index}");
edit.author = Some(actor.clone());
let current = f.edit(edit);
let facts = f.facts();
assert_eq!(facts.len(), index + 1);
let fact = facts.last().unwrap();
assert_eq!(fact.workspace, "workspace");
assert_eq!(fact.ticket, f.id);
assert_eq!(fact.kind, "content_edit");
assert_eq!(fact.actor.as_deref(), Some(actor.as_str()));
assert_eq!((fact.title, fact.body, fact.targets), flags);
assert_eq!(
fact.source.as_deref(),
flags.1.then_some(original.events[0].event_ref.as_str())
);
assert_eq!(Some(fact.at.as_str()), current.meta.updated_at.as_deref());
assert_eq!(current.events[0].author, original.events[0].author);
}
let facts = f.facts();
assert_eq!(
facts
.iter()
.map(|f| &f.id)
.collect::<std::collections::BTreeSet<_>>()
.len(),
facts.len()
);
// The schema itself excludes content snapshots, not just this particular INSERT.
let columns: Vec<String> = f
.conn
.prepare("SELECT name FROM pragma_table_info('typed_ticket_content_changes')")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap();
assert_eq!(
columns,
[
"change_id",
"workspace_id",
"ticket_id",
"kind",
"actor",
"at",
"title_changed",
"body_changed",
"targets_changed",
"source_event_ref"
]
);
let reopened = SqliteTicketBackend::open(&f.path, "workspace").unwrap();
assert_eq!(reopened.show(f.id.clone().into()).unwrap(), f.ticket());
assert_eq!(f.facts(), facts);
f.edit(TicketItemEdit {
title: Some("Unknown local editor".into()),
..Default::default()
});
assert_eq!(f.facts().last().unwrap().actor, None);
}
#[test]
fn post_edits_distinguish_initial_and_discussion_and_preserve_prior_edit_actors() {
let f = Fixture::new();
f.backend
.add_event(
f.id.clone().into(),
comment("Private discussion", "commenter"),
)
.unwrap();
assert!(f.facts().is_empty());
let before = f.ticket();
for (index, original) in before.events.iter().enumerate() {
let current = f
.backend
.edit_post(
f.id.clone().into(),
TicketPostEdit {
event_ref: original.event_ref.clone(),
expected_body: original.body.clone(),
body: MarkdownText::new(format!("Private edited post {index}")),
author: format!("post-editor-{index}"),
},
)
.unwrap();
let facts = f.facts();
assert_eq!(facts.len(), index + 1);
let fact = facts.last().unwrap();
assert_eq!(
fact.kind,
if original.is_initial {
"content_edit"
} else {
"comment_edit"
}
);
assert_eq!((fact.title, fact.body, fact.targets), (false, true, false));
assert_eq!(fact.source.as_ref(), Some(&original.event_ref));
assert_eq!(
fact.actor.as_deref(),
Some(format!("post-editor-{index}").as_str())
);
assert_eq!(
current.events[index].edited_at.as_deref(),
Some(fact.at.as_str())
);
}
let facts = f.facts();
let post = &f.ticket().events[0];
f.backend
.edit_post(
f.id.clone().into(),
TicketPostEdit {
event_ref: post.event_ref.clone(),
expected_body: post.body.clone(),
body: MarkdownText::new("Another private edit"),
author: "later-editor".into(),
},
)
.unwrap();
assert_eq!(&f.facts()[..2], facts.as_slice());
}
#[test]
fn noops_preserve_metadata_and_do_not_bypass_validation_or_emit_facts() {
let f = Fixture::new();
f.edit(TicketItemEdit {
targets: Some(targets(&["main"])),
author: Some("first".into()),
..Default::default()
});
let before = f.ticket();
let facts = f.facts();
for edit in [
TicketItemEdit {
title: Some(before.meta.title.clone()),
..Default::default()
},
TicketItemEdit {
body: Some(before.document.body.clone()),
..Default::default()
},
TicketItemEdit {
targets: Some(targets(&["main"])),
..Default::default()
},
TicketItemEdit {
title: Some(before.meta.title.clone()),
body: Some(before.document.body.clone()),
targets: Some(targets(&["main"])),
..Default::default()
},
] {
assert_eq!(
f.edit(TicketItemEdit {
author: Some("noop-editor".into()),
..edit
}),
before
);
}
let post = &before.events[0];
let edit = TicketPostEdit {
event_ref: post.event_ref.clone(),
expected_body: post.body.clone(),
body: post.body.clone(),
author: "noop-editor".into(),
};
assert_eq!(
f.backend
.edit_post(f.id.clone().into(), edit.clone())
.unwrap(),
before
);
assert!(
f.backend
.edit_post(
f.id.clone().into(),
TicketPostEdit {
expected_body: MarkdownText::new("stale"),
..edit
}
)
.is_err()
);
assert!(
f.backend
.edit_item(
f.id.clone().into(),
TicketItemEdit {
title: Some(before.meta.title.clone()),
author: Some(" ".into()),
..Default::default()
}
)
.is_err()
);
assert_eq!(f.facts(), facts);
assert_eq!(f.ticket(), before);
f.edit(TicketItemEdit {
targets: Some(TicketTargetsEdit::Clear),
..Default::default()
});
let empty = f.ticket();
let facts = f.facts();
assert_eq!(
f.edit(TicketItemEdit {
targets: Some(TicketTargetsEdit::Clear),
..Default::default()
}),
empty
);
assert_eq!(f.facts(), facts);
}
#[test]
fn failed_host_capture_rolls_back_edits_fact_and_trigger_writes_together() {
let f = Fixture::new();
f.conn.execute_batch("CREATE TABLE host_capture(change_id TEXT, actor TEXT); CREATE TRIGGER capture_fact AFTER INSERT ON typed_ticket_content_changes BEGIN INSERT INTO host_capture VALUES(NEW.change_id, NEW.actor); SELECT RAISE(ABORT, 'capture failed'); END;").unwrap();
let before = f.ticket();
assert!(
f.backend
.edit_item(
f.id.clone().into(),
TicketItemEdit {
title: Some("Changed title".into()),
body: Some(MarkdownText::new("Changed body")),
targets: Some(targets(&["main"])),
author: Some("item-editor".into()),
..Default::default()
}
)
.is_err()
);
let post = &before.events[0];
assert!(
f.backend
.edit_post(
f.id.clone().into(),
TicketPostEdit {
event_ref: post.event_ref.clone(),
expected_body: post.body.clone(),
body: MarkdownText::new("Changed post"),
author: "post-editor".into(),
}
)
.is_err()
);
assert_eq!(f.ticket(), before);
assert!(f.facts().is_empty());
assert_eq!(
f.conn
.query_row("SELECT count(*) FROM host_capture", [], |r| r
.get::<_, i64>(0))
.unwrap(),
0
);
f.conn.execute_batch("DROP TRIGGER capture_fact; CREATE TRIGGER capture_fact AFTER INSERT ON typed_ticket_content_changes BEGIN INSERT INTO host_capture VALUES(NEW.change_id, NEW.actor); END;").unwrap();
f.edit(TicketItemEdit {
title: Some("Successful title".into()),
author: Some("actual-editor".into()),
..Default::default()
});
let fact = f.facts().remove(0);
assert_eq!(
f.conn
.query_row("SELECT change_id, actor FROM host_capture", [], |r| Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?
)))
.unwrap(),
(fact.id, "actual-editor".into())
);
}
fn schema12(conn: &Connection) {
super::schema11(conn);
super::seed_legacy(conn, "retained");
conn.execute_batch(include_str!("../../src/post_migration.sql"))
.unwrap();
conn.execute_batch("INSERT INTO ticket_schema_migrations VALUES(12,'Canonical Ticket posts','original-post-marker');").unwrap();
}
fn schema(conn: &Connection) -> Vec<(String, String, String)> {
conn.prepare("SELECT type,name,sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type,name")
.unwrap()
.query_map([], |r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get::<_, String>(2)?
.split_whitespace()
.collect::<String>(),
))
})
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap()
}
#[test]
fn content_change_upgrade_matches_fresh_schema_without_backfill_and_preserves_markers() {
let upgraded = Connection::open_in_memory().unwrap();
upgraded
.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);")
.unwrap();
schema12(&upgraded);
migrate_sqlite_ticket_schema(&upgraded).unwrap();
let fresh = Connection::open_in_memory().unwrap();
fresh
.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);")
.unwrap();
migrate_sqlite_ticket_schema(&fresh).unwrap();
let upgraded_schema = schema(&upgraded);
let fresh_schema = schema(&fresh);
assert_eq!(upgraded_schema.len(), fresh_schema.len());
for (upgraded_object, fresh_object) in upgraded_schema.iter().zip(&fresh_schema) {
assert_eq!(upgraded_object, fresh_object);
}
assert_eq!(
upgraded
.query_row(
"SELECT count(*) FROM typed_ticket_content_changes",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
0
);
assert_eq!(
upgraded
.query_row(
"SELECT body FROM typed_ticket_events WHERE event_index=-1",
[],
|r| r.get::<_, String>(0)
)
.unwrap(),
"Latest body"
);
assert_eq!(
upgraded
.query_row(
"SELECT applied_at FROM ticket_schema_migrations WHERE version=12",
[],
|r| r.get::<_, String>(0)
)
.unwrap(),
"original-post-marker"
);
let markers: Vec<(i64, String, String)> = upgraded
.prepare("SELECT version,name,applied_at FROM ticket_schema_migrations ORDER BY version")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap();
migrate_sqlite_ticket_schema(&upgraded).unwrap();
let after: Vec<(i64, String, String)> = upgraded
.prepare("SELECT version,name,applied_at FROM ticket_schema_migrations ORDER BY version")
.unwrap()
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.unwrap()
.collect::<rusqlite::Result<_>>()
.unwrap();
assert_eq!(markers, after);
}
#[test]
fn failed_content_change_migration_rolls_back_ddl_and_marker_without_committing_host() {
let conn = Connection::open_in_memory().unwrap();
schema12(&conn);
conn.execute_batch("CREATE TABLE host_work(value TEXT); CREATE TRIGGER typed_ticket_content_changes_no_update BEFORE UPDATE ON host_work BEGIN SELECT RAISE(ABORT, 'collision'); END;").unwrap();
let before = schema(&conn);
conn.execute_batch("BEGIN IMMEDIATE; INSERT INTO host_work VALUES('pending');")
.unwrap();
assert!(migrate_sqlite_ticket_schema_in_transaction(&conn).is_err());
assert!(!conn.is_autocommit());
assert_eq!(schema(&conn), before);
assert_eq!(
conn.query_row(
"SELECT max(version) FROM ticket_schema_migrations",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
12
);
assert_eq!(
conn.query_row("SELECT count(*) FROM host_work", [], |r| r.get::<_, i64>(0))
.unwrap(),
1
);
conn.execute_batch("ROLLBACK; DROP TRIGGER typed_ticket_content_changes_no_update;")
.unwrap();
migrate_sqlite_ticket_schema(&conn).unwrap();
verify_sqlite_ticket_schema(&conn).unwrap();
}
#[test]
fn fact_constraints_reject_invalid_shapes_updates_and_cross_workspace_parent() {
let f = Fixture::new();
let insert = |id: &str,
workspace: &str,
kind: &str,
title: i64,
body: i64,
targets: i64,
source: Option<&str>| {
f.conn.execute("INSERT INTO typed_ticket_content_changes VALUES(?1,?2,?3,?4,'actor','time',?5,?6,?7,?8)", params![id, workspace, f.id, kind, title, body, targets, source])
};
for (kind, title, body, targets, source) in [
("unknown", 1, 0, 0, None),
("content_edit", 0, 0, 0, None),
("content_edit", 2, 0, 0, None),
("content_edit", 0, 2, 0, Some("post")),
("content_edit", 0, 0, 2, None),
("content_edit", 1, 0, 0, Some("post")),
("content_edit", 0, 1, 0, None),
("content_edit", 0, 1, 0, Some(" ")),
("comment_edit", 1, 1, 0, Some("post")),
("comment_edit", 0, 1, 1, Some("post")),
] {
assert!(insert("invalid", "workspace", kind, title, body, targets, source).is_err());
}
assert!(insert("foreign", "other-workspace", "content_edit", 1, 0, 0, None).is_err());
insert("valid", "workspace", "content_edit", 1, 0, 0, None).unwrap();
assert!(insert("valid", "workspace", "content_edit", 1, 0, 0, None).is_err());
assert!(
f.conn
.execute(
"UPDATE typed_ticket_content_changes SET actor='changed'",
[]
)
.is_err()
);
f.conn
.execute("DELETE FROM typed_ticket_content_changes", [])
.unwrap();
assert!(f.facts().is_empty());
}
#[test]
fn verifier_rejects_weakened_fact_constraints_trigger_and_migration_history() {
for (from, to) in [
("CHECK (kind IN ('content_edit', 'comment_edit'))", ""),
("CHECK (title_changed IN (0, 1))", ""),
("CHECK (body_changed IN (0, 1))", ""),
("CHECK (targets_changed IN (0, 1))", ""),
(
"CHECK (title_changed + body_changed + targets_changed > 0),",
"",
),
(
"REFERENCES typed_tickets(workspace_id, ticket_id)",
"REFERENCES typed_tickets(ticket_id, workspace_id)",
),
("ON DELETE CASCADE", "ON DELETE RESTRICT"),
(
"SELECT RAISE(ABORT, 'Ticket content change facts are immutable')",
"SELECT 1",
),
] {
let f = Fixture::new();
f.conn
.execute_batch("DROP TABLE typed_ticket_content_changes")
.unwrap();
let sql = include_str!("../../src/content_changes.sql").replace(from, to);
f.conn.execute_batch(&sql).unwrap();
assert!(verify_sqlite_ticket_schema(&f.conn).is_err(), "{from}");
assert!(migrate_sqlite_ticket_schema(&f.conn).is_err(), "{from}");
}
for mutation in [
"UPDATE ticket_schema_migrations SET name='unrecognized' WHERE version=13",
"INSERT INTO ticket_schema_migrations VALUES(12,'Canonical Ticket posts','invalid extra history')",
"UPDATE ticket_schema_migrations SET version=14 WHERE version=13",
] {
let f = Fixture::new();
f.conn.execute_batch(mutation).unwrap();
assert!(verify_sqlite_ticket_schema(&f.conn).is_err());
assert!(migrate_sqlite_ticket_schema(&f.conn).is_err());
}
}
@@ -640,6 +640,11 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
.await,
)
}
WorkspaceServerOperation::NotificationDiagnostics { query } => {
generated_workspace_response(
client.notification_diagnostics(workspace_id, query).await,
)
}
WorkspaceServerOperation::TicketAssignmentDiagnostics {
ticket_id,
query,
@@ -655,6 +660,26 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
.await,
)
}
WorkspaceServerOperation::TicketImplementationResult {
ticket_id,
request,
} => generated_workspace_response(
client
.ticket_implementation_result(workspace_id, ticket_id, request)
.await,
),
WorkspaceServerOperation::TicketImplementationResultGet {
ticket_id,
result_id,
} => generated_workspace_response(
client
.ticket_implementation_result_get(
workspace_id,
ticket_id,
result_id,
)
.await,
),
WorkspaceServerOperation::WorkerControlList => {
generated_workspace_response(
client.worker_control_list(workspace_id).await,
@@ -955,6 +980,19 @@ mod tests {
serde_json::json!({"workspace_id":"workspace-a", "ticket_id":"T-1", "assignment":null});
let answer: server_api::TicketAssignmentAnswerRequest = serde_json::from_value(serde_json::json!({ "assignment_id": "delegation", "expected_last_event_id": "observed", "operation_key": "answer-op", "body": "User answer\ncontinued" })).unwrap();
let cases = vec![
(
WorkspaceServerOperation::NotificationDiagnostics {
query: server_api::NotificationDiagnosticsQuery {
ticket: Some("T-1".into()),
source_event_id: Some("ticket-event:1".into()),
limit: Some(1),
..Default::default()
},
},
"GET",
"/api/w/workspace-a/notification-diagnostics?ticket=T-1&source_event_id=ticket-event%3A1&limit=1",
serde_json::json!({"items":[],"next_cursor":null,"source_checks":[]}),
),
(
WorkspaceServerOperation::TicketAssignmentSet {
ticket_id: "T-1".into(),
+252 -25
View File
@@ -40,6 +40,7 @@
mod assignment_public;
mod assignment_tracking;
mod notification_diagnostics;
mod tags;
use std::collections::{BTreeMap, BTreeSet, HashMap};
@@ -293,9 +294,9 @@ impl Tool for WorkspaceTicketReadTool {
))
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Workspace Ticket API request failed with HTTP status {}",
response.status
return Err(ToolError::ExecutionFailed(ticket_api_error_message(
response.status,
&response.body,
)));
}
let response_value: Value = serde_json::from_str(&response.body).map_err(|error| {
@@ -486,6 +487,7 @@ fn workspace_ticket_decision_definition(
fn workspace_ticket_tool_description(name: &str) -> String {
match name {
"TicketCaseUpdate" => TICKET_CASE_UPDATE_DESCRIPTION.into(),
"NotificationDiagnostics" => notification_diagnostics::DESCRIPTION.into(),
name if assignment_public::TOOL_NAMES.contains(&name) => {
assignment_public::description(name).into()
}
@@ -639,16 +641,20 @@ impl TicketFeatureAccess {
assignment_public::READ_TOOL_NAMES.contains(name)
|| self.authoring
|| self.workflow
|| (*name == "TicketAssignmentAnswer" && self.thread)
|| (matches!(
*name,
"TicketAssignmentAnswer" | "TicketImplementationResult"
) && self.thread)
}),
)
.chain(std::iter::once(notification_diagnostics::NAME))
.collect()
}
fn allows_tool(self, name: &str) -> bool {
// Publishing a decision tool is not an authority grant. The injected
// Workspace transport lets the Backend validate the current assignment
// or author/orchestrator permission on every decision, in both modes.
// Profile tool policy controls case-decision access in both modes.
// The Backend verifies actor/Workspace identity and CAS/replay, not
// orchestration assignment as an additional case-decision permission.
READ_ONLY_TOOL_NAMES.contains(&name)
|| (name == "TicketCaseUpdate" && (self.authoring || self.thread || self.workflow))
|| (self.authoring && AUTHORING_TOOL_NAMES.contains(&name))
@@ -665,6 +671,8 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &[
"TicketAssignmentTrackingHistory",
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketImplementationResultQuery",
"NotificationDiagnostics",
];
const AUTHORING_TOOL_NAMES: &[&str] = &[
@@ -676,7 +684,7 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketRelationRemove",
];
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment", "TicketImplementationResult"];
#[cfg(test)]
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
@@ -701,6 +709,9 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketAssignmentAnswer",
"TicketImplementationResult",
"TicketImplementationResultQuery",
"NotificationDiagnostics",
];
#[cfg(test)]
@@ -727,6 +738,9 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketAssignmentAnswer",
"TicketImplementationResult",
"TicketImplementationResultQuery",
"NotificationDiagnostics",
];
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
@@ -921,9 +935,9 @@ impl WorkspaceHttpTicketBackend {
TicketError::Conflict(format!("ticket REST request failed: {error}"))
})?;
if !response.is_success() {
return Err(TicketError::Conflict(format!(
"ticket REST API request failed with HTTP status {}",
response.status
return Err(TicketError::Conflict(ticket_api_error_message(
response.status,
&response.body,
)));
}
serde_json::from_str(&response.body)
@@ -997,9 +1011,9 @@ impl WorkspaceHttpTicketBackend {
TicketError::Conflict(format!("ticket REST request failed: {error}"))
})?;
if !response.is_success() {
return Err(TicketError::Conflict(format!(
"ticket REST API request failed with HTTP status {}",
response.status
return Err(TicketError::Conflict(ticket_api_error_message(
response.status,
&response.body,
)));
}
Ok(TicketBackendOperationResult::Unit)
@@ -1631,12 +1645,30 @@ fn native_ticket_operation(tool_name: &str) -> Option<NativeTicketOperation> {
identity_field: Some("ticket"),
mutating: false,
},
"NotificationDiagnostics" => NativeTicketOperation {
operation: "notification_diagnostics",
surface: NativeTicketSurface::Collection,
identity_field: None,
mutating: false,
},
"TicketAssignmentDiagnostics" => NativeTicketOperation {
operation: "assignment_diagnostics",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: false,
},
"TicketImplementationResult" => NativeTicketOperation {
operation: "implementation_result",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: true,
},
"TicketImplementationResultQuery" => NativeTicketOperation {
operation: "implementation_result_query",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: false,
},
"TicketAssignmentAnswer" => NativeTicketOperation {
operation: "assignment_answer",
surface: NativeTicketSurface::Item,
@@ -1765,11 +1797,16 @@ pub(crate) fn enabled_ticket_definitions(
assignment_public::PublicAssignmentKind::Assignments,
assignment_public::PublicAssignmentKind::Diagnostics,
assignment_public::PublicAssignmentKind::Answer,
assignment_public::PublicAssignmentKind::ImplementationResult,
assignment_public::PublicAssignmentKind::ImplementationResultQuery,
] {
if allowed.contains(&kind.name()) {
definitions.push(assignment_public::definition(client.clone(), kind));
}
}
if allowed.contains(&notification_diagnostics::NAME) {
definitions.push(notification_diagnostics::definition(client.clone()));
}
definitions
}
@@ -2100,6 +2137,56 @@ fn authorize_native_ticket(
}
}
// Only render the public API error envelope, never an arbitrary proxy body or
// unknown fields. Backend owns redaction of its public message; bound it here
// and keep diagnostics to stable codes rather than raw provider details.
fn ticket_api_error_message(status: u16, body: &str) -> String {
let mut message = format!("ticket REST API request failed with HTTP status {status}");
if body.len() > 64 * 1024 {
return message;
}
let Ok(error) = serde_json::from_str::<server_api::RepositoryApiError>(body) else {
return message;
};
let detail = if error.message.trim().is_empty() {
&error.error
} else {
&error.message
};
let mut chars = detail.trim().chars();
let bounded: String = chars
.by_ref()
.take(1024)
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
if !bounded.is_empty() {
message.push_str(": ");
message.push_str(&bounded);
if chars.next().is_some() {
message.push('…');
}
}
let codes: Vec<_> = error
.diagnostics
.iter()
.take(4)
.map(|diagnostic| diagnostic.code.as_str())
.filter(|code| {
!code.is_empty()
&& code.len() <= 64
&& code
.bytes()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-' | b'.'))
})
.collect();
if !codes.is_empty() {
message.push_str(" [");
message.push_str(&codes.join(", "));
message.push(']');
}
message
}
fn map_ticket_tool_error(error: ToolError) -> WipOperationError {
match error {
ToolError::InvalidArgument(message) => {
@@ -2112,17 +2199,19 @@ fn map_ticket_tool_error(error: ToolError) -> WipOperationError {
format!("{code}: {message}"),
),
ToolError::ExecutionFailed(message) => {
if message.contains("HTTP status 401") || message.contains("HTTP status 403") {
protocol_failure(ProtocolErrorCode::PermissionDenied, message)
} else if message.contains("HTTP status 404") {
protocol_failure(ProtocolErrorCode::NotFound, message)
} else if message.contains("HTTP status 400")
|| message.contains("HTTP status 409")
|| message.contains("HTTP status 422")
{
protocol_failure(ProtocolErrorCode::InvalidArguments, message)
} else {
WipOperationError::OutcomeUnknown(message)
// Parse the transport status before the public detail. A message
// mentioning a different HTTP status must not change classification.
let status = message
.split_once("HTTP status ")
.and_then(|(_, tail)| tail.split(|c: char| c == ':' || c.is_whitespace()).next())
.and_then(|status| status.parse::<u16>().ok());
match status {
Some(401 | 403) => protocol_failure(ProtocolErrorCode::PermissionDenied, message),
Some(404) => protocol_failure(ProtocolErrorCode::NotFound, message),
Some(400 | 409 | 422) => {
protocol_failure(ProtocolErrorCode::InvalidArguments, message)
}
_ => WipOperationError::OutcomeUnknown(message),
}
}
ToolError::Internal(message) => WipOperationError::OutcomeUnknown(message),
@@ -2462,6 +2551,135 @@ mod tests {
})
}
#[tokio::test]
async fn ticket_api_errors_keep_public_details_in_tools_and_wip() {
for (status, code) in [
(403, ProtocolErrorCode::PermissionDenied),
(404, ProtocolErrorCode::NotFound),
(409, ProtocolErrorCode::InvalidArguments),
] {
let client = Arc::new(DecisionClient {
requests: Mutex::new(Vec::new()),
response: crate::worker::WorkspaceResponse {
status,
body: json!({
"error": "Rejected",
"message": "Verified actor is required; unrelated HTTP status 401",
"diagnostics": [{"code":"ticket_actor_required", "severity":"error", "message":"provider-private-detail"}]
}).to_string(),
},
});
let (_, tool) = workspace_ticket_decision_definition(
client.clone(),
WorkspaceTicketDecisionKind::CaseUpdate,
)();
let error = tool
.execute(
&decision_input().to_string(),
ToolExecutionContext::direct(),
)
.await
.unwrap_err();
let text = error.to_string();
assert!(text.contains(&format!("HTTP status {status}")), "{text}");
assert!(text.contains("Verified actor is required"), "{text}");
assert!(text.contains("ticket_actor_required"), "{text}");
assert!(!text.contains("provider-private-detail"));
assert!(
matches!(map_ticket_tool_error(error), WipOperationError::Protocol(ProtocolError {code: actual, ..}) if actual == code)
);
// Unit-returning mutations and read tools share the same formatter.
let error = WorkspaceHttpTicketBackend::request_unit(
client.clone(),
WorkspaceRequestMethod::Post,
"/test".into(),
None,
)
.unwrap_err();
assert!(error.to_string().contains("Verified actor is required"));
let (_, read) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
let error = read
.execute(r#"{"id":"T-739"}"#, ToolExecutionContext::direct())
.await
.unwrap_err();
assert!(error.to_string().contains("ticket_actor_required"));
let tools =
native_ticket_tools(client.clone(), TicketFeatureAccess::workspace_authoring())
.unwrap();
let error = execute_native_ticket_tool(
tools
.iter()
.find(|tool| tool.name == "TicketCaseUpdate")
.unwrap(),
&None,
decision_input(),
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: String::new(),
},
)
.await
.unwrap_err();
assert!(
matches!(error, WipOperationError::Protocol(ProtocolError {code: actual, ref message}) if actual == code && message.contains("Verified actor is required"))
);
}
}
#[test]
fn ticket_api_errors_bound_public_fields_and_ignore_unstructured_bodies() {
let prefix = "ticket REST API request failed with HTTP status 502";
for body in [
"<html>proxy-private-detail</html>".into(),
"not JSON".into(),
json!({"message":"not the public envelope", "token":"private"}).to_string(),
json!({"error":"Rejected", "message":"detail", "token":"private"}).to_string(),
"x".repeat(64 * 1024 + 1),
] {
assert_eq!(ticket_api_error_message(502, &body), prefix);
}
let body =
json!({"error":"Rejected", "message":format!("line\n\u{1b}{}", "あ".repeat(2000)),
"diagnostics":[{"code":"/private/path", "severity":"error", "message":"secret"}] })
.to_string();
let message = ticket_api_error_message(502, &body);
assert!(message.ends_with('…'));
assert!(message.len() < 4096);
assert!(!message.chars().any(char::is_control));
assert!(!message.contains("secret") && !message.contains("/private/path"));
let message = ticket_api_error_message(
502,
&json!({"error":"Bad Gateway", "message":"upstream reported HTTP status 403"})
.to_string(),
);
assert!(matches!(
map_ticket_tool_error(ToolError::ExecutionFailed(message)),
WipOperationError::OutcomeUnknown(_)
));
}
#[test]
fn case_tool_availability_is_profile_controlled_in_both_interfaces() {
for (access, allowed) in [
(TicketFeatureAccess::read_only(), false),
(TicketFeatureAccess::review(), false),
(TicketFeatureAccess::workspace_authoring(), true),
(TicketFeatureAccess::work_report(), true),
(TicketFeatureAccess::workflow(), true),
] {
assert_eq!(access.tool_names().contains(&"TicketCaseUpdate"), allowed);
let tools =
native_ticket_tools(decision_client(200, TicketState::Open), access).unwrap();
assert_eq!(
tools.iter().any(|tool| tool.name == "TicketCaseUpdate"),
allowed
);
}
}
#[test]
fn case_backend_sends_explicit_judgment_without_domain_author_claim() {
let client = decision_client(200, TicketState::Closed);
@@ -3282,6 +3500,15 @@ mod tests {
WipOperationError::Protocol(ProtocolError { code: actual, .. }) if actual == code
));
}
// Assignment tools use a separate adapter but share this WIP classifier.
assert!(matches!(
map_ticket_tool_error(ToolError::ExecutionFailed("Assignment request failed with HTTP status 403: denied; HTTP status 409 is unrelated".into())),
WipOperationError::Protocol(ProtocolError { code: ProtocolErrorCode::PermissionDenied, .. })
));
assert!(matches!(
map_ticket_tool_error(ToolError::ExecutionFailed("Assignment tracking request failed with HTTP status 409: stale; HTTP status 403 is unrelated".into())),
WipOperationError::Protocol(ProtocolError { code: ProtocolErrorCode::InvalidArguments, .. })
));
assert!(matches!(
map_ticket_tool_error(ToolError::ExecutionFailed("transport disconnected".into())),
WipOperationError::OutcomeUnknown(_)
@@ -22,9 +22,14 @@ pub(super) const TOOL_NAMES: &[&str] = &[
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketAssignmentAnswer",
"TicketImplementationResult",
"TicketImplementationResultQuery",
];
pub(super) const READ_TOOL_NAMES: &[&str] = &[
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketImplementationResultQuery",
];
pub(super) const READ_TOOL_NAMES: &[&str] =
&["TicketAssignmentsQuery", "TicketAssignmentDiagnostics"];
#[derive(Clone, Copy)]
pub(super) enum PublicAssignmentKind {
@@ -33,6 +38,8 @@ pub(super) enum PublicAssignmentKind {
Assignments,
Diagnostics,
Answer,
ImplementationResult,
ImplementationResultQuery,
}
#[derive(Deserialize, JsonSchema)]
@@ -80,6 +87,20 @@ struct AnswerInput {
request: server_api::TicketAssignmentAnswerRequest,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ImplementationResultInput {
ticket: String,
#[serde(flatten)]
request: server_api::TicketImplementationResultRequest,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ImplementationResultQuery {
ticket: String,
result_id: String,
}
impl PublicAssignmentKind {
pub(super) fn name(self) -> &'static str {
match self {
@@ -88,6 +109,8 @@ impl PublicAssignmentKind {
Self::Assignments => TOOL_NAMES[2],
Self::Diagnostics => TOOL_NAMES[3],
Self::Answer => TOOL_NAMES[4],
Self::ImplementationResult => TOOL_NAMES[5],
Self::ImplementationResultQuery => TOOL_NAMES[6],
}
}
fn schema(self) -> Value {
@@ -109,6 +132,10 @@ impl PublicAssignmentKind {
Self::Assignments => json!(schemars::schema_for!(AssignmentsInput)),
Self::Diagnostics => json!(schemars::schema_for!(DiagnosticsInput)),
Self::Answer => json!(schemars::schema_for!(AnswerInput)),
Self::ImplementationResult => json!(schemars::schema_for!(ImplementationResultInput)),
Self::ImplementationResultQuery => {
json!(schemars::schema_for!(ImplementationResultQuery))
}
}
}
}
@@ -129,6 +156,12 @@ pub(super) fn description(name: &str) -> &'static str {
"TicketAssignmentAnswer" => {
"Append continuing input to the observed current waiting/inprogress assignment with assignment_id, expected_last_event_id, stable operation_key and bounded answer body. Query current tracking first. Answers continue the same row without automatic inprogress; done requires explicit new Assign/reissue instead. Backend authenticates/fences source, current assignment and event CAS, then saves input/audit/exact receipt atomically and notifies committed input. Exact retry retains full input/key; stale/cleared/replaced conflicts require rereading, and unknown outcome must not be retried as a fresh operation. No model actor/role claims, new assignment, case closure or Worker start."
}
"TicketImplementationResult" => {
"Save an explicit handoff, result or blocker for the current implementation assignment and its original Orchestrator delegation. Read ShowTicket first for assignment identities. Backend authenticates the implementation Worker and fences both assignments; this is not AssignmentAnswer or a handling update. Optional MR/source identifies retained evidence, never approval. Retry an unknown response using exactly the same operation_key and full input; changed input with the same key is rejected. Result and durable notification source commit together. No case closure, new Assign, Worker stop or integration occurs."
}
"TicketImplementationResultQuery" => {
"Read one saved implementation result by Ticket and result_id from a result notification/receipt. Historical evidence is not a new request or approval; reread current delegation and MR before acting."
}
_ => unreachable!("unknown public assignment tool"),
}
}
@@ -245,6 +278,47 @@ impl Tool for PublicAssignmentTool {
query: input.query,
}
}
PublicAssignmentKind::ImplementationResult => {
let input: ImplementationResultInput =
serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
for (name, value) in [
("assignment_id", &input.request.assignment_id),
(
"orchestrator_assignment_id",
&input.request.orchestrator_assignment_id,
),
("operation_key", &input.request.operation_key),
] {
identity(name, value)?;
}
if input.request.body.trim().is_empty()
|| input.request.body.len() > 16384
|| input.request.body.contains('\0')
{
return Err(ToolError::InvalidArgument(
"Result body must be nonblank, NUL-free and at most 16384 bytes".into(),
));
}
if let Some(mr) = &input.request.merge_request {
identity("merge_request_id", &mr.merge_request_id)?;
identity("subject_ref", &mr.subject_ref)?;
}
WorkspaceServerOperation::TicketImplementationResult {
ticket_id: input.ticket,
request: input.request,
}
}
PublicAssignmentKind::ImplementationResultQuery => {
let input: ImplementationResultQuery =
serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
identity("result_id", &input.result_id)?;
WorkspaceServerOperation::TicketImplementationResultGet {
ticket_id: input.ticket,
result_id: input.result_id,
}
}
PublicAssignmentKind::Answer => {
let input: AnswerInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
@@ -320,6 +394,13 @@ impl Tool for PublicAssignmentTool {
}
serde_json::to_value(result)
}
PublicAssignmentKind::ImplementationResult
| PublicAssignmentKind::ImplementationResultQuery => serde_json::to_value(
serde_json::from_str::<server_api::TicketImplementationResultReceipt>(
&response.body,
)
.map_err(decode)?,
),
PublicAssignmentKind::Answer => serde_json::to_value(
serde_json::from_str::<server_api::TicketAssignmentAnswerResponse>(&response.body)
.map_err(decode)?,
@@ -57,6 +57,16 @@ fn cases() -> Vec<(PublicAssignmentKind, Value, Value)> {
json!({"ticket":"T-743", "assignment_id":"delegation", "after_input_event_id":"ticket-event:7", "after_start_operation_id":"start/7", "limit":10}),
json!({"assignment_id":"delegation", "current":true, "input_origin_known":true, "actionable":true, "inputs":[], "next_input_event_id":null, "starts":[], "next_start_operation_id":null}),
),
(
PublicAssignmentKind::ImplementationResult,
json!({"ticket":"T-743","assignment_id":"implementation","orchestrator_assignment_id":"delegation","operation_key":"result-1","kind":"result","body":"Research without an MR","merge_request":null}),
json!({"result_id":"result-id","ticket_id":"T-743","created_at":"now","request":{"assignment_id":"implementation","orchestrator_assignment_id":"delegation","operation_key":"result-1","kind":"result","body":"Research without an MR","merge_request":null}}),
),
(
PublicAssignmentKind::ImplementationResultQuery,
json!({"ticket":"T-743","result_id":"result-id"}),
json!({"result_id":"result-id","ticket_id":"T-743","created_at":"now","request":{"assignment_id":"implementation","orchestrator_assignment_id":"delegation","operation_key":"result-1","kind":"result","body":"Research without an MR","merge_request":null}}),
),
(
PublicAssignmentKind::Answer,
json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"waiting", "operation_key":"answer-1", "body":"Proceed with option A"}),
@@ -0,0 +1,99 @@
//! Host-bound read-only notification evidence, shared by normal Tools and WIP.
#[cfg(test)]
mod tests;
use super::*;
use crate::worker::WorkspaceServerOperation;
pub(super) const NAME: &str = "NotificationDiagnostics";
pub(super) const DESCRIPTION: &str = "Read bounded, paginated saved notification routing and delivery evidence within the current Workspace. Filter by Ticket, MR, Job, source event, assignment, delivery or exact source identity; continue with next_cursor and unchanged filters (limit 1..100). Distinguishes awaiting routing, suppression, deferred delivery, rejection, unknown acceptance and Runtime acceptance. Acceptance does not prove model processing or successful work. Historical evidence and retry_candidate are not execution authority. This read never routes, sends, assigns, starts/restores Workers or changes case/handling; no notification bodies or binding credentials are exposed.";
struct DiagnosticsTool {
client: Arc<dyn WorkspaceClient>,
}
#[async_trait]
impl Tool for DiagnosticsTool {
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
self.client
.workspace_id()
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| {
ToolError::InvalidArgument(
"Notification diagnostics require a Backend Workspace; no local fallback"
.into(),
)
})?;
let query: server_api::NotificationDiagnosticsQuery =
serde_json::from_str(input).map_err(|e| ToolError::InvalidArgument(e.to_string()))?;
if !(1..=100).contains(&query.limit.unwrap_or(50)) {
return Err(ToolError::InvalidArgument(
"limit must be between 1 and 100".into(),
));
}
for value in [
&query.ticket,
&query.merge_request_id,
&query.job_id,
&query.source_event_id,
&query.assignment_id,
&query.delivery_id,
&query.source_id,
&query.cursor,
]
.into_iter()
.flatten()
{
if value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) {
return Err(ToolError::InvalidArgument(
"Notification filters must be nonempty, control-free and at most 512 bytes"
.into(),
));
}
}
let response = self
.client
.execute_server_operation(WorkspaceServerOperation::NotificationDiagnostics { query })
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
if !response.is_success() {
// Do not expose an arbitrary error body from upstream.
return Err(ToolError::ExecutionFailed(format!(
"Notification diagnostics failed with HTTP status {}",
response.status
)));
}
if response.body.len() > 1024 * 1024 {
return Err(ToolError::ExecutionFailed(
"Notification response exceeds 1 MiB".into(),
));
}
let page: server_api::NotificationDiagnosticsPage = serde_json::from_str(&response.body)
.map_err(|_| {
ToolError::ExecutionFailed("Invalid notification diagnostics response".into())
})?;
if page.items.len() > 100 {
return Err(ToolError::ExecutionFailed(
"Notification response exceeds 100 items".into(),
));
}
Ok(ToolOutput {
summary: NAME.into(),
content: Some(
serde_json::to_string(&page).map_err(|e| ToolError::Internal(e.to_string()))?,
),
attachments: vec![],
})
}
}
pub(super) fn definition(client: Arc<dyn WorkspaceClient>) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new(NAME)
.description(DESCRIPTION)
.input_schema(json!(schemars::schema_for!(
server_api::NotificationDiagnosticsQuery
))),
Arc::new(DiagnosticsTool {
client: client.clone(),
}) as Arc<dyn Tool>,
)
})
}
@@ -0,0 +1,159 @@
use super::*;
#[tokio::test]
async fn notification_wip_executes_same_read_and_honors_profile_denial() {
for denied in [false, true] {
let client = Arc::new(Client {
requests: Mutex::new(vec![]),
status: 200,
body: json!({"items":[],"next_cursor":null,"source_checks":[]}).to_string(),
});
let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::read_only()).unwrap();
let handler = TicketCollectionWipHandler {
tools: operation_map(
tools
.into_iter()
.filter(|t| t.projection.surface == NativeTicketSurface::Collection)
.collect(),
),
permissions: denied.then_some(ToolPermissionConfig {
default_action: ToolPermissionAction::Deny,
rules: vec![],
}),
collection_route: "/tickets".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
let args = BTreeMap::from([("ticket".into(), WipValue::String("T-755".into()))]);
let result = handler
.call(
"notification_diagnostics",
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into(),
},
)
.await;
assert_eq!(result.is_err(), denied);
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), usize::from(!denied));
if !denied {
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
assert_eq!(
requests[0].path,
"/api/w/workspace/notification-diagnostics?ticket=T-755"
);
}
}
}
use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
#[derive(Debug)]
struct Client {
requests: Mutex<Vec<WorkspaceRequest>>,
status: u16,
body: String,
}
impl WorkspaceClient for Client {
fn workspace_id(&self) -> Option<&str> {
Some("workspace")
}
fn kind(&self) -> &str {
"notification-test"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
Ok(WorkspaceResponse {
status: self.status,
body: self.body.clone(),
})
}
}
#[tokio::test]
async fn notification_tool_and_wip_share_bounded_read_contract() {
let client = Arc::new(Client {
requests: Mutex::new(vec![]),
status: 200,
body: json!({"items":[{"source_id":"notification-source:5","source_event_id":"mr-event:m:e","event_kind":"mr_review","recipient_role":"orchestrator","stage":"acceptance_unknown","legacy_evidence":false,"acceptance_outcome_unknown":true,"target_resolution":"multiple_authorized_targets","retry_candidate":true,"model_processing_confirmed":false}],"next_cursor":null,"source_checks":[]}).to_string(),
});
let (meta, tool) = definition(client.clone())();
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
let input = json!({"ticket":"T-755","merge_request_id":"mr/1?","job_id":"job","cursor":"notification-source:4","limit":10});
assert!(schema.is_valid(&input));
let output = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let page: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(page["items"][0]["acceptance_outcome_unknown"], true);
assert_eq!(
page["items"][0]["target_resolution"],
"multiple_authorized_targets"
);
assert!(page["items"][0]["target_worker_id"].is_null());
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
assert!(
requests[0]
.path
.starts_with("/api/w/workspace/notification-diagnostics?")
);
assert!(requests[0].path.contains("merge_request_id=mr%2F1%3F"));
assert!(requests[0].body.is_none());
drop(requests);
for bad in [
json!({"limit":0}),
json!({"limit":101}),
json!({"job_id":""}),
json!({"source_event_id":"x".repeat(513)}),
json!({"worker_id":"forged"}),
json!({"workspace_id":"other"}),
] {
assert!(
tool.execute(&bad.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
}
assert_eq!(client.requests.lock().unwrap().len(), 1);
let projection = native_ticket_operation(NAME).unwrap();
assert_eq!(projection.operation, "notification_diagnostics");
assert!(!projection.mutating);
assert!(projection.identity_field.is_none());
for access in [
TicketFeatureAccess::read_only(),
TicketFeatureAccess::workspace_authoring(),
TicketFeatureAccess::workflow(),
] {
assert!(access.tool_names().contains(&NAME));
}
}
#[tokio::test]
async fn notification_tool_rejects_raw_or_undeclared_backend_content() {
for (status, body) in [
(403, "SECRET-ERROR".to_string()),
(
200,
json!({"items":[],"next_cursor":null,"source_checks":[],"credential":"SECRET"})
.to_string(),
),
(200, "not-json-SECRET".into()),
] {
let (_, tool) = definition(Arc::new(Client {
requests: Mutex::new(vec![]),
status,
body,
}))();
let error = tool
.execute("{}", ToolExecutionContext::direct())
.await
.unwrap_err();
assert!(!error.to_string().contains("SECRET"));
}
}
+35
View File
@@ -746,6 +746,9 @@ pub enum WorkspaceServerOperation {
ticket_id: String,
query: server_api::TicketAssignmentsQuery,
},
NotificationDiagnostics {
query: server_api::NotificationDiagnosticsQuery,
},
TicketAssignmentDiagnostics {
ticket_id: String,
query: server_api::TicketAssignmentDiagnosticsQuery,
@@ -754,6 +757,14 @@ pub enum WorkspaceServerOperation {
ticket_id: String,
request: server_api::TicketAssignmentAnswerRequest,
},
TicketImplementationResult {
ticket_id: String,
request: server_api::TicketImplementationResultRequest,
},
TicketImplementationResultGet {
ticket_id: String,
result_id: String,
},
WorkerControlList,
WorkerControlSpawn(server_api::CreateWorkspaceWorkerRequest),
WorkerControlInput {
@@ -906,6 +917,13 @@ fn workspace_server_operation_request(
&query,
)
}
WorkspaceServerOperation::NotificationDiagnostics { query } => {
workspace_server_query_request(
WorkspaceRequestMethod::Get,
format!("{base}/notification-diagnostics"),
&query,
)
}
WorkspaceServerOperation::TicketAssignmentDiagnostics { ticket_id, query } => {
workspace_server_query_request(
WorkspaceRequestMethod::Get,
@@ -925,6 +943,23 @@ fn workspace_server_operation_request(
&request,
)
}
WorkspaceServerOperation::TicketImplementationResult { ticket_id, request } => {
workspace_server_json_request(
format!(
"/workspaces/{workspace_id}/tickets/{}/implementation-results",
encode_workspace_path_segment(&ticket_id)
),
&request,
)
}
WorkspaceServerOperation::TicketImplementationResultGet {
ticket_id,
result_id,
} => Ok(WorkspaceRequest::get(format!(
"/workspaces/{workspace_id}/tickets/{}/implementation-results/{}",
encode_workspace_path_segment(&ticket_id),
encode_workspace_path_segment(&result_id)
))),
WorkspaceServerOperation::WorkerControlList => Ok(WorkspaceRequest::get(format!(
"{base}/worker-control/workers"
))),
@@ -1,13 +1,14 @@
//! Durable operational delivery and startup fencing, independent of Ticket handling.
//!
//! All delivery triggers use the same pending-input scan: current actionable delegation,
//! committed input origin and open case, never Queue/open state alone. Waiting is not a
//! resend reason; comments, self-reports and case/routing history are not new requests.
//! Migration preserves evidence but creates origins only for reconciled assignment-specific
//! legacy Queue fences. Unknown/history/done/released assignments are not fresh work.
//! Notification eligibility comes from the shared routing policy and its persisted decisions.
//! This adapter retains the original assignment delivery identity, immutable request and ACK.
//! Retained migration SQL establishes origins only for reconciled assignment-specific legacy
//! Queue fences; migration does not reinterpret historical comments as new requests.
//!
//! Reserve, prepare and acknowledge recheck delegation, singleton and live binding under
//! IMMEDIATE ownership. Persist the entire rendered request before Runtime I/O, then reuse
//! Reserve and prepare recheck delegation, singleton 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
//! its request ID and content on response loss. Send outside the transaction; acknowledgement
//! proves Runtime acceptance, not model consideration, execution, handling or fulfillment.
//! Async callers offload whole helpers, never split authorization/receipt/CAS/audit across awaits.
@@ -132,46 +133,6 @@ pub(super) fn install_post_inputs(conn: &Connection) -> Result<()> {
Ok(())
}
const INPUTS_SQL: &str = r#"
WITH eligible AS (
SELECT a.*,o.input_event_id,o.after_ticket_event_index
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 ticket_assignment_delivery_origins o ON o.workspace_id=a.workspace_id AND o.assignment_id=a.assignment_id
JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id
WHERE a.workspace_id=?1 AND a.role='orchestrator' AND a.principal_kind='workspace_agent'
AND a.principal_id='workspace-orchestrator' AND t.workflow_state='open'
AND json_extract(a.tracking_json,'$.status') IN ('waiting','inprogress')
AND NOT EXISTS(SELECT 1 FROM ticket_assignment_work_releases r
WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id)
), inputs AS (
SELECT ticket_id,assignment_id,input_event_id,tracking_json,-1 AS input_order FROM eligible
UNION ALL
SELECT a.ticket_id,a.assignment_id,'ticket-event:' || e.event_index,a.tracking_json,e.event_index
FROM eligible a JOIN typed_ticket_events e ON e.workspace_id=a.workspace_id AND e.ticket_id=a.ticket_id
WHERE e.event_index>a.after_ticket_event_index
-- Continuing request intent is explicit: fenced assignment answers or item content edits.
-- Ordinary comments and all case/routing/handling history are not new requests.
AND e.kind IN ('assignment_answer','item_edit')
AND (e.kind<>'assignment_answer' OR EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value=a.assignment_id))
AND e.kind NOT LIKE 'assignment_tracking%'
AND e.kind NOT LIKE 'assignment_delivery%'
AND COALESCE(e.state_field,'') NOT IN ('case','routing','assignment_tracking')
AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value<>a.assignment_id)
UNION ALL
SELECT a.ticket_id,a.assignment_id,'ticket-edit:' || printf('%020d',e.input_sequence),a.tracking_json,e.after_ticket_event_index+1
FROM eligible a JOIN ticket_assignment_content_inputs e
ON e.workspace_id=a.workspace_id AND e.ticket_id=a.ticket_id AND e.assignment_id=a.assignment_id
)
SELECT i.ticket_id,i.assignment_id,i.input_event_id,
json_extract(i.tracking_json,'$.waiting_reason'),json_extract(i.tracking_json,'$.next_action'),d.payload_json
FROM inputs i
LEFT JOIN ticket_assignment_deliveries d ON d.workspace_id=?1 AND d.assignment_id=i.assignment_id AND d.input_event_id=i.input_event_id
WHERE NOT EXISTS(SELECT 1 FROM ticket_assignment_delivery_acknowledgements ack
WHERE ack.workspace_id=?1 AND ack.delivery_id=d.delivery_id)
AND (?3 IS NULL OR i.assignment_id=?3) AND (?4 IS NULL OR i.input_event_id=?4)
"#;
fn decode_payload(payload: &str) -> Result<TicketAssignmentDelivery> {
serde_json::from_str(payload).map_err(|e| Error::Store(e.to_string()))
}
@@ -181,57 +142,51 @@ pub(super) fn list_in_tx(
workspace_id: &str,
limit: usize,
) -> Result<Vec<TicketAssignmentDelivery>> {
inputs_in_tx(conn, workspace_id, limit, None, None)
if !(1..=100).contains(&limit) {
return Err(Error::InvalidInput("Delivery limit must be 1..100".into()));
}
notification_routing::pending_sources(conn, workspace_id, "orchestrator", limit)?
.into_iter()
.map(|(ticket, assignment, input, id)| {
delivery(conn, workspace_id, ticket, assignment, input, id)
})
.collect()
}
fn delivery(
conn: &Connection,
workspace: &str,
ticket_id: String,
assignment_id: String,
input_event_id: String,
delivery_id: String,
) -> Result<TicketAssignmentDelivery> {
let saved: Option<String> = conn.query_row("SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND assignment_id=?2 AND input_event_id=?3",params![workspace,assignment_id,input_event_id],|r|r.get(0)).optional()?;
if let Some(saved) = saved {
return decode_payload(&saved);
}
let (waiting_reason,next_action)=conn.query_row("SELECT json_extract(tracking_json,'$.waiting_reason'),json_extract(tracking_json,'$.next_action') FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace,assignment_id],|r|Ok((r.get(0)?,r.get(1)?)))?;
Ok(TicketAssignmentDelivery {
ticket_id,
assignment_id,
input_event_id,
request_id: delivery_id.clone(),
delivery_id,
waiting_reason,
next_action,
})
}
fn inputs_in_tx(
conn: &Connection,
workspace_id: &str,
limit: usize,
assignment_id: Option<&str>,
input_event_id: Option<&str>,
workspace: &str,
_limit: usize,
assignment: Option<&str>,
input: Option<&str>,
) -> Result<Vec<TicketAssignmentDelivery>> {
let sql = format!(
"{INPUTS_SQL} ORDER BY i.ticket_id,i.assignment_id,i.input_order,i.input_event_id LIMIT ?2"
);
let mut statement = conn.prepare(&sql)?;
let rows = statement.query_map(
params![workspace_id, limit as i64, assignment_id, input_event_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, Option<String>>(4)?,
row.get::<_, Option<String>>(5)?,
))
},
)?;
rows.map(|row| {
let (ticket_id, assignment_id, input_event_id, waiting_reason, next_action, payload) = row?;
if let Some(payload) = payload {
return decode_payload(&payload);
}
// Hash the identities, not mutable handling. Scope is Workspace-qualified.
let identities = serde_json::to_string(&(workspace_id, &assignment_id, &input_event_id))
.map_err(|e| Error::Store(e.to_string()))?;
let digest = Sha256::digest(identities.as_bytes());
let hex = digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let delivery_id = format!("assignment-delivery:{hex}");
Ok(TicketAssignmentDelivery {
ticket_id,
assignment_id,
input_event_id,
request_id: delivery_id.clone(),
delivery_id,
waiting_reason,
next_action,
})
})
.collect()
notification_routing::route_pending(conn, workspace)?;
let row: Option<(String,String,String,String)>=conn.query_row("SELECT s.ticket_id,s.assignment_id,s.input_event_id,d.delivery_id FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.workspace_id=?1 AND s.assignment_id=?2 AND s.input_event_id=?3 AND s.role='orchestrator' AND d.disposition='deliver' AND NOT EXISTS(SELECT 1 FROM ticket_assignment_delivery_acknowledgements a WHERE a.workspace_id=?1 AND a.delivery_id=d.delivery_id)",params![workspace,assignment,input],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional()?;
row.into_iter()
.map(|(ticket, a, i, id)| delivery(conn, workspace, ticket, a, i, id))
.collect()
}
fn source_guard(
@@ -255,22 +210,45 @@ pub(super) fn require_current(
ticket_id: &str,
assignment_id: &str,
) -> Result<()> {
let current:bool=conn.query_row(r#"SELECT EXISTS(SELECT 1 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='orchestrator'
AND a.principal_kind='workspace_agent' AND a.principal_id='workspace-orchestrator'
AND t.workflow_state='open' AND json_extract(a.tracking_json,'$.status') IN ('waiting','inprogress')
AND EXISTS(SELECT 1 FROM ticket_assignment_delivery_origins o WHERE o.workspace_id=a.workspace_id AND o.assignment_id=a.assignment_id)
AND NOT EXISTS(SELECT 1 FROM ticket_assignment_work_releases r WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id))"#,params![workspace_id,ticket_id,assignment_id],|r|r.get(0))?;
if !current {
notification_routing::require_current(
conn,
workspace_id,
ticket_id,
assignment_id,
"orchestrator",
)?;
let known: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM ticket_assignment_delivery_origins WHERE workspace_id=?1 AND assignment_id=?2)", params![workspace_id, assignment_id], |r|r.get(0))?;
if !known {
return Err(Error::TicketAssignmentConflict(
"Assignment is no longer a current actionable delegation".into(),
"Assignment has no committed input origin".into(),
));
}
Ok(())
}
// Call only after source_guard and require_current 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(
conn: &Connection,
workspace: &str,
delivery: &str,
source: &TicketAssignmentTrackingSourceContext,
) -> Result<()> {
conn.execute(
"INSERT OR IGNORE INTO notification_assignment_targets VALUES(?1,?2,?3,?4,?5,?6)",
params![
workspace,
delivery,
source.runtime_id,
source.worker_id,
source.agent_key,
source.authentication_binding_id
],
)?;
Ok(())
}
pub(super) fn reserve(
store: &SqliteWorkspaceStore,
workspace_id: &str,
@@ -284,13 +262,14 @@ pub(super) fn reserve(
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)?;
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)?;tx.commit()?;return Ok(result);}
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);}
// 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))?;
let delivery=candidates.into_iter().find(|d|d.assignment_id==assignment_id && d.input_event_id==input_event_id)
.ok_or_else(||Error::TicketAssignmentConflict("Assignment input is not pending".into()))?;
tx.execute("INSERT INTO ticket_assignment_deliveries VALUES(?1,?2,?3,?4,?5,?6)",params![workspace_id,assignment_id,input_event_id,delivery.delivery_id,serde_json::to_string(&delivery).map_err(|e|Error::Store(e.to_string()))?,serde_json::to_string(source).map_err(|e|Error::Store(e.to_string()))?])?;
save_authorized_target(&tx,workspace_id,&delivery.delivery_id,source)?;
tx.commit()?;
Ok(delivery)
})
@@ -317,6 +296,7 @@ pub(super) fn prepare_content(
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)?;
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 {
tx.execute("INSERT INTO ticket_assignment_delivery_requests VALUES(?1,?2,?3)",params![workspace_id,delivery_id,content])?;
@@ -335,10 +315,12 @@ pub(super) fn ack(
) -> Result<()> {
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],|r|r.get(0))?;
let delivery=decode_payload(&payload)?;
require_current(&tx,workspace_id,&delivery.ticket_id,&delivery.assignment_id)?;
// This is an acceptance receipt, not another send authorization. Match all
// 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))?;
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()?;
if let Some(saved)=saved {
@@ -413,41 +395,6 @@ pub(super) fn save_operation_fence(
Ok(())
}
// Same input selection as delivery, plus immutable delivered history. Never
// SELECT payload/source/content into the public diagnostic projection.
const DIAGNOSTIC_INPUTS_SQL: &str = r#"
WITH raw_inputs AS (
SELECT o.input_event_id,-1 AS input_order FROM ticket_assignment_delivery_origins o
WHERE o.workspace_id=?1 AND o.assignment_id=?3
UNION
SELECT d.input_event_id,COALESCE(e.after_ticket_event_index+1,CASE WHEN d.input_event_id LIKE 'ticket-event:%' THEN CAST(substr(d.input_event_id,14) AS INTEGER) ELSE -1 END)
FROM ticket_assignment_deliveries d
LEFT JOIN ticket_assignment_content_inputs e ON e.workspace_id=d.workspace_id AND e.assignment_id=d.assignment_id
AND d.input_event_id='ticket-edit:' || printf('%020d',e.input_sequence)
WHERE d.workspace_id=?1 AND d.assignment_id=?3
UNION
SELECT 'ticket-event:' || e.event_index,e.event_index
FROM ticket_assignment_delivery_origins o JOIN typed_ticket_events e ON e.workspace_id=o.workspace_id AND e.ticket_id=o.ticket_id
WHERE o.workspace_id=?1 AND o.ticket_id=?2 AND o.assignment_id=?3 AND e.event_index>o.after_ticket_event_index
AND e.kind IN ('assignment_answer','item_edit')
AND (e.kind<>'assignment_answer' OR EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value=?3))
AND e.kind NOT LIKE 'assignment_tracking%' AND e.kind NOT LIKE 'assignment_delivery%'
AND COALESCE(e.state_field,'') NOT IN ('case','routing','assignment_tracking')
-- Retain historical input identity after done/release. A later delegation's
-- origin bounds ordinary events; an explicit answer's attribute is authoritative.
AND e.event_index<=COALESCE((SELECT MIN(later.after_ticket_event_index) FROM ticket_assignment_delivery_origins later
JOIN ticket_worker_assignments la ON la.workspace_id=later.workspace_id AND la.assignment_id=later.assignment_id
JOIN ticket_worker_assignments original ON original.workspace_id=o.workspace_id AND original.assignment_id=o.assignment_id
WHERE later.workspace_id=o.workspace_id AND later.ticket_id=o.ticket_id AND la.rowid>original.rowid),9223372036854775807)
AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value<>?3)
UNION
SELECT 'ticket-edit:' || printf('%020d',input_sequence),after_ticket_event_index+1
FROM ticket_assignment_content_inputs WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3
), inputs AS (
SELECT input_event_id,ROW_NUMBER() OVER (ORDER BY input_order,input_event_id) AS input_order FROM raw_inputs
)
"#;
pub(super) fn diagnostics_page(
store: &SqliteWorkspaceStore,
workspace_id: &str,
@@ -473,20 +420,9 @@ pub(super) fn diagnostics_page(
Err(Error::TicketAssignmentConflict(_)) => false,
Err(error) => return Err(error),
};
let after = match after_input_event_id {
None => -2,
Some(id) => tx.query_row(&format!("{DIAGNOSTIC_INPUTS_SQL} SELECT input_order FROM inputs WHERE input_event_id=?5"),params![workspace_id,ticket_id,assignment_id,actionable,id],|r| r.get::<_,i64>(0)).optional()?.ok_or_else(|| Error::InvalidInput("Input cursor does not belong to this assignment".into()))?,
};
let sql = format!("{DIAGNOSTIC_INPUTS_SQL} SELECT i.input_event_id,d.delivery_id,EXISTS(SELECT 1 FROM ticket_assignment_delivery_requests req WHERE req.workspace_id=?1 AND req.delivery_id=d.delivery_id),EXISTS(SELECT 1 FROM ticket_assignment_delivery_acknowledgements ack WHERE ack.workspace_id=?1 AND ack.delivery_id=d.delivery_id) FROM inputs i LEFT JOIN ticket_assignment_deliveries d ON d.workspace_id=?1 AND d.assignment_id=?3 AND d.input_event_id=i.input_event_id WHERE i.input_order>?5 ORDER BY i.input_order LIMIT ?6");
let mut stmt = tx.prepare(&sql)?;
let mut inputs = stmt.query_map(params![workspace_id,ticket_id,assignment_id,actionable,after,(limit+1) as i64],|r| {
let delivery_id: Option<String> = r.get(1)?;
let prepared: bool = r.get(2)?;
let ack: bool = r.get(3)?;
use server_api::TicketAssignmentInputDeliveryState::*;
let notification = if ack { RuntimeAccepted } else if prepared { RequestPersisted } else if delivery_id.is_some() { Reserved } else { NotReserved };
Ok(server_api::TicketAssignmentInputDiagnostic { input_event_id:r.get(0)?, delivery_id,notification,acceptance_outcome_unknown:prepared && !ack })
})?.collect::<std::result::Result<Vec<_>,_>>()?;
let mut inputs = notification_routing::diagnostic_inputs(
&tx, workspace_id, ticket_id, assignment_id, after_input_event_id, limit,
)?;
let more = inputs.len()>limit;
inputs.truncate(limit);
let next_input_event_id = if more { inputs.last().map(|i| i.input_event_id.clone()) } else { None };
@@ -302,11 +302,9 @@ async fn assignment_delivery_clear_reassign_fences_scanned_and_reserved_inputs()
.reserve_ticket_assignment_delivery("w", "a", &old.input_event_id, &source)
.is_err()
);
assert!(
store
.ack_ticket_assignment_delivery("w", &old.delivery_id, &source)
.is_err()
);
// Acceptance of the previously authorized request is evidence, not permission
// to resend or transfer work to the new assignment.
store.ack_ticket_assignment_delivery("w", &old.delivery_id, &source).unwrap();
let pending = store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap();
@@ -327,7 +325,7 @@ async fn assignment_delivery_clear_reassign_fences_scanned_and_reserved_inputs()
}
#[tokio::test]
async fn assignment_delivery_old_worker_or_authenticated_binding_cannot_reserve_or_ack() {
async fn assignment_delivery_old_target_cannot_resend_but_can_record_authorized_acceptance() {
for change in ["replacement", "revoke", "binding-change"] {
let (_directory, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "a", "assign-a");
@@ -347,13 +345,9 @@ async fn assignment_delivery_old_worker_or_authenticated_binding_cannot_reserve_
),
"{change}"
);
assert!(
matches!(
store.ack_ticket_assignment_delivery("w", &old.delivery_id, &source),
Err(Error::WorkspacePermissionDenied(_))
),
"{change}"
);
let mut unreserved = source.clone();
unreserved.worker_id = "never-authorized-target".into();
assert!(store.ack_ticket_assignment_delivery("w", &old.delivery_id, &unreserved).is_err());
if change == "replacement" {
let mut replacement = source.clone();
replacement.worker_id = "replacement".into();
@@ -373,6 +367,8 @@ async fn assignment_delivery_old_worker_or_authenticated_binding_cannot_reserve_
.len(),
1
);
} else {
store.ack_ticket_assignment_delivery("w", &old.delivery_id, &source).unwrap();
}
}
}
@@ -1111,9 +1107,11 @@ async fn assignment_content_edits_never_revive_done_closed_or_reissued_delegatio
let source = tracking_store_source();
let initial = delivery_store_reserve_initial(&store, &source);
store.ack_ticket_assignment_delivery("w", &initial.delivery_id, &source).unwrap();
store.with_conn(|conn| {
conn.execute("UPDATE typed_tickets SET title='Input before authority change' WHERE ticket_id='tracking'", [])?;
Ok(())
use ticket::TicketBackend;
let backend = ticket::SqliteTicketBackend::open_verified(directory.path().join("workspace.db"), "w").unwrap();
backend.edit_item("tracking".into(), ticket::TicketItemEdit {
title: Some("Input before authority change".into()),
author: Some("fixture-editor".into()), ..Default::default()
}).unwrap();
let old = store.list_pending_ticket_assignment_deliveries("w", 100).unwrap().remove(0);
match change {
@@ -1121,8 +1119,6 @@ async fn assignment_content_edits_never_revive_done_closed_or_reissued_delegatio
"close-reopen" => { store.with_conn(|conn| { conn.execute_batch("UPDATE typed_tickets SET workflow_state='closed' WHERE ticket_id='tracking'; UPDATE typed_tickets SET workflow_state='open' WHERE ticket_id='tracking';")?; Ok(()) }).unwrap(); }
_ => { tracking_store_assign(&store, "b", "assign-b"); }
}
use ticket::TicketBackend;
let backend = ticket::SqliteTicketBackend::open_verified(directory.path().join("workspace.db"), "w").unwrap();
backend.edit_item("tracking".into(), ticket::TicketItemEdit {
body: Some(ticket::MarkdownText::new("New requirements")), author: Some("fixture-editor".into()), ..Default::default()
}).unwrap();
@@ -442,7 +442,7 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
.iter()
.map(|m| m.version)
.collect::<Vec<_>>(),
vec![92, 93, 94]
(92..=LATEST_SCHEMA_VERSION).collect::<Vec<_>>()
);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store
@@ -465,7 +465,13 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
93,
"Durable assignment deliveries and source-bound startup fences".into()
),
(94, "Canonical Ticket posts".into())
(94, "Canonical Ticket posts".into()),
(
95,
"Common notification routing and durable Worker delivery".into()
),
(96,"Backend Job common notification routing".into()),
(97, "MR notifications and authenticated implementation results".into())
]
);
for (table, rows) in retained.iter().zip(original.iter()) {
@@ -604,7 +610,7 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first
);
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))?, 12);
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");
@@ -1566,12 +1572,21 @@ async fn assignment_answer_replay_returns_saved_event_without_new_assignment_or_
let pending = store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap();
assert_eq!(pending.len(), 2);
assert_eq!(
pending.len(),
1,
"self-answer preserves the receipt without waking the same Orchestrator"
);
assert!(
pending
.iter()
.any(|p| p.input_event_id == saved.input_event_id)
.all(|p| p.input_event_id != saved.input_event_id)
);
store.with_conn(|conn| {
let reason: String = conn.query_row("SELECT d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.workspace_id='w' AND s.assignment_id='answer-a' AND s.input_event_id=?1 AND s.role='orchestrator'", [&saved.input_event_id], |row| row.get(0))?;
assert_eq!(reason, "self_notification");
Ok(())
}).unwrap();
store
.with_conn(|conn| {
assert_eq!(
@@ -0,0 +1,83 @@
//! Implementation results are authenticated facts, not Orchestrator answers or handling updates.
//! The write lock fences the original implementation assignment and its pinned delegation.
//! Replays return the original result after reassignment, but cannot authorize a new result.
use super::*;
impl SqliteWorkspaceStore {
pub(crate) fn implementation_result(
&self,
workspace: &str,
ticket: &str,
result: &str,
) -> Result<server_api::TicketImplementationResultReceipt> {
self.with_conn(|conn| {
let saved: Option<(String,String)> = conn.query_row("SELECT request_json,created_at FROM ticket_implementation_results WHERE workspace_id=?1 AND ticket_id=?2 AND result_id=?3",params![workspace,ticket,result],|r| Ok((r.get(0)?,r.get(1)?))).optional()?;
let (request,created_at) = saved.ok_or_else(|| Error::InvalidInput("Implementation result not found in this Ticket".into()))?;
Ok(server_api::TicketImplementationResultReceipt { result_id: result.into(), ticket_id: ticket.into(), request: serde_json::from_str(&request).map_err(|e| Error::Store(e.to_string()))?, created_at })
})
}
pub(crate) fn record_implementation_result(
&self,
workspace: &str,
ticket: &str,
request: &server_api::TicketImplementationResultRequest,
worker: &RuntimeWorkerRef,
binding: &str,
) -> Result<server_api::TicketImplementationResultReceipt> {
for (name, value) in [
("operation_key", &request.operation_key),
("assignment_id", &request.assignment_id),
(
"orchestrator_assignment_id",
&request.orchestrator_assignment_id,
),
] {
if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control)
{
return Err(Error::InvalidInput(format!(
"{name} must be nonempty, control-free and at most 1024 bytes"
)));
}
}
if request.body.trim().is_empty()
|| request.body.len() > 16384
|| request.body.contains('\0')
{
return Err(Error::InvalidInput(
"Result body must be nonblank, NUL-free and at most 16384 bytes".into(),
));
}
let encoded = serde_json::to_string(request).map_err(|e| Error::Store(e.to_string()))?;
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let authenticated: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM workspace_runtime_bindings WHERE workspace_id=?1 AND runtime_id=?2 AND binding_id=?3 AND revoked_at IS NULL)", params![workspace,worker.runtime_id,binding], |r| r.get(0))?;
if !authenticated {
return Err(Error::WorkspacePermissionDenied("Implementation result requires its authenticated Runtime binding".into()));
}
let saved: Option<(String,String,String,String,String,String)> = tx.query_row("SELECT result_id,ticket_id,request_json,runtime_id,worker_id,created_at FROM ticket_implementation_results WHERE workspace_id=?1 AND operation_key=?2",params![workspace,request.operation_key], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?))).optional()?;
if let Some((result_id,saved_ticket,saved_request,runtime,source,created_at)) = saved {
if saved_ticket != ticket || saved_request != encoded || runtime != worker.runtime_id || source != worker.worker_id {
return Err(Error::TicketAssignmentConflict("Implementation result operation key was used for different input or source".into()));
}
return Ok(server_api::TicketImplementationResultReceipt { result_id, ticket_id: ticket.into(), request: request.clone(), created_at });
}
let owns: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_current_worker_assignments c JOIN ticket_implementation_delegations d ON d.workspace_id=c.workspace_id AND d.assignment_id=c.assignment_id WHERE c.workspace_id=?1 AND c.ticket_id=?2 AND c.role='worker' AND c.assignment_id=?3 AND c.principal_kind='worker' AND c.runtime_id=?4 AND c.worker_id=?5 AND d.orchestrator_assignment_id=?6 AND NOT EXISTS(SELECT 1 FROM ticket_assignment_work_releases r WHERE r.workspace_id=c.workspace_id AND r.assignment_id=c.assignment_id))",params![workspace,ticket,request.assignment_id,worker.runtime_id,worker.worker_id,request.orchestrator_assignment_id],|r|r.get(0))?;
if !owns {
return Err(Error::WorkspacePermissionDenied("Result source is not the current implementation Worker for this delegation; legacy unknown work requires explicit assignment confirmation".into()));
}
// A delayed new result is rejected rather than retargeted to a new Orchestrator.
notification_routing::require_current(&tx,workspace,ticket,&request.orchestrator_assignment_id,"orchestrator")?;
if let Some(mr) = &request.merge_request {
// References identify retained evidence, not a model's assertion that a branch
// is approved/current. Integration must resolve Git and reread the latest MR.
let valid: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_ticket_relations r WHERE r.workspace_id=?1 AND r.ticket_id=?2 AND r.merge_request_id=?3 AND (EXISTS(SELECT 1 FROM merge_request_thread_events e WHERE e.workspace_id=r.workspace_id AND e.merge_request_id=r.merge_request_id AND (json_extract(e.payload_json,'$.subject_ref')=?4 OR json_extract(e.payload_json,'$.approved_source_ref')=?4)) OR EXISTS(SELECT 1 FROM merge_request_source_observations o WHERE o.workspace_id=r.workspace_id AND o.merge_request_id=r.merge_request_id AND o.subject_ref=?4)))",params![workspace,ticket,mr.merge_request_id,mr.subject_ref],|r|r.get(0))?;
if !valid { return Err(Error::InvalidInput("MR/source reference does not identify retained evidence for this Ticket".into())); }
}
let result_id = uuid::Uuid::now_v7().to_string();
let created_at = chrono::Utc::now().to_rfc3339();
tx.execute("INSERT INTO ticket_implementation_results VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",params![workspace,ticket,request.operation_key,result_id,request.assignment_id,request.orchestrator_assignment_id,worker.runtime_id,worker.worker_id,encoded,created_at])?;
tx.commit()?;
Ok(server_api::TicketImplementationResultReceipt { result_id, ticket_id: ticket.into(), request: request.clone(), created_at })
})
}
}
@@ -0,0 +1,51 @@
CREATE TABLE merge_request_source_observations (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id TEXT NOT NULL, merge_request_id TEXT NOT NULL,
subject_ref TEXT NOT NULL, previous_subject_ref TEXT,
observed_at TEXT NOT NULL,
FOREIGN KEY(workspace_id,merge_request_id) REFERENCES merge_requests(workspace_id,merge_request_id)
);
CREATE INDEX mr_source_observation_latest ON merge_request_source_observations(workspace_id,merge_request_id,sequence);
CREATE TRIGGER mr_source_observations_no_update BEFORE UPDATE ON merge_request_source_observations BEGIN SELECT RAISE(ABORT,'Source observations are immutable'); END;
CREATE TRIGGER mr_source_observations_no_delete BEFORE DELETE ON merge_request_source_observations BEGIN SELECT RAISE(ABORT,'Source observations are immutable'); END;
INSERT INTO merge_request_source_observations(workspace_id,merge_request_id,subject_ref,observed_at)
SELECT e.workspace_id,e.merge_request_id,json_extract(e.payload_json,'$.subject_ref'),e.created_at
FROM merge_request_thread_events e WHERE e.sequence=(SELECT MAX(x.sequence) FROM merge_request_thread_events x WHERE x.workspace_id=e.workspace_id AND x.merge_request_id=e.merge_request_id AND json_extract(x.payload_json,'$.subject_ref') IS NOT NULL);
-- Delegation is pinned when implementation responsibility is created, not at result arrival.
CREATE TABLE ticket_implementation_delegations (
workspace_id TEXT NOT NULL, assignment_id TEXT NOT NULL, orchestrator_assignment_id TEXT,
PRIMARY KEY(workspace_id,assignment_id),
FOREIGN KEY(workspace_id,assignment_id) REFERENCES ticket_worker_assignments(workspace_id,assignment_id),
FOREIGN KEY(workspace_id,orchestrator_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,assignment_id)
);
CREATE TRIGGER implementation_delegation_capture AFTER INSERT ON ticket_worker_assignments
WHEN NEW.role='worker' BEGIN
INSERT INTO ticket_implementation_delegations
SELECT NEW.workspace_id,NEW.assignment_id,c.assignment_id FROM (SELECT 1) seed
LEFT JOIN ticket_current_worker_assignments c ON c.workspace_id=NEW.workspace_id AND c.ticket_id=NEW.ticket_id AND c.role='orchestrator';
END;
-- Retain a pre-upgrade parent only when its committed startup operation has exactly
-- one authenticated delegation fence. Never infer ownership from time or comment prose.
-- Older ambiguous rows remain unknown; explicit reassignment can confirm that work.
INSERT INTO ticket_implementation_delegations
SELECT w.workspace_id,w.assignment_id,
(SELECT MIN(f.orchestrator_assignment_id) FROM ticket_assignment_operations o
JOIN ticket_assignment_operation_fences f ON f.workspace_id=o.workspace_id AND f.operation_id=o.operation_id
JOIN ticket_worker_assignments a ON a.workspace_id=f.workspace_id AND a.assignment_id=f.orchestrator_assignment_id AND a.ticket_id=w.ticket_id AND a.role='orchestrator'
WHERE o.workspace_id=w.workspace_id AND o.assignment_id=w.assignment_id AND o.claim_state='committed'
HAVING COUNT(DISTINCT f.orchestrator_assignment_id)=1)
FROM ticket_worker_assignments w WHERE w.role='worker';
CREATE TABLE ticket_implementation_results (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, operation_key TEXT NOT NULL,
result_id TEXT NOT NULL, assignment_id TEXT NOT NULL, orchestrator_assignment_id TEXT NOT NULL,
runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, request_json TEXT NOT NULL CHECK(json_valid(request_json)),
created_at TEXT NOT NULL,
PRIMARY KEY(workspace_id,operation_key), UNIQUE(workspace_id,result_id),
FOREIGN KEY(workspace_id,ticket_id,assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id),
FOREIGN KEY(workspace_id,ticket_id,orchestrator_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id)
);
CREATE TRIGGER implementation_results_no_update BEFORE UPDATE ON ticket_implementation_results BEGIN SELECT RAISE(ABORT,'Implementation results are immutable'); END;
CREATE TRIGGER implementation_results_no_delete BEFORE DELETE ON ticket_implementation_results BEGIN SELECT RAISE(ABORT,'Implementation results are immutable'); END;
CREATE TRIGGER implementation_delegations_no_update BEFORE UPDATE ON ticket_implementation_delegations BEGIN SELECT RAISE(ABORT,'Implementation delegation is immutable'); END;
CREATE TRIGGER implementation_delegations_no_delete BEFORE DELETE ON ticket_implementation_delegations BEGIN SELECT RAISE(ABORT,'Implementation delegation is immutable'); END;
@@ -0,0 +1,396 @@
// Production SQLite/store tests; signed HTTP and persistent Worker delivery are tested
// in server_assignment_delivery_tests. These do not substitute for caller authentication.
fn implementation_result_input() -> server_api::TicketImplementationResultRequest {
server_api::TicketImplementationResultRequest {
operation_key: "result-operation".into(),
assignment_id: "implementation".into(),
orchestrator_assignment_id: "delegation".into(),
kind: server_api::TicketImplementationResultKind::Result,
body: "Research complete; no repository was required".into(),
merge_request: None,
}
}
#[tokio::test]
async fn implementation_result_replay_is_atomic_and_cannot_be_retargeted() {
let (_dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "delegation", "assign");
let worker = routing_worker_assign(&store, "implementation").worker;
let input = implementation_result_input();
let before = store
.get_ticket_assignment_tracking("w", "tracking", "delegation")
.unwrap();
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
);
assert_eq!(
store
.implementation_result("w", "tracking", &receipt.result_id)
.unwrap(),
receipt
);
assert!(
store
.implementation_result("w", "other", &receipt.result_id)
.is_err()
);
assert_eq!(
store
.get_ticket_assignment_tracking("w", "tracking", "delegation")
.unwrap(),
before
);
let mut changed = input.clone();
changed.body.push('!');
assert!(
store
.record_implementation_result("w", "tracking", &changed, &worker, "tracking-binding")
.is_err()
);
for (workspace, ticket, source, binding) in [
("other", "tracking", worker.clone(), "tracking-binding"),
("w", "other", worker.clone(), "tracking-binding"),
(
"w",
"tracking",
RuntimeWorkerRef::new("r", "worker"),
"tracking-binding",
),
("w", "tracking", worker.clone(), "wrong-binding"),
] {
assert!(
store
.record_implementation_result(workspace, ticket, &input, &source, binding)
.is_err()
);
}
store
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT count(*) FROM ticket_implementation_results",
[],
|r| r.get::<_, i64>(0)
)?,
1
);
assert_eq!(
conn.query_row(
"SELECT count(*) FROM notification_sources WHERE kind='implementation_result'",
[],
|r| r.get::<_, i64>(0)
)?,
1
);
Ok(())
})
.unwrap();
let pending = store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap();
assert!(
pending
.iter()
.any(|p| p.input_event_id == format!("implementation-result:{}", receipt.result_id))
);
// Reassignment does not rewrite a receipt or deliver old results as new work.
tracking_store_assign(&store, "replacement-delegation", "replace");
assert_eq!(
store
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
.unwrap(),
receipt
);
changed.operation_key = "late-result".into();
assert!(
store
.record_implementation_result("w", "tracking", &changed, &worker, "tracking-binding")
.is_err()
);
changed.orchestrator_assignment_id = "replacement-delegation".into();
assert!(
store
.record_implementation_result("w", "tracking", &changed, &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:"))
);
}
#[tokio::test]
async fn implementation_result_rejects_forged_evidence_and_rolls_back_without_notification() {
let (_dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "delegation", "assign");
let worker = routing_worker_assign(&store, "implementation").worker;
let mut input = implementation_result_input();
input.merge_request = Some(server_api::MergeRequestReviewSubject {
merge_request_id: "fake".into(),
subject_ref: "fake".into(),
});
assert!(
store
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
.is_err()
);
store
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT count(*) FROM ticket_implementation_results",
[],
|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
);
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");
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.
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER implementation_delegation_capture;
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
UPDATE __yoi_schema_migrations SET version=96 WHERE version=97;")?;
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');
INSERT INTO merge_request_thread_events VALUES('w','historical-mr','old-comment',2,'comment','{\"body\":\"handoff done\"}',NULL,'3');")?;
conn.execute_batch("CREATE TABLE ticket_implementation_results(collision INTEGER);")?;
assert!(migrate_business_notifications_v96_to_v97(conn).is_err());
assert!(!table_exists(conn,"merge_request_source_observations")?);
assert!(!table_exists(conn,"ticket_implementation_delegations")?);
assert_eq!(current_schema_version(conn)?,96);
conn.execute_batch("DROP TABLE ticket_implementation_results;")?;
migrate_business_notifications_v96_to_v97(conn)?;
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE input_event_id='mr-event:historical-mr:approved-event'",[],|r|r.get::<_,i64>(0))?,2);
assert_eq!(conn.query_row("SELECT assignment_id FROM notification_sources WHERE input_event_id='mr-event:historical-mr:approved-event' AND role='worker'",[],|r|r.get::<_,String>(0))?,"", "equal legacy assignment timestamps must not guess ownership");
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE input_event_id LIKE '%old-comment%'",[],|r|r.get::<_,i64>(0))?,0);
assert_eq!(conn.query_row("SELECT orchestrator_assignment_id FROM ticket_implementation_delegations WHERE assignment_id='implementation'",[],|r|r.get::<_,Option<String>>(0))?,None);
assert_eq!(conn.query_row("SELECT orchestrator_assignment_id FROM ticket_implementation_delegations WHERE assignment_id='known-implementation'",[],|r|r.get::<_,Option<String>>(0))?,Some("delegation".into()));
assert!(conn.execute("UPDATE ticket_implementation_delegations SET orchestrator_assignment_id='delegation'",[]).is_err());
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_foreign_key_check",[],|r|r.get::<_,i64>(0))?,0);
Ok(())
}).unwrap();
let inputs = store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap();
assert!(
inputs
.iter()
.any(|i| i.input_event_id == "mr-event:historical-mr:approved-event")
);
for _ in 0..2 {
store
.observe_merge_request_source("w", "main", "refs/heads/work", "source-b")
.unwrap();
}
store
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT count(*) FROM notification_sources WHERE kind='mr_source_changed'",
[],
|r| r.get::<_, i64>(0)
)?,
2
);
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn implementation_result_notification_failure_rolls_back_and_concurrent_retries_share_receipt()
{
let (dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "delegation", "assign");
let worker = routing_worker_assign(&store, "implementation").worker;
let input = implementation_result_input();
store.with_conn(|conn| {
conn.execute_batch("CREATE TRIGGER reject_result_notification BEFORE INSERT ON notification_sources WHEN NEW.kind='implementation_result' BEGIN SELECT RAISE(ABORT,'injected capture failure'); END;")?;
Ok(())
}).unwrap();
assert!(
store
.record_implementation_result("w", "tracking", &input, &worker, "tracking-binding")
.is_err()
);
store
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT count(*) FROM ticket_implementation_results",
[],
|r| r.get::<_, i64>(0)
)?,
0
);
conn.execute_batch("DROP TRIGGER reject_result_notification;")?;
Ok(())
})
.unwrap();
let barrier = std::sync::Barrier::new(4);
let path = dir.path().join("workspace.db");
let receipts = std::thread::scope(|scope| {
let handles = (0..4)
.map(|_| {
let worker = &worker;
let input = &input;
let path = &path;
let barrier = &barrier;
scope.spawn(move || {
let store = SqliteWorkspaceStore::open_for_test(path).unwrap();
barrier.wait();
store
.record_implementation_result(
"w",
"tracking",
input,
worker,
"tracking-binding",
)
.unwrap()
})
})
.collect::<Vec<_>>();
handles
.into_iter()
.map(|h| h.join().unwrap())
.collect::<Vec<_>>()
});
assert!(receipts.iter().all(|r| r == &receipts[0]));
store
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT count(*) FROM ticket_implementation_results",
[],
|r| r.get::<_, i64>(0)
)?,
1
);
assert_eq!(
conn.query_row(
"SELECT count(*) FROM notification_sources WHERE kind='implementation_result'",
[],
|r| r.get::<_, i64>(0)
)?,
1
);
Ok(())
})
.unwrap();
}
#[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");
routing_worker_assign(&store, "implementation");
let events = [
("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_cancelled", None, None, None),
];
store.with_conn(|conn| {
if historical {
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER implementation_delegation_capture;
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
UPDATE __yoi_schema_migrations SET version=96 WHERE version=97;")?;
}
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() {
let mut payload = serde_json::json!({"subject_ref":"source-a"});
if let (Some(field), Some(worker)) = (field, worker) {
payload[*field] = serde_json::json!({"runtime_id":"r","worker_id":worker});
}
if *kind == "review_requested" {
// A reviewer on a request is not its actor.
payload["reviewer"] = serde_json::json!({"runtime_id":"r","worker_id":"worker"});
}
conn.execute("INSERT INTO merge_request_thread_events VALUES('w','actor-mr',?1,?2,?1,?3,NULL,'3')", params![kind,index as i64 + 1,payload.to_string()])?;
}
if historical { migrate_business_notifications_v96_to_v97(conn)?; }
notification_routing::route_pending(conn,"w")?;
for (kind, _, worker, suppressed_role) in events {
for role in ["worker", "orchestrator"] {
let (actor,disposition,reason):(Option<String>,String,String) = conn.query_row(
"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) {
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}");
}
}
}
Ok(())
}).unwrap();
}
}
#[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");
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');
INSERT INTO merge_requests VALUES('w','other-mr','main','open','other','main','3','3');
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),
] {
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:?}");
}
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();
}
@@ -0,0 +1,475 @@
//! Backend Job adapter for the common notification ledger. Results, not Worker
//! lifecycle, are the durable source. Fixed Worker recipients never follow leases.
use super::*;
use crate::backend_job::{
BackendJobAttemptState, BackendJobDeliveryRecord, BackendJobDeliveryState,
BackendJobResultAcceptance, BackendJobState,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobNotificationSource {
pub job_id: String,
pub attempt_id: String,
pub delivery_id: String,
}
/// Domain facts, not a second notification policy. Rendering is performed by the
/// domain adapter; only this routing module chooses deliver/suppress/defer.
#[derive(Debug, Clone)]
pub enum JobNotificationFacts {
Result(String),
NoFindings,
StaleContent,
/// SourceLookupFailed leaves routing undecided; other failures are terminal.
Failed(NotificationFailureCode),
}
pub(in crate::store) fn install(conn: &Connection) -> Result<()> {
// Generalize the existing source parent without changing any sequence, request,
// or receipt. The migration caller disables FK enforcement for this rebuild.
let triggers = conn
.prepare("SELECT name,sql FROM sqlite_schema WHERE type='trigger'")?
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
.collect::<rusqlite::Result<Vec<_>>>()?;
let indexes = conn.prepare("SELECT sql FROM sqlite_schema WHERE type='index' AND tbl_name='notification_sources' AND sql IS NOT NULL")?
.query_map([], |r| r.get::<_,String>(0))?.collect::<rusqlite::Result<Vec<_>>>()?;
for (name, _) in &triggers {
conn.execute_batch(&format!("DROP TRIGGER \"{}\"", name.replace('"', "\"\"")))?;
}
conn.execute_batch(
"CREATE TABLE notification_sources_job_upgrade (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id TEXT NOT NULL, ticket_id TEXT, input_event_id TEXT NOT NULL,
role TEXT NOT NULL, assignment_id TEXT NOT NULL, kind TEXT NOT NULL,
actor TEXT, event_index INTEGER, facts_json TEXT NOT NULL CHECK(json_valid(facts_json)),
CHECK(ticket_id IS NOT NULL OR role='backend_job'),
UNIQUE(workspace_id,ticket_id,input_event_id,role,assignment_id),
FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id));
INSERT INTO notification_sources_job_upgrade SELECT * FROM notification_sources;
DROP TABLE notification_sources;
ALTER TABLE notification_sources_job_upgrade RENAME TO notification_sources;",
)?;
for sql in indexes {
conn.execute_batch(&sql)?;
}
for (_, sql) in triggers {
conn.execute_batch(&sql)?;
}
conn.execute_batch("CREATE TRIGGER notification_job_source_guard BEFORE INSERT ON notification_sources WHEN NEW.role='backend_job' BEGIN
SELECT CASE WHEN NOT EXISTS(SELECT 1 FROM backend_jobs j JOIN backend_job_attempts a USING(workspace_id,job_id)
WHERE j.workspace_id=NEW.workspace_id AND j.job_id=json_extract(NEW.facts_json,'$.backend_job.job_id') AND a.attempt_id=json_extract(NEW.facts_json,'$.backend_job.attempt_id')
AND j.state='completed' AND a.state='completed' AND a.attempt=j.current_attempt AND j.result_digest=json_extract(NEW.facts_json,'$.result_digest'))
THEN RAISE(ABORT,'Job notification requires a committed current result') END;
END;
CREATE UNIQUE INDEX notification_job_identity ON notification_sources(workspace_id,input_event_id) WHERE role='backend_job';
CREATE TABLE notification_job_targets (
workspace_id TEXT NOT NULL, delivery_id TEXT NOT NULL, target_json TEXT NOT NULL CHECK(json_valid(target_json)),
PRIMARY KEY(workspace_id,delivery_id),
FOREIGN KEY(workspace_id,delivery_id) REFERENCES notification_requests(workspace_id,delivery_id));
CREATE TRIGGER notification_job_targets_no_update BEFORE UPDATE ON notification_job_targets BEGIN SELECT RAISE(ABORT,'Job notification target is immutable'); END;
CREATE TRIGGER notification_job_targets_no_delete BEFORE DELETE ON notification_job_targets BEGIN SELECT RAISE(ABORT,'Job notification target is immutable'); END;
CREATE TABLE notification_job_legacy_receipts (
workspace_id TEXT NOT NULL, delivery_id TEXT NOT NULL, record_json TEXT NOT NULL CHECK(json_valid(record_json)),
runtime_accepted INTEGER NOT NULL CHECK(runtime_accepted IN (0,1)),
PRIMARY KEY(workspace_id,delivery_id),
FOREIGN KEY(workspace_id,delivery_id) REFERENCES notification_decisions(workspace_id,delivery_id));
CREATE TRIGGER notification_job_legacy_no_update BEFORE UPDATE ON notification_job_legacy_receipts BEGIN SELECT RAISE(ABORT,'Legacy notification evidence is immutable'); END;
CREATE TRIGGER notification_job_legacy_no_delete BEFORE DELETE ON notification_job_legacy_receipts BEGIN SELECT RAISE(ABORT,'Legacy notification evidence is immutable'); END;")?;
// Preparation may fail before a routing decision exists. Keep observations
// against the common source, never invent a terminal decision to hold them.
conn.execute_batch("CREATE TABLE notification_source_preparation_failures (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
source_sequence INTEGER NOT NULL REFERENCES notification_sources(sequence),
code TEXT NOT NULL,
recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')));
CREATE INDEX notification_source_preparation_failures_source ON notification_source_preparation_failures(source_sequence,sequence);
CREATE TRIGGER notification_source_preparation_failures_no_update BEFORE UPDATE ON notification_source_preparation_failures BEGIN SELECT RAISE(ABORT,'Source preparation evidence is immutable'); END;
CREATE TRIGGER notification_source_preparation_failures_no_delete BEFORE DELETE ON notification_source_preparation_failures BEGIN SELECT RAISE(ABORT,'Source preparation evidence is immutable'); END;")?;
// Mutable scheduling only, independent of immutable source/decision evidence.
// Both undecided preparation retries and pinned deliveries use this clock.
conn.execute_batch(
"CREATE TABLE notification_source_offers (
source_sequence INTEGER PRIMARY KEY REFERENCES notification_sources(sequence),
last_offered INTEGER NOT NULL CHECK(last_offered > 0));",
)?;
// Includes no-target results and the crash gap before the former reservation.
let identities = conn.prepare("SELECT a.workspace_id,a.job_id,a.attempt_id FROM backend_job_attempts a JOIN backend_jobs j USING(workspace_id,job_id) WHERE a.state='completed' AND j.state='completed' AND a.attempt=j.current_attempt")?
.query_map([], |r| Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?)))?
.collect::<rusqlite::Result<Vec<_>>>()?;
for (workspace, job, attempt) in identities {
capture(conn, &workspace, &job, &attempt)?;
import_legacy(conn, &workspace, &job, &attempt)?;
}
Ok(())
}
pub(in crate::store) fn capture(
conn: &Connection,
workspace: &str,
job: &str,
attempt: &str,
) -> Result<()> {
let id = retained_delivery_id(conn, workspace, job, attempt)?;
let source = JobNotificationSource {
job_id: job.into(),
attempt_id: attempt.into(),
delivery_id: id.clone(),
};
let job = read_backend_job(conn, workspace, job)?
.ok_or_else(|| Error::Store("Job notification source missing".into()))?;
let target = &job.request.notification_target;
let facts = serde_json::json!({"principal_kind":if target.is_some(){Some("worker")}else{None}, "principal_id":null,"runtime_id":target.as_ref().map(|w|&w.runtime_id),"worker_id":target.as_ref().map(|w|&w.worker_id),"status":null,"case_state":"open","released":false,"backend_job":source,"recipient_kind":"fixed_worker","job_purpose":job.request.purpose,"result_digest":job.result_digest});
conn.execute("INSERT OR IGNORE INTO notification_sources(workspace_id,ticket_id,input_event_id,role,assignment_id,kind,facts_json) VALUES(?1,NULL,?2,'backend_job','','backend_job_result',?3)",params![workspace,id,json(&facts)?])?;
Ok(())
}
pub(in crate::store) fn pending(
store: &SqliteWorkspaceStore,
workspace: &str,
limit: usize,
) -> Result<Vec<JobNotificationSource>> {
store.with_conn_mut(|conn| {
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let rows = tx.prepare("SELECT s.facts_json,s.sequence FROM notification_sources s LEFT JOIN notification_decisions d ON d.source_sequence=s.sequence LEFT JOIN notification_source_offers o ON o.source_sequence=s.sequence WHERE s.workspace_id=?1 AND s.role='backend_job' AND (d.source_sequence IS NULL OR d.disposition='deliver') AND NOT EXISTS(SELECT 1 FROM notification_acknowledgements a WHERE a.workspace_id=?1 AND a.delivery_id=s.input_event_id) AND NOT EXISTS(SELECT 1 FROM notification_job_legacy_receipts l WHERE l.workspace_id=?1 AND l.delivery_id=s.input_event_id AND l.runtime_accepted=1) ORDER BY COALESCE(o.last_offered,0),s.sequence LIMIT ?2")?
.query_map(params![workspace,limit.clamp(1,100) as i64],|r|Ok((r.get::<_,String>(0)?,r.get::<_,i64>(1)?)))?
.collect::<rusqlite::Result<Vec<_>>>()?;
// Advance every offered source under the scan's write lock, even if no
// routing decision exists yet. Failure observation IDs are not a clock.
let tick:i64=tx.query_row("SELECT COALESCE(MAX(o.last_offered),0)+1 FROM notification_source_offers o JOIN notification_sources s ON s.sequence=o.source_sequence WHERE s.workspace_id=?1 AND s.role='backend_job'",[workspace],|r|r.get(0))?;
let mut sources=Vec::new();
for (facts,sequence) in rows {
let facts:serde_json::Value=decode(&facts)?;
sources.push(serde_json::from_value(facts["backend_job"].clone()).map_err(|e|Error::Store(e.to_string()))?);
tx.execute("INSERT INTO notification_source_offers(source_sequence,last_offered) VALUES(?1,?2) ON CONFLICT(source_sequence) DO UPDATE SET last_offered=excluded.last_offered",params![sequence,tick])?;
}
tx.commit()?; Ok(sources)
})
}
fn policy(target: bool, facts: &JobNotificationFacts) -> (&'static str, NotificationReason) {
if !target {
return ("suppress", NotificationReason::NoRecipient);
}
match facts {
JobNotificationFacts::Result(_) => ("deliver", NotificationReason::Result),
JobNotificationFacts::NoFindings => ("suppress", NotificationReason::NoFindings),
JobNotificationFacts::StaleContent => ("suppress", NotificationReason::StaleContent),
JobNotificationFacts::Failed(_) => ("defer", NotificationReason::SourceUnavailable),
}
}
pub(in crate::store) fn route(
store: &SqliteWorkspaceStore,
workspace: &str,
source: &JobNotificationSource,
facts: &JobNotificationFacts,
) -> Result<bool> {
let invalid;
let facts = match facts {
JobNotificationFacts::Result(content)
if content.len() > crate::backend_job::MAX_JOB_DELIVERY_BYTES =>
{
invalid = JobNotificationFacts::Failed(NotificationFailureCode::ContentTooLarge);
&invalid
}
JobNotificationFacts::Result(content) if content.is_empty() || content.contains('\0') => {
invalid = JobNotificationFacts::Failed(NotificationFailureCode::RenderingFailed);
&invalid
}
other => other,
};
store.with_conn_mut(|conn| {
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let job=read_backend_job(&tx,workspace,&source.job_id)?.ok_or_else(||Error::InvalidInput("Job notification source disappeared".into()))?;
let attempt=read_backend_job_attempt(&tx,workspace,&source.job_id,&source.attempt_id)?.ok_or_else(||Error::InvalidInput("Job notification attempt disappeared".into()))?;
if job.state!=BackendJobState::Completed || attempt.state!=BackendJobAttemptState::Completed || attempt.attempt!=job.current_attempt || source.delivery_id!=retained_delivery_id(&tx,workspace,&source.job_id,&source.attempt_id)? {
return Err(Error::InvalidInput("Job notification requires its current committed result".into()));
}
capture(&tx,workspace,&source.job_id,&source.attempt_id)?;
import_legacy(&tx, workspace, &source.job_id, &source.attempt_id)?;
let existing:Option<String>=tx.query_row("SELECT disposition FROM notification_decisions WHERE workspace_id=?1 AND delivery_id=?2",params![workspace,source.delivery_id],|r|r.get(0)).optional()?;
if let Some(disposition)=existing {
let ack:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM notification_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2) OR EXISTS(SELECT 1 FROM notification_job_legacy_receipts WHERE workspace_id=?1 AND delivery_id=?2 AND runtime_accepted=1)",params![workspace,source.delivery_id],|r|r.get(0))?;
tx.commit()?; return Ok(disposition=="deliver" && !ack);
}
if job.request.notification_target.is_some() && matches!(facts, JobNotificationFacts::Failed(NotificationFailureCode::SourceLookupFailed)) {
// A transient checker read is not suppression or an immutable defer.
// With no decision, the existing pending scan retries the same source.
// Check existing decisions first: never replace pinned request evidence.
tx.execute("INSERT INTO notification_source_preparation_failures(source_sequence,code) SELECT sequence,?3 FROM notification_sources WHERE workspace_id=?1 AND input_event_id=?2 AND role='backend_job'",params![workspace,source.delivery_id,code(NotificationFailureCode::SourceLookupFailed)?])?;
tx.commit()?; return Ok(false);
}
let (disposition,reason)=policy(job.request.notification_target.is_some(),facts);
save_decision(&tx, workspace, &source.delivery_id, disposition, reason)?;
if let JobNotificationFacts::Failed(failure)=facts { save_failure(&tx,workspace,&source.delivery_id,*failure)?; }
// Pin bytes with the decision, before any network call. A prepared body is
// never rerendered when content changes or an acknowledgement is lost.
if disposition=="deliver" {
if let (Some(target),JobNotificationFacts::Result(content))=(&job.request.notification_target,facts) {
tx.execute("INSERT OR IGNORE INTO notification_requests VALUES(?1,?2,?3,?4)",params![workspace,source.delivery_id,content,json(target)?])?;
}
}
tx.commit()?; Ok(disposition=="deliver")
})
}
pub(in crate::store) fn request(
store: &SqliteWorkspaceStore,
workspace: &str,
id: &str,
) -> Result<Option<(String, RuntimeWorkerRef)>> {
store.with_conn(|conn| {
let row:Option<(String,String)>=conn.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()?;
row.map(|(content,target)|Ok((content,decode(&target)?))).transpose()
})
}
pub(in crate::store) fn ack(
store: &SqliteWorkspaceStore,
workspace: &str,
id: &str,
target: &RuntimeWorkerRef,
) -> Result<()> {
store.with_conn_mut(|conn| {
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let saved:String=tx.query_row("SELECT target_json FROM notification_requests WHERE workspace_id=?1 AND delivery_id=?2",params![workspace,id],|r|r.get(0))?;
if saved!=json(target)? {return Err(Error::InvalidInput("Job notification target changed".into()));}
let authorized:String=tx.query_row("SELECT target_json FROM notification_job_targets WHERE workspace_id=?1 AND delivery_id=?2",params![workspace,id],|r|r.get(0))?;
let authorized_target:TicketWorkerNotificationTarget=decode(&authorized)?;
if authorized_target.worker!=*target {return Err(Error::InvalidInput("Job acknowledgement target differs from authorized send".into()));}
tx.execute("INSERT OR IGNORE INTO notification_acknowledgements VALUES(?1,?2,?3)",params![workspace,id,authorized])?;
tx.commit()?; Ok(())
})
}
fn save_decision(
conn: &Connection,
workspace: &str,
id: &str,
disposition: &str,
reason: NotificationReason,
) -> Result<()> {
conn.execute("INSERT INTO notification_decisions(source_sequence,workspace_id,delivery_id,disposition,reason,purpose) SELECT sequence,workspace_id,?2,?3,?4,'result' FROM notification_sources WHERE workspace_id=?1 AND input_event_id=?2 AND role='backend_job'",params![workspace,id,disposition,code(reason)?])?;
Ok(())
}
// Old rows did not store request bytes. Only the original deterministic renderers
// may reconstruct an interrupted request; no current Ticket contents participate.
// Accepted legacy rows keep their actual evidence, never a fabricated new request.
fn import_legacy(conn: &Connection, workspace: &str, job_id: &str, attempt_id: &str) -> Result<()> {
let id = retained_delivery_id(conn, workspace, job_id, attempt_id)?;
let exists:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM notification_decisions WHERE workspace_id=?1 AND delivery_id=?2)",params![workspace,id],|r|r.get(0))?;
if exists {
return Ok(());
}
let Some(old) = read_backend_job_delivery(conn, workspace, &id)? else {
return Ok(());
};
let accepted =
old.state == BackendJobDeliveryState::Completed && old.failure_category.is_none();
let count:i64=conn.query_row("SELECT COUNT(*) FROM backend_job_deliveries WHERE workspace_id=?1 AND job_id=?2 AND attempt_id=?3",params![workspace,job_id,attempt_id],|r|r.get(0))?;
let mut content = None;
let mut failure = legacy_failure(&old);
let (disposition, reason) = match old.failure_category.as_deref() {
_ if accepted => ("deliver", NotificationReason::Result),
_ if count > 1 => {
failure = Some(NotificationFailureCode::PreparationFailed);
("defer", NotificationReason::SourceUnavailable)
}
Some("notification_suppressed_no_findings") => ("suppress", NotificationReason::NoFindings),
Some("notification_suppressed_stale_ticket_content") => {
("suppress", NotificationReason::StaleContent)
}
_ if old.state == BackendJobDeliveryState::Completed => {
("defer", NotificationReason::SourceUnavailable)
}
Some(
"invalid_checker_input"
| "invalid_checker_result"
| "current_ticket_content_unavailable"
| "notification_too_large",
) => ("defer", NotificationReason::SourceUnavailable),
_ => {
let job = read_backend_job(conn, workspace, job_id)?
.ok_or_else(|| Error::Store("Legacy Job result missing".into()))?;
let attempt = read_backend_job_attempt(conn, workspace, job_id, attempt_id)?
.ok_or_else(|| Error::Store("Legacy Job attempt missing".into()))?;
let acceptance = BackendJobResultAcceptance {
job,
attempt,
replayed: true,
};
let facts = if acceptance.job.request.notification_target.as_ref() != Some(&old.target)
{
JobNotificationFacts::Failed(NotificationFailureCode::PreparationFailed)
} else {
legacy_result_facts(&acceptance)
};
let decision = policy(acceptance.job.request.notification_target.is_some(), &facts);
match facts {
JobNotificationFacts::Result(body) => content = Some(body),
JobNotificationFacts::Failed(code) => failure = Some(code),
// A valid empty result could not have passed the old sender's
// findings gate even if it crashed before recording suppression.
JobNotificationFacts::NoFindings => failure = None,
JobNotificationFacts::StaleContent => {}
}
decision
}
};
save_decision(conn, workspace, &id, disposition, reason)?;
conn.execute(
"INSERT INTO notification_job_legacy_receipts VALUES(?1,?2,?3,?4)",
params![workspace, id, json(&old)?, accepted],
)?;
if !accepted && disposition != "suppress" {
if let Some(failure) = failure {
save_failure(conn, workspace, &id, failure)?;
}
}
if disposition == "deliver" {
if let Some(content) = content {
conn.execute(
"INSERT INTO notification_requests VALUES(?1,?2,?3,?4)",
params![workspace, id, content, json(&old.target)?],
)?;
}
}
Ok(())
}
fn legacy_result_facts(acceptance: &BackendJobResultAcceptance) -> JobNotificationFacts {
let content = if acceptance.job.request.purpose == crate::ticket_item_checker::PURPOSE {
let Some(result) = &acceptance.job.result else {
return JobNotificationFacts::Failed(NotificationFailureCode::InvalidResult);
};
match crate::ticket_item_checker::validate_result(&acceptance.job.request.input, result) {
Ok(result) if result.findings.is_empty() => return JobNotificationFacts::NoFindings,
Ok(_) => {}
Err(_) => return JobNotificationFacts::Failed(NotificationFailureCode::InvalidResult),
}
crate::ticket_item_checker::render_result(acceptance)
} else {
Ok(format!(
"Backend Job `{}` completed attempt `{}` for `{}`. Durable result digest: {}.",
acceptance.job.request.job_id,
acceptance.attempt.attempt_id,
acceptance.job.request.purpose,
acceptance
.job
.result_digest
.as_deref()
.unwrap_or("unavailable")
))
};
match content {
Ok(content) if content.len() > crate::backend_job::MAX_JOB_DELIVERY_BYTES => {
JobNotificationFacts::Failed(NotificationFailureCode::ContentTooLarge)
}
Ok(content) if !content.is_empty() && !content.contains('\0') => {
JobNotificationFacts::Result(content)
}
_ => JobNotificationFacts::Failed(NotificationFailureCode::RenderingFailed),
}
}
fn legacy_failure(old: &BackendJobDeliveryRecord) -> Option<NotificationFailureCode> {
use NotificationFailureCode as F;
match old.failure_category.as_deref() {
Some("notification_outcome_unknown") => Some(F::AcceptanceUnknown),
Some("notification_rejected") => Some(F::RuntimeRejected),
Some("notification_too_large") => Some(F::ContentTooLarge),
Some("invalid_checker_input" | "invalid_checker_result") => Some(F::InvalidResult),
Some("current_ticket_content_unavailable") => Some(F::SourceLookupFailed),
_ if matches!(
old.state,
BackendJobDeliveryState::Sending
| BackendJobDeliveryState::Unknown
| BackendJobDeliveryState::Completed
) =>
{
Some(F::AcceptanceUnknown)
}
_ => None,
}
}
/// None authorizes this exact send target; a typed denial is not a DB failure.
pub(in crate::store) fn authorize(
store: &SqliteWorkspaceStore,
workspace: &str,
id: &str,
target: &TicketWorkerNotificationTarget,
) -> Result<Option<NotificationFailureCode>> {
store.with_conn_mut(|conn| {
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let recipient:String=tx.query_row("SELECT target_json FROM notification_requests WHERE workspace_id=?1 AND delivery_id=?2",params![workspace,id],|r|r.get(0))?;
if decode::<RuntimeWorkerRef>(&recipient)?!=target.worker {return Ok(Some(NotificationFailureCode::PreparationFailed));}
let present:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM worker_registry w WHERE w.workspace_id=?1 AND w.runtime_id=?2 AND w.worker_id=?3 AND NOT EXISTS(SELECT 1 FROM worker_removal_operations r WHERE r.workspace_id=w.workspace_id AND r.runtime_id=w.runtime_id AND r.worker_id=w.worker_id AND r.state IN ('executing','failed','succeeded')))",params![workspace,target.worker.runtime_id,target.worker.worker_id],|r|r.get(0))?;
if !present {return Ok(Some(NotificationFailureCode::TargetUnavailable));}
let binding:Option<(String,Option<String>)>=tx.query_row("SELECT binding_id,revoked_at FROM workspace_runtime_bindings WHERE workspace_id=?1 AND runtime_id=?2",params![workspace,target.worker.runtime_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()?;
let Some((binding,revoked))=binding else {return Ok(Some(NotificationFailureCode::TargetUnavailable));};
if revoked.is_some() || binding!=target.authentication_binding_id {return Ok(Some(NotificationFailureCode::BindingRevoked));}
let saved:Option<String>=tx.query_row("SELECT target_json FROM notification_job_targets WHERE workspace_id=?1 AND delivery_id=?2",params![workspace,id],|r|r.get(0)).optional()?;
let serialized=json(target)?;
if saved.as_ref().is_some_and(|saved|*saved!=serialized) {return Ok(Some(NotificationFailureCode::BindingRevoked));}
tx.execute("INSERT OR IGNORE INTO notification_job_targets VALUES(?1,?2,?3)",params![workspace,id,serialized])?;
tx.commit()?; Ok(None)
})
}
pub(in crate::store) fn verify(conn: &Connection) -> Result<()> {
for name in [
"notification_source_offers",
"notification_source_preparation_failures",
"notification_source_preparation_failures_source",
"notification_source_preparation_failures_no_update",
"notification_source_preparation_failures_no_delete",
"notification_job_identity",
"notification_job_source_guard",
"notification_job_targets",
"notification_job_targets_no_update",
"notification_job_targets_no_delete",
"notification_job_legacy_receipts",
"notification_job_legacy_no_update",
"notification_job_legacy_no_delete",
] {
let present: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name=?1)",
[name],
|r| r.get(0),
)?;
if !present {
return Err(Error::Store(format!(
"Job notification constraint {name} is missing"
)));
}
}
Ok(())
}
fn retained_delivery_id(
conn: &Connection,
workspace: &str,
job: &str,
attempt: &str,
) -> Result<String> {
// The production legacy sender used the derived ID, but retained receipts are
// authoritative even when imported history contains a non-derived identity.
// If history contains multiple identities, an actual ACK wins; otherwise the
// importer defers the ambiguity instead of manufacturing another send.
let saved:Option<String>=conn.query_row("SELECT delivery_id FROM backend_job_deliveries WHERE workspace_id=?1 AND job_id=?2 AND attempt_id=?3 ORDER BY (state='completed' AND failure_category IS NULL) DESC,created_at,delivery_id LIMIT 1",params![workspace,job,attempt],|r|r.get(0)).optional()?;
Ok(saved.unwrap_or_else(|| crate::backend_job::delivery_id(job, attempt)))
}
pub(in crate::store) fn source(
store: &SqliteWorkspaceStore,
workspace: &str,
job: &str,
attempt: &str,
) -> Result<JobNotificationSource> {
store.with_conn(|conn| {
let facts:String=conn.query_row("SELECT json_extract(facts_json,'$.backend_job') FROM notification_sources WHERE workspace_id=?1 AND role='backend_job' AND json_extract(facts_json,'$.backend_job.job_id')=?2 AND json_extract(facts_json,'$.backend_job.attempt_id')=?3",params![workspace,job,attempt],|r|r.get(0))?;
decode(&facts)
})
}
@@ -0,0 +1,891 @@
// Real SQLite result transactions, migration, reopen and concurrent recovery.
fn job_notice_fixture(
store: &SqliteWorkspaceStore,
id: &str,
target: bool,
) -> (BackendJobResultAcceptance, JobNotificationSource) {
let mut request = resource_job_request(id, id, id);
request.purpose = "notification_test".into();
request.grants = Default::default();
request.profile = "builtin:backend-job".into();
request.notification_target = target.then(|| RuntimeWorkerRef::new("r", "replacement"));
let worker = RuntimeWorkerRef::new("r", format!("job-worker-{id}"));
let mut registration = store
.get_worker_registry("w", &RuntimeWorkerRef::new("r", "replacement"))
.unwrap()
.unwrap();
registration.worker = worker.clone();
store.upsert_worker_registry(&registration).unwrap();
let reservation = store
.reserve_backend_job("w", &request, RESOURCE_JOB_NOW)
.unwrap();
store
.claim_backend_job_attempt_dispatch(
"w",
id,
&reservation.attempt.attempt_id,
RESOURCE_JOB_NOW,
)
.unwrap();
store
.bind_backend_job_attempt_worker(
"w",
id,
&reservation.attempt.attempt_id,
&worker,
Some("run"),
RESOURCE_JOB_NOW,
)
.unwrap();
let acceptance = store
.accept_backend_job_result(
"w",
&worker,
&BackendJobResultSubmission {
job_id: id.into(),
attempt_id: reservation.attempt.attempt_id.clone(),
input_digest: request.input_digest().unwrap(),
result: serde_json::json!({"valid":true}),
},
RESOURCE_JOB_NOW,
)
.unwrap();
let source = JobNotificationSource {
job_id: id.into(),
attempt_id: acceptance.attempt.attempt_id.clone(),
delivery_id: crate::backend_job::delivery_id(id, &acceptance.attempt.attempt_id),
};
(acceptance, source)
}
fn job_notice_evidence(
store: &SqliteWorkspaceStore,
source: &JobNotificationSource,
) -> (String, String, bool, Option<String>) {
store.with_conn(|conn|Ok(conn.query_row("SELECT disposition,reason,EXISTS(SELECT 1 FROM notification_acknowledgements WHERE workspace_id='w' AND delivery_id=?1),(SELECT code FROM notification_failures WHERE workspace_id='w' AND delivery_id=?1 ORDER BY sequence DESC LIMIT 1) FROM notification_decisions WHERE workspace_id='w' AND delivery_id=?1",[&source.delivery_id],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?)))?)).unwrap()
}
#[tokio::test]
async fn job_notification_routing_common_policy_distinguishes_suppression_failures_and_acceptance()
{
let (_directory, store) = tracking_store_fixture().await;
for (name, target, facts, disposition, reason, failure) in [
(
"normal",
true,
JobNotificationFacts::Result("result notice".into()),
"deliver",
"result",
None,
),
(
"no-target",
false,
JobNotificationFacts::Result("unused".into()),
"suppress",
"no_recipient",
None,
),
(
"no-target-lookup-failure",
false,
JobNotificationFacts::Failed(NotificationFailureCode::SourceLookupFailed),
"suppress",
"no_recipient",
Some("source_lookup_failed"),
),
(
"clean",
true,
JobNotificationFacts::NoFindings,
"suppress",
"no_findings",
None,
),
(
"stale",
true,
JobNotificationFacts::StaleContent,
"suppress",
"stale_content",
None,
),
(
"invalid",
true,
JobNotificationFacts::Failed(NotificationFailureCode::InvalidResult),
"defer",
"source_unavailable",
Some("invalid_result"),
),
(
"oversize",
true,
JobNotificationFacts::Result(
"x".repeat(crate::backend_job::MAX_JOB_DELIVERY_BYTES + 1),
),
"defer",
"source_unavailable",
Some("content_too_large"),
),
(
"render",
true,
JobNotificationFacts::Result(String::new()),
"defer",
"source_unavailable",
Some("rendering_failed"),
),
] {
let (acceptance, source) = job_notice_fixture(&store, name, target);
assert_eq!(
store.route_job_notification("w", &source, &facts).unwrap(),
disposition == "deliver"
);
let diagnostics=store.notification_diagnostics("w",&server_api::NotificationDiagnosticsQuery{job_id:Some(name.into()),..Default::default()}).unwrap();
let item=&diagnostics.items[0];
assert_eq!(item.disposition.as_deref(),Some(disposition));
assert_eq!(item.reason.as_deref(),Some(reason));
assert_eq!(item.job_id.as_deref(),Some(name));
assert!(diagnostics.source_checks[0].captured);
assert_eq!(diagnostics.source_checks[0].origin_present,Some(true));
assert!(!item.model_processing_confirmed && !item.legacy_evidence);
if disposition=="deliver" {
assert_eq!(item.stage,server_api::NotificationDiagnosticStage::RequestPersisted);
assert_eq!(item.target_worker_id.as_deref(),Some("replacement"));
} else {
assert!(!item.retry_candidate);
assert_eq!(item.stage,if disposition=="suppress"{server_api::NotificationDiagnosticStage::Suppressed}else{server_api::NotificationDiagnosticStage::Deferred});
}
assert_eq!(
job_notice_evidence(&store, &source),
(
disposition.into(),
reason.into(),
false,
failure.map(String::from)
)
);
assert_eq!(
store.get_backend_job("w", name).unwrap().unwrap().result,
acceptance.job.result
);
assert_eq!(
store.get_backend_job("w", name).unwrap().unwrap().state,
BackendJobState::Completed
);
if disposition != "deliver" {
assert!(
store
.job_notification_request("w", &source.delivery_id)
.unwrap()
.is_none()
);
assert!(
!store
.route_job_notification(
"w",
&source,
&JobNotificationFacts::Result("new rendering".into())
)
.unwrap()
);
}
}
assert_eq!(
store
.list_pending_job_notifications("w", 100)
.unwrap()
.len(),
1
);
}
#[tokio::test]
async fn job_notification_preparation_failures_remain_pending_and_append_only() {
let (_directory, store) = tracking_store_fixture().await;
for target in [true, false] {
let (_, source) = job_notice_fixture(&store, if target { "retry" } else { "retry-no-target" }, target);
for _ in 0..2 {
assert!(!store.route_job_notification("w", &source, &JobNotificationFacts::Failed(NotificationFailureCode::SourceLookupFailed)).unwrap());
}
if !target {
assert_eq!(job_notice_evidence(&store,&source),("suppress".into(),"no_recipient".into(),false,Some("source_lookup_failed".into())));
assert!(!store.list_pending_job_notifications("w",100).unwrap().iter().any(|s|s.delivery_id==source.delivery_id));
continue;
}
store.with_conn(|conn| {
let sequence: i64 = conn.query_row("SELECT sequence FROM notification_sources WHERE input_event_id=?1", [&source.delivery_id], |r| r.get(0))?;
assert_eq!(conn.query_row("SELECT COUNT(*) FROM notification_source_preparation_failures WHERE source_sequence=?1 AND code='source_lookup_failed'", [sequence], |r| r.get::<_, i64>(0))?, 2);
assert_eq!(conn.query_row("SELECT COUNT(*) FROM notification_decisions WHERE source_sequence=?1", [sequence], |r| r.get::<_, i64>(0))?, 0);
assert!(conn.execute("UPDATE notification_source_preparation_failures SET code='invalid_result' WHERE source_sequence=?1", [sequence]).is_err());
assert!(conn.execute("DELETE FROM notification_source_preparation_failures WHERE source_sequence=?1", [sequence]).is_err());
assert!(conn.execute("INSERT INTO notification_source_preparation_failures(source_sequence,code) VALUES(-1,'source_lookup_failed')", []).is_err());
Ok(())
}).unwrap();
assert!(store.list_pending_job_notifications("w", 100).unwrap().iter().any(|s| s.delivery_id == source.delivery_id));
assert_eq!(store.route_job_notification("w", &source, &JobNotificationFacts::Result("recovered body".into())).unwrap(), target);
let pinned = store.job_notification_request("w", &source.delivery_id).unwrap();
// Later failed reads cannot overwrite the pinned decision/body/recipient
// or fabricate a new pre-decision observation after routing has finished.
assert_eq!(store.route_job_notification("w", &source, &JobNotificationFacts::Failed(NotificationFailureCode::SourceLookupFailed)).unwrap(), target);
assert_eq!(store.job_notification_request("w", &source.delivery_id).unwrap(), pinned);
store.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT COUNT(*) FROM notification_source_preparation_failures f JOIN notification_sources s ON s.sequence=f.source_sequence WHERE s.input_event_id=?1", [&source.delivery_id], |r| r.get::<_, i64>(0))?, 2);
Ok(())
}).unwrap();
}
}
#[tokio::test]
async fn job_notification_fair_offers_survive_reopen_and_bound_preparation_and_ack_retries() {
let (directory, mut store) = tracking_store_fixture().await;
let mut failing = Vec::new();
for i in 0..25 {
let (_, source) = job_notice_fixture(&store, &format!("failing-{i}"), true);
assert!(!store.route_job_notification(
"w", &source,
&JobNotificationFacts::Failed(NotificationFailureCode::SourceLookupFailed),
).unwrap());
failing.push(source);
}
let (_, healthy) = job_notice_fixture(&store, "later-healthy", true);
let (_, retry) = job_notice_fixture(&store, "later-pinned-retry", true);
let target = TicketWorkerNotificationTarget {
worker: RuntimeWorkerRef::new("r", "replacement"),
authentication_binding_id: "tracking-binding".into(),
};
assert!(store.route_job_notification(
"w", &retry, &JobNotificationFacts::Result("original pinned bytes".into()),
).unwrap());
assert_eq!(store.authorize_job_notification("w", &retry.delivery_id, &target).unwrap(), None);
let retry_key = NotificationDeliveryKey::WorkerNotification {
delivery_id: retry.delivery_id.clone(),
};
store.record_notification_delivery_failure(
"w", &retry_key, NotificationFailureCode::AcceptanceUnknown,
).unwrap();
let pinned = store.job_notification_request("w", &retry.delivery_id).unwrap();
// Start with an already-offered pinned retry, not only a never-offered result.
// All sources share the same offer clock; 25 failure observations are not ticks.
assert_eq!(store.list_pending_job_notifications("w", 100).unwrap().len(), 27);
let snapshot = |store: &SqliteWorkspaceStore, table: &str| {
store.with_conn(|conn| Ok(stored_fixture_rows(conn, table))).unwrap()
};
let original_sources = snapshot(&store, "notification_sources");
let original_decisions = snapshot(&store, "notification_decisions");
let original_preparation_failures = snapshot(&store, "notification_source_preparation_failures");
let original_delivery_failures = snapshot(&store, "notification_failures");
let original_jobs = snapshot(&store, "backend_jobs");
let mut failure_offers = vec![0; failing.len()];
let mut healthy_round = None;
let mut retry_rounds = Vec::new();
for round in 1..=4 {
// Reopen before every production-sized round, including the first retry.
let saved_offers = snapshot(&store, "notification_source_offers");
drop(store);
store = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
assert_eq!(snapshot(&store, "notification_source_offers"), saved_offers);
let pending = store.list_pending_job_notifications("w", 20).unwrap();
assert_eq!(pending.len(), 20);
if round == 1 {
assert!(pending.iter().all(|s| s.job_id.starts_with("failing-")));
}
for source in pending {
if let Some(index) = failing.iter().position(|s| s.delivery_id == source.delivery_id) {
failure_offers[index] += 1;
assert!(!store.route_job_notification(
"w", &source,
&JobNotificationFacts::Failed(NotificationFailureCode::SourceLookupFailed),
).unwrap());
} else if source.delivery_id == healthy.delivery_id {
assert!(healthy_round.replace(round).is_none());
assert!(store.route_job_notification(
"w", &source, &JobNotificationFacts::Result("healthy body".into()),
).unwrap());
assert_eq!(store.authorize_job_notification("w", &source.delivery_id, &target).unwrap(), None);
store.ack_job_notification("w", &source.delivery_id, &target.worker).unwrap();
} else {
assert_eq!(source.delivery_id, retry.delivery_id);
retry_rounds.push(round);
// Neither scheduling nor a new rendering may replace the request.
assert!(store.route_job_notification(
"w", &source, &JobNotificationFacts::Result("must not replace pinned bytes".into()),
).unwrap());
assert_eq!(store.job_notification_request("w", &source.delivery_id).unwrap(), pinned);
if retry_rounds.len() == 1 {
store.record_notification_delivery_failure(
"w", &retry_key, NotificationFailureCode::AcceptanceUnknown,
).unwrap();
} else {
store.ack_job_notification("w", &source.delivery_id, &target.worker).unwrap();
}
}
}
if round == 2 {
assert!(failure_offers.iter().all(|count| *count >= 1));
assert_eq!(healthy_round, Some(2));
assert_eq!(retry_rounds, vec![2]);
}
}
// With 26 sources still pending after the healthy ACK, even the last source
// in an offer-time tie returns within ceil(26 / 20) = 2 further rounds.
assert_eq!(retry_rounds, vec![2, 4], "a pinned retry must not starve behind undecided sources");
assert!(job_notice_evidence(&store, &healthy).2);
assert!(job_notice_evidence(&store, &retry).2);
assert_eq!(snapshot(&store, "notification_sources"), original_sources);
assert_eq!(snapshot(&store, "backend_jobs"), original_jobs);
let decisions = snapshot(&store, "notification_decisions");
assert!(original_decisions.iter().all(|row| decisions.contains(row)));
let preparation_failures = snapshot(&store, "notification_source_preparation_failures");
assert!(preparation_failures.starts_with(&original_preparation_failures));
assert_eq!(preparation_failures.len(), 25 + failure_offers.iter().sum::<usize>());
assert!(snapshot(&store, "notification_failures").starts_with(&original_delivery_failures));
assert_eq!(store.job_notification_request("w", &retry.delivery_id).unwrap(), pinned);
assert!(store.list_pending_job_notifications("w", 20).unwrap().iter().all(|s| s.job_id.starts_with("failing-")));
store.with_conn(|conn| {
assert!(conn.execute("INSERT INTO notification_source_offers VALUES(-1,1)", []).is_err());
assert!(conn.execute("UPDATE notification_source_offers SET last_offered=0", []).is_err());
assert_eq!(conn.query_row("SELECT COUNT(*) FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.role='backend_job'", [], |r| r.get::<_, i64>(0))?, 2);
assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r.get::<_, i64>(0))?, 0);
Ok(())
}).unwrap();
}
#[tokio::test]
async fn job_notification_result_commit_reopen_parallel_recovery_and_fixed_target_receipt() {
let (directory, store) = tracking_store_fixture().await;
let (accepted, source) = job_notice_fixture(&store, "commit-gap", true);
// No delivery reservation or routing callback has run. The result transaction
// alone leaves one recoverable source, including after reopening SQLite.
assert!(
store
.job_notification_request("w", &source.delivery_id)
.unwrap()
.is_none()
);
let submission = BackendJobResultSubmission {
job_id: source.job_id.clone(),
attempt_id: source.attempt_id.clone(),
input_digest: accepted.attempt.input_digest.clone(),
result: accepted.job.result.clone().unwrap(),
};
assert!(
store
.accept_backend_job_result(
"w",
accepted.attempt.worker.as_ref().unwrap(),
&submission,
RESOURCE_JOB_NOW
)
.unwrap()
.replayed
);
assert!(
store
.accept_backend_job_result(
"other",
accepted.attempt.worker.as_ref().unwrap(),
&submission,
RESOURCE_JOB_NOW
)
.is_err()
);
let mut stale = submission.clone();
stale.attempt_id = "old-attempt".into();
assert!(
store
.accept_backend_job_result(
"w",
accepted.attempt.worker.as_ref().unwrap(),
&stale,
RESOURCE_JOB_NOW
)
.is_err()
);
drop(store);
let path = directory.path().join("workspace.db");
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
assert_eq!(
store
.list_pending_job_notifications("w", 100)
.unwrap()
.len(),
1
);
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let threads = (0..2)
.map(|i| {
let path = path.clone();
let source = source.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
let store = SqliteWorkspaceStore::open_for_test(path).unwrap();
barrier.wait();
store
.route_job_notification(
"w",
&source,
&JobNotificationFacts::Result(format!("immutable rendering {i}")),
)
.unwrap();
store
.job_notification_request("w", &source.delivery_id)
.unwrap()
.unwrap()
})
})
.collect::<Vec<_>>();
let results = threads
.into_iter()
.map(|thread| thread.join().unwrap())
.collect::<Vec<_>>();
assert_eq!(results[0], results[1]);
assert!(
store
.route_job_notification("w", &source, &JobNotificationFacts::StaleContent)
.unwrap()
);
assert_eq!(
store
.job_notification_request("w", &source.delivery_id)
.unwrap()
.unwrap(),
results[0]
);
let target = TicketWorkerNotificationTarget {
worker: RuntimeWorkerRef::new("r", "replacement"),
authentication_binding_id: "tracking-binding".into(),
};
assert_eq!(
store
.authorize_job_notification("w", &source.delivery_id, &target)
.unwrap(),
None
);
let mut replacement = target.clone();
replacement.worker.worker_id = "another-worker".into();
assert!(
store
.authorize_job_notification("w", &source.delivery_id, &replacement)
.unwrap()
.is_some()
);
replacement = target.clone();
replacement.authentication_binding_id = "replacement-binding".into();
assert!(
store
.authorize_job_notification("w", &source.delivery_id, &replacement)
.unwrap()
.is_some()
);
store
.record_notification_delivery_failure(
"w",
&NotificationDeliveryKey::WorkerNotification {
delivery_id: source.delivery_id.clone(),
},
NotificationFailureCode::AcceptanceUnknown,
)
.unwrap();
assert!(!job_notice_evidence(&store, &source).2);
assert_eq!(
store
.job_notification_request("w", &source.delivery_id)
.unwrap()
.unwrap(),
results[0]
);
store.with_conn(|conn| {conn.execute("UPDATE workspace_runtime_bindings SET revoked_at=?1 WHERE workspace_id='w' AND runtime_id='r'",[RESOURCE_JOB_NOW])?;Ok(())}).unwrap();
assert_eq!(
store
.authorize_job_notification("w", &source.delivery_id, &target)
.unwrap(),
Some(NotificationFailureCode::BindingRevoked)
);
// A receipt about the already authorized send remains valid after revocation.
store
.ack_job_notification("w", &source.delivery_id, &target.worker)
.unwrap();
store
.ack_job_notification("w", &source.delivery_id, &target.worker)
.unwrap();
assert!(job_notice_evidence(&store, &source).2);
assert!(
store
.list_pending_job_notifications("w", 100)
.unwrap()
.is_empty()
);
assert_eq!(
store
.get_backend_job("w", &source.job_id)
.unwrap()
.unwrap()
.current_attempt,
1
);
store
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM notification_sources WHERE role='backend_job'",
[],
|r| r.get::<_, i64>(0)
)?,
1
);
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM notification_requests", [], |r| r
.get::<_, i64>(0))?,
1
);
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r
.get::<_, i64>(
0
))?,
0
);
Ok(())
})
.unwrap();
}
fn job_notice_legacy_seed(
conn: &Connection,
name: &str,
state: Option<&str>,
category: Option<&str>,
target: bool,
) -> String {
let mut request = resource_job_request(name, name, name);
request.purpose = "legacy_notice".into();
request.grants = Default::default();
request.notification_target =
target.then(|| RuntimeWorkerRef::new("old-runtime", "old-worker"));
let attempt = attempt_id(name, 1);
let id = crate::backend_job::delivery_id(name, &attempt);
conn.execute("INSERT INTO backend_jobs(workspace_id,job_id,purpose,input_digest,input_ref,request_json,intent_fingerprint,state,current_attempt,result_json,result_digest,created_at,updated_at,completed_at) VALUES('w',?1,'legacy_notice',?2,'test://input',?3,'fingerprint','completed',1,'{}','digest',?4,?4,?4)",params![name,request.input_digest().unwrap(),serde_json::to_string(&request).unwrap(),RESOURCE_JOB_NOW]).unwrap();
conn.execute("INSERT INTO backend_job_attempts(workspace_id,job_id,attempt_id,attempt,input_digest,state,deadline_at,result_json,result_digest,created_at,updated_at,completed_at) VALUES('w',?1,?2,1,?3,'completed',?4,'{}','digest',?4,?4,?4)",params![name,attempt,request.input_digest().unwrap(),RESOURCE_JOB_NOW]).unwrap();
if let Some(state) = state {
conn.execute("INSERT INTO backend_job_deliveries(workspace_id,delivery_id,job_id,attempt_id,target_runtime_id,target_worker_id,state,failure_category,created_at,updated_at,delivered_at) VALUES('w',?1,?2,?3,'old-runtime','old-worker',?4,?5,?6,?6,CASE WHEN ?4='completed' THEN ?6 ELSE NULL END)",params![id,name,attempt,state,category,RESOURCE_JOB_NOW]).unwrap();
}
id
}
#[test]
fn job_notification_migration_retains_legacy_acks_suppressions_unknown_bytes_and_commit_gaps() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("workspace.db");
let conn = tracking_schema91_fixture(&path);
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();
// Populate the v95 common graph before rebuilding its source parent. Include
// both accepted and retryable Ticket requests, with retained failure history.
for id in ["ticket-ack", "ticket-pending"] {
conn.execute("INSERT INTO notification_sources(workspace_id,ticket_id,input_event_id,role,assignment_id,kind,facts_json) VALUES('w','pending',?1,'worker','ticket-worker-assignment','comment','{\"principal_kind\":\"worker\",\"principal_id\":null,\"runtime_id\":\"r\",\"worker_id\":\"worker\",\"status\":null,\"case_state\":\"open\",\"released\":false}')", [id]).unwrap();
conn.execute("INSERT INTO notification_decisions(source_sequence,workspace_id,delivery_id,disposition,reason,purpose,last_offered) SELECT sequence,'w',?1,'deliver','attention','attention',17 FROM notification_sources WHERE input_event_id=?1", [id]).unwrap();
conn.execute("INSERT INTO notification_requests VALUES('w',?1,'original Ticket bytes','{\"worker\":{\"runtime_id\":\"r\",\"worker_id\":\"worker\"},\"authentication_binding_id\":\"original-binding\"}')", [id]).unwrap();
conn.execute("INSERT INTO notification_failures(workspace_id,delivery_id,code,recorded_at) VALUES('w',?1,'acceptance_unknown','original-failure-time')", [id]).unwrap();
}
conn.execute("INSERT INTO notification_acknowledgements SELECT workspace_id,delivery_id,target_json FROM notification_requests WHERE delivery_id='ticket-ack'", []).unwrap();
let ticket_graph = |conn: &Connection| {
[
"SELECT * FROM notification_sources WHERE role!='backend_job' ORDER BY sequence".to_string(),
"SELECT d.* FROM notification_decisions d JOIN notification_sources s ON s.sequence=d.source_sequence WHERE s.role!='backend_job' ORDER BY d.rowid".to_string(),
].into_iter().chain(["notification_requests", "notification_acknowledgements", "notification_failures"].map(|table| format!("SELECT f.* FROM {table} f JOIN notification_decisions d USING(workspace_id,delivery_id) JOIN notification_sources s ON s.sequence=d.source_sequence WHERE s.role!='backend_job' ORDER BY f.rowid")))
.map(|sql| fixture_query_rows(conn, &sql)).collect::<Vec<_>>()
};
let ticket_before = ticket_graph(&conn);
assert!(ticket_before.iter().all(|rows| !rows.is_empty()));
let schema_before = fixture_query_rows(&conn, "SELECT type,name,sql FROM sqlite_schema WHERE type IN ('trigger','index') AND sql IS NOT NULL ORDER BY name");
let check_guards = |conn: &Connection| {
for sql in [
"UPDATE notification_sources SET kind='changed' WHERE input_event_id='ticket-ack'",
"DELETE FROM notification_sources WHERE input_event_id='ticket-ack'",
"UPDATE notification_decisions SET reason='changed' WHERE delivery_id='ticket-ack'",
"DELETE FROM notification_decisions WHERE delivery_id='ticket-ack'",
"UPDATE notification_requests SET content='changed' WHERE delivery_id='ticket-ack'",
"DELETE FROM notification_acknowledgements WHERE delivery_id='ticket-ack'",
"UPDATE notification_failures SET code='changed' WHERE delivery_id='ticket-ack'",
"INSERT INTO notification_decisions(source_sequence,workspace_id,delivery_id,disposition,reason,purpose) VALUES(-1,'w','orphan','deliver','result','result')",
"INSERT INTO notification_requests VALUES('w','orphan','content','{}')",
"INSERT INTO notification_acknowledgements VALUES('w','orphan','{}')",
"INSERT INTO notification_failures(workspace_id,delivery_id,code) VALUES('w','orphan','acceptance_unknown')",
] {
assert!(conn.execute_batch(sql).is_err(), "guard lost: {sql}");
}
assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r.get::<_, i64>(0)).unwrap(), 0);
assert_eq!(conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get::<_, i64>(0)).unwrap(), 0);
assert_eq!(conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0)).unwrap(), 1);
};
check_guards(&conn);
let accepted = job_notice_legacy_seed(&conn, "ack", Some("completed"), None, true);
let clean = job_notice_legacy_seed(
&conn,
"clean",
Some("completed"),
Some("notification_suppressed_no_findings"),
true,
);
let stale = job_notice_legacy_seed(
&conn,
"stale",
Some("completed"),
Some("notification_suppressed_stale_ticket_content"),
true,
);
let unknown = job_notice_legacy_seed(
&conn,
"unknown",
Some("unknown"),
Some("notification_outcome_unknown"),
true,
);
let ambiguous = job_notice_legacy_seed(
&conn,
"ambiguous",
Some("completed"),
Some("unrecognized_legacy_reason"),
true,
);
job_notice_legacy_seed(&conn, "no-target", None, None, false);
job_notice_legacy_seed(&conn, "commit-gap", None, None, true);
conn.execute("UPDATE backend_job_deliveries SET delivery_id='legacy-stable-request' WHERE delivery_id=?1",[&unknown]).unwrap();
let unknown = "legacy-stable-request".to_string();
let checker = job_notice_legacy_seed(&conn, "checker-unknown", Some("sending"), None, true);
let mut checker_request: BackendJobRequest = serde_json::from_str(
&conn
.query_row(
"SELECT request_json FROM backend_jobs WHERE job_id='checker-unknown'",
[],
|r| r.get::<_, String>(0),
)
.unwrap(),
)
.unwrap();
checker_request.purpose = crate::ticket_item_checker::PURPOSE.into();
checker_request.input = serde_json::json!({"ticket_id":"deleted-ticket","ticket_resource_key":"T-42","content_digest":"original-digest","title":"title","body":"Writer-only scope","edit":{"kind":"create"}});
let checker_result = serde_json::json!({"findings":[{"category":"writer_scope","quote":"Writer-only scope","reason":"This is writer scope.","suggestion":"Remove it."}]});
conn.execute(
"UPDATE backend_jobs SET request_json=?1,result_json=?2 WHERE job_id='checker-unknown'",
params![
serde_json::to_string(&checker_request).unwrap(),
checker_result.to_string()
],
)
.unwrap();
conn.execute(
"UPDATE backend_job_attempts SET result_json=?1 WHERE job_id='checker-unknown'",
[checker_result.to_string()],
)
.unwrap();
// Multiple retained identities must never create a fresh send: an ACK wins
// over unknown history; without an ACK the ambiguity stays terminal.
let multi_ack = job_notice_legacy_seed(&conn, "multi-ack", Some("completed"), None, true);
let multi_unknown = job_notice_legacy_seed(&conn, "multi-unknown", Some("unknown"), Some("notification_outcome_unknown"), true);
for id in [&multi_ack, &multi_unknown] {
conn.execute("INSERT INTO backend_job_deliveries(workspace_id,delivery_id,job_id,attempt_id,target_runtime_id,target_worker_id,state,failure_category,created_at,updated_at) SELECT workspace_id,'another-' || delivery_id,job_id,attempt_id,target_runtime_id,target_worker_id,'unknown','notification_outcome_unknown','0','0' FROM backend_job_deliveries WHERE delivery_id=?1", [id]).unwrap();
}
// Earlier unknown beats the derived identity only when there is no real ACK.
let multi_unknown_retained = format!("another-{multi_unknown}");
let before = stored_fixture_rows(&conn, "backend_job_deliveries");
// Force migration failure after the source parent rebuild and verify rollback
// restores the old parent, rows, constraints, version and connection pragmas.
conn.execute_batch("CREATE TABLE notification_job_targets(collision TEXT)")
.unwrap();
assert!(migrate_job_notifications_v95_to_v96(&conn).is_err());
assert_eq!(current_schema_version(&conn).unwrap(), 95);
assert_eq!(ticket_graph(&conn), ticket_before);
assert_eq!(fixture_query_rows(&conn, "SELECT type,name,sql FROM sqlite_schema WHERE type IN ('trigger','index') AND sql IS NOT NULL ORDER BY name"), schema_before);
check_guards(&conn);
assert_eq!(stored_fixture_rows(&conn, "backend_job_deliveries"), before);
assert_eq!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
.unwrap(),
1
);
conn.execute_batch("DROP TABLE notification_job_targets")
.unwrap();
migrate_job_notifications_v95_to_v96(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 96);
assert_eq!(ticket_graph(&conn), ticket_before);
check_guards(&conn);
let schema_after = fixture_query_rows(&conn, "SELECT type,name,sql FROM sqlite_schema WHERE type IN ('trigger','index') AND sql IS NOT NULL ORDER BY name");
assert!(schema_before.iter().all(|row| schema_after.contains(row)));
assert_eq!(stored_fixture_rows(&conn, "backend_job_deliveries"), before);
for (id, disposition, reason, runtime_accepted) in [
(&accepted, "deliver", "result", true),
(&clean, "suppress", "no_findings", false),
(&stale, "suppress", "stale_content", false),
(&unknown, "deliver", "result", false),
(&ambiguous, "defer", "source_unavailable", false),
(&multi_ack, "deliver", "result", true),
(&multi_unknown_retained, "defer", "source_unavailable", false),
] {
let got=conn.query_row("SELECT d.disposition,d.reason,l.runtime_accepted FROM notification_decisions d JOIN notification_job_legacy_receipts l USING(workspace_id,delivery_id) WHERE d.workspace_id='w' AND d.delivery_id=?1",[id],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,bool>(2)?))).unwrap();
assert_eq!(got, (disposition.into(), reason.into(), runtime_accepted));
}
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM notification_acknowledgements",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
1,
"retain the Ticket ACK, never fabricate modern ACKs for legacy Job receipts"
);
for id in [&multi_ack, &multi_unknown_retained] {
assert_eq!(conn.query_row("SELECT COUNT(*) FROM notification_requests WHERE delivery_id=?1", [id], |r| r.get::<_, i64>(0)).unwrap(), 0);
}
assert_eq!(conn.query_row("SELECT code FROM notification_failures WHERE delivery_id=?1", [&multi_unknown_retained], |r| r.get::<_, String>(0)).unwrap(), "preparation_failed");
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r
.get::<_, i64>(
0
))
.unwrap(),
0
);
let pinned: String = conn
.query_row(
"SELECT content FROM notification_requests WHERE delivery_id=?1",
[&unknown],
|r| r.get(0),
)
.unwrap();
assert!(pinned.contains("Durable result digest: digest."));
drop(conn);
let store = SqliteWorkspaceStore::open_for_test(path).unwrap();
store.with_conn(|conn| {
assert_eq!(ticket_graph(conn), ticket_before);
assert_eq!(stored_fixture_rows(conn, "backend_job_deliveries"), before);
check_guards(conn);
Ok(())
}).unwrap();
let pending = store.list_pending_job_notifications("w", 100).unwrap();
assert_eq!(pending.len(), 4);
for (id,stage) in [(&accepted,server_api::NotificationDiagnosticStage::RuntimeAccepted),(&clean,server_api::NotificationDiagnosticStage::Suppressed),(&stale,server_api::NotificationDiagnosticStage::Suppressed),(&unknown,server_api::NotificationDiagnosticStage::AcceptanceUnknown),(&ambiguous,server_api::NotificationDiagnosticStage::Deferred)] {
let page=store.notification_diagnostics("w",&server_api::NotificationDiagnosticsQuery{delivery_id:Some(id.clone()),..Default::default()}).unwrap();
let item=&page.items[0];
assert_eq!(item.stage,stage);
assert!(item.legacy_evidence);
assert!(!item.model_processing_confirmed);
if id==&accepted {
assert!(!item.retry_candidate && !item.acceptance_outcome_unknown);
assert!(item.target_worker_id.is_some());
}
let json=serde_json::to_string(item).unwrap();
assert!(!json.contains("record_json") && !json.contains("notification_suppressed"));
}
let checker_body = store
.job_notification_request("w", &checker)
.unwrap()
.unwrap()
.0;
assert!(checker_body.contains("Content digest: original-digest"));
assert!(checker_body.contains("Quote: \"Writer-only scope\""));
assert!(checker_body.contains("not a new user request, approval gate"));
assert!(pending.iter().all(|s| s.delivery_id != accepted
&& s.delivery_id != clean
&& s.delivery_id != stale
&& s.delivery_id != ambiguous
&& s.delivery_id != multi_ack
&& s.delivery_id != multi_unknown_retained
&& s.delivery_id != multi_unknown));
assert_eq!(
store
.job_notification_request("w", &unknown)
.unwrap()
.unwrap()
.0,
pinned
);
}
#[tokio::test]
async fn job_notification_source_is_atomic_with_result_and_schema_guards_are_verified() {
let (directory, store) = tracking_store_fixture().await;
let mut request = resource_job_request("atomic-source", "atomic-source", "atomic-source");
request.purpose = "notification_test".into();
request.grants = Default::default();
let worker = RuntimeWorkerRef::new("r", "replacement");
let reservation = store
.reserve_backend_job("w", &request, RESOURCE_JOB_NOW)
.unwrap();
store
.claim_backend_job_attempt_dispatch(
"w",
&request.job_id,
&reservation.attempt.attempt_id,
RESOURCE_JOB_NOW,
)
.unwrap();
store
.bind_backend_job_attempt_worker(
"w",
&request.job_id,
&reservation.attempt.attempt_id,
&worker,
Some("run"),
RESOURCE_JOB_NOW,
)
.unwrap();
let submission = BackendJobResultSubmission {
job_id: request.job_id.clone(),
attempt_id: reservation.attempt.attempt_id.clone(),
input_digest: request.input_digest().unwrap(),
result: serde_json::json!({"saved":true}),
};
store.with_conn(|conn| {conn.execute_batch("CREATE TRIGGER reject_job_source BEFORE INSERT ON notification_sources WHEN NEW.role='backend_job' BEGIN SELECT RAISE(ABORT,'injected source commit failure'); END;")?;Ok(())}).unwrap();
assert!(
store
.accept_backend_job_result("w", &worker, &submission, RESOURCE_JOB_NOW)
.is_err()
);
assert_eq!(
store
.get_backend_job("w", &request.job_id)
.unwrap()
.unwrap()
.state,
BackendJobState::Pending
);
assert!(
store
.list_pending_job_notifications("w", 100)
.unwrap()
.is_empty()
);
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER reject_job_source")?;
Ok(())
})
.unwrap();
store
.accept_backend_job_result("w", &worker, &submission, RESOURCE_JOB_NOW)
.unwrap();
assert_eq!(
store
.list_pending_job_notifications("w", 100)
.unwrap()
.len(),
1
);
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER notification_job_targets_no_update")?;
Ok(())
})
.unwrap();
drop(store);
assert!(
matches!(SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")),Err(error) if error.to_string().contains("notification_job_targets_no_update"))
);
}
@@ -0,0 +1,542 @@
//! Backend delivery consumes committed routing decisions, never event-kind policy.
//!
//! A mutation callback is only a low-latency wakeup. The Workspace hook also runs
//! bounded recovery rounds independently of mutations and Runtime subscriptions.
//! Requests are pinned before network I/O; acceptance is not model processing.
//!
//! Routing boundary map:
//! | Source / trigger | Owner | Delivery / recovery |
//! | --- | --- | --- |
//! | Assign, Answer, content and post edits, case, tags, tracking, release | Ticket transaction captures facts; `notification_routing` decides both recipients and non-notification reasons | Saved assignment/Worker requests and ACKs; mutation wakeup and periodic rounds |
//! | Workspace startup, Orchestrator create/restore/replacement, idle | Wakeup only; never a new request or a second policy | Same saved pending decisions |
//! | Backend Job result | Result transaction captures source; checker supplies domain facts; common routing records result/suppression/failure | Fixed Worker request bytes, binding, ACK and legacy receipt; same recovery rounds |
//! | MR review/request/revoke/cancel/merge and typed implementation result | Atomic business source capture; central policy | Same durable routing and periodic recovery; never inferred from comments |
//! | Explicit WorkerNotify/WorkerSendInput | Authenticated caller communication | Not automatic business routing |
//! | Internal SubWorker result and Web subscription | Parent/child execution and UI synchronization respectively | Remain separate; no Ticket policy in Runtime |
//!
//! Runtime receipt retention is finite. This layer preserves request identity and
//! bytes across retries; it does not claim unbounded exactly-once model execution.
use super::*;
use crate::store::{
NotificationDeliveryKey, NotificationFailureCode, TicketWorkerNotification,
TicketWorkerNotificationTarget,
};
#[derive(Default)]
struct NotificationDeliveryActivity {
stopped: bool,
in_flight: usize,
}
#[derive(Default)]
pub(super) struct NotificationDeliveryLifecycle {
state: Mutex<NotificationDeliveryActivity>,
drained: tokio::sync::Notify,
}
pub(super) struct NotificationDeliveryPermit(Arc<NotificationDeliveryLifecycle>);
impl NotificationDeliveryLifecycle {
pub(super) fn begin(self: &Arc<Self>) -> Option<NotificationDeliveryPermit> {
let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
if state.stopped {
return None;
}
state.in_flight += 1;
Some(NotificationDeliveryPermit(self.clone()))
}
pub(super) fn stopped(&self) -> bool {
self.state.lock().unwrap_or_else(|p| p.into_inner()).stopped
}
pub(super) async fn stop_and_drain(&self) {
self.state.lock().unwrap_or_else(|p| p.into_inner()).stopped = true;
loop {
let drained = self.drained.notified();
tokio::pin!(drained);
drained.as_mut().enable();
if self
.state
.lock()
.unwrap_or_else(|p| p.into_inner())
.in_flight
== 0
{
return;
}
drained.await;
}
}
}
impl Drop for NotificationDeliveryPermit {
fn drop(&mut self) {
let mut state = self.0.state.lock().unwrap_or_else(|p| p.into_inner());
state.in_flight -= 1;
if state.in_flight == 0 {
self.0.drained.notify_waiters();
}
}
}
pub(super) fn notification_failure(
result: &std::result::Result<WorkerInputResult, RuntimeRegistryError>,
) -> Option<NotificationFailureCode> {
match result {
Err(_) => Some(NotificationFailureCode::AcceptanceUnknown),
Ok(result) if result.disposition == WorkerInputDisposition::Unknown => {
Some(NotificationFailureCode::AcceptanceUnknown)
}
Ok(result)
if result.state == InternalWorkerOperationState::Accepted
&& result.disposition == WorkerInputDisposition::Accepted =>
{
None
}
Ok(result) if result.state == InternalWorkerOperationState::Unsupported => {
Some(NotificationFailureCode::RuntimeUnsupported)
}
Ok(_) => Some(NotificationFailureCode::RuntimeRejected),
}
}
pub(super) fn assignment_delivery_key(
input: &crate::store::TicketAssignmentDelivery,
) -> NotificationDeliveryKey {
NotificationDeliveryKey::AssignmentInput {
assignment_id: input.assignment_id.clone(),
input_event_id: input.input_event_id.clone(),
}
}
pub(super) fn record_notification_failure(
api: &WorkspaceApi,
key: &NotificationDeliveryKey,
code: NotificationFailureCode,
) {
if let Err(error) =
api.store
.record_notification_delivery_failure(&api.config.workspace_id, key, code)
{
// Do not turn failure to save diagnostics into a successful suppression.
tracing::warn!(%error, "notification failure could not be persisted");
}
}
pub(super) fn notification_target(
api: &WorkspaceApi,
input: &TicketWorkerNotification,
) -> std::result::Result<TicketWorkerNotificationTarget, NotificationFailureCode> {
fixed_worker_target(api, &input.worker)
}
fn fixed_worker_target(
api: &WorkspaceApi,
recipient: &RuntimeWorkerRef,
) -> std::result::Result<TicketWorkerNotificationTarget, NotificationFailureCode> {
let worker = api
.runtime
.worker(recipient)
.map_err(|_| NotificationFailureCode::TargetUnavailable)?;
if worker.workspace.workspace_id.as_deref() != Some(&api.config.workspace_id) {
return Err(NotificationFailureCode::WorkspaceMismatch);
}
if !matches!(worker.state.as_str(), "idle" | "running" | "paused") {
return Err(NotificationFailureCode::TargetStopped);
}
let binding = api
.config_store
.get_workspace_runtime_binding(&api.config.workspace_id, &recipient.runtime_id)
.map_err(|_| NotificationFailureCode::SourceLookupFailed)?
.ok_or(NotificationFailureCode::TargetUnavailable)?;
if binding.revoked_at.is_some() {
return Err(NotificationFailureCode::BindingRevoked);
}
Ok(TicketWorkerNotificationTarget {
worker: recipient.clone(),
authentication_binding_id: binding.binding_id,
})
}
pub(super) fn dispatch_ticket_worker_notifications(api: &WorkspaceApi) {
let Some(_permit) = api.notification_delivery_lifecycle.begin() else {
return;
};
let inputs = match api
.store
.list_pending_ticket_worker_notifications(&api.config.workspace_id, 20)
{
Ok(inputs) => inputs,
Err(error) => {
tracing::warn!(%error, "notification scan failed; committed sources remain recoverable");
return;
}
};
for input in inputs {
if api.notification_delivery_lifecycle.stopped() {
break;
}
let key = NotificationDeliveryKey::WorkerNotification {
delivery_id: input.delivery_id.clone(),
};
let target = match notification_target(api, &input) {
Ok(target) => target,
Err(code) => {
record_notification_failure(api, &key, code);
continue;
}
};
let resource_key = match api.store.resource_key(
&api.config.workspace_id,
WorkspaceResourceKind::Ticket,
&input.ticket_id,
) {
Ok(Some(key)) if canonical_ticket_resource_key(&key).is_some() => key,
_ => {
record_notification_failure(api, &key, NotificationFailureCode::SourceLookupFailed);
continue;
}
};
// An attention notification grants no new assignment or execution authority.
// The immutable source reference lets the recipient reread authoritative facts.
let content = format!(
"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(
&api.config.workspace_id,
&input.delivery_id,
&content,
&target,
) {
Ok(content) => content,
Err(error) => {
let code = match error {
Error::TicketAssignmentConflict(_) => {
NotificationFailureCode::AssignmentChanged
}
_ => NotificationFailureCode::PreparationFailed,
};
record_notification_failure(api, &key, code);
continue;
}
};
let result = api.runtime.send_input(
&input.worker,
WorkerInputRequest {
kind: WorkerInputKind::Notify,
content,
submission_request_id: Some(input.request_id.clone()),
segments: None,
},
);
if let Some(failure) = notification_failure(&result) {
record_notification_failure(api, &key, failure);
} else if api
.store
.ack_ticket_worker_notification(&api.config.workspace_id, &input.delivery_id, &target)
.is_err()
{
record_notification_failure(api, &key, NotificationFailureCode::AcknowledgementFailed);
}
// One recipient's failure never prevents the other recipients in this round.
}
}
pub(super) fn orchestrator_target_failure(
api: &WorkspaceApi,
worker: &RuntimeWorkerRef,
) -> NotificationFailureCode {
match api.runtime.worker(worker) {
Err(_) => return NotificationFailureCode::TargetUnavailable,
Ok(summary)
if summary.workspace.workspace_id.as_deref() != Some(&api.config.workspace_id) =>
{
return NotificationFailureCode::WorkspaceMismatch;
}
Ok(summary) if !matches!(summary.state.as_str(), "idle" | "running" | "paused") => {
return NotificationFailureCode::TargetStopped;
}
_ => {}
}
match api
.config_store
.get_workspace_runtime_binding(&api.config.workspace_id, &worker.runtime_id)
{
Err(_) => NotificationFailureCode::SourceLookupFailed,
Ok(None) => NotificationFailureCode::TargetUnavailable,
Ok(Some(binding)) if binding.revoked_at.is_some() => {
NotificationFailureCode::BindingRevoked
}
_ => NotificationFailureCode::AssignmentChanged,
}
}
pub(super) fn recover_ticket_notifications(api: &WorkspaceApi) {
let Some(_permit) = api.notification_delivery_lifecycle.begin() else {
return;
};
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"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transport_unknown_disposition_is_not_a_confirmed_rejection() {
for (state, disposition, expected) in [
(
InternalWorkerOperationState::Rejected,
WorkerInputDisposition::Unknown,
Some(NotificationFailureCode::AcceptanceUnknown),
),
(
InternalWorkerOperationState::Rejected,
WorkerInputDisposition::Rejected,
Some(NotificationFailureCode::RuntimeRejected),
),
(
InternalWorkerOperationState::Accepted,
WorkerInputDisposition::Accepted,
None,
),
] {
let result = Ok(WorkerInputResult {
state,
disposition,
worker: RuntimeWorkerRef::new("runtime", "worker"),
runtime_run_id: None,
diagnostics: Vec::new(),
});
assert_eq!(notification_failure(&result), expected);
}
}
#[tokio::test]
async fn shutdown_fences_new_rounds_and_drains_the_synchronous_io_owner() {
let lifecycle = Arc::new(NotificationDeliveryLifecycle::default());
let admitted = lifecycle.begin().unwrap();
let stopped = lifecycle.stop_and_drain();
tokio::pin!(stopped);
// Poll shutdown while a synchronous owner still holds its permit. This
// models an aborted async waiter without pretending its I/O was cancelled.
assert!(futures::poll!(stopped.as_mut()).is_pending());
assert!(lifecycle.begin().is_none());
drop(admitted);
tokio::time::timeout(std::time::Duration::from_secs(1), stopped)
.await
.unwrap();
assert!(lifecycle.begin().is_none());
}
}
/// Recover notification sources, not Job execution. This same bounded round runs
/// on startup and periodically; no Worker restoration or assignment is implicit.
pub(super) fn recover_job_notifications(api: &WorkspaceApi) {
let Some(_permit) = api.notification_delivery_lifecycle.begin() else {
return;
};
let sources = match api
.store
.list_pending_job_notifications(&api.config.workspace_id, 20)
{
Ok(sources) => sources,
Err(error) => {
tracing::warn!(%error,"Job notification scan failed");
return;
}
};
for source in sources {
if api.notification_delivery_lifecycle.stopped() {
break;
}
let acceptance = (|| -> Result<BackendJobResultAcceptance> {
let job = api
.store
.get_backend_job(&api.config.workspace_id, &source.job_id)?
.ok_or_else(|| Error::Store("Job notification result disappeared".into()))?;
let attempt = api
.store
.get_backend_job_attempt(
&api.config.workspace_id,
&source.job_id,
&source.attempt_id,
)?
.ok_or_else(|| Error::Store("Job notification attempt disappeared".into()))?;
Ok(BackendJobResultAcceptance {
job,
attempt,
replayed: true,
})
})();
match acceptance {
Ok(acceptance) => deliver_job_result(api, &acceptance),
Err(error) => tracing::warn!(%error,"Job notification source lookup failed"),
}
}
}
fn job_result_facts(
api: &WorkspaceApi,
acceptance: &BackendJobResultAcceptance,
) -> crate::store::JobNotificationFacts {
use crate::store::JobNotificationFacts as F;
let content = if acceptance.job.request.purpose == ticket_item_checker::PURPOSE {
let input = match ticket_item_checker::parse_input(&acceptance.job.request.input) {
Ok(input) => input,
Err(_) => return F::Failed(NotificationFailureCode::InvalidResult),
};
let ticket = match browser_ticket_backend(api)
.and_then(|backend| backend.show(input.ticket_id.into()).map_err(Error::from))
{
Ok(ticket) => ticket,
Err(Error::Ticket(ticket::TicketError::NotFound(_))) => {
return F::Failed(NotificationFailureCode::SourceNotFound);
}
Err(_) => return F::Failed(NotificationFailureCode::SourceLookupFailed),
};
match ticket_item_checker::result_facts(acceptance, &ticket) {
Ok(ticket_item_checker::TicketItemCheckFacts::Findings(content)) => content,
Ok(ticket_item_checker::TicketItemCheckFacts::NoFindings) => {
return F::NoFindings;
}
Ok(ticket_item_checker::TicketItemCheckFacts::Stale) => return F::StaleContent,
Err(_) => return F::Failed(NotificationFailureCode::InvalidResult),
}
} else {
format!(
"Backend Job `{}` completed attempt `{}` for `{}`. Durable result digest: {}.",
acceptance.job.request.job_id,
acceptance.attempt.attempt_id,
acceptance.job.request.purpose,
acceptance
.job
.result_digest
.as_deref()
.unwrap_or("unavailable")
)
};
if content.len() > MAX_JOB_DELIVERY_BYTES {
F::Failed(NotificationFailureCode::ContentTooLarge)
} else {
F::Result(content)
}
}
pub(super) fn deliver_job_result(api: &WorkspaceApi, acceptance: &BackendJobResultAcceptance) {
use crate::store::JobNotificationFacts;
let Some(_permit) = api.notification_delivery_lifecycle.begin() else {
return;
};
let source = match api.store.job_notification_source(
&api.config.workspace_id,
&acceptance.job.request.job_id,
&acceptance.attempt.attempt_id,
) {
Ok(source) => source,
Err(error) => {
tracing::warn!(%error,"Job notification source lookup failed");
return;
}
};
let workspace = &api.config.workspace_id;
let key = NotificationDeliveryKey::WorkerNotification {
delivery_id: source.delivery_id.clone(),
};
// The prepared body wins over all subsequent rendering and content changes.
let request = match api
.store
.job_notification_request(workspace, &source.delivery_id)
{
Ok(request) => request,
Err(error) => {
tracing::warn!(%error,"Job notification request lookup failed");
return;
}
};
let facts = match &request {
Some((content, _)) => JobNotificationFacts::Result(content.clone()),
None => job_result_facts(api, acceptance),
};
match api.store.route_job_notification(workspace, &source, &facts) {
Ok(true) => {}
Ok(false) => return,
Err(error) => {
tracing::warn!(%error,"Job notification routing failed; committed source retained");
return;
}
}
let request = match api
.store
.job_notification_request(workspace, &source.delivery_id)
{
Ok(Some(request)) => request,
_ => {
record_notification_failure(api, &key, NotificationFailureCode::PreparationFailed);
return;
}
};
let (content, target) = request;
if acceptance.job.request.notification_target.as_ref() != Some(&target) {
record_notification_failure(api, &key, NotificationFailureCode::PreparationFailed);
return;
}
let authorized = match fixed_worker_target(api, &target) {
Ok(target) => target,
Err(code) => {
record_notification_failure(api, &key, code);
return;
}
};
match api
.store
.authorize_job_notification(workspace, &source.delivery_id, &authorized)
{
Ok(None) => {}
Ok(Some(code)) => {
record_notification_failure(api, &key, code);
return;
}
Err(_) => {
record_notification_failure(api, &key, NotificationFailureCode::SourceLookupFailed);
return;
}
}
let result = api.runtime.send_input(
&target,
WorkerInputRequest {
kind: WorkerInputKind::Notify,
content,
submission_request_id: Some(source.delivery_id.clone()),
segments: None,
},
);
if let Some(failure) = notification_failure(&result) {
record_notification_failure(api, &key, failure);
} else if api
.store
.ack_job_notification(workspace, &source.delivery_id, &target)
.is_err()
{
record_notification_failure(api, &key, NotificationFailureCode::AcknowledgementFailed);
}
}
@@ -0,0 +1,294 @@
//! Read-only projection of source facts and saved routing/delivery evidence.
//! No policy reimplementation: absent decisions stay awaiting_routing, never suppression.
use super::*;
use server_api::{
NotificationDiagnostic, NotificationDiagnosticStage as Stage, NotificationDiagnosticsPage,
NotificationDiagnosticsQuery,
};
fn source_checks(
conn: &Connection,
workspace: &str,
query: &NotificationDiagnosticsQuery,
) -> Result<Vec<server_api::NotificationSourceCheck>> {
let mut checks = Vec::new();
if let Some(id) = &query.merge_request_id {
let origin: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_thread_events WHERE workspace_id=?1 AND merge_request_id=?2)",params![workspace,id],|r|r.get(0))?;
let captured: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM notification_sources WHERE workspace_id=?1 AND json_extract(facts_json,'$.merge_request_id')=?2)",params![workspace,id],|r|r.get(0))?;
checks.push(server_api::NotificationSourceCheck {
resource_kind: "merge_request_events".into(),
reference: id.clone(),
origin_present: Some(origin),
captured,
});
}
if let Some(id) = &query.job_id {
let origin: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM backend_jobs WHERE workspace_id=?1 AND job_id=?2 AND result_digest IS NOT NULL)",params![workspace,id],|r|r.get(0))?;
let captured: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM notification_sources WHERE workspace_id=?1 AND COALESCE(json_extract(facts_json,'$.job_id'),json_extract(facts_json,'$.backend_job.job_id'))=?2)",params![workspace,id],|r|r.get(0))?;
checks.push(server_api::NotificationSourceCheck {
resource_kind: "job_result".into(),
reference: id.clone(),
origin_present: Some(origin),
captured,
});
}
if let Some(id) = &query.source_event_id {
// Ticket event ordinals are Ticket-local; MR/result source identities are global within Workspace.
let scoped_ticket = id
.starts_with("ticket-event:")
.then_some(query.ticket.as_deref())
.flatten();
let captured: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM notification_sources s LEFT JOIN workspace_resource_keys k ON k.workspace_id=s.workspace_id AND k.resource_kind='ticket' AND k.resource_id=s.ticket_id WHERE s.workspace_id=?1 AND s.input_event_id=?2 AND (?3 IS NULL OR s.ticket_id=?3 OR k.resource_key=?3))",params![workspace,id,scoped_ticket],|r|r.get(0))?;
let origin = if captured {
Some(true)
} else if let (Some(ticket), Some(index)) = (
&query.ticket,
id.strip_prefix("ticket-event:")
.and_then(|v| v.parse::<i64>().ok()),
) {
Some(conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_events e LEFT JOIN workspace_resource_keys k ON k.workspace_id=e.workspace_id AND k.resource_kind='ticket' AND k.resource_id=e.ticket_id WHERE e.workspace_id=?1 AND (e.ticket_id=?2 OR k.resource_key=?2) AND e.event_index=?3)",params![workspace,ticket,index],|r|r.get(0))?)
} else if let Some((mr, event)) =
id.strip_prefix("mr-event:").and_then(|v| v.split_once(':'))
{
Some(conn.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_thread_events WHERE workspace_id=?1 AND merge_request_id=?2 AND event_id=?3)",params![workspace,mr,event],|r|r.get(0))?)
} else if let Some(result) = id.strip_prefix("implementation-result:") {
Some(conn.query_row("SELECT EXISTS(SELECT 1 FROM ticket_implementation_results WHERE workspace_id=?1 AND result_id=?2)",params![workspace,result],|r|r.get(0))?)
} else if let Some((mr, sequence)) = id
.strip_prefix("mr-source:")
.and_then(|v| v.split_once(':'))
{
Some(conn.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_source_observations WHERE workspace_id=?1 AND merge_request_id=?2 AND sequence=?3)",params![workspace,mr,sequence],|r|r.get(0))?)
} else {
None
};
checks.push(server_api::NotificationSourceCheck {
resource_kind: "source_event".into(),
reference: id.clone(),
origin_present: origin,
captured,
});
}
Ok(checks)
}
fn sequence(value: &str) -> Result<i64> {
value
.strip_prefix("notification-source:")
.and_then(|n| n.parse::<i64>().ok())
.filter(|n| *n > 0)
.ok_or_else(|| Error::InvalidInput("Invalid notification source cursor".into()))
}
fn bounded(value: Option<String>) -> Option<String> {
value.map(|s| s.chars().take(512).collect())
}
pub(super) fn page(
store: &SqliteWorkspaceStore,
workspace: &str,
query: &NotificationDiagnosticsQuery,
) -> Result<NotificationDiagnosticsPage> {
store.with_conn(|conn| {
let tx = conn.unchecked_transaction()?;
page_with_connection(&tx, workspace, query)
})
}
pub(super) fn page_with_connection(
conn: &Connection,
workspace: &str,
query: &NotificationDiagnosticsQuery,
) -> Result<NotificationDiagnosticsPage> {
let limit = query.limit.unwrap_or(50);
if !(1..=100).contains(&limit) {
return Err(Error::InvalidInput(
"Notification limit must be between 1 and 100".into(),
));
}
for value in [
&query.ticket,
&query.merge_request_id,
&query.job_id,
&query.source_event_id,
&query.assignment_id,
&query.delivery_id,
&query.source_id,
&query.cursor,
]
.into_iter()
.flatten()
{
if value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) {
return Err(Error::InvalidInput("Invalid notification filter".into()));
}
}
let after = query
.cursor
.as_deref()
.map(sequence)
.transpose()?
.unwrap_or(0);
let source = query.source_id.as_deref().map(sequence).transpose()?;
let filter = "s.workspace_id=?1
AND (?2 IS NULL OR s.ticket_id=?2 OR tk.resource_key=?2)
AND (?3 IS NULL OR json_extract(s.facts_json,'$.merge_request_id')=?3)
AND (?4 IS NULL OR COALESCE(json_extract(s.facts_json,'$.job_id'),json_extract(s.facts_json,'$.backend_job.job_id'))=?4)
AND (?5 IS NULL OR s.input_event_id=?5)
AND (?6 IS NULL OR s.assignment_id=?6)
AND (?7 IS NULL OR d.delivery_id=?7)
AND (?8 IS NULL OR s.sequence=?8)";
let joins = "FROM notification_sources s
LEFT JOIN notification_decisions d ON d.source_sequence=s.sequence AND d.workspace_id=s.workspace_id
LEFT JOIN workspace_resource_keys tk ON tk.workspace_id=s.workspace_id AND tk.resource_kind='ticket' AND tk.resource_id=s.ticket_id";
let bind = params![
workspace,
query.ticket,
query.merge_request_id,
query.job_id,
query.source_event_id,
query.assignment_id,
query.delivery_id,
source,
after,
limit as i64 + 1
];
// Validate the cursor against the same Workspace and filters, not a global row id.
if after > 0 {
let exists: bool = conn.query_row(
&format!("SELECT EXISTS(SELECT 1 {joins} WHERE {filter} AND s.sequence=?9)"),
&bind[..9],
|row| row.get(0),
)?;
if !exists {
return Err(Error::InvalidInput(
"Notification cursor does not belong to this query".into(),
));
}
}
let sql = format!("SELECT s.sequence,s.input_event_id,s.kind,COALESCE(tk.resource_key,s.ticket_id),
json_extract(s.facts_json,'$.merge_request_id'),COALESCE(json_extract(s.facts_json,'$.job_id'),json_extract(s.facts_json,'$.backend_job.job_id')),s.role,NULLIF(s.assignment_id,''),
d.disposition,d.reason,d.purpose,d.delivery_id,
ar.delivery_id IS NOT NULL,
(r.delivery_id IS NOT NULL OR ap.delivery_id IS NOT NULL),
(a.delivery_id IS NOT NULL OR aa.delivery_id IS NOT NULL OR COALESCE(l.runtime_accepted,0)=1),
COALESCE(json_extract(a.target_json,'$.worker.runtime_id'),json_extract(aa.source_json,'$.runtime_id'),json_extract(r.target_json,'$.worker.runtime_id'),json_extract(r.target_json,'$.runtime_id'),json_extract(ar.reserved_source_json,'$.runtime_id'),json_extract(l.record_json,'$.target.runtime_id')),
COALESCE(json_extract(a.target_json,'$.worker.worker_id'),json_extract(aa.source_json,'$.worker_id'),json_extract(r.target_json,'$.worker.worker_id'),json_extract(r.target_json,'$.worker_id'),json_extract(ar.reserved_source_json,'$.worker_id'),json_extract(l.record_json,'$.target.worker_id')),
COALESCE(f.code,pf.code),COALESCE(f.recorded_at,pf.recorded_at),
CASE WHEN s.role IN ('orchestrator','worker') AND s.assignment_id!='' THEN EXISTS(SELECT 1 FROM ticket_current_worker_assignments c WHERE c.workspace_id=s.workspace_id AND c.ticket_id=s.ticket_id AND c.role=s.role AND c.assignment_id=s.assignment_id) END,
EXISTS(SELECT 1 FROM notification_failures uf WHERE uf.workspace_id=s.workspace_id AND uf.delivery_id=d.delivery_id AND uf.code IN ('acceptance_unknown','acknowledgement_failed')),
(SELECT COUNT(*) FROM (SELECT DISTINCT at.runtime_id,at.worker_id FROM notification_assignment_targets at WHERE at.workspace_id=s.workspace_id AND at.delivery_id=d.delivery_id)),
l.delivery_id IS NOT NULL,
pf.sequence IS NOT NULL
{joins}
LEFT JOIN notification_source_preparation_failures pf ON pf.sequence=(SELECT MAX(p.sequence) FROM notification_source_preparation_failures p WHERE p.source_sequence=s.sequence)
LEFT JOIN notification_job_legacy_receipts l ON l.workspace_id=s.workspace_id AND l.delivery_id=d.delivery_id
LEFT JOIN notification_requests r ON r.workspace_id=s.workspace_id AND r.delivery_id=d.delivery_id
LEFT JOIN notification_acknowledgements a ON a.workspace_id=s.workspace_id AND a.delivery_id=d.delivery_id
LEFT JOIN ticket_assignment_deliveries ar ON ar.workspace_id=s.workspace_id AND ar.delivery_id=d.delivery_id
LEFT JOIN ticket_assignment_delivery_requests ap ON ap.workspace_id=s.workspace_id AND ap.delivery_id=ar.delivery_id
LEFT JOIN ticket_assignment_delivery_acknowledgements aa ON aa.workspace_id=s.workspace_id AND aa.delivery_id=ar.delivery_id
LEFT JOIN notification_failures f ON f.sequence=(SELECT MAX(nf.sequence) FROM notification_failures nf WHERE nf.workspace_id=s.workspace_id AND nf.delivery_id=d.delivery_id)
WHERE {filter} AND s.sequence>?9 ORDER BY s.sequence LIMIT ?10");
let mut stmt = conn.prepare(&sql)?;
let mut rows = stmt.query(bind)?;
let mut items = Vec::new();
while let Some(row) = rows.next()? {
let disposition: Option<String> = row.get(8)?;
let reserved: bool = row.get(12)?;
let prepared: bool = row.get(13)?;
let accepted: bool = row.get(14)?;
let failure: Option<String> = row.get(17)?;
let recipient_current: Option<bool> = row.get(19)?;
let unresolved_send: bool = row.get(20)?;
let multiple_targets = row.get::<_, i64>(21)? > 1;
let source_failure: bool = row.get(23)?;
let acceptance_outcome_unknown = !accepted && (prepared || unresolved_send);
use server_api::NotificationTargetResolution as Target;
let target_resolution = if accepted {
Target::RuntimeAccepted
} else if multiple_targets {
Target::MultipleAuthorizedTargets
} else if prepared {
Target::RequestPersisted
} else if reserved {
Target::Reserved
} else {
Target::Unresolved
};
let target_runtime_id = if multiple_targets && !accepted {
None
} else {
bounded(row.get(15)?)
};
let target_worker_id = if multiple_targets && !accepted {
None
} else {
bounded(row.get(16)?)
};
let stage = if accepted {
Stage::RuntimeAccepted
} else if recipient_current == Some(false) {
Stage::Historical
} else if disposition.is_none() {
if source_failure {
Stage::DeliveryFailed
} else {
Stage::AwaitingRouting
}
} else if disposition.as_deref() == Some("suppress") {
Stage::Suppressed
} else if disposition.as_deref() == Some("defer") {
Stage::Deferred
} else if unresolved_send {
Stage::AcceptanceUnknown
} else if failure.as_deref() == Some("runtime_rejected") {
Stage::RuntimeRejected
} else if failure.is_some() {
Stage::DeliveryFailed
} else if prepared {
Stage::RequestPersisted
} else if reserved {
Stage::Reserved
} else {
Stage::Pending
};
let retry_candidate = !accepted
&& recipient_current != Some(false)
&& failure.as_deref() != Some("assignment_changed")
&& (disposition.as_deref() == Some("deliver")
|| (disposition.is_none() && source_failure));
items.push(NotificationDiagnostic {
source_id: format!("notification-source:{}", row.get::<_, i64>(0)?),
source_event_id: bounded(row.get(1)?).unwrap_or_default(),
event_kind: bounded(row.get(2)?).unwrap_or_default(),
ticket: bounded(row.get(3)?),
merge_request_id: bounded(row.get(4)?),
job_id: bounded(row.get(5)?),
recipient_role: bounded(row.get(6)?).unwrap_or_default(),
assignment_id: bounded(row.get(7)?),
recipient_current,
disposition: bounded(disposition),
reason: bounded(row.get(9)?),
purpose: bounded(row.get(10)?),
delivery_id: bounded(row.get(11)?),
stage,
legacy_evidence: row.get(22)?,
acceptance_outcome_unknown,
target_resolution,
target_runtime_id,
target_worker_id,
last_failure_code: bounded(failure),
last_failure_at: bounded(row.get(18)?),
retry_candidate,
model_processing_confirmed: false,
});
}
let has_more = items.len() > limit;
items.truncate(limit);
let next_cursor = has_more.then(|| items.last().expect("positive limit").source_id.clone());
let source_checks = source_checks(conn, workspace, query)?;
Ok(NotificationDiagnosticsPage {
items,
next_cursor,
source_checks,
})
}
@@ -0,0 +1,149 @@
// Production Store and SQLite. No Runtime/model side effects are required for diagnostics.
#[tokio::test]
async fn notification_diagnostics_preserves_unknown_acceptance_and_marks_replacement_ambiguity() {
use server_api::{NotificationDiagnosticsQuery as Q,NotificationDiagnosticStage as S,NotificationTargetResolution as T};
for initial_failure in [NotificationFailureCode::AcceptanceUnknown,NotificationFailureCode::AcknowledgementFailed] {
let (_dir,store)=tracking_store_fixture().await;
tracking_store_assign(&store,"a","assign-a");
let source=tracking_store_source();
let delivery=delivery_store_reserve_initial(&store,&source);
store.prepare_ticket_assignment_delivery_content("w",&delivery.delivery_id,"frozen body",&source).unwrap();
let key=NotificationDeliveryKey::AssignmentInput{assignment_id:"a".into(),input_event_id:delivery.input_event_id.clone()};
store.record_notification_delivery_failure("w",&key,initial_failure).unwrap();
store.record_notification_delivery_failure("w",&key,NotificationFailureCode::TargetStopped).unwrap();
let query=Q{delivery_id:Some(delivery.delivery_id.clone()),..Default::default()};
let read=||store.notification_diagnostics("w",&query).unwrap().items.remove(0);
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();
let replacement=TicketAssignmentTrackingSourceContext{worker_id:"replacement".into(),..source};
store.prepare_ticket_assignment_delivery_content("w",&delivery.delivery_id,"ignored new body",&replacement).unwrap();
let ambiguous=read();
assert_eq!(ambiguous.target_resolution,T::MultipleAuthorizedTargets);
assert!(ambiguous.target_runtime_id.is_none() && ambiguous.target_worker_id.is_none());
store.ack_ticket_assignment_delivery("w",&delivery.delivery_id,&replacement).unwrap();
let accepted=read();
assert_eq!(accepted.target_resolution,T::RuntimeAccepted);
assert_eq!(accepted.target_worker_id.as_deref(),Some("replacement"));
assert!(!accepted.acceptance_outcome_unknown);
}
}
#[tokio::test]
async fn notification_diagnostics_detects_exact_missing_mr_event_after_earlier_capture() {
use server_api::NotificationDiagnosticsQuery as Q;
let (_dir,store)=tracking_store_fixture().await;
tracking_store_assign(&store,"a","assign-a");
store.with_conn(|conn| {
conn.execute("INSERT INTO merge_requests(workspace_id,merge_request_id,repository_id,state,selector_to,created_at,updated_at) SELECT workspace_id,'mr-diagnostic',repository_id,'open','develop','1','1' FROM repositories WHERE workspace_id='w' LIMIT 1",[])?;
for (event,seq) in [("first",1),("missing",2)] {
conn.execute("INSERT INTO merge_request_thread_events(workspace_id,merge_request_id,event_id,sequence,kind,payload_json,created_at) VALUES('w','mr-diagnostic',?1,?2,'review','{}','1')",params![event,seq])?;
}
conn.execute("INSERT INTO notification_sources(workspace_id,ticket_id,input_event_id,role,assignment_id,kind,facts_json) SELECT workspace_id,ticket_id,'mr-event:mr-diagnostic:first',role,assignment_id,'mr_review',json_set(facts_json,'$.merge_request_id','mr-diagnostic') FROM notification_sources WHERE assignment_id='a' LIMIT 1",[])?;
Ok(())
}).unwrap();
let page=store.notification_diagnostics("w",&Q{merge_request_id:Some("mr-diagnostic".into()),source_event_id:Some("mr-event:mr-diagnostic:missing".into()),..Default::default()}).unwrap();
assert!(page.items.is_empty());
assert!(page.source_checks[0].captured);
assert_eq!(page.source_checks[1].origin_present,Some(true));
assert!(!page.source_checks[1].captured);
}
#[tokio::test]
async fn notification_diagnostics_bounds_saved_reasons_without_returning_source_payloads() {
use server_api::NotificationDiagnosticsQuery as Q;
let (_dir,store)=tracking_store_fixture().await;
tracking_store_assign(&store,"a","assign-a");
store.with_conn(|conn| {
conn.execute("INSERT INTO notification_decisions(source_sequence,workspace_id,delivery_id,disposition,reason,purpose) SELECT sequence,workspace_id,'bounded-reason','suppress',?1,'attention' FROM notification_sources WHERE assignment_id='a' LIMIT 1",["長".repeat(2000)])?;
Ok(())
}).unwrap();
let page=store.notification_diagnostics("w",&Q{delivery_id:Some("bounded-reason".into()),..Q::default()}).unwrap();
assert_eq!(page.items[0].reason.as_ref().unwrap().chars().count(),512);
assert!(!serde_json::to_string(&page).unwrap().contains("facts_json"));
}
#[tokio::test]
async fn notification_diagnostics_detects_unconnected_origin_and_retains_history_without_pending_work() {
use server_api::{NotificationDiagnosticsQuery as Q,NotificationDiagnosticStage as S};
let (_dir,store)=tracking_store_fixture().await;
tracking_store_assign(&store,"a","assign-a");
let delivery=delivery_store_reserve_initial(&store,&tracking_store_source());
store.clear_current_ticket_role_assignment("w","tracking",TicketAssignmentRole::Orchestrator,"a","clear-a","clear-a","owner","2",None).unwrap();
let page=store.notification_diagnostics("w",&Q{delivery_id:Some(delivery.delivery_id),..Q::default()}).unwrap();
assert_eq!(page.items[0].stage,S::Historical);
assert_eq!(page.items[0].recipient_current,Some(false));
assert!(!page.items[0].retry_candidate);
// Simulate a missing producer adapter; a diagnostic read must not repair or hide it.
store.with_conn(|conn|{conn.execute_batch("DROP TRIGGER notification_ticket_event")?;Ok(())}).unwrap();
delivery_store_external_event(&store,999,"comment",None);
store.with_conn(|conn|{conn.execute_batch("PRAGMA query_only=ON")?;Ok(())}).unwrap();
let query=Q{ticket:Some("tracking".into()),source_event_id:Some("ticket-event:999".into()),..Q::default()};
let page=store.notification_diagnostics("w",&query).unwrap();
assert!(page.items.is_empty());
assert_eq!(page.source_checks[0].origin_present,Some(true));
assert!(!page.source_checks[0].captured);
let other=store.notification_diagnostics("other",&query).unwrap();
assert_eq!(other.source_checks[0].origin_present,Some(false));
assert!(!other.source_checks[0].captured);
}
#[tokio::test]
async fn notification_diagnostics_is_readonly_paginated_and_workspace_scoped() {
use server_api::{NotificationDiagnosticsQuery as Q, NotificationDiagnosticStage as S};
let (_dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "a", "assign-a");
delivery_store_answer(&store, "answer-a");
let before = store.with_conn(|conn| Ok((conn.total_changes(),stored_fixture_rows(conn,"notification_decisions")))).unwrap();
store.with_conn(|conn| { conn.execute_batch("PRAGMA query_only=ON")?; Ok(()) }).unwrap();
let first = store.notification_diagnostics("w", &Q{limit:Some(1), ..Q::default()}).unwrap();
assert_eq!(first.items.len(),1);
assert_eq!(first.items[0].stage,S::AwaitingRouting);
let next = store.notification_diagnostics("w", &Q{cursor:first.next_cursor.clone(), ..Q::default()}).unwrap();
assert!(next.items.iter().all(|i| i.source_id != first.items[0].source_id));
assert!(!next.items.is_empty());
assert!(store.notification_diagnostics("other-workspace", &Q{cursor:Some(first.items[0].source_id.clone()), ..Q::default()}).is_err());
assert!(store.notification_diagnostics("w", &Q{ticket:Some("missing".into()), cursor:Some(first.items[0].source_id.clone()), ..Q::default()}).is_err());
for limit in [0,101] { assert!(store.notification_diagnostics("w", &Q{limit:Some(limit), ..Q::default()}).is_err()); }
assert!(store.notification_diagnostics("w", &Q{cursor:Some("not-a-source".into()), ..Q::default()}).is_err());
assert_eq!(store.with_conn(|conn| Ok((conn.total_changes(),stored_fixture_rows(conn,"notification_decisions")))).unwrap(),before);
store.with_conn(|conn| { conn.execute_batch("PRAGMA query_only=OFF")?; Ok(()) }).unwrap();
}
#[tokio::test]
async fn notification_diagnostics_preserves_suppression_failure_and_acceptance_boundaries() {
use server_api::{NotificationDiagnosticsQuery as Q, NotificationDiagnosticStage as S};
let (_dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store,"a","assign-a");
let pending = store.list_pending_ticket_assignment_deliveries("w",100).unwrap();
assert_eq!(pending.len(),1);
let query = Q{assignment_id:Some("a".into()), source_event_id:Some(pending[0].input_event_id.clone()), ..Q::default()};
let read = || store.notification_diagnostics("w", &query).unwrap().items.remove(0);
assert_eq!(read().stage,S::Pending);
let source = tracking_store_source();
let delivery = delivery_store_reserve_initial(&store,&source);
assert_eq!(read().stage,S::Reserved);
store.prepare_ticket_assignment_delivery_content("w",&delivery.delivery_id,"PRIVATE-NOTIFICATION-CONTENT",&source).unwrap();
assert_eq!(read().stage,S::RequestPersisted);
let key = NotificationDeliveryKey::AssignmentInput{assignment_id:delivery.assignment_id.clone(),input_event_id:delivery.input_event_id.clone()};
store.record_notification_delivery_failure("w",&key,NotificationFailureCode::RuntimeRejected).unwrap();
assert_eq!(read().stage,S::RuntimeRejected);
store.record_notification_delivery_failure("w",&key,NotificationFailureCode::AcceptanceUnknown).unwrap();
assert_eq!(read().stage,S::AcceptanceUnknown);
store.ack_ticket_assignment_delivery("w",&delivery.delivery_id,&source).unwrap();
let accepted = read();
assert_eq!(accepted.stage,S::RuntimeAccepted);
assert!(!accepted.retry_candidate && !accepted.model_processing_confirmed);
assert_eq!(accepted.target_worker_id.as_deref(),Some(source.worker_id.as_str()));
let serialized = serde_json::to_string(&accepted).unwrap();
assert!(!serialized.contains("PRIVATE-NOTIFICATION-CONTENT"));
assert!(!serialized.contains(&source.authentication_binding_id));
let all = store.notification_diagnostics("w",&Q::default()).unwrap();
assert!(all.items.iter().any(|i| i.stage==S::Suppressed && i.reason.as_deref()==Some("no_recipient")));
store.with_conn_mut(|conn| {
conn.execute("INSERT INTO notification_sources(workspace_id,ticket_id,input_event_id,role,assignment_id,kind,facts_json) SELECT workspace_id,ticket_id,'future-event',role,assignment_id,'unregistered-event',facts_json FROM notification_sources WHERE assignment_id='a' LIMIT 1",[])?;
notification_routing::route_pending(conn,"w")
}).unwrap();
let unknown = store.notification_diagnostics("w",&Q{source_event_id:Some("future-event".into()), ..Q::default()}).unwrap();
assert_eq!(unknown.items[0].stage,S::Deferred);
assert_eq!(unknown.items[0].reason.as_deref(),Some("unknown_event"));
assert!(!unknown.items[0].retry_candidate);
}
@@ -0,0 +1,35 @@
//! Public MR event additions must be wired into business routing (or explicitly excluded).
use super::*;
#[test]
fn notification_registration_covers_public_mr_event_kinds() {
let document = server_api::canonical_openapi_document()
.unwrap()
.to_json()
.unwrap();
let document: serde_json::Value = serde_json::from_str(&document).unwrap();
let variants = document["components"]["schemas"]["MergeRequestThreadEvent"]["oneOf"]
.as_array()
.expect("public tagged MR event schema");
assert!(!variants.is_empty());
for variant in variants {
let kind = variant["properties"]["kind"]["const"]
.as_str()
.expect("MR event kind discriminator");
if kind == "comment" {
continue;
} // Explicit communication is not a review result/handoff.
assert_ne!(
NotificationEventKind::from_source(&format!("mr_{kind}")),
NotificationEventKind::Unknown,
"new public MR event {kind} needs a routing registration and source/receipt integration test"
);
}
for kind in ["implementation_result", "mr_source_changed"] {
assert_ne!(
NotificationEventKind::from_source(kind),
NotificationEventKind::Unknown,
"{kind} adapter is disconnected from routing"
);
}
}
@@ -0,0 +1,937 @@
//! Domain writes capture facts atomically; this module alone interprets notification intent.
//! Runtime acceptance, handling and execution authority remain independent. Explicit user
//! communication, internal child notifications and Web subscriptions are not business routes.
use super::*;
#[path = "job_notification_routing.rs"]
pub(super) mod jobs;
pub use jobs::{JobNotificationFacts, JobNotificationSource};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NotificationPurpose {
NewDelegation,
ContinuingRequest,
Attention,
Result,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NotificationEventKind {
Assignment,
Answer,
ContentEdit,
Comment,
Case,
Tracking,
Tags,
Release,
AssignmentChanged,
Result,
MergeRequest,
Unknown,
}
impl NotificationEventKind {
fn from_source(kind: &str) -> Self {
match kind {
"assignment" => Self::Assignment,
"assignment_answer" => Self::Answer,
"content_edit" | "item_edit" => Self::ContentEdit,
"comment" | "comment_edit" => Self::Comment,
"case" | "case_update" => Self::Case,
"tracking" | "assignment_tracking" => Self::Tracking,
"tags" | "tags_set" | "tags_changed" => Self::Tags,
"release" => Self::Release,
"assignment_changed" => Self::AssignmentChanged,
"backend_job_result" => Self::Result,
"implementation_result" => Self::Result,
"mr_review_requested"
| "mr_review"
| "mr_review_revoked"
| "mr_review_cancelled"
| "mr_merge"
| "mr_source_changed" => Self::MergeRequest,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NotificationReason {
Requested,
Attention,
Result,
NoRecipient,
NoFindings,
StaleContent,
SourceUnavailable,
UnsupportedRecipient,
HistoricalOnly,
CaseClosed,
HandlingDone,
HandlingUnknown,
SelfNotification,
NotRequest,
AssignmentChanged,
UnknownEvent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NotificationFailureCode {
ConfigurationUnavailable,
RenderingFailed,
ContentTooLarge,
InvalidResult,
SourceLookupFailed,
SourceNotFound,
TargetUnavailable,
TargetStopped,
WorkspaceMismatch,
BindingRevoked,
AssignmentChanged,
RuntimeRejected,
AcceptanceUnknown,
PreparationFailed,
AcknowledgementFailed,
RuntimeUnsupported,
RuntimeUnavailable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NotificationDeliveryKey {
AssignmentInput {
assignment_id: String,
input_event_id: String,
},
WorkerNotification {
delivery_id: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketWorkerNotification {
pub ticket_id: String,
pub assignment_id: String,
pub input_event_id: String,
pub delivery_id: String,
pub request_id: String,
pub worker: RuntimeWorkerRef,
pub purpose: NotificationPurpose,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TicketWorkerNotificationTarget {
pub worker: RuntimeWorkerRef,
pub authentication_binding_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Facts {
principal_kind: Option<String>,
principal_id: Option<String>,
runtime_id: Option<String>,
worker_id: Option<String>,
status: Option<String>,
case_state: String,
released: bool,
}
#[derive(Debug)]
struct Source {
sequence: i64,
workspace: String,
ticket: String,
input: String,
role: String,
assignment: String,
kind: String,
actor: Option<String>,
event_index: Option<i64>,
facts: Facts,
}
fn json<T: Serialize>(value: &T) -> Result<String> {
serde_json::to_string(value).map_err(|e| Error::Store(e.to_string()))
}
fn code<T: Serialize>(value: T) -> Result<String> {
Ok(json(&value)?.trim_matches('"').to_owned())
}
fn decode<T: serde::de::DeserializeOwned>(value: &str) -> Result<T> {
serde_json::from_str(value).map_err(|e| Error::Store(e.to_string()))
}
fn purpose(kind: NotificationEventKind, role: &str) -> NotificationPurpose {
match (kind, role) {
(NotificationEventKind::Assignment, "orchestrator") => NotificationPurpose::NewDelegation,
(NotificationEventKind::Answer | NotificationEventKind::ContentEdit, "orchestrator") => {
NotificationPurpose::ContinuingRequest
}
(NotificationEventKind::Result, _) => NotificationPurpose::Result,
_ => NotificationPurpose::Attention,
}
}
fn policy(
kind: NotificationEventKind,
role: &str,
assignment: &str,
facts: &Facts,
self_notification: bool,
) -> (&'static str, NotificationReason) {
use NotificationReason as R;
if kind == NotificationEventKind::Unknown {
return ("defer", R::UnknownEvent);
}
if assignment.is_empty() {
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")
} else {
role == "worker" && facts.principal_kind.as_deref() == Some("worker")
};
if !supported {
return ("suppress", R::UnsupportedRecipient);
}
if self_notification {
return ("suppress", R::SelfNotification);
}
if facts.released {
return ("suppress", R::HistoricalOnly);
}
if facts.case_state != "open" {
return ("suppress", R::CaseClosed);
}
if role == "orchestrator" {
match facts.status.as_deref() {
Some("waiting" | "inprogress") => {}
Some("done") => return ("suppress", R::HandlingDone),
_ => return ("suppress", R::HandlingUnknown),
}
if !matches!(
kind,
NotificationEventKind::Assignment
| NotificationEventKind::Answer
| NotificationEventKind::ContentEdit
| NotificationEventKind::Result
| NotificationEventKind::MergeRequest
) {
return ("suppress", R::NotRequest);
}
}
if matches!(
kind,
NotificationEventKind::Tracking | NotificationEventKind::Release
) {
return ("suppress", R::NotRequest);
}
(
"deliver",
match purpose(kind, role) {
NotificationPurpose::Result => R::Result,
NotificationPurpose::Attention => R::Attention,
_ => R::Requested,
},
)
}
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))";
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}
FROM (SELECT 'orchestrator' role UNION ALL SELECT 'worker') roles
JOIN typed_tickets t ON t.workspace_id=NEW.workspace_id AND t.ticket_id=NEW.ticket_id
LEFT JOIN ticket_current_worker_assignments c ON c.workspace_id=t.workspace_id AND c.ticket_id=t.ticket_id AND c.role=roles.role
LEFT JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id;")
}
pub(super) fn install(conn: &Connection) -> Result<()> {
conn.execute_batch(include_str!("notification_routing.sql"))?;
// Import only already recognized request identities and receipts. Never scan old comments.
conn.execute_batch(&format!("INSERT OR IGNORE INTO notification_sources(workspace_id,ticket_id,input_event_id,role,assignment_id,kind,event_index,facts_json)
SELECT a.workspace_id,a.ticket_id,o.input_event_id,a.role,a.assignment_id,'assignment',NULL,{FACTS_SQL}
FROM ticket_assignment_delivery_origins o JOIN ticket_worker_assignments a USING(workspace_id,assignment_id) JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id;
INSERT OR IGNORE INTO notification_sources(workspace_id,ticket_id,input_event_id,role,assignment_id,kind,event_index,facts_json)
SELECT a.workspace_id,a.ticket_id,'ticket-edit:' || printf('%020d',e.input_sequence),a.role,a.assignment_id,'content_edit',NULL,{FACTS_SQL}
FROM ticket_assignment_content_inputs e JOIN ticket_worker_assignments a USING(workspace_id,assignment_id) JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id;
INSERT OR IGNORE INTO notification_sources(workspace_id,ticket_id,input_event_id,role,assignment_id,kind,event_index,facts_json)
SELECT a.workspace_id,a.ticket_id,'ticket-event:' || e.event_index,a.role,a.assignment_id,e.kind,e.event_index,{FACTS_SQL}
FROM ticket_assignment_delivery_origins o JOIN ticket_worker_assignments a USING(workspace_id,assignment_id) JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id
JOIN typed_ticket_events e ON e.workspace_id=a.workspace_id AND e.ticket_id=a.ticket_id AND e.event_index>o.after_ticket_event_index
WHERE e.kind IN ('assignment_answer','item_edit') AND
(EXISTS(SELECT 1 FROM typed_ticket_event_attributes x WHERE x.workspace_id=e.workspace_id AND x.ticket_id=e.ticket_id AND x.event_index=e.event_index AND x.key='orchestrator_assignment_id' AND x.value=a.assignment_id)
OR (e.kind='item_edit' AND NOT EXISTS(SELECT 1 FROM ticket_assignment_delivery_origins later JOIN ticket_worker_assignments la ON la.workspace_id=later.workspace_id AND la.assignment_id=later.assignment_id WHERE later.workspace_id=a.workspace_id AND later.ticket_id=a.ticket_id AND la.rowid>a.rowid AND e.event_index>later.after_ticket_event_index)));
INSERT OR IGNORE INTO notification_sources(workspace_id,ticket_id,input_event_id,role,assignment_id,kind,event_index,facts_json)
SELECT a.workspace_id,a.ticket_id,d.input_event_id,a.role,a.assignment_id,'content_edit',NULL,{FACTS_SQL}
FROM ticket_assignment_deliveries d JOIN ticket_worker_assignments a USING(workspace_id,assignment_id) JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id;"))?;
let event = capture(
"'ticket-event:' || NEW.event_index",
"CASE WHEN NEW.state_field='case' THEN 'case' WHEN NEW.state_field='tags' THEN 'tags' ELSE NEW.kind END",
"NEW.author",
"NEW.event_index",
);
let history = capture(
"'assignment-event:' || NEW.event_id",
"CASE WHEN json_extract(NEW.source_context_json,'$.kind')='assignment' THEN 'assignment' ELSE 'tracking' END",
"json_extract(NEW.source_context_json,'$.actor')",
"NULL",
);
let change = capture(
"'assignment-change:' || NEW.event_id",
"'assignment_changed'",
"NEW.actor",
"NULL",
);
let release = capture(
"'assignment-release:' || NEW.assignment_id",
"'release'",
"NULL",
"NULL",
);
conn.execute_batch(&format!("CREATE TRIGGER notification_ticket_event AFTER INSERT ON typed_ticket_events WHEN NEW.event_index>=0 BEGIN {event} END;
CREATE TRIGGER notification_assignment_history AFTER INSERT ON typed_ticket_assignment_tracking_history BEGIN {history} END;
CREATE TRIGGER notification_assignment_change AFTER INSERT ON ticket_worker_assignment_events BEGIN {change}
INSERT OR IGNORE INTO notification_sources(workspace_id,ticket_id,input_event_id,role,assignment_id,kind,actor,event_index,facts_json)
SELECT a.workspace_id,a.ticket_id,'assignment-change:' || NEW.event_id,a.role,a.assignment_id,'release',NEW.actor,NULL,json_set({FACTS_SQL},'$.released',json('true'))
FROM ticket_worker_assignments a JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id
WHERE a.workspace_id=NEW.workspace_id AND a.assignment_id=NEW.previous_assignment_id AND NEW.previous_assignment_id IS NOT NEW.assignment_id;
END;"))?;
// Release rows have no Ticket column; use the assignment's immutable parent.
let release = release.replace("NEW.ticket_id", "(SELECT ticket_id FROM ticket_worker_assignments WHERE workspace_id=NEW.workspace_id AND assignment_id=NEW.assignment_id)");
conn.execute_batch(&format!("CREATE TRIGGER notification_assignment_release AFTER INSERT ON ticket_assignment_work_releases BEGIN {release} END;
CREATE TRIGGER notification_content_input AFTER INSERT ON ticket_assignment_content_inputs BEGIN
INSERT 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,'ticket-edit:' || printf('%020d',NEW.input_sequence),a.role,a.assignment_id,NEW.kind,NEW.actor,NULL,json_set({FACTS_SQL},'$.content_change_id',NEW.source_change_id)
FROM ticket_worker_assignments a JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id
WHERE a.workspace_id=NEW.workspace_id AND a.assignment_id=NEW.assignment_id;
END;"))?;
let missing = capture(
"'ticket-content:' || NEW.change_id",
"NEW.kind",
"NEW.actor",
"NULL",
)
.trim_end_matches(';')
.to_owned()
+ " WHERE a.assignment_id IS NULL;";
conn.execute_batch(&format!("CREATE TRIGGER notification_unassigned_content AFTER INSERT ON typed_ticket_content_changes BEGIN {missing} END;"))?;
for table in [
"notification_sources",
"notification_assignment_targets",
"notification_requests",
"notification_acknowledgements",
"notification_failures",
] {
for action in ["update", "delete"] {
conn.execute_batch(&format!("CREATE TRIGGER {table}_no_{action} BEFORE {action} ON {table} BEGIN SELECT RAISE(ABORT,'Notification evidence is immutable'); END;"))?;
}
}
conn.execute_batch("CREATE TRIGGER notification_decisions_no_delete BEFORE DELETE ON notification_decisions BEGIN SELECT RAISE(ABORT,'Notification decision is retained'); END;
CREATE TRIGGER notification_decisions_identity_no_update BEFORE UPDATE OF source_sequence,workspace_id,delivery_id,disposition,reason,purpose ON notification_decisions BEGIN SELECT RAISE(ABORT,'Notification decision is immutable'); END;")?;
Ok(())
}
/// Capture business facts in the same SQLite transaction as their owners. Historical
/// MR events are bounded by their original assignment interval, never today's delegation.
/// Comments are deliberately excluded: neither legacy handoff prose nor review comments
/// prove a typed result. MR input is an instruction to reread evidence, not approval.
pub(super) fn install_business_events(conn: &Connection) -> Result<()> {
conn.execute_batch(include_str!("implementation_result.sql"))?;
// Only identities retained by the typed event are actors. Cancellation and
// provider observations have no actor; never substitute the requester/reviewer.
let actor_path = "CASE NEW.kind WHEN 'review_requested' THEN '$.requested_by' WHEN 'review' THEN '$.reviewer' WHEN 'review_revoked' THEN '$.revoked_by' WHEN 'merge' THEN '$.merged_by' END";
let mr_actor = format!(
"'worker:' || json_extract(NEW.payload_json,({actor_path}) || '.runtime_id') || '/' || json_extract(NEW.payload_json,({actor_path}) || '.worker_id')"
);
let mr = 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,r.ticket_id,'mr-event:' || NEW.merge_request_id || ':' || NEW.event_id,roles.role,COALESCE(a.assignment_id,''),'mr_' || NEW.kind,{mr_actor},NULL,
json_set({FACTS_SQL},'$.merge_request_id',NEW.merge_request_id,'$.event_id',NEW.event_id,'$.subject_ref',COALESCE(json_extract(NEW.payload_json,'$.subject_ref'),json_extract(NEW.payload_json,'$.approved_source_ref')),'$.request_event_id',json_extract(NEW.payload_json,'$.request_event_id'))
FROM merge_request_ticket_relations r JOIN typed_tickets t ON t.workspace_id=r.workspace_id AND t.ticket_id=r.ticket_id
CROSS JOIN (SELECT 'orchestrator' role UNION ALL SELECT 'worker') roles
LEFT JOIN ticket_current_worker_assignments c ON c.workspace_id=t.workspace_id AND c.ticket_id=t.ticket_id AND c.role=roles.role
LEFT JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id
WHERE r.workspace_id=NEW.workspace_id AND r.merge_request_id=NEW.merge_request_id;");
conn.execute_batch(&format!("CREATE TRIGGER notification_mr_event AFTER INSERT ON merge_request_thread_events WHEN NEW.kind!='comment' BEGIN {mr} END;"))?;
let source = mr.replace(&mr_actor, "NULL")
.replace("'mr-event:' || NEW.merge_request_id || ':' || NEW.event_id", "'mr-source:' || NEW.merge_request_id || ':' || NEW.sequence")
.replace("'mr_' || NEW.kind", "'mr_source_changed'")
.replace("NEW.event_id", "NEW.sequence")
.replace("COALESCE(json_extract(NEW.payload_json,'$.subject_ref'),json_extract(NEW.payload_json,'$.approved_source_ref'))", "NEW.subject_ref")
.replace("json_extract(NEW.payload_json,'$.request_event_id')", "NULL");
conn.execute_batch(&format!("CREATE TRIGGER notification_mr_source AFTER INSERT ON merge_request_source_observations WHEN NEW.previous_subject_ref IS NOT NULL BEGIN {source} END;"))?;
// Recover retained review/merge evidence once. A source identity is the immutable
// MR event, so re-review is distinct and retry cannot duplicate an input.
// Equal/ambiguous assignment timestamps cannot prove event-time ownership;
// retain a no-recipient source instead of guessing by row order.
let historical = mr.replace("NEW.", "e.").replace(
"FROM merge_request_ticket_relations r", "FROM merge_request_thread_events e JOIN merge_request_ticket_relations r ON r.workspace_id=e.workspace_id AND r.merge_request_id=e.merge_request_id"
).replace(
"LEFT JOIN ticket_current_worker_assignments c ON c.workspace_id=t.workspace_id AND c.ticket_id=t.ticket_id AND c.role=roles.role\n LEFT JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id",
"LEFT JOIN ticket_worker_assignments a ON a.workspace_id=t.workspace_id AND a.ticket_id=t.ticket_id AND a.role=roles.role AND a.assignment_id=(SELECT CASE WHEN COUNT(*)=1 THEN MIN(h.assignment_id) END FROM ticket_worker_assignments h WHERE h.workspace_id=t.workspace_id AND h.ticket_id=t.ticket_id AND h.role=roles.role AND h.assigned_at=(SELECT MAX(prior.assigned_at) FROM ticket_worker_assignments prior WHERE prior.workspace_id=t.workspace_id AND prior.ticket_id=t.ticket_id AND prior.role=roles.role AND prior.assigned_at<e.created_at))"
).replace("WHERE r.workspace_id=e.workspace_id AND r.merge_request_id=e.merge_request_id;", "WHERE e.kind!='comment';");
conn.execute_batch(&historical)?;
conn.execute_batch(&format!("CREATE TRIGGER notification_implementation_result AFTER INSERT ON ticket_implementation_results BEGIN
INSERT 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,'implementation-result:' || NEW.result_id,'orchestrator',a.assignment_id,'implementation_result','worker:' || NEW.runtime_id || '/' || NEW.worker_id,NULL,
json_set({FACTS_SQL},'$.result_id',NEW.result_id,'$.implementation_assignment_id',NEW.assignment_id)
FROM ticket_worker_assignments a JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id
WHERE a.workspace_id=NEW.workspace_id AND a.assignment_id=NEW.orchestrator_assignment_id;
END;"))?;
Ok(())
}
impl SqliteWorkspaceStore {
/// Record a provider-resolved source observation, not approval. Git has no local
/// transactional event log: transitions are captured when the existing MR paths
/// observe them. Identical observations (including concurrent scans) are deduplicated.
pub(crate) fn observe_merge_request_source(
&self,
workspace: &str,
repository: &str,
selector: &str,
subject: &str,
) -> Result<()> {
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let ids = tx.prepare("SELECT merge_request_id FROM merge_requests WHERE workspace_id=?1 AND repository_id=?2 AND selector_from=?3 AND state='open'")?.query_map(params![workspace,repository,selector],|r|r.get::<_,String>(0))?.collect::<rusqlite::Result<Vec<_>>>()?;
for id in ids {
let previous: Option<String> = tx.query_row("SELECT subject_ref FROM merge_request_source_observations WHERE workspace_id=?1 AND merge_request_id=?2 ORDER BY sequence DESC LIMIT 1", params![workspace,id], |r|r.get(0)).optional()?;
// New MRs may already have a review before the first observation.
let previous = match previous {
Some(previous) => Some(previous),
None => tx.query_row("SELECT json_extract(payload_json,'$.subject_ref') FROM merge_request_thread_events WHERE workspace_id=?1 AND merge_request_id=?2 AND json_extract(payload_json,'$.subject_ref') IS NOT NULL ORDER BY sequence DESC LIMIT 1",params![workspace,id],|r|r.get(0)).optional()?,
};
if previous.as_deref() != Some(subject) {
tx.execute("INSERT INTO merge_request_source_observations(workspace_id,merge_request_id,subject_ref,previous_subject_ref,observed_at) VALUES(?1,?2,?3,?4,?5)",params![workspace,id,subject,previous,chrono::Utc::now().to_rfc3339()])?;
}
}
tx.commit()?;
Ok(())
})
}
}
pub(super) fn verify(conn: &Connection) -> Result<()> {
let mut names = vec![
"notification_sources".to_owned(),
"notification_assignment_targets".into(),
"notification_decisions".into(),
"notification_requests".into(),
"notification_acknowledgements".into(),
"notification_failures".into(),
];
for table in [
"notification_sources",
"notification_assignment_targets",
"notification_requests",
"notification_acknowledgements",
"notification_failures",
] {
for action in ["update", "delete"] {
names.push(format!("{table}_no_{action}"));
}
}
for name in [
"notification_decisions_no_delete",
"notification_decisions_identity_no_update",
"notification_ticket_event",
"notification_assignment_history",
"notification_assignment_change",
"notification_assignment_release",
"notification_content_input",
"notification_unassigned_content",
] {
names.push(name.into());
}
if current_schema_version(conn)? >= 96 {
names.extend(
[
"ticket_implementation_delegations",
"ticket_implementation_results",
"implementation_delegation_capture",
"implementation_results_no_update",
"implementation_results_no_delete",
"implementation_delegations_no_update",
"implementation_delegations_no_delete",
"notification_mr_event",
"notification_implementation_result",
"notification_mr_source",
"merge_request_source_observations",
"mr_source_observations_no_update",
"mr_source_observations_no_delete",
]
.into_iter()
.map(str::to_owned),
);
}
for name in names {
let present: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name=?1)",
[&name],
|r| r.get(0),
)?;
if !present {
return Err(Error::Store(format!(
"Notification routing constraint {name} is missing"
)));
}
}
Ok(())
}
fn read_source(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<(
i64,
String,
String,
String,
String,
String,
String,
Option<String>,
Option<i64>,
String,
)> {
Ok((
row.get(0)?,
row.get(1)?,
row.get::<_, Option<String>>(2)?.unwrap_or_default(),
row.get(3)?,
row.get(4)?,
row.get(5)?,
row.get(6)?,
row.get(7)?,
row.get(8)?,
row.get(9)?,
))
}
fn source_from_row(row: &rusqlite::Row<'_>) -> Result<Source> {
let (sequence, workspace, ticket, input, role, assignment, kind, actor, event_index, facts) =
read_source(row)?;
Ok(Source {
sequence,
workspace,
ticket,
input,
role,
assignment,
kind,
actor,
event_index,
facts: decode(&facts)?,
})
}
fn stable_id(source: &Source) -> Result<String> {
let identities = if source.role == "orchestrator" && !source.assignment.is_empty() {
json(&(&source.workspace, &source.assignment, &source.input))?
} else {
json(&(
&source.workspace,
&source.ticket,
&source.role,
&source.assignment,
&source.input,
))?
};
let digest = Sha256::digest(identities.as_bytes());
let hex = digest
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
Ok(format!(
"{}:{hex}",
if source.role == "orchestrator" {
"assignment-delivery"
} else {
"worker-notification"
}
))
}
fn self_notification(conn: &Connection, source: &Source) -> Result<bool> {
// Actor strings are authenticated audit attribution, not authorization grants.
if let (Some(runtime), Some(worker), Some(actor)) = (
&source.facts.runtime_id,
&source.facts.worker_id,
&source.actor,
) {
if actor == &format!("worker:{runtime}/{worker}")
|| actor == &format!("authenticated-worker:{runtime}/{worker}")
{
return Ok(true);
}
}
if let Some(index) = source.event_index {
let pair: (Option<String>,Option<String>)=conn.query_row("SELECT MAX(CASE WHEN key='source_runtime_id' THEN value END),MAX(CASE WHEN key='source_worker_id' THEN value END) FROM typed_ticket_event_attributes WHERE workspace_id=?1 AND ticket_id=?2 AND event_index=?3",params![source.workspace,source.ticket,index],|r|Ok((r.get(0)?,r.get(1)?)))?;
if pair.0.is_some() && pair.0 == source.facts.runtime_id && pair.1 == source.facts.worker_id
{
return Ok(true);
}
}
Ok(false)
}
fn decision_for_source(
conn: &Connection,
source: &Source,
) -> Result<(&'static str, NotificationReason, NotificationPurpose)> {
let workspace = &source.workspace;
let kind = NotificationEventKind::from_source(&source.kind);
let (mut disposition, mut reason) = policy(
kind,
&source.role,
&source.assignment,
&source.facts,
self_notification(conn, source)?,
);
if kind == NotificationEventKind::Answer && source.role == "orchestrator" {
let fence:Option<String>=conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE workspace_id=?1 AND ticket_id=?2 AND event_index=?3 AND key='orchestrator_assignment_id'",params![workspace,source.ticket,source.event_index],|r|r.get(0)).optional()?;
if fence.as_deref() != Some(&source.assignment) {
disposition = "suppress";
reason = NotificationReason::AssignmentChanged;
}
}
Ok((disposition, reason, purpose(kind, &source.role)))
}
pub(super) fn route_pending(conn: &Connection, workspace: &str) -> Result<()> {
let mut stmt = conn.prepare("SELECT s.* FROM notification_sources s LEFT JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.workspace_id=?1 AND s.role!='backend_job' AND d.source_sequence IS NULL ORDER BY s.sequence LIMIT 500")?;
let mut rows = stmt.query([workspace])?;
while let Some(row) = rows.next()? {
let source = source_from_row(row)?;
let (disposition, reason, purpose) = decision_for_source(conn, &source)?;
let saved_id: Option<String> = if source.role == "orchestrator" {
conn.query_row("SELECT delivery_id FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND assignment_id=?2 AND input_event_id=?3", params![workspace,source.assignment,source.input], |r|r.get(0)).optional()?
} else {
None
};
let id = match saved_id {
Some(id) => id,
None => stable_id(&source)?,
};
conn.execute("INSERT OR IGNORE INTO notification_decisions(source_sequence,workspace_id,delivery_id,disposition,reason,purpose) VALUES(?1,?2,?3,?4,?5,?6)",params![source.sequence,workspace,id,disposition,code(reason)?,code(purpose)?])?;
}
Ok(())
}
// Preview only this assignment's sources in sequence order. Persisted decisions and
// receipts win; unrouted sources use exactly the delivery policy, without saving it.
// Streaming avoids a global routing batch cap and bounds retained rows to limit + 1.
pub(super) fn diagnostic_inputs(
conn: &Connection,
workspace: &str,
ticket: &str,
assignment: &str,
after: Option<&str>,
limit: usize,
) -> Result<Vec<server_api::TicketAssignmentInputDiagnostic>> {
let mut stmt = conn.prepare("SELECT s.*,d.disposition,r.delivery_id,EXISTS(SELECT 1 FROM ticket_assignment_delivery_requests p WHERE p.workspace_id=s.workspace_id AND p.delivery_id=r.delivery_id),EXISTS(SELECT 1 FROM ticket_assignment_delivery_acknowledgements a WHERE a.workspace_id=s.workspace_id AND a.delivery_id=r.delivery_id) FROM notification_sources s LEFT JOIN notification_decisions d ON d.source_sequence=s.sequence LEFT JOIN ticket_assignment_deliveries r ON r.workspace_id=s.workspace_id AND r.assignment_id=s.assignment_id AND r.input_event_id=s.input_event_id WHERE s.workspace_id=?1 AND s.ticket_id=?2 AND s.assignment_id=?3 AND s.role='orchestrator' ORDER BY s.sequence")?;
let mut rows = stmt.query(params![workspace, ticket, assignment])?;
let mut cursor_seen = after.is_none();
let mut inputs = Vec::new();
while let Some(row) = rows.next()? {
let source = source_from_row(row)?;
let disposition: Option<String> = row.get(10)?;
let delivery_id: Option<String> = row.get(11)?;
let eligible = match disposition {
Some(disposition) => disposition == "deliver",
None => decision_for_source(conn, &source)?.0 == "deliver",
};
if !eligible && delivery_id.is_none() {
continue;
}
if !cursor_seen {
cursor_seen = after == Some(source.input.as_str());
continue;
}
let prepared: bool = row.get(12)?;
let ack: bool = row.get(13)?;
use server_api::TicketAssignmentInputDeliveryState::*;
let notification = if ack {
RuntimeAccepted
} else if prepared {
RequestPersisted
} else if delivery_id.is_some() {
Reserved
} else {
NotReserved
};
inputs.push(server_api::TicketAssignmentInputDiagnostic {
input_event_id: source.input,
delivery_id,
notification,
acceptance_outcome_unknown: prepared && !ack,
});
if inputs.len() > limit {
break;
}
}
if !cursor_seen {
return Err(Error::InvalidInput(
"Input cursor does not belong to this assignment".into(),
));
}
Ok(inputs)
}
pub(super) fn require_current(
conn: &Connection,
workspace: &str,
ticket: &str,
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()?;
if let Some(facts) = facts {
let facts: Facts = decode(&facts)?;
if policy(
NotificationEventKind::ContentEdit,
role,
assignment,
&facts,
false,
)
.0 == "deliver"
{
return Ok(());
}
}
Err(Error::TicketAssignmentConflict(
"Notification assignment is no longer current actionable responsibility".into(),
))
}
pub(super) fn pending_sources(
conn: &Connection,
workspace: &str,
role: &str,
limit: usize,
) -> Result<Vec<(String, String, String, String)>> {
route_pending(conn, workspace)?;
let mut stmt=conn.prepare("SELECT s.ticket_id,s.assignment_id,s.input_event_id,d.delivery_id FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.workspace_id=?1 AND s.role=?2 AND d.disposition='deliver' AND NOT EXISTS(SELECT 1 FROM ticket_assignment_delivery_acknowledgements a WHERE a.workspace_id=?1 AND a.delivery_id=d.delivery_id) AND NOT EXISTS(SELECT 1 FROM notification_acknowledgements a WHERE a.workspace_id=?1 AND a.delivery_id=d.delivery_id) ORDER BY d.last_offered,s.sequence LIMIT ?3")?;
let candidates = stmt
.query_map(params![workspace, role, limit.clamp(1, 100) as i64], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
let tick: i64 = conn.query_row(
"SELECT COALESCE(MAX(last_offered),0)+1 FROM notification_decisions WHERE workspace_id=?1",
[workspace],
|r| r.get(0),
)?;
let mut result = Vec::new();
for row in candidates {
conn.execute("UPDATE notification_decisions SET last_offered=?3 WHERE workspace_id=?1 AND delivery_id=?2",params![workspace,row.3,tick])?;
match require_current(conn, workspace, &row.0, &row.1, role) {
Ok(()) => result.push(row),
Err(Error::TicketAssignmentConflict(_)) => {
save_failure(
conn,
workspace,
&row.3,
NotificationFailureCode::AssignmentChanged,
)?;
}
Err(error) => return Err(error),
}
}
Ok(result)
}
fn save_failure(
conn: &Connection,
workspace: &str,
id: &str,
failure: NotificationFailureCode,
) -> Result<()> {
let value = code(failure)?;
conn.execute("INSERT INTO notification_failures(workspace_id,delivery_id,code) SELECT ?1,?2,?3 WHERE NOT EXISTS(SELECT 1 FROM notification_failures WHERE sequence=(SELECT MAX(sequence) FROM notification_failures WHERE workspace_id=?1 AND delivery_id=?2) AND code=?3)",params![workspace,id,value])?;
Ok(())
}
pub(super) fn failure(
store: &SqliteWorkspaceStore,
workspace: &str,
key: &NotificationDeliveryKey,
failure: NotificationFailureCode,
) -> Result<()> {
store.with_conn_mut(|conn| {let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
route_pending(&tx,workspace)?;
let id:String=match key {
NotificationDeliveryKey::WorkerNotification{delivery_id}=>tx.query_row("SELECT delivery_id FROM notification_decisions WHERE workspace_id=?1 AND delivery_id=?2",params![workspace,delivery_id],|r|r.get(0))?,
NotificationDeliveryKey::AssignmentInput{assignment_id,input_event_id}=>tx.query_row("SELECT d.delivery_id FROM notification_decisions d JOIN notification_sources s ON s.sequence=d.source_sequence WHERE s.workspace_id=?1 AND s.assignment_id=?2 AND s.input_event_id=?3 AND s.role='orchestrator'",params![workspace,assignment_id,input_event_id],|r|r.get(0))?,
};
save_failure(&tx,workspace,&id,failure)?;tx.commit()?;Ok(())
})
}
pub(super) fn list_workers(
store: &SqliteWorkspaceStore,
workspace: &str,
limit: usize,
) -> Result<Vec<TicketWorkerNotification>> {
if limit == 0 || limit > 100 {
return Err(Error::InvalidInput(
"Notification limit must be 1..100".into(),
));
}
store.with_conn_mut(|conn| {let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let mut result=Vec::new();
for (ticket_id,assignment_id,input_event_id,delivery_id) in pending_sources(&tx,workspace,"worker",limit)? {
let (runtime,worker):(String,String)=tx.query_row("SELECT runtime_id,worker_id FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace,assignment_id],|r|Ok((r.get(0)?,r.get(1)?)))?;
result.push(TicketWorkerNotification{ticket_id,assignment_id,input_event_id,request_id:delivery_id.clone(),delivery_id,worker:RuntimeWorkerRef::new(runtime,worker),purpose:NotificationPurpose::Attention});
}
tx.commit()?;Ok(result)
})
}
fn worker_guard(
conn: &Connection,
workspace: &str,
id: &str,
target: &TicketWorkerNotificationTarget,
) -> Result<()> {
let (ticket,assignment,runtime,worker):(String,String,String,String)=conn.query_row("SELECT s.ticket_id,s.assignment_id,json_extract(s.facts_json,'$.runtime_id'),json_extract(s.facts_json,'$.worker_id') FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE d.workspace_id=?1 AND d.delivery_id=?2 AND s.role='worker' AND d.disposition='deliver'",params![workspace,id],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?)))?;
require_current(conn, workspace, &ticket, &assignment, "worker")?;
if target.worker != RuntimeWorkerRef::new(runtime, worker) {
return Err(Error::WorkspacePermissionDenied(
"Notification target differs from captured assignment".into(),
));
}
let valid:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM worker_registry w JOIN workspace_runtime_bindings b ON b.workspace_id=w.workspace_id AND b.runtime_id=w.runtime_id WHERE w.workspace_id=?1 AND w.runtime_id=?2 AND w.worker_id=?3 AND b.binding_id=?4 AND b.revoked_at IS NULL AND NOT EXISTS(SELECT 1 FROM worker_removal_operations r WHERE r.workspace_id=w.workspace_id AND r.runtime_id=w.runtime_id AND r.worker_id=w.worker_id AND r.state IN ('executing','failed','succeeded')))",params![workspace,target.worker.runtime_id,target.worker.worker_id,target.authentication_binding_id],|r|r.get(0))?;
if !valid {
return Err(Error::WorkspacePermissionDenied(
"Notification target binding is unavailable or revoked".into(),
));
}
Ok(())
}
pub(super) fn prepare_worker(
store: &SqliteWorkspaceStore,
workspace: &str,
id: &str,
content: &str,
target: &TicketWorkerNotificationTarget,
) -> Result<String> {
if content.is_empty() || content.len() > 262144 || content.contains('\0') {
return Err(Error::InvalidInput(
"Notification content must be nonempty, NUL-free and at most 262144 bytes".into(),
));
}
store.with_conn_mut(|conn| {let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
worker_guard(&tx,workspace,id,target)?;
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())); }
saved
} else {tx.execute("INSERT INTO notification_requests VALUES(?1,?2,?3,?4)",params![workspace,id,content,json(target)?])?;content.to_owned()};
tx.commit()?;Ok(result)
})
}
pub(super) fn ack_worker(
store: &SqliteWorkspaceStore,
workspace: &str,
id: &str,
target: &TicketWorkerNotificationTarget,
) -> Result<()> {
store.with_conn_mut(|conn| {let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
// Acceptance is evidence about the prepared target, even if responsibility changed during I/O.
let saved:String=tx.query_row("SELECT target_json FROM notification_requests WHERE workspace_id=?1 AND delivery_id=?2",params![workspace,id],|r|r.get(0))?;
if saved!=json(target)? {return Err(Error::TicketAssignmentConflict("Acknowledgement target differs from prepared notification".into()));}
tx.execute("INSERT OR IGNORE INTO notification_acknowledgements VALUES(?1,?2,?3)",params![workspace,id,saved])?;
tx.commit()?;Ok(())
})
}
#[cfg(test)]
#[path = "notification_registration_tests.rs"]
mod registration_tests;
#[cfg(test)]
mod tests {
use super::*;
#[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()),
runtime_id: None,
worker_id: None,
status: Some("waiting".into()),
case_state: "open".into(),
released: false,
};
use NotificationEventKind as K;
use NotificationPurpose as P;
use NotificationReason as R;
for (kind, disposition, reason, purpose_value) in [
(K::Assignment, "deliver", R::Requested, P::NewDelegation),
(K::Answer, "deliver", R::Requested, P::ContinuingRequest),
(
K::ContentEdit,
"deliver",
R::Requested,
P::ContinuingRequest,
),
(K::Comment, "suppress", R::NotRequest, P::Attention),
(K::Case, "suppress", R::NotRequest, P::Attention),
(K::Tracking, "suppress", R::NotRequest, P::Attention),
(K::Tags, "suppress", R::NotRequest, P::Attention),
(K::Release, "suppress", R::NotRequest, P::Attention),
(
K::AssignmentChanged,
"suppress",
R::NotRequest,
P::Attention,
),
(K::Result, "deliver", R::Result, P::Result),
(K::Unknown, "defer", R::UnknownEvent, P::Attention),
] {
assert_eq!(
policy(kind, "orchestrator", "a", &facts, false),
(disposition, reason)
);
assert_eq!(purpose(kind, "orchestrator"), purpose_value);
}
facts.status = Some("done".into());
assert_eq!(
policy(K::Result, "orchestrator", "a", &facts, false),
("suppress", R::HandlingDone)
);
assert_eq!(
purpose(K::Result, "orchestrator"),
P::Result,
"terminal result never changes into a new delegation"
);
facts.principal_kind = Some("worker".into());
facts.principal_id = None;
for kind in [
K::Assignment,
K::Answer,
K::ContentEdit,
K::Comment,
K::Case,
K::Tags,
K::AssignmentChanged,
] {
assert_eq!(
policy(kind, "worker", "a", &facts, false),
("deliver", R::Attention)
);
assert_eq!(purpose(kind, "worker"), P::Attention);
}
assert_eq!(
policy(K::Comment, "worker", "a", &facts, true),
("suppress", R::SelfNotification)
);
assert_eq!(
policy(K::Comment, "worker", "", &facts, false),
("suppress", R::NoRecipient)
);
facts.case_state = "closed".into();
assert_eq!(
policy(K::Comment, "worker", "a", &facts, false),
("suppress", R::CaseClosed)
);
facts.released = true;
facts.case_state = "open".into();
assert_eq!(
policy(K::Comment, "worker", "a", &facts, false),
("suppress", R::HistoricalOnly)
);
}
}
@@ -0,0 +1,80 @@
-- Source facts commit with the domain write. Policy belongs to Rust, not these triggers.
CREATE TABLE notification_sources (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, input_event_id TEXT NOT NULL,
role TEXT NOT NULL, assignment_id TEXT NOT NULL, kind TEXT NOT NULL,
actor TEXT, event_index INTEGER, facts_json TEXT NOT NULL CHECK(json_valid(facts_json)),
UNIQUE(workspace_id,ticket_id,input_event_id,role,assignment_id),
FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id)
);
CREATE TABLE notification_decisions (
source_sequence INTEGER PRIMARY KEY REFERENCES notification_sources(sequence),
workspace_id TEXT NOT NULL, delivery_id TEXT NOT NULL,
disposition TEXT NOT NULL CHECK(disposition IN ('deliver','suppress','defer')),
reason TEXT NOT NULL, purpose TEXT NOT NULL,
last_offered INTEGER NOT NULL DEFAULT 0,
UNIQUE(workspace_id,delivery_id)
);
CREATE TABLE notification_requests (
workspace_id TEXT NOT NULL, delivery_id TEXT NOT NULL, content TEXT NOT NULL,
target_json TEXT NOT NULL CHECK(json_valid(target_json)),
PRIMARY KEY(workspace_id,delivery_id),
FOREIGN KEY(workspace_id,delivery_id) REFERENCES notification_decisions(workspace_id,delivery_id)
);
CREATE TABLE notification_acknowledgements (
workspace_id TEXT NOT NULL, delivery_id TEXT NOT NULL,
target_json TEXT NOT NULL CHECK(json_valid(target_json)),
PRIMARY KEY(workspace_id,delivery_id),
FOREIGN KEY(workspace_id,delivery_id) REFERENCES notification_requests(workspace_id,delivery_id)
);
CREATE TABLE notification_failures (
sequence INTEGER PRIMARY KEY AUTOINCREMENT, workspace_id TEXT NOT NULL,
delivery_id TEXT NOT NULL, code TEXT NOT NULL,
recorded_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
FOREIGN KEY(workspace_id,delivery_id) REFERENCES notification_decisions(workspace_id,delivery_id)
);
-- Authorized send targets survive responsibility/binding changes during Runtime I/O.
-- Reservation evidence is sufficient even before rendering; it was authorized under
-- the same write lock. Existing payload/request/ACK rows are never rewritten.
CREATE TABLE notification_assignment_targets (
workspace_id TEXT NOT NULL, delivery_id TEXT NOT NULL,
runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, agent_key TEXT NOT NULL,
authentication_binding_id TEXT NOT NULL,
PRIMARY KEY(workspace_id,delivery_id,runtime_id,worker_id,agent_key,authentication_binding_id),
FOREIGN KEY(workspace_id,delivery_id) REFERENCES ticket_assignment_deliveries(workspace_id,delivery_id)
);
INSERT INTO notification_assignment_targets
SELECT workspace_id,delivery_id,json_extract(reserved_source_json,'$.runtime_id'),
json_extract(reserved_source_json,'$.worker_id'),json_extract(reserved_source_json,'$.agent_key'),
json_extract(reserved_source_json,'$.authentication_binding_id') FROM ticket_assignment_deliveries;
-- A pre-upgrade ACK also proves its target passed the old current/source guards;
-- it may be a replacement different from the immutable original reservation.
INSERT OR IGNORE INTO notification_assignment_targets
SELECT workspace_id,delivery_id,json_extract(source_json,'$.runtime_id'),
json_extract(source_json,'$.worker_id'),json_extract(source_json,'$.agent_key'),
json_extract(source_json,'$.authentication_binding_id') FROM ticket_assignment_delivery_acknowledgements;
CREATE INDEX notification_sources_assignment ON notification_sources(workspace_id,ticket_id,assignment_id,role,sequence);
CREATE INDEX notification_sources_unrouted ON notification_sources(workspace_id,sequence);
CREATE INDEX notification_decisions_pending ON notification_decisions(workspace_id,disposition,last_offered,source_sequence);
CREATE INDEX notification_failures_delivery ON notification_failures(workspace_id,delivery_id,sequence);
-- Retain old content identities and rows; remove notification eligibility from capture.
DROP TRIGGER ticket_assignment_title_input;
DROP TRIGGER ticket_assignment_initial_post_input;
ALTER TABLE ticket_assignment_content_inputs ADD COLUMN actor TEXT;
ALTER TABLE ticket_assignment_content_inputs ADD COLUMN kind TEXT NOT NULL DEFAULT 'content_edit';
ALTER TABLE ticket_assignment_content_inputs ADD COLUMN source_change_id TEXT REFERENCES typed_ticket_content_changes(change_id);
-- Keep the operational input identity while consuming complete domain facts, including editor.
CREATE TRIGGER ticket_assignment_title_input AFTER INSERT ON typed_ticket_content_changes
WHEN NEW.title_changed=1 BEGIN
INSERT INTO ticket_assignment_content_inputs(workspace_id,ticket_id,assignment_id,after_ticket_event_index,actor,kind,source_change_id)
SELECT NEW.workspace_id,NEW.ticket_id,c.assignment_id,
COALESCE((SELECT MAX(event_index) FROM typed_ticket_events WHERE workspace_id=NEW.workspace_id AND ticket_id=NEW.ticket_id),-1),NEW.actor,NEW.kind,NEW.change_id
FROM ticket_current_worker_assignments c WHERE c.workspace_id=NEW.workspace_id AND c.ticket_id=NEW.ticket_id;
END;
CREATE TRIGGER ticket_assignment_initial_post_input AFTER INSERT ON typed_ticket_content_changes
WHEN NEW.title_changed=0 BEGIN
INSERT INTO ticket_assignment_content_inputs(workspace_id,ticket_id,assignment_id,after_ticket_event_index,actor,kind,source_change_id)
SELECT NEW.workspace_id,NEW.ticket_id,c.assignment_id,
COALESCE((SELECT MAX(event_index) FROM typed_ticket_events WHERE workspace_id=NEW.workspace_id AND ticket_id=NEW.ticket_id),-1),NEW.actor,NEW.kind,NEW.change_id
FROM ticket_current_worker_assignments c WHERE c.workspace_id=NEW.workspace_id AND c.ticket_id=NEW.ticket_id;
END;
@@ -0,0 +1,931 @@
// Real SQLite through the production Store. Runtime acceptance is supplied by the
// caller here; server_assignment_delivery_tests owns the real NotifyTracked boundary.
fn routing_worker_assign(store: &SqliteWorkspaceStore, id: &str) -> TicketWorkerNotificationTarget {
let assignment = TicketRoleAssignmentRecord {
principal: TicketAssignmentPrincipal::Worker {
runtime_id: "r".into(),
worker_id: "replacement".into(),
},
..manual_worker_assignment("tracking", id)
};
let current = store
.get_current_ticket_role_assignment("w", "tracking", TicketAssignmentRole::Worker)
.unwrap();
store
.set_current_ticket_role_assignment(
&assignment,
current.as_ref().map(|a| a.assignment_id.as_str()),
&format!("event-{id}"),
&format!("operation-{id}"),
true,
)
.unwrap();
TicketWorkerNotificationTarget {
worker: RuntimeWorkerRef::new("r", "replacement"),
authentication_binding_id: "tracking-binding".into(),
}
}
fn routing_ack_worker_pending(
store: &SqliteWorkspaceStore,
target: &TicketWorkerNotificationTarget,
) {
for input in store
.list_pending_ticket_worker_notifications("w", 100)
.unwrap()
{
store
.prepare_ticket_worker_notification_content(
"w",
&input.delivery_id,
"initial notice",
target,
)
.unwrap();
store
.ack_ticket_worker_notification("w", &input.delivery_id, target)
.unwrap();
}
}
#[tokio::test]
async fn notification_routing_diagnostics_preview_unrouted_requests_readonly_beyond_global_batch() {
let (_directory, store) = tracking_store_fixture().await;
seed_manual_start_ticket(&store, "other-ticket", "open");
store.with_conn_mut(|conn| {
let tx = conn.transaction()?;
for index in 0..260 {
tx.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,author,body) VALUES('w','other-ticket',?1,'other:' || ?1,'comment','human','unrelated')",[index])?;
}
tx.commit()?;
Ok(())
}).unwrap();
tracking_store_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);
let snapshot = || {
store
.with_conn(|conn| {
let changes: i64 = conn.query_row("SELECT total_changes()", [], |r| r.get(0))?;
let rows = [
"notification_sources",
"notification_decisions",
"notification_requests",
"notification_failures",
"notification_acknowledgements",
"notification_assignment_targets",
"ticket_assignment_deliveries",
"ticket_assignment_delivery_requests",
"ticket_assignment_delivery_acknowledgements",
]
.map(|table| stored_fixture_rows(conn, table));
Ok((changes, rows))
})
.unwrap()
};
let before = snapshot();
store
.with_conn(|conn| {
conn.execute_batch("PRAGMA query_only=ON")?;
Ok(())
})
.unwrap();
let first = store
.page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1)
.unwrap();
assert_eq!(first.inputs.len(), 1);
assert_eq!(
first.inputs[0].input_event_id,
"assignment-event:event-assign-a"
);
assert_eq!(
first.inputs[0].notification,
server_api::TicketAssignmentInputDeliveryState::NotReserved
);
assert_eq!(
first.next_input_event_id.as_deref(),
Some("assignment-event:event-assign-a")
);
let next = store
.page_ticket_assignment_diagnostics(
"w",
"tracking",
"a",
first.next_input_event_id.as_deref(),
None,
1,
)
.unwrap();
assert_eq!(next.inputs.len(), 1);
assert_eq!(next.inputs[0].input_event_id, "ticket-event:0");
assert!(next.next_input_event_id.is_none());
for (ticket, assignment, input, start) in [
("tracking", "missing", None, None),
("other-ticket", "a", None, None),
("tracking", "a", Some("missing"), None),
("tracking", "a", Some("ticket-event:1"), None),
("tracking", "a", None, Some("missing")),
] {
assert!(
store
.page_ticket_assignment_diagnostics("w", ticket, assignment, input, start, 1)
.is_err()
);
}
assert_eq!(
snapshot(),
before,
"diagnostics must not mutate even on invalid cursors/assignment"
);
store
.with_conn(|conn| {
conn.execute_batch("PRAGMA query_only=OFF")?;
Ok(())
})
.unwrap();
// Only the delivery path persists routing, in bounded batches.
assert!(
store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap()
.is_empty()
);
let pending = store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap();
assert_eq!(
pending
.iter()
.map(|d| d.input_event_id.as_str())
.collect::<Vec<_>>(),
vec![
first.inputs[0].input_event_id.as_str(),
next.inputs[0].input_event_id.as_str()
]
);
let persisted = snapshot();
let page = store
.page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 100)
.unwrap();
assert_eq!(page.inputs.len(), 2);
assert_eq!(snapshot(), persisted);
}
#[tokio::test]
async fn notification_routing_orchestrator_ack_retains_authorized_target_after_responsibility_changes()
{
for change in [
"done",
"release",
"reassign",
"revoked",
"binding-replaced",
"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 delivery = delivery_store_reserve_initial(&store, &source);
if prepare {
store
.prepare_ticket_assignment_delivery_content(
"w",
&delivery.delivery_id,
"immutable request",
&source,
)
.unwrap();
}
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"); }
_ => store.with_conn(|conn| {
match change {
"revoked" => conn.execute("UPDATE workspace_runtime_bindings SET revoked_at='revoked' WHERE workspace_id='w'", [])?,
"binding-replaced" => conn.execute("UPDATE workspace_runtime_bindings SET binding_id='replacement-binding' WHERE workspace_id='w'", [])?,
_ => conn.execute("UPDATE worker_singleton_owners SET worker_id='replacement' WHERE workspace_id='w'", [])?,
};
Ok(())
}).unwrap(),
}
for field in ["runtime", "worker", "agent", "binding"] {
let mut forged = source.clone();
match field {
"runtime" => forged.runtime_id = "forged".into(),
"worker" => forged.worker_id = "replacement".into(),
"agent" => forged.agent_key = "forged".into(),
_ => forged.authentication_binding_id = "replacement-binding".into(),
}
assert!(
matches!(
store.ack_ticket_assignment_delivery("w", &delivery.delivery_id, &forged),
Err(Error::WorkspacePermissionDenied(_))
),
"{change}/{field}"
);
}
assert!(
store
.ack_ticket_assignment_delivery("other", &delivery.delivery_id, &source)
.is_err()
);
store
.ack_ticket_assignment_delivery("w", &delivery.delivery_id, &source)
.unwrap();
store
.ack_ticket_assignment_delivery("w", &delivery.delivery_id, &source)
.unwrap();
assert!(
store
.reserve_ticket_assignment_delivery("w", "a", &delivery.input_event_id, &source)
.is_err(),
"{change}"
);
assert!(
store
.prepare_ticket_assignment_delivery_content(
"w",
&delivery.delivery_id,
"retry",
&source
)
.is_err(),
"{change}"
);
let page = store
.page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 100)
.unwrap();
let input = page
.inputs
.iter()
.find(|i| i.input_event_id == delivery.input_event_id)
.unwrap();
assert_eq!(
input.notification,
server_api::TicketAssignmentInputDeliveryState::RuntimeAccepted
);
assert!(!input.acceptance_outcome_unknown);
store.with_conn(|conn| {
assert_eq!(stored_fixture_rows(conn, "ticket_assignment_delivery_acknowledgements").len(), 1);
let payload: String = conn.query_row("SELECT payload_json FROM ticket_assignment_deliveries WHERE delivery_id=?1", [&delivery.delivery_id], |r| r.get(0))?;
assert_eq!(serde_json::from_str::<TicketAssignmentDelivery>(&payload).unwrap(), delivery);
Ok(())
}).unwrap();
}
}
}
#[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 delivery = delivery_store_reserve_initial(&store, &source);
store
.prepare_ticket_assignment_delivery_content(
"w",
&delivery.delivery_id,
"original request",
&source,
)
.unwrap();
store.with_conn(|conn| {
conn.execute("UPDATE worker_singleton_owners SET worker_id='replacement' WHERE workspace_id='w'", [])?;
Ok(())
}).unwrap();
let replacement = TicketAssignmentTrackingSourceContext {
worker_id: "replacement".into(),
..source.clone()
};
assert!(
store
.ack_ticket_assignment_delivery("w", &delivery.delivery_id, &replacement)
.is_err()
);
if prepare {
assert_eq!(
store
.prepare_ticket_assignment_delivery_content(
"w",
&delivery.delivery_id,
"different rendering",
&replacement
)
.unwrap(),
"original request"
);
} else {
assert_eq!(
store
.reserve_ticket_assignment_delivery(
"w",
"a",
&delivery.input_event_id,
&replacement
)
.unwrap(),
delivery
);
}
store
.ack_ticket_assignment_delivery("w", &delivery.delivery_id, &replacement)
.unwrap();
// A different target's receipt cannot overwrite the first ACK, even if both were authorized.
assert!(
store
.ack_ticket_assignment_delivery("w", &delivery.delivery_id, &source)
.is_err()
);
store
.with_conn(|conn| {
assert_eq!(
stored_fixture_rows(conn, "notification_assignment_targets").len(),
2
);
assert_eq!(
stored_fixture_rows(conn, "ticket_assignment_deliveries").len(),
1
);
assert_eq!(
stored_fixture_rows(conn, "ticket_assignment_delivery_requests").len(),
1
);
assert_eq!(
stored_fixture_rows(conn, "ticket_worker_assignments").len(),
1
);
assert!(
conn.execute(
"UPDATE notification_assignment_targets SET worker_id='forged'",
[]
)
.is_err()
);
assert!(
conn.execute("DELETE FROM notification_assignment_targets", [])
.is_err()
);
Ok(())
})
.unwrap();
}
}
#[tokio::test]
async fn notification_routing_commit_without_callback_recovers_and_rollback_has_no_source() {
let (directory, store) = tracking_store_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
routing_ack_worker_pending(&store, &target);
store.with_conn_mut(|conn| {
let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
tx.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,author,body) VALUES('w','tracking',0,'tracking:0','comment','human','rollback')",[])?;
tx.rollback()?;
let count:i64=conn.query_row("SELECT COUNT(*) FROM notification_sources WHERE input_event_id='ticket-event:0'",[],|r|r.get(0))?;
assert_eq!(count,0);Ok(())
}).unwrap();
delivery_store_external_event(&store, 0, "comment", None);
store.with_conn(|conn| {
let count:i64=conn.query_row("SELECT COUNT(*) FROM notification_sources s LEFT JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id='ticket-event:0' AND d.source_sequence IS NULL",[],|r|r.get(0))?;
assert_eq!(count,2,"both logical destinations are captured before routing");Ok(())
}).unwrap();
drop(store);
let store = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
let inputs = store
.list_pending_ticket_worker_notifications("w", 100)
.unwrap();
assert_eq!(inputs.len(), 1);
assert_eq!(inputs[0].input_event_id, "ticket-event:0");
assert_eq!(inputs[0].assignment_id, "worker-a");
assert_eq!(inputs[0].purpose, NotificationPurpose::Attention);
store.with_conn(|conn| {
let 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='ticket-event:0' AND s.role='orchestrator'",[],|r|r.get(0))?;
assert_eq!(reason,"no_recipient");Ok(())
}).unwrap();
}
#[tokio::test]
async fn notification_routing_parallel_scan_and_render_reuse_request_after_response_loss() {
let (directory, store) = tracking_store_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
routing_ack_worker_pending(&store, &target);
delivery_store_external_event(&store, 0, "comment", None);
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let threads = (0..2)
.map(|i| {
let barrier = barrier.clone();
let path = directory.path().join("workspace.db");
let target = target.clone();
std::thread::spawn(move || {
let store = SqliteWorkspaceStore::open_for_test(path).unwrap();
barrier.wait();
let input = store
.list_pending_ticket_worker_notifications("w", 100)
.unwrap()
.remove(0);
let text = store
.prepare_ticket_worker_notification_content(
"w",
&input.delivery_id,
&format!("rendered {i}"),
&target,
)
.unwrap();
(input, text)
})
})
.collect::<Vec<_>>();
let receipts = threads
.into_iter()
.map(|t| t.join().unwrap())
.collect::<Vec<_>>();
assert_eq!(receipts[0], receipts[1]);
let (input, content) = &receipts[0];
drop(store);
let store = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap();
assert_eq!(
&store
.prepare_ticket_worker_notification_content(
"w",
&input.delivery_id,
"new rendering",
&target
)
.unwrap(),
content
);
assert_eq!(
store
.list_pending_ticket_worker_notifications("w", 100)
.unwrap(),
vec![input.clone()]
);
store
.ack_ticket_worker_notification("w", &input.delivery_id, &target)
.unwrap();
store
.ack_ticket_worker_notification("w", &input.delivery_id, &target)
.unwrap();
assert!(
store
.list_pending_ticket_worker_notifications("w", 100)
.unwrap()
.is_empty()
);
store
.with_conn(|conn| {
assert!(
conn.execute("UPDATE notification_requests SET content='changed'", [])
.is_err()
);
assert!(
conn.execute("DELETE FROM notification_acknowledgements", [])
.is_err()
);
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM notification_requests WHERE delivery_id=?1",
[&input.delivery_id],
|r| r.get(0),
)?;
assert_eq!(count, 1);
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn notification_routing_assignment_change_between_commit_and_scan_never_transfers_input() {
let (_directory, store) = tracking_store_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
routing_ack_worker_pending(&store, &target);
delivery_store_external_event(&store, 0, "comment", None);
routing_worker_assign(&store, "worker-b");
let pending = store
.list_pending_ticket_worker_notifications("w", 100)
.unwrap();
assert!(pending.iter().all(|i| i.assignment_id == "worker-b"));
assert!(pending.iter().all(|i| i.input_event_id != "ticket-event:0"));
store.with_conn(|conn| {
let captured:String=conn.query_row("SELECT assignment_id FROM notification_sources WHERE input_event_id='ticket-event:0' AND role='worker'",[],|r|r.get(0))?;
assert_eq!(captured,"worker-a");
let failure:String=conn.query_row("SELECT f.code FROM notification_failures f JOIN notification_decisions d USING(workspace_id,delivery_id) JOIN notification_sources s ON s.sequence=d.source_sequence WHERE s.input_event_id='ticket-event:0' AND s.role='worker'",[],|r|r.get(0))?;
assert_eq!(failure,"assignment_changed");Ok(())
}).unwrap();
}
#[tokio::test]
async fn notification_routing_ack_retains_acceptance_after_responsibility_changes() {
let (_directory, store) = tracking_store_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
routing_ack_worker_pending(&store, &target);
delivery_store_external_event(&store, 0, "comment", None);
let input = store
.list_pending_ticket_worker_notifications("w", 100)
.unwrap()
.remove(0);
store
.prepare_ticket_worker_notification_content(
"w",
&input.delivery_id,
"accepted during reassignment",
&target,
)
.unwrap();
routing_worker_assign(&store, "worker-b");
assert!(
store
.prepare_ticket_worker_notification_content("w", &input.delivery_id, "retry", &target)
.is_err()
);
store
.ack_ticket_worker_notification("w", &input.delivery_id, &target)
.unwrap();
store
.with_conn(|conn| {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM notification_acknowledgements WHERE delivery_id=?1",
[input.delivery_id],
|r| r.get(0),
)?;
assert_eq!(count, 1);
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn notification_routing_editor_is_not_original_author_and_unknown_events_are_deferred() {
let (directory, store) = tracking_store_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
routing_ack_worker_pending(&store, &target);
use ticket::TicketBackend;
let backend =
ticket::SqliteTicketBackend::open_verified(directory.path().join("workspace.db"), "w")
.unwrap();
backend
.edit_item(
"tracking".into(),
ticket::TicketItemEdit {
body: Some(ticket::MarkdownText::new("Worker edited")),
author: Some("worker:r/replacement".into()),
..Default::default()
},
)
.unwrap();
assert!(
store
.list_pending_ticket_worker_notifications("w", 100)
.unwrap()
.is_empty()
);
backend
.edit_item(
"tracking".into(),
ticket::TicketItemEdit {
title: Some("Worker title edit".into()),
author: Some("worker:r/replacement".into()),
..Default::default()
},
)
.unwrap();
assert!(
store
.list_pending_ticket_worker_notifications("w", 100)
.unwrap()
.is_empty(),
"title-only edits retain the editor and suppress self-notification"
);
backend
.edit_item(
"tracking".into(),
ticket::TicketItemEdit {
body: Some(ticket::MarkdownText::new("Human edited")),
author: Some("human".into()),
..Default::default()
},
)
.unwrap();
let pending = store
.list_pending_ticket_worker_notifications("w", 100)
.unwrap();
assert_eq!(pending.len(), 1);
delivery_store_external_event(&store, 0, "future_event", None);
store
.list_pending_ticket_worker_notifications("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.actor='worker:r/replacement' AND s.role='worker'",[],|r|r.get(0))?;
assert_eq!(self_reason,"self_notification");
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.kind='future_event' AND s.role='worker'",[],|r|Ok((r.get(0)?,r.get(1)?)))?;
assert_eq!(decision,("defer".into(),"unknown_event".into()));Ok(())
}).unwrap();
}
#[tokio::test]
async fn notification_routing_failures_do_not_starve_other_inputs_or_replace_requests() {
let (_directory, store) = tracking_store_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
routing_ack_worker_pending(&store, &target);
delivery_store_external_event(&store, 0, "comment", None);
delivery_store_external_event(&store, 1, "comment", None);
let first = store
.list_pending_ticket_worker_notifications("w", 1)
.unwrap()
.remove(0);
let key = NotificationDeliveryKey::WorkerNotification {
delivery_id: first.delivery_id.clone(),
};
store
.prepare_ticket_worker_notification_content("w", &first.delivery_id, "pinned", &target)
.unwrap();
for _ in 0..2 {
store
.record_notification_delivery_failure(
"w",
&key,
NotificationFailureCode::AcceptanceUnknown,
)
.unwrap();
}
let second = store
.list_pending_ticket_worker_notifications("w", 1)
.unwrap()
.remove(0);
assert_ne!(first.delivery_id, second.delivery_id);
assert_eq!(
store
.prepare_ticket_worker_notification_content("w", &first.delivery_id, "changed", &target)
.unwrap(),
"pinned"
);
store
.with_conn(|conn| {
let failures: Vec<String> = conn
.prepare("SELECT code FROM notification_failures WHERE delivery_id=?1")?
.query_map([first.delivery_id], |r| r.get(0))?
.collect::<rusqlite::Result<_>>()?;
assert_eq!(failures, vec!["acceptance_unknown"]);
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn notification_routing_target_binding_and_workspace_are_rechecked_before_prepare() {
let (_directory, store) = tracking_store_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
let input = store
.list_pending_ticket_worker_notifications("w", 100)
.unwrap()
.remove(0);
let wrong = TicketWorkerNotificationTarget {
worker: RuntimeWorkerRef::new("r", "worker"),
..target.clone()
};
assert!(
store
.prepare_ticket_worker_notification_content("w", &input.delivery_id, "text", &wrong)
.is_err()
);
assert!(
store
.prepare_ticket_worker_notification_content(
"other",
&input.delivery_id,
"text",
&target
)
.is_err()
);
store
.with_conn(|conn| {
conn.execute(
"UPDATE workspace_runtime_bindings SET revoked_at='revoked' WHERE workspace_id='w'",
[],
)?;
Ok(())
})
.unwrap();
assert!(
store
.prepare_ticket_worker_notification_content("w", &input.delivery_id, "text", &target)
.is_err()
);
}
#[test]
fn notification_routing_migration_preserves_receipts_does_not_replay_comments_and_rolls_back() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("workspace.db");
let conn = tracking_schema91_fixture(&path);
migrate_ticket_tracking_v91_to_v92(&conn).unwrap();
migrate_assignment_delivery_v92_to_v93(&conn).unwrap();
migrate_ticket_posts_v93_to_v94(&conn).unwrap();
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,author,body) VALUES('w','pending',999,'pending:999','comment','human','historical')",[]).unwrap();
let payload = TicketAssignmentDelivery {
ticket_id: "pending".into(),
assignment_id: "a-pending".into(),
input_event_id: "assignment-event:migration-tracking:a-pending".into(),
delivery_id: "preserved-id".into(),
request_id: "preserved-request".into(),
waiting_reason: None,
next_action: None,
};
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()
],
)
.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()
};
conn.execute(
"INSERT INTO ticket_assignment_delivery_acknowledgements VALUES('w','preserved-id',?1)",
[serde_json::to_string(&acknowledged_target).unwrap()],
)
.unwrap();
let requests_before = stored_fixture_rows(&conn, "ticket_assignment_delivery_requests");
let acks_before = stored_fixture_rows(&conn, "ticket_assignment_delivery_acknowledgements");
let before = stored_fixture_rows(&conn, "ticket_assignment_deliveries");
conn.execute_batch("CREATE TRIGGER notification_ticket_event AFTER INSERT ON typed_ticket_events BEGIN SELECT 1; END;").unwrap();
assert!(migrate_notification_routing_v94_to_v95(&conn).is_err());
assert_eq!(current_schema_version(&conn).unwrap(), 94);
let missing: bool = conn
.query_row(
"SELECT NOT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='notification_sources')",
[],
|r| r.get(0),
)
.unwrap();
assert!(missing);
assert!(
conn.prepare("SELECT actor FROM ticket_assignment_content_inputs")
.is_err()
);
conn.execute_batch("DROP TRIGGER notification_ticket_event;")
.unwrap();
migrate_notification_routing_v94_to_v95(&conn).unwrap();
assert_eq!(
stored_fixture_rows(&conn, "ticket_assignment_deliveries"),
before
);
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, "notification_assignment_targets").len(),
2
);
let old_comments: i64 = conn
.query_row(
"SELECT COUNT(*) FROM notification_sources WHERE input_event_id='ticket-event:999'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(old_comments, 0);
drop(conn);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
let pending = store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap();
assert!(
!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();
}
#[tokio::test]
async fn notification_routing_missing_authorized_target_guard_refuses_startup() {
for guard in [
"notification_assignment_targets_no_update",
"notification_assignment_targets_no_delete",
] {
let (directory, store) = tracking_store_fixture().await;
store
.with_conn(|conn| {
conn.execute_batch(&format!("DROP TRIGGER {guard}"))?;
Ok(())
})
.unwrap();
drop(store);
let error = match SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db"))
{
Ok(_) => panic!("missing target evidence guard accepted"),
Err(error) => error,
};
assert!(error.to_string().contains(guard));
}
}
#[tokio::test]
async fn notification_routing_fresh_and_upgraded_schema_and_guards_match() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("upgraded.db");
let conn = tracking_schema91_fixture(&path);
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();
let snapshot = |conn: &Connection| -> Vec<(String, String)> {
conn.prepare("SELECT name,sql FROM sqlite_schema WHERE name LIKE 'notification_%' OR name LIKE 'ticket_assignment_content%' OR name IN ('ticket_assignment_title_input','ticket_assignment_initial_post_input') ORDER BY name").unwrap().query_map([],|r|Ok((r.get(0)?,r.get(1)?))).unwrap().collect::<rusqlite::Result<_>>().unwrap()
};
let upgraded = snapshot(&conn);
let (fresh_directory, fresh) = tracking_store_fixture().await;
fresh
.with_conn(|conn| {
assert_eq!(snapshot(conn), upgraded);
Ok(())
})
.unwrap();
fresh
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER notification_sources_no_update;")?;
Ok(())
})
.unwrap();
drop(fresh);
let error =
match SqliteWorkspaceStore::open_for_test(fresh_directory.path().join("workspace.db")) {
Ok(_) => panic!("missing guard accepted"),
Err(error) => error,
};
assert!(error.to_string().contains("notification_sources_no_update"));
}
#[tokio::test]
async fn notification_routing_content_without_recipient_records_reason_and_domain_reference() {
let (directory, store) = tracking_store_fixture().await;
use ticket::TicketBackend;
let backend =
ticket::SqliteTicketBackend::open_verified(directory.path().join("workspace.db"), "w")
.unwrap();
backend
.edit_item(
"tracking".into(),
ticket::TicketItemEdit {
title: Some("No recipient yet".into()),
author: Some("human".into()),
..Default::default()
},
)
.unwrap();
assert!(
store
.list_pending_ticket_worker_notifications("w", 100)
.unwrap()
.is_empty()
);
store.with_conn(|conn| {
let count:i64=conn.query_row("SELECT COUNT(*) FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence JOIN typed_ticket_content_changes c ON s.input_event_id='ticket-content:' || c.change_id WHERE d.reason='no_recipient' AND s.actor='human'",[],|r|r.get(0))?;
assert_eq!(count,2);Ok(())
}).unwrap();
}
#[tokio::test]
async fn notification_routing_clear_retains_original_assignment_and_suppression_reason() {
let (_directory, store) = tracking_store_fixture().await;
let target = routing_worker_assign(&store, "worker-a");
routing_ack_worker_pending(&store, &target);
store
.clear_current_ticket_role_assignment(
"w",
"tracking",
TicketAssignmentRole::Worker,
"worker-a",
"clear-a",
"clear-a",
"human",
"now",
None,
)
.unwrap();
assert!(
store
.list_pending_ticket_worker_notifications("w", 100)
.unwrap()
.is_empty()
);
store.with_conn(|conn| {
let row:(String,String)=conn.query_row("SELECT s.assignment_id,d.reason FROM notification_sources s JOIN notification_decisions d ON d.source_sequence=s.sequence WHERE s.input_event_id='assignment-change:clear-a' AND s.assignment_id='worker-a'",[],|r|Ok((r.get(0)?,r.get(1)?)))?;
assert_eq!(row,("worker-a".into(),"historical_only".into()));Ok(())
}).unwrap();
}
File diff suppressed because it is too large Load Diff
@@ -919,3 +919,91 @@ async fn saturated_host_before_command_start_returns_availability_without_a_prov
host.drain().await.unwrap();
fixture.api.store.db_executor().drain().await.unwrap();
}
#[tokio::test]
async fn canonical_ticket_case_errors_are_logged_without_request_secrets() {
let dir = tempfile::tempdir().unwrap();
let mut api = test_api(dir.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-case-log").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-case-log");
seed_worker_source_member(&api, "runtime-case-log", "companion");
let backend = browser_ticket_backend(&api).unwrap();
let reference = backend
.create(ticket::NewTicket::new("Case logging"))
.unwrap();
let before = backend.show(reference.id.clone().into()).unwrap();
let input = case_test_request(
&before,
"case-log",
ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::Completed,
},
);
let mut body = serde_json::to_value(input).unwrap();
body.as_object_mut().unwrap().remove("author");
body["explanation"] = json!("body-secret");
let path = format!(
"/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case",
reference.id
);
let app = build_router(api.clone());
let request = |worker, body: &Value| {
runtime_source_request(
&identity,
worker,
"POST",
&format!("{path}?token=query-secret"),
serde_json::to_vec(body).unwrap(),
)
};
// Runtime identity alone still cannot impersonate a Worker or human.
let (status, error, events) = request_with_logs(app.clone(), request(None, &body)).await;
assert_eq!(status, StatusCode::FORBIDDEN, "{error}");
assert_eq!(events.len(), 1, "{events:?}");
assert_eq!(events[0]["path"], path);
assert_eq!(events[0]["status"], 403);
assert_eq!(events[0]["message"], error["message"]);
assert!(
error["message"]
.as_str()
.unwrap()
.contains("verified Runtime Worker identity")
);
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before);
// Profile-authorized unassigned Workers reach the same CAS validation.
let mut stale = body.clone();
stale["expected_content_digest"] = json!("stale");
let (status, error, events) =
request_with_logs(app.clone(), request(Some("companion"), &stale)).await;
assert_eq!(status, StatusCode::CONFLICT, "{error}");
assert_eq!(events.len(), 1, "{events:?}");
assert_eq!(events[0]["path"], path);
assert_eq!(events[0]["status"], 409);
assert_eq!(events[0]["message"], error["message"]);
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before);
let (status, result, events) = request_with_logs(app, request(Some("companion"), &body)).await;
assert_eq!(status, StatusCode::OK, "{result}");
assert!(events.is_empty(), "{events:?}");
}
#[tokio::test]
async fn non_api_workspace_paths_are_not_logged_as_ticket_api_errors() {
let app = Router::new()
.route(
"/workspaces/not-a-ticket-page",
get(|| async { ApiError::from(Error::InvalidInput("not an API".into())) }),
)
.layer(middleware::from_fn(log_failed_api_response));
let (status, _, events) = request_with_logs(
app,
Request::builder()
.uri("/workspaces/not-a-ticket-page")
.body(Body::empty())
.unwrap(),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(events.is_empty());
}
@@ -102,8 +102,48 @@ impl WorkerExecutionBackend for LaunchExecutionBackend {
Ok(WorkspaceConfigFetchResult::Modified(bundle))
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
fn observe_repository_ref(
&self,
request: &RepositoryRefObservationRequest,
) -> std::result::Result<
RepositoryRefObservation,
worker_runtime::working_directory::WorkingDirectoryDiagnostic,
> {
self.execution.materializer.observe_repository_ref(request)
}
fn working_directory(
&self,
id: &str,
) -> std::result::Result<
worker_runtime::catalog::WorkingDirectoryStatus,
worker_runtime::working_directory::WorkingDirectoryDiagnostic,
> {
self.execution.working_directory(id)
}
fn create_working_directory(
&self,
request: &worker_runtime::catalog::WorkingDirectoryRequest,
) -> std::result::Result<
worker_runtime::catalog::WorkingDirectoryStatus,
worker_runtime::working_directory::WorkingDirectoryDiagnostic,
> {
self.execution.create_working_directory(request)
}
fn spawn_worker(&self, mut request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
self.creates.lock().unwrap().push(request.request.clone());
for claim in &request.request.workdir_attachments {
let binding = self
.execution
.materializer
.bind_working_directory(&claim.working_directory_id, claim.relative_cwd.as_deref())
.unwrap();
request
.workdir_attachments
.insert(claim.alias.clone(), binding);
}
self.execution.spawn_worker(request)
}
@@ -130,6 +170,7 @@ struct LaunchFixture {
backend: Arc<LaunchExecutionBackend>,
runtime_id: String,
runtime_server: Option<tokio::task::JoinHandle<()>>,
runtime_identity: Option<RuntimeIdentityMaterial>,
}
impl Drop for LaunchFixture {
@@ -156,6 +197,7 @@ impl LaunchFixture {
backend,
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
runtime_server: None,
runtime_identity: None,
};
if remote {
fixture.runtime_id = "ticket-worker-real-http".to_string();
@@ -164,6 +206,7 @@ impl LaunchFixture {
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
};
let runtime_identity = RuntimeIdentityMaterial::generate(&fixture.runtime_id).unwrap();
fixture.runtime_identity = Some(runtime_identity.clone());
let workspace_identity = fixture
.api
.signing_identities
@@ -1415,3 +1458,242 @@ async fn assignment_inputs_replace_queue_interest_without_reviving_released_work
== false
);
}
// Real Git publication, signed HTTP, Runtime source reads, and SQLite capture.
// Execution is the deterministic fixture backend; no LLM/reviewer is needed.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mr_open_retains_source_before_first_review_and_observed_result() {
let fixture = LaunchFixture::new(true).await;
let dir = &fixture._temp;
init_clean_git_workspace(dir.path());
let git = |root: &std::path::Path, args: &[&str]| {
let out = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"{args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap().trim().to_owned()
};
git(dir.path(), &["branch", "work/open-observation"]);
let source_a = git(dir.path(), &["rev-parse", "work/open-observation"]);
let api = &fixture.api;
fixture.backend.publish_profile(api, "builtin:coder");
let identity = fixture.runtime_identity.as_ref().unwrap();
let mut input = ticket::NewTicket::new("Opening observation and pre-review push");
set_test_ticket_target(&mut input, "test-repository", "develop");
let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap();
assign_test_orchestrator(&api, &ticket.id);
let (_, Json(workdir)) = create_workspace_working_directory(
api,
TEST_WORKSPACE_ID,
Some(&fixture.runtime_id),
BrowserWorkingDirectoryCreateRequest {
runtime_id: Some(fixture.runtime_id.clone()),
display_name: None,
repository_key: "test-repository".into(),
selector: Some("work/open-observation".into()),
operation_id: Some("opening-workdir".into()),
},
)
.await
.unwrap();
let checkout = fixture
.backend
.execution
.materializer
.bind_working_directory(&workdir.item.working_directory_id, None)
.unwrap();
git(
checkout.root(),
&["checkout", "-B", "work/open-observation"],
);
let Json(created) = create_workspace_worker(
State(api.clone()),
HeaderMap::new(),
Json(CreateWorkspaceWorkerRequest {
runtime_id: fixture.runtime_id.clone(),
display_name: "source-observation-coder".into(),
singleton_key: None,
profile: Some("builtin:coder".into()),
ticket_assignment: None,
initial_submit: Vec::new(),
workdir_attachments: vec![server_api::BrowserWorkerWorkingDirectorySelection {
alias: "checkout".into(),
working_directory_id: workdir.item.working_directory_id,
relative_cwd: None,
}],
feature_connections: Default::default(),
control_operation_id: None,
}),
)
.await
.unwrap();
let coder = RuntimeWorkerRef::new(created.runtime_id, created.worker_id);
seed_worker_source_member(&api, &coder.runtime_id, &coder.worker_id);
api.store
.set_current_ticket_worker_assignment(
&TicketWorkerAssignmentRecord {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: ticket.id.clone(),
assignment_id: "opening-coder".into(),
worker: coder.clone(),
assigned_by: "test".into(),
assigned_at: Utc::now().to_rfc3339(),
},
None,
"opening-assigned",
"opening-operation",
false,
)
.unwrap();
let app = build_router(api.clone());
let call = |method: &'static str, path: String, input: Value, expected: StatusCode| {
let body = if method == "GET" {
Vec::new()
} else {
serde_json::to_vec(&input).unwrap()
};
let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity)
.issue(
api.config.backend_base_url.as_deref().unwrap(),
TEST_WORKSPACE_ID,
Some(&coder.worker_id),
worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION,
method,
&path,
&body,
worker_runtime::auth::unix_now_seconds() as i64,
30,
)
.unwrap();
let request = Request::builder()
.method(method)
.uri(&path)
.header(
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
proof,
)
.header("x-yoi-runtime-id", &coder.runtime_id)
.header("x-yoi-worker-id", &coder.worker_id)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(body))
.unwrap();
let app = app.clone();
async move {
let response = app.oneshot(request).await.unwrap();
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(status, expected, "{}", String::from_utf8_lossy(&bytes));
serde_json::from_slice::<Value>(&bytes).unwrap()
}
};
let open_path = format!(
"/api/w/{TEST_WORKSPACE_ID}/tickets/{}/merge-request",
ticket.id
);
let open_input = json!({"repository_key":"test-repository","selector_from":"work/open-observation","selector_to":"develop","summary":""});
// A failed opening observation cannot leave behind a successfully opened MR.
api.config_store.with_conn(|conn| {
conn.execute_batch("CREATE TRIGGER reject_opening_observation BEFORE INSERT ON merge_request_source_observations BEGIN SELECT RAISE(ABORT,'injected opening capture failure'); END;")?;
Ok(())
}).unwrap();
call(
"POST",
open_path.clone(),
open_input.clone(),
StatusCode::CONFLICT,
)
.await;
api.config_store
.with_conn(|conn| {
assert_eq!(
conn.query_row("SELECT count(*) FROM merge_requests", [], |r| r
.get::<_, i64>(0))?,
0
);
conn.execute_batch("DROP TRIGGER reject_opening_observation;")?;
Ok(())
})
.unwrap();
let opened = call("POST", open_path, open_input, StatusCode::OK).await;
let mr = opened["merge_request_id"].as_str().unwrap();
api.config_store.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT subject_ref FROM merge_request_source_observations WHERE merge_request_id=?1",[mr],|r|r.get::<_,String>(0))?,source_a);
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE kind='mr_source_changed'",[],|r|r.get::<_,i64>(0))?,0);
Ok(())
}).unwrap();
let publisher = tempfile::tempdir().unwrap();
git(
publisher.path(),
&["clone", dir.path().to_str().unwrap(), "."],
);
git(publisher.path(), &["checkout", "work/open-observation"]);
git(
publisher.path(),
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.invalid",
"commit",
"--allow-empty",
"-m",
"source-b",
],
);
git(
publisher.path(),
&["push", "origin", "work/open-observation"],
);
let source_b = git(publisher.path(), &["rev-parse", "HEAD"]);
assert_ne!(source_a, source_b);
for _ in 0..2 {
call(
"GET",
format!("/api/w/{TEST_WORKSPACE_ID}/merge-requests/{mr}"),
Value::Null,
StatusCode::OK,
)
.await;
}
api.config_store.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT count(*) FROM merge_request_thread_events WHERE merge_request_id=?1",[mr],|r|r.get::<_,i64>(0))?,0,"no review event mentions B");
let (previous,current):(String,String)=conn.query_row("SELECT previous_subject_ref,subject_ref FROM merge_request_source_observations WHERE merge_request_id=?1 ORDER BY sequence DESC LIMIT 1",[mr],|r|Ok((r.get(0)?,r.get(1)?)))?;
assert_eq!((previous,current),(source_a.clone(),source_b.clone()));
assert_eq!(conn.query_row("SELECT count(*) FROM notification_sources WHERE kind='mr_source_changed' AND json_extract(facts_json,'$.merge_request_id')=?1",[mr],|r|r.get::<_,i64>(0))?,2,"one source per logical recipient, not per read");
Ok(())
}).unwrap();
let result_path = format!(
"/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/implementation-results",
ticket.id
);
let mut result = json!({"operation_key":"observed-result","assignment_id":"opening-coder","orchestrator_assignment_id":format!("orchestrator-{}",ticket.id),"kind":"handoff","body":"Published new source; review not requested yet.","merge_request":{"merge_request_id":mr,"subject_ref":"forged-source"}});
call(
"POST",
result_path.clone(),
result.clone(),
StatusCode::BAD_REQUEST,
)
.await;
result["merge_request"]["subject_ref"] = json!(source_b);
let receipt = call("POST", result_path.clone(), result.clone(), StatusCode::OK).await;
assert_eq!(
call("POST", result_path, result, StatusCode::OK).await,
receipt
);
api.runtime
.stop_worker(
&coder,
WorkerLifecycleRequest {
reason: None,
ticket_assignment: None,
},
)
.unwrap();
}
@@ -1,8 +1,31 @@
// Included as a child module of server::tests. These tests exercise committed
// Assign + HTTP + embedded Runtime boundaries, not the old Queue predicate.
// Ticket activity + SQLite routing + HTTP + embedded Runtime boundaries. Only
// the model and explicit response-loss boundary are replaced in the real fixture.
// Run with cargo test -p yoi-workspace-server --lib assignment_delivery.
use super::*;
use session_store::{LogEntry, Store, WorkerSessionStore};
use worker_runtime::execution as delivery_execution;
include!("server_implementation_result_tests.rs");
#[tokio::test]
async fn notification_diagnostics_http_auth_scope_and_readonly_evidence() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
let ticket = delivery_ticket(&api, "Notification diagnostics auth fixture");
delivery_assign(&api, ticket.id.as_str(), "diagnostics-assignment").await;
let path = format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?ticket={}&limit=1",ticket.id.as_str());
let page = delivery_http(&api,"GET",&path,None,StatusCode::OK).await;
assert_eq!(page["items"].as_array().unwrap().len(),1);
assert_eq!(page["items"][0]["model_processing_confirmed"],false);
assert!(page["items"][0].get("content").is_none());
let again=delivery_http(&api,"GET",&path,None,StatusCode::OK).await;
assert_eq!(again,page);
let response=build_router(api.clone()).oneshot(Request::builder().uri(&path).body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(response.status(),StatusCode::UNAUTHORIZED);
delivery_http(&api,"GET","/api/w/other-workspace/notification-diagnostics",None,StatusCode::NOT_FOUND).await;
delivery_http(&api,"GET",&format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?limit=101"),None,StatusCode::BAD_REQUEST).await;
}
fn delivery_assignment_path(ticket: &str) -> String {
format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator")
@@ -198,7 +221,7 @@ fn delivery_commit_answer_without_scan(api: &WorkspaceApi, ticket: &str, text: &
.unwrap();
}
fn delivery_external_comment(api: &WorkspaceApi, ticket: &str, text: &str) {
fn delivery_comment_without_scan(api: &WorkspaceApi, ticket: &str, text: &str) {
browser_ticket_backend(api)
.unwrap()
.add_event(
@@ -206,6 +229,10 @@ fn delivery_external_comment(api: &WorkspaceApi, ticket: &str, text: &str) {
ticket::NewTicketEvent::new(ticket::TicketEventKind::Comment, text),
)
.unwrap();
}
fn delivery_external_comment(api: &WorkspaceApi, ticket: &str, text: &str) {
delivery_comment_without_scan(api, ticket, text);
notify_ticket_recipients(api, TEST_WORKSPACE_ID, ticket, "open", "open", None);
}
@@ -973,6 +1000,10 @@ async fn assignment_real_worker_response_loss_restore_and_concurrent_scans_commi
"uncertain delivery changed request id or payload: {attempts:?}"
);
assert_eq!(delivery_receipts(&api, &worker).len(), 1);
let unknown = api.store.notification_diagnostics(TEST_WORKSPACE_ID, &server_api::NotificationDiagnosticsQuery {
assignment_id: Some(assignment.into()), ..Default::default()
}).unwrap().items.into_iter().find(|item| item.stage == server_api::NotificationDiagnosticStage::AcceptanceUnknown).expect("saved unknown acceptance must remain visible while Runtime already has its receipt");
assert!(!unknown.model_processing_confirmed);
// Change mutable rendering context while the acknowledgement is unknown.
// A new self-report is not a new input and cannot change the frozen retry.
seed_worker_source_member(&api, &worker.runtime_id, &worker.worker_id);
@@ -1032,6 +1063,11 @@ async fn assignment_real_worker_response_loss_restore_and_concurrent_scans_commi
}
delivery_wait_idle(&reopened, &worker, 2).await;
assert_eq!(delivery_receipts(&reopened, &worker).len(), 2);
let evidence = delivery_http(&reopened,"GET",&format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?source_id={}",unknown.source_id),None,StatusCode::OK).await;
assert_eq!(evidence["items"][0]["stage"],"runtime_accepted");
assert_eq!(evidence["items"][0]["delivery_id"],json!(unknown.delivery_id));
assert_eq!(evidence["items"][0]["model_processing_confirmed"],false);
assert_eq!(evidence["items"][0]["retry_candidate"],false);
assert_eq!(
delivery_commits(&reopened, &worker)
.iter()
@@ -1135,3 +1171,629 @@ async fn assignment_worker_replacement_receives_pending_input_without_recreating
assert_eq!(execution.attempts.lock().unwrap().len(), 2);
delivery_stop(&api, &replacement);
}
fn delivery_assign_worker(
api: &WorkspaceApi,
ticket: &str,
worker: &RuntimeWorkerRef,
assignment: &str,
previous: Option<&str>,
) -> TicketWorkerAssignmentRecord {
let record = TicketWorkerAssignmentRecord {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: ticket.into(),
assignment_id: assignment.into(),
worker: worker.clone(),
assigned_by: "fixture-account".into(),
assigned_at: TEST_CREATED_AT.into(),
};
api.store
.set_current_ticket_worker_assignment(
&record,
previous,
&format!("{assignment}-event"),
&format!("{assignment}-operation"),
previous.is_some(),
)
.unwrap();
record
}
fn delivery_worker_pending(api: &WorkspaceApi) -> Vec<crate::store::TicketWorkerNotification> {
api.store
.list_pending_ticket_worker_notifications(TEST_WORKSPACE_ID, 20)
.unwrap()
}
async fn delivery_settle_real_worker_assignment(
api: &WorkspaceApi,
worker: &RuntimeWorkerRef,
) -> usize {
let initial = delivery_worker_pending(api).len();
recover_ticket_notifications(api);
if initial > 0 {
delivery_wait_idle(api, worker, initial).await;
}
assert!(delivery_worker_pending(api).is_empty());
initial
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn worker_notification_missing_callback_is_recovered_to_real_worker_once() {
let dir = tempfile::tempdir().unwrap();
let (api, execution, _) = delivery_real_fixture(dir.path(), false).await;
let worker = spawn_ticket_check_source(&api, "notification-recipient");
let ticket = delivery_ticket(&api, "Recover committed Worker activity");
let assignment = delivery_assign_worker(&api, &ticket.id, &worker, "worker-owner", None);
let initial = delivery_settle_real_worker_assignment(&api, &worker).await;
let before = execution.attempts.lock().unwrap().len();
// Commit the actual domain event but deliberately omit the mutation callback.
delivery_comment_without_scan(&api, &ticket.id, "A human supplied useful context");
assert_eq!(execution.attempts.lock().unwrap().len(), before);
let pending = delivery_worker_pending(&api);
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].worker, worker);
recover_ticket_notifications(&api);
delivery_wait_idle(&api, &worker, initial + 1).await;
let attempts = execution.attempts.lock().unwrap().clone();
assert_eq!(attempts.len(), before + 1);
assert_eq!(attempts[before].0, pending[0].request_id);
assert!(
attempts[before]
.1
.contains(ticket.resource_key.as_deref().unwrap())
);
assert!(attempts[before].1.contains(&pending[0].input_event_id));
assert_eq!(delivery_receipts(&api, &worker).len(), initial + 1);
assert!(delivery_worker_pending(&api).is_empty());
for _ in 0..3 {
recover_ticket_notifications(&api);
}
assert_eq!(*execution.attempts.lock().unwrap(), attempts);
assert_eq!(
api.store
.get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id)
.unwrap(),
Some(assignment),
"attention acceptance must not replace assignment authority"
);
assert_eq!(
api.authority.ticket(&ticket.id).unwrap().state.as_str(),
"open"
);
delivery_stop(&api, &worker);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn worker_notification_response_loss_retries_same_request_without_duplicate_acceptance() {
let dir = tempfile::tempdir().unwrap();
let (api, execution, _) = delivery_real_fixture(dir.path(), false).await;
let worker = spawn_ticket_check_source(&api, "lost-response-recipient");
let ticket = delivery_ticket(&api, "Worker response loss");
delivery_assign_worker(&api, &ticket.id, &worker, "worker-owner", None);
let initial = delivery_settle_real_worker_assignment(&api, &worker).await;
let before = execution.attempts.lock().unwrap().len();
execution
.lose_responses
.store(true, std::sync::atomic::Ordering::SeqCst);
delivery_comment_without_scan(&api, &ticket.id, "Preserve this notification identity");
let pending = delivery_worker_pending(&api);
assert_eq!(pending.len(), 1);
recover_ticket_notifications(&api);
delivery_wait_idle(&api, &worker, initial + 1).await;
let accepted = execution.attempts.lock().unwrap()[before].clone();
assert_eq!(accepted.0, pending[0].request_id);
let failure: String = api.config_store.with_conn(|conn| {
Ok(conn.query_row("SELECT code FROM notification_failures WHERE workspace_id=?1 AND delivery_id=?2 ORDER BY sequence DESC LIMIT 1", rusqlite::params![TEST_WORKSPACE_ID, pending[0].delivery_id], |row| row.get(0))?)
}).unwrap();
assert_eq!(
failure, "acceptance_unknown",
"response loss is not a confirmed rejection"
);
assert_eq!(delivery_receipts(&api, &worker).len(), initial + 1);
assert_eq!(
delivery_worker_pending(&api),
pending,
"unknown ACK was treated as success"
);
// Reopen the durable Store while keeping the same real Runtime/session. A
// later domain edit must not change the frozen text of the uncertain request.
browser_ticket_backend(&api)
.unwrap()
.edit_item(
ticket.id.clone().into(),
ticket::TicketItemEdit {
title: Some("Title changed after Worker acceptance".into()),
..Default::default()
},
)
.unwrap();
let mut recovered = api.clone();
recovered.store =
Arc::new(SqliteWorkspaceStore::open_for_test(&api.config.database_path).unwrap());
execution
.lose_responses
.store(false, std::sync::atomic::Ordering::SeqCst);
recover_ticket_notifications(&recovered);
let attempts = execution.attempts.lock().unwrap().clone();
let retries = attempts
.iter()
.filter(|attempt| attempt.0 == accepted.0)
.collect::<Vec<_>>();
assert!(
retries.len() >= 2,
"uncertain Runtime acceptance was not retried"
);
assert!(
retries.iter().all(|attempt| **attempt == accepted),
"retry changed frozen ID/body"
);
let distinct = attempts
.iter()
.map(|attempt| &attempt.0)
.collect::<std::collections::HashSet<_>>()
.len();
delivery_wait_idle(&recovered, &worker, distinct).await;
assert_eq!(delivery_receipts(&recovered, &worker).len(), distinct);
assert_eq!(
delivery_commits(&recovered, &worker)
.iter()
.filter(|body| **body == accepted.1)
.count(),
1
);
assert!(delivery_worker_pending(&recovered).is_empty());
recover_ticket_notifications(&recovered);
assert_eq!(*execution.attempts.lock().unwrap(), attempts);
delivery_stop(&recovered, &worker);
}
#[tokio::test]
async fn worker_notification_stopped_recipient_does_not_block_other_recipient() {
let dir = tempfile::tempdir().unwrap();
let (api, execution, orchestrator, _) = delivery_recording_fixture(dir.path()).await;
let worker = spawn_ticket_check_source(&api, "stopped-notification-recipient");
let ticket = delivery_ticket(&api, "One input with independent recipients");
delivery_assign(&api, &ticket.id, "assign-orchestrator").await;
delivery_assign_worker(&api, &ticket.id, &worker, "worker-owner", None);
recover_ticket_notifications(&api);
assert!(delivery_worker_pending(&api).is_empty());
execution.take_inputs();
execution.take_input_request_ids();
delivery_stop(&api, &worker);
delivery_commit_answer_without_scan(
&api,
&ticket.id,
"Continue with this answer",
"answer-both",
);
let pending = delivery_worker_pending(&api);
assert_eq!(pending.len(), 1);
recover_ticket_notifications(&api);
let inputs = execution.take_inputs();
assert_eq!(
inputs.len(),
1,
"stopped Worker must not block Orchestrator delivery"
);
assert_eq!(inputs[0].0.worker_id.to_string(), orchestrator.worker_id);
assert!(
inputs[0]
.1
.contains(ticket.resource_key.as_deref().unwrap())
);
assert!(attention_pending(&api).is_empty());
assert_eq!(
delivery_worker_pending(&api),
pending,
"other recipient ACK consumed the failed delivery"
);
recover_ticket_notifications(&api);
assert!(
execution.take_inputs().is_empty(),
"successful recipient was sent again"
);
delivery_stop(&api, &orchestrator);
}
#[tokio::test]
async fn worker_notification_reassignment_never_forwards_old_pending_input() {
let dir = tempfile::tempdir().unwrap();
let (api, execution, orchestrator, _) = delivery_recording_fixture(dir.path()).await;
let old_worker = spawn_ticket_check_source(&api, "old-notification-recipient");
let replacement = spawn_ticket_check_source(&api, "new-notification-recipient");
let ticket = delivery_ticket(&api, "Do not forward old authority");
delivery_assign_worker(&api, &ticket.id, &old_worker, "old-owner", None);
recover_ticket_notifications(&api);
execution.take_inputs();
execution.take_input_request_ids();
delivery_comment_without_scan(&api, &ticket.id, "Activity belonging to the old owner");
let pending = delivery_worker_pending(&api);
assert_eq!(pending.len(), 1);
execution.reject_inputs("response outcome unknown");
recover_ticket_notifications(&api);
let failed = execution.take_inputs();
assert!(!failed.is_empty());
assert!(
failed
.iter()
.all(|input| input.0.worker_id.to_string() == old_worker.worker_id)
);
assert!(
execution
.take_input_request_ids()
.iter()
.all(|id| id.as_deref() == Some(pending[0].request_id.as_str()))
);
let current = delivery_assign_worker(
&api,
&ticket.id,
&replacement,
"new-owner",
Some("old-owner"),
);
*execution.input_failure.lock().unwrap() = None;
delivery_comment_without_scan(&api, &ticket.id, "Activity belonging to the new owner");
recover_ticket_notifications(&api);
let inputs = execution.take_inputs();
assert!(!inputs.is_empty(), "new owner's current activity was lost");
assert!(
inputs
.iter()
.all(|input| input.0.worker_id.to_string() == replacement.worker_id)
);
assert!(
inputs
.iter()
.all(|input| !input.1.contains(&pending[0].input_event_id)),
"old activity was forwarded to the replacement"
);
assert!(
execution
.take_input_request_ids()
.iter()
.all(|id| id.as_deref() != Some(pending[0].request_id.as_str()))
);
assert!(delivery_worker_pending(&api).is_empty());
assert_eq!(
api.store
.get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id)
.unwrap(),
Some(current)
);
recover_ticket_notifications(&api);
assert!(execution.take_inputs().is_empty());
delivery_stop(&api, &old_worker);
delivery_stop(&api, &replacement);
delivery_stop(&api, &orchestrator);
}
async fn delivery_drain_host_with_paused_time(api: &WorkspaceApi) {
let executor = api.host_executors.host().unwrap();
let drain = executor.drain();
tokio::pin!(drain);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
if let std::task::Poll::Ready(result) = futures::poll!(drain.as_mut()) {
result.unwrap();
return;
}
assert!(
std::time::Instant::now() < deadline,
"notification host round did not finish"
);
// Keep a runnable future while the real host executor finishes: Tokio
// must not auto-advance the paused retry clock to infer completion.
tokio::task::yield_now().await;
}
}
#[tokio::test(start_paused = true)]
async fn worker_notification_periodic_recovery_runs_without_runtime_subscription() {
let dir = tempfile::tempdir().unwrap();
let (mut api, execution, orchestrator, _) = delivery_recording_fixture(dir.path()).await;
let worker = spawn_ticket_check_source(&api, "periodic-notification-recipient");
let ticket = delivery_ticket(&api, "Recover without subscription or callback");
delivery_assign_worker(&api, &ticket.id, &worker, "worker-owner", None);
recover_ticket_notifications(&api);
execution.take_inputs();
execution.take_input_request_ids();
api.runtime_subscription_broker = RuntimeSubscriptionBroker::new(TEST_WORKSPACE_ID);
assert!(
api.runtime_subscription_broker
.subscribe(
EMBEDDED_WORKER_RUNTIME_ID,
protocol::subscription::EventSubscriptionSelector::RuntimeWorkers,
)
.is_err()
);
// Poll the production hook itself, not a timer or recovery stand-in. Keeping
// its future local also guarantees cancellation on assertion failure.
delivery_comment_without_scan(&api, &ticket.id, "Missed startup callback");
let hook = run_orchestrator_turn_end_hook(api.clone());
tokio::pin!(hook);
assert!(futures::poll!(hook.as_mut()).is_pending());
delivery_drain_host_with_paused_time(&api).await;
assert_eq!(
execution.take_inputs().len(),
1,
"startup round did not recover"
);
assert!(delivery_worker_pending(&api).is_empty());
assert!(futures::poll!(hook.as_mut()).is_pending());
delivery_comment_without_scan(&api, &ticket.id, "Missed callback after startup");
tokio::time::advance(std::time::Duration::from_secs(29)).await;
assert!(futures::poll!(hook.as_mut()).is_pending());
delivery_drain_host_with_paused_time(&api).await;
assert!(
execution.take_inputs().is_empty(),
"recovery retried before the bounded interval"
);
tokio::time::advance(std::time::Duration::from_secs(1)).await;
assert!(futures::poll!(hook.as_mut()).is_pending());
delivery_drain_host_with_paused_time(&api).await;
let inputs = execution.take_inputs();
assert_eq!(
inputs.len(),
1,
"periodic recovery required a subscription or mutation callback"
);
assert_eq!(inputs[0].0.worker_id.to_string(), worker.worker_id);
assert!(delivery_worker_pending(&api).is_empty());
assert!(api.runtime_subscription_broker.runtime_ids().is_empty());
delivery_stop(&api, &worker);
delivery_stop(&api, &orchestrator);
}
async fn delivery_signed_item_edit(
api: &WorkspaceApi,
identity: &RuntimeIdentityMaterial,
worker: &RuntimeWorkerRef,
ticket: &str,
edit: Value,
) -> ticket::Ticket {
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/item");
let response = build_router(api.clone())
.oneshot(runtime_source_request(
identity,
Some(&worker.worker_id),
"PATCH",
&path,
serde_json::to_vec(&edit).unwrap(),
))
.await
.unwrap();
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
status,
StatusCode::OK,
"{}",
String::from_utf8_lossy(&bytes)
);
let edited: ticket::Ticket = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
edited,
browser_ticket_backend(api)
.unwrap()
.show(ticket.to_owned().into())
.unwrap()
);
edited
}
#[tokio::test]
async fn notification_item_edit_worker_title_only_self_edit_does_not_notify_itself() {
let dir = tempfile::tempdir().unwrap();
let (api, execution, orchestrator, identity) = delivery_recording_fixture(dir.path()).await;
let worker = spawn_ticket_check_source(&api, "self-title-editor");
seed_worker_source_member(&api, &worker.runtime_id, &worker.worker_id);
let ticket = delivery_ticket(&api, "Worker will clarify its own title");
delivery_assign_worker(&api, &ticket.id, &worker, "self-title-owner", None);
recover_ticket_notifications(&api);
execution.take_inputs();
execution.take_input_request_ids();
let before = browser_ticket_backend(&api)
.unwrap()
.show(ticket.id.clone().into())
.unwrap();
let edited = delivery_signed_item_edit(
&api,
&identity,
&worker,
&ticket.id,
json!({"title":"Clarified by the assigned Worker"}),
)
.await;
assert_eq!(edited.meta.title, "Clarified by the assigned Worker");
assert_eq!(edited.document.body, before.document.body);
assert_eq!(edited.meta.targets, before.meta.targets);
for _ in 0..3 {
recover_ticket_notifications(&api);
}
let inputs = execution.take_inputs();
delivery_stop(&api, &worker);
delivery_stop(&api, &orchestrator);
assert!(
inputs
.iter()
.all(|(recipient, _)| recipient.worker_id.to_string() != worker.worker_id),
"authenticated title-only self-edit became fresh Worker input"
);
assert!(delivery_worker_pending(&api).is_empty());
}
#[tokio::test]
async fn notification_item_edit_orchestrator_initial_body_self_edit_does_not_reenter_delegation() {
let dir = tempfile::tempdir().unwrap();
let (api, execution, orchestrator, identity) = delivery_recording_fixture(dir.path()).await;
let ticket = delivery_ticket(&api, "Orchestrator clarifies the initial post");
let assigned = delivery_assign(&api, &ticket.id, "assign-self-body").await;
let assignment = assigned["assignment"]["assignment_id"].as_str().unwrap();
let initial = delivery_tracking(&api, &ticket.id, assignment);
execution.take_inputs();
execution.take_input_request_ids();
let before = browser_ticket_backend(&api)
.unwrap()
.show(ticket.id.clone().into())
.unwrap();
let edited = delivery_signed_item_edit(
&api,
&identity,
&orchestrator,
&ticket.id,
json!({"body":"Clarified requirements written by the current Orchestrator"}),
)
.await;
assert_eq!(
edited.document.body.as_str(),
"Clarified requirements written by the current Orchestrator"
);
assert_eq!(edited.meta.title, before.meta.title);
assert_eq!(
edited.events[0].edited_by.as_deref(),
Some(
format!(
"worker:{}/{}",
orchestrator.runtime_id, orchestrator.worker_id
)
.as_str()
)
);
for _ in 0..3 {
recover_ticket_notifications(&api);
}
let inputs = execution.take_inputs();
assert_eq!(delivery_tracking(&api, &ticket.id, assignment), initial);
delivery_stop(&api, &orchestrator);
assert!(
inputs
.iter()
.all(|(recipient, _)| recipient.worker_id.to_string() != orchestrator.worker_id),
"workspace_agent principal did not recognize its authenticated Orchestrator self-edit"
);
assert!(attention_pending(&api).is_empty());
}
#[tokio::test]
async fn notification_item_edit_external_targets_only_change_notifies_existing_recipients_once() {
let dir = tempfile::tempdir().unwrap();
let (api, execution, orchestrator, _) = delivery_recording_fixture(dir.path()).await;
let worker = spawn_ticket_check_source(&api, "target-change-recipient");
let ticket = delivery_ticket(&api, "External target change must not disappear");
delivery_assign(&api, &ticket.id, "assign-targets").await;
delivery_assign_worker(&api, &ticket.id, &worker, "targets-owner", None);
recover_ticket_notifications(&api);
execution.take_inputs();
execution.take_input_request_ids();
let before = browser_ticket_backend(&api)
.unwrap()
.show(ticket.id.clone().into())
.unwrap();
let targets = vec![test_ticket_target(
"test-repository",
"develop",
TicketTargetAccess::ReadWrite,
)];
let result = delivery_http(
&api,
"PATCH",
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/item", ticket.id),
Some(json!({"targets":{"action":"set","targets":targets}})),
StatusCode::OK,
)
.await;
let edited: ticket::Ticket = serde_json::from_value(result).unwrap();
assert_eq!(
edited,
browser_ticket_backend(&api)
.unwrap()
.show(ticket.id.clone().into())
.unwrap()
);
assert_eq!(edited.meta.targets, targets);
assert_eq!(edited.meta.title, before.meta.title);
assert_eq!(edited.document.body, before.document.body);
recover_ticket_notifications(&api);
let inputs = execution.take_inputs();
let ids = execution.take_input_request_ids();
recover_ticket_notifications(&api);
let repeats = execution.take_inputs();
delivery_stop(&api, &worker);
delivery_stop(&api, &orchestrator);
for recipient in [&orchestrator, &worker] {
assert_eq!(
inputs
.iter()
.filter(|(target, _)| target.worker_id.to_string() == recipient.worker_id)
.count(),
1,
"targets-only external edit did not reach {} exactly once: {inputs:?}",
recipient.worker_id
);
}
assert_eq!(ids.len(), 2);
assert!(ids.iter().all(Option::is_some));
assert_ne!(ids[0], ids[1]);
assert!(repeats.is_empty(), "targets edit was delivered repeatedly");
assert!(delivery_worker_pending(&api).is_empty());
assert!(attention_pending(&api).is_empty());
}
#[tokio::test]
async fn catalog_shutdown_stops_notification_hook_and_fences_later_dispatch() {
let dir = tempfile::tempdir().unwrap();
let (api, execution, orchestrator, _) = delivery_recording_fixture(dir.path()).await;
let ticket = delivery_ticket(&api, "Pending at server shutdown");
delivery_assign(&api, &ticket.id, "shutdown-assignment").await;
execution.take_inputs();
delivery_commit_answer_without_scan(
&api,
&ticket.id,
"Retain for the next server",
"shutdown-answer",
);
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
catalog
.apis
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), api.clone());
let hook = tokio::spawn(run_orchestrator_turn_end_hook(api.clone()));
catalog
.hook_handles
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), hook.abort_handle());
catalog.shutdown_feature_storage().await.unwrap();
assert!(
tokio::time::timeout(std::time::Duration::from_secs(2), hook)
.await
.unwrap()
.unwrap_err()
.is_cancelled()
);
assert!(catalog.hook_handles.lock().await.is_empty());
execution.take_inputs();
recover_ticket_notifications(&api);
dispatch_orchestrator_assignment_attention(&api, &orchestrator);
assert!(
execution.take_inputs().is_empty(),
"stopped server dispatched a saved notification"
);
assert_eq!(
attention_pending(&api).len(),
1,
"shutdown discarded durable work"
);
delivery_stop(&api, &orchestrator);
}
include!("server_job_notification_tests.rs");
@@ -1858,8 +1858,11 @@ async fn runtime_metadata_reads_wait_off_tokio_and_db_and_retain_cancelled_host_
);
reply.finish();
observed(fixture.host_executor.drain()).await.unwrap();
// Runtime metadata also awaits binding reads owned by the DB executor.
// Its reply can wake Host before DB reply/lifetime cleanup has finished;
// draining Host alone is not a barrier for that separately-owned job.
observed(fixture.executor.drain()).await.unwrap();
assert_eq!(leases.load(Ordering::SeqCst), 0);
fixture.executor.drain().await.unwrap();
}
}
@@ -0,0 +1,364 @@
// Included in the production persistent-Worker delivery fixture. Only the LLM is
// scripted. Signed Runtime HTTP, assignment fencing, SQLite and session receipts are real.
async fn result_signed_http(
api: &WorkspaceApi,
identity: &RuntimeIdentityMaterial,
worker: &RuntimeWorkerRef,
ticket: &str,
body: &Value,
expected: StatusCode,
) -> Value {
let path = format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/implementation-results");
let response = build_router(api.clone())
.oneshot(runtime_source_request(
identity,
Some(&worker.worker_id),
"POST",
&path,
serde_json::to_vec(body).unwrap(),
))
.await
.unwrap();
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(status, expected, "{}", String::from_utf8_lossy(&bytes));
serde_json::from_slice(&bytes).unwrap()
}
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);
let row = &rows[0];
assert_eq!(row["stage"], "runtime_accepted");
assert_eq!(row["event_kind"], "implementation_result");
assert_eq!(row["recipient_role"], "orchestrator");
assert_eq!(row["target_worker_id"], recipient.worker_id);
assert_eq!(row["retry_candidate"], false);
assert_eq!(row["model_processing_confirmed"], false);
let checks = page["source_checks"].as_array().unwrap();
assert_eq!(checks.len(), 1);
assert_eq!(checks[0]["origin_present"], true);
assert_eq!(checks[0]["captured"], true);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn implementation_result_signed_http_reaches_persistent_orchestrator_once_without_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();
configure_runtime_request_auth(&mut api, &identity, EMBEDDED_WORKER_RUNTIME_ID);
let ticket = delivery_ticket(&api, "Return repository-free research");
let assigned = delivery_assign(&api, &ticket.id, "research-delegation").await;
let delegation = assigned["assignment"]["assignment_id"].as_str().unwrap();
let orchestrator = delivery_worker(start_attention_test_orchestrator(&api).await);
seed_worker_source_member(&api, &orchestrator.runtime_id, &orchestrator.worker_id);
delivery_wait_idle(&api, &orchestrator, 1).await;
let initial = delivery_tracking(&api, &ticket.id, delegation);
delivery_self_report(
&api,
&identity,
&orchestrator,
&initial,
"inprogress",
"research-progress",
)
.await;
let coder = spawn_ticket_check_source(&api, "research-coder");
seed_worker_source_member(&api, &coder.runtime_id, &coder.worker_id);
delivery_assign_worker(&api, &ticket.id, &coder, "research-implementation", None);
recover_ticket_notifications(&api);
let before = delivery_tracking(&api, &ticket.id, delegation);
let input = json!({"operation_key":"research-result","assignment_id":"research-implementation","orchestrator_assignment_id":delegation,"kind":"result","body":"Documented findings; no repository or MR was required.","merge_request":null});
// The registered Orchestrator is not the implementation assignee and cannot forge this result.
result_signed_http(
&api,
&identity,
&orchestrator,
&ticket.id,
&input,
StatusCode::FORBIDDEN,
)
.await;
let receipt =
result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK).await;
delivery_wait_idle(&api, &orchestrator, 2).await;
assert_eq!(
result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK).await,
receipt
);
let mut changed = input.clone();
changed["body"] = json!("Different result");
result_signed_http(
&api,
&identity,
&coder,
&ticket.id,
&changed,
StatusCode::CONFLICT,
)
.await;
let mut forged = input.clone();
forged["operation_key"] = json!("forged-result");
forged["merge_request"] = json!({"merge_request_id":"missing","subject_ref":"fake"});
result_signed_http(
&api,
&identity,
&coder,
&ticket.id,
&forged,
StatusCode::BAD_REQUEST,
)
.await;
let id = receipt["result_id"].as_str().unwrap();
assert!(
delivery_commits(&api, &orchestrator)[1].contains(&format!("implementation-result:{id}"))
);
assert_eq!(delivery_tracking(&api, &ticket.id, delegation), before);
assert_eq!(
api.authority.ticket(&ticket.id).unwrap().state,
server_api::BrowserTicketState::Open
);
let saved = delivery_http(
&api,
"GET",
&format!(
"/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/implementation-results/{id}",
ticket.id
),
None,
StatusCode::OK,
)
.await;
assert_eq!(saved, receipt);
assert_result_diagnostics(&api, &orchestrator, id).await;
let attempts = execution.attempts.lock().unwrap().len();
for _ in 0..3 {
recover_ticket_notifications(&api);
}
assert_eq!(execution.attempts.lock().unwrap().len(), attempts);
assert_eq!(delivery_receipts(&api, &orchestrator).len(), 2);
delivery_stop(&api, &coder);
delivery_stop(&api, &orchestrator);
}
// MR event creation uses the production store and registered reviewer capability.
// Provider observations/child registration are fixture boundaries here; the existing
// authenticated MR router suite owns Git publication and trusted child attestation.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mr_result_recovery_and_handoff_are_distinct_persistent_inputs() {
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();
configure_runtime_request_auth(&mut api, &identity, EMBEDDED_WORKER_RUNTIME_ID);
let ticket = delivery_ticket(&api, "MR result followed by implementation handoff");
let assigned = delivery_assign(&api, &ticket.id, "mr-delegation").await;
let delegation = assigned["assignment"]["assignment_id"].as_str().unwrap();
let orchestrator = delivery_worker(start_attention_test_orchestrator(&api).await);
seed_worker_source_member(&api, &orchestrator.runtime_id, &orchestrator.worker_id);
delivery_wait_idle(&api, &orchestrator, 1).await;
let initial = delivery_tracking(&api, &ticket.id, delegation);
delivery_self_report(
&api,
&identity,
&orchestrator,
&initial,
"inprogress",
"mr-progress",
)
.await;
let coder = spawn_ticket_check_source(&api, "mr-coder");
seed_worker_source_member(&api, &coder.runtime_id, &coder.worker_id);
delivery_assign_worker(&api, &ticket.id, &coder, "mr-implementation", None);
seed_test_repository(&api, TEST_REPOSITORY_ID);
api.config.repositories = test_server_config(dir.path()).repositories;
let store = merge_request_store(&api, TEST_WORKSPACE_ID).unwrap();
let auth = merge_request::MergeRequestAuth {
workspace_id: TEST_WORKSPACE_ID.into(),
repository_id: TEST_REPOSITORY_ID.into(),
runtime_id: coder.runtime_id.clone(),
worker_id: coder.worker_id.clone(),
assignment_id: "mr-implementation".into(),
};
store
.open_merge_request(merge_request::OpenMergeRequest {
merge_request_id: "result-mr".into(),
ticket_id: ticket.id.clone(),
repository_id: TEST_REPOSITORY_ID.into(),
selector_from: "refs/heads/work".into(),
selector_to: "refs/heads/develop".into(),
summary: String::new(),
auth: auth.clone(),
now: Utc::now(),
})
.unwrap();
store
.register_reviewer_child_session(merge_request::RegisterReviewerChildSession {
workspace_id: TEST_WORKSPACE_ID.into(),
parent_runtime_id: coder.runtime_id.clone(),
parent_worker_id: coder.worker_id.clone(),
child_session_id: "result-reviewer".into(),
reviewer_profile: "builtin:reviewer".into(),
now: Utc::now(),
})
.unwrap();
let digest = api.authority.ticket(&ticket.id).unwrap().content_digest;
let requested = store
.request_review(merge_request::RequestMergeRequestReview {
merge_request_id: "result-mr".into(),
ticket_id: ticket.id.clone(),
ticket_content_digest: digest,
ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject {
merge_request_id: "result-mr".into(),
subject_ref: "source-a".into(),
}],
subject_ref: "source-a".into(),
child_session_id: "result-reviewer".into(),
capability_token: "result-review-capability".into(),
auth: auth.clone(),
now: Utc::now(),
})
.unwrap();
let approved = store
.submit_review(merge_request::SubmitMergeRequestReview {
merge_request_id: "result-mr".into(),
ticket_id: ticket.id.clone(),
current_subject_ref: "source-a".into(),
capability_token: "result-review-capability".into(),
decision: merge_request::ReviewDecision::Approve,
body: "Approved exact source".into(),
findings: vec![],
now: Utc::now(),
})
.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;
assert_eq!(pending["items"].as_array().unwrap().len(), 4);
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);
delivery_wait_idle(&api, &orchestrator, 3).await;
let input = json!({"operation_key":"approved-handoff","assignment_id":"mr-implementation","orchestrator_assignment_id":delegation,"kind":"handoff","body":"Implementation and validation complete; integration remains separate.","merge_request":{"merge_request_id":"result-mr","subject_ref":"source-a"}});
let receipt =
result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK).await;
delivery_wait_idle(&api, &orchestrator, 4).await;
let commits = delivery_commits(&api, &orchestrator);
assert!(
commits
.iter()
.any(|s| s.contains(&requested.request_event.event_id))
);
assert!(commits.iter().any(|s| s.contains(&approved.event_id)));
assert!(
commits
.iter()
.any(|s| s.contains(receipt["result_id"].as_str().unwrap()))
);
store
.revoke_review(merge_request::RevokeMergeRequestReview {
merge_request_id: "result-mr".into(),
ticket_id: ticket.id.clone(),
review_event_id: approved.event_id,
reason: "Approval withdrawn".into(),
auth: auth.clone(),
now: Utc::now(),
})
.unwrap();
api.config_store
.observe_merge_request_source(
TEST_WORKSPACE_ID,
TEST_REPOSITORY_ID,
"refs/heads/work",
"source-b",
)
.unwrap();
api.config_store
.observe_merge_request_source(
TEST_WORKSPACE_ID,
TEST_REPOSITORY_ID,
"refs/heads/work",
"source-b",
)
.unwrap();
recover_ticket_notifications(&api);
delivery_wait_idle(&api, &orchestrator, 6).await;
for (child, requested_source, observed_source) in [
("cancelled-reviewer", "source-b", "source-c"),
("changes-reviewer", "source-c", "source-c"),
] {
store
.register_reviewer_child_session(merge_request::RegisterReviewerChildSession {
workspace_id: TEST_WORKSPACE_ID.into(),
parent_runtime_id: coder.runtime_id.clone(),
parent_worker_id: coder.worker_id.clone(),
child_session_id: child.into(),
reviewer_profile: "builtin:reviewer".into(),
now: Utc::now(),
})
.unwrap();
store
.request_review(merge_request::RequestMergeRequestReview {
merge_request_id: "result-mr".into(),
ticket_id: ticket.id.clone(),
ticket_content_digest: api.authority.ticket(&ticket.id).unwrap().content_digest,
ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject {
merge_request_id: "result-mr".into(),
subject_ref: requested_source.into(),
}],
subject_ref: requested_source.into(),
child_session_id: child.into(),
capability_token: child.into(),
auth: auth.clone(),
now: Utc::now(),
})
.unwrap();
let submitted = store.submit_review(merge_request::SubmitMergeRequestReview {
merge_request_id: "result-mr".into(),
ticket_id: ticket.id.clone(),
current_subject_ref: observed_source.into(),
capability_token: child.into(),
decision: merge_request::ReviewDecision::RequestChanges,
body: "Recheck implementation".into(),
findings: vec![],
now: Utc::now(),
});
assert_eq!(submitted.is_err(), requested_source != observed_source);
}
recover_ticket_notifications(&api);
delivery_wait_idle(&api, &orchestrator, 10).await;
let attempts = execution.attempts.lock().unwrap().len();
result_signed_http(&api, &identity, &coder, &ticket.id, &input, StatusCode::OK).await;
recover_ticket_notifications(&api);
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 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();
assert!(!matching.is_empty(), "missing diagnostic for {kind}");
for row in matching {
assert_eq!(row["stage"], "runtime_accepted");
assert_eq!(row["target_worker_id"], orchestrator.worker_id);
assert_eq!(row["model_processing_confirmed"], false);
}
}
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_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
);
assert_eq!(
delivery_tracking(&api, &ticket.id, delegation)
.tracking
.unwrap()
.status,
ticket::TicketAssignmentTrackingStatus::Inprogress
);
delivery_stop(&api, &coder);
delivery_stop(&api, &orchestrator);
}
@@ -0,0 +1,282 @@
// Uses the real Controller/session fixtures above; only model output and the
// deliberately lost Runtime response are substituted.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn job_notification_real_worker_ack_loss_reopen_and_parallel_recovery_commits_once() {
let dir = tempfile::tempdir().unwrap();
let (api, execution, _) = delivery_real_fixture(dir.path(), false).await;
let target = spawn_ticket_check_source(&api, "fixed-job-result-target");
let request = BackendJobRequest {
job_id: "real-job-notification".into(),
purpose: "result_notification_fixture".into(),
input_ref: "test://job/result".into(),
input: json!({"fixed":true}),
instruction: "Return a result.".into(),
profile: "builtin:backend-job".into(),
grants: Default::default(),
serialization_key: None,
source_worker: None,
notification_target: Some(target.clone()),
limits: Default::default(),
};
let dispatched = api.dispatch_backend_job(&request).unwrap();
let worker = dispatched.attempt.worker.as_ref().unwrap();
let submission = BackendJobResultSubmission {
job_id: request.job_id.clone(),
attempt_id: dispatched.attempt.attempt_id.clone(),
input_digest: request.input_digest().unwrap(),
result: json!({"result":"saved"}),
};
let accepted = api
.store
.accept_backend_job_result(TEST_WORKSPACE_ID, worker, &submission, TEST_CREATED_AT)
.unwrap();
let id = crate::backend_job::delivery_id(&request.job_id, &accepted.attempt.attempt_id);
// Result commit without its wakeup. No old reserve record is necessary.
assert!(
api.store
.job_notification_request(TEST_WORKSPACE_ID, &id)
.unwrap()
.is_none()
);
execution
.lose_responses
.store(true, std::sync::atomic::Ordering::SeqCst);
notification_delivery::recover_job_notifications(&api);
delivery_wait_idle(&api, &target, 1).await;
let pinned = api
.store
.job_notification_request(TEST_WORKSPACE_ID, &id)
.unwrap()
.unwrap();
let evidence = job_notification_evidence(&api, &id);
assert!(!evidence.2);
assert_eq!(evidence.3.as_deref(), Some("acceptance_unknown"));
let diagnostics=delivery_http(&api,"GET",&format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?job_id={}",request.job_id),None,StatusCode::OK).await;
assert_eq!(diagnostics["items"][0]["stage"],"acceptance_unknown");
assert_eq!(diagnostics["items"][0]["target_worker_id"],target.worker_id);
assert_eq!(diagnostics["items"][0]["acceptance_outcome_unknown"],true);
assert_eq!(delivery_receipts(&api, &target).len(), 1);
// Reopen only Backend persistence, retaining the real Runtime receipt/session.
let mut reopened = api.clone();
reopened.store =
Arc::new(SqliteWorkspaceStore::open_for_test(&api.config.database_path).unwrap());
execution
.lose_responses
.store(false, std::sync::atomic::Ordering::SeqCst);
let barrier = Arc::new(std::sync::Barrier::new(4));
std::thread::scope(|scope| {
let joins = (0..4)
.map(|_| {
let api = reopened.clone();
let barrier = barrier.clone();
scope.spawn(move || {
barrier.wait();
notification_delivery::recover_job_notifications(&api);
})
})
.collect::<Vec<_>>();
for join in joins {
join.join().unwrap();
}
});
delivery_wait_idle(&reopened, &target, 1).await;
assert!(job_notification_evidence(&reopened, &id).2);
let diagnostics=delivery_http(&reopened,"GET",&format!("/api/w/{TEST_WORKSPACE_ID}/notification-diagnostics?job_id={}",request.job_id),None,StatusCode::OK).await;
assert_eq!(diagnostics["items"][0]["stage"],"runtime_accepted");
assert_eq!(diagnostics["items"][0]["target_worker_id"],target.worker_id);
assert_eq!(diagnostics["items"][0]["acceptance_outcome_unknown"],false);
assert_eq!(diagnostics["items"][0]["model_processing_confirmed"],false);
assert_eq!(delivery_receipts(&reopened, &target).len(), 1);
assert_eq!(delivery_commits(&reopened, &target), vec![pinned.0.clone()]);
let attempts = execution.attempts.lock().unwrap();
let retries = attempts
.iter()
.filter(|attempt| attempt.0 == id)
.collect::<Vec<_>>();
assert!(retries.len() >= 2);
assert!(retries.iter().all(|attempt| attempt.1 == pinned.0));
drop(attempts);
assert!(
reopened
.store
.accept_backend_job_result(TEST_WORKSPACE_ID, worker, &submission, TEST_CREATED_AT)
.unwrap()
.replayed
);
assert_eq!(
reopened
.store
.get_backend_job(TEST_WORKSPACE_ID, &request.job_id)
.unwrap()
.unwrap()
.current_attempt,
1
);
assert!(
reopened
.store
.list_pending_job_notifications(TEST_WORKSPACE_ID, 100)
.unwrap()
.is_empty()
);
delivery_stop(&reopened, &target);
delivery_stop(&reopened, worker);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn job_notification_checker_read_failure_reopen_recovers_same_source_to_real_receipt() {
let dir = tempfile::tempdir().unwrap();
let (api, execution, _) = delivery_real_fixture(dir.path(), false).await;
let target = spawn_ticket_check_source(&api, "checker-retry-target");
let backend = browser_ticket_backend(&api).unwrap();
let ticket_ref = backend.create(ticket::NewTicket::new("Writer-only scope in requirements")).unwrap();
let ticket = backend.show(ticket_ref.id.into()).unwrap();
let request = ticket_item_checker::request(
&ticket,
ticket_item_checker::TicketItemCheckEdit::Create,
target.clone(),
"Check saved Ticket content.".into(),
).unwrap();
let dispatched = api.dispatch_backend_job(&request).unwrap();
let worker = dispatched.attempt.worker.as_ref().unwrap();
let submission = BackendJobResultSubmission {
job_id: request.job_id.clone(),
attempt_id: dispatched.attempt.attempt_id.clone(),
input_digest: request.input_digest().unwrap(),
result: json!({"findings":[{"category":"writer_scope","quote":"Writer-only scope","reason":"Writer scope is mixed into requirements.","suggestion":"Remove the writer-specific sentence."}]}),
};
let accepted = api.store.accept_backend_job_result(TEST_WORKSPACE_ID, worker, &submission, TEST_CREATED_AT).unwrap();
let source = api.store.job_notification_source(TEST_WORKSPACE_ID, &request.job_id, &accepted.attempt.attempt_id).unwrap();
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
let source_snapshot = || conn.query_row("SELECT sequence,facts_json FROM notification_sources WHERE workspace_id=?1 AND input_event_id=?2", rusqlite::params![TEST_WORKSPACE_ID,source.delivery_id], |r| Ok((r.get::<_,i64>(0)?,r.get::<_,String>(1)?))).unwrap();
let original_source = source_snapshot();
// Fail exactly one checker read through the real adapter's error branch.
// Its independent Ticket connection cannot open a directory as SQLite, while
// the already-open result store remains available to persist failure evidence.
let mut unavailable = api.clone();
unavailable.config.database_path = dir.path().to_path_buf();
notification_delivery::recover_job_notifications(&unavailable);
let failures = || {
let mut query = conn.prepare("SELECT sequence,source_sequence,code,recorded_at FROM notification_source_preparation_failures WHERE source_sequence=?1 ORDER BY sequence").unwrap();
query.query_map([original_source.0], |r| Ok((r.get::<_,i64>(0)?,r.get::<_,i64>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?))).unwrap().collect::<rusqlite::Result<Vec<_>>>().unwrap()
};
let failed_once = failures();
assert_eq!(failed_once.len(), 1);
assert_eq!(failed_once[0].2, "source_lookup_failed");
let diagnostic=api.store.notification_diagnostics(TEST_WORKSPACE_ID,&server_api::NotificationDiagnosticsQuery{job_id:Some(request.job_id.clone()),..Default::default()}).unwrap().items.remove(0);
assert_eq!(diagnostic.stage,server_api::NotificationDiagnosticStage::DeliveryFailed);
assert!(diagnostic.disposition.is_none() && diagnostic.retry_candidate);
assert!(!diagnostic.acceptance_outcome_unknown);
assert_eq!(diagnostic.last_failure_code.as_deref(),Some("source_lookup_failed"));
assert_eq!(conn.query_row("SELECT COUNT(*) FROM notification_decisions WHERE source_sequence=?1", [original_source.0], |r| r.get::<_,i64>(0)).unwrap(), 0);
assert!(api.store.job_notification_request(TEST_WORKSPACE_ID, &source.delivery_id).unwrap().is_none());
assert!(delivery_receipts(&api, &target).is_empty());
assert!(!execution.attempts.lock().unwrap().iter().any(|attempt| attempt.0 == source.delivery_id));
drop(unavailable);
// Reopen Backend persistence and run the unchanged pending-source scan. It
// rereads the real Ticket, obtains findings, then reaches the actual Runtime.
let mut reopened = api.clone();
reopened.store = Arc::new(SqliteWorkspaceStore::open_for_test(&api.config.database_path).unwrap());
let pending = reopened.store.list_pending_job_notifications(TEST_WORKSPACE_ID, 100).unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].delivery_id, source.delivery_id);
assert_eq!(pending[0].job_id, source.job_id);
assert_eq!(pending[0].attempt_id, source.attempt_id);
notification_delivery::recover_job_notifications(&reopened);
delivery_wait_idle(&reopened, &target, 1).await;
assert_eq!(job_notification_evidence(&reopened, &source.delivery_id), ("deliver".into(), "result".into(), true, None));
let pinned = reopened.store.job_notification_request(TEST_WORKSPACE_ID, &source.delivery_id).unwrap().unwrap();
assert_eq!(pinned.1, target);
assert!(pinned.0.contains("Quote: \"Writer-only scope\""));
assert_eq!(delivery_commits(&reopened, &target), vec![pinned.0.clone()]);
assert_eq!(delivery_receipts(&reopened, &target).len(), 1);
assert!(execution.attempts.lock().unwrap().iter().any(|attempt| attempt.0 == source.delivery_id && attempt.1 == pinned.0));
assert_eq!(source_snapshot(), original_source);
assert_eq!(failures(), failed_once, "recovery must retain the original failure observation");
let diagnostic=reopened.store.notification_diagnostics(TEST_WORKSPACE_ID,&server_api::NotificationDiagnosticsQuery{job_id:Some(request.job_id.clone()),..Default::default()}).unwrap().items.remove(0);
assert_eq!(diagnostic.stage,server_api::NotificationDiagnosticStage::RuntimeAccepted);
assert_eq!(diagnostic.last_failure_code.as_deref(),Some("source_lookup_failed"));
assert!(!diagnostic.retry_candidate && !diagnostic.acceptance_outcome_unknown && !diagnostic.model_processing_confirmed);
let job = reopened.store.get_backend_job(TEST_WORKSPACE_ID, &request.job_id).unwrap().unwrap();
assert_eq!(job.state, BackendJobState::Completed);
assert_eq!(job.current_attempt, accepted.job.current_attempt);
assert_eq!(job.result, accepted.job.result);
assert_eq!(job.result_digest, accepted.job.result_digest);
assert!(reopened.store.accept_backend_job_result(TEST_WORKSPACE_ID, worker, &submission, TEST_CREATED_AT).unwrap().replayed);
notification_delivery::recover_job_notifications(&reopened);
assert_eq!(delivery_receipts(&reopened, &target).len(), 1);
assert!(reopened.store.list_pending_job_notifications(TEST_WORKSPACE_ID, 100).unwrap().is_empty());
delivery_stop(&reopened, &target);
delivery_stop(&reopened, worker);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn job_notification_stopped_fixed_recipient_is_not_replaced_or_restored() {
let dir = tempfile::tempdir().unwrap();
let (api, execution, _) = delivery_real_fixture(dir.path(), false).await;
let target = spawn_ticket_check_source(&api, "stopped-job-target");
let request = BackendJobRequest {
job_id: "stopped-fixed-job".into(),
purpose: "result_notification_fixture".into(),
input_ref: "test://job/stopped".into(),
input: json!({"fixed":true}),
instruction: "Return a result.".into(),
profile: "builtin:backend-job".into(),
grants: Default::default(),
serialization_key: None,
source_worker: None,
notification_target: Some(target.clone()),
limits: Default::default(),
};
let dispatched = api.dispatch_backend_job(&request).unwrap();
let worker = dispatched.attempt.worker.as_ref().unwrap();
let accepted = api
.store
.accept_backend_job_result(
TEST_WORKSPACE_ID,
worker,
&BackendJobResultSubmission {
job_id: request.job_id.clone(),
attempt_id: dispatched.attempt.attempt_id.clone(),
input_digest: request.input_digest().unwrap(),
result: json!({"saved":true}),
},
TEST_CREATED_AT,
)
.unwrap();
let id = crate::backend_job::delivery_id(&request.job_id, &accepted.attempt.attempt_id);
delivery_stop(&api, &target);
let replacement = spawn_ticket_check_source(&api, "different-job-target");
notification_delivery::recover_job_notifications(&api);
let evidence = job_notification_evidence(&api, &id);
assert!(!evidence.2);
assert_eq!(evidence.3.as_deref(), Some("target_stopped"));
assert!(
!execution
.attempts
.lock()
.unwrap()
.iter()
.any(|attempt| attempt.0 == id)
);
assert_eq!(
api.store
.job_notification_request(TEST_WORKSPACE_ID, &id)
.unwrap()
.unwrap()
.1,
target
);
assert_eq!(api.runtime.worker(&target).unwrap().state, "stopped");
assert_eq!(
api.store
.get_backend_job(TEST_WORKSPACE_ID, &request.job_id)
.unwrap()
.unwrap()
.result,
Some(json!({"saved":true}))
);
delivery_stop(&api, &replacement);
delivery_stop(&api, worker);
}
@@ -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_and_case_authority_runtime_lookup_leave_db_and_tokio_free() {
async fn ticket_tracking_authority_and_case_audit_lookup_leave_db_and_tokio_free() {
for operation in ["tracking", "case"] {
let fixture = fixture().await;
let ticket = browser_ticket_backend(&fixture.api)
@@ -841,11 +841,25 @@ async fn ticket_tracking_and_case_authority_runtime_lookup_leave_db_and_tokio_fr
observed(fixture.host_executor.drain()).await.unwrap();
observed(fixture.executor.drain()).await.unwrap();
assert_eq!(leases.load(Ordering::SeqCst), 0);
assert_eq!(
backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(),
before,
"failed live authority must not make a decision"
);
let after = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap();
if operation == "tracking" {
assert_eq!(
after, before,
"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.
assert_eq!(after.meta.workflow_state, ticket::TicketState::Closed);
assert_eq!(
after.events.last().unwrap().attributes["source_actor_role"],
"worker"
);
assert_eq!(
after.current_closure.unwrap().operation_key,
"case-operation"
);
}
}
}
@@ -34,59 +34,62 @@ fn signed_case_request(
}
#[tokio::test]
async fn signed_unassigned_worker_without_hints_cannot_close_reopen_or_correct_cases() {
async fn signed_unassigned_worker_can_close_correct_and_reopen_without_orchestration() {
let dir = tempfile::tempdir().unwrap();
let (mut api, execution) = test_api_with_recording_backend(dir.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-case");
seed_worker_source_member(&api, "runtime-case", "other");
seed_worker_source_member(&api, "runtime-case", "companion");
let backend = browser_ticket_backend(&api).unwrap();
let reference = backend
.create(ticket::NewTicket::new("Case authority"))
.create(ticket::NewTicket::new(
"Case permission is independent of assignment",
))
.unwrap();
seed_case_assignment(
&api,
&reference.id,
&RuntimeWorkerRef::new("runtime-case", "owner"),
"unauthorized-owner",
);
let authority = case_authority_snapshot(&api);
let app = build_router(api.clone());
for action in [
for (index, action) in [
ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::Completed,
},
ticket::TicketCaseAction::Reopen,
ticket::TicketCaseAction::CorrectClosure {
reason: ticket::TicketClosureReason::NotPlanned,
},
] {
if !matches!(action, ticket::TicketCaseAction::Close { .. }) {
let before = backend.show(reference.id.clone().into()).unwrap();
if before.meta.workflow_state == TicketState::Open {
backend
.update_case(
&reference.id,
case_test_request(
&before,
"fixture-close",
ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::Completed,
},
),
)
.unwrap();
}
}
ticket::TicketCaseAction::Reopen,
]
.into_iter()
.enumerate()
{
let before = backend.show(reference.id.clone().into()).unwrap();
let input = case_test_request(&before, "unauthorized", action);
let authority = case_authority_snapshot(&api);
case_response(
let input = case_test_request(&before, &format!("case-{index}"), action);
let first = case_response(
app.clone(),
signed_case_request(&identity, Some("other"), &reference.id, &input),
StatusCode::FORBIDDEN,
signed_case_request(&identity, Some("companion"), &reference.id, &input),
StatusCode::OK,
)
.await;
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before);
assert_eq!(
first["workflow_state"],
if index == 2 { "open" } else { "closed" }
);
let after = backend.show(reference.id.clone().into()).unwrap();
let event = after.events.last().unwrap();
assert_eq!(
event.author.as_deref(),
Some("worker:runtime-case/companion")
);
assert_eq!(event.attributes["source_actor_role"], "worker");
assert!(!event.attributes.contains_key("source_assignment_id"));
assert_eq!(
case_response(
app.clone(),
signed_case_request(&identity, Some("companion"), &reference.id, &input),
StatusCode::OK,
)
.await,
first
);
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), after);
assert_eq!(case_authority_snapshot(&api), authority);
}
assert!(execution.protocol_methods().is_empty());
@@ -254,17 +257,7 @@ async fn signed_assigned_worker_case_replay_keeps_original_actor_after_release_w
StatusCode::CONFLICT,
)
.await;
let reopen = case_test_request(
&closed,
"released-worker-reopen",
ticket::TicketCaseAction::Reopen,
);
case_response(
app.clone(),
signed_case_request(&identity, Some("owner"), &reference.id, &reopen),
StatusCode::FORBIDDEN,
)
.await;
// Releasing execution grants does not revoke Profile case-decision access.
let correction = case_test_request(
&closed,
"released-worker-correction",
@@ -273,13 +266,38 @@ async fn signed_assigned_worker_case_replay_keeps_original_actor_after_release_w
},
);
case_response(
app,
app.clone(),
signed_case_request(&identity, Some("owner"), &reference.id, &correction),
StatusCode::FORBIDDEN,
StatusCode::OK,
)
.await;
assert_eq!(backend.show(reference.id.into()).unwrap(), closed);
let stale_reopen = case_test_request(&closed, "stale-reopen", ticket::TicketCaseAction::Reopen);
case_response(
app.clone(),
signed_case_request(&identity, Some("owner"), &reference.id, &stale_reopen),
StatusCode::CONFLICT,
)
.await;
let corrected = backend.show(reference.id.clone().into()).unwrap();
let reopen = case_test_request(
&corrected,
"released-worker-reopen",
ticket::TicketCaseAction::Reopen,
);
case_response(
app,
signed_case_request(&identity, Some("owner"), &reference.id, &reopen),
StatusCode::OK,
)
.await;
let reopened = backend.show(reference.id.into()).unwrap();
assert_eq!(reopened.meta.workflow_state, TicketState::Open);
assert_eq!(
reopened.events.last().unwrap().attributes["source_actor_role"],
"worker"
);
assert_eq!(case_authority_snapshot(&api), released);
assert!(execution.protocol_methods().is_empty());
}
@@ -492,15 +510,13 @@ async fn typed_and_generic_case_dispatch_keep_the_same_verified_worker_and_recei
reference.id
);
let generic = generic_case_router(api.clone());
for worker in [Some("other"), None] {
case_response(
generic.clone(),
signed_case_request(&identity, worker, &reference.id, &input),
StatusCode::FORBIDDEN,
)
.await;
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before);
}
case_response(
generic.clone(),
signed_case_request(&identity, None, &reference.id, &input),
StatusCode::FORBIDDEN,
)
.await;
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before);
let closed = case_response(
generic.clone(),
runtime_source_request(
+289 -8
View File
@@ -62,8 +62,19 @@ use crate::{Error, Result};
#[path = "assignment_delivery.rs"]
mod assignment_delivery;
#[path = "notification_diagnostics.rs"]
mod notification_diagnostics;
#[path = "notification_routing.rs"]
mod notification_routing;
pub use notification_routing::{
JobNotificationFacts, JobNotificationSource, NotificationDeliveryKey, NotificationEventKind,
NotificationFailureCode, NotificationPurpose, NotificationReason, TicketWorkerNotification,
TicketWorkerNotificationTarget,
};
#[path = "assignment_tracking.rs"]
mod assignment_tracking;
#[path = "implementation_result.rs"]
mod implementation_result;
pub use assignment_delivery::TicketAssignmentDelivery;
pub use assignment_tracking::TicketOrchestratorAssignmentSourceContext;
pub use ticket::{
@@ -73,7 +84,7 @@ pub use ticket::{
};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 94;
const LATEST_SCHEMA_VERSION: i64 = 97;
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";
@@ -351,6 +362,21 @@ const MIGRATIONS: &[Migration] = &[
name: "Canonical Ticket posts",
apply: migrate_ticket_posts_v93_to_v94,
},
Migration {
version: 95,
name: "Common notification routing and durable Worker delivery",
apply: migrate_notification_routing_v94_to_v95,
},
Migration {
version: 96,
name: "Backend Job common notification routing",
apply: migrate_job_notifications_v95_to_v96,
},
Migration {
version: 97,
name: "MR notifications and authenticated implementation results",
apply: migrate_business_notifications_v96_to_v97,
},
];
#[derive(Clone, Copy)]
@@ -1691,6 +1717,40 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
failure_detail: Option<&str>,
now: &str,
) -> Result<BackendJobAttemptRecord>;
fn job_notification_source(
&self,
workspace: &str,
job: &str,
attempt: &str,
) -> Result<JobNotificationSource>;
fn list_pending_job_notifications(
&self,
workspace: &str,
limit: usize,
) -> Result<Vec<JobNotificationSource>>;
fn route_job_notification(
&self,
workspace: &str,
source: &JobNotificationSource,
facts: &JobNotificationFacts,
) -> Result<bool>;
fn job_notification_request(
&self,
workspace: &str,
id: &str,
) -> Result<Option<(String, RuntimeWorkerRef)>>;
fn authorize_job_notification(
&self,
workspace: &str,
id: &str,
target: &TicketWorkerNotificationTarget,
) -> Result<Option<NotificationFailureCode>>;
fn ack_job_notification(
&self,
workspace: &str,
id: &str,
target: &RuntimeWorkerRef,
) -> Result<()>;
fn reserve_backend_job_delivery(
&self,
workspace_id: &str,
@@ -1886,6 +1946,35 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
delivery_id: &str,
source: &TicketAssignmentTrackingSourceContext,
) -> Result<()>;
fn notification_diagnostics(
&self,
workspace_id: &str,
query: &server_api::NotificationDiagnosticsQuery,
) -> Result<server_api::NotificationDiagnosticsPage>;
fn list_pending_ticket_worker_notifications(
&self,
workspace_id: &str,
limit: usize,
) -> Result<Vec<TicketWorkerNotification>>;
fn prepare_ticket_worker_notification_content(
&self,
workspace_id: &str,
delivery_id: &str,
content: &str,
target: &TicketWorkerNotificationTarget,
) -> Result<String>;
fn ack_ticket_worker_notification(
&self,
workspace_id: &str,
delivery_id: &str,
target: &TicketWorkerNotificationTarget,
) -> Result<()>;
fn record_notification_delivery_failure(
&self,
workspace_id: &str,
key: &NotificationDeliveryKey,
code: NotificationFailureCode,
) -> Result<()>;
fn reserve_ticket_assignment_operation_for_orchestrator(
&self,
workspace_id: &str,
@@ -6931,6 +7020,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
.ok_or_else(|| Error::Store("accepted Backend Job disappeared".to_string()))?;
let attempt = read_backend_job_attempt(&tx, workspace_id, &submission.job_id, &submission.attempt_id)?
.ok_or_else(|| Error::Store("accepted Backend Job attempt disappeared".to_string()))?;
notification_routing::jobs::capture(&tx,workspace_id,&submission.job_id,&submission.attempt_id)?;
tx.commit()?;
Ok(BackendJobResultAcceptance { job, attempt, replayed: false })
})
@@ -7145,6 +7235,53 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn job_notification_source(
&self,
workspace: &str,
job: &str,
attempt: &str,
) -> Result<JobNotificationSource> {
notification_routing::jobs::source(self, workspace, job, attempt)
}
fn list_pending_job_notifications(
&self,
workspace: &str,
limit: usize,
) -> Result<Vec<JobNotificationSource>> {
notification_routing::jobs::pending(self, workspace, limit)
}
fn route_job_notification(
&self,
workspace: &str,
source: &JobNotificationSource,
facts: &JobNotificationFacts,
) -> Result<bool> {
notification_routing::jobs::route(self, workspace, source, facts)
}
fn job_notification_request(
&self,
workspace: &str,
id: &str,
) -> Result<Option<(String, RuntimeWorkerRef)>> {
notification_routing::jobs::request(self, workspace, id)
}
fn authorize_job_notification(
&self,
workspace: &str,
id: &str,
target: &TicketWorkerNotificationTarget,
) -> Result<Option<NotificationFailureCode>> {
notification_routing::jobs::authorize(self, workspace, id, target)
}
fn ack_job_notification(
&self,
workspace: &str,
id: &str,
target: &RuntimeWorkerRef,
) -> Result<()> {
notification_routing::jobs::ack(self, workspace, id, target)
}
fn reserve_backend_job_delivery(
&self,
workspace_id: &str,
@@ -8006,6 +8143,45 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
None,
)
}
fn notification_diagnostics(
&self,
workspace_id: &str,
query: &server_api::NotificationDiagnosticsQuery,
) -> Result<server_api::NotificationDiagnosticsPage> {
notification_diagnostics::page(self, workspace_id, query)
}
fn list_pending_ticket_worker_notifications(
&self,
workspace_id: &str,
limit: usize,
) -> Result<Vec<TicketWorkerNotification>> {
notification_routing::list_workers(self, workspace_id, limit)
}
fn prepare_ticket_worker_notification_content(
&self,
workspace_id: &str,
delivery_id: &str,
content: &str,
target: &TicketWorkerNotificationTarget,
) -> Result<String> {
notification_routing::prepare_worker(self, workspace_id, delivery_id, content, target)
}
fn ack_ticket_worker_notification(
&self,
workspace_id: &str,
delivery_id: &str,
target: &TicketWorkerNotificationTarget,
) -> Result<()> {
notification_routing::ack_worker(self, workspace_id, delivery_id, target)
}
fn record_notification_delivery_failure(
&self,
workspace_id: &str,
key: &NotificationDeliveryKey,
code: NotificationFailureCode,
) -> Result<()> {
notification_routing::failure(self, workspace_id, key, code)
}
fn reserve_ticket_assignment_operation_for_orchestrator(
&self,
workspace_id: &str,
@@ -8051,7 +8227,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"Assignment delivery scan limit must be 1..=1000".into(),
));
}
self.with_conn(|conn| assignment_delivery::list_in_tx(conn, workspace_id, limit))
self.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let result = assignment_delivery::list_in_tx(&tx, workspace_id, limit)?;
tx.commit()?;
Ok(result)
})
}
fn reserve_ticket_assignment_delivery(
&self,
@@ -14023,10 +14204,95 @@ fn migrate_ticket_posts_v93_to_v94(conn: &Connection) -> Result<()> {
Ok(())
}
fn migrate_notification_routing_v94_to_v95(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 94 {
return Err(Error::Store(
"Notification routing migration requires schema 94".into(),
));
}
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}")))?;
notification_routing::install(&tx)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(95,?1)",
["Common notification routing and durable Worker delivery"],
)?;
verify_workspace_resource_constraints(&tx)?;
let violations: i64 =
tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| {
r.get(0)
})?;
if violations != 0 {
return Err(Error::Store(
"Notification routing migration left foreign-key violations".into(),
));
}
tx.commit()?;
Ok(())
}
fn migrate_job_notifications_v95_to_v96(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 95 {
return Err(Error::Store(
"Job notification migration requires schema 95".into(),
));
}
let foreign_keys: bool = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0))?;
let legacy_alter: bool = conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get(0))?;
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?;
let result = (|| -> Result<()> {
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
notification_routing::jobs::install(&tx)?;
let violations: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM pragma_foreign_key_check)",
[],
|r| r.get(0),
)?;
if violations {
return Err(Error::Store(
"Job notification migration foreign-key violation".into(),
));
}
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(96,'Backend Job common notification routing')",[])?;
tx.commit()?;
Ok(())
})();
let restore = conn
.execute_batch(&format!(
"PRAGMA legacy_alter_table={}; PRAGMA foreign_keys={};",
i32::from(legacy_alter),
i32::from(foreign_keys)
))
.map_err(Error::from);
result.and(restore)
}
fn migrate_business_notifications_v96_to_v97(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 96 {
return Err(Error::Store(
"Business notifications migration requires schema 96".into(),
));
}
// Older Workspace databases may not have opened the MR domain yet. Complete
// its owner-managed, idempotent migration before composing business triggers.
// A failed host migration leaves that independent domain upgrade intact.
merge_request::migrate(conn)?;
let tx = conn.unchecked_transaction()?;
notification_routing::install_business_events(&tx)?;
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(97,'MR notifications and authenticated implementation results')", [])?;
verify_workspace_resource_constraints(&tx)?;
tx.commit()?;
Ok(())
}
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
create_workspace_schema_structure(conn)?;
assignment_delivery::install(conn)?;
assignment_delivery::install_post_inputs(conn)?;
notification_routing::install(conn)?;
notification_routing::jobs::install(conn)?;
notification_routing::install_business_events(conn)?;
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
Error::Store(format!(
"fresh Workspace Ticket schema verification failed: {error}"
@@ -14282,6 +14548,12 @@ fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> {
}
}
}
if current_schema_version(conn)? >= 95 {
notification_routing::verify(conn)?;
}
if current_schema_version(conn)? >= 96 {
notification_routing::jobs::verify(conn)?;
}
Ok(())
}
@@ -15036,6 +15308,10 @@ fn table_columns(conn: &Connection, table_name: &str) -> Result<Vec<String>> {
mod tests {
include!("assignment_tracking_tests.rs");
include!("assignment_delivery_tests.rs");
include!("notification_routing_tests.rs");
include!("notification_diagnostics_tests.rs");
include!("job_notification_tests.rs");
include!("implementation_result_tests.rs");
include!("store_workspace_config_tests.rs");
fn drive_grant_fixture(store: &SqliteWorkspaceStore) -> server_api::DriveGrantResponse {
@@ -17248,6 +17524,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
WorkspaceSchemaMigrationStep {version:92, name:"Ticket-owned assignment tracking".into()},
WorkspaceSchemaMigrationStep {version:93, name:"Durable assignment deliveries and source-bound startup fences".into()},
WorkspaceSchemaMigrationStep {version:94, name:"Canonical Ticket posts".into()},
WorkspaceSchemaMigrationStep {version:95, name:"Common notification routing and durable Worker delivery".into()},
WorkspaceSchemaMigrationStep {version:96, name:"Backend Job common notification routing".into()},
WorkspaceSchemaMigrationStep {version:97, name:"MR notifications and authenticated implementation results".into()},
]
);
@@ -17352,6 +17631,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(92, "Ticket-owned assignment tracking".to_string()),
(93, "Durable assignment deliveries and source-bound startup fences".to_string()),
(94, "Canonical Ticket posts".to_string()),
(95, "Common notification routing and durable Worker delivery".to_string()),
(96, "Backend Job common notification routing".to_string()),
(97, "MR notifications and authenticated implementation results".to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -19954,11 +20236,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
.iter()
.map(|step| step.version)
.collect::<Vec<_>>(),
if version == 89 {
vec![90, 91, 92, 93, 94]
} else {
vec![91, 92, 93, 94]
}
((version + 1)..=LATEST_SCHEMA_VERSION).collect::<Vec<_>>()
);
drop(conn);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
@@ -19974,7 +20252,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(91, "Ticket open/closed cases and retained routing evidence".into()),
(92, "Ticket-owned assignment tracking".into()),
(93, "Durable assignment deliveries and source-bound startup fences".into()),
(94, "Canonical Ticket posts".into())]);
(94, "Canonical Ticket posts".into()),
(95, "Common notification routing and durable Worker delivery".into()),
(96, "Backend Job common notification routing".into()),
(97, "MR notifications and authenticated implementation results".into())]);
for (table, rows) in unchanged.iter().zip(before.iter()) {
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
}
@@ -13,9 +13,10 @@
//!
//! Results are checked for unknown fields, duplicates, count/string limits, and quotes that are
//! exact substrings of the checked title/body. Invalid results end as invalid_result.
//! Delivery rereads the Ticket and notifies the original Worker only of findings matching the
//! saved digest. No findings and stale results end delivery with distinct suppression reasons,
//! without redelivery on recovery. Advice is neither an approval gate nor implementation guidance;
//! Before routing, the adapter rereads the Ticket and reports findings/content freshness.
//! Common routing records clean/stale suppression separately from Runtime acceptance. Once
//! a request is pinned, recovery reuses those bytes even if the Ticket later changes.
//! Advice is neither an approval gate nor implementation guidance;
//! no findings do not prove the entire specification correct.
use std::collections::BTreeSet;
@@ -217,18 +218,18 @@ fn validate_finding_text(field: &str, value: &str, max_bytes: usize) -> Result<(
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TicketItemCheckNotification {
Deliver(String),
pub enum TicketItemCheckFacts {
Findings(String),
NoFindings,
Stale,
}
/// Decide whether a completed check should produce an advisory. Clean and stale
/// results remain distinct so delivery recovery can record why no input was sent.
pub fn notification(
/// Domain facts about validated results and the current checked content.
/// The common routing policy owns whether these facts produce an advisory.
pub fn result_facts(
acceptance: &BackendJobResultAcceptance,
current_ticket: &ticket::Ticket,
) -> Result<TicketItemCheckNotification> {
) -> Result<TicketItemCheckFacts> {
let input = parse_input(&acceptance.job.request.input)?;
let result = validate_result(
&acceptance.job.request.input,
@@ -239,12 +240,32 @@ pub fn notification(
if input.content_digest != content_digest(current_ticket)
|| input.ticket_id != current_ticket.meta.id
{
return Ok(TicketItemCheckNotification::Stale);
return Ok(TicketItemCheckFacts::Stale);
}
if result.findings.is_empty() {
return Ok(TicketItemCheckNotification::NoFindings);
return Ok(TicketItemCheckFacts::NoFindings);
}
Ok(TicketItemCheckFacts::Findings(render_result(acceptance)?))
}
/// Stable rendering from the original checked snapshot. Used to pin pre-upgrade
/// unacknowledged requests without reinterpreting them against newer Ticket text.
pub(crate) fn render_result(acceptance: &BackendJobResultAcceptance) -> Result<String> {
let input = parse_input(&acceptance.job.request.input)?;
let result = validate_result(
&acceptance.job.request.input,
acceptance
.job
.result
.as_ref()
.ok_or_else(|| Error::InvalidInput("completed checker has no result".into()))?,
)?;
if result.findings.is_empty() {
return Err(Error::InvalidInput(
"clean checker result has no advisory body".into(),
));
}
let mut content = format!(
"[Ticket item checker advisory]\nTicket: {}\nContent digest: {}\n\nThis is a post-save advisory based only on the inspected Ticket text. It is not a new user request, approval gate, workflow blocker, or implementation instruction.\n",
input.ticket_resource_key, input.content_digest
@@ -262,7 +283,7 @@ pub fn notification(
finding.suggestion
));
}
Ok(TicketItemCheckNotification::Deliver(content))
Ok(content)
}
#[cfg(test)]
@@ -61,7 +61,7 @@ fn ticket_posts_schema93_preserves_initial_body_creator_comments_and_archived_au
.iter()
.map(|step| step.version)
.collect::<Vec<_>>(),
[94]
(94..=LATEST_SCHEMA_VERSION).collect::<Vec<_>>()
);
let conn = Connection::open(&path).unwrap();
assert!(
@@ -76,14 +76,15 @@ fn ticket_posts_schema93_preserves_initial_body_creator_comments_and_archived_au
assert_eq!(SqliteWorkspaceStore::migrate_database(&path).unwrap(), plan);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 94);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert!(!column_exists(conn,"typed_tickets","body")?);
assert_eq!(stored_fixture_rows(conn,"typed_ticket_events"),old_events);
assert_eq!(stored_fixture_rows(conn,"typed_ticket_event_attributes"),old_attributes);
assert_eq!(stored_fixture_rows(conn,"typed_ticket_event_references"),old_references);
let markers = stored_fixture_rows(conn,"ticket_schema_migrations");
assert_eq!(&markers[..old_markers.len()],old_markers.as_slice());
assert_eq!(markers.len(),old_markers.len()+1);
assert_eq!(markers.len(),old_markers.len()+2);
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)?)))?;
assert_eq!(initial,("Current requirements after edits".into(),Some("creator".into()),Some("creator".into()),"comment".into(),"authored:-1".into()));
@@ -341,7 +342,7 @@ fn ticket_posts_oldest_supported_host50_preserves_content_through_full_chain() {
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
store
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 94);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
ticket::verify_sqlite_ticket_schema(conn).unwrap();
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r
@@ -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))?,12);
assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,13);
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)
)?,
12
13
);
assert_eq!(
stored_fixture_rows(conn, "typed_ticket_case_history"),
@@ -26,6 +26,9 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"flow_sources",
"memory_staging_records",
"memory_staging_resolutions",
"merge_request_source_observations",
"ticket_implementation_results",
"ticket_implementation_delegations",
"merge_request_review_grants",
"merge_request_reviewer_child_sessions",
"merge_request_thread_events",
@@ -45,6 +48,14 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"repository_ssh_host_trust_keys",
"repository_ssh_host_trusts",
"server_secret_objects",
"notification_job_targets",
"notification_job_legacy_receipts",
"notification_assignment_targets",
"notification_acknowledgements",
"notification_requests",
"notification_failures",
"notification_decisions",
"notification_sources",
"ticket_assignment_delivery_acknowledgements",
"ticket_assignment_delivery_requests",
"ticket_assignment_deliveries",
@@ -66,6 +77,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"typed_ticket_artifacts",
"typed_ticket_event_attributes",
"typed_ticket_event_references",
"typed_ticket_content_changes",
"typed_ticket_events",
"typed_ticket_labels",
"typed_ticket_orchestration_plans",
@@ -482,6 +494,18 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
// commit; update guards remain installed throughout.
let mut ticket_delete_guards = Vec::new();
for (table, trigger) in [
("notification_source_preparation_failures", "notification_source_preparation_failures_no_delete"),
("notification_job_targets", "notification_job_targets_no_delete"),
("notification_job_legacy_receipts", "notification_job_legacy_no_delete"),
("notification_assignment_targets", "notification_assignment_targets_no_delete"),
("merge_request_source_observations", "mr_source_observations_no_delete"),
("ticket_implementation_results", "implementation_results_no_delete"),
("ticket_implementation_delegations", "implementation_delegations_no_delete"),
("notification_sources", "notification_sources_no_delete"),
("notification_decisions", "notification_decisions_no_delete"),
("notification_requests", "notification_requests_no_delete"),
("notification_acknowledgements", "notification_acknowledgements_no_delete"),
("notification_failures", "notification_failures_no_delete"),
("ticket_worker_assignments", "typed_ticket_assignment_identity_no_delete"),
("ticket_worker_assignment_events", "typed_ticket_assignment_events_no_delete"),
("ticket_assignment_operations", "typed_ticket_assignment_receipt_no_delete"),
@@ -504,6 +528,11 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
tx.execute_batch(&format!("DROP TRIGGER \"{trigger}\";"))?;
ticket_delete_guards.push(definition);
}
// Source-owned children have no Workspace column; purge them through
// the authenticated parent's scope before removing the source graph.
for table in ["notification_source_preparation_failures", "notification_source_offers"] {
tx.execute(&format!("DELETE FROM {table} WHERE source_sequence IN (SELECT sequence FROM notification_sources WHERE workspace_id=?1)"),[&workspace_id])?;
}
for table in WORKSPACE_DELETION_PURGE_TABLES {
tx.execute(
&format!("DELETE FROM \"{table}\" WHERE workspace_id = ?1"),
@@ -1096,6 +1125,8 @@ mod tests {
serde_json::to_string(&snapshot).unwrap()
],
)?;
conn.execute("INSERT INTO notification_source_preparation_failures(source_sequence,code) SELECT sequence,'source_lookup_failed' FROM notification_sources WHERE workspace_id=?1 LIMIT 1",[workspace])?;
conn.execute("INSERT INTO notification_source_offers(source_sequence,last_offered) SELECT sequence,1 FROM notification_sources WHERE workspace_id=?1 LIMIT 1",[workspace])?;
Ok(())
})
.unwrap();
@@ -1106,6 +1137,8 @@ mod tests {
assert!(conn.execute(&format!("DELETE FROM {table} WHERE workspace_id=?1"), [workspace]).is_err());
assert!(conn.execute(&format!("UPDATE {table} SET ticket_id=ticket_id WHERE workspace_id=?1"), [workspace]).is_err());
}
assert!(conn.execute("DELETE FROM notification_source_preparation_failures WHERE source_sequence IN (SELECT sequence FROM notification_sources WHERE workspace_id=?1)",[workspace]).is_err());
assert_eq!(conn.query_row("SELECT COUNT(*) FROM notification_source_preparation_failures p JOIN notification_sources s ON s.sequence=p.source_sequence WHERE s.workspace_id=?1",[workspace],|r|r.get::<_,i64>(0))?,1);
assert!(conn.execute("DELETE FROM typed_tickets WHERE workspace_id=?1", [workspace]).is_err(),
"ordinary Ticket deletion must not cascade away case evidence");
Ok(())
+105
View File
@@ -0,0 +1,105 @@
# Notification routing and evidence
Business notifications are derived from committed source facts. A callback is only a
low-latency wakeup: startup and bounded periodic recovery consume the same persisted
sources, decisions and requests. A notification neither grants authority nor changes
Ticket case/handling, MR approval, Job execution, or Worker lifecycle.
## Connection inventory
| Committed origin | Capture / decision owner | Recipient and negative outcomes | Delivery / recovery / evidence |
| --- | --- | --- | --- |
| Ticket Assign, Answer, item/content edits | Ticket transaction triggers → `notification_routing` | Original delegation; explicit request vs attention. No recipient, released/history, closed/done, unsupported recipient and self-notification are recorded separately. | Assignment reservations, pinned requests, authorized targets and ACKs; common recovery; `assignment_delivery_tests.rs` and `server_assignment_delivery_tests.rs`. |
| Ticket comments/post edits, tags, case, tracking, release | Same capture and routing ledger | They do not become new Orchestrator requests. Applicable implementation-Worker attention is distinct from new delegation. | Same evidence; `notification_routing_tests.rs` covers positive and negative routes. Unknown source kinds defer explicitly. |
| MR review requested/result/revoked/cancelled/merge and observed source changes | MR transaction/source-observation triggers → common routing | Ticket delegation and implementation recipient captured at the event. MR remains the freshness/approval authority. | Stable MR event identity; current MR must be reread before action. Ordinary review comments are not inferred handoffs. `implementation_result_tests.rs` and `server_implementation_result_tests.rs` cover capture, self-suppression, recovery and public diagnostics. |
| Explicit implementation result/handoff/blocker | Authenticated `TicketImplementationResult` transaction | Implementation assignment and its pinned Orchestrator delegation, with exact-replay identity. No substitution of AssignmentAnswer or self-reported handling. | Result and source persist together; same common delivery/receipt path. No MR is required. |
| Backend Job accepted result | Result transaction → `notification_routing::jobs` | Fixed recipient, never replacement by the current Orchestrator. No target, checker no-findings and stale content suppress without ACK. Domain validation/rendering remains in the adapter. | Common request/ACK/failure ledger, fixed bytes/recipient, startup and periodic recovery; `job_notification_tests.rs` and `server_job_notification_tests.rs`. |
| Explicit WorkerNotify/WorkerSendInput | Authenticated caller | Explicit communication, not automatic business routing. | Existing Worker control boundary; not inferred from Ticket activity. |
| Internal SubWorker → parent / Web subscriptions | Parent-owned execution / UI synchronization | Separate ownership and authorization. | Not migrated into business-notification routing. |
The capture adapters and the central event-kind mapping must be updated together.
The tests intentionally exercise unknown kinds and missing source capture: neither
is represented as successful delivery or intentional suppression. Adding a producer
requires a positive persisted-receipt test and a negative/suppression test, not merely
an assertion that a send function ran. `notification_registration_tests.rs` checks
public MR event variants against the central mapping; comments are explicitly excluded.
MR source changes are captured when an existing Backend MR operation observes the
provider ref, not by an external Git-push listener. The initial source observation
is saved atomically with MR creation. `mr_open_retains_source_before_first_review_and_observed_result`
uses real Git publication and signed HTTP to cover the opening baseline, a later
source change before review, and result reporting against observation-only evidence.
Schema 96 migrates Job notifications; schema 97 installs MR/implementation-result
capture. Legacy Job ACKs and suppressions remain distinct. Ambiguous legacy
implementation ownership remains unknown; comments never synthesize a result.
Preparation failures remain retryable and visible after recovery; durable fair
scheduling prevents repeated failures from starving healthy or already-pinned work.
## Public diagnostic read
- HTTP: `GET /api/w/{workspace_id}/notification-diagnostics`
- Normal Tool: `NotificationDiagnostics`
- WIP: `notification_diagnostics` on the Ticket collection
- Shared DTOs: `crates/server-api/src/notification.rs`
Filters include Ticket, MR, Job, source event, assignment, delivery and exact source
identity. Pages contain at most 100 items. Continue with `next_cursor` and unchanged
filters. Cursors are validated against the Workspace and filters; they are not global
authority. A read uses a consistent database transaction and does not route, retry,
start/restore Workers or modify evidence.
`source_checks` reconciles requested origin references independently of pagination
and recipient filters. A present origin with no captured source is **unconnected**,
not suppressed. An MR-wide check reports whether *any* source was captured; it is not
proof of complete capture. Use the exact `mr-event:<mr-id>:<event-id>` reference to
check a particular event, including a later event after earlier successful capture.
Unsupported origin-reference forms report unknown presence, not absence.
The projection distinguishes awaiting routing, suppression, deferral, historical
recipients, pending/reserved/persisted requests, rejection, failures, uncertain
acceptance and durable Runtime acceptance. Decision/reason remain separate from
current delivery evidence and the last failure. Historical rows are not pending
work. `retry_candidate` is saved evidence, not authorization to send.
`acceptance_outcome_unknown` is conservative: a prepared request without a durable
ACK is sufficient, and does not prove a network attempt occurred. Later preflight
failure cannot disprove earlier acceptance. Multiple authorized assignment targets
without an ACK are reported as ambiguous rather than attributed to the original
Worker; an ACK identifies the accepted target. Old Job receipts are marked with
`legacy_evidence`, retaining the distinction between accepted and suppressed legacy
Completed records. No modern ACK is fabricated from suppression.
The response contains public references and bounded reason codes, not notification
body text, binding credentials, capabilities, or private session content. Runtime
acceptance is not model comprehension, successful handling, or permission to act;
`model_processing_confirmed` remains false without such evidence.
## Verification boundaries
Store fixtures use actual SQLite transactions, migrations, reopen, concurrent scans
and immutable records. HTTP tests use the authenticated production router. The
assignment and Job integration fixtures use the production Worker controller and
persistent session, substituting fixed model output and the deliberate response-loss
boundary. They verify request identity/bytes, one retained session notification,
restart recovery and agreement with public diagnostics. They do not establish real
model decision quality or unbounded exactly-once execution after Runtime receipt
retention expires.
Useful focused checks:
```sh
cargo test -p yoi-workspace-server --lib notification_diagnostics
cargo test -p yoi-workspace-server --lib job_notification
cargo test -p yoi-workspace-server --lib implementation_result
cargo test -p yoi-workspace-server --lib mr_result_recovery_and_handoff
cargo test -p yoi-workspace-server --lib notification_registration
cargo test -p yoi-workspace-server --lib assignment_real_worker_response_loss
cargo test -p worker --lib feature::builtin::ticket::notification_diagnostics
cargo test -p worker-runtime --lib worker_source::tests::
cargo test -p server-api
```
OpenAPI and the affected browser DTO artifacts are generated from the same public
contract. WIP and normal Tool paths share the typed operation and Profile permission
check; there is no separate WIP notification policy.
+806 -1
View File
@@ -5632,6 +5632,283 @@
],
"type": "object"
},
"NotificationDiagnostic": {
"additionalProperties": false,
"properties": {
"acceptance_outcome_unknown": {
"description": "Conservative: a persisted request without ACK is enough; this does not prove a send occurred.\nCan remain true alongside a later preflight failure, rejection or historical recipient.",
"type": "boolean"
},
"assignment_id": {
"type": [
"string",
"null"
]
},
"delivery_id": {
"type": [
"string",
"null"
]
},
"disposition": {
"type": [
"string",
"null"
]
},
"event_kind": {
"type": "string"
},
"job_id": {
"type": [
"string",
"null"
]
},
"last_failure_at": {
"type": [
"string",
"null"
]
},
"last_failure_code": {
"type": [
"string",
"null"
]
},
"legacy_evidence": {
"description": "A retained pre-cutover Job receipt contributes to this projection.",
"type": "boolean"
},
"merge_request_id": {
"type": [
"string",
"null"
]
},
"model_processing_confirmed": {
"description": "No model-processing evidence is currently collected by this read contract.",
"type": "boolean"
},
"purpose": {
"type": [
"string",
"null"
]
},
"reason": {
"type": [
"string",
"null"
]
},
"recipient_current": {
"description": "Current assignment identity only; null for fixed-worker or absent recipients.",
"type": [
"boolean",
"null"
]
},
"recipient_role": {
"type": "string"
},
"retry_candidate": {
"description": "Saved delivery candidate, NOT permission to send. Live recipient and binding checks remain required.",
"type": "boolean"
},
"source_event_id": {
"type": "string"
},
"source_id": {
"type": "string"
},
"stage": {
"$ref": "#/components/schemas/NotificationDiagnosticStage"
},
"target_resolution": {
"$ref": "#/components/schemas/NotificationTargetResolution",
"description": "Failure records do not identify a particular authorized replacement target."
},
"target_runtime_id": {
"type": [
"string",
"null"
]
},
"target_worker_id": {
"type": [
"string",
"null"
]
},
"ticket": {
"type": [
"string",
"null"
]
}
},
"required": [
"source_id",
"source_event_id",
"event_kind",
"recipient_role",
"stage",
"legacy_evidence",
"acceptance_outcome_unknown",
"target_resolution",
"retry_candidate",
"model_processing_confirmed"
],
"type": "object"
},
"NotificationDiagnosticStage": {
"enum": [
"historical",
"awaiting_routing",
"suppressed",
"deferred",
"pending",
"reserved",
"request_persisted",
"runtime_rejected",
"acceptance_unknown",
"delivery_failed",
"runtime_accepted"
],
"type": "string"
},
"NotificationDiagnosticsPage": {
"additionalProperties": false,
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/NotificationDiagnostic"
},
"type": "array"
},
"next_cursor": {
"type": [
"string",
"null"
]
},
"source_checks": {
"items": {
"$ref": "#/components/schemas/NotificationSourceCheck"
},
"type": "array"
}
},
"required": [
"items",
"source_checks"
],
"type": "object"
},
"NotificationDiagnosticsQuery": {
"additionalProperties": false,
"properties": {
"assignment_id": {
"type": [
"string",
"null"
]
},
"cursor": {
"type": [
"string",
"null"
]
},
"delivery_id": {
"type": [
"string",
"null"
]
},
"job_id": {
"type": [
"string",
"null"
]
},
"limit": {
"format": "uint",
"minimum": 0,
"type": [
"integer",
"null"
]
},
"merge_request_id": {
"type": [
"string",
"null"
]
},
"source_event_id": {
"type": [
"string",
"null"
]
},
"source_id": {
"description": "Exact source identity for a single-record lookup.",
"type": [
"string",
"null"
]
},
"ticket": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"NotificationSourceCheck": {
"additionalProperties": false,
"properties": {
"captured": {
"description": "Whether the origin has any captured source (independent of pagination/recipient filters).\nA present origin with false here is unconnected, not intentionally suppressed.",
"type": "boolean"
},
"origin_present": {
"description": "Whether an exact durable origin is present; null means that reference form was not verified.",
"type": [
"boolean",
"null"
]
},
"reference": {
"type": "string"
},
"resource_kind": {
"type": "string"
}
},
"required": [
"resource_kind",
"reference",
"captured"
],
"type": "object"
},
"NotificationTargetResolution": {
"enum": [
"unresolved",
"reserved",
"request_persisted",
"runtime_accepted",
"multiple_authorized_targets"
],
"type": "string"
},
"Nullable_string": {
"type": [
"string",
@@ -12422,6 +12699,76 @@
}
]
},
"TicketImplementationResultKind": {
"enum": [
"handoff",
"result",
"blocker"
],
"type": "string"
},
"TicketImplementationResultReceipt": {
"additionalProperties": false,
"properties": {
"created_at": {
"type": "string"
},
"request": {
"$ref": "#/components/schemas/TicketImplementationResultRequest"
},
"result_id": {
"type": "string"
},
"ticket_id": {
"type": "string"
}
},
"required": [
"result_id",
"ticket_id",
"request",
"created_at"
],
"type": "object"
},
"TicketImplementationResultRequest": {
"additionalProperties": false,
"properties": {
"assignment_id": {
"type": "string"
},
"body": {
"type": "string"
},
"kind": {
"$ref": "#/components/schemas/TicketImplementationResultKind"
},
"merge_request": {
"anyOf": [
{
"$ref": "#/components/schemas/MergeRequestReviewSubject"
},
{
"type": "null"
}
]
},
"operation_key": {
"type": "string"
},
"orchestrator_assignment_id": {
"type": "string"
}
},
"required": [
"operation_key",
"assignment_id",
"orchestrator_assignment_id",
"kind",
"body"
],
"type": "object"
},
"TicketIntakeSummary": {
"properties": {
"author": {
@@ -17235,7 +17582,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904"
"x-yoi-source-digest": "sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c"
},
"openapi": "3.1.0",
"paths": {
@@ -24539,6 +24886,103 @@
]
}
},
"/api/w/{workspace_id}/notification-diagnostics": {
"get": {
"operationId": "notification_diagnostics",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": true,
"in": "query",
"name": "query",
"required": true,
"schema": {
"$ref": "#/components/schemas/NotificationDiagnosticsQuery"
},
"style": "form"
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotificationDiagnosticsPage"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/objectives": {
"get": {
"operationId": "objective_list",
@@ -37933,6 +38377,133 @@
]
}
},
"/api/w/{workspace_id}/tickets/{ticket_id}/implementation-results": {
"post": {
"operationId": "ticket_implementation_result_browser",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "ticket_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketImplementationResultRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketImplementationResultReceipt"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/workers": {
"get": {
"operationId": "workspace_worker_list",
@@ -40794,6 +41365,240 @@
}
]
}
},
"/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results": {
"post": {
"operationId": "ticket_implementation_result",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "ticket_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketImplementationResultRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketImplementationResultReceipt"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/workspaces/{workspace_id}/tickets/{ticket_id}/implementation-results/{result_id}": {
"get": {
"operationId": "ticket_implementation_result_get",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "ticket_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "result_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketImplementationResultReceipt"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
}
}
}
+1 -1
View File
@@ -43,7 +43,7 @@
};
complete = {
instructions = "Call `ShowMergeRequest` and verify that authoritative approval still matches the exact current source ref, Ticket content digest, and complete linked result snapshot, keep that reviewed source ref immutable, leave concise implementation and validation evidence on the Ticket when useful, then hand off to the Orchestrator. Target-only movement does not invalidate this source approval; the Orchestrator refreshes readiness/integration evidence against the current target. Do not call `CompleteMergeRequest`, update the target selector, create an integration branch, or treat the Flow terminal state as Ticket completion authority. After durable handoff evidence exists, request a Flow transition.";
instructions = "Call `ShowMergeRequest` and verify that authoritative approval still matches the exact current source ref, Ticket content digest, and complete linked result snapshot, keep that reviewed source ref immutable, save concise implementation and validation evidence with `TicketImplementationResult` using your current implementation assignment, original Orchestrator delegation and stable operation_key. A saved MR approval and this typed handoff are separate facts; do not add a general comment or AssignmentAnswer solely for notification. Target-only movement does not invalidate this source approval; the Orchestrator refreshes readiness/integration evidence against the current target. Do not call `CompleteMergeRequest`, update the target selector, create an integration branch, or treat the Flow terminal state as Ticket completion authority. After the typed result receipt exists, request a Flow transition. Retry unknown outcomes only with the same key and full input.";
transitions = {
completed = {
target = "done";
+2 -2
View File
@@ -1,10 +1,10 @@
You are the assigned Coder. Implement the requested scope in the provided Workdir. Follow natural-language user intent and the requested return path rather than imposing a universal review, merge, or Ticket conclusion gate. The following Git/MR practices are the default code recipe unless the bounded request or return path says otherwise, not Backend prerequisites for every Ticket. The optional `builtin:coder-review` Flow guides that recipe and does not grant authority. A request for no review or return without conclusion must not silently become a review cycle or `TicketCaseUpdate` decision; independent review before merge still requires the trusted Reviewer path, and merge remains Orchestrator authority.
Return the implementation summary, changed files, validation, and limitations in conversation and optionally a Ticket comment or available Drive result when useful or requested. Ticket conclusion is a separate authorized decision based on actual satisfaction and user intent, not an automatic result of a commit, approval, or terminal Flow state. Ticket cases are open/closed; Orchestrator waiting/inprogress/done is its own assignment self-report, not your Worker state or MR evidence. Return bounded progress/results to the current delegation without claiming Orchestrator reporting authority. No ready/Queue step or mandatory terminal unassignment is part of code handoff.
Return the implementation summary, changed files, validation, and limitations in conversation and optionally a Ticket comment or available Drive result when useful or requested. Ticket conclusion is a separate authorized decision based on actual satisfaction and user intent, not an automatic result of a commit, approval, or terminal Flow state. Ticket cases are open/closed; Orchestrator waiting/inprogress/done is its own assignment self-report, not your Worker state or MR evidence. Use `TicketImplementationResult` for important handoffs, results and blockers to the original Orchestrator delegation. Read `ShowTicket` for your implementation assignment and `TicketAssignmentTrackingQuery` for the current delegation; supply both identities, a stable operation_key, kind and bounded body. Backend verifies your authenticated implementation identity; do not substitute `TicketAssignmentAnswer`, an Orchestrator self-report, or a comment. Retry response loss with exactly the same key and full input. The receipt is result evidence, not Ticket completion or handling done. MR/source references are optional and must identify retained MR evidence; MR-free research and documents use the same operation. No ready/Queue step or mandatory terminal unassignment is part of code handoff.
Use each existing repository-scoped Merge Request as the routine authority for its review requests, verdicts, fixes, and rereview cycles. A Ticket may have multiple Merge Requests, but at most one open Merge Request for a given repository. `OpenMergeRequest` creates that repository's selector-based Merge Request; if one already exists, use its explicit ID with `ShowMergeRequest`, keep its original selectors, and advance only that same source ref with a normal non-force push. Never invent an operation to append another source candidate, replacement Merge Request, or fresh integration branch. Source movement requires fresh review of the exact new ref. Target-only movement does not invalidate source approval and is handled later by Orchestrator integration authority.
Do not add a Ticket comment for each review or fix iteration. An optional Ticket comment can record a blocker or decision requiring Orchestrator attention or concise final implementation and validation evidence when useful or requested; an approved handoff is not mandatory for every task.
Do not add a Ticket comment for each review or fix iteration. Use the typed result operation for a blocker or final handoff; optional comments are discussion, never a notification substitute. An approved MR is not mandatory for a result.
Treat the first committed user message as the bounded Ticket/action context and do not infer control-plane identity from prose. When multiple Workdirs are attached, route every Workdir tool call by its stable alias. Every declared `read_write` target is an authorized implementation/publish/Merge Request repository, but writable targets are not automatically mandatory deliverables; `read_only` targets are reference material and must never be modified or used as a command target. Treat a Backend capability rejection as authority rather than attempting another path to write.
+1 -1
View File
@@ -4,7 +4,7 @@ You are the Ticket Orchestrator role.
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Read the Ticket and follow its natural-language intent and requested return path; do not impose implementation, review, merge, approval, or conclusion on every Ticket. Work may be investigation, analysis, planning, documentation, or code. Results may be returned in conversation, optionally recorded in a Ticket comment or available Drive surface, or published to a repository as requested. Complete, close, continue, or return without conclusion according to the request and actual result, using only authorized typed tools. Tool mechanisms enforce authority, not real judgment of whether requirements are satisfied.
Read the current Ticket and `TicketAssignmentTrackingQuery` before interpreting an input or deciding work, waiting, or return. Preserve the same assignment for answers during ongoing/waiting handling; only an explicit reissue after done creates a new assignment row. Record your self-reported handling, waiting reason, progress, next action, and result with the observed assignment/event CAS and exact-retry key. Returning research can leave the Ticket open and handling done. User-requested approval conditions govern whether to wait; generic close has no universal MR or human approval gate. Do not infer status from delivery, acceptance, Worker state, or MR evidence, and do not always unassign at done or close.
Read the current Ticket and `TicketAssignmentTrackingQuery` before interpreting an input or deciding work, waiting, or return. A `mr-event:<MR ID>:<event ID>` notification means reread that MR; a review, revocation, cancellation or merge notification is not current approval or integration authority. An `implementation-result:<result ID>` input identifies a separately saved handoff: use `TicketImplementationResultQuery` with that Ticket/result ID. Compare its original delegation with the current assignment before acting. Neither notification creates a new request, closes the case, reports handling done or stops a Worker. Legacy comments are not typed results; unknown ownership requires explicit confirmation, not inferred handoff. Preserve the same assignment for answers during ongoing/waiting handling; only an explicit reissue after done creates a new assignment row. Record your self-reported handling, waiting reason, progress, next action, and result with the observed assignment/event CAS and exact-retry key. Returning research can leave the Ticket open and handling done. User-requested approval conditions govern whether to wait; generic close has no universal MR or human approval gate. Do not infer status from delivery, acceptance, Worker state, or MR evidence, and do not always unassign at done or close.
Explicitly start bounded Ticket work with `SpawnTicketWorker`, the observed current `orchestrator_assignment_id`, an `initial_request`, and optional registered `profile`, `flow`, and alias-keyed `workdir_attachments`. Omitting the profile selects the general `builtin:ticket-worker`; no Flow or repository attachment is required. Assignment is independent from Profile: choose another registered Profile when its tools and instructions fit the task. Git implementation can select `builtin:coder` and the optional `builtin:coder-review` recipe. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Case `open` is not a launch request: the explicit guarded operation accepts initial input, assignment, and attachment finalization without changing the case state. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. Supply that same observed delegation identity for generic `WorkerSpawn` Ticket starts and existing-Worker assignment as well; stale, cleared or reissued delegations cannot admit new work. Dependencies remain diagnostics, not a blanket refusal, and are never implicitly started. After start, reread the Ticket and verify the new Worker assignment identity and authoritative unfinished-work acceptance, not retained `current_worker` alone before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope.
+1 -1
View File
@@ -1,6 +1,6 @@
You are the assigned Ticket Worker. Carry out the user's requested work and keep durable progress and completion evidence on the Ticket. Ticket work may be research, analysis, planning, documentation, or other non-code work; do not assume a repository, Workdir, Flow, git operation, Merge Request, or review cycle is required.
Read the current Ticket with `ShowTicket` and its current assignment context before acting. Ticket open/closed, Orchestrator self-reported waiting/inprogress/done, actual Worker state/work release, and MR evidence are independent. Report bounded results or blockers to the Orchestrator for its same-delegation tracking; being the assigned Worker does not authorize you to self-report as the Workspace Orchestrator. An answer during waiting continues that assignment, whereas an explicit reissue after done creates a new row without overwriting prior results. No ready/Queue stage or terminal-always-unassign rule applies. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it.
Read the current Ticket with `ShowTicket` and its current assignment context before acting. Ticket open/closed, Orchestrator self-reported waiting/inprogress/done, actual Worker state/work release, and MR evidence are independent. Report bounded results or blockers with `TicketImplementationResult`, using your observed implementation assignment, its original Orchestrator delegation and a stable operation_key. MR/source references are optional; research and documents use the same operation without a repository. Retry unknown outcomes only with the same key and full input. A saved result and its notification do not report handling done or conclude the Ticket. Being the assigned Worker does not authorize `TicketAssignmentAnswer` or self-reporting as the Workspace Orchestrator. An answer during waiting continues that assignment, whereas an explicit reissue after done creates a new row without overwriting prior results. No ready/Queue stage or terminal-always-unassign rule applies. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it.
Choose the requested return path rather than always concluding the Ticket. Return results in conversation, optionally use `TicketComment` for useful progress, results, blockers, or decisions, and optionally publish an available Drive document/artifact when useful or requested. No comment or artifact is universally required. Use `TicketCaseUpdate` only for a requested case judgment: reasoned close, reopen, or correction of the current closure. Choose `completed` only when actually fulfilled; `not_planned`, `duplicate`, `unknown`, or a documented custom reason do not claim fulfillment. The immutable history preserves prior actor, reason, explanation, and references. A request to return without conclusion must not become an automatic completion or closure decision. Use the current `content_digest` as `expected_content_digest` and expected case state plus nullable `closure_operation_key` from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. The Backend checks authority and CAS, not real judgment of natural-language satisfaction.
@@ -72,6 +72,49 @@ async function queryRequests(base: string) {
return requests.filter((row: { path: string }) => row.path === "/tickets/query");
}
Deno.test("Ticket parser failures stay local, retain Workspace navigation, and recover on retry", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
const wire = await detail(base, "T-1");
delete wire.initial_post;
let invalid = true;
await page.route("**/api/w/tickets/tickets/T-1", async (route) => {
if (invalid) await route.fulfill({ status: 200, json: wire });
else await route.continue();
});
await page.goto(`${base}/w/tickets/tickets/T-1`);
await page.getByRole("alert").filter({ hasText: "Ticket detail.initial_post must be an object" }).waitFor();
assertEquals(await page.getByText("The selected Workspace cannot be opened", { exact: true }).count(), 0);
assertEquals(await page.getByText("Workspace unavailable", { exact: true }).count(), 0);
assert(await page.getByRole("navigation", { name: "Current workspace location" }).isVisible());
invalid = false;
await page.getByRole("button", { name: "Retry", exact: true }).click();
await page.locator(".ticket-detail-header").waitFor();
assertEquals(await page.getByRole("alert").filter({ hasText: "initial_post must be an object" }).count(), 0);
await page.close();
});
});
Deno.test("Workspace HTTP failure retains its real status and recovers without recreating the Workspace", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
let unavailable = true;
await page.route("**/api/w/tickets/workspace", async (route) => {
if (unavailable) await route.fulfill({ status: 503, body: "private server failure" });
else await route.continue();
});
await page.goto(`${base}/w/tickets/tickets/T-1`);
await page.getByRole("heading", { name: "Workspace details could not be loaded" }).waitFor();
assert(await page.locator(".workspace-page [role=alert]").filter({ hasText: "HTTP 503" }).isVisible());
assertEquals(await page.getByText("The selected Workspace cannot be opened", { exact: true }).count(), 0);
assertEquals(await page.getByText("private server failure", { exact: true }).count(), 0);
unavailable = false;
await page.getByRole("button", { name: "Retry", exact: true }).click();
await page.locator(".ticket-detail-header").waitFor();
await page.close();
});
});
Deno.test("single Ticket query searches beyond loaded rows and preserves qualifiers on pagination", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
@@ -365,14 +408,20 @@ async function assertThreadLayout(page: Page) {
assertEquals(await page.locator(".ticket-delivery").count(), 0);
assertEquals(await page.getByText("Delivery and execution", { exact: true }).count(), 0);
assertEquals(await rail.locator(".ticket-thread-event[data-initial=true], .ticket-thread-event, .ticket-thread-composer").count(), 0);
for (const selector of [".ticket-orchestrator-worker", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resources"]) {
for (const selector of [".ticket-orchestrator-worker", ".ticket-worker-observation", ".ticket-worker-card", ".ticket-resource-editor"]) {
const details = rail.locator(selector);
assertEquals(await details.evaluate((element) => element.tagName), "DETAILS");
assertEquals(await details.getAttribute("open"), null);
assert(await details.locator(":scope > summary").isVisible());
}
assertEquals(await rail.locator(".ticket-worker-card > summary").textContent(), "Role assignments and manual Workers");
assert(/^Repository resources \(\d+\)$/.test((await rail.locator(".ticket-resources > summary").textContent())!));
assert(await rail.getByRole("heading", { name: "Repository resources", exact: true }).isVisible());
assert(await rail.locator(".ticket-resource-summary").isVisible());
assertEquals(await rail.locator(":scope > section").first().getAttribute("aria-label"), "Repository resources");
assert(await rail.locator(".ticket-resources").evaluate((element) =>
element.getBoundingClientRect().bottom <= element.parentElement!.querySelector(".ticket-handling")!.getBoundingClientRect().top
));
assertEquals(await rail.locator(".ticket-resource-editor > summary").textContent(), "Edit resources");
const evidence = rail.locator("details").filter({ has: page.locator("summary", { hasText: /^MR requirement evidence$/ }) });
if (await evidence.count()) assertEquals(await evidence.getAttribute("open"), null);
}
@@ -1047,6 +1096,43 @@ Deno.test("canonical thread renders long initial post and chronological multilin
});
});
Deno.test("Repository summary stays visible while edits remain explicit and refresh after saving", async () => {
await withFixture(async (browser, base) => {
const page = await browser.newPage();
const wire = await detail(base, "T-1");
wire.targets = [{ repository_key: "main", ref_selector: null, access: "read_only" }];
const writes: unknown[] = [];
await page.route("**/api/w/tickets/tickets/*", async (route) => {
const reference = decodeURIComponent(new URL(route.request().url()).pathname.split("/").at(-1)!);
if (reference !== "T-1" && reference !== wire.id) return await route.continue();
if (route.request().method() === "PATCH") {
const body = route.request().postDataJSON();
writes.push(body);
wire.targets = body.target.action === "clear" ? [] : body.target.targets;
}
await route.fulfill({ status: 200, json: wire });
});
await page.goto(`${base}/w/tickets/tickets/T-1`);
const resources = page.getByRole("region", { name: "Repository resources", exact: true });
await resources.getByText("Ref not specified", { exact: true }).waitFor();
const summary = resources.locator(".ticket-resource-editor > summary");
await summary.focus();
await summary.press("Enter");
await resources.getByLabel("Ref selector", { exact: true }).fill("feature/sidebar");
await resources.getByRole("combobox", { name: /^Access/ }).selectOption("read_write");
assertEquals(writes, []);
assertEquals(await resources.locator(".ticket-resource-summary code").textContent(), "Ref not specified");
await resources.getByRole("button", { name: "Save targets", exact: true }).click();
await resources.locator(".ticket-resource-summary code").filter({ hasText: "feature/sidebar" }).waitFor();
assertEquals(writes, [{ target: { action: "set", targets: [{ repository_key: "main", ref_selector: "feature/sidebar", access: "read_write" }] } }]);
assertEquals(await resources.locator(".ticket-resource-access").textContent(), "Read/write");
await resources.getByRole("button", { name: "Remove target", exact: true }).click();
await resources.getByRole("button", { name: "Save targets", exact: true }).click();
await resources.locator(".ticket-resource-summary").getByText("No repository targets.", { exact: true }).waitFor();
assertEquals(writes[1], { target: { action: "clear" } });
});
});
Deno.test("Ticket metadata stays in the rail with flat links, wrapping labels and keyboard navigation", async () => {
await withFixture(async (browser, base) => {
const output = artifactPath("metadata");
@@ -1059,6 +1145,10 @@ Deno.test("Ticket metadata stays in the rail with flat links, wrapping labels an
const body = await response.json();
if (body.resource_key === "T-1" && body.relations) {
body.tags[0].name = "Classification-with-a-long-name-".repeat(4);
body.targets = [
{ repository_key: "Repository-with-a-long-name-".repeat(4), ref_selector: "feature/long-selector-".repeat(6), access: "read_write" },
{ repository_key: "docs", ref_selector: null, access: "read_only" },
];
body.relations.outgoing = [{ at: "2026-01-01T00:00:00Z", author: "fixture-owner", kind: "related", target: "internal-T-2", target_resource_key: "T-2", ticket_id: body.id, note: "Related investigation with a long note. ".repeat(5) }];
}
await route.fulfill({ response, json: body });
@@ -1068,6 +1158,13 @@ Deno.test("Ticket metadata stays in the rail with flat links, wrapping labels an
const link = rail.getByRole("link", { name: /T-2/ });
await link.waitFor();
assertEquals(await link.getAttribute("href"), "/w/tickets/tickets/T-2");
const resources = rail.getByRole("region", { name: "Repository resources", exact: true });
assertEquals(await resources.locator(".ticket-resource-summary strong").allTextContents(), ["Repository-with-a-long-name-".repeat(4), "docs"]);
assertEquals(await resources.locator(".ticket-resource-summary code").allTextContents(), ["feature/long-selector-".repeat(6), "Ref not specified"]);
assertEquals(await resources.locator(".ticket-resource-access").allTextContents(), ["Read/write", "Read only"]);
assert(await resources.locator(".ticket-resource-summary").evaluate((element) => element.scrollWidth <= element.clientWidth));
assertEquals(await resources.locator(".ticket-resource-editor").getAttribute("open"), null);
await resources.screenshot({ path: `${output}/resources-${colorScheme}-${width}.png` });
assertEquals(await page.locator(".ticket-detail-main .ticket-tags, .ticket-detail-main .ticket-relations").count(), 0);
assertEquals(await page.evaluate("document.documentElement.scrollWidth > innerWidth"), false);
assert(await rail.locator(".ticket-tag-chip, .ticket-relations-list a").evaluateAll((elements) => elements.every((element) => {
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904)
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904)
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904)
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
+17 -1
View File
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904)
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -61,6 +61,16 @@ export type MergeStrategy = "fast_forward" | "merge";
export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array<string>; slug?: string | null; targets?: Array<TicketTarget>; title: string; workflow_state?: TicketState | null; };
export type NotificationDiagnostic = { acceptance_outcome_unknown: boolean; assignment_id?: string | null; delivery_id?: string | null; disposition?: string | null; event_kind: string; job_id?: string | null; last_failure_at?: string | null; last_failure_code?: string | null; legacy_evidence: boolean; merge_request_id?: string | null; model_processing_confirmed: boolean; purpose?: string | null; reason?: string | null; recipient_current?: boolean | null; recipient_role: string; retry_candidate: boolean; source_event_id: string; source_id: string; stage: NotificationDiagnosticStage; target_resolution: NotificationTargetResolution; target_runtime_id?: string | null; target_worker_id?: string | null; ticket?: string | null; };
export type NotificationDiagnosticStage = "historical" | "awaiting_routing" | "suppressed" | "deferred" | "pending" | "reserved" | "request_persisted" | "runtime_rejected" | "acceptance_unknown" | "delivery_failed" | "runtime_accepted";
export type NotificationDiagnosticsPage = { items: Array<NotificationDiagnostic>; next_cursor?: string | null; source_checks: Array<NotificationSourceCheck>; };
export type NotificationSourceCheck = { captured: boolean; origin_present?: boolean | null; reference: string; resource_kind: string; };
export type NotificationTargetResolution = "unresolved" | "reserved" | "request_persisted" | "runtime_accepted" | "multiple_authorized_targets";
export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array<ObjectiveEventDetail>; id: string; linked_ticket_summaries: Array<ObjectiveLinkedTicketSummary>; linked_tickets: Array<string>; record_source: string; resource_key: string; resources: Array<ObjectiveResourceSummary>; state: string; title: string; updated_at?: string | null; };
export type ObjectiveEventDetail = { body?: string | null; created_at: string; event_ref: string; kind: string; };
@@ -155,6 +165,12 @@ export type TicketEvidenceEvent = { at?: string | null; author?: string | null;
export type TicketEvidenceSummary = { approved_current_subject: boolean; complete_for_integration: boolean; has_commit: boolean; has_current_subject_ref: boolean; has_merge_request: boolean; has_review_request: boolean; missing: Array<string>; review_after_rescope: boolean; review_status?: string | null; unresolved_request_changes: boolean; };
export type TicketImplementationResultKind = "handoff" | "result" | "blocker";
export type TicketImplementationResultReceipt = { created_at: string; request: TicketImplementationResultRequest; result_id: string; ticket_id: string; };
export type TicketImplementationResultRequest = { assignment_id: string; body: string; kind: TicketImplementationResultKind; merge_request?: MergeRequestReviewSubject | null; operation_key: string; orchestrator_assignment_id: string; };
export type TicketIntakeSummary = { author?: string | null; body: MarkdownText; references: Array<TicketReference>; };
export type TicketLegacyWorkflowSnapshot = { queued_at?: string | null; queued_by?: string | null; resolution?: string | null; status: string; workflow_state: string; workflow_state_explicit: boolean; };
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904)
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -12,6 +12,8 @@ import {
export type ApiResult<T> = {
data: T | null;
error: string | null;
/** HTTP failure status, when a response was received; absent for local/transport failures. */
status?: number;
};
export type { SkillCatalogResponse, SkillDetailResponse };
@@ -19,6 +21,8 @@ export type { SkillCatalogResponse, SkillDetailResponse };
export type JsonLoadPolicy = {
diagnosticLabel: string;
maxResponseBytes: number;
/** Only explicitly trusted validator errors may expose bounded field diagnostics. */
describeValidationError?: (error: unknown) => string | undefined;
};
const SKILL_API_LOAD_POLICY: JsonLoadPolicy = {
@@ -96,6 +100,7 @@ export async function loadJson<T>(
parse: (value: unknown) => T = (value) => value as T,
policy?: JsonLoadPolicy,
): Promise<ApiResult<T>> {
let stage: "request" | "decode" | "validate" = "request";
try {
const response = await fetchFn(path, init);
if (!response.ok) {
@@ -103,6 +108,7 @@ export async function loadJson<T>(
await response.body?.cancel();
return {
data: null,
status: response.status,
error:
`${policy.diagnosticLabel} request failed with HTTP ${response.status}`,
};
@@ -110,19 +116,31 @@ export async function loadJson<T>(
const text = await response.text();
return {
data: null,
status: response.status,
error: text || `${path} request failed (${response.status})`,
};
}
stage = "decode";
const payload: unknown = policy
? await readBoundedJson(response, policy.maxResponseBytes)
: await response.json();
stage = "validate";
return { data: parse(payload), error: null };
} catch (error) {
if (policy) {
const validationDiagnostic = stage === "validate"
? policy.describeValidationError?.(error)
: undefined;
const diagnostic = error instanceof SkillApiContractError
? error.message
: validationDiagnostic !== undefined
? `${policy.diagnosticLabel}: ${validationDiagnostic}`
: error instanceof ResponseByteLimitError
? `${policy.diagnosticLabel} response exceeds its byte limit`
: stage === "request"
? `${policy.diagnosticLabel} request could not be completed`
: stage === "decode"
? `${policy.diagnosticLabel} response could not be decoded as JSON`
: `${policy.diagnosticLabel} response is invalid`;
return { data: null, error: diagnostic.slice(0, 256) };
}
@@ -2,8 +2,10 @@ declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void;
};
import { loadJson } from "./http.ts";
import { fixturePost } from "../home/dashboard.test-fixtures.ts";
import {
TICKET_BROWSER_API_LOAD_POLICY,
parseTicketEvent,
parseMergeRequestDetailResponse,
parseMergeRequestListResponse,
@@ -64,12 +66,37 @@ Deno.test("Ticket post parser rejects absent safety fields and unknown wire fiel
}
assertThrows(() => parseTicketEvent({ ...fixturePost("Post"), editable: "yes" }), "editable");
assertThrows(() => parseTicketEvent({ ...fixturePost("Post"), edited_by: 7 }), "edited_by");
assertThrows(() => parseTicketEvent({ ...fixturePost("Post"), surprise: true }), "unknown field surprise");
assertThrows(() => parseTicketEvent({ ...fixturePost("Post"), surprise: true }), "unknown field");
const detail: Record<string, unknown> = { ...ticketDetailFixture() };
delete detail.initial_post;
assertThrows(() => parseTicketDetail(detail), "initial_post");
});
Deno.test("Ticket load diagnostics retain the failed field without leaking response contents", async () => {
const secret = "PRIVATE_RESPONSE_TEXT";
const wire: Record<string, unknown> = { ...ticketDetailFixture(), body: secret };
delete wire.initial_post;
const load = (payload: unknown) => loadJson(
(async () => Response.json(payload)) as typeof fetch,
"/api/w/workspace-a/tickets/T-1",
undefined,
parseTicketDetail,
TICKET_BROWSER_API_LOAD_POLICY,
);
const missing = await load(wire);
assertEquals(missing.data, null);
assertEquals(missing.error, "Ticket Browser API: Ticket detail.initial_post must be an object");
assertEquals(missing.status, undefined);
const unknown = await load({ ...ticketDetailFixture(), [secret]: true });
assertEquals(unknown.error, "Ticket Browser API: Ticket detail contains unknown field");
const nested = ticketDetailFixture();
const malformed = await load({ ...nested, initial_post: { ...nested.initial_post, attributes: { [secret]: 5 } } });
assertEquals(malformed.error, "Ticket Browser API: Ticket detail.initial_post.attributes value must be a string");
for (const result of [missing, unknown, malformed]) {
assertEquals(result.error?.includes(secret), false);
}
});
Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () => {
const parsed = parseTicketListResponse({
workspace_id: "workspace-a",
@@ -105,7 +132,7 @@ Deno.test("Ticket Browser parser rejects unknown fields and out-of-range limits"
};
assertThrows(
() => parseTicketListResponse({ ...fixture, stale: true }),
"unknown field stale",
"unknown field",
);
assertThrows(
() => parseTicketListResponse({ ...fixture, limit: 1001 }),
@@ -296,7 +323,7 @@ Deno.test("Ticket Browser parser accepts target collections and rejects singular
repository_key: "main",
ref_selector: "develop",
}),
"unknown field repository_key",
"unknown field",
);
});
@@ -393,11 +420,11 @@ Deno.test("Merge Request summaries reject missing and malformed tagged source ob
{ status: "unavailable", code: "x".repeat(262_145) },
"code exceeds its length limit",
],
[{ status: "observed", code: "stale" }, "unknown field code"],
[{ status: "not_required", code: "stale" }, "unknown field code"],
[{ status: "observed", code: "stale" }, "unknown field"],
[{ status: "not_required", code: "stale" }, "unknown field"],
[
{ status: "unavailable", code: "missing", extra: true },
"unknown field extra",
"unknown field",
],
] as const
) {
@@ -529,7 +556,7 @@ Deno.test("Ticket Browser parser validates the canonical Merge Request thread",
...parsed,
thread: [{ ...parsed.thread[0], unexpected: true }],
}),
"unknown field unexpected",
"unknown field",
);
});
@@ -692,7 +719,7 @@ Deno.test("Ticket tag projections reject missing collections and malformed ident
"last_operation_key must be a string",
],
[{ ...tag, deleted: "false" }, "deleted must be a boolean"],
[{ ...tag, state: "closed" }, "unknown field state"],
[{ ...tag, state: "closed" }, "unknown field"],
] as const
) {
assertThrows(() => parse([value]), message);
@@ -54,10 +54,14 @@ import type {
WorkspaceTag,
} from "#lib/generated/ticket-api.ts";
export class TicketBrowserContractError extends Error {}
export const TICKET_BROWSER_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
export const TICKET_BROWSER_API_LOAD_POLICY = {
diagnosticLabel: "Ticket Browser API",
maxResponseBytes: TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
describeValidationError: (error: unknown) =>
error instanceof TicketBrowserContractError ? error.message : undefined,
} as const;
const MAX_STRING_LENGTH = 262_144;
@@ -66,7 +70,7 @@ const MAX_MAP_ENTRIES = 4_096;
function object(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
throw new TicketBrowserContractError(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
@@ -78,20 +82,20 @@ function exact(
): void {
const unexpected = Object.keys(value).find((key) => !allowed.includes(key));
if (unexpected !== undefined) {
throw new Error(`${label} contains unknown field ${unexpected}`);
throw new TicketBrowserContractError(`${label} contains unknown field`);
}
}
function string(value: unknown, label: string): string {
if (typeof value !== "string") throw new Error(`${label} must be a string`);
if (typeof value !== "string") throw new TicketBrowserContractError(`${label} must be a string`);
if (value.length > MAX_STRING_LENGTH) {
throw new Error(`${label} exceeds its length limit`);
throw new TicketBrowserContractError(`${label} exceeds its length limit`);
}
return value;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
if (typeof value !== "boolean") throw new TicketBrowserContractError(`${label} must be a boolean`);
return value;
}
@@ -105,7 +109,7 @@ function integer(
typeof value !== "number" || !Number.isSafeInteger(value) ||
value < minimum || value > maximum
) {
throw new Error(
throw new TicketBrowserContractError(
`${label} must be an integer from ${minimum} through ${maximum}`,
);
}
@@ -128,9 +132,9 @@ function array<T>(
label: string,
parse: (item: unknown, label: string) => T,
): T[] {
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
if (!Array.isArray(value)) throw new TicketBrowserContractError(`${label} must be an array`);
if (value.length > MAX_COLLECTION_LENGTH) {
throw new Error(`${label} exceeds its item limit`);
throw new TicketBrowserContractError(`${label} exceeds its item limit`);
}
return value.map((item, index) => parse(item, `${label}[${index}]`));
}
@@ -143,12 +147,12 @@ function stringMap(value: unknown, label: string): Record<string, string> {
const source = object(value, label);
const entries = Object.entries(source);
if (entries.length > MAX_MAP_ENTRIES) {
throw new Error(`${label} exceeds its entry limit`);
throw new TicketBrowserContractError(`${label} exceeds its entry limit`);
}
return Object.fromEntries(
entries.map(([key, item]) => [
string(key, `${label} key`),
string(item, `${label}.${key}`),
string(item, `${label} value`),
]),
);
}
@@ -199,7 +203,7 @@ function parseInvalidProjectRecord(
function parseTicketState(value: unknown, label: string): BrowserTicketState {
if (value !== "open" && value !== "closed") {
throw new Error(`${label} is invalid`);
throw new TicketBrowserContractError(`${label} is invalid`);
}
return value;
}
@@ -226,7 +230,7 @@ function parseCaseAction(value: unknown, label: string): TicketCaseAction {
return { action: "reopen" };
}
if (item.action !== "close" && item.action !== "correct_closure") {
throw new Error(`${label}.action is invalid`);
throw new TicketBrowserContractError(`${label}.action is invalid`);
}
exact(item, ["action", "reason"], label);
return {
@@ -351,7 +355,7 @@ export function parseWorkspaceTag(
], label);
const id = string(item.id, `${label}.id`);
if (id.trim().length === 0 || new TextEncoder().encode(id).length > 128) {
throw new Error(`${label}.id must be nonempty and at most 128 UTF-8 bytes`);
throw new TicketBrowserContractError(`${label}.id must be nonempty and at most 128 UTF-8 bytes`);
}
return {
workspace_id: string(item.workspace_id, `${label}.workspace_id`),
@@ -588,7 +592,7 @@ function parseTicketAssignmentPrincipal(
exact(item, ["agent_key", "kind"], label);
return { kind, agent_key: string(item.agent_key, `${label}.agent_key`) };
}
throw new Error(`${label}.kind is invalid`);
throw new TicketBrowserContractError(`${label}.kind is invalid`);
}
function parseTicketAssignmentSummary(
@@ -653,7 +657,7 @@ function parseTicketRoleAssignmentRecord(
], label);
const role = string(item.role, `${label}.role`);
if (!["orchestrator", "worker", "owner", "contributor"].includes(role)) {
throw new Error(`${label}.role is invalid`);
throw new TicketBrowserContractError(`${label}.role is invalid`);
}
return {
assigned_at: string(item.assigned_at, `${label}.assigned_at`),
@@ -681,7 +685,7 @@ export function parseAssignmentTracking(
);
const status = string(item.status, `${label}.status`);
if (status !== "waiting" && status !== "inprogress" && status !== "done") {
throw new Error(`${label}.status is invalid`);
throw new TicketBrowserContractError(`${label}.status is invalid`);
}
return {
status,
@@ -715,7 +719,7 @@ function parseAssignmentRelease(
], label);
const action = string(item.action, `${label}.action`);
if (action !== "unassigned" && action !== "replaced") {
throw new Error(`${label}.action is invalid`);
throw new TicketBrowserContractError(`${label}.action is invalid`);
}
return {
action,
@@ -839,7 +843,7 @@ function parseSourceRefObservation(
exact(item, ["status", "code"], label);
return { status, code: string(item.code, `${label}.code`) };
}
throw new Error(`${label}.status is invalid`);
throw new TicketBrowserContractError(`${label}.status is invalid`);
}
function parseTicketMergeRequestSummary(
@@ -1085,7 +1089,7 @@ function parseTicketTarget(value: unknown, label: string): TicketTarget {
exact(item, ["access", "ref_selector", "repository_key"], label);
const access = string(item.access, `${label}.access`);
if (access !== "read_only" && access !== "read_write") {
throw new Error(`${label}.access is invalid`);
throw new TicketBrowserContractError(`${label}.access is invalid`);
}
return {
access,
@@ -1234,7 +1238,7 @@ export function parseTicketRecordRef(value: unknown): TicketRef {
exact(item, ["id", "resource_key", "slug", "status"], label);
const status = string(item.status, `${label}.status`);
if (status !== "Open" && status !== "Closed") {
throw new Error(`${label}.status is invalid`);
throw new TicketBrowserContractError(`${label}.status is invalid`);
}
return {
id: string(item.id, `${label}.id`),
@@ -1502,7 +1506,7 @@ function parseReviewFinding(value: unknown, label: string): ReviewFinding {
exact(item, ["body", "code", "line", "path", "severity"], label);
const severity = string(item.severity, `${label}.severity`);
if (!["blocker", "major", "minor", "note"].includes(severity)) {
throw new Error(`${label}.severity is invalid`);
throw new TicketBrowserContractError(`${label}.severity is invalid`);
}
return {
body: string(item.body, `${label}.body`),
@@ -1576,7 +1580,7 @@ function parseMergeRequestThreadEvent(
], label);
const decision = string(item.decision, `${label}.decision`);
if (!["approve", "request_changes"].includes(decision)) {
throw new Error(`${label}.decision is invalid`);
throw new TicketBrowserContractError(`${label}.decision is invalid`);
}
return {
...eventBase,
@@ -1658,10 +1662,10 @@ function parseMergeRequestThreadEvent(
const strategy = string(item.strategy, `${label}.strategy`);
const resolution = string(item.resolution, `${label}.resolution`);
if (!["fast_forward", "merge"].includes(strategy)) {
throw new Error(`${label}.strategy is invalid`);
throw new TicketBrowserContractError(`${label}.strategy is invalid`);
}
if (!["none", "clean", "conflicts_resolved"].includes(resolution)) {
throw new Error(`${label}.resolution is invalid`);
throw new TicketBrowserContractError(`${label}.resolution is invalid`);
}
return {
...eventBase,
@@ -1688,7 +1692,7 @@ function parseMergeRequestThreadEvent(
),
};
}
throw new Error(`${label}.kind is invalid`);
throw new TicketBrowserContractError(`${label}.kind is invalid`);
}
function parseMergeRequestRef(
@@ -1743,7 +1747,7 @@ export function parseMergeRequestDetailResponse(
], label);
const state = string(item.state, `${label}.state`);
if (!["open", "merged", "closed"].includes(state)) {
throw new Error(`${label}.state is invalid`);
throw new TicketBrowserContractError(`${label}.state is invalid`);
}
return {
created_at: string(item.created_at, `${label}.created_at`),
@@ -13,6 +13,31 @@
let resolved = $state.raw<{ path: string; value: ResolvedPageData<T> } | null>(null);
let failure = $state.raw<{ path: string; value: DeferredFailure } | null>(null);
let loading = $state(true);
let renderFailure = $state.raw<{ path: string; error: unknown } | null>(null);
let recoveryVersion = $state(0);
let retrying = $state(false);
let retryFailure = $state<string | null>(null);
const errorMessage = (error: unknown) => {
// SvelteKit may transform render exceptions into a serialized App.Error.
if (error !== null && typeof error === 'object' && 'message' in error && typeof error.message === 'string') {
return error.message;
}
return String(error);
};
async function retry() {
if (retrying || loading) return;
const requestedPath = path;
retrying = true;
retryFailure = null;
try {
await invalidateAll();
} catch (error) {
if (path === requestedPath) retryFailure = errorMessage(error);
} finally {
retrying = false;
}
}
const path = $derived(identity ?? page.url.pathname);
const current = $derived(resolved?.path === path ? resolved.value : null);
const currentFailure = $derived(failure?.path === path ? failure.value : null);
@@ -23,9 +48,14 @@
let active = true;
loading = true;
failure = null;
retryFailure = null;
void resolvePageData(requestedData).then(async (result) => {
if (!active) return;
if (result.ok) {
// Never reset a failed boundary against stale data, including while Retry
// invalidation is pending. Remount only after this load has fresh data.
if (renderFailure?.path === requestedPath) recoveryVersion += 1;
renderFailure = null;
resolved = { path: requestedPath, value: result.data };
loading = false;
} else if (result.failure.redirect) {
@@ -51,7 +81,7 @@
</script>
<svelte:head>
{#if currentFailure}<title>{title} · Unavailable</title>
{#if currentFailure || renderFailure?.path === path}<title>{title} · Error</title>
{:else if !current}<title>{title} · Loading</title>{/if}
</svelte:head>
@@ -59,11 +89,26 @@
<section class="workspace-page deferred-page" aria-label={title}>
<h1>{title}</h1>
<p role="alert">{currentFailure.message}</p>
<button type="button" class="workspace-secondary-button" onclick={() => invalidateAll()}>Retry</button>
{#if retryFailure}<p role="alert">{retryFailure}</p>{/if}
<button type="button" class="workspace-secondary-button" disabled={retrying || loading} onclick={retry}>Retry</button>
</section>
{:else if current}
{#if loading}<p class="deferred-refresh" role="status">Updating…</p>{/if}
{#key path}{@render children(current)}{/key}
{#key path}
{#key recoveryVersion}
<svelte:boundary onerror={(error) => { renderFailure = { path, error }; }}>
{@render children(current)}
{#snippet failed(error)}
<section class="workspace-page deferred-page" aria-label={title}>
<h1>{title}</h1>
<p role="alert">{errorMessage(error)}</p>
{#if retryFailure}<p role="alert">{retryFailure}</p>{/if}
<button type="button" class="workspace-secondary-button" disabled={retrying || loading} onclick={retry}>Retry</button>
</section>
{/snippet}
</svelte:boundary>
{/key}
{/key}
{:else}
<section class="workspace-page deferred-page" aria-label={title} aria-busy="true">
<h1>{title}</h1>
@@ -0,0 +1,31 @@
<script lang="ts">
import DeferredPage from './DeferredPage.svelte';
import DeferredPageTestContent from './DeferredPageTestContent.svelte';
import type { DeferredResult } from './deferred.ts';
let { data, identity, onConstruct }: {
data: {
deferredWorkspace?: Promise<DeferredResult<{ workspace: string }>>;
deferredPage: Promise<DeferredResult<{ label: string; message?: string; serializedError?: boolean }>>;
};
identity: string;
onConstruct?: (label: string) => void;
} = $props();
</script>
<svelte:boundary>
<header>Valid Workspace</header>
<aside aria-label="Workspace sidebar"><a href="/w/team/tickets">Tickets</a></aside>
<main>
<DeferredPage {data} {identity} title="Ticket">
{#snippet children(content)}
<DeferredPageTestContent {content} {onConstruct} />
{/snippet}
</DeferredPage>
</main>
{#snippet failed()}
<h1>Workspace unavailable</h1>
<p>Workspace removed</p>
<button type="button">Create Workspace</button>
{/snippet}
</svelte:boundary>
@@ -0,0 +1,19 @@
<script lang="ts">
let { content, onConstruct }: {
content: { label: string; message?: string; serializedError?: boolean };
onConstruct?: (label: string) => void;
} = $props();
// Model PageContent's constructor check when an ApiResult has null data.
function construct() {
onConstruct?.(content.label);
if (content.message) {
if (content.serializedError) throw { message: content.message };
throw new Error(content.message);
}
}
construct();
</script>
<p>{content.label}</p>
<label>Draft<input aria-label="Draft" /></label>
@@ -1,8 +1,9 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import { createRawSnippet, tick } from "svelte";
import DeferredPage from "./DeferredPage.svelte";
import DeferredPageHarness from "./DeferredPageHarness.svelte";
import { type DeferredResult, settle } from "./deferred.ts";
const navigation = vi.hoisted(() => ({
@@ -19,6 +20,7 @@ afterEach(() => {
cleanup();
vi.clearAllMocks();
navigation.to = null;
navigation.invalidateAll.mockImplementation(async () => {});
});
function gate<T>() {
let resolve!: (value: T) => void;
@@ -141,3 +143,148 @@ test("old canonical redirect cannot overtake a navigation still loading its rout
await tick();
expect(navigation.goto).not.toHaveBeenCalled();
});
function expectWorkspacePreserved() {
expect(screen.getByText("Valid Workspace")).toBeTruthy();
expect(screen.getByRole("complementary", { name: "Workspace sidebar" })).toBeTruthy();
expect(screen.queryByText("Workspace unavailable")).toBeNull();
expect(screen.queryByText("Workspace removed")).toBeNull();
expect(screen.queryByRole("button", { name: "Create Workspace" })).toBeNull();
}
const renderError = 'Ticket lookup failed: <img src=x onerror="throw 1">';
const brokenData = () => ({ deferredPage: settle({ label: "Broken ticket", message: renderError }) });
test("child constructor error stays local, with the actual message escaped below the page title", async () => {
const view = render(DeferredPageHarness, { data: brokenData(), identity: "team:ticket-a" });
const alert = await view.findByRole("alert");
expect(view.getByRole("heading", { name: "Ticket" })).toBeTruthy();
expect(alert.textContent).toBe(renderError);
expect(alert.querySelector("img")).toBeNull();
expect(view.container.querySelector("main h1 + p")).toBe(alert);
expect(view.queryByLabelText("Draft")).toBeNull();
expectWorkspacePreserved();
});
test("serialized SvelteKit App.Error displays its message, not object coercion", async () => {
const view = render(DeferredPageHarness, {
data: { deferredPage: settle({ label: "Broken ticket", message: renderError, serializedError: true }) },
identity: "team:ticket-a",
});
const alert = await view.findByRole("alert");
expect(alert.textContent).toBe(renderError);
expect(alert.querySelector("img")).toBeNull();
expectWorkspacePreserved();
});
test("Retry waits for new successful deferred data before remounting a failed child", async () => {
const onConstruct = vi.fn();
const view = render(DeferredPageHarness, { data: brokenData(), identity: "team:ticket-a", onConstruct });
await view.findByRole("alert");
const invalidation = gate<void>();
navigation.invalidateAll.mockImplementationOnce(() => invalidation.promise);
await fireEvent.click(view.getByRole("button", { name: "Retry" }));
expect(navigation.invalidateAll).toHaveBeenCalledOnce();
expect(onConstruct).toHaveBeenCalledTimes(1);
expect((view.getByRole("button", { name: "Retry" }) as HTMLButtonElement).disabled).toBe(true);
const refresh = gate<DeferredResult<Content>>();
await view.rerender({ data: { deferredPage: refresh.promise }, identity: "team:ticket-a", onConstruct });
invalidation.resolve();
await tick();
expect(view.getByRole("alert").textContent).toBe(renderError);
expect(onConstruct).toHaveBeenCalledTimes(1);
expect((view.getByRole("button", { name: "Retry" }) as HTMLButtonElement).disabled).toBe(true);
expectWorkspacePreserved();
refresh.resolve({ ok: true, data: { label: "Recovered ticket" } });
await view.findByText("Recovered ticket");
expect(onConstruct).toHaveBeenCalledTimes(2);
expect(view.queryByRole("alert")).toBeNull();
expect(view.queryByRole("status")).toBeNull();
expect(view.getByLabelText("Draft")).toBeTruthy();
expectWorkspacePreserved();
});
test("invalidation without new data never resets the boundary against stale failed data", async () => {
const onConstruct = vi.fn();
const view = render(DeferredPageHarness, { data: brokenData(), identity: "team:ticket-a", onConstruct });
await view.findByRole("alert");
await fireEvent.click(view.getByRole("button", { name: "Retry" }));
await tick();
expect(onConstruct).toHaveBeenCalledTimes(1);
expect(view.getByRole("alert").textContent).toBe(renderError);
expectWorkspacePreserved();
});
test("failed replacement data remains local and another fresh result can recover", async () => {
const onConstruct = vi.fn();
const view = render(DeferredPageHarness, { data: brokenData(), identity: "team:ticket-a", onConstruct });
await view.findByRole("alert");
await view.rerender({
data: { deferredPage: settle({ label: "Still broken", message: "Ticket still missing" }) },
identity: "team:ticket-a", onConstruct,
});
await waitFor(() => expect(view.getByRole("alert").textContent).toBe("Ticket still missing"));
expect(onConstruct).toHaveBeenCalledTimes(2);
expectWorkspacePreserved();
await view.rerender({ data: { deferredPage: settle({ label: "Recovered" }) }, identity: "team:ticket-a", onConstruct });
await view.findByText("Recovered");
expect(view.queryByRole("alert")).toBeNull();
});
test("changing route identity clears a failed boundary and rejects late cross-workspace results", async () => {
const onConstruct = vi.fn();
const view = render(DeferredPageHarness, { data: brokenData(), identity: "team:ticket-a", onConstruct });
await view.findByRole("alert");
const stale = gate<DeferredResult<Content>>();
await view.rerender({ data: { deferredPage: stale.promise }, identity: "team:ticket-a", onConstruct });
const workspace = gate<DeferredResult<{ workspace: string }>>();
const destination = gate<DeferredResult<Content>>();
await view.rerender({
data: { deferredWorkspace: workspace.promise, deferredPage: destination.promise },
identity: "other-team:ticket-b", onConstruct,
});
expect(view.queryByRole("alert")).toBeNull();
expect(view.getByRole("status").textContent).toBe("Loading…");
expect(view.queryByLabelText("Draft")).toBeNull();
destination.resolve({ ok: true, data: { label: "Other Workspace ticket" } });
stale.resolve({ ok: true, data: { label: "Stale team ticket" } });
await tick();
expect(onConstruct).toHaveBeenCalledTimes(1);
expect(view.queryByText("Stale team ticket")).toBeNull();
workspace.resolve({ ok: true, data: { workspace: "other-team" } });
await view.findByText("Other Workspace ticket");
expect(onConstruct.mock.calls.map(([label]) => label)).toEqual(["Broken ticket", "Other Workspace ticket"]);
expect(view.queryByRole("alert")).toBeNull();
expectWorkspacePreserved();
});
test("a failed deferred Retry never reconstructs the old failed child", async () => {
const onConstruct = vi.fn();
const view = render(DeferredPageHarness, { data: brokenData(), identity: "team:ticket-a", onConstruct });
await view.findByRole("alert");
await view.rerender({
data: { deferredPage: Promise.resolve({ ok: false, failure: { message: "Ticket access denied", status: 403, redirect: null } }) },
identity: "team:ticket-a", onConstruct,
});
await waitFor(() => expect(view.getByRole("alert").textContent).toBe("Ticket access denied"));
expect(onConstruct).toHaveBeenCalledTimes(1);
expect(view.queryByLabelText("Draft")).toBeNull();
expectWorkspacePreserved();
await view.rerender({ data: { deferredPage: settle({ label: "Authorized ticket" }) }, identity: "team:ticket-a", onConstruct });
await view.findByText("Authorized ticket");
expect(onConstruct).toHaveBeenCalledTimes(2);
expect(view.queryByRole("alert")).toBeNull();
});
test("a failed Retry request is handled locally without losing the original render error", async () => {
const view = render(DeferredPageHarness, { data: brokenData(), identity: "team:ticket-a" });
await view.findByRole("alert");
navigation.invalidateAll.mockRejectedValueOnce(new Error("Reload failed"));
await fireEvent.click(view.getByRole("button", { name: "Retry" }));
await view.findByText("Reload failed");
expect(view.getByText(renderError)).toBeTruthy();
expect((view.getByRole("button", { name: "Retry" }) as HTMLButtonElement).disabled).toBe(false);
expectWorkspacePreserved();
});
@@ -344,14 +344,24 @@
}
.ticket-post-attribution { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2); min-width: 0; overflow-wrap: anywhere; }
.ticket-post-edited { color: var(--text-muted); font-size: var(--font-size-compact); }
.ticket-metadata dl { display: grid; gap: var(--space-1); margin: 0 0 var(--space-3); }
.ticket-metadata dt { color: var(--text-muted); font-size: var(--font-size-compact); }
.ticket-metadata dd { margin: 0 0 var(--space-2); overflow-wrap: anywhere; }
.ticket-resource-summary { display: grid; gap: var(--space-2); list-style: none; padding: 0; margin: 0 0 var(--space-2); }
.ticket-resource-summary li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-1) var(--space-2); align-items: baseline; }
.ticket-resource-summary code { grid-column: 1 / -1; overflow-wrap: anywhere; color: var(--text-muted); font-size: inherit; }
.ticket-resource-access { color: var(--text-muted); white-space: nowrap; }
.ticket-metadata dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--space-1) var(--space-3); margin: 0 0 var(--space-2); }
.ticket-metadata dt { color: var(--text-muted); }
.ticket-metadata dd { margin: 0; overflow-wrap: anywhere; }
.ticket-detail-page .ticket-control-rail { gap: var(--space-2); font-size: var(--font-size-compact); }
.ticket-control-rail :is(.ticket-section-heading, .ticket-control-card > header) { margin-bottom: var(--space-2); }
.ticket-control-rail :is(.ticket-section-heading h2, .ticket-control-card h2) { font-size: var(--font-size-compact); }
.ticket-control-rail :is(.workspace-primary-button, .workspace-secondary-button, .workspace-danger-button) { min-height: 2rem; padding: var(--space-1) var(--space-2); }
.ticket-control-rail .ticket-relations-list a { grid-template-columns: auto minmax(0, 1fr); gap: var(--space-1) var(--space-2); padding-block: var(--space-1); }
.ticket-relations-list small { grid-column: 1 / -1; }
.ticket-metadata .ticket-editor { margin-top: var(--space-3); }
.ticket-detail-page .ticket-control-rail summary {
cursor: pointer;
color: var(--text-strong);
font-size: var(--font-size-body);
font-size: var(--font-size-compact);
font-weight: 600;
}
.ticket-control-rail details[open] > summary { margin-bottom: var(--space-3); }
@@ -150,7 +150,6 @@
{#if ticket.state === "open" && current?.last_event_id && current.tracking && current.tracking.status !== "done"}
<form class="ticket-control-form ticket-answer" onsubmit={submitAnswer}>
<label>Answer / continue this handling<textarea bind:value={answer} rows="3" required disabled={busy || pending !== null}></textarea></label>
<p>Continues assignment <code>{current.assignment_id}</code>; not a new delegation. A stale assignment is rejected.</p>
<button class="workspace-secondary-button" disabled={busy || pending !== null || !answer.trim()}>Send answer to this assignment</button>
</form>
{/if}
@@ -60,7 +60,6 @@
</script>
<section class="ticket-detail-section ticket-tags">
<div class="ticket-section-heading"><h2>Classification tags</h2></div>
<p class="workspace-empty-copy">Classification only. Tag changes do not close or reopen this case.</p>
<div class="ticket-tag-chips">{#each ticket.tags as tag (tag.id)}<span class="ticket-tag-chip">{tag.name}</span>{:else}<span>No tags</span>{/each}</div>
{#if loading}<p role="status">Loading tag catalog…</p>{/if}
{#if error}<p class="workspace-callout is-error" role="alert">{error}</p>{/if}
@@ -1,23 +1,16 @@
<script lang="ts">
import { page } from "$app/state";
import "#lib/workspace/styles/workspace-catalog.css";
const workspaceId = $derived(page.params.workspaceId ?? "unknown");
import "#lib/workspace/styles/workspace-pages.css";
</script>
<svelte:head><title>Workspace unavailable · Yoi</title></svelte:head>
<svelte:head><title>Page error · Yoi</title></svelte:head>
<div class="workspace-catalog-shell">
<section class="workspace-empty-state">
<p class="workspace-catalog-eyebrow">Workspace unavailable</p>
<h1>The selected Workspace cannot be opened</h1>
<p>
<code>{workspaceId}</code> may have been removed, become inaccessible, or no longer exist on
this Backend. No state from a previously selected Workspace was retained.
</p>
<div class="workspace-switcher-actions">
<a href="/">Choose another Workspace</a>
<a href="/#workspace-create-title">Create Workspace</a>
</div>
</section>
</div>
<section class="workspace-page">
<h1>Unable to display this page</h1>
<p role="alert">{page.error?.message ?? "An unexpected error occurred."}</p>
<p>Error {page.status}</p>
<div class="workspace-toolbar">
<button type="button" class="workspace-secondary-button" onclick={() => window.location.reload()}>Reload page</button>
<a href="/">Home</a>
</div>
</section>
@@ -91,13 +91,13 @@
/>
<svelte:head>
{#if workspaceError}<title>Workspace · Unavailable</title>
{#if workspaceError}<title>Workspace · Load error</title>
{:else if !workspace}<title>Workspace · Loading</title>{/if}
</svelte:head>
{#if workspaceError}
<section class="workspace-page">
<h1>Workspace unavailable</h1>
<h1>Workspace details could not be loaded</h1>
<p role="alert">{workspaceError}</p>
<button type="button" class="workspace-secondary-button" onclick={() => invalidateAll()}>Retry</button>
</section>
@@ -25,9 +25,13 @@ async function loadWorkspace(fetch: Parameters<LayoutLoad>[0]["fetch"], workspac
const workspace = workspaceResult.data;
const workspaceError = workspaceResult.error;
if (!workspace) {
error(404, {
message: workspaceError ?? `Workspace ${workspaceId} is unavailable`,
});
const message = workspaceError ?? "Workspace details could not be loaded";
const status = workspaceResult.status;
if (status !== undefined && status >= 400 && status <= 599) {
error(status, message);
}
// Transport/decoding failures do not prove that the Workspace was removed.
throw new Error(message);
}
const repositories = repositoryResult.data;
@@ -1,4 +1,4 @@
import { redirect } from "@sveltejs/kit";
import { error, redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
@@ -36,6 +36,14 @@ export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0])
parseBrowserWorkspaceOrchestratorResponse,
),
]);
if (!ticket.data) {
const message = ticket.error ?? "Ticket details could not be loaded";
if (ticket.status !== undefined && ticket.status >= 400 && ticket.status <= 599) {
error(ticket.status, message);
}
// Expected read failures belong to the deferred loader, not component construction.
throw new Error(message);
}
if (ticket.data) {
const canonical = canonicalResourceReference(
ticket.data.resource_key,
@@ -524,23 +524,60 @@
<div class="ticket-column-divider"><BevelLine direction="y" length="100%" decorative /></div>
<aside class="ticket-control-rail" aria-label="Ticket details">
<div class="ticket-mobile-sidebar-divider"><BevelLine decorative /></div>
<section class="ticket-control-card ticket-metadata" aria-label="Ticket metadata">
<dl>
<dt>Created by</dt><dd>{ticket.created_by ?? "Unknown author"}</dd>
<dt>Created</dt><dd><time datetime={ticket.created_at ?? undefined}>{prettyDate(ticket.created_at)}</time></dd>
<dt>Updated</dt><dd><time datetime={ticket.updated_at ?? undefined}>{prettyDate(ticket.updated_at)}</time></dd>
</dl>
<button class="workspace-secondary-button" type="button" onclick={() => { editTitle = ticket.title; editing = !editing; }}>
{editing ? "Cancel title edit" : "Edit title"}
</button>
{#if editing}
<form class="ticket-editor" aria-label="Edit ticket title" onsubmit={saveEdit}>
<label>Title<input bind:value={editTitle} required disabled={busy !== null} /></label>
<button class="workspace-primary-button" type="submit" disabled={busy !== null || !editTitle.trim()}>
{busy === "edit" ? "Saving…" : "Save title"}
<section class="ticket-control-card ticket-resources" aria-label="Repository resources">
<div class="ticket-section-heading"><h2>Repository resources</h2><span>{ticket.targets.length}</span></div>
<ul class="ticket-resource-summary">
{#each ticket.targets as target}
<li>
<strong>{target.repository_key}</strong>
<span class="ticket-resource-access">{target.access === "read_write" ? "Read/write" : "Read only"}</span>
<code>{target.ref_selector ?? "Ref not specified"}</code>
</li>
{:else}
<li class="workspace-empty-copy">No repository targets.</li>
{/each}
</ul>
{#if data.repositories.error}<p class="workspace-callout is-error">Repository catalog unavailable: {data.repositories.error}</p>{/if}
<details class="ticket-resource-editor">
<summary>Edit resources</summary>
<p class="workspace-empty-copy">Target changes do not reconnect Workers or change live permissions.</p>
<form class="ticket-control-form" onsubmit={saveTarget}>
<div class="ticket-target-list">
{#each targetDrafts as target, index}
{@const selectedRepository = repositoryFor(target.repository_key)}
<fieldset class="ticket-target-row">
<legend>Target {index + 1}</legend>
<label>Repository
<select bind:value={target.repository_key} disabled={busy !== null} required>
<option value="">Choose repository</option>
{#each loadedRepositories?.items ?? [] as repository}
<option value={repository.repository_key}>{repository.repository_key}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or commit"} disabled={busy !== null} /></label>
<label>Access
<select bind:value={target.access} disabled={busy !== null}>
<option value="read_write">Read and write</option>
<option value="read_only">Read only</option>
</select>
</label>
<button class="workspace-secondary-button" type="button" onclick={() => removeTarget(index)}>Remove target</button>
</fieldset>
{:else}
<p class="workspace-empty-copy">No repository targets.</p>
{/each}
</div>
<button
class="workspace-secondary-button"
type="button"
onclick={() => addTarget()}
>Add target</button>
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !targetCandidateValid}>
{busy === "target" ? "Saving…" : "Save targets"}
</button>
</form>
{/if}
</details>
</section>
<BevelLine decorative />
{#each ticket.action_eligibility.blockers as blocker}<p class="workspace-callout">{blocker}</p>{/each}
@@ -602,6 +639,50 @@
</div>
</section>
{#if mergeRequests.length > 0}
<BevelLine decorative />
<details class="ticket-control-card">
<summary>MR requirement evidence</summary>
<p><strong>Current requirements:</strong> {ticket.evidence.complete_for_integration ? "satisfied for integration" : "not satisfied for integration"}.</p>
<p><strong>Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}.</p>
{#if ticket.evidence.missing.length > 0}
<ul>
{#each ticket.evidence.missing as missing}<li>{missing}</li>{/each}
</ul>
{/if}
<p class="workspace-empty-copy">This evaluates linked MR requirements, separately from the Ticket case judgment and persisted integration approval.</p>
{#if ticket.state === "closed"}
<p class="workspace-empty-copy">This case remains closed. Incomplete MR evidence does not alter the recorded case judgment.</p>
{/if}
</details>
{/if}
<BevelLine decorative />
<section class="ticket-control-card">
<header><h2>Merge Requests</h2></header>
{#if mergeRequests.length > 0}
{#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)}
<article class="ticket-control-card">
<p><strong>{mergeRequest.repository_key}: {mergeRequest.state}</strong></p>
<p>
From <code>{mergeRequest.selector_from ?? "requires repair"}</code>
to <code>{mergeRequest.selector_to}</code>
</p>
<p><strong>{mergeRequest.state === "merged" ? "Immutable integration evidence" : "Live source review"}:</strong> {summarySourceReviewStatus(mergeRequest)}</p>
{#if mergeRequest.integration_evidence_error !== null}
<p class="workspace-callout is-error"><strong>Integration evidence error:</strong> {mergeRequest.integration_evidence_error}</p>
{/if}
<p><strong>Target integration:</strong> {mergeRequest.state === "merged" ? "recorded" : `awaiting Orchestrator integration into ${mergeRequest.selector_to}`}.</p>
<a
class="workspace-secondary-button"
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
>Open Merge Request</a>
</article>
{/each}
{:else}
<p class="workspace-empty-copy">No Merge Requests linked.</p>
{/if}
</section>
<BevelLine decorative />
{#key JSON.stringify([data.workspaceId, ticket.id])}
<TicketWorkerObservation workspaceId={data.workspaceId} {ticket} />
@@ -609,7 +690,7 @@
<BevelLine decorative />
<details class="ticket-control-card ticket-worker-card">
<summary>Role assignments and manual Workers</summary>
<p class="workspace-empty-copy">Responsibility is retained after work ends and Worker removal; it does not indicate a running Worker or active work authority.</p>
<p class="workspace-empty-copy">Assignments are retained independently of Worker state.</p>
{#if ticket.assignments.length > 0}
<ul class="ticket-assignment-list">
{#each ticket.assignments as assignment}
@@ -633,11 +714,10 @@
{#if implementationStartEligible}
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket)}>Start a Ticket Worker</a>
<details><summary>Assign an existing Worker</summary>
<p class="workspace-empty-copy">Explicit assignment is separate from state. Backend validates the Worker and resource binding; a retained assignment is not execution authority.</p>
<form class="ticket-control-form" onsubmit={assignExistingWorker}>
<label>Runtime ID<input bind:value={manualRuntimeId} required /></label>
<label>Worker ID<input bind:value={manualWorkerId} required /></label>
<p class="workspace-empty-copy">Select only resources needed for this request. No selection binds no Workdirs; unrelated existing attachments are not inherited. Use authorized connection IDs for explicit rebind.</p>
<p class="workspace-empty-copy">Only selected Workdirs are bound. Rebinding requires authorized connection IDs.</p>
{#each manualBindings as binding, index}
<fieldset class="ticket-target-row"><legend>Resource binding {index + 1}</legend>
<label>Alias<input bind:value={binding.alias} required /></label>
@@ -682,95 +762,22 @@
</details>
<BevelLine decorative />
<details class="ticket-control-card ticket-resources">
<summary>Repository resources ({ticket.targets.length})</summary>
<p class="workspace-empty-copy">Targets describe permitted resources, not connected attachments. Editing targets or the body does not expand live permissions. Changed resources require explicit reconnection; stale or unavailable connections are rejected by Backend.</p>
{#if data.repositories.error}<p class="workspace-callout is-error">Repository catalog unavailable: {data.repositories.error}</p>{/if}
<form class="ticket-control-form" onsubmit={saveTarget}>
<div class="ticket-target-list">
{#each targetDrafts as target, index}
{@const selectedRepository = repositoryFor(target.repository_key)}
<fieldset class="ticket-target-row">
<legend>Target {index + 1}</legend>
<label>Repository
<select bind:value={target.repository_key} disabled={busy !== null} required>
<option value="">Choose repository</option>
{#each loadedRepositories?.items ?? [] as repository}
<option value={repository.repository_key}>{repository.repository_key}</option>
{/each}
</select>
</label>
<label>Ref selector<input bind:value={target.ref_selector} placeholder={selectedRepository?.default_selector ?? "branch, tag, or commit"} disabled={busy !== null} /></label>
<label>Access
<select bind:value={target.access} disabled={busy !== null}>
<option value="read_write">Read and write</option>
<option value="read_only">Read only</option>
</select>
</label>
<button class="workspace-secondary-button" type="button" onclick={() => removeTarget(index)}>Remove target</button>
</fieldset>
{:else}
<p class="workspace-empty-copy">No repository targets.</p>
{/each}
</div>
<button
class="workspace-secondary-button"
type="button"
onclick={() => addTarget()}
>Add target</button>
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !targetCandidateValid}>
{busy === "target" ? "Saving…" : "Save targets"}
</button>
</form>
</details>
{#if mergeRequests.length > 0}
<BevelLine decorative />
<details class="ticket-control-card">
<summary>MR requirement evidence</summary>
<p><strong>Current requirements:</strong> {ticket.evidence.complete_for_integration ? "satisfied for integration" : "not satisfied for integration"}.</p>
<p><strong>Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}.</p>
{#if ticket.evidence.missing.length > 0}
<ul>
{#each ticket.evidence.missing as missing}<li>{missing}</li>{/each}
</ul>
{/if}
<p class="workspace-empty-copy">This evaluates linked MR requirements, separately from the Ticket case judgment and persisted integration approval.</p>
{#if ticket.state === "closed"}
<p class="workspace-empty-copy">This case remains closed. Incomplete MR evidence does not alter the recorded case judgment.</p>
{/if}
</details>
{/if}
<BevelLine decorative />
<section class="ticket-control-card">
<header><h2>Merge Requests</h2></header>
<p class="workspace-empty-copy">Ticket closure is not MR approval or integration.</p>
{#if mergeRequests.length > 0}
{#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)}
<article class="ticket-control-card">
<p><strong>{mergeRequest.repository_key}: {mergeRequest.state}</strong></p>
<p>
From <code>{mergeRequest.selector_from ?? "requires repair"}</code>
to <code>{mergeRequest.selector_to}</code>
</p>
<p><strong>{mergeRequest.state === "merged" ? "Immutable integration evidence" : "Live source review"}:</strong> {summarySourceReviewStatus(mergeRequest)}</p>
{#if mergeRequest.integration_evidence_error !== null}
<p class="workspace-callout is-error"><strong>Integration evidence error:</strong> {mergeRequest.integration_evidence_error}</p>
{/if}
<p><strong>Target integration:</strong> {mergeRequest.state === "merged" ? "recorded" : `awaiting Orchestrator integration into ${mergeRequest.selector_to}`}.</p>
{#if mergeRequest.state === "merged"}
<p class="workspace-empty-copy">The merged source and integration approval are persisted evidence, not a live selector observation.</p>
{:else}
<p class="workspace-empty-copy">Target-only movement refreshes integration evidence; it does not invalidate approval for an unchanged source.</p>
{/if}
<a
class="workspace-secondary-button"
href={mergeRequestPagePath(data.workspaceId, mergeRequest.merge_request_id)}
>Open Merge Request</a>
</article>
{/each}
{:else}
<p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be closed with a comment or document result.</p>
<section class="ticket-control-card ticket-metadata" aria-label="Ticket metadata">
<dl>
<dt>Created by</dt><dd>{ticket.created_by ?? "Unknown author"}</dd>
<dt>Created</dt><dd><time datetime={ticket.created_at ?? undefined}>{prettyDate(ticket.created_at)}</time></dd>
<dt>Updated</dt><dd><time datetime={ticket.updated_at ?? undefined}>{prettyDate(ticket.updated_at)}</time></dd>
</dl>
<button class="workspace-secondary-button" type="button" onclick={() => { editTitle = ticket.title; editing = !editing; }}>
{editing ? "Cancel title edit" : "Edit title"}
</button>
{#if editing}
<form class="ticket-editor" aria-label="Edit ticket title" onsubmit={saveEdit}>
<label>Title<input bind:value={editTitle} required disabled={busy !== null} /></label>
<button class="workspace-primary-button" type="submit" disabled={busy !== null || !editTitle.trim()}>
{busy === "edit" ? "Saving…" : "Save title"}
</button>
</form>
{/if}
</section>
@@ -0,0 +1,58 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import WorkspaceError from "./+error.svelte";
import { load } from "./+layout.ts";
const state = vi.hoisted(() => ({
page: { params: { workspaceId: "workspace-a" }, status: 500, error: { message: "Ticket Browser API response is invalid" } },
}));
vi.mock("$app/state", () => state);
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
test("a child page error preserves its message and never claims Workspace deletion", async () => {
const reload = vi.spyOn(window.location, "reload").mockImplementation(() => {});
const view = render(WorkspaceError);
expect(view.getByRole("alert").textContent).toBe("Ticket Browser API response is invalid");
expect(view.getByText("Error 500")).toBeTruthy();
expect(view.queryByText("The selected Workspace cannot be opened")).toBeNull();
expect(view.queryByText("Workspace unavailable")).toBeNull();
expect(view.queryByText("Create Workspace")).toBeNull();
expect(view.queryByText("Choose another Workspace")).toBeNull();
await fireEvent.click(view.getByRole("button", { name: "Reload page" }));
expect(reload).toHaveBeenCalledOnce();
});
async function loadFailure(workspaceFetch: () => Promise<Response>) {
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
if (String(input).endsWith("/workspace")) return await workspaceFetch();
return new Response("repository lookup unavailable", { status: 503 });
});
return await load({ fetch: fetcher, params: { workspaceId: "workspace-a" } } as unknown as Parameters<typeof load>[0]).deferredWorkspace;
}
test.each([401, 403, 404, 500, 503])("Workspace HTTP %i is not rewritten as 404", async (status) => {
const result = await loadFailure(async () => new Response("private backend body", { status }));
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a failed Workspace load");
expect(result.failure.status).toBe(status);
expect(result.failure.message).toBe(`Workspace API request failed with HTTP ${status}`);
expect(result.failure.message).not.toContain("private backend body");
});
test.each(["network", "json", "contract"] as const)("Workspace %s failures do not imply a missing Workspace", async (kind) => {
const result = await loadFailure(async () => {
if (kind === "network") throw new TypeError("Failed to fetch");
return kind === "json" ? new Response("not json") : Response.json({});
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a failed Workspace load");
expect(result.failure.status).toBeNull();
expect(result.failure.message).toBe({
network: "Workspace API request could not be completed",
json: "Workspace API response could not be decoded as JSON",
contract: "Workspace API response is invalid",
}[kind]);
expect(result.failure.message).not.toContain("removed");
expect(result.failure.message).not.toContain("404");
});
@@ -63,11 +63,7 @@ Deno.test("ticket detail fences stale mutation responses", () => {
Deno.test("ticket responsibility display does not claim Worker liveness or work authority", () => {
assertStringIncludes(pageSource, "Role assignments and manual Workers");
assertStringIncludes(pageSource, "after work ends and Worker removal");
assertStringIncludes(
pageSource,
"does not indicate a running Worker or active work authority",
);
assertStringIncludes(pageSource, "Assignments are retained independently of Worker state.");
assertStringIncludes(pageSource, "assignment.principal.runtime_id");
assertStringIncludes(pageSource, "assignment.principal.worker_id");
assert(!pageSource.includes("No active role assignment."));