Compare commits

...

7 Commits

47 changed files with 2807 additions and 508 deletions

View File

@ -38,7 +38,22 @@ Orchestrator の cwd が orchestration 用ブランチ/worktree の場合、通
## 検証
検証は変更内容に応じて `cargo test` / `cargo check` / `git diff --check` など、妥当な範囲で行う。重い検証は必要性が高い場合に選ぶ。
開発中は、変更した契約を証明する最小の target / filter から実行する。
```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
```
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.
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.
License: MIT. See [`LICENSE`](LICENSE).

View File

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

View File

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

View File

@ -441,6 +441,23 @@ mod tests {
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]
fn codex_gpt55_catalog_records_effective_context_window() {
let providers = load_builtin_providers().unwrap();

View File

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

View File

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

View File

@ -363,7 +363,6 @@ mod tests {
display_name: "label".to_string(),
singleton_key: None,
tags: Vec::new(),
role: None,
profile: profile.map(str::to_string),
workspace: BackendWorkerWorkspaceSummary {
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"),
role: TicketRole::Intake,
worker_name: "intake-worker".to_string(),
profile: "builtin:default".to_string(),
profile: "builtin:companion".to_string(),
launch_prompt_ref: None,
run_segments: vec![],
},

View File

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

View File

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

View File

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

View File

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

View File

@ -39,6 +39,15 @@ pub enum RuntimeError {
#[error("invalid request: {0}")]
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)]
WorkingDirectory(#[from] WorkingDirectoryDiagnostic),

View File

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

View File

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

View File

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

View File

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

View File

@ -39,6 +39,22 @@ use std::sync::{Arc, Mutex, MutexGuard};
#[cfg(feature = "ws-server")]
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.
///
/// The default implementation is memory-backed and tools/provider-less by
@ -320,11 +336,35 @@ impl Runtime {
pub fn create_worker(
&self,
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> {
let (backend, worker_ref, spawn_request) = {
let mut state = self.lock()?;
state.ensure_running()?;
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)?;
if let Some(working_directory_id) = requested_primary_workdir_id(&request) {
if let Some(owner_worker_id) =
@ -354,6 +394,7 @@ impl Runtime {
worker_ref: worker_ref.clone(),
worker_id: worker_id.clone(),
status: WorkerStatus::Stopped,
workspace_id: scope.map(|scope| scope.workspace_id.clone()),
request: request.clone(),
working_directory: None,
execution_handle: None,
@ -446,6 +487,65 @@ impl Runtime {
.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.
pub fn worker_detail(&self, worker_ref: &WorkerRef) -> Result<WorkerDetail, RuntimeError> {
let state = self.lock()?;
@ -453,6 +553,33 @@ impl Runtime {
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.
///
/// Current liveness is never read from disk. If a handle is already
@ -541,6 +668,17 @@ impl Runtime {
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.
pub fn send_input(
&self,
@ -612,6 +750,18 @@ 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.
pub fn worker_completions(
&self,
@ -634,6 +784,17 @@ impl Runtime {
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.
///
/// Most methods are delivered to the execution backend unchanged. Methods with
@ -720,6 +881,10 @@ impl Runtime {
fn rollback_failed_create(&self, worker_ref: &WorkerRef) -> Result<(), RuntimeError> {
let mut state = self.lock()?;
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| {
event.id != record.last_event_id || event.worker_ref.as_ref() != Some(worker_ref)
});
@ -783,6 +948,17 @@ 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.
pub fn stop_worker(
&self,
@ -798,6 +974,17 @@ 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.
pub fn cancel_worker(
&self,
@ -813,6 +1000,16 @@ 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.
pub fn delete_worker(
&self,
@ -833,6 +1030,9 @@ impl Runtime {
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")]
state
.observation_events
@ -1216,6 +1416,7 @@ struct RuntimeState {
#[cfg(feature = "fs-store")]
next_diagnostic_id: u64,
workers: BTreeMap<WorkerId, WorkerRecord>,
workspace_owners: BTreeMap<String, String>,
config_bundles: BTreeMap<String, ConfigBundle>,
events: Vec<RuntimeEvent>,
diagnostics: Vec<RuntimeDiagnostic>,
@ -1241,6 +1442,7 @@ impl RuntimeState {
#[cfg(feature = "fs-store")]
next_diagnostic_id: 1,
workers: BTreeMap::new(),
workspace_owners: BTreeMap::new(),
config_bundles: BTreeMap::new(),
events: Vec::new(),
diagnostics: Vec::new(),
@ -1271,6 +1473,7 @@ impl RuntimeState {
#[cfg(feature = "fs-store")]
next_diagnostic_id: 1,
workers: BTreeMap::new(),
workspace_owners: BTreeMap::new(),
config_bundles: BTreeMap::new(),
events: Vec::new(),
diagnostics: Vec::new(),
@ -1298,6 +1501,7 @@ impl RuntimeState {
worker_ref: worker.worker_ref,
worker_id: worker.worker_id,
status: WorkerStatus::Stopped,
workspace_id: worker.workspace_id,
request: worker.request,
working_directory: worker.working_directory,
execution_handle: None,
@ -1318,6 +1522,7 @@ impl RuntimeState {
next_diagnostic_id,
workers,
config_bundles: persisted.config_bundles,
workspace_owners: persisted.workspace_owners,
events: persisted.events,
diagnostics,
#[cfg(feature = "ws-server")]
@ -1344,6 +1549,7 @@ impl RuntimeState {
.map(|(worker_id, worker)| (worker_id.clone(), worker.persisted_record()))
.collect(),
config_bundles: self.config_bundles.clone(),
workspace_owners: self.workspace_owners.clone(),
events: self.events.clone(),
diagnostics: self.diagnostics.clone(),
}
@ -1486,6 +1692,59 @@ impl RuntimeState {
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> {
self.ensure_worker_ref(worker_ref)?;
self.workers
@ -1661,6 +1920,7 @@ struct WorkerRecord {
worker_ref: WorkerRef,
worker_id: WorkerId,
status: WorkerStatus,
workspace_id: Option<String>,
request: CreateWorkerRequest,
working_directory: Option<CatalogWorkingDirectoryStatus>,
execution_handle: Option<WorkerExecutionHandle>,
@ -1668,11 +1928,16 @@ struct WorkerRecord {
}
impl WorkerRecord {
fn belongs_to_workspace(&self, workspace_id: &str) -> bool {
self.workspace_id.as_deref() == Some(workspace_id)
}
fn summary(&self) -> WorkerSummary {
WorkerSummary {
worker_ref: self.worker_ref.clone(),
worker_id: self.worker_id,
status: self.status,
workspace_id: self.workspace_id.clone(),
working_directory: self.working_directory.clone(),
profile: self.request.profile.clone(),
display_name: self.request.display_name.clone(),
@ -1687,6 +1952,7 @@ impl WorkerRecord {
worker_ref: self.worker_ref.clone(),
worker_id: self.worker_id,
status: self.status,
workspace_id: self.workspace_id.clone(),
working_directory: self.working_directory.clone(),
profile: self.request.profile.clone(),
display_name: self.request.display_name.clone(),
@ -1702,6 +1968,7 @@ impl WorkerRecord {
worker_ref: self.worker_ref.clone(),
worker_id: self.worker_id.clone(),
request: self.request.clone(),
workspace_id: self.workspace_id.clone(),
working_directory: self.working_directory.clone(),
last_event_id: self.last_event_id,
}
@ -1766,6 +2033,32 @@ fn validate_create_worker_request(request: &CreateWorkerRequest) -> Result<(), R
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> {
if !input.kind.is_empty_content_allowed() && input.content.trim().is_empty() {
return Err(RuntimeError::InvalidRequest(
@ -1815,7 +2108,7 @@ fn input_protocol_event(input: &WorkerInput) -> protocol::Event {
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{ConfigBundleRef, ProfileSelector};
use crate::catalog::{ConfigBundleRef, ProfileSelector, WorkspaceApiRef};
use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
ConfigDeclarationKind, ConfigProfileDescriptor,
@ -1863,6 +2156,19 @@ 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 {
ConfigBundle {
metadata: ConfigBundleMetadata {
@ -2040,6 +2346,182 @@ mod tests {
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]
fn create_list_and_detail_preserve_runtime_local_worker_authority() {
let runtime = runtime_with_backend();
@ -2686,6 +3168,82 @@ mod tests {
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")]
#[test]
fn fs_store_restores_active_worker_execution_handles() {

View File

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

View File

@ -1,4 +1,5 @@
//! 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
//! local `.yoi/objectives` paths, so model-visible Objective tools go through
@ -43,23 +44,119 @@ impl WorkspaceHttpObjectiveBackend {
}
async fn show(&self, input: ObjectiveShowInput) -> Result<ToolOutput, ToolError> {
let id = input.id.trim();
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 id = validate_id(&input.id, "ObjectiveShow")?;
let url = self.objective_url(id);
let response = get_json::<ObjectiveDetail>(&url)
.await
.map_err(backend_error)?;
Ok(ToolOutput {
summary: format!("Read objective {}", response.id),
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
})
Ok(objective_output(
format!("Read objective {}", response.id),
response,
)?)
}
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
)
}
}
@ -88,6 +185,32 @@ async fn get_json<T: for<'de> Deserialize<'de>>(
url: &str,
) -> Result<T, WorkspaceObjectiveBackendError> {
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 body = response.text().await?;
if !status.is_success() {
@ -96,6 +219,23 @@ async fn get_json<T: for<'de> Deserialize<'de>>(
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(
workspace_id: impl Into<String>,
base_url: impl Into<String>,
@ -113,9 +253,44 @@ pub fn workspace_http_objective_tools(
"ObjectiveShow",
SHOW_DESCRIPTION,
show_schema(),
backend,
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,
ObjectiveOperation::UnlinkTicket,
),
]
}
@ -123,6 +298,11 @@ pub fn workspace_http_objective_tools(
enum ObjectiveOperation {
List,
Show,
Create,
Edit,
SetState,
LinkTicket,
UnlinkTicket,
}
fn objective_tool(
@ -167,6 +347,26 @@ impl Tool for WorkspaceHttpObjectiveTool {
let input = parse_input::<ObjectiveShowInput>(input_json)?;
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
}
}
}
}
@ -179,6 +379,16 @@ const LIST_DESCRIPTION: &str =
"List Objective records through Backend Workspace API authority as bounded summaries.";
const SHOW_DESCRIPTION: &str =
"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 {
json!({
@ -191,16 +401,81 @@ fn list_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!({
"type":"object",
"additionalProperties": false,
"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":{
"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)]
struct ObjectiveListInput {
limit: Option<usize>,
@ -211,6 +486,67 @@ struct ObjectiveShowInput {
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)]
struct ObjectiveListResponse {
items: Vec<ObjectiveSummary>,
@ -263,20 +599,37 @@ mod tests {
}
#[test]
fn workspace_http_objective_tools_include_read_only_objective_tools() {
fn workspace_http_objective_tools_include_objective_crud_tools() {
let names = tool_names(workspace_http_objective_tools(
"workspace".to_string(),
"http://backend".to_string(),
));
assert_eq!(names, vec!["ObjectiveList", "ObjectiveShow"]);
assert_eq!(
names,
vec![
"ObjectiveCreate",
"ObjectiveEdit",
"ObjectiveLinkTicket",
"ObjectiveList",
"ObjectiveSetState",
"ObjectiveShow",
"ObjectiveUnlinkTicket",
]
);
}
#[test]
fn objective_tool_schemas_are_bounded_and_read_only() {
fn objective_tool_schemas_are_bounded_and_mutation_scoped() {
let list = list_schema();
assert_eq!(list["properties"]["limit"]["maximum"], 1000);
let show = show_schema();
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`
/// for the effective child default profile, use `inherit` to derive
/// reusable config from the spawner, or use a registry selector such as
/// `project:coder`, `project:reviewer`, `builtin:default`, or an
/// `project:coder`, `project:reviewer`, `builtin:companion`, or an
/// unambiguous profile slug. Raw/path selectors are rejected.
#[serde(default)]
profile: Option<String>,
@ -1516,7 +1516,7 @@ max_tokens = 3333
assert!(invalid.contains("Use `default`, `inherit`"));
assert!(invalid.contains("`project:coder`"));
let default_config = build_spawn_config_json_for_profile(
let default_error = build_spawn_config_json_for_profile(
&parent,
&available,
&project,
@ -1525,8 +1525,8 @@ max_tokens = 3333
&scope,
SpawnProfileSelector::Default,
)
.unwrap();
assert!(default_config.contains("\"name\":\"child\""));
.unwrap_err();
assert!(default_error.contains("no default profile is configured"));
let user_config = tmp.path().join("user-profiles.toml");
std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.toml\"\n").unwrap();

View File

@ -1,6 +1,7 @@
use std::path::PathBuf;
use chrono::Utc;
use project_record::{allocate_record_id, unix_epoch_millis_now};
use ticket::{
SqliteTicketBackend, TicketBackend, TicketIdOrSlug, TicketListQuery,
@ -13,7 +14,7 @@ use crate::records::{
};
use crate::store::{
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
SqliteWorkspaceStore,
ObjectiveEventRecord, ObjectiveRecord, ObjectiveTicketLinkRecord, SqliteWorkspaceStore,
};
use crate::{Error, Result};
@ -38,6 +39,27 @@ pub trait TicketAuthority {
pub trait ObjectiveAuthority {
fn list_objectives(&self, limit: usize) -> Result<ProjectRecordList<ObjectiveSummary>>;
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 {
@ -110,6 +132,75 @@ impl SqliteWorkspaceAuthority {
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 {
@ -189,52 +280,172 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
updated_at: Some(record.updated_at),
summary: summarize_body(&record.body_md),
linked_tickets,
record_source: "workspace-sqlite".to_string(),
record_source: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(),
});
}
Ok(ProjectRecordList {
items,
invalid_records: Vec::new(),
record_authority: "workspace-sqlite".to_string(),
record_authority: RECORD_SOURCE_WORKSPACE_SQLITE.to_string(),
})
}
fn objective(&self, id: &str) -> Result<ObjectiveDetail> {
validate_project_id(id)?;
let record = self
.store
.get_objective(&self.workspace_id, id)?
.ok_or_else(|| Error::Store(format!("unknown objective `{id}`")))?;
let linked_tickets = self
.store
.list_objective_ticket_links(&self.workspace_id, &record.objective_id)?
let record = self.objective_record(id)?;
self.objective_detail_from_record(record)
}
fn create_objective(&self, input: ObjectiveCreateInput) -> Result<ObjectiveDetail> {
validate_objective_title(&input.title)?;
validate_objective_state(&input.state)?;
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()
.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,
.map(|ticket_id| ObjectiveTicketLinkRecord {
workspace_id: self.workspace_id.clone(),
objective_id: objective_id.clone(),
ticket_id,
kind: "linked".to_string(),
created_at: now.clone(),
})
.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: "workspace-sqlite".to_string(),
})
.collect::<Vec<_>>();
self.store
.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
.list_objective_ticket_links(&self.workspace_id, id)?;
if !links.iter().any(|link| link.ticket_id == ticket_id) {
links.push(ObjectiveTicketLinkRecord {
workspace_id: self.workspace_id.clone(),
objective_id: id.to_string(),
ticket_id: ticket_id.to_string(),
kind: "linked".to_string(),
created_at: now,
});
self.store
.replace_objective_ticket_links(&self.workspace_id, id, &links)?;
self.insert_objective_event(id, "link_ticket", Some(ticket_id))?;
}
self.objective(id)
}
fn unlink_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 mut links = self
.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)
}
}
@ -372,6 +583,38 @@ 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<()> {
let value: serde_json::Value = serde_json::from_str(raw_json)
.map_err(|err| Error::Store(format!("memory {field} must be valid JSON: {err}")))?;
@ -551,6 +794,80 @@ 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]
fn does_not_read_legacy_ticket_files_without_sqlite_import() {
let dir = tempfile::tempdir().unwrap();

View File

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

View File

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

View File

@ -43,7 +43,8 @@ use crate::auth::{
session_set_cookie, token_hash,
};
use crate::authority::{
MemoryAuthority, ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority,
MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput,
SqliteWorkspaceAuthority, TicketAuthority,
};
use crate::companion::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
@ -514,12 +515,24 @@ pub fn build_router(api: WorkspaceApi) -> Router {
.route("/api/objectives", get(list_objectives))
.route(
"/api/w/{workspace_id}/objectives",
get(scoped_list_objectives),
get(scoped_list_objectives).post(scoped_create_objective),
)
.route("/api/objectives/{id}", get(get_objective))
.route(
"/api/w/{workspace_id}/objectives/{id}",
get(scoped_get_objective),
"/api/w/{workspace_id}/objectives/{objective_id}",
get(scoped_get_objective).patch(scoped_edit_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(
@ -1046,6 +1059,7 @@ pub struct RemoteRuntimeTestResponse {
pub struct WorkerLaunchOptionsResponse {
pub workspace_id: String,
pub runtimes: Vec<WorkerLaunchRuntimeOption>,
pub default_profile: Option<String>,
pub profiles: Vec<WorkerLaunchProfileCandidate>,
pub repositories: Vec<WorkingDirectoryRepositoryOption>,
pub working_directories: Vec<WorkingDirectorySummary>,
@ -1115,7 +1129,8 @@ pub struct BrowserWorkerWorkingDirectorySelection {
pub struct BrowserCreateWorkerRequest {
pub runtime_id: String,
pub display_name: String,
pub profile: String,
#[serde(default)]
pub profile: Option<String>,
pub initial_text: String,
#[serde(default)]
pub working_directory: Option<BrowserWorkerWorkingDirectorySelection>,
@ -1195,6 +1210,53 @@ struct ObjectiveListQuery {
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)]
struct TranscriptQuery {
start: Option<usize>,
@ -1619,7 +1681,7 @@ fn start_memory_staging_consolidation(
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 1,
},
profile: Some(profile_selector),
profile: profile_selector,
initial_input: Some(input),
working_directory_request: None,
resolved_working_directory_request: None,
@ -1728,10 +1790,7 @@ fn try_reuse_memory_consolidation_worker(
fn is_memory_consolidation_worker(worker: &WorkerSummary) -> bool {
worker.singleton_key.as_deref() == Some(MEMORY_CONSOLIDATION_SINGLETON_KEY)
|| [worker.profile.as_deref(), worker.role.as_deref()]
.into_iter()
.flatten()
.any(|value| value == MEMORY_CONSOLIDATION_PROFILE)
|| worker.profile.as_deref() == Some(MEMORY_CONSOLIDATION_PROFILE)
}
fn memory_consolidation_input_content(candidate_count: usize, total_bytes: u64) -> String {
@ -1831,10 +1890,78 @@ async fn scoped_list_objectives(
async fn scoped_get_objective(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRecordPath>,
AxumPath(path): AxumPath<ScopedObjectivePath>,
) -> ApiResult<Json<ObjectiveDetail>> {
validate_workspace_scope(&api, &path.workspace_id)?;
get_objective(State(api), AxumPath(path.id)).await
get_objective(State(api), AxumPath(path.objective_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(
@ -4133,20 +4260,40 @@ async fn create_workspace_worker(
State(api): State<WorkspaceApi>,
Json(request): Json<BrowserCreateWorkerRequest>,
) -> 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 =
profile_selector_for_candidate_with_root(&api.config.workspace_root, &request.profile)
.ok_or_else(|| {
profile_selector_for_candidate_with_root(&api.config.workspace_root, &profile).ok_or_else(
|| {
settings_bad_request(
"unsupported_worker_profile",
"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(
&api.config.workspace_root,
&api.config.workspace_id,
&api.config.workspace_created_at,
&request.profile,
&profile,
)?
} else {
None
@ -4192,7 +4339,7 @@ async fn create_workspace_worker(
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: if initial_input.is_some() { 1 } else { 0 },
},
profile: Some(profile_selector),
profile: profile_selector,
initial_input,
working_directory_request: None,
resolved_working_directory_request: None,
@ -5645,10 +5792,32 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
}
})
.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 {
workspace_id: api.config.workspace_id.clone(),
runtimes,
profiles: worker_profile_candidates_for_root(&api.config.workspace_root),
default_profile: profile_settings.default_profile,
profiles,
repositories: working_directory_repository_options(api),
working_directories: available_working_directory_summaries(api).unwrap_or_default(),
diagnostics: Vec::new(),
@ -5804,7 +5973,6 @@ fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary
worker_id: record.runtime_worker_id.to_string(),
runtime_id: record.runtime_id.clone(),
host_id: "backend-registry".to_string(),
role: None,
display_name: record.display_name.clone(),
label: record.display_name.clone(),
singleton_key: None,
@ -6245,58 +6413,25 @@ 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(
workspace_root: &Path,
profile: &str,
) -> Option<ProfileSelector> {
if profile_selector_for_candidate(profile).is_some() {
profile_selector_for_candidate(profile)
} else if crate::profile_settings::is_profile_candidate(workspace_root, profile) {
if let Some(selector @ ProfileSelector::Builtin(_)) =
crate::profile_settings::selector_for_builtin_candidate(profile)
} else {
None
{
return Some(selector);
}
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> {
@ -6313,7 +6448,7 @@ fn sanitize_worker_display_name(value: &str) -> Option<String> {
if display_name.chars().any(char::is_control) {
None
} else if display_name.is_empty() {
Some("Coding Worker".to_string())
Some("Worker".to_string())
} else {
Some(display_name.chars().take(80).collect())
}
@ -6690,7 +6825,9 @@ impl IntoResponse for ApiError {
StatusCode::CONFLICT
}
Error::RuntimeOperationFailed { code, .. }
if code == "unknown_profile_source" || code == "unknown_profile_selector" =>
if code == "unknown_profile_source"
|| code == "unknown_profile_selector"
|| code == "unknown_objective" =>
{
StatusCode::NOT_FOUND
}
@ -6759,7 +6896,7 @@ mod tests {
};
use crate::store::{
MemoryDocumentRecord, MemoryStagingRecord, ObjectiveRecord, ObjectiveResourceRecord,
ObjectiveTicketLinkRecord, SqliteWorkspaceStore,
ObjectiveTicketLinkRecord, SqliteWorkspaceStore, WorkspaceRecord,
};
const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
@ -6935,17 +7072,89 @@ mod tests {
#[test]
fn worker_profile_candidates_are_backend_published_and_mapped() {
let candidates = worker_profile_candidates();
let dir = tempfile::tempdir().unwrap();
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!(
candidates
settings
.profiles
.iter()
.any(|candidate| candidate.id == "builtin:coder")
.all(|profile| profile.profile_id != "builtin:default")
);
assert_eq!(
settings.default_profile.as_deref(),
Some("builtin:companion")
);
assert!(matches!(
profile_selector_for_candidate("builtin:coder"),
profile_selector_for_candidate_with_root(dir.path(), "builtin:coder"),
Some(ProfileSelector::Builtin(value)) if value == "builtin:coder"
));
assert!(profile_selector_for_candidate("free-text-profile").is_none());
assert!(
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]
@ -7128,11 +7337,19 @@ mod tests {
)
.unwrap();
fs::write(dir.path().join(".yoi/profiles/bad.dcdl"), "not decodal").unwrap();
assert!(
worker_profile_candidates_for_root(dir.path())
.iter()
.all(|candidate| candidate.id != "project:bad")
);
let candidates =
crate::profile_settings::load_profile_settings("workspace-test", dir.path())
.profiles
.into_iter()
.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());
}
@ -7453,9 +7670,7 @@ mod tests {
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0,
},
profile: Some(ProfileSelector::Builtin(
MEMORY_CONSOLIDATION_PROFILE.to_string(),
)),
profile: ProfileSelector::Builtin(MEMORY_CONSOLIDATION_PROFILE.to_string()),
initial_input: None,
working_directory_request: None,
resolved_working_directory_request: None,
@ -8011,7 +8226,9 @@ mod tests {
},
},
profiles: vec![worker_runtime::config_bundle::ConfigProfileDescriptor {
selector: worker_runtime::catalog::ProfileSelector::RuntimeDefault,
selector: worker_runtime::catalog::ProfileSelector::Builtin(
"builtin:companion".to_string(),
),
label: Some("server-test".to_string()),
}],
declarations: Vec::new(),
@ -8024,7 +8241,9 @@ mod tests {
fn runtime_create_request() -> worker_runtime::catalog::CreateWorkerRequest {
let bundle = runtime_test_bundle();
worker_runtime::catalog::CreateWorkerRequest {
profile: worker_runtime::catalog::ProfileSelector::RuntimeDefault,
profile: worker_runtime::catalog::ProfileSelector::Builtin(
"builtin:companion".to_string(),
),
display_name: None,
profile_source: worker_runtime::catalog::ProfileSourceArchiveSource::Http {
location: worker_runtime::catalog::ProfileSourceArchiveHttpRef {
@ -8404,8 +8623,14 @@ mod tests {
}
#[tokio::test]
async fn browser_worker_create_succeeds_and_preserves_unsupported_diagnostics() {
async fn browser_worker_create_uses_workspace_default_and_preserves_unsupported_diagnostics() {
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 created = post_json(
app.clone(),
@ -8413,7 +8638,6 @@ mod tests {
serde_json::json!({
"runtime_id": "embedded-worker-runtime",
"display_name": "",
"profile": "runtime_default",
"initial_text": ""
}),
)
@ -8426,7 +8650,9 @@ mod tests {
.iter()
.find(|worker| worker["worker_id"] == created["worker_id"])
.expect("created Worker should be listed");
assert_eq!(worker["label"], "Coding Worker");
assert_eq!(worker["label"], "Worker");
assert_eq!(worker["profile"], "builtin:coder");
assert!(worker.get("role").is_none());
assert_eq!(worker["worker_id"], created["worker_id"]);
let detail_path = format!(
"/api/runtimes/{}/workers/{}",
@ -8434,7 +8660,7 @@ mod tests {
created["worker_id"].as_str().unwrap()
);
let detail = get_json(app.clone(), detail_path.as_str()).await;
assert_eq!(detail["label"], "Coding Worker");
assert_eq!(detail["label"], "Worker");
assert_eq!(detail["worker_id"], created["worker_id"]);
assert!(
created["console_href"]
@ -8477,7 +8703,7 @@ mod tests {
Some(serde_json::json!({
"runtime_id": "remote-runtime",
"display_name": "Remote Worker",
"profile": "runtime_default",
"profile": "builtin:companion",
"initial_text": ""
})),
StatusCode::BAD_REQUEST,
@ -8516,6 +8742,10 @@ mod tests {
"kind": "run_accepted",
"expected_segments": 0
},
"profile": {
"kind": "builtin",
"value": "builtin:coder"
},
"working_directory_request": {
"repository_id": TEST_REPOSITORY_ID,
"local_path": dir.path().display().to_string()
@ -8551,6 +8781,10 @@ mod tests {
"kind": "run_accepted",
"expected_segments": 0
},
"profile": {
"kind": "builtin",
"value": "builtin:coder"
},
"working_directory_request": {
"repository_id": TEST_REPOSITORY_ID,
"selector": "HEAD"
@ -9129,7 +9363,7 @@ mod tests {
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0,
},
profile: None,
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
initial_input: None,
working_directory_request: None,
resolved_working_directory_request: None,
@ -9371,6 +9605,10 @@ mod tests {
"acceptance": {
"kind": "run_accepted",
"expected_segments": 0
},
"profile": {
"kind": "builtin",
"value": "builtin:coder"
}
}),
)
@ -9674,6 +9912,103 @@ mod tests {
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 {
let response = app
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
@ -9702,8 +10037,14 @@ mod tests {
.oneshot(builder.body(request_body).unwrap())
.await
.unwrap();
assert_eq!(response.status(), expected_status, "{method} {uri}");
let status = response.status();
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::json!({ "message": String::from_utf8_lossy(&bytes).to_string() }),
)

View File

@ -77,6 +77,11 @@ const MIGRATIONS: &[Migration] = &[
name: "trusted remote runtime registry",
apply: create_trusted_runtime_registry_tables,
},
Migration {
version: 13,
name: "objective mutation audit events",
apply: create_objective_event_tables,
},
];
struct Migration {
@ -267,6 +272,16 @@ pub struct ObjectiveTicketLinkRecord {
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)]
pub struct ObjectiveResourceRecord {
pub workspace_id: String,
@ -335,6 +350,12 @@ pub trait ControlPlaneStore: Send + Sync {
workspace_id: &str,
objective_id: &str,
) -> 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 list_objective_resources(
&self,
@ -792,6 +813,52 @@ 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<()> {
self.with_conn(|conn| {
conn.execute(
@ -2089,6 +2156,22 @@ CREATE TABLE IF NOT EXISTS memory_staging_resolutions (
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<()> {
conn.execute_batch(
r#"
@ -2647,6 +2730,15 @@ CREATE TABLE IF NOT EXISTS objective_ticket_links (
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 (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
objective_id TEXT NOT NULL REFERENCES objectives(objective_id) ON DELETE CASCADE,
@ -2818,7 +2910,7 @@ mod tests {
let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 11);
assert_eq!(store.schema_version().await.unwrap(), 13);
let record = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
@ -2831,7 +2923,7 @@ mod tests {
store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 11);
assert_eq!(reopened.schema_version().await.unwrap(), 13);
assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(),
Some(record)
@ -3052,7 +3144,7 @@ mod tests {
.unwrap();
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 11);
assert_eq!(store.schema_version().await.unwrap(), 13);
store
.with_conn(|conn| {
@ -3146,7 +3238,7 @@ mod tests {
#[tokio::test]
async fn repository_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 11);
assert_eq!(store.schema_version().await.unwrap(), 13);
let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
owner_account_id: None,
@ -3184,7 +3276,7 @@ mod tests {
#[tokio::test]
async fn memory_authority_records_round_trip_and_close_staging() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 11);
assert_eq!(store.schema_version().await.unwrap(), 13);
let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
owner_account_id: None,
@ -3358,7 +3450,7 @@ mod tests {
#[tokio::test]
async fn account_and_login_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 11);
assert_eq!(store.schema_version().await.unwrap(), 13);
let now = "2026-07-22T00:00:00Z".to_string();
let account = AccountRecord {
account_id: "acct-user-alice".to_string(),

View File

@ -18,7 +18,8 @@ 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.
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.
13. [`development/validation.md`](development/validation.md) — how to check changes.
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.
14. [`development/validation.md`](development/validation.md) — how to check changes.
## What belongs here

View File

@ -0,0 +1,177 @@
# 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.
## Current limitation
## E2E validation
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.
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.

View File

@ -0,0 +1,29 @@
# 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,6 +32,17 @@ context_window = 256000
# 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
# 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]]
id = "gpt-5.5"
provider = "codex-oauth"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,6 +38,7 @@ const options: WorkerLaunchOptionsResponse = {
diagnostics: [],
},
],
default_profile: "builtin:coder",
profiles: [
{ id: "builtin:companion", label: "Companion", description: "chat" },
{ id: "builtin:coder", label: "Coder", description: "code" },
@ -65,7 +66,7 @@ const options: WorkerLaunchOptionsResponse = {
diagnostics: [],
};
Deno.test("defaultWorkerLaunchForm chooses active runtime, coder profile, repository, and working directory", () => {
Deno.test("defaultWorkerLaunchForm uses the Backend-published Workspace default profile", () => {
const form = defaultWorkerLaunchForm(options, {
runtime_id: "",
display_name: "",
@ -78,7 +79,7 @@ Deno.test("defaultWorkerLaunchForm chooses active runtime, coder profile, reposi
});
assertEquals(form.runtime_id, "remote");
assertEquals(form.display_name, "Coding Worker");
assertEquals(form.display_name, "Worker");
assertEquals(form.profile, "builtin:coder");
assertEquals(form.initial_text, "hello");
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[0];
const preferredProfile =
options?.profiles.find((candidate) => candidate.id === "builtin:coder") ??
options?.profiles[0];
const preferredProfile = options?.profiles.find((candidate) =>
candidate.id === options.default_profile
);
const availableWorkingDirectories =
options?.working_directories.filter((directory) =>
directory.status === "active" &&
@ -60,7 +60,7 @@ export function defaultWorkerLaunchForm(
return {
runtime_id: current.runtime_id || preferredRuntime?.runtime_id || "",
display_name: current.display_name || "Coding Worker",
display_name: current.display_name || "Worker",
profile:
options?.profiles.some((candidate) => candidate.id === current.profile)
? current.profile

View File

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

View File

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

View File

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

View File

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