Compare commits

..

No commits in common. "3e217adc14102218d27ccaa5a2b723bb85045dc5" and "0b64eab148edbafb43187173455cda33ca4c570a" have entirely different histories.

47 changed files with 507 additions and 2806 deletions

View File

@ -38,22 +38,7 @@ Orchestrator の cwd が orchestration 用ブランチ/worktree の場合、通
## 検証 ## 検証
開発中は、変更した契約を証明する最小の target / filter から実行する。 検証は変更内容に応じて `cargo test` / `cargo check` / `git diff --check` など、妥当な範囲で行う。重い検証は必要性が高い場合に選ぶ。
```sh
cargo test -p <crate> --lib <test-or-module-filter>
cargo test -p <crate> --test <test-target> <test-filter>
cargo check -p <crate> -p <dependent-crate>
```
完了前には変更内容に応じて、対象crate全体のtest、影響するfeature構成、依存crateのcheckを追加する。通常の差分検証には以下を使う。
```sh
cargo fmt --all -- --check
git diff --check HEAD
```
workspace全体、E2E、Nix/Docker buildなどの重い検証は、変更した境界を狭い検証では証明できない場合や明示的に要求された場合に選ぶ。実行した検証が何を証明するのかを意識し、広い検証を形式的に回すだけにしない。
--- ---

View File

@ -80,6 +80,6 @@ cargo check --workspace --all-targets
cargo test --workspace cargo test --workspace
``` ```
Real-process E2E testing is opt-in. Do not design or implement E2E coverage unless the work explicitly requires E2E; otherwise protect the narrower parser, API, protocol, authority, or runtime boundary. E2E testing with real spawned processes is not yet designed. Keep changes scoped, preserve durable authority boundaries, and prefer clear type-safe structure over short-term compatibility layers.
License: MIT. See [`LICENSE`](LICENSE). License: MIT. See [`LICENSE`](LICENSE).

View File

