feat(ticket): expose case tags and independent assignment handling
This commit is contained in:
@@ -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"
|
||||
))),
|
||||
|
||||
Reference in New Issue
Block a user