feat(ticket): expose case tags and independent assignment handling

This commit is contained in:
2026-10-12 07:33:11 +09:00
parent 56795fb92c
commit 9b0d42fdbc
79 changed files with 9326 additions and 713 deletions
+142
View File
@@ -362,6 +362,12 @@ impl_openapi_schema!(
TicketOrchestrationPlanRecord,
TicketOrchestrationPlanRecordList,
TicketOrchestrationPlanSearchRequest,
TicketAssignmentsQuery,
TicketAssignmentsPage,
TicketAssignmentAnswerRequest,
TicketAssignmentAnswerResponse,
TicketAssignmentDiagnosticsQuery,
TicketAssignmentDiagnosticsPage,
TicketQueryRequest,
TicketQueryResponse,
TicketQueueResponse,
@@ -3198,6 +3204,60 @@ pub trait ServerApi {
#[path] ticket_id: String,
#[body] request: TicketCaseUpdateRequest,
) -> Result<TicketSummary, RepositoryApiError>;
#[get("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_assignments_page(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] ticket_id: String,
#[query] query: TicketAssignmentsQuery,
) -> Result<TicketAssignmentsPage, RepositoryApiError>;
#[get("/api/w/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_assignments_page_browser(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] ticket_id: String,
#[query] query: TicketAssignmentsQuery,
) -> Result<TicketAssignmentsPage, 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,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] ticket_id: String,
#[query] query: TicketAssignmentDiagnosticsQuery,
) -> Result<TicketAssignmentDiagnosticsPage, RepositoryApiError>;
#[get("/api/w/{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_browser(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] ticket_id: String,
#[query] query: TicketAssignmentDiagnosticsQuery,
) -> Result<TicketAssignmentDiagnosticsPage, RepositoryApiError>;
#[post("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-answer", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_assignment_answer(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] ticket_id: String,
#[body] request: TicketAssignmentAnswerRequest,
) -> Result<TicketAssignmentAnswerResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{ticket_id}/assignment-answer", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_assignment_answer_browser(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] ticket_id: String,
#[body] request: TicketAssignmentAnswerRequest,
) -> Result<TicketAssignmentAnswerResponse, 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,
@@ -7604,6 +7664,21 @@ impl TicketAttention {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct TicketQueryRequest {
/// Exact reason of the current closed-case judgment; reopened/history-only reasons do not match.
#[cfg_attr(feature = "typescript", ts(type = "TicketClosureReason | null"))]
pub closure_reason: Option<ticket::TicketClosureReason>,
/// Exact current assigned principal; retained relationships do not match.
#[cfg_attr(
feature = "typescript",
ts(type = "TicketAssignmentPrincipalSnapshot | null")
)]
pub assigned_principal: Option<ticket::TicketAssignmentPrincipalSnapshot>,
/// Explicit self-reported handling only; never inferred from Worker state.
#[cfg_attr(
feature = "typescript",
ts(type = "TicketAssignmentTrackingStatus | null")
)]
pub handling_status: Option<ticket::TicketAssignmentTrackingStatus>,
pub query: Option<String>,
#[serde(default)]
#[cfg_attr(feature = "typescript", ts(type = "Array<BrowserTicketState>"))]
@@ -7860,6 +7935,73 @@ pub struct TicketAssignmentTrackingHistoryPage {
pub next_event_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketAssignmentsQuery {
pub after_assignment_id: Option<String>,
#[schemars(range(min = 1, max = 100))]
pub limit: Option<usize>,
}
transparent_ticket_dto!(TicketAssignmentsPage, ticket::TicketAssignmentsPage);
transparent_ticket_dto!(
TicketAssignmentAnswerRequest,
ticket::TicketAssignmentAnswer
);
transparent_ticket_dto!(
TicketAssignmentAnswerResponse,
ticket::TicketAssignmentAnswerReceipt
);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketAssignmentDiagnosticsQuery {
pub assignment_id: String,
pub after_input_event_id: Option<String>,
pub after_start_operation_id: Option<String>,
#[schemars(range(min = 1, max = 100))]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TicketAssignmentInputDeliveryState {
NotReserved,
Reserved,
RequestPersisted,
RuntimeAccepted,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
pub struct TicketAssignmentInputDiagnostic {
pub input_event_id: String,
pub delivery_id: Option<String>,
pub notification: TicketAssignmentInputDeliveryState,
/// Absence of a Runtime ack is not proof of failed external delivery.
pub acceptance_outcome_unknown: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
pub struct TicketAssignmentStartDiagnostic {
pub operation_id: String,
/// Durable start/assignment reservation result, not Worker liveness.
pub claim_state: String,
pub assignment_id: Option<String>,
pub runtime_id: Option<String>,
pub worker_id: Option<String>,
/// Existing records do not attest that a model processed a particular input.
pub actual_processing_unknown: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
pub struct TicketAssignmentDiagnosticsPage {
pub assignment_id: String,
pub current: bool,
pub input_origin_known: bool,
/// Noncurrent/done/closed/released records are history, not pending work.
pub actionable: bool,
pub inputs: Vec<TicketAssignmentInputDiagnostic>,
pub next_input_event_id: Option<String>,
/// Assignment-scoped admission evidence; not attributed to individual input.
pub starts: Vec<TicketAssignmentStartDiagnostic>,
pub next_start_operation_id: Option<String>,
}
transparent_ticket_dto!(WorkspaceTicketTagListRequest, ticket::TagListRequest);
transparent_ticket_dto!(
WorkspaceTicketTagMutationRequest,
@@ -500,6 +500,48 @@ fn validate_ticket_browser_operations(
request: Some("TicketCaseUpdateRequest"),
successes: &[("200", "TicketSummary")],
},
OperationContract {
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments",
method: "get",
operation_id: "ticket_assignments_page",
request: None,
successes: &[("200", "TicketAssignmentsPage")],
},
OperationContract {
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics",
method: "get",
operation_id: "ticket_assignment_diagnostics",
request: None,
successes: &[("200", "TicketAssignmentDiagnosticsPage")],
},
OperationContract {
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-answer",
method: "post",
operation_id: "ticket_assignment_answer",
request: Some("TicketAssignmentAnswer"),
successes: &[("200", "TicketAssignmentAnswerReceipt")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments",
method: "get",
operation_id: "ticket_assignments_page_browser",
request: None,
successes: &[("200", "TicketAssignmentsPage")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics",
method: "get",
operation_id: "ticket_assignment_diagnostics_browser",
request: None,
successes: &[("200", "TicketAssignmentDiagnosticsPage")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-answer",
method: "post",
operation_id: "ticket_assignment_answer_browser",
request: Some("TicketAssignmentAnswer"),
successes: &[("200", "TicketAssignmentAnswerReceipt")],
},
OperationContract {
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking",
method: "get",
+117
View File
@@ -0,0 +1,117 @@
//! Continuing input is authored against an observed delegation, never a new Assign.
use super::*;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketAssignmentAnswer {
pub assignment_id: String,
pub expected_last_event_id: String,
pub operation_key: String,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TicketAssignmentAnswerReceipt {
pub assignment_id: String,
pub input_event_id: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub event_index: u64,
}
/// Host owns the IMMEDIATE transaction and authenticates/fences its source before
/// calling, including on replay. Reuse Ticket's existing immutable operation
/// receipts with a tagged input; these are not handling events or another ledger.
pub fn sqlite_answer_ticket_assignment_in_transaction(
conn: &Connection,
workspace_id: &str,
ticket_id: &str,
answer: &TicketAssignmentAnswer,
source_context: &serde_json::Value,
actor: &str,
) -> Result<TicketAssignmentAnswerReceipt> {
for value in [
&answer.assignment_id,
&answer.expected_last_event_id,
&answer.operation_key,
actor,
] {
if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
return Err(TicketError::Conflict(
"Answer requires bounded nonempty identities".into(),
));
}
}
if answer.body.trim().is_empty() || answer.body.len() > 16384 || answer.body.contains('\0') {
return Err(TicketError::Conflict(
"Answer body must be nonempty, at most 16384 bytes and without NUL".into(),
));
}
let input =
serde_json::to_string(&serde_json::json!({"kind":"assignment_answer", "answer":answer}))
.map_err(sqlite_err)?;
let source = serde_json::to_string(
&serde_json::json!({"kind":"assignment_answer", "source":source_context, "actor":actor}),
)
.map_err(sqlite_err)?;
let receipt: Option<(String,String,String,String)> = conn.query_row(
"SELECT ticket_id,input_json,source_context_json,result_json FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key=?2",
params![workspace_id, answer.operation_key], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional().map_err(sqlite_err)?;
if let Some((saved_ticket, saved_input, saved_source, saved_result)) = receipt {
if saved_ticket != ticket_id || saved_input != input || saved_source != source {
return Err(TicketError::OperationFingerprintMismatch {
operation_key: answer.operation_key.clone(),
});
}
return serde_json::from_str(&saved_result).map_err(sqlite_err);
}
let current =
sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &answer.assignment_id)?
.ok_or_else(|| {
TicketError::Conflict("Answer assignment does not exist in this Ticket".into())
})?;
if !current.current
|| current.last_event_id.as_deref() != Some(&answer.expected_last_event_id)
|| !current.tracking.as_ref().is_some_and(|tracking| {
matches!(
tracking.status,
TicketAssignmentTrackingStatus::Waiting
| TicketAssignmentTrackingStatus::Inprogress
)
})
{
return Err(TicketError::Conflict("Answer observed assignment or handling event is stale; read current responsibility before authoring".into()));
}
let open: bool = conn.query_row("SELECT workflow_state='open' FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", params![workspace_id,ticket_id], |r| r.get(0)).map_err(sqlite_err)?;
if !open {
return Err(TicketError::Conflict(
"A closed Ticket does not admit continuing work input".into(),
));
}
let index: i64 = conn.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![workspace_id,ticket_id],|r| r.get(0)).map_err(sqlite_err)?;
let at = now_utc();
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,heading,body) VALUES(?1,?2,?3,'assignment_answer',?4,?5,'Assignment answer',?6)", params![workspace_id,ticket_id,index,actor,at,answer.body]).map_err(sqlite_err)?;
for (key, value) in [
("orchestrator_assignment_id", answer.assignment_id.as_str()),
(
"expected_last_event_id",
answer.expected_last_event_id.as_str(),
),
("operation_key", answer.operation_key.as_str()),
("authenticated_source", source.as_str()),
] {
conn.execute("INSERT INTO typed_ticket_event_attributes(workspace_id,ticket_id,event_index,key,value) VALUES(?1,?2,?3,?4,?5)",params![workspace_id,ticket_id,index,key,value]).map_err(sqlite_err)?;
}
conn.execute(
"UPDATE typed_tickets SET updated_at=?3 WHERE workspace_id=?1 AND ticket_id=?2",
params![workspace_id, ticket_id, at],
)
.map_err(sqlite_err)?;
let result = TicketAssignmentAnswerReceipt {
assignment_id: answer.assignment_id.clone(),
input_event_id: format!("ticket-event:{index}"),
event_index: index as u64,
};
let result_json = serde_json::to_string(&result).map_err(sqlite_err)?;
conn.execute("INSERT INTO typed_ticket_assignment_tracking_operations(workspace_id,operation_key,ticket_id,input_json,source_context_json,result_json) VALUES(?1,?2,?3,?4,?5,?6)",params![workspace_id,answer.operation_key,ticket_id,input,source,result_json]).map_err(sqlite_err)?;
Ok(result)
}
+50
View File
@@ -898,3 +898,53 @@ pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()>
#[cfg(test)]
#[path = "assignment_tracking_tests.rs"]
mod tests;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct TicketAssignmentsPage {
pub items: Vec<TicketAssignmentTrackingProjection>,
pub next_assignment_id: Option<String>,
}
/// Immutable append-order identities. The cursor must be an identity in this
/// Ticket/Workspace; timestamps and lexical IDs are not insertion order.
pub fn sqlite_ticket_assignments_page(
conn: &Connection,
workspace_id: &str,
ticket_id: &str,
after_assignment_id: Option<&str>,
limit: usize,
) -> Result<TicketAssignmentsPage> {
if !(1..=100).contains(&limit) {
return Err(TicketError::Conflict(
"Assignment page limit must be 1..100".into(),
));
}
let after: i64 = match after_assignment_id {
None => 0,
Some(id) => conn.query_row("SELECT rowid FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND assignment_id=?3",params![workspace_id,ticket_id,id],|r| r.get(0)).optional().map_err(sqlite_err)?
.ok_or_else(|| TicketError::Conflict("Assignment cursor does not belong to this Ticket".into()))?,
};
let mut stmt = conn.prepare("SELECT assignment_id FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND rowid>?3 ORDER BY rowid LIMIT ?4").map_err(sqlite_err)?;
let mut ids = stmt
.query_map(
params![workspace_id, ticket_id, after, (limit + 1) as i64],
|r| r.get::<_, String>(0),
)
.map_err(sqlite_err)?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(sqlite_err)?;
let has_more = ids.len() > limit;
ids.truncate(limit);
let next_assignment_id = if has_more { ids.last().cloned() } else { None };
let items = ids
.iter()
.map(|id| {
sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, id)?
.ok_or_else(|| TicketError::Conflict("Assignment disappeared while paging".into()))
})
.collect::<Result<Vec<_>>>()?;
Ok(TicketAssignmentsPage {
items,
next_assignment_id,
})
}
@@ -728,3 +728,104 @@ fn schema_verification_does_not_normalize_away_case_sensitive_authority_literals
.contains("differs from canonical schema")
);
}
#[test]
fn assignment_answer_commits_one_audited_input_and_exact_receipt_without_tracking_mutation() {
let conn = db();
assign(&conn, "a", "assigned-a");
let answer = TicketAssignmentAnswer {
assignment_id: "a".into(),
expected_last_event_id: "assigned-a".into(),
operation_key: "answer-a".into(),
body: "Continue with B".into(),
};
let source = serde_json::json!({"kind":"account","account_id":"account","user_id":"human"});
let apply = |answer: &TicketAssignmentAnswer| {
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
let result = sqlite_answer_ticket_assignment_in_transaction(
&conn, "w", "t", answer, &source, "human",
);
conn.execute_batch(if result.is_ok() { "COMMIT" } else { "ROLLBACK" })
.unwrap();
result
};
let result = apply(&answer).unwrap();
assert_eq!(result.input_event_id, "ticket-event:0");
assert_eq!(apply(&answer).unwrap(), result);
assert_eq!(
conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r
.get::<_, i64>(0))
.unwrap(),
1
);
assert_eq!(
conn.query_row("SELECT body FROM typed_ticket_events", [], |r| r
.get::<_, String>(0))
.unwrap(),
answer.body
);
assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE key='orchestrator_assignment_id'",[],|r| r.get::<_,String>(0)).unwrap(),"a");
assert_eq!(
sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
.unwrap()
.unwrap()
.last_event_id
.as_deref(),
Some("assigned-a")
);
let mut altered = answer.clone();
altered.body = "Changed".into();
assert!(apply(&altered).is_err());
assign(&conn, "b", "assigned-b");
assert_eq!(apply(&answer).unwrap(), result);
altered.operation_key = "fresh-stale".into();
assert!(apply(&altered).is_err());
}
#[test]
fn assignment_answer_rejects_unbounded_body_and_malformed_identities_without_appending_input() {
let conn = db();
assign(&conn, "a", "assigned-a");
let original = TicketAssignmentAnswer {
assignment_id: "a".into(),
expected_last_event_id: "assigned-a".into(),
operation_key: "answer-a".into(),
body: "Answer".into(),
};
for field in [
"body-large",
"body-nul",
"body-empty",
"identity-control",
"event-empty",
] {
let mut answer = original.clone();
match field {
"body-large" => answer.body = "a".repeat(16385),
"body-nul" => answer.body = "answer\0hidden".into(),
"body-empty" => answer.body = " ".into(),
"identity-control" => answer.operation_key = "key\n".into(),
_ => answer.expected_last_event_id = "".into(),
}
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
assert!(
sqlite_answer_ticket_assignment_in_transaction(
&conn,
"w",
"t",
&answer,
&serde_json::json!({"actor":"source"}),
"human"
)
.is_err(),
"{field}"
);
conn.execute_batch("ROLLBACK").unwrap();
}
assert_eq!(
conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r
.get::<_, i64>(0))
.unwrap(),
0
);
}
+2
View File
@@ -20,6 +20,8 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
mod assignment_answer;
pub use assignment_answer::*;
mod assignment_tracking;
pub mod config;
mod sqlite_schema;
+184
View File
@@ -18,6 +18,15 @@ use crate::auth::{
use crate::runtime::RuntimeWorkspaceScope;
use crate::worker_backend::WorkspacePromptProjectionCache;
fn ticket_assignment_role_segment(role: server_api::TicketAssignmentRole) -> &'static str {
match role {
server_api::TicketAssignmentRole::Orchestrator => "orchestrator",
server_api::TicketAssignmentRole::Worker => "worker",
server_api::TicketAssignmentRole::Owner => "owner",
server_api::TicketAssignmentRole::Contributor => "contributor",
}
}
pub const DEFAULT_WORKER_MUTATION_SOURCE_TTL_SECONDS: u64 = 60;
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -590,6 +599,62 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
runtime.block_on(async move {
match operation {
WorkspaceServerOperation::TicketAssignmentSet {
ticket_id,
role,
request,
} => {
let role = ticket_assignment_role_segment(role);
generated_workspace_response(
client
.ticket_assignment_set(
workspace_id,
ticket_id,
role.to_owned(),
request,
)
.await,
)
}
WorkspaceServerOperation::TicketAssignmentClear {
ticket_id,
role,
query,
} => {
let role = ticket_assignment_role_segment(role);
generated_workspace_response(
client
.ticket_assignment_clear(
workspace_id,
ticket_id,
role.to_owned(),
query,
)
.await,
)
}
WorkspaceServerOperation::TicketAssignmentsPage { ticket_id, query } => {
generated_workspace_response(
client
.ticket_assignments_page(workspace_id, ticket_id, query)
.await,
)
}
WorkspaceServerOperation::TicketAssignmentDiagnostics {
ticket_id,
query,
} => generated_workspace_response(
client
.ticket_assignment_diagnostics(workspace_id, ticket_id, query)
.await,
),
WorkspaceServerOperation::TicketAssignmentAnswer { ticket_id, request } => {
generated_workspace_response(
client
.ticket_assignment_answer(workspace_id, ticket_id, request)
.await,
)
}
WorkspaceServerOperation::WorkerControlList => {
generated_workspace_response(
client.worker_control_list(workspace_id).await,
@@ -882,6 +947,125 @@ mod tests {
(base_url, server)
}
#[test]
fn generated_ticket_assignment_transport_signs_exact_authoring_and_bounded_read_subjects() {
use crate::auth::{RuntimeRequestSourceExpectation, verify_runtime_request_source};
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let assignment_result =
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::TicketAssignmentSet {
ticket_id: "T-1".into(),
role: server_api::TicketAssignmentRole::Orchestrator,
request: server_api::SetTicketRoleAssignmentRequest {
operation_id: "assign-op".into(),
expected_assignment_id: Some("delegation".into()),
orchestrator_assignment_id: None,
principal: server_api::TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".into(),
},
workdir_bindings: vec![],
},
},
"PUT",
"/api/w/workspace-a/tickets/T-1/assignments/orchestrator",
assignment_result.clone(),
),
(
WorkspaceServerOperation::TicketAssignmentClear {
ticket_id: "T-1".into(),
role: server_api::TicketAssignmentRole::Orchestrator,
query: server_api::ClearTicketRoleAssignmentQuery {
operation_id: Some("clear-op".into()),
assignment_id: Some("delegation".into()),
},
},
"DELETE",
"/api/w/workspace-a/tickets/T-1/assignments/orchestrator?operation_id=clear-op&assignment_id=delegation",
assignment_result,
),
(
WorkspaceServerOperation::TicketAssignmentsPage {
ticket_id: "T-1".into(),
query: server_api::TicketAssignmentsQuery {
after_assignment_id: Some("old id".into()),
limit: Some(1),
},
},
"GET",
"/workspaces/workspace-a/tickets/T-1/assignment-tracking/assignments?after_assignment_id=old+id&limit=1",
serde_json::json!({"items":[],"next_assignment_id":null}),
),
(
WorkspaceServerOperation::TicketAssignmentDiagnostics {
ticket_id: "T-1".into(),
query: server_api::TicketAssignmentDiagnosticsQuery {
assignment_id: "delegation".into(),
after_input_event_id: Some("ticket-event:1".into()),
after_start_operation_id: None,
limit: Some(1),
},
},
"GET",
"/workspaces/workspace-a/tickets/T-1/assignment-diagnostics?assignment_id=delegation&after_input_event_id=ticket-event%3A1&limit=1",
serde_json::json!({"assignment_id":"delegation","current":true,"input_origin_known":true,"actionable":true,"inputs":[],"starts":[],"next_input_event_id":null,"next_start_operation_id":null}),
),
(
WorkspaceServerOperation::TicketAssignmentAnswer {
ticket_id: "T-1".into(),
request: answer.clone(),
},
"POST",
"/workspaces/workspace-a/tickets/T-1/assignment-answer",
serde_json::json!({"assignment_id":"delegation","input_event_id":"ticket-event:1","event_index":1}),
),
];
for (operation, method, path, reply) in cases {
let reply = reply.to_string();
let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", reply.len(), reply).into_bytes();
let (url, server) = binary_loopback(response);
let client =
RuntimeOwnedWorkspaceClient::new("workspace-a", url, "runtime-a", "worker-a")
.with_runtime_request_source(&identity, "server-a");
let result = client.execute_server_operation(operation).unwrap();
assert_eq!(result.status, 200);
let (head, body) = server.join().unwrap();
assert!(
head.starts_with(&format!("{method} {path} HTTP/1.1\r\n")),
"unexpected request: {head}"
);
let token = head
.lines()
.find_map(|line| {
let (key, value) = line.split_once(':')?;
key.eq_ignore_ascii_case(RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
.then(|| value.trim())
})
.unwrap();
let digest = request_body_digest(&body);
let expected = RuntimeRequestSourceExpectation {
identity_id: "runtime-a",
audience: "server-a",
workspace_id: "workspace-a",
worker_id: Some("worker-a"),
permission: WORKSPACE_REQUEST_PERMISSION,
method,
path,
body_digest: &digest,
now_unix: unix_now_seconds() as i64,
};
verify_runtime_request_source(token, &identity.public_key, &expected).unwrap();
if method == "POST" {
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
serde_json::to_value(&answer).unwrap()
);
}
}
}
#[test]
fn binary_roundtrip_signs_exact_query_method_worker_and_raw_body() {
use crate::auth::{RuntimeRequestSourceExpectation, verify_runtime_request_source};
+122 -31
View File
@@ -4,6 +4,7 @@
//! module binds an authority-scoped Workspace client, declares the built-in
//! feature, and contributes those tools through the normal registry path.
mod assignment_public;
mod assignment_tracking;
mod tags;
@@ -134,7 +135,14 @@ enum WorkspaceTicketSort {
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct WorkspaceQueryTicketInput {
/// Exact current closed-case reason; reopened/history-only reasons do not match.
closure_reason: Option<ticket::TicketClosureReason>,
/// Exact current assigned principal; retained relationships do not match.
assigned_principal: Option<ticket::TicketAssignmentPrincipalSnapshot>,
/// Explicit self-reported handling, never inferred from Worker/MR/delivery.
handling_status: Option<ticket::TicketAssignmentTrackingStatus>,
/// Full-text match over Ticket title, item body, and bounded thread excerpts.
query: Option<String>,
/// Exact case states (open or closed). Empty means every state.
@@ -444,6 +452,9 @@ fn workspace_ticket_decision_definition(
fn workspace_ticket_tool_description(name: &str) -> String {
match name {
"TicketCaseUpdate" => TICKET_CASE_UPDATE_DESCRIPTION.into(),
name if assignment_public::TOOL_NAMES.contains(&name) => {
assignment_public::description(name).into()
}
name if assignment_tracking::TOOL_NAMES.contains(&name) => {
assignment_tracking::description(name).into()
}
@@ -586,6 +597,17 @@ impl TicketFeatureAccess {
.copied()
.filter(|name| *name != "TicketAssignmentTrackingUpdate" || self.workflow),
)
.chain(
assignment_public::TOOL_NAMES
.iter()
.copied()
.filter(|name| {
assignment_public::READ_TOOL_NAMES.contains(name)
|| self.authoring
|| self.workflow
|| (*name == "TicketAssignmentAnswer" && self.thread)
}),
)
.collect()
}
@@ -597,7 +619,6 @@ impl TicketFeatureAccess {
|| (name == "TicketCaseUpdate" && (self.authoring || self.thread || self.workflow))
|| (self.authoring && AUTHORING_TOOL_NAMES.contains(&name))
|| (self.thread && THREAD_TOOL_NAMES.contains(&name))
|| (self.intake && INTAKE_TOOL_NAMES.contains(&name))
|| (self.workflow && WORKFLOW_ADDITIONAL_TOOL_NAMES.contains(&name))
}
}
@@ -608,13 +629,13 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &[
"WorkspaceTagList",
"TicketAssignmentTrackingQuery",
"TicketAssignmentTrackingHistory",
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
];
const AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate",
"TicketEditItem",
"TicketMarkReady",
"TicketQueue",
"TicketCaseUpdate",
"TicketRelationRecord",
"TicketRelationRemove",
@@ -622,8 +643,6 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
#[cfg(test)]
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate",
@@ -631,8 +650,6 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"QueryTicket",
"ShowTicket",
"TicketComment",
"TicketMarkReady",
"TicketQueue",
"TicketCaseUpdate",
"TicketRelationRecord",
"TicketRelationRemove",
@@ -643,6 +660,11 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketTagsSet",
"TicketAssignmentTrackingQuery",
"TicketAssignmentTrackingHistory",
"TicketOrchestratorAssign",
"TicketOrchestratorClear",
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketAssignmentAnswer",
];
#[cfg(test)]
@@ -664,6 +686,11 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
"TicketAssignmentTrackingQuery",
"TicketAssignmentTrackingHistory",
"TicketAssignmentTrackingUpdate",
"TicketOrchestratorAssign",
"TicketOrchestratorClear",
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketAssignmentAnswer",
];
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
@@ -1518,20 +1545,32 @@ fn native_ticket_operation(tool_name: &str) -> Option<NativeTicketOperation> {
identity_field: Some("ticket"),
mutating: true,
},
"TicketMarkReady" => NativeTicketOperation {
operation: "mark_ready",
"TicketOrchestratorAssign" => NativeTicketOperation {
operation: "orchestrator_assign",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: true,
},
"TicketIntakeReady" => NativeTicketOperation {
operation: "intake_ready",
"TicketOrchestratorClear" => NativeTicketOperation {
operation: "orchestrator_clear",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: true,
},
"TicketQueue" => NativeTicketOperation {
operation: "queue",
"TicketAssignmentsQuery" => NativeTicketOperation {
operation: "assignments_query",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: false,
},
"TicketAssignmentDiagnostics" => NativeTicketOperation {
operation: "assignment_diagnostics",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: false,
},
"TicketAssignmentAnswer" => NativeTicketOperation {
operation: "assignment_answer",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: true,
@@ -1652,6 +1691,17 @@ pub(crate) fn enabled_ticket_definitions(
definitions.push(assignment_tracking::definition(client.clone(), kind));
}
}
for kind in [
assignment_public::PublicAssignmentKind::Assign,
assignment_public::PublicAssignmentKind::Clear,
assignment_public::PublicAssignmentKind::Assignments,
assignment_public::PublicAssignmentKind::Diagnostics,
assignment_public::PublicAssignmentKind::Answer,
] {
if allowed.contains(&kind.name()) {
definitions.push(assignment_public::definition(client.clone(), kind));
}
}
definitions
}
@@ -2134,15 +2184,6 @@ fn record_affected_ticket_mutations(
{
affected.insert(target.to_string());
}
if let Some(queued) = response.get("queued_tickets").and_then(Value::as_array) {
affected.extend(
queued
.iter()
.filter_map(Value::as_str)
.filter(|reference| is_ticket_route_reference(reference))
.map(ToOwned::to_owned),
);
}
for reference in affected.difference(&subject_references) {
record_ticket_observation(observations, Some(reference), &Value::Null, true);
}
@@ -2966,7 +3007,7 @@ mod tests {
}
#[test]
fn related_ticket_mutations_invalidate_target_and_queue_observations() {
fn related_ticket_mutation_invalidates_inverse_target_but_not_unrelated_tickets() {
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
for ticket in ["T-42", "T-43", "T-44"] {
record_ticket_observation(
@@ -2983,22 +3024,21 @@ mod tests {
observations: Arc::clone(&observations),
};
let target_v1 = resolver.resolve("T-43").unwrap().object.validator;
let queued_v1 = resolver.resolve("T-44").unwrap().object.validator;
let unrelated_v1 = resolver.resolve("T-44").unwrap().object.validator;
record_affected_ticket_mutations(
&observations,
"T-42",
&json!({
"ticket": "T-42",
"target": "T-43",
"queued_tickets": ["T-42", "T-44"]
"target": "T-43"
}),
);
assert_ne!(
target_v1,
resolver.resolve("T-43").unwrap().object.validator
);
assert_ne!(
queued_v1,
assert_eq!(
unrelated_v1,
resolver.resolve("T-44").unwrap().object.validator
);
}
@@ -3168,7 +3208,7 @@ mod tests {
.collect::<Vec<_>>();
assert!(workspace_tools.contains(&"TicketCreate"));
assert!(workspace_tools.contains(&"TicketEditItem"));
assert!(workspace_tools.contains(&"TicketQueue"));
assert!(!workspace_tools.contains(&"TicketQueue"));
assert!(workspace_tools.contains(&"TicketCaseUpdate"));
let orchestration = workspace_feature(TicketFeatureAccess::workflow());
@@ -3205,6 +3245,57 @@ mod tests {
assert!(!review_tools.contains(&"TicketCaseUpdate"));
}
#[test]
fn normal_and_wip_catalogs_exclude_retired_ticket_workflow_operations() {
let client: Arc<dyn WorkspaceClient> = Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://unused"),
);
let retired = [
"TicketMarkReady",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
"CompleteTicket",
"TicketClose",
];
for access in [
TicketFeatureAccess::workspace_authoring(),
TicketFeatureAccess::intake(),
TicketFeatureAccess::workflow(),
TicketFeatureAccess::work_report(),
TicketFeatureAccess::read_only(),
TicketFeatureAccess {
authoring: true,
thread: true,
intake: true,
workflow: true,
},
] {
let normal = enabled_ticket_definitions(client.clone(), access);
let native = native_ticket_tools(client.clone(), access).unwrap();
for name in retired {
assert!(
normal.iter().all(|definition| definition().0.name != name),
"{name}"
);
assert!(native.iter().all(|tool| tool.name != name), "{name}");
assert!(native_ticket_operation(name).is_none(), "{name}");
}
if access.authoring || access.thread || access.workflow {
assert!(
normal
.iter()
.any(|definition| definition().0.name == "TicketCaseUpdate")
);
assert!(
native
.iter()
.any(|tool| tool.projection.operation == "case_update")
);
}
}
}
#[test]
fn read_only_installation_does_not_expose_mutating_tools() {
let mut pending_tools = Vec::new();
@@ -3248,8 +3339,8 @@ mod tests {
assert_eq!(installed, WORKSPACE_AUTHORING_TOOL_NAMES);
assert!(installed.iter().any(|tool| *tool == "TicketCreate"));
assert!(installed.iter().any(|tool| *tool == "TicketEditItem"));
assert!(installed.iter().any(|tool| *tool == "TicketQueue"));
assert!(installed.iter().any(|tool| *tool == "TicketMarkReady"));
assert!(!installed.iter().any(|tool| *tool == "TicketQueue"));
assert!(!installed.iter().any(|tool| *tool == "TicketMarkReady"));
assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady"));
assert!(installed.iter().any(|tool| *tool == "TicketCaseUpdate"));
assert!(
@@ -0,0 +1,340 @@
//! Public delegation operations over shared host-bound typed Workspace forwarding.
//! Source/actor authorization and assignment/receipt authority remain Backend-owned.
#[cfg(test)]
mod tests;
use super::*;
use crate::worker::WorkspaceServerOperation;
pub(super) const TOOL_NAMES: &[&str] = &[
"TicketOrchestratorAssign",
"TicketOrchestratorClear",
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketAssignmentAnswer",
];
pub(super) const READ_TOOL_NAMES: &[&str] =
&["TicketAssignmentsQuery", "TicketAssignmentDiagnostics"];
#[derive(Clone, Copy)]
pub(super) enum PublicAssignmentKind {
Assign,
Clear,
Assignments,
Diagnostics,
Answer,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct AssignInput {
ticket: String,
/// Target principal, not the authenticated actor. Backend validates role/principal pairing.
principal: server_api::TicketAssignmentPrincipal,
/// Observed current assignment ID, or explicitly null for an unassigned Ticket.
#[serde(deserialize_with = "observed_assignment")]
expected_assignment_id: Option<String>,
/// Stable identity of this Assign/reissue; exact retries keep the same full input.
operation_id: String,
}
fn observed_assignment<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
Option::<String>::deserialize(d)
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ClearInput {
ticket: String,
/// Observed exact current delegation to release, not Worker identity/status.
assignment_id: String,
operation_id: String,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct AssignmentsInput {
ticket: String,
#[serde(flatten)]
query: server_api::TicketAssignmentsQuery,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct DiagnosticsInput {
ticket: String,
#[serde(flatten)]
query: server_api::TicketAssignmentDiagnosticsQuery,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct AnswerInput {
ticket: String,
#[serde(flatten)]
request: server_api::TicketAssignmentAnswerRequest,
}
impl PublicAssignmentKind {
pub(super) fn name(self) -> &'static str {
match self {
Self::Assign => TOOL_NAMES[0],
Self::Clear => TOOL_NAMES[1],
Self::Assignments => TOOL_NAMES[2],
Self::Diagnostics => TOOL_NAMES[3],
Self::Answer => TOOL_NAMES[4],
}
}
fn schema(self) -> Value {
match self {
Self::Assign => {
let mut schema = json!(schemars::schema_for!(AssignInput));
let required = schema["required"]
.as_array_mut()
.expect("Assign object schema");
if !required
.iter()
.any(|field| field == "expected_assignment_id")
{
required.push(json!("expected_assignment_id"));
}
schema
}
Self::Clear => json!(schemars::schema_for!(ClearInput)),
Self::Assignments => json!(schemars::schema_for!(AssignmentsInput)),
Self::Diagnostics => json!(schemars::schema_for!(DiagnosticsInput)),
Self::Answer => json!(schemars::schema_for!(AnswerInput)),
}
}
}
pub(super) fn description(name: &str) -> &'static str {
match name {
"TicketOrchestratorAssign" => {
"Explicitly delegate or reissue this Ticket to a target Orchestrator principal using the existing role-assignment API. Query current assignment first; provide observed expected_assignment_id (explicit null only when unassigned), target principal and stable operation_id. Assign persists initial waiting without ready/Queue. Explicit reissue after done creates a new row even for the same principal and preserves the old result. Exact retry keeps the same operation_id and full input; never automatically generate a new key after conflict or unknown outcome. Reread and reconcile stale responsibility. Saved Assign and notification failure are separate outcomes, not a reason to reassign. Backend authenticates the actor and validates role/principal; target principal is not an authority claim. This does not start/stop Workers, close the case, or authorize review/merge."
}
"TicketOrchestratorClear" => {
"Release the observed current Orchestrator delegation using assignment_id and stable operation_id. Query first; exact retry retains the same full input/key, stale conflict requires rereading, and unknown outcome is not automatic success/failure or a new operation. Preserve last handling/result and release history; do not turn release into done, Worker stop, work transfer or resource cleanup. Done/closed do not require clear. Actor authority is authenticated by Backend, never supplied by the model."
}
"TicketAssignmentsQuery" => {
"Read a bounded page of retained Orchestrator assignment identities for this Ticket, role/principal, handling and independent release/replacement history. Use after_assignment_id from the returned next_assignment_id, limit 1..100. Historical done/results are not new work or execution authority. Use current tracking query before a new mutation; a replay receipt is not a fresh observation."
}
"TicketAssignmentDiagnostics" => {
"Read bounded sanitized existing delivery/acceptance/start evidence for an assignment_id, with independent after_input_event_id and after_start_operation_id cursors (limit 1..100). Saved delegation, notification persistence, Runtime acceptance and actual processing are distinct; absent acknowledgement may mean unknown outcome, not failed Assign. History/unknown are not pending work. No rendered content or binding credentials are exposed. Never infer or update self-reported handling from these diagnostics, Worker liveness or MR state."
}
"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."
}
_ => unreachable!("unknown public assignment tool"),
}
}
fn identity(name: &str, value: &str) -> Result<(), ToolError> {
if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
return Err(ToolError::InvalidArgument(format!(
"{name} must be nonempty, control-free and at most 1024 bytes"
)));
}
Ok(())
}
fn page(limit: Option<usize>) -> Result<usize, ToolError> {
let limit = limit.unwrap_or(100);
if !(1..=100).contains(&limit) {
return Err(ToolError::InvalidArgument(
"limit must be between 1 and 100".into(),
));
}
Ok(limit)
}
fn optional_identity(name: &str, value: &Option<String>) -> Result<(), ToolError> {
value
.as_deref()
.map(|value| identity(name, value))
.transpose()
.map(|_| ())
}
struct PublicAssignmentTool {
client: Arc<dyn WorkspaceClient>,
kind: PublicAssignmentKind,
}
#[async_trait]
impl Tool for PublicAssignmentTool {
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
self.client
.workspace_id()
.filter(|id| !id.trim().is_empty())
.ok_or_else(|| {
ToolError::InvalidArgument(
"Assignment tools require Backend Workspace identity; no local fallback".into(),
)
})?;
let parse = |error: serde_json::Error| ToolError::InvalidArgument(error.to_string());
let operation = match self.kind {
PublicAssignmentKind::Assign => {
let input: AssignInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
identity("operation_id", &input.operation_id)?;
optional_identity("expected_assignment_id", &input.expected_assignment_id)?;
match &input.principal {
server_api::TicketAssignmentPrincipal::User { account_id } => {
identity("account_id", account_id)?
}
server_api::TicketAssignmentPrincipal::Worker {
runtime_id,
worker_id,
} => {
identity("runtime_id", runtime_id)?;
identity("worker_id", worker_id)?;
}
server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
identity("agent_key", agent_key)?
}
}
WorkspaceServerOperation::TicketAssignmentSet {
ticket_id: input.ticket,
role: server_api::TicketAssignmentRole::Orchestrator,
request: server_api::SetTicketRoleAssignmentRequest {
orchestrator_assignment_id: None,
operation_id: input.operation_id,
principal: input.principal,
expected_assignment_id: input.expected_assignment_id,
workdir_bindings: vec![],
},
}
}
PublicAssignmentKind::Clear => {
let input: ClearInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
identity("assignment_id", &input.assignment_id)?;
identity("operation_id", &input.operation_id)?;
WorkspaceServerOperation::TicketAssignmentClear {
ticket_id: input.ticket,
role: server_api::TicketAssignmentRole::Orchestrator,
query: server_api::ClearTicketRoleAssignmentQuery {
operation_id: Some(input.operation_id),
assignment_id: Some(input.assignment_id),
},
}
}
PublicAssignmentKind::Assignments => {
let mut input: AssignmentsInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
optional_identity("after_assignment_id", &input.query.after_assignment_id)?;
input.query.limit = Some(page(input.query.limit)?);
WorkspaceServerOperation::TicketAssignmentsPage {
ticket_id: input.ticket,
query: input.query,
}
}
PublicAssignmentKind::Diagnostics => {
let mut input: DiagnosticsInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
identity("assignment_id", &input.query.assignment_id)?;
optional_identity("after_input_event_id", &input.query.after_input_event_id)?;
optional_identity(
"after_start_operation_id",
&input.query.after_start_operation_id,
)?;
input.query.limit = Some(page(input.query.limit)?);
WorkspaceServerOperation::TicketAssignmentDiagnostics {
ticket_id: input.ticket,
query: input.query,
}
}
PublicAssignmentKind::Answer => {
let input: AnswerInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
for (name, value) in [
("assignment_id", &input.request.0.assignment_id),
(
"expected_last_event_id",
&input.request.0.expected_last_event_id,
),
("operation_key", &input.request.0.operation_key),
] {
identity(name, value)?;
}
let body = &input.request.0.body;
if body.trim().is_empty() || body.len() > 16384 || body.contains('\0') {
return Err(ToolError::InvalidArgument(
"Answer body must be nonempty, at most 16384 bytes and without NUL".into(),
));
}
WorkspaceServerOperation::TicketAssignmentAnswer {
ticket_id: input.ticket,
request: input.request,
}
}
};
let response = self
.client
.execute_server_operation(operation)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Assignment request failed with HTTP status {}: {}",
response.status,
response.body.chars().take(1024).collect::<String>()
)));
}
if response.body.len() > 1024 * 1024 {
return Err(ToolError::ExecutionFailed(
"Assignment response exceeds 1 MiB; request a smaller page".into(),
));
}
// Decode the actual public DTO and serialize only its declared fields; no raw
// backend payload/credentials or alternative result authority reaches the model.
let decode = |error: serde_json::Error| {
ToolError::ExecutionFailed(format!(
"Invalid Backend assignment response (outcome unknown after dispatch): {error}"
))
};
let content = match self.kind {
PublicAssignmentKind::Assign | PublicAssignmentKind::Clear => serde_json::to_value(
serde_json::from_str::<server_api::TicketRoleAssignmentMutationResponse>(
&response.body,
)
.map_err(decode)?,
),
PublicAssignmentKind::Assignments => {
let result: server_api::TicketAssignmentsPage =
serde_json::from_str(&response.body).map_err(decode)?;
if result.0.items.len() > 100 {
return Err(ToolError::ExecutionFailed(
"Assignment page exceeds 100 items".into(),
));
}
serde_json::to_value(result)
}
PublicAssignmentKind::Diagnostics => {
let result: server_api::TicketAssignmentDiagnosticsPage =
serde_json::from_str(&response.body).map_err(decode)?;
if result.inputs.len() > 100 || result.starts.len() > 100 {
return Err(ToolError::ExecutionFailed(
"Diagnostic page exceeds 100 items".into(),
));
}
serde_json::to_value(result)
}
PublicAssignmentKind::Answer => serde_json::to_value(
serde_json::from_str::<server_api::TicketAssignmentAnswerResponse>(&response.body)
.map_err(decode)?,
),
}
.map_err(|error| ToolError::Internal(error.to_string()))?;
Ok(ToolOutput {
summary: self.kind.name().into(),
content: Some(content.to_string()),
attachments: vec![],
})
}
}
pub(super) fn definition(
client: Arc<dyn WorkspaceClient>,
kind: PublicAssignmentKind,
) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new(kind.name())
.description(description(kind.name()))
.input_schema(kind.schema()),
Arc::new(PublicAssignmentTool {
client: client.clone(),
kind,
}) as Arc<dyn Tool>,
)
})
}
@@ -0,0 +1,598 @@
use super::*;
use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
#[derive(Debug)]
struct Transport {
requests: Mutex<Vec<WorkspaceRequest>>,
response: Result<WorkspaceResponse, String>,
available: bool,
}
impl WorkspaceClient for Transport {
fn workspace_id(&self) -> Option<&str> {
self.available.then_some("workspace")
}
fn kind(&self) -> &str {
"assignment-test"
}
fn is_available(&self) -> bool {
self.available
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
self.response.clone().map_err(WorkspaceClientError::Request)
}
}
fn transport(status: u16, body: Value) -> Arc<Transport> {
Arc::new(Transport {
requests: Mutex::new(vec![]),
available: true,
response: Ok(WorkspaceResponse {
status,
body: body.to_string(),
}),
})
}
fn cases() -> Vec<(PublicAssignmentKind, Value, Value)> {
vec![
(
PublicAssignmentKind::Assign,
json!({"ticket":"T-743", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}, "expected_assignment_id":null, "operation_id":"assign-1"}),
json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}),
),
(
PublicAssignmentKind::Clear,
json!({"ticket":"T-743", "assignment_id":"delegation", "operation_id":"clear-1"}),
json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}),
),
(
PublicAssignmentKind::Assignments,
json!({"ticket":"T-743", "after_assignment_id":"old/delegation?", "limit":10}),
json!({"items":[], "next_assignment_id":null}),
),
(
PublicAssignmentKind::Diagnostics,
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::Answer,
json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"waiting", "operation_key":"answer-1", "body":"Proceed with option A"}),
json!({"assignment_id":"delegation", "input_event_id":"ticket-event:8", "event_index":8}),
),
]
}
#[tokio::test]
async fn public_assignment_schemas_require_observations_and_exclude_model_actor_claims() {
for (kind, input, response) in cases() {
let client = transport(200, response);
let (meta, tool) = definition(client.clone(), kind)();
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
assert!(
schema.is_valid(&input),
"{}: {}",
meta.name,
meta.input_schema
);
for field in meta.input_schema["required"]
.as_array()
.into_iter()
.flatten()
{
let mut missing = input.clone();
missing
.as_object_mut()
.unwrap()
.remove(field.as_str().unwrap());
assert!(!schema.is_valid(&missing), "{} missing {field}", meta.name);
assert!(matches!(
tool.execute(&missing.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
for actor_claim in [
"actor",
"author",
"role",
"runtime_id",
"worker_id",
"authentication_binding_id",
] {
let mut forged = input.clone();
forged[actor_claim] = json!("authority");
assert!(
!schema.is_valid(&forged),
"{} accepts {actor_claim}",
meta.name
);
assert!(matches!(
tool.execute(&forged.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
assert!(client.requests.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn assign_reissue_and_clear_forward_observed_identity_and_exact_retry_without_new_key() {
for (kind, mut input, response) in cases().into_iter().filter(|(kind, _, _)| {
matches!(
kind,
PublicAssignmentKind::Assign | PublicAssignmentKind::Clear
)
}) {
if matches!(kind, PublicAssignmentKind::Assign) {
input["expected_assignment_id"] = json!("previous-done");
}
let client = transport(200, response);
let (_, tool) = definition(client.clone(), kind)();
for _ in 0..2 {
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
}
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[0], requests[1]);
assert!(
requests[0]
.path
.starts_with("/api/w/workspace/tickets/T-743/assignments/orchestrator")
);
match kind {
PublicAssignmentKind::Assign => {
assert_eq!(requests[0].method, WorkspaceRequestMethod::Put);
let body: Value =
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["expected_assignment_id"], "previous-done");
assert_eq!(body["operation_id"], input["operation_id"]);
assert_eq!(body["principal"], input["principal"]);
assert!(body.get("actor").is_none());
assert!(body.get("orchestrator_assignment_id").is_none());
assert_eq!(body["workdir_bindings"], json!([]));
}
PublicAssignmentKind::Clear => {
assert_eq!(requests[0].method, WorkspaceRequestMethod::Delete);
assert!(requests[0].path.contains("assignment_id=delegation"));
assert!(requests[0].path.contains("operation_id=clear-1"));
assert!(requests[0].body.is_none());
}
_ => unreachable!(),
}
}
}
#[tokio::test]
async fn continuing_answer_forwards_same_assignment_event_cas_and_saved_receipt() {
let (kind, input, response) = cases().pop().unwrap();
let client = transport(200, response.clone());
let (_, tool) = definition(client.clone(), kind)();
for _ in 0..2 {
let output = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(output.content.as_deref().unwrap()).unwrap(),
response
);
}
let requests = client.requests.lock().unwrap();
assert_eq!(requests[0], requests[1]);
assert_eq!(
requests[0].path,
"/workspaces/workspace/tickets/T-743/assignment-answer"
);
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
let mut expected = input;
expected.as_object_mut().unwrap().remove("ticket");
assert_eq!(body, expected);
assert!(body.get("tracking").is_none());
}
#[tokio::test]
async fn assignment_pages_forward_independent_encoded_cursors_and_reject_unbounded_input() {
for (kind, input, response) in cases().into_iter().filter(|(kind, _, _)| {
matches!(
kind,
PublicAssignmentKind::Assignments | PublicAssignmentKind::Diagnostics
)
}) {
let client = transport(200, response);
let (_, tool) = definition(client.clone(), kind)();
for limit in [0, 101] {
let mut invalid = input.clone();
invalid["limit"] = json!(limit);
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
assert!(requests[0].path.contains("limit=10"));
match kind {
PublicAssignmentKind::Assignments => assert!(
requests[0]
.path
.contains("after_assignment_id=old%2Fdelegation%3F")
),
PublicAssignmentKind::Diagnostics => {
assert!(
requests[0]
.path
.contains("after_input_event_id=ticket-event%3A7")
);
assert!(
requests[0]
.path
.contains("after_start_operation_id=start%2F7")
);
}
_ => unreachable!(),
}
}
}
#[tokio::test]
async fn public_assignment_rejections_and_unknown_outcomes_never_auto_retry_or_replace_identity() {
for (kind, input, _) in cases() {
for response in [
Ok(WorkspaceResponse {
status: 403,
body: "permission denied".into(),
}),
Ok(WorkspaceResponse {
status: 409,
body: "stale assignment or operation fingerprint mismatch".into(),
}),
Ok(WorkspaceResponse {
status: 200,
body: "invalid response after dispatch".into(),
}),
Err("response lost; outcome unknown".into()),
] {
let client = Arc::new(Transport {
requests: Mutex::new(vec![]),
available: true,
response,
});
let (_, tool) = definition(client.clone(), kind)();
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert_eq!(
client.requests.lock().unwrap().len(),
1,
"{} retried",
kind.name()
);
}
let client = Arc::new(Transport {
requests: Mutex::new(vec![]),
available: false,
response: Err("unavailable".into()),
});
let (_, tool) = definition(client.clone(), kind)();
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap_err()
.to_string()
.contains("Backend Workspace identity")
);
assert!(client.requests.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn diagnostics_strip_undeclared_content_and_preserve_unknown_processing() {
let (kind, input, mut response) = cases().remove(3);
response["rendered_content"] = json!("must not reach model");
response["authentication_binding_id"] = json!("must not reach model");
response["inputs"] = json!([{"input_event_id":"ticket-event:7", "delivery_id":null, "notification":"not_reserved", "acceptance_outcome_unknown":true, "rendered_content":"hidden"}]);
response["starts"] = json!([{"operation_id":"start", "claim_state":"reserved", "assignment_id":null, "runtime_id":null, "worker_id":null, "actual_processing_unknown":true, "authentication_binding_id":"hidden"}]);
let (_, tool) = definition(transport(200, response), kind)();
let output = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let output: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert!(output.get("rendered_content").is_none());
assert!(output.get("authentication_binding_id").is_none());
assert_eq!(output["inputs"][0]["acceptance_outcome_unknown"], true);
assert!(output["inputs"][0].get("rendered_content").is_none());
assert_eq!(output["starts"][0]["actual_processing_unknown"], true);
assert!(
output["starts"][0]
.get("authentication_binding_id")
.is_none()
);
}
#[test]
fn public_assignment_normal_and_wip_access_excludes_mutations_for_read_only_and_review() {
for access in [
TicketFeatureAccess::read_only(),
TicketFeatureAccess::review(),
] {
let client = transport(200, json!({}));
let normal = enabled_ticket_definitions(client.clone(), access);
let native = native_ticket_tools(client, access).unwrap();
for name in READ_TOOL_NAMES {
assert!(normal.iter().any(|d| d().0.name == *name));
assert!(native.iter().any(|t| t.name == *name));
}
for name in [
"TicketOrchestratorAssign",
"TicketOrchestratorClear",
"TicketAssignmentAnswer",
] {
assert!(!normal.iter().any(|d| d().0.name == name));
assert!(!native.iter().any(|t| t.name == name));
}
}
for name in TOOL_NAMES {
let native = native_ticket_operation(name).unwrap();
assert_eq!(native.identity_field, Some("ticket"));
assert_eq!(native.mutating, !READ_TOOL_NAMES.contains(name));
}
}
#[tokio::test]
async fn ticket_query_closure_reason_is_typed_and_not_a_classification_tag() {
let client = transport(
200,
json!({"items":[],"page":{"has_more":false,"next_cursor":null}}),
);
let (meta, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
let input = json!({"states":["closed"],"closure_reason":"completed","tag_ids":["tag-bug"]});
assert!(schema.is_valid(&input));
let invalid = json!({"closure_reason":"bug"});
assert!(!schema.is_valid(&invalid));
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["closure_reason"], "completed");
assert_eq!(body["tag_ids"], json!(["tag-bug"]));
}
#[tokio::test]
async fn ticket_query_forwards_current_principal_and_self_reported_handling_filters() {
let client = transport(
200,
json!({"items":[],"page":{"has_more":false,"next_cursor":null}}),
);
let (meta, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
let input = json!({"assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"handling_status":"waiting","states":["open"],"limit":5});
assert!(schema.is_valid(&input));
for status in ["running", "idle", "stopped", "closed"] {
let mut invalid = input.clone();
invalid["handling_status"] = json!(status);
assert!(!schema.is_valid(&invalid));
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["assigned_principal"], input["assigned_principal"]);
assert_eq!(body["handling_status"], "waiting");
assert_eq!(body["states"], json!(["open"]));
}
#[test]
fn resolved_builtin_profiles_expose_assignment_capabilities_without_repo_or_mr_prerequisites() {
let dir = tempfile::tempdir().unwrap();
for profile in [
"intake",
"orchestrator",
"coder",
"ticket-worker",
"reviewer",
] {
let resolved = manifest::ProfileResolver::new()
.with_workspace_base(dir.path())
.resolve(
&manifest::ProfileSelector::parse_cli(&format!("builtin:{profile}")),
manifest::ProfileResolveOptions::with_worker_name("assignment-fixture"),
)
.unwrap();
let config = resolved.manifest.feature.ticket;
assert!(config.enabled, "{profile}");
let delegates = config.authoring || config.workflow;
let answers = delegates || config.thread;
let reports = config.workflow;
let access = TicketFeatureAccess {
authoring: config.authoring,
thread: config.thread,
intake: config.intake,
workflow: config.workflow,
};
let names = access.tool_names();
assert_eq!(
names.contains(&"TicketOrchestratorAssign"),
delegates,
"{profile}"
);
assert_eq!(
names.contains(&"TicketOrchestratorClear"),
delegates,
"{profile}"
);
assert_eq!(
names.contains(&"TicketAssignmentAnswer"),
answers,
"{profile}"
);
assert_eq!(
names.contains(&"TicketAssignmentTrackingUpdate"),
reports,
"{profile}"
);
assert!(
names.contains(&"TicketAssignmentsQuery")
&& names.contains(&"TicketAssignmentDiagnostics"),
"{profile}"
);
assert!(
!names.iter().any(|name| matches!(
*name,
"TicketQueue" | "TicketMarkReady" | "TicketIntakeReady"
)),
"{profile}"
);
}
}
#[tokio::test]
async fn invalid_answer_body_and_assignment_identities_fail_before_dispatch() {
let (kind, input, response) = cases().pop().unwrap();
let client = transport(200, response);
let (_, tool) = definition(client.clone(), kind)();
for body in [
String::new(),
" ".into(),
"x".repeat(16385),
"text\0hidden".into(),
] {
let mut invalid = input.clone();
invalid["body"] = json!(body);
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
for field in ["assignment_id", "expected_last_event_id", "operation_key"] {
for value in [String::new(), "x\n".into(), "x".repeat(1025)] {
let mut invalid = input.clone();
invalid[field] = json!(value);
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
}
assert!(client.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn assignment_wip_binds_ticket_and_preserves_normal_permission_and_cas_inputs() {
for (kind, mut input, response) in cases() {
let client = transport(200, response);
let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap();
let handler = TicketItemWipHandler {
tools: operation_map(
tools
.into_iter()
.filter(|t| t.projection.surface == NativeTicketSurface::Item)
.collect(),
),
permissions: None,
ticket_reference: "T-743".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
input.as_object_mut().unwrap().remove("ticket");
let args: BTreeMap<_, _> = input
.as_object()
.unwrap()
.iter()
.map(|(key, value)| (key.clone(), json_to_wip(value).unwrap()))
.collect();
let operation = native_ticket_operation(kind.name()).unwrap().operation;
handler
.call(
operation,
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into(),
},
)
.await
.unwrap_or_else(|_| panic!("WIP {operation} rejected"));
assert_eq!(client.requests.lock().unwrap().len(), 1);
let mut forged = args.clone();
forged.insert("ticket".into(), WipValue::String("T-other".into()));
assert!(
handler
.call(
operation,
&forged,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into()
}
)
.await
.is_err()
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
}
let (_, input, response) = cases().pop().unwrap();
let client = transport(200, response);
let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap();
let denied = TicketItemWipHandler {
tools: operation_map(
tools
.into_iter()
.filter(|t| t.projection.surface == NativeTicketSurface::Item)
.collect(),
),
permissions: Some(ToolPermissionConfig {
default_action: ToolPermissionAction::Deny,
rules: vec![],
}),
ticket_reference: "T-743".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
let args = input
.as_object()
.unwrap()
.iter()
.filter(|(k, _)| k.as_str() != "ticket")
.map(|(k, v)| (k.clone(), json_to_wip(v).unwrap()))
.collect();
assert!(matches!(
denied
.call(
"assignment_answer",
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into()
}
)
.await,
Err(WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
..
}))
));
assert!(client.requests.lock().unwrap().is_empty());
}
@@ -74,7 +74,7 @@ pub(super) fn description(name: &str) -> &'static str {
"Read bounded immutable history for one assignment_id, including released/replaced delegations. Continue with the returned event identity. Historical results do not grant execution rights or create a new request."
}
"TicketAssignmentTrackingUpdate" => {
"Record the current Workspace Orchestrator's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Backend authenticates the current registered Orchestrator; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations."
"Record the current Workspace Orchestrator's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Answers during waiting continue the same row; explicit reissue after done requires a new Assign row, not overwriting the previous result. Send the complete tracking payload. Exact retry uses the same operation_key and full input; different input with that key is rejected. On CAS/stale conflict reread current authority and reconcile; unknown outcome is not success or failure and must not become a fresh operation automatically. Backend authenticates the current registered Orchestrator; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations."
}
_ => unreachable!("unknown assignment tracking tool"),
}
+4 -3
View File
@@ -866,9 +866,10 @@ mod tests {
.unwrap();
assert!(server.starts_with("Assigned Tickets have unreceived input:"));
// TUI's final operation model is a separate downstream scope; both
// renderers still consume the same bounded resource-key projection.
assert!(tui.starts_with("Queued Tickets require attention:"));
assert!(tui.starts_with("Assigned Tickets have input requiring attention:"));
assert!(tui.contains("no ready/Queue step is required"));
assert!(tui.contains("were omitted from this notice: 1"));
assert!(!tui.contains("T-21"));
assert!(server.contains("- T-1 — Ticket 1 with control "));
assert!(!server.contains("T-21"));
assert!(server.contains("were omitted from this notice: 1"));
+158 -8
View File
@@ -334,19 +334,28 @@ mod tests {
fn ticket_workflow_fixture(
role: &str,
mr_config: MergeRequestFeatureConfig,
) -> (String, Vec<agen::tool::ToolMeta>) {
ticket_fixture_with_access(
role,
mr_config,
TicketFeatureAccess {
thread: true,
workflow: true,
..Default::default()
},
)
}
fn ticket_fixture_with_access(
role: &str,
mr_config: MergeRequestFeatureConfig,
access: TicketFeatureAccess,
) -> (String, Vec<agen::tool::ToolMeta>) {
let client = Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused"));
let mut tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(TicketFeature::new(
client.clone(),
TicketFeatureAccess {
thread: true,
workflow: true,
..Default::default()
},
))
.with_module(TicketFeature::new(client.clone(), access))
.with_module(MergeRequestFeature::new(client, mr_config))
.install_into_pending(&mut tools, &mut hooks);
assert!(!report.has_errors(), "{}", report.error_message());
@@ -471,6 +480,147 @@ mod tests {
}
}
#[test]
fn effective_ticket_instructions_separate_case_handling_and_delegation_retries() {
for role in [
"role.ticket_worker",
"role.coder",
"role.orchestrator",
"role.intake",
] {
let (prompt, tools) =
ticket_workflow_fixture(role, MergeRequestFeatureConfig::default());
for policy in [
"only `open`",
"stable tag IDs, not display names",
"no ready step or separate Queue is required",
"Orchestrator's self-reported handling",
"Answers during waiting or ongoing handling continue the same assignment row",
"explicit reissue creates a new row/assignment_id",
"Respect an explicit user approval condition",
"done does not require unassignment",
"expected_last_event_id",
"Exact retries retain the same key and full input",
"Assign persistence, notification delivery, Runtime acceptance and actual work start",
"concrete API insufficiency",
] {
assert!(prompt.contains(policy), "{role}: missing {policy}");
}
let update = tools
.iter()
.find(|meta| meta.name == "TicketAssignmentTrackingUpdate")
.unwrap();
for policy in [
"self-reported",
"same row",
"new Assign row",
"complete tracking payload",
"different input with that key is rejected",
"reread current authority",
"exact retry receipts are not fresh observations",
] {
assert!(
update.description.contains(policy),
"{role}: missing {policy}"
);
}
assert!(!tools.iter().any(|meta| matches!(
meta.name.as_str(),
"TicketMarkReady"
| "TicketQueue"
| "TicketIntakeReady"
| "CompleteTicket"
| "TicketWorkflowState"
| "TicketClose"
)));
if role == "role.orchestrator" {
assert!(prompt.contains("observed current `orchestrator_assignment_id`"));
assert!(prompt.contains(
"generic `WorkerSpawn` Ticket starts and existing-Worker assignment"
));
}
}
}
// Scripted policy/input fixtures: they do not run a model or certify its judgment.
#[test]
fn effective_return_wait_answer_and_close_fixtures_match_resolved_profile_tools_without_repo_or_mr()
{
let dir = tempfile::tempdir().unwrap();
let resolved = manifest::ProfileResolver::new()
.with_workspace_base(dir.path())
.resolve(
&manifest::ProfileSelector::parse_cli("builtin:orchestrator"),
manifest::ProfileResolveOptions::with_worker_name("orchestrator-fixture"),
)
.unwrap();
let config = resolved.manifest.feature.ticket;
// Omit MR feature for the no-repository/no-MR work fixture; profile selection
// makes it available, not mandatory to exercise for every Ticket.
let (prompt, tools) = ticket_fixture_with_access(
&resolved.manifest.engine.instruction,
MergeRequestFeatureConfig::default(),
TicketFeatureAccess {
authoring: config.authoring,
thread: config.thread,
intake: config.intake,
workflow: config.workflow,
},
);
assert!(!tools.iter().any(|meta| matches!(
meta.name.as_str(),
"OpenMergeRequest" | "ReviewMergeRequest" | "CompleteMergeRequest"
)));
for (policy, name, input) in [
(
"Use `TicketOrchestratorAssign` for explicit initial delegation or reissue",
"TicketOrchestratorAssign",
serde_json::json!({"ticket":"T-743","principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"expected_assignment_id":null,"operation_id":"delegate-research"}),
),
(
"research returned to a human can be `open` + handling `done`",
"TicketAssignmentTrackingUpdate",
serde_json::json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"assigned", "operation_key":"return-options", "tracking":{"status":"done", "result":"Options returned", "next_action":"Human decision"}}),
),
(
"Respect an explicit user approval condition",
"TicketAssignmentTrackingUpdate",
serde_json::json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"assigned", "operation_key":"wait-for-answer", "tracking":{"status":"waiting", "waiting_reason":"User requested confirmation first", "next_action":"Read the user's answer on this assignment"}}),
),
(
"use `TicketAssignmentAnswer` with that `assignment_id`",
"TicketAssignmentAnswer",
serde_json::json!({"ticket":"T-743","assignment_id":"delegation","expected_last_event_id":"waiting","operation_key":"answer-on-same-row","body":"Option A approved; continue within scope"}),
),
(
"No Merge Request or approval is a prerequisite for a generic Ticket decision",
"TicketCaseUpdate",
serde_json::json!({"ticket":"T-743", "expected_content_digest":"observed-digest", "expected_state":"open", "expected_closure_operation_key":null, "operation_key":"fulfilled-close", "action":{"action":"close", "reason":"completed"}, "explanation":"The whole bounded request is fulfilled and closure was requested"}),
),
] {
assert!(prompt.contains(policy), "missing {policy}");
let meta = tools.iter().find(|meta| meta.name == name).unwrap();
let validator = jsonschema::validator_for(&meta.input_schema).unwrap();
assert!(validator.is_valid(&input), "{name} rejected {input}");
let required = meta.input_schema["required"].as_array().unwrap();
assert!(!required.iter().any(|field| matches!(
field.as_str(),
Some(
"repository_key"
| "merge_request_id"
| "approval_event_id"
| "workdir_attachments"
)
)));
let mut forged = input;
forged["actor"] = serde_json::json!("workspace-orchestrator");
assert!(
!validator.is_valid(&forged),
"model-supplied authority accepted"
);
}
}
#[test]
fn sub_worker_capabilities_follow_the_registered_canonical_control_tools() {
let names = [
+131
View File
@@ -729,6 +729,28 @@ pub struct WorkspaceWorkerDiscoveryRequest {
#[derive(Debug, Clone)]
pub enum WorkspaceServerOperation {
TicketAssignmentSet {
ticket_id: String,
role: server_api::TicketAssignmentRole,
request: server_api::SetTicketRoleAssignmentRequest,
},
TicketAssignmentClear {
ticket_id: String,
role: server_api::TicketAssignmentRole,
query: server_api::ClearTicketRoleAssignmentQuery,
},
TicketAssignmentsPage {
ticket_id: String,
query: server_api::TicketAssignmentsQuery,
},
TicketAssignmentDiagnostics {
ticket_id: String,
query: server_api::TicketAssignmentDiagnosticsQuery,
},
TicketAssignmentAnswer {
ticket_id: String,
request: server_api::TicketAssignmentAnswerRequest,
},
WorkerControlList,
WorkerControlSpawn(server_api::CreateWorkspaceWorkerRequest),
WorkerControlInput {
@@ -784,6 +806,45 @@ fn encode_workspace_path_segment(value: &str) -> String {
encoded
}
fn workspace_server_query_request<T: serde::Serialize>(
method: WorkspaceRequestMethod,
path: String,
query: &T,
) -> Result<WorkspaceRequest, WorkspaceClientError> {
let query = serde_json::to_value(query)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
let fields = query.as_object().ok_or_else(|| {
WorkspaceClientError::Request("Workspace query must serialize to an object".into())
})?;
let mut pairs = Vec::new();
for (name, value) in fields {
let value = match value {
serde_json::Value::Null => continue,
serde_json::Value::String(value) => value.clone(),
serde_json::Value::Number(value) => value.to_string(),
_ => {
return Err(WorkspaceClientError::Request(
"Workspace query fields must be scalar".into(),
));
}
};
pairs.push(format!(
"{}={}",
encode_workspace_path_segment(name),
encode_workspace_path_segment(&value)
));
}
Ok(WorkspaceRequest {
method,
path: if pairs.is_empty() {
path
} else {
format!("{path}?{}", pairs.join("&"))
},
body: None,
})
}
fn workspace_server_operation_request(
workspace_id: &str,
operation: WorkspaceServerOperation,
@@ -791,6 +852,76 @@ fn workspace_server_operation_request(
let workspace_id = encode_workspace_path_segment(workspace_id);
let base = format!("/api/w/{workspace_id}");
match operation {
WorkspaceServerOperation::TicketAssignmentSet {
ticket_id,
role,
request,
} => {
let role = match role {
server_api::TicketAssignmentRole::Orchestrator => "orchestrator",
server_api::TicketAssignmentRole::Worker => "worker",
server_api::TicketAssignmentRole::Owner => "owner",
server_api::TicketAssignmentRole::Contributor => "contributor",
};
Ok(WorkspaceRequest::json(
WorkspaceRequestMethod::Put,
format!(
"{base}/tickets/{}/assignments/{role}",
encode_workspace_path_segment(&ticket_id)
),
serde_json::to_string(&request)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
))
}
WorkspaceServerOperation::TicketAssignmentClear {
ticket_id,
role,
query,
} => {
let role = match role {
server_api::TicketAssignmentRole::Orchestrator => "orchestrator",
server_api::TicketAssignmentRole::Worker => "worker",
server_api::TicketAssignmentRole::Owner => "owner",
server_api::TicketAssignmentRole::Contributor => "contributor",
};
workspace_server_query_request(
WorkspaceRequestMethod::Delete,
format!(
"{base}/tickets/{}/assignments/{role}",
encode_workspace_path_segment(&ticket_id)
),
&query,
)
}
WorkspaceServerOperation::TicketAssignmentsPage { ticket_id, query } => {
workspace_server_query_request(
WorkspaceRequestMethod::Get,
format!(
"/workspaces/{workspace_id}/tickets/{}/assignment-tracking/assignments",
encode_workspace_path_segment(&ticket_id)
),
&query,
)
}
WorkspaceServerOperation::TicketAssignmentDiagnostics { ticket_id, query } => {
workspace_server_query_request(
WorkspaceRequestMethod::Get,
format!(
"/workspaces/{workspace_id}/tickets/{}/assignment-diagnostics",
encode_workspace_path_segment(&ticket_id)
),
&query,
)
}
WorkspaceServerOperation::TicketAssignmentAnswer { ticket_id, request } => {
workspace_server_json_request(
format!(
"/workspaces/{workspace_id}/tickets/{}/assignment-answer",
encode_workspace_path_segment(&ticket_id)
),
&request,
)
}
WorkspaceServerOperation::WorkerControlList => Ok(WorkspaceRequest::get(format!(
"{base}/worker-control/workers"
))),
@@ -124,12 +124,14 @@ WITH eligible AS (
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
-- External content/answer inputs, not case/routing/history or own self-report.
-- Ticket supports typed extension events; an answer need not be named comment.
AND e.kind NOT IN ('create','close','state_changed','status_changed','history')
-- 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)
)
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
@@ -378,3 +380,85 @@ 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 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,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 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)
)
"#;
pub(super) fn diagnostics_page(
store: &SqliteWorkspaceStore,
workspace_id: &str,
ticket_id: &str,
assignment_id: &str,
after_input_event_id: Option<&str>,
after_start_operation_id: Option<&str>,
limit: usize,
) -> Result<server_api::TicketAssignmentDiagnosticsPage> {
if !(1..=100).contains(&limit) {
return Err(Error::InvalidInput(
"Diagnostic page limit must be 1..100".into(),
));
}
store.with_conn(|conn| {
// One read snapshot for current assignment, evidence and cursor validation.
let tx = conn.unchecked_transaction()?;
let assignment = ticket::sqlite_ticket_assignment_tracking(&tx,workspace_id,ticket_id,assignment_id)
.map_err(|e| Error::Store(e.to_string()))?.ok_or_else(|| Error::InvalidInput("Assignment does not belong to this Ticket".into()))?;
let known: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_assignment_delivery_origins WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3)",params![workspace_id,ticket_id,assignment_id],|r| r.get(0))?;
let actionable = match require_current(&tx,workspace_id,ticket_id,assignment_id) {
Ok(()) => true,
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 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 };
let after_start = match after_start_operation_id {
None => 0,
Some(id) => tx.query_row("SELECT f.rowid FROM ticket_assignment_operation_fences f JOIN ticket_assignment_operations o ON o.workspace_id=f.workspace_id AND o.operation_id=f.operation_id WHERE f.workspace_id=?1 AND f.orchestrator_assignment_id=?3 AND o.ticket_id=?2 AND f.operation_id=?4",params![workspace_id,ticket_id,assignment_id,id],|r| r.get::<_,i64>(0)).optional()?.ok_or_else(|| Error::InvalidInput("Start cursor does not belong to this assignment".into()))?,
};
let mut stmt = tx.prepare("SELECT f.operation_id,o.claim_state,o.assignment_id,o.runtime_id,o.worker_id FROM ticket_assignment_operation_fences f JOIN ticket_assignment_operations o ON o.workspace_id=f.workspace_id AND o.operation_id=f.operation_id WHERE f.workspace_id=?1 AND o.ticket_id=?2 AND f.orchestrator_assignment_id=?3 AND f.rowid>?4 ORDER BY f.rowid LIMIT ?5")?;
let mut starts = stmt.query_map(params![workspace_id,ticket_id,assignment_id,after_start,(limit+1) as i64],|r| Ok(server_api::TicketAssignmentStartDiagnostic { operation_id:r.get(0)?,claim_state:r.get(1)?,assignment_id:r.get(2)?,runtime_id:r.get(3)?,worker_id:r.get(4)?,actual_processing_unknown:true }))?.collect::<std::result::Result<Vec<_>,_>>()?;
let more = starts.len()>limit;
starts.truncate(limit);
let next_start_operation_id = if more { starts.last().map(|i| i.operation_id.clone()) } else { None };
Ok(server_api::TicketAssignmentDiagnosticsPage { assignment_id:assignment_id.into(),current:assignment.current,input_origin_known:known,actionable,inputs,next_input_event_id,starts,next_start_operation_id })
})
}
@@ -11,6 +11,27 @@ fn delivery_store_external_event(
}).unwrap();
}
fn delivery_store_answer(store: &SqliteWorkspaceStore, key: &str) {
let current = store
.list_current_ticket_assignment_tracking("w", "tracking")
.unwrap()
.remove(0);
store
.answer_ticket_assignment(
"w",
"tracking",
&ticket::TicketAssignmentAnswer {
assignment_id: current.assignment_id,
expected_last_event_id: current.last_event_id.unwrap(),
operation_key: key.into(),
body: "Explicit continuing answer".into(),
},
&assignment_answer_source(),
"fixture-actor",
)
.unwrap();
}
fn delivery_store_reserve_initial(
store: &SqliteWorkspaceStore,
source: &TicketAssignmentTrackingSourceContext,
@@ -60,11 +81,18 @@ async fn assignment_delivery_waiting_self_reports_do_not_busy_loop_and_answers_a
Some("assignment_tracking"),
);
delivery_store_external_event(&store, 2, "comment", None);
assert!(
store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap()
.is_empty()
);
delivery_store_answer(&store, "answer-after-comment");
let pending = store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].input_event_id, "ticket-event:2");
assert_eq!(pending[0].input_event_id, "ticket-event:3");
assert_eq!(
pending[0].waiting_reason.as_deref(),
Some("Waiting for owner answer")
@@ -74,7 +102,7 @@ async fn assignment_delivery_waiting_self_reports_do_not_busy_loop_and_answers_a
Some("Read answer and decide")
);
let answer = store
.reserve_ticket_assignment_delivery("w", "a", "ticket-event:2", &source)
.reserve_ticket_assignment_delivery("w", "a", "ticket-event:3", &source)
.unwrap();
store
.ack_ticket_assignment_delivery("w", &answer.delivery_id, &source)
@@ -85,16 +113,16 @@ async fn assignment_delivery_waiting_self_reports_do_not_busy_loop_and_answers_a
.unwrap()
.is_empty()
);
// Typed extension inputs must not disappear merely because they are not
// comments; own tracking/history events remain excluded.
delivery_store_external_event(&store, 3, "assignment_tracking_updated", None);
delivery_store_external_event(&store, 4, "history", None);
delivery_store_external_event(&store, 5, "owner_answer", None);
// Explicit fenced answers remain inputs; own tracking/history and ordinary
// comments remain excluded.
delivery_store_external_event(&store, 4, "assignment_tracking_updated", None);
delivery_store_external_event(&store, 5, "history", None);
delivery_store_answer(&store, "next-explicit-answer");
let pending = store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].input_event_id, "ticket-event:5");
assert_eq!(pending[0].input_event_id, "ticket-event:6");
let saved = store
.get_ticket_assignment_tracking("w", "tracking", "a")
.unwrap()
@@ -792,3 +820,216 @@ async fn assignment_startup_unfinished_dependencies_remain_diagnostics_not_store
})
.unwrap();
}
#[tokio::test]
async fn assignment_diagnostics_keep_persisted_notification_acceptance_and_history_distinct_without_payload()
{
let (_dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "a", "assign-a");
let initial = store
.list_pending_ticket_assignment_deliveries("w", 10)
.unwrap()
.remove(0);
let page = store
.page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1)
.unwrap();
assert!(page.current && page.actionable && page.input_origin_known);
assert_eq!(
page.inputs[0].notification,
server_api::TicketAssignmentInputDeliveryState::NotReserved
);
let initial = store
.reserve_ticket_assignment_delivery(
"w",
"a",
&initial.input_event_id,
&tracking_store_source(),
)
.unwrap();
assert_eq!(
store
.page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1)
.unwrap()
.inputs[0]
.notification,
server_api::TicketAssignmentInputDeliveryState::Reserved
);
store
.prepare_ticket_assignment_delivery_content(
"w",
&initial.delivery_id,
"SECRET RENDERED PAYLOAD",
&tracking_store_source(),
)
.unwrap();
let pending = store
.page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1)
.unwrap();
assert_eq!(
pending.inputs[0].notification,
server_api::TicketAssignmentInputDeliveryState::RequestPersisted
);
assert!(pending.inputs[0].acceptance_outcome_unknown);
store
.ack_ticket_assignment_delivery("w", &initial.delivery_id, &tracking_store_source())
.unwrap();
delivery_store_answer(&store, "diagnostic-answer");
delivery_store_external_event(&store, 1, "history", None);
let accepted = store
.page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1)
.unwrap();
assert_eq!(
accepted.inputs[0].notification,
server_api::TicketAssignmentInputDeliveryState::RuntimeAccepted
);
assert!(!accepted.inputs[0].acceptance_outcome_unknown);
assert_eq!(
accepted.next_input_event_id.as_deref(),
Some(initial.input_event_id.as_str())
);
let next = store
.page_ticket_assignment_diagnostics(
"w",
"tracking",
"a",
accepted.next_input_event_id.as_deref(),
None,
1,
)
.unwrap();
assert_eq!(next.inputs[0].input_event_id, "ticket-event:0");
assert!(next.next_input_event_id.is_none());
let wire = serde_json::to_string(&accepted).unwrap();
for forbidden in [
"SECRET",
"tracking-binding",
"authenticated-worker",
"payload_json",
"source_json",
"content",
] {
assert!(!wire.contains(forbidden), "{wire}");
}
store
.update_ticket_assignment_tracking(
"w",
"tracking",
&tracking_store_input("a", "event-assign-a", "done"),
&tracking_store_source(),
)
.unwrap();
let history = store
.page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 100)
.unwrap();
assert!(history.current && !history.actionable);
assert_eq!(history.inputs.len(), 2); // retained inputs are history, not fresh work
assert_eq!(
store
.page_ticket_assignment_diagnostics(
"w",
"tracking",
"a",
accepted.next_input_event_id.as_deref(),
None,
100
)
.unwrap()
.inputs[0]
.input_event_id,
"ticket-event:0"
);
assert!(
store
.page_ticket_assignment_diagnostics(
"w",
"tracking",
"a",
Some("ticket-event:999"),
None,
1
)
.is_err()
);
assert!(
store
.page_ticket_assignment_diagnostics("other", "tracking", "a", None, None, 1)
.is_err()
);
}
#[tokio::test]
async fn assignment_diagnostics_unknown_retained_relationship_does_not_invent_input_or_acceptance()
{
let (_dir, store) = tracking_store_fixture().await;
store.with_conn(|conn| {
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','tracking','legacy-unknown','orchestrator','workspace_agent','workspace-orchestrator','legacy','past')",[])?;
conn.execute("UPDATE ticket_worker_assignments SET tracking_json=NULL,tracking_updated_at=NULL,tracking_migration_diagnostic='Unknown historical relationship' WHERE assignment_id='legacy-unknown'",[])?;
conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','tracking','legacy-unknown','orchestrator','workspace_agent','workspace-orchestrator','past')",[])?;
Ok(())
}).unwrap();
delivery_store_external_event(&store, 0, "comment", None);
let page = store
.page_ticket_assignment_diagnostics("w", "tracking", "legacy-unknown", None, None, 100)
.unwrap();
assert!(page.current);
assert!(!page.actionable && !page.input_origin_known);
assert!(page.inputs.is_empty() && page.starts.is_empty());
assert!(
store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn assignment_diagnostic_start_cursor_pages_existing_claims_without_input_causality_or_processing_inference()
{
let (_dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "a", "assign-a");
let source = tracking_store_source();
delivery_store_start_reserve(&store, "z-first-start", "a", &source).unwrap();
store
.fail_ticket_assignment_operation("w", "z-first-start", "External resource refused start")
.unwrap();
delivery_store_start_reserve(&store, "a-second-start", "a", &source).unwrap();
let first = store
.page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1)
.unwrap();
assert_eq!(first.starts.len(), 1);
assert_eq!(first.starts[0].operation_id, "z-first-start");
assert_eq!(first.starts[0].claim_state, "failed");
assert!(first.starts[0].actual_processing_unknown);
assert_eq!(
first.next_start_operation_id.as_deref(),
Some("z-first-start")
);
let second = store
.page_ticket_assignment_diagnostics(
"w",
"tracking",
"a",
None,
first.next_start_operation_id.as_deref(),
1,
)
.unwrap();
assert_eq!(second.starts[0].operation_id, "a-second-start");
assert_eq!(second.starts[0].claim_state, "pending");
assert!(second.starts[0].actual_processing_unknown);
assert!(second.next_start_operation_id.is_none());
assert!(
store
.page_ticket_assignment_diagnostics(
"w",
"tracking",
"a",
None,
Some("foreign-operation"),
1
)
.is_err()
);
let wire = serde_json::to_string(&first).unwrap();
assert!(!wire.contains("tracking-binding") && !wire.contains("External resource"));
}
@@ -171,3 +171,45 @@ pub(super) fn history_page(
})
})
}
pub(super) fn assignments_page(
store: &SqliteWorkspaceStore,
workspace_id: &str,
ticket_id: &str,
after_assignment_id: Option<&str>,
limit: usize,
) -> Result<ticket::TicketAssignmentsPage> {
store.with_conn(|conn| {
let tx = conn.unchecked_transaction()?;
ticket::sqlite_ticket_assignments_page(
&tx,
workspace_id,
ticket_id,
after_assignment_id,
limit,
)
.map_err(|e| Error::InvalidInput(e.to_string()))
})
}
pub(super) fn answer(
store: &SqliteWorkspaceStore,
workspace_id: &str,
ticket_id: &str,
input: &ticket::TicketAssignmentAnswer,
source: &TicketOrchestratorAssignmentSourceContext,
actor: &str,
) -> Result<ticket::TicketAssignmentAnswerReceipt> {
store.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
require_assignment_source(&tx,workspace_id,source)?;
// Replay retains its saved result after assignment replacement; new input
// must still be admitted by the exact current operational delegation.
let replay: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key=?2)",params![workspace_id,input.operation_key],|r| r.get(0))?;
if !replay { assignment_delivery::require_current(&tx,workspace_id,ticket_id,&input.assignment_id)?; }
let source = serde_json::to_value(source).map_err(|e| Error::Store(e.to_string()))?;
let result = ticket::sqlite_answer_ticket_assignment_in_transaction(&tx,workspace_id,ticket_id,input,&source,actor).map_err(|e| Error::TicketAssignmentConflict(e.to_string()))?;
tx.commit()?;
Ok(result)
})
}
@@ -458,7 +458,10 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
vec![
(91, "workspace schema baseline".into()),
(92, "Ticket-owned assignment tracking".into()),
(93, "Durable assignment deliveries and source-bound startup fences".into())
(
93,
"Durable assignment deliveries and source-bound startup fences".into()
)
]
);
for (table, rows) in retained.iter().zip(original.iter()) {
@@ -1485,3 +1488,337 @@ async fn orchestrator_clear_receipt_compares_nullable_reason_as_full_payload_not
.unwrap()
);
}
fn assignment_answer_input(key: &str) -> ticket::TicketAssignmentAnswer {
ticket::TicketAssignmentAnswer {
assignment_id: "answer-a".into(),
expected_last_event_id: "event-answer-assign".into(),
operation_key: key.into(),
body: "Proceed with option B".into(),
}
}
fn assignment_answer_source() -> TicketOrchestratorAssignmentSourceContext {
TicketOrchestratorAssignmentSourceContext::Runtime {
runtime_id: "r".into(),
worker_id: "worker".into(),
agent_key: "workspace-orchestrator".into(),
authentication_binding_id: "tracking-binding".into(),
}
}
#[tokio::test]
async fn assignment_answer_replay_returns_saved_event_without_new_assignment_or_status_change() {
let (dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "answer-a", "answer-assign");
let input = assignment_answer_input("answer-key");
let before = store
.get_ticket_assignment_tracking("w", "tracking", "answer-a")
.unwrap()
.unwrap();
let saved = store
.answer_ticket_assignment(
"w",
"tracking",
&input,
&assignment_answer_source(),
"authenticated-worker:r/worker",
)
.unwrap();
drop(store);
let store = SqliteWorkspaceStore::open(dir.path().join("workspace.db")).unwrap();
assert_eq!(
store
.answer_ticket_assignment(
"w",
"tracking",
&input,
&assignment_answer_source(),
"authenticated-worker:r/worker"
)
.unwrap(),
saved
);
assert_eq!(
store
.get_ticket_assignment_tracking("w", "tracking", "answer-a")
.unwrap()
.unwrap(),
before
);
let mut changed = input.clone();
changed.body = "Different answer".into();
assert!(
store
.answer_ticket_assignment(
"w",
"tracking",
&changed,
&assignment_answer_source(),
"authenticated-worker:r/worker"
)
.is_err()
);
let pending = store
.list_pending_ticket_assignment_deliveries("w", 100)
.unwrap();
assert_eq!(pending.len(), 2);
assert!(
pending
.iter()
.any(|p| p.input_event_id == saved.input_event_id)
);
store
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT count(*) FROM typed_ticket_events WHERE kind='assignment_answer'",
[],
|r| r.get::<_, i64>(0)
)?,
1
);
assert_eq!(
conn.query_row(
"SELECT value FROM typed_ticket_event_attributes WHERE key='operation_key'",
[],
|r| r.get::<_, String>(0)
)?,
"answer-key"
);
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn assignment_answer_receipt_failure_rolls_back_body_audit_and_ticket_touch() {
let (_dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "answer-a", "answer-assign");
store.with_conn(|conn| {
conn.execute_batch("CREATE TRIGGER reject_answer_receipt BEFORE INSERT ON typed_ticket_assignment_tracking_operations BEGIN SELECT RAISE(ABORT,'receipt failed'); END;")?;
Ok(())
}).unwrap();
let before: String = store
.with_conn(|conn| {
Ok(conn.query_row(
"SELECT updated_at FROM typed_tickets WHERE ticket_id='tracking'",
[],
|r| r.get(0),
)?)
})
.unwrap();
assert!(
store
.answer_ticket_assignment(
"w",
"tracking",
&assignment_answer_input("failed-key"),
&assignment_answer_source(),
"authenticated-worker:r/worker"
)
.is_err()
);
store
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT count(*) FROM typed_ticket_events WHERE kind='assignment_answer'",
[],
|r| r.get::<_, i64>(0)
)?,
0
);
assert_eq!(
conn.query_row(
"SELECT count(*) FROM typed_ticket_event_attributes WHERE key='operation_key'",
[],
|r| r.get::<_, i64>(0)
)?,
0
);
assert_eq!(
conn.query_row(
"SELECT updated_at FROM typed_tickets WHERE ticket_id='tracking'",
[],
|r| r.get::<_, String>(0)
)?,
before
);
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn assignment_answer_new_input_rejects_replaced_or_changed_handling_but_exact_retry_is_saved()
{
let (_dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "answer-a", "answer-assign");
let input = assignment_answer_input("saved-answer");
let saved = store
.answer_ticket_assignment(
"w",
"tracking",
&input,
&assignment_answer_source(),
"actor",
)
.unwrap();
let mut update = tracking_store_input("answer-a", "event-answer-assign", "next-status");
update.tracking.status = TicketAssignmentTrackingStatus::Waiting;
store
.update_ticket_assignment_tracking("w", "tracking", &update, &tracking_store_source())
.unwrap();
assert!(
store
.answer_ticket_assignment(
"w",
"tracking",
&assignment_answer_input("stale-answer"),
&assignment_answer_source(),
"actor"
)
.is_err()
);
let mut replacement = manual_worker_assignment("tracking", "answer-b");
replacement.role = TicketAssignmentRole::Orchestrator;
replacement.principal = TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".into(),
};
store
.set_current_ticket_role_assignment(
&replacement,
Some("answer-a"),
"replace-answer",
"replace-answer-key",
true,
)
.unwrap();
assert!(
store
.answer_ticket_assignment(
"w",
"tracking",
&assignment_answer_input("replaced-answer"),
&assignment_answer_source(),
"actor"
)
.is_err()
);
assert_eq!(
store
.answer_ticket_assignment(
"w",
"tracking",
&input,
&assignment_answer_source(),
"actor"
)
.unwrap(),
saved
);
store
.with_conn(|conn| {
conn.execute(
"UPDATE worker_singleton_owners SET worker_id='replacement' WHERE workspace_id='w'",
[],
)?;
Ok(())
})
.unwrap();
assert!(
store
.answer_ticket_assignment(
"w",
"tracking",
&input,
&assignment_answer_source(),
"actor"
)
.is_err()
);
}
#[tokio::test]
async fn assignment_identity_pages_keep_release_and_last_status_separate_and_reject_foreign_cursor()
{
let (_dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "z-first", "first");
let mut replacement = manual_worker_assignment("tracking", "a-second");
replacement.role = TicketAssignmentRole::Orchestrator;
replacement.principal = TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".into(),
};
store
.set_current_ticket_role_assignment(
&replacement,
Some("z-first"),
"replacement-event",
"replacement-key",
true,
)
.unwrap();
let page = store
.page_ticket_assignments("w", "tracking", None, 1)
.unwrap();
assert_eq!(page.next_assignment_id.as_deref(), Some("z-first"));
assert!(!page.items[0].current);
assert_eq!(
page.items[0].tracking.as_ref().unwrap().status,
TicketAssignmentTrackingStatus::Waiting
);
assert_eq!(
page.items[0].release.as_ref().unwrap().action,
ticket::TicketAssignmentReleaseAction::Replaced
);
let next = store
.page_ticket_assignments("w", "tracking", page.next_assignment_id.as_deref(), 1)
.unwrap();
assert_eq!(next.items[0].assignment_id, "a-second");
assert!(next.items[0].current);
assert!(next.next_assignment_id.is_none());
assert!(
store
.page_ticket_assignments("other", "tracking", Some("z-first"), 1)
.is_err()
);
assert!(
store
.page_ticket_assignments("w", "tracking", None, 101)
.is_err()
);
}
#[tokio::test]
async fn assignment_answer_concurrent_exact_retry_on_two_connections_creates_one_event_and_receipt()
{
let (dir, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "answer-a", "answer-assign");
let path = dir.path().join("workspace.db");
let first = SqliteWorkspaceStore::open(&path).unwrap();
let second = SqliteWorkspaceStore::open(&path).unwrap();
let barrier = std::sync::Barrier::new(2);
let results = std::thread::scope(|scope| {
let run = |store: &SqliteWorkspaceStore| {
barrier.wait();
store
.answer_ticket_assignment(
"w",
"tracking",
&assignment_answer_input("concurrent-answer"),
&assignment_answer_source(),
"actor",
)
.unwrap()
};
let a = scope.spawn(move || run(&first));
let b = scope.spawn(move || run(&second));
(a.join().unwrap(), b.join().unwrap())
});
assert_eq!(results.0, results.1);
store.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT count(*) FROM typed_ticket_events WHERE kind='assignment_answer'",[],|r| r.get::<_,i64>(0))?,1);
assert_eq!(conn.query_row("SELECT count(*) FROM typed_ticket_assignment_tracking_operations WHERE operation_key='concurrent-answer'",[],|r| r.get::<_,i64>(0))?,1);
Ok(())
}).unwrap();
}
+110 -2
View File
@@ -405,6 +405,54 @@ impl SqliteWorkspaceAuthority {
.collect::<Vec<_>>();
predicates.push(format!("t.workflow_state IN ({})", states.join(",")));
}
if let Some(reason) = &query.closure_reason {
let encoded =
serde_json::to_string(reason).map_err(|e| Error::InvalidInput(e.to_string()))?;
let value = bind(SqlValue::Text(encoded));
predicates.push(format!("t.workflow_state='closed' AND EXISTS (SELECT 1 FROM typed_ticket_case_metadata m JOIN typed_ticket_case_history h ON h.workspace_id=m.workspace_id AND h.ticket_id=m.ticket_id AND h.operation_key=m.closure_operation_key WHERE m.workspace_id=t.workspace_id AND m.ticket_id=t.ticket_id AND json_extract(h.judgment_json,'$.action.reason')=json_extract({value},'$'))"));
}
if query.assigned_principal.is_some() || query.handling_status.is_some() {
let mut assignment = vec![
"a.workspace_id=t.workspace_id".to_string(),
"a.ticket_id=t.ticket_id".to_string(),
];
if let Some(principal) = &query.assigned_principal {
match principal {
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { agent_key } => {
let value = bind(SqlValue::Text(agent_key.clone()));
assignment.push(format!(
"a.principal_kind='workspace_agent' AND a.principal_id={value}"
));
}
ticket::TicketAssignmentPrincipalSnapshot::User { account_id } => {
let value = bind(SqlValue::Text(account_id.clone()));
assignment.push(format!(
"a.principal_kind='user' AND a.principal_id={value}"
));
}
ticket::TicketAssignmentPrincipalSnapshot::Worker {
runtime_id,
worker_id,
} => {
let runtime = bind(SqlValue::Text(runtime_id.clone()));
let worker = bind(SqlValue::Text(worker_id.clone()));
assignment.push(format!("a.principal_kind='worker' AND a.runtime_id={runtime} AND a.worker_id={worker}"));
}
}
}
if let Some(status) = &query.handling_status {
let value = match status {
ticket::TicketAssignmentTrackingStatus::Waiting => "waiting",
ticket::TicketAssignmentTrackingStatus::Inprogress => "inprogress",
ticket::TicketAssignmentTrackingStatus::Done => "done",
};
let value = bind(SqlValue::Text(value.into()));
assignment.push(format!(
"a.role='orchestrator' AND json_extract(a.tracking_json,'$.status')={value}"
));
}
predicates.push(format!("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 WHERE {})",assignment.join(" AND ")));
}
for id in &query.tag_ids {
let id = bind(SqlValue::Text(id.clone()));
predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_tags l JOIN workspace_tags tag ON tag.workspace_id=l.workspace_id AND tag.tag_id=l.tag_id WHERE l.workspace_id=t.workspace_id AND l.ticket_id=t.ticket_id AND l.tag_id={id} AND tag.deleted=0)"));
@@ -426,7 +474,9 @@ impl SqliteWorkspaceAuthority {
"(lower(t.title) LIKE {p} OR lower(t.body) LIKE {p} OR EXISTS (
SELECT 1 FROM typed_ticket_events e
WHERE e.workspace_id=t.workspace_id AND e.ticket_id=t.ticket_id
AND lower(e.body) LIKE {p}))",
AND lower(e.body) LIKE {p}) OR EXISTS (
SELECT 1 FROM typed_ticket_case_history h WHERE h.workspace_id=t.workspace_id AND h.ticket_id=t.ticket_id
AND lower(CAST(json_extract(h.judgment_json,'$.action.reason') AS TEXT)) LIKE {p}))",
p = pattern
));
}
@@ -1121,6 +1171,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
let summary = ticket_summary_from_ticket(&authoritative)?;
let authoritative_body = authoritative.document.body.clone();
let authoritative_events = authoritative.events.clone();
let authoritative_case_history = authoritative.case_history.clone();
let detail = self.ticket_detail_from_ticket(
authoritative,
TicketShowRequest {
@@ -1134,6 +1185,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
&detail,
authoritative_body.as_str(),
&authoritative_events,
&authoritative_case_history,
&query,
);
let item = ticket_query_item(
@@ -1141,6 +1193,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
&detail,
authoritative_body.as_str(),
&authoritative_events,
&authoritative_case_history,
&query,
);
last_scanned_cursor = Some(make_ticket_cursor(&item, sort, &fingerprint));
@@ -1993,6 +2046,33 @@ fn ticket_evidence_summary(
}
fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
if let Some(ticket::TicketClosureReason::Other(text)) = &query.closure_reason
&& (text.trim().is_empty() || text.len() > 1024 || text.contains('\0'))
{
return Err(Error::InvalidInput(
"Closure reason Other must be nonblank and at most 1024 bytes without NUL".into(),
));
}
if let Some(principal) = &query.assigned_principal {
let ids: Vec<&str> = match principal {
ticket::TicketAssignmentPrincipalSnapshot::User { account_id } => vec![account_id],
ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { agent_key } => {
vec![agent_key]
}
ticket::TicketAssignmentPrincipalSnapshot::Worker {
runtime_id,
worker_id,
} => vec![runtime_id, worker_id],
};
if ids
.iter()
.any(|id| id.trim().is_empty() || id.len() > 1024 || id.chars().any(char::is_control))
{
return Err(Error::InvalidInput(
"Assigned principal requires bounded stable identities".into(),
));
}
}
for ids in [&query.tag_ids, &query.tag_ids_any, &query.tag_ids_none] {
if ids.len() > 100 || ids.iter().any(|id| id.is_empty() || id.len() > 128) {
return Err(Error::InvalidInput(
@@ -2181,11 +2261,27 @@ fn ticket_attention_matches(
}
}
fn closure_reason_text(action: &ticket::TicketCaseAction) -> Option<String> {
let reason = match action {
ticket::TicketCaseAction::Close { reason }
| ticket::TicketCaseAction::CorrectClosure { reason } => reason,
ticket::TicketCaseAction::Reopen => return None,
};
Some(match reason {
ticket::TicketClosureReason::Completed => "completed".into(),
ticket::TicketClosureReason::NotPlanned => "not_planned".into(),
ticket::TicketClosureReason::Duplicate => "duplicate".into(),
ticket::TicketClosureReason::Unknown => "unknown".into(),
ticket::TicketClosureReason::Other(text) => format!("other {text}"),
})
}
fn ticket_matches_query(
summary: &TicketSummary,
detail: &TicketDetail,
authoritative_body: &str,
authoritative_events: &[TicketEvent],
authoritative_case_history: &[ticket::TicketCaseHistoryEntry],
query: &TicketQueryRequest,
) -> bool {
let has_tag = |id: &String| detail.tags.iter().any(|tag| &tag.id == id);
@@ -2311,7 +2407,10 @@ fn ticket_matches_query(
}
query.query.as_ref().is_none_or(|text| {
let needle = text.to_lowercase();
summary.title.to_lowercase().contains(&needle)
authoritative_case_history.iter().any(|entry| {
closure_reason_text(&entry.action)
.is_some_and(|reason| reason.to_lowercase().contains(&needle))
}) || summary.title.to_lowercase().contains(&needle)
|| authoritative_body.to_lowercase().contains(&needle)
|| authoritative_events
.iter()
@@ -2324,6 +2423,7 @@ fn ticket_query_item(
detail: &TicketDetail,
authoritative_body: &str,
authoritative_events: &[TicketEvent],
authoritative_case_history: &[ticket::TicketCaseHistoryEntry],
query: &TicketQueryRequest,
) -> TicketQueryItem {
let mut matched_fields = Vec::new();
@@ -2331,6 +2431,14 @@ fn ticket_query_item(
let mut matching_event = None;
if let Some(text) = query.query.as_ref() {
let needle = text.to_lowercase();
if let Some(reason) = authoritative_case_history
.iter()
.filter_map(|entry| closure_reason_text(&entry.action))
.find(|reason| reason.to_lowercase().contains(&needle))
{
matched_fields.push("closure_reason".into());
snippet = Some(reason);
}
if summary.title.to_lowercase().contains(&needle) {
matched_fields.push("title".to_string());
snippet = Some(summary.title.clone());
+192 -6
View File
@@ -3097,7 +3097,9 @@ fn is_server_global_forward(path: &str) -> bool {
}
fn is_server_static_forward(path: &str) -> bool {
!path.starts_with("/api/") && !path.starts_with("/internal/")
!path.starts_with("/api/")
&& !path.starts_with("/internal/")
&& canonical_ticket_operation_workspace_id(path).is_none()
}
async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Response {
@@ -3107,7 +3109,7 @@ async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Resp
static_file_or_spa_response(static_root, path).await
}
// Canonical case/tracking routes are deliberately not /api/w aliases. Recognize
// Canonical Ticket operation routes are deliberately not /api/w aliases. Recognize
// only these operations so authentication/scope are identical without exposing CRUD.
fn canonical_ticket_operation_workspace_id(path: &str) -> Option<&str> {
let mut segments = path.strip_prefix("/workspaces/")?.split('/');
@@ -3119,10 +3121,10 @@ fn canonical_ticket_operation_workspace_id(path: &str) -> Option<&str> {
&& resource == "tickets"
&& !ticket_id.is_empty()
&& match operation {
"case" => segments.next().is_none(),
"case" | "assignment-diagnostics" | "assignment-answer" => segments.next().is_none(),
"assignment-tracking" => match segments.next() {
None => true,
Some("history") => segments.next().is_none(),
Some("history" | "assignments") => segments.next().is_none(),
_ => false,
},
_ => false,
@@ -6614,6 +6616,18 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
.merge(server_api::server_api_axum::ticket_case_update(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_assignments_page(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_assignments_page_browser(service.clone()))
.merge(server_api::server_api_axum::ticket_assignment_diagnostics(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_assignment_diagnostics_browser(service.clone()))
.merge(server_api::server_api_axum::ticket_assignment_answer(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_assignment_answer_browser(service.clone()))
.merge(server_api::server_api_axum::ticket_assignment_tracking_get(
service.clone(),
))
@@ -10566,6 +10580,132 @@ impl server_api::ServerApi for ServerApiContractService {
Ok(response)
}
async fn ticket_assignments_page(
&self,
_context: server_api::ServerRequestContext,
workspace_id: String,
ticket_id: String,
query: server_api::TicketAssignmentsQuery,
) -> std::result::Result<server_api::TicketAssignmentsPage, server_api::RepositoryApiError>
{
let api = self.workspace_api()?;
validate_workspace_scope(api, &workspace_id)
.map_err(ApiError::from)
.map_err(ApiError::into_repository_api_error)?;
let ticket_id = resolve_workspace_ticket_reference(api, &workspace_id, &ticket_id)
.map_err(ApiError::from)
.map_err(ApiError::into_repository_api_error)?;
api.store
.page_ticket_assignments(
&workspace_id,
&ticket_id,
query.after_assignment_id.as_deref(),
query.limit.unwrap_or(30),
)
.map(server_api::TicketAssignmentsPage)
.map_err(ApiError::from)
.map_err(ApiError::into_repository_api_error)
}
async fn ticket_assignments_page_browser(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
ticket_id: String,
query: server_api::TicketAssignmentsQuery,
) -> std::result::Result<server_api::TicketAssignmentsPage, server_api::RepositoryApiError>
{
self.ticket_assignments_page(context, workspace_id, ticket_id, query)
.await
}
async fn ticket_assignment_diagnostics(
&self,
_context: server_api::ServerRequestContext,
workspace_id: String,
ticket_id: String,
query: server_api::TicketAssignmentDiagnosticsQuery,
) -> std::result::Result<
server_api::TicketAssignmentDiagnosticsPage,
server_api::RepositoryApiError,
> {
let api = self.workspace_api()?;
validate_workspace_scope(api, &workspace_id)
.map_err(ApiError::from)
.map_err(ApiError::into_repository_api_error)?;
let ticket_id = resolve_workspace_ticket_reference(api, &workspace_id, &ticket_id)
.map_err(ApiError::from)
.map_err(ApiError::into_repository_api_error)?;
api.store
.page_ticket_assignment_diagnostics(
&workspace_id,
&ticket_id,
&query.assignment_id,
query.after_input_event_id.as_deref(),
query.after_start_operation_id.as_deref(),
query.limit.unwrap_or(30),
)
.map_err(ApiError::from)
.map_err(ApiError::into_repository_api_error)
}
async fn ticket_assignment_diagnostics_browser(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
ticket_id: String,
query: server_api::TicketAssignmentDiagnosticsQuery,
) -> std::result::Result<
server_api::TicketAssignmentDiagnosticsPage,
server_api::RepositoryApiError,
> {
self.ticket_assignment_diagnostics(context, workspace_id, ticket_id, query)
.await
}
async fn ticket_assignment_answer(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
ticket_id: String,
request: server_api::TicketAssignmentAnswerRequest,
) -> std::result::Result<
server_api::TicketAssignmentAnswerResponse,
server_api::RepositoryApiError,
> {
let api = self.workspace_api()?;
validate_workspace_scope(api, &workspace_id)
.map_err(ApiError::from)
.map_err(ApiError::into_repository_api_error)?;
let ticket_id = resolve_workspace_ticket_reference(api, &workspace_id, &ticket_id)
.map_err(ApiError::from)
.map_err(ApiError::into_repository_api_error)?;
let (actor, source) =
authenticated_assignment_source(api, &contract_request_headers(&context)?, &context)
.map_err(ApiError::from)
.map_err(ApiError::into_repository_api_error)?;
let receipt = api
.store
.answer_ticket_assignment(&workspace_id, &ticket_id, &request.0, &source, &actor)
.map_err(ApiError::from)
.map_err(ApiError::into_repository_api_error)?;
// The entire event/audit/receipt transaction has committed. Response loss
// retries the saved receipt; delivery scan deduplicates accepted inputs.
if let Some(orchestrator) = find_workspace_orchestrator(api) {
dispatch_orchestrator_assignment_attention(api, &orchestrator.worker);
}
Ok(server_api::TicketAssignmentAnswerResponse(receipt))
}
async fn ticket_assignment_answer_browser(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
ticket_id: String,
request: server_api::TicketAssignmentAnswerRequest,
) -> std::result::Result<
server_api::TicketAssignmentAnswerResponse,
server_api::RepositoryApiError,
> {
self.ticket_assignment_answer(context, workspace_id, ticket_id, request)
.await
}
async fn ticket_assignment_tracking_get(
&self,
_context: server_api::ServerRequestContext,
@@ -48195,6 +48335,42 @@ mod tests {
}
}
async fn author_notification_test_answer(api: &WorkspaceApi, ticket_id: &str, key: &str) {
let current = api
.store
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket_id)
.unwrap()
.remove(0);
let response = build_router(api.clone())
.oneshot(
Request::builder()
.method("POST")
.uri(format!(
"/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket_id}/assignment-answer"
))
.header(
"authorization",
format!("Bearer api-token-{TEST_WORKSPACE_ID}"),
)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
json!({
"assignment_id": current.assignment_id,
"expected_last_event_id": current.last_event_id.unwrap(),
"operation_key": key,
"body": "Explicit continuing input"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
}
#[tokio::test]
async fn orchestrator_ticket_notifications_project_authoritative_post_mutation_state() {
let dir = tempfile::tempdir().unwrap();
@@ -48350,8 +48526,15 @@ mod tests {
.unwrap();
}
assert_eq!(
execution.take_inputs().len(),
1,
"ordinary history must not create unfenced continuing input"
);
seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
author_notification_test_answer(&api, &ticket.id, "notification-explicit-answer").await;
let inputs = execution.take_inputs();
assert_eq!(inputs.len(), 4, "Assign input plus three external events");
assert_eq!(inputs.len(), 1, "one fenced continuing answer");
let unique = inputs
.iter()
.map(|(_, content)| content.clone())
@@ -50357,11 +50540,13 @@ mod tests {
assert!(!serialized.contains("queued update"));
assert!(!serialized.contains("Ticket notification:"));
seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
author_notification_test_answer(&api, &ticket_ref.id, "notification-saved-answer").await;
let notifications = execution.take_inputs();
assert_eq!(
notifications.len(),
2,
"initial delegation plus external comment"
"initial delegation plus fenced answer; ordinary comment is history"
);
assert_eq!(
notifications[0].0.worker_id.to_string(),
@@ -50406,6 +50591,7 @@ mod tests {
api.authority.ticket(&ticket_ref.id).unwrap().state.as_str(),
TicketState::Open.as_str()
);
author_notification_test_answer(&api, &ticket_ref.id, "notification-failed-answer").await;
let warnings = TICKET_NOTIFICATION_DELIVERY_WARNING_CAPTURE
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
@@ -309,7 +309,11 @@ async fn orchestrator_attention_unknown_response_retry_keeps_id_and_payload() {
"existing"
);
let retry = execution.take_inputs();
assert_eq!(retry.len(), 2, "acknowledged retry must also drain the changed backlog");
assert_eq!(
retry.len(),
2,
"acknowledged retry must also drain the changed backlog"
);
assert_eq!(retry[0].1, pending.content);
assert!(retry[1].1.contains("Changed backlog"));
let ids = execution.take_input_request_ids();
@@ -760,11 +764,27 @@ async fn real_attention_worker_start_restore(lose_response: bool) {
assert_eq!(receipts().len(), 1);
assert_eq!(commits(), 1);
// Only a new external input earns another delivery, not restore itself.
browser_ticket_backend(&api)
seed_test_api_token(api.store.as_ref(), "attention-answer");
let observed = api
.store
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket.id)
.unwrap()
.add_event(
ticket.id.clone().into(),
ticket::NewTicketEvent::new(ticket::TicketEventKind::Comment, "New owner answer"),
.remove(0);
api.store
.answer_ticket_assignment(
TEST_WORKSPACE_ID,
&ticket.id,
&ticket::TicketAssignmentAnswer {
assignment_id: observed.assignment_id,
expected_last_event_id: observed.last_event_id.unwrap(),
operation_key: "attention-owner-answer".into(),
body: "New owner answer".into(),
},
&crate::store::TicketOrchestratorAssignmentSourceContext::Account {
account_id: "account-attention-answer".into(),
user_id: "user-attention-answer".into(),
},
"fixture-account",
)
.unwrap();
maybe_dispatch_orchestrator_turn_end(&api, &worker.worker_id, Some(S::Running), S::Idle);
@@ -163,6 +163,41 @@ async fn delivery_self_report(
serde_json::from_slice(&bytes).unwrap()
}
async fn delivery_external_answer(api: &WorkspaceApi, ticket: &str, text: &str, key: &str) {
let current = api
.store
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket)
.unwrap()
.remove(0);
delivery_http(api,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer"),Some(json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":key,"body":text})),StatusCode::OK).await;
}
fn delivery_commit_answer_without_scan(api: &WorkspaceApi, ticket: &str, text: &str, key: &str) {
seed_test_api_token(api.store.as_ref(), "direct-answer");
let current = api
.store
.list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket)
.unwrap()
.remove(0);
api.store
.answer_ticket_assignment(
TEST_WORKSPACE_ID,
ticket,
&ticket::TicketAssignmentAnswer {
assignment_id: current.assignment_id,
expected_last_event_id: current.last_event_id.unwrap(),
operation_key: key.into(),
body: text.into(),
},
&crate::store::TicketOrchestratorAssignmentSourceContext::Account {
account_id: "account-direct-answer".into(),
user_id: "user-direct-answer".into(),
},
"fixture-account",
)
.unwrap();
}
fn delivery_external_comment(api: &WorkspaceApi, ticket: &str, text: &str) {
browser_ticket_backend(api)
.unwrap()
@@ -212,7 +247,18 @@ async fn assignment_waiting_self_report_does_not_loop_but_external_answer_is_del
);
let waiting = delivery_tracking(&api, &ticket.id, assignment);
assert_eq!(serde_json::to_value(&waiting).unwrap(), reported);
delivery_external_comment(&api, &ticket.id, "Human answer: choose option B");
delivery_external_comment(&api, &ticket.id, "History-only annotation while waiting");
assert!(
execution.take_inputs().is_empty(),
"ordinary waiting comment became fresh work"
);
delivery_external_answer(
&api,
&ticket.id,
"Human answer: choose option B",
"choose-B",
)
.await;
let answer = execution.take_inputs();
assert_eq!(
answer.len(),
@@ -369,7 +415,10 @@ async fn assignment_clear_and_reassign_discard_old_unknown_delivery_without_stop
assert!(inputs[0].1.contains(new_assignment));
let ids = execution.take_input_request_ids();
assert_eq!(ids.len(), 1);
assert_ne!(ids[0], old_ids[0], "new assignment reused stale input identity");
assert_ne!(
ids[0], old_ids[0],
"new assignment reused stale input identity"
);
assert_eq!(
delivery_assign(&api, &ticket.id, "assign-old").await,
assigned
@@ -404,7 +453,8 @@ async fn assignment_or_answer_committed_during_empty_scan_is_delivered_without_a
&ticket.id,
assigned["assignment"]["assignment_id"].as_str().unwrap(),
);
delivery_self_report(&api, &identity, &worker, &current, "waiting", "wait-answer").await;
delivery_self_report(&api, &identity, &worker, &current, "waiting", "wait-answer")
.await;
execution.take_inputs();
execution.take_input_request_ids();
}
@@ -422,29 +472,33 @@ async fn assignment_or_answer_committed_during_empty_scan_is_delivered_without_a
true,
|state, pending| {
check_orchestrator_assignment_attention(
&caller_api, &caller_worker, state, pending,
&caller_api,
&caller_worker,
state,
pending,
);
if checks == 0 {
// The production scan has read an empty snapshot but
// still owns its reservation. Race a real HTTP commit.
entered_tx.send(()).unwrap();
release_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
release_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
}
checks += 1;
},
);
assert_eq!(checks, 2, "committed input must defer a scan");
});
entered_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
entered_rx
.recv_timeout(std::time::Duration::from_secs(5))
.unwrap();
if answer {
futures::executor::block_on(delivery_http(
futures::executor::block_on(delivery_external_answer(
&api,
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/thread-events", ticket.id),
Some(serde_json::to_value(NewTicketEvent::new(
TicketEventKind::Comment, "Human answer: go ahead",
)).unwrap()),
StatusCode::NO_CONTENT,
&ticket.id,
"Human answer: go ahead",
"racing-answer",
));
} else {
futures::executor::block_on(delivery_assign(&api, &ticket.id, "racing-assign"));
@@ -456,7 +510,11 @@ async fn assignment_or_answer_committed_during_empty_scan_is_delivered_without_a
});
let inputs = execution.take_inputs();
assert_eq!(inputs.len(), 1, "idle Orchestrator was stranded");
assert!(inputs[0].1.contains(ticket.resource_key.as_deref().unwrap()));
assert!(
inputs[0]
.1
.contains(ticket.resource_key.as_deref().unwrap())
);
if answer {
assert!(inputs[0].1.contains("Need the human's choice"));
}
@@ -476,15 +534,26 @@ async fn assignment_failed_delivery_and_its_reentrant_idle_do_not_schedule_retry
execution.before_input_returns(move |worker| {
use protocol::subscription::SubscriptionWorkerState as S;
maybe_dispatch_orchestrator_turn_end(
&callback_api, &worker.worker_id.to_string(), Some(S::Running), S::Idle,
&callback_api,
&worker.worker_id.to_string(),
Some(S::Running),
S::Idle,
);
});
delivery_assign(&api, &ticket.id, "assign-failed").await;
assert_eq!(execution.take_inputs().len(), 2, "idle must not add retries beyond the bounded send");
assert_eq!(
execution.take_inputs().len(),
2,
"idle must not add retries beyond the bounded send"
);
let ids = execution.take_input_request_ids();
assert_eq!(ids.len(), 2);
assert_eq!(ids[0], ids[1]);
assert_eq!(attention_pending(&api).len(), 1, "failure must retain durable input");
assert_eq!(
attention_pending(&api).len(),
1,
"failure must retain durable input"
);
delivery_stop(&api, &worker);
}
@@ -796,8 +865,22 @@ async fn delivery_wait_idle(
"duplicate canonical delivery: {:?}",
delivery_commits(api, worker)
);
let entries = delivery_session_entries(api, worker);
let last_notification = entries.iter().rposition(|entry| matches!(
entry,
LogEntry::AnnotatedSystemItem { entry, .. }
if matches!(entry.item, session_store::SystemItem::Notification { .. })
));
let last_completed = entries.iter().rposition(|entry| matches!(entry, LogEntry::RunCompleted { .. }));
// Runtime acceptance and the saved notification precede processing.
// Initial Idle can therefore be observed before any model request.
// Require a committed run outcome after the latest input, not a
// guessed delay or Worker liveness as proof of consumption.
let processed = last_notification.zip(last_completed)
.is_some_and(|(input, completed)| completed > input);
let summary = api.runtime.worker(worker).unwrap();
if commits == expected_commits
&& processed
&& summary
.worker_state
.as_ref()
@@ -962,16 +1045,12 @@ async fn assignment_real_worker_response_loss_restore_and_concurrent_scans_commi
waiting
);
let before = execution.attempts.lock().unwrap().len();
browser_ticket_backend(&reopened)
.unwrap()
.add_event(
ticket.id.clone().into(),
ticket::NewTicketEvent::new(
ticket::TicketEventKind::Comment,
"External answer after server restart",
),
)
.unwrap();
delivery_commit_answer_without_scan(
&reopened,
&ticket.id,
"External answer after server restart",
"answer-after-restart",
);
let barrier = Arc::new(std::sync::Barrier::new(8));
std::thread::scope(|scope| {
let joins = (0..8)
@@ -68,9 +68,13 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_
})).unwrap();
tracking_response(
app.clone(),
Request::builder().method("PUT").uri(&worker_path).header(CONTENT_TYPE, "application/json")
Request::builder()
.method("PUT")
.uri(&worker_path)
.header(CONTENT_TYPE, "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(worker_input)).unwrap(),
.body(Body::from(worker_input))
.unwrap(),
StatusCode::OK,
)
.await;
@@ -127,7 +131,10 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_
StatusCode::OK,
)
.await;
assert_ne!(fresh["assignment"]["assignment_id"].as_str(), Some(assignment));
assert_ne!(
fresh["assignment"]["assignment_id"].as_str(),
Some(assignment)
);
assert_eq!(
api.store
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, assignment)
@@ -146,9 +153,18 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_
.unwrap(),
active
);
assert_eq!(api.store.get_current_ticket_role_assignment(TEST_WORKSPACE_ID, &ticket,
TicketAssignmentRole::Orchestrator).unwrap().unwrap().assignment_id,
fresh["assignment"]["assignment_id"].as_str().unwrap());
assert_eq!(
api.store
.get_current_ticket_role_assignment(
TEST_WORKSPACE_ID,
&ticket,
TicketAssignmentRole::Orchestrator
)
.unwrap()
.unwrap()
.assignment_id,
fresh["assignment"]["assignment_id"].as_str().unwrap()
);
}
}
@@ -1494,3 +1510,550 @@ async fn assignment_signed_runtime_without_worker_cannot_self_assign_service_rol
.is_empty()
);
}
#[tokio::test]
async fn public_assignment_answer_authenticates_source_replays_saved_result_and_rejects_read_reassign_race()
{
let directory = tempfile::tempdir().unwrap();
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
let current = assign_tracking(&api, &identity, &source, &ticket).await;
let token = seed_test_api_token(api.store.as_ref(), "answer-owner");
let app = build_router(api.clone());
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer");
let body = serde_json::json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":"answer-op","body":"Choose the bounded option"});
let account_request = |body: &serde_json::Value| {
Request::builder()
.method("POST")
.uri(&path)
.header(CONTENT_TYPE, "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(serde_json::to_vec(body).unwrap()))
.unwrap()
};
tracking_response(
app.clone(),
Request::builder()
.method("POST")
.uri(&path)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
StatusCode::UNAUTHORIZED,
)
.await;
let saved = tracking_response(app.clone(), account_request(&body), StatusCode::OK).await;
assert_eq!(saved["assignment_id"], current.assignment_id);
let backend = browser_ticket_backend(&api).unwrap();
let authored = backend.show(ticket.clone().into()).unwrap();
assert_eq!(
authored
.events
.iter()
.filter(|e| e.kind.as_str() == "assignment_answer")
.count(),
1
);
let event = authored
.events
.iter()
.find(|e| e.kind.as_str() == "assignment_answer")
.unwrap();
assert!(
event
.author
.as_deref()
.unwrap()
.starts_with("workspace-user:")
);
assert_eq!(event.body.as_str(), "Choose the bounded option");
assert_eq!(
api.store
.get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, &current.assignment_id)
.unwrap()
.unwrap(),
current
);
assert_eq!(
tracking_response(app.clone(), account_request(&body), StatusCode::OK).await,
saved
);
let mut changed = body.clone();
changed["body"] = "Changed payload".into();
tracking_response(app.clone(), account_request(&changed), StatusCode::CONFLICT).await;
let mut spoof = body.clone();
spoof["actor"] = "owner".into();
tracking_response(
app.clone(),
account_request(&spoof),
StatusCode::UNPROCESSABLE_ENTITY,
)
.await;
let assignment_path =
format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
tracking_response(app.clone(),runtime_source_request(&identity,Some(&source.worker_id),"PUT",&assignment_path,serde_json::to_vec(&serde_json::json!({"operation_id":"replace-after-read","expected_assignment_id":current.assignment_id,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap()),StatusCode::OK).await;
let mut stale = body.clone();
stale["operation_key"] = "fresh-stale-answer".into();
tracking_response(app.clone(), account_request(&stale), StatusCode::CONFLICT).await;
assert_eq!(
tracking_response(app, account_request(&body), StatusCode::OK).await,
saved
);
assert_eq!(
backend
.show(ticket.into())
.unwrap()
.events
.iter()
.filter(|e| e.kind.as_str() == "assignment_answer")
.count(),
1
);
}
#[tokio::test]
async fn public_assignment_pages_and_diagnostics_require_scoped_read_and_reject_foreign_cursors() {
let directory = tempfile::tempdir().unwrap();
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
let current = assign_tracking(&api, &identity, &source, &ticket).await;
let token = seed_test_api_token(api.store.as_ref(), "diagnostics-reader");
let app = build_router(api.clone());
for prefix in [
format!("/api/w/{TEST_WORKSPACE_ID}"),
format!("/workspaces/{TEST_WORKSPACE_ID}"),
] {
let path = format!("{prefix}/tickets/{ticket}/assignment-tracking/assignments?limit=1");
tracking_response(
app.clone(),
Request::builder().uri(&path).body(Body::empty()).unwrap(),
StatusCode::UNAUTHORIZED,
)
.await;
let request = |path: String| {
Request::builder()
.uri(path)
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap()
};
let page = tracking_response(app.clone(), request(path), StatusCode::OK).await;
assert_eq!(page["items"][0]["assignment_id"], current.assignment_id);
assert!(page["next_assignment_id"].is_null());
let path = format!(
"{prefix}/tickets/{ticket}/assignment-diagnostics?assignment_id={}&limit=1",
current.assignment_id
);
let diagnostic =
tracking_response(app.clone(), request(path.clone()), StatusCode::OK).await;
assert_eq!(diagnostic["assignment_id"], current.assignment_id);
assert_eq!(diagnostic["input_origin_known"], true);
assert!(diagnostic.get("payload_json").is_none());
tracking_response(
app.clone(),
request(format!("{path}&after_input_event_id=ticket-event:999")),
StatusCode::BAD_REQUEST,
)
.await;
tracking_response(app.clone(),request(format!("{prefix}/tickets/{ticket}/assignment-tracking/assignments?after_assignment_id=foreign")),StatusCode::BAD_REQUEST).await;
tracking_response(
app.clone(),
request(format!(
"{prefix}/tickets/{ticket}/assignment-diagnostics?assignment_id={}&limit=101",
current.assignment_id
)),
StatusCode::BAD_REQUEST,
)
.await;
}
let path = format!(
"/api/w/another-workspace/tickets/{ticket}/assignment-diagnostics?assignment_id={}",
current.assignment_id
);
let response = app
.oneshot(
Request::builder()
.uri(path)
.header("authorization", format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert!(!response.status().is_success());
}
#[tokio::test]
async fn ticket_query_principal_and_handling_filter_before_single_item_page_without_worker_inference()
{
let directory = tempfile::tempdir().unwrap();
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
let current = assign_tracking(&api, &identity, &source, &ticket).await;
tracking_response(
build_router(api.clone()),
signed_tracking_update(
&identity,
Some(&source.worker_id),
&ticket,
&tracking_update(&current, "query-done"),
),
StatusCode::OK,
)
.await;
let backend = browser_ticket_backend(&api).unwrap();
for n in 0..8 {
backend
.create(ticket::NewTicket::new(format!("Unassigned newer {n}")))
.unwrap();
}
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"done","assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"limit":1})).unwrap();
let found = api.authority.query_tickets(query).unwrap();
assert_eq!(found.items.len(), 1);
assert_eq!(found.items[0].id, ticket);
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"waiting","assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"limit":1})).unwrap();
assert!(api.authority.query_tickets(query).unwrap().items.is_empty());
let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"assigned_principal":{"kind":"worker","runtime_id":source.runtime_id,"worker_id":source.worker_id},"limit":1})).unwrap();
assert!(api.authority.query_tickets(query).unwrap().items.is_empty());
}
#[test]
fn canonical_assignment_operations_are_explicit_scoped_api_routes_not_static_shell_paths() {
for suffix in [
"case",
"assignment-tracking",
"assignment-tracking/history",
"assignment-tracking/assignments",
"assignment-diagnostics",
"assignment-answer",
] {
let path = format!("/workspaces/workspace-a/tickets/ticket-a/{suffix}");
assert_eq!(
canonical_ticket_operation_workspace_id(&path),
Some("workspace-a")
);
assert_eq!(scoped_workspace_id(&path), Some("workspace-a"));
assert!(!is_server_static_forward(&path));
assert!(!is_server_global_forward(&path));
assert!(canonical_ticket_operation_workspace_id(&format!("{path}/extra")).is_none());
}
for invalid in [
"/workspaces//tickets/t/assignment-answer",
"/workspaces/w/tickets//assignment-diagnostics",
"/workspaces/w/tickets/t",
"/workspaces/w/tickets/t/unknown",
"/workspaces/w/tickets/t/assignment-tracking/unknown",
"/workspaces/w/workers/t/assignment-answer",
] {
assert!(
canonical_ticket_operation_workspace_id(invalid).is_none(),
"{invalid}"
);
}
}
#[tokio::test]
async fn catalog_assignment_routes_apply_cookie_workspace_auth_and_origin_to_browser_and_canonical_paths()
{
let directory = tempfile::tempdir().unwrap();
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
let current = assign_tracking(&api, &identity, &source, &ticket).await;
seed_test_api_token(api.store.as_ref(), "assignment-cookie");
api.store
.create_browser_session(&BrowserSessionRecord {
token_hash: crate::auth::token_hash("assignment-browser-session"),
session_id: "assignment-session".into(),
user_id: "user-assignment-cookie".into(),
created_at: "2026-01-01T00:00:00Z".into(),
expires_at: "2099-01-01T00:00:00Z".into(),
revoked_at: None,
})
.unwrap();
let AuthConfig::Passkey {
origin,
cookie_name,
..
} = &api.config.auth;
let cookie = format!("{cookie_name}=assignment-browser-session");
let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone());
catalog
.apis
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), api.clone());
catalog
.routers
.lock()
.await
.insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone()));
let app = workspace_server_router(catalog);
let body = serde_json::json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":"cookie-answer","body":"Authorized browser continuation"});
let mut receipt = None;
for prefix in [
format!("/api/w/{TEST_WORKSPACE_ID}"),
format!("/workspaces/{TEST_WORKSPACE_ID}"),
] {
let identity_path =
format!("{prefix}/tickets/{ticket}/assignment-tracking/assignments?limit=1");
let diagnostic_path = format!(
"{prefix}/tickets/{ticket}/assignment-diagnostics?assignment_id={}",
current.assignment_id
);
for path in [identity_path, diagnostic_path] {
tracking_response(
app.clone(),
Request::builder().uri(&path).body(Body::empty()).unwrap(),
StatusCode::UNAUTHORIZED,
)
.await;
tracking_response(
app.clone(),
Request::builder()
.uri(path)
.header(axum::http::header::COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
StatusCode::OK,
)
.await;
}
let answer_path = format!("{prefix}/tickets/{ticket}/assignment-answer");
let request = |with_origin: bool| {
let mut request = Request::builder()
.method("POST")
.uri(&answer_path)
.header(CONTENT_TYPE, "application/json")
.header(axum::http::header::COOKIE, &cookie);
if with_origin {
request = request.header(ORIGIN, origin);
}
request
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap()
};
tracking_response(app.clone(), request(false), StatusCode::FORBIDDEN).await;
let saved = tracking_response(app.clone(), request(true), StatusCode::OK).await;
if let Some(previous) = &receipt {
assert_eq!(&saved, previous);
} else {
receipt = Some(saved);
}
let cross = answer_path.replace(TEST_WORKSPACE_ID, "foreign-workspace");
tracking_response(
app.clone(),
Request::builder()
.method("POST")
.uri(cross)
.header(CONTENT_TYPE, "application/json")
.header(axum::http::header::COOKIE, &cookie)
.header(ORIGIN, origin)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
StatusCode::NOT_FOUND,
)
.await;
}
let events = browser_ticket_backend(&api)
.unwrap()
.show(ticket.into())
.unwrap()
.events;
let answers = events
.iter()
.filter(|event| event.kind.as_str() == "assignment_answer")
.collect::<Vec<_>>();
assert_eq!(answers.len(), 1);
assert!(
answers[0]
.author
.as_deref()
.unwrap()
.contains("account-assignment-cookie")
);
}
#[tokio::test]
async fn canonical_answer_uses_actual_runtime_binding_and_rejects_tampered_source_or_binding_replay()
{
let directory = tempfile::tempdir().unwrap();
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
let current = assign_tracking(&api, &identity, &source, &ticket).await;
let path = format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer");
let input = serde_json::json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":"runtime-answer","body":"Runtime-authenticated continuation"});
let request = || {
runtime_source_request(
&identity,
Some(&source.worker_id),
"POST",
&path,
serde_json::to_vec(&input).unwrap(),
)
};
let app = build_router(api.clone());
let mut unsigned = request();
unsigned
.headers_mut()
.remove(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER);
tracking_response(app.clone(), unsigned, StatusCode::UNAUTHORIZED).await;
let mut tampered = request();
*tampered.body_mut() = Body::from("{}");
tracking_response(app.clone(), tampered, StatusCode::UNAUTHORIZED).await;
let attacker = RuntimeIdentityMaterial::generate(&source.runtime_id).unwrap();
tracking_response(
app.clone(),
runtime_source_request(
&attacker,
Some(&source.worker_id),
"POST",
&path,
serde_json::to_vec(&input).unwrap(),
),
StatusCode::UNAUTHORIZED,
)
.await;
tracking_response(
app.clone(),
runtime_source_request(
&identity,
None,
"POST",
&path,
serde_json::to_vec(&input).unwrap(),
),
StatusCode::FORBIDDEN,
)
.await;
let saved = tracking_response(app.clone(), request(), StatusCode::OK).await;
assert_eq!(
tracking_response(app.clone(), request(), StatusCode::OK).await,
saved
);
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
let binding:String=conn.query_row("SELECT binding_id FROM workspace_runtime_bindings WHERE workspace_id=?1 AND runtime_id=?2",rusqlite::params![TEST_WORKSPACE_ID,source.runtime_id],|r| r.get(0)).unwrap();
let receipt_source:String=conn.query_row("SELECT source_context_json FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key='runtime-answer'",[TEST_WORKSPACE_ID],|r| r.get(0)).unwrap();
let receipt_source: serde_json::Value = serde_json::from_str(&receipt_source).unwrap();
assert_eq!(
receipt_source["source"]["authentication_binding_id"],
binding
);
assert_eq!(receipt_source["source"]["worker_id"], source.worker_id);
conn.execute("UPDATE workspace_runtime_bindings SET binding_id='new-answer-binding' WHERE workspace_id=?1 AND runtime_id=?2",rusqlite::params![TEST_WORKSPACE_ID,source.runtime_id]).unwrap();
tracking_response(app.clone(), request(), StatusCode::CONFLICT).await;
let foreign = path.replace(TEST_WORKSPACE_ID, "foreign-workspace");
let response = app
.oneshot(runtime_source_request(
&identity,
Some(&source.worker_id),
"POST",
&foreign,
serde_json::to_vec(&input).unwrap(),
))
.await
.unwrap();
assert!(!response.status().is_success());
assert_eq!(
browser_ticket_backend(&api)
.unwrap()
.show(ticket.into())
.unwrap()
.events
.iter()
.filter(|event| event.kind.as_str() == "assignment_answer")
.count(),
1
);
}
#[tokio::test]
async fn closure_reason_filter_matches_current_judgment_before_paging_and_text_search_retains_old_reasons()
{
let directory = tempfile::tempdir().unwrap();
let api = test_api(directory.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let reference = backend
.create(ticket::NewTicket::new("Reason classification"))
.unwrap();
let mutate = |key: &str, action: ticket::TicketCaseAction| {
let observed = backend.show(reference.id.clone().into()).unwrap();
backend
.update_case(
&reference.id,
ticket::TicketCaseUpdate {
expected_content_digest: ticket::ticket_content_digest(&observed),
expected_state: observed.meta.workflow_state,
expected_closure_operation_key: observed.closure_operation_key,
operation_key: key.into(),
action,
explanation: "Case decision explanation, no reason name here".into(),
references: vec![],
author: Some("human".into()),
},
)
.unwrap();
};
mutate(
"close-not-planned",
ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::NotPlanned,
},
);
for n in 0..8 {
backend
.create(ticket::NewTicket::new(format!("Newer unrelated {n}")))
.unwrap();
}
let query = |value: serde_json::Value| {
api.authority
.query_tickets(serde_json::from_value(value).unwrap())
.unwrap()
};
assert_eq!(
query(json!({"closure_reason":"not_planned","limit":1})).items[0].id,
reference.id
);
mutate(
"correct-duplicate",
ticket::TicketCaseAction::CorrectClosure {
reason: ticket::TicketClosureReason::Duplicate,
},
);
assert!(
query(json!({"closure_reason":"not_planned","limit":1}))
.items
.is_empty()
);
assert_eq!(
query(json!({"closure_reason":"duplicate","limit":1})).items[0].id,
reference.id
);
assert_eq!(
query(json!({"query":"not_planned","limit":1})).items[0].matched_fields,
vec!["closure_reason"]
);
mutate("reopen", ticket::TicketCaseAction::Reopen);
assert!(
query(json!({"closure_reason":"duplicate","limit":1}))
.items
.is_empty()
);
assert_eq!(
query(json!({"query":"duplicate","limit":1})).items[0].id,
reference.id
);
mutate(
"close-custom",
ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::Other("Superseded plan".into()),
},
);
assert_eq!(
query(json!({"closure_reason":{"other":"Superseded plan"},"limit":1})).items[0].id,
reference.id
);
assert!(
query(json!({"closure_reason":{"other":"Other plan"},"limit":1}))
.items
.is_empty()
);
assert_eq!(
query(json!({"query":"Superseded plan","limit":1})).items[0].id,
reference.id
);
}
+68
View File
@@ -1889,6 +1889,30 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
) -> Result<Option<TicketRoleAssignmentRecord>>;
/// Retained responsibility, including ended work and removed principals.
/// Use the active variants for admission, routing, authorization or removal.
fn page_ticket_assignments(
&self,
workspace_id: &str,
ticket_id: &str,
after_assignment_id: Option<&str>,
limit: usize,
) -> Result<ticket::TicketAssignmentsPage>;
fn page_ticket_assignment_diagnostics(
&self,
workspace_id: &str,
ticket_id: &str,
assignment_id: &str,
after_input_event_id: Option<&str>,
after_start_operation_id: Option<&str>,
limit: usize,
) -> Result<server_api::TicketAssignmentDiagnosticsPage>;
fn answer_ticket_assignment(
&self,
workspace_id: &str,
ticket_id: &str,
input: &ticket::TicketAssignmentAnswer,
source: &TicketOrchestratorAssignmentSourceContext,
actor: &str,
) -> Result<ticket::TicketAssignmentAnswerReceipt>;
fn update_ticket_assignment_tracking(
&self,
workspace_id: &str,
@@ -7872,6 +7896,50 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn page_ticket_assignments(
&self,
workspace_id: &str,
ticket_id: &str,
after_assignment_id: Option<&str>,
limit: usize,
) -> Result<ticket::TicketAssignmentsPage> {
assignment_tracking::assignments_page(
self,
workspace_id,
ticket_id,
after_assignment_id,
limit,
)
}
fn page_ticket_assignment_diagnostics(
&self,
workspace_id: &str,
ticket_id: &str,
assignment_id: &str,
after_input_event_id: Option<&str>,
after_start_operation_id: Option<&str>,
limit: usize,
) -> Result<server_api::TicketAssignmentDiagnosticsPage> {
assignment_delivery::diagnostics_page(
self,
workspace_id,
ticket_id,
assignment_id,
after_input_event_id,
after_start_operation_id,
limit,
)
}
fn answer_ticket_assignment(
&self,
workspace_id: &str,
ticket_id: &str,
input: &ticket::TicketAssignmentAnswer,
source: &TicketOrchestratorAssignmentSourceContext,
actor: &str,
) -> Result<ticket::TicketAssignmentAnswerReceipt> {
assignment_tracking::answer(self, workspace_id, ticket_id, input, source, actor)
}
fn update_ticket_assignment_tracking(
&self,
workspace_id: &str,