chore: merge develop into hare/develop

This commit is contained in:
2026-09-24 01:01:35 +09:00
178 changed files with 61787 additions and 7023 deletions
+136 -110
View File
@@ -19,10 +19,7 @@ use crate::feature::{
ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration,
};
use crate::spawn::registry::{SpawnedWorkerRegistry, SubWorkerStopSummary};
use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
WorkspaceResponse,
};
use crate::worker::{WorkspaceClient, WorkspaceClientError, WorkspaceResponse};
const FEATURE_ID: &str = "worker";
const FEATURE_NAME: &str = "Worker";
@@ -76,16 +73,22 @@ pub trait WorkerControlService: Send + Sync {
&self,
request: WorkerLifecycleSpawnRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError>;
async fn restore_worker(
&self,
runtime_id: String,
worker_id: String,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let _ = (runtime_id, worker_id);
Err(WorkspaceClientError::Unavailable(
"Runtime Worker restore is unavailable".to_string(),
))
}
fn remove_runtime_worker(
&self,
runtime_id: &str,
worker_id: &str,
reason: &str,
) -> Result<WorkspaceResponse, WorkspaceClientError>;
async fn execute_runtime(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError>;
async fn ensure_permission(
&self,
subject: &super::worker_observation::WorkerObservationSubjectRef,
@@ -144,11 +147,9 @@ impl WorkerControlService for WorkspaceWorkerControlService {
async fn list_workers(&self) -> Result<WorkspaceResponse, WorkspaceClientError> {
let response = if self.runtime_worker_control {
self.execute_runtime(WorkspaceRequest::get(format!(
"/api/w/{}/worker-control/workers",
self.workspace_id
)))
.await?
self.client.execute_server_operation(
crate::worker::WorkspaceServerOperation::WorkerControlList,
)?
} else {
WorkspaceResponse {
status: 200,
@@ -210,19 +211,17 @@ impl WorkerControlService for WorkspaceWorkerControlService {
WorkerControlInputKind::User => "user",
WorkerControlInputKind::Notify => "notify",
};
self.execute_runtime(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{}/worker-control/workers/{runtime_id}/{worker_id}/input",
self.workspace_id
),
serde_json::json!({
"kind": kind,
"content": content,
})
.to_string(),
))
.await
self.client.execute_server_operation(
crate::worker::WorkspaceServerOperation::WorkerControlInput {
runtime_id,
worker_id,
request: server_api::RuntimeWorkerInputRequest {
kind: Some(kind.to_string()),
content,
segments: None,
},
},
)
}
WorkerControlSubject::SubWorker { name } => {
if kind != WorkerControlInputKind::User {
@@ -273,19 +272,29 @@ impl WorkerControlService for WorkspaceWorkerControlService {
runtime_id,
worker_id,
} => {
let action = match kind {
WorkerControlStopKind::Cancel => "cancel",
WorkerControlStopKind::Stop => "stop",
let operation = match kind {
WorkerControlStopKind::Cancel => {
crate::worker::WorkspaceServerOperation::WorkerControlCancel {
runtime_id,
worker_id,
request: server_api::RuntimeWorkerLifecycleRequest {
reason,
ticket_assignment: None,
},
}
}
WorkerControlStopKind::Stop => {
crate::worker::WorkspaceServerOperation::WorkerControlStop {
runtime_id,
worker_id,
request: server_api::RuntimeWorkerLifecycleRequest {
reason,
ticket_assignment: None,
},
}
}
};
self.execute_runtime(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{}/worker-control/workers/{runtime_id}/{worker_id}/{action}",
self.workspace_id
),
serde_json::json!({ "reason": reason }).to_string(),
))
.await
self.client.execute_server_operation(operation)
}
WorkerControlSubject::SubWorker { name } => {
if kind != WorkerControlStopKind::Stop {
@@ -329,12 +338,29 @@ impl WorkerControlService for WorkspaceWorkerControlService {
) -> Result<WorkspaceResponse, WorkspaceClientError> {
WorkspaceWorkerLifecycleService {
client: self.client.clone(),
workspace_id: self.workspace_id.clone(),
}
.spawn(request)
.await
}
async fn restore_worker(
&self,
runtime_id: String,
worker_id: String,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if !self.runtime_worker_control {
return Err(WorkspaceClientError::Unavailable(
"Runtime Worker control is not enabled for this Worker".to_string(),
));
}
self.client.execute_server_operation(
crate::worker::WorkspaceServerOperation::WorkerControlRestore {
runtime_id,
worker_id,
},
)
}
fn remove_runtime_worker(
&self,
runtime_id: &str,
@@ -350,18 +376,6 @@ impl WorkerControlService for WorkspaceWorkerControlService {
.execute_worker_remove(runtime_id, worker_id, reason)
}
async fn execute_runtime(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if !self.runtime_worker_control {
return Err(WorkspaceClientError::Request(
"Runtime Worker control is not enabled for this Worker".to_string(),
));
}
self.client.execute(request)
}
async fn ensure_permission(
&self,
subject: &super::worker_observation::WorkerObservationSubjectRef,
@@ -391,10 +405,9 @@ impl WorkerControlService for WorkspaceWorkerControlService {
"Runtime Worker control is not enabled for this Worker".to_string(),
));
}
let response = self.client.execute(WorkspaceRequest::get(format!(
"/api/w/{}/worker-control/workers",
self.workspace_id
)))?;
let response = self.client.execute_server_operation(
crate::worker::WorkspaceServerOperation::WorkerControlList,
)?;
if !response.is_success() {
return Err(WorkspaceClientError::Request(format!(
"Workspace control request returned {}: {}",
@@ -445,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>,
@@ -459,7 +482,6 @@ pub struct WorkerLifecycleSpawnRequest {
struct WorkspaceWorkerLifecycleService {
client: Arc<dyn WorkspaceClient>,
workspace_id: String,
}
fn workspace_worker_create_request(
@@ -489,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),
})
}
@@ -505,12 +531,9 @@ impl WorkerLifecycleService for WorkspaceWorkerLifecycleService {
request: WorkerLifecycleSpawnRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let body = workspace_worker_create_request(request)?;
self.client.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{}/worker-control/workers", self.workspace_id),
serde_json::to_string(&body)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
))
self.client.execute_server_operation(
crate::worker::WorkspaceServerOperation::WorkerControlSpawn(body),
)
}
}
@@ -639,20 +662,17 @@ impl FeatureModule for ManageWorkerFeature {
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let workspace_id = self
.client
self.client
.workspace_id()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
FeatureInstallError::InvalidDescriptor(
"worker feature requires a Workspace id".to_string(),
)
})?
.to_string();
})?;
let lifecycle: Arc<dyn WorkerLifecycleService> =
Arc::new(WorkspaceWorkerLifecycleService {
client: self.client.clone(),
workspace_id: workspace_id.clone(),
});
context.services().provide(
ServiceDeclaration::new(
@@ -692,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
@@ -825,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 => {
"Start a fresh turn by sending user input to a known Idle Worker. Running or Paused targets reject Submit; use WorkerNotify for advisory information during work already in progress."
@@ -876,14 +898,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,
@@ -952,14 +977,7 @@ impl Tool for WorkspaceWorkerTool {
let input = parse::<WorkerTargetInput>(input_json, "WorkerRestore")?;
let (runtime_id, worker_id) = runtime_subject_ids(&input.subject, self.operation)?;
self.control
.execute_runtime(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!(
"/api/w/{}/worker-control/workers/{runtime_id}/{worker_id}/restore",
self.control.workspace_id()
),
"{}",
))
.restore_worker(runtime_id, worker_id)
.await
.map_err(control_tool_error)?
}
@@ -1197,7 +1215,7 @@ mod tests {
use std::sync::Mutex;
use super::*;
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
#[derive(Debug, Default)]
struct RecordingWorkspaceClient {
@@ -1367,15 +1385,6 @@ mod tests {
))
}
async fn execute_runtime(
&self,
_request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
Err(WorkspaceClientError::Unavailable(
"Runtime Worker control is disabled in this test".to_string(),
))
}
async fn ensure_permission(
&self,
_subject: &crate::feature::builtin::WorkerObservationSubjectRef,
@@ -1406,6 +1415,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": [
@@ -1451,7 +1461,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());
@@ -1725,11 +1735,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,
@@ -1744,16 +1764,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());
+12 -4
View File
@@ -104,7 +104,7 @@ async fn execute_memory_backend(
let response = client.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/memory/backend"),
serde_json::to_string(&operation)?,
serde_json::to_string(&server_api::MemoryBackendRequest(operation))?,
))?;
let status = reqwest::StatusCode::from_u16(response.status)
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
@@ -114,7 +114,7 @@ async fn execute_memory_backend(
body: response.body,
});
}
match serde_json::from_str::<MemoryBackendHttpResponse>(&response.body)? {
match serde_json::from_str::<server_api::MemoryBackendResponse>(&response.body)?.0 {
MemoryBackendHttpResponse::Ok { result } => Ok(result),
MemoryBackendHttpResponse::Error { message } => {
Err(WorkspaceMemoryBackendError::Backend(message))
@@ -138,7 +138,9 @@ async fn execute_memory_consolidation(
let response = client.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/memory/consolidation"),
serde_json::to_string(&operation)?,
serde_json::to_string(&server_api::MemoryConsolidateStagingRequest {
force: operation.force,
})?,
))?;
let status = reqwest::StatusCode::from_u16(response.status)
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
@@ -148,7 +150,13 @@ async fn execute_memory_consolidation(
body: response.body,
});
}
serde_json::from_str::<MemoryConsolidationOutput>(&response.body).map_err(Into::into)
let response = serde_json::from_str::<server_api::MemoryConsolidationResponse>(&response.body)?;
Ok(MemoryConsolidationOutput {
status: response.status,
summary: response.summary,
candidate_count: response.candidate_count,
total_bytes: response.total_bytes,
})
}
pub fn workspace_http_memory_tools(client: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
@@ -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",
] {
@@ -125,17 +125,16 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
async fn list_worker_sessions(
&self,
) -> Result<Vec<WorkerObservationSubject>, WorkerObservationError> {
let workspace_id = self.client.workspace_id().ok_or_else(|| {
let _workspace_id = self.client.workspace_id().ok_or_else(|| {
WorkerObservationError::Unavailable(
"Workspace observation requires a scoped Workspace client".to_string(),
)
})?;
let response = self
.client
.execute(crate::worker::WorkspaceRequest::get(format!(
"/api/w/{}/worker-observation/sessions",
workspace_id
)))
.execute_server_operation(
crate::worker::WorkspaceServerOperation::WorkerObservationSessions,
)
.map_err(workspace_client_error)?;
let body = workspace_response_body(response)?;
serde_json::from_str::<WorkspaceWorkerObservationListResponse>(&body)
@@ -147,20 +146,28 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
&self,
subject: &WorkerObservationSubjectRef,
) -> Result<WorkerSessionCapture, WorkerObservationError> {
let body = serde_json::to_string(subject)
.map_err(|error| WorkerObservationError::Unavailable(error.to_string()))?;
let workspace_id = self.client.workspace_id().ok_or_else(|| {
let request = match subject {
WorkerObservationSubjectRef::RuntimeWorker {
runtime_id,
worker_id,
} => server_api::WorkerObservationSubjectRef::RuntimeWorker {
runtime_id: runtime_id.clone(),
worker_id: worker_id.clone(),
},
WorkerObservationSubjectRef::SubWorker { name } => {
server_api::WorkerObservationSubjectRef::SubWorker { name: name.clone() }
}
};
let _workspace_id = self.client.workspace_id().ok_or_else(|| {
WorkerObservationError::Unavailable(
"Workspace observation requires a scoped Workspace client".to_string(),
)
})?;
let response = self
.client
.execute(crate::worker::WorkspaceRequest::json(
crate::worker::WorkspaceRequestMethod::Post,
format!("/api/w/{}/worker-observation/session", workspace_id),
body,
))
.execute_server_operation(
crate::worker::WorkspaceServerOperation::WorkerObservationCapture(request),
)
.map_err(workspace_client_error)?;
let body = workspace_response_body(response)?;
let response = serde_json::from_str::<WorkspaceWorkerObservationCaptureResponse>(&body)
+2 -2
View File
@@ -60,6 +60,6 @@ pub use worker::{
LocalWorkingDirectory, Worker, WorkerError, WorkerFilesystemAuthority, WorkerRunResult,
WorkerWorkspaceContext, WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError,
WorkspacePromptCatalogResolution, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
WorkspaceWorkerDiscoveryRequest, apply_worker_manifest, marker_workspace_client,
unavailable_workspace_client,
WorkspaceServerOperation, WorkspaceWorkerDiscoveryRequest, apply_worker_manifest,
marker_workspace_client, unavailable_workspace_client,
};
+4 -14
View File
@@ -1,23 +1,13 @@
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use crate::worker::WorkspaceClient;
pub use server_api::{
SkillActivationStatus, SkillCatalogEntry, SkillCatalogResponse, SkillDetailResponse,
SkillDiagnostic, SkillDiagnosticSeverity, SkillProjectionIdentity, SkillProjectionStatus,
SkillProvenance, SkillResourceRef, SkillSourceKind,
SkillActivationResponse, SkillActivationStatus, SkillCatalogEntry, SkillCatalogResponse,
SkillDetailResponse, SkillDiagnostic, SkillDiagnosticSeverity, SkillProjectionIdentity,
SkillProjectionStatus, SkillProvenance, SkillResourceRef, SkillSourceKind,
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillActivationResponse {
pub name: String,
pub provenance: SkillProvenance,
#[serde(default)]
pub diagnostics: Vec<SkillDiagnostic>,
/// Imported Markdown content to append to Worker history on explicit activation.
pub body: String,
}
#[derive(Debug, thiserror::Error)]
pub enum SkillClientError {
#[error("workspace client is unavailable: {0}")]
+128 -4
View File
@@ -544,6 +544,112 @@ pub struct WorkspaceWorkerDiscoveryRequest {
pub query: Option<String>,
}
#[derive(Debug, Clone)]
pub enum WorkspaceServerOperation {
WorkerControlList,
WorkerControlSpawn(server_api::CreateWorkspaceWorkerRequest),
WorkerControlInput {
runtime_id: String,
worker_id: String,
request: server_api::RuntimeWorkerInputRequest,
},
WorkerControlCancel {
runtime_id: String,
worker_id: String,
request: server_api::RuntimeWorkerLifecycleRequest,
},
WorkerControlStop {
runtime_id: String,
worker_id: String,
request: server_api::RuntimeWorkerLifecycleRequest,
},
WorkerControlRestore {
runtime_id: String,
worker_id: String,
},
WorkerObservationSessions,
WorkerObservationCapture(server_api::WorkerObservationSubjectRef),
}
fn workspace_server_json_request<T: serde::Serialize>(
path: String,
body: &T,
) -> Result<WorkspaceRequest, WorkspaceClientError> {
Ok(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
path,
serde_json::to_string(body)
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?,
))
}
fn encode_workspace_path_segment(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
encoded.push(byte as char);
} else {
use std::fmt::Write as _;
let _ = write!(encoded, "%{byte:02X}");
}
}
encoded
}
fn workspace_server_operation_request(
workspace_id: &str,
operation: WorkspaceServerOperation,
) -> Result<WorkspaceRequest, WorkspaceClientError> {
let workspace_id = encode_workspace_path_segment(workspace_id);
let base = format!("/api/w/{workspace_id}");
match operation {
WorkspaceServerOperation::WorkerControlList => Ok(WorkspaceRequest::get(format!(
"{base}/worker-control/workers"
))),
WorkspaceServerOperation::WorkerControlSpawn(request) => {
workspace_server_json_request(format!("{base}/worker-control/workers"), &request)
}
WorkspaceServerOperation::WorkerControlInput {
runtime_id,
worker_id,
request,
} => workspace_server_json_request(
format!("{base}/worker-control/workers/{runtime_id}/{worker_id}/input"),
&request,
),
WorkspaceServerOperation::WorkerControlCancel {
runtime_id,
worker_id,
request,
} => workspace_server_json_request(
format!("{base}/worker-control/workers/{runtime_id}/{worker_id}/cancel"),
&request,
),
WorkspaceServerOperation::WorkerControlStop {
runtime_id,
worker_id,
request,
} => workspace_server_json_request(
format!("{base}/worker-control/workers/{runtime_id}/{worker_id}/stop"),
&request,
),
WorkspaceServerOperation::WorkerControlRestore {
runtime_id,
worker_id,
} => Ok(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("{base}/worker-control/workers/{runtime_id}/{worker_id}/restore"),
"".to_string(),
)),
WorkspaceServerOperation::WorkerObservationSessions => Ok(WorkspaceRequest::get(format!(
"{base}/worker-observation/sessions"
))),
WorkspaceServerOperation::WorkerObservationCapture(request) => {
workspace_server_json_request(format!("{base}/worker-observation/session"), &request)
}
}
}
/// Path-free Workspace operation authority injected by Runtime/host code.
///
/// Workers receive this trait object rather than a Backend URL. The concrete
@@ -556,6 +662,18 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
fn execute(&self, request: WorkspaceRequest)
-> Result<WorkspaceResponse, WorkspaceClientError>;
fn execute_server_operation(
&self,
operation: WorkspaceServerOperation,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let workspace_id = self.workspace_id().ok_or_else(|| {
WorkspaceClientError::Unavailable(
"Workspace Server operation requires Workspace identity".to_string(),
)
})?;
self.execute(workspace_server_operation_request(workspace_id, operation)?)
}
/// Lists Workspace-visible Workers through dedicated Runtime-owned source
/// proof. Implementations must not fall back to generic Workspace request
/// authority.
@@ -3942,8 +4060,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.ok_or_else(|| {
WorkerError::FlowInput("Workspace client has no Workspace scope".to_string())
})?;
let request = flow::FlowSourceResolveRequest {
selector: selector.clone(),
let request = server_api::FlowSourceResolveRequest {
selector: selector.to_string(),
};
let response = workspace
.execute(WorkspaceRequest {
@@ -3968,13 +4086,19 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
response.status
)));
}
let source: flow::ResolvedFlowSource = serde_json::from_str(&response.body)
let wire_source: server_api::ResolvedFlowSource = serde_json::from_str(&response.body)
.map_err(|error| WorkerError::FlowInput(format!("decode Flow source: {error}")))?;
if source.workspace_id != workspace_id || source.selector != selector {
if wire_source.workspace_id != workspace_id || wire_source.selector != selector.to_string()
{
return Err(WorkerError::FlowInput(
"Workspace returned a Flow source outside the requested scope".to_string(),
));
}
let source: flow::ResolvedFlowSource =
serde_json::from_value(serde_json::to_value(wire_source).map_err(|error| {
WorkerError::FlowInput(format!("project Flow source transport: {error}"))
})?)
.map_err(|error| WorkerError::FlowInput(format!("convert Flow source: {error}")))?;
let (state, initial_instructions) =
flow::FlowRuntimeState::start(&source, uuid::Uuid::now_v7().to_string())
.map_err(|error| WorkerError::FlowInput(error.to_string()))?;