feat(ticket): expose case tags and independent assignment handling
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Continuing input is authored against an observed delegation, never a new Assign.
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketAssignmentAnswer {
|
||||
pub assignment_id: String,
|
||||
pub expected_last_event_id: String,
|
||||
pub operation_key: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketAssignmentAnswerReceipt {
|
||||
pub assignment_id: String,
|
||||
pub input_event_id: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub event_index: u64,
|
||||
}
|
||||
|
||||
/// Host owns the IMMEDIATE transaction and authenticates/fences its source before
|
||||
/// calling, including on replay. Reuse Ticket's existing immutable operation
|
||||
/// receipts with a tagged input; these are not handling events or another ledger.
|
||||
pub fn sqlite_answer_ticket_assignment_in_transaction(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
answer: &TicketAssignmentAnswer,
|
||||
source_context: &serde_json::Value,
|
||||
actor: &str,
|
||||
) -> Result<TicketAssignmentAnswerReceipt> {
|
||||
for value in [
|
||||
&answer.assignment_id,
|
||||
&answer.expected_last_event_id,
|
||||
&answer.operation_key,
|
||||
actor,
|
||||
] {
|
||||
if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
|
||||
return Err(TicketError::Conflict(
|
||||
"Answer requires bounded nonempty identities".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if answer.body.trim().is_empty() || answer.body.len() > 16384 || answer.body.contains('\0') {
|
||||
return Err(TicketError::Conflict(
|
||||
"Answer body must be nonempty, at most 16384 bytes and without NUL".into(),
|
||||
));
|
||||
}
|
||||
let input =
|
||||
serde_json::to_string(&serde_json::json!({"kind":"assignment_answer", "answer":answer}))
|
||||
.map_err(sqlite_err)?;
|
||||
let source = serde_json::to_string(
|
||||
&serde_json::json!({"kind":"assignment_answer", "source":source_context, "actor":actor}),
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let receipt: Option<(String,String,String,String)> = conn.query_row(
|
||||
"SELECT ticket_id,input_json,source_context_json,result_json FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key=?2",
|
||||
params![workspace_id, answer.operation_key], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional().map_err(sqlite_err)?;
|
||||
if let Some((saved_ticket, saved_input, saved_source, saved_result)) = receipt {
|
||||
if saved_ticket != ticket_id || saved_input != input || saved_source != source {
|
||||
return Err(TicketError::OperationFingerprintMismatch {
|
||||
operation_key: answer.operation_key.clone(),
|
||||
});
|
||||
}
|
||||
return serde_json::from_str(&saved_result).map_err(sqlite_err);
|
||||
}
|
||||
let current =
|
||||
sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &answer.assignment_id)?
|
||||
.ok_or_else(|| {
|
||||
TicketError::Conflict("Answer assignment does not exist in this Ticket".into())
|
||||
})?;
|
||||
if !current.current
|
||||
|| current.last_event_id.as_deref() != Some(&answer.expected_last_event_id)
|
||||
|| !current.tracking.as_ref().is_some_and(|tracking| {
|
||||
matches!(
|
||||
tracking.status,
|
||||
TicketAssignmentTrackingStatus::Waiting
|
||||
| TicketAssignmentTrackingStatus::Inprogress
|
||||
)
|
||||
})
|
||||
{
|
||||
return Err(TicketError::Conflict("Answer observed assignment or handling event is stale; read current responsibility before authoring".into()));
|
||||
}
|
||||
let open: bool = conn.query_row("SELECT workflow_state='open' FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", params![workspace_id,ticket_id], |r| r.get(0)).map_err(sqlite_err)?;
|
||||
if !open {
|
||||
return Err(TicketError::Conflict(
|
||||
"A closed Ticket does not admit continuing work input".into(),
|
||||
));
|
||||
}
|
||||
let index: i64 = conn.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![workspace_id,ticket_id],|r| r.get(0)).map_err(sqlite_err)?;
|
||||
let at = now_utc();
|
||||
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,heading,body) VALUES(?1,?2,?3,'assignment_answer',?4,?5,'Assignment answer',?6)", params![workspace_id,ticket_id,index,actor,at,answer.body]).map_err(sqlite_err)?;
|
||||
for (key, value) in [
|
||||
("orchestrator_assignment_id", answer.assignment_id.as_str()),
|
||||
(
|
||||
"expected_last_event_id",
|
||||
answer.expected_last_event_id.as_str(),
|
||||
),
|
||||
("operation_key", answer.operation_key.as_str()),
|
||||
("authenticated_source", source.as_str()),
|
||||
] {
|
||||
conn.execute("INSERT INTO typed_ticket_event_attributes(workspace_id,ticket_id,event_index,key,value) VALUES(?1,?2,?3,?4,?5)",params![workspace_id,ticket_id,index,key,value]).map_err(sqlite_err)?;
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET updated_at=?3 WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![workspace_id, ticket_id, at],
|
||||
)
|
||||
.map_err(sqlite_err)?;
|
||||
let result = TicketAssignmentAnswerReceipt {
|
||||
assignment_id: answer.assignment_id.clone(),
|
||||
input_event_id: format!("ticket-event:{index}"),
|
||||
event_index: index as u64,
|
||||
};
|
||||
let result_json = serde_json::to_string(&result).map_err(sqlite_err)?;
|
||||
conn.execute("INSERT INTO typed_ticket_assignment_tracking_operations(workspace_id,operation_key,ticket_id,input_json,source_context_json,result_json) VALUES(?1,?2,?3,?4,?5,?6)",params![workspace_id,answer.operation_key,ticket_id,input,source,result_json]).map_err(sqlite_err)?;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -898,3 +898,53 @@ pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()>
|
||||
#[cfg(test)]
|
||||
#[path = "assignment_tracking_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketAssignmentsPage {
|
||||
pub items: Vec<TicketAssignmentTrackingProjection>,
|
||||
pub next_assignment_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Immutable append-order identities. The cursor must be an identity in this
|
||||
/// Ticket/Workspace; timestamps and lexical IDs are not insertion order.
|
||||
pub fn sqlite_ticket_assignments_page(
|
||||
conn: &Connection,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
after_assignment_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<TicketAssignmentsPage> {
|
||||
if !(1..=100).contains(&limit) {
|
||||
return Err(TicketError::Conflict(
|
||||
"Assignment page limit must be 1..100".into(),
|
||||
));
|
||||
}
|
||||
let after: i64 = match after_assignment_id {
|
||||
None => 0,
|
||||
Some(id) => conn.query_row("SELECT rowid FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND assignment_id=?3",params![workspace_id,ticket_id,id],|r| r.get(0)).optional().map_err(sqlite_err)?
|
||||
.ok_or_else(|| TicketError::Conflict("Assignment cursor does not belong to this Ticket".into()))?,
|
||||
};
|
||||
let mut stmt = conn.prepare("SELECT assignment_id FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND rowid>?3 ORDER BY rowid LIMIT ?4").map_err(sqlite_err)?;
|
||||
let mut ids = stmt
|
||||
.query_map(
|
||||
params![workspace_id, ticket_id, after, (limit + 1) as i64],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.map_err(sqlite_err)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(sqlite_err)?;
|
||||
let has_more = ids.len() > limit;
|
||||
ids.truncate(limit);
|
||||
let next_assignment_id = if has_more { ids.last().cloned() } else { None };
|
||||
let items = ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, id)?
|
||||
.ok_or_else(|| TicketError::Conflict("Assignment disappeared while paging".into()))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(TicketAssignmentsPage {
|
||||
items,
|
||||
next_assignment_id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -728,3 +728,104 @@ fn schema_verification_does_not_normalize_away_case_sensitive_authority_literals
|
||||
.contains("differs from canonical schema")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_answer_commits_one_audited_input_and_exact_receipt_without_tracking_mutation() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
let answer = TicketAssignmentAnswer {
|
||||
assignment_id: "a".into(),
|
||||
expected_last_event_id: "assigned-a".into(),
|
||||
operation_key: "answer-a".into(),
|
||||
body: "Continue with B".into(),
|
||||
};
|
||||
let source = serde_json::json!({"kind":"account","account_id":"account","user_id":"human"});
|
||||
let apply = |answer: &TicketAssignmentAnswer| {
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
let result = sqlite_answer_ticket_assignment_in_transaction(
|
||||
&conn, "w", "t", answer, &source, "human",
|
||||
);
|
||||
conn.execute_batch(if result.is_ok() { "COMMIT" } else { "ROLLBACK" })
|
||||
.unwrap();
|
||||
result
|
||||
};
|
||||
let result = apply(&answer).unwrap();
|
||||
assert_eq!(result.input_event_id, "ticket-event:0");
|
||||
assert_eq!(apply(&answer).unwrap(), result);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT body FROM typed_ticket_events", [], |r| r
|
||||
.get::<_, String>(0))
|
||||
.unwrap(),
|
||||
answer.body
|
||||
);
|
||||
assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE key='orchestrator_assignment_id'",[],|r| r.get::<_,String>(0)).unwrap(),"a");
|
||||
assert_eq!(
|
||||
sqlite_ticket_assignment_tracking(&conn, "w", "t", "a")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.last_event_id
|
||||
.as_deref(),
|
||||
Some("assigned-a")
|
||||
);
|
||||
let mut altered = answer.clone();
|
||||
altered.body = "Changed".into();
|
||||
assert!(apply(&altered).is_err());
|
||||
assign(&conn, "b", "assigned-b");
|
||||
assert_eq!(apply(&answer).unwrap(), result);
|
||||
altered.operation_key = "fresh-stale".into();
|
||||
assert!(apply(&altered).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_answer_rejects_unbounded_body_and_malformed_identities_without_appending_input() {
|
||||
let conn = db();
|
||||
assign(&conn, "a", "assigned-a");
|
||||
let original = TicketAssignmentAnswer {
|
||||
assignment_id: "a".into(),
|
||||
expected_last_event_id: "assigned-a".into(),
|
||||
operation_key: "answer-a".into(),
|
||||
body: "Answer".into(),
|
||||
};
|
||||
for field in [
|
||||
"body-large",
|
||||
"body-nul",
|
||||
"body-empty",
|
||||
"identity-control",
|
||||
"event-empty",
|
||||
] {
|
||||
let mut answer = original.clone();
|
||||
match field {
|
||||
"body-large" => answer.body = "a".repeat(16385),
|
||||
"body-nul" => answer.body = "answer\0hidden".into(),
|
||||
"body-empty" => answer.body = " ".into(),
|
||||
"identity-control" => answer.operation_key = "key\n".into(),
|
||||
_ => answer.expected_last_event_id = "".into(),
|
||||
}
|
||||
conn.execute_batch("BEGIN IMMEDIATE").unwrap();
|
||||
assert!(
|
||||
sqlite_answer_ticket_assignment_in_transaction(
|
||||
&conn,
|
||||
"w",
|
||||
"t",
|
||||
&answer,
|
||||
&serde_json::json!({"actor":"source"}),
|
||||
"human"
|
||||
)
|
||||
.is_err(),
|
||||
"{field}"
|
||||
);
|
||||
conn.execute_batch("ROLLBACK").unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r
|
||||
.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use thiserror::Error;
|
||||
|
||||
mod assignment_answer;
|
||||
pub use assignment_answer::*;
|
||||
mod assignment_tracking;
|
||||
pub mod config;
|
||||
mod sqlite_schema;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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, ¤t, "waiting", "wait-answer").await;
|
||||
delivery_self_report(&api, &identity, &worker, ¤t, "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, ¤t.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(¤t, "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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -81,7 +81,7 @@ Runtime accepts exactly one Flow segment only when the resolved Profile enables
|
||||
|
||||
The generic model-facing `WorkerSpawn` accepts `initial_submit: Vec<Segment>` and routes them unchanged through the shared Workspace spawn request into Runtime `CreateWorkerRequest.initial_input`. It does not have a parallel `initial_text` or a role-specific `SpawnCoder` wrapper. Backend derives the flat content projection from the canonical segment vector, validates Flow shape before spawn, and includes the segment vector in lifecycle idempotency fingerprints. Runtime does not commit Worker creation or report spawn success merely because the initial Submit request entered the Worker's in-memory channel: Runtime assigns the Submit an opaque id, the Worker commits that id as an extension on the same `UserInput` entry as any initial `FlowRuntimeState`, and the execution backend must return a matching typed input-commit acknowledgement. Restoring the same Worker never replays spawn initial segments.
|
||||
|
||||
When an Orchestrator supplies `ticket_id` to generic `WorkerSpawn`, the Worker tool derives the assignment operation id from the durable tool-call id rather than accepting lifecycle authority from model input. The shared Workspace worker-create route projects that request into a Coder Ticket-role intent and atomically applies the existing queued-Ticket assignment operation only after Runtime has returned the input-commit acknowledgement. A spawn or pre-commit input failure therefore leaves the Ticket queued and unassigned.
|
||||
When an Orchestrator supplies `ticket_id` to generic `WorkerSpawn`, it must also supply its observed current `orchestrator_assignment_id`. The tool derives the start operation identity from durable tool-call context, not model role text. The shared guarded Worker-create path accepts the selected Profile/optional Flow and binds Worker assignment plus resource finalization only after Runtime initial-input commit acknowledgement. Assign is delegation without ready/Queue; unresolved dependencies are diagnostics, not a blanket start gate. Backend fences the exact current delegation and authenticated source across reserve/bind/finalize/recovery. A failed or unknown external outcome must be reconciled from durable operation evidence rather than silently creating a replacement. Start does not update Ticket open/closed or Orchestrator self-reported waiting/inprogress/done. See [delivery/start fences](../development/ticket-assignment-delivery.md).
|
||||
|
||||
`RequestFlowTransition` accepts only:
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ Typical role surfaces are therefore:
|
||||
- Reviewer: item `read` and `review`.
|
||||
- Orchestrator: item `read`, `check_readiness`, and `complete`.
|
||||
|
||||
`CompleteTicket` belongs to the Ticket Feature and route-bound Ticket item `complete`, alongside state and close decisions. It takes current item/state CAS, a replay key, reason, and optional references; it requires neither an MR result set nor a Reviewer approval. The former MR collection `complete_ticket` operation is not published. The review descriptor never contains the injected capability token, and a review item route must exactly match the Merge Request captured by that capability before the ordinary review Tool can run.
|
||||
`TicketCaseUpdate` belongs to the Ticket Feature and route-bound Ticket item `case_update`, not this MR provider. Close/reopen/correction judgments take current item/state/nullable closure-key CAS, an exact-retry key, typed reason/explanation and optional references; generic closure requires neither an MR result set nor Reviewer approval. Assignment handling done is an independent self-report and does not close the case, integrate an MR, release actual work or stop a Worker. Explicit user approval conditions remain judgment requirements; MR integration still uses its guarded review path. Historical `CompleteTicket` and former MR collection `complete_ticket` operations are not published. The review descriptor never contains the injected capability token, and a review item route must exactly match the Merge Request captured by that capability before the ordinary review Tool can run.
|
||||
|
||||
## Preserved review and integration gates
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Orchestrator worktree and implementation worktree layout
|
||||
|
||||
> Historical layout decision and branch report. The Queue/state and mandatory merge→close→cleanup policy below is superseded by [current work operations](../development/work-items.md). Current delegation starts at Assign without ready/Queue; open/closed case judgments, self-reported assignment handling, guarded MR integration, actual work release and Worker/resource cleanup are independent. Respect the requested return path. The original report is preserved below; explicit attached Workdirs and Backend resource authority, not inferred roots, govern current execution.
|
||||
|
||||
Yoi can run an Orchestrator from a dedicated orchestration worktree while implementation worktrees still belong under the original workspace root. The runtime must therefore distinguish role execution cwd/workspace from the target repository root used for implementation branches and merge-completion.
|
||||
|
||||
This decision records the layout policy for Ticket `00001KTTB479X`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Ticket evidence and immutable Merge Request results
|
||||
|
||||
Ticket lifecycle, repository integration, and current requirement attestation are separate facts. An incomplete read-side evidence summary does **not** undo a recorded `done` state or a `CompleteTicket` event.
|
||||
Ticket open/closed case state, assignment self-reported waiting/inprogress/done, Worker lifecycle, repository integration, and current requirement attestation are separate facts. An incomplete read-side evidence summary does **not** undo a recorded case judgment or handling result. Historical `done` states and `CompleteTicket` events remain readable as history, not current case states or operations.
|
||||
|
||||
## Evidence subjects
|
||||
|
||||
@@ -16,9 +16,9 @@ A merged MR's review status/timestamps describe **integration approval**, not a
|
||||
|
||||
`approved_current_subject` describes valid source/integration approvals. `review_after_rescope` retains its wire name but now describes exact current requirement attestation, not ordering of wall-clock timestamps. `approved_review` query filtering requires that current attestation. `stale_after_rescope` requires substantive item-edit history and a known source-approved snapshot lacking current attestation; unknown observations are only `missing_evidence`.
|
||||
|
||||
`complete_for_integration` means a nonempty linked MR set has valid source approvals and current requirement evidence, including for partially or fully integrated sets. It is not a claim that open repositories have been integrated, that target integration readiness has been checked, or that Ticket completion is authorized. An MRless Ticket has no applicable MR evidence: `missing` is empty, `review_status` is null, and approval/integration flags remain false. It does not match `missing_evidence` or `approved_review` merely because its state is done.
|
||||
`complete_for_integration` means a nonempty linked MR set has valid source approvals and current requirement evidence, including for partially or fully integrated sets. It is not a claim that open repositories have been integrated, that target integration readiness has been checked, or that Ticket completion is authorized. An MRless Ticket has no applicable MR evidence: `missing` is empty, `review_status` is null, and approval/integration flags remain false. It does not match `missing_evidence` or `approved_review` merely because the case is closed or handling is done.
|
||||
|
||||
CompleteTicket belongs to the Ticket domain. It records the authenticated actor's reason/result and optional references using current item `content_digest`/state CAS and an operation receipt. It needs no MR or Reviewer approval, and does not create or modify those proofs. State setting, completion, close, and reopen share the same transactional update boundary. Exact replay returns current Ticket authority without reapplying a historical decision or appending an event. Historical MR-based `ticket_completion` records remain readable and distinct from new judgment receipts.
|
||||
`TicketCaseUpdate` records the authenticated actor's close/reopen/correction judgment, typed reason, explanation and optional references using current item `content_digest`, open/closed state and explicitly observed nullable closure operation key CAS, plus an exact-replay operation key. It needs no MR or Reviewer approval and creates neither proof. Explicit user approval conditions still govern judgment; guarded MR integration still requires trusted review. Exact replay never reapplies a historical decision or appends another event; reread current authority before a new operation. Historical `CompleteTicket` and MR-based `ticket_completion` records remain readable and distinct from current case receipts. Handling done is an independent self-report, not closure, Backend certification of fulfillment, MR approval/integration, work release or Worker termination. Classification tag edits do not alter a case judgment.
|
||||
|
||||
Writable targets authorize deliverables, not mandatory MRs for every target. Every linked MR remains part of the exact requirement snapshot; no arbitrary latest-review or single-repository shortcut is used.
|
||||
|
||||
|
||||
@@ -26,7 +26,11 @@ Collection interface `yoi.ticket/collection/v1`:
|
||||
| Native operation | Existing Tool identity | Subject identity |
|
||||
| --- | --- | --- |
|
||||
| `query` | `QueryTicket` | Collection; preserves bounded filters, limit and cursor |
|
||||
| `create` | `TicketCreate` | Collection; Backend assigns identity |
|
||||
| `create` | `TicketCreate` | Collection; Backend assigns identity to an open case |
|
||||
| `tag_list` | `WorkspaceTagList` | Workspace classification; bounded stable-ID pagination |
|
||||
| `tag_create` | `WorkspaceTagCreate` | Display name and exact-retry operation key |
|
||||
| `tag_rename` | `WorkspaceTagRename` | Stable tag ID, observed `expected_last_operation_key`, name and operation key |
|
||||
| `tag_delete` | `WorkspaceTagDelete` | Stable tag ID, observed `expected_last_operation_key` and operation key |
|
||||
|
||||
Item interface `yoi.ticket/item/v1`:
|
||||
|
||||
@@ -35,11 +39,16 @@ Item interface `yoi.ticket/item/v1`:
|
||||
| `read` | `ShowTicket` | `event_limit`, `event_cursor` |
|
||||
| `edit` | `TicketEditItem` | title/body replacement and targets edit |
|
||||
| `comment` | `TicketComment` | comment body |
|
||||
| `mark_ready` | `TicketMarkReady` | optional reason |
|
||||
| `intake_ready` | `TicketIntakeReady` | intake summary and optional reason |
|
||||
| `queue` | `TicketQueue` | none |
|
||||
| `transition` | `TicketWorkflowState` | expected/target state, reason and body |
|
||||
| `close` | `TicketClose` | resolution |
|
||||
| `case_update` | `TicketCaseUpdate` | typed close/reopen/correction action, explanation, item/state/nullable closure-key CAS and exact-retry key |
|
||||
| `assignment_tracking_query` | `TicketAssignmentTrackingQuery` | optional retained `assignment_id` |
|
||||
| `assignment_tracking_history` | `TicketAssignmentTrackingHistory` | `assignment_id`, bounded limit and event cursor |
|
||||
| `assignment_tracking_update` | `TicketAssignmentTrackingUpdate` | current `assignment_id`, observed `expected_last_event_id`, operation key and complete self-reported handling |
|
||||
| `orchestrator_assign` | `TicketOrchestratorAssign` | target principal, explicitly observed nullable `expected_assignment_id`, stable `operation_id`; explicit reissue uses a new operation, exact retry keeps the original |
|
||||
| `orchestrator_clear` | `TicketOrchestratorClear` | observed `assignment_id`, stable `operation_id` |
|
||||
| `assignments_query` | `TicketAssignmentsQuery` | bounded limit and `after_assignment_id` cursor for retained rows |
|
||||
| `assignment_diagnostics` | `TicketAssignmentDiagnostics` | `assignment_id`, bounded limit, independent `after_input_event_id` / `after_start_operation_id` cursors |
|
||||
| `assignment_answer` | `TicketAssignmentAnswer` | observed same `assignment_id` / `expected_last_event_id`, stable `operation_key`, bounded answer body |
|
||||
| `tags_set` | `TicketTagsSet` | stable desired `tag_ids`, observed full `expected_tag_ids` set and operation key |
|
||||
| `dependency_check` | `TicketDependencyCheck` | none |
|
||||
| `record_relation` | `TicketRelationRecord` | relation kind, **target Ticket**, optional note |
|
||||
| `remove_relation` | `TicketRelationRemove` | relation kind and **target Ticket** |
|
||||
@@ -50,11 +59,12 @@ The subject `ticket`/`id` parameter is removed from every item descriptor and re
|
||||
|
||||
Access profiles therefore expose different native interfaces:
|
||||
|
||||
- read-only/review: collection `query`; item `read`;
|
||||
- workspace authoring: query/create plus read/edit/comment/ready/queue/close/relation operations;
|
||||
- intake: the authoring surface plus `intake_ready`;
|
||||
- workflow/orchestration: query/read/comment, workflow transition/close/dependency checks, relation operations and orchestration-plan operations;
|
||||
- work-report: query/read/comment only.
|
||||
- read-only/review: collection query/tag list; item read, assignment tracking query/history, retained assignments and sanitized diagnostics;
|
||||
- workspace authoring/intake: query/create, read/edit/comment/case judgment, relation/classification operations, tracking reads, explicit Orchestrator Assign/reissue/clear and continuing answer;
|
||||
- workflow/orchestration: query/read/comment/case judgment, dependency diagnostics, relation/plan/classification operations, tracking reads/self-report update, explicit Assign/reissue/clear and continuing answer;
|
||||
- work-report: query/read/comment/case judgment/classification, assignment reads/diagnostics and continuing answer. Actual exposure is additive from manifest flags; authoring-enabled Coder configurations also expose delegation, subject to Backend authorization.
|
||||
|
||||
There are no current native ready/intake-ready/queue/six-state transition operations. Assign is delegation without a separate Queue; Worker starts are separately guarded and require the current observed `orchestrator_assignment_id` for Orchestrator calls. Assignment public operations call shared typed `WorkspaceClient::execute_server_operation` forwarding with actual Server DTOs; no direct HTTP/storage fallback or second ledger is introduced. Role assignment tools target Orchestrator responsibility only, not Worker execution/lifecycle; target principal is never the actor. Query first, preserve exact operation/input on retry, and reread after conflict/unknown outcome rather than automatically rekeying. Missing host support is a concrete authority error, never an alternate transport.
|
||||
|
||||
No native operation is inferred for a Tool that the Feature did not register. Enabled Ticket tools claimed by the native projection are removed from `/tools`; normal Tool mode is unchanged.
|
||||
|
||||
@@ -64,7 +74,7 @@ No native operation is inferred for a Tool that the Feature did not register. En
|
||||
|
||||
Backend 401/403 failures map to permission denial, 404 to missing objects, and deterministic 400/409/422 validation or workflow failures to invalid arguments when the typed operation fails before any successful mutation. Cancellation and interruption remain distinct. Post-mutation canonical-reference lookups are explicitly marked as unknown-outcome stages, so even a later deterministic HTTP status cannot misreport an already-committed write as a rejection. Transport, server, malformed response, and other post-dispatch failures retain unknown outcome, and mutating operations are not retried automatically.
|
||||
|
||||
Successful reads observe the authoritative item `content_digest`. Successful mutations advance a local observation generation even when the first call uses an internal ID whose canonical alias has not yet been learned. Once a read or mutation reveals the canonical key, the canonical/internal alias groups are merged and invalidated together. Relation mutations also invalidate the reported target Ticket, while queue results invalidate every reported queued Ticket, because those objects' incoming relations or workflow states changed. A stale validator is rejected by the common WIP Host before another Backend dispatch; the caller must rediscover/reinspect and explicitly decide whether to call again.
|
||||
Successful reads observe the authoritative item `content_digest`. Successful mutations advance a local observation generation even when the first call uses an internal ID whose canonical alias has not yet been learned. Once a read or mutation reveals the canonical key, the canonical/internal alias groups are merged and invalidated together. Relation mutations also invalidate the reported target Ticket because its inverse relation view changed. A stale validator is rejected by the common WIP Host before another Backend dispatch; the caller must rediscover/reinspect and explicitly decide whether to call again.
|
||||
|
||||
## Preserved Ticket contracts
|
||||
|
||||
@@ -72,11 +82,12 @@ The projection invokes the existing Tool and scoped Backend routes, so the follo
|
||||
|
||||
- item content digest and audited item/thread/state events;
|
||||
- title/body/targets input validation;
|
||||
- planning-to-ready target validation and write-target requirement;
|
||||
- queue dependency traversal, planning-dependency rejection and cycle detection;
|
||||
- expected-state workflow transitions and close semantics;
|
||||
- open/closed case judgments with item/state/closure-key CAS, typed reason and immutable close/correction/reopen history;
|
||||
- stable classification tag IDs, tag operation CAS, full tag-set comparison and exact-retry receipts;
|
||||
- immutable assignment identity and self-reported waiting/inprogress/done, observed event CAS and complete-payload exact replay;
|
||||
- dependencies as diagnostics rather than ready/Queue or blanket launch gates;
|
||||
- canonical `T-*`/internal-ID resolution and Workspace isolation;
|
||||
- exact forward relation ownership, inverse views and relation target validation;
|
||||
- orchestration-plan validation and bounded query behavior.
|
||||
|
||||
The ordinary JSON Schema is retained as a second validation boundary behind the WIP descriptor. Optional fields omitted by the caller stay omitted, while an explicitly supplied `null` stays present as JSON `null`, so permission matching sees the same input shape as normal Tool mode. Defaults are applied only by the same ordinary typed Tool that applies them in Tool mode.
|
||||
The ordinary JSON Schema is retained as a second validation boundary behind the WIP descriptor. Handling is never inferred from delivery, Worker or MR state. Waiting answers continue the same assignment; explicit done reissue creates a new row and preserves the prior result. Done does not close the case, release work, stop a Worker or require unassignment. Generic reasoned closure has no universal MR/human-approval gate; explicit user conditions and guarded MR integration remain separate. Exact retry retains the same operation key and full input; a receipt is not a fresh observation. On stale/current-assignment or CAS conflicts reread and reconcile; unknown outcomes must not be silently retried as a fresh operation. Optional fields omitted by the caller stay omitted, while an explicitly supplied `null` stays present as JSON `null`, so permission matching sees the same input shape as normal Tool mode. Defaults are applied only by the same ordinary typed Tool that applies them in Tool mode.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Workspace Kanban to backend Orchestrator runtime
|
||||
|
||||
> Historical design, not current operating instructions. O-19 / T-739–T-743 supersede the state/Queue routing and dependency start gates below. Current Ticket cases are open/closed; Assign starts delegation and initial waiting without ready/Queue, handling is an Orchestrator self-report, and MR integration remains independently guarded. See [current work operations](../development/work-items.md), [assignment tracking](../development/ticket-assignment-tracking.md), and [delivery/start fences](../development/ticket-assignment-delivery.md). The original proposal below is preserved, not rewritten as implementation history.
|
||||
|
||||
Workspace Kanban operations are control-plane requests. They may change Ticket state and request orchestration, but they must not directly execute shell, git, filesystem work, or send authority-bearing messages to raw local Worker sockets. The durable boundary is an orchestration event consumed by a backend-internal Orchestrator Worker.
|
||||
|
||||
This document records the design boundary for connecting Kanban operations, Tickets, the Workspace backend, `WorkerRuntimeRegistry`, and filesystem-capable Workers. It is intentionally a planning artifact: it does not require the Workspace backend to implement every table, API, remote protocol, or spawn adapter immediately.
|
||||
|
||||
@@ -45,7 +45,11 @@ Unfinished dependencies remain query/relation diagnostics and do not disable man
|
||||
|
||||
Action projection permits delegation independently of existing Worker occupancy; actual Worker start eligibility still excludes unfinished Worker work and closed cases. `can_start_manual_worker` no longer includes an unresolved-dependency predicate. `can_assign_orchestrator` permits an explicit fresh Assign/reissue operation even when a current row exists; callers can supply the observed expected assignment for CAS. `can_unassign_orchestrator` uses the retained current pointer, not active-work or case-state filtering. These authoring actions are not Worker transfer. Final browser controls must use these Backend decisions rather than recreating old phase/dependency gates.
|
||||
|
||||
Delivery diagnostics are available through the typed Store scan/reservation/ack records and structured `ticket_assignment_delivery_pending` warning (`assignment_id`, `input_event_id`, `delivery_id`, recipient and error category, never content). Existing `ticket_notification_delivery_failed` remains for recipient failures. No browser acknowledgement/status-edit endpoint is introduced: successful delivery is not a user handling update. T-743 can consume the operational query independently of handling without inventing a third status in tracking.
|
||||
Delivery diagnostics are available through the typed Store scan/reservation/ack records and structured `ticket_assignment_delivery_pending` warning (`assignment_id`, `input_event_id`, `delivery_id`, recipient and error category, never content). Existing `ticket_notification_delivery_failed` remains for recipient failures. No browser acknowledgement/status-edit endpoint is introduced: successful delivery is not a user handling update. At the T-743 base `56795fb9`, `openapi/server-api.json` exposes assignment tracking and start fences but no assignment delivery query/receipt endpoint. The Store scan is an internal Rust API, not an authenticated browser/normal Tool/WIP route. Consumers must report this concrete API insufficiency for the owning Server/API handoff, not claim they can query delivery or infer it from handling/Worker state. Saved Assign plus delivery failure remains a saved delegation, never a reason to reassign. Any future diagnostics route must keep delivery, acceptance and actual start evidence separate from self-reported handling, and preserve bounded Workspace/assignment-scoped read authority.
|
||||
|
||||
## T-743 public handoff following the base API gap
|
||||
|
||||
The accepted T-743 public API adds canonical/browser `assignment-diagnostics`, `assignment-tracking/assignments` and fenced `assignment-answer` routes over the existing assignment/origin/delivery/start/receipt authority. The corresponding normal Tools/native WIP use shared typed Workspace forwarding. Diagnostics takes assignment identity and independent input/start cursors, bounds each page, and retains unknown/history distinctions; it does not expose saved rendered content or binding credentials, attest actual model processing, or mutate handling. Continuing answer atomically fences authenticated source/current assignment/observed event and saves the input/receipt, then triggers committed-input notification; it creates neither a new row nor automatic inprogress. These definitions supersede the base's unavailable public read/input surfaces, not the underlying delivery ledger or T-748 transaction ownership. See [assignment tracking](ticket-assignment-tracking.md) for ordinary Tools/WIP names and CAS/retry contracts. Combined route/generated/runtime validation is separately required; source definitions alone do not prove environment rollout.
|
||||
|
||||
## DB caller/transaction handoff for T-748 and protocol boundaries
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ Canonical Workspace endpoints:
|
||||
- `POST` on the same path: self-report update with event CAS and exact retry key.
|
||||
- `GET .../assignment-tracking/history`: assignment-scoped handling audit, with `assignment_id`, optional `after_event_id`, and `limit` (1–100). The returned `next_event_id` continues that history.
|
||||
|
||||
Normal tools and route-bound WIP operations expose `TicketAssignmentTrackingQuery`, `TicketAssignmentTrackingHistory`, and `TicketAssignmentTrackingUpdate`. Ticket query/detail and model projections include current assignment tracking. Read access does not grant mutation authority; Backend checks every update.
|
||||
Normal tools and route-bound WIP operations expose `TicketAssignmentTrackingQuery`, `TicketAssignmentTrackingHistory`, and `TicketAssignmentTrackingUpdate`. T-743's public handoff adds `TicketAssignmentsQuery` for bounded retained Orchestrator rows (`GET .../assignment-tracking/assignments`, `after_assignment_id`, limit 1–100, `next_assignment_id`), `TicketAssignmentDiagnostics` for sanitized independent delivery/start pages (`GET .../assignment-diagnostics`, assignment ID, separate input/start cursors), and `TicketAssignmentAnswer` (`POST .../assignment-answer`). Answer uses the existing authenticated operation receipts with assignment/event CAS and bounded body; it continues waiting/inprogress on the same row and does not change handling. Done requires explicit reissue instead.
|
||||
|
||||
`TicketOrchestratorAssign` / `TicketOrchestratorClear` use the existing role-assignment routes with a target principal, observed assignment identity and stable `operation_id`; explicit nullable `expected_assignment_id` is mandatory in Assign Tool/WIP input. These tools target responsibility, not Worker execution/lifecycle, and never derive the actor from model role text. Query first; retry exact full input with the original identity, never automatically generate a fresh key on stale/unknown outcome. Typed forwarding is shared `WorkspaceClient::execute_server_operation`; it does not introduce another ledger or direct HTTP/storage fallback. Read-only/review access includes retained rows and diagnostics, not Assign/clear/answer/self-report mutation. Ticket query/detail and model projections include current assignment tracking; `QueryTicket` also exposes exact current `assigned_principal` and self-reported `handling_status` filters. Read access does not grant mutation authority; Backend checks every update.
|
||||
|
||||
Read a fresh projection before a new update. Do not treat a replayed receipt's last-event identity as the latest observed value. Retained-assignment query exposes Assign attribution and release/replacement independently; the history endpoint pages initial/migrated handling and subsequent self-reports, not delivery acknowledgements.
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ Workspace Server control-plane records, append-only Ticket events, and any linke
|
||||
|
||||
## Concepts
|
||||
|
||||
- `Ticket`: durable work record containing intent, requirements, decisions, useful results/evidence, and resolution history.
|
||||
- `Ticket`: durable work record containing intent, requirements, decisions, useful results/evidence, and immutable closure history. Its case state is only `open` or `closed`; open is not readiness, and closed is not necessarily fulfillment.
|
||||
- `Classification tag`: Workspace-owned stable identity with a mutable display name. Multiple tags classify/filter Tickets; they never substitute for closure reasons, delegation or start authority.
|
||||
- `Orchestrator handling`: self-reported `waiting` / `inprogress` / `done` on the current assignment row, with waiting reason, progress, next action and result. These are not case states, Worker states, MR judgments or delivery status.
|
||||
- `Objective`: a first-class medium-term goal with background, strategy, success criteria, and canonical Ticket context links. It neither schedules nor authorizes Ticket work.
|
||||
- `Task`: session-local progress tracking, not the project record.
|
||||
- `Assignment`: an authorized delegation of Ticket work to a Worker, independent from its Profile or Flow.
|
||||
@@ -25,13 +27,17 @@ The Server resolves Workspace access, active configuration, and immutable Profil
|
||||
|
||||
`SpawnTicketWorker` starts and atomically assigns a generic Worker with an `initial_request`. Its registered `profile` is optional (omitted/null selects `builtin:ticket-worker`); `flow` and alias-keyed `workdir_attachments` are optional. No repository or Flow is required for non-code work. Choose an explicit Profile when its tools and instructions fit the task; assignment does not depend on choosing the Coder role. Backend validates claims, derives repository attachment access from declared Ticket targets, and preserves explicit ExternalGrant ceilings. Callers cannot request capabilities through attachment selection.
|
||||
|
||||
The guarded start records acceptance only after spawn, initial input, assignment, and attachment finalization. Do not pre-write `inprogress` or mistake a state update for a launch. On a failed or unknown outcome, reread durable operation evidence before retrying or spawning a replacement. After acceptance, reread Ticket state and current Worker responsibility.
|
||||
Orchestrator Assign delegates handling and persists initial waiting with the assignment/audit/receipt; no separate ready/Queue operation is required. The stable `workspace_agent:workspace-orchestrator` principal is distinct from the concrete registered Worker. Answers during waiting/ongoing work continue the same assignment; status is explicitly self-reported, never automatically set on delivery or acceptance. An explicit reissue after done creates a new assignment row even for the same principal, preserving prior result/release/replacement history. Reopen and Worker exchange alone create neither a fresh delegation nor execution rights. See [assignment tracking](ticket-assignment-tracking.md) and [delivery/start fences](ticket-assignment-delivery.md).
|
||||
|
||||
The guarded start records acceptance only after spawn, initial input, assignment, and attachment finalization. Current Orchestrator starts (including generic Worker spawn and existing-Worker assignment) must carry the observed `orchestrator_assignment_id`. Do not pre-write handling `inprogress` or mistake a case/status update for a launch. On a failed or unknown outcome, reread durable operation evidence before retrying the same operation; never silently spawn a replacement. After acceptance, reread current Worker responsibility and authoritative unfinished work, not merely `current_worker`. Unfinished dependencies are diagnostics for partial work/waiting, not a blanket start refusal, and are not implicitly started; actual authentication, case closure, resource/claim and recovery guards still apply.
|
||||
|
||||
Results can be returned in conversation, optionally recorded in a `TicketComment`, optionally published as a Drive document/artifact when that surface is available, or written/published to an authorized repository. Neither comments nor Drive artifacts are universally required. Preserve concise evidence useful to the user without fabricating work, tests, or conclusions. `implementation_report` is optional historical/audit context, not integration readiness or completion authority.
|
||||
|
||||
The return choice is independent of result format. Examples:
|
||||
|
||||
- Research with review not required: answer the question and optionally record a useful summary; no MR or approval is required for an authorized completion decision.
|
||||
- Research returned to the human: record handling done with a bounded result; leave the case open when no closure was requested. No MR or universal approval is required.
|
||||
- Explicit user confirmation before continuing: record waiting with the specific reason and next action. An answer continues the same assignment; receipt alone is not an inprogress self-report.
|
||||
- Whole request fulfilled and closure requested: separately record handling and a reasoned case close with `completed`. Other closure reasons (not planned, duplicate, unknown, or custom) do not claim fulfillment.
|
||||
- Code requiring independent review before merge: use the trusted review path and guarded MR integration. A Profile, Flow transition, tool invocation, or prose approval is not the review judgment.
|
||||
- Return without conclusion: report the result/limitations and leave the Ticket unconcluded. A finished turn, an artifact, or an approved MR does not itself decide completion.
|
||||
|
||||
@@ -41,9 +47,11 @@ These are instruction/tool contracts, not a claim that a mechanism proves real a
|
||||
|
||||
`QueryTicket` provides bounded discovery/filtering; `ShowTicket` provides authoritative item content and `content_digest`, paged thread, relations, Objectives, and any current MR context. Read the relevant Ticket before implementation, routing, review, state, or conclusion decisions. Check potential duplicates before creation or material rescope. Use `QueryObjective` and `ShowObjective` for broader context without replacing the Ticket read.
|
||||
|
||||
Useful mutation surfaces include `TicketCreate`, `TicketComment`, relation/plan tools, and authorized `TicketWorkflowState`, `CompleteTicket`, and `TicketClose` decisions. Exposure depends on the effective Profile/Feature configuration. Decision tools use current `content_digest` as `expected_content_digest`, expected state, a reason/resolution, stable operation key, and optional supporting references. Backend checks actor authority, CAS, replay, and resource boundaries. It does not enforce a universal state sequence, MR/approval gate, or natural-language satisfaction.
|
||||
Useful mutation surfaces include `TicketCreate`, `TicketComment`, classification and relation/plan tools, `TicketOrchestratorAssign` (explicit delegation or reissue), `TicketOrchestratorClear`, fenced `TicketAssignmentAnswer`, `TicketAssignmentTrackingUpdate`, and `TicketCaseUpdate`. Read current tracking first; use `TicketAssignmentsQuery` for bounded retained rows and `TicketAssignmentDiagnostics` for sanitized separate input/start evidence. `QueryTicket` supports exact current principal and self-reported handling filters. Exposure depends on the effective Profile/Feature configuration and does not grant authority. Ready, Queue, six-state transitions and `CompleteTicket`/`TicketClose` are not current Worker operations; old events remain historical evidence.
|
||||
|
||||
`CompleteTicket` records completion; `TicketClose` records closure. Neither integrates an MR or expands resource grants. Ticket progress/conclusion can occur without commits, MRs, or approvals when consistent with the request. Conversely, code's default publication/review practices must not be erased by confusing an independent Ticket decision with merge authority. Do not claim an unmerged result was integrated. Reread after conflicts and replay a recorded operation only with the same fingerprint.
|
||||
Assign/reissue uses target principal, explicitly observed nullable `expected_assignment_id`, and stable `operation_id`; clear uses the observed assignment_id and operation_id. These role operations do not start/stop Workers. Answers use the same current assignment/event CAS and stable operation_key with bounded body, never an unfenced comment to manufacture continuation. Case judgments use current `content_digest` as `expected_content_digest`, expected open/closed state, explicitly observed nullable `closure_operation_key`, typed close/reopen/correct-closure action, explanation, stable operation key and optional references. Tracking updates use immutable `assignment_id`, observed `last_event_id` as `expected_last_event_id`, stable `operation_key` and the complete handling payload. Tag rename/delete mutations use stable tag identity and observed `expected_last_operation_key`; Ticket tag-set updates compare the observed full `expected_tag_ids` set and send the complete desired `tag_ids` set, never a display name or status-only comparison. Exact retry keeps the same key and entire input; changed input under that key is rejected. Replayed receipts are not fresh observations. After conflict, reread and reconcile, never silently overwrite or retry as a new operation against another assignment. Unknown dispatch outcomes are not success or failure. Saved Assign plus failed notification is not failed delegation and must not prompt another Assign. Do not infer missing delivery/acceptance diagnostics from Worker or handling state; report the concrete unavailable endpoint.
|
||||
|
||||
`TicketCaseUpdate` records an authenticated close, reopen or correction judgment with immutable reason/actor/history. It neither integrates an MR nor expands grants nor changes handling status. Handling done and case closed do not imply one another; done does not require unassignment. Reopen alone is not reissue or old Worker revival. Generic closure can occur without commits, MRs or approvals when consistent with the request, while explicit user approval conditions must be respected. Backend checks actor authority, CAS, replay and resource boundaries, not natural-language satisfaction. Code's guarded MR integration remains separate: do not claim an unmerged result was integrated.
|
||||
|
||||
Relations remain metadata, not a scheduling mechanism: record actual project dependencies, but do not encode capacity, Worker/worktree ownership, or execution ordering as relations. Use orchestration/session records for those concerns and inspect current Backend claim rules rather than inferring launch eligibility from a relation alone.
|
||||
|
||||
@@ -80,7 +88,7 @@ Intake clarifies ambiguous intent, checks duplicates and relevant context, and r
|
||||
|
||||
A useful Ticket records background/motivation, requirements, acceptance criteria, binding decisions/invariants, implementation latitude, and relevant open questions/risks. Include useful result evidence and final resolution when appropriate. Separate confirmed facts, user claims, hypotheses, and undecided questions. Do not copy Worker role boundaries, a temporary plan, or a decision to stop after authoring into the Ticket as a product prohibition. Record an approval gate only when the user asks for it; do not prematurely prescribe implementation tactics.
|
||||
|
||||
Risk flags prompt context checks and reviewer focus, not automatic stop gates. Return to planning only for a named missing decision/information after bounded checks; escalate beyond-scope decisions without inventing new constraints.
|
||||
Risk flags prompt context checks and reviewer focus, not automatic stop gates. Record a waiting reason and next action only for a named missing decision/information after bounded checks; escalate beyond-scope decisions without inventing new constraints or moving the case to a planning state.
|
||||
|
||||
Do not create new umbrella/progress-container Tickets. Split broad work into concrete useful outcomes, record the split in Ticket/Objective context, and use Objectives for enduring medium-term goals. Do not replace umbrellas with parent/child, sub-ticket, part-of, contains, or other hierarchy relations. A concrete planning/design/investigation Ticket is valid when requested. Existing umbrellas can be retired as superseded/decomposed without rewriting history; distinguish container retirement from completion of future work.
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# T-743 → T-748: published domain-helper / caller handoff
|
||||
|
||||
## Source identity and composition
|
||||
|
||||
This inventory belongs to the T-743 commit carrying this file on `work/T-743-ticket-public-surfaces` (T-742 base `56795fb92cb9def9bcd701bd51d92e99526f8eff`). Its exact published SHA and MR ID are recorded in the Ticket publication handoff. It is not a copy of the unpublished T-748 adapter and is not evidence that async DB execution has been integrated or reviewed.
|
||||
|
||||
Normal-merge the validated public T-748 source into this existing work branch. Keep the MR's source/target selectors. Recheck the concrete new call graph below against the merged implementation; no placeholder executor, second connection authority, transaction split, fresh integration branch or force push is needed.
|
||||
|
||||
## Three added synchronous Store signatures
|
||||
|
||||
Declared in `crates/workspace-server/src/store.rs` on `ControlPlaneStore`, implemented by `SqliteWorkspaceStore`:
|
||||
|
||||
```rust
|
||||
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>;
|
||||
```
|
||||
|
||||
Implementations forward whole calls respectively to `assignment_tracking::assignments_page`, `assignment_delivery::diagnostics_page` and `assignment_tracking::answer`.
|
||||
|
||||
The answer helper owns one IMMEDIATE transaction: `require_assignment_source` (including replay), current actionable delegation admission for a new operation, `ticket::sqlite_answer_ticket_assignment_in_transaction` (full input/source fingerprint, retained receipt replay, observed assignment/event, open-case gate, body event, audit attributes and immutable operation result), then commit. Keep this complete helper inside one accepted DB job. Do not validate/CAS on one side of an await and append/receipt on the other. The two read helpers use their existing connection/snapshot boundaries and bounded cursors; the diagnostics input and start cursors are independent.
|
||||
|
||||
## Added async HTTP callers and surrounding synchronous work
|
||||
|
||||
In `crates/workspace-server/src/server.rs`, `WorkspaceContractService`:
|
||||
|
||||
| Canonical caller | Browser forwarding caller | Whole Store operation |
|
||||
| --- | --- | --- |
|
||||
| `ticket_assignments_page` | `ticket_assignments_page_browser` | `page_ticket_assignments` |
|
||||
| `ticket_assignment_diagnostics` | `ticket_assignment_diagnostics_browser` | `page_ticket_assignment_diagnostics` |
|
||||
| `ticket_assignment_answer` | `ticket_assignment_answer_browser` | `answer_ticket_assignment` |
|
||||
|
||||
Canonical paths are `/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments`, `/assignment-diagnostics` and `/assignment-answer`; Browser equivalents use `/api/w/{workspace_id}/tickets/{ticket_id}/...`. Browser methods forward to canonical methods, so they must not independently repeat a mutation or transaction.
|
||||
|
||||
All three canonical methods currently call `resolve_workspace_ticket_reference` before their Store method. This uses Ticket authority/backend DB reads, so moving only the final Store method is not enough. The answer path also calls `authenticated_assignment_source`; its Runtime branch checks the registered current Workspace Orchestrator through `find_workspace_orchestrator`, while its Account branch derives actor/source exclusively from authenticated request context. Preserve source authentication and the in-transaction source fence; do not turn request target principal into source authority. Own/cloned workspace/Ticket/query/body/context/actor values must cross the common execution boundary without borrowing request frames.
|
||||
|
||||
After the answer returns committed, the current handler calls `find_workspace_orchestrator` and `dispatch_orchestrator_assignment_attention`. Account for the former's DB-backed projection lookup and the latter's Runtime/network dispatch in T-748's existing stage/lane design. Runtime/network waits must not occupy the sole DB worker. Keep commit-before-publication and durable scan/recovery semantics even if the awaiting HTTP request is cancelled or its response is lost. Do not automatically resubmit an accepted mutation with a new operation key.
|
||||
|
||||
## Query authority extension and all entry paths
|
||||
|
||||
`TicketQueryRequest` adds typed `assigned_principal`, `handling_status` and `closure_reason`. Existing `TicketAuthority::query_tickets(query: TicketQueryRequest) -> Result<TicketQueryResponse>` in `authority.rs` applies current role/principal/handling and current closed-case reason constraints in its SQL candidate query **before** limit/pagination. Query text separately searches retained closure judgments, including after correction/reopen.
|
||||
|
||||
The HTTP call graph remains `WorkspaceContractService::ticket_query` → `scoped_query_tickets` → `api.authority.query_tickets(query)`. Canonical and Browser query routing, including the pre-existing browser GET query adapter, converge on this authority. T-748 must cover the whole authority read/enrichment call, not just the added SQL fragment or only one UI route.
|
||||
|
||||
Normal Tool/WIP read/assign/release/answer calls forward typed `WorkspaceServerOperation` values from `crates/worker/src/worker.rs`, through `crates/worker-runtime/src/worker_source.rs`, to the same canonical HTTP contracts. Added variants are `TicketAssignmentSet`, `TicketAssignmentClear`, `TicketAssignmentsPage`, `TicketAssignmentDiagnostics`, and `TicketAssignmentAnswer`; `QueryTicket` uses the existing typed query transport with the added filters. The set/clear variants expose existing assignment Store authority rather than creating additional new Store helpers. Their existing T-748 execution-boundary changes must be retained when resolving merge conflicts.
|
||||
|
||||
No new Tool-specific DB adapter is needed. Frontend helpers in `ticket-operations.ts`, TicketHandling/Tags and list/Orchestrator loaders are consumers, never replacement authorities.
|
||||
|
||||
## Composition validation
|
||||
|
||||
Existing T-743 proof: full selected Rust crate suites, generated contract checks, Web/component/browser tests and real-Worker notification/processing synchronization. See `T-743-public-surface-validation.md` for commands and explicit limits.
|
||||
|
||||
After T-748 composition, add/run the narrow boundary checks for all three new HTTP operations and query paths under controlled DB lock/admission, queue/job bounds, overload response, stale/source refusal, rollback, accepted-job cancellation and exact replay. Retain cookie Origin and signed Runtime-binding denial tests, assignment source fencing, one-transaction receipt/event tests and canonical/Browser route equivalence. Root check and relevant full changed-crate tests must cover the combined tree. Then publish only the same source branch normally, verify provider SHA equals local HEAD, and request fresh independent review of that exact combined source.
|
||||
|
||||
Current publication is neither final independent approval nor target integration, Ticket/MR completion, live update or cleanup.
|
||||
@@ -0,0 +1,51 @@
|
||||
# T-743 additional assignment Tools / WIP handoff
|
||||
|
||||
This is an additive follow-up to `T-743-prompt-surfaces.md`; that prior report/history is not rewritten. Additional scope follows T-743 thread sequence 6 and the parent request. Read current typed T-743 and inspected the backend child's actual Server DTOs/routes, not just the proposed names.
|
||||
|
||||
## Implemented public surface
|
||||
|
||||
| Normal Tool | Native Ticket item operation | Contract |
|
||||
| --- | --- | --- |
|
||||
| `TicketOrchestratorAssign` | `orchestrator_assign` | Explicit delegation/reissue to target principal; required observed nullable `expected_assignment_id`, stable `operation_id` |
|
||||
| `TicketOrchestratorClear` | `orchestrator_clear` | Explicit release of observed assignment_id with stable operation_id; not Worker lifecycle |
|
||||
| `TicketAssignmentsQuery` | `assignments_query` | Bounded retained Orchestrator rows, after_assignment_id / next_assignment_id |
|
||||
| `TicketAssignmentDiagnostics` | `assignment_diagnostics` | Actual sanitized DTO with separate input/start cursors and unknown/history distinctions |
|
||||
| `TicketAssignmentAnswer` | `assignment_answer` | Same current waiting/inprogress assignment, observed event CAS, stable operation_key, bounded body |
|
||||
|
||||
Reissue is the explicit Assign operation with a new caller-chosen operation identity against the observed old assignment. Exact replay retains the original key, full principal/CAS/input; no tool automatically queries a newer row, rewrites the expected identity or generates a new operation. All descriptions require querying current authority first. Target principal is operation data, never source/actor authority; the tools fix the target role to Orchestrator responsibility, leaving real role/principal/authentication checks in Backend. No worker-start/transfer operation was invented.
|
||||
|
||||
The new endpoints are the actual typed canonical definitions for `assignment-tracking/assignments`, `assignment-diagnostics` and `assignment-answer`. `WorkspaceClient` has no dedicated role-assignment method: the shared typed `WorkspaceServerOperation` forwarding surface was extended in the Worker crate, using actual `server_api` request DTOs and the existing host-bound execution authority. No client-crate extension is needed for these operations, no direct HTTP/storage fallback is used by the tools, and no second assignment/delivery/receipt ledger was introduced.
|
||||
|
||||
`QueryTicket` now accepts the actual typed current `assigned_principal`, `handling_status`, and current closed-case `closure_reason`. Closure reason remains separate from classification tag IDs. New read-only/review surfaces contain retained query and diagnostics, never Assign/clear/answer/self-report mutation. Mutation exposure is additive from the actual manifest authoring/thread/workflow flags; availability is not Backend authorization.
|
||||
|
||||
## Changed paths in this follow-up
|
||||
|
||||
- `crates/worker/src/worker.rs`: shared typed operation forwarding and scalar encoded query transport.
|
||||
- `crates/worker/src/feature/builtin/ticket.rs`: feature inventories, query schema, native operation mappings and registration.
|
||||
- `crates/worker/src/feature/builtin/ticket/assignment_public.rs`: new Tools, schemas, input validation and typed bounded/sanitized response handling.
|
||||
- `crates/worker/src/feature/builtin/ticket/assignment_public/tests.rs`: new boundary/transport/WIP/access/profile fixtures.
|
||||
- `crates/worker/src/prompt/system.rs`: effective actual-profile research return, explicit confirmation wait, same-row answer and generic close fixtures without repository/MR prerequisites; guarded integration fixture remains separate.
|
||||
- `resources/prompts/common/tickets.md`: concrete tool names, query-first/CAS/exact replay/actor boundaries.
|
||||
- `docs/development/ticket-assignment-tracking.md`
|
||||
- `docs/development/ticket-assignment-delivery.md`
|
||||
- `docs/development/work-items.md`
|
||||
- `docs/design/ticket-wip-projection.md`
|
||||
- this new report.
|
||||
|
||||
## Validation evidence
|
||||
|
||||
Passed focused commands:
|
||||
|
||||
- `cargo test -p worker --lib feature::builtin::ticket::`: **57 passed**, including 12 new public assignment/filter tests. These exercise required observed null/CAS, unknown model authority fields, same-input replay forwarding, read/diagnostic cursor encoding and bounds, answer body/identity validation, stale/permission/unknown outcome non-retry, diagnostic content/credential stripping, ordinary/WIP equivalence and route binding, read-only exclusions, and actual resolved builtin manifest access.
|
||||
- `cargo test -p worker --lib prompt::system::tests::effective_`: **5 passed**, including the actual resolved-profile no-repo/no-MR research→waiting+answer→reasoned-close fixture and the independent guarded MR-review fixture.
|
||||
- `cargo test -p worker --lib feature::builtin::orchestration::`: **7 passed**.
|
||||
- `cargo test -p worker --lib feature::builtin::manage_worker::tests::worker_lifecycle_spawn_serializes_ticket_assignment_without_workdirs_or_flow`: **1 passed**. The prior concurrent macro blocker no longer prevented this test.
|
||||
- `cargo test -p worker --test feature_invocation_test`: **13 passed**.
|
||||
- Root `cargo check`: passed at the observed combined snapshot, including Runtime, TUI and Server compile closure.
|
||||
- Direct delegated-file `rustfmt --check`, `git diff --check HEAD`, and naming check: passed (`558 matching lines; 0 unapproved; 0 stale exceptions`).
|
||||
|
||||
Root `cargo fmt --all -- --check` was **not clean** at the latest observation: non-delegated concurrent edits still needed formatting in `crates/worker-runtime/src/worker_source.rs` and `crates/workspace-server/src/authority.rs`. No outside-scope formatting write was performed. Parent owns the final combined formatting/validation pass.
|
||||
|
||||
## Limits and return
|
||||
|
||||
Transport/schema fixtures use a recording host-bound Workspace adapter; they prove concrete tool wiring, boundary input, permissions and non-retry behavior, not fresh signed Runtime→Server→DB→notification end-to-end execution or model judgment. Backend child's actual persistence/source-fence tests, generated contract synchronization, UI/browser/visual validation, all-Worker/full-stack tests, final combined acceptance and environment rollout remain parent-owned/separate. New API source definitions and one successful root compile are not whole-Ticket satisfaction evidence. No T-743 completion, review request, commits, pushes, merge, Worker stop or backend ledger implementation was performed.
|
||||
@@ -0,0 +1,64 @@
|
||||
# T-743 delegated Prompt / Profile / Flow / Worker handoff
|
||||
|
||||
## Boundaries
|
||||
|
||||
Base: `56795fb92cb9def9bcd701bd51d92e99526f8eff`, branch `work/T-743-ticket-public-surfaces`. Read typed T-743, O-19 and T-739–T-742, assignment tracking/delivery docs, generated OpenAPI and the Rust testing strategy before tests. This report covers only delegated resources/docs/Worker changes, not the parent's UI/Server/API work. No backend ledger, commits, pushes, merge, review request or Ticket conclusion was performed. Existing reports and history were not rewritten. Two historical design documents received only a supersession notice above their preserved original content.
|
||||
|
||||
## Changes
|
||||
|
||||
- Remove ready/intake-ready/Queue from normal Worker Ticket catalogs and native WIP mappings, including all-capability configurations. Remove obsolete queue-response observation invalidation; preserve inverse relation invalidation. Internal trait adapters are not new public operations.
|
||||
- Align common/role/panel instructions: cases open/closed; stable classification IDs and actual tag/set CAS; assignment waiting/inprogress/done is self-report, independent of Worker/MR/delivery/acceptance/work release.
|
||||
- Answers during waiting/ongoing handling continue the same assignment; explicit done reissue creates a new row even for the same principal and preserves old results. Reopen and Worker exchange alone are not reissue. Done/close do not require unassignment.
|
||||
- Explain full-input operation identity/replay, observed assignment/event CAS, stale reread/reconciliation and unknown outcomes without automatic fresh-operation retry. Distinguish saved Assign from failed delivery. Supply observed `orchestrator_assignment_id` for Orchestrator starts, including generic spawn/existing-Worker assignment.
|
||||
- Preserve explicit user approval conditions and guarded trusted review for MR integration, without a universal MR/human approval gate on generic closure. Flow terminal handoff is not case closure, handling done, work release or Worker stop.
|
||||
- Add catalog exclusion and effective prompt/schema fixtures for research return, confirmation wait and reasoned closure. These are scripted instruction/schema checks, not model judgment or full-stack behavior tests.
|
||||
|
||||
## Delegated diff paths
|
||||
|
||||
Worker:
|
||||
- `crates/worker/src/feature/builtin/ticket.rs`
|
||||
- `crates/worker/src/feature/builtin/ticket/assignment_tracking.rs`
|
||||
- `crates/worker/src/prompt/system.rs`
|
||||
|
||||
Resources:
|
||||
- `resources/flows/coder-review.dcdl`
|
||||
- `resources/profiles/intake.dcdl`
|
||||
- `resources/profiles/orchestrator.dcdl`
|
||||
- `resources/prompts/common/tickets.md`
|
||||
- `resources/prompts/panel/orchestrator_idle_queue_notice.md`
|
||||
- `resources/prompts/role/coder.md`
|
||||
- `resources/prompts/role/intake.md`
|
||||
- `resources/prompts/role/orchestrator.md`
|
||||
- `resources/prompts/role/ticket-worker.md`
|
||||
|
||||
Current docs / historical-status notices:
|
||||
- `docs/development/work-items.md`
|
||||
- `docs/development/ticket-assignment-delivery.md`
|
||||
- `docs/design/ticket-wip-projection.md`
|
||||
- `docs/design/merge-request-wip-projection.md`
|
||||
- `docs/design/ticket-evidence.md`
|
||||
- `docs/design/flow-state-graph.md`
|
||||
- `docs/design/workspace-kanban-orchestrator-runtime.md` (notice only)
|
||||
- `docs/design/orchestrator-worktree-layout.md` (notice only)
|
||||
- this new report
|
||||
|
||||
## Focused validation evidence
|
||||
|
||||
Passed:
|
||||
- `cargo test -p worker --lib prompt::system::tests::effective_`: 5 tests (including new return/wait/close schema fixture).
|
||||
- `cargo test -p worker --lib feature::builtin::ticket::`: 45 tests, including normal/WIP retirement, case CAS, tag schemas/transport, assignment tracking and relation observation tests.
|
||||
- `cargo test -p worker --lib feature::builtin::orchestration::`: 7 tests, including observed delegation forwarding and resource/Profile boundaries.
|
||||
- `cargo test -p manifest --lib builtin_profile::tests::`: 4 tests.
|
||||
- `cargo test -p manifest --lib profile::tests::builtin_orchestrator_keeps_cleanup_tool_providers_enabled`: 1 test.
|
||||
- `cargo test -p flow --lib builtin::tests::`: 2 tests; built-in recipe compiles and keeps current selector review contracts.
|
||||
- Root `cargo check`, `cargo fmt --all -- --check`, `git diff --check HEAD`, and naming check (`558 matching lines; 0 unapproved; 0 stale exceptions`) passed at an earlier snapshot before subsequent concurrent Server/API edits. Worker files were formatted again after the final fixture addition; diff check also passed afterward.
|
||||
|
||||
One subsequent Worker spawn-contract invocation was blocked before test execution by a concurrent parent-owned API edit: `crates/server-api/src/lib.rs:3208` (`ticket_assignments_page`) used `#[context] context: ServerRequestContext`; the API macro requires `#[path]`, `#[query]`, `#[header]`, `#[body]`, `#[binary]` or `#[extension]` and rejected it. Command: `cargo test -p worker --lib feature::builtin::manage_worker::tests::worker_lifecycle_spawn_serializes_ticket_assignment_without_workdirs_or_flow`. No test result or final combined-tree compile success is claimed for that invocation. Parent must revalidate its current API/combined tree.
|
||||
|
||||
## Concrete API / runtime limits
|
||||
|
||||
At base `56795fb9`, generated `openapi/server-api.json` has assignment tracking/history and start fences but no assignment-delivery query/receipt HTTP endpoint. `list_pending_ticket_assignment_deliveries` is an internal Store method, not a browser or normal Tool/WIP route. Normal Worker/WIP Ticket inventories also have no Orchestrator Assign/reissue/clear operation; this change removes obsolete Queue rather than fabricating a replacement operation or ledger. The parent owns any public endpoint/Tool handoff needed to satisfy the whole Ticket. Instructions explicitly require reporting unavailable typed operations and forbid inferring delivery/acceptance from status or Worker state.
|
||||
|
||||
The granted live typed Ticket tools still returned legacy six-state projections for the requested reads; those reads established requirements/history, not evidence that the running environment has adopted the repository's new API. No environment update occurred.
|
||||
|
||||
Not run/claimed: whole Worker test suite, Web/browser/visual validation, generated-contract regeneration/check, live migration, model judgment evaluation, full-stack E2E, final combined-tree root compile after concurrent API changes, or T-748 DB-boundary validation. Parent's concurrent Ticket/Server/API/Web changes are excluded from this implementation's evidence.
|
||||
@@ -0,0 +1,58 @@
|
||||
# T-743 public surfaces: combined implementation and validation
|
||||
|
||||
## Snapshot and scope
|
||||
|
||||
Work branch: `work/T-743-ticket-public-surfaces`, based on T-742's `56795fb92cb9def9bcd701bd51d92e99526f8eff`. This is implementation evidence, not an integration, approval, Ticket conclusion or live-environment update. See the delegated Prompt and Tool reports in this directory for their narrower evidence and earlier blockers; the checks below supersede their earlier compile/format limitations for the combined snapshot.
|
||||
|
||||
The existing Ticket, assignment tracking, assignment delivery and start records remain the authorities. No browser ledger, alternative assignment store or copied DB executor was added.
|
||||
|
||||
## Implemented boundaries
|
||||
|
||||
- Ticket list and detail use open/closed case state, stable classification tag IDs and an independent current responsibility/handling projection. Tags and current/historical closure reasons remain distinct. List filtering for tag IDs, principal, case and current close reason is server-side and preserves cursors; ordinary text search can match retained closure reasons after correction/reopen.
|
||||
- The Orchestrator page reads current assignments with waiting/inprogress/done, reasons, progress, next action and results. Worker liveness, MR evidence, delivery and acceptance do not manufacture a handling status.
|
||||
- Detail supports classification catalog create/list/rename/delete, multiple membership changes, reasoned close/correction/reopen, Assign, observed replacement/reissue, release, retained assignment/result/release history and explicit assignment-fenced answers. Responsibility principal choice uses the real typed user/Worker/Workspace-agent request, not caller authentication data. Backend still validates identities and actual authority.
|
||||
- A saved Assign is not reported as successful delivery or execution. Unknown/stale/permission/unavailable outcomes are distinct. Exact operation snapshots preserve the original key, observed assignment/event or tag set and body; no automatic new delegation is substituted after a lost response.
|
||||
- Pending child operations and observations reset on the Workspace **and** Ticket key, including Workspace changes that retain the same Ticket ID.
|
||||
- Normal Tools/WIP and effective Prompt/Profile/Flow descriptions use Assign as delegation, no ready/Queue prerequisite, same-row continuation answers, explicit new-row reissue and separate case judgment. Retained responsibility and Done are not Worker activity, merge or third-party approval. Historical reports were not rewritten; superseded design descriptions have explicit notices.
|
||||
|
||||
### Canonical API contacts
|
||||
|
||||
Canonical `/workspaces/{workspace_id}/tickets/{ticket_id}` and `/api/w/{workspace_id}/tickets/{ticket_id}` routes expose:
|
||||
|
||||
- GET `assignment-tracking/assignments`: bounded retained rows with assignment-identity pagination.
|
||||
- GET `assignment-diagnostics`: sanitized existing input/delivery/ack/start records, independent input/start cursors, unknown origin/acceptance/actual-processing distinctions; no rendered notification content or binding credentials.
|
||||
- POST `assignment-answer`: authenticated source, observed assignment and handling-event CAS, stable operation-key receipt, body event and audit under one IMMEDIATE transaction. Publication occurs only after the helper returns with its transaction committed. Exact replay retains the receipt; generic history comments do not become unfenced continuing input.
|
||||
- Shared Ticket query principal/handling/current-closure-reason filtering is applied in SQL before paging.
|
||||
|
||||
## Validation
|
||||
|
||||
Passed on the combined working snapshot:
|
||||
|
||||
- Root `cargo check` (normal TUI/Server compile closure).
|
||||
- `cargo test --no-fail-fast -p ticket -p worker-runtime -p worker -p yoi-workspace-server -p server-api`: all selected crate targets passed. Library counts: server-api 98; ticket 99; worker 994; worker-runtime 417; workspace-server 946 with one existing ignored test. Included integration/binary targets also passed. Log: `target/t743-changed-crates-final.log`.
|
||||
- Focused real-Worker delivery fixture now synchronizes on a canonical `RunCompleted` **after** the latest notification plus final Idle, not the initial Idle window, a fixed sleep or acceptance alone. The original pre-processing Idle failure was reproduced and the focused test plus full Server suite passed after the fix.
|
||||
- Focused manifest builtin-profile (4) and builtin Flow (2) tests passed. Effective research-return, confirmation-wait/same-assignment-answer and separate reasoned-close fixtures are included in the Worker suite.
|
||||
- Web `deno task test`: **613 passed**. Web `deno task test:component`: **318 passed**. Web check: **0 errors, 0 warnings**. Static production build passed (existing chunk-size advisory remains).
|
||||
- `ticket_shell_test.ts` plus `ticket_principal_test.ts`: **7 Chromium browser tests passed**. These exercise repository-free creation, server-filter pagination, known/custom close reasons, same-row answer, new-row reissue, retained results/releases, separate close/reopen, stable tag management, ambiguous saved Assign/exact retry, stale and permission denial, and the case/handling/Worker/MR/empty/error matrix in both themes. The native principal-selector test captures the selected target/observed row and waits on the second actual failed request for retry synchronization. Logs: `target/t743-browser-final.log`.
|
||||
- All six server-api TypeScript generators' `--check` commands passed (ticket, repository, runtime, workdir, Worker launch and companion); generated contract provenance is synchronized with OpenAPI.
|
||||
- `cargo fmt --all -- --check`, `git diff --check HEAD` and `env -u LD_LIBRARY_PATH deno run --allow-read --allow-run=git tools/check-state-contract-names.ts` passed; naming check: **558 matching lines, 0 unapproved, 0 stale exceptions**.
|
||||
|
||||
Earlier validation found and corrected the obsolete TUI queued-notice expectation, two delivery tests expecting ordinary comments to generate new continuing input, and a Home fixture missing the canonical assignment-tracking array. One run also exposed an unchanged Runtime cleanup test's differing walker counts. Its focused invocation and subsequent complete selected-crate run passed; no cleanup implementation fix or claim that this intermittent issue has been eliminated is made.
|
||||
|
||||
## Visual evidence
|
||||
|
||||
Baseline captures: `target/web-ux/t743-baseline-light` and `t743-baseline-dark`. Detail after captures: `t743-after-light` and `t743-after-dark`; actual contact-sheet slices were viewed, not merely generated. Conditions include member/non-owner fixture, Backend action-ineligible controls, 1440/768/390px, light/dark, open+done, open+waiting/stopped Worker, inprogress/running Worker/MR, closed/history, empty, long content and deliberate errors.
|
||||
|
||||
The deliberate query-error route retains its expected HTTP 503/document errors, so the full scenario's exit 2 is not presented as a clean no-error capture. Fixture "anonymous" transport is a synthetic member scenario, not proof of live anonymous authorization.
|
||||
|
||||
Principal-selector captures: `t743-principals-light`/`dark` before a styling correction and `t743-principals-final-light`/`dark` after it. Each final bundle has nine captures (agent/account/Worker choices at all three widths), with no capture errors. The first inspection found cramped native fieldset controls. The fix reuses shared Ticket form styling and removes the redundant fieldset border; final light/dark contact sheets were viewed. Controls, labels and focus remain readable without page overflow.
|
||||
|
||||
List/Orchestrator final evidence: `t743-list-final-reviewed-light` and `t743-list-final-reviewed-dark`, 33 captures each. Parent recovered the child's committed check/build and eight summary-model tests, inspected its final capture metadata (only the intentional query-error route had errors), and viewed actual list/Orchestrator screenshots at desktop/narrow/mobile. The refinements remove repeated offline/count prose, preserve the first mobile case in view and fold close-reason controls separately from classification. Parent verified current diff and browser tests, released the delegated file authority and owns the published handoff. This is observed tool/image evidence, not an invented child review verdict.
|
||||
|
||||
## Explicit remaining work and limits
|
||||
|
||||
T-748's DB execution facade is **not** copied or integrated. Its latest notified candidate is local-only and its full Server suite has unresolved failures. When T-748 publishes a validated source, normal-merge it, place each new whole synchronous domain helper/authority query into the common bounded DB execution boundary, keep the answer transaction intact, and leave Runtime/network waiting off the DB worker. Recheck cancellation/overload and commit-before-publication at the combined boundary. The exact combined published source then needs independent trusted review.
|
||||
|
||||
Current evidence does not attest actual model judgment quality. Prompt/Tool fixtures are scripted inclusion/schema/transport tests; Chromium uses a loopback API fixture. The real-Worker Server fixture uses a scripted model with actual Controller/session/Runtime acceptance and canonical processing synchronization. None is a live migration/rollout, proof of actual processing for every production delivery, or completion of T-744's full real-path validation.
|
||||
|
||||
No target integration, Ticket conclusion, live process update or branch/Workdir cleanup was performed.
|
||||
+961
-1
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,7 @@
|
||||
};
|
||||
|
||||
done = {
|
||||
instructions = "The approved implementation has been handed off for Orchestrator-owned readiness, target integration, and `CompleteMergeRequest`. Flow terminal state only reflects that handoff.";
|
||||
instructions = "The approved implementation has been handed off for Orchestrator-owned readiness, target integration, and `CompleteMergeRequest`. Flow terminal state only reflects that handoff; it is neither Ticket closed nor an assignment handling done self-report, work release, unassignment, or Worker stop. Generic Ticket closure has no universal MR gate; explicit user approval conditions and guarded MR integration remain separate.";
|
||||
terminal = true;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,6 +12,6 @@ import "./base.dcdl" // {
|
||||
web = { enabled = true; };
|
||||
sub_worker = { enabled = false; };
|
||||
worker = { enabled = false; };
|
||||
ticket = { enabled = true; authoring = true; thread = true; intake = true; };
|
||||
ticket = { enabled = true; authoring = true; thread = true; };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./base.dcdl" // {
|
||||
slug = "orchestrator";
|
||||
description = "Intent-led Ticket orchestration with separate guarded MR integration authority.";
|
||||
description = "Intent-led Ticket delegation and self-reported assignment handling; case closure and guarded MR integration are separate.";
|
||||
scope = "workspace_write";
|
||||
engine = { instruction = "role.orchestrator"; };
|
||||
|
||||
|
||||
@@ -10,4 +10,14 @@ Choose a result and return path suited to the request: conversation, an optional
|
||||
|
||||
Ticket cases have only `open` (still handled) and `closed` (handling ended for now); open is not execution readiness and closed is not necessarily fulfilled. Use authorized `TicketCaseUpdate` with `close`, `reopen`, or `correct_closure`, an explanation and typed closure reason when the request calls for it. Observe `content_digest`, case state, and nullable `closure_operation_key` from `ShowTicket` for CAS, and use a stable operation key for exact replay. Closure reasons and immutable history are separate from classification tags; tag changes never alter the closure judgment. Reread and reconcile after conflict; never guess a newer observation. Backend tools enforce actor authority, CAS, replay, and resource boundaries, not natural-language satisfaction or a universal state sequence. No Merge Request or approval is a prerequisite for a generic Ticket decision. Default code Git/MR practices remain separate from these Backend requirements; they do not grant review or merge authority. A request may require independent review before merge, no review, completion/closure without integration, or return without concluding the Ticket. Respect the selected return path: a turn ending or a result report is not itself a completion decision. Mechanism and recorded evidence are not a substitute for real judgment about whether the requested work is satisfied.
|
||||
|
||||
Treat workflow states and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish a result report, Ticket conclusion, MR review/integration, unfinished assignment, and Worker lifecycle. Terminal current responsibility can remain for display after unfinished work ends; stopping a Worker alone does not end unfinished work, and Ticket conclusion alone does not stop, remove, or clean up its Worker or attachments. Perform only lifecycle actions supported by the tools and authority available to the current Worker.
|
||||
Classification tags use stable tag IDs, not display names, for mutations and filters. Use the shared Workspace tag tools for creation/list/rename/delete and Ticket tag-set changes with their observed operation identities; names such as `ready` never grant start permission. Tag-set CAS compares `expected_tag_ids` with the observed full ID set; tag rename/delete CAS compares `expected_last_operation_key` with the observed tag operation identity. Classification is separate from a close reason and never closes/reopens or delegates a Ticket.
|
||||
|
||||
Read the current Orchestrator delegation with `TicketAssignmentTrackingQuery`; enumerate retained assignment IDs with bounded `TicketAssignmentsQuery`, then read a selected row and its `TicketAssignmentTrackingHistory`. Use `TicketAssignmentDiagnostics` for independent bounded delivery/input and start evidence; its separate cursors and explicit unknown/history fields are not a handling status. `QueryTicket` can filter the exact current `assigned_principal`, self-reported `handling_status`, and current closed-case `closure_reason`. Closure reason is distinct from tag IDs; retained/reopened reasons are history, and Worker status is never inferred. Each immutable `assignment_id` denotes this particular delegation to the stable Workspace Orchestrator principal, not a concrete Runtime Worker. Assign is delegation and initial judgment input; no ready step or separate Queue is required. `waiting`, `inprogress`, and `done` are the Orchestrator's self-reported handling, never Ticket case states, delivery/acceptance or Worker running/idle/stop. Record waiting reason, progress, next action and result through `TicketAssignmentTrackingUpdate` when authorized, not just conversation memory. Waiting must identify what is needed next; research returned to a human can be `open` + handling `done`. Respect an explicit user approval condition; do not add a universal human/MR gate. Done is not Backend certification of satisfaction, third-party approval, merge, actual work release, or Worker stop.
|
||||
|
||||
Use `TicketOrchestratorAssign` for explicit initial delegation or reissue, with target principal, observed `expected_assignment_id` (explicit null only when unassigned), and stable `operation_id`. Query first; target principal is not the actor or an authority claim. The usual target is `{kind: "workspace_agent", agent_key: "workspace-orchestrator"}`; Backend validates role/principal and actual authentication. Use `TicketOrchestratorClear` only for explicit release of the observed assignment with its ID and operation_id, not automatically on done/closed. Neither operation starts or stops a Worker. Exact retry retains the same operation_id and full input; conflict or unknown outcome must not silently generate a new key. Reread current tracking before any subsequent mutation.
|
||||
|
||||
Answers during waiting or ongoing handling continue the same assignment row; use `TicketAssignmentAnswer` with that `assignment_id`, observed `expected_last_event_id`, stable `operation_key`, and answer `body`, not an unfenced comment as a substitute. Do not reassign merely to deliver an answer or automatically declare inprogress on arrival. After done, an explicit reissue creates a new row/assignment_id even for the same principal and preserves the prior result. Reopen alone is neither reissue nor resurrection of an old Worker. Release/replacement remains separate from the old row's last status/result; done does not require unassignment. Worker replacement alone does not create a new delegation. Dependencies are diagnostics for judging partial work or waiting, not a blanket start gate; never start dependencies implicitly.
|
||||
|
||||
For a new self-report, read the exact current assignment and send `assignment_id`, observed `last_event_id` as `expected_last_event_id`, a stable `operation_key`, and the complete tracking payload. Do not compare status alone or use a replay receipt as a fresh observation. Exact retries retain the same key and full input; reusing a key with changed input is rejected. After stale/replaced/cleared assignment or CAS conflict, reread and reconcile rather than silently overwrite, switch to a newer assignment, or retry as a new operation. Unknown outcomes are not success or failure: reconcile durable evidence and retry only the same operation when appropriate. Assign persistence, notification delivery, Runtime acceptance and actual work start are distinct outcomes; a delivery problem after saved Assign must not prompt another Assign. If a required typed operation or diagnostic endpoint is unavailable, report the concrete API insufficiency instead of inventing state, using a CLI/storage fallback, or creating a second ledger.
|
||||
|
||||
Treat case state, assignment handling, and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish a result report, Ticket conclusion, MR review/integration, unfinished assignment, and Worker lifecycle. Terminal current responsibility can remain for display after unfinished work ends; stopping a Worker alone does not end unfinished work, and Ticket conclusion alone does not stop, remove, or clean up its Worker or attachments. Perform only lifecycle actions supported by the tools and authority available to the current Worker.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
Queued Tickets require attention:
|
||||
Assigned Tickets have input requiring attention:
|
||||
{% for ticket in tickets -%}
|
||||
- {{ ticket.resource_key }} {{ separator }} {{ ticket.title }}
|
||||
{% endfor -%}
|
||||
{% if omitted_ticket_count > 0 -%}
|
||||
Additional queued Tickets were omitted from this notice: {{ omitted_ticket_count }}. Re-query current Ticket authority for the complete set.
|
||||
Additional assigned Tickets were omitted from this notice: {{ omitted_ticket_count }}. Re-query current Ticket authority for the complete set.
|
||||
{% endif -%}
|
||||
Reread the current Ticket state before acting. Preserve the human queue gate and current assignment, dependency, Worker, and Workdir authority; do not create duplicate work.
|
||||
Reread the current Ticket, current assignment/event identity, handling and new input before acting. Assign is delegation; no ready/Queue step is required. Waiting alone or done history is not new work. Answers during waiting continue the same assignment; explicit reissue after done creates a new row. Supply the observed orchestrator_assignment_id for starts. Handling is a self-report, not delivery, acceptance or Worker state. Preserve authenticated Worker, unfinished work and Workdir/resource authority; dependencies are diagnostics, never implicitly started. Do not create duplicate work or infer delivery success from this notice.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
You are the assigned Coder. Implement the requested scope in the provided Workdir. Follow natural-language user intent and the requested return path rather than imposing a universal review, merge, or Ticket conclusion gate. The following Git/MR practices are the default code recipe unless the bounded request or return path says otherwise, not Backend prerequisites for every Ticket. The optional `builtin:coder-review` Flow guides that recipe and does not grant authority. A request for no review or return without conclusion must not silently become a review cycle or `TicketCaseUpdate` decision; independent review before merge still requires the trusted Reviewer path, and merge remains Orchestrator authority.
|
||||
|
||||
Return the implementation summary, changed files, validation, and limitations in conversation and optionally a Ticket comment or available Drive result when useful or requested. Ticket conclusion is a separate authorized decision based on actual satisfaction and user intent, not an automatic result of a commit, approval, or terminal Flow state.
|
||||
Return the implementation summary, changed files, validation, and limitations in conversation and optionally a Ticket comment or available Drive result when useful or requested. Ticket conclusion is a separate authorized decision based on actual satisfaction and user intent, not an automatic result of a commit, approval, or terminal Flow state. Ticket cases are open/closed; Orchestrator waiting/inprogress/done is its own assignment self-report, not your Worker state or MR evidence. Return bounded progress/results to the current delegation without claiming Orchestrator reporting authority. No ready/Queue step or mandatory terminal unassignment is part of code handoff.
|
||||
|
||||
Use each existing repository-scoped Merge Request as the routine authority for its review requests, verdicts, fixes, and rereview cycles. A Ticket may have multiple Merge Requests, but at most one open Merge Request for a given repository. `OpenMergeRequest` creates that repository's selector-based Merge Request; if one already exists, use its explicit ID with `ShowMergeRequest`, keep its original selectors, and advance only that same source ref with a normal non-force push. Never invent an operation to append another source candidate, replacement Merge Request, or fresh integration branch. Source movement requires fresh review of the exact new ref. Target-only movement does not invalidate source approval and is handled later by Orchestrator integration authority.
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@ Use one Ticket for a deliverable that can be explained as a specification or fea
|
||||
|
||||
Create or update Tickets only after user agreement or an explicit user instruction to record the agreed draft. Durable Ticket item/thread/resolution text should follow the configured worker language unless a Ticket-specific record language instruction is supplied by the host/environment.
|
||||
|
||||
Intake is not a scheduler. Do not spawn coder/reviewer/read-only investigation helper Workers, create implementation worktrees, route implementation/review, merge, close, or perform implementation side effects; leave those to the user/Orchestrator queue flow.
|
||||
Intake is not a scheduler. Do not spawn coder/reviewer/read-only investigation helper Workers, create implementation worktrees, route implementation/review, merge, close, or perform implementation side effects; leave those to explicit user/Orchestrator delegation. Ticket creation records an open case, not execution readiness. Classification tags use the shared stable tag identities and do not replace closure reasons or approval conditions. Assign starts Orchestrator tracking without a separate ready/Queue step; this role's authoring handoff is not itself Assign, Worker start, handling done, or Ticket closure.
|
||||
|
||||
Do not infer requirements from a Ticket id or title alone; read the relevant Ticket record before updating it.
|
||||
|
||||
@@ -4,7 +4,9 @@ You are the Ticket Orchestrator role.
|
||||
|
||||
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Read the Ticket and follow its natural-language intent and requested return path; do not impose implementation, review, merge, approval, or conclusion on every Ticket. Work may be investigation, analysis, planning, documentation, or code. Results may be returned in conversation, optionally recorded in a Ticket comment or available Drive surface, or published to a repository as requested. Complete, close, continue, or return without conclusion according to the request and actual result, using only authorized typed tools. Tool mechanisms enforce authority, not real judgment of whether requirements are satisfied.
|
||||
|
||||
Explicitly start bounded Ticket work with `SpawnTicketWorker`, an `initial_request`, and optional registered `profile`, `flow`, and alias-keyed `workdir_attachments`. Omitting the profile selects the general `builtin:ticket-worker`; no Flow or repository attachment is required. Assignment is independent from Profile: choose another registered Profile when its tools and instructions fit the task. Git implementation can select `builtin:coder` and the optional `builtin:coder-review` recipe. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Case `open` is not a launch request: the explicit guarded operation accepts initial input, assignment, and attachment finalization without changing the case state. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. After start, reread the Ticket and verify the new assignment identity and authoritative unfinished-work acceptance, not retained `current_worker` alone before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope.
|
||||
Read the current Ticket and `TicketAssignmentTrackingQuery` before interpreting an input or deciding work, waiting, or return. Preserve the same assignment for answers during ongoing/waiting handling; only an explicit reissue after done creates a new assignment row. Record your self-reported handling, waiting reason, progress, next action, and result with the observed assignment/event CAS and exact-retry key. Returning research can leave the Ticket open and handling done. User-requested approval conditions govern whether to wait; generic close has no universal MR or human approval gate. Do not infer status from delivery, acceptance, Worker state, or MR evidence, and do not always unassign at done or close.
|
||||
|
||||
Explicitly start bounded Ticket work with `SpawnTicketWorker`, the observed current `orchestrator_assignment_id`, an `initial_request`, and optional registered `profile`, `flow`, and alias-keyed `workdir_attachments`. Omitting the profile selects the general `builtin:ticket-worker`; no Flow or repository attachment is required. Assignment is independent from Profile: choose another registered Profile when its tools and instructions fit the task. Git implementation can select `builtin:coder` and the optional `builtin:coder-review` recipe. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Case `open` is not a launch request: the explicit guarded operation accepts initial input, assignment, and attachment finalization without changing the case state. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. Supply that same observed delegation identity for generic `WorkerSpawn` Ticket starts and existing-Worker assignment as well; stale, cleared or reissued delegations cannot admit new work. Dependencies remain diagnostics, not a blanket refusal, and are never implicitly started. After start, reread the Ticket and verify the new Worker assignment identity and authoritative unfinished-work acceptance, not retained `current_worker` alone before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope.
|
||||
|
||||
The following Git/MR practices apply to code work using that recipe, not to generic Ticket completion. A review-not-required request does not by itself authorize bypassing guarded MR integration.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
You are the assigned Ticket Worker. Carry out the user's requested work and keep durable progress and completion evidence on the Ticket. Ticket work may be research, analysis, planning, documentation, or other non-code work; do not assume a repository, Workdir, Flow, git operation, Merge Request, or review cycle is required.
|
||||
|
||||
Read the current Ticket with `ShowTicket` before acting. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it.
|
||||
Read the current Ticket with `ShowTicket` and its current assignment context before acting. Ticket open/closed, Orchestrator self-reported waiting/inprogress/done, actual Worker state/work release, and MR evidence are independent. Report bounded results or blockers to the Orchestrator for its same-delegation tracking; being the assigned Worker does not authorize you to self-report as the Workspace Orchestrator. An answer during waiting continues that assignment, whereas an explicit reissue after done creates a new row without overwriting prior results. No ready/Queue stage or terminal-always-unassign rule applies. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it.
|
||||
|
||||
Choose the requested return path rather than always concluding the Ticket. Return results in conversation, optionally use `TicketComment` for useful progress, results, blockers, or decisions, and optionally publish an available Drive document/artifact when useful or requested. No comment or artifact is universally required. Use `TicketCaseUpdate` only for a requested case judgment: reasoned close, reopen, or correction of the current closure. Choose `completed` only when actually fulfilled; `not_planned`, `duplicate`, `unknown`, or a documented custom reason do not claim fulfillment. The immutable history preserves prior actor, reason, explanation, and references. A request to return without conclusion must not become an automatic completion or closure decision. Use the current `content_digest` as `expected_content_digest` and expected case state plus nullable `closure_operation_key` from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. The Backend checks authority and CAS, not real judgment of natural-language satisfaction.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
|
||||
import { chromium } from "npm:playwright@1.59.1";
|
||||
import { loadScenario } from "../src/scenario.ts";
|
||||
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
|
||||
|
||||
// Native select and confirmed replacement against the loopback browser fixture.
|
||||
// happy-dom does not match selected options with :checked (used by Svelte's
|
||||
// select binding); do not substitute fake component state for this boundary.
|
||||
Deno.test("principal replacement snapshots the target and observed assignment for exact unknown-outcome retry", async () => {
|
||||
const scenarioPath = new URL("../scenarios/tickets.json", import.meta.url).pathname;
|
||||
const scenario = await loadScenario(scenarioPath);
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
scenario.processes![0].args![4] = String(port);
|
||||
scenario.processes![0].readyUrl = `${base}/health`;
|
||||
const processes = await startOwnedProcesses(
|
||||
scenario.processes!,
|
||||
scenarioPath,
|
||||
new URL(`../../../target/web-ux/t743-principal/${port}`, import.meta.url).pathname,
|
||||
[],
|
||||
);
|
||||
let browser: Awaited<ReturnType<typeof chromium.launch>> | null = null;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/w/tickets/tickets/T-2`);
|
||||
await page.getByText("Self-reported handling: waiting", { exact: true }).waitFor();
|
||||
await page.getByText("Choose responsibility principal", { exact: true }).click();
|
||||
await page.getByRole("combobox", { name: "Principal kind", exact: true }).selectOption("user");
|
||||
await page.getByLabel("Account identity", { exact: true }).fill("alice");
|
||||
await page.getByText("Replace this delegation", { exact: true }).click();
|
||||
const sent: string[] = [];
|
||||
await page.route("**/assignments/orchestrator", async (route) => {
|
||||
if (route.request().method() === "PUT") {
|
||||
sent.push(route.request().postData()!);
|
||||
await route.abort("failed");
|
||||
} else await route.continue();
|
||||
});
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByRole("button", { name: "Replace assignment", exact: true }).click();
|
||||
await page.getByRole("alert").filter({ hasText: "Outcome unknown" }).waitFor();
|
||||
assertEquals(sent.length, 1);
|
||||
const input = JSON.parse(sent[0]);
|
||||
assertEquals(input.principal, { kind: "user", account_id: "alice" });
|
||||
assertEquals(input.expected_assignment_id, "assignment-T-2");
|
||||
assert(typeof input.operation_id === "string" && input.operation_id.length > 0);
|
||||
assert(await page.getByRole("combobox", { name: "Principal kind", exact: true }).isDisabled());
|
||||
const retryFailed = page.waitForEvent(
|
||||
"requestfailed",
|
||||
(request) =>
|
||||
request.method() === "PUT" && request.url().endsWith("/assignments/orchestrator"),
|
||||
);
|
||||
await page.getByRole("button", { name: "Retry exact operation", exact: true }).click();
|
||||
// The alert text is unchanged on retry; synchronize on the actual second
|
||||
// request failure, not a fixed delay or an unrelated Worker state.
|
||||
await retryFailed;
|
||||
assertEquals(sent.length, 2);
|
||||
assertEquals(sent[1], sent[0]);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
});
|
||||
@@ -1,28 +1,601 @@
|
||||
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
|
||||
import { chromium } from "npm:playwright@1.59.1";
|
||||
import { type Browser, chromium, type Page } from "npm:playwright@1.59.1";
|
||||
import { loadScenario } from "../src/scenario.ts";
|
||||
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
|
||||
|
||||
// Browser boundary over the production static build and synthetic loopback API.
|
||||
// No Backend, Worker, Repository, review or integration operation is executed.
|
||||
Deno.test("Ticket progress, comment results and reopen work without Git while real MR evidence stays separate", async () => {
|
||||
const scenarioPath = new URL("../scenarios/tickets.json", import.meta.url).pathname;
|
||||
// Explicit browser boundary: production static build + loopback API fixture.
|
||||
// These tests prove UI/request semantics, not real Backend delivery or model judgment.
|
||||
async function withFixture(
|
||||
run: (browser: Browser, baseUrl: string) => Promise<void>,
|
||||
) {
|
||||
const scenarioPath =
|
||||
new URL("../scenarios/tickets.json", import.meta.url).pathname;
|
||||
const scenario = await loadScenario(scenarioPath);
|
||||
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (listener.addr as Deno.NetAddr).port;
|
||||
listener.close();
|
||||
scenario.baseUrl = `http://127.0.0.1:${port}`;
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
scenario.processes![0].args![4] = String(port);
|
||||
scenario.processes![0].readyUrl = `${scenario.baseUrl}/health`;
|
||||
const output = new URL("../../../target/web-ux/t720-browser/", import.meta.url).pathname;
|
||||
scenario.processes![0].readyUrl = `${baseUrl}/health`;
|
||||
const logs =
|
||||
new URL(`../../../target/web-ux/t743-browser/${port}/logs`, import.meta.url)
|
||||
.pathname;
|
||||
const processes = await startOwnedProcesses(
|
||||
scenario.processes!,
|
||||
scenarioPath,
|
||||
`${output}/logs`,
|
||||
logs,
|
||||
[],
|
||||
);
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
let browser: Browser | null = null;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
await run(browser, baseUrl);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
}
|
||||
async function detail(base: string, key: string) {
|
||||
return await (await fetch(`${base}/api/w/tickets/tickets/${key}`)).json();
|
||||
}
|
||||
async function control(base: string, body: unknown) {
|
||||
const response = await fetch(`${base}/fixture-control`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
assert(response.ok);
|
||||
await response.json();
|
||||
}
|
||||
function queryResponse(
|
||||
page: Page,
|
||||
predicate: (body: Record<string, unknown>) => boolean,
|
||||
) {
|
||||
return page.waitForResponse((response) =>
|
||||
response.url().endsWith("/tickets/query") &&
|
||||
response.request().method() === "POST" &&
|
||||
predicate(response.request().postDataJSON())
|
||||
);
|
||||
}
|
||||
|
||||
Deno.test("Ticket filters and Orchestrator handling query the server beyond loaded pages and preserve filters on pagination", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/w/tickets-pages/tickets`);
|
||||
await page.locator('.ticket-lane[data-state="open"] .ticket-card').first()
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
await page.locator('.ticket-lane[data-state="open"] .ticket-card')
|
||||
.count(),
|
||||
30,
|
||||
);
|
||||
assertEquals(
|
||||
await page.locator(".ticket-card-id").filter({ hasText: /^T-35$/ })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
await page.getByRole("checkbox", { name: "UI", exact: true }).check();
|
||||
await page.getByRole("combobox", {
|
||||
name: "Assigned principal",
|
||||
exact: true,
|
||||
}).selectOption("orchestrator");
|
||||
const filtered = queryResponse(
|
||||
page,
|
||||
(body) =>
|
||||
(body.tag_ids as string[])?.includes("tag-ui") &&
|
||||
(body.assigned_principal as { kind: string })?.kind ===
|
||||
"workspace_agent" &&
|
||||
(body.states as string[]).includes("open"),
|
||||
);
|
||||
await page.getByRole("button", { name: "Apply filters", exact: true })
|
||||
.click();
|
||||
const response = await filtered;
|
||||
assertEquals(response.request().postDataJSON(), {
|
||||
query: null,
|
||||
states: ["open"],
|
||||
tag_ids: ["tag-ui"],
|
||||
assigned_principal: {
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
},
|
||||
handling_status: null,
|
||||
sort: "updated_desc",
|
||||
limit: 30,
|
||||
cursor: null,
|
||||
});
|
||||
await page.locator(".ticket-card-id").filter({ hasText: /^T-35$/ })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
await page.locator('.ticket-lane[data-state="open"] .ticket-card')
|
||||
.count(),
|
||||
2,
|
||||
);
|
||||
await page.goto(
|
||||
`${base}/w/tickets-pages/tickets?case=open&principal=orchestrator&tag=tag-investigation`,
|
||||
);
|
||||
await page.getByRole("button", { name: "Load more open tickets" })
|
||||
.waitFor();
|
||||
const next = queryResponse(page, (body) => body.cursor === "30");
|
||||
await page.getByRole("button", { name: "Load more open tickets" }).click();
|
||||
const nextRequest = (await next).request().postDataJSON();
|
||||
assertEquals(nextRequest.tag_ids, ["tag-investigation"]);
|
||||
assertEquals(nextRequest.states, ["open"]);
|
||||
assertEquals(nextRequest.assigned_principal, {
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
});
|
||||
await page.locator(".ticket-card-id").filter({ hasText: /^T-40$/ })
|
||||
.waitFor();
|
||||
assertEquals(await page.locator(".ticket-card").count(), 39);
|
||||
await page.goto(`${base}/w/tickets-pages/orchestrator?handling=done`);
|
||||
await page.getByRole("button", { name: "Load more assignments" }).waitFor();
|
||||
const moreHandling = queryResponse(
|
||||
page,
|
||||
(body) => body.cursor === "30" && body.handling_status === "done",
|
||||
);
|
||||
await page.getByRole("button", { name: "Load more assignments" }).click();
|
||||
const handlingRequest = (await moreHandling).request().postDataJSON();
|
||||
assertEquals(handlingRequest.assigned_principal, {
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
});
|
||||
assertEquals(handlingRequest.states, []);
|
||||
await page.locator(".orchestrator-assignment").filter({ hasText: "T-40" })
|
||||
.waitFor();
|
||||
assertEquals(await page.locator(".orchestrator-assignment").count(), 38);
|
||||
await page.getByRole("combobox", {
|
||||
name: "Handling (self-reported)",
|
||||
exact: true,
|
||||
}).selectOption("waiting");
|
||||
const waiting = queryResponse(
|
||||
page,
|
||||
(body) => body.handling_status === "waiting" && body.cursor === null,
|
||||
);
|
||||
await page.getByRole("button", { name: "Apply filters", exact: true })
|
||||
.click();
|
||||
await waiting;
|
||||
await page.locator(".orchestrator-assignment").filter({
|
||||
hasText: "Awaiting the user's policy choice",
|
||||
}).waitFor();
|
||||
assertEquals(await page.locator(".orchestrator-assignment").count(), 1);
|
||||
assertEquals(await page.getByRole("link", { name: /T-1 / }).count(), 0);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("current close-reason filtering sends canonical known/custom reasons and never closes cases", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/w/tickets/tickets`);
|
||||
await page.getByText("Close reason filter", { exact: true }).click();
|
||||
await page.getByRole("combobox", {
|
||||
name: "Current close reason",
|
||||
exact: true,
|
||||
}).selectOption("completed");
|
||||
await page.getByRole("combobox", { name: "Case", exact: true })
|
||||
.selectOption("closed");
|
||||
const response = queryResponse(
|
||||
page,
|
||||
(body) => body.closure_reason === "completed",
|
||||
);
|
||||
await page.getByRole("button", { name: "Apply filters", exact: true })
|
||||
.click();
|
||||
assertEquals((await response).request().postDataJSON().states, ["closed"]);
|
||||
await page.locator('.ticket-lane[data-state="open"]').waitFor({
|
||||
state: "hidden",
|
||||
});
|
||||
assertEquals(await page.locator(".ticket-card").count(), 1);
|
||||
assertEquals(await page.locator(".ticket-card-id").textContent(), "T-4");
|
||||
await page.getByRole("combobox", {
|
||||
name: "Current close reason",
|
||||
exact: true,
|
||||
}).selectOption("other");
|
||||
await page.getByLabel("Close reason text", { exact: true }).fill(
|
||||
"Superseded plan",
|
||||
);
|
||||
const custom = queryResponse(
|
||||
page,
|
||||
(body) =>
|
||||
(body.closure_reason as { other: string })?.other === "Superseded plan",
|
||||
);
|
||||
await page.getByRole("button", { name: "Apply filters", exact: true })
|
||||
.click();
|
||||
assertEquals((await custom).request().postDataJSON().closure_reason, {
|
||||
other: "Superseded plan",
|
||||
});
|
||||
await page.getByText("No tickets", { exact: true }).waitFor();
|
||||
assertEquals(
|
||||
(await detail(base, "T-4")).current_closure.reason,
|
||||
"completed",
|
||||
);
|
||||
const requests = await (await fetch(`${base}/fixture-state`)).json();
|
||||
assert(
|
||||
!requests.some((row: { path: string }) => row.path.endsWith("/case")),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("waiting answers retain a delegation, done reissue retains results, and reasoned close/reopen leaves handling separate", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/w/tickets/tickets/T-2`);
|
||||
await page.getByText("Self-reported handling: waiting", { exact: true })
|
||||
.waitFor();
|
||||
const before = await detail(base, "T-2");
|
||||
const row = before.assignment_tracking[0];
|
||||
await page.getByText("Record result or comment", { exact: true }).click();
|
||||
await page.getByLabel("Body", { exact: true }).fill(
|
||||
"Ordinary retained discussion, not an answer input",
|
||||
);
|
||||
const eventResponse = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/events") &&
|
||||
response.request().method() === "POST"
|
||||
);
|
||||
await page.getByRole("button", { name: "Append event", exact: true })
|
||||
.click();
|
||||
assertEquals((await eventResponse).request().postDataJSON(), {
|
||||
role: "comment",
|
||||
body: "Ordinary retained discussion, not an answer input",
|
||||
});
|
||||
await page.getByText("Ordinary retained discussion, not an answer input", {
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals((await detail(base, "T-2")).assignment_tracking[0], row);
|
||||
await page.getByLabel("Answer / continue this handling", { exact: true })
|
||||
.fill("Choose read-only comparison; keep the same delegation");
|
||||
const answerResponse = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/assignment-answer") &&
|
||||
response.request().method() === "POST"
|
||||
);
|
||||
await page.getByRole("button", {
|
||||
name: "Send answer to this assignment",
|
||||
exact: true,
|
||||
}).click();
|
||||
const answer = (await answerResponse).request().postDataJSON();
|
||||
assertEquals(answer.assignment_id, row.assignment_id);
|
||||
assertEquals(answer.expected_last_event_id, row.last_event_id);
|
||||
assertEquals(
|
||||
answer.body,
|
||||
"Choose read-only comparison; keep the same delegation",
|
||||
);
|
||||
assert(typeof answer.operation_key === "string");
|
||||
assertEquals(Object.keys(answer).toSorted(), [
|
||||
"assignment_id",
|
||||
"body",
|
||||
"expected_last_event_id",
|
||||
"operation_key",
|
||||
]);
|
||||
await page.getByRole("status").filter({
|
||||
hasText: "Answer saved to assignment",
|
||||
}).waitFor();
|
||||
const after = await detail(base, "T-2");
|
||||
assertEquals(after.assignment_tracking[0].assignment_id, row.assignment_id);
|
||||
assertEquals(after.assignment_tracking[0].tracking.status, "waiting");
|
||||
assertEquals(after.events.at(-1).kind, "assignment_answer");
|
||||
await page.goto(`${base}/w/tickets/tickets/T-1`);
|
||||
await page.getByText("Self-reported handling: done", { exact: true })
|
||||
.waitFor();
|
||||
const original = (await detail(base, "T-1")).assignment_tracking[0];
|
||||
const reissueResponse = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/assignments/orchestrator") &&
|
||||
response.request().method() === "PUT"
|
||||
);
|
||||
await page.getByRole("button", {
|
||||
name: "Request again (new assignment)",
|
||||
exact: true,
|
||||
}).click();
|
||||
const request = (await reissueResponse).request().postDataJSON();
|
||||
assertEquals(request.expected_assignment_id, original.assignment_id);
|
||||
assertEquals(request.principal, {
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
});
|
||||
assert(typeof request.operation_id === "string");
|
||||
await page.getByText("Self-reported handling: waiting", { exact: true })
|
||||
.waitFor();
|
||||
const reissued = await detail(base, "T-1");
|
||||
assert(
|
||||
reissued.assignment_tracking[0].assignment_id !== original.assignment_id,
|
||||
);
|
||||
assertEquals(reissued.state, "open");
|
||||
await page.getByText("Past assignment and release history", { exact: true })
|
||||
.click();
|
||||
await page.getByLabel("Assignment identity", { exact: true }).fill(
|
||||
original.assignment_id,
|
||||
);
|
||||
await page.getByRole("button", { name: "Read assignment", exact: true })
|
||||
.click();
|
||||
await page.getByText(/Previous result: Compared options/).waitFor();
|
||||
await page.getByText(/replaced by fixture-owner/).waitFor();
|
||||
await page.goto(`${base}/w/tickets/tickets/T-4`);
|
||||
await page.getByText("Self-reported handling: done", { exact: true })
|
||||
.waitFor();
|
||||
await page.locator(".ticket-case-card").getByLabel("Explanation", {
|
||||
exact: true,
|
||||
}).fill("Reconsider the case without delegating again");
|
||||
const reopenResponse = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/case") && response.request().method() === "POST"
|
||||
);
|
||||
await page.getByRole("button", { name: "Reopen ticket", exact: true })
|
||||
.click();
|
||||
const reopen = (await reopenResponse).request().postDataJSON();
|
||||
assertEquals(reopen.action, { action: "reopen" });
|
||||
assertEquals(reopen.expected_state, "closed");
|
||||
assertEquals(reopen.expected_closure_operation_key, "close-T-4");
|
||||
await page.getByRole("button", { name: "Close ticket", exact: true })
|
||||
.waitFor();
|
||||
const reopened = await detail(base, "T-4");
|
||||
assertEquals(reopened.state, "open");
|
||||
assertEquals(reopened.assignment_tracking[0].tracking.status, "done");
|
||||
assertEquals(reopened.closure_history.length, 2);
|
||||
await page.locator(".ticket-case-card").getByLabel("Explanation", {
|
||||
exact: true,
|
||||
}).fill("The full requested investigation is delivered");
|
||||
await page.locator(".ticket-case-card select").selectOption("completed");
|
||||
const closeResponse = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/case") && response.request().method() === "POST"
|
||||
);
|
||||
await page.getByRole("button", { name: "Close ticket", exact: true })
|
||||
.click();
|
||||
const close = (await closeResponse).request().postDataJSON();
|
||||
assertEquals(close.action, { action: "close", reason: "completed" });
|
||||
assertEquals(close.expected_state, "open");
|
||||
await page.getByRole("button", { name: "Reopen ticket", exact: true })
|
||||
.waitFor();
|
||||
const closed = await detail(base, "T-4");
|
||||
assertEquals(closed.current_closure.reason, "completed");
|
||||
assertEquals(closed.tags, reopened.tags);
|
||||
assertEquals(
|
||||
closed.assignment_tracking[0].assignment_id,
|
||||
reopened.assignment_tracking[0].assignment_id,
|
||||
);
|
||||
await page.getByText("Past assignment and release history", { exact: true })
|
||||
.click();
|
||||
await page.getByLabel("Assignment identity", { exact: true }).fill(
|
||||
"released-T-4",
|
||||
);
|
||||
await page.getByRole("button", { name: "Read assignment", exact: true })
|
||||
.click();
|
||||
await page.getByText(/unassigned by fixture-owner/).waitFor();
|
||||
await page.getByText(
|
||||
"Previous result: Delivered the full requested investigation",
|
||||
{
|
||||
exact: true,
|
||||
},
|
||||
).waitFor();
|
||||
assert(
|
||||
await page.getByText("Self-reported handling: done", { exact: true })
|
||||
.isVisible(),
|
||||
);
|
||||
const requests = await (await fetch(`${base}/fixture-state`)).json();
|
||||
assert(
|
||||
!requests.some((r: { path: string }) =>
|
||||
/\/(queue|ready|state)$/.test(r.path)
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("repository-free creation and canonical tag create/attach/rename/delete do not close or delegate the case", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/w/tickets/tickets`);
|
||||
await page.getByRole("button", { name: "Add Ticket", exact: true }).click();
|
||||
await page.getByLabel("Title", { exact: true }).fill(
|
||||
"Repository-free investigation",
|
||||
);
|
||||
await page.getByLabel("Body", { exact: true }).fill(
|
||||
"Compare options and return without deciding",
|
||||
);
|
||||
const createResponse = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/tickets") &&
|
||||
response.request().method() === "POST"
|
||||
);
|
||||
await page.getByRole("button", { name: "Create Ticket", exact: true })
|
||||
.click();
|
||||
const create = (await createResponse).request().postDataJSON();
|
||||
assertEquals(create.targets, []);
|
||||
assertEquals(create.workflow_state, "open");
|
||||
assertEquals(create.title, "Repository-free investigation");
|
||||
await page.getByRole("heading", {
|
||||
name: "Repository-free investigation",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await page.getByText("No current Orchestrator assignment.", { exact: true })
|
||||
.waitFor();
|
||||
await page.getByText("Manage Workspace tags", { exact: true }).click();
|
||||
await page.getByLabel("Tag name", { exact: true }).fill("Comparisons");
|
||||
const tagCreate = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/ticket-tags") &&
|
||||
response.request().method() === "POST"
|
||||
);
|
||||
await page.getByRole("button", { name: "Create tag", exact: true }).click();
|
||||
const tagResult = await tagCreate;
|
||||
const tag = await tagResult.json();
|
||||
assertEquals(tagResult.request().postDataJSON().action, {
|
||||
kind: "create",
|
||||
name: "Comparisons",
|
||||
});
|
||||
await page.getByText("Edit classification", { exact: true }).click();
|
||||
await page.getByRole("checkbox", { name: "Comparisons", exact: true })
|
||||
.check();
|
||||
await page.getByRole("checkbox", { name: "Investigation", exact: true })
|
||||
.check();
|
||||
const attach = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/tags") && response.request().method() === "PUT"
|
||||
);
|
||||
await page.getByRole("button", { name: "Save classification", exact: true })
|
||||
.click();
|
||||
const attachRequest = (await attach).request().postDataJSON();
|
||||
assertEquals(attachRequest.expected_tag_ids, []);
|
||||
assertEquals(
|
||||
attachRequest.tag_ids.toSorted(),
|
||||
[tag.id, "tag-investigation"].toSorted(),
|
||||
);
|
||||
await page.locator(".ticket-tag-chips").getByText("Comparisons", {
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await page.getByRole("combobox", { name: "Existing tag", exact: true })
|
||||
.selectOption(tag.id);
|
||||
await page.getByLabel("Tag name", { exact: true }).fill("Options");
|
||||
const renamed = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/ticket-tags") &&
|
||||
response.request().method() === "POST"
|
||||
);
|
||||
await page.getByRole("button", { name: "Rename tag", exact: true }).click();
|
||||
const rename = (await renamed).request().postDataJSON();
|
||||
assertEquals(rename.action, {
|
||||
kind: "rename",
|
||||
tag_id: tag.id,
|
||||
expected_last_operation_key: tag.last_operation_key,
|
||||
name: "Options",
|
||||
});
|
||||
await page.locator(".ticket-tag-chips").getByText("Options", {
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
const deleted = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/ticket-tags") &&
|
||||
response.request().method() === "POST"
|
||||
);
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByRole("button", { name: "Delete tag", exact: true }).click();
|
||||
assertEquals((await deleted).request().postDataJSON().action, {
|
||||
kind: "delete",
|
||||
tag_id: tag.id,
|
||||
expected_last_operation_key: rename.operation_key,
|
||||
});
|
||||
await page.locator(".ticket-tag-chips").getByText("Options", {
|
||||
exact: true,
|
||||
}).waitFor({ state: "hidden" });
|
||||
const ticket = await detail(base, "T-41");
|
||||
assertEquals(ticket.state, "open");
|
||||
assertEquals(ticket.assignment_tracking, []);
|
||||
assertEquals(ticket.tags.map((tag: { id: string }) => tag.id), [
|
||||
"tag-investigation",
|
||||
]);
|
||||
const requests = await (await fetch(`${base}/fixture-state`)).json();
|
||||
assert(
|
||||
!requests.some((row: { path: string }) =>
|
||||
/assignment|\/case$/.test(row.path)
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("ambiguous saved Assign retries exact JSON once, while stale and permission denials never claim success", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/w/tickets/tickets/T-1`);
|
||||
await page.getByRole("button", {
|
||||
name: "Request again (new assignment)",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await control(base, { failure: "unknown_saved" });
|
||||
await page.getByRole("button", {
|
||||
name: "Request again (new assignment)",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("alert").filter({ hasText: "Outcome unknown" })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("status").filter({
|
||||
hasText: "Assignment operation saved",
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
const saved =
|
||||
(await detail(base, "T-1")).assignment_tracking[0].assignment_id;
|
||||
const beforeRetry = await (await fetch(`${base}/fixture-state`)).json();
|
||||
const first = beforeRetry.filter((item: { path: string }) =>
|
||||
item.path.endsWith("/assignments/orchestrator")
|
||||
);
|
||||
assertEquals(first.length, 1);
|
||||
await page.getByRole("button", {
|
||||
name: "Retry exact operation",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("status").filter({
|
||||
hasText: "Assignment operation saved",
|
||||
}).waitFor();
|
||||
const requests = await (await fetch(`${base}/fixture-state`)).json();
|
||||
const assignmentRequests = requests.filter((item: { path: string }) =>
|
||||
item.path.endsWith("/assignments/orchestrator")
|
||||
);
|
||||
assertEquals(assignmentRequests.length, 2);
|
||||
assertEquals(assignmentRequests[0].body, assignmentRequests[1].body);
|
||||
assertEquals(
|
||||
(await detail(base, "T-1")).assignment_tracking[0].assignment_id,
|
||||
saved,
|
||||
);
|
||||
for (const failure of ["stale", "denied", "unknown_unsaved"] as const) {
|
||||
await control(base, {
|
||||
reset: true,
|
||||
failure: failure === "stale" ? "none" : failure,
|
||||
});
|
||||
await page.goto(`${base}/w/tickets/tickets/T-1`);
|
||||
await page.getByRole("button", {
|
||||
name: "Request again (new assignment)",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
let expectedAssignment = "assignment-T-1";
|
||||
if (failure === "stale") {
|
||||
const replacement = await fetch(
|
||||
`${base}/api/w/tickets/tickets/internal-T-1/assignments/orchestrator`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
operation_id: "concurrent-replacement",
|
||||
expected_assignment_id: "assignment-T-1",
|
||||
principal: {
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert(replacement.ok);
|
||||
expectedAssignment =
|
||||
(await replacement.json()).assignment.assignment_id;
|
||||
}
|
||||
await page.getByRole("button", {
|
||||
name: "Request again (new assignment)",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("alert").filter({
|
||||
hasText: failure === "stale"
|
||||
? "Stale observation"
|
||||
: failure === "denied"
|
||||
? "Permission denied"
|
||||
: "Outcome unknown",
|
||||
}).waitFor();
|
||||
assertEquals(
|
||||
(await detail(base, "T-1")).assignment_tracking[0].assignment_id,
|
||||
expectedAssignment,
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByRole("status").filter({
|
||||
hasText: "Assignment operation saved",
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
const attempts = await (await fetch(`${base}/fixture-state`)).json();
|
||||
assertEquals(
|
||||
attempts.filter((
|
||||
r: { path: string; body: { operation_id?: string } },
|
||||
) =>
|
||||
r.path.endsWith("/assignments/orchestrator") &&
|
||||
r.body.operation_id !== "concurrent-replacement"
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("case and handling matrix renders without overflow in both themes and empty/error/unknown states stay distinct", async () => {
|
||||
await withFixture(async (browser, base) => {
|
||||
const output =
|
||||
new URL("../../../target/web-ux/t743-browser/matrix", import.meta.url)
|
||||
.pathname;
|
||||
await Deno.mkdir(output, { recursive: true });
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const context = await browser.newContext({
|
||||
@@ -32,227 +605,123 @@ Deno.test("Ticket progress, comment results and reopen work without Git while re
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const unexpected: string[] = [];
|
||||
page.on("pageerror", (e) => unexpected.push(String(e)));
|
||||
await page.goto(`${scenario.baseUrl}/w/tickets/tickets`);
|
||||
const addTicket = page.getByRole("button", { name: "Add Ticket", exact: true });
|
||||
await addTicket.waitFor();
|
||||
const action = await addTicket.boundingBox();
|
||||
assert(action && action.x >= 0 && action.x + action.width <= width && action.y < 900);
|
||||
assertEquals(
|
||||
await page.evaluate("document.documentElement.scrollWidth > innerWidth"),
|
||||
false,
|
||||
);
|
||||
await addTicket.click();
|
||||
await page.locator(".ticket-create-form").waitFor();
|
||||
assertEquals(await page.locator(".ticket-target-row").count(), 0);
|
||||
assertEquals(
|
||||
await page.evaluate("document.documentElement.scrollWidth > innerWidth"),
|
||||
false,
|
||||
);
|
||||
await page.goto(
|
||||
`${scenario.baseUrl}/w/tickets/tickets/T-1-compare-options-without-deciding`,
|
||||
);
|
||||
await page.locator(".ticket-detail-header").waitFor();
|
||||
// Intent comes first at one-column widths, unlike the former controls-first layout.
|
||||
if (width < 1024) {
|
||||
const intent = await page.getByRole("heading", { name: "Intent", exact: true })
|
||||
.boundingBox();
|
||||
const progress = await page.getByRole("heading", { name: "Progress decision" })
|
||||
.boundingBox();
|
||||
assert(intent && progress && intent.y < progress.y);
|
||||
}
|
||||
const state = page.getByRole("combobox", { name: "State", exact: true });
|
||||
await state.waitFor();
|
||||
assertEquals(await state.locator("option").allTextContents(), [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"closed",
|
||||
]);
|
||||
await state.selectOption("inprogress");
|
||||
await page.getByLabel("Reason", { exact: true }).first().fill(
|
||||
"Investigating options directly",
|
||||
);
|
||||
await page.getByRole("button", { name: "Apply state", exact: true }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="inprogress"]').waitFor();
|
||||
await page.getByText("Record result or comment", { exact: true }).click();
|
||||
await page.getByLabel("Body", { exact: true }).fill(
|
||||
`Option A and B are available; return to the user without a conclusion (${colorScheme}/${width}).`,
|
||||
);
|
||||
await page.getByRole("button", { name: "Append event" }).click();
|
||||
await page.getByText(
|
||||
`Option A and B are available; return to the user without a conclusion (${colorScheme}/${width}).`,
|
||||
{ exact: true },
|
||||
).waitFor();
|
||||
await state.selectOption("done");
|
||||
await page.getByLabel("Reason", { exact: true }).first().fill(
|
||||
"Requested comparison returned",
|
||||
);
|
||||
await page.getByLabel("Result, references, and remaining work (optional)").fill(
|
||||
"Result: timeline comment. [Reference](https://example.com/options). Remaining: user choice.",
|
||||
);
|
||||
await page.getByRole("button", { name: "Complete Ticket", exact: true }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="done"]').waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("heading", { name: "MR requirement evidence" }).count(),
|
||||
0,
|
||||
);
|
||||
assert(await page.getByText("No Merge Requests linked.", { exact: false }).isVisible());
|
||||
await state.selectOption("planning");
|
||||
await page.getByLabel("Reason", { exact: true }).first().fill(
|
||||
"User requests another comparison",
|
||||
);
|
||||
await page.getByRole("button", { name: "Reopen / apply state" }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="planning"]').waitFor();
|
||||
await page.getByRole("button", { name: "Request Orchestrator (queue)" }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="queued"]').waitFor();
|
||||
await page.locator(".ticket-close-card > summary").click();
|
||||
await page.getByLabel("Resolution").fill("Discussion returned; no code or MR required");
|
||||
await page.getByRole("button", { name: "Close ticket", exact: true }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="closed"]').waitFor();
|
||||
await page.goto(`${scenario.baseUrl}/w/tickets/tickets/T-2-read-only-investigation`);
|
||||
await page.locator(".ticket-detail-header").waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("combobox", { name: "Access", exact: true }).inputValue(),
|
||||
"read_only",
|
||||
);
|
||||
assert(await page.getByRole("button", { name: "Mark ready", exact: true }).isEnabled());
|
||||
await page.getByRole("button", { name: "Mark ready", exact: true }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="ready"]').waitFor();
|
||||
assert(await page.getByRole("combobox", { name: "Repository", exact: true }).isEnabled());
|
||||
await page.getByRole("button", { name: "Save targets", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Save targets", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
});
|
||||
await page.goto(
|
||||
`${scenario.baseUrl}/w/tickets/tickets/T-3-independent-review-before-merge`,
|
||||
);
|
||||
await page.locator(".ticket-detail-header").waitFor();
|
||||
await state.selectOption("done");
|
||||
await page.getByLabel("Reason", { exact: true }).first().fill(
|
||||
"Progress decision is separate from review",
|
||||
);
|
||||
await page.getByRole("button", { name: "Complete Ticket" }).click();
|
||||
await page.locator('.ticket-detail-kicker [data-status="done"]').waitFor();
|
||||
assert(await page.getByText("main: open", { exact: true }).isVisible());
|
||||
assert(
|
||||
await page.getByText("Ticket completion is not MR approval or integration.", {
|
||||
exact: true,
|
||||
}).isVisible(),
|
||||
);
|
||||
assertEquals(await page.getByText("main: merged", { exact: true }).count(), 0);
|
||||
// Member normal operations do not enable owner configuration controls.
|
||||
assertEquals(await page.getByRole("link", { name: "Secrets", exact: true }).count(), 0);
|
||||
assertEquals(
|
||||
await page.evaluate("document.documentElement.scrollWidth > innerWidth"),
|
||||
false,
|
||||
);
|
||||
await Deno.mkdir(output, { recursive: true });
|
||||
await page.screenshot({
|
||||
path: `${output}/${colorScheme}-${width}-mr-done.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
assertEquals(unexpected, []);
|
||||
// Reset synthetic records for the next viewport using the fixture's ordinary state API.
|
||||
for (const key of ["T-1", "T-2", "T-3"]) {
|
||||
const detail =
|
||||
await (await context.request.get(`${scenario.baseUrl}/api/w/tickets/tickets/${key}`))
|
||||
.json();
|
||||
await context.request.post(`${scenario.baseUrl}/api/w/tickets/tickets/${key}/state`, {
|
||||
data: {
|
||||
state: "planning",
|
||||
reason: "Fixture reset",
|
||||
expected_state: detail.state,
|
||||
expected_content_digest: detail.content_digest,
|
||||
},
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
for (
|
||||
const [workspace, route, ready] of [
|
||||
["tickets", "tickets", ".ticket-card"],
|
||||
["tickets", "orchestrator", ".orchestrator-assignment"],
|
||||
["tickets-long", "tickets", ".ticket-card"],
|
||||
["tickets-empty", "orchestrator", ".orchestrator-assignments"],
|
||||
["tickets-unknown", "orchestrator", ".orchestrator-assignment"],
|
||||
["tickets-error", "tickets", ".ticket-lane-page-error"],
|
||||
["tickets", "tickets/T-1", ".ticket-detail-header"],
|
||||
["tickets", "tickets/T-2", ".ticket-worker-observation strong"],
|
||||
["tickets", "tickets/T-3", ".ticket-worker-observation strong"],
|
||||
["tickets", "tickets/T-4", ".ticket-detail-header"],
|
||||
[
|
||||
"tickets-readonly",
|
||||
"tickets/T-2",
|
||||
".ticket-worker-observation strong",
|
||||
],
|
||||
[
|
||||
"tickets-running",
|
||||
"tickets/T-2",
|
||||
".ticket-worker-observation strong",
|
||||
],
|
||||
]
|
||||
) {
|
||||
await page.goto(`${base}/w/${workspace}/${route}`);
|
||||
await page.locator(ready).first().waitFor();
|
||||
assertEquals(
|
||||
await page.evaluate(
|
||||
"document.documentElement.scrollWidth > innerWidth",
|
||||
),
|
||||
false,
|
||||
);
|
||||
if (workspace === "tickets-empty") {
|
||||
assert(
|
||||
await page.getByText(
|
||||
"No current assignments match these filters.",
|
||||
{ exact: true },
|
||||
).isVisible(),
|
||||
);
|
||||
}
|
||||
if (workspace === "tickets-error") {
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Retry", exact: true })
|
||||
.count(),
|
||||
2,
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByText("No tickets", { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
if (workspace === "tickets-unknown") {
|
||||
assertEquals(
|
||||
await page.getByText("Handling: Unknown (not reported)", {
|
||||
exact: true,
|
||||
}).count(),
|
||||
4,
|
||||
);
|
||||
}
|
||||
if (route === "tickets/T-2") {
|
||||
assert(
|
||||
await page.getByText("Self-reported handling: waiting", {
|
||||
exact: true,
|
||||
}).isVisible(),
|
||||
);
|
||||
assertEquals(
|
||||
await page.locator(".ticket-worker-observation strong")
|
||||
.textContent(),
|
||||
"Stopped",
|
||||
);
|
||||
if (workspace === "tickets-readonly") {
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Replace assignment",
|
||||
exact: true,
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
if (workspace === "tickets-running") {
|
||||
assert(
|
||||
await page.locator(".ticket-orchestrator-worker").getByText(
|
||||
"Running",
|
||||
{ exact: true },
|
||||
).isVisible(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (route === "tickets/T-3") {
|
||||
assert(
|
||||
await page.getByText("Self-reported handling: inprogress", {
|
||||
exact: true,
|
||||
}).isVisible(),
|
||||
);
|
||||
assertEquals(
|
||||
await page.locator(".ticket-worker-observation strong")
|
||||
.textContent(),
|
||||
"Running",
|
||||
);
|
||||
assert(
|
||||
await page.getByText("main: open", { exact: true }).isVisible(),
|
||||
);
|
||||
}
|
||||
await page.screenshot({
|
||||
path: `${output}/${colorScheme}-${width}-${workspace}-${
|
||||
route.replaceAll("/", "-")
|
||||
}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
assertEquals(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
const context = await browser.newContext();
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${scenario.baseUrl}/w/tickets/tickets`);
|
||||
await page.getByRole("button", { name: "Add Ticket", exact: true }).click();
|
||||
await page.getByLabel("Title", { exact: true }).fill("Repository-free investigation");
|
||||
await page.getByLabel("Body", { exact: true }).fill(
|
||||
"Compare options and return without conclusion; review is unnecessary.",
|
||||
);
|
||||
await page.getByRole("button", { name: "Create Ticket", exact: true }).click();
|
||||
await page.getByRole("heading", { name: "Repository-free investigation", exact: true })
|
||||
.waitFor();
|
||||
assert(await page.getByText("No repository targets.", { exact: true }).isVisible());
|
||||
// Generic remote launch: a natural request, not an inferred code/review pipeline.
|
||||
await page.goto(
|
||||
`${scenario.baseUrl}/w/tickets/workers/new?ticketId=T-4&initialInput=Return%20without%20conclusion.%20Review%20is%20unnecessary.`,
|
||||
);
|
||||
await page.locator(".worker-launch-form").waitFor();
|
||||
await page.getByRole("combobox", { name: "Runtime", exact: true }).selectOption("remote");
|
||||
const launches: Record<string, unknown>[] = [];
|
||||
await page.route("**/api/w/tickets/workers", async (route) => {
|
||||
launches.push(route.request().postDataJSON());
|
||||
await route.fulfill({
|
||||
status: 403,
|
||||
json: { error: "permission_denied", message: "Permission denied", diagnostics: [] },
|
||||
});
|
||||
});
|
||||
await page.getByRole("button", { name: "Start Worker", exact: true }).click();
|
||||
await page.getByText("permission_denied: Permission denied", { exact: true }).waitFor();
|
||||
assertEquals(launches.length, 1);
|
||||
assertEquals(launches[0].workdir_attachments, []);
|
||||
assertEquals(launches[0].profile, null);
|
||||
assertEquals(launches[0].initial_submit, [{
|
||||
kind: "text",
|
||||
content: "Return without conclusion. Review is unnecessary.",
|
||||
}]);
|
||||
assertEquals((launches[0].ticket_assignment as { ticket_id: string }).ticket_id, "T-4");
|
||||
await page.unroute("**/api/w/tickets/workers");
|
||||
for (
|
||||
const [status, message] of [[409, "Update conflict."], [403, "Permission denied."], [
|
||||
503,
|
||||
"Outcome unknown or request rejected.",
|
||||
]] as const
|
||||
) {
|
||||
await page.goto(
|
||||
`${scenario.baseUrl}/w/tickets/tickets/T-1-compare-options-without-deciding`,
|
||||
);
|
||||
await page.locator(".ticket-detail-header").waitFor();
|
||||
await page.route(
|
||||
"**/tickets/*/state",
|
||||
(route) => route.fulfill({ status, json: { error: "failure", message } }),
|
||||
);
|
||||
await page.getByRole("combobox", { name: "State", exact: true }).selectOption("done");
|
||||
await page.getByLabel("Reason", { exact: true }).first().fill(
|
||||
"Keep this draft on rejection",
|
||||
);
|
||||
await page.getByRole("button", { name: "Complete Ticket" }).click();
|
||||
await page.getByRole("alert").getByText(message, { exact: false }).waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Reason", { exact: true }).first().inputValue(),
|
||||
"Keep this draft on rejection",
|
||||
);
|
||||
assert(await page.locator('.ticket-detail-kicker [data-status="planning"]').isVisible());
|
||||
assert(await page.getByRole("button", { name: "Refresh Ticket status" }).isEnabled());
|
||||
await page.unroute("**/tickets/*/state");
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
const requests = await (await fetch(`${scenario.baseUrl}/fixture-state`)).json();
|
||||
assert(
|
||||
requests.some((r: { body: { body?: string } }) =>
|
||||
r.body.body?.includes("Remaining: user choice")
|
||||
),
|
||||
);
|
||||
assert(!requests.some((r: { path: string }) => /merge|review|workers/.test(r.path)));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts",
|
||||
"check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts",
|
||||
"test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests",
|
||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
|
||||
"test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts"
|
||||
},
|
||||
"fmt": {
|
||||
"lineWidth": 100,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "ticket-principals",
|
||||
"title": "Observed assignment and responsibility principal choice",
|
||||
"baseUrl": "http://127.0.0.1:15290",
|
||||
"colorScheme": "dark",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "member",
|
||||
"label": "Workspace member, not settings owner",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "principal",
|
||||
"label": "Replace responsibility, not Worker liveness",
|
||||
"path": "/w/tickets/tickets/T-2",
|
||||
"goal": "Choose the target for a new observed assignment",
|
||||
"dataState": "Open + waiting with retained prior assignment",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-handling"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "agent",
|
||||
"label": "Default Workspace agent",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".assignment-principal summary"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "account",
|
||||
"label": "Account target",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".assignment-principal select",
|
||||
"key": "Home"
|
||||
},
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".assignment-principal select",
|
||||
"key": "ArrowDown"
|
||||
},
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".assignment-principal select",
|
||||
"key": "Enter"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".assignment-principal input"
|
||||
},
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "worker",
|
||||
"label": "Worker principal, separate from actual Orchestrator Worker",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".assignment-principal select",
|
||||
"key": "End"
|
||||
},
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".assignment-principal select",
|
||||
"key": "Enter"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".assignment-principal label:has-text(\"Runtime identity\")"
|
||||
},
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "ticket-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "..",
|
||||
"args": [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"browser-tests/ticket_fixture_server.ts",
|
||||
"15290",
|
||||
"../../web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15290/health"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "ticket-principals",
|
||||
"title": "Observed assignment and responsibility principal choice",
|
||||
"baseUrl": "http://127.0.0.1:15290",
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "member",
|
||||
"label": "Workspace member, not settings owner",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "principal",
|
||||
"label": "Replace responsibility, not Worker liveness",
|
||||
"path": "/w/tickets/tickets/T-2",
|
||||
"goal": "Choose the target for a new observed assignment",
|
||||
"dataState": "Open + waiting with retained prior assignment",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-handling"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "agent",
|
||||
"label": "Default Workspace agent",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".assignment-principal summary"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "account",
|
||||
"label": "Account target",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".assignment-principal select",
|
||||
"key": "Home"
|
||||
},
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".assignment-principal select",
|
||||
"key": "ArrowDown"
|
||||
},
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".assignment-principal select",
|
||||
"key": "Enter"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".assignment-principal input"
|
||||
},
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "worker",
|
||||
"label": "Worker principal, separate from actual Orchestrator Worker",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".assignment-principal select",
|
||||
"key": "End"
|
||||
},
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".assignment-principal select",
|
||||
"key": "Enter"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".assignment-principal label:has-text(\"Runtime identity\")"
|
||||
},
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "ticket-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "..",
|
||||
"args": [
|
||||
"run",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"browser-tests/ticket_fixture_server.ts",
|
||||
"15290",
|
||||
"../../web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15290/health"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "tickets-dark",
|
||||
"title": "Flexible Ticket progress and optional resources",
|
||||
"title": "Ticket cases, classification and independent Orchestrator handling",
|
||||
"baseUrl": "http://127.0.0.1:15289",
|
||||
"colorScheme": "dark",
|
||||
"reducedMotion": "reduce",
|
||||
@@ -50,15 +50,11 @@
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"label": "Reasoned case judgment",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
"selector": ".ticket-case-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
@@ -83,15 +79,11 @@
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"label": "Reasoned case judgment",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
"selector": ".ticket-case-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
@@ -116,15 +108,11 @@
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"label": "Reasoned case judgment",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
"selector": ".ticket-case-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
@@ -153,8 +141,8 @@
|
||||
"id": "list",
|
||||
"label": "Member creates a repository-free Ticket",
|
||||
"path": "/w/tickets/tickets",
|
||||
"goal": "Create work from intent with optional resources, not a fixed delivery pipeline",
|
||||
"dataState": "Three lightweight progress lanes, member normal operations",
|
||||
"goal": "Server-filter open and closed cases by canonical tags and current principal",
|
||||
"dataState": "Open+done, open+waiting, inprogress with MR, closed with history",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
@@ -162,7 +150,7 @@
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Progress lanes",
|
||||
"label": "Case list",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
@@ -179,6 +167,201 @@
|
||||
"selector": ".ticket-create-form"
|
||||
},
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "last-case",
|
||||
"label": "Closed case and final handling result",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".ticket-lane[data-state=closed] .ticket-card",
|
||||
"key": "End"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "reason-filter",
|
||||
"label": "Current close reason versus historical reason search",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-panel-controls button:has-text(\"Cancel\")"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-reason-filter summary"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "orchestrator",
|
||||
"path": "/w/tickets/orchestrator",
|
||||
"label": "Current self-reported handling",
|
||||
"goal": "Current self-reported handling",
|
||||
"dataState": "Waiting, inprogress and done independent of case",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".orchestrator-assignments"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Current self-reported handling",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "last-case",
|
||||
"label": "Closed case and final handling result",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".orchestrator-assignment:last-child",
|
||||
"key": "End"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "closed",
|
||||
"path": "/w/tickets/tickets/T-4",
|
||||
"label": "Closed case and retained history",
|
||||
"goal": "Closed case and retained history",
|
||||
"dataState": "Completed closure and replaced/unassigned history",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Closed case and retained history",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "permission",
|
||||
"path": "/w/tickets-readonly/tickets/T-2",
|
||||
"label": "Read-only assignment",
|
||||
"goal": "Read-only assignment",
|
||||
"dataState": "Backend assignment eligibility denied",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Read-only assignment",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "long",
|
||||
"path": "/w/tickets-long/tickets",
|
||||
"label": "Long list titles and handling",
|
||||
"goal": "Long list titles and handling",
|
||||
"dataState": "Long titles and multiline results",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Long list titles and handling",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "last-case",
|
||||
"label": "Closed case and final handling result",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".ticket-lane[data-state=closed] .ticket-card",
|
||||
"key": "End"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "empty",
|
||||
"path": "/w/tickets-empty/orchestrator",
|
||||
"label": "No current assignments",
|
||||
"goal": "No current assignments",
|
||||
"dataState": "Empty response",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".orchestrator-assignments"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "No current assignments",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "error",
|
||||
"path": "/w/tickets-error/tickets",
|
||||
"label": "Query failure recovery",
|
||||
"goal": "Query failure recovery",
|
||||
"dataState": "503 query, scoped errors and retry",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-lane-page-error"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Query failure recovery",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "unknown",
|
||||
"path": "/w/tickets-unknown/orchestrator",
|
||||
"label": "Unknown historical handling",
|
||||
"goal": "Unknown historical handling",
|
||||
"dataState": "Null handling without inference",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".orchestrator-assignments"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Unknown historical handling",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "running",
|
||||
"path": "/w/tickets-running/tickets/T-2",
|
||||
"label": "Waiting with running Orchestrator Worker",
|
||||
"goal": "Waiting with running Orchestrator Worker",
|
||||
"dataState": "Waiting remains waiting while Worker is running",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Waiting with running Orchestrator Worker",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "tickets",
|
||||
"title": "Flexible Ticket progress and optional resources",
|
||||
"title": "Ticket cases, classification and independent Orchestrator handling",
|
||||
"baseUrl": "http://127.0.0.1:15289",
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
@@ -50,15 +50,11 @@
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"label": "Reasoned case judgment",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
"selector": ".ticket-case-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
@@ -83,15 +79,11 @@
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"label": "Reasoned case judgment",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
"selector": ".ticket-case-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
@@ -116,15 +108,11 @@
|
||||
},
|
||||
{
|
||||
"id": "resolution",
|
||||
"label": "Resolution and MR state",
|
||||
"label": "Reasoned case judgment",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card > summary"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-close-card textarea"
|
||||
"selector": ".ticket-case-card textarea"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
@@ -153,8 +141,8 @@
|
||||
"id": "list",
|
||||
"label": "Member creates a repository-free Ticket",
|
||||
"path": "/w/tickets/tickets",
|
||||
"goal": "Create work from intent with optional resources, not a fixed delivery pipeline",
|
||||
"dataState": "Three lightweight progress lanes, member normal operations",
|
||||
"goal": "Server-filter open and closed cases by canonical tags and current principal",
|
||||
"dataState": "Open+done, open+waiting, inprogress with MR, closed with history",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
@@ -162,7 +150,7 @@
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Progress lanes",
|
||||
"label": "Case list",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
@@ -179,6 +167,201 @@
|
||||
"selector": ".ticket-create-form"
|
||||
},
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "last-case",
|
||||
"label": "Closed case and final handling result",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".ticket-lane[data-state=closed] .ticket-card",
|
||||
"key": "End"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "reason-filter",
|
||||
"label": "Current close reason versus historical reason search",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-panel-controls button:has-text(\"Cancel\")"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": ".ticket-reason-filter summary"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "orchestrator",
|
||||
"path": "/w/tickets/orchestrator",
|
||||
"label": "Current self-reported handling",
|
||||
"goal": "Current self-reported handling",
|
||||
"dataState": "Waiting, inprogress and done independent of case",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".orchestrator-assignments"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Current self-reported handling",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "last-case",
|
||||
"label": "Closed case and final handling result",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".orchestrator-assignment:last-child",
|
||||
"key": "End"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "closed",
|
||||
"path": "/w/tickets/tickets/T-4",
|
||||
"label": "Closed case and retained history",
|
||||
"goal": "Closed case and retained history",
|
||||
"dataState": "Completed closure and replaced/unassigned history",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Closed case and retained history",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "permission",
|
||||
"path": "/w/tickets-readonly/tickets/T-2",
|
||||
"label": "Read-only assignment",
|
||||
"goal": "Read-only assignment",
|
||||
"dataState": "Backend assignment eligibility denied",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Read-only assignment",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "long",
|
||||
"path": "/w/tickets-long/tickets",
|
||||
"label": "Long list titles and handling",
|
||||
"goal": "Long list titles and handling",
|
||||
"dataState": "Long titles and multiline results",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-kanban"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Long list titles and handling",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "last-case",
|
||||
"label": "Closed case and final handling result",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": ".ticket-lane[data-state=closed] .ticket-card",
|
||||
"key": "End"
|
||||
}
|
||||
],
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "empty",
|
||||
"path": "/w/tickets-empty/orchestrator",
|
||||
"label": "No current assignments",
|
||||
"goal": "No current assignments",
|
||||
"dataState": "Empty response",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".orchestrator-assignments"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "No current assignments",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "error",
|
||||
"path": "/w/tickets-error/tickets",
|
||||
"label": "Query failure recovery",
|
||||
"goal": "Query failure recovery",
|
||||
"dataState": "503 query, scoped errors and retry",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-lane-page-error"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Query failure recovery",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "unknown",
|
||||
"path": "/w/tickets-unknown/orchestrator",
|
||||
"label": "Unknown historical handling",
|
||||
"goal": "Unknown historical handling",
|
||||
"dataState": "Null handling without inference",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".orchestrator-assignments"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Unknown historical handling",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "running",
|
||||
"path": "/w/tickets-running/tickets/T-2",
|
||||
"label": "Waiting with running Orchestrator Worker",
|
||||
"goal": "Waiting with running Orchestrator Worker",
|
||||
"dataState": "Waiting remains waiting while Worker is running",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".ticket-detail-header"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Waiting with running Orchestrator Worker",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
|
||||
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b)
|
||||
// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b)
|
||||
// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b)
|
||||
// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b)
|
||||
// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
@@ -87,6 +87,16 @@ export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string |
|
||||
|
||||
export type TicketActionEligibility = { blockers: Array<string>; can_assign_orchestrator: boolean; can_queue: boolean; can_start_manual_worker: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array<string>; };
|
||||
|
||||
export type TicketAssignmentAnswer = { assignment_id: string; body: string; expected_last_event_id: string; operation_key: string; };
|
||||
|
||||
export type TicketAssignmentAnswerReceipt = { assignment_id: string; event_index: number; input_event_id: string; };
|
||||
|
||||
export type TicketAssignmentDiagnosticsPage = { actionable: boolean; assignment_id: string; current: boolean; input_origin_known: boolean; inputs: Array<TicketAssignmentInputDiagnostic>; next_input_event_id?: string | null; next_start_operation_id?: string | null; starts: Array<TicketAssignmentStartDiagnostic>; };
|
||||
|
||||
export type TicketAssignmentInputDeliveryState = "not_reserved" | "reserved" | "request_persisted" | "runtime_accepted";
|
||||
|
||||
export type TicketAssignmentInputDiagnostic = { acceptance_outcome_unknown: boolean; delivery_id?: string | null; input_event_id: string; notification: TicketAssignmentInputDeliveryState; };
|
||||
|
||||
export type TicketAssignmentPrincipal = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
|
||||
|
||||
export type TicketAssignmentPrincipalSnapshot = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
|
||||
@@ -97,6 +107,8 @@ export type TicketAssignmentReleaseAction = "unassigned" | "replaced";
|
||||
|
||||
export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contributor";
|
||||
|
||||
export type TicketAssignmentStartDiagnostic = { actual_processing_unknown: boolean; assignment_id?: string | null; claim_state: string; operation_id: string; runtime_id?: string | null; worker_id?: string | null; };
|
||||
|
||||
export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; };
|
||||
|
||||
export type TicketAssignmentTracking = { next_action?: string | null; progress?: string | null; result?: string | null; status: TicketAssignmentTrackingStatus; waiting_reason?: string | null; };
|
||||
@@ -111,6 +123,8 @@ export type TicketAssignmentTrackingStatus = "waiting" | "inprogress" | "done";
|
||||
|
||||
export type TicketAssignmentTrackingUpdate = { assignment_id: string; expected_last_event_id: string; operation_key: string; tracking: TicketAssignmentTracking; };
|
||||
|
||||
export type TicketAssignmentsPage = { items: Array<TicketAssignmentTrackingProjection>; next_assignment_id?: string | null; };
|
||||
|
||||
export type TicketAttention = "unresolved_review" | "missing_commit" | "blocked" | "unblocked" | "awaiting_review" | "unresolved_changes" | "stale_after_rescope" | "missing_evidence";
|
||||
|
||||
export type TicketCaseAction = { action: "close"; reason: TicketClosureReason; } | { action: "reopen"; } | { action: "correct_closure"; reason: TicketClosureReason; };
|
||||
@@ -155,7 +169,7 @@ export type TicketMergeRequestSummary = { current_subject_ref?: string | null; i
|
||||
|
||||
export type TicketQueryItem = { assignment_tracking: Array<TicketAssignmentTrackingProjection>; blocker_count: number; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; evidence: TicketEvidenceSummary; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objective_ids: Array<string>; linked_objective_keys: Array<string>; matched_fields: Array<string>; matching_event?: TicketEvidenceEvent | null; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; readiness?: string | null; relation_count: number; resource_key: string; snippet?: string | null; state: BrowserTicketState; tags: Array<WorkspaceTag>; title: string; unresolved_blocker_count: number; unresolved_review_count: number; updated_at?: string | null; workspace_action_priority: string; };
|
||||
|
||||
export type TicketQueryRequest = { attention?: Array<TicketAttention>; cursor?: string | null; event_kinds?: Array<string>; evidence?: Array<string>; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; linked_objective_id?: string | null; query?: string | null; related_ticket_id?: string | null; relation_kind?: string | null; review_status?: string | null; sort?: string | null; states?: Array<TicketState>; tag_ids?: Array<string>; tag_ids_any?: Array<string>; tag_ids_none?: Array<string>; updated_after?: string | null; updated_before?: string | null; };
|
||||
export type TicketQueryRequest = { assigned_principal?: TicketAssignmentPrincipalSnapshot | null; attention?: Array<TicketAttention>; closure_reason?: TicketClosureReason | null; cursor?: string | null; event_kinds?: Array<string>; evidence?: Array<string>; handling_status?: TicketAssignmentTrackingStatus | null; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; linked_objective_id?: string | null; query?: string | null; related_ticket_id?: string | null; relation_kind?: string | null; review_status?: string | null; sort?: string | null; states?: Array<TicketState>; tag_ids?: Array<string>; tag_ids_any?: Array<string>; tag_ids_none?: Array<string>; updated_after?: string | null; updated_before?: string | null; };
|
||||
|
||||
export type TicketQueryResponse = { items: Array<TicketQueryItem>; page: QueryPage; record_authority: string; };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3)
|
||||
// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b)
|
||||
// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -41,6 +41,8 @@ import type {
|
||||
TicketListItemSummary,
|
||||
TicketListResponse,
|
||||
TicketMergeRequestSummary,
|
||||
TicketQueryItem,
|
||||
TicketQueryResponse,
|
||||
TicketQueueOutcome,
|
||||
TicketRef,
|
||||
TicketReference,
|
||||
@@ -335,7 +337,10 @@ function caseProjection(item: Record<string, unknown>, label: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkspaceTag(value: unknown, label: string): WorkspaceTag {
|
||||
export function parseWorkspaceTag(
|
||||
value: unknown,
|
||||
label = "Workspace tag",
|
||||
): WorkspaceTag {
|
||||
const item = object(value, label);
|
||||
exact(item, [
|
||||
"workspace_id",
|
||||
@@ -652,7 +657,7 @@ function parseTicketRoleAssignmentRecord(
|
||||
};
|
||||
}
|
||||
|
||||
function parseAssignmentTracking(
|
||||
export function parseAssignmentTracking(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): TicketAssignmentTracking {
|
||||
@@ -722,7 +727,7 @@ function parseAssignmentRelease(
|
||||
};
|
||||
}
|
||||
|
||||
function parseAssignmentTrackingProjection(
|
||||
export function parseAssignmentTrackingProjection(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): TicketAssignmentTrackingProjection {
|
||||
@@ -1750,3 +1755,152 @@ export function parseMergeRequestDetailResponse(
|
||||
workspace_id: string(item.workspace_id, `${label}.workspace_id`),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTicketQueryResponse(value: unknown): TicketQueryResponse {
|
||||
const label = "Ticket query response";
|
||||
const page = object(value, label);
|
||||
exact(page, ["items", "page", "record_authority"], label);
|
||||
return {
|
||||
page: parseQueryPage(page.page, `${label}.page`),
|
||||
record_authority: string(
|
||||
page.record_authority,
|
||||
`${label}.record_authority`,
|
||||
),
|
||||
items: array(
|
||||
page.items,
|
||||
`${label}.items`,
|
||||
(value, label): TicketQueryItem => {
|
||||
const item = object(value, label);
|
||||
exact(item, [
|
||||
"assignment_tracking",
|
||||
"blocker_count",
|
||||
"closure_operation_key",
|
||||
"content_digest",
|
||||
"created_at",
|
||||
"current_closure",
|
||||
"current_worker",
|
||||
"evidence",
|
||||
"id",
|
||||
"legacy_handoff",
|
||||
"linked_objective_ids",
|
||||
"linked_objective_keys",
|
||||
"matched_fields",
|
||||
"matching_event",
|
||||
"merge_request",
|
||||
"merge_requests",
|
||||
"priority",
|
||||
"readiness",
|
||||
"relation_count",
|
||||
"resource_key",
|
||||
"snippet",
|
||||
"state",
|
||||
"tags",
|
||||
"title",
|
||||
"unresolved_blocker_count",
|
||||
"unresolved_review_count",
|
||||
"updated_at",
|
||||
"workspace_action_priority",
|
||||
], label);
|
||||
return {
|
||||
id: string(item.id, `${label}.id`),
|
||||
resource_key: string(item.resource_key, `${label}.resource_key`),
|
||||
title: string(item.title, `${label}.title`),
|
||||
state: parseTicketState(item.state, `${label}.state`),
|
||||
priority: string(item.priority, `${label}.priority`),
|
||||
assignment_tracking: array(
|
||||
item.assignment_tracking,
|
||||
`${label}.assignment_tracking`,
|
||||
parseAssignmentTrackingProjection,
|
||||
),
|
||||
tags: array(item.tags, `${label}.tags`, parseWorkspaceTag),
|
||||
blocker_count: integer(item.blocker_count, `${label}.blocker_count`),
|
||||
unresolved_blocker_count: integer(
|
||||
item.unresolved_blocker_count,
|
||||
`${label}.unresolved_blocker_count`,
|
||||
),
|
||||
unresolved_review_count: integer(
|
||||
item.unresolved_review_count,
|
||||
`${label}.unresolved_review_count`,
|
||||
),
|
||||
relation_count: integer(
|
||||
item.relation_count,
|
||||
`${label}.relation_count`,
|
||||
),
|
||||
content_digest: string(
|
||||
item.content_digest,
|
||||
`${label}.content_digest`,
|
||||
),
|
||||
created_at: optionalNullableString(
|
||||
item.created_at,
|
||||
`${label}.created_at`,
|
||||
),
|
||||
updated_at: optionalNullableString(
|
||||
item.updated_at,
|
||||
`${label}.updated_at`,
|
||||
),
|
||||
closure_operation_key: optionalNullableString(
|
||||
item.closure_operation_key,
|
||||
`${label}.closure_operation_key`,
|
||||
),
|
||||
current_closure: item.current_closure == null
|
||||
? item.current_closure as null | undefined
|
||||
: parseClosure(item.current_closure, `${label}.current_closure`),
|
||||
current_worker: item.current_worker == null
|
||||
? item.current_worker as null | undefined
|
||||
: parseTicketAssignmentSummary(
|
||||
item.current_worker,
|
||||
`${label}.current_worker`,
|
||||
),
|
||||
evidence: parseTicketEvidenceSummary(
|
||||
item.evidence,
|
||||
`${label}.evidence`,
|
||||
),
|
||||
legacy_handoff: item.legacy_handoff == null
|
||||
? item.legacy_handoff as null | undefined
|
||||
: parseLegacyHandoff(
|
||||
item.legacy_handoff,
|
||||
`${label}.legacy_handoff`,
|
||||
),
|
||||
linked_objective_ids: strings(
|
||||
item.linked_objective_ids,
|
||||
`${label}.linked_objective_ids`,
|
||||
),
|
||||
linked_objective_keys: strings(
|
||||
item.linked_objective_keys,
|
||||
`${label}.linked_objective_keys`,
|
||||
),
|
||||
matched_fields: strings(
|
||||
item.matched_fields,
|
||||
`${label}.matched_fields`,
|
||||
),
|
||||
matching_event: item.matching_event == null
|
||||
? item.matching_event as null | undefined
|
||||
: parseTicketEvidenceEvent(
|
||||
item.matching_event,
|
||||
`${label}.matching_event`,
|
||||
),
|
||||
merge_request: item.merge_request == null
|
||||
? item.merge_request as null | undefined
|
||||
: parseTicketMergeRequestSummary(
|
||||
item.merge_request,
|
||||
`${label}.merge_request`,
|
||||
),
|
||||
merge_requests: array(
|
||||
item.merge_requests,
|
||||
`${label}.merge_requests`,
|
||||
parseTicketMergeRequestSummary,
|
||||
),
|
||||
readiness: optionalNullableString(
|
||||
item.readiness,
|
||||
`${label}.readiness`,
|
||||
),
|
||||
snippet: optionalNullableString(item.snippet, `${label}.snippet`),
|
||||
workspace_action_priority: string(
|
||||
item.workspace_action_priority,
|
||||
`${label}.workspace_action_priority`,
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
function ordered(value: unknown): unknown {
|
||||
return Array.isArray(value)
|
||||
? value.map(ordered)
|
||||
: value && typeof value === "object"
|
||||
? Object.fromEntries(
|
||||
Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map((
|
||||
[key, item],
|
||||
) => [key, ordered(item)]),
|
||||
)
|
||||
: value;
|
||||
}
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(ordered(actual)) !== JSON.stringify(ordered(expected))) {
|
||||
throw new Error(
|
||||
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
function assertThrows(operation: () => unknown): void {
|
||||
try {
|
||||
operation();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected rejection");
|
||||
}
|
||||
function assertStringIncludes(actual: string, expected: string): void {
|
||||
if (!actual.includes(expected)) {
|
||||
throw new Error(`${actual} does not include ${expected}`);
|
||||
}
|
||||
}
|
||||
import type {
|
||||
TicketAssignmentTrackingProjection,
|
||||
TicketDetail,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
import {
|
||||
answerOperation,
|
||||
assignmentOperation,
|
||||
observeOperation,
|
||||
operationError,
|
||||
parseAnswerReceipt,
|
||||
parseAssignmentDiagnostics,
|
||||
parseAssignmentsPage,
|
||||
parseTrackingHistory,
|
||||
} from "./ticket-operations.ts";
|
||||
|
||||
const current: TicketAssignmentTrackingProjection = {
|
||||
workspace_id: "team one",
|
||||
ticket_id: "ticket/one",
|
||||
assignment_id: "assignment-1",
|
||||
role: "orchestrator",
|
||||
current: true,
|
||||
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
|
||||
assigned_by: "account/user",
|
||||
assigned_at: "2026-01-01T00:00:00Z",
|
||||
tracking: {
|
||||
status: "waiting",
|
||||
waiting_reason: "User decision needed",
|
||||
next_action: "Read answer",
|
||||
},
|
||||
last_event_id: "event-1",
|
||||
updated_at: "2026-01-02T00:00:00Z",
|
||||
};
|
||||
function observed(): Pick<TicketDetail, "assignments"> {
|
||||
return {
|
||||
assignments: [{
|
||||
assignment_id: current.assignment_id,
|
||||
role: "orchestrator",
|
||||
principal: current.principal,
|
||||
assigned_at: current.assigned_at,
|
||||
assigned_by: current.assigned_by,
|
||||
}],
|
||||
};
|
||||
}
|
||||
Deno.test("assignment retry retains the exact observed pointer, operation key and payload after new data arrives", () => {
|
||||
const old = observed();
|
||||
const operation = assignmentOperation(
|
||||
"team one",
|
||||
"ticket/one",
|
||||
old,
|
||||
"operation-1",
|
||||
);
|
||||
old.assignments[0].assignment_id = "replacement";
|
||||
assertEquals(JSON.parse(operation.body!), {
|
||||
operation_id: "operation-1",
|
||||
expected_assignment_id: "assignment-1",
|
||||
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
|
||||
});
|
||||
assertEquals(
|
||||
operation.path,
|
||||
"/api/w/team%20one/tickets/ticket%2Fone/assignments/orchestrator",
|
||||
);
|
||||
assertEquals(Object.isFrozen(operation), true);
|
||||
const fresh = assignmentOperation(
|
||||
"team one",
|
||||
"ticket/one",
|
||||
old,
|
||||
"operation-2",
|
||||
);
|
||||
assertEquals(JSON.parse(fresh.body!).expected_assignment_id, "replacement");
|
||||
const clear = assignmentOperation(
|
||||
"team one",
|
||||
"ticket/one",
|
||||
old,
|
||||
"release",
|
||||
true,
|
||||
);
|
||||
assertStringIncludes(
|
||||
clear.path,
|
||||
"operation_id=release&assignment_id=replacement",
|
||||
);
|
||||
assertEquals(clear.method, "DELETE");
|
||||
assertThrows(() =>
|
||||
assignmentOperation("team", "ticket", { assignments: [] }, "clear", true)
|
||||
);
|
||||
});
|
||||
Deno.test("waiting answers carry the same assignment and observed event without status or Assign mutation", () => {
|
||||
const operation = answerOperation(
|
||||
"team one",
|
||||
"ticket/one",
|
||||
current,
|
||||
"My answer",
|
||||
"answer-key",
|
||||
);
|
||||
assertEquals(JSON.parse(operation.body!), {
|
||||
assignment_id: "assignment-1",
|
||||
expected_last_event_id: "event-1",
|
||||
body: "My answer",
|
||||
operation_key: "answer-key",
|
||||
});
|
||||
assertEquals(
|
||||
operation.path,
|
||||
"/api/w/team%20one/tickets/ticket%2Fone/assignment-answer",
|
||||
);
|
||||
for (
|
||||
const unavailable of [{ ...current, current: false }, {
|
||||
...current,
|
||||
last_event_id: null,
|
||||
}, { ...current, tracking: { status: "done" as const } }]
|
||||
) {
|
||||
assertThrows(() =>
|
||||
answerOperation("team", "ticket", unavailable, "answer", "new-op")
|
||||
);
|
||||
}
|
||||
});
|
||||
Deno.test("operation snapshots are serialized once rather than silently rebased for exact retry", () => {
|
||||
const body = {
|
||||
operation_key: "saved",
|
||||
tag_ids: ["stable-id"],
|
||||
expected_tag_ids: [] as string[],
|
||||
};
|
||||
const operation = observeOperation("/tags", "PUT", body);
|
||||
body.tag_ids[0] = "renamed-id";
|
||||
assertEquals(JSON.parse(operation.body!).tag_ids, ["stable-id"]);
|
||||
});
|
||||
Deno.test("delivery diagnostics keep saved notification, acceptance uncertainty and actual processing separate", () => {
|
||||
const wire = {
|
||||
assignment_id: "assignment-1",
|
||||
current: true,
|
||||
input_origin_known: true,
|
||||
actionable: true,
|
||||
inputs: [{
|
||||
input_event_id: "input-1",
|
||||
delivery_id: "delivery-1",
|
||||
notification: "request_persisted" as const,
|
||||
acceptance_outcome_unknown: true,
|
||||
}],
|
||||
starts: [{
|
||||
operation_id: "start-1",
|
||||
claim_state: "pending",
|
||||
assignment_id: null,
|
||||
runtime_id: null,
|
||||
worker_id: null,
|
||||
actual_processing_unknown: true,
|
||||
}],
|
||||
next_input_event_id: null,
|
||||
next_start_operation_id: null,
|
||||
};
|
||||
assertEquals(parseAssignmentDiagnostics(wire), wire);
|
||||
assertThrows(() =>
|
||||
parseAssignmentDiagnostics({ ...wire, rendered_content: "must not leak" })
|
||||
);
|
||||
assertThrows(() =>
|
||||
parseAssignmentDiagnostics({
|
||||
...wire,
|
||||
inputs: [{ ...wire.inputs[0], notification: "done" }],
|
||||
})
|
||||
);
|
||||
assertThrows(() =>
|
||||
parseAssignmentDiagnostics({
|
||||
...wire,
|
||||
inputs: Array(101).fill(wire.inputs[0]),
|
||||
})
|
||||
);
|
||||
});
|
||||
Deno.test("retained assignment page preserves result and release rather than rewriting handling to done", () => {
|
||||
const row = {
|
||||
...current,
|
||||
current: false,
|
||||
release: {
|
||||
action: "replaced",
|
||||
event_id: "release-event",
|
||||
actor: "account/user",
|
||||
at: current.assigned_at,
|
||||
replacement_assignment_id: "assignment-2",
|
||||
last_tracking: current.tracking,
|
||||
},
|
||||
};
|
||||
const page = parseAssignmentsPage({
|
||||
items: [row],
|
||||
next_assignment_id: "assignment-1",
|
||||
});
|
||||
assertEquals(page.items[0].tracking?.status, "waiting");
|
||||
assertEquals(page.items[0].release?.action, "replaced");
|
||||
assertThrows(() =>
|
||||
parseAssignmentsPage({
|
||||
items: Array(101).fill(row),
|
||||
next_assignment_id: null,
|
||||
})
|
||||
);
|
||||
assertThrows(() =>
|
||||
parseAssignmentsPage({
|
||||
items: [row],
|
||||
next_assignment_id: null,
|
||||
guessed_status: "done",
|
||||
})
|
||||
);
|
||||
assertThrows(() => parseTrackingHistory({ items: [], next_event_id: 1 }));
|
||||
});
|
||||
Deno.test("answer receipt rejects unsafe event indices and extra inferred result fields", () => {
|
||||
assertEquals(
|
||||
parseAnswerReceipt({
|
||||
assignment_id: "a",
|
||||
input_event_id: "event:1",
|
||||
event_index: 1,
|
||||
}),
|
||||
{ assignment_id: "a", input_event_id: "event:1", event_index: 1 },
|
||||
);
|
||||
assertThrows(() =>
|
||||
parseAnswerReceipt({
|
||||
assignment_id: "a",
|
||||
input_event_id: "event:1",
|
||||
event_index: 2 ** 54,
|
||||
})
|
||||
);
|
||||
assertThrows(() =>
|
||||
parseAnswerReceipt({
|
||||
assignment_id: "a",
|
||||
input_event_id: "event:1",
|
||||
event_index: 1,
|
||||
status: "inprogress",
|
||||
})
|
||||
);
|
||||
});
|
||||
Deno.test("conflict, permission, unavailable and unknown errors never claim successful new delegation", () => {
|
||||
assertStringIncludes(
|
||||
operationError(new Error("request failed (409)")),
|
||||
"Stale observation",
|
||||
);
|
||||
assertStringIncludes(
|
||||
operationError(new Error("request failed (403)")),
|
||||
"Permission denied",
|
||||
);
|
||||
assertStringIncludes(
|
||||
operationError(new Error("request failed (404)")),
|
||||
"unavailable",
|
||||
);
|
||||
assertStringIncludes(
|
||||
operationError(new Error("network response lost")),
|
||||
"Outcome unknown",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import type {
|
||||
SetTicketRoleAssignmentRequest,
|
||||
TicketAssignmentAnswer,
|
||||
TicketAssignmentAnswerReceipt,
|
||||
TicketAssignmentDiagnosticsPage,
|
||||
TicketAssignmentsPage,
|
||||
TicketAssignmentTrackingHistoryPage,
|
||||
TicketAssignmentTrackingProjection,
|
||||
TicketDetail,
|
||||
WorkspaceTag,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
import { workspaceApiPath } from "./http.ts";
|
||||
import {
|
||||
parseAssignmentTracking,
|
||||
parseAssignmentTrackingProjection,
|
||||
parseWorkspaceTag,
|
||||
} from "./ticket-browser.ts";
|
||||
|
||||
export function parseTags(value: unknown): WorkspaceTag[] {
|
||||
if (!Array.isArray(value) || value.length > 1000) {
|
||||
throw new Error("Tag list is invalid or unbounded");
|
||||
}
|
||||
return value.map((tag, index) => parseWorkspaceTag(tag, `Tags[${index}]`));
|
||||
}
|
||||
export function parseTracking(
|
||||
value: unknown,
|
||||
): TicketAssignmentTrackingProjection[] {
|
||||
if (!Array.isArray(value) || value.length > 1000) {
|
||||
throw new Error("Assignment list is invalid or unbounded");
|
||||
}
|
||||
return value.map((item, index) =>
|
||||
parseAssignmentTrackingProjection(item, `Assignments[${index}]`)
|
||||
);
|
||||
}
|
||||
export function parseTrackingHistory(
|
||||
value: unknown,
|
||||
): TicketAssignmentTrackingHistoryPage {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("History must be an object");
|
||||
}
|
||||
const page = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(page).some((key) =>
|
||||
!["items", "next_event_id"].includes(key)
|
||||
) || !Array.isArray(page.items) || page.items.length > 100
|
||||
) throw new Error("History page is invalid");
|
||||
const items = page.items.map((value, index) => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("History entry must be an object");
|
||||
}
|
||||
const item = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(item).some((key) =>
|
||||
!["at", "event_id", "operation_key", "source_context", "tracking"]
|
||||
.includes(key)
|
||||
)
|
||||
) throw new Error("Unexpected history field");
|
||||
for (const key of ["at", "event_id", "operation_key"]) {
|
||||
if (
|
||||
typeof item[key] !== "string" || (item[key] as string).length > 1024
|
||||
) throw new Error(`History ${key} is invalid`);
|
||||
}
|
||||
return {
|
||||
at: item.at as string,
|
||||
event_id: item.event_id as string,
|
||||
operation_key: item.operation_key as string,
|
||||
source_context: item.source_context,
|
||||
tracking: parseAssignmentTracking(item.tracking, `History[${index}]`),
|
||||
};
|
||||
});
|
||||
if (
|
||||
page.next_event_id != null &&
|
||||
(typeof page.next_event_id !== "string" || page.next_event_id.length > 1024)
|
||||
) throw new Error("History cursor is invalid");
|
||||
return {
|
||||
items,
|
||||
next_event_id: page.next_event_id as string | null | undefined,
|
||||
};
|
||||
}
|
||||
export function trackingPath(workspaceId: string, ticketId: string): string {
|
||||
return `/workspaces/${encodeURIComponent(workspaceId)}/tickets/${
|
||||
encodeURIComponent(ticketId)
|
||||
}/assignment-tracking`;
|
||||
}
|
||||
export type ObservedOperation = Readonly<
|
||||
{ path: string; method: "POST" | "PUT" | "DELETE"; body?: string }
|
||||
>;
|
||||
// Keep the exact serialized input and comparison subject for explicit retry. A new
|
||||
// observation must never silently rebase an ambiguous operation onto a new assignment.
|
||||
export function observeOperation(
|
||||
path: string,
|
||||
method: ObservedOperation["method"],
|
||||
body?: unknown,
|
||||
): ObservedOperation {
|
||||
return Object.freeze({
|
||||
path,
|
||||
method,
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
}
|
||||
export function assignmentOperation(
|
||||
workspaceId: string,
|
||||
ticketId: string,
|
||||
observed: Pick<TicketDetail, "assignments">,
|
||||
operationId: string,
|
||||
clear = false,
|
||||
principal: SetTicketRoleAssignmentRequest["principal"] = {
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
},
|
||||
): ObservedOperation {
|
||||
const current = observed.assignments.find((row) =>
|
||||
row.role === "orchestrator"
|
||||
);
|
||||
const path = workspaceApiPath(
|
||||
workspaceId,
|
||||
`/tickets/${encodeURIComponent(ticketId)}/assignments/orchestrator`,
|
||||
);
|
||||
if (clear) {
|
||||
if (!current) throw new Error("No observed current assignment to release");
|
||||
return observeOperation(
|
||||
`${path}?${new URLSearchParams({
|
||||
operation_id: operationId,
|
||||
assignment_id: current.assignment_id,
|
||||
})}`,
|
||||
"DELETE",
|
||||
);
|
||||
}
|
||||
const request: SetTicketRoleAssignmentRequest = {
|
||||
operation_id: operationId,
|
||||
expected_assignment_id: current?.assignment_id ?? null,
|
||||
principal,
|
||||
};
|
||||
return observeOperation(path, "PUT", request);
|
||||
}
|
||||
export function operationError(error: unknown): string {
|
||||
const text = error instanceof Error ? error.message : String(error);
|
||||
if (/\(409\)/.test(text)) {
|
||||
return `Stale observation / conflict. Refresh and reconcile; do not overwrite or retry as a new operation. ${text}`;
|
||||
}
|
||||
if (/\(401\)|\(403\)/.test(text)) {
|
||||
return `Permission denied. No access was granted. ${text}`;
|
||||
}
|
||||
if (/\(404\)/.test(text)) {
|
||||
return `Resource or assignment unavailable. ${text}`;
|
||||
}
|
||||
if (/\(400\)|\(422\)/.test(text)) return `Input rejected. ${text}`;
|
||||
return `Outcome unknown. This may already be saved; retain the same operation key for exact retry. ${text}`;
|
||||
}
|
||||
|
||||
function record(value: unknown, keys: string[]): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Expected API object");
|
||||
}
|
||||
const item = value as Record<string, unknown>;
|
||||
if (Object.keys(item).some((key) => !keys.includes(key))) {
|
||||
throw new Error("Unexpected API field");
|
||||
}
|
||||
return item;
|
||||
}
|
||||
function text(value: unknown): string {
|
||||
if (typeof value !== "string" || value.length > 16384) {
|
||||
throw new Error("Expected bounded string");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function optionalText(value: unknown): string | null | undefined {
|
||||
return value == null ? value as null | undefined : text(value);
|
||||
}
|
||||
function flag(value: unknown): boolean {
|
||||
if (typeof value !== "boolean") throw new Error("Expected boolean");
|
||||
return value;
|
||||
}
|
||||
function pageItems<T>(value: unknown, parse: (value: unknown) => T): T[] {
|
||||
if (!Array.isArray(value) || value.length > 100) {
|
||||
throw new Error("Expected bounded page");
|
||||
}
|
||||
return value.map(parse);
|
||||
}
|
||||
export function parseAssignmentsPage(value: unknown): TicketAssignmentsPage {
|
||||
const page = record(value, ["items", "next_assignment_id"]);
|
||||
return {
|
||||
items: pageItems(
|
||||
page.items,
|
||||
(item) => parseAssignmentTrackingProjection(item, "Assignment page"),
|
||||
),
|
||||
next_assignment_id: optionalText(page.next_assignment_id),
|
||||
};
|
||||
}
|
||||
export function parseAnswerReceipt(
|
||||
value: unknown,
|
||||
): TicketAssignmentAnswerReceipt {
|
||||
const item = record(value, [
|
||||
"assignment_id",
|
||||
"input_event_id",
|
||||
"event_index",
|
||||
]);
|
||||
if (
|
||||
!Number.isSafeInteger(item.event_index) || (item.event_index as number) < 0
|
||||
) throw new Error("Answer event index invalid");
|
||||
return {
|
||||
assignment_id: text(item.assignment_id),
|
||||
input_event_id: text(item.input_event_id),
|
||||
event_index: item.event_index as number,
|
||||
};
|
||||
}
|
||||
export function answerOperation(
|
||||
workspaceId: string,
|
||||
ticketId: string,
|
||||
observed: TicketAssignmentTrackingProjection,
|
||||
body: string,
|
||||
operationKey: string,
|
||||
): ObservedOperation {
|
||||
if (
|
||||
!observed.current || !observed.last_event_id || !observed.tracking ||
|
||||
observed.tracking.status === "done"
|
||||
) throw new Error("Read actionable current assignment before answering");
|
||||
const request: TicketAssignmentAnswer = {
|
||||
assignment_id: observed.assignment_id,
|
||||
expected_last_event_id: observed.last_event_id,
|
||||
body,
|
||||
operation_key: operationKey,
|
||||
};
|
||||
return observeOperation(
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/tickets/${encodeURIComponent(ticketId)}/assignment-answer`,
|
||||
),
|
||||
"POST",
|
||||
request,
|
||||
);
|
||||
}
|
||||
export function parseAssignmentDiagnostics(
|
||||
value: unknown,
|
||||
): TicketAssignmentDiagnosticsPage {
|
||||
const page = record(value, [
|
||||
"assignment_id",
|
||||
"current",
|
||||
"input_origin_known",
|
||||
"actionable",
|
||||
"inputs",
|
||||
"next_input_event_id",
|
||||
"starts",
|
||||
"next_start_operation_id",
|
||||
]);
|
||||
return {
|
||||
assignment_id: text(page.assignment_id),
|
||||
current: flag(page.current),
|
||||
input_origin_known: flag(page.input_origin_known),
|
||||
actionable: flag(page.actionable),
|
||||
next_input_event_id: optionalText(page.next_input_event_id),
|
||||
next_start_operation_id: optionalText(page.next_start_operation_id),
|
||||
inputs: pageItems(page.inputs, (value) => {
|
||||
const item = record(value, [
|
||||
"input_event_id",
|
||||
"delivery_id",
|
||||
"notification",
|
||||
"acceptance_outcome_unknown",
|
||||
]);
|
||||
if (
|
||||
!["not_reserved", "reserved", "request_persisted", "runtime_accepted"]
|
||||
.includes(item.notification as string)
|
||||
) throw new Error("Unexpected notification state");
|
||||
return {
|
||||
input_event_id: text(item.input_event_id),
|
||||
delivery_id: optionalText(item.delivery_id),
|
||||
notification: item
|
||||
.notification as TicketAssignmentDiagnosticsPage["inputs"][number][
|
||||
"notification"
|
||||
],
|
||||
acceptance_outcome_unknown: flag(item.acceptance_outcome_unknown),
|
||||
};
|
||||
}),
|
||||
starts: pageItems(page.starts, (value) => {
|
||||
const item = record(value, [
|
||||
"operation_id",
|
||||
"claim_state",
|
||||
"assignment_id",
|
||||
"runtime_id",
|
||||
"worker_id",
|
||||
"actual_processing_unknown",
|
||||
]);
|
||||
return {
|
||||
operation_id: text(item.operation_id),
|
||||
claim_state: text(item.claim_state),
|
||||
assignment_id: optionalText(item.assignment_id),
|
||||
runtime_id: optionalText(item.runtime_id),
|
||||
worker_id: optionalText(item.worker_id),
|
||||
actual_processing_unknown: flag(item.actual_processing_unknown),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -392,16 +392,13 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
"Tickets, Objectives, and Merge Requests should each be a single primary sidebar link",
|
||||
);
|
||||
assert(
|
||||
ticketsLoad.includes("loadLane(fetch, workspaceId") &&
|
||||
ticketsLoad.includes('limit: "30"') &&
|
||||
ticketsLoad.includes('states: states.join(",")') &&
|
||||
ticketsLoad.includes("/tickets?${search}") &&
|
||||
!ticketsLoad.includes("/tickets/query") &&
|
||||
ticketsLoad.includes("ticketQueryRequest(filters, [state])") &&
|
||||
ticketsLoad.includes("/tickets/query") &&
|
||||
ticketsLoad.includes('method: "POST"') &&
|
||||
ticketsPage.includes('class="ticket-kanban"') &&
|
||||
ticketsPage.includes("laneState") &&
|
||||
ticketsPage.includes("loadMore(lane.id)") &&
|
||||
ticketsPage.includes("handleLaneScroll(event, lane.id)"),
|
||||
"Tickets list should fetch lightweight paginated summaries for each Kanban lane",
|
||||
ticketsPage.includes("loadMore(lane.id)"),
|
||||
"Tickets list must apply shared server query filters before paging case lanes",
|
||||
);
|
||||
assert(
|
||||
ticketPanelModel.includes('label: "Open"') &&
|
||||
@@ -428,14 +425,16 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
assert(
|
||||
ticketDetailLoad.includes("/repositories") &&
|
||||
ticketDetailPage.includes("ticketCaseRequest(ticket, action") &&
|
||||
ticketDetailPage.includes("async function queueTicket") &&
|
||||
ticketDetailPage.includes("TicketHandling") &&
|
||||
ticketDetailPage.includes("const path = ticketPath") &&
|
||||
ticketDetailPage.includes("`${path}/queue`") &&
|
||||
!ticketDetailPage.includes("queueTicket") &&
|
||||
!ticketDetailPage.includes("markReady") &&
|
||||
!ticketDetailPage.includes("/merge-request/merge") &&
|
||||
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
||||
ticketDetailPage.includes("summarySourceReviewStatus(mergeRequest)") &&
|
||||
ticketDetailPage.includes("mergeRequestPagePath") &&
|
||||
ticketDetailPage.includes("await updateTicketCase(") &&
|
||||
ticketDetailPage.includes("runCaseDecision") &&
|
||||
ticketDetailPage.includes("TicketTags") &&
|
||||
ticketDetailPage.includes("mutateAssignment") &&
|
||||
ticketDetailPage.includes("can_start_manual_worker") &&
|
||||
ticketDetailPage.includes("ticket.relations.outgoing"),
|
||||
|
||||
@@ -49,6 +49,7 @@ export function fixtureDetail(key = "T-101") {
|
||||
artifacts: [],
|
||||
artifact_count: 0,
|
||||
assignments: [],
|
||||
assignment_tracking: [],
|
||||
assignment_diagnostics: [],
|
||||
events: [],
|
||||
event_count: 0,
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import type { TicketDetail, TicketAssignmentTrackingProjection, TicketAssignmentTrackingHistoryPage, TicketAssignmentsPage, TicketAssignmentDiagnosticsPage, TicketAssignmentPrincipalSnapshot } from "#lib/generated/ticket-api.ts";
|
||||
import type { WorkspaceOrchestratorStatus } from "./ticket-panel.ts";
|
||||
import { workspaceApiJson, workspaceApiJsonWithBody, workspaceApiPath, formatDate } from "#lib/workspace/api/http.ts";
|
||||
import { parseTicketRoleAssignmentMutationResponse, TICKET_BROWSER_API_MAX_RESPONSE_BYTES } from "#lib/workspace/api/ticket-browser.ts";
|
||||
import { assignmentOperation, answerOperation, parseAnswerReceipt, parseAssignmentsPage, parseAssignmentDiagnostics, operationError, parseTracking, parseTrackingHistory, trackingPath, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
|
||||
let { workspaceId, ticket, orchestrator, refresh }: { workspaceId: string; ticket: TicketDetail; orchestrator: WorkspaceOrchestratorStatus | null; refresh: () => Promise<void> } = $props();
|
||||
const current = $derived(ticket.assignment_tracking.find((row) => row.current && row.role === "orchestrator"));
|
||||
let busy = $state(false);
|
||||
let pending = $state<ObservedOperation | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let savedNotice = $state<string | null>(null);
|
||||
let pendingKind = $state<"assignment" | "answer">("assignment");
|
||||
let assignments = $state<TicketAssignmentsPage | null>(null);
|
||||
let assignmentPageBusy = $state(false);
|
||||
let diagnostic = $state<TicketAssignmentDiagnosticsPage | null>(null);
|
||||
let diagnosticError = $state<string | null>(null);
|
||||
let diagnosticBusy = $state(false);
|
||||
let diagnosticRead = 0;
|
||||
let answer = $state("");
|
||||
let principalKind = $state<"workspace_agent" | "user" | "worker">("workspace_agent");
|
||||
let accountId = $state("");
|
||||
let runtimeId = $state("");
|
||||
let workerId = $state("");
|
||||
const principalReady = $derived(principalKind === "workspace_agent" || (principalKind === "user" ? !!accountId.trim() : !!runtimeId.trim() && !!workerId.trim()));
|
||||
function selectedPrincipal(): TicketAssignmentPrincipalSnapshot {
|
||||
if (principalKind === "user") return { kind: "user", account_id: accountId.trim() };
|
||||
if (principalKind === "worker") return { kind: "worker", runtime_id: runtimeId.trim(), worker_id: workerId.trim() };
|
||||
return { kind: "workspace_agent", agent_key: "workspace-orchestrator" };
|
||||
}
|
||||
let historyId = $state("");
|
||||
let retained = $state<TicketAssignmentTrackingProjection | null>(null);
|
||||
let history = $state<TicketAssignmentTrackingHistoryPage | null>(null);
|
||||
let historyError = $state<string | null>(null);
|
||||
let historyBusy = $state(false);
|
||||
let alive = true;
|
||||
onDestroy(() => { alive = false; });
|
||||
async function run(operation: ObservedOperation, kind: "assignment" | "answer" = pendingKind) {
|
||||
if (busy) return;
|
||||
busy = true; error = null; pending = operation; pendingKind = kind; savedNotice = null;
|
||||
try {
|
||||
if (kind === "answer") {
|
||||
const receipt = await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseAnswerReceipt, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
||||
if (!alive) return;
|
||||
savedNotice = `Answer saved to assignment ${receipt.assignment_id}, input ${receipt.input_event_id}. Handling status is unchanged.`;
|
||||
answer = "";
|
||||
} else {
|
||||
const result = await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseTicketRoleAssignmentMutationResponse, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
||||
if (!alive) return;
|
||||
savedNotice = `Assignment operation saved: ${result.assignment?.assignment_id ?? "released"}. Delivery and execution are separate.`;
|
||||
}
|
||||
pending = null;
|
||||
await refresh();
|
||||
await readAssignments();
|
||||
if (current) await readDiagnostics(current.assignment_id);
|
||||
} catch (cause) { if (alive) error = savedNotice ? `Operation saved; latest projection unavailable. ${operationError(cause)}` : operationError(cause); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
function delegate(clear = false) {
|
||||
if (!clear && !principalReady) return;
|
||||
void run(assignmentOperation(workspaceId, ticket.id, ticket, crypto.randomUUID(), clear, selectedPrincipal()), "assignment");
|
||||
}
|
||||
async function reconcile() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try { await refresh(); if (!alive) return; pending = null; error = null; savedNotice = null; await readAssignments(); if (current) await readDiagnostics(current.assignment_id); }
|
||||
catch (cause) { if (alive) error = operationError(cause); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
async function readHistory(assignmentId: string, more = false) {
|
||||
if (!assignmentId || historyBusy) return;
|
||||
historyBusy = true; historyError = null;
|
||||
const root = trackingPath(workspaceId, ticket.id);
|
||||
try {
|
||||
const rows = await workspaceApiJson(`${root}?${new URLSearchParams({ assignment_id: assignmentId })}`, parseTracking, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
||||
retained = rows[0] ?? null;
|
||||
const search = new URLSearchParams({ assignment_id: assignmentId, limit: "20" });
|
||||
if (more && history?.next_event_id) search.set("after_event_id", history.next_event_id);
|
||||
const page = await workspaceApiJson(`${root}/history?${search}`, parseTrackingHistory, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
||||
history = more && history ? { ...page, items: [...history.items, ...page.items] } : page;
|
||||
historyId = assignmentId;
|
||||
} catch (cause) { historyError = operationError(cause); }
|
||||
finally { historyBusy = false; }
|
||||
}
|
||||
|
||||
async function readAssignments(more = false) {
|
||||
if (assignmentPageBusy) return;
|
||||
assignmentPageBusy = true;
|
||||
try {
|
||||
const query = new URLSearchParams({ limit: "20" });
|
||||
if (more && assignments?.next_assignment_id) query.set("after_assignment_id", assignments.next_assignment_id);
|
||||
const page = await workspaceApiJson(trackingPath(workspaceId, ticket.id) + "/assignments?" + query, parseAssignmentsPage, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
||||
if (!alive) return;
|
||||
assignments = more && assignments ? { ...page, items: [...assignments.items, ...page.items] } : page;
|
||||
} catch (cause) { historyError = operationError(cause); }
|
||||
finally { assignmentPageBusy = false; }
|
||||
}
|
||||
async function readDiagnostics(id: string, more = false) {
|
||||
const read = ++diagnosticRead;
|
||||
diagnosticBusy = true; diagnosticError = null;
|
||||
try {
|
||||
const query = new URLSearchParams({ assignment_id: id, limit: "20" });
|
||||
if (more && diagnostic?.assignment_id === id) {
|
||||
const inputCursor = diagnostic.next_input_event_id ?? diagnostic.inputs.at(-1)?.input_event_id;
|
||||
const startCursor = diagnostic.next_start_operation_id ?? diagnostic.starts.at(-1)?.operation_id;
|
||||
if (inputCursor) query.set("after_input_event_id", inputCursor);
|
||||
if (startCursor) query.set("after_start_operation_id", startCursor);
|
||||
}
|
||||
const page = await workspaceApiJson(workspaceApiPath(workspaceId, "/tickets/" + encodeURIComponent(ticket.id) + "/assignment-diagnostics?") + query, parseAssignmentDiagnostics, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
||||
if (!alive || read !== diagnosticRead) return;
|
||||
diagnostic = more && diagnostic?.assignment_id === id ? { ...page, inputs: [...diagnostic.inputs, ...page.inputs], starts: [...diagnostic.starts, ...page.starts] } : page;
|
||||
} catch (cause) { if (alive && read === diagnosticRead) diagnosticError = operationError(cause); }
|
||||
finally { if (read === diagnosticRead) diagnosticBusy = false; }
|
||||
}
|
||||
function submitAnswer(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!current || pending || !answer.trim()) return;
|
||||
void run(answerOperation(workspaceId, ticket.id, current, answer, crypto.randomUUID()), "answer");
|
||||
}
|
||||
onMount(() => { void readAssignments(); if (current) void readDiagnostics(current.assignment_id); });
|
||||
</script>
|
||||
<section class="ticket-detail-section ticket-handling">
|
||||
<div class="ticket-section-heading"><h2>Orchestrator handling</h2><a href={`/w/${encodeURIComponent(workspaceId)}/orchestrator`}>All handling</a></div>
|
||||
{#if current}
|
||||
<p><strong>Self-reported handling: {current.tracking?.status ?? "unknown"}</strong></p>
|
||||
<p>Principal: {current.principal.kind === "workspace_agent" ? current.principal.agent_key : current.principal.kind === "worker" ? `${current.principal.runtime_id}/${current.principal.worker_id}` : current.principal.account_id}</p>
|
||||
<p>Assignment <code>{current.assignment_id}</code> · assigned by {current.assigned_by} · {formatDate(current.assigned_at)}</p>
|
||||
{#if current.tracking}
|
||||
<dl>
|
||||
{#if current.tracking.waiting_reason}<dt>Waiting reason</dt><dd>{current.tracking.waiting_reason}</dd>{/if}
|
||||
{#if current.tracking.progress}<dt>Progress</dt><dd>{current.tracking.progress}</dd>{/if}
|
||||
{#if current.tracking.next_action}<dt>Next action</dt><dd>{current.tracking.next_action}</dd>{/if}
|
||||
{#if current.tracking.result}<dt>Result of this handling</dt><dd>{current.tracking.result}</dd>{/if}
|
||||
</dl>
|
||||
{/if}
|
||||
{#if current.updated_at}<p>Updated {formatDate(current.updated_at)}</p>{/if}
|
||||
{#if current.migration_diagnostic}<p class="workspace-callout">{current.migration_diagnostic}</p>{/if}
|
||||
{#if current.tracking?.status === "done"}<p>This handling was returned. Ticket {ticket.state} is unchanged.</p>{/if}
|
||||
<button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(current.assignment_id)}>View handling history</button>
|
||||
{:else}<p class="workspace-empty-copy">No current Orchestrator assignment.</p>{/if}
|
||||
{#if error}<p class="workspace-callout is-error" role="alert">{error}</p>{/if}
|
||||
{#if savedNotice}<p role="status">{savedNotice}</p>{/if}
|
||||
{#if pending}
|
||||
<p>Unresolved operation. New delegation is disabled until reconciled.</p>
|
||||
<button class="workspace-secondary-button" disabled={busy} onclick={() => pending && void run(pending)}>Retry exact operation</button>
|
||||
<button class="workspace-secondary-button" disabled={busy} onclick={() => void reconcile()}>Refresh and reconcile assignment</button>
|
||||
{/if}
|
||||
{#if ticket.state === "closed" && ticket.action_eligibility.can_assign_orchestrator}<p class="workspace-empty-copy">A delegation can be saved, but this closed case does not admit new work. Reopening the case is a separate decision and does not itself reissue handling.</p>{/if}
|
||||
{#if ticket.action_eligibility.can_assign_orchestrator}
|
||||
<details class="assignment-principal"><summary>Choose responsibility principal</summary>
|
||||
<fieldset class="ticket-control-form" disabled={busy || pending !== null}>
|
||||
<label>Principal kind<select bind:value={principalKind}><option value="workspace_agent">Workspace Orchestrator agent</option><option value="user">User account</option><option value="worker">Worker principal</option></select></label>
|
||||
{#if principalKind === "user"}<label>Account identity<input bind:value={accountId} maxlength="1024" /></label>{/if}
|
||||
{#if principalKind === "worker"}<label>Runtime identity<input bind:value={runtimeId} maxlength="1024" /></label><label>Worker identity<input bind:value={workerId} maxlength="1024" /></label>{/if}
|
||||
</fieldset>
|
||||
<p>Backend validates the selected principal. Responsibility does not choose or start a concrete Worker.</p>
|
||||
</details>
|
||||
{/if}
|
||||
<div class="handling-actions">
|
||||
{#if ticket.action_eligibility.can_assign_orchestrator}
|
||||
{#if !current || current.tracking?.status === "done"}
|
||||
<button class="workspace-primary-button" disabled={busy || pending !== null || !principalReady} onclick={() => delegate()}>{busy ? "Saving…" : current ? "Request again (new assignment)" : "Request Orchestrator"}</button>
|
||||
{:else}
|
||||
<details><summary>Replace this delegation</summary><p>A replacement starts a new assignment even for the same principal. The previous result and release history remain.</p><button class="workspace-secondary-button" disabled={busy || pending !== null || !principalReady} onclick={() => { if (confirm("Replace this delegation with a new assignment to the selected principal?")) delegate(); }}>Replace assignment</button></details>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if ticket.action_eligibility.can_unassign_orchestrator}<button class="workspace-danger-button" disabled={busy || pending !== null} onclick={() => { if (confirm("Release responsibility? This does not stop any running Worker.")) delegate(true); }}>Release assignment</button>{/if}
|
||||
</div>
|
||||
{#if ticket.state === "open" && current?.last_event_id && current.tracking && current.tracking.status !== "done"}
|
||||
<form class="ticket-control-form ticket-answer" onsubmit={submitAnswer}>
|
||||
<label>Answer / continue this handling<textarea bind:value={answer} rows="3" required disabled={busy || pending !== null}></textarea></label>
|
||||
<p>Continues assignment <code>{current.assignment_id}</code>; not a new delegation. A stale assignment is rejected.</p>
|
||||
<button class="workspace-secondary-button" disabled={busy || pending !== null || !answer.trim()}>Send answer to this assignment</button>
|
||||
</form>
|
||||
{/if}
|
||||
<details class="ticket-assignment-history"><summary>Past assignment and release history</summary>
|
||||
{#if assignmentPageBusy}<p role="status">Loading assignment history…</p>{/if}
|
||||
{#each assignments?.items ?? [] as row (row.assignment_id)}<p><button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(row.assignment_id)}>{row.current ? "Current" : "Past"} · {row.tracking?.status ?? "unknown"} · {formatDate(row.assigned_at)}</button> <code>{row.assignment_id}</code>{#if row.release} · {row.release.action}{/if}{#if row.tracking?.result} · {row.tracking.result}{/if}</p>{/each}
|
||||
{#if assignments?.next_assignment_id}<button class="workspace-secondary-button" disabled={assignmentPageBusy} onclick={() => void readAssignments(true)}>More assignments</button>{/if}
|
||||
<form onsubmit={(event) => { event.preventDefault(); void readHistory(historyId); }}><label>Assignment identity<input bind:value={historyId} required /></label><button class="workspace-secondary-button" disabled={historyBusy}>Read assignment</button></form>
|
||||
{#if historyError}<p role="alert" class="workspace-callout is-error">{historyError}</p>{/if}
|
||||
{#if retained}
|
||||
<p><code>{retained.assignment_id}</code> · {retained.current ? "current" : "past"} · last handling {retained.tracking?.status ?? "unknown"}</p>
|
||||
{#if retained.tracking?.result}<p>Previous result: {retained.tracking.result}</p>{/if}
|
||||
{#if retained.release}<p>{retained.release.action} by {retained.release.actor} · {formatDate(retained.release.at)}</p>{#if retained.release.replacement_assignment_id}<button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(retained!.release!.replacement_assignment_id!)}>Read replacement assignment</button>{/if}{/if}
|
||||
{/if}
|
||||
{#if history}{#each history.items as entry (entry.event_id)}<article><p>{entry.tracking.status} · {formatDate(entry.at)}</p>{#if entry.tracking.waiting_reason}<p>Waiting reason: {entry.tracking.waiting_reason}</p>{/if}{#if entry.tracking.progress}<p>Progress: {entry.tracking.progress}</p>{/if}{#if entry.tracking.next_action}<p>Next action: {entry.tracking.next_action}</p>{/if}{#if entry.tracking.result}<p>Result: {entry.tracking.result}</p>{/if}</article>{/each}{#if history.next_event_id}<button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(historyId, true)}>More handling history</button>{/if}{/if}
|
||||
</details>
|
||||
</section>
|
||||
<section class="ticket-detail-section ticket-delivery">
|
||||
<div class="ticket-section-heading"><h2>Delivery and execution</h2></div>
|
||||
<p>Assignment persistence: {current ? "Saved: " + current.assignment_id : "No current assignment"}</p>
|
||||
{#if current}<button class="workspace-secondary-button" disabled={diagnosticBusy} onclick={() => current && void readDiagnostics(current.assignment_id)}>Refresh delivery diagnostics</button>{/if}
|
||||
{#if diagnosticError}<p class="workspace-callout is-error" role="alert">{diagnosticError}</p>{/if}
|
||||
{#if diagnosticBusy}<p role="status">Loading delivery diagnostics…</p>{/if}
|
||||
{#if diagnostic}
|
||||
<p>Assignment <code>{diagnostic.assignment_id}</code> · {diagnostic.actionable ? "actionable input" : "retained history / not actionable"}</p>
|
||||
{#if !diagnostic.input_origin_known}<p>Input origin unknown; not inferred from handling.</p>{/if}
|
||||
{#each diagnostic.inputs as input (input.input_event_id)}
|
||||
<article><p>Input <code>{input.input_event_id}</code></p><dl><dt>Notification</dt><dd>{input.notification.replaceAll("_", " ")}</dd><dt>Runtime acceptance</dt><dd>{input.notification === "runtime_accepted" ? "Accepted immutable notification" : input.acceptance_outcome_unknown ? "Outcome unknown — may already be accepted" : "No acceptance acknowledgement observed"}</dd></dl></article>
|
||||
{:else}<p>No delivery input records in this page.</p>{/each}
|
||||
<h3>Work start / admission evidence</h3>
|
||||
{#each diagnostic.starts as start (start.operation_id)}<p><code>{start.operation_id}</code>: {start.claim_state} · {start.runtime_id ?? "runtime unknown"}/{start.worker_id ?? "worker unknown"}{#if start.actual_processing_unknown} · actual model processing not attested{/if}</p>{:else}<p>No work start records observed. This is not inferred from Worker state.</p>{/each}
|
||||
{#if diagnostic.next_input_event_id || diagnostic.next_start_operation_id}<button class="workspace-secondary-button" disabled={diagnosticBusy} onclick={() => diagnostic && void readDiagnostics(diagnostic.assignment_id, true)}>More delivery / start records</button>{/if}
|
||||
{:else}<p>Delivery / Runtime acceptance / work start not observed.</p>{/if}
|
||||
</section>
|
||||
<section class="ticket-detail-section ticket-orchestrator-worker">
|
||||
<div class="ticket-section-heading"><h2>Orchestrator Worker</h2></div>
|
||||
{#if orchestrator?.worker}<p>{orchestrator.worker.runtime_id}/{orchestrator.worker.worker_id} · actual state: <strong>{orchestrator.worker.state}</strong> · {orchestrator.online ? "online" : "offline"}</p><a href={`/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(orchestrator.worker.worker_id)}?runtimeId=${encodeURIComponent(orchestrator.worker.runtime_id)}`}>Open Worker</a>{:else}<p>Worker observation unavailable. This does not change handling status.</p>{/if}
|
||||
</section>
|
||||
<style>
|
||||
.assignment-principal fieldset { min-width: 0; margin: var(--space-3, 0.75rem) 0 0; padding: 0; border: 0; }
|
||||
dl { display: grid; grid-template-columns: minmax(7rem, 1fr) minmax(0, 3fr); gap: 0.5rem 1rem; }
|
||||
dt { color: var(--text-muted); } dd { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
code { overflow-wrap: anywhere; } .handling-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-block: 1rem; }
|
||||
article + article { margin-top: 1rem; }
|
||||
@media (max-width: 540px) { dl { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import type { TicketDetail, WorkspaceTag, WorkspaceTagMutation, TicketTagSetRequest } from "#lib/generated/ticket-api.ts";
|
||||
import { workspaceApiJson, workspaceApiJsonWithBody, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import { parseWorkspaceTag, TICKET_BROWSER_API_MAX_RESPONSE_BYTES } from "#lib/workspace/api/ticket-browser.ts";
|
||||
import { observeOperation, operationError, parseTags, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
|
||||
let { workspaceId, ticket, refresh }: { workspaceId: string; ticket: TicketDetail; refresh: () => Promise<void> } = $props();
|
||||
let catalog = $state<WorkspaceTag[]>([]);
|
||||
let selected = $state<string[]>([]);
|
||||
let loading = $state(true);
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let pending = $state<ObservedOperation | null>(null);
|
||||
let saved = $state(false);
|
||||
let name = $state("");
|
||||
let tagId = $state("");
|
||||
const observed = $derived(catalog.find((tag) => tag.id === tagId));
|
||||
let alive = true;
|
||||
onDestroy(() => { alive = false; });
|
||||
const catalogPath = $derived(workspaceApiPath(workspaceId, "/ticket-tags"));
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
catalog = await workspaceApiJson(catalogPath, parseTags, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
||||
selected = ticket.tags.map((tag) => tag.id);
|
||||
if (!pending) error = null;
|
||||
return true;
|
||||
} catch (cause) { if (alive) error = operationError(cause); return false; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
onMount(() => { void load(); });
|
||||
async function run(operation: ObservedOperation, tagResult: boolean) {
|
||||
if (busy) return;
|
||||
busy = true; pending = operation; saved = false; error = null;
|
||||
try {
|
||||
await workspaceApiJsonWithBody<WorkspaceTag | WorkspaceTag[]>(operation.path, { method: operation.method, body: operation.body }, tagResult ? parseWorkspaceTag : parseTags, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
||||
if (!alive) return;
|
||||
pending = null; saved = true;
|
||||
await refresh(); await load();
|
||||
} catch (cause) { if (alive) error = saved ? `Tag operation saved; latest projection unavailable. ${operationError(cause)}` : operationError(cause); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
let retryTagResult = $state(false);
|
||||
function mutateTag(action: WorkspaceTagMutation["action"]) {
|
||||
retryTagResult = true;
|
||||
void run(observeOperation(catalogPath, "POST", { action, operation_key: crypto.randomUUID() } satisfies WorkspaceTagMutation), true);
|
||||
}
|
||||
function setTags() {
|
||||
retryTagResult = false;
|
||||
const request: TicketTagSetRequest = { expected_tag_ids: ticket.tags.map((tag) => tag.id), tag_ids: selected, operation_key: crypto.randomUUID() };
|
||||
void run(observeOperation(workspaceApiPath(workspaceId, `/tickets/${encodeURIComponent(ticket.id)}/tags`), "PUT", request), false);
|
||||
}
|
||||
async function reconcile() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try { await refresh(); if (!alive || !await load()) return; pending = null; error = null; saved = false; }
|
||||
catch (cause) { if (alive) error = operationError(cause); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
</script>
|
||||
<section class="ticket-detail-section ticket-tags">
|
||||
<div class="ticket-section-heading"><h2>Classification tags</h2></div>
|
||||
<p class="workspace-empty-copy">Classification only. Tag changes do not close or reopen this case.</p>
|
||||
<div class="ticket-tag-chips">{#each ticket.tags as tag (tag.id)}<span class="ticket-tag-chip">{tag.name}</span>{:else}<span>No tags</span>{/each}</div>
|
||||
{#if loading}<p role="status">Loading tag catalog…</p>{/if}
|
||||
{#if error}<p class="workspace-callout is-error" role="alert">{error}</p>{/if}
|
||||
{#if saved}<p role="status">Tag operation saved.</p>{/if}
|
||||
{#if pending}
|
||||
<p>Pending operation retained; new operations are disabled.</p>
|
||||
<button class="workspace-secondary-button" disabled={busy} onclick={() => pending && void run(pending, retryTagResult)}>Retry exact tag operation</button>
|
||||
<button class="workspace-secondary-button" disabled={busy} onclick={() => void reconcile()}>Refresh and reconcile tags</button>
|
||||
{/if}
|
||||
<details><summary>Edit classification</summary>
|
||||
<form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); setTags(); }}>
|
||||
<fieldset disabled={busy || loading || pending !== null}><legend>Multiple tags</legend>
|
||||
{#each catalog.filter((tag) => !tag.deleted) as tag (tag.id)}<label><input type="checkbox" bind:group={selected} value={tag.id} />{tag.name}</label>{/each}
|
||||
</fieldset>
|
||||
<button class="workspace-secondary-button" disabled={busy || loading || pending !== null}>Save classification</button>
|
||||
</form>
|
||||
</details>
|
||||
<details><summary>Manage Workspace tags</summary>
|
||||
<form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); mutateTag({ kind: "create", name: name.trim() }); }}>
|
||||
<label>Tag name<input bind:value={name} required disabled={busy || pending !== null} /></label>
|
||||
<button class="workspace-secondary-button" disabled={busy || pending !== null || !name.trim()}>Create tag</button>
|
||||
</form>
|
||||
<label>Existing tag<select bind:value={tagId} disabled={busy || pending !== null}><option value="">Select tag</option>{#each catalog.filter((tag) => !tag.deleted) as tag (tag.id)}<option value={tag.id}>{tag.name}</option>{/each}</select></label>
|
||||
{#if observed}
|
||||
<p><code>{observed.id}</code></p>
|
||||
<button class="workspace-secondary-button" disabled={busy || pending !== null || !name.trim()} onclick={() => mutateTag({ kind: "rename", tag_id: observed.id, expected_last_operation_key: observed.last_operation_key, name: name.trim() })}>Rename tag</button>
|
||||
<button class="workspace-danger-button" disabled={busy || pending !== null} onclick={() => { if (confirm(`Delete classification tag ${observed.name}? Historical audit is retained.`)) mutateTag({ kind: "delete", tag_id: observed.id, expected_last_operation_key: observed.last_operation_key }); }}>Delete tag</button>
|
||||
{/if}
|
||||
</details>
|
||||
</section>
|
||||
<style>
|
||||
.ticket-tag-chips { display: flex; flex-wrap: wrap; gap: var(--space-2, 0.5rem); margin-block: 0.75rem; }
|
||||
.ticket-tag-chip { padding: 0.2rem 0.5rem; background: var(--bg-subtle); border-radius: var(--radius-soft); overflow-wrap: anywhere; }
|
||||
fieldset label { display: flex; align-items: center; gap: 0.5rem; }
|
||||
fieldset input { width: auto; }
|
||||
code { overflow-wrap: anywhere; }
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { untrack, onDestroy } from "svelte";
|
||||
import type { TicketDetail } from "#lib/generated/ticket-api.ts";
|
||||
import type { WorkerSummary } from "#lib/generated/worker-launch-api.ts";
|
||||
import { workspaceApiJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import { parseWorkerSummary } from "#lib/workspace/api/workers.ts";
|
||||
import { workerHref } from "#lib/workspace/resource-links.ts";
|
||||
let { workspaceId, ticket }: { workspaceId: string; ticket: TicketDetail } = $props();
|
||||
let worker = $state<WorkerSummary | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let observationRequest = 0;
|
||||
onDestroy(() => { observationRequest += 1; });
|
||||
async function load() {
|
||||
const request = ++observationRequest;
|
||||
const reference = ticket.current_worker?.worker_resource_key;
|
||||
worker = null; error = null;
|
||||
if (!reference) { loading = false; return; }
|
||||
loading = true;
|
||||
try {
|
||||
const observed = await workspaceApiJson(workspaceApiPath(workspaceId, `/workers/${encodeURIComponent(reference)}`), parseWorkerSummary, 1024 * 1024);
|
||||
if (request !== observationRequest) return;
|
||||
worker = observed;
|
||||
}
|
||||
catch (cause) { if (request === observationRequest) error = cause instanceof Error ? cause.message : String(cause); }
|
||||
finally { if (request === observationRequest) loading = false; }
|
||||
}
|
||||
$effect(() => { void ticket.current_worker?.worker_resource_key; untrack(() => { void load(); }); });
|
||||
</script>
|
||||
<section class="ticket-detail-section ticket-worker-observation">
|
||||
<div class="ticket-section-heading"><h2>Related Worker — actual state</h2><button class="workspace-secondary-button" disabled={loading} onclick={() => void load()}>Refresh Worker observation</button></div>
|
||||
{#if loading}<p role="status">Loading Worker observation…</p>{/if}
|
||||
{#if error}<p role="alert" class="workspace-callout is-error">Worker observation unavailable: {error}</p>{/if}
|
||||
{#if worker}<p>{worker.runtime_id}/{worker.worker_id}: <strong>{worker.state}</strong> · availability {worker.availability}</p><a href={workerHref(workspaceId, { resource_key: worker.resource_key, display_name: worker.display_name ?? worker.label })}>Open Worker</a>
|
||||
{:else}<p class="workspace-empty-copy">{ticket.current_worker ? "Retained Worker identity; live state not observed." : "No related Worker recorded."}</p>{/if}
|
||||
<p class="workspace-empty-copy">Worker state does not determine Ticket case state or self-reported handling.</p>
|
||||
</section>
|
||||
@@ -0,0 +1,214 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
TicketAssignmentTrackingProjection,
|
||||
TicketDetail,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
import { fixtureDetail } from "../home/dashboard.test-fixtures.ts";
|
||||
import TicketHandling from "./TicketHandling.svelte";
|
||||
import TicketTags from "./TicketTags.svelte";
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
const assignment = (
|
||||
id = "delegation-1",
|
||||
status: "waiting" | "done" = "waiting",
|
||||
): TicketAssignmentTrackingProjection => ({
|
||||
workspace_id: "team",
|
||||
ticket_id: "T-1",
|
||||
assignment_id: id,
|
||||
role: "orchestrator",
|
||||
principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" },
|
||||
assigned_by: "user",
|
||||
assigned_at: "2026-01-01T00:00:00Z",
|
||||
current: true,
|
||||
tracking: {
|
||||
status,
|
||||
waiting_reason: status === "waiting" ? "User answer needed" : null,
|
||||
result: status === "done" ? "Options returned" : null,
|
||||
},
|
||||
last_event_id: "event-1",
|
||||
});
|
||||
function detail(row = assignment()): TicketDetail {
|
||||
return {
|
||||
...fixtureDetail("T-1"),
|
||||
action_eligibility: {
|
||||
...fixtureDetail("T-1").action_eligibility,
|
||||
can_assign_orchestrator: true,
|
||||
can_unassign_orchestrator: true,
|
||||
},
|
||||
assignment_tracking: [row],
|
||||
assignments: [{
|
||||
assignment_id: row.assignment_id,
|
||||
role: row.role,
|
||||
principal: row.principal,
|
||||
assigned_by: row.assigned_by,
|
||||
assigned_at: row.assigned_at,
|
||||
}],
|
||||
};
|
||||
}
|
||||
function reads(path: string, row: TicketAssignmentTrackingProjection) {
|
||||
if (path.includes("/assignment-tracking/assignments")) {
|
||||
return Response.json({ items: [row], next_assignment_id: null });
|
||||
}
|
||||
if (path.includes("/assignment-diagnostics")) {
|
||||
return Response.json({
|
||||
assignment_id: row.assignment_id,
|
||||
current: true,
|
||||
actionable: true,
|
||||
input_origin_known: true,
|
||||
inputs: [{
|
||||
input_event_id: "input",
|
||||
delivery_id: "delivery",
|
||||
notification: "request_persisted",
|
||||
acceptance_outcome_unknown: true,
|
||||
}],
|
||||
starts: [],
|
||||
next_input_event_id: null,
|
||||
next_start_operation_id: null,
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected read ${path}`);
|
||||
}
|
||||
test("an answer response loss keeps exact assignment/event/key/body for retry even when observed responsibility changes", async () => {
|
||||
const row = assignment();
|
||||
const sent: string[] = [];
|
||||
let attempts = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const path = String(input);
|
||||
if (path.endsWith("/assignment-answer")) {
|
||||
sent.push(String(init?.body));
|
||||
if (++attempts === 1) throw new Error("response lost");
|
||||
return Response.json({
|
||||
assignment_id: row.assignment_id,
|
||||
input_event_id: "input-2",
|
||||
event_index: 2,
|
||||
});
|
||||
}
|
||||
return reads(path, row);
|
||||
}),
|
||||
);
|
||||
const refresh = vi.fn(async () => {});
|
||||
const view = render(TicketHandling, {
|
||||
workspaceId: "team",
|
||||
ticket: detail(row),
|
||||
orchestrator: null,
|
||||
refresh,
|
||||
});
|
||||
await fireEvent.input(
|
||||
view.getByLabelText("Answer / continue this handling"),
|
||||
{ target: { value: "Use option B" } },
|
||||
);
|
||||
await fireEvent.click(view.getByText("Send answer to this assignment"));
|
||||
await waitFor(() => expect(view.getByText(/Outcome unknown\./)).toBeTruthy());
|
||||
await view.rerender({ ticket: detail(assignment("delegation-2")) });
|
||||
expect(
|
||||
(view.getByText("Send answer to this assignment") as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
await fireEvent.click(view.getByText("Retry exact operation"));
|
||||
await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1));
|
||||
expect(sent).toHaveLength(2);
|
||||
expect(sent[1]).toBe(sent[0]);
|
||||
expect(JSON.parse(sent[1])).toEqual({
|
||||
assignment_id: "delegation-1",
|
||||
expected_last_event_id: "event-1",
|
||||
body: "Use option B",
|
||||
operation_key: expect.any(String),
|
||||
});
|
||||
expect(view.container.textContent).toContain("Handling status is unchanged");
|
||||
});
|
||||
test("a done reissue persists before a failed refresh and does not pretend delivery or execution succeeded", async () => {
|
||||
const row = assignment("delegation-1", "done");
|
||||
const sent: unknown[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
sent.push(JSON.parse(String(init.body)));
|
||||
return Response.json({
|
||||
workspace_id: "team",
|
||||
ticket_id: "T-1",
|
||||
assignment: {
|
||||
assignment_id: "delegation-2",
|
||||
workspace_id: "team",
|
||||
ticket_id: "T-1",
|
||||
role: "orchestrator",
|
||||
principal: row.principal,
|
||||
assigned_at: row.assigned_at,
|
||||
assigned_by: row.assigned_by,
|
||||
},
|
||||
});
|
||||
}
|
||||
return reads(String(input), row);
|
||||
}),
|
||||
);
|
||||
const view = render(TicketHandling, {
|
||||
workspaceId: "team",
|
||||
ticket: detail(row),
|
||||
orchestrator: null,
|
||||
refresh: vi.fn(async () => {
|
||||
throw new Error("refresh unavailable");
|
||||
}),
|
||||
});
|
||||
await fireEvent.click(view.getByText("Request again (new assignment)"));
|
||||
await waitFor(() =>
|
||||
expect(view.getByText(/Assignment operation saved: delegation-2/))
|
||||
.toBeTruthy()
|
||||
);
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(sent[0]).toMatchObject({ expected_assignment_id: "delegation-1" });
|
||||
expect(view.queryByText("Retry exact operation")).toBeNull();
|
||||
expect(view.container.textContent).toContain("Outcome unknown");
|
||||
expect(view.container.textContent).toContain(
|
||||
"Outcome unknown — may already be accepted",
|
||||
);
|
||||
expect(view.container.textContent).toContain(
|
||||
"No work start records observed",
|
||||
);
|
||||
expect(view.container.textContent).toContain("Self-reported handling: done");
|
||||
});
|
||||
test("classification conflict keeps stable IDs and the original observed set across an exact retry", async () => {
|
||||
const tag = {
|
||||
workspace_id: "team",
|
||||
id: "tag-1",
|
||||
name: "closed",
|
||||
last_operation_key: "tag-create",
|
||||
deleted: false,
|
||||
};
|
||||
const requests: string[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init?.method === "PUT") {
|
||||
requests.push(String(init.body));
|
||||
return new Response("", { status: 409 });
|
||||
}
|
||||
return Response.json([tag]);
|
||||
}),
|
||||
);
|
||||
const view = render(TicketTags, {
|
||||
workspaceId: "team",
|
||||
ticket: { ...detail(), tags: [] },
|
||||
refresh: vi.fn(async () => {}),
|
||||
});
|
||||
await waitFor(() => expect(view.queryByText("Loading tag catalog…")).toBeNull());
|
||||
await fireEvent.click(view.getByLabelText("closed"));
|
||||
await fireEvent.click(view.getByText("Save classification"));
|
||||
await waitFor(() => expect(view.getByText(/Stale observation/)).toBeTruthy());
|
||||
await view.rerender({ ticket: { ...detail(), tags: [tag] } });
|
||||
await fireEvent.click(view.getByText("Retry exact tag operation"));
|
||||
await waitFor(() => expect(requests).toHaveLength(2));
|
||||
expect(requests[1]).toBe(requests[0]);
|
||||
expect(JSON.parse(requests[1])).toMatchObject({
|
||||
expected_tag_ids: [],
|
||||
tag_ids: ["tag-1"],
|
||||
});
|
||||
expect((view.getByText("Save classification") as HTMLButtonElement).disabled)
|
||||
.toBe(true);
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
import {
|
||||
closureReasonLabel,
|
||||
currentOrchestratorAssignment,
|
||||
handlingLabel,
|
||||
handlingSummary,
|
||||
nextTicketLaneVisibleCount,
|
||||
readTicketFilters,
|
||||
TICKET_LANE_PAGE_SIZE,
|
||||
ticketLanes,
|
||||
ticketQueryRequest,
|
||||
ticketWorkerLaunchHref,
|
||||
ticketWorkerMessage,
|
||||
} from "./ticket-panel.ts";
|
||||
@@ -112,44 +118,107 @@ Deno.test("generic Ticket launch carries natural text without role or resource c
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("ticket detail retains backend queue eligibility and generic Worker assignment actions", async () => {
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||
import.meta.url,
|
||||
Deno.test("case, stable tags and principal filters survive every server-query page", () => {
|
||||
const filters = readTicketFilters(
|
||||
new URLSearchParams(
|
||||
"q=options&case=open&tag=tag-1&tag=tag-2&principal=orchestrator",
|
||||
),
|
||||
);
|
||||
for (const cursor of [null, "next-page"]) {
|
||||
const request = JSON.parse(
|
||||
JSON.stringify(ticketQueryRequest(filters, ["open"], cursor)),
|
||||
);
|
||||
assertEquals(request, {
|
||||
query: "options",
|
||||
states: ["open"],
|
||||
tag_ids: ["tag-1", "tag-2"],
|
||||
assigned_principal: {
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
},
|
||||
handling_status: null,
|
||||
sort: "updated_desc",
|
||||
limit: 30,
|
||||
cursor,
|
||||
});
|
||||
const handling = JSON.parse(
|
||||
JSON.stringify(ticketQueryRequest(filters, [], cursor, "done")),
|
||||
);
|
||||
assertEquals(handling.handling_status, "done");
|
||||
assertEquals(handling.states, []);
|
||||
}
|
||||
});
|
||||
|
||||
assertEquals(source.includes("ticket.action_eligibility.can_queue"), true);
|
||||
assertEquals(source.includes("ticket.relations.blockers.length > 0"), true);
|
||||
assertEquals(source.includes("outcome.queued_tickets.join"), true);
|
||||
Deno.test("current close-reason queries preserve known and custom reasons on every page without changing case intent", () => {
|
||||
for (
|
||||
const [input, expected] of [["completed", "completed"], [
|
||||
"other&reason_text=Superseded%20plan",
|
||||
{ other: "Superseded plan" },
|
||||
]] as const
|
||||
) {
|
||||
const filters = readTicketFilters(
|
||||
new URLSearchParams(`case=closed&reason=${input}`),
|
||||
);
|
||||
for (const cursor of [null, "next-page"]) {
|
||||
const request = JSON.parse(
|
||||
JSON.stringify(ticketQueryRequest(filters, ["closed"], cursor)),
|
||||
);
|
||||
assertEquals(request.closure_reason, expected);
|
||||
assertEquals(request.states, ["closed"]);
|
||||
assertEquals(request.cursor, cursor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("handling summary uses only the current self-report and never Worker or case status", () => {
|
||||
const row = {
|
||||
assignment_id: "a",
|
||||
current: true,
|
||||
role: "orchestrator",
|
||||
tracking: { status: "waiting", waiting_reason: "Awaiting user choice" },
|
||||
} as TicketDetail["assignment_tracking"][number];
|
||||
for (const state of ["open", "closed"] as const) {
|
||||
for (const workerState of ["Running", "Stopped"]) {
|
||||
const value = {
|
||||
state,
|
||||
workerState,
|
||||
assignment_tracking: [{
|
||||
...row,
|
||||
assignment_id: "released",
|
||||
current: false,
|
||||
tracking: { status: "done" as const, result: "Old result" },
|
||||
}, row],
|
||||
};
|
||||
const current = currentOrchestratorAssignment(value);
|
||||
assertEquals(current?.assignment_id, "a");
|
||||
assertEquals(handlingLabel(current?.tracking?.status), "Waiting");
|
||||
assertEquals(handlingSummary(current), "Awaiting user choice");
|
||||
}
|
||||
}
|
||||
assertEquals(
|
||||
source.includes("resolve the listed blockers before Queue"),
|
||||
false,
|
||||
handlingSummary({
|
||||
...row,
|
||||
tracking: { status: "done", result: "Compared options" },
|
||||
}),
|
||||
"Compared options",
|
||||
);
|
||||
assertEquals(
|
||||
source.includes("ticket.action_eligibility.can_assign_orchestrator"),
|
||||
true,
|
||||
handlingSummary({
|
||||
...row,
|
||||
tracking: null,
|
||||
migration_diagnostic: "Unknown historical handling",
|
||||
}),
|
||||
"Unknown historical handling",
|
||||
);
|
||||
assertEquals(handlingLabel(null), "Unknown");
|
||||
assertEquals(
|
||||
source.includes('mutateAssignment("start-manual", "worker"'),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
source.includes('mutateAssignment("start-manual", "reviewer"'),
|
||||
false,
|
||||
);
|
||||
assertEquals(source.includes('kind: "workspace_agent"'), true);
|
||||
assertEquals(source.includes('kind: "worker"'), true);
|
||||
assertEquals(source.includes("ticket.assignee"), false);
|
||||
assertEquals(source.includes('action: "set", targets'), true);
|
||||
assertEquals(source.includes("updatedTicket.targets"), true);
|
||||
assertEquals(source.includes("ticket.repository_key"), false);
|
||||
assertEquals(source.includes("ticket.ref_selector"), false);
|
||||
assertEquals(
|
||||
source.includes("(repository.diagnostics ?? []).length > 0"),
|
||||
false,
|
||||
currentOrchestratorAssignment({
|
||||
assignment_tracking: [{ ...row, current: false }],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assertEquals(handlingSummary(null), "Not assigned");
|
||||
assertEquals(closureReasonLabel({ other: "Superseded" }), "Superseded");
|
||||
});
|
||||
|
||||
Deno.test("Ticket authoring creates the target collection without singular authority", async () => {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { BrowserWorkspaceOrchestratorResponse } from "#lib/generated/worker-launch-api.ts";
|
||||
import type {
|
||||
TicketAssignmentPrincipalSnapshot,
|
||||
TicketAssignmentTrackingProjection,
|
||||
TicketAssignmentTrackingStatus,
|
||||
TicketClosureReason,
|
||||
TicketDetail,
|
||||
TicketListItemSummary,
|
||||
TicketQueryItem,
|
||||
TicketQueryRequest,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
|
||||
export const TICKET_STATES = ["open", "closed"] as const;
|
||||
@@ -16,10 +22,14 @@ export const TICKET_LANE_PAGE_SIZE = 30;
|
||||
|
||||
export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number];
|
||||
export type TicketLaneId = TicketLaneDefinition["id"];
|
||||
export type TicketCardSummary = Pick<
|
||||
TicketListItemSummary,
|
||||
"id" | "resource_key" | "title" | "state" | "priority" | "updated_at"
|
||||
>;
|
||||
export type TicketCardSummary =
|
||||
& Pick<
|
||||
TicketListItemSummary,
|
||||
"id" | "resource_key" | "title" | "state" | "priority" | "updated_at"
|
||||
>
|
||||
& Partial<
|
||||
Pick<TicketQueryItem, "tags" | "current_closure" | "assignment_tracking">
|
||||
>;
|
||||
|
||||
export type TicketLane = {
|
||||
id: TicketLaneId;
|
||||
@@ -81,6 +91,148 @@ export function ticketWorkerLaunchHref(
|
||||
}/workers/new?${params.toString()}`;
|
||||
}
|
||||
|
||||
export const HANDLING_STATES = ["waiting", "inprogress", "done"] as const;
|
||||
export const ORCHESTRATOR_PRINCIPAL = {
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
} as const;
|
||||
|
||||
export function currentOrchestratorAssignment(
|
||||
ticket: Pick<TicketQueryItem, "assignment_tracking">,
|
||||
): TicketAssignmentTrackingProjection | null {
|
||||
return ticket.assignment_tracking.find((row) =>
|
||||
row.current && row.role === "orchestrator"
|
||||
) ?? null;
|
||||
}
|
||||
export function handlingLabel(
|
||||
status?: TicketAssignmentTrackingStatus | null,
|
||||
): string {
|
||||
return status === "waiting"
|
||||
? "Waiting"
|
||||
: status === "inprogress"
|
||||
? "In progress"
|
||||
: status === "done"
|
||||
? "Done"
|
||||
: "Unknown";
|
||||
}
|
||||
export function assignmentPrincipalLabel(
|
||||
principal: TicketAssignmentPrincipalSnapshot,
|
||||
): string {
|
||||
if (principal.kind === "workspace_agent") {
|
||||
return `Workspace agent: ${principal.agent_key}`;
|
||||
}
|
||||
if (principal.kind === "user") return `User account: ${principal.account_id}`;
|
||||
return `Worker: ${principal.runtime_id}/${principal.worker_id}`;
|
||||
}
|
||||
export function handlingSummary(
|
||||
row: TicketAssignmentTrackingProjection | null,
|
||||
): string {
|
||||
const tracking = row?.tracking;
|
||||
if (!row) return "Not assigned";
|
||||
if (!tracking) return row.migration_diagnostic ?? "Handling unavailable";
|
||||
return tracking.status === "waiting"
|
||||
? tracking.waiting_reason ?? "Waiting reason not reported"
|
||||
: tracking.status === "inprogress"
|
||||
? tracking.progress ?? "Progress not reported"
|
||||
: tracking.result ?? "Result not reported";
|
||||
}
|
||||
export function closureReasonLabel(reason: TicketClosureReason): string {
|
||||
return typeof reason === "object" ? reason.other : ({
|
||||
completed: "Completed",
|
||||
not_planned: "Not planned",
|
||||
duplicate: "Duplicate",
|
||||
unknown: "Unknown",
|
||||
})[reason];
|
||||
}
|
||||
export type TicketFilters = {
|
||||
query: string;
|
||||
caseState: "all" | TicketState;
|
||||
tagIds: string[];
|
||||
principal: "all" | "orchestrator" | "user" | "worker";
|
||||
accountId: string;
|
||||
runtimeId: string;
|
||||
workerId: string;
|
||||
closureReason:
|
||||
| "all"
|
||||
| "completed"
|
||||
| "not_planned"
|
||||
| "duplicate"
|
||||
| "unknown"
|
||||
| "other";
|
||||
closureText: string;
|
||||
};
|
||||
export function readTicketFilters(search: URLSearchParams): TicketFilters {
|
||||
const principal = search.get("principal");
|
||||
const state = search.get("case");
|
||||
const reason = search.get("reason");
|
||||
return {
|
||||
query: search.get("q") ?? "",
|
||||
caseState: state === "open" || state === "closed" ? state : "all",
|
||||
tagIds: [...new Set(search.getAll("tag"))],
|
||||
principal: principal === "orchestrator" || principal === "user" ||
|
||||
principal === "worker"
|
||||
? principal
|
||||
: "all",
|
||||
accountId: search.get("account") ?? "",
|
||||
runtimeId: search.get("runtime") ?? "",
|
||||
workerId: search.get("worker") ?? "",
|
||||
closureReason:
|
||||
["completed", "not_planned", "duplicate", "unknown", "other"].includes(
|
||||
reason ?? "",
|
||||
)
|
||||
? reason as TicketFilters["closureReason"]
|
||||
: "all",
|
||||
closureText: search.get("reason_text") ?? "",
|
||||
};
|
||||
}
|
||||
export function filterPrincipal(
|
||||
filters: TicketFilters,
|
||||
): TicketAssignmentPrincipalSnapshot | null {
|
||||
if (filters.principal === "orchestrator") return ORCHESTRATOR_PRINCIPAL;
|
||||
if (filters.principal === "user") {
|
||||
if (!filters.accountId.trim()) {
|
||||
throw new Error("Enter an account ID for the user principal filter");
|
||||
}
|
||||
return { kind: "user", account_id: filters.accountId.trim() };
|
||||
}
|
||||
if (filters.principal === "worker") {
|
||||
if (!filters.runtimeId.trim() || !filters.workerId.trim()) {
|
||||
throw new Error(
|
||||
"Enter Runtime and Worker IDs for the Worker principal filter",
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind: "worker",
|
||||
runtime_id: filters.runtimeId.trim(),
|
||||
worker_id: filters.workerId.trim(),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Every page repeats the same server-side filters. Never search a loaded slice.
|
||||
export function ticketQueryRequest(
|
||||
filters: TicketFilters,
|
||||
states: TicketState[],
|
||||
cursor: string | null = null,
|
||||
handlingStatus: TicketAssignmentTrackingStatus | null = null,
|
||||
): TicketQueryRequest {
|
||||
return {
|
||||
query: filters.query.trim() || null,
|
||||
states,
|
||||
...(filters.closureReason === "all" ? {} : {
|
||||
closure_reason: filters.closureReason === "other"
|
||||
? { other: filters.closureText.trim() }
|
||||
: filters.closureReason,
|
||||
}),
|
||||
tag_ids: filters.tagIds,
|
||||
assigned_principal: filterPrincipal(filters),
|
||||
handling_status: handlingStatus,
|
||||
sort: "updated_desc",
|
||||
limit: TICKET_LANE_PAGE_SIZE as TicketQueryRequest["limit"],
|
||||
cursor,
|
||||
};
|
||||
}
|
||||
|
||||
export function relationLabel(kind: string): string {
|
||||
return kind.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { untrack } from "svelte";
|
||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import { parseTicketQueryResponse, TICKET_BROWSER_API_LOAD_POLICY } from "#lib/workspace/api/ticket-browser.ts";
|
||||
import { currentOrchestratorAssignment, handlingLabel, handlingSummary, ticketQueryRequest, closureReasonLabel } from "#lib/workspace/tickets/ticket-panel.ts";
|
||||
import { ticketHref } from "#lib/workspace/resource-links.ts";
|
||||
import type { PageData } from "./$types";
|
||||
let { data }: { data: PageData } = $props();
|
||||
let response = $state(untrack(() => data.tickets));
|
||||
let busy = $state(false);
|
||||
$effect(() => { const latest = data.tickets; untrack(() => { response = latest; }); });
|
||||
async function applyFilters(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of new FormData(event.currentTarget as HTMLFormElement)) if (typeof value === "string" && value) search.append(key, value);
|
||||
busy = true;
|
||||
try { await goto(`?${search}`); } finally { busy = false; }
|
||||
}
|
||||
async function loadMore() {
|
||||
if (busy || (response.data && !response.data.page.has_more)) return;
|
||||
busy = true;
|
||||
const observedData = data;
|
||||
const previous = response.data;
|
||||
const result = await loadJson(fetch, workspaceApiPath(data.workspaceId, "/tickets/query"), {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(ticketQueryRequest(data.filters, data.filters.caseState === "all" ? [] : [data.filters.caseState], previous?.page.next_cursor ?? null, data.handling)),
|
||||
}, parseTicketQueryResponse, TICKET_BROWSER_API_LOAD_POLICY);
|
||||
if (data !== observedData) { busy = false; return; }
|
||||
if (result.data && previous) {
|
||||
const items = new Map(previous.items.map((item) => [item.id, item]));
|
||||
for (const item of result.data.items) items.set(item.id, item);
|
||||
response = { data: { ...result.data, items: [...items.values()] }, error: null };
|
||||
} else response = { data: result.data ?? previous, error: result.error };
|
||||
busy = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Orchestrator · {data.workspaceId}</title></svelte:head>
|
||||
<div class="workspace-page orchestrator-page">
|
||||
<header class="workspace-page-header"><h1>Orchestrator</h1><a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets`}>Ticket cases</a></header>
|
||||
<p>Principal: workspace-orchestrator · Worker availability: {data.orchestrator.data?.online ? "Online" : data.orchestrator.error ? "Unavailable" : "Offline"}</p>
|
||||
{#if data.orchestrator.error}<p class="workspace-callout is-error" role="alert">Worker availability: {data.orchestrator.error}</p>{/if}
|
||||
<form class="orchestrator-filters" aria-label="Orchestrator filters" onsubmit={applyFilters}>
|
||||
<label>Search<input name="q" type="search" value={data.filters.query} /></label>
|
||||
<label>Handling (self-reported)<select name="handling" value={data.handling ?? ""}><option value="">All including unknown</option><option value="waiting">Waiting</option><option value="inprogress">In progress</option><option value="done">Done</option></select></label>
|
||||
<label>Ticket case<select name="case" value={data.filters.caseState}><option value="all">Open and closed</option><option value="open">Open</option><option value="closed">Closed</option></select></label>
|
||||
<button class="workspace-secondary-button" disabled={busy}>{busy ? "Loading…" : "Apply filters"}</button>
|
||||
</form>
|
||||
<section class="orchestrator-assignments" aria-label="Current Orchestrator assignments">
|
||||
{#each response.data?.items ?? [] as ticket (ticket.id)}
|
||||
{@const row = currentOrchestratorAssignment(ticket)}
|
||||
<a class="orchestrator-assignment" href={ticketHref(data.workspaceId, ticket)}>
|
||||
<div class="assignment-heading"><span>{ticket.resource_key}</span><strong>{ticket.title}</strong></div>
|
||||
<div class="assignment-meta"><span>Handling: {handlingLabel(row?.tracking?.status)} ({row?.tracking ? "self-reported" : "not reported"})</span><span>Ticket case: {ticket.state}</span></div>
|
||||
<p>{handlingSummary(row)}</p>
|
||||
{#if row?.tracking?.next_action}<p>Next action: {row.tracking.next_action}</p>{/if}
|
||||
{#if ticket.current_closure}<p>Close reason: {closureReasonLabel(ticket.current_closure.reason)}</p>{/if}
|
||||
<span>Tags: {ticket.tags.length ? ticket.tags.map((tag) => tag.name).join(", ") : "None"}</span>
|
||||
{#if row?.updated_at}<time datetime={row.updated_at}>Handling updated: {new Date(row.updated_at).toLocaleString()}</time>{/if}
|
||||
<span class="assignment-detail-link">View Ticket, handling history and delivery evidence →</span>
|
||||
</a>
|
||||
{:else}
|
||||
{#if !response.error}<p class="workspace-empty-copy">No current assignments match these filters.</p>{/if}
|
||||
{/each}
|
||||
{#if response.error}<div class="workspace-callout is-error" role="alert"><p>{response.error}</p><button class="workspace-secondary-button" type="button" onclick={loadMore} disabled={busy}>Retry query</button></div>{/if}
|
||||
{#if busy}<p aria-live="polite">Loading assignments…</p>{:else if response.data?.page.has_more && !response.error}<button class="workspace-secondary-button" type="button" onclick={loadMore}>Load more assignments</button>{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.orchestrator-page { max-width: 72rem; }
|
||||
.workspace-page-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); }
|
||||
.orchestrator-filters { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); margin-block: var(--space-4); }
|
||||
.orchestrator-filters label { display: grid; gap: var(--space-1); min-width: 0; flex: 1 1 12rem; }
|
||||
input, select { min-width: 0; width: 100%; padding: var(--space-2); color: var(--text); background: var(--bg-raised); border: 1px solid var(--line); border-radius: var(--radius-soft); font: inherit; }
|
||||
.orchestrator-assignments { display: grid; }
|
||||
.orchestrator-assignment { display: grid; gap: var(--space-2); padding-block: var(--space-4); border-bottom: 1px solid var(--line); color: var(--text); text-decoration: none; overflow-wrap: anywhere; }
|
||||
.orchestrator-assignment:hover { background: var(--interactive-hover); }
|
||||
.assignment-heading, .assignment-meta { display: flex; flex-wrap: wrap; gap: var(--space-3); }
|
||||
.assignment-heading strong { color: var(--text-strong); }
|
||||
.assignment-meta, time { color: var(--text-muted); font-size: var(--font-size-compact); }
|
||||
.orchestrator-assignment p { margin: 0; white-space: pre-wrap; }
|
||||
.assignment-detail-link { color: var(--accent); }
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import {
|
||||
parseTicketQueryResponse,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
} from "#lib/workspace/api/ticket-browser.ts";
|
||||
import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts";
|
||||
import {
|
||||
HANDLING_STATES,
|
||||
readTicketFilters,
|
||||
ticketQueryRequest,
|
||||
} from "#lib/workspace/tickets/ticket-panel.ts";
|
||||
import type { TicketAssignmentTrackingStatus } from "#lib/generated/ticket-api.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params, url }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const filters = {
|
||||
...readTicketFilters(url.searchParams),
|
||||
principal: "orchestrator" as const,
|
||||
tagIds: [],
|
||||
closureReason: "all" as const,
|
||||
closureText: "",
|
||||
};
|
||||
const input = url.searchParams.get("handling");
|
||||
const handling =
|
||||
HANDLING_STATES.includes(input as TicketAssignmentTrackingStatus)
|
||||
? input as TicketAssignmentTrackingStatus
|
||||
: null;
|
||||
const request = ticketQueryRequest(
|
||||
filters,
|
||||
filters.caseState === "all" ? [] : [filters.caseState],
|
||||
null,
|
||||
handling,
|
||||
);
|
||||
const [tickets, orchestrator] = await Promise.all([
|
||||
loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/tickets/query"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(request),
|
||||
},
|
||||
parseTicketQueryResponse,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
),
|
||||
loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/orchestrator"),
|
||||
undefined,
|
||||
parseBrowserWorkspaceOrchestratorResponse,
|
||||
),
|
||||
]);
|
||||
return { workspaceId, filters, handling, tickets, orchestrator };
|
||||
};
|
||||
@@ -11,27 +11,28 @@
|
||||
import {
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||
parseTicketListResponse,
|
||||
parseTicketQueryResponse,
|
||||
parseTicketRecordRef,
|
||||
} from "#lib/workspace/api/ticket-browser.ts";
|
||||
import type {
|
||||
NewTicket,
|
||||
QueryPage,
|
||||
TicketListItemSummary as TicketSummary,
|
||||
TicketQueryItem as TicketSummary,
|
||||
TicketState,
|
||||
TicketTarget,
|
||||
TicketTargetAccess,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
import { ticketHref } from "#lib/workspace/resource-links.ts";
|
||||
import {
|
||||
ticketLanes,
|
||||
ticketQueryRequest, currentOrchestratorAssignment, assignmentPrincipalLabel, handlingLabel, handlingSummary, closureReasonLabel,
|
||||
type WorkspaceOrchestratorStatus,
|
||||
} from "#lib/workspace/tickets/ticket-panel.ts";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
type LaneState = {
|
||||
states: string[];
|
||||
states: TicketState[];
|
||||
tickets: TicketSummary[];
|
||||
page: QueryPage;
|
||||
page: QueryPage | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
@@ -43,21 +44,19 @@
|
||||
};
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
// svelte-ignore state_referenced_locally
|
||||
let laneState = $state<Record<string, LaneState>>(
|
||||
Object.fromEntries(
|
||||
Object.entries(data.ticketLanes).map(([laneId, lane]) => [
|
||||
laneId,
|
||||
{
|
||||
states: [...lane.states],
|
||||
tickets: lane.response.items,
|
||||
page: lane.response.page,
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
function initialLanes(): Record<string, LaneState> {
|
||||
return Object.fromEntries(Object.entries(data.ticketLanes).flatMap(([laneId, result]) => result ? [[laneId, {
|
||||
states: [laneId as TicketState], tickets: result.data?.items ?? [], page: result.data?.page ?? null,
|
||||
loading: false, error: result.error,
|
||||
}]] : []));
|
||||
}
|
||||
let laneState = $state<Record<string, LaneState>>(untrack(initialLanes));
|
||||
let principal = $state(untrack(() => data.filters.principal));
|
||||
let closureChoice = $state(untrack(() => data.filters.closureReason));
|
||||
let filtersBusy = $state(false);
|
||||
$effect(() => { const currentData = data; untrack(() => {
|
||||
laneState = initialLanes(); principal = currentData.filters.principal; closureChoice = currentData.filters.closureReason; orchestrator = currentData.orchestrator;
|
||||
}); });
|
||||
let orchestrator = $state<ApiResult<WorkspaceOrchestratorStatus>>(
|
||||
untrack(() => data.orchestrator),
|
||||
);
|
||||
@@ -81,10 +80,16 @@
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const tickets = $derived(
|
||||
Object.values(laneState).flatMap((lane) => lane.tickets),
|
||||
);
|
||||
const lanes = $derived(ticketLanes(tickets));
|
||||
const lanes = $derived(Object.entries(laneState).map(([id, lane]) => ({ id, label: id === "open" ? "Open" : "Closed", tickets: lane.tickets })));
|
||||
|
||||
async function applyFilters(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget as HTMLFormElement;
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of new FormData(form)) if (typeof value === "string" && value) search.append(key, value);
|
||||
filtersBusy = true;
|
||||
try { await goto(`?${search}`); } finally { filtersBusy = false; }
|
||||
}
|
||||
|
||||
function mergeTickets(
|
||||
current: TicketSummary[],
|
||||
@@ -97,22 +102,17 @@
|
||||
|
||||
async function loadMore(laneId: string): Promise<void> {
|
||||
const lane = laneState[laneId];
|
||||
if (!lane || lane.loading || !lane.page.has_more || !lane.page.next_cursor) {
|
||||
if (!lane || lane.loading || (lane.page && !lane.page.has_more)) {
|
||||
return;
|
||||
}
|
||||
lane.loading = true;
|
||||
lane.error = null;
|
||||
try {
|
||||
const search = new URLSearchParams({
|
||||
limit: "30",
|
||||
states: lane.states.join(","),
|
||||
cursor: lane.page.next_cursor,
|
||||
});
|
||||
const result = await loadJson(
|
||||
fetch,
|
||||
`/api/w/${encodeURIComponent(data.workspaceId)}/tickets?${search}`,
|
||||
undefined,
|
||||
parseTicketListResponse,
|
||||
workspaceApiPath(data.workspaceId, "/tickets/query"),
|
||||
{ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(ticketQueryRequest(data.filters, lane.states, lane.page?.next_cursor ?? null)) },
|
||||
parseTicketQueryResponse,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
);
|
||||
if (!result.data) {
|
||||
@@ -127,13 +127,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleLaneScroll(event: Event, laneId: string): void {
|
||||
const container = event.currentTarget as HTMLElement;
|
||||
const remaining =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
if (remaining <= 96) void loadMore(laneId);
|
||||
}
|
||||
|
||||
function addCreateTarget(): void {
|
||||
createTargets.push({
|
||||
repository_key: "",
|
||||
@@ -227,9 +220,9 @@
|
||||
<span class="orchestrator-status-dot"></span>
|
||||
<div>
|
||||
<strong>Orchestrator</strong>
|
||||
<span>{orchestrator.data?.online ? "Online" : "Offline"}</span>
|
||||
<span>{orchestrator.data ? orchestrator.data.online ? "Online" : "Offline" : "Unavailable"}</span>
|
||||
</div>
|
||||
{#if !orchestrator.data?.online}
|
||||
{#if orchestrator.data && !orchestrator.data.online}
|
||||
<button
|
||||
class="workspace-secondary-button"
|
||||
type="button"
|
||||
@@ -240,10 +233,6 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="ticket-panel-summary" aria-label="Ticket summary">
|
||||
<strong>{tickets.length}</strong>
|
||||
<span>loaded tickets</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -295,13 +284,30 @@
|
||||
<p class="workspace-callout is-error">
|
||||
Orchestrator status: {orchestrator.error}
|
||||
</p>
|
||||
{:else if !orchestrator.data?.online}
|
||||
<p class="workspace-callout">
|
||||
Orchestrator is offline. Requests can be queued; case decisions and results can still be recorded directly.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<section class="ticket-kanban" aria-label="Ticket workflow board">
|
||||
<form class="ticket-query-form" onsubmit={applyFilters} aria-label="Ticket filters">
|
||||
<label>Search<input name="q" value={data.filters.query} type="search" /></label>
|
||||
<label>Case<select name="case" value={data.filters.caseState}><option value="all">Open and closed</option><option value="open">Open</option><option value="closed">Closed</option></select></label>
|
||||
<label>Assigned principal<select name="principal" bind:value={principal}><option value="all">Any</option><option value="orchestrator">Orchestrator</option><option value="user">User</option><option value="worker">Worker</option></select></label>
|
||||
{#if principal === "user"}<label>Account ID<input name="account" value={data.filters.accountId} required /></label>{/if}
|
||||
{#if principal === "worker"}<label>Runtime ID<input name="runtime" value={data.filters.runtimeId} required /></label><label>Worker ID<input name="worker" value={data.filters.workerId} required /></label>{/if}
|
||||
<fieldset><legend>Classification tags (all selected)</legend>
|
||||
{#each data.tags.data ?? [] as tag (tag.id)}
|
||||
{#if !tag.deleted}<label class="ticket-tag-filter"><input type="checkbox" name="tag" value={tag.id} checked={data.filters.tagIds.includes(tag.id)} />{tag.name}</label>{/if}
|
||||
{:else}<span class="workspace-empty-copy">No tags</span>{/each}
|
||||
{#if data.tags.error}<p class="workspace-callout is-error" role="alert">Tags: {data.tags.error}</p>{/if}
|
||||
</fieldset>
|
||||
<details class="ticket-reason-filter" open={data.filters.closureReason !== "all"}><summary>Close reason filter</summary>
|
||||
<label>Current close reason<select name="reason" bind:value={closureChoice}><option value="all">Any reason</option><option value="completed">Completed</option><option value="not_planned">Not planned</option><option value="duplicate">Duplicate</option><option value="unknown">Unknown</option><option value="other">Other (exact text)</option></select></label>
|
||||
{#if closureChoice === "other"}<label>Close reason text<input name="reason_text" value={data.filters.closureText} required /></label>{/if}
|
||||
<p class="workspace-empty-copy">Matches current closed cases. Search also includes past close reasons.</p>
|
||||
</details>
|
||||
<button class="workspace-secondary-button" disabled={filtersBusy}>{filtersBusy ? "Searching…" : "Apply filters"}</button>
|
||||
<a href={`/w/${encodeURIComponent(data.workspaceId)}/orchestrator`}>Orchestrator handling</a>
|
||||
</form>
|
||||
|
||||
<section class="ticket-kanban" aria-label="Ticket cases">
|
||||
{#each lanes as lane (lane.id)}
|
||||
{@const pagination = laneState[lane.id]}
|
||||
<section class="ticket-lane" data-state={lane.id}>
|
||||
@@ -310,14 +316,14 @@
|
||||
<span class="ticket-state-dot"></span>
|
||||
<h2>{lane.label}</h2>
|
||||
</div>
|
||||
<span class="ticket-lane-count">{lane.tickets.length}</span>
|
||||
<span class="ticket-lane-count">{lane.tickets.length} loaded</span>
|
||||
</header>
|
||||
<div
|
||||
class="ticket-lane-cards"
|
||||
data-lane-id={lane.id}
|
||||
onscroll={(event) => handleLaneScroll(event, lane.id)}
|
||||
>
|
||||
{#each lane.tickets as ticket (ticket.id)}
|
||||
{@const assignment = currentOrchestratorAssignment(ticket)}
|
||||
<a
|
||||
class="ticket-card"
|
||||
href={ticketHref(data.workspaceId, ticket)}
|
||||
@@ -325,12 +331,19 @@
|
||||
<span class="ticket-card-id">{ticket.resource_key}</span>
|
||||
<strong>{ticket.title}</strong>
|
||||
<div class="ticket-card-meta">
|
||||
<span>{ticket.state} · {ticket.priority}</span>
|
||||
<span>Case: {ticket.state} · {ticket.priority}</span>
|
||||
<time>{prettyDate(ticket.updated_at)}</time>
|
||||
</div>
|
||||
{#if ticket.current_closure}<span class="ticket-closure-summary">Close reason: {closureReasonLabel(ticket.current_closure.reason)}</span>{/if}
|
||||
<span class="ticket-classification">Tags: {ticket.tags.length ? ticket.tags.map((tag) => tag.name).join(", ") : "None"}</span>
|
||||
<div class="ticket-handling-summary">
|
||||
<span>Orchestrator handling: {assignment ? handlingLabel(assignment.tracking?.status) : "Not assigned"}{#if assignment}{" "}<small>({assignment.tracking ? "self-reported" : "not reported"})</small>{/if}</span>
|
||||
{#if assignment}<span>Assigned principal: {assignmentPrincipalLabel(assignment.principal)}</span>{/if}
|
||||
<span>{handlingSummary(assignment)}</span>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="ticket-lane-empty">No tickets</div>
|
||||
{#if !pagination?.error}<div class="ticket-lane-empty">No tickets</div>{/if}
|
||||
{/each}
|
||||
{#if pagination?.loading}
|
||||
<p class="ticket-lane-page-state" aria-live="polite">Loading…</p>
|
||||
@@ -339,7 +352,9 @@
|
||||
<span>{pagination.error}</span>
|
||||
<button type="button" onclick={() => loadMore(lane.id)}>Retry</button>
|
||||
</div>
|
||||
{:else if pagination && !pagination.page.has_more && lane.tickets.length > 0}
|
||||
{:else if pagination?.page?.has_more}
|
||||
<button class="workspace-secondary-button" type="button" onclick={() => loadMore(lane.id)}>Load more {lane.label.toLowerCase()} tickets</button>
|
||||
{:else if pagination?.page && !pagination.page.has_more && lane.tickets.length > 0}
|
||||
<p class="ticket-lane-page-state">End of lane</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -347,3 +362,22 @@
|
||||
{/each}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ticket-query-form { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-3); margin-block: var(--space-4); }
|
||||
.ticket-query-form > label { display: grid; gap: var(--space-1); min-width: 0; flex: 1 1 10rem; }
|
||||
.ticket-query-form input:not([type="checkbox"]), .ticket-query-form select { min-width: 0; width: 100%; padding: var(--space-2); color: var(--text); background: var(--bg-raised); border: 1px solid var(--line); border-radius: var(--radius-soft); font: inherit; }
|
||||
.ticket-query-form fieldset { display: flex; flex-wrap: wrap; gap: var(--space-3); border: 0; padding: 0; flex: 1 1 100%; min-width: 0; }
|
||||
.ticket-tag-filter { display: flex; align-items: center; gap: var(--space-1); overflow-wrap: anywhere; }
|
||||
.ticket-reason-filter { flex: 1 1 100%; }
|
||||
.ticket-reason-filter label { display: grid; gap: var(--space-1); max-width: 28rem; margin-block: var(--space-2); }
|
||||
.ticket-reason-filter summary { cursor: pointer; }
|
||||
.ticket-query-form legend { margin-bottom: var(--space-2); }
|
||||
.ticket-kanban { grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); align-items: start; overflow: visible; }
|
||||
.ticket-lane { height: auto; min-height: 0; max-height: none; }
|
||||
.ticket-lane-cards { max-height: none; overflow: visible; }
|
||||
.ticket-card { overflow-wrap: anywhere; }
|
||||
.ticket-handling-summary { display: grid; gap: var(--space-1); border-top: 1px solid var(--line); padding-top: var(--space-2); color: var(--text-muted); }
|
||||
.ticket-classification { color: var(--text-muted); }
|
||||
.ticket-closure-summary { font-weight: 600; }
|
||||
</style>
|
||||
|
||||
@@ -2,56 +2,52 @@ import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/wo
|
||||
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
|
||||
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
|
||||
import {
|
||||
parseTicketListResponse,
|
||||
parseTicketQueryResponse,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
} from "#lib/workspace/api/ticket-browser.ts";
|
||||
import type { TicketListResponse } from "#lib/generated/ticket-api.ts";
|
||||
import type { WorkspaceOrchestratorStatus } from "#lib/workspace/tickets/ticket-panel.ts";
|
||||
import { parseTags } from "#lib/workspace/api/ticket-operations.ts";
|
||||
import {
|
||||
readTicketFilters,
|
||||
ticketQueryRequest,
|
||||
} from "#lib/workspace/tickets/ticket-panel.ts";
|
||||
import type { TicketState } from "#lib/generated/ticket-api.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
const LANE_STATES = { open: ["open"], closed: ["closed"] } as const;
|
||||
|
||||
export type TicketLaneId = keyof typeof LANE_STATES;
|
||||
|
||||
export type TicketLanePage = {
|
||||
states: readonly string[];
|
||||
response: TicketListResponse;
|
||||
};
|
||||
|
||||
async function loadLane(
|
||||
fetchFn: typeof fetch,
|
||||
workspaceId: string,
|
||||
laneId: TicketLaneId,
|
||||
): Promise<TicketLanePage> {
|
||||
const states = LANE_STATES[laneId];
|
||||
const search = new URLSearchParams({
|
||||
limit: "30",
|
||||
states: states.join(","),
|
||||
});
|
||||
const result = await loadJson(
|
||||
fetchFn,
|
||||
`/api/w/${encodeURIComponent(workspaceId)}/tickets?${search}`,
|
||||
undefined,
|
||||
parseTicketListResponse,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
);
|
||||
if (!result.data) {
|
||||
throw new Error(result.error ?? `failed to load ${laneId} Ticket lane`);
|
||||
}
|
||||
return { states: [...states], response: result.data };
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
export const load: PageLoad = async ({ fetch, params, url }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const [
|
||||
open,
|
||||
closed,
|
||||
orchestrator,
|
||||
repositories,
|
||||
] = await Promise.all([
|
||||
loadLane(fetch, workspaceId, "open"),
|
||||
loadLane(fetch, workspaceId, "closed"),
|
||||
loadJson<WorkspaceOrchestratorStatus>(
|
||||
const filters = readTicketFilters(url.searchParams);
|
||||
const loadLane = async (state: TicketState) => {
|
||||
if (filters.caseState !== "all" && filters.caseState !== state) return null;
|
||||
try {
|
||||
return await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/tickets/query"),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(ticketQueryRequest(filters, [state])),
|
||||
},
|
||||
parseTicketQueryResponse,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
const [open, closed, tags, orchestrator, repositories] = await Promise.all([
|
||||
loadLane("open"),
|
||||
loadLane("closed"),
|
||||
loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/ticket-tags"),
|
||||
undefined,
|
||||
parseTags,
|
||||
TICKET_BROWSER_API_LOAD_POLICY,
|
||||
),
|
||||
loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/orchestrator"),
|
||||
undefined,
|
||||
@@ -59,13 +55,11 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
),
|
||||
loadWorkspaceRepositoryList(fetch, workspaceId),
|
||||
]);
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
ticketLanes: {
|
||||
open,
|
||||
closed,
|
||||
},
|
||||
filters,
|
||||
ticketLanes: { open, closed },
|
||||
tags,
|
||||
orchestrator,
|
||||
repositories,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import TicketTags from "#lib/workspace/tickets/TicketTags.svelte";
|
||||
import TicketWorkerObservation from "#lib/workspace/tickets/TicketWorkerObservation.svelte";
|
||||
import TicketHandling from "#lib/workspace/tickets/TicketHandling.svelte";
|
||||
import { observeOperation, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts";
|
||||
import RichMarkdown from "#lib/workspace/console/RichMarkdown.svelte";
|
||||
import {
|
||||
workspaceApiJson,
|
||||
@@ -8,11 +12,11 @@
|
||||
} from "#lib/workspace/api/http.ts";
|
||||
import {
|
||||
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||
parseTicketSummary,
|
||||
parseTicketDetail,
|
||||
parseTicketQueueOutcome,
|
||||
parseTicketRoleAssignmentMutationResponse,
|
||||
} from "#lib/workspace/api/ticket-browser.ts";
|
||||
import { ticketCaseRequest, updateTicketCase } from "#lib/workspace/api/ticket-case.ts";
|
||||
import { ticketCaseRequest } from "#lib/workspace/api/ticket-case.ts";
|
||||
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
|
||||
import { currentRequirementApprovalStatus, summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts";
|
||||
import {
|
||||
@@ -86,8 +90,8 @@
|
||||
let threadBody = $state("");
|
||||
let busy = $state<string | null>(null);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let queueMessage = $state<string | null>(null);
|
||||
let readyOperationKey = $state<string | null>(null);
|
||||
let caseOperation = $state<ObservedOperation | null>(null);
|
||||
let caseSaved = $state(false);
|
||||
let manualRuntimeId = $state("");
|
||||
let manualWorkerId = $state("");
|
||||
let manualBindings = $state<{ alias: string; working_directory_id: string; connection_id: string }[]>([]);
|
||||
@@ -151,8 +155,8 @@
|
||||
threadBody = "";
|
||||
busy = null;
|
||||
errorMessage = null;
|
||||
queueMessage = null;
|
||||
readyOperationKey = null;
|
||||
caseOperation = null;
|
||||
caseSaved = false;
|
||||
manualRuntimeId = "";
|
||||
manualWorkerId = "";
|
||||
manualBindings = [];
|
||||
@@ -226,36 +230,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function queueTicket(): Promise<void> {
|
||||
if (busy) return;
|
||||
async function refreshOperationTicket(): Promise<void> {
|
||||
const generation = routeGeneration;
|
||||
const path = ticketPath;
|
||||
busy = "queue";
|
||||
errorMessage = null;
|
||||
queueMessage = null;
|
||||
try {
|
||||
const outcome = await workspaceApiJsonWithBody(
|
||||
`${path}/queue`,
|
||||
{ method: "POST", body: JSON.stringify({}) },
|
||||
parseTicketQueueOutcome,
|
||||
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
if (generation !== routeGeneration) return;
|
||||
const updatedTicket = await workspaceApiJson(
|
||||
path,
|
||||
parseTicketDetail,
|
||||
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
if (generation !== routeGeneration) return;
|
||||
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
|
||||
applyTicket(updatedTicket);
|
||||
} catch (error) {
|
||||
if (generation === routeGeneration) {
|
||||
errorMessage = ticketMutationError(error);
|
||||
}
|
||||
} finally {
|
||||
if (generation === routeGeneration) busy = null;
|
||||
}
|
||||
const updated = await workspaceApiJson(ticketPath, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
||||
if (generation !== routeGeneration) return;
|
||||
applyTicket(updated);
|
||||
}
|
||||
|
||||
async function mutateAssignment(
|
||||
@@ -276,7 +255,7 @@
|
||||
body: JSON.stringify({
|
||||
operation_id: crypto.randomUUID(),
|
||||
principal,
|
||||
expected_assignment_id: null,
|
||||
expected_assignment_id: ticket.assignments.find((row) => row.role === role)?.assignment_id ?? null,
|
||||
...(role === "worker" ? { workdir_bindings: manualBindings } : {}),
|
||||
}),
|
||||
},
|
||||
@@ -300,13 +279,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function assignOrchestrator(): Promise<void> {
|
||||
await mutateAssignment("assign-orchestrator", "orchestrator", {
|
||||
kind: "workspace_agent",
|
||||
agent_key: "workspace-orchestrator",
|
||||
});
|
||||
}
|
||||
|
||||
async function assignExistingWorker(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!manualRuntimeId.trim() || !manualWorkerId.trim()) return;
|
||||
@@ -361,34 +333,30 @@
|
||||
await mutate("target", "", targetEditBody(), "PATCH");
|
||||
}
|
||||
|
||||
async function markReady() {
|
||||
if (ticket.state !== "open" || busy) return;
|
||||
readyOperationKey ??= crypto.randomUUID();
|
||||
if (
|
||||
await mutate("ready", "/ready", {
|
||||
operation_key: readyOperationKey,
|
||||
reason: transitionReason.trim() || null,
|
||||
})
|
||||
) {
|
||||
readyOperationKey = null;
|
||||
transitionReason = "";
|
||||
}
|
||||
}
|
||||
|
||||
function selectedClosureReason(): TicketClosureReason {
|
||||
return closureReason === "other" ? { other: otherClosureReason.trim() } : closureReason as TicketClosureReason;
|
||||
}
|
||||
|
||||
async function caseDecision(action: TicketCaseAction): Promise<void> {
|
||||
if (busy || !transitionReason.trim()) return;
|
||||
if (busy || caseOperation || !transitionReason.trim()) return;
|
||||
const request = ticketCaseRequest(ticket, action, transitionReason, crypto.randomUUID());
|
||||
const path = `/workspaces/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(data.ticketId)}/case`;
|
||||
await runCaseDecision(observeOperation(path, "POST", request));
|
||||
}
|
||||
|
||||
async function runCaseDecision(operation: ObservedOperation): Promise<void> {
|
||||
if (busy) return;
|
||||
const generation = routeGeneration;
|
||||
const path = ticketPath;
|
||||
const request = ticketCaseRequest(ticket, action, transitionReason, crypto.randomUUID());
|
||||
busy = action.action;
|
||||
busy = "case";
|
||||
caseOperation = operation;
|
||||
errorMessage = null;
|
||||
caseSaved = false;
|
||||
try {
|
||||
await updateTicketCase(data.workspaceId, data.ticketId, request);
|
||||
await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseTicketSummary, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
||||
if (generation !== routeGeneration) return;
|
||||
caseOperation = null;
|
||||
caseSaved = true;
|
||||
const updated = await workspaceApiJson(path, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
||||
if (generation !== routeGeneration) return;
|
||||
applyTicket(updated);
|
||||
@@ -432,9 +400,11 @@
|
||||
<header class="ticket-detail-header">
|
||||
<div>
|
||||
<div class="ticket-detail-kicker">
|
||||
<span class="workspace-status-pill" data-status={ticket.state}>{ticket.state}</span>
|
||||
<span>{ticket.resource_key}</span>
|
||||
<span class="workspace-status-pill" data-status={ticket.state}>Case: {ticket.state}</span>
|
||||
</div>
|
||||
<h1>{ticket.title}</h1>
|
||||
{#if ticket.state === "closed" && ticket.current_closure}<p><strong>Closure reason:</strong> {closureReasonLabel(ticket.current_closure.reason)}</p>{/if}
|
||||
<p>Updated {prettyDate(ticket.updated_at)}</p>
|
||||
</div>
|
||||
<button class="workspace-secondary-button" type="button" onclick={() => editing = !editing}>
|
||||
@@ -446,9 +416,6 @@
|
||||
<div class="workspace-callout is-error" role="alert"><p>{errorMessage}</p><p>No success or approval is inferred. An uncertain response may already have applied; refresh the current Ticket before retrying.</p><button type="button" class="workspace-secondary-button" disabled={busy !== null} onclick={refreshTicket}>Refresh Ticket status</button></div>
|
||||
{/if}
|
||||
|
||||
{#if queueMessage}
|
||||
<div class="workspace-callout" role="status">{queueMessage}</div>
|
||||
{/if}
|
||||
|
||||
{#if editing}
|
||||
<form class="ticket-editor" onsubmit={saveEdit}>
|
||||
@@ -471,6 +438,12 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#key JSON.stringify([data.workspaceId, ticket.id])}
|
||||
<TicketTags workspaceId={data.workspaceId} {ticket} refresh={refreshOperationTicket} />
|
||||
<TicketHandling workspaceId={data.workspaceId} {ticket} orchestrator={data.orchestrator.data} refresh={refreshOperationTicket} />
|
||||
<TicketWorkerObservation workspaceId={data.workspaceId} {ticket} />
|
||||
{/key}
|
||||
|
||||
<section class="ticket-detail-section">
|
||||
<div class="ticket-section-heading">
|
||||
<h2>Relations</h2>
|
||||
@@ -481,7 +454,7 @@
|
||||
{#each ticket.relations.blockers as blocker}
|
||||
{#if blocker.blocking_resource_key}
|
||||
<a href={`/w/${encodeURIComponent(data.workspaceId)}/tickets/${encodeURIComponent(blocker.blocking_resource_key)}`}>
|
||||
<strong>Blocked by {blocker.blocking_resource_key}</strong>
|
||||
<strong>Dependency {blocker.blocking_resource_key} remains unresolved</strong>
|
||||
<span>{relationLabel(blocker.relation_kind)} · {blocker.blocking_state}</span>
|
||||
</a>
|
||||
{:else}
|
||||
@@ -550,9 +523,15 @@
|
||||
</main>
|
||||
|
||||
<aside class="ticket-control-rail">
|
||||
<section class="ticket-control-card">
|
||||
<section class="ticket-control-card ticket-case-card">
|
||||
<header><h2>Case decision</h2></header>
|
||||
<p class="workspace-empty-copy">Open means the case remains under consideration; closed does not necessarily mean completed. Case decisions do not start or stop Workers, grant access, approve review, merge code, or change tags. Reopening does not resume an old Worker.</p>
|
||||
{#if caseSaved}<p role="status">Case decision saved. This is not handling Done or MR approval.</p>{/if}
|
||||
{#if caseOperation}
|
||||
<p>The case decision may already be saved. The exact observed input and operation key are retained.</p>
|
||||
<button class="workspace-secondary-button" disabled={busy !== null} onclick={() => caseOperation && void runCaseDecision(caseOperation)}>Retry exact case decision</button>
|
||||
<button class="workspace-secondary-button" disabled={busy !== null} onclick={async () => { await refreshTicket(); if (!errorMessage) caseOperation = null; }}>Refresh and reconcile case</button>
|
||||
{/if}
|
||||
<form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); void caseDecision(ticket.state === "open" ? { action: "close", reason: selectedClosureReason() } : { action: "reopen" }); }}>
|
||||
<label>Explanation<textarea bind:value={transitionReason} rows="3" required></textarea></label>
|
||||
<label>Closure reason
|
||||
@@ -565,18 +544,13 @@
|
||||
</select>
|
||||
</label>
|
||||
{#if closureReason === "other"}<label>Other reason<input bind:value={otherClosureReason} required={ticket.state === "open"} /></label>{/if}
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !transitionReason.trim() || (ticket.state === "open" && closureReason === "other" && !otherClosureReason.trim())}>
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || caseOperation !== null || !transitionReason.trim() || (ticket.state === "open" && closureReason === "other" && !otherClosureReason.trim())}>
|
||||
{ticket.state === "open" ? "Close ticket" : "Reopen ticket"}
|
||||
</button>
|
||||
{#if ticket.state === "closed" && ticket.current_closure}
|
||||
<button class="workspace-secondary-button" type="button" disabled={busy !== null || !transitionReason.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button>
|
||||
<button class="workspace-secondary-button" type="button" disabled={busy !== null || caseOperation !== null || !transitionReason.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button>
|
||||
{/if}
|
||||
</form>
|
||||
{#if ticket.state === "open"}
|
||||
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null} onclick={markReady}>{busy === "ready" ? "Recording readiness…" : "Record routing readiness"}</button>
|
||||
{/if}
|
||||
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>{busy === "queue" ? "Queueing…" : "Request Orchestrator (queue)"}</button>
|
||||
<p class="workspace-empty-copy">Queue requests only this Ticket. Dependencies remain diagnostic information and are not automatically queued.</p>
|
||||
{#each ticket.action_eligibility.blockers as blocker}<p class="workspace-callout">{blocker}</p>{/each}
|
||||
</section>
|
||||
|
||||
@@ -603,16 +577,6 @@
|
||||
{:else}
|
||||
<p class="workspace-empty-copy">No role responsibility recorded.</p>
|
||||
{/if}
|
||||
{#if ticket.action_eligibility.can_assign_orchestrator}
|
||||
<button
|
||||
class="workspace-primary-button"
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onclick={assignOrchestrator}
|
||||
>
|
||||
{busy === "assign-orchestrator" ? "Assigning…" : "Assign Orchestrator"}
|
||||
</button>
|
||||
{/if}
|
||||
{#if implementationStartEligible}
|
||||
<a class="workspace-secondary-button" href={ticketWorkerLaunchHref(data.workspaceId, ticket)}>Start a Ticket Worker</a>
|
||||
<details><summary>Assign an existing Worker</summary>
|
||||
|
||||
@@ -25,12 +25,25 @@ Deno.test("ticket detail synchronizes reused route data", () => {
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("ticket child operations reset across Workspaces even when the retained Ticket id matches", () => {
|
||||
assertStringIncludes(
|
||||
pageSource,
|
||||
"{#key JSON.stringify([data.workspaceId, ticket.id])}",
|
||||
);
|
||||
const children = pageSource.slice(
|
||||
pageSource.indexOf("{#key JSON.stringify([data.workspaceId, ticket.id])}"),
|
||||
pageSource.indexOf("{/key}"),
|
||||
);
|
||||
assertStringIncludes(children, "<TicketTags");
|
||||
assertStringIncludes(children, "<TicketHandling");
|
||||
assertStringIncludes(children, "<TicketWorkerObservation");
|
||||
});
|
||||
|
||||
Deno.test("ticket detail fences stale mutation responses", () => {
|
||||
for (
|
||||
const operation of [
|
||||
"async function mutate(",
|
||||
"async function queueTicket(",
|
||||
"async function caseDecision(",
|
||||
"async function runCaseDecision(",
|
||||
"async function mutateAssignment(",
|
||||
]
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user