feat(ticket): persist Orchestrator assignment tracking and audit
This commit is contained in:
@@ -17,6 +17,7 @@ struct ModelTicketQueryItem {
|
||||
closure_operation_key: Option<String>,
|
||||
current_closure: Option<ticket::TicketClosureJudgment>,
|
||||
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
||||
assignment_tracking: Vec<ModelAssignmentTracking>,
|
||||
tags: Vec<ticket::WorkspaceTag>,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
@@ -46,6 +47,7 @@ pub(super) struct ModelTicketDetail {
|
||||
closure_operation_key: Option<String>,
|
||||
current_closure: Option<ticket::TicketClosureJudgment>,
|
||||
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
|
||||
assignment_tracking: Vec<ModelAssignmentTracking>,
|
||||
tags: Vec<ticket::WorkspaceTag>,
|
||||
readiness: Option<String>,
|
||||
priority: Option<String>,
|
||||
@@ -98,6 +100,21 @@ pub(super) struct ModelObjectiveDetail {
|
||||
event_page: ModelObjectiveEventPage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelAssignmentTracking {
|
||||
assignment_id: String,
|
||||
role: String,
|
||||
principal: ticket::TicketAssignmentPrincipalSnapshot,
|
||||
assigned_by: String,
|
||||
assigned_at: String,
|
||||
release: Option<ticket::TicketAssignmentRelease>,
|
||||
current: bool,
|
||||
tracking: Option<ticket::TicketAssignmentTracking>,
|
||||
last_event_id: Option<String>,
|
||||
updated_at: Option<String>,
|
||||
migration_diagnostic: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ModelWorkerSummary {
|
||||
worker: String,
|
||||
@@ -266,6 +283,7 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
||||
closure_operation_key: typed_field(item, "closure_operation_key")?,
|
||||
current_closure: typed_field(item, "current_closure")?,
|
||||
legacy_handoff: typed_field(item, "legacy_handoff")?,
|
||||
assignment_tracking: project_assignment_tracking(item)?,
|
||||
tags: project_tags(item)?,
|
||||
readiness: optional_string(item, "readiness")?,
|
||||
priority: optional_string(item, "priority")?,
|
||||
@@ -322,6 +340,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
||||
current_closure: typed_field(root, "current_closure")?,
|
||||
legacy_handoff: typed_field(root, "legacy_handoff")?,
|
||||
closure_history: typed_field(root, "closure_history")?,
|
||||
assignment_tracking: project_assignment_tracking(root)?,
|
||||
tags: project_tags(root)?,
|
||||
readiness: optional_string(root, "readiness")?,
|
||||
priority: optional_string(root, "priority")?,
|
||||
@@ -672,6 +691,36 @@ fn object_field<'a>(
|
||||
.ok_or_else(|| format!("missing or invalid {key}"))
|
||||
}
|
||||
|
||||
fn project_assignment_tracking(
|
||||
object: &Map<String, Value>,
|
||||
) -> Result<Vec<ModelAssignmentTracking>, String> {
|
||||
let values = array_field(object, "assignment_tracking")?;
|
||||
if values.len() > 1 {
|
||||
return Err("Ticket has more than one current Orchestrator delegation".into());
|
||||
}
|
||||
values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
let projection: ticket::TicketAssignmentTrackingProjection =
|
||||
serde_json::from_value(value.clone())
|
||||
.map_err(|error| format!("invalid assignment tracking: {error}"))?;
|
||||
Ok(ModelAssignmentTracking {
|
||||
assignment_id: projection.assignment_id,
|
||||
role: projection.role,
|
||||
principal: projection.principal,
|
||||
assigned_by: projection.assigned_by,
|
||||
assigned_at: projection.assigned_at,
|
||||
release: projection.release,
|
||||
current: projection.current,
|
||||
tracking: projection.tracking,
|
||||
last_event_id: projection.last_event_id,
|
||||
updated_at: projection.updated_at,
|
||||
migration_diagnostic: projection.migration_diagnostic,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn project_tags(object: &Map<String, Value>) -> Result<Vec<ticket::WorkspaceTag>, String> {
|
||||
let values = array_field(object, "tags")?;
|
||||
if values.len() > 100 {
|
||||
@@ -770,7 +819,7 @@ mod tests {
|
||||
fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() {
|
||||
for state in ["open", "closed"] {
|
||||
let projected = project_ticket_detail(json!({
|
||||
"resource_key": "T-718", "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
|
||||
"resource_key": "T-718", "assignment_tracking": [], "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
|
||||
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
|
||||
"state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}],
|
||||
"linked_objectives": [], "assignments": [], "implementation_reports": [],
|
||||
@@ -812,7 +861,7 @@ mod tests {
|
||||
"queued_by": "workspace-user:alice", "queued_at": "2025-12-31T00:00:00Z", "resolution": null
|
||||
});
|
||||
let response = json!({
|
||||
"tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}],
|
||||
"assignment_tracking": [], "tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}],
|
||||
"resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements",
|
||||
"state": "open", "closure_operation_key": "close-1", "current_closure": null,
|
||||
"closure_history": history, "legacy_handoff": legacy,
|
||||
@@ -857,7 +906,7 @@ mod tests {
|
||||
"linked_tickets": ["00001M0E82D1V"],
|
||||
"linked_ticket_summaries": [{
|
||||
"id": "00001M0E82D1V",
|
||||
"resource_key": "T-496", "tags": [],
|
||||
"resource_key": "T-496", "assignment_tracking": [], "tags": [],
|
||||
"title": "Ticket",
|
||||
"state": "closed",
|
||||
"updated_at": "2026-01-02T00:00:00Z"
|
||||
@@ -888,7 +937,7 @@ mod tests {
|
||||
"record_authority": "workspace_sqlite",
|
||||
"items": [{
|
||||
"id": "00001TICKETINTERNAL",
|
||||
"resource_key": "T-543", "tags": [],
|
||||
"resource_key": "T-543", "assignment_tracking": [], "tags": [],
|
||||
"title": "Ticket",
|
||||
"state": "open",
|
||||
"closure_operation_key": null, "current_closure": null, "legacy_handoff": null,
|
||||
@@ -960,7 +1009,7 @@ mod tests {
|
||||
fn ticket_detail_projection_accepts_current_blocker_shape() {
|
||||
let projected = project_ticket_detail(json!({
|
||||
"id": "internal-ticket",
|
||||
"resource_key": "T-588", "tags": [],
|
||||
"resource_key": "T-588", "assignment_tracking": [], "tags": [],
|
||||
"content_digest": "ticket-content-digest",
|
||||
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
|
||||
"title": "Queued Submit",
|
||||
@@ -1064,7 +1113,7 @@ mod tests {
|
||||
"review_excerpt": "Reviewed source"
|
||||
});
|
||||
let projected = project_ticket_detail(json!({
|
||||
"resource_key": "T-716", "tags": [],
|
||||
"resource_key": "T-716", "assignment_tracking": [], "tags": [],
|
||||
"content_digest": "current-ticket-content-digest",
|
||||
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
|
||||
"title": "Source evidence",
|
||||
@@ -1246,13 +1295,14 @@ mod tests {
|
||||
assert!(error.contains("W-"));
|
||||
}
|
||||
#[test]
|
||||
fn ticket_query_and_detail_preserve_classification_separately_from_closure_judgment() {
|
||||
fn ticket_query_and_detail_preserve_case_classification_and_delegation_independently() {
|
||||
let tracking = json!({"workspace_id":"ws", "ticket_id":"internal-id", "assignment_id":"delegation", "role":"orchestrator", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}, "assigned_by":"workspace-user:alice", "assigned_at":"2026-01-01T00:00:00Z", "release":null, "current":true, "last_event_id":"reported", "updated_at":"2026-01-01T00:00:00Z", "migration_diagnostic":null, "tracking":{"status":"waiting", "waiting_reason":"Human answer", "progress":null, "next_action":"Compare the response", "result":null}});
|
||||
let tag = json!({"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false});
|
||||
let closure = json!({"operation_key":"close-1", "reason":"not_planned", "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"});
|
||||
let history = json!([{"operation_key":"close-1", "previous_closure_operation_key":null, "action":{"action":"close", "reason":"not_planned"}, "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}]);
|
||||
let detail = json!({
|
||||
"resource_key":"T-739", "title":"Case", "body":"Requirements", "content_digest":"content-1",
|
||||
"state":"closed", "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure,
|
||||
"state":"closed", "assignment_tracking": [tracking], "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure,
|
||||
"legacy_handoff":null, "closure_history":history, "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]
|
||||
});
|
||||
let mut item = detail.clone();
|
||||
@@ -1288,6 +1338,14 @@ mod tests {
|
||||
"query {field}"
|
||||
);
|
||||
}
|
||||
for projected in [&projected_detail, &projected_query["tickets"][0]] {
|
||||
let actual = &projected["assignment_tracking"][0];
|
||||
assert_eq!(actual["assignment_id"], "delegation");
|
||||
assert_eq!(actual["last_event_id"], "reported");
|
||||
assert_eq!(actual["tracking"], tracking["tracking"]);
|
||||
assert!(actual.get("workspace_id").is_none());
|
||||
assert!(actual.get("ticket_id").is_none());
|
||||
}
|
||||
assert_eq!(projected_detail["closure_history"], history);
|
||||
assert_eq!(projected_detail["content_digest"], "content-1");
|
||||
// Unknown observations must not be replaced with empty/null values.
|
||||
|
||||
@@ -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_tracking;
|
||||
mod tags;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
@@ -443,6 +444,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_tracking::TOOL_NAMES.contains(&name) => {
|
||||
assignment_tracking::description(name).into()
|
||||
}
|
||||
name if tags::TAG_TOOL_NAMES.contains(&name) => tags::description(name).into(),
|
||||
_ => ticket_tool_description(name, None),
|
||||
}
|
||||
@@ -576,6 +580,12 @@ impl TicketFeatureAccess {
|
||||
.chain(tags::TAG_TOOL_NAMES.iter().copied().filter(|name| {
|
||||
*name == "WorkspaceTagList" || self.authoring || self.workflow || self.thread
|
||||
}))
|
||||
.chain(
|
||||
assignment_tracking::TOOL_NAMES
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|name| *name != "TicketAssignmentTrackingUpdate" || self.workflow),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -592,7 +602,13 @@ impl TicketFeatureAccess {
|
||||
}
|
||||
}
|
||||
|
||||
const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket", "WorkspaceTagList"];
|
||||
const READ_ONLY_TOOL_NAMES: &[&str] = &[
|
||||
"QueryTicket",
|
||||
"ShowTicket",
|
||||
"WorkspaceTagList",
|
||||
"TicketAssignmentTrackingQuery",
|
||||
"TicketAssignmentTrackingHistory",
|
||||
];
|
||||
|
||||
const AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketCreate",
|
||||
@@ -625,6 +641,8 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"WorkspaceTagRename",
|
||||
"WorkspaceTagDelete",
|
||||
"TicketTagsSet",
|
||||
"TicketAssignmentTrackingQuery",
|
||||
"TicketAssignmentTrackingHistory",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -643,6 +661,9 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
|
||||
"WorkspaceTagRename",
|
||||
"WorkspaceTagDelete",
|
||||
"TicketTagsSet",
|
||||
"TicketAssignmentTrackingQuery",
|
||||
"TicketAssignmentTrackingHistory",
|
||||
"TicketAssignmentTrackingUpdate",
|
||||
];
|
||||
|
||||
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
|
||||
@@ -1515,6 +1536,24 @@ fn native_ticket_operation(tool_name: &str) -> Option<NativeTicketOperation> {
|
||||
identity_field: Some("ticket"),
|
||||
mutating: true,
|
||||
},
|
||||
"TicketAssignmentTrackingQuery" => NativeTicketOperation {
|
||||
operation: "assignment_tracking_query",
|
||||
surface: NativeTicketSurface::Item,
|
||||
identity_field: Some("ticket"),
|
||||
mutating: false,
|
||||
},
|
||||
"TicketAssignmentTrackingHistory" => NativeTicketOperation {
|
||||
operation: "assignment_tracking_history",
|
||||
surface: NativeTicketSurface::Item,
|
||||
identity_field: Some("ticket"),
|
||||
mutating: false,
|
||||
},
|
||||
"TicketAssignmentTrackingUpdate" => NativeTicketOperation {
|
||||
operation: "assignment_tracking_update",
|
||||
surface: NativeTicketSurface::Item,
|
||||
identity_field: Some("ticket"),
|
||||
mutating: true,
|
||||
},
|
||||
"TicketCaseUpdate" => NativeTicketOperation {
|
||||
operation: "case_update",
|
||||
surface: NativeTicketSurface::Item,
|
||||
@@ -1604,6 +1643,15 @@ pub(crate) fn enabled_ticket_definitions(
|
||||
definitions.push(tags::definition(client.clone(), kind));
|
||||
}
|
||||
}
|
||||
for kind in [
|
||||
assignment_tracking::TrackingToolKind::Query,
|
||||
assignment_tracking::TrackingToolKind::History,
|
||||
assignment_tracking::TrackingToolKind::Update,
|
||||
] {
|
||||
if allowed.contains(&kind.name()) {
|
||||
definitions.push(assignment_tracking::definition(client.clone(), kind));
|
||||
}
|
||||
}
|
||||
definitions
|
||||
}
|
||||
|
||||
@@ -2700,23 +2748,10 @@ mod tests {
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
authoring_operations,
|
||||
[
|
||||
"create",
|
||||
"edit",
|
||||
"query",
|
||||
"read",
|
||||
"comment",
|
||||
"mark_ready",
|
||||
"queue",
|
||||
"case_update",
|
||||
"record_relation",
|
||||
"remove_relation",
|
||||
"tag_list",
|
||||
"tag_create",
|
||||
"tag_rename",
|
||||
"tag_delete",
|
||||
"tags_set",
|
||||
]
|
||||
WORKSPACE_AUTHORING_TOOL_NAMES
|
||||
.iter()
|
||||
.map(|name| native_ticket_operation(name).unwrap().operation)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
let item = ticket_descriptor(
|
||||
&authoring
|
||||
@@ -2753,22 +2788,10 @@ mod tests {
|
||||
.iter()
|
||||
.map(|tool| tool.projection.operation)
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
"query",
|
||||
"read",
|
||||
"comment",
|
||||
"case_update",
|
||||
"dependency_check",
|
||||
"record_relation",
|
||||
"remove_relation",
|
||||
"record_orchestration_plan",
|
||||
"query_orchestration_plans",
|
||||
"tag_list",
|
||||
"tag_create",
|
||||
"tag_rename",
|
||||
"tag_delete",
|
||||
"tags_set",
|
||||
]
|
||||
WORKFLOW_TOOL_NAMES
|
||||
.iter()
|
||||
.map(|name| native_ticket_operation(name).unwrap().operation)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
native_ticket_tools(client, TicketFeatureAccess::review())
|
||||
@@ -2776,7 +2799,10 @@ mod tests {
|
||||
.iter()
|
||||
.map(|tool| tool.projection.operation)
|
||||
.collect::<Vec<_>>(),
|
||||
["query", "read", "tag_list"]
|
||||
READ_ONLY_TOOL_NAMES
|
||||
.iter()
|
||||
.map(|name| native_ticket_operation(name).unwrap().operation)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
//! Self-reported handling of one immutable delegation. This transport never
|
||||
//! supplies actor/role authority or converts tracking into execution lifecycle.
|
||||
use super::*;
|
||||
|
||||
pub(super) const TOOL_NAMES: &[&str] = &[
|
||||
"TicketAssignmentTrackingQuery",
|
||||
"TicketAssignmentTrackingHistory",
|
||||
"TicketAssignmentTrackingUpdate",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum TrackingToolKind {
|
||||
Query,
|
||||
History,
|
||||
Update,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct QueryInput {
|
||||
/// Ticket reference, normally T-*.
|
||||
ticket: String,
|
||||
/// Omit for the current delegation; set to inspect a retained assignment and release.
|
||||
assignment_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct HistoryInput {
|
||||
ticket: String,
|
||||
/// Immutable delegation identity, including released or replaced assignments.
|
||||
assignment_id: String,
|
||||
/// Continue after an event identity returned by this assignment's history.
|
||||
after_event_id: Option<String>,
|
||||
#[schemars(range(min = 1, max = 100))]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct UpdateInput {
|
||||
ticket: String,
|
||||
assignment_id: String,
|
||||
/// Last event observed for this exact delegation, not Ticket status or Worker state.
|
||||
expected_last_event_id: String,
|
||||
/// Reuse the same key and complete input after an uncertain result.
|
||||
operation_key: String,
|
||||
tracking: ticket::TicketAssignmentTracking,
|
||||
}
|
||||
|
||||
impl TrackingToolKind {
|
||||
pub(super) fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Query => TOOL_NAMES[0],
|
||||
Self::History => TOOL_NAMES[1],
|
||||
Self::Update => TOOL_NAMES[2],
|
||||
}
|
||||
}
|
||||
fn schema(self) -> Value {
|
||||
match self {
|
||||
Self::Query => json!(schemars::schema_for!(QueryInput)),
|
||||
Self::History => json!(schemars::schema_for!(HistoryInput)),
|
||||
Self::Update => json!(schemars::schema_for!(UpdateInput)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn description(name: &str) -> &'static str {
|
||||
match name {
|
||||
"TicketAssignmentTrackingQuery" => {
|
||||
"Read the current Ticket delegation, or one retained assignment_id with its role/principal, Assign actor/time and separate release/replacement record, and durable self-reported waiting/inprogress/done, last event identity, waiting reason, progress, next action and result. Tracking is independent of Ticket open/closed, Worker running/idle/stop, MR and delivery state."
|
||||
}
|
||||
"TicketAssignmentTrackingHistory" => {
|
||||
"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."
|
||||
}
|
||||
_ => unreachable!("unknown assignment tracking tool"),
|
||||
}
|
||||
}
|
||||
|
||||
fn required(name: &str, value: &str) -> Result<(), ToolError> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"{name} must not be empty"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_segment(value: &str) -> String {
|
||||
value
|
||||
.bytes()
|
||||
.map(|byte| {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
|
||||
char::from(byte).to_string()
|
||||
} else {
|
||||
format!("%{byte:02X}")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TrackingTool {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
kind: TrackingToolKind,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TrackingTool {
|
||||
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
|
||||
let workspace = self
|
||||
.client
|
||||
.workspace_id()
|
||||
.filter(|id| !id.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
ToolError::InvalidArgument(
|
||||
"Assignment tracking requires Backend Workspace identity; no local fallback"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let prefix = format!("/workspaces/{}/tickets", encode_segment(workspace));
|
||||
let parse_error = |error: serde_json::Error| ToolError::InvalidArgument(error.to_string());
|
||||
let (method, path, body) = match self.kind {
|
||||
TrackingToolKind::Query => {
|
||||
let input: QueryInput = serde_json::from_str(input).map_err(parse_error)?;
|
||||
required("ticket", &input.ticket)?;
|
||||
let mut path = format!(
|
||||
"{prefix}/{}/assignment-tracking",
|
||||
encode_segment(&input.ticket)
|
||||
);
|
||||
if let Some(assignment_id) = input.assignment_id {
|
||||
required("assignment_id", &assignment_id)?;
|
||||
path.push_str(&format!(
|
||||
"?assignment_id={}",
|
||||
encode_segment(&assignment_id)
|
||||
));
|
||||
}
|
||||
(WorkspaceRequestMethod::Get, path, None)
|
||||
}
|
||||
TrackingToolKind::History => {
|
||||
let input: HistoryInput = serde_json::from_str(input).map_err(parse_error)?;
|
||||
required("ticket", &input.ticket)?;
|
||||
required("assignment_id", &input.assignment_id)?;
|
||||
let limit = input.limit.unwrap_or(100);
|
||||
if !(1..=100).contains(&limit) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"limit must be between 1 and 100".into(),
|
||||
));
|
||||
}
|
||||
let mut path = format!(
|
||||
"{prefix}/{}/assignment-tracking/history?assignment_id={}&limit={limit}",
|
||||
encode_segment(&input.ticket),
|
||||
encode_segment(&input.assignment_id)
|
||||
);
|
||||
if let Some(after) = input.after_event_id {
|
||||
required("after_event_id", &after)?;
|
||||
path.push_str(&format!("&after_event_id={}", encode_segment(&after)));
|
||||
}
|
||||
(WorkspaceRequestMethod::Get, path, None)
|
||||
}
|
||||
TrackingToolKind::Update => {
|
||||
let input: UpdateInput = serde_json::from_str(input).map_err(parse_error)?;
|
||||
for (name, value) in [
|
||||
("ticket", &input.ticket),
|
||||
("assignment_id", &input.assignment_id),
|
||||
("expected_last_event_id", &input.expected_last_event_id),
|
||||
("operation_key", &input.operation_key),
|
||||
] {
|
||||
required(name, value)?;
|
||||
}
|
||||
// Unknown fields (including author, role, runtime and worker) are
|
||||
// rejected before transport; authenticated context belongs to Backend.
|
||||
let body = json!({"assignment_id": input.assignment_id, "expected_last_event_id": input.expected_last_event_id, "operation_key": input.operation_key, "tracking": input.tracking});
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!(
|
||||
"{prefix}/{}/assignment-tracking",
|
||||
encode_segment(&input.ticket)
|
||||
),
|
||||
Some(body.to_string()),
|
||||
)
|
||||
}
|
||||
};
|
||||
let request = match body {
|
||||
Some(body) => WorkspaceRequest::json(method, path, body),
|
||||
None => WorkspaceRequest::get(path),
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.execute(request)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !response.is_success() {
|
||||
let detail: String = response.body.chars().take(1024).collect();
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Assignment tracking request failed with HTTP status {}: {}",
|
||||
response.status, detail
|
||||
)));
|
||||
}
|
||||
if response.body.len() > 1024 * 1024 {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"Assignment tracking response exceeds 1 MiB; request a smaller history page".into(),
|
||||
));
|
||||
}
|
||||
let content: Value = serde_json::from_str(&response.body).map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"Backend returned invalid assignment tracking JSON: {error}"
|
||||
))
|
||||
})?;
|
||||
Ok(ToolOutput {
|
||||
summary: self.kind.name().into(),
|
||||
content: Some(content.to_string()),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn definition(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
kind: TrackingToolKind,
|
||||
) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(kind.name())
|
||||
.description(description(kind.name()))
|
||||
.input_schema(kind.schema()),
|
||||
Arc::new(TrackingTool {
|
||||
client: client.clone(),
|
||||
kind,
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Transport {
|
||||
workspace: Option<String>,
|
||||
response: WorkspaceResponse,
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
}
|
||||
impl WorkspaceClient for Transport {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
self.workspace.as_deref()
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"tracking-contract-test"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(self.response.clone())
|
||||
}
|
||||
}
|
||||
fn transport(status: u16, body: &str) -> Arc<Transport> {
|
||||
Arc::new(Transport {
|
||||
workspace: Some("workspace".into()),
|
||||
response: WorkspaceResponse {
|
||||
status,
|
||||
body: body.into(),
|
||||
},
|
||||
requests: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
fn update() -> Value {
|
||||
json!({"ticket":"T-741", "assignment_id":"delegation", "expected_last_event_id":"assigned-event", "operation_key":"report", "tracking":{"status":"done", "waiting_reason":null, "progress":"Alternatives compared", "next_action":"Human decision", "result":"Returned options"}})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assignment_tracking_update_requires_observed_identity_and_excludes_actor_claims() {
|
||||
let client = transport(
|
||||
200,
|
||||
r#"{"assignment_id":"delegation","last_event_id":"reported-event","tracking":{"status":"done"}}"#,
|
||||
);
|
||||
let (meta, tool) = definition(client.clone(), TrackingToolKind::Update)();
|
||||
assert_eq!(meta.name, "TicketAssignmentTrackingUpdate");
|
||||
let validator = jsonschema::validator_for(&meta.input_schema).unwrap();
|
||||
assert!(validator.is_valid(&update()));
|
||||
for field in [
|
||||
"assignment_id",
|
||||
"expected_last_event_id",
|
||||
"operation_key",
|
||||
"tracking",
|
||||
] {
|
||||
let mut input = update();
|
||||
input.as_object_mut().unwrap().remove(field);
|
||||
assert!(!validator.is_valid(&input), "required {field}");
|
||||
assert!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for field in [
|
||||
"author",
|
||||
"role",
|
||||
"runtime_id",
|
||||
"worker_id",
|
||||
"source_context",
|
||||
] {
|
||||
let mut input = update();
|
||||
input[field] = json!("orchestrator");
|
||||
assert!(
|
||||
tool.execute(&input.to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.is_err(),
|
||||
"reject {field}"
|
||||
);
|
||||
}
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
for _ in 0..2 {
|
||||
let output = tool
|
||||
.execute(&update().to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&output.content.unwrap()).unwrap()["last_event_id"],
|
||||
"reported-event"
|
||||
);
|
||||
}
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(
|
||||
requests[0], requests[1],
|
||||
"uncertain retry preserves full input"
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0].path,
|
||||
"/workspaces/workspace/tickets/T-741/assignment-tracking"
|
||||
);
|
||||
let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(body["tracking"]["status"], "done");
|
||||
assert!(body.get("ticket").is_none());
|
||||
assert!(body.get("author").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assignment_tracking_reads_are_bounded_and_carry_history_cursor() {
|
||||
let client = transport(200, r#"{"items":[],"next_event_id":null}"#);
|
||||
let (_, tool) = definition(client.clone(), TrackingToolKind::History)();
|
||||
for limit in [0, 101] {
|
||||
assert!(
|
||||
tool.execute(
|
||||
&json!({"ticket":"T-741", "assignment_id":"a", "limit":limit}).to_string(),
|
||||
ToolExecutionContext::direct()
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
tool.execute(
|
||||
&json!({"ticket":"T-741", "assignment_id":"a", "after_event_id":"event/1", "limit":10})
|
||||
.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
|
||||
.ends_with("?assignment_id=a&limit=10&after_event_id=event%2F1")
|
||||
);
|
||||
drop(requests);
|
||||
let oversized = transport(200, &"x".repeat(1024 * 1024 + 1));
|
||||
let (_, tool) = definition(oversized, TrackingToolKind::Query)();
|
||||
assert!(
|
||||
tool.execute(r#"{"ticket":"T-741"}"#, ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("1 MiB")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retained_assignment_query_encodes_identity_without_changing_workspace_scope() {
|
||||
let client = transport(
|
||||
200,
|
||||
r#"[{"assignment_id":"old/delegation?x","current":false,"release":{"action":"replaced"}}]"#,
|
||||
);
|
||||
let (_, tool) = definition(client.clone(), TrackingToolKind::Query)();
|
||||
let output = tool
|
||||
.execute(
|
||||
r#"{"ticket":"T-741","assignment_id":"old/delegation?x"}"#,
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let content: Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||
assert_eq!(content[0]["current"], false);
|
||||
assert_eq!(content[0]["release"]["action"], "replaced");
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
|
||||
assert_eq!(
|
||||
requests[0].path,
|
||||
"/workspaces/workspace/tickets/T-741/assignment-tracking?assignment_id=old%2Fdelegation%3Fx"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assignment_tracking_requires_backend_and_propagates_authority_rejection() {
|
||||
let client = Arc::new(Transport {
|
||||
workspace: None,
|
||||
response: WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{}".into(),
|
||||
},
|
||||
requests: Mutex::new(Vec::new()),
|
||||
});
|
||||
let (_, tool) = definition(client.clone(), TrackingToolKind::Update)();
|
||||
assert!(
|
||||
tool.execute(&update().to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Backend Workspace identity")
|
||||
);
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
let client = transport(403, r#"{"code":"current_orchestrator_required"}"#);
|
||||
let (_, tool) = definition(client, TrackingToolKind::Update)();
|
||||
assert!(
|
||||
tool.execute(&update().to_string(), ToolExecutionContext::direct())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("403")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_tracking_tools_share_native_wip_and_access_contract() {
|
||||
let client = transport(200, "{}");
|
||||
let definitions = enabled_ticket_definitions(client, TicketFeatureAccess::workflow());
|
||||
for name in TOOL_NAMES {
|
||||
let (meta, _) = definitions
|
||||
.iter()
|
||||
.find(|definition| definition().0.name == *name)
|
||||
.unwrap()();
|
||||
let native = native_ticket_operation(name).unwrap();
|
||||
assert_eq!(native.identity_field, Some("ticket"));
|
||||
assert_eq!(native.mutating, *name == "TicketAssignmentTrackingUpdate");
|
||||
assert!(
|
||||
meta.description.contains("delegation")
|
||||
|| meta.description.contains("assignment_id")
|
||||
);
|
||||
}
|
||||
let reads = TicketFeatureAccess::read_only().tool_names();
|
||||
assert!(reads.contains(&"TicketAssignmentTrackingQuery"));
|
||||
assert!(reads.contains(&"TicketAssignmentTrackingHistory"));
|
||||
assert!(!reads.contains(&"TicketAssignmentTrackingUpdate"));
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ async fn classification_tool_schemas_require_observations_and_reject_actor_claim
|
||||
|
||||
#[tokio::test]
|
||||
async fn classification_reads_forward_id_filters_and_preserve_tag_observations() {
|
||||
let item = json!({"resource_key":"T-740", "title":"Classify", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "tags":[tag()], "matched_fields":[], "linked_objective_keys":[], "relation_count":0, "blocker_count":0, "unresolved_blocker_count":0, "unresolved_review_count":0, "merge_requests":[]});
|
||||
let item = json!({"resource_key":"T-740", "title":"Classify", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "matched_fields":[], "linked_objective_keys":[], "relation_count":0, "blocker_count":0, "unresolved_blocker_count":0, "unresolved_review_count":0, "merge_requests":[]});
|
||||
let client = recording(json!({"items":[item], "page":{"has_more":false, "next_cursor":null}}));
|
||||
let (meta, tool) =
|
||||
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
|
||||
@@ -139,7 +139,7 @@ async fn classification_reads_forward_id_filters_and_preserve_tag_observations()
|
||||
assert_eq!(body[field], input[field]);
|
||||
}
|
||||
}
|
||||
let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]});
|
||||
let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]});
|
||||
let client = recording(detail);
|
||||
let (_, tool) =
|
||||
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
|
||||
|
||||
Reference in New Issue
Block a user