worker: support typed initial submit on spawn
This commit is contained in:
@@ -8,8 +8,10 @@ license.workspace = true
|
||||
default = ["stream"]
|
||||
stream = ["dep:tokio"]
|
||||
typescript = ["dep:ts-rs"]
|
||||
json-schema = ["dep:schemars"]
|
||||
|
||||
[dependencies]
|
||||
schemars = { workspace = true, optional = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["io-util"], optional = true }
|
||||
|
||||
@@ -183,6 +183,7 @@ impl WorkerEvent {
|
||||
/// the dropped intent.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Segment {
|
||||
/// Free-form text. The fallback every client can produce.
|
||||
|
||||
@@ -3427,6 +3427,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_does_not_redispatch_spawn_initial_submit() {
|
||||
let backend = Arc::new(TestExecutionBackend::default());
|
||||
let runtime = Runtime::with_execution_backend(
|
||||
RuntimeOptions {
|
||||
..RuntimeOptions::default()
|
||||
},
|
||||
backend.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||
let mut request = task_request("flow restore");
|
||||
request.initial_input = Some(WorkerInput {
|
||||
kind: WorkerInputKind::User,
|
||||
content: String::new(),
|
||||
segments: Some(vec![
|
||||
protocol::Segment::Flow {
|
||||
selector: "builtin:coder-review".to_string(),
|
||||
},
|
||||
protocol::Segment::text("Implement Ticket 00001"),
|
||||
]),
|
||||
});
|
||||
let detail = runtime.create_worker(request).unwrap();
|
||||
assert_eq!(backend.dispatched_inputs.lock().unwrap().len(), 1);
|
||||
|
||||
runtime
|
||||
.stop_worker(&detail.worker_ref, Some("restore test".to_string()))
|
||||
.unwrap();
|
||||
runtime.restore_worker(&detail.worker_ref).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
backend.dispatched_inputs.lock().unwrap().len(),
|
||||
1,
|
||||
"restore must continue durable Worker state without replaying spawn initial input"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_input_dispatches_segment_only_flow_submission() {
|
||||
let backend = Arc::new(TestExecutionBackend::default());
|
||||
|
||||
@@ -16,7 +16,7 @@ session-store = { workspace = true }
|
||||
secrets = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
mcp = { workspace = true }
|
||||
protocol = { workspace = true }
|
||||
protocol = { workspace = true, features = ["json-schema"] }
|
||||
client = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -9,6 +9,8 @@ use llm_engine::tool::{
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use protocol::Segment;
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
|
||||
ToolDeclaration,
|
||||
@@ -96,8 +98,10 @@ struct WorkerSpawnInput {
|
||||
profile: String,
|
||||
#[serde(default)]
|
||||
display_name: Option<String>,
|
||||
/// Normal typed initial user submission delivered after spawn. An empty
|
||||
/// vector starts the Worker without initial input.
|
||||
#[serde(default)]
|
||||
initial_text: Option<String>,
|
||||
initial_submit: Vec<Segment>,
|
||||
#[serde(default)]
|
||||
relative_cwd: Option<String>,
|
||||
}
|
||||
@@ -107,7 +111,7 @@ struct WorkerSpawnRequest {
|
||||
runtime_id: String,
|
||||
display_name: String,
|
||||
profile: String,
|
||||
initial_text: String,
|
||||
initial_submit: Vec<Segment>,
|
||||
working_directory: WorkerWorkingDirectorySelection,
|
||||
}
|
||||
|
||||
@@ -197,7 +201,7 @@ impl Tool for WorkspaceWorkerTool {
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "Workspace Worker".to_string()),
|
||||
profile: non_empty(input.profile, "profile")?,
|
||||
initial_text: input.initial_text.unwrap_or_default(),
|
||||
initial_submit: input.initial_submit,
|
||||
working_directory: WorkerWorkingDirectorySelection {
|
||||
working_directory_id: authority_id(
|
||||
&input.working_directory_id,
|
||||
@@ -320,7 +324,78 @@ fn validate_relative_cwd(value: &str) -> Result<String, ToolError> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::*;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RecordingWorkspaceClient {
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
}
|
||||
|
||||
impl WorkspaceClient for RecordingWorkspaceClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace/test")
|
||||
}
|
||||
|
||||
fn kind(&self) -> &str {
|
||||
"recording"
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "{}".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_spawn_forwards_typed_initial_submit_to_workspace_api() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
let tool = WorkspaceWorkerTool {
|
||||
operation: WorkerOperation::Spawn,
|
||||
client: client.clone(),
|
||||
workspace_id: "workspace%2Ftest".to_string(),
|
||||
};
|
||||
tool.execute(
|
||||
&serde_json::json!({
|
||||
"runtime_id": "runtime-1",
|
||||
"working_directory_id": "workdir-1",
|
||||
"profile": "builtin:coder",
|
||||
"initial_submit": [
|
||||
{ "kind": "flow", "selector": "builtin:coder-review" },
|
||||
{ "kind": "text", "content": "Implement Ticket 00001" }
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].path, "/api/w/workspace%2Ftest/workers");
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(body["initial_submit"][0]["kind"], "flow");
|
||||
assert_eq!(
|
||||
body["initial_submit"][0]["selector"],
|
||||
"builtin:coder-review"
|
||||
);
|
||||
assert_eq!(body["initial_submit"][1]["kind"], "text");
|
||||
assert!(body.get("initial_text").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_tool_family_is_distinct_from_sub_worker_tools() {
|
||||
@@ -330,13 +405,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_spawn_schema_exposes_normal_typed_segment_variants() {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(WorkerSpawnInput)).unwrap();
|
||||
let text = serde_json::to_string(&schema).unwrap();
|
||||
assert!(text.contains("initial_submit"));
|
||||
assert!(text.contains("selector"));
|
||||
assert!(text.contains("flow"));
|
||||
assert!(!text.contains("initial_text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_spawn_request_uses_authority_ids_without_runtime_paths() {
|
||||
let request = WorkerSpawnRequest {
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
display_name: "Coder".to_string(),
|
||||
profile: "builtin:coder".to_string(),
|
||||
initial_text: "Implement the Ticket".to_string(),
|
||||
initial_submit: vec![
|
||||
Segment::Flow {
|
||||
selector: "builtin:coder-review".to_string(),
|
||||
},
|
||||
Segment::text("Implement the Ticket"),
|
||||
],
|
||||
working_directory: WorkerWorkingDirectorySelection {
|
||||
working_directory_id: "wd-1".to_string(),
|
||||
relative_cwd: Some("repo".to_string()),
|
||||
@@ -348,6 +438,13 @@ mod tests {
|
||||
assert!(value.get("cwd").is_none());
|
||||
assert!(value.get("runtime_url").is_none());
|
||||
assert!(value["working_directory"].get("mode").is_none());
|
||||
assert_eq!(value["initial_submit"][0]["kind"], "flow");
|
||||
assert_eq!(
|
||||
value["initial_submit"][0]["selector"],
|
||||
"builtin:coder-review"
|
||||
);
|
||||
assert_eq!(value["initial_submit"][1]["kind"], "text");
|
||||
assert!(value.get("initial_text").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::Error;
|
||||
use crate::resource_broker::{BackendResourceBroker, BackendResourceTarget};
|
||||
use chrono::Utc;
|
||||
use protocol::Segment;
|
||||
use reqwest::blocking::{Client as BlockingHttpClient, RequestBuilder};
|
||||
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
|
||||
use reqwest::{Client as AsyncHttpClient, StatusCode, Url};
|
||||
@@ -359,8 +360,8 @@ pub struct WorkerSpawnRequest {
|
||||
pub profile: ProfileSelector,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ticket_assignment: Option<WorkerTicketAssignmentRequest>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub initial_input: Option<EmbeddedWorkerInput>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub initial_submit: Vec<Segment>,
|
||||
/// Optional safe working-directory creation request. The Workspace server resolves
|
||||
/// this into a runtime-internal `WorkingDirectoryRequest` from configured
|
||||
/// repositories before calling a host.
|
||||
@@ -410,6 +411,17 @@ pub enum WorkerSpawnAcceptanceRequirement {
|
||||
RunAccepted { expected_segments: usize },
|
||||
}
|
||||
|
||||
fn initial_worker_input(segments: &[Segment]) -> Option<EmbeddedWorkerInput> {
|
||||
if segments.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(EmbeddedWorkerInput {
|
||||
kind: EmbeddedWorkerInputKind::User,
|
||||
content: Segment::flatten_to_text(segments),
|
||||
segments: Some(segments.to_vec()),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerSpawnResult {
|
||||
pub state: WorkerOperationState,
|
||||
@@ -1141,6 +1153,30 @@ impl RuntimeRegistry {
|
||||
request: WorkerSpawnRequest,
|
||||
) -> Result<WorkerSpawnResult, RuntimeRegistryError> {
|
||||
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||
match request.acceptance {
|
||||
WorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments }
|
||||
if expected_segments != request.initial_submit.len() =>
|
||||
{
|
||||
return Err(RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
code: "worker_initial_segment_count_mismatch".to_string(),
|
||||
message: format!(
|
||||
"spawn acceptance expects {expected_segments} initial segment(s), request carries {}",
|
||||
request.initial_submit.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
WorkerSpawnAcceptanceRequirement::SocketReady if !request.initial_submit.is_empty() => {
|
||||
return Err(RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: runtime_id.to_string(),
|
||||
code: "worker_initial_submit_require_run_acceptance".to_string(),
|
||||
message:
|
||||
"spawn requests with initial segments must require RunAccepted acceptance"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let runtime = self.runtime(runtime_id)?;
|
||||
Ok(runtime.spawn_worker(request))
|
||||
}
|
||||
@@ -1865,7 +1901,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
display_name: request.requested_worker_name.clone(),
|
||||
config_bundle: None,
|
||||
profile_source,
|
||||
initial_input: request.initial_input.clone(),
|
||||
initial_input: initial_worker_input(&request.initial_submit),
|
||||
working_directory_request: request.resolved_working_directory_request.clone(),
|
||||
working_directory: request.resolved_working_directory.clone(),
|
||||
worker_observation_enabled: request.resolved_worker_observation_enabled,
|
||||
@@ -2970,7 +3006,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
display_name: request.requested_worker_name.clone(),
|
||||
config_bundle: None,
|
||||
profile_source,
|
||||
initial_input: request.initial_input.clone(),
|
||||
initial_input: initial_worker_input(&request.initial_submit),
|
||||
working_directory_request: request.resolved_working_directory_request.clone(),
|
||||
working_directory: request.resolved_working_directory.clone(),
|
||||
worker_observation_enabled: request.resolved_worker_observation_enabled,
|
||||
@@ -4551,7 +4587,7 @@ mod tests {
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_input: None,
|
||||
initial_submit: Vec::new(),
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
@@ -4601,29 +4637,48 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_runtime_rejects_system_initial_input_without_worker_projection() {
|
||||
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
||||
"local:test",
|
||||
Arc::new(AcceptingExecutionBackend::default()),
|
||||
)
|
||||
.expect("test backend should connect");
|
||||
fn worker_spawn_idempotency_fingerprint_covers_canonical_initial_submit() {
|
||||
let mut request = embedded_spawn_request();
|
||||
request.initial_input = Some(EmbeddedWorkerInput {
|
||||
kind: EmbeddedWorkerInputKind::Notify,
|
||||
content: "system/role instruction belongs in profile".to_string(),
|
||||
segments: None,
|
||||
request.ticket_assignment = Some(WorkerTicketAssignmentRequest {
|
||||
ticket_id: "00001KVZSGT0Q".to_string(),
|
||||
operation_id: "operation-1".to_string(),
|
||||
});
|
||||
request.initial_submit = vec![
|
||||
Segment::Flow {
|
||||
selector: "builtin:coder-review".to_string(),
|
||||
},
|
||||
Segment::text("Implement Ticket 00001KVZSGT0Q"),
|
||||
];
|
||||
request.acceptance = WorkerSpawnAcceptanceRequirement::RunAccepted {
|
||||
expected_segments: request.initial_submit.len(),
|
||||
};
|
||||
|
||||
let spawned = runtime.spawn_worker(request);
|
||||
assert_eq!(spawned.state, WorkerOperationState::Rejected);
|
||||
assert!(spawned.worker.is_none());
|
||||
assert!(spawned.diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic.code == "embedded_worker_initial_input_kind_invalid"
|
||||
&& diagnostic
|
||||
.message
|
||||
.contains("initial worker input must be user input")
|
||||
}));
|
||||
assert!(runtime.list_workers(10).items.is_empty());
|
||||
let first = worker_spawn_idempotency(&request).unwrap().unwrap();
|
||||
let repeated = worker_spawn_idempotency(&request).unwrap().unwrap();
|
||||
assert_eq!(first, repeated);
|
||||
assert_eq!(first.0, "operation-1");
|
||||
|
||||
let mut changed = request.clone();
|
||||
changed.initial_submit[1] = Segment::text("Different instruction");
|
||||
let changed = worker_spawn_idempotency(&changed).unwrap().unwrap();
|
||||
assert_ne!(first.1, changed.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_spawn_projects_typed_initial_submit_to_runtime_user_input() {
|
||||
let segments = vec![
|
||||
Segment::Flow {
|
||||
selector: "builtin:coder-review".to_string(),
|
||||
},
|
||||
Segment::text("Implement Ticket 00001"),
|
||||
];
|
||||
|
||||
let input = initial_worker_input(&segments).expect("typed initial input");
|
||||
|
||||
assert_eq!(input.kind, EmbeddedWorkerInputKind::User);
|
||||
assert_eq!(input.content, Segment::flatten_to_text(&segments));
|
||||
assert_eq!(input.segments, Some(segments));
|
||||
assert!(initial_worker_input(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4702,7 +4757,7 @@ mod tests {
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_input: None,
|
||||
initial_submit: Vec::new(),
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
@@ -4799,7 +4854,7 @@ mod tests {
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_input: None,
|
||||
initial_submit: Vec::new(),
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
@@ -4835,7 +4890,7 @@ mod tests {
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
|
||||
profile: ProfileSelector::Builtin("builtin:companion".to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_input: None,
|
||||
initial_submit: Vec::new(),
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
|
||||
@@ -18,6 +18,7 @@ use memory::backend::{
|
||||
MemoryBackendHttpResponse, MemoryBackendOperation, MemoryConsolidateStagingOperation,
|
||||
MemoryConsolidationOutput,
|
||||
};
|
||||
use protocol::Segment;
|
||||
use protocol::stream::{decode_method, encode_event};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -114,9 +115,6 @@ use worker_runtime::http_server::{
|
||||
RuntimeHttpSummaryResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse,
|
||||
};
|
||||
use worker_runtime::identity::RuntimeWorkerRef;
|
||||
use worker_runtime::interaction::{
|
||||
WorkerInput as EmbeddedWorkerInput, WorkerInputKind as EmbeddedWorkerInputKind,
|
||||
};
|
||||
|
||||
const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime";
|
||||
|
||||
@@ -1558,12 +1556,13 @@ pub struct BrowserWorkspaceOrchestratorResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BrowserCreateWorkerRequest {
|
||||
pub struct CreateWorkspaceWorkerRequest {
|
||||
pub runtime_id: String,
|
||||
pub display_name: String,
|
||||
#[serde(default)]
|
||||
pub profile: Option<String>,
|
||||
pub initial_text: String,
|
||||
#[serde(default)]
|
||||
pub initial_submit: Vec<Segment>,
|
||||
#[serde(default)]
|
||||
pub working_directory: Option<BrowserWorkerWorkingDirectorySelection>,
|
||||
}
|
||||
@@ -3949,11 +3948,7 @@ fn start_memory_staging_consolidation(
|
||||
&api.config.workspace_created_at,
|
||||
MEMORY_CONSOLIDATION_PROFILE,
|
||||
)?;
|
||||
let input = EmbeddedWorkerInput {
|
||||
kind: EmbeddedWorkerInputKind::User,
|
||||
content: input_content,
|
||||
segments: None,
|
||||
};
|
||||
let initial_submit = vec![Segment::text(input_content)];
|
||||
let result = api.spawn_workspace_worker(
|
||||
&runtime_id,
|
||||
WorkerSpawnRequest {
|
||||
@@ -3964,7 +3959,7 @@ fn start_memory_staging_consolidation(
|
||||
},
|
||||
profile: profile_selector,
|
||||
ticket_assignment: None,
|
||||
initial_input: Some(input),
|
||||
initial_submit,
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
@@ -4510,7 +4505,7 @@ async fn scoped_start_workspace_orchestrator(
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:orchestrator".to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_input: None,
|
||||
initial_submit: Vec::new(),
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
@@ -4574,7 +4569,7 @@ fn workspace_orchestrator_is_online(worker: &WorkerSummary) -> bool {
|
||||
async fn scoped_create_workspace_worker(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Json(request): Json<BrowserCreateWorkerRequest>,
|
||||
Json(request): Json<CreateWorkspaceWorkerRequest>,
|
||||
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
create_workspace_worker(State(api), Json(request)).await
|
||||
@@ -6832,9 +6827,38 @@ fn configured_working_directory_request(
|
||||
))
|
||||
}
|
||||
|
||||
fn validate_worker_initial_submit(segments: &[Segment]) -> Result<()> {
|
||||
let flow_selectors = segments
|
||||
.iter()
|
||||
.filter_map(|segment| match segment {
|
||||
Segment::Flow { selector } => Some(selector),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if flow_selectors.len() > 1 {
|
||||
return Err(Error::InvalidInput(
|
||||
"initial_submit may contain at most one Flow segment".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(selector) = flow_selectors.first() {
|
||||
selector
|
||||
.parse::<flow::FlowSelector>()
|
||||
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
||||
}
|
||||
if segments
|
||||
.iter()
|
||||
.any(|segment| matches!(segment, Segment::Unknown))
|
||||
{
|
||||
return Err(Error::InvalidInput(
|
||||
"initial_submit must not contain unknown segment variants".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_workspace_worker(
|
||||
State(api): State<WorkspaceApi>,
|
||||
Json(request): Json<BrowserCreateWorkerRequest>,
|
||||
Json(request): Json<CreateWorkspaceWorkerRequest>,
|
||||
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
||||
let profile = request
|
||||
.profile
|
||||
@@ -6883,16 +6907,9 @@ async fn create_workspace_worker(
|
||||
if display_name == crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY {
|
||||
return Err(Error::ReservedWorkerName(display_name).into());
|
||||
}
|
||||
let initial_text = request.initial_text.trim().to_string();
|
||||
let initial_input = if initial_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(EmbeddedWorkerInput {
|
||||
kind: EmbeddedWorkerInputKind::User,
|
||||
content: initial_text.clone(),
|
||||
segments: None,
|
||||
})
|
||||
};
|
||||
let initial_submit = request.initial_submit;
|
||||
validate_worker_initial_submit(&initial_submit)?;
|
||||
let expected_segments = initial_submit.len();
|
||||
let selected_working_directory_id = request
|
||||
.working_directory
|
||||
.as_ref()
|
||||
@@ -6914,12 +6931,10 @@ async fn create_workspace_worker(
|
||||
WorkerSpawnRequest {
|
||||
requested_worker_name: Some(display_name.clone()),
|
||||
intent: WorkerSpawnIntent::WorkspaceCoding,
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
|
||||
expected_segments: if initial_input.is_some() { 1 } else { 0 },
|
||||
},
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments },
|
||||
profile: profile_selector,
|
||||
ticket_assignment: None,
|
||||
initial_input,
|
||||
initial_submit,
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory,
|
||||
@@ -10212,6 +10227,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_initial_submit_validate_flow_shape_before_spawn() {
|
||||
let valid = vec![
|
||||
Segment::Flow {
|
||||
selector: "builtin:coder-review".to_string(),
|
||||
},
|
||||
Segment::text("Implement Ticket 00001"),
|
||||
];
|
||||
assert!(validate_worker_initial_submit(&valid).is_ok());
|
||||
assert!(validate_worker_initial_submit(&[]).is_ok());
|
||||
|
||||
let duplicate = vec![
|
||||
Segment::Flow {
|
||||
selector: "builtin:coder-review".to_string(),
|
||||
},
|
||||
Segment::Flow {
|
||||
selector: "workspace:coder-review".to_string(),
|
||||
},
|
||||
];
|
||||
assert!(matches!(
|
||||
validate_worker_initial_submit(&duplicate),
|
||||
Err(Error::InvalidInput(message)) if message.contains("at most one")
|
||||
));
|
||||
assert!(matches!(
|
||||
validate_worker_initial_submit(&[Segment::Flow {
|
||||
selector: "coder-review".to_string(),
|
||||
}]),
|
||||
Err(Error::InvalidInput(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
validate_worker_initial_submit(&[Segment::Unknown]),
|
||||
Err(Error::InvalidInput(message)) if message.contains("unknown")
|
||||
));
|
||||
assert!(
|
||||
serde_json::from_value::<CreateWorkspaceWorkerRequest>(serde_json::json!({
|
||||
"runtime_id": "runtime-1",
|
||||
"display_name": "coder",
|
||||
"initial_text": "legacy parallel authority",
|
||||
"working_directory": { "kind": "without_workspace" }
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_source_auth_rejects_cross_workspace_mutation() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
@@ -10219,11 +10278,11 @@ mod tests {
|
||||
let api = test_api(workspace.path()).await;
|
||||
let Json(created) = create_workspace_worker(
|
||||
State(api.clone()),
|
||||
Json(BrowserCreateWorkerRequest {
|
||||
Json(CreateWorkspaceWorkerRequest {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||
display_name: "Scoped Worker".to_string(),
|
||||
profile: Some("builtin:coder".to_string()),
|
||||
initial_text: String::new(),
|
||||
initial_submit: Vec::new(),
|
||||
working_directory: None,
|
||||
}),
|
||||
)
|
||||
@@ -10252,11 +10311,11 @@ mod tests {
|
||||
|
||||
let Json(generic) = create_workspace_worker(
|
||||
State(api.clone()),
|
||||
Json(BrowserCreateWorkerRequest {
|
||||
Json(CreateWorkspaceWorkerRequest {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||
display_name: "Generic Orchestrator Profile Worker".to_string(),
|
||||
profile: Some("builtin:orchestrator".to_string()),
|
||||
initial_text: String::new(),
|
||||
initial_submit: Vec::new(),
|
||||
working_directory: None,
|
||||
}),
|
||||
)
|
||||
@@ -10266,11 +10325,11 @@ mod tests {
|
||||
assert!(find_workspace_orchestrator(&api).is_none());
|
||||
let reserved = create_workspace_worker(
|
||||
State(api.clone()),
|
||||
Json(BrowserCreateWorkerRequest {
|
||||
Json(CreateWorkspaceWorkerRequest {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||
display_name: crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.to_string(),
|
||||
profile: Some("builtin:orchestrator".to_string()),
|
||||
initial_text: String::new(),
|
||||
initial_submit: Vec::new(),
|
||||
working_directory: None,
|
||||
}),
|
||||
)
|
||||
@@ -11123,7 +11182,7 @@ mod tests {
|
||||
},
|
||||
profile: ProfileSelector::Builtin(MEMORY_CONSOLIDATION_PROFILE.to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_input: None,
|
||||
initial_submit: Vec::new(),
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
@@ -11305,7 +11364,7 @@ mod tests {
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_input: None,
|
||||
initial_submit: Vec::new(),
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
@@ -11521,7 +11580,7 @@ mod tests {
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_input: None,
|
||||
initial_submit: Vec::new(),
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
@@ -11663,7 +11722,7 @@ mod tests {
|
||||
ticket_id: first_ticket.id.clone(),
|
||||
operation_id: "spawn-assignment-operation".to_string(),
|
||||
}),
|
||||
initial_input: None,
|
||||
initial_submit: Vec::new(),
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
@@ -11895,7 +11954,7 @@ mod tests {
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||
ticket_assignment: Some(assignment.clone()),
|
||||
initial_input: None,
|
||||
initial_submit: Vec::new(),
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
@@ -12812,7 +12871,7 @@ mod tests {
|
||||
"runtime_id": "remote-runtime",
|
||||
"display_name": "Coding Worker",
|
||||
"profile": "builtin:coder",
|
||||
"initial_text": "",
|
||||
"initial_submit": [],
|
||||
"working_directory": {
|
||||
"working_directory_id": working_directory_id,
|
||||
"relative_cwd": "../escape"
|
||||
@@ -13173,7 +13232,7 @@ mod tests {
|
||||
serde_json::json!({
|
||||
"runtime_id": "embedded-worker-runtime",
|
||||
"display_name": "",
|
||||
"initial_text": ""
|
||||
"initial_submit": []
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -13239,7 +13298,7 @@ mod tests {
|
||||
"runtime_id": "remote-runtime",
|
||||
"display_name": "Remote Worker",
|
||||
"profile": "builtin:companion",
|
||||
"initial_text": ""
|
||||
"initial_submit": []
|
||||
})),
|
||||
StatusCode::BAD_REQUEST,
|
||||
)
|
||||
@@ -13352,7 +13411,7 @@ mod tests {
|
||||
"runtime_id": "embedded-worker-runtime",
|
||||
"display_name": "Coding Worker",
|
||||
"profile": "builtin:coder",
|
||||
"initial_text": "",
|
||||
"initial_submit": [],
|
||||
"kind": "internal"
|
||||
})),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
@@ -13898,7 +13957,7 @@ mod tests {
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_input: None,
|
||||
initial_submit: Vec::new(),
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
@@ -14418,7 +14477,7 @@ mod tests {
|
||||
"builtin:companion".to_string(),
|
||||
),
|
||||
ticket_assignment: None,
|
||||
initial_input: None,
|
||||
initial_submit: Vec::new(),
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
|
||||
Reference in New Issue
Block a user