feat: support permissioned multi-repository ticket targets

This commit is contained in:
2026-09-23 23:11:37 +09:00
parent 9c107eaf74
commit 9ccf2fbc3b
36 changed files with 4093 additions and 1360 deletions
@@ -458,11 +458,21 @@ pub trait WorkerLifecycleService: Send + Sync {
) -> Result<WorkspaceResponse, WorkspaceClientError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerLifecycleWorkdirAttachment {
/// Stable Worker-local routing alias preserved into the canonical create request.
pub alias: String,
/// Workspace-authoritative Workdir id preserved into the canonical create request.
pub working_directory_id: String,
/// Optional normalized initial cwd relative to this Workdir.
pub relative_cwd: Option<String>,
}
#[derive(Debug, Clone)]
pub struct WorkerLifecycleSpawnRequest {
pub runtime_id: String,
pub working_directory_id: String,
pub relative_cwd: Option<String>,
/// Ordered alias-keyed selections forwarded as canonical Workdir attachments.
pub workdir_attachments: Vec<WorkerLifecycleWorkdirAttachment>,
pub profile: String,
pub ticket_id: Option<String>,
pub operation_id: Option<String>,
@@ -501,11 +511,15 @@ fn workspace_worker_create_request(
profile: Some(request.profile),
ticket_assignment,
initial_submit: request.initial_submit,
workdir_attachments: vec![BrowserWorkerWorkingDirectorySelection {
alias: INITIAL_WORKDIR_ALIAS.to_string(),
working_directory_id: request.working_directory_id,
relative_cwd: request.relative_cwd,
}],
workdir_attachments: request
.workdir_attachments
.into_iter()
.map(|attachment| BrowserWorkerWorkingDirectorySelection {
alias: attachment.alias,
working_directory_id: attachment.working_directory_id,
relative_cwd: attachment.relative_cwd,
})
.collect(),
control_operation_id: Some(control_operation_id),
})
}
@@ -698,6 +712,8 @@ struct WorkerListInput {}
#[serde(deny_unknown_fields)]
struct WorkerSpawnInput {
runtime_id: String,
/// Singular Workdir selection wrapped into the shared attachment collection
/// under the stable `workdir` alias.
working_directory_id: String,
profile: String,
/// Optional inprogress Ticket already accepted by the Orchestrator. Set
@@ -831,7 +847,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 an existing Workspace Workdir. 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. 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."
}
Self::SendInput => "Send user input to a known Runtime Worker when allowed.",
Self::Notify => "Send an advisory notification to a known Runtime Worker when allowed.",
@@ -878,14 +894,17 @@ impl Tool for WorkspaceWorkerTool {
self.control
.spawn_worker(WorkerLifecycleSpawnRequest {
runtime_id: authority_id(&input.runtime_id, "runtime_id")?,
working_directory_id: authority_id(
&input.working_directory_id,
"working_directory_id",
)?,
relative_cwd: input
.relative_cwd
.map(|value| validate_relative_cwd(&value))
.transpose()?,
workdir_attachments: vec![WorkerLifecycleWorkdirAttachment {
alias: INITIAL_WORKDIR_ALIAS.to_string(),
working_directory_id: authority_id(
&input.working_directory_id,
"working_directory_id",
)?,
relative_cwd: input
.relative_cwd
.map(|value| validate_relative_cwd(&value))
.transpose()?,
}],
profile: non_empty(input.profile, "profile")?,
ticket_id,
operation_id,
@@ -1390,6 +1409,7 @@ mod tests {
&serde_json::json!({
"runtime_id": "runtime-1",
"working_directory_id": "workdir-1",
"relative_cwd": "crates/worker",
"profile": "builtin:coder",
"ticket_id": "00001KZ9E0DBS",
"initial_submit": [
@@ -1435,7 +1455,7 @@ mod tests {
);
assert_eq!(
body["workdir_attachments"][0]["relative_cwd"],
serde_json::Value::Null
"crates/worker"
);
assert!(body.get("working_directory").is_none());
assert!(body.get("initial_text").is_none());
@@ -1709,11 +1729,21 @@ mod tests {
}
#[test]
fn worker_spawn_request_uses_canonical_initial_attachment_contract() {
fn worker_lifecycle_spawn_request_preserves_canonical_attachment_collection() {
let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest {
runtime_id: "runtime-1".to_string(),
working_directory_id: "wd-1".to_string(),
relative_cwd: Some("repo".to_string()),
workdir_attachments: vec![
WorkerLifecycleWorkdirAttachment {
alias: "checkout".to_string(),
working_directory_id: "wd-1".to_string(),
relative_cwd: Some("repo".to_string()),
},
WorkerLifecycleWorkdirAttachment {
alias: "docs".to_string(),
working_directory_id: "wd-2".to_string(),
relative_cwd: None,
},
],
profile: "builtin:coder".to_string(),
ticket_id: None,
operation_id: None,
@@ -1728,16 +1758,22 @@ mod tests {
.unwrap();
let value = serde_json::to_value(request).unwrap();
assert_eq!(value["runtime_id"], "runtime-1");
assert_eq!(value["workdir_attachments"].as_array().unwrap().len(), 1);
assert_eq!(
value["workdir_attachments"][0]["alias"],
INITIAL_WORKDIR_ALIAS
);
assert_eq!(value["workdir_attachments"].as_array().unwrap().len(), 2);
assert_eq!(value["workdir_attachments"][0]["alias"], "checkout");
assert_eq!(
value["workdir_attachments"][0]["working_directory_id"],
"wd-1"
);
assert_eq!(value["workdir_attachments"][0]["relative_cwd"], "repo");
assert_eq!(value["workdir_attachments"][1]["alias"], "docs");
assert_eq!(
value["workdir_attachments"][1]["working_directory_id"],
"wd-2"
);
assert_eq!(
value["workdir_attachments"][1]["relative_cwd"],
serde_json::Value::Null
);
assert!(value.get("working_directory").is_none());
assert!(value.get("cwd").is_none());
assert!(value.get("runtime_url").is_none());
@@ -1,5 +1,6 @@
//! Semantic Ticket orchestration tools backed by Feature Services.
use std::collections::BTreeSet;
use std::sync::Arc;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -10,6 +11,7 @@ use serde::Deserialize;
use super::manage_worker::{
WORKER_LIFECYCLE_SERVICE_ID, WorkerLifecycleService, WorkerLifecycleSpawnRequest,
WorkerLifecycleWorkdirAttachment,
};
use super::ticket::{TICKET_SERVICE_ID, TicketService};
use crate::feature::{
@@ -43,7 +45,7 @@ impl FeatureModule for OrchestrationFeature {
))
.with_tool(ToolDeclaration::new(
TOOL_NAME,
"Spawn and atomically assign a Coder Worker for a queued or already-inprogress Ticket. 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 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.",
))
}
@@ -64,14 +66,26 @@ impl FeatureModule for OrchestrationFeature {
}
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SpawnTicketCoderWorkdirInput {
/// Stable Worker-local routing alias (for example `checkout` or `docs`).
alias: String,
/// Workspace-authoritative Workdir id. Paths and URLs are not accepted.
working_directory_id: String,
/// Optional normalized path inside this Workdir for the Worker's initial cwd.
#[serde(default)]
relative_cwd: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct SpawnTicketCoderInput {
ticket_id: String,
runtime_id: String,
working_directory_id: String,
#[serde(default)]
relative_cwd: Option<String>,
/// Every Workdir required by the Ticket target set. Backend authority derives
/// effective read/write capabilities; callers cannot request capabilities.
workdir_attachments: Vec<SpawnTicketCoderWorkdirInput>,
}
struct SpawnTicketCoderTool {
@@ -105,16 +119,13 @@ impl Tool for SpawnTicketCoderTool {
)));
}
let call_id = non_empty(ctx.call_id, "tool call_id")?;
let relative_cwd = input.relative_cwd.map(validate_relative_cwd).transpose()?;
let runtime_id = authority_id(input.runtime_id, "runtime_id")?;
let workdir_attachments = validate_workdir_attachments(input.workdir_attachments)?;
let response = self
.worker_service
.spawn(WorkerLifecycleSpawnRequest {
runtime_id: authority_id(input.runtime_id, "runtime_id")?,
working_directory_id: authority_id(
input.working_directory_id,
"working_directory_id",
)?,
relative_cwd,
runtime_id,
workdir_attachments,
profile: CODER_PROFILE.to_string(),
ticket_id: Some(ticket.id.clone()),
operation_id: Some(format!("spawn-ticket-coder:{}:{call_id}", ticket.id)),
@@ -142,6 +153,47 @@ impl Tool for SpawnTicketCoderTool {
}
}
fn validate_workdir_attachments(
attachments: Vec<SpawnTicketCoderWorkdirInput>,
) -> 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
.into_iter()
.map(|attachment| {
let alias = authority_id(attachment.alias, "workdir_attachments.alias")?;
if !aliases.insert(alias.clone()) {
return Err(ToolError::InvalidArgument(format!(
"duplicate Workdir attachment alias `{alias}`"
)));
}
let working_directory_id = authority_id(
attachment.working_directory_id,
"workdir_attachments.working_directory_id",
)?;
if !workdir_ids.insert(working_directory_id.clone()) {
return Err(ToolError::InvalidArgument(format!(
"duplicate Workdir attachment `{working_directory_id}`"
)));
}
Ok(WorkerLifecycleWorkdirAttachment {
alias,
working_directory_id,
relative_cwd: attachment
.relative_cwd
.map(validate_relative_cwd)
.transpose()?,
})
})
.collect()
}
fn definition(
ticket_service: Arc<dyn TicketService>,
worker_service: Arc<dyn WorkerLifecycleService>,
@@ -150,7 +202,9 @@ fn definition(
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketCoderInput))
.unwrap_or_else(|_| serde_json::json!({}));
let meta = ToolMeta::new(TOOL_NAME)
.description("Spawn and atomically assign a policy-configured Coder for a Ticket.")
.description(
"Spawn and atomically assign a policy-configured Coder for a Ticket with every required alias-keyed Workdir attachment.",
)
.input_schema(schema);
let tool: Arc<dyn Tool> = Arc::new(SpawnTicketCoderTool {
ticket_service: ticket_service.clone(),
@@ -260,8 +314,17 @@ mod tests {
&serde_json::json!({
"ticket_id": "T-482",
"runtime_id": "runtime-1",
"working_directory_id": "workdir-1",
"relative_cwd": "crates/yoi"
"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),
@@ -278,8 +341,22 @@ mod tests {
Some("spawn-ticket-coder:00001KZXN51C7:call-7")
);
assert_eq!(request.display_name, "Coder · T-482");
assert_eq!(request.working_directory_id, "workdir-1");
assert_eq!(request.relative_cwd.as_deref(), Some("crates/yoi"));
assert_eq!(request.workdir_attachments.len(), 2);
assert_eq!(request.workdir_attachments[0].alias, "checkout");
assert_eq!(
request.workdir_attachments[0].working_directory_id,
"workdir-1"
);
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![
@@ -307,7 +384,10 @@ mod tests {
&serde_json::json!({
"ticket_id": "00001KZXN51C7",
"runtime_id": "runtime-1",
"working_directory_id": "workdir-1"
"workdir_attachments": [{
"alias": "checkout",
"working_directory_id": "workdir-1"
}]
})
.to_string(),
ToolExecutionContext::new("call-queued", "batch-1", 0),
@@ -318,6 +398,106 @@ mod tests {
assert!(worker_service.requests.lock().unwrap().is_empty());
}
#[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(),
};
let cases = [
(
serde_json::json!({
"ticket_id": "T-482",
"runtime_id": "runtime-1",
"workdir_attachments": []
}),
"must contain at least one Workdir",
),
(
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`",
),
(
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 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"
}
]
}),
"relative_cwd must be a normalized relative path inside the Workdir",
),
];
for (index, (input, expected)) in cases.into_iter().enumerate() {
let error = tool
.execute(
&input.to_string(),
ToolExecutionContext::new(format!("call-{index}"), "batch-validation", index),
)
.await
.unwrap_err();
assert!(
error.to_string().contains(expected),
"unexpected error for case {index}: {error}"
);
}
assert!(worker_service.requests.lock().unwrap().is_empty());
}
#[test]
fn orchestration_descriptor_requires_ticket_and_worker_services() {
let descriptor = orchestration_feature().descriptor();
@@ -341,6 +521,8 @@ mod tests {
for field in [
"ticket_id",
"runtime_id",
"workdir_attachments",
"alias",
"working_directory_id",
"relative_cwd",
] {