feat: route MR events and authenticated implementation results
This commit is contained in:
@@ -639,7 +639,10 @@ impl TicketFeatureAccess {
|
||||
assignment_public::READ_TOOL_NAMES.contains(name)
|
||||
|| self.authoring
|
||||
|| self.workflow
|
||||
|| (*name == "TicketAssignmentAnswer" && self.thread)
|
||||
|| (matches!(
|
||||
*name,
|
||||
"TicketAssignmentAnswer" | "TicketImplementationResult"
|
||||
) && self.thread)
|
||||
}),
|
||||
)
|
||||
.collect()
|
||||
@@ -665,6 +668,7 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &[
|
||||
"TicketAssignmentTrackingHistory",
|
||||
"TicketAssignmentsQuery",
|
||||
"TicketAssignmentDiagnostics",
|
||||
"TicketImplementationResultQuery",
|
||||
];
|
||||
|
||||
const AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
@@ -676,7 +680,7 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketRelationRemove",
|
||||
];
|
||||
|
||||
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
|
||||
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment", "TicketImplementationResult"];
|
||||
|
||||
#[cfg(test)]
|
||||
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
@@ -701,6 +705,8 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
|
||||
"TicketAssignmentsQuery",
|
||||
"TicketAssignmentDiagnostics",
|
||||
"TicketAssignmentAnswer",
|
||||
"TicketImplementationResult",
|
||||
"TicketImplementationResultQuery",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -727,6 +733,8 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
|
||||
"TicketAssignmentsQuery",
|
||||
"TicketAssignmentDiagnostics",
|
||||
"TicketAssignmentAnswer",
|
||||
"TicketImplementationResult",
|
||||
"TicketImplementationResultQuery",
|
||||
];
|
||||
|
||||
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
|
||||
@@ -1637,6 +1645,18 @@ fn native_ticket_operation(tool_name: &str) -> Option<NativeTicketOperation> {
|
||||
identity_field: Some("ticket"),
|
||||
mutating: false,
|
||||
},
|
||||
"TicketImplementationResult" => NativeTicketOperation {
|
||||
operation: "implementation_result",
|
||||
surface: NativeTicketSurface::Item,
|
||||
identity_field: Some("ticket"),
|
||||
mutating: true,
|
||||
},
|
||||
"TicketImplementationResultQuery" => NativeTicketOperation {
|
||||
operation: "implementation_result_query",
|
||||
surface: NativeTicketSurface::Item,
|
||||
identity_field: Some("ticket"),
|
||||
mutating: false,
|
||||
},
|
||||
"TicketAssignmentAnswer" => NativeTicketOperation {
|
||||
operation: "assignment_answer",
|
||||
surface: NativeTicketSurface::Item,
|
||||
@@ -1765,6 +1785,8 @@ pub(crate) fn enabled_ticket_definitions(
|
||||
assignment_public::PublicAssignmentKind::Assignments,
|
||||
assignment_public::PublicAssignmentKind::Diagnostics,
|
||||
assignment_public::PublicAssignmentKind::Answer,
|
||||
assignment_public::PublicAssignmentKind::ImplementationResult,
|
||||
assignment_public::PublicAssignmentKind::ImplementationResultQuery,
|
||||
] {
|
||||
if allowed.contains(&kind.name()) {
|
||||
definitions.push(assignment_public::definition(client.clone(), kind));
|
||||
|
||||
@@ -22,9 +22,14 @@ pub(super) const TOOL_NAMES: &[&str] = &[
|
||||
"TicketAssignmentsQuery",
|
||||
"TicketAssignmentDiagnostics",
|
||||
"TicketAssignmentAnswer",
|
||||
"TicketImplementationResult",
|
||||
"TicketImplementationResultQuery",
|
||||
];
|
||||
pub(super) const READ_TOOL_NAMES: &[&str] = &[
|
||||
"TicketAssignmentsQuery",
|
||||
"TicketAssignmentDiagnostics",
|
||||
"TicketImplementationResultQuery",
|
||||
];
|
||||
pub(super) const READ_TOOL_NAMES: &[&str] =
|
||||
&["TicketAssignmentsQuery", "TicketAssignmentDiagnostics"];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum PublicAssignmentKind {
|
||||
@@ -33,6 +38,8 @@ pub(super) enum PublicAssignmentKind {
|
||||
Assignments,
|
||||
Diagnostics,
|
||||
Answer,
|
||||
ImplementationResult,
|
||||
ImplementationResultQuery,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
@@ -80,6 +87,20 @@ struct AnswerInput {
|
||||
request: server_api::TicketAssignmentAnswerRequest,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ImplementationResultInput {
|
||||
ticket: String,
|
||||
#[serde(flatten)]
|
||||
request: server_api::TicketImplementationResultRequest,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ImplementationResultQuery {
|
||||
ticket: String,
|
||||
result_id: String,
|
||||
}
|
||||
|
||||
impl PublicAssignmentKind {
|
||||
pub(super) fn name(self) -> &'static str {
|
||||
match self {
|
||||
@@ -88,6 +109,8 @@ impl PublicAssignmentKind {
|
||||
Self::Assignments => TOOL_NAMES[2],
|
||||
Self::Diagnostics => TOOL_NAMES[3],
|
||||
Self::Answer => TOOL_NAMES[4],
|
||||
Self::ImplementationResult => TOOL_NAMES[5],
|
||||
Self::ImplementationResultQuery => TOOL_NAMES[6],
|
||||
}
|
||||
}
|
||||
fn schema(self) -> Value {
|
||||
@@ -109,6 +132,10 @@ impl PublicAssignmentKind {
|
||||
Self::Assignments => json!(schemars::schema_for!(AssignmentsInput)),
|
||||
Self::Diagnostics => json!(schemars::schema_for!(DiagnosticsInput)),
|
||||
Self::Answer => json!(schemars::schema_for!(AnswerInput)),
|
||||
Self::ImplementationResult => json!(schemars::schema_for!(ImplementationResultInput)),
|
||||
Self::ImplementationResultQuery => {
|
||||
json!(schemars::schema_for!(ImplementationResultQuery))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,6 +156,12 @@ pub(super) fn description(name: &str) -> &'static str {
|
||||
"TicketAssignmentAnswer" => {
|
||||
"Append continuing input to the observed current waiting/inprogress assignment with assignment_id, expected_last_event_id, stable operation_key and bounded answer body. Query current tracking first. Answers continue the same row without automatic inprogress; done requires explicit new Assign/reissue instead. Backend authenticates/fences source, current assignment and event CAS, then saves input/audit/exact receipt atomically and notifies committed input. Exact retry retains full input/key; stale/cleared/replaced conflicts require rereading, and unknown outcome must not be retried as a fresh operation. No model actor/role claims, new assignment, case closure or Worker start."
|
||||
}
|
||||
"TicketImplementationResult" => {
|
||||
"Save an explicit handoff, result or blocker for the current implementation assignment and its original Orchestrator delegation. Read ShowTicket first for assignment identities. Backend authenticates the implementation Worker and fences both assignments; this is not AssignmentAnswer or a handling update. Optional MR/source identifies retained evidence, never approval. Retry an unknown response using exactly the same operation_key and full input; changed input with the same key is rejected. Result and durable notification source commit together. No case closure, new Assign, Worker stop or integration occurs."
|
||||
}
|
||||
"TicketImplementationResultQuery" => {
|
||||
"Read one saved implementation result by Ticket and result_id from a result notification/receipt. Historical evidence is not a new request or approval; reread current delegation and MR before acting."
|
||||
}
|
||||
_ => unreachable!("unknown public assignment tool"),
|
||||
}
|
||||
}
|
||||
@@ -245,6 +278,47 @@ impl Tool for PublicAssignmentTool {
|
||||
query: input.query,
|
||||
}
|
||||
}
|
||||
PublicAssignmentKind::ImplementationResult => {
|
||||
let input: ImplementationResultInput =
|
||||
serde_json::from_str(input).map_err(parse)?;
|
||||
identity("ticket", &input.ticket)?;
|
||||
for (name, value) in [
|
||||
("assignment_id", &input.request.assignment_id),
|
||||
(
|
||||
"orchestrator_assignment_id",
|
||||
&input.request.orchestrator_assignment_id,
|
||||
),
|
||||
("operation_key", &input.request.operation_key),
|
||||
] {
|
||||
identity(name, value)?;
|
||||
}
|
||||
if input.request.body.trim().is_empty()
|
||||
|| input.request.body.len() > 16384
|
||||
|| input.request.body.contains('\0')
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"Result body must be nonblank, NUL-free and at most 16384 bytes".into(),
|
||||
));
|
||||
}
|
||||
if let Some(mr) = &input.request.merge_request {
|
||||
identity("merge_request_id", &mr.merge_request_id)?;
|
||||
identity("subject_ref", &mr.subject_ref)?;
|
||||
}
|
||||
WorkspaceServerOperation::TicketImplementationResult {
|
||||
ticket_id: input.ticket,
|
||||
request: input.request,
|
||||
}
|
||||
}
|
||||
PublicAssignmentKind::ImplementationResultQuery => {
|
||||
let input: ImplementationResultQuery =
|
||||
serde_json::from_str(input).map_err(parse)?;
|
||||
identity("ticket", &input.ticket)?;
|
||||
identity("result_id", &input.result_id)?;
|
||||
WorkspaceServerOperation::TicketImplementationResultGet {
|
||||
ticket_id: input.ticket,
|
||||
result_id: input.result_id,
|
||||
}
|
||||
}
|
||||
PublicAssignmentKind::Answer => {
|
||||
let input: AnswerInput = serde_json::from_str(input).map_err(parse)?;
|
||||
identity("ticket", &input.ticket)?;
|
||||
@@ -320,6 +394,13 @@ impl Tool for PublicAssignmentTool {
|
||||
}
|
||||
serde_json::to_value(result)
|
||||
}
|
||||
PublicAssignmentKind::ImplementationResult
|
||||
| PublicAssignmentKind::ImplementationResultQuery => serde_json::to_value(
|
||||
serde_json::from_str::<server_api::TicketImplementationResultReceipt>(
|
||||
&response.body,
|
||||
)
|
||||
.map_err(decode)?,
|
||||
),
|
||||
PublicAssignmentKind::Answer => serde_json::to_value(
|
||||
serde_json::from_str::<server_api::TicketAssignmentAnswerResponse>(&response.body)
|
||||
.map_err(decode)?,
|
||||
|
||||
@@ -57,6 +57,16 @@ fn cases() -> Vec<(PublicAssignmentKind, Value, Value)> {
|
||||
json!({"ticket":"T-743", "assignment_id":"delegation", "after_input_event_id":"ticket-event:7", "after_start_operation_id":"start/7", "limit":10}),
|
||||
json!({"assignment_id":"delegation", "current":true, "input_origin_known":true, "actionable":true, "inputs":[], "next_input_event_id":null, "starts":[], "next_start_operation_id":null}),
|
||||
),
|
||||
(
|
||||
PublicAssignmentKind::ImplementationResult,
|
||||
json!({"ticket":"T-743","assignment_id":"implementation","orchestrator_assignment_id":"delegation","operation_key":"result-1","kind":"result","body":"Research without an MR","merge_request":null}),
|
||||
json!({"result_id":"result-id","ticket_id":"T-743","created_at":"now","request":{"assignment_id":"implementation","orchestrator_assignment_id":"delegation","operation_key":"result-1","kind":"result","body":"Research without an MR","merge_request":null}}),
|
||||
),
|
||||
(
|
||||
PublicAssignmentKind::ImplementationResultQuery,
|
||||
json!({"ticket":"T-743","result_id":"result-id"}),
|
||||
json!({"result_id":"result-id","ticket_id":"T-743","created_at":"now","request":{"assignment_id":"implementation","orchestrator_assignment_id":"delegation","operation_key":"result-1","kind":"result","body":"Research without an MR","merge_request":null}}),
|
||||
),
|
||||
(
|
||||
PublicAssignmentKind::Answer,
|
||||
json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"waiting", "operation_key":"answer-1", "body":"Proceed with option A"}),
|
||||
|
||||
@@ -754,6 +754,14 @@ pub enum WorkspaceServerOperation {
|
||||
ticket_id: String,
|
||||
request: server_api::TicketAssignmentAnswerRequest,
|
||||
},
|
||||
TicketImplementationResult {
|
||||
ticket_id: String,
|
||||
request: server_api::TicketImplementationResultRequest,
|
||||
},
|
||||
TicketImplementationResultGet {
|
||||
ticket_id: String,
|
||||
result_id: String,
|
||||
},
|
||||
WorkerControlList,
|
||||
WorkerControlSpawn(server_api::CreateWorkspaceWorkerRequest),
|
||||
WorkerControlInput {
|
||||
@@ -925,6 +933,23 @@ fn workspace_server_operation_request(
|
||||
&request,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::TicketImplementationResult { ticket_id, request } => {
|
||||
workspace_server_json_request(
|
||||
format!(
|
||||
"/workspaces/{workspace_id}/tickets/{}/implementation-results",
|
||||
encode_workspace_path_segment(&ticket_id)
|
||||
),
|
||||
&request,
|
||||
)
|
||||
}
|
||||
WorkspaceServerOperation::TicketImplementationResultGet {
|
||||
ticket_id,
|
||||
result_id,
|
||||
} => Ok(WorkspaceRequest::get(format!(
|
||||
"/workspaces/{workspace_id}/tickets/{}/implementation-results/{}",
|
||||
encode_workspace_path_segment(&ticket_id),
|
||||
encode_workspace_path_segment(&result_id)
|
||||
))),
|
||||
WorkspaceServerOperation::WorkerControlList => Ok(WorkspaceRequest::get(format!(
|
||||
"{base}/worker-control/workers"
|
||||
))),
|
||||
|
||||
Reference in New Issue
Block a user