feat(ticket): generalize Worker launch and durable resource claims
This commit is contained in:
@@ -368,11 +368,9 @@ fn apply_profile_launch_policy(
|
||||
apply_scope_launch_defaults(&mut manifest.scope, default_scope);
|
||||
manifest.delegation_scope = ScopeConfig::default();
|
||||
}
|
||||
Some(TicketRole::Coder) => {
|
||||
let default_scope = workspace_scope(workspace_root, Permission::Write, &[]);
|
||||
apply_scope_launch_defaults(&mut manifest.scope, default_scope);
|
||||
manifest.delegation_scope = ScopeConfig::default();
|
||||
}
|
||||
// Work assignment is not a filesystem or delegation grant. The selected
|
||||
// Profile and explicit resource bindings remain the authority.
|
||||
Some(TicketRole::Worker) => {}
|
||||
None => {
|
||||
let worktree_root = workspace_root.join(".worktree");
|
||||
let default_scope = workspace_scope(
|
||||
@@ -872,6 +870,41 @@ language = "override"
|
||||
assert_scope_contains(&manifest.scope.allow, &workspace, Permission::Write);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_worker_role_preserves_profile_scope_and_delegation_without_adding_grants() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = tmp.path().join("workspace");
|
||||
let explicit_scope = ScopeConfig {
|
||||
allow: vec![scope_rule(&workspace.join("documents"), Permission::Write)],
|
||||
deny: vec![scope_rule(
|
||||
&workspace.join("documents/private"),
|
||||
Permission::Write,
|
||||
)],
|
||||
};
|
||||
let explicit_delegation = ScopeConfig {
|
||||
allow: vec![scope_rule(&workspace.join("reference"), Permission::Read)],
|
||||
deny: vec![scope_rule(
|
||||
&workspace.join("reference/private"),
|
||||
Permission::Read,
|
||||
)],
|
||||
};
|
||||
for (scope, delegation) in [
|
||||
(ScopeConfig::default(), ScopeConfig::default()),
|
||||
(explicit_scope, explicit_delegation),
|
||||
] {
|
||||
let (mut manifest, _) = load_builtin_default_manifest("ticket-worker").unwrap();
|
||||
manifest.scope = scope.clone();
|
||||
manifest.delegation_scope = delegation.clone();
|
||||
|
||||
apply_profile_launch_policy(&mut manifest, &workspace, Some("worker")).unwrap();
|
||||
|
||||
assert_eq!(manifest.scope.allow, scope.allow);
|
||||
assert_eq!(manifest.scope.deny, scope.deny);
|
||||
assert_eq!(manifest.delegation_scope.allow, delegation.allow);
|
||||
assert_eq!(manifest.delegation_scope.deny, delegation.deny);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_direct_startup_gets_read_root_and_worktree_delegation_from_launch_policy() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -725,8 +725,8 @@ struct WorkerSpawnInput {
|
||||
/// Optional opaque Workspace singleton key. Prefixes are caller-owned and uninterpreted.
|
||||
#[serde(default)]
|
||||
singleton_key: Option<String>,
|
||||
/// Optional inprogress Ticket already accepted by the Orchestrator. Set
|
||||
/// this with one Flow segment to assign the new Coder atomically.
|
||||
/// Optional Ticket assignment. Backend validates claims and resource requirements;
|
||||
/// neither the profile nor an initial Flow segment grants review authority.
|
||||
#[serde(default)]
|
||||
ticket_id: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -861,7 +861,7 @@ impl WorkerOperation {
|
||||
"List only known Runtime Workers and direct SubWorkers granted to the current Worker."
|
||||
}
|
||||
Self::Spawn => {
|
||||
"Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input."
|
||||
"Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission, with an optional Flow segment. Set `ticket_id` to atomically assign the Worker; Backend validates claims and resource requirements. Selecting a profile or Flow does not grant review authority. The operation id is derived from the durable tool call rather than model input."
|
||||
}
|
||||
Self::SendInput => {
|
||||
"Start a fresh turn for an Idle Worker. Non-Idle Workers reject WorkerSendInput without queueing. Use WorkerNotify for advisory information during work already in progress. Direct SubWorkers also require Idle."
|
||||
@@ -1949,6 +1949,41 @@ mod tests {
|
||||
assert!(value.get("initial_text").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_lifecycle_spawn_serializes_ticket_assignment_without_workdirs_or_flow() {
|
||||
let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest {
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
workdir_attachments: Vec::new(),
|
||||
profile: "project:ticket-worker".to_string(),
|
||||
singleton_key: None,
|
||||
ticket_id: Some("T-719".to_string()),
|
||||
operation_id: Some("spawn-ticket-worker:T-719:call-1".to_string()),
|
||||
display_name: "Worker · T-719".to_string(),
|
||||
initial_submit: vec![Segment::text("Investigate Ticket T-719.")],
|
||||
})
|
||||
.unwrap();
|
||||
let value = serde_json::to_value(request).unwrap();
|
||||
assert_eq!(value["workdir_attachments"], serde_json::json!([]));
|
||||
assert_eq!(value["profile"], "project:ticket-worker");
|
||||
assert_eq!(
|
||||
value["ticket_assignment"],
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-719",
|
||||
"operation_id": "spawn-ticket-worker:T-719:call-1",
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
value["control_operation_id"],
|
||||
"spawn-ticket-worker:T-719:call-1"
|
||||
);
|
||||
assert_eq!(
|
||||
value["initial_submit"],
|
||||
serde_json::json!([
|
||||
{"kind": "text", "content": "Investigate Ticket T-719."}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_message_and_cancel_use_permission_fenced_control_routes() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
|
||||
@@ -20,9 +20,7 @@ use crate::feature::{
|
||||
};
|
||||
|
||||
const FEATURE_ID: &str = "orchestration";
|
||||
const TOOL_NAME: &str = "SpawnTicketCoder";
|
||||
const CODER_PROFILE: &str = "builtin:coder";
|
||||
const CODER_FLOW: &str = "builtin:coder-review";
|
||||
const TOOL_NAME: &str = "SpawnTicketWorker";
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OrchestrationFeature;
|
||||
@@ -37,15 +35,15 @@ impl FeatureModule for OrchestrationFeature {
|
||||
.with_description("Semantic Ticket orchestration operations.")
|
||||
.with_service_requirement(ServiceRequirement::required(
|
||||
ServiceId::builtin(TICKET_SERVICE_ID),
|
||||
"SpawnTicketCoder requires current typed Ticket authority",
|
||||
"SpawnTicketWorker requires current typed Ticket authority",
|
||||
))
|
||||
.with_service_requirement(ServiceRequirement::required(
|
||||
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
|
||||
"SpawnTicketCoder requires Workspace Worker lifecycle authority",
|
||||
"SpawnTicketWorker requires Workspace Worker lifecycle authority",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
TOOL_NAME,
|
||||
"Spawn and atomically assign a Coder Worker for a queued or already-inprogress Ticket. Select every required Workdir by stable alias; Backend authority derives each attachment's access from the Ticket targets. The guarded operation records queued acceptance only after spawn, initial input, assignment, and Workdir finalization. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.",
|
||||
"Spawn and atomically assign a configurable Worker for a Ticket. Supply a registered profile selector, an initial request, and optionally a Flow selector. Workdir attachments may be empty; Backend authority validates claims and derives each attachment's access from the Ticket targets. The guarded operation records acceptance only after spawn, initial input, assignment, and resource finalization. Selecting a profile or Flow does not grant review authority.",
|
||||
))
|
||||
}
|
||||
|
||||
@@ -68,7 +66,7 @@ impl FeatureModule for OrchestrationFeature {
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SpawnTicketCoderWorkdirInput {
|
||||
struct SpawnTicketWorkerWorkdirInput {
|
||||
/// Stable Worker-local routing alias (for example `checkout` or `docs`).
|
||||
alias: String,
|
||||
/// Workspace-authoritative Workdir id. Paths and URLs are not accepted.
|
||||
@@ -80,27 +78,35 @@ struct SpawnTicketCoderWorkdirInput {
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SpawnTicketCoderInput {
|
||||
struct SpawnTicketWorkerInput {
|
||||
ticket_id: String,
|
||||
runtime_id: String,
|
||||
/// Every Workdir required by the Ticket target set. Backend authority derives
|
||||
/// effective read/write capabilities; callers cannot request capabilities.
|
||||
workdir_attachments: Vec<SpawnTicketCoderWorkdirInput>,
|
||||
/// Registered profile selector. Backend resolves the profile and validates claims.
|
||||
profile: String,
|
||||
/// Initial user request delivered through the normal typed submission path.
|
||||
initial_request: String,
|
||||
/// Optional Flow selector, delivered as a typed Flow segment before the request.
|
||||
#[serde(default)]
|
||||
flow: Option<String>,
|
||||
/// Alias-keyed Workdir selections; omit or leave empty for a Worker without
|
||||
/// Workdirs. Backend derives effective capabilities; callers cannot request them.
|
||||
#[serde(default)]
|
||||
workdir_attachments: Vec<SpawnTicketWorkerWorkdirInput>,
|
||||
}
|
||||
|
||||
struct SpawnTicketCoderTool {
|
||||
struct SpawnTicketWorkerTool {
|
||||
ticket_service: Arc<dyn TicketService>,
|
||||
worker_service: Arc<dyn WorkerLifecycleService>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SpawnTicketCoderTool {
|
||||
impl Tool for SpawnTicketWorkerTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| {
|
||||
let input: SpawnTicketWorkerInput = serde_json::from_str(input_json).map_err(|error| {
|
||||
ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}"))
|
||||
})?;
|
||||
let ticket_ref = authority_id(input.ticket_id, "ticket_id")?;
|
||||
@@ -108,35 +114,28 @@ impl Tool for SpawnTicketCoderTool {
|
||||
.ticket_service
|
||||
.ticket_handoff(&ticket_ref)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !matches!(
|
||||
ticket.workflow_state,
|
||||
ticket::TicketWorkflowState::Queued | ticket::TicketWorkflowState::InProgress
|
||||
) {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Ticket {} must be queued or inprogress before spawning its Coder; current state is {}",
|
||||
ticket.resource_key,
|
||||
ticket.workflow_state.as_str()
|
||||
)));
|
||||
}
|
||||
let call_id = non_empty(ctx.call_id, "tool call_id")?;
|
||||
let runtime_id = authority_id(input.runtime_id, "runtime_id")?;
|
||||
let profile = non_empty(input.profile, "profile")?;
|
||||
let initial_request = non_empty(input.initial_request, "initial_request")?;
|
||||
let flow = input.flow.map(|flow| non_empty(flow, "flow")).transpose()?;
|
||||
let workdir_attachments = validate_workdir_attachments(input.workdir_attachments)?;
|
||||
let mut initial_submit = Vec::new();
|
||||
if let Some(selector) = flow {
|
||||
initial_submit.push(Segment::Flow { selector });
|
||||
}
|
||||
initial_submit.push(Segment::text(initial_request));
|
||||
let response = self
|
||||
.worker_service
|
||||
.spawn(WorkerLifecycleSpawnRequest {
|
||||
runtime_id,
|
||||
workdir_attachments,
|
||||
profile: CODER_PROFILE.to_string(),
|
||||
profile,
|
||||
singleton_key: None,
|
||||
ticket_id: Some(ticket.id.clone()),
|
||||
operation_id: Some(format!("spawn-ticket-coder:{}:{call_id}", ticket.id)),
|
||||
display_name: format!("Coder · {}", ticket.resource_key),
|
||||
initial_submit: vec![
|
||||
Segment::Flow {
|
||||
selector: CODER_FLOW.to_string(),
|
||||
},
|
||||
Segment::text(format!("Implement Ticket {}.", ticket.resource_key)),
|
||||
],
|
||||
operation_id: Some(format!("spawn-ticket-worker:{}:{call_id}", ticket.id)),
|
||||
display_name: format!("Worker · {}", ticket.resource_key),
|
||||
initial_submit,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
@@ -147,7 +146,7 @@ impl Tool for SpawnTicketCoderTool {
|
||||
)));
|
||||
}
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Spawned Coder for Ticket {}", ticket.resource_key),
|
||||
summary: format!("Spawned Worker for Ticket {}", ticket.resource_key),
|
||||
content: Some(response.body),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
@@ -155,14 +154,8 @@ impl Tool for SpawnTicketCoderTool {
|
||||
}
|
||||
|
||||
fn validate_workdir_attachments(
|
||||
attachments: Vec<SpawnTicketCoderWorkdirInput>,
|
||||
attachments: Vec<SpawnTicketWorkerWorkdirInput>,
|
||||
) -> Result<Vec<WorkerLifecycleWorkdirAttachment>, ToolError> {
|
||||
if attachments.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"workdir_attachments must contain at least one Workdir".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut aliases = BTreeSet::new();
|
||||
let mut workdir_ids = BTreeSet::new();
|
||||
attachments
|
||||
@@ -200,14 +193,14 @@ fn definition(
|
||||
worker_service: Arc<dyn WorkerLifecycleService>,
|
||||
) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketCoderInput))
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput))
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let meta = ToolMeta::new(TOOL_NAME)
|
||||
.description(
|
||||
"Spawn and atomically assign a policy-configured Coder for a Ticket with every required alias-keyed Workdir attachment.",
|
||||
"Spawn and atomically assign a configurable Ticket Worker with a registered profile, initial request, optional typed Flow, and zero or more alias-keyed Workdir attachments. Backend validates claims and resources; profile and Flow selection do not grant review authority.",
|
||||
)
|
||||
.input_schema(schema);
|
||||
let tool: Arc<dyn Tool> = Arc::new(SpawnTicketCoderTool {
|
||||
let tool: Arc<dyn Tool> = Arc::new(SpawnTicketWorkerTool {
|
||||
ticket_service: ticket_service.clone(),
|
||||
worker_service: worker_service.clone(),
|
||||
});
|
||||
@@ -254,32 +247,18 @@ mod tests {
|
||||
|
||||
use ticket::{TicketError, TicketWorkflowState};
|
||||
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
use super::*;
|
||||
use crate::feature::builtin::ticket::TicketHandoff;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingTicketService;
|
||||
|
||||
impl TicketService for RecordingTicketService {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
assert_eq!(ticket_ref, "T-482");
|
||||
Ok(TicketHandoff {
|
||||
id: "00001KZXN51C7".to_string(),
|
||||
resource_key: "T-482".to_string(),
|
||||
workflow_state: TicketWorkflowState::Queued,
|
||||
})
|
||||
}
|
||||
}
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
struct FixedTicketService(TicketWorkflowState);
|
||||
|
||||
impl TicketService for FixedTicketService {
|
||||
fn ticket_handoff(&self, _ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
assert_eq!(ticket_ref, "T-719");
|
||||
Ok(TicketHandoff {
|
||||
id: "00001KZXN51C7".to_string(),
|
||||
resource_key: "T-482".to_string(),
|
||||
resource_key: "T-719".to_string(),
|
||||
workflow_state: self.0,
|
||||
})
|
||||
}
|
||||
@@ -288,6 +267,7 @@ mod tests {
|
||||
#[derive(Default)]
|
||||
struct RecordingService {
|
||||
requests: Mutex<Vec<WorkerLifecycleSpawnRequest>>,
|
||||
response: Option<WorkspaceResponse>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -297,193 +277,236 @@ mod tests {
|
||||
request: WorkerLifecycleSpawnRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(WorkspaceResponse {
|
||||
Ok(self.response.clone().unwrap_or_else(|| WorkspaceResponse {
|
||||
status: 200,
|
||||
body: r#"{"worker_id":"42"}"#.to_string(),
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn input() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-719",
|
||||
"runtime_id": "runtime-1",
|
||||
"profile": "project:ticket-worker",
|
||||
"initial_request": "Investigate Ticket T-719."
|
||||
})
|
||||
}
|
||||
|
||||
fn tool(service: Arc<RecordingService>, state: TicketWorkflowState) -> SpawnTicketWorkerTool {
|
||||
SpawnTicketWorkerTool {
|
||||
ticket_service: Arc::new(FixedTicketService(state)),
|
||||
worker_service: service,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_coder_fixes_profile_flow_assignment_and_message() {
|
||||
async fn spawn_ticket_worker_forwards_selected_profile_flow_request_and_workdirs() {
|
||||
let service = Arc::new(RecordingService::default());
|
||||
let tool = SpawnTicketCoderTool {
|
||||
ticket_service: Arc::new(RecordingTicketService),
|
||||
worker_service: service.clone(),
|
||||
};
|
||||
tool.execute(
|
||||
&serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": [
|
||||
{
|
||||
"alias": "checkout",
|
||||
"working_directory_id": "workdir-1",
|
||||
"relative_cwd": "crates/yoi"
|
||||
},
|
||||
{
|
||||
"alias": "docs",
|
||||
"working_directory_id": "workdir-2"
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
ToolExecutionContext::new("call-7", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut input = input();
|
||||
input["flow"] = serde_json::json!("project:investigation");
|
||||
input["workdir_attachments"] = serde_json::json!([
|
||||
{
|
||||
"alias": "checkout",
|
||||
"working_directory_id": "workdir-1",
|
||||
"relative_cwd": "crates/worker"
|
||||
},
|
||||
{"alias": "docs", "working_directory_id": "workdir-2"}
|
||||
]);
|
||||
let output = tool(service.clone(), TicketWorkflowState::Queued)
|
||||
.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("call-7", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let requests = service.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let request = &requests[0];
|
||||
assert_eq!(request.profile, CODER_PROFILE);
|
||||
assert_eq!(request.runtime_id, "runtime-1");
|
||||
assert_eq!(request.profile, "project:ticket-worker");
|
||||
assert_eq!(request.ticket_id.as_deref(), Some("00001KZXN51C7"));
|
||||
assert_eq!(
|
||||
request.operation_id.as_deref(),
|
||||
Some("spawn-ticket-coder:00001KZXN51C7:call-7")
|
||||
Some("spawn-ticket-worker:00001KZXN51C7:call-7")
|
||||
);
|
||||
assert_eq!(request.display_name, "Coder · T-482");
|
||||
assert_eq!(request.workdir_attachments.len(), 2);
|
||||
assert_eq!(request.workdir_attachments[0].alias, "checkout");
|
||||
assert_eq!(request.display_name, "Worker · T-719");
|
||||
assert_eq!(request.singleton_key, None);
|
||||
assert_eq!(
|
||||
request.workdir_attachments[0].working_directory_id,
|
||||
"workdir-1"
|
||||
request.workdir_attachments,
|
||||
vec![
|
||||
WorkerLifecycleWorkdirAttachment {
|
||||
alias: "checkout".into(),
|
||||
working_directory_id: "workdir-1".into(),
|
||||
relative_cwd: Some("crates/worker".into()),
|
||||
},
|
||||
WorkerLifecycleWorkdirAttachment {
|
||||
alias: "docs".into(),
|
||||
working_directory_id: "workdir-2".into(),
|
||||
relative_cwd: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request.workdir_attachments[0].relative_cwd.as_deref(),
|
||||
Some("crates/yoi")
|
||||
);
|
||||
assert_eq!(request.workdir_attachments[1].alias, "docs");
|
||||
assert_eq!(
|
||||
request.workdir_attachments[1].working_directory_id,
|
||||
"workdir-2"
|
||||
);
|
||||
assert_eq!(request.workdir_attachments[1].relative_cwd, None);
|
||||
assert_eq!(
|
||||
request.initial_submit,
|
||||
vec![
|
||||
Segment::Flow {
|
||||
selector: CODER_FLOW.to_string()
|
||||
selector: "project:investigation".into()
|
||||
},
|
||||
Segment::text("Implement Ticket T-482.")
|
||||
Segment::text("Investigate Ticket T-719."),
|
||||
]
|
||||
);
|
||||
assert!(!request.display_name.contains("00001KZXN51C7"));
|
||||
assert!(request.initial_submit.iter().all(|segment| {
|
||||
!Segment::flatten_to_text(std::slice::from_ref(segment)).contains("00001KZXN51C7")
|
||||
}));
|
||||
assert_eq!(output.summary, "Spawned Worker for Ticket T-719");
|
||||
assert_eq!(output.content.as_deref(), Some(r#"{"worker_id":"42"}"#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_coder_rejects_ineligible_ticket_before_worker_side_effect() {
|
||||
let worker_service = Arc::new(RecordingService::default());
|
||||
let tool = SpawnTicketCoderTool {
|
||||
ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Planning)),
|
||||
worker_service: worker_service.clone(),
|
||||
};
|
||||
let error = tool
|
||||
async fn spawn_ticket_worker_allows_zero_workdirs_and_omitted_flow() {
|
||||
let service = Arc::new(RecordingService::default());
|
||||
for explicit_empty in [false, true] {
|
||||
let mut input = input();
|
||||
if explicit_empty {
|
||||
input["workdir_attachments"] = serde_json::json!([]);
|
||||
input["flow"] = serde_json::Value::Null;
|
||||
}
|
||||
tool(service.clone(), TicketWorkflowState::Queued)
|
||||
.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("call-empty", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let requests = service.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
for request in requests.iter() {
|
||||
assert!(request.workdir_attachments.is_empty());
|
||||
assert_eq!(
|
||||
request.initial_submit,
|
||||
vec![Segment::text("Investigate Ticket T-719.")]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_worker_defers_claim_eligibility_to_backend() {
|
||||
let service = Arc::new(RecordingService {
|
||||
response: Some(WorkspaceResponse {
|
||||
status: 409,
|
||||
body: "Backend rejected Ticket claim".into(),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let error = tool(service.clone(), TicketWorkflowState::Planning)
|
||||
.execute(
|
||||
&serde_json::json!({
|
||||
"ticket_id": "00001KZXN51C7",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": [{
|
||||
"alias": "checkout",
|
||||
"working_directory_id": "workdir-1"
|
||||
}]
|
||||
})
|
||||
.to_string(),
|
||||
ToolExecutionContext::new("call-queued", "batch-1", 0),
|
||||
&input().to_string(),
|
||||
ToolExecutionContext::new("call-claim", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("must be queued or inprogress"));
|
||||
assert!(worker_service.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(service.requests.lock().unwrap().len(), 1);
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("HTTP 409: Backend rejected Ticket claim")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_coder_rejects_invalid_workdir_collections_before_worker_side_effect() {
|
||||
let worker_service = Arc::new(RecordingService::default());
|
||||
let tool = SpawnTicketCoderTool {
|
||||
ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Queued)),
|
||||
worker_service: worker_service.clone(),
|
||||
};
|
||||
async fn spawn_ticket_worker_rejects_invalid_input_before_worker_side_effect() {
|
||||
let service = Arc::new(RecordingService::default());
|
||||
let tool = tool(service.clone(), TicketWorkflowState::Queued);
|
||||
let cases = [
|
||||
(
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": []
|
||||
}),
|
||||
"must contain at least one Workdir",
|
||||
"profile",
|
||||
serde_json::json!(" "),
|
||||
"profile must not be empty",
|
||||
),
|
||||
(
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": [
|
||||
{"alias": "checkout", "working_directory_id": "workdir-1"},
|
||||
{"alias": " checkout ", "working_directory_id": "workdir-2"}
|
||||
]
|
||||
}),
|
||||
"duplicate Workdir attachment alias `checkout`",
|
||||
"initial_request",
|
||||
serde_json::json!(" \n "),
|
||||
"initial_request must not be empty",
|
||||
),
|
||||
("flow", serde_json::json!(" "), "flow must not be empty"),
|
||||
(
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": [
|
||||
{"alias": "checkout", "working_directory_id": "workdir-1"},
|
||||
{"alias": "docs", "working_directory_id": " workdir-1 "}
|
||||
]
|
||||
}),
|
||||
"duplicate Workdir attachment `workdir-1`",
|
||||
),
|
||||
(
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": [
|
||||
{"alias": "docs/path", "working_directory_id": "workdir-1"}
|
||||
]
|
||||
}),
|
||||
"workdir_attachments.alias must be an authority id",
|
||||
),
|
||||
(
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": [
|
||||
{"alias": "docs", "working_directory_id": "https://example.test/workdir"}
|
||||
]
|
||||
}),
|
||||
"workdir_attachments.working_directory_id must be an authority id",
|
||||
),
|
||||
(
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime/1",
|
||||
"workdir_attachments": [
|
||||
{"alias": "checkout", "working_directory_id": "workdir-1"}
|
||||
]
|
||||
}),
|
||||
"runtime_id",
|
||||
serde_json::json!("runtime/1"),
|
||||
"runtime_id must be an authority id",
|
||||
),
|
||||
(
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": [
|
||||
{
|
||||
"alias": "checkout",
|
||||
"working_directory_id": "workdir-1",
|
||||
"relative_cwd": "../outside"
|
||||
}
|
||||
]
|
||||
}),
|
||||
"ticket_id",
|
||||
serde_json::json!("https://example.test/ticket"),
|
||||
"ticket_id must be an authority id",
|
||||
),
|
||||
(
|
||||
"workdir_attachments",
|
||||
serde_json::json!([
|
||||
{"alias": "checkout", "working_directory_id": "workdir-1"},
|
||||
{"alias": " checkout ", "working_directory_id": "workdir-2"}
|
||||
]),
|
||||
"duplicate Workdir attachment alias `checkout`",
|
||||
),
|
||||
(
|
||||
"workdir_attachments",
|
||||
serde_json::json!([
|
||||
{"alias": "checkout", "working_directory_id": "workdir-1"},
|
||||
{"alias": "docs", "working_directory_id": " workdir-1 "}
|
||||
]),
|
||||
"duplicate Workdir attachment `workdir-1`",
|
||||
),
|
||||
(
|
||||
"workdir_attachments",
|
||||
serde_json::json!([
|
||||
{"alias": "docs/path", "working_directory_id": "workdir-1"}
|
||||
]),
|
||||
"workdir_attachments.alias must be an authority id",
|
||||
),
|
||||
(
|
||||
"workdir_attachments",
|
||||
serde_json::json!([
|
||||
{"alias": "docs", "working_directory_id": "https://example.test/workdir"}
|
||||
]),
|
||||
"workdir_attachments.working_directory_id must be an authority id",
|
||||
),
|
||||
(
|
||||
"workdir_attachments",
|
||||
serde_json::json!([
|
||||
{"alias": "checkout", "working_directory_id": "workdir-1", "relative_cwd": "../outside"}
|
||||
]),
|
||||
"relative_cwd must be a normalized relative path inside the Workdir",
|
||||
),
|
||||
(
|
||||
"workdir_attachments",
|
||||
serde_json::json!([
|
||||
{"alias": "checkout", "working_directory_id": "workdir-1", "access": "write"}
|
||||
]),
|
||||
"unknown field `access`",
|
||||
),
|
||||
(
|
||||
"review",
|
||||
serde_json::json!({"ticket_id": "T-719", "merge_request_id": "MR-1"}),
|
||||
"unknown field `review`",
|
||||
),
|
||||
(
|
||||
"operation_id",
|
||||
serde_json::json!("caller-claim"),
|
||||
"unknown field `operation_id`",
|
||||
),
|
||||
(
|
||||
"display_name",
|
||||
serde_json::json!("Reviewer"),
|
||||
"unknown field `display_name`",
|
||||
),
|
||||
(
|
||||
"initial_submit",
|
||||
serde_json::json!([]),
|
||||
"unknown field `initial_submit`",
|
||||
),
|
||||
];
|
||||
|
||||
for (index, (input, expected)) in cases.into_iter().enumerate() {
|
||||
for (index, (field, value, expected)) in cases.into_iter().enumerate() {
|
||||
let mut input = input();
|
||||
input[field] = value;
|
||||
let error = tool
|
||||
.execute(
|
||||
&input.to_string(),
|
||||
@@ -493,10 +516,27 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains(expected),
|
||||
"unexpected error for case {index}: {error}"
|
||||
"unexpected error for {field}: {error}"
|
||||
);
|
||||
}
|
||||
assert!(worker_service.requests.lock().unwrap().is_empty());
|
||||
for field in ["ticket_id", "runtime_id", "profile", "initial_request"] {
|
||||
let mut input = input();
|
||||
input.as_object_mut().unwrap().remove(field);
|
||||
let error = tool
|
||||
.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("call-missing", "batch-validation", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains(&format!("missing field `{field}`")),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
assert!(service.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -517,26 +557,38 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_surface_does_not_expose_profile_flow_or_assignment_controls() {
|
||||
let schema = serde_json::to_string(&schemars::schema_for!(SpawnTicketCoderInput)).unwrap();
|
||||
for field in [
|
||||
"ticket_id",
|
||||
"runtime_id",
|
||||
"workdir_attachments",
|
||||
"alias",
|
||||
"working_directory_id",
|
||||
"relative_cwd",
|
||||
] {
|
||||
assert!(schema.contains(field));
|
||||
fn spawn_ticket_worker_schema_exposes_configuration_not_claim_or_review_grants() {
|
||||
let definition = definition(
|
||||
Arc::new(FixedTicketService(TicketWorkflowState::Queued)),
|
||||
Arc::new(RecordingService::default()),
|
||||
);
|
||||
let (meta, _) = definition();
|
||||
assert_eq!(meta.name, "SpawnTicketWorker");
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput)).unwrap();
|
||||
assert_eq!(schema["additionalProperties"], false);
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
for field in ["ticket_id", "runtime_id", "profile", "initial_request"] {
|
||||
assert!(
|
||||
required.contains(&serde_json::json!(field)),
|
||||
"missing required field {field}"
|
||||
);
|
||||
}
|
||||
for field in ["flow", "workdir_attachments"] {
|
||||
assert!(schema["properties"].get(field).is_some());
|
||||
assert!(!required.contains(&serde_json::json!(field)));
|
||||
}
|
||||
for forbidden in [
|
||||
"profile",
|
||||
"selector",
|
||||
"review",
|
||||
"operation_id",
|
||||
"display_name",
|
||||
"initial_submit",
|
||||
"claim",
|
||||
"access",
|
||||
] {
|
||||
assert!(!schema.contains(forbidden), "schema leaked {forbidden}");
|
||||
assert!(
|
||||
schema["properties"].get(forbidden).is_none(),
|
||||
"schema leaked {forbidden}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ struct ModelTicketQueryItem {
|
||||
workspace_action_priority: Option<String>,
|
||||
matched_fields: Vec<String>,
|
||||
snippet: Option<String>,
|
||||
current_coder: Option<ModelWorkerSummary>,
|
||||
current_worker: Option<ModelWorkerSummary>,
|
||||
linked_objectives: Vec<String>,
|
||||
relation_count: usize,
|
||||
blocker_count: usize,
|
||||
@@ -47,7 +47,7 @@ pub(super) struct ModelTicketDetail {
|
||||
relations: ModelTicketRelations,
|
||||
linked_objectives: Vec<ModelObjectiveSummary>,
|
||||
assignments: Vec<ModelAssignment>,
|
||||
current_coder: Option<ModelWorkerSummary>,
|
||||
current_worker: Option<ModelWorkerSummary>,
|
||||
implementation_reports: Vec<ModelEvidenceEvent>,
|
||||
merge_requests: Vec<ModelMergeRequest>,
|
||||
merge_request: Option<ModelMergeRequest>,
|
||||
@@ -195,7 +195,7 @@ struct ModelTicketActions {
|
||||
can_assign_orchestrator: bool,
|
||||
can_unassign_orchestrator: bool,
|
||||
can_queue: bool,
|
||||
can_start_manual_coder: bool,
|
||||
can_start_manual_worker: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -244,8 +244,8 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
||||
workspace_action_priority: optional_string(item, "workspace_action_priority")?,
|
||||
matched_fields: string_array(item, "matched_fields")?,
|
||||
snippet: optional_string(item, "snippet")?,
|
||||
current_coder: item
|
||||
.get("current_coder")
|
||||
current_worker: item
|
||||
.get("current_worker")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_worker)
|
||||
.transpose()?,
|
||||
@@ -272,14 +272,14 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
||||
|
||||
pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, String> {
|
||||
let root = object(&value, "Ticket detail response")?;
|
||||
let current_coder = root
|
||||
.get("current_coder")
|
||||
let current_worker = root
|
||||
.get("current_worker")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_worker)
|
||||
.transpose()?;
|
||||
let assignments = array_field(root, "assignments")?
|
||||
.iter()
|
||||
.map(|assignment| project_assignment(assignment, current_coder.as_ref()))
|
||||
.map(|assignment| project_assignment(assignment, current_worker.as_ref()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(ModelTicketDetail {
|
||||
@@ -302,7 +302,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
||||
.map(project_objective_summary)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
assignments,
|
||||
current_coder,
|
||||
current_worker,
|
||||
implementation_reports: array_field(root, "implementation_reports")?
|
||||
.iter()
|
||||
.map(project_evidence_event)
|
||||
@@ -483,13 +483,13 @@ fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
|
||||
|
||||
fn project_assignment(
|
||||
value: &Value,
|
||||
current_coder: Option<&ModelWorkerSummary>,
|
||||
current_worker: Option<&ModelWorkerSummary>,
|
||||
) -> Result<ModelAssignment, String> {
|
||||
let assignment = object(value, "Ticket assignment")?;
|
||||
let principal = object_field(assignment, "principal")?;
|
||||
let kind = string_field(principal, "kind")?;
|
||||
let principal = match kind.as_str() {
|
||||
"worker" => current_coder
|
||||
"worker" => current_worker
|
||||
.map(|coder| coder.worker.clone())
|
||||
.ok_or_else(|| "Worker assignment is missing a Workspace key projection".to_string())?,
|
||||
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
|
||||
@@ -572,7 +572,7 @@ fn project_actions(value: &Value) -> Result<ModelTicketActions, String> {
|
||||
can_assign_orchestrator: bool_field(actions, "can_assign_orchestrator")?,
|
||||
can_unassign_orchestrator: bool_field(actions, "can_unassign_orchestrator")?,
|
||||
can_queue: bool_field(actions, "can_queue")?,
|
||||
can_start_manual_coder: bool_field(actions, "can_start_manual_coder")?,
|
||||
can_start_manual_worker: bool_field(actions, "can_start_manual_worker")?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -783,7 +783,7 @@ mod tests {
|
||||
"workspace_action_priority": "active_work",
|
||||
"matched_fields": ["title"],
|
||||
"snippet": "Ticket",
|
||||
"current_coder": {"runtime_id": "runtime-internal", "worker_id": "worker-internal", "worker_resource_key": "W-12"},
|
||||
"current_worker": {"runtime_id": "runtime-internal", "worker_id": "worker-internal", "worker_resource_key": "W-12"},
|
||||
"linked_objective_ids": ["00001OBJECTIVEINTERNAL"],
|
||||
"linked_objective_keys": ["O-6"],
|
||||
"relation_count": 0,
|
||||
@@ -870,7 +870,7 @@ mod tests {
|
||||
"linked_objectives": [],
|
||||
"implementation_reports": [],
|
||||
"assignments": [],
|
||||
"current_coder": null,
|
||||
"current_worker": null,
|
||||
"merge_requests": [],
|
||||
"merge_request": null,
|
||||
"evidence": {
|
||||
@@ -889,7 +889,7 @@ mod tests {
|
||||
"can_assign_orchestrator": true,
|
||||
"can_unassign_orchestrator": false,
|
||||
"can_queue": false,
|
||||
"can_start_manual_coder": false
|
||||
"can_start_manual_worker": false
|
||||
},
|
||||
"event_page": {"next_cursor": null, "has_more": false}
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user