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

This commit is contained in:
2026-10-12 07:33:11 +09:00
parent 56795fb92c
commit 9b0d42fdbc
79 changed files with 9326 additions and 713 deletions
+122 -31
View File
@@ -4,6 +4,7 @@
//! module binds an authority-scoped Workspace client, declares the built-in
//! feature, and contributes those tools through the normal registry path.
mod assignment_public;
mod assignment_tracking;
mod tags;
@@ -134,7 +135,14 @@ enum WorkspaceTicketSort {
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct WorkspaceQueryTicketInput {
/// Exact current closed-case reason; reopened/history-only reasons do not match.
closure_reason: Option<ticket::TicketClosureReason>,
/// Exact current assigned principal; retained relationships do not match.
assigned_principal: Option<ticket::TicketAssignmentPrincipalSnapshot>,
/// Explicit self-reported handling, never inferred from Worker/MR/delivery.
handling_status: Option<ticket::TicketAssignmentTrackingStatus>,
/// Full-text match over Ticket title, item body, and bounded thread excerpts.
query: Option<String>,
/// Exact case states (open or closed). Empty means every state.
@@ -444,6 +452,9 @@ fn workspace_ticket_decision_definition(
fn workspace_ticket_tool_description(name: &str) -> String {
match name {
"TicketCaseUpdate" => TICKET_CASE_UPDATE_DESCRIPTION.into(),
name if assignment_public::TOOL_NAMES.contains(&name) => {
assignment_public::description(name).into()
}
name if assignment_tracking::TOOL_NAMES.contains(&name) => {
assignment_tracking::description(name).into()
}
@@ -586,6 +597,17 @@ impl TicketFeatureAccess {
.copied()
.filter(|name| *name != "TicketAssignmentTrackingUpdate" || self.workflow),
)
.chain(
assignment_public::TOOL_NAMES
.iter()
.copied()
.filter(|name| {
assignment_public::READ_TOOL_NAMES.contains(name)
|| self.authoring
|| self.workflow
|| (*name == "TicketAssignmentAnswer" && self.thread)
}),
)
.collect()
}
@@ -597,7 +619,6 @@ impl TicketFeatureAccess {
|| (name == "TicketCaseUpdate" && (self.authoring || self.thread || self.workflow))
|| (self.authoring && AUTHORING_TOOL_NAMES.contains(&name))
|| (self.thread && THREAD_TOOL_NAMES.contains(&name))
|| (self.intake && INTAKE_TOOL_NAMES.contains(&name))
|| (self.workflow && WORKFLOW_ADDITIONAL_TOOL_NAMES.contains(&name))
}
}
@@ -608,13 +629,13 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &[
"WorkspaceTagList",
"TicketAssignmentTrackingQuery",
"TicketAssignmentTrackingHistory",
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
];
const AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate",
"TicketEditItem",
"TicketMarkReady",
"TicketQueue",
"TicketCaseUpdate",
"TicketRelationRecord",
"TicketRelationRemove",
@@ -622,8 +643,6 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[
const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"];
const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"];
#[cfg(test)]
const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketCreate",
@@ -631,8 +650,6 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"QueryTicket",
"ShowTicket",
"TicketComment",
"TicketMarkReady",
"TicketQueue",
"TicketCaseUpdate",
"TicketRelationRecord",
"TicketRelationRemove",
@@ -643,6 +660,11 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[
"TicketTagsSet",
"TicketAssignmentTrackingQuery",
"TicketAssignmentTrackingHistory",
"TicketOrchestratorAssign",
"TicketOrchestratorClear",
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketAssignmentAnswer",
];
#[cfg(test)]
@@ -664,6 +686,11 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[
"TicketAssignmentTrackingQuery",
"TicketAssignmentTrackingHistory",
"TicketAssignmentTrackingUpdate",
"TicketOrchestratorAssign",
"TicketOrchestratorClear",
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketAssignmentAnswer",
];
const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[
@@ -1518,20 +1545,32 @@ fn native_ticket_operation(tool_name: &str) -> Option<NativeTicketOperation> {
identity_field: Some("ticket"),
mutating: true,
},
"TicketMarkReady" => NativeTicketOperation {
operation: "mark_ready",
"TicketOrchestratorAssign" => NativeTicketOperation {
operation: "orchestrator_assign",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: true,
},
"TicketIntakeReady" => NativeTicketOperation {
operation: "intake_ready",
"TicketOrchestratorClear" => NativeTicketOperation {
operation: "orchestrator_clear",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: true,
},
"TicketQueue" => NativeTicketOperation {
operation: "queue",
"TicketAssignmentsQuery" => NativeTicketOperation {
operation: "assignments_query",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: false,
},
"TicketAssignmentDiagnostics" => NativeTicketOperation {
operation: "assignment_diagnostics",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: false,
},
"TicketAssignmentAnswer" => NativeTicketOperation {
operation: "assignment_answer",
surface: NativeTicketSurface::Item,
identity_field: Some("ticket"),
mutating: true,
@@ -1652,6 +1691,17 @@ pub(crate) fn enabled_ticket_definitions(
definitions.push(assignment_tracking::definition(client.clone(), kind));
}
}
for kind in [
assignment_public::PublicAssignmentKind::Assign,
assignment_public::PublicAssignmentKind::Clear,
assignment_public::PublicAssignmentKind::Assignments,
assignment_public::PublicAssignmentKind::Diagnostics,
assignment_public::PublicAssignmentKind::Answer,
] {
if allowed.contains(&kind.name()) {
definitions.push(assignment_public::definition(client.clone(), kind));
}
}
definitions
}
@@ -2134,15 +2184,6 @@ fn record_affected_ticket_mutations(
{
affected.insert(target.to_string());
}
if let Some(queued) = response.get("queued_tickets").and_then(Value::as_array) {
affected.extend(
queued
.iter()
.filter_map(Value::as_str)
.filter(|reference| is_ticket_route_reference(reference))
.map(ToOwned::to_owned),
);
}
for reference in affected.difference(&subject_references) {
record_ticket_observation(observations, Some(reference), &Value::Null, true);
}
@@ -2966,7 +3007,7 @@ mod tests {
}
#[test]
fn related_ticket_mutations_invalidate_target_and_queue_observations() {
fn related_ticket_mutation_invalidates_inverse_target_but_not_unrelated_tickets() {
let observations = Arc::new(Mutex::new(TicketObservationState::default()));
for ticket in ["T-42", "T-43", "T-44"] {
record_ticket_observation(
@@ -2983,22 +3024,21 @@ mod tests {
observations: Arc::clone(&observations),
};
let target_v1 = resolver.resolve("T-43").unwrap().object.validator;
let queued_v1 = resolver.resolve("T-44").unwrap().object.validator;
let unrelated_v1 = resolver.resolve("T-44").unwrap().object.validator;
record_affected_ticket_mutations(
&observations,
"T-42",
&json!({
"ticket": "T-42",
"target": "T-43",
"queued_tickets": ["T-42", "T-44"]
"target": "T-43"
}),
);
assert_ne!(
target_v1,
resolver.resolve("T-43").unwrap().object.validator
);
assert_ne!(
queued_v1,
assert_eq!(
unrelated_v1,
resolver.resolve("T-44").unwrap().object.validator
);
}
@@ -3168,7 +3208,7 @@ mod tests {
.collect::<Vec<_>>();
assert!(workspace_tools.contains(&"TicketCreate"));
assert!(workspace_tools.contains(&"TicketEditItem"));
assert!(workspace_tools.contains(&"TicketQueue"));
assert!(!workspace_tools.contains(&"TicketQueue"));
assert!(workspace_tools.contains(&"TicketCaseUpdate"));
let orchestration = workspace_feature(TicketFeatureAccess::workflow());
@@ -3205,6 +3245,57 @@ mod tests {
assert!(!review_tools.contains(&"TicketCaseUpdate"));
}
#[test]
fn normal_and_wip_catalogs_exclude_retired_ticket_workflow_operations() {
let client: Arc<dyn WorkspaceClient> = Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://unused"),
);
let retired = [
"TicketMarkReady",
"TicketIntakeReady",
"TicketQueue",
"TicketWorkflowState",
"CompleteTicket",
"TicketClose",
];
for access in [
TicketFeatureAccess::workspace_authoring(),
TicketFeatureAccess::intake(),
TicketFeatureAccess::workflow(),
TicketFeatureAccess::work_report(),
TicketFeatureAccess::read_only(),
TicketFeatureAccess {
authoring: true,
thread: true,
intake: true,
workflow: true,
},
] {
let normal = enabled_ticket_definitions(client.clone(), access);
let native = native_ticket_tools(client.clone(), access).unwrap();
for name in retired {
assert!(
normal.iter().all(|definition| definition().0.name != name),
"{name}"
);
assert!(native.iter().all(|tool| tool.name != name), "{name}");
assert!(native_ticket_operation(name).is_none(), "{name}");
}
if access.authoring || access.thread || access.workflow {
assert!(
normal
.iter()
.any(|definition| definition().0.name == "TicketCaseUpdate")
);
assert!(
native
.iter()
.any(|tool| tool.projection.operation == "case_update")
);
}
}
}
#[test]
fn read_only_installation_does_not_expose_mutating_tools() {
let mut pending_tools = Vec::new();
@@ -3248,8 +3339,8 @@ mod tests {
assert_eq!(installed, WORKSPACE_AUTHORING_TOOL_NAMES);
assert!(installed.iter().any(|tool| *tool == "TicketCreate"));
assert!(installed.iter().any(|tool| *tool == "TicketEditItem"));
assert!(installed.iter().any(|tool| *tool == "TicketQueue"));
assert!(installed.iter().any(|tool| *tool == "TicketMarkReady"));
assert!(!installed.iter().any(|tool| *tool == "TicketQueue"));
assert!(!installed.iter().any(|tool| *tool == "TicketMarkReady"));
assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady"));
assert!(installed.iter().any(|tool| *tool == "TicketCaseUpdate"));
assert!(
@@ -0,0 +1,340 @@
//! Public delegation operations over shared host-bound typed Workspace forwarding.
//! Source/actor authorization and assignment/receipt authority remain Backend-owned.
#[cfg(test)]
mod tests;
use super::*;
use crate::worker::WorkspaceServerOperation;
pub(super) const TOOL_NAMES: &[&str] = &[
"TicketOrchestratorAssign",
"TicketOrchestratorClear",
"TicketAssignmentsQuery",
"TicketAssignmentDiagnostics",
"TicketAssignmentAnswer",
];
pub(super) const READ_TOOL_NAMES: &[&str] =
&["TicketAssignmentsQuery", "TicketAssignmentDiagnostics"];
#[derive(Clone, Copy)]
pub(super) enum PublicAssignmentKind {
Assign,
Clear,
Assignments,
Diagnostics,
Answer,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct AssignInput {
ticket: String,
/// Target principal, not the authenticated actor. Backend validates role/principal pairing.
principal: server_api::TicketAssignmentPrincipal,
/// Observed current assignment ID, or explicitly null for an unassigned Ticket.
#[serde(deserialize_with = "observed_assignment")]
expected_assignment_id: Option<String>,
/// Stable identity of this Assign/reissue; exact retries keep the same full input.
operation_id: String,
}
fn observed_assignment<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
Option::<String>::deserialize(d)
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ClearInput {
ticket: String,
/// Observed exact current delegation to release, not Worker identity/status.
assignment_id: String,
operation_id: String,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct AssignmentsInput {
ticket: String,
#[serde(flatten)]
query: server_api::TicketAssignmentsQuery,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct DiagnosticsInput {
ticket: String,
#[serde(flatten)]
query: server_api::TicketAssignmentDiagnosticsQuery,
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct AnswerInput {
ticket: String,
#[serde(flatten)]
request: server_api::TicketAssignmentAnswerRequest,
}
impl PublicAssignmentKind {
pub(super) fn name(self) -> &'static str {
match self {
Self::Assign => TOOL_NAMES[0],
Self::Clear => TOOL_NAMES[1],
Self::Assignments => TOOL_NAMES[2],
Self::Diagnostics => TOOL_NAMES[3],
Self::Answer => TOOL_NAMES[4],
}
}
fn schema(self) -> Value {
match self {
Self::Assign => {
let mut schema = json!(schemars::schema_for!(AssignInput));
let required = schema["required"]
.as_array_mut()
.expect("Assign object schema");
if !required
.iter()
.any(|field| field == "expected_assignment_id")
{
required.push(json!("expected_assignment_id"));
}
schema
}
Self::Clear => json!(schemars::schema_for!(ClearInput)),
Self::Assignments => json!(schemars::schema_for!(AssignmentsInput)),
Self::Diagnostics => json!(schemars::schema_for!(DiagnosticsInput)),
Self::Answer => json!(schemars::schema_for!(AnswerInput)),
}
}
}
pub(super) fn description(name: &str) -> &'static str {
match name {
"TicketOrchestratorAssign" => {
"Explicitly delegate or reissue this Ticket to a target Orchestrator principal using the existing role-assignment API. Query current assignment first; provide observed expected_assignment_id (explicit null only when unassigned), target principal and stable operation_id. Assign persists initial waiting without ready/Queue. Explicit reissue after done creates a new row even for the same principal and preserves the old result. Exact retry keeps the same operation_id and full input; never automatically generate a new key after conflict or unknown outcome. Reread and reconcile stale responsibility. Saved Assign and notification failure are separate outcomes, not a reason to reassign. Backend authenticates the actor and validates role/principal; target principal is not an authority claim. This does not start/stop Workers, close the case, or authorize review/merge."
}
"TicketOrchestratorClear" => {
"Release the observed current Orchestrator delegation using assignment_id and stable operation_id. Query first; exact retry retains the same full input/key, stale conflict requires rereading, and unknown outcome is not automatic success/failure or a new operation. Preserve last handling/result and release history; do not turn release into done, Worker stop, work transfer or resource cleanup. Done/closed do not require clear. Actor authority is authenticated by Backend, never supplied by the model."
}
"TicketAssignmentsQuery" => {
"Read a bounded page of retained Orchestrator assignment identities for this Ticket, role/principal, handling and independent release/replacement history. Use after_assignment_id from the returned next_assignment_id, limit 1..100. Historical done/results are not new work or execution authority. Use current tracking query before a new mutation; a replay receipt is not a fresh observation."
}
"TicketAssignmentDiagnostics" => {
"Read bounded sanitized existing delivery/acceptance/start evidence for an assignment_id, with independent after_input_event_id and after_start_operation_id cursors (limit 1..100). Saved delegation, notification persistence, Runtime acceptance and actual processing are distinct; absent acknowledgement may mean unknown outcome, not failed Assign. History/unknown are not pending work. No rendered content or binding credentials are exposed. Never infer or update self-reported handling from these diagnostics, Worker liveness or MR state."
}
"TicketAssignmentAnswer" => {
"Append continuing input to the observed current waiting/inprogress assignment with assignment_id, expected_last_event_id, stable operation_key and bounded answer body. Query current tracking first. Answers continue the same row without automatic inprogress; done requires explicit new Assign/reissue instead. Backend authenticates/fences source, current assignment and event CAS, then saves input/audit/exact receipt atomically and notifies committed input. Exact retry retains full input/key; stale/cleared/replaced conflicts require rereading, and unknown outcome must not be retried as a fresh operation. No model actor/role claims, new assignment, case closure or Worker start."
}
_ => unreachable!("unknown public assignment tool"),
}
}
fn identity(name: &str, value: &str) -> Result<(), ToolError> {
if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
return Err(ToolError::InvalidArgument(format!(
"{name} must be nonempty, control-free and at most 1024 bytes"
)));
}
Ok(())
}
fn page(limit: Option<usize>) -> Result<usize, ToolError> {
let limit = limit.unwrap_or(100);
if !(1..=100).contains(&limit) {
return Err(ToolError::InvalidArgument(
"limit must be between 1 and 100".into(),
));
}
Ok(limit)
}
fn optional_identity(name: &str, value: &Option<String>) -> Result<(), ToolError> {
value
.as_deref()
.map(|value| identity(name, value))
.transpose()
.map(|_| ())
}
struct PublicAssignmentTool {
client: Arc<dyn WorkspaceClient>,
kind: PublicAssignmentKind,
}
#[async_trait]
impl Tool for PublicAssignmentTool {
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
self.client
.workspace_id()
.filter(|id| !id.trim().is_empty())
.ok_or_else(|| {
ToolError::InvalidArgument(
"Assignment tools require Backend Workspace identity; no local fallback".into(),
)
})?;
let parse = |error: serde_json::Error| ToolError::InvalidArgument(error.to_string());
let operation = match self.kind {
PublicAssignmentKind::Assign => {
let input: AssignInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
identity("operation_id", &input.operation_id)?;
optional_identity("expected_assignment_id", &input.expected_assignment_id)?;
match &input.principal {
server_api::TicketAssignmentPrincipal::User { account_id } => {
identity("account_id", account_id)?
}
server_api::TicketAssignmentPrincipal::Worker {
runtime_id,
worker_id,
} => {
identity("runtime_id", runtime_id)?;
identity("worker_id", worker_id)?;
}
server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
identity("agent_key", agent_key)?
}
}
WorkspaceServerOperation::TicketAssignmentSet {
ticket_id: input.ticket,
role: server_api::TicketAssignmentRole::Orchestrator,
request: server_api::SetTicketRoleAssignmentRequest {
orchestrator_assignment_id: None,
operation_id: input.operation_id,
principal: input.principal,
expected_assignment_id: input.expected_assignment_id,
workdir_bindings: vec![],
},
}
}
PublicAssignmentKind::Clear => {
let input: ClearInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
identity("assignment_id", &input.assignment_id)?;
identity("operation_id", &input.operation_id)?;
WorkspaceServerOperation::TicketAssignmentClear {
ticket_id: input.ticket,
role: server_api::TicketAssignmentRole::Orchestrator,
query: server_api::ClearTicketRoleAssignmentQuery {
operation_id: Some(input.operation_id),
assignment_id: Some(input.assignment_id),
},
}
}
PublicAssignmentKind::Assignments => {
let mut input: AssignmentsInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
optional_identity("after_assignment_id", &input.query.after_assignment_id)?;
input.query.limit = Some(page(input.query.limit)?);
WorkspaceServerOperation::TicketAssignmentsPage {
ticket_id: input.ticket,
query: input.query,
}
}
PublicAssignmentKind::Diagnostics => {
let mut input: DiagnosticsInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
identity("assignment_id", &input.query.assignment_id)?;
optional_identity("after_input_event_id", &input.query.after_input_event_id)?;
optional_identity(
"after_start_operation_id",
&input.query.after_start_operation_id,
)?;
input.query.limit = Some(page(input.query.limit)?);
WorkspaceServerOperation::TicketAssignmentDiagnostics {
ticket_id: input.ticket,
query: input.query,
}
}
PublicAssignmentKind::Answer => {
let input: AnswerInput = serde_json::from_str(input).map_err(parse)?;
identity("ticket", &input.ticket)?;
for (name, value) in [
("assignment_id", &input.request.0.assignment_id),
(
"expected_last_event_id",
&input.request.0.expected_last_event_id,
),
("operation_key", &input.request.0.operation_key),
] {
identity(name, value)?;
}
let body = &input.request.0.body;
if body.trim().is_empty() || body.len() > 16384 || body.contains('\0') {
return Err(ToolError::InvalidArgument(
"Answer body must be nonempty, at most 16384 bytes and without NUL".into(),
));
}
WorkspaceServerOperation::TicketAssignmentAnswer {
ticket_id: input.ticket,
request: input.request,
}
}
};
let response = self
.client
.execute_server_operation(operation)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Assignment request failed with HTTP status {}: {}",
response.status,
response.body.chars().take(1024).collect::<String>()
)));
}
if response.body.len() > 1024 * 1024 {
return Err(ToolError::ExecutionFailed(
"Assignment response exceeds 1 MiB; request a smaller page".into(),
));
}
// Decode the actual public DTO and serialize only its declared fields; no raw
// backend payload/credentials or alternative result authority reaches the model.
let decode = |error: serde_json::Error| {
ToolError::ExecutionFailed(format!(
"Invalid Backend assignment response (outcome unknown after dispatch): {error}"
))
};
let content = match self.kind {
PublicAssignmentKind::Assign | PublicAssignmentKind::Clear => serde_json::to_value(
serde_json::from_str::<server_api::TicketRoleAssignmentMutationResponse>(
&response.body,
)
.map_err(decode)?,
),
PublicAssignmentKind::Assignments => {
let result: server_api::TicketAssignmentsPage =
serde_json::from_str(&response.body).map_err(decode)?;
if result.0.items.len() > 100 {
return Err(ToolError::ExecutionFailed(
"Assignment page exceeds 100 items".into(),
));
}
serde_json::to_value(result)
}
PublicAssignmentKind::Diagnostics => {
let result: server_api::TicketAssignmentDiagnosticsPage =
serde_json::from_str(&response.body).map_err(decode)?;
if result.inputs.len() > 100 || result.starts.len() > 100 {
return Err(ToolError::ExecutionFailed(
"Diagnostic page exceeds 100 items".into(),
));
}
serde_json::to_value(result)
}
PublicAssignmentKind::Answer => serde_json::to_value(
serde_json::from_str::<server_api::TicketAssignmentAnswerResponse>(&response.body)
.map_err(decode)?,
),
}
.map_err(|error| ToolError::Internal(error.to_string()))?;
Ok(ToolOutput {
summary: self.kind.name().into(),
content: Some(content.to_string()),
attachments: vec![],
})
}
}
pub(super) fn definition(
client: Arc<dyn WorkspaceClient>,
kind: PublicAssignmentKind,
) -> ToolDefinition {
Arc::new(move || {
(
ToolMeta::new(kind.name())
.description(description(kind.name()))
.input_schema(kind.schema()),
Arc::new(PublicAssignmentTool {
client: client.clone(),
kind,
}) as Arc<dyn Tool>,
)
})
}
@@ -0,0 +1,598 @@
use super::*;
use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
#[derive(Debug)]
struct Transport {
requests: Mutex<Vec<WorkspaceRequest>>,
response: Result<WorkspaceResponse, String>,
available: bool,
}
impl WorkspaceClient for Transport {
fn workspace_id(&self) -> Option<&str> {
self.available.then_some("workspace")
}
fn kind(&self) -> &str {
"assignment-test"
}
fn is_available(&self) -> bool {
self.available
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
self.response.clone().map_err(WorkspaceClientError::Request)
}
}
fn transport(status: u16, body: Value) -> Arc<Transport> {
Arc::new(Transport {
requests: Mutex::new(vec![]),
available: true,
response: Ok(WorkspaceResponse {
status,
body: body.to_string(),
}),
})
}
fn cases() -> Vec<(PublicAssignmentKind, Value, Value)> {
vec![
(
PublicAssignmentKind::Assign,
json!({"ticket":"T-743", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}, "expected_assignment_id":null, "operation_id":"assign-1"}),
json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}),
),
(
PublicAssignmentKind::Clear,
json!({"ticket":"T-743", "assignment_id":"delegation", "operation_id":"clear-1"}),
json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}),
),
(
PublicAssignmentKind::Assignments,
json!({"ticket":"T-743", "after_assignment_id":"old/delegation?", "limit":10}),
json!({"items":[], "next_assignment_id":null}),
),
(
PublicAssignmentKind::Diagnostics,
json!({"ticket":"T-743", "assignment_id":"delegation", "after_input_event_id":"ticket-event:7", "after_start_operation_id":"start/7", "limit":10}),
json!({"assignment_id":"delegation", "current":true, "input_origin_known":true, "actionable":true, "inputs":[], "next_input_event_id":null, "starts":[], "next_start_operation_id":null}),
),
(
PublicAssignmentKind::Answer,
json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"waiting", "operation_key":"answer-1", "body":"Proceed with option A"}),
json!({"assignment_id":"delegation", "input_event_id":"ticket-event:8", "event_index":8}),
),
]
}
#[tokio::test]
async fn public_assignment_schemas_require_observations_and_exclude_model_actor_claims() {
for (kind, input, response) in cases() {
let client = transport(200, response);
let (meta, tool) = definition(client.clone(), kind)();
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
assert!(
schema.is_valid(&input),
"{}: {}",
meta.name,
meta.input_schema
);
for field in meta.input_schema["required"]
.as_array()
.into_iter()
.flatten()
{
let mut missing = input.clone();
missing
.as_object_mut()
.unwrap()
.remove(field.as_str().unwrap());
assert!(!schema.is_valid(&missing), "{} missing {field}", meta.name);
assert!(matches!(
tool.execute(&missing.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
for actor_claim in [
"actor",
"author",
"role",
"runtime_id",
"worker_id",
"authentication_binding_id",
] {
let mut forged = input.clone();
forged[actor_claim] = json!("authority");
assert!(
!schema.is_valid(&forged),
"{} accepts {actor_claim}",
meta.name
);
assert!(matches!(
tool.execute(&forged.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
assert!(client.requests.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn assign_reissue_and_clear_forward_observed_identity_and_exact_retry_without_new_key() {
for (kind, mut input, response) in cases().into_iter().filter(|(kind, _, _)| {
matches!(
kind,
PublicAssignmentKind::Assign | PublicAssignmentKind::Clear
)
}) {
if matches!(kind, PublicAssignmentKind::Assign) {
input["expected_assignment_id"] = json!("previous-done");
}
let client = transport(200, response);
let (_, tool) = definition(client.clone(), kind)();
for _ in 0..2 {
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
}
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(requests[0], requests[1]);
assert!(
requests[0]
.path
.starts_with("/api/w/workspace/tickets/T-743/assignments/orchestrator")
);
match kind {
PublicAssignmentKind::Assign => {
assert_eq!(requests[0].method, WorkspaceRequestMethod::Put);
let body: Value =
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["expected_assignment_id"], "previous-done");
assert_eq!(body["operation_id"], input["operation_id"]);
assert_eq!(body["principal"], input["principal"]);
assert!(body.get("actor").is_none());
assert!(body.get("orchestrator_assignment_id").is_none());
assert_eq!(body["workdir_bindings"], json!([]));
}
PublicAssignmentKind::Clear => {
assert_eq!(requests[0].method, WorkspaceRequestMethod::Delete);
assert!(requests[0].path.contains("assignment_id=delegation"));
assert!(requests[0].path.contains("operation_id=clear-1"));
assert!(requests[0].body.is_none());
}
_ => unreachable!(),
}
}
}
#[tokio::test]
async fn continuing_answer_forwards_same_assignment_event_cas_and_saved_receipt() {
let (kind, input, response) = cases().pop().unwrap();
let client = transport(200, response.clone());
let (_, tool) = definition(client.clone(), kind)();
for _ in 0..2 {
let output = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(output.content.as_deref().unwrap()).unwrap(),
response
);
}
let requests = client.requests.lock().unwrap();
assert_eq!(requests[0], requests[1]);
assert_eq!(
requests[0].path,
"/workspaces/workspace/tickets/T-743/assignment-answer"
);
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
let mut expected = input;
expected.as_object_mut().unwrap().remove("ticket");
assert_eq!(body, expected);
assert!(body.get("tracking").is_none());
}
#[tokio::test]
async fn assignment_pages_forward_independent_encoded_cursors_and_reject_unbounded_input() {
for (kind, input, response) in cases().into_iter().filter(|(kind, _, _)| {
matches!(
kind,
PublicAssignmentKind::Assignments | PublicAssignmentKind::Diagnostics
)
}) {
let client = transport(200, response);
let (_, tool) = definition(client.clone(), kind)();
for limit in [0, 101] {
let mut invalid = input.clone();
invalid["limit"] = json!(limit);
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, WorkspaceRequestMethod::Get);
assert!(requests[0].path.contains("limit=10"));
match kind {
PublicAssignmentKind::Assignments => assert!(
requests[0]
.path
.contains("after_assignment_id=old%2Fdelegation%3F")
),
PublicAssignmentKind::Diagnostics => {
assert!(
requests[0]
.path
.contains("after_input_event_id=ticket-event%3A7")
);
assert!(
requests[0]
.path
.contains("after_start_operation_id=start%2F7")
);
}
_ => unreachable!(),
}
}
}
#[tokio::test]
async fn public_assignment_rejections_and_unknown_outcomes_never_auto_retry_or_replace_identity() {
for (kind, input, _) in cases() {
for response in [
Ok(WorkspaceResponse {
status: 403,
body: "permission denied".into(),
}),
Ok(WorkspaceResponse {
status: 409,
body: "stale assignment or operation fingerprint mismatch".into(),
}),
Ok(WorkspaceResponse {
status: 200,
body: "invalid response after dispatch".into(),
}),
Err("response lost; outcome unknown".into()),
] {
let client = Arc::new(Transport {
requests: Mutex::new(vec![]),
available: true,
response,
});
let (_, tool) = definition(client.clone(), kind)();
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert_eq!(
client.requests.lock().unwrap().len(),
1,
"{} retried",
kind.name()
);
}
let client = Arc::new(Transport {
requests: Mutex::new(vec![]),
available: false,
response: Err("unavailable".into()),
});
let (_, tool) = definition(client.clone(), kind)();
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap_err()
.to_string()
.contains("Backend Workspace identity")
);
assert!(client.requests.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn diagnostics_strip_undeclared_content_and_preserve_unknown_processing() {
let (kind, input, mut response) = cases().remove(3);
response["rendered_content"] = json!("must not reach model");
response["authentication_binding_id"] = json!("must not reach model");
response["inputs"] = json!([{"input_event_id":"ticket-event:7", "delivery_id":null, "notification":"not_reserved", "acceptance_outcome_unknown":true, "rendered_content":"hidden"}]);
response["starts"] = json!([{"operation_id":"start", "claim_state":"reserved", "assignment_id":null, "runtime_id":null, "worker_id":null, "actual_processing_unknown":true, "authentication_binding_id":"hidden"}]);
let (_, tool) = definition(transport(200, response), kind)();
let output = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let output: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert!(output.get("rendered_content").is_none());
assert!(output.get("authentication_binding_id").is_none());
assert_eq!(output["inputs"][0]["acceptance_outcome_unknown"], true);
assert!(output["inputs"][0].get("rendered_content").is_none());
assert_eq!(output["starts"][0]["actual_processing_unknown"], true);
assert!(
output["starts"][0]
.get("authentication_binding_id")
.is_none()
);
}
#[test]
fn public_assignment_normal_and_wip_access_excludes_mutations_for_read_only_and_review() {
for access in [
TicketFeatureAccess::read_only(),
TicketFeatureAccess::review(),
] {
let client = transport(200, json!({}));
let normal = enabled_ticket_definitions(client.clone(), access);
let native = native_ticket_tools(client, access).unwrap();
for name in READ_TOOL_NAMES {
assert!(normal.iter().any(|d| d().0.name == *name));
assert!(native.iter().any(|t| t.name == *name));
}
for name in [
"TicketOrchestratorAssign",
"TicketOrchestratorClear",
"TicketAssignmentAnswer",
] {
assert!(!normal.iter().any(|d| d().0.name == name));
assert!(!native.iter().any(|t| t.name == name));
}
}
for name in TOOL_NAMES {
let native = native_ticket_operation(name).unwrap();
assert_eq!(native.identity_field, Some("ticket"));
assert_eq!(native.mutating, !READ_TOOL_NAMES.contains(name));
}
}
#[tokio::test]
async fn ticket_query_closure_reason_is_typed_and_not_a_classification_tag() {
let client = transport(
200,
json!({"items":[],"page":{"has_more":false,"next_cursor":null}}),
);
let (meta, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
let input = json!({"states":["closed"],"closure_reason":"completed","tag_ids":["tag-bug"]});
assert!(schema.is_valid(&input));
let invalid = json!({"closure_reason":"bug"});
assert!(!schema.is_valid(&invalid));
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["closure_reason"], "completed");
assert_eq!(body["tag_ids"], json!(["tag-bug"]));
}
#[tokio::test]
async fn ticket_query_forwards_current_principal_and_self_reported_handling_filters() {
let client = transport(
200,
json!({"items":[],"page":{"has_more":false,"next_cursor":null}}),
);
let (meta, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
let schema = jsonschema::validator_for(&meta.input_schema).unwrap();
let input = json!({"assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"handling_status":"waiting","states":["open"],"limit":5});
assert!(schema.is_valid(&input));
for status in ["running", "idle", "stopped", "closed"] {
let mut invalid = input.clone();
invalid["handling_status"] = json!(status);
assert!(!schema.is_valid(&invalid));
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["assigned_principal"], input["assigned_principal"]);
assert_eq!(body["handling_status"], "waiting");
assert_eq!(body["states"], json!(["open"]));
}
#[test]
fn resolved_builtin_profiles_expose_assignment_capabilities_without_repo_or_mr_prerequisites() {
let dir = tempfile::tempdir().unwrap();
for profile in [
"intake",
"orchestrator",
"coder",
"ticket-worker",
"reviewer",
] {
let resolved = manifest::ProfileResolver::new()
.with_workspace_base(dir.path())
.resolve(
&manifest::ProfileSelector::parse_cli(&format!("builtin:{profile}")),
manifest::ProfileResolveOptions::with_worker_name("assignment-fixture"),
)
.unwrap();
let config = resolved.manifest.feature.ticket;
assert!(config.enabled, "{profile}");
let delegates = config.authoring || config.workflow;
let answers = delegates || config.thread;
let reports = config.workflow;
let access = TicketFeatureAccess {
authoring: config.authoring,
thread: config.thread,
intake: config.intake,
workflow: config.workflow,
};
let names = access.tool_names();
assert_eq!(
names.contains(&"TicketOrchestratorAssign"),
delegates,
"{profile}"
);
assert_eq!(
names.contains(&"TicketOrchestratorClear"),
delegates,
"{profile}"
);
assert_eq!(
names.contains(&"TicketAssignmentAnswer"),
answers,
"{profile}"
);
assert_eq!(
names.contains(&"TicketAssignmentTrackingUpdate"),
reports,
"{profile}"
);
assert!(
names.contains(&"TicketAssignmentsQuery")
&& names.contains(&"TicketAssignmentDiagnostics"),
"{profile}"
);
assert!(
!names.iter().any(|name| matches!(
*name,
"TicketQueue" | "TicketMarkReady" | "TicketIntakeReady"
)),
"{profile}"
);
}
}
#[tokio::test]
async fn invalid_answer_body_and_assignment_identities_fail_before_dispatch() {
let (kind, input, response) = cases().pop().unwrap();
let client = transport(200, response);
let (_, tool) = definition(client.clone(), kind)();
for body in [
String::new(),
" ".into(),
"x".repeat(16385),
"text\0hidden".into(),
] {
let mut invalid = input.clone();
invalid["body"] = json!(body);
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
for field in ["assignment_id", "expected_last_event_id", "operation_key"] {
for value in [String::new(), "x\n".into(), "x".repeat(1025)] {
let mut invalid = input.clone();
invalid[field] = json!(value);
assert!(matches!(
tool.execute(&invalid.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
}
}
assert!(client.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn assignment_wip_binds_ticket_and_preserves_normal_permission_and_cas_inputs() {
for (kind, mut input, response) in cases() {
let client = transport(200, response);
let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap();
let handler = TicketItemWipHandler {
tools: operation_map(
tools
.into_iter()
.filter(|t| t.projection.surface == NativeTicketSurface::Item)
.collect(),
),
permissions: None,
ticket_reference: "T-743".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
input.as_object_mut().unwrap().remove("ticket");
let args: BTreeMap<_, _> = input
.as_object()
.unwrap()
.iter()
.map(|(key, value)| (key.clone(), json_to_wip(value).unwrap()))
.collect();
let operation = native_ticket_operation(kind.name()).unwrap().operation;
handler
.call(
operation,
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into(),
},
)
.await
.unwrap_or_else(|_| panic!("WIP {operation} rejected"));
assert_eq!(client.requests.lock().unwrap().len(), 1);
let mut forged = args.clone();
forged.insert("ticket".into(), WipValue::String("T-other".into()));
assert!(
handler
.call(
operation,
&forged,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into()
}
)
.await
.is_err()
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
}
let (_, input, response) = cases().pop().unwrap();
let client = transport(200, response);
let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap();
let denied = TicketItemWipHandler {
tools: operation_map(
tools
.into_iter()
.filter(|t| t.projection.surface == NativeTicketSurface::Item)
.collect(),
),
permissions: Some(ToolPermissionConfig {
default_action: ToolPermissionAction::Deny,
rules: vec![],
}),
ticket_reference: "T-743".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
let args = input
.as_object()
.unwrap()
.iter()
.filter(|(k, _)| k.as_str() != "ticket")
.map(|(k, v)| (k.clone(), json_to_wip(v).unwrap()))
.collect();
assert!(matches!(
denied
.call(
"assignment_answer",
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into()
}
)
.await,
Err(WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
..
}))
));
assert!(client.requests.lock().unwrap().is_empty());
}
@@ -74,7 +74,7 @@ pub(super) fn description(name: &str) -> &'static str {
"Read bounded immutable history for one assignment_id, including released/replaced delegations. Continue with the returned event identity. Historical results do not grant execution rights or create a new request."
}
"TicketAssignmentTrackingUpdate" => {
"Record the current Workspace Orchestrator's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Backend authenticates the current registered Orchestrator; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations."
"Record the current Workspace Orchestrator's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Answers during waiting continue the same row; explicit reissue after done requires a new Assign row, not overwriting the previous result. Send the complete tracking payload. Exact retry uses the same operation_key and full input; different input with that key is rejected. On CAS/stale conflict reread current authority and reconcile; unknown outcome is not success or failure and must not become a fresh operation automatically. Backend authenticates the current registered Orchestrator; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations."
}
_ => unreachable!("unknown assignment tracking tool"),
}
+4 -3
View File
@@ -866,9 +866,10 @@ mod tests {
.unwrap();
assert!(server.starts_with("Assigned Tickets have unreceived input:"));
// TUI's final operation model is a separate downstream scope; both
// renderers still consume the same bounded resource-key projection.
assert!(tui.starts_with("Queued Tickets require attention:"));
assert!(tui.starts_with("Assigned Tickets have input requiring attention:"));
assert!(tui.contains("no ready/Queue step is required"));
assert!(tui.contains("were omitted from this notice: 1"));
assert!(!tui.contains("T-21"));
assert!(server.contains("- T-1 — Ticket 1 with control "));
assert!(!server.contains("T-21"));
assert!(server.contains("were omitted from this notice: 1"));
+158 -8
View File
@@ -334,19 +334,28 @@ mod tests {
fn ticket_workflow_fixture(
role: &str,
mr_config: MergeRequestFeatureConfig,
) -> (String, Vec<agen::tool::ToolMeta>) {
ticket_fixture_with_access(
role,
mr_config,
TicketFeatureAccess {
thread: true,
workflow: true,
..Default::default()
},
)
}
fn ticket_fixture_with_access(
role: &str,
mr_config: MergeRequestFeatureConfig,
access: TicketFeatureAccess,
) -> (String, Vec<agen::tool::ToolMeta>) {
let client = Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused"));
let mut tools = Vec::new();
let mut hooks = HookRegistryBuilder::default();
let report = FeatureRegistryBuilder::new()
.with_module(TicketFeature::new(
client.clone(),
TicketFeatureAccess {
thread: true,
workflow: true,
..Default::default()
},
))
.with_module(TicketFeature::new(client.clone(), access))
.with_module(MergeRequestFeature::new(client, mr_config))
.install_into_pending(&mut tools, &mut hooks);
assert!(!report.has_errors(), "{}", report.error_message());
@@ -471,6 +480,147 @@ mod tests {
}
}
#[test]
fn effective_ticket_instructions_separate_case_handling_and_delegation_retries() {
for role in [
"role.ticket_worker",
"role.coder",
"role.orchestrator",
"role.intake",
] {
let (prompt, tools) =
ticket_workflow_fixture(role, MergeRequestFeatureConfig::default());
for policy in [
"only `open`",
"stable tag IDs, not display names",
"no ready step or separate Queue is required",
"Orchestrator's self-reported handling",
"Answers during waiting or ongoing handling continue the same assignment row",
"explicit reissue creates a new row/assignment_id",
"Respect an explicit user approval condition",
"done does not require unassignment",
"expected_last_event_id",
"Exact retries retain the same key and full input",
"Assign persistence, notification delivery, Runtime acceptance and actual work start",
"concrete API insufficiency",
] {
assert!(prompt.contains(policy), "{role}: missing {policy}");
}
let update = tools
.iter()
.find(|meta| meta.name == "TicketAssignmentTrackingUpdate")
.unwrap();
for policy in [
"self-reported",
"same row",
"new Assign row",
"complete tracking payload",
"different input with that key is rejected",
"reread current authority",
"exact retry receipts are not fresh observations",
] {
assert!(
update.description.contains(policy),
"{role}: missing {policy}"
);
}
assert!(!tools.iter().any(|meta| matches!(
meta.name.as_str(),
"TicketMarkReady"
| "TicketQueue"
| "TicketIntakeReady"
| "CompleteTicket"
| "TicketWorkflowState"
| "TicketClose"
)));
if role == "role.orchestrator" {
assert!(prompt.contains("observed current `orchestrator_assignment_id`"));
assert!(prompt.contains(
"generic `WorkerSpawn` Ticket starts and existing-Worker assignment"
));
}
}
}
// Scripted policy/input fixtures: they do not run a model or certify its judgment.
#[test]
fn effective_return_wait_answer_and_close_fixtures_match_resolved_profile_tools_without_repo_or_mr()
{
let dir = tempfile::tempdir().unwrap();
let resolved = manifest::ProfileResolver::new()
.with_workspace_base(dir.path())
.resolve(
&manifest::ProfileSelector::parse_cli("builtin:orchestrator"),
manifest::ProfileResolveOptions::with_worker_name("orchestrator-fixture"),
)
.unwrap();
let config = resolved.manifest.feature.ticket;
// Omit MR feature for the no-repository/no-MR work fixture; profile selection
// makes it available, not mandatory to exercise for every Ticket.
let (prompt, tools) = ticket_fixture_with_access(
&resolved.manifest.engine.instruction,
MergeRequestFeatureConfig::default(),
TicketFeatureAccess {
authoring: config.authoring,
thread: config.thread,
intake: config.intake,
workflow: config.workflow,
},
);
assert!(!tools.iter().any(|meta| matches!(
meta.name.as_str(),
"OpenMergeRequest" | "ReviewMergeRequest" | "CompleteMergeRequest"
)));
for (policy, name, input) in [
(
"Use `TicketOrchestratorAssign` for explicit initial delegation or reissue",
"TicketOrchestratorAssign",
serde_json::json!({"ticket":"T-743","principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"expected_assignment_id":null,"operation_id":"delegate-research"}),
),
(
"research returned to a human can be `open` + handling `done`",
"TicketAssignmentTrackingUpdate",
serde_json::json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"assigned", "operation_key":"return-options", "tracking":{"status":"done", "result":"Options returned", "next_action":"Human decision"}}),
),
(
"Respect an explicit user approval condition",
"TicketAssignmentTrackingUpdate",
serde_json::json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"assigned", "operation_key":"wait-for-answer", "tracking":{"status":"waiting", "waiting_reason":"User requested confirmation first", "next_action":"Read the user's answer on this assignment"}}),
),
(
"use `TicketAssignmentAnswer` with that `assignment_id`",
"TicketAssignmentAnswer",
serde_json::json!({"ticket":"T-743","assignment_id":"delegation","expected_last_event_id":"waiting","operation_key":"answer-on-same-row","body":"Option A approved; continue within scope"}),
),
(
"No Merge Request or approval is a prerequisite for a generic Ticket decision",
"TicketCaseUpdate",
serde_json::json!({"ticket":"T-743", "expected_content_digest":"observed-digest", "expected_state":"open", "expected_closure_operation_key":null, "operation_key":"fulfilled-close", "action":{"action":"close", "reason":"completed"}, "explanation":"The whole bounded request is fulfilled and closure was requested"}),
),
] {
assert!(prompt.contains(policy), "missing {policy}");
let meta = tools.iter().find(|meta| meta.name == name).unwrap();
let validator = jsonschema::validator_for(&meta.input_schema).unwrap();
assert!(validator.is_valid(&input), "{name} rejected {input}");
let required = meta.input_schema["required"].as_array().unwrap();
assert!(!required.iter().any(|field| matches!(
field.as_str(),
Some(
"repository_key"
| "merge_request_id"
| "approval_event_id"
| "workdir_attachments"
)
)));
let mut forged = input;
forged["actor"] = serde_json::json!("workspace-orchestrator");
assert!(
!validator.is_valid(&forged),
"model-supplied authority accepted"
);
}
}
#[test]
fn sub_worker_capabilities_follow_the_registered_canonical_control_tools() {
let names = [
+131
View File
@@ -729,6 +729,28 @@ pub struct WorkspaceWorkerDiscoveryRequest {
#[derive(Debug, Clone)]
pub enum WorkspaceServerOperation {
TicketAssignmentSet {
ticket_id: String,
role: server_api::TicketAssignmentRole,
request: server_api::SetTicketRoleAssignmentRequest,
},
TicketAssignmentClear {
ticket_id: String,
role: server_api::TicketAssignmentRole,
query: server_api::ClearTicketRoleAssignmentQuery,
},
TicketAssignmentsPage {
ticket_id: String,
query: server_api::TicketAssignmentsQuery,
},
TicketAssignmentDiagnostics {
ticket_id: String,
query: server_api::TicketAssignmentDiagnosticsQuery,
},
TicketAssignmentAnswer {
ticket_id: String,
request: server_api::TicketAssignmentAnswerRequest,
},
WorkerControlList,
WorkerControlSpawn(server_api::CreateWorkspaceWorkerRequest),
WorkerControlInput {
@@ -784,6 +806,45 @@ fn encode_workspace_path_segment(value: &str) -> String {
encoded
}
fn workspace_server_query_request<T: serde::Serialize>(
method: WorkspaceRequestMethod,
path: String,
query: &T,
) -> Result<WorkspaceRequest, WorkspaceClientError> {
let query = serde_json::to_value(query)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
let fields = query.as_object().ok_or_else(|| {
WorkspaceClientError::Request("Workspace query must serialize to an object".into())
})?;
let mut pairs = Vec::new();
for (name, value) in fields {
let value = match value {
serde_json::Value::Null => continue,
serde_json::Value::String(value) => value.clone(),
serde_json::Value::Number(value) => value.to_string(),
_ => {
return Err(WorkspaceClientError::Request(
"Workspace query fields must be scalar".into(),
));
}
};
pairs.push(format!(
"{}={}",
encode_workspace_path_segment(name),
encode_workspace_path_segment(&value)
));
}
Ok(WorkspaceRequest {
method,
path: if pairs.is_empty() {
path
} else {
format!("{path}?{}", pairs.join("&"))
},
body: None,
})
}
fn workspace_server_operation_request(
workspace_id: &str,
operation: WorkspaceServerOperation,
@@ -791,6 +852,76 @@ fn workspace_server_operation_request(
let workspace_id = encode_workspace_path_segment(workspace_id);
let base = format!("/api/w/{workspace_id}");
match operation {
WorkspaceServerOperation::TicketAssignmentSet {
ticket_id,
role,
request,
} => {
let role = match role {
server_api::TicketAssignmentRole::Orchestrator => "orchestrator",
server_api::TicketAssignmentRole::Worker => "worker",
server_api::TicketAssignmentRole::Owner => "owner",
server_api::TicketAssignmentRole::Contributor => "contributor",
};
Ok(WorkspaceRequest::json(
WorkspaceRequestMethod::Put,
format!(
"{base}/tickets/{}/assignments/{role}",
encode_workspace_path_segment(&ticket_id)
),
serde_json::to_string(&request)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
))
}
WorkspaceServerOperation::TicketAssignmentClear {
ticket_id,
role,
query,
} => {
let role = match role {
server_api::TicketAssignmentRole::Orchestrator => "orchestrator",
server_api::TicketAssignmentRole::Worker => "worker",
server_api::TicketAssignmentRole::Owner => "owner",
server_api::TicketAssignmentRole::Contributor => "contributor",
};
workspace_server_query_request(
WorkspaceRequestMethod::Delete,
format!(
"{base}/tickets/{}/assignments/{role}",
encode_workspace_path_segment(&ticket_id)
),
&query,
)
}
WorkspaceServerOperation::TicketAssignmentsPage { ticket_id, query } => {
workspace_server_query_request(
WorkspaceRequestMethod::Get,
format!(
"/workspaces/{workspace_id}/tickets/{}/assignment-tracking/assignments",
encode_workspace_path_segment(&ticket_id)
),
&query,
)
}
WorkspaceServerOperation::TicketAssignmentDiagnostics { ticket_id, query } => {
workspace_server_query_request(
WorkspaceRequestMethod::Get,
format!(
"/workspaces/{workspace_id}/tickets/{}/assignment-diagnostics",
encode_workspace_path_segment(&ticket_id)
),
&query,
)
}
WorkspaceServerOperation::TicketAssignmentAnswer { ticket_id, request } => {
workspace_server_json_request(
format!(
"/workspaces/{workspace_id}/tickets/{}/assignment-answer",
encode_workspace_path_segment(&ticket_id)
),
&request,
)
}
WorkspaceServerOperation::WorkerControlList => Ok(WorkspaceRequest::get(format!(
"{base}/worker-control/workers"
))),