@ -145,6 +145,8 @@ pub struct BackendWorkerSummary {
pub display_name: String, pub display_name: String,
pub label: String, pub label: String,
#[serde(default)] #[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub profile: Option<String>, pub profile: Option<String>,
#[serde(default)] #[serde(default)]
pub singleton_key: Option<String>, pub singleton_key: Option<String>,

View File

@ -252,7 +252,7 @@ pub enum TicketRoleLaunchError {
#[error(transparent)] #[error(transparent)]
LaunchConfig(#[from] TicketRoleLaunchConfigError), LaunchConfig(#[from] TicketRoleLaunchConfigError),
#[error( #[error(
"Ticket role `{role}` profile selector `{selector}` is not resolvable before launch: {message}. Configure `[ticket.roles.{role}].profile` with an executable concrete profile selector such as `builtin:companion` or a project/user profile" "Ticket role `{role}` profile selector `{selector}` is not resolvable before launch: {message}. Configure `[ticket.roles.{role}].profile` with an executable concrete profile selector such as `builtin:default` or a project/user profile"
)] )]
ProfileResolution { ProfileResolution {
role: TicketRole, role: TicketRole,
@ -701,20 +701,17 @@ mod tests {
let mut config = String::from("[ticket]\n"); let mut config = String::from("[ticket]\n");
for role in roles { for role in roles {
config.push_str(&format!( config.push_str(&format!(
"\n[ticket.roles.{role}]\nprofile = \"builtin:companion\"\n" "\n[ticket.roles.{role}]\nprofile = \"builtin:default\"\n"
)); ));
} }
write_config(workspace, &config); write_config(workspace, &config);
} }
fn text_segment(plan: &TicketRoleLaunchPlan) -> &str { fn text_segment(plan: &TicketRoleLaunchPlan) -> &str {
plan.run_segments match &plan.run_segments[1] {
.iter() Segment::Text { content } => content,
.find_map(|segment| match segment { other => panic!("expected text segment, got {other:?}"),
Segment::Text { content } => Some(content.as_str()), }
_ => None,
})
.expect("expected text segment")
} }
async fn write_test_event<W>(writer: &mut W, event: Event) async fn write_test_event<W>(writer: &mut W, event: Event)
@ -952,7 +949,7 @@ profile = "project:no-such-ticket-role-profile"
let plan = plan_ticket_role_launch(context).unwrap(); let plan = plan_ticket_role_launch(context).unwrap();
assert_eq!(plan.role, TicketRole::Intake); assert_eq!(plan.role, TicketRole::Intake);
assert_eq!(plan.profile, "builtin:companion"); assert_eq!(plan.profile, "builtin:default");
} }
#[test] #[test]
@ -965,7 +962,7 @@ profile = "project:no-such-ticket-role-profile"
language = "Japanese" language = "Japanese"
[ticket.roles.intake] [ticket.roles.intake]
profile = "builtin:companion" profile = "builtin:default"
"#, "#,
); );
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake); let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake);
@ -1027,7 +1024,7 @@ profile = "builtin:companion"
temp.path(), temp.path(),
r#" r#"
[ticket.roles.reviewer] [ticket.roles.reviewer]
profile = "builtin:companion" profile = "builtin:default"
launch_prompt = "$workspace/ticket/reviewer/launch" launch_prompt = "$workspace/ticket/reviewer/launch"
"#, "#,
); );
@ -1040,7 +1037,7 @@ launch_prompt = "$workspace/ticket/reviewer/launch"
let text = text_segment(&plan); let text = text_segment(&plan);
assert_eq!(plan.worker_name, "reviewer-fixed"); assert_eq!(plan.worker_name, "reviewer-fixed");
assert_eq!(plan.profile, "builtin:companion"); assert_eq!(plan.profile, "builtin:default");
assert_eq!( assert_eq!(
plan.launch_prompt_ref.as_deref(), plan.launch_prompt_ref.as_deref(),
Some("$workspace/ticket/reviewer/launch") Some("$workspace/ticket/reviewer/launch")
@ -1048,7 +1045,7 @@ launch_prompt = "$workspace/ticket/reviewer/launch"
assert!(matches!(&plan.run_segments[0], Segment::Text { .. })); assert!(matches!(&plan.run_segments[0], Segment::Text { .. }));
assert!(!text.contains("Configured launch_prompt")); assert!(!text.contains("Configured launch_prompt"));
assert!(!text.contains("$workspace/ticket/reviewer/launch")); assert!(!text.contains("$workspace/ticket/reviewer/launch"));
assert!(!text.contains("Profile selector: builtin:companion")); assert!(!text.contains("Profile selector: builtin:default"));
assert!(!text.contains("Role: reviewer")); assert!(!text.contains("Role: reviewer"));
assert!(!text.contains("system_instruction")); assert!(!text.contains("system_instruction"));
assert!(text.contains("Target Ticket:")); assert!(text.contains("Target Ticket:"));
@ -1059,7 +1056,7 @@ launch_prompt = "$workspace/ticket/reviewer/launch"
.spawn_config(WorkerRuntimeCommand::for_executable("/bin/yoi")) .spawn_config(WorkerRuntimeCommand::for_executable("/bin/yoi"))
.unwrap(); .unwrap();
assert_eq!(spawn.worker_name, "reviewer-fixed"); assert_eq!(spawn.worker_name, "reviewer-fixed");
assert_eq!(spawn.profile.as_deref(), Some("builtin:companion")); assert_eq!(spawn.profile.as_deref(), Some("builtin:default"));
assert_eq!(spawn.workspace_root, temp.path()); assert_eq!(spawn.workspace_root, temp.path());
assert!(spawn.cwd.is_none()); assert!(spawn.cwd.is_none());
assert_eq!( assert_eq!(

View File

@ -441,23 +441,6 @@ mod tests {
assert_eq!(cfg.context_window, 123_456); assert_eq!(cfg.context_window, 123_456);
} }
#[test]
fn codex_gpt56_sol_catalog_clamps_public_window_to_backend_limit() {
let providers = load_builtin_providers().unwrap();
let models = load_builtin_models().unwrap();
let manifest = ModelManifest {
ref_: Some("codex-oauth/gpt-5.6-sol".into()),
..Default::default()
};
let cfg = resolve_with_catalogs(&manifest, &providers, &models).unwrap();
assert_eq!(cfg.model_id, "gpt-5.6-sol");
assert_eq!(cfg.context_window, 272_000);
assert_eq!(cfg.max_context_window, Some(272_000));
let capability = cfg.capability.expect("catalog capability");
assert!(capability.vision);
assert!(capability.reasoning.is_some());
}
#[test] #[test]
fn codex_gpt55_catalog_records_effective_context_window() { fn codex_gpt55_catalog_records_effective_context_window() {
let providers = load_builtin_providers().unwrap(); let providers = load_builtin_providers().unwrap();

View File

@ -21,6 +21,7 @@ use crate::{
}; };
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1"; const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
const BUILTIN_DEFAULT_PROFILE_NAME: &str = "default";
const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml"); const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml");
const WORKSPACE_OVERRIDE_LOCAL_FILENAME: &str = "override.local.toml"; const WORKSPACE_OVERRIDE_LOCAL_FILENAME: &str = "override.local.toml";
@ -31,6 +32,11 @@ struct BuiltinProfile {
} }
const BUILTIN_PROFILES: &[BuiltinProfile] = &[ const BUILTIN_PROFILES: &[BuiltinProfile] = &[
BuiltinProfile {
name: BUILTIN_DEFAULT_PROFILE_NAME,
label: "builtin:default",
description: "Bundled default Yoi coding profile",
},
BuiltinProfile { BuiltinProfile {
name: "companion", name: "companion",
label: "builtin:companion", label: "builtin:companion",
@ -294,6 +300,23 @@ impl ProfileRegistry {
fn set_default(&mut self, default: ProfileDefault) { fn set_default(&mut self, default: ProfileDefault) {
self.default = Some(default); self.default = Some(default);
} }
fn set_builtin_default_if_available(&mut self) {
if self.default.is_some() {
return;
}
if self
.select_named(
Some(ProfileRegistrySource::Builtin),
BUILTIN_DEFAULT_PROFILE_NAME,
)
.is_ok()
{
self.default = Some(ProfileDefault {
source: Some(ProfileRegistrySource::Builtin),
name: BUILTIN_DEFAULT_PROFILE_NAME.to_string(),
});
}
}
fn mark_default_flags(&mut self) { fn mark_default_flags(&mut self) {
let Some(default) = self.default.clone() else { let Some(default) = self.default.clone() else {
return; return;
@ -343,6 +366,7 @@ impl ProfileDiscovery {
if let Some(path) = &self.project_config { if let Some(path) = &self.project_config {
load_profile_registry_file(&mut registry, ProfileRegistrySource::Project, path)?; load_profile_registry_file(&mut registry, ProfileRegistrySource::Project, path)?;
} }
registry.set_builtin_default_if_available();
registry.mark_default_flags(); registry.mark_default_flags();
Ok(registry) Ok(registry)
} }
@ -862,8 +886,9 @@ fn read_profile_artifact_file(path: &Path) -> Result<serde_json::Value, ProfileE
} }
fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> { fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
let mut value = builtin_base_profile_artifact(); let mut value = builtin_default_profile_artifact();
match label { match label {
"builtin:default" | "default" => Some(value),
"builtin:companion" | "companion" => { "builtin:companion" | "companion" => {
apply_role_profile( apply_role_profile(
&mut value, &mut value,
@ -945,7 +970,7 @@ fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
} }
} }
fn builtin_base_profile_artifact() -> serde_json::Value { fn builtin_default_profile_artifact() -> serde_json::Value {
serde_json::json!({ serde_json::json!({
"slug": "default", "slug": "default",
"description": "Default Yoi coding profile.", "description": "Default Yoi coding profile.",
@ -1370,18 +1395,16 @@ mod tests {
); );
} }
#[test] #[test]
fn builtin_profiles_do_not_define_an_implicit_default() { fn builtin_default_profile_is_registered_as_default() {
let registry = ProfileDiscovery::with_sources(None, None) let registry = ProfileDiscovery::with_sources(None, None)
.discover() .discover()
.unwrap(); .unwrap();
assert!(matches!( let default = registry.default_entry().unwrap();
registry.default_entry(), assert_eq!(default.source, ProfileRegistrySource::Builtin);
Err(ProfileError::NoDefaultProfile) assert_eq!(default.name, BUILTIN_DEFAULT_PROFILE_NAME);
)); assert!(default.is_default);
assert!(matches!( assert_eq!(default.path, None);
registry.select(&ProfileSelector::Default), assert_eq!(default.provenance, "builtin:default");
Err(ProfileError::NoDefaultProfile)
));
} }
#[test] #[test]
fn builtin_role_profiles_are_registered_and_resolve() { fn builtin_role_profiles_are_registered_and_resolve() {
@ -1785,12 +1808,12 @@ worker_context_max_tokens = 68000
assert!(err.to_string().contains("model.auth.file")); assert!(err.to_string().contains("model.auth.file"));
} }
#[test] #[test]
fn builtin_companion_resolves_without_external_evaluator() { fn builtin_default_resolves_without_external_evaluator() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new() let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path()) .with_workspace_base(tmp.path())
.resolve( .resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "companion"), &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"),
ProfileResolveOptions::with_worker_name("runtime-workspace"), ProfileResolveOptions::with_worker_name("runtime-workspace"),
) )
.unwrap(); .unwrap();
@ -1806,15 +1829,15 @@ worker_context_max_tokens = 68000
assert!(!resolved.manifest.feature.ticket.orchestration_control); assert!(!resolved.manifest.feature.ticket.orchestration_control);
assert_eq!( assert_eq!(
resolved.profile.as_ref().unwrap().name.as_deref(), resolved.profile.as_ref().unwrap().name.as_deref(),
Some("companion") Some("default")
); );
assert_eq!( assert_eq!(
resolved.source, resolved.source,
ProfileSource::Registry { ProfileSource::Registry {
source: ProfileRegistrySource::Builtin, source: ProfileRegistrySource::Builtin,
name: "companion".into(), name: "default".into(),
path: None, path: None,
provenance: Some("builtin:companion".into()), provenance: Some("builtin:default".into()),
} }
); );
} }

View File

@ -1192,7 +1192,7 @@ root = ".yoi/tickets"
temp.path(), temp.path(),
r#" r#"
[roles.intake] [roles.intake]
profile = "builtin:companion" profile = "builtin:default"
"#, "#,
); );
@ -1203,7 +1203,7 @@ profile = "builtin:companion"
.unwrap() .unwrap()
.profile .profile
.as_str(), .as_str(),
"builtin:companion" "builtin:default"
); );
assert_eq!( assert_eq!(
config config
@ -1313,7 +1313,7 @@ profile = "inherit"
temp.path(), temp.path(),
r#" r#"
[roles.investigator] [roles.investigator]
profile = "builtin:companion" profile = "builtin:default"
"#, "#,
); );

View File

@ -363,6 +363,7 @@ mod tests {
display_name: "label".to_string(), display_name: "label".to_string(),
singleton_key: None, singleton_key: None,
tags: Vec::new(), tags: Vec::new(),
role: None,
profile: profile.map(str::to_string), profile: profile.map(str::to_string),
workspace: BackendWorkerWorkspaceSummary { workspace: BackendWorkerWorkspaceSummary {
visibility: "workspace".to_string(), visibility: "workspace".to_string(),

View File

@ -2782,7 +2782,7 @@ fn dashboard_ticket_intake_finish_success_clears_composer_and_reports_pod() {
implementation_worktree_root: PathBuf::from("/tmp/workspace/.worktree"), implementation_worktree_root: PathBuf::from("/tmp/workspace/.worktree"),
role: TicketRole::Intake, role: TicketRole::Intake,
worker_name: "intake-worker".to_string(), worker_name: "intake-worker".to_string(),
profile: "builtin:companion".to_string(), profile: "builtin:default".to_string(),
launch_prompt_ref: None, launch_prompt_ref: None,
run_segments: vec![], run_segments: vec![],
}, },

View File

@ -699,10 +699,10 @@ description = "Project coder"
.unwrap(); .unwrap();
let (choices, default_index) = profile_choices_for_cwd(&project); let (choices, default_index) = profile_choices_for_cwd(&project);
assert_eq!(choices[0].selector.as_deref(), Some("builtin:companion")); assert_eq!(choices[0].selector.as_deref(), Some("builtin:default"));
assert_eq!( assert_eq!(
choices[0].label, choices[0].label,
"builtin:companion — Bundled Companion role profile" "builtin:default — Bundled default Yoi coding profile"
); );
let project_index = choices let project_index = choices
.iter() .iter()

View File

@ -37,8 +37,6 @@ pub enum RuntimeAuthError {
WrongAudience { expected: String, actual: String }, WrongAudience { expected: String, actual: String },
#[error("capability token is expired")] #[error("capability token is expired")]
Expired, Expired,
#[error("capability token is missing workspace scope")]
MissingWorkspaceScope,
#[error("capability token is missing required permission `{0}`")] #[error("capability token is missing required permission `{0}`")]
MissingPermission(String), MissingPermission(String),
} }
@ -189,9 +187,6 @@ pub fn verify_capability_token(
if claims.exp < now_seconds { if claims.exp < now_seconds {
return Err(RuntimeAuthError::Expired); return Err(RuntimeAuthError::Expired);
} }
if claims.workspace_id.trim().is_empty() {
return Err(RuntimeAuthError::MissingWorkspaceScope);
}
if let Some(required) = required_permission { if let Some(required) = required_permission {
if !claims if !claims
.permissions .permissions

View File

@ -8,10 +8,17 @@ use std::path::PathBuf;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")] #[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum ProfileSelector { pub enum ProfileSelector {
RuntimeDefault,
Builtin(String), Builtin(String),
Named(String), Named(String),
} }
impl Default for ProfileSelector {
fn default() -> Self {
Self::RuntimeDefault
}
}
/// Runtime fetch/caching metadata for a Backend-authored Decodal profile source archive. /// Runtime fetch/caching metadata for a Backend-authored Decodal profile source archive.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProfileSourceArchiveHttpRef { pub struct ProfileSourceArchiveHttpRef {
@ -223,8 +230,6 @@ pub struct WorkerSummary {
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub status: WorkerStatus, pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryStatus>, pub working_directory: Option<WorkingDirectoryStatus>,
pub profile: ProfileSelector, pub profile: ProfileSelector,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@ -242,8 +247,6 @@ pub struct WorkerDetail {
pub worker_id: WorkerId, pub worker_id: WorkerId,
pub status: WorkerStatus, pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<WorkingDirectoryStatus>, pub working_directory: Option<WorkingDirectoryStatus>,
pub profile: ProfileSelector, pub profile: ProfileSelector,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]

View File

@ -353,6 +353,7 @@ pub(crate) fn validate_profile_selector(
bundle_id: Option<&str>, bundle_id: Option<&str>,
) -> Result<(), RuntimeError> { ) -> Result<(), RuntimeError> {
match selector { match selector {
ProfileSelector::RuntimeDefault => Ok(()),
ProfileSelector::Builtin(value) | ProfileSelector::Named(value) => { ProfileSelector::Builtin(value) | ProfileSelector::Named(value) => {
if value.trim().is_empty() { if value.trim().is_empty() {
Err(RuntimeError::InvalidProfileSelector { Err(RuntimeError::InvalidProfileSelector {
@ -543,6 +544,7 @@ fn declaration_sort_key(declaration: &ConfigDeclaration) -> String {
fn profile_sort_key(selector: &ProfileSelector) -> String { fn profile_sort_key(selector: &ProfileSelector) -> String {
match selector { match selector {
ProfileSelector::RuntimeDefault => "runtime_default".to_string(),
ProfileSelector::Builtin(value) => format!("builtin\0{value}"), ProfileSelector::Builtin(value) => format!("builtin\0{value}"),
ProfileSelector::Named(value) => format!("named\0{value}"), ProfileSelector::Named(value) => format!("named\0{value}"),
} }

View File

@ -39,15 +39,6 @@ pub enum RuntimeError {
#[error("invalid request: {0}")] #[error("invalid request: {0}")]
InvalidRequest(String), InvalidRequest(String),
#[error(
"workspace `{workspace_id}` is owned by Runtime server `{owner_server_id}`, not `{requester_server_id}`"
)]
WorkspaceOwnerMismatch {
workspace_id: String,
owner_server_id: String,
requester_server_id: String,
},
#[error(transparent)] #[error(transparent)]
WorkingDirectory(#[from] WorkingDirectoryDiagnostic), WorkingDirectory(#[from] WorkingDirectoryDiagnostic),

View File

@ -292,7 +292,6 @@ pub(crate) struct PersistedRuntimeState {
pub(crate) next_event_id: u64, pub(crate) next_event_id: u64,
pub(crate) next_diagnostic_id: u64, pub(crate) next_diagnostic_id: u64,
pub(crate) workers: BTreeMap<WorkerId, PersistedWorkerRecord>, pub(crate) workers: BTreeMap<WorkerId, PersistedWorkerRecord>,
pub(crate) workspace_owners: BTreeMap<String, String>,
pub(crate) config_bundles: BTreeMap<String, ConfigBundle>, pub(crate) config_bundles: BTreeMap<String, ConfigBundle>,
pub(crate) events: Vec<RuntimeEvent>, pub(crate) events: Vec<RuntimeEvent>,
pub(crate) diagnostics: Vec<RuntimeDiagnostic>, pub(crate) diagnostics: Vec<RuntimeDiagnostic>,
@ -303,7 +302,6 @@ pub(crate) struct PersistedWorkerRecord {
pub(crate) worker_ref: WorkerRef, pub(crate) worker_ref: WorkerRef,
pub(crate) worker_id: WorkerId, pub(crate) worker_id: WorkerId,
pub(crate) request: CreateWorkerRequest, pub(crate) request: CreateWorkerRequest,
pub(crate) workspace_id: Option<String>,
pub(crate) working_directory: Option<WorkingDirectoryStatus>, pub(crate) working_directory: Option<WorkingDirectoryStatus>,
pub(crate) last_event_id: u64, pub(crate) last_event_id: u64,
} }
@ -320,8 +318,6 @@ struct RuntimeSnapshot {
next_diagnostic_id: u64, next_diagnostic_id: u64,
#[serde(default)] #[serde(default)]
config_bundles: BTreeMap<String, ConfigBundle>, config_bundles: BTreeMap<String, ConfigBundle>,
#[serde(default)]
workspace_owners: BTreeMap<String, String>,
diagnostics: Vec<RuntimeDiagnostic>, diagnostics: Vec<RuntimeDiagnostic>,
} }
@ -353,7 +349,6 @@ impl RuntimeSnapshot {
next_event_id: state.next_event_id, next_event_id: state.next_event_id,
next_diagnostic_id: state.next_diagnostic_id, next_diagnostic_id: state.next_diagnostic_id,
config_bundles: state.config_bundles.clone(), config_bundles: state.config_bundles.clone(),
workspace_owners: state.workspace_owners.clone(),
diagnostics: state.diagnostics.clone(), diagnostics: state.diagnostics.clone(),
} }
} }
@ -393,7 +388,6 @@ impl RuntimeSnapshot {
next_diagnostic_id: self.next_diagnostic_id, next_diagnostic_id: self.next_diagnostic_id,
workers, workers,
config_bundles: self.config_bundles, config_bundles: self.config_bundles,
workspace_owners: self.workspace_owners,
events, events,
diagnostics: self.diagnostics, diagnostics: self.diagnostics,
} }
@ -407,8 +401,6 @@ struct WorkerSnapshot {
worker_id: WorkerId, worker_id: WorkerId,
request: CreateWorkerRequest, request: CreateWorkerRequest,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
working_directory: Option<WorkingDirectoryStatus>, working_directory: Option<WorkingDirectoryStatus>,
/// One-way migration input for schema-v1 snapshots. New snapshots never /// One-way migration input for schema-v1 snapshots. New snapshots never
/// write the removed execution projection. /// write the removed execution projection.
@ -430,7 +422,6 @@ impl WorkerSnapshot {
worker_ref: worker.worker_ref.clone(), worker_ref: worker.worker_ref.clone(),
worker_id: worker.worker_id.clone(), worker_id: worker.worker_id.clone(),
request: worker.request.clone(), request: worker.request.clone(),
workspace_id: worker.workspace_id.clone(),
working_directory: worker.working_directory.clone(), working_directory: worker.working_directory.clone(),
legacy_execution: None, legacy_execution: None,
last_event_id: worker.last_event_id, last_event_id: worker.last_event_id,
@ -462,17 +453,10 @@ impl WorkerSnapshot {
} }
fn into_persisted(self) -> PersistedWorkerRecord { fn into_persisted(self) -> PersistedWorkerRecord {
let workspace_id = self.workspace_id.or_else(|| {
self.request
.workspace_api
.as_ref()
.map(|workspace_api| workspace_api.workspace_id.clone())
});
PersistedWorkerRecord { PersistedWorkerRecord {
worker_ref: self.worker_ref, worker_ref: self.worker_ref,
worker_id: self.worker_id, worker_id: self.worker_id,
request: self.request, request: self.request,
workspace_id,
working_directory: self.working_directory.or_else(|| { working_directory: self.working_directory.or_else(|| {
self.legacy_execution self.legacy_execution
.and_then(|execution| execution.working_directory) .and_then(|execution| execution.working_directory)

View File

@ -6,9 +6,9 @@
//! Runtime process directly; a backend is expected to own any browser-facing //! Runtime process directly; a backend is expected to own any browser-facing
//! credentials, registration, and policy. //! credentials, registration, and policy.
use crate::Runtime;
use crate::auth::{ use crate::auth::{
RuntimeAuthContext, RuntimeAuthError, RuntimeHttpAuthConfig, unix_now_seconds, RuntimeAuthContext, RuntimeHttpAuthConfig, unix_now_seconds, verify_capability_token,
verify_capability_token,
}; };
use crate::catalog::{ use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary, ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
@ -21,12 +21,11 @@ use crate::interaction::{WorkerInput, WorkerInteractionAck};
use crate::management::{RuntimeLimits, RuntimeSummary, WorkerDeleteResult}; use crate::management::{RuntimeLimits, RuntimeSummary, WorkerDeleteResult};
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
use crate::observation::WorkerObservationCursor; use crate::observation::WorkerObservationCursor;
use crate::{Runtime, RuntimeWorkspaceScope};
use axum::body::{Body, Bytes}; use axum::body::{Body, Bytes};
use axum::extract::rejection::{JsonRejection, QueryRejection}; use axum::extract::rejection::{JsonRejection, QueryRejection};
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Path, Query, State}; use axum::extract::{Path, Query, State};
use axum::http::{Method, Request, StatusCode, header}; use axum::http::{Method, Request, StatusCode, header};
use axum::middleware::{self, Next}; use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
@ -116,7 +115,6 @@ pub async fn serve_runtime_http(
listener: TcpListener, listener: TcpListener,
local_token: Option<String>, local_token: Option<String>,
) -> Result<(), RuntimeHttpServerError> { ) -> Result<(), RuntimeHttpServerError> {
let local_token = local_token.ok_or(RuntimeHttpServerError::AuthRequired)?;
axum::serve(listener, runtime_http_router(runtime, local_token)).await?; axum::serve(listener, runtime_http_router(runtime, local_token)).await?;
Ok(()) Ok(())
} }
@ -128,12 +126,9 @@ pub async fn serve_runtime_http_with_auth(
local_token: Option<String>, local_token: Option<String>,
auth: Option<RuntimeHttpAuthConfig>, auth: Option<RuntimeHttpAuthConfig>,
) -> Result<(), RuntimeHttpServerError> { ) -> Result<(), RuntimeHttpServerError> {
if local_token.is_none() && auth.is_none() {
return Err(RuntimeHttpServerError::AuthRequired);
}
axum::serve( axum::serve(
listener, listener,
runtime_http_router_with_optional_auth(runtime, local_token, auth), runtime_http_router_with_auth(runtime, local_token, auth),
) )
.await?; .await?;
Ok(()) Ok(())
@ -144,20 +139,12 @@ pub async fn serve_runtime_http_with_auth(
/// Handlers delegate to [`Runtime`] methods and keep Worker authority Runtime-local. /// Handlers delegate to [`Runtime`] methods and keep Worker authority Runtime-local.
/// The path contains only a Runtime-local `worker_id`; backend aliases are not /// The path contains only a Runtime-local `worker_id`; backend aliases are not
/// accepted or forwarded as Runtime authority. /// accepted or forwarded as Runtime authority.
pub fn runtime_http_router(runtime: Runtime, local_token: String) -> Router { pub fn runtime_http_router(runtime: Runtime, local_token: Option<String>) -> Router {
runtime_http_router_with_optional_auth(runtime, Some(local_token), None) runtime_http_router_with_auth(runtime, local_token, None)
} }
/// Build the REST router for an existing Runtime with signed capability-token auth. /// Build the REST router for an existing Runtime with signed capability-token auth.
pub fn runtime_http_router_with_auth( pub fn runtime_http_router_with_auth(
runtime: Runtime,
local_token: Option<String>,
auth: RuntimeHttpAuthConfig,
) -> Router {
runtime_http_router_with_optional_auth(runtime, local_token, Some(auth))
}
fn runtime_http_router_with_optional_auth(
runtime: Runtime, runtime: Runtime,
local_token: Option<String>, local_token: Option<String>,
auth: Option<RuntimeHttpAuthConfig>, auth: Option<RuntimeHttpAuthConfig>,
@ -396,20 +383,12 @@ async fn check_config_bundle(
async fn list_workers( async fn list_workers(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
query: Result<Query<RuntimeHttpWorkersQuery>, QueryRejection>, query: Result<Query<RuntimeHttpWorkersQuery>, QueryRejection>,
) -> RestResult<RuntimeHttpWorkersResponse> { ) -> RestResult<RuntimeHttpWorkersResponse> {
let Query(query) = query.map_err(RuntimeHttpRestError::query_rejection)?; let Query(query) = query.map_err(RuntimeHttpRestError::query_rejection)?;
let scope = auth_workspace_scope(&state, auth.as_ref())?; let workers = match query.status {
let workers = match (query.status, scope.as_ref()) { Some(RuntimeHttpWorkerStatusFilter::Stopped) => state.runtime.list_stopped_workers(),
(Some(RuntimeHttpWorkerStatusFilter::Stopped), Some(scope)) => { None => state.runtime.list_workers(),
state.runtime.list_stopped_workers_scoped(scope)
}
(Some(RuntimeHttpWorkerStatusFilter::Stopped), None) => {
state.runtime.list_stopped_workers()
}
(None, Some(scope)) => state.runtime.list_workers_scoped(scope),
(None, None) => state.runtime.list_workers(),
} }
.map_err(RuntimeHttpRestError::runtime)?; .map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkersResponse { workers })) Ok(Json(RuntimeHttpWorkersResponse { workers }))
@ -469,81 +448,67 @@ async fn cleanup_working_directory(
async fn get_worker( async fn get_worker(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>, Path(worker_id): Path<String>,
) -> RestResult<RuntimeHttpWorkerResponse> { ) -> RestResult<RuntimeHttpWorkerResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?; let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let worker = match auth_workspace_scope(&state, auth.as_ref())? { let worker = state
Some(scope) => state.runtime.worker_detail_scoped(&scope, &worker_ref), .runtime
None => state.runtime.worker_detail(&worker_ref), .worker_detail(&worker_ref)
} .map_err(RuntimeHttpRestError::runtime)?;
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerResponse { worker })) Ok(Json(RuntimeHttpWorkerResponse { worker }))
} }
async fn delete_worker( async fn delete_worker(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>, Path(worker_id): Path<String>,
) -> RestResult<RuntimeHttpWorkerDeleteResponse> { ) -> RestResult<RuntimeHttpWorkerDeleteResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?; let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let worker = match auth_workspace_scope(&state, auth.as_ref())? { let worker = state
Some(scope) => state.runtime.delete_worker_scoped(&scope, &worker_ref), .runtime
None => state.runtime.delete_worker(&worker_ref), .delete_worker(&worker_ref)
} .map_err(RuntimeHttpRestError::runtime)?;
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerDeleteResponse { worker })) Ok(Json(RuntimeHttpWorkerDeleteResponse { worker }))
} }
async fn create_worker( async fn create_worker(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
body: Result<Json<CreateWorkerRequest>, JsonRejection>, body: Result<Json<CreateWorkerRequest>, JsonRejection>,
) -> RestResult<RuntimeHttpWorkerResponse> { ) -> RestResult<RuntimeHttpWorkerResponse> {
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?; let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
let worker = match auth_workspace_scope(&state, auth.as_ref())? { let worker = state
Some(scope) => state.runtime.create_worker_scoped(&scope, request), .runtime
None => state.runtime.create_worker(request), .create_worker(request)
} .map_err(RuntimeHttpRestError::runtime)?;
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerResponse { worker })) Ok(Json(RuntimeHttpWorkerResponse { worker }))
} }
async fn restore_worker( async fn restore_worker(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>, Path(worker_id): Path<String>,
) -> RestResult<RuntimeHttpWorkerResponse> { ) -> RestResult<RuntimeHttpWorkerResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?; let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let worker = match auth_workspace_scope(&state, auth.as_ref())? { let worker = state
Some(scope) => state.runtime.restore_worker_scoped(&scope, &worker_ref), .runtime
None => state.runtime.restore_worker(&worker_ref), .restore_worker(&worker_ref)
} .map_err(RuntimeHttpRestError::runtime)?;
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerResponse { worker })) Ok(Json(RuntimeHttpWorkerResponse { worker }))
} }
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
async fn worker_protocol_ws( async fn worker_protocol_ws(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>, Path(worker_id): Path<String>,
Query(query): Query<RuntimeWorkerEventsWsQuery>, Query(query): Query<RuntimeWorkerEventsWsQuery>,
ws: WebSocketUpgrade, ws: WebSocketUpgrade,
) -> Result<Response, RuntimeHttpRestError> { ) -> Result<Response, RuntimeHttpRestError> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?; let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let scope = auth_workspace_scope(&state, auth.as_ref())?; state
match scope.as_ref() { .runtime
Some(scope) => state .worker_detail(&worker_ref)
.runtime .map_err(RuntimeHttpRestError::runtime)?;
.worker_detail_scoped(scope, &worker_ref)
.map(|_| ()),
None => state.runtime.worker_detail(&worker_ref).map(|_| ()),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(ws Ok(ws
.on_upgrade(move |socket| { .on_upgrade(move |socket| {
worker_protocol_ws_session(state.runtime, scope, worker_ref, query, socket) worker_protocol_ws_session(state.runtime, worker_ref, query, socket)
}) })
.into_response()) .into_response())
} }
@ -551,7 +516,6 @@ async fn worker_protocol_ws(
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
async fn worker_protocol_ws_session( async fn worker_protocol_ws_session(
runtime: Runtime, runtime: Runtime,
scope: Option<RuntimeWorkspaceScope>,
worker_ref: WorkerRef, worker_ref: WorkerRef,
query: RuntimeWorkerEventsWsQuery, query: RuntimeWorkerEventsWsQuery,
mut socket: WebSocket, mut socket: WebSocket,
@ -635,28 +599,20 @@ async fn worker_protocol_ws_session(
inbound = socket.next() => { inbound = socket.next() => {
match inbound { match inbound {
Some(Ok(WsMessage::Text(text))) => match decode_method(&text) { Some(Ok(WsMessage::Text(text))) => match decode_method(&text) {
Ok(method) => { Ok(method) => match runtime.send_protocol_method(&worker_ref, method) {
let result = match scope.as_ref() { Ok(events) => {
Some(scope) => { for event in events {
runtime.send_protocol_method_scoped(scope, &worker_ref, method)
}
None => runtime.send_protocol_method(&worker_ref, method),
};
match result {
Ok(events) => {
for event in events {
if !send_protocol_event(&mut socket, &event).await {
return;
}
}
}
Err(error) => {
let event = protocol_error_event(error.to_string());
if !send_protocol_event(&mut socket, &event).await { if !send_protocol_event(&mut socket, &event).await {
return; return;
} }
} }
} }
Err(error) => {
let event = protocol_error_event(error.to_string());
if !send_protocol_event(&mut socket, &event).await {
return;
}
}
}, },
Err(error) => { Err(error) => {
let event = protocol_error_event(format!( let event = protocol_error_event(format!(
@ -732,40 +688,29 @@ fn protocol_error_event(message: impl Into<String>) -> protocol::Event {
async fn send_worker_input( async fn send_worker_input(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>, Path(worker_id): Path<String>,
body: Result<Json<WorkerInput>, JsonRejection>, body: Result<Json<WorkerInput>, JsonRejection>,
) -> RestResult<RuntimeHttpWorkerInputResponse> { ) -> RestResult<RuntimeHttpWorkerInputResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?; let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let Json(input) = body.map_err(RuntimeHttpRestError::json_rejection)?; let Json(input) = body.map_err(RuntimeHttpRestError::json_rejection)?;
let ack = match auth_workspace_scope(&state, auth.as_ref())? { let ack = state
Some(scope) => state.runtime.send_input_scoped(&scope, &worker_ref, input), .runtime
None => state.runtime.send_input(&worker_ref, input), .send_input(&worker_ref, input)
} .map_err(RuntimeHttpRestError::runtime)?;
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerInputResponse { ack })) Ok(Json(RuntimeHttpWorkerInputResponse { ack }))
} }
async fn worker_completions( async fn worker_completions(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>, Path(worker_id): Path<String>,
body: Result<Json<RuntimeHttpWorkerCompletionsRequest>, JsonRejection>, body: Result<Json<RuntimeHttpWorkerCompletionsRequest>, JsonRejection>,
) -> RestResult<RuntimeHttpWorkerCompletionsResponse> { ) -> RestResult<RuntimeHttpWorkerCompletionsResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?; let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?; let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
let entries = match auth_workspace_scope(&state, auth.as_ref())? { let entries = state
Some(scope) => state.runtime.worker_completions_scoped( .runtime
&scope, .worker_completions(&worker_ref, request.kind, &request.prefix)
&worker_ref, .map_err(RuntimeHttpRestError::runtime)?;
request.kind,
&request.prefix,
),
None => state
.runtime
.worker_completions(&worker_ref, request.kind, &request.prefix),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerCompletionsResponse { Ok(Json(RuntimeHttpWorkerCompletionsResponse {
kind: request.kind, kind: request.kind,
prefix: request.prefix, prefix: request.prefix,
@ -775,37 +720,29 @@ async fn worker_completions(
async fn stop_worker( async fn stop_worker(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>, Path(worker_id): Path<String>,
body: Bytes, body: Bytes,
) -> RestResult<RuntimeHttpWorkerLifecycleResponse> { ) -> RestResult<RuntimeHttpWorkerLifecycleResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?; let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let request = parse_optional_lifecycle_request(body)?; let request = parse_optional_lifecycle_request(body)?;
let ack = match auth_workspace_scope(&state, auth.as_ref())? { let ack = state
Some(scope) => state .runtime
.runtime .stop_worker(&worker_ref, request.reason)
.stop_worker_scoped(&scope, &worker_ref, request.reason), .map_err(RuntimeHttpRestError::runtime)?;
None => state.runtime.stop_worker(&worker_ref, request.reason),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack })) Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack }))
} }
async fn cancel_worker( async fn cancel_worker(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
auth: Option<Extension<RuntimeAuthContext>>,
Path(worker_id): Path<String>, Path(worker_id): Path<String>,
body: Bytes, body: Bytes,
) -> RestResult<RuntimeHttpWorkerLifecycleResponse> { ) -> RestResult<RuntimeHttpWorkerLifecycleResponse> {
let worker_ref = worker_ref_for(&state.runtime, worker_id)?; let worker_ref = worker_ref_for(&state.runtime, worker_id)?;
let request = parse_optional_lifecycle_request(body)?; let request = parse_optional_lifecycle_request(body)?;
let ack = match auth_workspace_scope(&state, auth.as_ref())? { let ack = state
Some(scope) => state .runtime
.runtime .cancel_worker(&worker_ref, request.reason)
.cancel_worker_scoped(&scope, &worker_ref, request.reason), .map_err(RuntimeHttpRestError::runtime)?;
None => state.runtime.cancel_worker(&worker_ref, request.reason),
}
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack })) Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack }))
} }
@ -869,7 +806,12 @@ async fn require_runtime_auth(
return next.run(request).await; return next.run(request).await;
} }
Err(error) => { Err(error) => {
return runtime_auth_error_response(error).into_response(); return RuntimeHttpRestError::new(
StatusCode::UNAUTHORIZED,
"unauthorized",
format!("invalid Runtime capability token: {error}"),
)
.into_response();
} }
} }
} }
@ -894,59 +836,6 @@ async fn require_runtime_auth(
next.run(request).await next.run(request).await
} }
fn runtime_auth_error_response(error: RuntimeAuthError) -> RuntimeHttpRestError {
match error {
RuntimeAuthError::MissingPermission(permission) => RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"forbidden",
format!("Runtime capability token is missing required permission `{permission}`"),
),
RuntimeAuthError::MissingWorkspaceScope => RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"workspace_scope_required",
"Runtime capability token is missing workspace scope",
),
other => RuntimeHttpRestError::new(
StatusCode::UNAUTHORIZED,
"unauthorized",
format!("invalid Runtime capability token: {other}"),
),
}
}
fn auth_workspace_scope(
state: &RuntimeHttpState,
auth: Option<&Extension<RuntimeAuthContext>>,
) -> Result<Option<RuntimeWorkspaceScope>, RuntimeHttpRestError> {
let Some(Extension(context)) = auth else {
if state.auth.is_some() || state.local_token.is_some() {
return Err(RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"workspace_scope_required",
"Runtime worker operation requires a workspace-scoped authorization context",
));
}
return Ok(None);
};
let workspace_id = context.workspace_id.trim();
if workspace_id.is_empty() {
return Err(RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"workspace_scope_required",
"Runtime worker operation requires a non-empty workspace scope",
));
}
let server_id = context.server_id.trim();
if server_id.is_empty() {
return Err(RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"server_scope_required",
"Runtime worker operation requires a non-empty server scope",
));
}
Ok(Some(RuntimeWorkspaceScope::new(workspace_id, server_id)))
}
fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static str> { fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static str> {
if path == "/v1/runtime" { if path == "/v1/runtime" {
return None; return None;
@ -1046,7 +935,6 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
| RuntimeError::WorkerExecutionUnavailable { .. } | RuntimeError::WorkerExecutionUnavailable { .. }
| RuntimeError::ExecutionBackendUnavailable { .. } | RuntimeError::ExecutionBackendUnavailable { .. }
| RuntimeError::WorkerExecutionRejected { .. } => StatusCode::CONFLICT, | RuntimeError::WorkerExecutionRejected { .. } => StatusCode::CONFLICT,
RuntimeError::WorkspaceOwnerMismatch { .. } => StatusCode::FORBIDDEN,
RuntimeError::LimitTooLarge { .. } RuntimeError::LimitTooLarge { .. }
| RuntimeError::InvalidRequest(_) | RuntimeError::InvalidRequest(_)
| RuntimeError::InvalidInitialInputKind { .. } | RuntimeError::InvalidInitialInputKind { .. }
@ -1072,7 +960,6 @@ fn code_for_runtime_error(error: &RuntimeError) -> String {
"execution_backend_unavailable".to_string() "execution_backend_unavailable".to_string()
} }
RuntimeError::WorkerExecutionRejected { .. } => "worker_execution_rejected".to_string(), RuntimeError::WorkerExecutionRejected { .. } => "worker_execution_rejected".to_string(),
RuntimeError::WorkspaceOwnerMismatch { .. } => "workspace_owner_mismatch".to_string(),
RuntimeError::LimitTooLarge { .. } => "limit_too_large".to_string(), RuntimeError::LimitTooLarge { .. } => "limit_too_large".to_string(),
RuntimeError::InvalidRequest(_) => "invalid_request".to_string(), RuntimeError::InvalidRequest(_) => "invalid_request".to_string(),
RuntimeError::WorkingDirectory(diagnostic) => diagnostic.code.clone(), RuntimeError::WorkingDirectory(diagnostic) => diagnostic.code.clone(),
@ -1097,8 +984,6 @@ fn code_for_runtime_error(error: &RuntimeError) -> String {
pub enum RuntimeHttpServerError { pub enum RuntimeHttpServerError {
#[error(transparent)] #[error(transparent)]
Runtime(#[from] RuntimeError), Runtime(#[from] RuntimeError),
#[error("Runtime HTTP server requires capability-token auth or a local bearer token")]
AuthRequired,
#[error("Runtime HTTP server I/O failed: {0}")] #[error("Runtime HTTP server I/O failed: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
} }
@ -1106,11 +991,7 @@ pub enum RuntimeHttpServerError {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::auth::{ use crate::catalog::{ConfigBundleRef, ProfileSelector, WorkerStatus};
CapabilityTokenSigner, RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey,
capability_claims,
};
use crate::catalog::{ConfigBundleRef, ProfileSelector, WorkerStatus, WorkspaceApiRef};
use crate::config_bundle::{ use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
}; };
@ -1148,264 +1029,6 @@ mod tests {
.with_computed_digest() .with_computed_digest()
} }
fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest {
let mut request = task_request(objective);
request.workspace_api = Some(WorkspaceApiRef {
workspace_id: workspace_id.to_string(),
base_url: format!("https://workspace.example/{workspace_id}"),
});
request
}
fn auth_config_and_signer() -> (RuntimeHttpAuthConfig, CapabilityTokenSigner) {
let identity = RuntimeIdentityMaterial::generate("server-a").unwrap();
let signer = CapabilityTokenSigner::new(identity.identity_id.clone(), identity.private_key);
let auth = RuntimeHttpAuthConfig {
runtime_id: "runtime-test".to_string(),
trusted_servers: vec![TrustedServerKey {
server_id: identity.identity_id,
public_key: identity.public_key,
display_name: None,
}],
};
(auth, signer)
}
fn auth_config_and_two_signers() -> (
RuntimeHttpAuthConfig,
CapabilityTokenSigner,
CapabilityTokenSigner,
) {
let identity_a = RuntimeIdentityMaterial::generate("server-a").unwrap();
let identity_b = RuntimeIdentityMaterial::generate("server-b").unwrap();
let signer_a =
CapabilityTokenSigner::new(identity_a.identity_id.clone(), identity_a.private_key);
let signer_b =
CapabilityTokenSigner::new(identity_b.identity_id.clone(), identity_b.private_key);
let auth = RuntimeHttpAuthConfig {
runtime_id: "runtime-test".to_string(),
trusted_servers: vec![
TrustedServerKey {
server_id: identity_a.identity_id,
public_key: identity_a.public_key,
display_name: None,
},
TrustedServerKey {
server_id: identity_b.identity_id,
public_key: identity_b.public_key,
display_name: None,
},
],
};
(auth, signer_a, signer_b)
}
fn token_for_workspace(signer: &CapabilityTokenSigner, workspace_id: &str) -> String {
token_for_workspace_with_permissions(
signer,
workspace_id,
[
"workers:list",
"workers:create",
"workers:read",
"workers:input",
"workers:stop",
"workers:protocol",
"workers:delete",
],
)
}
fn token_for_workspace_with_permissions<const N: usize>(
signer: &CapabilityTokenSigner,
workspace_id: &str,
permissions: [&str; N],
) -> String {
let claims = capability_claims(
signer.server_id(),
"runtime-test",
workspace_id,
permissions.into_iter().map(str::to_string).collect(),
3600,
)
.unwrap();
signer.sign(&claims).unwrap()
}
fn bearer_request(
method: Method,
uri: impl AsRef<str>,
token: &str,
body: impl Into<Body>,
) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri.as_ref())
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(body.into())
.unwrap()
}
#[tokio::test]
async fn capability_workspace_scope_filters_list_and_hides_detail() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
let (auth, signer) = auth_config_and_signer();
let token_a = token_for_workspace(&signer, "workspace-a");
let token_b = token_for_workspace(&signer, "workspace-b");
let app = runtime_http_router_with_auth(runtime, None, auth);
let create_a = scoped_task_request("a", "workspace-a");
let response = app
.clone()
.oneshot(bearer_request(
Method::POST,
"/v1/workers",
&token_a,
serde_json::to_vec(&create_a).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let worker_a: RuntimeHttpWorkerResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(worker_a.worker.workspace_id.as_deref(), Some("workspace-a"));
let create_b = scoped_task_request("b", "workspace-b");
let response = app
.clone()
.oneshot(bearer_request(
Method::POST,
"/v1/workers",
&token_b,
serde_json::to_vec(&create_b).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let worker_b: RuntimeHttpWorkerResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(worker_b.worker.workspace_id.as_deref(), Some("workspace-b"));
let response = app
.clone()
.oneshot(bearer_request(
Method::GET,
"/v1/workers",
&token_a,
Body::empty(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let workers: RuntimeHttpWorkersResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(workers.workers.len(), 1);
assert_eq!(workers.workers[0].worker_ref, worker_a.worker.worker_ref);
assert_eq!(
workers.workers[0].workspace_id.as_deref(),
Some("workspace-a")
);
let response = app
.oneshot(bearer_request(
Method::GET,
format!("/v1/workers/{}", worker_b.worker.worker_id),
&token_a,
Body::empty(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn capability_workspace_owner_binding_rejects_other_trusted_server() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
let (auth, signer_a, signer_b) = auth_config_and_two_signers();
let token_a = token_for_workspace(&signer_a, "workspace-a");
let token_b = token_for_workspace(&signer_b, "workspace-a");
let app = runtime_http_router_with_auth(runtime, None, auth);
let create_a = scoped_task_request("a", "workspace-a");
let response = app
.clone()
.oneshot(bearer_request(
Method::POST,
"/v1/workers",
&token_a,
serde_json::to_vec(&create_a).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let create_b = scoped_task_request("b", "workspace-a");
let response = app
.oneshot(bearer_request(
Method::POST,
"/v1/workers",
&token_b,
serde_json::to_vec(&create_b).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn capability_token_without_workspace_scope_is_forbidden() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
let (auth, signer) = auth_config_and_signer();
let token = token_for_workspace_with_permissions(&signer, "", ["workers:list"]);
let app = runtime_http_router_with_auth(runtime, None, auth);
let response = app
.oneshot(bearer_request(
Method::GET,
"/v1/workers",
&token,
Body::empty(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn capability_token_without_worker_permission_is_forbidden() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
let (auth, signer) = auth_config_and_signer();
let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:list"]);
let app = runtime_http_router_with_auth(runtime, None, auth);
let create = scoped_task_request("a", "workspace-a");
let response = app
.oneshot(bearer_request(
Method::POST,
"/v1/workers",
&token,
serde_json::to_vec(&create).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
fn task_request(_objective: &str) -> CreateWorkerRequest { fn task_request(_objective: &str) -> CreateWorkerRequest {
let profile = ProfileSelector::Builtin("builtin:coder".to_string()); let profile = ProfileSelector::Builtin("builtin:coder".to_string());
let bundle = test_bundle(profile.clone()); let bundle = test_bundle(profile.clone());
@ -1488,18 +1111,16 @@ mod tests {
} }
} }
async fn authed_json_request<T: Serialize>( async fn json_request<T: Serialize>(
app: Router, app: Router,
method: Method, method: Method,
uri: &str, uri: &str,
token: &str,
body: &T, body: &T,
) -> axum::response::Response { ) -> axum::response::Response {
app.oneshot( app.oneshot(
Request::builder() Request::builder()
.method(method) .method(method)
.uri(uri) .uri(uri)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json") .header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(body).unwrap())) .body(Body::from(serde_json::to_vec(body).unwrap()))
.unwrap(), .unwrap(),
@ -1520,24 +1141,6 @@ mod tests {
.unwrap() .unwrap()
} }
async fn authed_empty_request(
app: Router,
method: Method,
uri: &str,
token: &str,
) -> axum::response::Response {
app.oneshot(
Request::builder()
.method(method)
.uri(uri)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
}
async fn read_json<T: for<'de> Deserialize<'de>>(response: Response) -> T { async fn read_json<T: for<'de> Deserialize<'de>>(response: Response) -> T {
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&body).unwrap() serde_json::from_slice(&body).unwrap()
@ -1553,14 +1156,12 @@ mod tests {
"builtin:coder".to_string(), "builtin:coder".to_string(),
))) )))
.unwrap(); .unwrap();
let token = "local-token"; let app = runtime_http_router(runtime.clone(), None);
let app = runtime_http_router(runtime.clone(), token.to_string());
let response = authed_json_request( let response = json_request(
app.clone(), app.clone(),
Method::POST, Method::POST,
"/v1/workers", "/v1/workers",
token,
&task_request("rest"), &task_request("rest"),
) )
.await; .await;
@ -1572,84 +1173,77 @@ mod tests {
); );
let input = WorkerInput::user("hello from backend"); let input = WorkerInput::user("hello from backend");
let response = authed_json_request( let response = json_request(
app.clone(), app.clone(),
Method::POST, Method::POST,
&format!("/v1/workers/{}/input", created.worker.worker_id), &format!("/v1/workers/{}/input", created.worker.worker_id),
token,
&input, &input,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let _input_ack: RuntimeHttpWorkerInputResponse = read_json(response).await; let _input_ack: RuntimeHttpWorkerInputResponse = read_json(response).await;
let response = authed_empty_request( let response = empty_request(
app.clone(), app.clone(),
Method::GET, Method::GET,
&format!("/v1/workers/{}", created.worker.worker_id), &format!("/v1/workers/{}", created.worker.worker_id),
token,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let _detail: RuntimeHttpWorkerResponse = read_json(response).await; let _detail: RuntimeHttpWorkerResponse = read_json(response).await;
let response = authed_empty_request( let response = empty_request(
app.clone(), app.clone(),
Method::GET, Method::GET,
&format!("/v1/workers/{}/transcript", created.worker.worker_id), &format!("/v1/workers/{}/transcript", created.worker.worker_id),
token,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = authed_empty_request( let response = empty_request(
app.clone(), app.clone(),
Method::POST, Method::POST,
&format!("/v1/workers/{}/stop", created.worker.worker_id), &format!("/v1/workers/{}/stop", created.worker.worker_id),
token,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let stop: RuntimeHttpWorkerLifecycleResponse = read_json(response).await; let stop: RuntimeHttpWorkerLifecycleResponse = read_json(response).await;
assert_eq!(stop.ack.worker_ref, created.worker.worker_ref); assert_eq!(stop.ack.worker_ref, created.worker.worker_ref);
let response = authed_empty_request( let response = empty_request(
app.clone(), app.clone(),
Method::POST, Method::POST,
&format!("/v1/workers/{}/restore", created.worker.worker_id), &format!("/v1/workers/{}/restore", created.worker.worker_id),
token,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let restored: RuntimeHttpWorkerResponse = read_json(response).await; let restored: RuntimeHttpWorkerResponse = read_json(response).await;
assert_eq!(restored.worker.status, WorkerStatus::Idle); assert_eq!(restored.worker.status, WorkerStatus::Idle);
let response = authed_empty_request( let response = empty_request(
app.clone(), app.clone(),
Method::POST, Method::POST,
&format!("/v1/workers/{}/stop", created.worker.worker_id), &format!("/v1/workers/{}/stop", created.worker.worker_id),
token,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let response = authed_empty_request( let response = empty_request(
app.clone(), app.clone(),
Method::POST, Method::POST,
&format!("/v1/workers/{}/cancel", created.worker.worker_id), &format!("/v1/workers/{}/cancel", created.worker.worker_id),
token,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let cancel: RuntimeHttpWorkerLifecycleResponse = read_json(response).await; let cancel: RuntimeHttpWorkerLifecycleResponse = read_json(response).await;
assert_eq!(cancel.ack.worker_ref, created.worker.worker_ref); assert_eq!(cancel.ack.worker_ref, created.worker.worker_ref);
let response = authed_empty_request(app.clone(), Method::GET, "/v1/workers", token).await; let response = empty_request(app.clone(), Method::GET, "/v1/workers").await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let workers: RuntimeHttpWorkersResponse = read_json(response).await; let workers: RuntimeHttpWorkersResponse = read_json(response).await;
assert_eq!(workers.workers.len(), 1); assert_eq!(workers.workers.len(), 1);
let response = authed_empty_request(app, Method::GET, "/v1/runtime", token).await; let response = empty_request(app, Method::GET, "/v1/runtime").await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let summary: RuntimeHttpSummaryResponse = read_json(response).await; let summary: RuntimeHttpSummaryResponse = read_json(response).await;
assert_eq!(summary.runtime.worker_count, 1); assert_eq!(summary.runtime.worker_count, 1);
@ -1658,7 +1252,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn local_token_placeholder_rejects_missing_bearer_token() { async fn local_token_placeholder_rejects_missing_bearer_token() {
let app = runtime_http_router(Runtime::new_memory(), "local-token".to_string()); let app = runtime_http_router(Runtime::new_memory(), Some("local-token".to_string()));
let response = empty_request(app.clone(), Method::GET, "/v1/runtime").await; let response = empty_request(app.clone(), Method::GET, "/v1/runtime").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
@ -1681,9 +1275,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn runtime_errors_use_typed_rest_error_shape() { async fn runtime_errors_use_typed_rest_error_shape() {
let token = "local-token"; let app = runtime_http_router(Runtime::new_memory(), None);
let app = runtime_http_router(Runtime::new_memory(), token.to_string()); let response = empty_request(app, Method::GET, "/v1/workers/999").await;
let response = authed_empty_request(app, Method::GET, "/v1/workers/999", token).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.status(), StatusCode::NOT_FOUND);
let error: RuntimeHttpErrorResponse = read_json(response).await; let error: RuntimeHttpErrorResponse = read_json(response).await;
@ -1691,21 +1284,6 @@ mod tests {
assert!(error.error.message.contains("999")); assert!(error.error.message.contains("999"));
} }
#[tokio::test]
async fn serve_runtime_http_rejects_missing_auth_configuration() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let error = serve_runtime_http(Runtime::new_memory(), listener, None)
.await
.unwrap_err();
assert!(matches!(error, RuntimeHttpServerError::AuthRequired));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let error = serve_runtime_http_with_auth(Runtime::new_memory(), listener, None, None)
.await
.unwrap_err();
assert!(matches!(error, RuntimeHttpServerError::AuthRequired));
}
#[test] #[test]
fn workdir_runtime_errors_preserve_diagnostic_code() { fn workdir_runtime_errors_preserve_diagnostic_code() {
let error = let error =
@ -1739,8 +1317,6 @@ mod ws_tests {
use std::sync::Arc; use std::sync::Arc;
use tokio_tungstenite::connect_async; use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::header as ws_header;
struct WsBackend; struct WsBackend;
@ -1808,9 +1384,9 @@ mod ws_tests {
} }
fn ws_create_request() -> CreateWorkerRequest { fn ws_create_request() -> CreateWorkerRequest {
let bundle = ws_test_bundle(ProfileSelector::Builtin("builtin:companion".to_string())); let bundle = ws_test_bundle(ProfileSelector::RuntimeDefault);
CreateWorkerRequest { CreateWorkerRequest {
profile: ProfileSelector::Builtin("builtin:companion".to_string()), profile: ProfileSelector::RuntimeDefault,
display_name: None, display_name: None,
profile_source: crate::catalog::ProfileSourceArchiveSource::Http { profile_source: crate::catalog::ProfileSourceArchiveSource::Http {
location: crate::catalog::ProfileSourceArchiveHttpRef { location: crate::catalog::ProfileSourceArchiveHttpRef {
@ -1845,25 +1421,14 @@ mod ws_tests {
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(WsBackend)) Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(WsBackend))
.unwrap(); .unwrap();
runtime runtime
.store_config_bundle(ws_test_bundle(ProfileSelector::Builtin( .store_config_bundle(ws_test_bundle(ProfileSelector::RuntimeDefault))
"builtin:companion".to_string(),
)))
.unwrap();
let worker = runtime
.create_worker_scoped(
&RuntimeWorkspaceScope::new("local", "local-token"),
ws_create_request(),
)
.unwrap(); .unwrap();
let worker = runtime.create_worker(ws_create_request()).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap(); let addr = listener.local_addr().unwrap();
tokio::spawn({ tokio::spawn({
let runtime = runtime.clone(); let runtime = runtime.clone();
async move { async move { serve_runtime_http(runtime, listener, None).await.unwrap() }
serve_runtime_http(runtime, listener, Some("local-token".to_string()))
.await
.unwrap()
}
}); });
( (
runtime, runtime,
@ -1875,15 +1440,6 @@ mod ws_tests {
) )
} }
fn authed_ws_request(url: &str) -> tokio_tungstenite::tungstenite::http::Request<()> {
let mut request = url.into_client_request().unwrap();
request.headers_mut().insert(
ws_header::AUTHORIZATION,
"Bearer local-token".parse().unwrap(),
);
request
}
async fn next_frame( async fn next_frame(
stream: &mut tokio_tungstenite::WebSocketStream< stream: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>, tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
@ -1899,7 +1455,7 @@ mod ws_tests {
#[tokio::test] #[tokio::test]
async fn protocol_ws_connect_sends_snapshot_and_live_worker_events() { async fn protocol_ws_connect_sends_snapshot_and_live_worker_events() {
let (runtime, worker_ref, url) = spawn_runtime_server().await; let (runtime, worker_ref, url) = spawn_runtime_server().await;
let (mut stream, _) = connect_async(authed_ws_request(&url)).await.unwrap(); let (mut stream, _) = connect_async(&url).await.unwrap();
assert!(matches!( assert!(matches!(
next_frame(&mut stream).await, next_frame(&mut stream).await,
@ -1941,8 +1497,9 @@ mod ws_tests {
) )
.unwrap(); .unwrap();
let resume_url = format!("{url}?cursor={}", first.cursor); let (mut stream, _) = connect_async(format!("{url}?cursor={}", first.cursor))
let (mut stream, _) = connect_async(authed_ws_request(&resume_url)).await.unwrap(); .await
.unwrap();
assert!(matches!( assert!(matches!(
next_frame(&mut stream).await, next_frame(&mut stream).await,
protocol::Event::Snapshot { .. } protocol::Event::Snapshot { .. }
@ -1965,16 +1522,13 @@ mod ws_tests {
#[tokio::test] #[tokio::test]
async fn protocol_ws_reports_malformed_cursor_and_method_frame() { async fn protocol_ws_reports_malformed_cursor_and_method_frame() {
let (_runtime, _worker_ref, url) = spawn_runtime_server().await; let (_runtime, _worker_ref, url) = spawn_runtime_server().await;
let malformed_url = format!("{url}?cursor=bad"); let (mut malformed, _) = connect_async(format!("{url}?cursor=bad")).await.unwrap();
let (mut malformed, _) = connect_async(authed_ws_request(&malformed_url))
.await
.unwrap();
assert!(matches!( assert!(matches!(
next_frame(&mut malformed).await, next_frame(&mut malformed).await,
protocol::Event::Error { .. } protocol::Event::Error { .. }
)); ));
let (mut stream, _) = connect_async(authed_ws_request(&url)).await.unwrap(); let (mut stream, _) = connect_async(&url).await.unwrap();
let _ = next_frame(&mut stream).await; let _ = next_frame(&mut stream).await;
stream.send(Message::Text("{}".into())).await.unwrap(); stream.send(Message::Text("{}".into())).await.unwrap();
assert!(matches!( assert!(matches!(

View File

@ -29,4 +29,4 @@ pub mod working_directory;
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions}; pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
pub use management::{RuntimeLimits, RuntimeOptions}; pub use management::{RuntimeLimits, RuntimeOptions};
pub use runtime::{Runtime, RuntimeWorkspaceScope}; pub use runtime::Runtime;

View File

@ -91,6 +91,9 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
), ),
)); ));
} }
if let Some(profile) = config.profile.clone() {
factory = factory.with_profile(profile);
}
let backend = Arc::new( let backend = Arc::new(
WorkerRuntimeExecutionBackend::new(factory) WorkerRuntimeExecutionBackend::new(factory)
.map_err(ProcessError::WorkerAdapter)? .map_err(ProcessError::WorkerAdapter)?
@ -168,6 +171,9 @@ where
"--backend-resource-token" => { "--backend-resource-token" => {
config.backend_resource_token = Some(take_value(&flag, inline_value, &mut args)?); config.backend_resource_token = Some(take_value(&flag, inline_value, &mut args)?);
} }
"--profile" => {
config.profile = Some(take_value(&flag, inline_value, &mut args)?);
}
"--store" => { "--store" => {
let value = take_value(&flag, inline_value, &mut args)?; let value = take_value(&flag, inline_value, &mut args)?;
if !matches!(value.as_str(), "fs" | "fs-store") { if !matches!(value.as_str(), "fs" | "fs-store") {
@ -280,6 +286,7 @@ struct ProcessConfig {
no_store: bool, no_store: bool,
backend_resource_endpoint: Option<String>, backend_resource_endpoint: Option<String>,
backend_resource_token: Option<String>, backend_resource_token: Option<String>,
profile: Option<String>,
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@ -301,6 +308,7 @@ impl ProcessConfig {
no_store: false, no_store: false,
backend_resource_endpoint: None, backend_resource_endpoint: None,
backend_resource_token: None, backend_resource_token: None,
profile: None,
}) })
} }
@ -796,6 +804,7 @@ Browsers must not connect to this Runtime process directly.
Options: Options:
--bind <ADDR> Bind socket address (default: 127.0.0.1:38800) --bind <ADDR> Bind socket address (default: 127.0.0.1:38800)
--display-name <NAME> Runtime display name --display-name <NAME> Runtime display name
--profile <SELECTOR> Force spawned Workers to use a Profile selector
--fs-root <PATH> Filesystem-backed storage root (default: user data dir) --fs-root <PATH> Filesystem-backed storage root (default: user data dir)
--fs-worker-dir <PATH> Worker storage directory (default: <fs-root>/worker) --fs-worker-dir <PATH> Worker storage directory (default: <fs-root>/worker)
--fs-runtime-dir <PATH> Runtime catalog directory (default: <fs-root>/runtime) --fs-runtime-dir <PATH> Runtime catalog directory (default: <fs-root>/runtime)
@ -843,7 +852,6 @@ mod tests {
assert!(parse_args(["--worker-store-dir", "/tmp/sessions"]).is_err()); assert!(parse_args(["--worker-store-dir", "/tmp/sessions"]).is_err());
assert!(parse_args(["--worker-metadata-dir", "/tmp/metadata"]).is_err()); assert!(parse_args(["--worker-metadata-dir", "/tmp/metadata"]).is_err());
assert!(parse_args(["--worker-runtime-base-dir", "/tmp/runtime"]).is_err()); assert!(parse_args(["--worker-runtime-base-dir", "/tmp/runtime"]).is_err());
assert!(parse_args(["--profile", "builtin:coder"]).is_err());
} }
#[test] #[test]
@ -858,6 +866,8 @@ mod tests {
"127.0.0.1:0", "127.0.0.1:0",
"--display-name", "--display-name",
"Runtime Alpha", "Runtime Alpha",
"--profile",
"builtin:coder",
"--local-token-env", "--local-token-env",
"TEST_RUNTIME_TOKEN", "TEST_RUNTIME_TOKEN",
]); ]);
@ -871,6 +881,8 @@ mod tests {
"127.0.0.1:0", "127.0.0.1:0",
"--display-name", "--display-name",
"Runtime Alpha", "Runtime Alpha",
"--profile",
"builtin:coder",
"--local-token-env", "--local-token-env",
"TEST_RUNTIME_TOKEN", "TEST_RUNTIME_TOKEN",
]) ])
@ -882,6 +894,7 @@ mod tests {
assert_eq!(config.http.bind_addr, "127.0.0.1:0".parse().unwrap()); assert_eq!(config.http.bind_addr, "127.0.0.1:0".parse().unwrap());
assert_eq!(config.http.display_name.as_deref(), Some("Runtime Alpha")); assert_eq!(config.http.display_name.as_deref(), Some("Runtime Alpha"));
assert_eq!(config.profile.as_deref(), Some("builtin:coder"));
assert_eq!(config.http.local_token.as_deref(), Some("secret-token")); assert_eq!(config.http.local_token.as_deref(), Some("secret-token"));
} }

View File

@ -39,22 +39,6 @@ use std::sync::{Arc, Mutex, MutexGuard};
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
use tokio::sync::broadcast; use tokio::sync::broadcast;
/// Workspace-scoped Runtime authorization context supplied by a trusted backend.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RuntimeWorkspaceScope {
pub workspace_id: String,
pub server_id: String,
}
impl RuntimeWorkspaceScope {
pub fn new(workspace_id: impl Into<String>, server_id: impl Into<String>) -> Self {
Self {
workspace_id: workspace_id.into(),
server_id: server_id.into(),
}
}
}
/// Concrete embedded Runtime domain entity. /// Concrete embedded Runtime domain entity.
/// ///
/// The default implementation is memory-backed and tools/provider-less by /// The default implementation is memory-backed and tools/provider-less by
@ -336,35 +320,11 @@ impl Runtime {
pub fn create_worker( pub fn create_worker(
&self, &self,
request: CreateWorkerRequest, request: CreateWorkerRequest,
) -> Result<WorkerDetail, RuntimeError> {
self.create_worker_with_workspace(request, None)
}
/// Create a Worker scoped to a workspace authorization context.
pub fn create_worker_scoped(
&self,
scope: &RuntimeWorkspaceScope,
request: CreateWorkerRequest,
) -> Result<WorkerDetail, RuntimeError> {
self.create_worker_with_workspace(request, Some(scope))
}
fn create_worker_with_workspace(
&self,
request: CreateWorkerRequest,
scope: Option<&RuntimeWorkspaceScope>,
) -> Result<WorkerDetail, RuntimeError> { ) -> Result<WorkerDetail, RuntimeError> {
let (backend, worker_ref, spawn_request) = { let (backend, worker_ref, spawn_request) = {
let mut state = self.lock()?; let mut state = self.lock()?;
state.ensure_running()?; state.ensure_running()?;
validate_create_worker_request(&request)?; validate_create_worker_request(&request)?;
validate_create_workspace_scope(
&request,
scope.map(|scope| scope.workspace_id.as_str()),
)?;
if let Some(scope) = scope {
state.ensure_workspace_owner(scope, true)?;
};
state.validate_worker_config_boundary(&request)?; state.validate_worker_config_boundary(&request)?;
if let Some(working_directory_id) = requested_primary_workdir_id(&request) { if let Some(working_directory_id) = requested_primary_workdir_id(&request) {
if let Some(owner_worker_id) = if let Some(owner_worker_id) =
@ -394,7 +354,6 @@ impl Runtime {
worker_ref: worker_ref.clone(), worker_ref: worker_ref.clone(),
worker_id: worker_id.clone(), worker_id: worker_id.clone(),
status: WorkerStatus::Stopped, status: WorkerStatus::Stopped,
workspace_id: scope.map(|scope| scope.workspace_id.clone()),
request: request.clone(), request: request.clone(),
working_directory: None, working_directory: None,
execution_handle: None, execution_handle: None,
@ -487,65 +446,6 @@ impl Runtime {
.collect()) .collect())
} }
/// List Workers visible to a workspace-scoped Runtime authorization context.
pub fn list_workers_scoped(
&self,
scope: &RuntimeWorkspaceScope,
) -> Result<Vec<WorkerSummary>, RuntimeError> {
let mut state = self.lock()?;
let visible_workspace = state.ensure_workspace_owner_for_existing_workers(scope)?;
if !visible_workspace {
return Ok(Vec::new());
}
state.persist_runtime_snapshot()?;
Ok(state
.workers
.values()
.filter(|worker| worker.belongs_to_workspace(&scope.workspace_id))
.map(WorkerRecord::summary)
.collect())
}
/// List stopped Workers visible to a workspace-scoped Runtime authorization context.
pub fn list_stopped_workers_scoped(
&self,
scope: &RuntimeWorkspaceScope,
) -> Result<Vec<WorkerSummary>, RuntimeError> {
let mut state = self.lock()?;
let visible_workspace = state.ensure_workspace_owner_for_existing_workers(scope)?;
if !visible_workspace {
return Ok(Vec::new());
}
state.persist_runtime_snapshot()?;
Ok(state
.workers
.values()
.filter(|worker| {
worker.status == WorkerStatus::Stopped
&& worker.belongs_to_workspace(&scope.workspace_id)
})
.map(WorkerRecord::summary)
.collect())
}
/// Fetch Worker detail through a workspace-scoped Runtime authorization context.
pub fn worker_detail_scoped(
&self,
scope: &RuntimeWorkspaceScope,
worker_ref: &WorkerRef,
) -> Result<WorkerDetail, RuntimeError> {
let mut state = self.lock()?;
let worker = state.worker(worker_ref)?;
if !worker.belongs_to_workspace(&scope.workspace_id) {
return Err(RuntimeError::WorkerNotFound {
worker_id: worker_ref.worker_id,
});
}
state.ensure_workspace_owner(scope, true)?;
state.persist_runtime_snapshot()?;
Ok(state.worker(worker_ref)?.detail())
}
/// Fetch Worker detail. The supplied [`WorkerRef`] must match this Runtime. /// Fetch Worker detail. The supplied [`WorkerRef`] must match this Runtime.
pub fn worker_detail(&self, worker_ref: &WorkerRef) -> Result<WorkerDetail, RuntimeError> { pub fn worker_detail(&self, worker_ref: &WorkerRef) -> Result<WorkerDetail, RuntimeError> {
let state = self.lock()?; let state = self.lock()?;
@ -553,33 +453,6 @@ impl Runtime {
Ok(worker.detail()) Ok(worker.detail())
} }
fn ensure_worker_in_workspace(
&self,
scope: &RuntimeWorkspaceScope,
worker_ref: &WorkerRef,
) -> Result<(), RuntimeError> {
let mut state = self.lock()?;
let worker = state.worker(worker_ref)?;
if !worker.belongs_to_workspace(&scope.workspace_id) {
return Err(RuntimeError::WorkerNotFound {
worker_id: worker_ref.worker_id,
});
}
state.ensure_workspace_owner(scope, true)?;
state.persist_runtime_snapshot()?;
Ok(())
}
/// Attach a live execution through a workspace-scoped Runtime authorization context.
pub fn restore_worker_scoped(
&self,
scope: &RuntimeWorkspaceScope,
worker_ref: &WorkerRef,
) -> Result<WorkerDetail, RuntimeError> {
self.ensure_worker_in_workspace(scope, worker_ref)?;
self.restore_worker(worker_ref)
}
/// Attach a live execution to a persisted Worker definition. /// Attach a live execution to a persisted Worker definition.
/// ///
/// Current liveness is never read from disk. If a handle is already /// Current liveness is never read from disk. If a handle is already
@ -668,17 +541,6 @@ impl Runtime {
self.restore_worker(worker_ref).map(|_| ()) self.restore_worker(worker_ref).map(|_| ())
} }
/// Accept input into a Worker through a workspace-scoped Runtime authorization context.
pub fn send_input_scoped(
&self,
scope: &RuntimeWorkspaceScope,
worker_ref: &WorkerRef,
input: WorkerInput,
) -> Result<WorkerInteractionAck, RuntimeError> {
self.ensure_worker_in_workspace(scope, worker_ref)?;
self.send_input(worker_ref, input)
}
/// Accept input into a Worker. /// Accept input into a Worker.
pub fn send_input( pub fn send_input(
&self, &self,
@ -750,18 +612,6 @@ impl Runtime {
}) })
} }
/// Return live completion entries through a workspace-scoped Runtime authorization context.
pub fn worker_completions_scoped(
&self,
scope: &RuntimeWorkspaceScope,
worker_ref: &WorkerRef,
kind: protocol::CompletionKind,
prefix: &str,
) -> Result<Vec<protocol::CompletionEntry>, RuntimeError> {
self.ensure_worker_in_workspace(scope, worker_ref)?;
self.worker_completions(worker_ref, kind, prefix)
}
/// Return live completion entries for the Worker composer. /// Return live completion entries for the Worker composer.
pub fn worker_completions( pub fn worker_completions(
&self, &self,
@ -784,17 +634,6 @@ impl Runtime {
Ok(backend.worker_completions(&handle, kind, prefix)) Ok(backend.worker_completions(&handle, kind, prefix))
} }
/// Accept a protocol method through a workspace-scoped Runtime authorization context.
pub fn send_protocol_method_scoped(
&self,
scope: &RuntimeWorkspaceScope,
worker_ref: &WorkerRef,
method: Method,
) -> Result<Vec<Event>, RuntimeError> {
self.ensure_worker_in_workspace(scope, worker_ref)?;
self.send_protocol_method(worker_ref, method)
}
/// Accept a protocol method for a Worker through a Backend/runtime transport. /// Accept a protocol method for a Worker through a Backend/runtime transport.
/// ///
/// Most methods are delivered to the execution backend unchanged. Methods with /// Most methods are delivered to the execution backend unchanged. Methods with
@ -881,10 +720,6 @@ impl Runtime {
fn rollback_failed_create(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> { fn rollback_failed_create(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> {
let mut state = self.lock()?; let mut state = self.lock()?;
if let Some(record) = state.workers.remove(&worker_ref.worker_id) { if let Some(record) = state.workers.remove(&worker_ref.worker_id) {
let workspace_id = record.workspace_id.clone();
if let Some(workspace_id) = workspace_id.as_deref() {
state.forget_workspace_owner_if_unused(workspace_id);
}
state.events.retain(|event| { state.events.retain(|event| {
event.id != record.last_event_id || event.worker_ref.as_ref() != Some(worker_ref) event.id != record.last_event_id || event.worker_ref.as_ref() != Some(worker_ref)
}); });
@ -948,17 +783,6 @@ impl Runtime {
}) })
} }
/// Stop a Worker through a workspace-scoped Runtime authorization context.
pub fn stop_worker_scoped(
&self,
scope: &RuntimeWorkspaceScope,
worker_ref: &WorkerRef,
reason: Option<String>,
) -> Result<WorkerLifecycleAck, RuntimeError> {
self.ensure_worker_in_workspace(scope, worker_ref)?;
self.stop_worker(worker_ref, reason)
}
/// Stop a Worker. Repeated stops are idempotent and return the last event id. /// Stop a Worker. Repeated stops are idempotent and return the last event id.
pub fn stop_worker( pub fn stop_worker(
&self, &self,
@ -974,17 +798,6 @@ impl Runtime {
) )
} }
/// Cancel a Worker through a workspace-scoped Runtime authorization context.
pub fn cancel_worker_scoped(
&self,
scope: &RuntimeWorkspaceScope,
worker_ref: &WorkerRef,
reason: Option<String>,
) -> Result<WorkerLifecycleAck, RuntimeError> {
self.ensure_worker_in_workspace(scope, worker_ref)?;
self.cancel_worker(worker_ref, reason)
}
/// Cancel a Worker. Repeated cancels are idempotent and return the last event id. /// Cancel a Worker. Repeated cancels are idempotent and return the last event id.
pub fn cancel_worker( pub fn cancel_worker(
&self, &self,
@ -1000,16 +813,6 @@ impl Runtime {
) )
} }
/// Delete a non-running Worker through a workspace-scoped Runtime authorization context.
pub fn delete_worker_scoped(
&self,
scope: &RuntimeWorkspaceScope,
worker_ref: &WorkerRef,
) -> Result<WorkerDeleteResult, RuntimeError> {
self.ensure_worker_in_workspace(scope, worker_ref)?;
self.delete_worker(worker_ref)
}
/// Delete a non-running Worker from Runtime state and persisted Worker storage. /// Delete a non-running Worker from Runtime state and persisted Worker storage.
pub fn delete_worker( pub fn delete_worker(
&self, &self,
@ -1030,9 +833,6 @@ impl Runtime {
worker_id: worker_ref.worker_id, worker_id: worker_ref.worker_id,
} }
})?; })?;
if let Some(workspace_id) = removed.workspace_id.as_deref() {
state.forget_workspace_owner_if_unused(workspace_id);
}
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
state state
.observation_events .observation_events
@ -1416,7 +1216,6 @@ struct RuntimeState {
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: u64, next_diagnostic_id: u64,
workers: BTreeMap<WorkerId, WorkerRecord>, workers: BTreeMap<WorkerId, WorkerRecord>,
workspace_owners: BTreeMap<String, String>,
config_bundles: BTreeMap<String, ConfigBundle>, config_bundles: BTreeMap<String, ConfigBundle>,
events: Vec<RuntimeEvent>, events: Vec<RuntimeEvent>,
diagnostics: Vec<RuntimeDiagnostic>, diagnostics: Vec<RuntimeDiagnostic>,
@ -1442,7 +1241,6 @@ impl RuntimeState {
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: 1, next_diagnostic_id: 1,
workers: BTreeMap::new(), workers: BTreeMap::new(),
workspace_owners: BTreeMap::new(),
config_bundles: BTreeMap::new(), config_bundles: BTreeMap::new(),
events: Vec::new(), events: Vec::new(),
diagnostics: Vec::new(), diagnostics: Vec::new(),
@ -1473,7 +1271,6 @@ impl RuntimeState {
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: 1, next_diagnostic_id: 1,
workers: BTreeMap::new(), workers: BTreeMap::new(),
workspace_owners: BTreeMap::new(),
config_bundles: BTreeMap::new(), config_bundles: BTreeMap::new(),
events: Vec::new(), events: Vec::new(),
diagnostics: Vec::new(), diagnostics: Vec::new(),
@ -1501,7 +1298,6 @@ impl RuntimeState {
worker_ref: worker.worker_ref, worker_ref: worker.worker_ref,
worker_id: worker.worker_id, worker_id: worker.worker_id,
status: WorkerStatus::Stopped, status: WorkerStatus::Stopped,
workspace_id: worker.workspace_id,
request: worker.request, request: worker.request,
working_directory: worker.working_directory, working_directory: worker.working_directory,
execution_handle: None, execution_handle: None,
@ -1522,7 +1318,6 @@ impl RuntimeState {
next_diagnostic_id, next_diagnostic_id,
workers, workers,
config_bundles: persisted.config_bundles, config_bundles: persisted.config_bundles,
workspace_owners: persisted.workspace_owners,
events: persisted.events, events: persisted.events,
diagnostics, diagnostics,
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
@ -1549,7 +1344,6 @@ impl RuntimeState {
.map(|(worker_id, worker)| (worker_id.clone(), worker.persisted_record())) .map(|(worker_id, worker)| (worker_id.clone(), worker.persisted_record()))
.collect(), .collect(),
config_bundles: self.config_bundles.clone(), config_bundles: self.config_bundles.clone(),
workspace_owners: self.workspace_owners.clone(),
events: self.events.clone(), events: self.events.clone(),
diagnostics: self.diagnostics.clone(), diagnostics: self.diagnostics.clone(),
} }
@ -1692,59 +1486,6 @@ impl RuntimeState {
Ok(()) Ok(())
} }
fn ensure_workspace_owner(
&mut self,
scope: &RuntimeWorkspaceScope,
claim_if_missing: bool,
) -> Result<bool, RuntimeError> {
if scope.workspace_id.trim().is_empty() {
return Err(RuntimeError::InvalidRequest(
"Runtime auth workspace_id must not be empty".to_string(),
));
}
if scope.server_id.trim().is_empty() {
return Err(RuntimeError::InvalidRequest(
"Runtime auth server_id must not be empty".to_string(),
));
}
match self.workspace_owners.get(&scope.workspace_id) {
Some(owner_server_id) if owner_server_id == &scope.server_id => Ok(true),
Some(owner_server_id) => Err(RuntimeError::WorkspaceOwnerMismatch {
workspace_id: scope.workspace_id.clone(),
owner_server_id: owner_server_id.clone(),
requester_server_id: scope.server_id.clone(),
}),
None if claim_if_missing => {
self.workspace_owners
.insert(scope.workspace_id.clone(), scope.server_id.clone());
Ok(true)
}
None => Ok(false),
}
}
fn ensure_workspace_owner_for_existing_workers(
&mut self,
scope: &RuntimeWorkspaceScope,
) -> Result<bool, RuntimeError> {
let has_workspace_worker = self
.workers
.values()
.any(|worker| worker.belongs_to_workspace(&scope.workspace_id));
self.ensure_workspace_owner(scope, has_workspace_worker)
}
fn forget_workspace_owner_if_unused(&mut self, workspace_id: &str) -> bool {
if self
.workers
.values()
.any(|worker| worker.belongs_to_workspace(workspace_id))
{
return false;
}
self.workspace_owners.remove(workspace_id).is_some()
}
fn worker(&self, worker_ref: &WorkerRef) -> Result<&WorkerRecord, RuntimeError> { fn worker(&self, worker_ref: &WorkerRef) -> Result<&WorkerRecord, RuntimeError> {
self.ensure_worker_ref(worker_ref)?; self.ensure_worker_ref(worker_ref)?;
self.workers self.workers
@ -1920,7 +1661,6 @@ struct WorkerRecord {
worker_ref: WorkerRef, worker_ref: WorkerRef,
worker_id: WorkerId, worker_id: WorkerId,
status: WorkerStatus, status: WorkerStatus,
workspace_id: Option<String>,
request: CreateWorkerRequest, request: CreateWorkerRequest,
working_directory: Option<CatalogWorkingDirectoryStatus>, working_directory: Option<CatalogWorkingDirectoryStatus>,
execution_handle: Option<WorkerExecutionHandle>, execution_handle: Option<WorkerExecutionHandle>,
@ -1928,16 +1668,11 @@ struct WorkerRecord {
} }
impl WorkerRecord { impl WorkerRecord {
fn belongs_to_workspace(&self, workspace_id: &str) -> bool {
self.workspace_id.as_deref() == Some(workspace_id)
}
fn summary(&self) -> WorkerSummary { fn summary(&self) -> WorkerSummary {
WorkerSummary { WorkerSummary {
worker_ref: self.worker_ref.clone(), worker_ref: self.worker_ref.clone(),
worker_id: self.worker_id, worker_id: self.worker_id,
status: self.status, status: self.status,
workspace_id: self.workspace_id.clone(),
working_directory: self.working_directory.clone(), working_directory: self.working_directory.clone(),
profile: self.request.profile.clone(), profile: self.request.profile.clone(),
display_name: self.request.display_name.clone(), display_name: self.request.display_name.clone(),
@ -1952,7 +1687,6 @@ impl WorkerRecord {
worker_ref: self.worker_ref.clone(), worker_ref: self.worker_ref.clone(),
worker_id: self.worker_id, worker_id: self.worker_id,
status: self.status, status: self.status,
workspace_id: self.workspace_id.clone(),
working_directory: self.working_directory.clone(), working_directory: self.working_directory.clone(),
profile: self.request.profile.clone(), profile: self.request.profile.clone(),
display_name: self.request.display_name.clone(), display_name: self.request.display_name.clone(),
@ -1968,7 +1702,6 @@ impl WorkerRecord {
worker_ref: self.worker_ref.clone(), worker_ref: self.worker_ref.clone(),
worker_id: self.worker_id.clone(), worker_id: self.worker_id.clone(),
request: self.request.clone(), request: self.request.clone(),
workspace_id: self.workspace_id.clone(),
working_directory: self.working_directory.clone(), working_directory: self.working_directory.clone(),
last_event_id: self.last_event_id, last_event_id: self.last_event_id,
} }
@ -2033,32 +1766,6 @@ fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), R
Ok(()) Ok(())
} }
fn validate_create_workspace_scope(
request: &CreateWorkerRequest,
workspace_id: Option<&str>,
) -> Result<(), RuntimeError> {
let Some(workspace_id) = workspace_id else {
return Ok(());
};
if workspace_id.trim().is_empty() {
return Err(RuntimeError::InvalidRequest(
"Runtime auth workspace_id must not be empty".to_string(),
));
}
if let Some(request_workspace_id) = request
.workspace_api
.as_ref()
.map(|workspace_api| workspace_api.workspace_id.as_str())
{
if request_workspace_id != workspace_id {
return Err(RuntimeError::InvalidRequest(format!(
"request workspace_id {request_workspace_id} does not match Runtime auth workspace_id {workspace_id}"
)));
}
}
Ok(())
}
fn validate_worker_input(input: &WorkerInput) -> Result<(), RuntimeError> { fn validate_worker_input(input: &WorkerInput) -> Result<(), RuntimeError> {
if !input.kind.is_empty_content_allowed() && input.content.trim().is_empty() { if !input.kind.is_empty_content_allowed() && input.content.trim().is_empty() {
return Err(RuntimeError::InvalidRequest( return Err(RuntimeError::InvalidRequest(
@ -2108,7 +1815,7 @@ fn input_protocol_event(input: &WorkerInput) -> protocol::Event {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::catalog::{ConfigBundleRef, ProfileSelector, WorkspaceApiRef}; use crate::catalog::{ConfigBundleRef, ProfileSelector};
use crate::config_bundle::{ use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration, ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
ConfigDeclarationKind, ConfigProfileDescriptor, ConfigDeclarationKind, ConfigProfileDescriptor,
@ -2156,19 +1863,6 @@ mod tests {
} }
} }
fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest {
let mut request = task_request(objective);
request.workspace_api = Some(WorkspaceApiRef {
workspace_id: workspace_id.to_string(),
base_url: format!("https://workspace.example/{workspace_id}"),
});
request
}
fn scope(workspace_id: &str, server_id: &str) -> RuntimeWorkspaceScope {
RuntimeWorkspaceScope::new(workspace_id, server_id)
}
fn test_bundle_for_profile(profile: ProfileSelector) -> ConfigBundle { fn test_bundle_for_profile(profile: ProfileSelector) -> ConfigBundle {
ConfigBundle { ConfigBundle {
metadata: ConfigBundleMetadata { metadata: ConfigBundleMetadata {
@ -2346,182 +2040,6 @@ mod tests {
request request
} }
#[test]
fn scoped_worker_access_hides_other_workspace_workers() {
let runtime = runtime_with_backend();
let workspace_a = runtime
.create_worker_scoped(
&scope("workspace-a", "server-a"),
scoped_task_request("a", "workspace-a"),
)
.unwrap();
let workspace_b = runtime
.create_worker_scoped(
&scope("workspace-b", "server-b"),
scoped_task_request("b", "workspace-b"),
)
.unwrap();
assert_eq!(workspace_a.workspace_id.as_deref(), Some("workspace-a"));
assert_eq!(workspace_b.workspace_id.as_deref(), Some("workspace-b"));
assert_eq!(
runtime
.list_workers_scoped(&scope("workspace-a", "server-a"))
.unwrap()
.into_iter()
.map(|worker| worker.worker_ref)
.collect::<Vec<_>>(),
vec![workspace_a.worker_ref.clone()]
);
assert_eq!(
runtime
.list_workers_scoped(&scope("workspace-b", "server-b"))
.unwrap()
.into_iter()
.map(|worker| worker.worker_ref)
.collect::<Vec<_>>(),
vec![workspace_b.worker_ref.clone()]
);
let detail_error = runtime
.worker_detail_scoped(&scope("workspace-a", "server-a"), &workspace_b.worker_ref)
.unwrap_err();
assert!(matches!(detail_error, RuntimeError::WorkerNotFound { .. }));
let input_error = runtime
.send_input_scoped(
&scope("workspace-a", "server-a"),
&workspace_b.worker_ref,
WorkerInput::user("cross workspace"),
)
.unwrap_err();
assert!(matches!(input_error, RuntimeError::WorkerNotFound { .. }));
let protocol_error = runtime
.send_protocol_method_scoped(
&scope("workspace-a", "server-a"),
&workspace_b.worker_ref,
Method::Shutdown,
)
.unwrap_err();
assert!(matches!(
protocol_error,
RuntimeError::WorkerNotFound { .. }
));
assert_eq!(
runtime
.worker_detail(&workspace_b.worker_ref)
.unwrap()
.status,
WorkerStatus::Idle
);
}
#[test]
fn workspace_owner_binding_rejects_other_backend_and_forgets_after_last_worker_delete() {
let runtime = runtime_with_backend();
let server_a = scope("workspace-a", "server-a");
let server_b = scope("workspace-a", "server-b");
let first = runtime
.create_worker_scoped(&server_a, scoped_task_request("first", "workspace-a"))
.unwrap();
let second = runtime
.create_worker_scoped(&server_a, scoped_task_request("second", "workspace-a"))
.unwrap();
let create_error = runtime
.create_worker_scoped(&server_b, scoped_task_request("stolen", "workspace-a"))
.unwrap_err();
assert!(matches!(
create_error,
RuntimeError::WorkspaceOwnerMismatch { .. }
));
let read_error = runtime
.worker_detail_scoped(&server_b, &first.worker_ref)
.unwrap_err();
assert!(matches!(
read_error,
RuntimeError::WorkspaceOwnerMismatch { .. }
));
runtime.stop_worker(&first.worker_ref, None).unwrap();
runtime.stop_worker(&second.worker_ref, None).unwrap();
runtime
.delete_worker_scoped(&server_a, &first.worker_ref)
.unwrap();
let still_owned_error = runtime
.create_worker_scoped(&server_b, scoped_task_request("still owned", "workspace-a"))
.unwrap_err();
assert!(matches!(
still_owned_error,
RuntimeError::WorkspaceOwnerMismatch { .. }
));
runtime
.delete_worker_scoped(&server_a, &second.worker_ref)
.unwrap();
let rebound = runtime
.create_worker_scoped(&server_b, scoped_task_request("rebound", "workspace-a"))
.unwrap();
assert_eq!(rebound.workspace_id.as_deref(), Some("workspace-a"));
}
#[test]
fn scoped_create_rejects_request_workspace_mismatch() {
let runtime = runtime_with_backend();
let error = runtime
.create_worker_scoped(
&scope("workspace-a", "server-a"),
scoped_task_request("mismatch", "workspace-b"),
)
.unwrap_err();
assert!(matches!(error, RuntimeError::InvalidRequest(_)));
assert!(runtime.list_workers().unwrap().is_empty());
}
#[test]
fn unscoped_legacy_workers_are_hidden_from_scoped_access() {
let runtime = runtime_with_backend();
let legacy = runtime.create_worker(task_request("legacy")).unwrap();
assert!(legacy.workspace_id.is_none());
assert!(
runtime
.list_workers_scoped(&scope("workspace-a", "server-a"))
.unwrap()
.is_empty()
);
let detail_error = runtime
.worker_detail_scoped(&scope("workspace-a", "server-a"), &legacy.worker_ref)
.unwrap_err();
assert!(matches!(detail_error, RuntimeError::WorkerNotFound { .. }));
}
#[test]
fn stopped_worker_scoped_list_uses_workspace_boundary() {
let runtime = runtime_with_backend();
let workspace_a = runtime
.create_worker_scoped(
&scope("workspace-a", "server-a"),
scoped_task_request("a", "workspace-a"),
)
.unwrap();
let workspace_b = runtime
.create_worker_scoped(
&scope("workspace-b", "server-b"),
scoped_task_request("b", "workspace-b"),
)
.unwrap();
runtime.stop_worker(&workspace_a.worker_ref, None).unwrap();
runtime.stop_worker(&workspace_b.worker_ref, None).unwrap();
let stopped = runtime
.list_stopped_workers_scoped(&scope("workspace-a", "server-a"))
.unwrap();
assert_eq!(stopped.len(), 1);
assert_eq!(stopped[0].worker_ref, workspace_a.worker_ref);
}
#[test] #[test]
fn create_list_and_detail_preserve_runtime_local_worker_authority() { fn create_list_and_detail_preserve_runtime_local_worker_authority() {
let runtime = runtime_with_backend(); let runtime = runtime_with_backend();
@ -3168,82 +2686,6 @@ mod tests {
let _ = std::fs::remove_dir_all(root); let _ = std::fs::remove_dir_all(root);
} }
#[cfg(feature = "fs-store")]
#[test]
fn fs_store_restores_workspace_scope_and_hides_legacy_workers_from_scoped_access() {
let root = fs_store_root("workspace-scope");
let runtime = Runtime::with_fs_store_and_execution_backend(
crate::fs_store::FsRuntimeStoreOptions {
root: root.clone(),
display_name: None,
limits: RuntimeLimits::default(),
},
Arc::new(TestExecutionBackend::default()),
)
.unwrap();
runtime.store_config_bundle(test_bundle()).unwrap();
let scoped = runtime
.create_worker_scoped(
&scope("workspace-a", "server-a"),
scoped_task_request("persist workspace", "workspace-a"),
)
.unwrap();
let legacy = runtime
.create_worker(task_request("legacy persist"))
.unwrap();
let recoverable_legacy = runtime
.create_worker(scoped_task_request(
"legacy recoverable persist",
"workspace-b",
))
.unwrap();
drop(runtime);
let restored = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
root: root.clone(),
display_name: None,
limits: RuntimeLimits::default(),
})
.unwrap();
let restored_scoped = restored.worker_detail(&scoped.worker_ref).unwrap();
assert_eq!(restored_scoped.workspace_id.as_deref(), Some("workspace-a"));
assert_eq!(
restored
.list_workers_scoped(&scope("workspace-a", "server-a"))
.unwrap()
.into_iter()
.map(|worker| worker.worker_ref)
.collect::<Vec<_>>(),
vec![scoped.worker_ref.clone()]
);
let legacy_error = restored
.worker_detail_scoped(&scope("workspace-a", "server-a"), &legacy.worker_ref)
.unwrap_err();
assert!(matches!(legacy_error, RuntimeError::WorkerNotFound { .. }));
let recovered_legacy = restored
.worker_detail_scoped(
&scope("workspace-b", "server-b"),
&recoverable_legacy.worker_ref,
)
.unwrap();
assert_eq!(
recovered_legacy.workspace_id.as_deref(),
Some("workspace-b")
);
let stolen_legacy_error = restored
.worker_detail_scoped(
&scope("workspace-b", "server-c"),
&recoverable_legacy.worker_ref,
)
.unwrap_err();
assert!(matches!(
stolen_legacy_error,
RuntimeError::WorkspaceOwnerMismatch { .. }
));
let _ = std::fs::remove_dir_all(root);
}
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
#[test] #[test]
fn fs_store_restores_active_worker_execution_handles() { fn fs_store_restores_active_worker_execution_handles() {

View File

@ -105,6 +105,7 @@ pub struct ProfileRuntimeWorkerFactory {
profile_base_dir: PathBuf, profile_base_dir: PathBuf,
store_dir: Option<PathBuf>, store_dir: Option<PathBuf>,
worker_metadata_dir: Option<PathBuf>, worker_metadata_dir: Option<PathBuf>,
profile: Option<String>,
runtime_base_dir: RuntimeArtifactRoot, runtime_base_dir: RuntimeArtifactRoot,
resource_client: Option<Arc<dyn BackendResourceClient>>, resource_client: Option<Arc<dyn BackendResourceClient>>,
profile_archive_cache: Arc<ProfileSourceArchiveCache>, profile_archive_cache: Arc<ProfileSourceArchiveCache>,
@ -117,6 +118,7 @@ impl ProfileRuntimeWorkerFactory {
profile_base_dir, profile_base_dir,
store_dir: None, store_dir: None,
worker_metadata_dir: None, worker_metadata_dir: None,
profile: None,
runtime_base_dir: RuntimeArtifactRoot::owned(), runtime_base_dir: RuntimeArtifactRoot::owned(),
resource_client: None, resource_client: None,
profile_archive_cache: Arc::new(ProfileSourceArchiveCache::default()), profile_archive_cache: Arc::new(ProfileSourceArchiveCache::default()),
@ -133,6 +135,13 @@ impl ProfileRuntimeWorkerFactory {
self self
} }
/// Set the profile selector used for Runtime-created Workers. When unset,
/// normal default profile discovery is used.
pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
self.profile = Some(profile.into());
self
}
pub fn with_runtime_base_dir(mut self, runtime_base_dir: impl Into<PathBuf>) -> Self { pub fn with_runtime_base_dir(mut self, runtime_base_dir: impl Into<PathBuf>) -> Self {
self.runtime_base_dir = RuntimeArtifactRoot::External(runtime_base_dir.into()); self.runtime_base_dir = RuntimeArtifactRoot::External(runtime_base_dir.into());
self self
@ -175,27 +184,37 @@ impl ProfileRuntimeWorkerFactory {
fn runtime_profile_value( fn runtime_profile_value(
profile: &crate::catalog::ProfileSelector, profile: &crate::catalog::ProfileSelector,
) -> std::borrow::Cow<'_, str> { ) -> Option<std::borrow::Cow<'_, str>> {
match profile { match profile {
crate::catalog::ProfileSelector::RuntimeDefault => None,
crate::catalog::ProfileSelector::Named(name) => { crate::catalog::ProfileSelector::Named(name) => {
std::borrow::Cow::Borrowed(name.as_str()) Some(std::borrow::Cow::Borrowed(name.as_str()))
} }
crate::catalog::ProfileSelector::Builtin(name) => { crate::catalog::ProfileSelector::Builtin(name) => {
if name.starts_with("builtin:") { if name.starts_with("builtin:") {
std::borrow::Cow::Borrowed(name.as_str()) Some(std::borrow::Cow::Borrowed(name.as_str()))
} else { } else {
std::borrow::Cow::Owned(format!("builtin:{name}")) Some(std::borrow::Cow::Owned(format!("builtin:{name}")))
} }
} }
} }
} }
fn runtime_profile_for_request(request: &CreateWorkerRequest) -> std::borrow::Cow<'_, str> { fn runtime_profile_for_request<'a>(
&'a self,
request: &'a CreateWorkerRequest,
) -> Option<std::borrow::Cow<'a, str>> {
if let Some(profile) = self.profile.as_deref() {
return Some(std::borrow::Cow::Borrowed(profile));
}
Self::runtime_profile_value(&request.profile) Self::runtime_profile_value(&request.profile)
} }
fn runtime_profile(request: &WorkerExecutionSpawnRequest) -> std::borrow::Cow<'_, str> { fn runtime_profile<'a>(
Self::runtime_profile_for_request(&request.request) &'a self,
request: &'a WorkerExecutionSpawnRequest,
) -> Option<std::borrow::Cow<'a, str>> {
self.runtime_profile_for_request(&request.request)
} }
fn restore_fallback_manifest( fn restore_fallback_manifest(
@ -349,7 +368,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
request: WorkerExecutionSpawnRequest, request: WorkerExecutionSpawnRequest,
) -> Result<WorkerHandle, String> { ) -> Result<WorkerHandle, String> {
let worker_name = Self::runtime_worker_name(&request); let worker_name = Self::runtime_worker_name(&request);
let profile = Self::runtime_profile(&request); let profile = self.runtime_profile(&request);
let has_local_filesystem = request.working_directory.is_some(); let has_local_filesystem = request.working_directory.is_some();
let worker_root = request let worker_root = request
.working_directory .working_directory
@ -369,7 +388,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
let workspace_backend_ref = let workspace_backend_ref =
RuntimeWorkspaceBackendRef::from_worker_request(&request.request); RuntimeWorkspaceBackendRef::from_worker_request(&request.request);
let workspace_context = workspace_backend_ref.worker_context(); let workspace_context = workspace_backend_ref.worker_context();
let selector = profile.as_ref(); let selector = profile.as_deref().unwrap_or("builtin:default");
let archive = self let archive = self
.resolve_profile_source_archive(&request.request.profile_source) .resolve_profile_source_archive(&request.request.profile_source)
.await?; .await?;
@ -1402,7 +1421,7 @@ mod tests {
}, },
}, },
profiles: vec![crate::config_bundle::ConfigProfileDescriptor { profiles: vec![crate::config_bundle::ConfigProfileDescriptor {
selector: ProfileSelector::Builtin("builtin:companion".to_string()), selector: ProfileSelector::RuntimeDefault,
label: Some("adapter-test".to_string()), label: Some("adapter-test".to_string()),
}], }],
declarations: Vec::new(), declarations: Vec::new(),
@ -1438,7 +1457,7 @@ mod tests {
fn create_request(_name: &str) -> CreateWorkerRequest { fn create_request(_name: &str) -> CreateWorkerRequest {
let bundle = test_bundle(); let bundle = test_bundle();
CreateWorkerRequest { CreateWorkerRequest {
profile: ProfileSelector::Builtin("builtin:companion".to_string()), profile: ProfileSelector::RuntimeDefault,
display_name: None, display_name: None,
profile_source: crate::catalog::ProfileSourceArchiveSource::Embedded { profile_source: crate::catalog::ProfileSourceArchiveSource::Embedded {
archive: bundle.profile_source_archive.clone().unwrap(), archive: bundle.profile_source_archive.clone().unwrap(),
@ -1635,15 +1654,15 @@ mod tests {
ProfileRuntimeWorkerFactory::runtime_profile_value( ProfileRuntimeWorkerFactory::runtime_profile_value(
&crate::catalog::ProfileSelector::Builtin("coder".to_string()) &crate::catalog::ProfileSelector::Builtin("coder".to_string())
) )
.as_ref(), .as_deref(),
"builtin:coder" Some("builtin:coder")
); );
assert_eq!( assert_eq!(
ProfileRuntimeWorkerFactory::runtime_profile_value( ProfileRuntimeWorkerFactory::runtime_profile_value(
&crate::catalog::ProfileSelector::Builtin("builtin:coder".to_string()) &crate::catalog::ProfileSelector::Builtin("builtin:coder".to_string())
) )
.as_ref(), .as_deref(),
"builtin:coder" Some("builtin:coder")
); );
} }

View File

@ -1,5 +1,4 @@
//! //! Backend Workspace API backed Objective read tools.
//! Objective tool registration backed by Workspace API authority.
//! //!
//! Objectives are project-level planning context. Runtime Workers may not know //! Objectives are project-level planning context. Runtime Workers may not know
//! local `.yoi/objectives` paths, so model-visible Objective tools go through //! local `.yoi/objectives` paths, so model-visible Objective tools go through
@ -44,119 +43,23 @@ impl WorkspaceHttpObjectiveBackend {
} }
async fn show(&self, input: ObjectiveShowInput) -> Result<ToolOutput, ToolError> { async fn show(&self, input: ObjectiveShowInput) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ObjectiveShow")?; let id = input.id.trim();
let url = self.objective_url(id); if id.is_empty() || id.contains('/') {
return Err(ToolError::InvalidArgument(
"ObjectiveShow requires non-empty canonical id without '/'".to_string(),
));
}
let url = format!(
"{}/api/w/{}/objectives/{}",
self.base_url, self.workspace_id, id
);
let response = get_json::<ObjectiveDetail>(&url) let response = get_json::<ObjectiveDetail>(&url)
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
Ok(objective_output( Ok(ToolOutput {
format!("Read objective {}", response.id), summary: format!("Read objective {}", response.id),
response, content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
)?) })
}
async fn create(&self, input: ObjectiveCreateInput) -> Result<ToolOutput, ToolError> {
if input.title.trim().is_empty() {
return Err(ToolError::InvalidArgument(
"ObjectiveCreate requires non-empty title".to_string(),
));
}
let url = format!("{}/api/w/{}/objectives", self.base_url, self.workspace_id);
let response =
send_json::<ObjectiveCreateInput, ObjectiveDetail>(reqwest::Method::POST, &url, &input)
.await
.map_err(backend_error)?;
Ok(objective_output(
format!("Created objective {}", response.id),
response,
)?)
}
async fn edit(&self, input: ObjectiveEditInput) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ObjectiveEdit")?;
if input.title.is_none() && input.old_string.is_none() && input.new_string.is_none() {
return Err(ToolError::InvalidArgument(
"ObjectiveEdit requires title or old_string/new_string".to_string(),
));
}
let url = self.objective_url(id);
let body = ObjectiveEditRequest {
title: input.title,
old_string: input.old_string,
new_string: input.new_string,
replace_all: input.replace_all,
};
let response =
send_json::<ObjectiveEditRequest, ObjectiveDetail>(reqwest::Method::PATCH, &url, &body)
.await
.map_err(backend_error)?;
Ok(objective_output(
format!("Edited objective {}", response.id),
response,
)?)
}
async fn set_state(&self, input: ObjectiveSetStateInput) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ObjectiveSetState")?;
if input.state.trim().is_empty() {
return Err(ToolError::InvalidArgument(
"ObjectiveSetState requires non-empty state".to_string(),
));
}
let url = format!("{}/state", self.objective_url(id));
let response = send_json::<ObjectiveSetStateRequest, ObjectiveDetail>(
reqwest::Method::POST,
&url,
&ObjectiveSetStateRequest { state: input.state },
)
.await
.map_err(backend_error)?;
Ok(objective_output(
format!("Updated objective {} state", response.id),
response,
)?)
}
async fn link_ticket(&self, input: ObjectiveLinkTicketInput) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ObjectiveLinkTicket")?;
let ticket_id = validate_id(&input.ticket_id, "ObjectiveLinkTicket")?;
let url = format!("{}/ticket-links", self.objective_url(id));
let response = send_json::<ObjectiveLinkTicketRequest, ObjectiveDetail>(
reqwest::Method::POST,
&url,
&ObjectiveLinkTicketRequest {
ticket_id: ticket_id.to_string(),
},
)
.await
.map_err(backend_error)?;
Ok(objective_output(
format!("Linked ticket {ticket_id} to objective {}", response.id),
response,
)?)
}
async fn unlink_ticket(
&self,
input: ObjectiveUnlinkTicketInput,
) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ObjectiveUnlinkTicket")?;
let ticket_id = validate_id(&input.ticket_id, "ObjectiveUnlinkTicket")?;
let url = format!("{}/ticket-links/{}", self.objective_url(id), ticket_id);
let response = delete_json::<ObjectiveDetail>(&url)
.await
.map_err(backend_error)?;
Ok(objective_output(
format!("Unlinked ticket {ticket_id} from objective {}", response.id),
response,
)?)
}
fn objective_url(&self, id: &str) -> String {
format!(
"{}/api/w/{}/objectives/{}",
self.base_url, self.workspace_id, id
)
} }
} }
@ -185,32 +88,6 @@ async fn get_json<T: for<'de> Deserialize<'de>>(
url: &str, url: &str,
) -> Result<T, WorkspaceObjectiveBackendError> { ) -> Result<T, WorkspaceObjectiveBackendError> {
let response = reqwest::Client::new().get(url).send().await?; let response = reqwest::Client::new().get(url).send().await?;
decode_response(response).await
}
async fn send_json<B: Serialize, T: for<'de> Deserialize<'de>>(
method: reqwest::Method,
url: &str,
body: &B,
) -> Result<T, WorkspaceObjectiveBackendError> {
let response = reqwest::Client::new()
.request(method, url)
.json(body)
.send()
.await?;
decode_response(response).await
}
async fn delete_json<T: for<'de> Deserialize<'de>>(
url: &str,
) -> Result<T, WorkspaceObjectiveBackendError> {
let response = reqwest::Client::new().delete(url).send().await?;
decode_response(response).await
}
async fn decode_response<T: for<'de> Deserialize<'de>>(
response: reqwest::Response,
) -> Result<T, WorkspaceObjectiveBackendError> {
let status = response.status(); let status = response.status();
let body = response.text().await?; let body = response.text().await?;
if !status.is_success() { if !status.is_success() {
@ -219,23 +96,6 @@ async fn decode_response<T: for<'de> Deserialize<'de>>(
serde_json::from_str(&body).map_err(Into::into) serde_json::from_str(&body).map_err(Into::into)
} }
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput {
summary,
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
})
}
fn validate_id<'a>(id: &'a str, tool_name: &str) -> Result<&'a str, ToolError> {
let id = id.trim();
if id.is_empty() || id.contains('/') {
return Err(ToolError::InvalidArgument(format!(
"{tool_name} requires non-empty canonical id without '/'"
)));
}
Ok(id)
}
pub fn workspace_http_objective_tools( pub fn workspace_http_objective_tools(
workspace_id: impl Into<String>, workspace_id: impl Into<String>,
base_url: impl Into<String>, base_url: impl Into<String>,
@ -253,43 +113,8 @@ pub fn workspace_http_objective_tools(
"ObjectiveShow", "ObjectiveShow",
SHOW_DESCRIPTION, SHOW_DESCRIPTION,
show_schema(), show_schema(),
backend.clone(),
ObjectiveOperation::Show,
),
objective_tool(
"ObjectiveCreate",
CREATE_DESCRIPTION,
create_schema(),
backend.clone(),
ObjectiveOperation::Create,
),
objective_tool(
"ObjectiveEdit",
EDIT_DESCRIPTION,
edit_schema(),
backend.clone(),
ObjectiveOperation::Edit,
),
objective_tool(
"ObjectiveSetState",
SET_STATE_DESCRIPTION,
set_state_schema(),
backend.clone(),
ObjectiveOperation::SetState,
),
objective_tool(
"ObjectiveLinkTicket",
LINK_TICKET_DESCRIPTION,
link_ticket_schema(),
backend.clone(),
ObjectiveOperation::LinkTicket,
),
objective_tool(
"ObjectiveUnlinkTicket",
UNLINK_TICKET_DESCRIPTION,
unlink_ticket_schema(),
backend, backend,
ObjectiveOperation::UnlinkTicket, ObjectiveOperation::Show,
), ),
] ]
} }
@ -298,11 +123,6 @@ pub fn workspace_http_objective_tools(
enum ObjectiveOperation { enum ObjectiveOperation {
List, List,
Show, Show,
Create,
Edit,
SetState,
LinkTicket,
UnlinkTicket,
} }
fn objective_tool( fn objective_tool(
@ -347,26 +167,6 @@ impl Tool for WorkspaceHttpObjectiveTool {
let input = parse_input::<ObjectiveShowInput>(input_json)?; let input = parse_input::<ObjectiveShowInput>(input_json)?;
self.backend.show(input).await self.backend.show(input).await
} }
ObjectiveOperation::Create => {
let input = parse_input::<ObjectiveCreateInput>(input_json)?;
self.backend.create(input).await
}
ObjectiveOperation::Edit => {
let input = parse_input::<ObjectiveEditInput>(input_json)?;
self.backend.edit(input).await
}
ObjectiveOperation::SetState => {
let input = parse_input::<ObjectiveSetStateInput>(input_json)?;
self.backend.set_state(input).await
}
ObjectiveOperation::LinkTicket => {
let input = parse_input::<ObjectiveLinkTicketInput>(input_json)?;
self.backend.link_ticket(input).await
}
ObjectiveOperation::UnlinkTicket => {
let input = parse_input::<ObjectiveUnlinkTicketInput>(input_json)?;
self.backend.unlink_ticket(input).await
}
} }
} }
} }
@ -379,16 +179,6 @@ const LIST_DESCRIPTION: &str =
"List Objective records through Backend Workspace API authority as bounded summaries."; "List Objective records through Backend Workspace API authority as bounded summaries.";
const SHOW_DESCRIPTION: &str = const SHOW_DESCRIPTION: &str =
"Show one Objective record by canonical id through Backend Workspace API authority."; "Show one Objective record by canonical id through Backend Workspace API authority.";
const CREATE_DESCRIPTION: &str =
"Create an Objective record through Backend Workspace API authority.";
const EDIT_DESCRIPTION: &str =
"Partially edit an Objective title and/or body through Backend Workspace API authority.";
const SET_STATE_DESCRIPTION: &str =
"Set an Objective state through Backend Workspace API authority.";
const LINK_TICKET_DESCRIPTION: &str =
"Link a Ticket id to an Objective through Backend Workspace API authority.";
const UNLINK_TICKET_DESCRIPTION: &str =
"Unlink a Ticket id from an Objective through Backend Workspace API authority.";
fn list_schema() -> serde_json::Value { fn list_schema() -> serde_json::Value {
json!({ json!({
@ -401,81 +191,16 @@ fn list_schema() -> serde_json::Value {
} }
fn show_schema() -> serde_json::Value { fn show_schema() -> serde_json::Value {
id_schema(&["id"])
}
fn create_schema() -> serde_json::Value {
json!({
"type":"object",
"additionalProperties": false,
"required":["title"],
"properties":{
"title":{"type":"string","minLength":1},
"body_md":{"type":"string"},
"state":{"type":"string","default":"active"},
"linked_tickets":{"type":"array","items":{"type":"string"}}
}
})
}
fn edit_schema() -> serde_json::Value {
json!({ json!({
"type":"object", "type":"object",
"additionalProperties": false, "additionalProperties": false,
"required":["id"], "required":["id"],
"properties":{
"id":{"type":"string"},
"title":{"type":["string","null"]},
"old_string":{"type":["string","null"]},
"new_string":{"type":["string","null"]},
"replace_all":{"type":"boolean","default":false}
}
})
}
fn set_state_schema() -> serde_json::Value {
json!({
"type":"object",
"additionalProperties": false,
"required":["id","state"],
"properties":{
"id":{"type":"string"},
"state":{"type":"string","minLength":1}
}
})
}
fn link_ticket_schema() -> serde_json::Value {
id_ticket_schema(&["id", "ticket_id"])
}
fn unlink_ticket_schema() -> serde_json::Value {
id_ticket_schema(&["id", "ticket_id"])
}
fn id_schema(required: &[&str]) -> serde_json::Value {
json!({
"type":"object",
"additionalProperties": false,
"required": required,
"properties":{ "properties":{
"id":{"type":"string"} "id":{"type":"string"}
} }
}) })
} }
fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
json!({
"type":"object",
"additionalProperties": false,
"required": required,
"properties":{
"id":{"type":"string"},
"ticket_id":{"type":"string"}
}
})
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ObjectiveListInput { struct ObjectiveListInput {
limit: Option<usize>, limit: Option<usize>,
@ -486,67 +211,6 @@ struct ObjectiveShowInput {
id: String, id: String,
} }
#[derive(Debug, Serialize, Deserialize)]
struct ObjectiveCreateInput {
title: String,
#[serde(default)]
body_md: String,
#[serde(default = "default_state")]
state: String,
#[serde(default)]
linked_tickets: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ObjectiveEditInput {
id: String,
title: Option<String>,
old_string: Option<String>,
new_string: Option<String>,
#[serde(default)]
replace_all: bool,
}
#[derive(Debug, Serialize)]
struct ObjectiveEditRequest {
title: Option<String>,
old_string: Option<String>,
new_string: Option<String>,
replace_all: bool,
}
#[derive(Debug, Deserialize)]
struct ObjectiveSetStateInput {
id: String,
state: String,
}
#[derive(Debug, Serialize)]
struct ObjectiveSetStateRequest {
state: String,
}
#[derive(Debug, Deserialize)]
struct ObjectiveLinkTicketInput {
id: String,
ticket_id: String,
}
#[derive(Debug, Deserialize)]
struct ObjectiveUnlinkTicketInput {
id: String,
ticket_id: String,
}
#[derive(Debug, Serialize)]
struct ObjectiveLinkTicketRequest {
ticket_id: String,
}
fn default_state() -> String {
"active".to_string()
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct ObjectiveListResponse { struct ObjectiveListResponse {
items: Vec<ObjectiveSummary>, items: Vec<ObjectiveSummary>,
@ -599,37 +263,20 @@ mod tests {
} }
#[test] #[test]
fn workspace_http_objective_tools_include_objective_crud_tools() { fn workspace_http_objective_tools_include_read_only_objective_tools() {
let names = tool_names(workspace_http_objective_tools( let names = tool_names(workspace_http_objective_tools(
"workspace".to_string(), "workspace".to_string(),
"http://backend".to_string(), "http://backend".to_string(),
)); ));
assert_eq!( assert_eq!(names, vec!["ObjectiveList", "ObjectiveShow"]);
names,
vec![
"ObjectiveCreate",
"ObjectiveEdit",
"ObjectiveLinkTicket",
"ObjectiveList",
"ObjectiveSetState",
"ObjectiveShow",
"ObjectiveUnlinkTicket",
]
);
} }
#[test] #[test]
fn objective_tool_schemas_are_bounded_and_mutation_scoped() { fn objective_tool_schemas_are_bounded_and_read_only() {
let list = list_schema(); let list = list_schema();
assert_eq!(list["properties"]["limit"]["maximum"], 1000); assert_eq!(list["properties"]["limit"]["maximum"], 1000);
let show = show_schema(); let show = show_schema();
assert_eq!(show["required"][0], "id"); assert_eq!(show["required"][0], "id");
let create = create_schema();
assert_eq!(create["required"][0], "title");
let edit = edit_schema();
assert_eq!(edit["required"][0], "id");
let link = link_ticket_schema();
assert_eq!(link["required"], json!(["id", "ticket_id"]));
} }
} }

View File

@ -45,7 +45,7 @@ struct SpawnWorkerInput {
/// Profile selector for child role configuration. Omit or use `default` /// Profile selector for child role configuration. Omit or use `default`
/// for the effective child default profile, use `inherit` to derive /// for the effective child default profile, use `inherit` to derive
/// reusable config from the spawner, or use a registry selector such as /// reusable config from the spawner, or use a registry selector such as
/// `project:coder`, `project:reviewer`, `builtin:companion`, or an /// `project:coder`, `project:reviewer`, `builtin:default`, or an
/// unambiguous profile slug. Raw/path selectors are rejected. /// unambiguous profile slug. Raw/path selectors are rejected.
#[serde(default)] #[serde(default)]
profile: Option<String>, profile: Option<String>,
@ -1516,7 +1516,7 @@ max_tokens = 3333
assert!(invalid.contains("Use `default`, `inherit`")); assert!(invalid.contains("Use `default`, `inherit`"));
assert!(invalid.contains("`project:coder`")); assert!(invalid.contains("`project:coder`"));
let default_error = build_spawn_config_json_for_profile( let default_config = build_spawn_config_json_for_profile(
&parent, &parent,
&available, &available,
&project, &project,
@ -1525,8 +1525,8 @@ max_tokens = 3333
&scope, &scope,
SpawnProfileSelector::Default, SpawnProfileSelector::Default,
) )
.unwrap_err(); .unwrap();
assert!(default_error.contains("no default profile is configured")); assert!(default_config.contains("\"name\":\"child\""));
let user_config = tmp.path().join("user-profiles.toml"); let user_config = tmp.path().join("user-profiles.toml");
std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.toml\"\n").unwrap(); std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.toml\"\n").unwrap();

View File

@ -1,7 +1,6 @@
use std::path::PathBuf; use std::path::PathBuf;
use chrono::Utc; use chrono::Utc;
use project_record::{allocate_record_id, unix_epoch_millis_now};
use ticket::{ use ticket::{
SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery, SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery,
@ -14,7 +13,7 @@ use crate::records::{
}; };
use crate::store::{ use crate::store::{
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore, SqliteWorkspaceStore,
}; };
use crate::{Error, Result}; use crate::{Error, Result};
@ -39,27 +38,6 @@ pub trait TicketAuthority {
pub trait ObjectiveAuthority { pub trait ObjectiveAuthority {
fn list_objectives(&self, limit: usize) -> Result<ProjectRecordList<ObjectiveSummary>>; fn list_objectives(&self, limit: usize) -> Result<ProjectRecordList<ObjectiveSummary>>;
fn objective(&self, id: &str) -> Result<ObjectiveDetail>; fn objective(&self, id: &str) -> Result<ObjectiveDetail>;
fn create_objective(&self, input: ObjectiveCreateInput) -> Result<ObjectiveDetail>;
fn edit_objective(&self, id: &str, input: ObjectiveEditInput) -> Result<ObjectiveDetail>;
fn set_objective_state(&self, id: &str, state: &str) -> Result<ObjectiveDetail>;
fn link_objective_ticket(&self, id: &str, ticket_id: &str) -> Result<ObjectiveDetail>;
fn unlink_objective_ticket(&self, id: &str, ticket_id: &str) -> Result<ObjectiveDetail>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ObjectiveCreateInput {
pub title: String,
pub body_md: String,
pub state: String,
pub linked_tickets: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ObjectiveEditInput {
pub title: Option<String>,
pub old_string: Option<String>,
pub new_string: Option<String>,
pub replace_all: bool,
} }
pub trait MemoryAuthority { pub trait MemoryAuthority {
@ -132,75 +110,6 @@ impl SqliteWorkspaceAuthority {
ticket_backend: SqliteTicketBackend::new(database_path, workspace_id), ticket_backend: SqliteTicketBackend::new(database_path, workspace_id),
}) })
} }
fn objective_record(&self, id: &str) -> Result<ObjectiveRecord> {
self.store
.get_objective(&self.workspace_id, id)?
.ok_or_else(|| unknown_objective_error(id))
}
fn objective_detail_from_record(&self, record: ObjectiveRecord) -> Result<ObjectiveDetail> {
let linked_tickets = self
.store
.list_objective_ticket_links(&self.workspace_id, &record.objective_id)?
.into_iter()
.map(|link| link.ticket_id)
.collect::<Vec<_>>();
let resources = self
.store
.list_objective_resources(&self.workspace_id, &record.objective_id)?
.into_iter()
.map(|resource| ObjectiveResourceSummary {
path: resource.resource_path,
media_type: resource.media_type,
bytes: resource.body.len(),
updated_at: resource.updated_at,
})
.collect();
let (body, body_truncated) = truncate_body(&record.body_md, DETAIL_BODY_LIMIT);
Ok(ObjectiveDetail {
id: record.objective_id,
title: record.title,
state: record.state,
created_at: Some(record.created_at),
updated_at: Some(record.updated_at),
linked_tickets,
resources,
body,
body_truncated,
record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(),
})
}
fn insert_objective_event(
&self,
objective_id: &str,
kind: &str,
body_md: Option<&str>,
) -> Result<()> {
let event_id = allocate_record_id(
unix_epoch_millis_now().map_err(|err| {
invalid_objective_error(format!("failed to read objective event clock: {err}"))
})?,
|candidate| {
self.store
.list_objective_events(&self.workspace_id, objective_id)
.map(|events| events.iter().any(|event| event.event_id == candidate))
.unwrap_or(true)
},
)
.map_err(|err| {
invalid_objective_error(format!("failed to allocate objective event id: {err}"))
})?;
self.store.insert_objective_event(&ObjectiveEventRecord {
workspace_id: self.workspace_id.clone(),
objective_id: objective_id.to_string(),
event_id,
kind: kind.to_string(),
body_md: body_md.map(str::to_string),
created_at: now_rfc3339(),
})
}
} }
impl TicketAuthority for SqliteWorkspaceAuthority { impl TicketAuthority for SqliteWorkspaceAuthority {
@ -280,172 +189,52 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
updated_at: Some(record.updated_at), updated_at: Some(record.updated_at),
summary: summarize_body(&record.body_md), summary: summarize_body(&record.body_md),
linked_tickets, linked_tickets,
record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), record_source: "workspace-sqlite".to_string(),
}); });
} }
Ok(ProjectRecordList { Ok(ProjectRecordList {
items, items,
invalid_records: Vec::new(), invalid_records: Vec::new(),
record_authority: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(), record_authority: "workspace-sqlite".to_string(),
}) })
} }
fn objective(&self, id: &str) -> Result<ObjectiveDetail> { fn objective(&self, id: &str) -> Result<ObjectiveDetail> {
validate_project_id(id)?; validate_project_id(id)?;
let record = self.objective_record(id)?; let record = self
self.objective_detail_from_record(record) .store
} .get_objective(&self.workspace_id, id)?
.ok_or_else(|| Error::Store(format!("unknown objective `{id}`")))?;
fn create_objective(&self, input: ObjectiveCreateInput) -> Result<ObjectiveDetail> { let linked_tickets = self
validate_objective_title(&input.title)?; .store
validate_objective_state(&input.state)?; .list_objective_ticket_links(&self.workspace_id, &record.objective_id)?
for ticket_id in &input.linked_tickets {
validate_project_id(ticket_id)?;
}
let now = now_rfc3339();
let objective_id = allocate_record_id(
unix_epoch_millis_now().map_err(|err| {
invalid_objective_error(format!("failed to read objective clock: {err}"))
})?,
|candidate| {
self.store
.get_objective(&self.workspace_id, candidate)
.map(|record| record.is_some())
.unwrap_or(true)
},
)
.map_err(|err| {
invalid_objective_error(format!("failed to allocate objective id: {err}"))
})?;
let record = ObjectiveRecord {
workspace_id: self.workspace_id.clone(),
objective_id: objective_id.clone(),
title: input.title.trim().to_string(),
state: input.state.trim().to_string(),
body_md: input.body_md,
created_at: now.clone(),
updated_at: now.clone(),
};
self.store.upsert_objective(&record)?;
let links = input
.linked_tickets
.into_iter() .into_iter()
.map(|ticket_id| ObjectiveTicketLinkRecord { .map(|link| link.ticket_id)
workspace_id: self.workspace_id.clone(),
objective_id: objective_id.clone(),
ticket_id,
kind: "linked".to_string(),
created_at: now.clone(),
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
self.store let resources = self
.replace_objective_ticket_links(&self.workspace_id, &objective_id, &links)?;
self.insert_objective_event(&objective_id, "create", Some(&record.body_md))?;
self.objective(&objective_id)
}
fn edit_objective(&self, id: &str, input: ObjectiveEditInput) -> Result<ObjectiveDetail> {
validate_project_id(id)?;
let mut record = self.objective_record(id)?;
let mut changed = false;
if let Some(title) = input.title {
validate_objective_title(&title)?;
let title = title.trim().to_string();
if title != record.title {
record.title = title;
changed = true;
}
}
match (input.old_string, input.new_string) {
(Some(old_string), Some(new_string)) => {
if old_string.is_empty() {
return Err(invalid_objective_error("old_string must not be empty"));
}
let matches = record.body_md.matches(&old_string).count();
if matches == 0 {
return Err(invalid_objective_error(
"old_string was not found in objective body",
));
}
if matches > 1 && !input.replace_all {
return Err(invalid_objective_error(format!(
"old_string matched {matches} times; set replace_all = true or provide a unique string"
)));
}
record.body_md = if input.replace_all {
record.body_md.replace(&old_string, &new_string)
} else {
record.body_md.replacen(&old_string, &new_string, 1)
};
changed = true;
}
(None, None) => {}
_ => {
return Err(invalid_objective_error(
"old_string and new_string must be provided together",
));
}
}
if !changed {
return Err(invalid_objective_error(
"objective edit must change title or body",
));
}
record.updated_at = now_rfc3339();
self.store.upsert_objective(&record)?;
self.insert_objective_event(id, "edit", None)?;
self.objective(id)
}
fn set_objective_state(&self, id: &str, state: &str) -> Result<ObjectiveDetail> {
validate_project_id(id)?;
validate_objective_state(state)?;
let mut record = self.objective_record(id)?;
record.state = state.trim().to_string();
record.updated_at = now_rfc3339();
self.store.upsert_objective(&record)?;
self.insert_objective_event(id, "state", Some(&record.state))?;
self.objective(id)
}
fn link_objective_ticket(&self, id: &str, ticket_id: &str) -> Result<ObjectiveDetail> {
validate_project_id(id)?;
validate_project_id(ticket_id)?;
let _record = self.objective_record(id)?;
let now = now_rfc3339();
let mut links = self
.store .store
.list_objective_ticket_links(&self.workspace_id, id)?; .list_objective_resources(&self.workspace_id, &record.objective_id)?
if !links.iter().any(|link| link.ticket_id == ticket_id) { .into_iter()
links.push(ObjectiveTicketLinkRecord { .map(|resource| ObjectiveResourceSummary {
workspace_id: self.workspace_id.clone(), path: resource.resource_path,
objective_id: id.to_string(), media_type: resource.media_type,
ticket_id: ticket_id.to_string(), bytes: resource.body.len(),
kind: "linked".to_string(), updated_at: resource.updated_at,
created_at: now, })
}); .collect();
self.store let (body, body_truncated) = truncate_body(&record.body_md, DETAIL_BODY_LIMIT);
.replace_objective_ticket_links(&self.workspace_id, id, &links)?; Ok(ObjectiveDetail {
self.insert_objective_event(id, "link_ticket", Some(ticket_id))?; id: record.objective_id,
} title: record.title,
self.objective(id) state: record.state,
} created_at: Some(record.created_at),
updated_at: Some(record.updated_at),
fn unlink_objective_ticket(&self, id: &str, ticket_id: &str) -> Result<ObjectiveDetail> { linked_tickets,
validate_project_id(id)?; resources,
validate_project_id(ticket_id)?; body,
let _record = self.objective_record(id)?; body_truncated,
let mut links = self record_source: "workspace-sqlite".to_string(),
.store })
.list_objective_ticket_links(&self.workspace_id, id)?;
let original_len = links.len();
links.retain(|link| link.ticket_id != ticket_id);
if links.len() != original_len {
self.store
.replace_objective_ticket_links(&self.workspace_id, id, &links)?;
self.insert_objective_event(id, "unlink_ticket", Some(ticket_id))?;
}
self.objective(id)
} }
} }
@ -583,38 +372,6 @@ fn validate_non_empty(value: &str, field: &str) -> Result<()> {
} }
} }
fn validate_objective_title(title: &str) -> Result<()> {
if title.trim().is_empty() {
Err(invalid_objective_error("objective title must not be empty"))
} else {
Ok(())
}
}
fn validate_objective_state(state: &str) -> Result<()> {
if state.trim().is_empty() {
Err(invalid_objective_error("objective state must not be empty"))
} else {
Ok(())
}
}
fn invalid_objective_error(message: impl Into<String>) -> Error {
Error::RuntimeOperationFailed {
runtime_id: "workspace-authority".to_string(),
code: "invalid_objective_request".to_string(),
message: message.into(),
}
}
fn unknown_objective_error(id: &str) -> Error {
Error::RuntimeOperationFailed {
runtime_id: "workspace-authority".to_string(),
code: "unknown_objective".to_string(),
message: format!("unknown objective `{id}`"),
}
}
fn validate_json_object(raw_json: &str, field: &str) -> Result<()> { fn validate_json_object(raw_json: &str, field: &str) -> Result<()> {
let value: serde_json::Value = serde_json::from_str(raw_json) let value: serde_json::Value = serde_json::from_str(raw_json)
.map_err(|err| Error::Store(format!("memory {field} must be valid JSON: {err}")))?; .map_err(|err| Error::Store(format!("memory {field} must be valid JSON: {err}")))?;
@ -794,80 +551,6 @@ mod tests {
); );
} }
#[tokio::test]
async fn objective_mutations_write_sqlite_records_and_audit_events() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("workspace.db");
let store = SqliteWorkspaceStore::open(&db_path).unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace-test".to_string(),
owner_account_id: None,
display_name: "Workspace Test".to_string(),
state: "active".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
})
.await
.unwrap();
let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace-test").unwrap();
let created = authority
.create_objective(ObjectiveCreateInput {
title: "Create Objective".to_string(),
body_md: "Alpha body".to_string(),
state: "active".to_string(),
linked_tickets: vec!["00000000001J2".to_string()],
})
.unwrap();
assert_eq!(created.title, "Create Objective");
assert_eq!(created.linked_tickets, vec!["00000000001J2"]);
let edited = authority
.edit_objective(
&created.id,
ObjectiveEditInput {
title: Some("Edited Objective".to_string()),
old_string: Some("Alpha".to_string()),
new_string: Some("Beta".to_string()),
replace_all: false,
},
)
.unwrap();
assert_eq!(edited.title, "Edited Objective");
assert_eq!(edited.body, "Beta body");
let state = authority
.set_objective_state(&created.id, "paused")
.unwrap();
assert_eq!(state.state, "paused");
assert_eq!(
authority
.link_objective_ticket(&created.id, "00000000001J3")
.unwrap()
.linked_tickets,
vec!["00000000001J2", "00000000001J3"]
);
assert_eq!(
authority
.unlink_objective_ticket(&created.id, "00000000001J2")
.unwrap()
.linked_tickets,
vec!["00000000001J3"]
);
let events = store
.list_objective_events("workspace-test", &created.id)
.unwrap();
assert_eq!(
events
.iter()
.map(|event| event.kind.as_str())
.collect::<Vec<_>>(),
vec!["create", "edit", "state", "link_ticket", "unlink_ticket"]
);
}
#[test] #[test]
fn does_not_read_legacy_ticket_files_without_sqlite_import() { fn does_not_read_legacy_ticket_files_without_sqlite_import() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();

View File

@ -234,6 +234,7 @@ pub struct WorkerSummary {
pub display_name: String, pub display_name: String,
/// Backward-compatible display label. New UI should prefer `display_name`. /// Backward-compatible display label. New UI should prefer `display_name`.
pub label: String, pub label: String,
pub role: Option<String>,
pub profile: Option<String>, pub profile: Option<String>,
pub singleton_key: Option<String>, pub singleton_key: Option<String>,
#[serde(default)] #[serde(default)]
@ -273,7 +274,8 @@ impl<T> RuntimeList<T> {
} }
fn is_retired_companion_worker(worker: &WorkerSummary) -> bool { fn is_retired_companion_worker(worker: &WorkerSummary) -> bool {
worker.profile.as_deref() == Some("builtin:companion") worker.role.as_deref() == Some("builtin:companion")
|| worker.profile.as_deref() == Some("builtin:companion")
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -319,7 +321,8 @@ pub struct WorkerSpawnRequest {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub requested_worker_name: Option<String>, pub requested_worker_name: Option<String>,
pub acceptance: WorkerSpawnAcceptanceRequirement, pub acceptance: WorkerSpawnAcceptanceRequirement,
pub profile: ProfileSelector, #[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<ProfileSelector>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_input: Option<EmbeddedWorkerInput>, pub initial_input: Option<EmbeddedWorkerInput>,
/// Optional safe working-directory creation request. The Workspace server resolves /// Optional safe working-directory creation request. The Workspace server resolves
@ -1402,6 +1405,7 @@ impl EmbeddedWorkerRuntime {
host_id: self.host_id.clone(), host_id: self.host_id.clone(),
display_name: display.display_name.clone(), display_name: display.display_name.clone(),
label: display.display_name, label: display.display_name,
role: profile.clone(),
profile, profile,
singleton_key: display.singleton_key, singleton_key: display.singleton_key,
tags: display.tags, tags: display.tags,
@ -1441,6 +1445,7 @@ impl EmbeddedWorkerRuntime {
host_id: self.host_id.clone(), host_id: self.host_id.clone(),
display_name: display.display_name.clone(), display_name: display.display_name.clone(),
label: display.display_name, label: display.display_name,
role: profile.clone(),
profile, profile,
singleton_key: display.singleton_key, singleton_key: display.singleton_key,
tags: display.tags, tags: display.tags,
@ -1700,8 +1705,11 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
)); ));
} }
let profile = request.profile.clone(); let profile = request
let profile_source = match profile_source_archive_source(&request, &profile) { .profile
.clone()
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
let profile_source = match default_profile_source_archive_source(&profile) {
Ok(source) => source, Ok(source) => source,
Err(error) => { Err(error) => {
diagnostics.push(diagnostic( diagnostics.push(diagnostic(
@ -2356,6 +2364,7 @@ impl RemoteWorkerRuntime {
host_id: self.host_id.clone(), host_id: self.host_id.clone(),
display_name: display.display_name.clone(), display_name: display.display_name.clone(),
label: display.display_name, label: display.display_name,
role: profile.clone(),
profile, profile,
singleton_key: display.singleton_key, singleton_key: display.singleton_key,
tags: display.tags, tags: display.tags,
@ -2399,6 +2408,7 @@ impl RemoteWorkerRuntime {
host_id: self.host_id.clone(), host_id: self.host_id.clone(),
display_name: display.display_name.clone(), display_name: display.display_name.clone(),
label: display.display_name, label: display.display_name,
role: profile.clone(),
profile, profile,
singleton_key: display.singleton_key, singleton_key: display.singleton_key,
tags: display.tags, tags: display.tags,
@ -2668,9 +2678,11 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
)], )],
}; };
} }
let profile = request.profile.clone(); let profile = request
let profile_source = match profile_source_archive_http_source( .profile
&request, .clone()
.unwrap_or_else(|| embedded_profile_selector(&request.intent));
let profile_source = match default_profile_source_archive_http_source(
&profile, &profile,
&self.workspace_id, &self.workspace_id,
Some(self.runtime_id.as_str()), Some(self.runtime_id.as_str()),
@ -2941,38 +2953,22 @@ fn embedded_worker_projection_diagnostics() -> Vec<RuntimeDiagnostic> {
)] )]
} }
fn profile_source_archive_for_request( fn default_profile_source_archive_source(
request: &WorkerSpawnRequest,
profile: &ProfileSelector,
) -> Result<ProfileSourceArchive, String> {
if let Some(archive) = request
.resolved_config_bundle
.as_ref()
.and_then(|bundle| bundle.profile_source_archive.clone())
{
return Ok(archive);
}
builtin_profile_source_archive(profile)
}
fn profile_source_archive_source(
request: &WorkerSpawnRequest,
profile: &ProfileSelector, profile: &ProfileSelector,
) -> Result<ProfileSourceArchiveSource, String> { ) -> Result<ProfileSourceArchiveSource, String> {
Ok(ProfileSourceArchiveSource::Embedded { Ok(ProfileSourceArchiveSource::Embedded {
archive: profile_source_archive_for_request(request, profile)?, archive: default_profile_source_archive(profile)?,
}) })
} }
fn profile_source_archive_http_source( fn default_profile_source_archive_http_source(
request: &WorkerSpawnRequest,
profile: &ProfileSelector, profile: &ProfileSelector,
workspace_id: &str, workspace_id: &str,
runtime_id: Option<&str>, runtime_id: Option<&str>,
resource_broker: &BackendResourceBroker, resource_broker: &BackendResourceBroker,
backend_base_url: &str, backend_base_url: &str,
) -> Result<ProfileSourceArchiveSource, String> { ) -> Result<ProfileSourceArchiveSource, String> {
let archive = profile_source_archive_for_request(request, profile)?; let archive = default_profile_source_archive(profile)?;
let _handle = resource_broker.issue_profile_source_archive_handle( let _handle = resource_broker.issue_profile_source_archive_handle(
workspace_id.to_string(), workspace_id.to_string(),
runtime_id, runtime_id,
@ -3003,7 +2999,7 @@ enum ProfileSourceArchiveTransport {
} }
#[cfg(test)] #[cfg(test)]
fn builtin_profile_config_bundle( fn default_embedded_config_bundle(
profile: &ProfileSelector, profile: &ProfileSelector,
workspace_id: &str, workspace_id: &str,
runtime_id: Option<&str>, runtime_id: Option<&str>,
@ -3016,7 +3012,7 @@ fn builtin_profile_config_bundle(
.unwrap_or_else(|| "default".to_string()) .unwrap_or_else(|| "default".to_string())
.replace([':', '/', ' '], "-") .replace([':', '/', ' '], "-")
); );
let archive = builtin_profile_source_archive(profile)?; let archive = default_profile_source_archive(profile)?;
let (profile_source_archive, profile_source_archive_handle) = match archive_transport { let (profile_source_archive, profile_source_archive_handle) = match archive_transport {
ProfileSourceArchiveTransport::Inline => (Some(archive), None), ProfileSourceArchiveTransport::Inline => (Some(archive), None),
ProfileSourceArchiveTransport::BackendResourceHandle => { ProfileSourceArchiveTransport::BackendResourceHandle => {
@ -3052,13 +3048,17 @@ fn builtin_profile_config_bundle(
.with_computed_digest()) .with_computed_digest())
} }
fn builtin_profile_source_archive( fn default_profile_source_archive(
profile: &ProfileSelector, profile: &ProfileSelector,
) -> Result<ProfileSourceArchive, String> { ) -> Result<ProfileSourceArchive, String> {
let selected = embedded_profile_label(profile) let selected = embedded_profile_label(profile).unwrap_or_else(|| "default".to_string());
.ok_or_else(|| "profile selector must identify a concrete profile".to_string())?;
let selected_path = embedded_profile_path(profile)?; let selected_path = embedded_profile_path(profile)?;
let mut entrypoints = BTreeMap::new(); let mut entrypoints = BTreeMap::new();
entrypoints.insert("default".to_string(), "profiles/default.dcdl".to_string());
entrypoints.insert(
"builtin:default".to_string(),
"profiles/default.dcdl".to_string(),
);
for slug in [ for slug in [
"companion", "companion",
"intake", "intake",
@ -3073,8 +3073,8 @@ fn builtin_profile_source_archive(
let mut sources = BTreeMap::new(); let mut sources = BTreeMap::new();
sources.insert( sources.insert(
"profiles/base.dcdl".to_string(), "profiles/default.dcdl".to_string(),
include_str!("../../../resources/profiles/base.dcdl").to_string(), include_str!("../../../resources/profiles/default.dcdl").to_string(),
); );
sources.insert( sources.insert(
"profiles/companion.dcdl".to_string(), "profiles/companion.dcdl".to_string(),
@ -3111,8 +3111,8 @@ fn builtin_profile_source_archive(
"memory-consolidation", "memory-consolidation",
] { ] {
imports.insert( imports.insert(
format!("profiles/{slug}.dcdl\0./base.dcdl"), format!("profiles/{slug}.dcdl\0./default.dcdl"),
"profiles/base.dcdl".to_string(), "profiles/default.dcdl".to_string(),
); );
} }
@ -3127,7 +3127,9 @@ fn builtin_profile_source_archive(
fn embedded_profile_path(profile: &ProfileSelector) -> Result<String, String> { fn embedded_profile_path(profile: &ProfileSelector) -> Result<String, String> {
match profile { match profile {
ProfileSelector::RuntimeDefault => Ok("profiles/default.dcdl".to_string()),
ProfileSelector::Builtin(name) => match name.strip_prefix("builtin:").unwrap_or(name) { ProfileSelector::Builtin(name) => match name.strip_prefix("builtin:").unwrap_or(name) {
"default" => Ok("profiles/default.dcdl".to_string()),
"companion" => Ok("profiles/companion.dcdl".to_string()), "companion" => Ok("profiles/companion.dcdl".to_string()),
"intake" => Ok("profiles/intake.dcdl".to_string()), "intake" => Ok("profiles/intake.dcdl".to_string()),
"orchestrator" => Ok("profiles/orchestrator.dcdl".to_string()), "orchestrator" => Ok("profiles/orchestrator.dcdl".to_string()),
@ -3140,8 +3142,31 @@ fn embedded_profile_path(profile: &ProfileSelector) -> Result<String, String> {
} }
} }
fn embedded_profile_selector(intent: &WorkerSpawnIntent) -> ProfileSelector {
match intent {
WorkerSpawnIntent::TicketRole { role, .. } => {
ProfileSelector::Builtin(format!("builtin:{}", ticket_role_profile_slug(role)))
}
WorkerSpawnIntent::WorkspaceCompanion => {
ProfileSelector::Builtin("builtin:companion".to_string())
}
WorkerSpawnIntent::WorkspaceOrchestrator => ProfileSelector::RuntimeDefault,
WorkerSpawnIntent::WorkspaceCoding => ProfileSelector::Builtin("builtin:coder".to_string()),
}
}
fn ticket_role_profile_slug(role: &TicketWorkerRole) -> &'static str {
match role {
TicketWorkerRole::Intake => "intake",
TicketWorkerRole::Orchestrator => "orchestrator",
TicketWorkerRole::Coder => "coder",
TicketWorkerRole::Reviewer => "reviewer",
}
}
fn embedded_profile_label(profile: &ProfileSelector) -> Option<String> { fn embedded_profile_label(profile: &ProfileSelector) -> Option<String> {
Some(match profile { Some(match profile {
ProfileSelector::RuntimeDefault => "runtime_default".to_string(),
ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => { ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => {
if name.strip_prefix("builtin:").unwrap_or(name) == MEMORY_CONSOLIDATION_PROFILE { if name.strip_prefix("builtin:").unwrap_or(name) == MEMORY_CONSOLIDATION_PROFILE {
MEMORY_CONSOLIDATION_PROFILE.to_string() MEMORY_CONSOLIDATION_PROFILE.to_string()
@ -3202,18 +3227,21 @@ fn worker_display_metadata(
} }
fn profile_display_name(profile_label: &str) -> String { fn profile_display_name(profile_label: &str) -> String {
profile_label match profile_label {
.split(['-', '_']) "runtime_default" => "Default Worker".to_string(),
.filter(|part| !part.is_empty()) value => value
.map(|part| { .split(['-', '_'])
let mut chars = part.chars(); .filter(|part| !part.is_empty())
match chars.next() { .map(|part| {
Some(first) => first.to_uppercase().chain(chars).collect::<String>(), let mut chars = part.chars();
None => String::new(), match chars.next() {
} Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
}) None => String::new(),
.collect::<Vec<_>>() }
.join(" ") })
.collect::<Vec<_>>()
.join(" "),
}
} }
fn embedded_input_rejected( fn embedded_input_rejected(
@ -3321,7 +3349,6 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
workdir_diagnostic.message.clone(), workdir_diagnostic.message.clone(),
), ),
EmbeddedRuntimeError::InvalidRequest(_) EmbeddedRuntimeError::InvalidRequest(_)
| EmbeddedRuntimeError::WorkspaceOwnerMismatch { .. }
| EmbeddedRuntimeError::ConfigBundleMissing { .. } | EmbeddedRuntimeError::ConfigBundleMissing { .. }
| EmbeddedRuntimeError::ConfigBundleDigestMismatch { .. } | EmbeddedRuntimeError::ConfigBundleDigestMismatch { .. }
| EmbeddedRuntimeError::InvalidProfileSelector { .. } | EmbeddedRuntimeError::InvalidProfileSelector { .. }
@ -3604,6 +3631,7 @@ pub fn placeholder_worker(host_id: impl Into<String>) -> WorkerSummary {
host_id, host_id,
display_name: "Worker runtime actions are not implemented".to_string(), display_name: "Worker runtime actions are not implemented".to_string(),
label: "Worker runtime actions are not implemented".to_string(), label: "Worker runtime actions are not implemented".to_string(),
role: None,
profile: None, profile: None,
singleton_key: None, singleton_key: None,
tags: Vec::new(), tags: Vec::new(),
@ -3661,6 +3689,7 @@ mod tests {
let broker = BackendResourceBroker::default(); let broker = BackendResourceBroker::default();
let runtime_id = "runtime-test"; let runtime_id = "runtime-test";
for selector in [ for selector in [
ProfileSelector::RuntimeDefault,
ProfileSelector::Builtin("builtin:companion".to_string()), ProfileSelector::Builtin("builtin:companion".to_string()),
ProfileSelector::Builtin("builtin:intake".to_string()), ProfileSelector::Builtin("builtin:intake".to_string()),
ProfileSelector::Builtin("builtin:orchestrator".to_string()), ProfileSelector::Builtin("builtin:orchestrator".to_string()),
@ -3668,7 +3697,7 @@ mod tests {
ProfileSelector::Builtin("builtin:reviewer".to_string()), ProfileSelector::Builtin("builtin:reviewer".to_string()),
ProfileSelector::Builtin("builtin:memory-consolidation".to_string()), ProfileSelector::Builtin("builtin:memory-consolidation".to_string()),
] { ] {
let bundle = builtin_profile_config_bundle( let bundle = default_embedded_config_bundle(
&selector, &selector,
"workspace-test", "workspace-test",
Some(runtime_id), Some(runtime_id),
@ -3694,7 +3723,9 @@ mod tests {
.verify() .verify()
.unwrap(); .unwrap();
let selector_key = match &selector { let selector_key = match &selector {
ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => name.clone(), ProfileSelector::RuntimeDefault => "default".to_string(),
ProfileSelector::Builtin(name) => name.clone(),
ProfileSelector::Named(name) => name.clone(),
}; };
let manifest = archive let manifest = archive
.resolve_profile(&selector_key, root.path(), "embedded-test-worker") .resolve_profile(&selector_key, root.path(), "embedded-test-worker")
@ -3713,7 +3744,7 @@ mod tests {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
let broker = BackendResourceBroker::default(); let broker = BackendResourceBroker::default();
let runtime_id = "remote:test"; let runtime_id = "remote:test";
let bundle = builtin_profile_config_bundle( let bundle = default_embedded_config_bundle(
&ProfileSelector::Builtin("builtin:coder".to_string()), &ProfileSelector::Builtin("builtin:coder".to_string()),
"workspace-test", "workspace-test",
Some(runtime_id), Some(runtime_id),
@ -3725,7 +3756,7 @@ mod tests {
let archive = bundle let archive = bundle
.profile_source_archive .profile_source_archive
.as_ref() .as_ref()
.expect("remote built-in bundle carries inline profile archive") .expect("remote default bundle carries inline profile archive")
.verify() .verify()
.unwrap(); .unwrap();
let manifest = archive let manifest = archive
@ -3734,38 +3765,11 @@ mod tests {
assert_eq!(manifest.worker.name, "remote-test-worker"); assert_eq!(manifest.worker.name, "remote-test-worker");
} }
#[test]
fn resolved_project_profile_archive_is_used_for_runtime_delivery() {
let broker = BackendResourceBroker::default();
let builtin_selector = ProfileSelector::Builtin("builtin:coder".to_string());
let archive = builtin_profile_source_archive(&builtin_selector)
.expect("build stand-in project profile archive");
let mut bundle = builtin_profile_config_bundle(
&builtin_selector,
"workspace-test",
Some("runtime-test"),
&broker,
ProfileSourceArchiveTransport::Inline,
)
.expect("build project profile bundle");
bundle.profile_source_archive = Some(archive.clone());
let mut request = embedded_spawn_request();
request.profile = ProfileSelector::Named("project:custom".to_string());
request.resolved_config_bundle = Some(bundle);
let delivered = profile_source_archive_for_request(&request, &request.profile)
.expect("resolve project profile archive");
assert_eq!(delivered.reference, archive.reference);
assert_eq!(delivered.content, archive.content);
}
#[test] #[test]
fn remote_profile_source_archive_url_uses_workspace_id_not_host_id() { fn remote_profile_source_archive_url_uses_workspace_id_not_host_id() {
let broker = BackendResourceBroker::default(); let broker = BackendResourceBroker::default();
let runtime_id = "remote:test"; let runtime_id = "remote:test";
let request = embedded_spawn_request(); let source = default_profile_source_archive_http_source(
let source = profile_source_archive_http_source(
&request,
&ProfileSelector::Builtin("builtin:coder".to_string()), &ProfileSelector::Builtin("builtin:coder".to_string()),
"workspace-actual", "workspace-actual",
Some(runtime_id), Some(runtime_id),
@ -3791,7 +3795,7 @@ mod tests {
let broker = BackendResourceBroker::default(); let broker = BackendResourceBroker::default();
let runtime_id = "runtime-test"; let runtime_id = "runtime-test";
assert!( assert!(
builtin_profile_config_bundle( default_embedded_config_bundle(
&ProfileSelector::Builtin("builtin:missing".to_string()), &ProfileSelector::Builtin("builtin:missing".to_string()),
"workspace-test", "workspace-test",
Some(runtime_id), Some(runtime_id),
@ -3801,7 +3805,7 @@ mod tests {
.is_err() .is_err()
); );
assert!( assert!(
builtin_profile_config_bundle( default_embedded_config_bundle(
&ProfileSelector::Named("custom".to_string()), &ProfileSelector::Named("custom".to_string()),
"workspace-test", "workspace-test",
Some(runtime_id), Some(runtime_id),
@ -3961,6 +3965,7 @@ mod tests {
host_id: host_id.to_string(), host_id: host_id.to_string(),
display_name: label.to_string(), display_name: label.to_string(),
label: label.to_string(), label: label.to_string(),
role: None,
profile: None, profile: None,
singleton_key: None, singleton_key: None,
tags: Vec::new(), tags: Vec::new(),
@ -4152,7 +4157,7 @@ mod tests {
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0, expected_segments: 0,
}, },
profile: ProfileSelector::Builtin("builtin:coder".to_string()), profile: None,
initial_input: None, initial_input: None,
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
@ -4278,7 +4283,7 @@ mod tests {
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0, expected_segments: 0,
}, },
profile: ProfileSelector::Builtin("builtin:coder".to_string()), profile: None,
initial_input: None, initial_input: None,
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
@ -4374,7 +4379,7 @@ mod tests {
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0, expected_segments: 0,
}, },
profile: ProfileSelector::Builtin("builtin:coder".to_string()), profile: Some(ProfileSelector::Builtin("builtin:coder".to_string())),
initial_input: None, initial_input: None,
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
@ -4406,7 +4411,7 @@ mod tests {
intent: WorkerSpawnIntent::WorkspaceCompanion, intent: WorkerSpawnIntent::WorkspaceCompanion,
requested_worker_name: None, requested_worker_name: None,
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady, acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
profile: ProfileSelector::Builtin("builtin:companion".to_string()), profile: None,
initial_input: None, initial_input: None,
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,

View File

@ -19,14 +19,21 @@ const PROFILE_SOURCE_TREE_ID: &str = "project";
const PROFILE_SOURCE_TREE_DISPLAY_ROOT: &str = "profiles"; const PROFILE_SOURCE_TREE_DISPLAY_ROOT: &str = "profiles";
const MAX_PROFILE_SOURCE_BYTES: u64 = 256 * 1024; const MAX_PROFILE_SOURCE_BYTES: u64 = 256 * 1024;
const BUILTIN_PROFILE_IDS: &[&str] = &[ const BUILTIN_PROFILE_IDS: &[&str] = &[
"builtin:default",
"builtin:companion", "builtin:companion",
"builtin:intake", "builtin:intake",
"builtin:orchestrator", "builtin:orchestrator",
"builtin:coder", "builtin:coder",
"builtin:reviewer", "builtin:reviewer",
]; ];
const BUILTIN_PROFILE_SLUGS: &[&str] = const BUILTIN_PROFILE_SLUGS: &[&str] = &[
&["companion", "intake", "orchestrator", "coder", "reviewer"]; "default",
"companion",
"intake",
"orchestrator",
"coder",
"reviewer",
];
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceMetadataSettingsResponse { pub struct WorkspaceMetadataSettingsResponse {
@ -825,6 +832,7 @@ pub fn project_profile_candidates(workspace_root: &Path) -> Vec<WorkspaceProfile
pub fn is_profile_candidate(workspace_root: &Path, profile_id: &str) -> bool { pub fn is_profile_candidate(workspace_root: &Path, profile_id: &str) -> bool {
BUILTIN_PROFILE_IDS.contains(&profile_id) BUILTIN_PROFILE_IDS.contains(&profile_id)
|| profile_id == "runtime_default"
|| project_profile_candidates(workspace_root) || project_profile_candidates(workspace_root)
.into_iter() .into_iter()
.any(|profile| profile.profile_id == profile_id) .any(|profile| profile.profile_id == profile_id)
@ -832,6 +840,7 @@ pub fn is_profile_candidate(workspace_root: &Path, profile_id: &str) -> bool {
fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProfileSummary> { fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProfileSummary> {
let labels = [ let labels = [
("builtin:default", "Default", "Bundled default Yoi profile"),
( (
"builtin:companion", "builtin:companion",
"Companion", "Companion",
@ -1182,6 +1191,15 @@ fn build_profile_archive_from_tree_sources(
entrypoints.insert(project_selector(&entry.name), path); entrypoints.insert(project_selector(&entry.name), path);
} }
} }
if let Some(default) = registry
.default
.as_deref()
.filter(|value| value.starts_with("project:"))
{
if let Some(path) = entrypoints.get(default).cloned() {
entrypoints.insert("default".to_string(), path);
}
}
build_profile_archive_from_source_set(entrypoints, sources) build_profile_archive_from_source_set(entrypoints, sources)
} }
@ -1902,7 +1920,9 @@ pub fn selector_for_builtin_candidate(
id: &str, id: &str,
) -> Option<worker_runtime::catalog::ProfileSelector> { ) -> Option<worker_runtime::catalog::ProfileSelector> {
match id { match id {
"builtin:companion" "runtime_default" => Some(worker_runtime::catalog::ProfileSelector::RuntimeDefault),
"builtin:default"
| "builtin:companion"
| "builtin:intake" | "builtin:intake"
| "builtin:orchestrator" | "builtin:orchestrator"
| "builtin:coder" | "builtin:coder"

View File

@ -43,8 +43,7 @@ use crate::auth::{
session_set_cookie, token_hash, session_set_cookie, token_hash,
}; };
use crate::authority::{ use crate::authority::{
MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput, MemoryAuthority, ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority,
SqliteWorkspaceAuthority, TicketAuthority,
}; };
use crate::companion::{ use crate::companion::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
@ -515,24 +514,12 @@ pub fn build_router(api: WorkspaceApi) -> Router {
.route("/api/objectives", get(list_objectives)) .route("/api/objectives", get(list_objectives))
.route( .route(
"/api/w/{workspace_id}/objectives", "/api/w/{workspace_id}/objectives",
get(scoped_list_objectives).post(scoped_create_objective), get(scoped_list_objectives),
) )
.route("/api/objectives/{id}", get(get_objective)) .route("/api/objectives/{id}", get(get_objective))
.route( .route(
"/api/w/{workspace_id}/objectives/{objective_id}", "/api/w/{workspace_id}/objectives/{id}",
get(scoped_get_objective).patch(scoped_edit_objective), get(scoped_get_objective),
)
.route(
"/api/w/{workspace_id}/objectives/{objective_id}/state",
post(scoped_set_objective_state),
)
.route(
"/api/w/{workspace_id}/objectives/{objective_id}/ticket-links",
post(scoped_link_objective_ticket),
)
.route(
"/api/w/{workspace_id}/objectives/{objective_id}/ticket-links/{ticket_id}",
delete(scoped_unlink_objective_ticket),
) )
.route("/api/repositories", get(list_repositories)) .route("/api/repositories", get(list_repositories))
.route( .route(
@ -1059,7 +1046,6 @@ pub struct RemoteRuntimeTestResponse {
pub struct WorkerLaunchOptionsResponse { pub struct WorkerLaunchOptionsResponse {
pub workspace_id: String, pub workspace_id: String,
pub runtimes: Vec<WorkerLaunchRuntimeOption>, pub runtimes: Vec<WorkerLaunchRuntimeOption>,
pub default_profile: Option<String>,
pub profiles: Vec<WorkerLaunchProfileCandidate>, pub profiles: Vec<WorkerLaunchProfileCandidate>,
pub repositories: Vec<WorkingDirectoryRepositoryOption>, pub repositories: Vec<WorkingDirectoryRepositoryOption>,
pub working_directories: Vec<WorkingDirectorySummary>, pub working_directories: Vec<WorkingDirectorySummary>,
@ -1129,8 +1115,7 @@ pub struct BrowserWorkerWorkingDirectorySelection {
pub struct BrowserCreateWorkerRequest { pub struct BrowserCreateWorkerRequest {
pub runtime_id: String, pub runtime_id: String,
pub display_name: String, pub display_name: String,
#[serde(default)] pub profile: String,
pub profile: Option<String>,
pub initial_text: String, pub initial_text: String,
#[serde(default)] #[serde(default)]
pub working_directory: Option<BrowserWorkerWorkingDirectorySelection>, pub working_directory: Option<BrowserWorkerWorkingDirectorySelection>,
@ -1210,53 +1195,6 @@ struct ObjectiveListQuery {
limit: Option<usize>, limit: Option<usize>,
} }
#[derive(Debug, Deserialize)]
struct ObjectiveCreateRequest {
title: String,
#[serde(default)]
body_md: String,
#[serde(default = "default_objective_state")]
state: String,
#[serde(default)]
linked_tickets: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ObjectiveEditRequest {
title: Option<String>,
old_string: Option<String>,
new_string: Option<String>,
#[serde(default)]
replace_all: bool,
}
#[derive(Debug, Deserialize)]
struct ObjectiveStateRequest {
state: String,
}
#[derive(Debug, Deserialize)]
struct ObjectiveLinkTicketRequest {
ticket_id: String,
}
#[derive(Debug, Deserialize)]
struct ScopedObjectivePath {
workspace_id: String,
objective_id: String,
}
#[derive(Debug, Deserialize)]
struct ScopedObjectiveTicketPath {
workspace_id: String,
objective_id: String,
ticket_id: String,
}
fn default_objective_state() -> String {
"active".to_string()
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct TranscriptQuery { struct TranscriptQuery {
start: Option<usize>, start: Option<usize>,
@ -1681,7 +1619,7 @@ fn start_memory_staging_consolidation(
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 1, expected_segments: 1,
}, },
profile: profile_selector, profile: Some(profile_selector),
initial_input: Some(input), initial_input: Some(input),
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
@ -1790,7 +1728,10 @@ fn try_reuse_memory_consolidation_worker(
fn is_memory_consolidation_worker(worker: &WorkerSummary) -> bool { fn is_memory_consolidation_worker(worker: &WorkerSummary) -> bool {
worker.singleton_key.as_deref() == Some(MEMORY_CONSOLIDATION_SINGLETON_KEY) worker.singleton_key.as_deref() == Some(MEMORY_CONSOLIDATION_SINGLETON_KEY)
|| worker.profile.as_deref() == Some(MEMORY_CONSOLIDATION_PROFILE) || [worker.profile.as_deref(), worker.role.as_deref()]
.into_iter()
.flatten()
.any(|value| value == MEMORY_CONSOLIDATION_PROFILE)
} }
fn memory_consolidation_input_content(candidate_count: usize, total_bytes: u64) -> String { fn memory_consolidation_input_content(candidate_count: usize, total_bytes: u64) -> String {
@ -1890,78 +1831,10 @@ async fn scoped_list_objectives(
async fn scoped_get_objective( async fn scoped_get_objective(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedObjectivePath>, AxumPath(path): AxumPath<ScopedRecordPath>,
) -> ApiResult<Json<ObjectiveDetail>> { ) -> ApiResult<Json<ObjectiveDetail>> {
validate_workspace_scope(&api, &path.workspace_id)?; validate_workspace_scope(&api, &path.workspace_id)?;
get_objective(State(api), AxumPath(path.objective_id)).await get_objective(State(api), AxumPath(path.id)).await
}
async fn scoped_create_objective(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
Json(request): Json<ObjectiveCreateRequest>,
) -> ApiResult<Json<ObjectiveDetail>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(api.authority.create_objective(
ObjectiveCreateInput {
title: request.title,
body_md: request.body_md,
state: request.state,
linked_tickets: request.linked_tickets,
},
)?))
}
async fn scoped_edit_objective(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedObjectivePath>,
Json(request): Json<ObjectiveEditRequest>,
) -> ApiResult<Json<ObjectiveDetail>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(api.authority.edit_objective(
&path.objective_id,
ObjectiveEditInput {
title: request.title,
old_string: request.old_string,
new_string: request.new_string,
replace_all: request.replace_all,
},
)?))
}
async fn scoped_set_objective_state(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedObjectivePath>,
Json(request): Json<ObjectiveStateRequest>,
) -> ApiResult<Json<ObjectiveDetail>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(
api.authority
.set_objective_state(&path.objective_id, &request.state)?,
))
}
async fn scoped_link_objective_ticket(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedObjectivePath>,
Json(request): Json<ObjectiveLinkTicketRequest>,
) -> ApiResult<Json<ObjectiveDetail>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(api.authority.link_objective_ticket(
&path.objective_id,
&request.ticket_id,
)?))
}
async fn scoped_unlink_objective_ticket(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedObjectiveTicketPath>,
) -> ApiResult<Json<ObjectiveDetail>> {
validate_workspace_scope(&api, &path.workspace_id)?;
Ok(Json(api.authority.unlink_objective_ticket(
&path.objective_id,
&path.ticket_id,
)?))
} }
async fn scoped_list_repositories( async fn scoped_list_repositories(
@ -4260,40 +4133,20 @@ async fn create_workspace_worker(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
Json(request): Json<BrowserCreateWorkerRequest>, Json(request): Json<BrowserCreateWorkerRequest>,
) -> ApiResult<Json<BrowserCreateWorkerResponse>> { ) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
let profile = request
.profile
.as_deref()
.map(str::trim)
.filter(|profile| !profile.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
crate::profile_settings::load_profile_settings(
&api.config.workspace_id,
&api.config.workspace_root,
)
.default_profile
})
.ok_or_else(|| {
settings_bad_request(
"workspace_default_profile_missing",
"profile is required because this Workspace has no default profile configured",
)
})?;
let profile_selector = let profile_selector =
profile_selector_for_candidate_with_root(&api.config.workspace_root, &profile).ok_or_else( profile_selector_for_candidate_with_root(&api.config.workspace_root, &request.profile)
|| { .ok_or_else(|| {
settings_bad_request( settings_bad_request(
"unsupported_worker_profile", "unsupported_worker_profile",
"profile must be selected from Backend-published worker profile candidates", "profile must be selected from Backend-published worker profile candidates",
) )
}, })?;
)?; let resolved_config_bundle = if request.profile.starts_with("project:") {
let resolved_config_bundle = if profile.starts_with("project:") {
crate::profile_settings::build_workspace_profile_config_bundle( crate::profile_settings::build_workspace_profile_config_bundle(
&api.config.workspace_root, &api.config.workspace_root,
&api.config.workspace_id, &api.config.workspace_id,
&api.config.workspace_created_at, &api.config.workspace_created_at,
&profile, &request.profile,
)? )?
} else { } else {
None None
@ -4339,7 +4192,7 @@ async fn create_workspace_worker(
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: if initial_input.is_some() { 1 } else { 0 }, expected_segments: if initial_input.is_some() { 1 } else { 0 },
}, },
profile: profile_selector, profile: Some(profile_selector),
initial_input, initial_input,
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
@ -5792,32 +5645,10 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
} }
}) })
.collect(); .collect();
let profile_settings = crate::profile_settings::load_profile_settings(
&api.config.workspace_id,
&api.config.workspace_root,
);
let profiles = profile_settings
.profiles
.into_iter()
.filter(|profile| {
!profile
.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
})
.map(|profile| WorkerLaunchProfileCandidate {
id: profile.profile_id,
label: profile.label,
description: profile
.description
.unwrap_or_else(|| "Workspace profile.".to_string()),
})
.collect();
WorkerLaunchOptionsResponse { WorkerLaunchOptionsResponse {
workspace_id: api.config.workspace_id.clone(), workspace_id: api.config.workspace_id.clone(),
runtimes, runtimes,
default_profile: profile_settings.default_profile, profiles: worker_profile_candidates_for_root(&api.config.workspace_root),
profiles,
repositories: working_directory_repository_options(api), repositories: working_directory_repository_options(api),
working_directories: available_working_directory_summaries(api).unwrap_or_default(), working_directories: available_working_directory_summaries(api).unwrap_or_default(),
diagnostics: Vec::new(), diagnostics: Vec::new(),
@ -5973,6 +5804,7 @@ fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary
worker_id: record.runtime_worker_id.to_string(), worker_id: record.runtime_worker_id.to_string(),
runtime_id: record.runtime_id.clone(), runtime_id: record.runtime_id.clone(),
host_id: "backend-registry".to_string(), host_id: "backend-registry".to_string(),
role: None,
display_name: record.display_name.clone(), display_name: record.display_name.clone(),
label: record.display_name.clone(), label: record.display_name.clone(),
singleton_key: None, singleton_key: None,
@ -6413,25 +6245,58 @@ fn working_directory_request_for_browser(
}) })
} }
#[cfg(test)]
fn worker_profile_candidates() -> Vec<WorkerLaunchProfileCandidate> {
worker_profile_candidates_for_root(Path::new("."))
.into_iter()
.filter(|candidate| candidate.id == "builtin:coder" || candidate.id == "runtime_default")
.collect()
}
fn worker_profile_candidates_for_root(workspace_root: &Path) -> Vec<WorkerLaunchProfileCandidate> {
let mut candidates = vec![
WorkerLaunchProfileCandidate {
id: "builtin:coder".to_string(),
label: "Coding Worker".to_string(),
description: "Built-in coding role profile for implementation work.".to_string(),
},
WorkerLaunchProfileCandidate {
id: "runtime_default".to_string(),
label: "Runtime default".to_string(),
description: "Use the selected Runtime's default profile.".to_string(),
},
];
candidates.extend(
crate::profile_settings::project_profile_candidates(workspace_root)
.into_iter()
.filter(|profile| profile.diagnostics.is_empty())
.map(|profile| WorkerLaunchProfileCandidate {
id: profile.profile_id,
label: profile.label,
description: profile
.description
.unwrap_or_else(|| "Workspace Decodal profile source.".to_string()),
}),
);
candidates
}
fn profile_selector_for_candidate(profile: &str) -> Option<ProfileSelector> {
crate::profile_settings::selector_for_builtin_candidate(profile)
.filter(|_| matches!(profile, "builtin:coder" | "runtime_default"))
}
fn profile_selector_for_candidate_with_root( fn profile_selector_for_candidate_with_root(
workspace_root: &Path, workspace_root: &Path,
profile: &str, profile: &str,
) -> Option<ProfileSelector> { ) -> Option<ProfileSelector> {
if let Some(selector @ ProfileSelector::Builtin(_)) = if profile_selector_for_candidate(profile).is_some() {
profile_selector_for_candidate(profile)
} else if crate::profile_settings::is_profile_candidate(workspace_root, profile) {
crate::profile_settings::selector_for_builtin_candidate(profile) crate::profile_settings::selector_for_builtin_candidate(profile)
{ } else {
return Some(selector); None
} }
crate::profile_settings::project_profile_candidates(workspace_root)
.into_iter()
.find(|candidate| {
candidate.profile_id == profile
&& !candidate
.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
})
.and_then(|_| crate::profile_settings::selector_for_builtin_candidate(profile))
} }
fn parse_runtime_worker_id_for_registry(worker_id: &str) -> ApiResult<u64> { fn parse_runtime_worker_id_for_registry(worker_id: &str) -> ApiResult<u64> {
@ -6448,7 +6313,7 @@ fn sanitize_worker_display_name(value: &str) -> Option<String> {
if display_name.chars().any(char::is_control) { if display_name.chars().any(char::is_control) {
None None
} else if display_name.is_empty() { } else if display_name.is_empty() {
Some("Worker".to_string()) Some("Coding Worker".to_string())
} else { } else {
Some(display_name.chars().take(80).collect()) Some(display_name.chars().take(80).collect())
} }
@ -6825,9 +6690,7 @@ impl IntoResponse for ApiError {
StatusCode::CONFLICT StatusCode::CONFLICT
} }
Error::RuntimeOperationFailed { code, .. } Error::RuntimeOperationFailed { code, .. }
if code == "unknown_profile_source" if code == "unknown_profile_source" || code == "unknown_profile_selector" =>
|| code == "unknown_profile_selector"
|| code == "unknown_objective" =>
{ {
StatusCode::NOT_FOUND StatusCode::NOT_FOUND
} }
@ -6896,7 +6759,7 @@ mod tests {
}; };
use crate::store::{ use crate::store::{
MemoryDocumentRecord, MemoryStagingRecord, ObjectiveRecord, ObjectiveResourceRecord, MemoryDocumentRecord, MemoryStagingRecord, ObjectiveRecord, ObjectiveResourceRecord,
ObjectiveTicketLinkRecord, SqliteWorkspaceStore, WorkspaceRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore,
}; };
const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001"; const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
@ -7072,89 +6935,17 @@ mod tests {
#[test] #[test]
fn worker_profile_candidates_are_backend_published_and_mapped() { fn worker_profile_candidates_are_backend_published_and_mapped() {
let dir = tempfile::tempdir().unwrap(); let candidates = worker_profile_candidates();
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
fs::write(
dir.path().join(".yoi/profiles.toml"),
"default = \"builtin:companion\"\n",
)
.unwrap();
let settings = crate::profile_settings::load_profile_settings("workspace-test", dir.path());
for expected in [
"builtin:companion",
"builtin:intake",
"builtin:orchestrator",
"builtin:coder",
"builtin:reviewer",
] {
assert!(
settings
.profiles
.iter()
.any(|profile| profile.profile_id == expected),
"missing {expected}"
);
}
assert!( assert!(
settings candidates
.profiles
.iter() .iter()
.all(|profile| profile.profile_id != "builtin:default") .any(|candidate| candidate.id == "builtin:coder")
);
assert_eq!(
settings.default_profile.as_deref(),
Some("builtin:companion")
); );
assert!(matches!( assert!(matches!(
profile_selector_for_candidate_with_root(dir.path(), "builtin:coder"), profile_selector_for_candidate("builtin:coder"),
Some(ProfileSelector::Builtin(value)) if value == "builtin:coder" Some(ProfileSelector::Builtin(value)) if value == "builtin:coder"
)); ));
assert!( assert!(profile_selector_for_candidate("free-text-profile").is_none());
profile_selector_for_candidate_with_root(dir.path(), "free-text-profile").is_none()
);
}
#[tokio::test]
async fn worker_launch_options_publish_every_valid_workspace_profile_and_default() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap();
fs::write(
dir.path().join(".yoi/profiles.toml"),
concat!(
"default = \"builtin:companion\"\n",
"[profile.custom]\n",
"path = \".yoi/profiles/custom.dcdl\"\n",
),
)
.unwrap();
fs::write(
dir.path().join(".yoi/profiles/custom.dcdl"),
"slug = \"custom\"; description = \"Custom profile\";\n",
)
.unwrap();
let api = test_app(dir.path()).await;
let response = get_json(api, "/api/workers/launch-options").await;
assert_eq!(response["default_profile"], "builtin:companion");
let profiles = response["profiles"].as_array().unwrap();
for expected in [
"builtin:companion",
"builtin:intake",
"builtin:orchestrator",
"builtin:coder",
"builtin:reviewer",
"project:custom",
] {
assert!(
profiles.iter().any(|profile| profile["id"] == expected),
"missing {expected}: {profiles:?}"
);
}
assert!(
profiles
.iter()
.all(|profile| profile["id"] != "builtin:default")
);
} }
#[test] #[test]
@ -7337,19 +7128,11 @@ mod tests {
) )
.unwrap(); .unwrap();
fs::write(dir.path().join(".yoi/profiles/bad.dcdl"), "not decodal").unwrap(); fs::write(dir.path().join(".yoi/profiles/bad.dcdl"), "not decodal").unwrap();
let candidates = assert!(
crate::profile_settings::load_profile_settings("workspace-test", dir.path()) worker_profile_candidates_for_root(dir.path())
.profiles .iter()
.into_iter() .all(|candidate| candidate.id != "project:bad")
.filter(|profile| { );
!profile
.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error)
})
.map(|profile| profile.profile_id)
.collect::<Vec<_>>();
assert!(!candidates.iter().any(|profile| profile == "project:bad"));
assert!(profile_selector_for_candidate_with_root(dir.path(), "project:bad").is_none()); assert!(profile_selector_for_candidate_with_root(dir.path(), "project:bad").is_none());
} }
@ -7670,7 +7453,9 @@ mod tests {
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0, expected_segments: 0,
}, },
profile: ProfileSelector::Builtin(MEMORY_CONSOLIDATION_PROFILE.to_string()), profile: Some(ProfileSelector::Builtin(
MEMORY_CONSOLIDATION_PROFILE.to_string(),
)),
initial_input: None, initial_input: None,
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
@ -8226,9 +8011,7 @@ mod tests {
}, },
}, },
profiles: vec![worker_runtime::config_bundle::ConfigProfileDescriptor { profiles: vec![worker_runtime::config_bundle::ConfigProfileDescriptor {
selector: worker_runtime::catalog::ProfileSelector::Builtin( selector: worker_runtime::catalog::ProfileSelector::RuntimeDefault,
"builtin:companion".to_string(),
),
label: Some("server-test".to_string()), label: Some("server-test".to_string()),
}], }],
declarations: Vec::new(), declarations: Vec::new(),
@ -8241,9 +8024,7 @@ mod tests {
fn runtime_create_request() -> worker_runtime::catalog::CreateWorkerRequest { fn runtime_create_request() -> worker_runtime::catalog::CreateWorkerRequest {
let bundle = runtime_test_bundle(); let bundle = runtime_test_bundle();
worker_runtime::catalog::CreateWorkerRequest { worker_runtime::catalog::CreateWorkerRequest {
profile: worker_runtime::catalog::ProfileSelector::Builtin( profile: worker_runtime::catalog::ProfileSelector::RuntimeDefault,
"builtin:companion".to_string(),
),
display_name: None, display_name: None,
profile_source: worker_runtime::catalog::ProfileSourceArchiveSource::Http { profile_source: worker_runtime::catalog::ProfileSourceArchiveSource::Http {
location: worker_runtime::catalog::ProfileSourceArchiveHttpRef { location: worker_runtime::catalog::ProfileSourceArchiveHttpRef {
@ -8623,14 +8404,8 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn browser_worker_create_uses_workspace_default_and_preserves_unsupported_diagnostics() { async fn browser_worker_create_succeeds_and_preserves_unsupported_diagnostics() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(".yoi")).unwrap();
fs::write(
dir.path().join(".yoi/profiles.toml"),
"default = \"builtin:coder\"\n",
)
.unwrap();
let app = test_app(dir.path()).await; let app = test_app(dir.path()).await;
let created = post_json( let created = post_json(
app.clone(), app.clone(),
@ -8638,6 +8413,7 @@ mod tests {
serde_json::json!({ serde_json::json!({
"runtime_id": "embedded-worker-runtime", "runtime_id": "embedded-worker-runtime",
"display_name": "", "display_name": "",
"profile": "runtime_default",
"initial_text": "" "initial_text": ""
}), }),
) )
@ -8650,9 +8426,7 @@ mod tests {
.iter() .iter()
.find(|worker| worker["worker_id"] == created["worker_id"]) .find(|worker| worker["worker_id"] == created["worker_id"])
.expect("created Worker should be listed"); .expect("created Worker should be listed");
assert_eq!(worker["label"], "Worker"); assert_eq!(worker["label"], "Coding Worker");
assert_eq!(worker["profile"], "builtin:coder");
assert!(worker.get("role").is_none());
assert_eq!(worker["worker_id"], created["worker_id"]); assert_eq!(worker["worker_id"], created["worker_id"]);
let detail_path = format!( let detail_path = format!(
"/api/runtimes/{}/workers/{}", "/api/runtimes/{}/workers/{}",
@ -8660,7 +8434,7 @@ mod tests {
created["worker_id"].as_str().unwrap() created["worker_id"].as_str().unwrap()
); );
let detail = get_json(app.clone(), detail_path.as_str()).await; let detail = get_json(app.clone(), detail_path.as_str()).await;
assert_eq!(detail["label"], "Worker"); assert_eq!(detail["label"], "Coding Worker");
assert_eq!(detail["worker_id"], created["worker_id"]); assert_eq!(detail["worker_id"], created["worker_id"]);
assert!( assert!(
created["console_href"] created["console_href"]
@ -8703,7 +8477,7 @@ mod tests {
Some(serde_json::json!({ Some(serde_json::json!({
"runtime_id": "remote-runtime", "runtime_id": "remote-runtime",
"display_name": "Remote Worker", "display_name": "Remote Worker",
"profile": "builtin:companion", "profile": "runtime_default",
"initial_text": "" "initial_text": ""
})), })),
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
@ -8742,10 +8516,6 @@ mod tests {
"kind": "run_accepted", "kind": "run_accepted",
"expected_segments": 0 "expected_segments": 0
}, },
"profile": {
"kind": "builtin",
"value": "builtin:coder"
},
"working_directory_request": { "working_directory_request": {
"repository_id": TEST_REPOSITORY_ID, "repository_id": TEST_REPOSITORY_ID,
"local_path": dir.path().display().to_string() "local_path": dir.path().display().to_string()
@ -8781,10 +8551,6 @@ mod tests {
"kind": "run_accepted", "kind": "run_accepted",
"expected_segments": 0 "expected_segments": 0
}, },
"profile": {
"kind": "builtin",
"value": "builtin:coder"
},
"working_directory_request": { "working_directory_request": {
"repository_id": TEST_REPOSITORY_ID, "repository_id": TEST_REPOSITORY_ID,
"selector": "HEAD" "selector": "HEAD"
@ -9363,7 +9129,7 @@ mod tests {
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0, expected_segments: 0,
}, },
profile: ProfileSelector::Builtin("builtin:coder".to_string()), profile: None,
initial_input: None, initial_input: None,
working_directory_request: None, working_directory_request: None,
resolved_working_directory_request: None, resolved_working_directory_request: None,
@ -9605,10 +9371,6 @@ mod tests {
"acceptance": { "acceptance": {
"kind": "run_accepted", "kind": "run_accepted",
"expected_segments": 0 "expected_segments": 0
},
"profile": {
"kind": "builtin",
"value": "builtin:coder"
} }
}), }),
) )
@ -9912,103 +9674,6 @@ mod tests {
assert_ne!(login_without_registered_passkey.status(), StatusCode::OK); assert_ne!(login_without_registered_passkey.status(), StatusCode::OK);
} }
#[tokio::test]
async fn objective_mutation_endpoints_round_trip_through_workspace_authority() {
let dir = tempfile::tempdir().unwrap();
let config = test_server_config(dir.path());
let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap());
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(),
owner_account_id: None,
display_name: "Test Workspace".to_string(),
state: "active".to_string(),
created_at: TEST_CREATED_AT.to_string(),
updated_at: TEST_CREATED_AT.to_string(),
})
.await
.unwrap();
let api = WorkspaceApi::new_with_execution_backend(
config,
store,
Arc::new(DeterministicExecutionBackend::default()),
)
.await
.unwrap();
let app = build_router(api);
let objectives_path = format!("/api/w/{TEST_WORKSPACE_ID}/objectives");
let created = request_json(
app.clone(),
"POST",
&objectives_path,
Some(json!({
"title": "Objective CRUD",
"body_md": "First body",
"state": "active",
"linked_tickets": ["00000000001J2"]
})),
StatusCode::OK,
)
.await;
let id = created["id"].as_str().unwrap().to_string();
assert_eq!(created["title"], "Objective CRUD");
assert_eq!(created["linked_tickets"], json!(["00000000001J2"]));
let edited = request_json(
app.clone(),
"PATCH",
&format!("{objectives_path}/{id}"),
Some(json!({
"title": "Objective CRUD updated",
"old_string": "First",
"new_string": "Updated"
})),
StatusCode::OK,
)
.await;
assert_eq!(edited["title"], "Objective CRUD updated");
assert_eq!(edited["body"], "Updated body");
let state = request_json(
app.clone(),
"POST",
&format!("{objectives_path}/{id}/state"),
Some(json!({ "state": "paused" })),
StatusCode::OK,
)
.await;
assert_eq!(state["state"], "paused");
let linked = request_json(
app.clone(),
"POST",
&format!("{objectives_path}/{id}/ticket-links"),
Some(json!({ "ticket_id": "00000000001J3" })),
StatusCode::OK,
)
.await;
assert_eq!(
linked["linked_tickets"],
json!(["00000000001J2", "00000000001J3"])
);
let unlinked = request_json(
app.clone(),
"DELETE",
&format!("{objectives_path}/{id}/ticket-links/00000000001J2"),
None,
StatusCode::OK,
)
.await;
assert_eq!(unlinked["linked_tickets"], json!(["00000000001J3"]));
let shown = get_json(app, &format!("{objectives_path}/{id}")).await;
assert_eq!(shown["title"], "Objective CRUD updated");
assert_eq!(shown["state"], "paused");
assert_eq!(shown["linked_tickets"], json!(["00000000001J3"]));
}
async fn get_json(app: Router, uri: &str) -> Value { async fn get_json(app: Router, uri: &str) -> Value {
let response = app let response = app
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
@ -10037,14 +9702,8 @@ mod tests {
.oneshot(builder.body(request_body).unwrap()) .oneshot(builder.body(request_body).unwrap())
.await .await
.unwrap(); .unwrap();
let status = response.status(); assert_eq!(response.status(), expected_status, "{method} {uri}");
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
status,
expected_status,
"{method} {uri}: {}",
String::from_utf8_lossy(&bytes)
);
serde_json::from_slice(&bytes).unwrap_or_else( serde_json::from_slice(&bytes).unwrap_or_else(
|_| serde_json::json!({ "message": String::from_utf8_lossy(&bytes).to_string() }), |_| serde_json::json!({ "message": String::from_utf8_lossy(&bytes).to_string() }),
) )

View File

@ -77,11 +77,6 @@ const MIGRATIONS: &[Migration] = &[
name: "trusted remote runtime registry", name: "trusted remote runtime registry",
apply: create_trusted_runtime_registry_tables, apply: create_trusted_runtime_registry_tables,
}, },
Migration {
version: 13,
name: "objective mutation audit events",
apply: create_objective_event_tables,
},
]; ];
struct Migration { struct Migration {
@ -272,16 +267,6 @@ pub struct ObjectiveTicketLinkRecord {
pub created_at: String, pub created_at: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveEventRecord {
pub workspace_id: String,
pub objective_id: String,
pub event_id: String,
pub kind: String,
pub body_md: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ObjectiveResourceRecord { pub struct ObjectiveResourceRecord {
pub workspace_id: String, pub workspace_id: String,
@ -350,12 +335,6 @@ pub trait ControlPlaneStore: Send + Sync {
workspace_id: &str, workspace_id: &str,
objective_id: &str, objective_id: &str,
) -> Result<Vec<ObjectiveTicketLinkRecord>>; ) -> Result<Vec<ObjectiveTicketLinkRecord>>;
fn insert_objective_event(&self, record: &ObjectiveEventRecord) -> Result<()>;
fn list_objective_events(
&self,
workspace_id: &str,
objective_id: &str,
) -> Result<Vec<ObjectiveEventRecord>>;
fn upsert_objective_resource(&self, record: &ObjectiveResourceRecord) -> Result<()>; fn upsert_objective_resource(&self, record: &ObjectiveResourceRecord) -> Result<()>;
fn list_objective_resources( fn list_objective_resources(
&self, &self,
@ -813,52 +792,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}) })
} }
fn insert_objective_event(&self, record: &ObjectiveEventRecord) -> Result<()> {
self.with_conn(|conn| {
conn.execute(
r#"INSERT INTO objective_events (
workspace_id, objective_id, event_id, kind, body_md, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
params![
record.workspace_id,
record.objective_id,
record.event_id,
record.kind,
record.body_md,
record.created_at,
],
)?;
Ok(())
})
}
fn list_objective_events(
&self,
workspace_id: &str,
objective_id: &str,
) -> Result<Vec<ObjectiveEventRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, objective_id, event_id, kind, body_md, created_at
FROM objective_events
WHERE workspace_id = ?1 AND objective_id = ?2
ORDER BY created_at ASC, event_id ASC"#,
)?;
let rows = stmt.query_map(params![workspace_id, objective_id], |row| {
Ok(ObjectiveEventRecord {
workspace_id: row.get(0)?,
objective_id: row.get(1)?,
event_id: row.get(2)?,
kind: row.get(3)?,
body_md: row.get(4)?,
created_at: row.get(5)?,
})
})?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
})
}
fn upsert_objective_resource(&self, record: &ObjectiveResourceRecord) -> Result<()> { fn upsert_objective_resource(&self, record: &ObjectiveResourceRecord) -> Result<()> {
self.with_conn(|conn| { self.with_conn(|conn| {
conn.execute( conn.execute(
@ -2156,22 +2089,6 @@ CREATE TABLE IF NOT EXISTS memory_staging_resolutions (
Ok(()) Ok(())
} }
fn create_objective_event_tables(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS objective_events (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE,
event_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
body_md TEXT,
created_at TEXT NOT NULL
);
"#,
)?;
Ok(())
}
fn create_trusted_runtime_registry_tables(conn: &Connection) -> Result<()> { fn create_trusted_runtime_registry_tables(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
r#" r#"
@ -2730,15 +2647,6 @@ CREATE TABLE IF NOT EXISTS objective_ticket_links (
PRIMARY KEY (objective_id, ticket_id, kind) PRIMARY KEY (objective_id, ticket_id, kind)
); );
CREATE TABLE IF NOT EXISTS objective_events (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE,
event_id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
body_md TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS objective_resources ( CREATE TABLE IF NOT EXISTS objective_resources (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE, objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE,
@ -2910,7 +2818,7 @@ mod tests {
let db = dir.path().join("control-plane.sqlite"); let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap(); let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 13); assert_eq!(store.schema_version().await.unwrap(), 11);
let record = WorkspaceRecord { let record = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
@ -2923,7 +2831,7 @@ mod tests {
store.upsert_workspace(&record).await.unwrap(); store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 13); assert_eq!(reopened.schema_version().await.unwrap(), 11);
assert_eq!( assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(), reopened.get_workspace("local-dev").await.unwrap(),
Some(record) Some(record)
@ -3144,7 +3052,7 @@ mod tests {
.unwrap(); .unwrap();
let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 13); assert_eq!(store.schema_version().await.unwrap(), 11);
store store
.with_conn(|conn| { .with_conn(|conn| {
@ -3238,7 +3146,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn repository_records_round_trip() { async fn repository_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 13); assert_eq!(store.schema_version().await.unwrap(), 11);
let workspace = WorkspaceRecord { let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
owner_account_id: None, owner_account_id: None,
@ -3276,7 +3184,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn memory_authority_records_round_trip_and_close_staging() { async fn memory_authority_records_round_trip_and_close_staging() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 13); assert_eq!(store.schema_version().await.unwrap(), 11);
let workspace = WorkspaceRecord { let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
owner_account_id: None, owner_account_id: None,
@ -3450,7 +3358,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn account_and_login_records_round_trip() { async fn account_and_login_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 13); assert_eq!(store.schema_version().await.unwrap(), 11);
let now = "2026-07-22T00:00:00Z".to_string(); let now = "2026-07-22T00:00:00Z".to_string();
let account = AccountRecord { let account = AccountRecord {
account_id: "acct-user-alice".to_string(), account_id: "acct-user-alice".to_string(),

View File

@ -18,8 +18,7 @@ It is not a dumping ground for external research, old plans, API inventories, or
10. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary. 10. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary.
11. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks. 11. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks.
12. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed. 12. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
13. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them. 13. [`development/validation.md`](development/validation.md) — how to check changes.
14. [`development/validation.md`](development/validation.md) — how to check changes.
## What belongs here ## What belongs here

View File

@ -1,177 +0,0 @@
# Rust testing strategy for Yoi
Yoi tests protect behavior that Rust's type system cannot prove and deliberate compile-time API constraints that must not regress. A test must name the contract it protects and fail for one understandable reason.
This document adapts the general Rust distinction between unit and integration tests to Yoi's architecture: `yoi` CLI, Workspace Server, Worker Runtime, Worker tools, SQLite authorities, Web/API DTOs, and local/runtime process boundaries.
References used while writing this policy:
- Rust Book, "Test Organization": unit tests are focused and may test module-private behavior; integration tests use public APIs the way external code does.
- Cargo Book, `cargo test`: package, target, and feature selection determine which tests Cargo builds and runs.
## What Rust types already guarantee
Do not write tests that only re-prove these facts for values already constructed as Rust types:
- enum values are one of the declared variants;
- required struct fields exist;
- references obey Rust ownership and lifetime rules;
- `Option<T>` is present or absent explicitly;
- exhaustive `match` expressions handle every variant;
- ordinary function argument and return types agree;
- a `Box<dyn Target>` or trait object satisfies the trait at compile time;
- a typed DTO has fields of the declared Rust type after successful construction.
A typed value still needs a **boundary** test when it is converted from untrusted JSON, TOML, files, SQLite rows, WebSocket frames, HTTP requests, CLI argv, environment variables, model tool input, external process output, or platform paths. Serde representation, path containment, HTTP route shape, SQLite schema, and prompt/tool JSON schemas are external contracts, not compiler guarantees.
Compile-time behavior may itself be a public API contract. Compile-pass or compile-fail tests are appropriate when downstream code must continue to compile or be rejected, including macro expansion, typestate transitions, public trait bounds, and unavailable methods in a restricted state. Such a test protects the shape of the public API; it does not re-run a compiler guarantee as a runtime assertion.
Yoi currently uses `trybuild` for compile-fail API-shape tests in `llm-engine`. Add new `trybuild` tests only when the compile-time accept/reject behavior is the product contract being protected. Keep fixtures minimal and name them after the API rule, not after the implementation helper that happens to trigger the compiler error.
## Test names
A test name must describe an observable rule. Prefer names of the form `<condition>_<expected_behavior>` or `<operation>_<expected_result>`. Include the input class or state when omitting it would make the rule ambiguous.
Good names:
- `backend_target_rejects_local_worker_operations`
- `cli_backend_url_overrides_config_file_value`
- `repository_path_rejects_symlink_escape`
- `second_enter_does_not_submit_rewind_twice`
- `workspace_server_returns_not_found_for_unknown_worker`
Bad names:
- `test_target`, `test_parse`, or `test_create`: names the subject but not the rule.
- `works`, `basic`, `happy_path`, or `success`: does not say what outcome is protected.
- `regression_1234`: records provenance but loses the lasting product behavior.
- `ticket_test` or `server_test_2`: adds no information beyond the module or file.
- a private helper name alone, such as `normalize_path`: couples the name to implementation without stating the externally relevant result.
No prefix is required. `contract_` is normally redundant because every test in this policy protects a contract. The following narrower labels are optional:
- `boundary_` when distinguishing external input handling from typed internal behavior is useful in that test target;
- `regression_` when the original failure symptom is itself the clearest durable rule;
- `smoke_` when the deliberately limited contract is successful wiring to a minimum state.
Examples such as `boundary_repository_path_rejects_symlink_escape`, `regression_second_enter_does_not_submit_rewind_twice`, and `smoke_workspace_server_reaches_health_route` remain meaningful after removing the prefix. A label does not rescue a vague name such as `boundary_path`, `regression_1234`, or `smoke_works`.
## Yoi contract map
| Area | Guaranteed by types | Must be tested |
| --- | --- | --- |
| CLI parser and Target selection | parsed `Mode`, `LaunchMode`, `TargetKind`, and command enum variants exist | top-level target option precedence, mutually exclusive options, default config merge, local/backend-only rejection, help/usage text for public command surface |
| Client config | typed config structs after TOML/environment parse | data-dir and cwd config locations, environment snapshot to startup config, property-wise overlay, missing/empty backend URL diagnostics, workspace-to-backend resolution, explicit CLI override over config |
| Workspace Server HTTP API | handler function signatures and response DTO field types | route shape, scoped workspace authority, status-code mapping, request validation, bounded list/detail bodies, mutation round trips, compatibility routes only where explicitly adopted as product contracts |
| Runtime HTTP/WebSocket protocol | protocol enum variants and typed frames after decode | JSON wire compatibility, frame ordering, snapshot/backlog/live semantics, bounded output, cancellation/restore lifecycle, unknown id/status errors |
| Client crate Target operations | trait method signatures and result DTO types | unsupported operation errors, Local/Backend behavior parity where promised, stopped-worker semantics, URL/workspace/routing normalization |
| Worker tool definitions | `Tool` trait implementation and JSON value construction compile | tool name/schema stability, input validation, Backend authority requirement, bounded output, no direct local authority fallback, side effects/audit records |
| Tickets | typed Ticket backend API and workflow-state enum after parse | SQLite authority round trip, lifecycle transitions, dependency/queue gates, relation metadata, event/audit append, local import compatibility only where explicit |
| Objectives | typed Objective summaries/details after authority read | SQLite authority mutation round trip, canonical id allocation, state/title/body validation, ticket link/unlink, audit/event rows, scoped API/tool behavior |
| Memory | typed memory operation inputs after parse | single-document edit semantics, old_string uniqueness, staging close lifecycle, bounded reads, Backend Workspace authority use, no filesystem fallback in Worker tools |
| Worker lifecycle and workdirs | Rust structs for worker/workdir records | restore/dry-restore decisions, stopped/corrupted state transitions, occupancy projection, repository path containment, cleanup idempotence |
| Workspace authority and SQLite stores | Rust records and trait methods compile | schema migrations, foreign-key/unique constraints, transaction rollback behavior, authority boundary selection, import-only legacy paths |
| Nix/Docker/dev scripts | Nix attr names and shell strings are strings | binary names, package/bin target selectors, image entrypoints, fixed-output hashes, smoke help checks |
| Web frontend TypeScript/Rust DTO boundary | generated/declared TS types after decode | API route compatibility, URL construction, projection ordering, text truncation, auth/session redirect exceptions, source-level UI policy tests |
| Prompts and tool schema resources | resource files exist as strings | prompt inclusion policy, feature-driven instruction contribution, schema names/descriptions visible to the model, no stale command/tool references |
## Test design rules
1. Arrange only the state relevant to the named contract.
2. Exercise public or module-boundary behavior where possible. Test a helper directly only when that helper is the policy boundary.
3. Assert outcomes, rejected inputs, API responses, durable records, or state transitions rather than private call order and incidental ids.
4. Use a table in one test when several inputs describe one policy matrix. Use separate tests when failures would represent different product rules.
5. Boundary tests must include the boundary input in the fixture: argv slice, JSON body, TOML text, explicit environment snapshot, SQLite row/migration, path, HTTP request, protocol frame, or tool input JSON.
6. Filesystem tests use an RAII temporary directory and leave no files behind, including after panic. Do not write tests against the user's real Yoi data dir.
7. SQLite tests use temporary databases or in-memory stores and explicitly seed the authority records they need. Do not depend on the developer's live `server.db`.
8. Tool tests assert the model-visible contract: tool name, schema, validation, bounded content, authority errors, and durable side effects. Do not only assert that a Rust helper was called.
9. API tests should assert status codes and response bodies for both success and rejection. If a helper hides response bodies, include the body in assertion failure output.
10. Serde/DTO tests should exist only for external representation contracts: renamed fields, wire enums, bounded truncation, or explicitly adopted compatibility with existing persisted data. Do not add aliases, legacy variants, routes, or tests for them without an explicit compatibility decision.
11. Snapshot-like assertions are allowed only when the exact text/wire format is the contract. Prefer semantic assertions for large JSON/API responses.
12. Built-in resources and prompts should be tested by deriving expected references from manifests or feature config, not by duplicating mutable inventory counts in Rust assertions.
13. Do not test private helpers just to improve coverage numbers. If a private helper carries a policy boundary, name the contract in the test and keep the fixture minimal.
14. Every bug fix either strengthens an existing test or adds one focused test whose name states the lasting rule or the original failure mode. A `regression_` prefix is not required.
15. When a test needs a production resource, state which compatibility contract that resource represents. Generic fixtures should use minimal generated definitions.
16. Do not introduce environment variables whose purpose is test control, test fixtures, or test observation.
17. Code that consumes environment variables must snapshot them into typed startup configuration once. Test the environment-to-configuration mapping as a boundary over an explicit snapshot; all downstream logic tests pass typed configuration and do not read, set, remove, or depend on process-global environment variables.
18. Tests must not depend on execution order. Ordinary tests must be safe under Cargo's default parallel execution and must not coordinate through process-global state.
19. Synchronize on observable events or conditions. Do not use a fixed sleep to guess that asynchronous work has completed; use a timeout only as an upper bound that turns a hang into a diagnosable failure.
20. Tests must not depend on live external services or unrestricted network access. Use an in-process adapter or loopback fake at the narrow protocol boundary.
21. Do not implement a test that spawns real product processes or crosses a complete browser/TUI/client/backend/runtime flow unless the work explicitly requires the test to be designed and implemented as E2E. Otherwise protect the narrower parser, API, protocol, authority, or runtime contract.
## Placement rules
- Put unit tests next to the code when the policy boundary is module-local and does not require public API composition.
- Put integration tests under `tests/` when the contract is the public API of a library or binary-facing behavior. Shared integration-test helpers should live under `tests/common/mod.rs` so Cargo does not treat helper-only files as separate test crates.
- Binary parser/help tests may live in the binary crate when the parser is intentionally internal. Extract parser and startup-configuration boundaries for direct testing where practical. A real product-process invocation is added only when process-level behavior is explicitly required as an E2E contract.
- Workspace Server route tests belong near the router/API code unless they require a cross-crate external client contract.
- Store and authority tests should distinguish pure store constraints from Workspace authority behavior. A store test proves persistence constraints; an authority test proves product policy.
- Worker tool tests belong with the feature/tool module when they assert model-visible names, schemas, validation, authority selection, or bounded output.
## Boundary-specific guidance
### CLI
Parser tests must focus on user-visible command contracts: accepted argv shapes, target precedence, config defaults, and clear rejection. Do not assert that a parser took a particular private branch.
Help tests should prevent stale public surface: command names, binary names, config locations, and removed shims. They should not duplicate the entire help text unless the exact text is the compatibility contract.
### Environment-backed startup configuration
Environment lookup belongs at startup, before operational logic begins. Materialize an explicit environment snapshot, resolve it into typed configuration once, and pass that configuration inward.
Test parsing, precedence, missing values, and invalid values against the explicit snapshot. Do not repeatedly exercise the same environment lookup through unrelated logic tests, and do not mutate the test process environment.
### Workspace Server and Runtime APIs
Route tests should use HTTP-like requests against the router and assert status plus JSON body. A direct handler test is appropriate only when route wiring is irrelevant and the handler is the boundary.
Protocol tests should assert observable event/order/state semantics, not internal channel choreography.
### SQLite authority
Migration tests must assert the current schema version and the persisted behavior introduced by the migration. A schema version bump without a behavior test is insufficient when the migration adds a table, index, constraint, or compatibility transform.
Authority mutation tests should prove durable state after re-read and audit/event side effects where the authority promises them.
### Worker tools
A Worker tool exposes an LLM-facing contract. Tests should cover:
- exact tool name when the name is model-visible;
- required/optional schema fields and bounds;
- rejected malformed input;
- authority errors when Backend Workspace authority is required;
- bounded and useful output content;
- durable side effects for mutation tools.
Do not expose local filesystem fallback in a tool test unless that fallback is the intended product contract.
### Prompts and resources
Prompt tests should assert inclusion/exclusion and feature ownership, not incidental whitespace. If exact wording is a product policy, keep the asserted snippet short and name the policy.
## Execution assumptions for test authors
An ordinary test is part of the normal Cargo test suite. It must be deterministic, order-independent, parallel-safe, hermetic, and fast enough for repeated local execution.
If a contract exists only under a Cargo feature, gate the test with the same feature and place it in a target that Cargo actually builds for that feature. Do not hide ordinary product tests behind an unrelated test-only feature or environment variable.
An opt-in, ignored, process-spawning, privileged, platform-specific, or E2E test must be an explicit design decision. Its target, feature gate, runtime prerequisites, cleanup behavior, timeout, and diagnostic artifacts are part of the test design rather than assumptions left to the person running it.
Commands and validation scope used while developing are repository workflow rules and belong in `AGENTS.md` or `development/validation.md`, not in this test-authoring policy.
## Review checklist
When reviewing or adding a test, ask:
1. What product contract does this test protect?
2. Could Rust types alone already prove this?
3. Is this the right boundary: parser/API/store/tool/protocol/public library?
4. Would one failure point to one understandable rule?
5. Does the test avoid live user data, real data dirs, and incidental ids?
6. If this is a bug fix, did we encode the lasting rule or the original regression symptom?
7. Does the test avoid test-only environment variables and process-global environment access?
8. Is the test order-independent, parallel-safe, and synchronized without guessed sleeps?
9. Is any compatibility behavior backed by an explicit product decision?
10. If this spawns a product process or crosses the full stack, was E2E implementation explicitly required?

View File

@ -42,6 +42,6 @@ yoi ticket doctor
Use work item review to verify that the implementation satisfies the ticket, not only that the diff looks plausible. Use work item review to verify that the implementation satisfies the ticket, not only that the diff looks plausible.
## E2E validation ## Current limitation
Real-process E2E tests are opt-in. Do not add or extend E2E coverage unless the work explicitly requires the E2E test to be designed and implemented. Otherwise validate the narrower parser, API, protocol, authority, or runtime boundary. End-to-end tests that spawn real processes are not yet designed. When changing Worker restoration, socket behavior, or orchestration, compensate with targeted unit/integration tests and concrete manual evidence in the implementation report.

View File

@ -1,29 +0,0 @@
# SpawnWorker failed because the active Runtime CLI rejected its launch protocol
Date: 2026-07-30
## Context
While implementing Ticket `00001KYS7YMN5`, the parent Worker delegated two read-only code-mapping tasks through `SpawnWorker`.
Both launches failed before a child socket appeared. The spawned process reported:
```text
yoi-runtime: unexpected positional argument `worker`
Usage: yoi-runtime [OPTIONS]
```
The active `yoi-runtime` binary accepts only its HTTP Runtime server options and auth subcommands, while the Worker-management layer attempted to start a child through a legacy `worker` positional command.
## Impact
- Delegated read-only investigation was unavailable.
- The parent Worker had to perform the code mapping directly.
- This is protocol/version drift between the Worker-management launch path and the active Runtime binary, not a failure in the delegated task or scope.
## Suggested improvement
Make the child-launch protocol explicit and versioned rather than invoking a user-facing Runtime CLI subcommand implicitly. At minimum, Runtime registration/health should advertise the supported spawn protocol, and `SpawnWorker` should reject an incompatible Runtime with a bounded compatibility diagnostic before attempting to create a child socket.
The active backend/runtime should also be rebuilt and restarted together after CLI/launch-protocol changes so long-running Worker-management processes do not retain an obsolete invocation contract.

View File

@ -32,17 +32,6 @@ context_window = 256000
# Codex CLI's model catalog exposes these coding models with a 272k effective # Codex CLI's model catalog exposes these coding models with a 272k effective
# context window on this route, even when public API docs advertise larger # context window on this route, even when public API docs advertise larger
# model-family windows. # model-family windows.
# Codex currently caps the ChatGPT-backed route at 272k even though the public
# GPT-5.6 Sol API model advertises a 1.05M context window. Keep both values so
# resolution and compaction use the effective backend limit without losing the
# underlying model capability.
[[model]]
id = "gpt-5.6-sol"
provider = "codex-oauth"
context_window = 1050000
max_context_window = 272000
capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "effort", vision = true, prompt_caching = { kind = "auto" } }
[[model]] [[model]]
id = "gpt-5.5" id = "gpt-5.5"
provider = "codex-oauth" provider = "codex-oauth"

View File

@ -1,4 +1,4 @@
import "./base.dcdl" // { import "./default.dcdl" // {
slug = "coder"; slug = "coder";
description = "Ticket implementation coder profile."; description = "Ticket implementation coder profile.";
scope = "workspace_write"; scope = "workspace_write";

View File

@ -1,4 +1,4 @@
import "./base.dcdl" // { import "./default.dcdl" // {
slug = "companion"; slug = "companion";
description = "Workspace companion profile."; description = "Workspace companion profile.";
scope = "workspace_write"; scope = "workspace_write";

View File

@ -1,9 +1,9 @@
slug = "base"; slug = "default";
description = "Shared base configuration for Yoi Worker profiles."; description = "Default Yoi coding profile.";
scope = "workspace_write"; scope = "workspace_write";
model = { model = {
ref = "codex-oauth/gpt-5.6-sol"; ref = "codex-oauth/gpt-5.5";
}; };
session = { session = {

View File

@ -1,4 +1,4 @@
import "./base.dcdl" // { import "./default.dcdl" // {
slug = "intake"; slug = "intake";
description = "Ticket intake profile."; description = "Ticket intake profile.";
scope = "workspace_write"; scope = "workspace_write";

View File

@ -1,4 +1,4 @@
import "./base.dcdl" // { import "./default.dcdl" // {
slug = "memory-consolidation"; slug = "memory-consolidation";
description = "Memory staging consolidation profile."; description = "Memory staging consolidation profile.";
scope = "workspace_read"; scope = "workspace_read";

View File

@ -1,4 +1,4 @@
import "./base.dcdl" // { import "./default.dcdl" // {
slug = "orchestrator"; slug = "orchestrator";
description = "Ticket orchestrator profile."; description = "Ticket orchestrator profile.";
scope = "workspace_write"; scope = "workspace_write";

View File

@ -1,4 +1,4 @@
import "./base.dcdl" // { import "./default.dcdl" // {
slug = "reviewer"; slug = "reviewer";
description = "Ticket review profile."; description = "Ticket review profile.";
scope = "workspace_read"; scope = "workspace_read";

View File

@ -109,7 +109,7 @@
<span class="worker-task-title">-</span> <span class="worker-task-title">-</span>
</span> </span>
<span class="item-meta"> <span class="item-meta">
worker {worker.worker_id} · {worker.profile ? `${worker.profile} · ` : ''}{worker.state} · 🖥 {worker.host_id} worker {worker.worker_id} · {worker.role ? `${worker.role} · ` : ''}{worker.state} · 🖥 {worker.host_id}
{worker.working_directory ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.resolved_commit.slice(0, 8)}` : ''} {worker.working_directory ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.resolved_commit.slice(0, 8)}` : ''}
</span> </span>
</a> </a>

View File

@ -79,6 +79,7 @@ export type Worker = {
host_id: string; host_id: string;
display_name: string; display_name: string;
label: string; label: string;
role?: string | null;
profile?: string | null; profile?: string | null;
singleton_key?: string | null; singleton_key?: string | null;
tags: string[]; tags: string[];
@ -232,7 +233,6 @@ export type BrowserWorkingDirectoryCreateRequest = {
export type WorkerLaunchOptionsResponse = { export type WorkerLaunchOptionsResponse = {
workspace_id: string; workspace_id: string;
runtimes: WorkerLaunchRuntimeOption[]; runtimes: WorkerLaunchRuntimeOption[];
default_profile?: string | null;
profiles: WorkerLaunchProfileCandidate[]; profiles: WorkerLaunchProfileCandidate[];
repositories: WorkingDirectoryRepositoryOption[]; repositories: WorkingDirectoryRepositoryOption[];
working_directories: WorkingDirectorySummary[]; working_directories: WorkingDirectorySummary[];

View File

@ -38,7 +38,6 @@ const options: WorkerLaunchOptionsResponse = {
diagnostics: [], diagnostics: [],
}, },
], ],
default_profile: "builtin:coder",
profiles: [ profiles: [
{ id: "builtin:companion", label: "Companion", description: "chat" }, { id: "builtin:companion", label: "Companion", description: "chat" },
{ id: "builtin:coder", label: "Coder", description: "code" }, { id: "builtin:coder", label: "Coder", description: "code" },
@ -66,7 +65,7 @@ const options: WorkerLaunchOptionsResponse = {
diagnostics: [], diagnostics: [],
}; };
Deno.test("defaultWorkerLaunchForm uses the Backend-published Workspace default profile", () => { Deno.test("defaultWorkerLaunchForm chooses active runtime, coder profile, repository, and working directory", () => {
const form = defaultWorkerLaunchForm(options, { const form = defaultWorkerLaunchForm(options, {
runtime_id: "", runtime_id: "",
display_name: "", display_name: "",
@ -79,7 +78,7 @@ Deno.test("defaultWorkerLaunchForm uses the Backend-published Workspace default
}); });
assertEquals(form.runtime_id, "remote"); assertEquals(form.runtime_id, "remote");
assertEquals(form.display_name, "Worker"); assertEquals(form.display_name, "Coding Worker");
assertEquals(form.profile, "builtin:coder"); assertEquals(form.profile, "builtin:coder");
assertEquals(form.initial_text, "hello"); assertEquals(form.initial_text, "hello");
assertEquals(form.working_directory_id, "wd-1-repo"); assertEquals(form.working_directory_id, "wd-1-repo");

View File

@ -32,9 +32,9 @@ export function defaultWorkerLaunchForm(
) ?? ) ??
options?.runtimes.find((runtime) => runtime.can_spawn_worker) ?? options?.runtimes.find((runtime) => runtime.can_spawn_worker) ??
options?.runtimes[0]; options?.runtimes[0];
const preferredProfile = options?.profiles.find((candidate) => const preferredProfile =
candidate.id === options.default_profile options?.profiles.find((candidate) => candidate.id === "builtin:coder") ??
); options?.profiles[0];
const availableWorkingDirectories = const availableWorkingDirectories =
options?.working_directories.filter((directory) => options?.working_directories.filter((directory) =>
directory.status === "active" && directory.status === "active" &&
@ -60,7 +60,7 @@ export function defaultWorkerLaunchForm(
return { return {
runtime_id: current.runtime_id || preferredRuntime?.runtime_id || "", runtime_id: current.runtime_id || preferredRuntime?.runtime_id || "",
display_name: current.display_name || "Worker", display_name: current.display_name || "Coding Worker",
profile: profile:
options?.profiles.some((candidate) => candidate.id === current.profile) options?.profiles.some((candidate) => candidate.id === current.profile)
? current.profile ? current.profile

View File

@ -18,6 +18,7 @@ function worker(overrides: Partial<Worker>): Worker {
host_id: "host", host_id: "host",
display_name: "Worker 1", display_name: "Worker 1",
label: "Worker 1", label: "Worker 1",
role: null,
profile: null, profile: null,
singleton_key: null, singleton_key: null,
tags: [], tags: [],

View File

@ -1259,8 +1259,11 @@
<dd><code>{worker.host_id}</code></dd> <dd><code>{worker.host_id}</code></dd>
</div> </div>
<div> <div>
<dt>Profile</dt> <dt>Role / profile</dt>
<dd>{worker.profile ?? "unknown"}</dd> <dd>
{worker.role ?? "unknown"} / {worker.profile ??
"unknown"}
</dd>
</div> </div>
<div> <div>
<dt>Workspace</dt> <dt>Workspace</dt>

View File

@ -139,7 +139,7 @@
} }
function workerProfile(worker: Worker): string { function workerProfile(worker: Worker): string {
return worker.profile ?? 'unknown'; return worker.profile ?? worker.role ?? 'unknown';
} }
function workerDirectory(worker: Worker): string { function workerDirectory(worker: Worker): string {

View File

@ -31,9 +31,9 @@
let optionsError = $state<string | null>(null); let optionsError = $state<string | null>(null);
let submitting = $state(false); let submitting = $state(false);
let submitError = $state<DisplayError | null>(null); let submitError = $state<DisplayError | null>(null);
let displayName = $state('Worker'); let displayName = $state('Coding Worker');
let runtimeId = $state(''); let runtimeId = $state('');
let profile = $state(''); let profile = $state('builtin:coder');
let initialText = $state(''); let initialText = $state('');
let workingDirectoryId = $state(''); let workingDirectoryId = $state('');
let workingDirectoryRepositoryId = $state(''); let workingDirectoryRepositoryId = $state('');