返回 CodeWhale
runtime_api.rs
根目录 / crates / tui / src / runtime_api.rs
1 //! Runtime HTTP/SSE API for local Codewhale automation.
2
3 use std::collections::{BTreeMap, BTreeSet, HashMap};
4 use std::convert::Infallible;
5 use std::fs;
6 use std::net::{IpAddr, SocketAddr};
7 use std::path::{Path as FsPath, PathBuf};
8 use std::sync::Arc;
9 use std::time::Duration;
10
11 use anyhow::{Context, Result, anyhow, bail};
12 use async_stream::stream;
13 use axum::extract::{ConnectInfo, DefaultBodyLimit, Path, Query, Request, State};
14 use axum::http::header;
15 use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
16 use axum::middleware;
17 use axum::response::Html;
18 use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
19 use axum::response::{IntoResponse, Response};
20 use axum::routing::{delete, get, post, put};
21 use axum::{Json, Router};
22 use base64::Engine as _;
23 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
24 use chrono::Utc;
25 use codewhale_protocol::agent_mail::{
26 AgentMailDeliveryMode, AgentMailEnvelope, AgentMailMessageId, AgentMailSendRequest,
27 AgentMailSendResponse,
28 };
29 use codewhale_protocol::runtime::{
30 DynamicToolCallResult, RUNTIME_API_VERSION, RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION,
31 RuntimeCapabilities, RuntimeEventEnvelope, RuntimeExperimentalCapabilities,
32 };
33 use codewhale_secrets::account::{
34 ACCOUNT_API_BASE_ENV, DEFAULT_ACCOUNT_API_BASE, RuntimeAccountInfo,
35 };
36 #[cfg(not(test))]
37 use codewhale_secrets::account::{AccountSessionStore, secure_account_session_secrets};
38 use serde::{Deserialize, Serialize};
39 use serde_json::{Value, json};
40 use sha2::{Digest, Sha256};
41 use tokio::net::TcpListener;
42 use tokio::sync::Mutex;
43 use tokio_util::sync::CancellationToken;
44 use tower_http::cors::CorsLayer;
45
46 mod notification_delivery;
47
48 #[cfg(test)]
49 use crate::dependencies::ExternalTool;
50
51 use crate::automation_manager::{
52 AutomationManager, AutomationRecord, AutomationRunRecord, AutomationSchedulerConfig,
53 CreateAutomationRequest, SharedAutomationManager, UpdateAutomationRequest, spawn_scheduler,
54 };
55 #[cfg(test)]
56 use crate::config::DEFAULT_TEXT_MODEL;
57 use crate::config::{Config, ProviderKind, normalize_model_name_for_provider, validate_route};
58 use crate::fleet::executor::{FleetExecutor, configured_codewhale_binary};
59 use crate::fleet::ledger::{
60 FleetEventReplayError, FleetLedgerState, FleetTaskLedgerStatus, fleet_ledger_path,
61 subscribe_fleet_ledger_appends,
62 };
63 use crate::fleet::manager::{
64 FleetManager, FleetStatusSnapshot, FleetWorkerInspection, FleetWorkerRuntimeProjection,
65 ManagedFleetRunDescriptor,
66 };
67 use crate::fleet::profile::canonical_public_role_name;
68 use crate::fleet::task_spec::FleetTaskSpecDocument;
69 use crate::fleet::worker_runtime::fleet_write_roots;
70 use crate::mcp::McpPool;
71 use crate::runtime_threads::{
72 CompactThreadRequest, CreateThreadRequest, ExternalApprovalDecision,
73 MAX_RUNTIME_EVENT_REPLAY_TAIL, RuntimeThreadManager, RuntimeThreadManagerConfig,
74 SharedRuntimeThreadManager, StartTurnRequest, SteerTurnRequest, ThreadDetail, ThreadListFilter,
75 ThreadRecord, TurnRecord, UpdateThreadRequest, UsageGroupBy, UsageTotals,
76 };
77 // `TurnItemKind` is read only by the summary tests now that the route builds
78 // its rows from `ThreadListFacts` instead of walking item records here.
79 #[cfg(test)]
80 pub(super) use crate::runtime_threads::{RuntimeTurnStatus, TurnItemKind, TurnItemLifecycleStatus};
81 use crate::session_manager::default_sessions_dir;
82 #[cfg(test)]
83 pub(super) use crate::session_manager::{SavedSession, SessionMetadata};
84 use crate::skill_state::SkillStateStore;
85 use crate::task_manager::{
86 NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskRecord, TaskSummary,
87 };
88 use crate::tools::subagent::{
89 AgentWorkerRecord, AgentWorkerStatus, SharedSubAgentManager, SubAgentStatus,
90 new_shared_subagent_manager_with_timeout,
91 };
92 #[cfg(test)]
93 pub(super) use codewhale_models::{ContentBlock, Message};
94 use codewhale_protocol::fleet::{
95 FleetArtifactKind, FleetEventReplay, FleetRun, FleetRunId, FleetRuntimeEvent,
96 FleetRuntimeTarget, FleetSecurityPolicy, FleetTaskSpec, FleetWorkerEventPayload,
97 FleetWorkerSpec, FleetWorkerStatus, FleetWorkflowDescriptor, FleetWorkflowKind,
98 };
99
100 mod auth;
101 mod computer_display;
102 mod context;
103 mod diagnostics;
104 mod git;
105 mod jobs;
106 mod lsp;
107 mod mcp_import;
108 mod memory_lens;
109 mod mobile;
110 mod plans;
111 mod plugins;
112 mod secrets;
113 pub(crate) mod sessions;
114 mod targets;
115 mod terminal;
116 pub(crate) mod thread_history;
117 mod turn_artifacts;
118 mod voice;
119 mod web;
120 mod workspace;
121 #[cfg(test)]
122 use self::auth::ResolvedRuntimeAuth;
123 use self::auth::{
124 require_runtime_token, resolve_runtime_auth, runtime_auth_status_lines,
125 runtime_request_is_authorized,
126 };
127 use self::sessions::{
128 create_session_from_thread, delete_session, get_session, get_session_repair,
129 list_session_artifacts, list_sessions, list_sessions_summary, patch_session,
130 read_session_artifact, resume_session_thread, save_current_session,
131 };
132 #[cfg(test)]
133 use self::sessions::{messages_from_thread_detail, session_to_detail};
134 #[cfg(test)]
135 use self::workspace::collect_workspace_status;
136 use self::workspace::{
137 WorkspaceGitMetadata, collect_workspace_git_metadata, workspace_file_read,
138 workspace_file_search, workspace_file_write, workspace_files_list, workspace_instructions,
139 workspace_status,
140 };
141
142 const RUNTIME_TOKEN_ENV: &str = "CODEWHALE_RUNTIME_TOKEN";
143 const LEGACY_RUNTIME_TOKEN_ENV: &str = "DEEPSEEK_RUNTIME_TOKEN";
144 const LEGACY_RUNTIME_TOKEN_WARNING: &str = "Warning: DEEPSEEK_RUNTIME_TOKEN is deprecated; use \
145 CODEWHALE_RUNTIME_TOKEN (the legacy alias is removed in 0.10.0).";
146
147 struct RuntimeTokenEnvironment {
148 token: Option<String>,
149 legacy_alias_used: bool,
150 }
151
152 fn runtime_token_environment(lookup: &dyn Fn(&str) -> Option<String>) -> RuntimeTokenEnvironment {
153 let nonblank = |name| {
154 lookup(name)
155 .map(|value| value.trim().to_string())
156 .filter(|value| !value.is_empty())
157 };
158
159 if let Some(token) = nonblank(RUNTIME_TOKEN_ENV) {
160 return RuntimeTokenEnvironment {
161 token: Some(token),
162 legacy_alias_used: false,
163 };
164 }
165
166 let token = nonblank(LEGACY_RUNTIME_TOKEN_ENV);
167 RuntimeTokenEnvironment {
168 legacy_alias_used: token.is_some(),
169 token,
170 }
171 }
172
173 fn runtime_token_alias_warning(
174 cli_token: Option<&str>,
175 environment: &RuntimeTokenEnvironment,
176 ) -> Option<&'static str> {
177 let cli_token_is_used = cli_token.is_some_and(|token| !token.trim().is_empty());
178 (!cli_token_is_used && environment.legacy_alias_used).then_some(LEGACY_RUNTIME_TOKEN_WARNING)
179 }
180
181 #[derive(Clone)]
182 pub struct RuntimeApiState {
183 config: Arc<parking_lot::RwLock<Config>>,
184 workspace: PathBuf,
185 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
186 task_manager: SharedTaskManager,
187 runtime_threads: SharedRuntimeThreadManager,
188 cors_origins: Vec<String>,
189 sessions_dir: PathBuf,
190 /// Original `--config` path (if any) used to load the initial config.
191 /// Passed to `Config::load` on reload and to persistence helpers so
192 /// GUI-driven config changes target the same file the server was
193 /// started with, instead of falling back to the default discovery.
194 config_path: Option<PathBuf>,
195 /// Effective initial profile (`--profile` or `DEEPSEEK_PROFILE`).
196 /// Reload must retain this overlay so profile-scoped routes do not vanish.
197 config_profile: Option<String>,
198 automations: SharedAutomationManager,
199 sub_agent_manager: SharedSubAgentManager,
200 runtime_token: Option<String>,
201 skill_state: Arc<Mutex<SkillStateStore>>,
202 auth_required: bool,
203 bind_host: String,
204 bind_port: u16,
205 mobile_enabled: bool,
206 mobile: Option<mobile::RuntimeMobileState>,
207 web: Option<web::RuntimeWebState>,
208 /// Executable used by Runtime API-owned Fleet manager loops. Stored on
209 /// state so tests and embedded callers can provide a hermetic worker.
210 fleet_codewhale_binary: String,
211 /// Held by the actual owner, shared by all matching listener scopes.
212 workspace_scopes: Arc<RuntimeWorkspaceScopes>,
213 workspace_scope: Arc<RuntimeWorkspaceScope>,
214 /// The computer this Engine runs on: display socket, human control
215 /// lease, device client tokens and `computer.*` events (§3.3).
216 computer: computer_display::ComputerState,
217 /// Fires when the server stops on purpose, so open thread event streams
218 /// end with a typed `stream.end` rather than a bare EOF.
219 shutdown: RuntimeServerShutdown,
220 /// Serializes this runtime's git writes (stage/unstage/discard/commit/
221 /// branch) so a precondition check and its write are atomic with respect
222 /// to other windows on the same server (#6647).
223 git_writes: Arc<tokio::sync::Mutex<()>>,
224 /// Serializes provider switches: each one saves, applies and, when the
225 /// apply is refused, takes back its own save before the next one starts.
226 provider_switches: Arc<tokio::sync::Mutex<()>>,
227 #[cfg(test)]
228 compat_stream_test_hook: Option<tokio::sync::mpsc::UnboundedSender<CompatStreamTestPoint>>,
229 }
230
231 // This is a cache bound inside the existing owner, matching the daemon's
232 // maximum of 64 live frontend connections. Scopes remain retained until owner
233 // retirement; opening another listener never creates a parallel cache.
234 const MAX_RUNTIME_WORKSPACE_SCOPES: usize = 64;
235
236 struct RuntimeWorkspaceScope {
237 lexical: PathBuf,
238 canonical: PathBuf,
239 directory: Arc<std::fs::File>,
240 mcp: Mutex<Option<(u64, Arc<Mutex<McpPool>>)>>,
241 lsp: std::sync::OnceLock<Arc<crate::lsp::LspManager>>,
242 owner: SharedRuntimeThreadManager,
243 cleanup_runtime: tokio::runtime::Handle,
244 }
245
246 pub(crate) fn open_workspace_directory(workspace: &FsPath) -> Result<(PathBuf, std::fs::File)> {
247 let canonical = workspace
248 .canonicalize()
249 .context("workspace is unavailable")?;
250 let mut options = std::fs::OpenOptions::new();
251 options.read(true);
252 #[cfg(unix)]
253 {
254 use std::os::unix::fs::OpenOptionsExt as _;
255 options.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC);
256 }
257 #[cfg(windows)]
258 {
259 use std::os::windows::fs::OpenOptionsExt as _;
260 options.custom_flags(0x0200_0000 | 0x0020_0000); // BACKUP_SEMANTICS, OPEN_REPARSE_POINT
261 }
262 let directory = options.open(&canonical)?;
263 let metadata = directory.metadata()?;
264 anyhow::ensure!(
265 metadata.is_dir() && !crate::plugins::metadata_is_link_or_reparse(&metadata),
266 "workspace is not an ordinary directory"
267 );
268 anyhow::ensure!(
269 workspace.canonicalize()? == canonical,
270 "selected workspace changed"
271 );
272 Ok((canonical, directory))
273 }
274
275 impl RuntimeWorkspaceScope {
276 fn validate_sync(&self) -> Result<()> {
277 let (canonical, directory) = open_workspace_directory(&self.lexical)?;
278 anyhow::ensure!(
279 canonical == self.canonical
280 && crate::fleet::files::same_file(&self.directory, &directory)?,
281 "selected workspace identity changed"
282 );
283 Ok(())
284 }
285 async fn validate(self: &Arc<Self>) -> Result<()> {
286 let scope = self.clone();
287 codewhale_app_server::daemon_socket::owner_work(move || scope.validate_sync()).await
288 }
289 }
290
291 impl Drop for RuntimeWorkspaceScope {
292 fn drop(&mut self) {
293 let mcp = self.mcp.get_mut().take().map(|(_, pool)| pool);
294 let lsp = self.lsp.take();
295 // The canonical writer lease and held directory survive the actual
296 // transport cleanup, including waiter cancellation and guest detach.
297 let owner = self.owner.clone();
298 let directory = self.directory.clone();
299 self.cleanup_runtime.spawn(async move {
300 let _owner = owner;
301 let _directory = directory;
302 if let Some(pool) = mcp {
303 pool.lock().await.shutdown_all().await;
304 }
305 if let Some(manager) = lsp {
306 manager.shutdown_all().await;
307 }
308 });
309 }
310 }
311
312 struct RuntimeWorkspaceScopes {
313 scopes: parking_lot::Mutex<BTreeMap<PathBuf, Arc<RuntimeWorkspaceScope>>>,
314 mcp_generation: std::sync::atomic::AtomicU64,
315 dynamic_servers: Arc<parking_lot::RwLock<HashMap<String, crate::mcp::McpServerConfig>>>,
316 owner: SharedRuntimeThreadManager,
317 workers: SharedSubAgentManager,
318 cleanup_runtime: tokio::runtime::Handle,
319 }
320
321 impl RuntimeWorkspaceScopes {
322 fn new(owner: SharedRuntimeThreadManager, workers: SharedSubAgentManager) -> Arc<Self> {
323 Arc::new(Self {
324 scopes: parking_lot::Mutex::new(BTreeMap::new()),
325 mcp_generation: std::sync::atomic::AtomicU64::new(0),
326 dynamic_servers: Arc::new(parking_lot::RwLock::new(HashMap::new())),
327 owner,
328 workers,
329 cleanup_runtime: tokio::runtime::Handle::current(),
330 })
331 }
332
333 async fn admit(self: &Arc<Self>, lexical: PathBuf) -> Result<Arc<RuntimeWorkspaceScope>> {
334 let owner = self.clone();
335 codewhale_app_server::daemon_socket::owner_work(move || {
336 let (canonical, directory) = open_workspace_directory(&lexical)?;
337 let directory = Arc::new(directory);
338 let mut scopes = owner.scopes.lock();
339 if let Some(scope) = scopes.get(&lexical) {
340 anyhow::ensure!(
341 canonical == scope.canonical
342 && crate::fleet::files::same_file(&scope.directory, &directory)?,
343 "selected workspace identity changed"
344 );
345 return Ok(scope.clone());
346 }
347 anyhow::ensure!(
348 scopes.len() < MAX_RUNTIME_WORKSPACE_SCOPES,
349 "Runtime workspace scope limit reached; restart the owner to retire unused scopes"
350 );
351 owner
352 .workers
353 .blocking_write()
354 .admit_coordination_workspace(lexical.clone(), canonical.clone(), directory.clone())
355 .map_err(anyhow::Error::msg)?;
356 let scope = Arc::new(RuntimeWorkspaceScope {
357 lexical: lexical.clone(),
358 canonical,
359 directory,
360 mcp: Mutex::new(None),
361 lsp: std::sync::OnceLock::new(),
362 owner: owner.owner.clone(),
363 cleanup_runtime: owner.cleanup_runtime.clone(),
364 });
365 scopes.insert(lexical, scope.clone());
366 Ok(scope)
367 })
368 .await
369 }
370 }
371
372 async fn require_workspace_scope(
373 State(state): State<RuntimeApiState>,
374 request: Request,
375 next: middleware::Next,
376 ) -> Response {
377 if state.workspace_scope.validate().await.is_err() {
378 return ApiError::conflict("selected workspace identity changed").into_response();
379 }
380 next.run(request).await
381 }
382
383 /// How the Runtime API server stops on purpose.
384 ///
385 /// `requested` fires once the server decides to stop: the listener stops
386 /// accepting, idle connections close, and every open thread event stream sends
387 /// `stream.end {reason: "runtime_shutdown"}` and finishes. `stopped` fires once
388 /// `serve_runtime_api` has drained every connection.
389 #[derive(Clone, Default)]
390 pub(crate) struct RuntimeServerShutdown {
391 requested: CancellationToken,
392 stopped: CancellationToken,
393 }
394
395 impl RuntimeServerShutdown {
396 /// Ask the server to stop and wait at most `deadline` for it to drain.
397 /// Returns whether it drained in time; a long-lived response that does not
398 /// watch `requested` (a turn or Fleet stream) can hold it to the deadline.
399 pub(crate) async fn drain(&self, deadline: Duration) -> bool {
400 self.requested.cancel();
401 tokio::time::timeout(deadline, self.stopped.cancelled())
402 .await
403 .is_ok()
404 }
405 }
406
407 /// Serve `app` until `shutdown` is requested, then drain gracefully so the
408 /// final frames of open streams reach their clients before connections close.
409 async fn serve_runtime_api(
410 listener: TcpListener,
411 app: Router,
412 shutdown: RuntimeServerShutdown,
413 ) -> std::io::Result<()> {
414 let result = axum::serve(
415 listener,
416 app.into_make_service_with_connect_info::<SocketAddr>(),
417 )
418 .with_graceful_shutdown(shutdown.requested.clone().cancelled_owned())
419 .await;
420 shutdown.stopped.cancel();
421 result
422 }
423
424 /// Listener state is local to the selected authenticated frontend. All service
425 /// handles remain the original owner's captured handles.
426 #[derive(Serialize, Deserialize)]
427 #[serde(deny_unknown_fields)]
428 struct RuntimeFrontendReady {
429 endpoint: SocketAddr,
430 auth_required: bool,
431 generated_auth: bool,
432 reused_owner_listener: bool,
433 mobile_bootstrap_url: Option<String>,
434 web_bootstrap_url: Option<String>,
435 }
436 fn canonical_runtime_config_source(source: Option<PathBuf>) -> Result<Option<PathBuf>> {
437 let Some(source) = source else {
438 return Ok(None);
439 };
440 anyhow::ensure!(
441 source.as_os_str().len() <= 32768,
442 "operator config source exceeds its bounds"
443 );
444 let source = std::path::absolute(source)?;
445 if source.try_exists()? {
446 anyhow::ensure!(
447 source.is_file(),
448 "operator config source is not a regular file"
449 );
450 return Ok(Some(source.canonicalize()?));
451 }
452 // The captured loader permits a missing config document and missing
453 // parents. Anchor its exact uncreated suffix under the nearest existing
454 // directory without resolving another default or creating those paths.
455 let mut ancestor = source.as_path();
456 while !ancestor.try_exists()? {
457 ancestor = ancestor
458 .parent()
459 .context("operator config source has no existing ancestor")?;
460 }
461 anyhow::ensure!(
462 ancestor.is_dir(),
463 "operator config source ancestor is not a directory"
464 );
465 let suffix = source.strip_prefix(ancestor)?;
466 Ok(Some(ancestor.canonicalize()?.join(suffix)))
467 }
468
469 struct CapturedRuntimeFrontend {
470 worker_setting: usize,
471 generated_auth: bool,
472 config_source: Option<PathBuf>,
473 state: RuntimeApiState,
474 default_model: String,
475 }
476 impl CapturedRuntimeFrontend {
477 async fn capture(
478 state: RuntimeApiState,
479 model: String,
480 worker_setting: usize,
481 generated_auth: bool,
482 ) -> Result<Arc<Self>> {
483 let source = state
484 .config
485 .read()
486 .loaded_config_path
487 .clone()
488 .or_else(|| state.config_path.clone());
489 let config_source = codewhale_app_server::daemon_socket::owner_work(move || {
490 canonical_runtime_config_source(source)
491 })
492 .await?;
493 Ok(Arc::new(Self {
494 state,
495 default_model: model,
496 worker_setting,
497 generated_auth,
498 config_source,
499 }))
500 }
501 async fn validate_scope(
502 &self,
503 scope: &codewhale_app_server::RuntimeFrontendScope,
504 ) -> Result<()> {
505 scope.validate_bounds()?;
506 anyhow::ensure!(
507 scope.workers == self.worker_setting,
508 "selected worker setting differs from the held scheduler"
509 );
510 anyhow::ensure!(
511 scope.config_profile == self.state.config_profile,
512 "selected config profile differs from the captured owner scope"
513 );
514 if let Some(source) = scope.config_source.clone() {
515 let source = codewhale_app_server::daemon_socket::owner_work(move || {
516 canonical_runtime_config_source(Some(source))
517 })
518 .await?;
519 anyhow::ensure!(
520 source == self.config_source,
521 "selected operator config differs from the captured owner source"
522 );
523 }
524 Ok(())
525 }
526 }
527 impl codewhale_app_server::RuntimeOwnerFrontend for CapturedRuntimeFrontend {
528 fn validate_selection<'a>(
529 &'a self,
530 selection: &'a codewhale_app_server::RuntimeOwnerFrontendSelection,
531 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
532 Box::pin(async move {
533 match selection {
534 codewhale_app_server::RuntimeOwnerFrontendSelection::Control(scope) => {
535 self.validate_scope(scope).await?;
536 anyhow::ensure!(
537 !self.state.runtime_threads.is_acp_host(),
538 "immutable ACP-base owner cannot admit ordinary control turns"
539 );
540 }
541 codewhale_app_server::RuntimeOwnerFrontendSelection::Acp { scope, model } => {
542 if let Some(scope) = scope {
543 self.validate_scope(scope).await?;
544 }
545 anyhow::ensure!(
546 model
547 .as_ref()
548 .is_none_or(|model| !model.trim().is_empty() && model.len() <= 1024),
549 "invalid selected ACP model"
550 );
551 }
552 codewhale_app_server::RuntimeOwnerFrontendSelection::Listener(selection) => {
553 selection.validate_bounds()?;
554 self.validate_scope(&codewhale_app_server::RuntimeFrontendScope {
555 workers: selection.workers,
556 workspace: selection.workspace.clone(),
557 config_profile: selection.config_profile.clone(),
558 config_source: selection.config_source.clone(),
559 })
560 .await?;
561 anyhow::ensure!(
562 !self.state.runtime_threads.is_acp_host(),
563 "immutable ACP-base owner cannot admit ordinary listener turns"
564 );
565 validate_runtime_listener_security(&RuntimeApiOptions {
566 host: selection.host.clone(),
567 port: selection.port,
568 cors_origins: selection.cors_origins.clone(),
569 auth_token: selection.auth_token.clone(),
570 insecure_no_auth: selection.insecure_no_auth,
571 mobile: selection.mobile,
572 web: selection.web,
573 control_frontend: Some(
574 codewhale_app_server::RuntimeControlFrontend::LegacyHttp,
575 ),
576 ..Default::default()
577 })?;
578 }
579 }
580 Ok(())
581 })
582 }
583 fn serve(
584 &self,
585 selection: codewhale_app_server::RuntimeOwnerFrontendSelection,
586 compatibility: codewhale_app_server::AppState,
587 input: Box<dyn tokio::io::AsyncBufRead + Send + Unpin>,
588 mut output: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
589 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>> {
590 Box::pin(async move {
591 self.validate_selection(&selection).await?;
592 let selection = match selection {
593 codewhale_app_server::RuntimeOwnerFrontendSelection::Control(scope) => {
594 self.validate_scope(&scope).await?;
595 anyhow::ensure!(
596 !self.state.runtime_threads.is_acp_host(),
597 "immutable ACP-base owner cannot admit ordinary control turns"
598 );
599 return codewhale_app_server::run_guest_control(
600 compatibility,
601 scope.workspace,
602 input,
603 output,
604 )
605 .await;
606 }
607 codewhale_app_server::RuntimeOwnerFrontendSelection::Acp { scope, model } => {
608 let workspace = if let Some(scope) = scope {
609 self.validate_scope(&scope).await?;
610 scope.workspace
611 } else {
612 self.state.workspace.clone()
613 };
614 let config = self.state.config.read().clone();
615 let acp = crate::acp_server::capture_frontend(
616 config,
617 model.unwrap_or_else(|| self.default_model.clone()),
618 workspace,
619 self.state.runtime_threads.clone(),
620 self.state.sessions_dir.clone(),
621 self.state.config_path.clone(),
622 self.state.config_profile.clone(),
623 )?;
624 return acp.serve(input, output).await;
625 }
626 codewhale_app_server::RuntimeOwnerFrontendSelection::Listener(selection) => {
627 selection
628 }
629 };
630 selection.validate_bounds()?;
631 anyhow::ensure!(
632 selection.workers == self.worker_setting,
633 "selected worker setting differs from the held scheduler"
634 );
635 anyhow::ensure!(
636 selection.config_profile == self.state.config_profile,
637 "selected config profile differs from the captured owner scope"
638 );
639 anyhow::ensure!(
640 !self.state.runtime_threads.is_acp_host(),
641 "immutable ACP-base owner cannot admit ordinary listener turns"
642 );
643 let workspace = selection.workspace;
644 let options = RuntimeApiOptions {
645 host: selection.host,
646 port: selection.port,
647 cors_origins: selection.cors_origins,
648 auth_token: selection.auth_token,
649 insecure_no_auth: selection.insecure_no_auth,
650 mobile: selection.mobile,
651 web: selection.web,
652 control_frontend: Some(codewhale_app_server::RuntimeControlFrontend::LegacyHttp),
653 ..Default::default()
654 };
655 validate_runtime_listener_security(&options)?;
656 let selected_addr = runtime_bind_address(&options.host, options.port)?;
657 let original_addr = runtime_bind_address(&self.state.bind_host, self.state.bind_port)?;
658 let same_auth = match (
659 options
660 .auth_token
661 .as_deref()
662 .map(str::trim)
663 .filter(|token| !token.is_empty()),
664 self.state.runtime_token.as_deref(),
665 ) {
666 (Some(selected), Some(original)) => codewhale_core::secret_eq::constant_time_eq(
667 selected.as_bytes(),
668 original.as_bytes(),
669 ),
670 (None, Some(_)) => self.generated_auth && !options.insecure_no_auth,
671 (None, None) => options.insecure_no_auth,
672 _ => false,
673 };
674 if selected_addr == original_addr
675 && !options.web
676 && !options.mobile
677 && self.state.web.is_none()
678 && self.state.mobile.is_none()
679 && options.cors_origins == self.state.cors_origins
680 && workspace == self.state.workspace
681 && same_auth
682 {
683 let ready = RuntimeFrontendReady {
684 endpoint: original_addr,
685 auth_required: self.state.auth_required,
686 generated_auth: self.generated_auth,
687 reused_owner_listener: true,
688 mobile_bootstrap_url: None,
689 web_bootstrap_url: None,
690 };
691 write_frontend_ready(&mut output, ready).await?;
692 return tokio::select! {
693 result = wait_frontend_input_close(input) => result,
694 _ = self.state.shutdown.requested.cancelled() => Ok(()),
695 };
696 }
697 let resolved =
698 resolve_runtime_auth(options.auth_token.clone(), None, options.insecure_no_auth);
699 let listener =
700 TcpListener::bind(runtime_bind_address(&options.host, options.port)?).await?;
701 let endpoint = listener.local_addr()?;
702 let workspace_scope = self.state.workspace_scopes.admit(workspace.clone()).await?;
703 let mut state = self.state.clone();
704 state.workspace = workspace.clone();
705 state.workspace_scope = workspace_scope;
706 state.runtime_token = resolved.token.clone();
707 state.auth_required = resolved.token.is_some();
708 state.bind_host = options.host;
709 state.bind_port = endpoint.port();
710 state.cors_origins = options.cors_origins.clone();
711 state.mobile_enabled = options.mobile;
712 let (web, web_bootstrap_url) = if options.web {
713 anyhow::ensure!(
714 state.auth_required,
715 "Codewhale web requires Runtime authentication"
716 );
717 let (web, nonce) = web::RuntimeWebState::new();
718 (Some(web), Some(web::bootstrap_url(endpoint, &nonce)))
719 } else {
720 (None, None)
721 };
722 let (mobile, mobile_bootstrap_url) = if options.mobile && state.auth_required {
723 let (mobile, nonce) = mobile::RuntimeMobileState::new();
724 (Some(mobile), Some(mobile::bootstrap_url(endpoint, &nonce)))
725 } else {
726 (None, None)
727 };
728 state.web = web;
729 state.mobile = mobile;
730 // The guest may drain its own streams/listener. It cannot replace
731 // the global signal registration or stop shared schedulers/managers.
732 let shutdown = RuntimeServerShutdown::default();
733 state.shutdown = shutdown.clone();
734 let app =
735 build_router(state).merge(codewhale_app_server::runtime_compatibility_router(
736 compatibility,
737 &options.cors_origins,
738 resolved.token.clone(),
739 Some(workspace),
740 ));
741 let ready = RuntimeFrontendReady {
742 endpoint,
743 auth_required: resolved.token.is_some(),
744 generated_auth: resolved.generated,
745 reused_owner_listener: false,
746 mobile_bootstrap_url,
747 web_bootstrap_url,
748 };
749 write_frontend_ready(&mut output, ready).await?;
750 let serving = serve_runtime_api(listener, app, shutdown.clone());
751 tokio::pin!(serving);
752 let input_result = tokio::select! {
753 result = &mut serving => { result?; return Ok(()); }
754 _ = self.state.shutdown.requested.cancelled() => Ok(()),
755 result = wait_frontend_input_close(input) => result,
756 };
757 shutdown.requested.cancel();
758 // Accepted turns live in the held manager, independently of this
759 // response or listener drain. Never retry their uncertain writes.
760 tokio::time::timeout(Duration::from_secs(5), &mut serving)
761 .await
762 .context("selected listener drain deadline expired")??;
763 input_result
764 })
765 }
766 }
767
768 async fn write_frontend_ready(
769 output: &mut (dyn tokio::io::AsyncWrite + Send + Unpin),
770 ready: RuntimeFrontendReady,
771 ) -> Result<()> {
772 use tokio::io::AsyncWriteExt as _;
773 let frame = serde_json::to_vec(
774 &json!({"jsonrpc":"2.0","method":"daemon/frontend_ready","params":ready}),
775 )?;
776 tokio::time::timeout(Duration::from_secs(5), async {
777 output.write_all(&frame).await?;
778 output.write_all(b"\n").await?;
779 output.flush().await
780 })
781 .await
782 .context("selected listener readiness write timed out; outcome uncertain")??;
783 Ok(())
784 }
785 async fn wait_frontend_input_close(
786 input: Box<dyn tokio::io::AsyncBufRead + Send + Unpin>,
787 ) -> Result<()> {
788 let mut input = codewhale_app_server::BoundedLines::new(input);
789 if let Some(line) = input.next_line().await? {
790 let value: Value = serde_json::from_str(&line)?;
791 anyhow::ensure!(
792 codewhale_app_server::is_control_input_closed(&value),
793 "selected listener accepts only its logical input close"
794 );
795 }
796 Ok(())
797 }
798
799 /// The serving Runtime API, for the process signal handler (`lib.rs`), which
800 /// exits the process on a terminating signal. Without this it would cut every
801 /// open stream mid-connection, indistinguishable from a network drop.
802 static SIGNAL_SHUTDOWN: std::sync::Mutex<Option<RuntimeServerShutdown>> =
803 std::sync::Mutex::new(None);
804
805 /// How long a terminating signal waits for open streams to say goodbye. A
806 /// second signal skips the wait.
807 const SIGNAL_SHUTDOWN_DRAIN: Duration = Duration::from_secs(2);
808
809 /// Clears `SIGNAL_SHUTDOWN` when the server that registered it returns.
810 struct SignalShutdownRegistration;
811
812 impl SignalShutdownRegistration {
813 fn register(shutdown: &RuntimeServerShutdown) -> Self {
814 if let Ok(mut slot) = SIGNAL_SHUTDOWN.lock() {
815 *slot = Some(shutdown.clone());
816 }
817 Self
818 }
819 }
820
821 impl Drop for SignalShutdownRegistration {
822 fn drop(&mut self) {
823 if let Ok(mut slot) = SIGNAL_SHUTDOWN.lock() {
824 *slot = None;
825 }
826 }
827 }
828
829 /// Called by the process signal handler before it exits: stop the serving
830 /// Runtime API (if any) and give its open streams a bounded window to send
831 /// their final `stream.end` frame.
832 pub(crate) async fn drain_for_signal_exit() {
833 let shutdown = SIGNAL_SHUTDOWN.lock().ok().and_then(|slot| slot.clone());
834 if let Some(shutdown) = shutdown
835 && !shutdown.drain(SIGNAL_SHUTDOWN_DRAIN).await
836 {
837 tracing::warn!("Runtime API did not drain within the signal shutdown window");
838 }
839 }
840
841 #[cfg(test)]
842 enum CompatStreamTestPoint {
843 ThreadCreated {
844 thread_id: String,
845 resume: tokio::sync::oneshot::Sender<()>,
846 },
847 SubscribedBeforeReplay {
848 thread_id: String,
849 turn_id: String,
850 resume: tokio::sync::oneshot::Sender<()>,
851 },
852 ReplayLoaded {
853 thread_id: String,
854 turn_id: String,
855 resume: tokio::sync::oneshot::Sender<()>,
856 },
857 }
858
859 #[derive(Debug, Clone)]
860 pub struct RuntimeApiOptions {
861 pub host: String,
862 pub port: u16,
863 pub workers: usize,
864 /// Additional CORS origins to allow on top of the built-in defaults
865 /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,
866 /// `tauri://localhost`). Populated by `--cors-origin` (repeatable),
867 /// `CODEWHALE_CORS_ORIGINS` (comma-separated, `DEEPSEEK_CORS_ORIGINS`
868 /// as alias), and `[runtime_api] cors_origins` in `config.toml`.
869 /// Whalescale#255 / #561.
870 pub cors_origins: Vec<String>,
871 /// Optional bearer token required for `/v1/*` routes. If omitted here,
872 /// `run_http_server` checks `CODEWHALE_RUNTIME_TOKEN`, then
873 /// `DEEPSEEK_RUNTIME_TOKEN` as an alias.
874 pub auth_token: Option<String>,
875 /// Allow `/v1/*` routes without auth when no token is configured.
876 pub insecure_no_auth: bool,
877 /// Enables the built-in mobile control page at `/mobile`.
878 pub mobile: bool,
879 /// Enables the embedded local browser client and opens it after binding.
880 /// Web mode is always loopback-only and uses a one-time bootstrap cookie
881 /// exchange rather than exposing the Runtime token to the browser URL.
882 pub web: bool,
883 /// Show a QR code for the mobile URL in the terminal.
884 pub show_qr: bool,
885 /// Original `--config` path used to load the initial config. When
886 /// `Some`, GUI-driven config reloads and persistence target this file
887 /// instead of the default discovery path.
888 pub config_path: Option<PathBuf>,
889 /// Effective profile used to load the server's initial Config.
890 pub config_profile: Option<String>,
891 pub control_frontend: Option<codewhale_app_server::RuntimeControlFrontend>,
892 }
893
894 impl Default for RuntimeApiOptions {
895 fn default() -> Self {
896 Self {
897 host: "127.0.0.1".to_string(),
898 port: 7878,
899 workers: 2,
900 cors_origins: Vec::new(),
901 auth_token: None,
902 insecure_no_auth: false,
903 mobile: false,
904 web: false,
905 show_qr: false,
906 config_path: None,
907 config_profile: None,
908 control_frontend: None,
909 }
910 }
911 }
912
913 #[derive(Debug, Deserialize)]
914 struct StreamTurnRequest {
915 #[serde(default, rename = "maxOutputTokens", alias = "max_output_tokens")]
916 max_output_tokens: Option<std::num::NonZeroU32>,
917 prompt: String,
918 #[serde(default)]
919 images: Vec<codewhale_protocol::runtime::RuntimeImageInput>,
920 model: Option<String>,
921 mode: Option<String>,
922 permission_posture: Option<String>,
923 workspace: Option<PathBuf>,
924 allow_shell: Option<bool>,
925 trust_mode: Option<bool>,
926 auto_approve: Option<bool>,
927 }
928
929 #[derive(Debug, Serialize)]
930 struct HealthResponse {
931 status: &'static str,
932 service: &'static str,
933 mode: &'static str,
934 }
935
936 #[derive(Debug, Serialize)]
937 struct TasksResponse {
938 tasks: Vec<TaskSummary>,
939 counts: crate::task_manager::TaskCounts,
940 }
941
942 #[derive(Debug, Deserialize)]
943 struct TasksQuery {
944 limit: Option<usize>,
945 workspace: Option<PathBuf>,
946 }
947
948 #[derive(Debug, Deserialize)]
949 struct ThreadsQuery {
950 limit: Option<usize>,
951 include_archived: Option<bool>,
952 /// When `true`, returns archived threads only (overrides `include_archived`).
953 /// Whalescale#260 / #563.
954 archived_only: Option<bool>,
955 }
956
957 #[derive(Debug, Deserialize)]
958 struct ThreadSummaryQuery {
959 limit: Option<usize>,
960 search: Option<String>,
961 include_archived: Option<bool>,
962 /// When `true`, returns archived threads only (overrides `include_archived`).
963 /// Whalescale#260 / #563.
964 archived_only: Option<bool>,
965 }
966
967 fn resolve_thread_filter(
968 include_archived: Option<bool>,
969 archived_only: Option<bool>,
970 ) -> ThreadListFilter {
971 if archived_only.unwrap_or(false) {
972 ThreadListFilter::ArchivedOnly
973 } else if include_archived.unwrap_or(false) {
974 ThreadListFilter::IncludeArchived
975 } else {
976 ThreadListFilter::ActiveOnly
977 }
978 }
979
980 #[derive(Debug, Serialize)]
981 struct ThreadSummary {
982 id: String,
983 title: String,
984 preview: String,
985 model: String,
986 mode: String,
987 workspace: PathBuf,
988 branch: Option<String>,
989 head: Option<String>,
990 dirty: bool,
991 archived: bool,
992 updated_at: chrono::DateTime<Utc>,
993 latest_turn_id: Option<String>,
994 latest_turn_status: Option<String>,
995 /// Pending approvals plus pending user-input requests in the canonical
996 /// thread snapshot. Clients use this typed fact for attention grouping;
997 /// lifecycle prose and turn-status strings are not an authority signal.
998 pending_attention_count: usize,
999 }
1000
1001 #[derive(Debug, Serialize)]
1002 struct SkillEntry {
1003 name: String,
1004 description: String,
1005 /// Native Skill locator. Reviewed plugin paths are deliberately omitted;
1006 /// their bodies are available only through the authority-bound snapshot.
1007 path: Option<PathBuf>,
1008 source: String,
1009 plugin_id: Option<String>,
1010 plugin_generation: Option<u64>,
1011 plugin_content_hash: Option<String>,
1012 enabled: bool,
1013 is_bundled: bool,
1014 }
1015
1016 #[derive(Debug, Serialize)]
1017 struct SkillsResponse {
1018 directory: PathBuf,
1019 directories: Vec<PathBuf>,
1020 warnings: Vec<String>,
1021 skills: Vec<SkillEntry>,
1022 }
1023
1024 #[derive(Debug, Serialize)]
1025 struct AgentRunsResponse {
1026 runs: Vec<AgentWorkerRecord>,
1027 /// Live launch-governor state for agents this runtime launches (Fleet
1028 /// runs), so a client can say why a queued run waits (addendum F5).
1029 governor: AgentRunsGovernor,
1030 }
1031
1032 /// The rate-limit governor behind agent launches, as of this response.
1033 #[derive(Debug, Serialize)]
1034 struct AgentRunsGovernor {
1035 /// Launch slots currently granted, after any rate-limit shrink.
1036 launch_slots: usize,
1037 /// Configured launch concurrency.
1038 max_launch_slots: usize,
1039 /// New launches are held entirely after sustained provider rate limits.
1040 paused: bool,
1041 /// Provider rate limits seen inside the governor's sliding window.
1042 recent_rate_limits: usize,
1043 /// One human line while launches are held back; absent at full speed.
1044 #[serde(skip_serializing_if = "Option::is_none")]
1045 status: Option<String>,
1046 }
1047
1048 #[derive(Debug, Deserialize)]
1049 struct SetSkillEnabledRequest {
1050 enabled: bool,
1051 }
1052
1053 #[derive(Debug, Serialize)]
1054 struct SetSkillEnabledResponse {
1055 name: String,
1056 enabled: bool,
1057 }
1058
1059 // ─── Skill lifecycle request/response types ────────────────────────────────
1060
1061 #[derive(Debug, Deserialize)]
1062 struct InstallSkillRequest {
1063 /// Remote source spec: `github:owner/repo`, `https://…`, or a registry name.
1064 source: String,
1065 /// `"project"` or `"global"` (default: `"global"`).
1066 #[serde(default)]
1067 scope: Option<String>,
1068 }
1069
1070 #[derive(Debug, Deserialize)]
1071 struct UpdateSkillRequest {
1072 /// `"project"`, `"global"`, or `null` (auto-detect).
1073 #[serde(default)]
1074 scope: Option<String>,
1075 /// Digest the caller observed before requesting the update. The mutation
1076 /// will fail if the on-disk digest has changed since.
1077 #[serde(default)]
1078 expected_digest: Option<String>,
1079 }
1080
1081 #[derive(Debug, Deserialize)]
1082 struct UninstallSkillQuery {
1083 /// `"project"`, `"global"`, or `null` (auto-detect).
1084 #[serde(default)]
1085 scope: Option<String>,
1086 /// Digest the caller observed. The mutation will fail if it has drifted.
1087 #[serde(default)]
1088 expected_digest: Option<String>,
1089 }
1090
1091 #[derive(Debug, Deserialize)]
1092 struct TrustSkillRequest {
1093 /// `"project"`, `"global"`, or `null` (auto-detect).
1094 #[serde(default)]
1095 scope: Option<String>,
1096 /// Digest the caller reviewed. The mutation will fail if it has drifted.
1097 #[serde(default)]
1098 expected_digest: Option<String>,
1099 }
1100
1101 /// Scope query parameter used by the audit endpoint.
1102 #[derive(Debug, Deserialize, Default)]
1103 struct SkillScopeQuery {
1104 /// `"project"` or `"global"` to restrict to one root.
1105 scope: Option<String>,
1106 }
1107
1108 #[derive(Debug, Serialize)]
1109 struct SkillMutationReceiptResponse {
1110 /// Skill name as recorded by the mutation.
1111 name: String,
1112 /// Human-readable action performed: `"installed"`, `"updated"`, `"removed"`,
1113 /// `"trusted"`, `"no_change"`, etc.
1114 outcome: &'static str,
1115 /// Resolved install scope: `"project"` or `"global"`.
1116 scope: String,
1117 /// Display path of the skill package (may be redacted for plugin snapshots).
1118 safe_target_path: String,
1119 /// Trust advisory note, present only for `"trusted"` outcomes.
1120 #[serde(skip_serializing_if = "Option::is_none")]
1121 trust_note: Option<&'static str>,
1122 }
1123
1124 /// Read-only audit receipt for a single installed skill.
1125 #[derive(Debug, Serialize)]
1126 struct SkillAuditEntry {
1127 name: String,
1128 safe_display_path: String,
1129 source_kind: String,
1130 scope: String,
1131 digest: SkillAuditDigest,
1132 trust: String,
1133 integrity: String,
1134 available_actions: Vec<String>,
1135 warnings: Vec<String>,
1136 }
1137
1138 #[derive(Debug, Serialize)]
1139 struct SkillAuditDigest {
1140 state: String,
1141 /// Hex digest value; absent when the digest is unknown.
1142 #[serde(skip_serializing_if = "Option::is_none")]
1143 value: Option<String>,
1144 }
1145
1146 #[derive(Debug, Serialize)]
1147 struct SkillAuditResponse {
1148 /// `true` when multiple owned copies with the same name exist. The
1149 /// caller should re-request with an explicit `scope` parameter.
1150 ambiguous: bool,
1151 skills: Vec<SkillAuditEntry>,
1152 }
1153
1154 #[derive(Debug, Deserialize)]
1155 struct DecideApprovalBody {
1156 decision: String,
1157 #[serde(default)]
1158 remember: bool,
1159 }
1160
1161 #[derive(Debug, Serialize)]
1162 struct DecideApprovalResponse {
1163 ok: bool,
1164 approval_id: String,
1165 decision: String,
1166 delivered: bool,
1167 }
1168
1169 #[derive(Debug, Deserialize)]
1170 struct SubmitUserInputBody {
1171 answers: Vec<UserInputAnswerBody>,
1172 }
1173
1174 #[derive(Debug, Deserialize)]
1175 struct UserInputAnswerBody {
1176 id: String,
1177 label: String,
1178 value: String,
1179 }
1180
1181 #[derive(Debug, Serialize)]
1182 struct SubmitUserInputResponse {
1183 ok: bool,
1184 input_id: String,
1185 delivered: bool,
1186 }
1187
1188 #[derive(Debug, Serialize)]
1189 struct RuntimeInfoResponse {
1190 service: &'static str,
1191 runtime_api_version: &'static str,
1192 codewhale_version: &'static str,
1193 /// Full 40-character source commit embedded by the shared build script.
1194 /// Desktop compatibility intentionally rejects `unknown` and abbreviated
1195 /// values, so source archives without build provenance fail closed.
1196 codewhale_commit: &'static str,
1197 bind_host: String,
1198 port: u16,
1199 auth_required: bool,
1200 transports: Vec<&'static str>,
1201 capabilities: RuntimeCapabilities,
1202 account: RuntimeAccountInfo,
1203 experimental: RuntimeExperimentalCapabilities,
1204 // Backward-compatible alias kept for existing clients.
1205 version: &'static str,
1206 }
1207
1208 fn default_runtime_capabilities() -> RuntimeCapabilities {
1209 RuntimeCapabilities {
1210 account_session: true,
1211 threads: true,
1212 thread_shell_consent: true,
1213 turns: true,
1214 turn_operation_idempotency: true,
1215 turn_operation_lookup: true,
1216 turn_image_inputs: true,
1217 turn_output_token_limit: true,
1218 turn_steer: true,
1219 turn_interrupt: true,
1220 event_replay: true,
1221 external_tools: true,
1222 environments: false,
1223 worker_runtime: true,
1224 fleet_run_create: true,
1225 fleet_run_start: true,
1226 fleet_event_replay: true,
1227 fleet_event_stream: true,
1228 fleet_local_target: true,
1229 thread_goals: true,
1230 memory: true,
1231 mcp_server_management: true,
1232 skill_lifecycle: true,
1233 plugin_management: true,
1234 agent_mail: true,
1235 // SSE journal frames carry their durable `seq` as the event id, and the
1236 // thread event stream resumes from `Last-Event-ID`.
1237 event_stream_resume: true,
1238 // The terminal family follows the routes' own gate: the owner is
1239 // `#[cfg(unix)]` end to end, and the Windows and OpenHarmony builds
1240 // answer 501. A client must be able to feature-detect that before it
1241 // offers a pane, so the flag must never outrun the handler.
1242 terminal_stream: cfg!(all(unix, not(target_env = "ohos"))),
1243 terminal_input: cfg!(all(unix, not(target_env = "ohos"))),
1244 terminal_resize: cfg!(all(unix, not(target_env = "ohos"))),
1245 terminal_kill: cfg!(all(unix, not(target_env = "ohos"))),
1246 }
1247 }
1248
1249 fn runtime_api_sub_agent_manager(workspace: &FsPath, workers: usize) -> SharedSubAgentManager {
1250 let max_agents = workers.max(1);
1251 new_shared_subagent_manager_with_timeout(
1252 workspace.to_path_buf(),
1253 max_agents,
1254 max_agents,
1255 Duration::from_secs(crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS),
1256 max_agents,
1257 )
1258 }
1259
1260 #[derive(Debug, Serialize)]
1261 struct McpServerEntry {
1262 name: String,
1263 origin: &'static str,
1264 writable: bool,
1265 auth_required: bool,
1266 enabled: bool,
1267 required: bool,
1268 command: Option<String>,
1269 url: Option<String>,
1270 connected: bool,
1271 enabled_tools: Vec<String>,
1272 disabled_tools: Vec<String>,
1273 }
1274
1275 #[derive(Debug, Serialize)]
1276 struct McpServersResponse {
1277 revision: String,
1278 servers: Vec<McpServerEntry>,
1279 }
1280
1281 #[derive(Debug, Deserialize)]
1282 struct McpToolsQuery {
1283 server: Option<String>,
1284 #[serde(default)]
1285 connect: bool,
1286 }
1287
1288 #[derive(Debug, Serialize)]
1289 struct McpToolEntry {
1290 server: String,
1291 name: String,
1292 prefixed_name: String,
1293 description: Option<String>,
1294 input_schema: Value,
1295 }
1296
1297 #[derive(Debug, Serialize)]
1298 struct McpToolsResponse {
1299 tools: Vec<McpToolEntry>,
1300 connections: Vec<McpConnectionOutcome>,
1301 }
1302
1303 #[derive(Debug, Serialize)]
1304 struct McpConnectionOutcome {
1305 server: String,
1306 connected: bool,
1307 auth_required: bool,
1308 #[serde(skip_serializing_if = "Option::is_none")]
1309 error: Option<String>,
1310 }
1311
1312 /// Request body for `POST /v1/apps/mcp/servers` (create) and
1313 /// `PATCH /v1/apps/mcp/servers/{name}` (update).
1314 ///
1315 /// Either `command` **or** `url` must be set on create. On update, only
1316 /// supplied fields are applied; absent fields leave the existing value in
1317 /// place.
1318 #[derive(Debug, Deserialize)]
1319 struct McpServerWriteRequest {
1320 /// stdio command binary (e.g. `"npx"`).
1321 #[serde(default, deserialize_with = "deserialize_present_nullable")]
1322 command: Option<Option<String>>,
1323 /// Arguments for the stdio command.
1324 args: Option<Vec<String>>,
1325 /// Environment variables injected into the stdio child process.
1326 /// Values are stored as-is; use `${VAR}` syntax to reference environment
1327 /// variables at runtime instead of embedding secrets here.
1328 env: Option<std::collections::HashMap<String, String>>,
1329 /// HTTP(S) endpoint for streamable-HTTP or SSE MCP servers.
1330 #[serde(default, deserialize_with = "deserialize_present_nullable")]
1331 url: Option<Option<String>>,
1332 /// Explicit transport override (`"sse"` or `"streamable_http"`).
1333 #[serde(default, deserialize_with = "deserialize_present_nullable")]
1334 transport: Option<Option<String>>,
1335 /// Override the server-level connect timeout in seconds.
1336 #[serde(default, deserialize_with = "deserialize_present_nullable")]
1337 connect_timeout: Option<Option<u64>>,
1338 /// Override the server-level execute timeout in seconds.
1339 #[serde(default, deserialize_with = "deserialize_present_nullable")]
1340 execute_timeout: Option<Option<u64>>,
1341 /// Override the server-level read timeout in seconds.
1342 #[serde(default, deserialize_with = "deserialize_present_nullable")]
1343 read_timeout: Option<Option<u64>>,
1344 /// Whether the server is enabled. Defaults to `true` on create.
1345 enabled: Option<bool>,
1346 /// Whether a connection failure for this server is fatal.
1347 required: Option<bool>,
1348 /// Allowlist of tool names to expose (empty = expose all).
1349 enabled_tools: Option<Vec<String>>,
1350 /// Denylist of tool names to hide.
1351 disabled_tools: Option<Vec<String>>,
1352 /// Variable names whose runtime values are injected as HTTP headers.
1353 /// The key in this map is the HTTP header name; the value is the
1354 /// environment variable whose value supplies the header value at
1355 /// request time. Credentials remain in the environment, not on disk.
1356 env_headers: Option<std::collections::HashMap<String, String>>,
1357 /// Environment variable that contains a bearer token for URL-based servers.
1358 #[serde(default, deserialize_with = "deserialize_present_nullable")]
1359 bearer_token_env_var: Option<Option<String>>,
1360 /// OAuth scopes requested during `codewhale mcp login`.
1361 scopes: Option<Vec<String>>,
1362 /// RFC 8707 resource parameter for the OAuth authorization URL.
1363 #[serde(default, deserialize_with = "deserialize_present_nullable")]
1364 oauth_resource: Option<Option<String>>,
1365 }
1366
1367 /// Preserve the difference between an omitted PATCH field and an explicit
1368 /// `null`: serde only calls this decoder when the field is present.
1369 fn deserialize_present_nullable<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
1370 where
1371 D: serde::Deserializer<'de>,
1372 T: Deserialize<'de>,
1373 {
1374 Option::<T>::deserialize(deserializer).map(Some)
1375 }
1376
1377 /// Response returned by MCP server management endpoints.
1378 ///
1379 /// Sensitive fields (`headers`, `env_headers`, `bearer_token_env_var`,
1380 /// `env`, OAuth client secrets) are intentionally omitted or redacted so
1381 /// the API never echoes credentials back to callers.
1382 #[derive(Debug, Serialize)]
1383 struct McpServerDetail {
1384 revision: String,
1385 name: String,
1386 credential_configured: bool,
1387 origin: &'static str,
1388 writable: bool,
1389 auth_required: bool,
1390 enabled: bool,
1391 required: bool,
1392 command: Option<String>,
1393 args: Vec<String>,
1394 /// Environment variable names injected into the process.
1395 /// Values are **not** returned — callers see only the keys.
1396 env_keys: Vec<String>,
1397 url: Option<String>,
1398 transport: Option<String>,
1399 connect_timeout: Option<u64>,
1400 execute_timeout: Option<u64>,
1401 read_timeout: Option<u64>,
1402 enabled_tools: Vec<String>,
1403 disabled_tools: Vec<String>,
1404 /// HTTP header names that are read from environment variables.
1405 /// The corresponding environment variable values are **not** returned.
1406 env_header_keys: Vec<String>,
1407 /// Whether a `bearer_token_env_var` is configured (value not returned).
1408 has_bearer_token_env_var: bool,
1409 scopes: Vec<String>,
1410 oauth_resource: Option<String>,
1411 /// Live connection state from the in-memory pool (if the pool is active).
1412 connected: bool,
1413 }
1414
1415 impl McpServerDetail {
1416 fn from_config(
1417 name: &str,
1418 cfg: &crate::mcp::McpServerConfig,
1419 connected: bool,
1420 revision: String,
1421 ) -> Self {
1422 let mut env_keys: Vec<String> = cfg.env.keys().cloned().collect();
1423 env_keys.sort();
1424 let mut env_header_keys: Vec<String> = cfg.env_headers.keys().cloned().collect();
1425 env_header_keys.sort();
1426 Self {
1427 revision,
1428 name: name.to_string(),
1429 credential_configured: mcp_credential_configured(cfg),
1430 origin: "global",
1431 writable: true,
1432 auth_required: false,
1433 enabled: cfg.is_enabled(),
1434 required: cfg.required,
1435 command: cfg.command.clone(),
1436 args: cfg.args.clone(),
1437 env_keys,
1438 url: cfg.url.clone(),
1439 transport: cfg.transport.clone(),
1440 connect_timeout: cfg.connect_timeout,
1441 execute_timeout: cfg.execute_timeout,
1442 read_timeout: cfg.read_timeout,
1443 enabled_tools: cfg.enabled_tools.clone(),
1444 disabled_tools: cfg.disabled_tools.clone(),
1445 env_header_keys,
1446 has_bearer_token_env_var: cfg.bearer_token_env_var.is_some(),
1447 scopes: cfg.scopes.clone(),
1448 oauth_resource: cfg.oauth_resource.clone(),
1449 connected,
1450 }
1451 }
1452 }
1453
1454 #[derive(Debug, Serialize)]
1455 struct McpServerActionReceipt {
1456 #[serde(skip_serializing_if = "Option::is_none")]
1457 revision: Option<String>,
1458 name: String,
1459 action: &'static str,
1460 ok: bool,
1461 #[serde(skip_serializing_if = "Option::is_none")]
1462 connection: Option<McpConnectionOutcome>,
1463 }
1464
1465 #[derive(Debug, Deserialize)]
1466 struct AutomationRunsQuery {
1467 limit: Option<usize>,
1468 }
1469
1470 #[derive(Debug, Deserialize)]
1471 struct ThreadEventsQuery {
1472 since_seq: Option<u64>,
1473 replay_limit: Option<usize>,
1474 #[serde(default)]
1475 progress: bool,
1476 }
1477
1478 const DEFAULT_FLEET_EVENT_REPLAY_LIMIT: usize = 250;
1479 const MAX_FLEET_EVENT_REPLAY_LIMIT: usize = 1_000;
1480
1481 #[derive(Debug, Deserialize)]
1482 #[serde(deny_unknown_fields)]
1483 struct CreateFleetRunRequest {
1484 #[serde(default)]
1485 name: Option<String>,
1486 target: FleetRuntimeTarget,
1487 roles: Vec<ManagedFleetRoleRequest>,
1488 workflow: ManagedFleetWorkflowRequest,
1489 #[serde(default, alias = "workers")]
1490 worker_specs: Vec<FleetWorkerSpec>,
1491 #[serde(default)]
1492 labels: BTreeMap<String, String>,
1493 #[serde(default)]
1494 security_policy: Option<FleetSecurityPolicy>,
1495 #[serde(default)]
1496 max_workers: Option<usize>,
1497 /// Optional run-wide usage ceiling (R6, #5567).
1498 #[serde(default)]
1499 usage_ceiling: Option<codewhale_protocol::fleet::FleetUsageCeiling>,
1500 }
1501
1502 #[derive(Debug, Deserialize)]
1503 #[serde(deny_unknown_fields)]
1504 struct ManagedFleetRoleRequest {
1505 name: String,
1506 #[serde(default)]
1507 agent_profile: Option<String>,
1508 }
1509
1510 #[derive(Debug, Deserialize)]
1511 #[serde(deny_unknown_fields)]
1512 struct ManagedFleetWorkflowRequest {
1513 id: String,
1514 kind: FleetWorkflowKind,
1515 #[serde(alias = "task_specs")]
1516 tasks: Vec<FleetTaskSpec>,
1517 }
1518
1519 #[derive(Debug, Deserialize)]
1520 struct FleetEventsQuery {
1521 after: Option<String>,
1522 limit: Option<usize>,
1523 }
1524
1525 #[derive(Debug, Serialize)]
1526 struct StartTurnResponse {
1527 thread: ThreadRecord,
1528 turn: TurnRecord,
1529 /// Present only when the durable `operation_key` made this submission a
1530 /// replay of one already accepted: the turn is the original and nothing
1531 /// new was admitted. Omitted otherwise so every existing response stays
1532 /// byte-identical — a client that never sends a key sees no change.
1533 #[serde(skip_serializing_if = "replay_flag_is_absent")]
1534 idempotent_replay: bool,
1535 }
1536
1537 fn replay_flag_is_absent(replayed: &bool) -> bool {
1538 !*replayed
1539 }
1540
1541 fn install_runtime_server_workshop_budgets(
1542 config: &Config,
1543 ) -> crate::tools::large_output_router::WorkshopConfig {
1544 crate::tools::large_output_router::WorkshopConfig::install_active(config.workshop.as_ref())
1545 }
1546
1547 #[cfg(test)]
1548 fn open_runtime_threads_for_server(
1549 config: &Config,
1550 workspace: PathBuf,
1551 manager_config: RuntimeThreadManagerConfig,
1552 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1553 ) -> Result<(
1554 SharedRuntimeThreadManager,
1555 crate::tools::large_output_router::WorkshopConfig,
1556 )> {
1557 open_runtime_threads_for_host(config, workspace, manager_config, plugin_registry, false)
1558 }
1559
1560 pub(crate) fn open_runtime_threads_for_host(
1561 config: &Config,
1562 workspace: PathBuf,
1563 manager_config: RuntimeThreadManagerConfig,
1564 plugin_registry: Arc<crate::plugins::PluginRegistry>,
1565 acp: bool,
1566 ) -> Result<(
1567 SharedRuntimeThreadManager,
1568 crate::tools::large_output_router::WorkshopConfig,
1569 )> {
1570 // The Runtime API lazily creates engines after the HTTP/Web server starts.
1571 // Install the resolved process-wide read/tool byte limits before the
1572 // thread manager can spawn any of those engines, matching interactive and
1573 // headless exec startup.
1574 let workshop_activation = install_runtime_server_workshop_budgets(config);
1575 let manager = Arc::new(if acp {
1576 RuntimeThreadManager::open_acp(config.clone(), workspace, manager_config, plugin_registry)
1577 } else {
1578 RuntimeThreadManager::open_with_plugin_registry(
1579 config.clone(),
1580 workspace,
1581 manager_config,
1582 plugin_registry,
1583 )
1584 }?);
1585 // Publish the same exact endpoint-scoped catalog as interactive startup
1586 // before the server admits turns. A cached model list alone does not make
1587 // its capabilities available to route resolution.
1588 crate::provider_catalog_live::maybe_load_persisted_cache_for_config(config);
1589 Ok((manager, workshop_activation))
1590 }
1591
1592 /// Prefix of the first line the Runtime prints once it holds its listener.
1593 pub const RUNTIME_LISTENING_PREFIX: &str = "Runtime API listening on http://";
1594
1595 /// Start the runtime API server.
1596 pub async fn run_http_server(
1597 config: Config,
1598 workspace: PathBuf,
1599 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
1600 options: RuntimeApiOptions,
1601 ) -> Result<()> {
1602 validate_runtime_listener_security(&options)?;
1603 let acp_selected = matches!(
1604 options.control_frontend.as_ref(),
1605 Some(codewhale_app_server::RuntimeControlFrontend::Acp { .. })
1606 );
1607
1608 let task_default_model = runtime_request_model(&config, None).unwrap_or_else(|_| "auto".into());
1609 let task_cfg = TaskManagerConfig::from_runtime(
1610 &config,
1611 workspace.clone(),
1612 Some(task_default_model.clone()),
1613 Some(options.workers),
1614 );
1615 #[cfg(any(unix, windows))]
1616 let published = codewhale_app_server::daemon_client::connect_if_published(
1617 options.config_path.clone(),
1618 selected_control_socket(&options),
1619 )
1620 .await?;
1621 #[cfg(any(unix, windows))]
1622 if let Some(control) = published {
1623 let selected_store =
1624 RuntimeThreadManagerConfig::from_task_data_dir(task_cfg.data_dir.clone()).data_dir;
1625 validate_selected_owner(&control, selected_store).await?;
1626 let observed_owner = control.receipt().clone();
1627 let client = match options.control_frontend.as_ref() {
1628 Some(codewhale_app_server::RuntimeControlFrontend::Acp { model }) => {
1629 drop(control);
1630 let client =
1631 codewhale_app_server::daemon_client::connect_selected_acp_if_published(
1632 options.config_path.clone(),
1633 selected_control_socket(&options),
1634 codewhale_app_server::RuntimeFrontendScope {
1635 workers: options.workers,
1636 workspace: workspace.clone(),
1637 config_profile: options.config_profile.clone(),
1638 config_source: config
1639 .loaded_config_path
1640 .clone()
1641 .or_else(|| options.config_path.clone()),
1642 },
1643 model.clone(),
1644 observed_owner.clone(),
1645 )
1646 .await?
1647 .context("authenticated owner disappeared before ACP attachment")?;
1648 anyhow::ensure!(
1649 client.receipt() == &observed_owner,
1650 "selected owner changed before ACP attachment"
1651 );
1652 client
1653 }
1654 Some(
1655 codewhale_app_server::RuntimeControlFrontend::Stdio
1656 | codewhale_app_server::RuntimeControlFrontend::Socket { .. },
1657 ) => {
1658 drop(control);
1659 codewhale_app_server::daemon_client::connect_scoped_control_if_published(
1660 options.config_path.clone(),
1661 selected_control_socket(&options),
1662 codewhale_app_server::RuntimeFrontendScope {
1663 workers: options.workers,
1664 workspace: workspace.clone(),
1665 config_profile: options.config_profile.clone(),
1666 config_source: config
1667 .loaded_config_path
1668 .clone()
1669 .or_else(|| options.config_path.clone()),
1670 },
1671 observed_owner,
1672 )
1673 .await?
1674 .context("authenticated owner disappeared before control attachment")?
1675 }
1676 _ => {
1677 let environment = runtime_token_environment(&|name| std::env::var(name).ok());
1678 let selection = codewhale_app_server::RuntimeListenerSelection {
1679 workers: options.workers,
1680 workspace: workspace.clone(),
1681 config_profile: options.config_profile.clone(),
1682 config_source: config
1683 .loaded_config_path
1684 .clone()
1685 .or_else(|| options.config_path.clone()),
1686 host: options.host.clone(),
1687 port: options.port,
1688 cors_origins: options.cors_origins.clone(),
1689 auth_token: options
1690 .auth_token
1691 .clone()
1692 .filter(|token| !token.trim().is_empty())
1693 .or(environment.token),
1694 insecure_no_auth: options.insecure_no_auth,
1695 mobile: options.mobile,
1696 web: options.web,
1697 };
1698 drop(control);
1699 codewhale_app_server::daemon_client::connect_listener_if_published(
1700 options.config_path.clone(),
1701 selected_control_socket(&options),
1702 selection,
1703 observed_owner,
1704 )
1705 .await?
1706 .context("authenticated owner disappeared before listener attachment")?
1707 }
1708 };
1709 return run_attached_frontend(client, &options).await;
1710 }
1711 // No publication permits guessing an owner or bypassing its lease. The
1712 // real manager open below is still the exclusive bootstrap authority.
1713 let addr = runtime_bind_address(&options.host, options.port)?;
1714 let listener = TcpListener::bind(addr)
1715 .await
1716 .with_context(|| format!("Failed to bind {addr}"))?;
1717 let bound_addr = listener
1718 .local_addr()
1719 .context("Failed to read Runtime API listener address")?;
1720 let sessions_dir = default_sessions_dir().unwrap_or_else(|_| fallback_sessions_dir());
1721 let mut manager_config =
1722 RuntimeThreadManagerConfig::from_task_data_dir(task_cfg.data_dir.clone());
1723 manager_config.sessions_dir = Some(sessions_dir.clone());
1724 let (runtime_threads, _workshop_activation) = open_runtime_threads_for_host(
1725 &config,
1726 workspace.clone(),
1727 manager_config,
1728 plugin_discovery.registry_for_workspace(&workspace),
1729 acp_selected,
1730 )?;
1731 let sessions_dir = runtime_threads.sessions_dir().to_path_buf();
1732 let task_manager =
1733 TaskManager::start_with_runtime_manager(task_cfg, config.clone(), runtime_threads.clone())
1734 .await?;
1735 let _task_shutdown = task_manager.shutdown_guard();
1736 let mut automation_service = AutomationManager::default_location()?;
1737 automation_service.bind_task_manager(&task_manager)?;
1738 let automations = Arc::new(Mutex::new(automation_service));
1739 runtime_threads.attach_automation_manager(automations.clone());
1740 let scheduler_cancel = CancellationToken::new();
1741 let scheduler_handle = spawn_scheduler(
1742 automations.clone(),
1743 task_manager.clone(),
1744 scheduler_cancel.clone(),
1745 AutomationSchedulerConfig::default(),
1746 );
1747
1748 // Repair the saved-session store once per server start (#6144); this
1749 // server's own store is open by now, so it reads as in use.
1750 crate::session_reconcile::spawn_background_reconcile(None);
1751 let runtime_token_env = runtime_token_environment(&|name| std::env::var(name).ok());
1752 let runtime_token_alias_warning =
1753 runtime_token_alias_warning(options.auth_token.as_deref(), &runtime_token_env);
1754 let resolved_auth = resolve_runtime_auth(
1755 options.auth_token.clone(),
1756 runtime_token_env.token,
1757 options.insecure_no_auth,
1758 );
1759 let runtime_token = resolved_auth.token.clone();
1760 let auth_enabled = runtime_token.is_some();
1761 let (web, web_bootstrap) = if options.web {
1762 runtime_token
1763 .as_ref()
1764 .context("Codewhale web requires a Runtime authentication token")?;
1765 let (web, bootstrap) = web::RuntimeWebState::new();
1766 (Some(web), Some(bootstrap))
1767 } else {
1768 (None, None)
1769 };
1770 let (mobile, mobile_bootstrap) = if options.mobile && auth_enabled {
1771 let (mobile, bootstrap) = mobile::RuntimeMobileState::new();
1772 (Some(mobile), Some(bootstrap))
1773 } else {
1774 (None, None)
1775 };
1776 let skill_state = SkillStateStore::load_default()
1777 .context("load persistent Skill activation state for Runtime API")?;
1778 let sub_agent_manager = runtime_api_sub_agent_manager(&workspace, options.workers);
1779 let shutdown = RuntimeServerShutdown::default();
1780 // Opening a thread is every client's first read, and the store can only
1781 // answer it after one pass over the whole items directory (an item's
1782 // filename names the item, not its turn). Every open used to pay that pass;
1783 // here it is paid once, while the server is starting and nobody is waiting
1784 // for it. See [`RuntimeThreadStore::ensure_item_index`].
1785 let warm_threads = runtime_threads.clone();
1786 tokio::task::spawn_blocking(move || {
1787 if let Err(error) = warm_threads.warm_item_index() {
1788 tracing::warn!(%error, "thread item index warm-up failed");
1789 }
1790 });
1791 let workspace_scopes =
1792 RuntimeWorkspaceScopes::new(runtime_threads.clone(), sub_agent_manager.clone());
1793 let workspace_scope = workspace_scopes.admit(workspace.clone()).await?;
1794 let state = RuntimeApiState {
1795 config: Arc::new(parking_lot::RwLock::new(config.clone())),
1796 workspace,
1797 plugin_discovery,
1798 task_manager: task_manager.clone(),
1799 runtime_threads,
1800 cors_origins: options.cors_origins.clone(),
1801 sessions_dir,
1802 config_path: options.config_path.clone(),
1803 config_profile: options.config_profile.clone(),
1804 automations,
1805 sub_agent_manager,
1806 runtime_token: runtime_token.clone(),
1807 skill_state: Arc::new(Mutex::new(skill_state)),
1808 auth_required: auth_enabled,
1809 bind_host: options.host.clone(),
1810 bind_port: bound_addr.port(),
1811 mobile_enabled: options.mobile,
1812 mobile,
1813 web,
1814 fleet_codewhale_binary: configured_codewhale_binary(),
1815 workspace_scopes,
1816 workspace_scope,
1817 computer: computer_display::ComputerState::from_env(),
1818 shutdown: shutdown.clone(),
1819 git_writes: Arc::new(tokio::sync::Mutex::new(())),
1820 provider_switches: Arc::new(tokio::sync::Mutex::new(())),
1821 #[cfg(test)]
1822 compat_stream_test_hook: None,
1823 };
1824 #[cfg(any(unix, windows))]
1825 let (owner_frontend, control_state) = bind_captured_runtime_frontends(
1826 &state,
1827 selected_control_socket(&options),
1828 match options.control_frontend.as_ref() {
1829 Some(codewhale_app_server::RuntimeControlFrontend::Acp { model }) => model.clone(),
1830 _ => task_default_model.clone(),
1831 },
1832 options.workers,
1833 resolved_auth.generated,
1834 )
1835 .await?;
1836 let listener_workspace = state.workspace.clone();
1837 let app = build_router(state);
1838 #[cfg(any(unix, windows))]
1839 let app = app.merge(codewhale_app_server::runtime_compatibility_router(
1840 control_state.clone(),
1841 &options.cors_origins,
1842 runtime_token.clone(),
1843 Some(listener_workspace),
1844 ));
1845 let owned_stdio = matches!(
1846 options.control_frontend.as_ref(),
1847 Some(
1848 codewhale_app_server::RuntimeControlFrontend::Stdio
1849 | codewhale_app_server::RuntimeControlFrontend::Acp { .. }
1850 )
1851 );
1852
1853 if !owned_stdio {
1854 // First stdout line, flushed: a supervising parent reads the endpoint
1855 // from here instead of guessing a port (stdout is block-buffered on a pipe).
1856 println!("{RUNTIME_LISTENING_PREFIX}{bound_addr}");
1857 let _ = std::io::Write::flush(&mut std::io::stdout());
1858 for line in runtime_auth_status_lines(&resolved_auth) {
1859 println!("{line}");
1860 }
1861 if let Some(warning) = runtime_token_alias_warning {
1862 println!("{warning}");
1863 }
1864 if options.mobile {
1865 print_mobile_urls(
1866 bound_addr,
1867 auth_enabled,
1868 resolved_auth.generated,
1869 options.show_qr,
1870 mobile_bootstrap.as_deref(),
1871 );
1872 }
1873 if let Some(bootstrap) = web_bootstrap {
1874 println!("Codewhale web enabled at http://{bound_addr}/");
1875 let bootstrap_url = web::bootstrap_url(bound_addr, &bootstrap);
1876 println!(
1877 "Codewhale web bootstrap (single-use, expires in {} min): {bootstrap_url}",
1878 web::BOOTSTRAP_TTL.as_secs() / 60
1879 );
1880 if let Some(warning) = web_launcher_warning(crate::utils::open_url(&bootstrap_url)) {
1881 println!("{warning}");
1882 }
1883 }
1884 let is_loopback = is_loopback_bind_host(&options.host);
1885 if is_loopback {
1886 println!(
1887 "Security: this server is local-first. Do not expose it to untrusted networks."
1888 );
1889 } else {
1890 println!(
1891 "Security: bound to {host}; reachable from any peer that can route to this address.",
1892 host = options.host
1893 );
1894 if !auth_enabled {
1895 println!(
1896 " WARNING: auth is disabled. Anyone on the network can call /v1/* without authentication."
1897 );
1898 }
1899 println!(
1900 " /v1/runtime/info reports bind_host={host:?}, port={port}, auth_required={auth}.",
1901 host = options.host,
1902 port = bound_addr.port(),
1903 auth = auth_enabled,
1904 );
1905 }
1906 }
1907 let signal_registration = SignalShutdownRegistration::register(&shutdown);
1908 #[cfg(any(unix, windows))]
1909 let (serve_result, owner_result) = {
1910 let owner_handle = owner_frontend.shutdown_handle();
1911 let mut owner_task = tokio::spawn(owner_frontend.serve());
1912 let stdio = async {
1913 if acp_selected {
1914 codewhale_app_server::run_owned_acp(control_state).await
1915 } else if owned_stdio {
1916 codewhale_app_server::run_owned_stdio(control_state).await
1917 } else {
1918 std::future::pending::<Result<()>>().await
1919 }
1920 };
1921 tokio::pin!(stdio);
1922 let serve = serve_runtime_api(listener, app, shutdown.clone());
1923 tokio::pin!(serve);
1924 tokio::select! {
1925 result = &mut serve => {
1926 owner_handle.trigger();
1927 let owner = owner_task.await.context("owner control frontend task failed")
1928 .and_then(|result| result.map_err(Into::into));
1929 (result.map_err(|e| anyhow!("Runtime API server error: {e}")), owner)
1930 }
1931 result = &mut stdio => {
1932 shutdown.requested.cancel();
1933 let served = serve.await.map_err(|error|anyhow!("Runtime API server error: {error}"));
1934 owner_handle.trigger();
1935 let owner=owner_task.await.context("owner control frontend task failed").and_then(|result|result.map_err(Into::into));
1936 (result.and(served),owner)
1937 }
1938 owner = &mut owner_task => {
1939 shutdown.requested.cancel();
1940 let result = serve.await.map_err(|e| anyhow!("Runtime API server error: {e}"));
1941 let owner = owner.context("owner control frontend task failed")
1942 .and_then(|result| result.map_err(Into::into));
1943 (result, owner.and_then(|()| Err(anyhow!("owner control frontend stopped before its Runtime host"))))
1944 }
1945 }
1946 };
1947 #[cfg(not(any(unix, windows)))]
1948 let serve_result = serve_runtime_api(listener, app, shutdown)
1949 .await
1950 .map_err(|e| anyhow!("Runtime API server error: {e}"));
1951 drop(signal_registration);
1952 scheduler_cancel.cancel();
1953 scheduler_handle.abort();
1954 task_manager.shutdown_and_wait().await?;
1955 #[cfg(any(unix, windows))]
1956 owner_result?;
1957 serve_result
1958 }
1959
1960 fn selected_control_socket(options: &RuntimeApiOptions) -> Option<PathBuf> {
1961 match options.control_frontend.as_ref() {
1962 Some(codewhale_app_server::RuntimeControlFrontend::Socket { path }) => path.clone(),
1963 _ => None,
1964 }
1965 }
1966
1967 #[cfg(any(unix, windows))]
1968 pub(crate) async fn validate_selected_owner(
1969 client: &codewhale_app_server::daemon_client::OwnerClient,
1970 selected_store: PathBuf,
1971 ) -> Result<()> {
1972 let receipt = client.receipt().clone();
1973 codewhale_app_server::daemon_socket::owner_work(move || {
1974 let selected = crate::runtime_threads::RuntimeStoreBinding::for_store_dir(&selected_store)?;
1975 selected.validate_existing_store()?;
1976 anyhow::ensure!(
1977 selected.data_dir == receipt.data_dir
1978 && !selected.execution_scope.is_empty()
1979 && selected.execution_scope == receipt.execution_scope,
1980 "authenticated Runtime owner belongs to another selected store; refusing attachment"
1981 );
1982 Ok(())
1983 })
1984 .await
1985 }
1986
1987 #[cfg(any(unix, windows))]
1988 async fn bind_captured_runtime_frontends(
1989 state: &RuntimeApiState,
1990 selected_socket: Option<PathBuf>,
1991 model: String,
1992 worker_setting: usize,
1993 generated_auth: bool,
1994 ) -> Result<(
1995 codewhale_app_server::daemon_socket::DaemonSocket,
1996 codewhale_app_server::AppState,
1997 )> {
1998 let captured_manager = state.runtime_threads.clone();
1999 let (binding, generation) = codewhale_app_server::daemon_socket::owner_work(move || {
2000 captured_manager.capture_control_owner()
2001 })
2002 .await?;
2003 let config_path = state.config_path.clone();
2004 let socket_path =
2005 codewhale_app_server::daemon_socket::owner_work(move || match selected_socket {
2006 Some(path) => Ok(path),
2007 None => codewhale_app_server::daemon_socket::default_socket_path().map_err(Into::into),
2008 })
2009 .await?;
2010 let process_start =
2011 codewhale_app_server::daemon_socket::capture_process_start(std::process::id()).await?;
2012 #[cfg(unix)]
2013 let principal =
2014 codewhale_config::private_directory::PrivateDirectory::current_user_id().to_string();
2015 #[cfg(windows)]
2016 let principal = codewhale_app_server::daemon_socket::owner_work(|| {
2017 codewhale_config::windows_identity::CurrentWindowsUser::open()?.sid_string()
2018 })
2019 .await?;
2020 let owner = codewhale_protocol::RuntimeOwnerReceipt {
2021 version: 1,
2022 data_dir: binding.data_dir,
2023 execution_scope: binding.execution_scope,
2024 lease_generation: generation,
2025 pid: std::process::id(),
2026 process_start,
2027 principal,
2028 socket_path,
2029 config_path: config_path.clone(),
2030 };
2031 codewhale_app_server::bind_runtime_frontends(
2032 config_path,
2033 state.runtime_token.clone(),
2034 owner,
2035 codewhale_app_server::RuntimeOwnerRouting {
2036 workers: Some(worker_setting),
2037 workspace: Some(state.workspace.clone()),
2038 endpoint: runtime_bind_address(&state.bind_host, state.bind_port)?,
2039 mobile: state.mobile.is_some(),
2040 web: state.web.is_some(),
2041 acp: true,
2042 acp_only: state.runtime_threads.is_acp_host(),
2043 },
2044 Some(
2045 CapturedRuntimeFrontend::capture(state.clone(), model, worker_setting, generated_auth)
2046 .await?,
2047 ),
2048 )
2049 .await
2050 }
2051
2052 #[cfg(any(unix, windows))]
2053 async fn run_attached_frontend(
2054 mut client: codewhale_app_server::daemon_client::OwnerClient,
2055 options: &RuntimeApiOptions,
2056 ) -> Result<()> {
2057 let routing = client
2058 .routing()
2059 .context("authenticated owner has no selected frontend facts")?;
2060 let acp_selected = matches!(
2061 options.control_frontend.as_ref(),
2062 Some(codewhale_app_server::RuntimeControlFrontend::Acp { .. })
2063 );
2064 anyhow::ensure!(
2065 (!acp_selected || routing.acp) && (acp_selected || !routing.acp_only),
2066 "selected owner cannot admit this frontend within its captured base profile"
2067 );
2068 if matches!(
2069 options.control_frontend.as_ref(),
2070 Some(
2071 codewhale_app_server::RuntimeControlFrontend::Stdio
2072 | codewhale_app_server::RuntimeControlFrontend::Acp { .. }
2073 )
2074 ) {
2075 return client
2076 .forward(tokio::io::stdin(), tokio::io::stdout())
2077 .await;
2078 }
2079 if matches!(
2080 options.control_frontend.as_ref(),
2081 Some(codewhale_app_server::RuntimeControlFrontend::Socket { .. })
2082 ) {
2083 println!(
2084 "Attached to the authenticated Runtime control owner at {}.",
2085 routing.endpoint
2086 );
2087 } else {
2088 let frame = tokio::time::timeout(Duration::from_secs(10), client.recv())
2089 .await
2090 .context("selected listener readiness deadline expired; attachment outcome uncertain")??
2091 .context("owner closed before selected listener readiness")?;
2092 anyhow::ensure!(
2093 frame["jsonrpc"] == "2.0"
2094 && frame["method"] == "daemon/frontend_ready"
2095 && frame.get("id").is_none(),
2096 "invalid selected listener readiness response"
2097 );
2098 let ready: RuntimeFrontendReady = serde_json::from_value(frame["params"].clone())?;
2099 println!("{RUNTIME_LISTENING_PREFIX}{}", ready.endpoint);
2100 if ready.generated_auth {
2101 println!("Runtime authentication enabled; generated bearer is not printed.");
2102 }
2103 if let Some(url) = ready.web_bootstrap_url {
2104 println!("Codewhale web: {url}");
2105 }
2106 if let Some(url) = ready.mobile_bootstrap_url {
2107 println!("Codewhale mobile: {url}");
2108 }
2109 }
2110
2111 // This local guest owns its connection only. A signal detaches; it never
2112 // sends the host shutdown request or replays an uncertain operation.
2113 tokio::select! {
2114 result=async {while client.recv().await?.is_some() {} Ok::<(), anyhow::Error>(())}=>result,
2115 result=tokio::signal::ctrl_c()=>result.map_err(Into::into),
2116 }
2117 }
2118
2119 /// Mobile control uses plain HTTP only on loopback. It has no TLS or verified
2120 /// overlay transport, so a non-loopback listener would expose the Runtime API
2121 /// to peers that can observe or replay browser traffic.
2122 fn validate_runtime_listener_security(options: &RuntimeApiOptions) -> Result<()> {
2123 if matches!(
2124 options.control_frontend.as_ref(),
2125 Some(codewhale_app_server::RuntimeControlFrontend::LegacyHttp)
2126 ) && !is_loopback_bind_host(&options.host)
2127 && options
2128 .auth_token
2129 .as_ref()
2130 .is_none_or(|token| token.trim().is_empty())
2131 {
2132 bail!("refusing non-loopback compatibility bind without explicit auth token");
2133 }
2134 if matches!(
2135 options.control_frontend.as_ref(),
2136 Some(
2137 codewhale_app_server::RuntimeControlFrontend::Stdio
2138 | codewhale_app_server::RuntimeControlFrontend::Socket { .. }
2139 | codewhale_app_server::RuntimeControlFrontend::Acp { .. }
2140 )
2141 ) && !is_loopback_bind_host(&options.host)
2142 {
2143 bail!("owned local control requires a loopback private Runtime listener");
2144 }
2145 // Port 0 asks the kernel for an ephemeral port. Only a plain loopback
2146 // Runtime may use it: web and mobile clients are given a fixed endpoint.
2147 if options.port == 0 && (options.web || options.mobile || !is_loopback_bind_host(&options.host))
2148 {
2149 bail!("Port must be > 0");
2150 }
2151 if options.web && options.host != "127.0.0.1" {
2152 bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
2153 }
2154 if options.web && options.insecure_no_auth {
2155 bail!("Codewhale web requires Runtime authentication; remove --insecure");
2156 }
2157 if options.mobile && !is_loopback_bind_host(&options.host) {
2158 bail!(
2159 "Codewhale mobile is loopback-only without TLS or a verified overlay; bind to 127.0.0.1 or ::1"
2160 );
2161 }
2162 if options.insecure_no_auth && !is_loopback_bind_host(&options.host) {
2163 bail!(
2164 "Unauthenticated Runtime access is loopback-only; remove --insecure or bind to 127.0.0.1 or ::1"
2165 );
2166 }
2167 Ok(())
2168 }
2169
2170 fn is_loopback_bind_host(host: &str) -> bool {
2171 host.parse::<IpAddr>()
2172 .is_ok_and(|address| address.is_loopback())
2173 }
2174
2175 fn runtime_bind_address(host: &str, port: u16) -> Result<SocketAddr> {
2176 let address = match host.parse::<IpAddr>() {
2177 Ok(IpAddr::V6(_)) => format!("[{host}]:{port}"),
2178 _ => format!("{host}:{port}"),
2179 };
2180 address
2181 .parse()
2182 .with_context(|| format!("Invalid bind address '{host}:{port}'"))
2183 }
2184
2185 fn web_launcher_warning(result: Result<()>) -> Option<String> {
2186 result.err().map(|error| {
2187 format!(
2188 "warning: could not open the default browser ({error}); open the bootstrap URL above manually"
2189 )
2190 })
2191 }
2192
2193 fn fallback_sessions_dir() -> PathBuf {
2194 if let Some(home) = codewhale_paths::codewhale_home_override().ok().flatten() {
2195 return home.join("sessions");
2196 }
2197 codewhale_paths::legacy_deepseek_home()
2198 .unwrap_or_else(|| PathBuf::from(codewhale_paths::LEGACY_APP_DIR))
2199 .join("sessions")
2200 }
2201
2202 pub fn build_router(state: RuntimeApiState) -> Router {
2203 diagnostics::mark_server_started();
2204 let api_routes = Router::new()
2205 .route(
2206 "/v1/sessions",
2207 get(list_sessions)
2208 .post(create_session_from_thread)
2209 .put(save_current_session),
2210 )
2211 .route("/v1/sessions/summary", get(list_sessions_summary))
2212 .route("/v1/sessions/repair", get(get_session_repair))
2213 .route(
2214 "/v1/sessions/{id}",
2215 get(get_session).patch(patch_session).delete(delete_session),
2216 )
2217 .route(
2218 "/v1/sessions/{id}/resume-thread",
2219 post(resume_session_thread),
2220 )
2221 .route("/v1/sessions/{id}/artifacts", get(list_session_artifacts))
2222 .route(
2223 "/v1/sessions/{id}/artifacts/{artifact_id}",
2224 get(read_session_artifact),
2225 )
2226 .route("/v1/workspace/status", get(workspace_status))
2227 // The Engine's terminal byte stream (#34). Auth is the route layer's,
2228 // not this module's; these never create a session — see terminal.rs.
2229 .route("/v1/terminal/{name}/output", get(terminal::terminal_output))
2230 .route("/v1/terminal/{name}/input", post(terminal::terminal_input))
2231 .route(
2232 "/v1/terminal/{name}/resize",
2233 post(terminal::terminal_resize),
2234 )
2235 .route("/v1/terminal/{name}/kill", post(terminal::terminal_kill))
2236 .route("/v1/workspace/files/search", get(workspace_file_search))
2237 .route(
2238 "/v1/workspace/files",
2239 get(workspace_files_list)
2240 .put(workspace_file_write)
2241 .layer(DefaultBodyLimit::max(
2242 self::workspace::FILE_WRITE_BODY_LIMIT_BYTES,
2243 )),
2244 )
2245 .route("/v1/workspace/files/read", get(workspace_file_read))
2246 .route("/v1/workspace/instructions", get(workspace_instructions))
2247 .route("/v1/agent-runs", get(list_agent_runs))
2248 .route("/v1/agent-runs/{run_id}", get(get_agent_run))
2249 .route("/v1/agent-runs/{run_id}/cancel", post(cancel_agent_run))
2250 .route("/v1/fleet/profiles", get(list_fleet_profiles))
2251 .route(
2252 "/v1/fleet/runs",
2253 get(list_fleet_runs).post(create_fleet_run),
2254 )
2255 .route("/v1/fleet/runs/{run_id}", get(get_fleet_run))
2256 .route(
2257 "/v1/fleet/runs/{run_id}/workers",
2258 get(list_fleet_run_workers),
2259 )
2260 .route("/v1/fleet/runs/{run_id}/start", post(start_fleet_run))
2261 .route("/v1/fleet/runs/{run_id}/events", get(stream_fleet_events))
2262 .route(
2263 "/v1/fleet/runs/{run_id}/events/replay",
2264 get(replay_fleet_events),
2265 )
2266 .route("/v1/fleet/runs/{run_id}/stop", post(stop_fleet_run))
2267 .route(
2268 "/v1/fleet/runs/{run_id}/receipts",
2269 get(list_fleet_run_receipts),
2270 )
2271 .route(
2272 "/v1/fleet/runs/{run_id}/receipts/{task_id}",
2273 get(get_fleet_run_receipt),
2274 )
2275 .route(
2276 "/v1/fleet/runs/{run_id}/receipts/{task_id}/evidence",
2277 get(inspect_fleet_run_receipt_evidence),
2278 )
2279 .route("/v1/fleet/workers/{worker_id}", get(get_fleet_worker))
2280 .route(
2281 "/v1/fleet/workers/{worker_id}/interrupt",
2282 post(interrupt_fleet_worker),
2283 )
2284 .route(
2285 "/v1/fleet/workers/{worker_id}/stop",
2286 post(stop_fleet_worker),
2287 )
2288 .route(
2289 "/v1/fleet/workers/{worker_id}/restart",
2290 post(restart_fleet_worker),
2291 )
2292 .route(
2293 "/v1/stream",
2294 post(stream_turn).layer(DefaultBodyLimit::max(
2295 codewhale_protocol::runtime::MAX_RUNTIME_IMAGE_BODY_BYTES,
2296 )),
2297 )
2298 .route("/v1/git", get(git::git_status_detail))
2299 .route("/v1/changes", get(git::git_changes))
2300 .route("/v1/diff", get(git::git_diff))
2301 .route("/v1/workspace/diff", get(git::workspace_diff))
2302 .route("/v1/git/graph", get(git::git_graph))
2303 .route("/v1/git/stage", post(git::git_stage))
2304 .route("/v1/git/unstage", post(git::git_unstage))
2305 .route("/v1/git/discard", post(git::git_discard))
2306 .route("/v1/git/commit", post(git::git_commit))
2307 .route("/v1/git/push", post(git::git_push))
2308 .route("/v1/git/branch", post(git::git_branch))
2309 .route("/v1/logs", get(diagnostics::list_logs))
2310 .route("/v1/logs/{name}", get(diagnostics::read_log))
2311 .route("/v1/crashes", get(diagnostics::list_crashes))
2312 .route("/v1/crashes/{name}", get(diagnostics::read_crash))
2313 .route("/v1/process", get(diagnostics::process_info))
2314 .route("/v1/jobs", get(jobs::list_jobs))
2315 .route("/v1/threads", get(list_threads).post(create_thread))
2316 .route("/v1/threads/summary", get(list_threads_summary))
2317 .route("/v1/threads/running", get(list_running_threads))
2318 .route("/v1/threads/{id}/notices", get(list_thread_notices))
2319 .route(
2320 "/v1/threads/{id}/notices/{notice_id}",
2321 delete(ack_thread_notice),
2322 )
2323 .route("/v1/threads/{id}", get(get_thread).patch(update_thread))
2324 .route(
2325 "/v1/threads/{id}/history",
2326 get(thread_history::snapshot_thread_history),
2327 )
2328 .route(
2329 "/v1/threads/{id}/jobs",
2330 get(jobs::list_thread_jobs).post(jobs::create_thread_job),
2331 )
2332 .route("/v1/threads/{id}/jobs/{job_id}", get(jobs::get_thread_job))
2333 .route(
2334 "/v1/threads/{id}/jobs/{job_id}/output",
2335 get(jobs::get_thread_job_output),
2336 )
2337 .route(
2338 "/v1/threads/{id}/jobs/{job_id}/stdin",
2339 post(jobs::write_thread_job_stdin),
2340 )
2341 .route(
2342 "/v1/threads/{id}/jobs/{job_id}/kill",
2343 post(jobs::kill_thread_job),
2344 )
2345 .route(
2346 "/v1/threads/{id}/jobs/{job_id}/resize",
2347 post(jobs::resize_thread_job),
2348 )
2349 .route("/v1/threads/{id}/context", get(context::get_thread_context))
2350 .route("/v1/threads/{id}/plan", get(plans::get_thread_plan))
2351 .route("/v1/threads/{id}/todo", get(plans::get_thread_todo))
2352 .route("/v1/plan", get(plans::latest_plan))
2353 .route("/v1/todo", get(plans::latest_todo_route))
2354 .route("/v1/plans", get(plans::list_plans))
2355 .route("/v1/todos", get(plans::list_todos))
2356 .route(
2357 "/v1/targets",
2358 get(targets::list_targets).post(targets::create_target),
2359 )
2360 .route("/v1/targets/switch", post(targets::switch_target))
2361 .route("/v1/remote", get(targets::remote_status))
2362 .route("/v1/remote/connect", post(targets::remote_connect))
2363 .route(
2364 "/v1/ssh",
2365 get(targets::ssh_status).post(targets::ssh_connect),
2366 )
2367 .route("/v1/ssh/connect", post(targets::ssh_connect))
2368 .route(
2369 "/v1/cloud",
2370 get(targets::cloud_status).post(targets::cloud_attach),
2371 )
2372 .route("/v1/cloud/attach", post(targets::cloud_attach))
2373 .route("/v1/lsp", get(lsp::lsp_status))
2374 .route("/v1/diagnostics", get(lsp::lsp_diagnostics))
2375 .route("/v1/definition", get(lsp::lsp_definition))
2376 .route("/v1/references", get(lsp::lsp_references))
2377 .route("/v1/symbols", get(lsp::lsp_symbols))
2378 .route("/v1/voice", get(voice::voice_status))
2379 .route("/v1/voice/dictate", post(voice::voice_dictate))
2380 .route("/v1/voice/send", post(voice::voice_send))
2381 .route("/v1/voice/control", post(voice::voice_control))
2382 .route("/v1/threads/{id}/resume", post(resume_thread))
2383 .route("/v1/threads/{id}/fork", post(fork_thread))
2384 .route("/v1/threads/{id}/undo", post(undo_thread_turn))
2385 .route("/v1/threads/{id}/fork-at-turn", post(fork_thread_at_turn))
2386 .route("/v1/threads/{id}/patch-undo", post(patch_undo_thread_turn))
2387 .route("/v1/threads/{id}/file-revert", post(revert_thread_file))
2388 .route("/v1/threads/{id}/retry", post(retry_thread_turn))
2389 .route(
2390 "/v1/threads/{id}/turn-operations/{operation_key}",
2391 get(get_thread_turn_operation),
2392 )
2393 .route(
2394 "/v1/threads/{id}/turns",
2395 post(start_thread_turn).layer(DefaultBodyLimit::max(
2396 codewhale_protocol::runtime::MAX_RUNTIME_IMAGE_BODY_BYTES,
2397 )),
2398 )
2399 .route(
2400 "/v1/threads/{id}/turns/{turn_id}/steer",
2401 post(steer_thread_turn),
2402 )
2403 .route(
2404 "/v1/threads/{id}/turns/{turn_id}/artifacts",
2405 get(turn_artifacts::list_turn_artifacts),
2406 )
2407 .route(
2408 "/v1/threads/{id}/turns/{turn_id}/artifacts/{artifact_id}",
2409 get(turn_artifacts::read_turn_artifact),
2410 )
2411 .route(
2412 "/v1/threads/{id}/turns/{turn_id}/interrupt",
2413 post(interrupt_thread_turn),
2414 )
2415 .route(
2416 "/v1/threads/{id}/turns/{turn_id}/tool-calls/{call_id}/result",
2417 post(deliver_dynamic_tool_result),
2418 )
2419 .route("/v1/threads/{id}/compact", post(compact_thread))
2420 .route("/v1/threads/{id}/usage", get(get_thread_usage))
2421 .route("/v1/threads/{id}/receipt", get(get_thread_receipt))
2422 .route(
2423 "/v1/threads/{id}/turns/{turn_id}/receipt",
2424 get(get_turn_receipt),
2425 )
2426 .route("/v1/threads/{id}/events", get(stream_thread_events))
2427 .route("/v1/agent-mail", post(send_agent_mail))
2428 .route("/v1/threads/{id}/agent-mail", get(list_agent_mail))
2429 .route(
2430 "/v1/threads/{id}/agent-mail/{message_id}/deliver",
2431 post(deliver_agent_mail),
2432 )
2433 .route(
2434 "/v1/threads/{id}/agent-mail/{message_id}/read",
2435 post(mark_agent_mail_read),
2436 )
2437 .route(
2438 "/v1/threads/{id}/agent-mail/{message_id}/cancel",
2439 post(cancel_agent_mail),
2440 )
2441 .route(
2442 "/v1/threads/{id}/goal",
2443 get(get_thread_goal)
2444 .put(upsert_thread_goal)
2445 .delete(delete_thread_goal),
2446 )
2447 .route("/v1/threads/{id}/goal/complete", post(complete_thread_goal))
2448 .route("/v1/threads/{id}/goal/block", post(block_thread_goal))
2449 .route("/v1/approvals", get(list_approvals))
2450 .route("/v1/approvals/{approval_id}", post(decide_approval))
2451 .route(
2452 "/v1/threads/{id}/approval-grants/{grant_id}",
2453 delete(revoke_approval_grant),
2454 )
2455 .route(
2456 "/v1/user-input/{thread_id}/{input_id}",
2457 post(submit_user_input),
2458 )
2459 .route("/v1/tasks", get(list_tasks).post(create_task))
2460 .route("/v1/tasks/{id}", get(get_task))
2461 .route("/v1/tasks/{id}/cancel", post(cancel_task))
2462 .route("/v1/skills", get(list_skills))
2463 .route("/v1/commands", get(list_commands))
2464 .route("/v1/hooks", get(list_hooks))
2465 .route(
2466 "/v1/skills/{name}",
2467 post(set_skill_enabled).delete(uninstall_skill_api),
2468 )
2469 .route(
2470 "/v1/apps/mcp/imports",
2471 get(mcp_import::preview).post(mcp_import::apply),
2472 )
2473 .route(
2474 "/v1/apps/mcp/servers",
2475 get(list_mcp_servers).post(create_mcp_server),
2476 )
2477 .route(
2478 "/v1/apps/mcp/servers/{name}",
2479 get(get_mcp_server)
2480 .patch(update_mcp_server)
2481 .delete(delete_mcp_server),
2482 )
2483 .route(
2484 "/v1/apps/mcp/servers/{name}/enable",
2485 post(enable_mcp_server),
2486 )
2487 .route(
2488 "/v1/apps/mcp/servers/{name}/disable",
2489 post(disable_mcp_server),
2490 )
2491 .route(
2492 "/v1/apps/mcp/servers/{name}/reconnect",
2493 post(reconnect_mcp_server),
2494 )
2495 .route("/v1/skills/install", post(install_skill_api))
2496 .route("/v1/skills/{name}/update", post(update_skill_api))
2497 .route("/v1/skills/{name}/trust", post(trust_skill_api))
2498 .route("/v1/skills/{name}/audit", get(audit_skill_api))
2499 .route("/v1/apps/mcp/tools", get(list_mcp_tools))
2500 .route("/v1/apps/plugins", get(plugins::list_plugins))
2501 .route(
2502 "/v1/apps/plugins/install",
2503 post(plugins::install_plugin_api),
2504 )
2505 .route(
2506 "/v1/apps/plugins/import/dsh/preview",
2507 post(plugins::preview_dsh_plugin_api),
2508 )
2509 .route(
2510 "/v1/apps/plugins/{selector}",
2511 get(plugins::get_plugin).delete(plugins::uninstall_plugin_api),
2512 )
2513 .route(
2514 "/v1/apps/plugins/{selector}/update",
2515 post(plugins::update_plugin_api),
2516 )
2517 .route(
2518 "/v1/apps/plugins/{selector}/trust",
2519 post(plugins::trust_plugin_api),
2520 )
2521 .route(
2522 "/v1/apps/plugins/{selector}/enable",
2523 post(plugins::enable_plugin_api),
2524 )
2525 .route(
2526 "/v1/apps/plugins/{selector}/disable",
2527 post(plugins::disable_plugin_api),
2528 )
2529 .route(
2530 "/v1/apps/plugins/{selector}/revoke",
2531 post(plugins::revoke_plugin_api),
2532 )
2533 .route(
2534 "/v1/apps/marketplaces",
2535 get(plugins::list_marketplaces).post(plugins::add_marketplace),
2536 )
2537 .route(
2538 "/v1/apps/marketplaces/{name}",
2539 get(plugins::get_marketplace).delete(plugins::remove_marketplace),
2540 )
2541 .route(
2542 "/v1/apps/marketplaces/{name}/install",
2543 post(plugins::install_marketplace_candidate_api),
2544 )
2545 .route(
2546 "/v1/automations",
2547 get(list_automations).post(create_automation),
2548 )
2549 .route(
2550 "/v1/automations/{id}",
2551 get(get_automation)
2552 .patch(update_automation)
2553 .delete(delete_automation),
2554 )
2555 .route("/v1/automations/{id}/run", post(run_automation))
2556 .route("/v1/automations/{id}/pause", post(pause_automation))
2557 .route("/v1/automations/{id}/resume", post(resume_automation))
2558 .route("/v1/automations/{id}/runs", get(list_automation_runs))
2559 .route(
2560 "/v1/operate",
2561 get(get_operate).post(start_operate).patch(patch_operate),
2562 )
2563 .route("/v1/operate/keepalive", post(keepalive_operate))
2564 .route("/v1/operate/plan", put(put_operate_plan))
2565 .route("/v1/operate/cancel", post(cancel_operate))
2566 .route("/v1/operate/stop", post(cancel_operate))
2567 .route(
2568 "/v1/operate/auto-merge/check",
2569 post(check_operate_auto_merge),
2570 )
2571 .route("/v1/usage", get(get_usage))
2572 .route("/v1/snapshots", get(list_snapshots))
2573 .route("/v1/snapshots/{id}/restore", post(restore_snapshot))
2574 .route(
2575 "/v1/account/model-access",
2576 get(secrets::get_account_model_access)
2577 .put(secrets::set_account_model_access)
2578 .delete(secrets::clear_account_model_access)
2579 .layer(DefaultBodyLimit::max(
2580 secrets::PROVIDER_KEY_BODY_LIMIT_BYTES,
2581 )),
2582 )
2583 .route("/v1/providers", get(list_providers))
2584 .route("/v1/providers/{id}/models", get(list_provider_models))
2585 .route(
2586 "/v1/providers/{id}/models/refresh",
2587 post(refresh_provider_models),
2588 )
2589 .route("/v1/providers/{id}/switch", post(switch_provider))
2590 .route(
2591 "/v1/providers/{id}/key",
2592 put(secrets::set_provider_key)
2593 .delete(secrets::clear_provider_key)
2594 .layer(DefaultBodyLimit::max(
2595 secrets::PROVIDER_KEY_BODY_LIMIT_BYTES,
2596 )),
2597 )
2598 .route("/v1/config", get(get_config).post(set_config))
2599 .route("/v1/config/reload", post(reload_config))
2600 .route("/v1/settings/schema", get(get_settings_schema))
2601 .route(
2602 "/v1/threads/{id}/notifications/prepare",
2603 post(notification_delivery::prepare),
2604 )
2605 .route(
2606 "/v1/memory",
2607 get(list_memory)
2608 .post(create_memory_entry)
2609 .delete(clear_memory),
2610 )
2611 .route("/v1/memory/{id}", get(get_memory_entry))
2612 .merge(memory_lens::routes())
2613 .route_layer(middleware::from_fn_with_state(
2614 state.clone(),
2615 require_workspace_scope,
2616 ))
2617 .route_layer(middleware::from_fn_with_state(
2618 state.clone(),
2619 require_runtime_token,
2620 ));
2621
2622 Router::new()
2623 .route("/", get(web::web_page))
2624 .route("/assets/codewhale-web.css", get(web::web_styles))
2625 .route("/assets/codewhale-web.js", get(web::web_script))
2626 .route("/assets/codewhale-192.png", get(web::web_icon))
2627 .route(
2628 "/__codewhale/bootstrap/{nonce}",
2629 get(web::exchange_bootstrap),
2630 )
2631 .route(
2632 "/__codewhale/web/stream-ticket",
2633 post(web::refresh_stream_ticket),
2634 )
2635 .route(
2636 "/__codewhale/mobile/bootstrap/{nonce}",
2637 get(exchange_mobile_bootstrap),
2638 )
2639 .route("/__codewhale/mobile/session", post(exchange_mobile_session))
2640 .route(
2641 "/__codewhale/mobile/stream-ticket",
2642 post(refresh_mobile_stream_ticket),
2643 )
2644 .route("/health", get(health))
2645 .route("/mobile", get(mobile_page))
2646 .route("/mobile/", get(mobile_page))
2647 .route(
2648 "/v1/thread-history/operations/lookup",
2649 post(thread_history::lookup_thread_history_operation),
2650 )
2651 .route(
2652 "/v1/thread-history/operations/recover",
2653 post(thread_history::recover_thread_history_operation),
2654 )
2655 .route(
2656 "/v1/thread-history/mutate",
2657 post(thread_history::mutate_thread_history),
2658 )
2659 .route(
2660 "/v1/thread-history/import",
2661 post(thread_history::import_thread_history),
2662 )
2663 .route("/v1/runtime/info", get(runtime_info))
2664 // Authenticates per handler: the display WS also takes a single-use
2665 // ticket, and client-token minting is master-token only.
2666 .merge(computer_display::router(
2667 state.computer.clone(),
2668 state.runtime_token.clone(),
2669 ))
2670 .merge(api_routes)
2671 .layer(cors_layer(&state.cors_origins))
2672 .with_state(state)
2673 }
2674
2675 async fn mobile_page(State(state): State<RuntimeApiState>) -> Result<Response, ApiError> {
2676 if !state.mobile_enabled {
2677 return Ok((
2678 StatusCode::NOT_FOUND,
2679 "mobile control is disabled; start with `codewhale serve --mobile`",
2680 )
2681 .into_response());
2682 }
2683 let settings = tokio::task::spawn_blocking(crate::settings::Settings::load_read_only)
2684 .await
2685 .map_err(|err| ApiError::internal(format!("mobile settings task failed: {err}")))?
2686 .map_err(|err| ApiError::internal(format!("mobile settings unavailable: {err}")))?;
2687 let locale = codewhale_localization::resolve_locale(&settings.locale);
2688 let mut response = Html(mobile_html(locale)).into_response();
2689 secure_mobile_response(&mut response);
2690 Ok(response)
2691 }
2692
2693 #[derive(Serialize)]
2694 struct MobileSessionResponse {
2695 request_proof: String,
2696 stream_ticket: String,
2697 session_expires_in_seconds: u64,
2698 stream_ticket_expires_in_seconds: u64,
2699 }
2700
2701 async fn exchange_mobile_bootstrap(
2702 State(state): State<RuntimeApiState>,
2703 ConnectInfo(peer): ConnectInfo<SocketAddr>,
2704 Path(nonce): Path<String>,
2705 ) -> Response {
2706 let Some(mobile_state) = state.mobile.as_ref() else {
2707 return mobile_not_found();
2708 };
2709 let session = match mobile_state.consume_bootstrap(&nonce, peer.ip()) {
2710 Ok(session) => session,
2711 Err(mobile::BootstrapError::NonLoopback) => {
2712 return secured_mobile_text(StatusCode::FORBIDDEN, "bootstrap unavailable");
2713 }
2714 Err(mobile::BootstrapError::Invalid | mobile::BootstrapError::Expired) => {
2715 return secured_mobile_text(StatusCode::UNAUTHORIZED, "bootstrap unavailable");
2716 }
2717 };
2718
2719 let location = format!(
2720 "/mobile#request_proof={}&stream_ticket={}",
2721 session.request_proof, session.stream_ticket
2722 );
2723 let cookie = mobile::mobile_session_cookie(&session.session_cookie);
2724 let mut response = (StatusCode::SEE_OTHER, "").into_response();
2725 response.headers_mut().insert(
2726 header::LOCATION,
2727 HeaderValue::from_str(&location).expect("generated mobile fragment is a valid header"),
2728 );
2729 response.headers_mut().insert(
2730 header::SET_COOKIE,
2731 HeaderValue::from_str(&cookie).expect("generated mobile cookie is a valid header"),
2732 );
2733 secure_mobile_response(&mut response);
2734 response
2735 }
2736
2737 async fn exchange_mobile_session(State(state): State<RuntimeApiState>, req: Request) -> Response {
2738 let Some(mobile_state) = state.mobile.as_ref() else {
2739 return mobile_not_found();
2740 };
2741 let Some(expected) = state.runtime_token.as_deref() else {
2742 return mobile_not_found();
2743 };
2744 if !auth::request_has_header_runtime_token(&req, expected) {
2745 return mobile_unauthorized();
2746 }
2747 mobile_session_response(mobile_state.issue_session())
2748 }
2749
2750 async fn refresh_mobile_stream_ticket(
2751 State(state): State<RuntimeApiState>,
2752 req: Request,
2753 ) -> Response {
2754 let Some(mobile_state) = state.mobile.as_ref() else {
2755 return mobile_not_found();
2756 };
2757 if !auth::mobile_session_request_is_authorized(&req, &state, mobile_state) {
2758 return mobile_unauthorized();
2759 }
2760 let ticket = mobile_state.refresh_stream_ticket(
2761 req.headers()
2762 .get(header::COOKIE)
2763 .and_then(|value| value.to_str().ok()),
2764 req.headers()
2765 .get(mobile::MOBILE_REQUEST_HEADER)
2766 .and_then(|value| value.to_str().ok()),
2767 );
2768 let Some(ticket) = ticket else {
2769 return mobile_unauthorized();
2770 };
2771 let mut response = Json(json!({
2772 "stream_ticket": ticket.ticket,
2773 "expires_in_seconds": ticket.expires_in_seconds,
2774 }))
2775 .into_response();
2776 secure_mobile_response(&mut response);
2777 response
2778 }
2779
2780 fn mobile_session_response(session: mobile::MobileSessionBootstrap) -> Response {
2781 let cookie = mobile::mobile_session_cookie(&session.session_cookie);
2782 let mut response = Json(MobileSessionResponse {
2783 request_proof: session.request_proof,
2784 stream_ticket: session.stream_ticket,
2785 session_expires_in_seconds: session.session_ttl_seconds,
2786 stream_ticket_expires_in_seconds: session.stream_ticket_ttl_seconds,
2787 })
2788 .into_response();
2789 response.headers_mut().insert(
2790 header::SET_COOKIE,
2791 HeaderValue::from_str(&cookie).expect("generated mobile cookie is a valid header"),
2792 );
2793 secure_mobile_response(&mut response);
2794 response
2795 }
2796
2797 fn mobile_not_found() -> Response {
2798 secured_mobile_text(StatusCode::NOT_FOUND, "not found")
2799 }
2800
2801 fn mobile_unauthorized() -> Response {
2802 let mut response = auth::runtime_token_required_response();
2803 secure_mobile_response(&mut response);
2804 response
2805 }
2806
2807 fn secured_mobile_text(status: StatusCode, body: &'static str) -> Response {
2808 let mut response = (status, body).into_response();
2809 secure_mobile_response(&mut response);
2810 response
2811 }
2812
2813 fn secure_mobile_response(response: &mut Response) {
2814 let headers = response.headers_mut();
2815 headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
2816 headers.insert(
2817 header::CONTENT_SECURITY_POLICY,
2818 HeaderValue::from_static(
2819 "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; object-src 'none'",
2820 ),
2821 );
2822 headers.insert(
2823 header::X_CONTENT_TYPE_OPTIONS,
2824 HeaderValue::from_static("nosniff"),
2825 );
2826 headers.insert(
2827 header::REFERRER_POLICY,
2828 HeaderValue::from_static("no-referrer"),
2829 );
2830 }
2831
2832 fn print_mobile_urls(
2833 addr: SocketAddr,
2834 auth_enabled: bool,
2835 generated_auth: bool,
2836 show_qr: bool,
2837 mobile_bootstrap: Option<&str>,
2838 ) {
2839 println!("Mobile control page enabled.");
2840
2841 let url = format!("http://{addr}/mobile");
2842 println!(" URL: {url}");
2843 if auth_enabled {
2844 if let Some(bootstrap) = mobile_bootstrap {
2845 let bootstrap_url = mobile::bootstrap_url(addr, bootstrap);
2846 println!(
2847 " Bootstrap (single-use, expires in {} min): {bootstrap_url}",
2848 mobile::BOOTSTRAP_TTL.as_secs() / 60
2849 );
2850 } else if generated_auth {
2851 println!(
2852 " Auth uses an unprinted generated token; open the bootstrap URL printed above."
2853 );
2854 } else {
2855 println!(
2856 " Use the bootstrap URL; the page also supports one-time bearer entry without storing it."
2857 );
2858 }
2859 }
2860 println!(
2861 "Mobile security: loopback-only; no LAN/VPN device access without a verified transport boundary."
2862 );
2863
2864 if show_qr {
2865 println!(" QR is loopback-only and cannot pair another device.");
2866 match qrcode::QrCode::new(url.as_bytes()) {
2867 Ok(qr) => {
2868 let qr_str = qr.render::<qrcode::render::unicode::Dense1x2>().build();
2869 println!("\n{qr_str}");
2870 }
2871 Err(e) => {
2872 eprintln!("Warning: could not generate QR code: {e}");
2873 }
2874 }
2875 }
2876 }
2877
2878 async fn health() -> Json<HealthResponse> {
2879 Json(HealthResponse {
2880 status: "ok",
2881 service: "codewhale-runtime-api",
2882 mode: "local",
2883 })
2884 }
2885
2886 fn runtime_request_model(config: &Config, requested: Option<&str>) -> Result<String, ApiError> {
2887 if let Some(model) = requested {
2888 return Ok(model.to_string());
2889 }
2890 let identity = config
2891 .active_provider_identity()
2892 .map_err(ApiError::bad_request)?;
2893 let model = provider_default_model_for_api(config, &identity);
2894 if model.is_empty() {
2895 return Err(ApiError::bad_request(
2896 "The active provider has no available default model; refresh its catalog or select an explicit model.",
2897 ));
2898 }
2899 Ok(model)
2900 }
2901
2902 async fn create_task(
2903 State(state): State<RuntimeApiState>,
2904 Json(mut req): Json<NewTaskRequest>,
2905 ) -> Result<(StatusCode, Json<TaskRecord>), ApiError> {
2906 if req.prompt.trim().is_empty() {
2907 return Err(ApiError::bad_request("prompt is required"));
2908 }
2909 if req.workspace.is_none() {
2910 req.workspace = Some(state.workspace.clone());
2911 }
2912 if req.model.is_none() && req.model_provider.is_none() && req.model_provider_id.is_none() {
2913 req.model = Some(runtime_request_model(&state.config.read(), None)?);
2914 }
2915 let task = state
2916 .task_manager
2917 .add_task(req)
2918 .await
2919 .map_err(|e| ApiError::bad_request(e.to_string()))?;
2920 Ok((StatusCode::CREATED, Json(task)))
2921 }
2922
2923 async fn create_thread(
2924 State(state): State<RuntimeApiState>,
2925 Json(mut req): Json<CreateThreadRequest>,
2926 ) -> Result<(StatusCode, Json<ThreadRecord>), ApiError> {
2927 if req.workspace.is_none() {
2928 req.workspace = Some(state.workspace.clone());
2929 }
2930 if req.mode.as_ref().is_none_or(|m| m.trim().is_empty()) {
2931 req.mode = Some("agent".to_string());
2932 }
2933
2934 let thread = state
2935 .runtime_threads
2936 .create_thread_with_shell_policy(
2937 req,
2938 state.config_path.as_deref(),
2939 state.config_profile.as_deref(),
2940 )
2941 .await
2942 .map_err(|e| ApiError::bad_request(e.to_string()))?;
2943 Ok((StatusCode::CREATED, Json(thread)))
2944 }
2945
2946 async fn list_threads(
2947 State(state): State<RuntimeApiState>,
2948 Query(query): Query<ThreadsQuery>,
2949 ) -> Result<Json<Vec<ThreadRecord>>, ApiError> {
2950 let filter = resolve_thread_filter(query.include_archived, query.archived_only);
2951 let threads = state
2952 .runtime_threads
2953 .list_threads(filter, query.limit)
2954 .await
2955 .map_err(|e| ApiError::internal(e.to_string()))?;
2956 Ok(Json(threads))
2957 }
2958
2959 /// Threads with queued or in-progress turns, for quit/background
2960 /// accounting (#6180). One call, no inference from latest-turn status.
2961 async fn list_running_threads(
2962 State(state): State<RuntimeApiState>,
2963 ) -> Result<Json<Vec<crate::runtime_threads::RunningThread>>, ApiError> {
2964 let running = state
2965 .runtime_threads
2966 .running_threads()
2967 .await
2968 .map_err(|e| ApiError::internal(e.to_string()))?;
2969 Ok(Json(running))
2970 }
2971
2972 /// Active notices on one thread (#6180): the TUI-visible conditions a
2973 /// watch-only client must surface — subagent-terminal, elevation-needed,
2974 /// model-notify — each with turn identity for targeting.
2975 async fn list_thread_notices(
2976 State(state): State<RuntimeApiState>,
2977 Path(id): Path<String>,
2978 ) -> Result<Json<Vec<crate::runtime_threads::ActiveNotice>>, ApiError> {
2979 state
2980 .runtime_threads
2981 .get_thread(&id)
2982 .await
2983 .map_err(map_thread_err)?;
2984 Ok(Json(state.runtime_threads.list_notices(&id)))
2985 }
2986
2987 /// Acknowledge (clear) one notice. Terminal/notify kinds clear only here;
2988 /// elevation additionally auto-clears when its tool call completes.
2989 async fn ack_thread_notice(
2990 State(state): State<RuntimeApiState>,
2991 Path((id, notice_id)): Path<(String, String)>,
2992 ) -> Result<StatusCode, ApiError> {
2993 state
2994 .runtime_threads
2995 .get_thread(&id)
2996 .await
2997 .map_err(map_thread_err)?;
2998 if !state.runtime_threads.ack_notice(&id, &notice_id) {
2999 return Err(ApiError::not_found(format!(
3000 "thread '{id}' has no notice '{notice_id}'"
3001 )));
3002 }
3003 Ok(StatusCode::NO_CONTENT)
3004 }
3005
3006 /// First-appearance dedupe that preserves the order paths were seen in.
3007 ///
3008 /// The thread summary resolves git metadata per distinct workspace rather than
3009 /// per row. One resolution runs up to five blocking `git` processes, and rows
3010 /// overwhelmingly share a single workspace, so resolving per row multiplied a
3011 /// listing's process count by its row count.
3012 fn distinct_paths(paths: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
3013 let mut distinct: Vec<PathBuf> = Vec::new();
3014 for path in paths {
3015 if !distinct.contains(&path) {
3016 distinct.push(path);
3017 }
3018 }
3019 distinct
3020 }
3021
3022 async fn list_threads_summary(
3023 State(state): State<RuntimeApiState>,
3024 Query(query): Query<ThreadSummaryQuery>,
3025 ) -> Result<Json<Vec<ThreadSummary>>, ApiError> {
3026 let limit = query.limit.unwrap_or(50).clamp(1, 500);
3027 let search = query.search.as_deref().map(str::to_ascii_lowercase);
3028 let filter = resolve_thread_filter(query.include_archived, query.archived_only);
3029 // `limit` bounds the rows this route returns, not how far a search looks.
3030 // Passing it to the store read as well matched only inside the newest
3031 // `limit` threads, so any older match — the row the caller typed the query
3032 // to find — was invisible. Unsearched listings keep the cheap bounded read;
3033 // a search scans in newest-first order and stops at `limit` matches.
3034 //
3035 // Match on the thread record *before* harvesting row facts. Preview is
3036 // filled only for rows that are returned; it is not a search key.
3037 let scan_limit = if search.is_some() { None } else { Some(limit) };
3038 let threads = state
3039 .runtime_threads
3040 .list_threads(filter, scan_limit)
3041 .await
3042 .map_err(|e| ApiError::internal(e.to_string()))?;
3043
3044 let mut rows = Vec::new();
3045 for thread in threads {
3046 if rows.len() >= limit {
3047 break;
3048 }
3049 if let Some(search) = &search
3050 && !state
3051 .runtime_threads
3052 .thread_matches_summary_search(&thread, search)
3053 {
3054 continue;
3055 }
3056 rows.push(thread);
3057 }
3058
3059 // Harvest every returned row's facts in ONE pass over the store. Reading a
3060 // whole thread detail per row made this route `rows x (all_turns +
3061 // all_items)` JSON reads and parses — seconds-per-thread, so the rail timed
3062 // out and went blank on a store of a few dozen threads. Preview and turn
3063 // status now come from that same scan; attention comes from live state.
3064 let row_ids: Vec<String> = rows.iter().map(|thread| thread.id.clone()).collect();
3065
3066 // Settle queued recovery receipts before reading the rows, exactly as the
3067 // per-row detail read did. The flush cancels a recovered turn's pending
3068 // requests, and this page's attention count reads that state, so it has to
3069 // precede the scan.
3070 state
3071 .runtime_threads
3072 .flush_recovery_receipts(&row_ids)
3073 .await
3074 .map_err(|e| ApiError::internal(e.to_string()))?;
3075
3076 let facts = state
3077 .runtime_threads
3078 .thread_list_facts(&row_ids)
3079 .await
3080 .map_err(|e| ApiError::internal(e.to_string()))?;
3081
3082 // Resolve git metadata once per workspace, not once per row. Each
3083 // resolution spawns up to five blocking `git` processes — `rev-parse
3084 // --is-inside-work-tree` twice, `--abbrev-ref HEAD`, `--short HEAD` and
3085 // `status --porcelain` — and rows overwhelmingly share one workspace, so a
3086 // per-row resolve turned a 72-thread listing into roughly 360 process
3087 // spawns, every one of them blocking whichever runtime thread ran it.
3088 // One blocking task now covers every distinct workspace on the page.
3089 let workspaces = distinct_paths(rows.iter().map(|thread| thread.workspace.clone()));
3090 let git_by_workspace: Vec<(PathBuf, WorkspaceGitMetadata)> =
3091 tokio::task::spawn_blocking(move || {
3092 workspaces
3093 .into_iter()
3094 .map(|workspace| {
3095 let metadata = collect_workspace_git_metadata(&workspace);
3096 (workspace, metadata)
3097 })
3098 .collect()
3099 })
3100 .await
3101 .map_err(|e| ApiError::internal(format!("Workspace git metadata task failed: {e}")))?;
3102
3103 let mut summaries = Vec::with_capacity(rows.len());
3104 for thread in rows {
3105 let facts = facts.get(&thread.id);
3106 let latest_status = facts.and_then(|facts| facts.latest_turn_status.clone());
3107 let pending_attention_count = facts.map_or(0, |facts| facts.pending_attention_count);
3108 let latest_input_summary =
3109 facts.and_then(|facts| facts.latest_turn_input_summary.as_deref());
3110
3111 let title = thread
3112 .title
3113 .as_deref()
3114 .map(str::trim)
3115 .filter(|t| !t.is_empty())
3116 .map(|t| truncate_text(t, 72))
3117 .unwrap_or_else(|| {
3118 latest_input_summary
3119 .map(|summary| {
3120 if summary.trim().is_empty() {
3121 "New Thread".to_string()
3122 } else {
3123 truncate_text(summary, 72)
3124 }
3125 })
3126 .unwrap_or_else(|| "New Thread".to_string())
3127 });
3128
3129 let preview = facts
3130 .and_then(|facts| facts.preview.as_deref())
3131 .map(|text| truncate_text(text, 140))
3132 .unwrap_or_else(|| title.clone());
3133
3134 let workspace_git = git_by_workspace
3135 .iter()
3136 .find(|(workspace, _)| workspace == &thread.workspace)
3137 .map(|(_, metadata)| metadata);
3138 summaries.push(ThreadSummary {
3139 id: thread.id,
3140 title,
3141 preview,
3142 model: thread.model,
3143 mode: thread.mode,
3144 branch: workspace_git.and_then(|git| git.branch.clone()),
3145 head: workspace_git.and_then(|git| git.head.clone()),
3146 dirty: workspace_git.is_some_and(|git| git.dirty),
3147 workspace: thread.workspace,
3148 archived: thread.archived,
3149 updated_at: thread.updated_at,
3150 latest_turn_id: thread.latest_turn_id,
3151 latest_turn_status: latest_status,
3152 pending_attention_count,
3153 });
3154 }
3155
3156 Ok(Json(summaries))
3157 }
3158
3159 fn same_agent_worker_launch(current: &AgentWorkerRecord, expected: &AgentWorkerRecord) -> bool {
3160 current.spec.worker_id == expected.spec.worker_id
3161 && current.owner_session_id == expected.owner_session_id
3162 && current.spec.run_id == expected.spec.run_id
3163 && current.created_at_ms == expected.created_at_ms
3164 && current
3165 .spec
3166 .launch_manifest
3167 .as_ref()
3168 .map(|manifest| manifest.generation)
3169 == expected
3170 .spec
3171 .launch_manifest
3172 .as_ref()
3173 .map(|manifest| manifest.generation)
3174 }
3175
3176 fn fleet_worker_has_selected_lease(
3177 record: &AgentWorkerRecord,
3178 fleet: &crate::fleet::ledger::FleetLedgerState,
3179 ) -> bool {
3180 fleet.tasks.values().any(|task| {
3181 task.entry.run_id.0 == record.spec.run_id
3182 && task.leased_to.as_deref() == Some(record.spec.worker_id.as_str())
3183 })
3184 }
3185
3186 async fn workspace_agent_runs(state: &RuntimeApiState) -> Result<Vec<AgentWorkerRecord>, ApiError> {
3187 let manager = state.sub_agent_manager.clone().read_owned().await;
3188 let selected_state = state.clone();
3189 codewhale_app_server::daemon_socket::owner_work(move || {
3190 let projected = manager
3191 .worker_records_for_workspace(&selected_state.workspace)
3192 .map_err(anyhow::Error::msg)?;
3193 let fleet = if projected.iter().any(|(_, fleet)| *fleet) {
3194 Some(
3195 open_fleet_manager(&selected_state)
3196 .map_err(|error| anyhow::anyhow!(error.message))?
3197 .rebuild_state()?,
3198 )
3199 } else {
3200 None
3201 };
3202 Ok(projected
3203 .into_iter()
3204 .filter_map(|(record, needs_lease)| {
3205 if needs_lease
3206 && !fleet
3207 .as_ref()
3208 .is_some_and(|fleet| fleet_worker_has_selected_lease(&record, fleet))
3209 {
3210 return None;
3211 }
3212 Some(record)
3213 })
3214 .collect())
3215 })
3216 .await
3217 .map_err(|error| ApiError::conflict(format!("agent run origin could not be verified: {error}")))
3218 }
3219
3220 async fn list_agent_runs(
3221 State(state): State<RuntimeApiState>,
3222 ) -> Result<Json<AgentRunsResponse>, ApiError> {
3223 let runs = workspace_agent_runs(&state).await?;
3224 let snapshot = state
3225 .sub_agent_manager
3226 .read()
3227 .await
3228 .rate_limit_governor()
3229 .snapshot(std::time::Instant::now());
3230 Ok(Json(AgentRunsResponse {
3231 runs,
3232 governor: AgentRunsGovernor {
3233 launch_slots: snapshot.launch_capacity,
3234 max_launch_slots: snapshot.max_capacity,
3235 paused: snapshot.paused,
3236 recent_rate_limits: snapshot.window_limited,
3237 status: snapshot.status_line(),
3238 },
3239 }))
3240 }
3241
3242 async fn get_agent_run(
3243 State(state): State<RuntimeApiState>,
3244 Path(run_id): Path<String>,
3245 ) -> Result<Json<AgentWorkerRecord>, ApiError> {
3246 let runs = workspace_agent_runs(&state).await?;
3247 let run = runs
3248 .into_iter()
3249 .find(|record| agent_run_matches(record, &run_id))
3250 .ok_or_else(|| ApiError::not_found(format!("agent run '{run_id}' not found")))?;
3251 Ok(Json(run))
3252 }
3253
3254 /// A run is addressed by its run id, or by its worker id for records that
3255 /// predate run ids.
3256 fn agent_run_matches(record: &AgentWorkerRecord, run_id: &str) -> bool {
3257 let effective_run_id = if record.spec.run_id.is_empty() {
3258 record.spec.worker_id.as_str()
3259 } else {
3260 record.spec.run_id.as_str()
3261 };
3262 effective_run_id == run_id || record.spec.worker_id == run_id
3263 }
3264
3265 /// How long a stop request waits for the owning engine to record the
3266 /// terminal receipt before answering `202 Accepted` with the live record.
3267 const AGENT_RUN_CANCEL_SETTLE: Duration = Duration::from_secs(3);
3268
3269 /// `POST /v1/agent-runs/{run_id}/cancel`: stop a delegated agent run and
3270 /// answer with its receipt (addendum F2).
3271 ///
3272 /// The stop goes through the same session-scoped path as the TUI's `X` and
3273 /// the `agent/cancel` tool, so descendants stop with it and a write-scoped
3274 /// child's work is inventoried rather than dropped. The answer is:
3275 /// - `200` with the terminal record once the run is stopped (or was already
3276 /// finished — stopping is idempotent);
3277 /// - `202` with the current record when the owning engine accepted the stop
3278 /// but has not recorded the terminal receipt yet;
3279 /// - `404` for an unknown run;
3280 /// - `409` when the run belongs to a session this runtime does not host, so
3281 /// nothing here can reach it.
3282 async fn cancel_agent_run(
3283 State(state): State<RuntimeApiState>,
3284 Path(run_id): Path<String>,
3285 ) -> Result<(StatusCode, Json<AgentWorkerRecord>), ApiError> {
3286 let selected_record = workspace_agent_runs(&state)
3287 .await?
3288 .into_iter()
3289 .find(|record| agent_run_matches(record, &run_id))
3290 .ok_or_else(|| ApiError::not_found(format!("agent run '{run_id}' not found")))?;
3291 // Runs this runtime is executing itself (Fleet-launched children) stop
3292 // in place. Only a running child in this process qualifies for mutation;
3293 // a terminal receipt can be returned without mutating or consulting disk.
3294 // Other persisted runs still go through their owning session below.
3295 let owned = {
3296 let manager = state.sub_agent_manager.clone().read_owned().await;
3297 let selected_workspace = state.workspace.clone();
3298 let expected = selected_record.clone();
3299 codewhale_app_server::daemon_socket::owner_work(move || {
3300 Ok(manager
3301 .worker_records_for_workspace(&selected_workspace)
3302 .map_err(anyhow::Error::msg)?
3303 .into_iter()
3304 .map(|(record, _)| record)
3305 .find(|record| same_agent_worker_launch(record, &expected))
3306 .filter(|record| {
3307 manager
3308 .get_result(&record.spec.worker_id)
3309 .is_ok_and(|agent| {
3310 agent.status == SubAgentStatus::Running || record.status.is_terminal()
3311 })
3312 }))
3313 })
3314 .await
3315 .map_err(|error| ApiError::conflict(error.to_string()))?
3316 };
3317 if let Some(record) = owned {
3318 // Persistence is asynchronous. A repeated stop must answer from the
3319 // owning manager's terminal receipt, not race the disk projection and
3320 // incorrectly report a run we just stopped as missing or still live.
3321 if record.status.is_terminal() {
3322 return Ok((StatusCode::OK, Json(record)));
3323 }
3324 let mut manager = state.sub_agent_manager.clone().write_owned().await;
3325 let selected_workspace = state.workspace.clone();
3326 let selected_state = state.clone();
3327 let expected_record = record.clone();
3328 let cancelled = codewhale_app_server::daemon_socket::owner_work(move || {
3329 let (current, needs_lease) = manager
3330 .worker_records_for_workspace(&selected_workspace)
3331 .map_err(anyhow::Error::msg)?
3332 .into_iter()
3333 .find(|(record, _)| same_agent_worker_launch(record, &expected_record))
3334 .ok_or_else(|| anyhow::anyhow!("worker origin changed before cancellation"))?;
3335 if needs_lease {
3336 let fleet = open_fleet_manager(&selected_state)
3337 .map_err(|error| anyhow::anyhow!(error.message))?
3338 .rebuild_state()?;
3339 anyhow::ensure!(
3340 fleet_worker_has_selected_lease(&current, &fleet),
3341 "selected Fleet lease changed before cancellation"
3342 );
3343 // Revalidate the held root after the bounded ledger read, before
3344 // the actor mutation. No awaited operation follows this check.
3345 anyhow::ensure!(
3346 manager
3347 .worker_records_for_workspace(&selected_workspace)
3348 .map_err(anyhow::Error::msg)?
3349 .iter()
3350 .any(|(record, _)| same_agent_worker_launch(record, &current)),
3351 "worker origin changed during selected Fleet lease validation"
3352 );
3353 }
3354 if current.owner_session_id.is_empty() {
3355 manager.cancel_agent(&current.spec.worker_id)
3356 } else {
3357 manager.cancel_agent_for_session(&current.owner_session_id, &current.spec.worker_id)
3358 }
3359 })
3360 .await
3361 .map_err(|err| {
3362 ApiError::conflict(format!("agent run '{run_id}' could not be stopped: {err}"))
3363 })?;
3364 let cancelled =
3365 crate::tools::subagent::settle_requested_child(&state.sub_agent_manager, cancelled)
3366 .await;
3367 crate::tools::subagent::preserve_cancelled_work(&state.sub_agent_manager, cancelled).await;
3368 let manager = state.sub_agent_manager.read().await;
3369 let record = manager
3370 .list_worker_records()
3371 .into_iter()
3372 .find(|current| same_agent_worker_launch(current, &record))
3373 .ok_or_else(|| {
3374 ApiError::conflict("worker launch changed while cancellation settled")
3375 })?;
3376 let status = if record.status.is_terminal() {
3377 StatusCode::OK
3378 } else {
3379 StatusCode::ACCEPTED
3380 };
3381 return Ok((status, Json(record)));
3382 }
3383
3384 let record = selected_record;
3385
3386 // A runtime thread's session id is its thread id: its live engine owns
3387 // the child and stops it through the session-scoped cancel path. The
3388 // on-disk projection cannot tell a live child from an orphan (loading it
3389 // marks every in-flight record interrupted), so a hosted thread is always
3390 // asked, and only its own write settles the answer.
3391 let engine = if record.owner_session_id.is_empty() {
3392 None
3393 } else {
3394 state
3395 .runtime_threads
3396 .loaded_engine(&record.owner_session_id)
3397 .await
3398 };
3399 let Some(engine) = engine else {
3400 if record.status.is_terminal() {
3401 return Ok((StatusCode::OK, Json(record)));
3402 }
3403 return Err(ApiError::conflict(format!(
3404 "agent run '{run_id}' belongs to a session this runtime is not hosting; stop it from that session"
3405 )));
3406 };
3407 engine
3408 .send(crate::core::ops::Op::CancelSubAgent {
3409 agent_id: record.spec.worker_id.clone(),
3410 })
3411 .await
3412 .map_err(|err| ApiError::internal(format!("Failed to reach the run's engine: {err}")))?;
3413
3414 let settled = |current: &AgentWorkerRecord| {
3415 current.status.is_terminal()
3416 && (current.status != AgentWorkerStatus::Interrupted
3417 || current.latest_message != record.latest_message)
3418 };
3419 let deadline = tokio::time::Instant::now() + AGENT_RUN_CANCEL_SETTLE;
3420 loop {
3421 let current = workspace_agent_runs(&state)
3422 .await?
3423 .into_iter()
3424 .find(|current| same_agent_worker_launch(current, &record))
3425 .unwrap_or_else(|| record.clone());
3426 if settled(&current) {
3427 return Ok((StatusCode::OK, Json(current)));
3428 }
3429 if tokio::time::Instant::now() >= deadline {
3430 return Ok((StatusCode::ACCEPTED, Json(current)));
3431 }
3432 tokio::time::sleep(Duration::from_millis(100)).await;
3433 }
3434 }
3435
3436 async fn list_fleet_profiles(
3437 State(state): State<RuntimeApiState>,
3438 ) -> Result<Json<Value>, ApiError> {
3439 let manager = open_fleet_manager(&state)?;
3440 // Same roster path the manager uses to validate `agent_profile` ids on
3441 // run creation, so GUI pickers can never offer a profile the runtime
3442 // would reject.
3443 let roster = manager.agent_roster();
3444 let profiles = roster
3445 .members()
3446 .iter()
3447 .map(|member| {
3448 json!({
3449 "id": member.id.clone(),
3450 "display_name": member.display_name.clone(),
3451 "description": member.description.clone(),
3452 "origin": member.origin.to_string(),
3453 })
3454 })
3455 .collect::<Vec<_>>();
3456 Ok(Json(json!({
3457 "profiles": profiles,
3458 "load_error": roster.load_error().map(str::to_string),
3459 })))
3460 }
3461
3462 async fn create_fleet_run(
3463 State(state): State<RuntimeApiState>,
3464 Json(request): Json<CreateFleetRunRequest>,
3465 ) -> Result<(StatusCode, Json<Value>), ApiError> {
3466 if request.target != FleetRuntimeTarget::ThisComputer {
3467 return Err(ApiError::not_implemented(format!(
3468 "Fleet target {:?} is not available in this local Runtime; choose this_computer",
3469 request.target
3470 )));
3471 }
3472 let (document, descriptor, max_workers) = prepare_managed_fleet_run(request)?;
3473 let manager = open_fleet_manager(&state)?;
3474 let report = manager
3475 .create_queued_run_with_descriptor(document, max_workers, descriptor)
3476 .map_err(|error| ApiError::bad_request(format!("Failed to create Fleet run: {error}")))?;
3477 let ledger_state = manager
3478 .rebuild_state()
3479 .map_err(|error| ApiError::internal(format!("Failed to rebuild Fleet state: {error}")))?;
3480 let run = ledger_state
3481 .runs
3482 .get(&report.run_id.0)
3483 .ok_or_else(|| ApiError::internal("Created Fleet run was missing from its ledger"))?;
3484 Ok((
3485 StatusCode::CREATED,
3486 Json(json!({
3487 "execution": "awaiting_start",
3488 "run": fleet_run_detail_json(&manager, run, &ledger_state)?,
3489 "warnings": report.warnings,
3490 })),
3491 ))
3492 }
3493
3494 fn prepare_managed_fleet_run(
3495 request: CreateFleetRunRequest,
3496 ) -> Result<(FleetTaskSpecDocument, ManagedFleetRunDescriptor, usize), ApiError> {
3497 if request.security_policy.is_some() {
3498 return Err(ApiError::not_implemented(
3499 "Managed Fleet security_policy overrides are not executable yet; use named roles and bounded task workspace/tool scopes",
3500 ));
3501 }
3502 if !request.worker_specs.is_empty() {
3503 return Err(ApiError::not_implemented(
3504 "Managed Fleet custom worker_specs are not available yet; local Runtime worker IDs are generated per run so worker controls cannot collide across Fleets",
3505 ));
3506 }
3507 if request.roles.is_empty() {
3508 return Err(ApiError::bad_request(
3509 "roles must declare at least one named Fleet role",
3510 ));
3511 }
3512 if request.roles.len() > 128 {
3513 return Err(ApiError::bad_request(
3514 "roles cannot contain more than 128 entries",
3515 ));
3516 }
3517 let workflow_id = managed_fleet_token("workflow.id", &request.workflow.id)?;
3518 let workflow_kind = request.workflow.kind;
3519 let name = request
3520 .name
3521 .as_deref()
3522 .map(str::trim)
3523 .filter(|name| !name.is_empty())
3524 .unwrap_or(workflow_id.as_str())
3525 .to_string();
3526 if name.len() > 256 || name.chars().any(char::is_control) {
3527 return Err(ApiError::bad_request(
3528 "name must be one printable line no longer than 256 bytes",
3529 ));
3530 }
3531
3532 let mut roles = BTreeMap::new();
3533 for role in request.roles {
3534 let normalized = canonical_public_role_name(&managed_fleet_token("role.name", &role.name)?);
3535 let agent_profile = role
3536 .agent_profile
3537 .as_deref()
3538 .map(|profile| managed_fleet_token("role.agent_profile", profile))
3539 .transpose()?;
3540 if roles.insert(normalized.clone(), agent_profile).is_some() {
3541 return Err(ApiError::bad_request(format!(
3542 "duplicate Fleet role '{normalized}'"
3543 )));
3544 }
3545 }
3546
3547 let mut tasks = request.workflow.tasks;
3548 let mut used_roles = BTreeSet::new();
3549 for task in &mut tasks {
3550 let worker = task.worker.as_mut().ok_or_else(|| {
3551 ApiError::bad_request(format!(
3552 "Fleet task '{}' must select one named role through worker.role",
3553 task.id
3554 ))
3555 })?;
3556 let role = worker.role.as_deref().ok_or_else(|| {
3557 ApiError::bad_request(format!(
3558 "Fleet task '{}' must select one named role through worker.role",
3559 task.id
3560 ))
3561 })?;
3562 let role = canonical_public_role_name(&managed_fleet_token("task.worker.role", role)?);
3563 let declared_profile = roles.get(&role).ok_or_else(|| {
3564 ApiError::bad_request(format!(
3565 "Fleet task '{}' references undeclared role '{role}'",
3566 task.id
3567 ))
3568 })?;
3569 if let Some(profile) = declared_profile {
3570 match worker.agent_profile.as_deref() {
3571 Some(task_profile) if task_profile != profile => {
3572 return Err(ApiError::bad_request(format!(
3573 "Fleet task '{}' overrides role '{role}' agent_profile '{profile}' with '{task_profile}'",
3574 task.id
3575 )));
3576 }
3577 None => worker.agent_profile = Some(profile.clone()),
3578 Some(_) => {}
3579 }
3580 }
3581 worker.role = Some(role.clone());
3582 used_roles.insert(role);
3583 }
3584 let unused_roles = roles
3585 .keys()
3586 .filter(|role| !used_roles.contains(*role))
3587 .cloned()
3588 .collect::<Vec<_>>();
3589 if !unused_roles.is_empty() {
3590 return Err(ApiError::bad_request(format!(
3591 "Every declared Fleet role must own a Workflow task; unused roles: {}",
3592 unused_roles.join(", ")
3593 )));
3594 }
3595 reject_parallel_write_collisions(&tasks)?;
3596
3597 let default_workers = roles.len().min(tasks.len()).max(1);
3598 let max_workers = request.max_workers.unwrap_or(default_workers);
3599 if !(1..=128).contains(&max_workers) {
3600 return Err(ApiError::bad_request(
3601 "max_workers must be between 1 and 128",
3602 ));
3603 }
3604 let role_names = roles.into_keys().collect::<Vec<_>>();
3605 Ok((
3606 FleetTaskSpecDocument {
3607 name: Some(name),
3608 labels: request.labels,
3609 security_policy: None,
3610 workers: Vec::new(),
3611 tasks,
3612 usage_ceiling: request.usage_ceiling,
3613 },
3614 ManagedFleetRunDescriptor {
3615 target: Some(request.target),
3616 workflow: Some(FleetWorkflowDescriptor {
3617 id: workflow_id,
3618 kind: workflow_kind,
3619 }),
3620 roles: role_names,
3621 },
3622 max_workers,
3623 ))
3624 }
3625
3626 fn managed_fleet_token(field: &str, value: &str) -> Result<String, ApiError> {
3627 let value = value.trim();
3628 if value.is_empty()
3629 || value.len() > 128
3630 || !value
3631 .chars()
3632 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
3633 {
3634 return Err(ApiError::bad_request(format!(
3635 "{field} must be a simple ASCII token no longer than 128 bytes"
3636 )));
3637 }
3638 Ok(value.to_string())
3639 }
3640
3641 fn reject_parallel_write_collisions(tasks: &[FleetTaskSpec]) -> Result<(), ApiError> {
3642 let mut claims: Vec<(String, String)> = Vec::new();
3643 for task in tasks {
3644 let write_roots = fleet_write_roots(task).map_err(|error| {
3645 ApiError::bad_request(format!(
3646 "Fleet task '{}' has an invalid write scope: {error}",
3647 task.id
3648 ))
3649 })?;
3650 for normalized in write_roots {
3651 for (owner, existing) in &claims {
3652 if owner != &task.id && managed_paths_overlap(existing.as_str(), &normalized) {
3653 return Err(ApiError::bad_request(format!(
3654 "Parallel Workflow write scope collision: tasks '{owner}' and '{}' both claim overlapping paths",
3655 task.id
3656 )));
3657 }
3658 }
3659 claims.push((task.id.clone(), normalized));
3660 }
3661 }
3662 Ok(())
3663 }
3664
3665 fn managed_paths_overlap(left: &str, right: &str) -> bool {
3666 // `normalize_fleet_relative_path` collapses the workspace root to ".", so
3667 // a task claiming the whole tree presents as "." rather than as a textual
3668 // prefix of its siblings. String containment alone never matched it, and
3669 // two workers could be admitted to write the same tree in parallel.
3670 if left == "." || right == "." {
3671 return true;
3672 }
3673 left == right
3674 || left
3675 .strip_prefix(right)
3676 .is_some_and(|suffix| suffix.starts_with('/'))
3677 || right
3678 .strip_prefix(left)
3679 .is_some_and(|suffix| suffix.starts_with('/'))
3680 }
3681
3682 async fn start_fleet_run(
3683 State(state): State<RuntimeApiState>,
3684 Path(run_id): Path<String>,
3685 ) -> Result<(StatusCode, Json<Value>), ApiError> {
3686 let manager = open_fleet_manager(&state)?;
3687 let durable = manager
3688 .rebuild_state()
3689 .map_err(|error| ApiError::internal(format!("Failed to rebuild Fleet state: {error}")))?;
3690 let run = durable
3691 .runs
3692 .get(&run_id)
3693 .ok_or_else(|| ApiError::not_found(format!("Fleet run '{run_id}' not found")))?;
3694 match run.target {
3695 Some(FleetRuntimeTarget::ThisComputer) => {}
3696 Some(target) => {
3697 return Err(ApiError::not_implemented(format!(
3698 "Fleet target {target:?} is not available in this local Runtime"
3699 )));
3700 }
3701 None => {
3702 return Err(ApiError::bad_request(
3703 "Fleet run has no explicit Runtime target and cannot be started through the managed API",
3704 ));
3705 }
3706 }
3707 if run.workflow.is_none() || run.roles.is_empty() {
3708 return Err(ApiError::bad_request(
3709 "Fleet run has no managed Workflow/role descriptor and cannot be started through the managed API",
3710 ));
3711 }
3712 let run_id = FleetRunId::from(run_id);
3713 let report = manager.activate_run(&run_id).map_err(|error| {
3714 let message = format!("Failed to start Fleet run '{}': {error}", run_id.0);
3715 if message.contains("already terminal") {
3716 ApiError::conflict(message)
3717 } else {
3718 ApiError::bad_request(message)
3719 }
3720 })?;
3721 let max_workers = durable
3722 .runs
3723 .get(&run_id.0)
3724 .and_then(|run| run.max_workers)
3725 .unwrap_or_else(|| report.worker_ids.len().max(1));
3726 let workspace = state.workspace.clone();
3727 let codewhale_binary = state.fleet_codewhale_binary.clone();
3728 let sessions_dir = state.sessions_dir.clone();
3729 let execution_run_id = run_id.clone();
3730 let workspace_scope = state.workspace_scope.clone();
3731 tokio::spawn(async move {
3732 let _workspace_scope = workspace_scope;
3733 let mut executor = FleetExecutor::new(&workspace).with_sessions_dir(sessions_dir);
3734 if let Err(error) = manager
3735 .run_to_completion(
3736 &execution_run_id,
3737 max_workers,
3738 &mut executor,
3739 &codewhale_binary,
3740 None,
3741 Duration::from_millis(250),
3742 )
3743 .await
3744 {
3745 tracing::error!(
3746 run_id = %execution_run_id.0,
3747 error = %error,
3748 "Runtime API Fleet manager exited with an error"
3749 );
3750 }
3751 });
3752 Ok((
3753 StatusCode::ACCEPTED,
3754 Json(json!({
3755 "action": "start",
3756 "execution": "scheduled",
3757 "run_id": run_id.0,
3758 "target": "this_computer",
3759 "leased": report.leased,
3760 "queued": report.queued,
3761 "worker_ids": report.worker_ids,
3762 })),
3763 ))
3764 }
3765
3766 async fn replay_fleet_events(
3767 State(state): State<RuntimeApiState>,
3768 Path(run_id): Path<String>,
3769 Query(query): Query<FleetEventsQuery>,
3770 ) -> Result<Json<FleetEventReplay>, ApiError> {
3771 let (after, limit) = validate_fleet_events_query(query)?;
3772 let replay = load_fleet_event_replay(state, FleetRunId::from(run_id), after, limit)
3773 .await
3774 .map_err(map_fleet_replay_error)?;
3775 Ok(Json(replay))
3776 }
3777
3778 async fn stream_fleet_events(
3779 State(state): State<RuntimeApiState>,
3780 Path(run_id): Path<String>,
3781 Query(query): Query<FleetEventsQuery>,
3782 ) -> Result<Sse<impl futures_util::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
3783 let (after, limit) = validate_fleet_events_query(query)?;
3784 let run_id = FleetRunId::from(run_id);
3785 // Subscribe before the initial load so no append between the load and the
3786 // first wait is missed for longer than the fallback poll (#6211 R7b).
3787 let appends = subscribe_fleet_ledger_appends(&fleet_ledger_path(&state.workspace));
3788 let initial = load_fleet_event_replay(state.clone(), run_id.clone(), after.clone(), limit)
3789 .await
3790 .map_err(map_fleet_replay_error)?;
3791 let event_stream = replay_live_fleet_events(state, run_id, after, limit, initial, appends);
3792 Ok(Sse::new(event_stream).keep_alive(
3793 KeepAlive::new()
3794 .interval(Duration::from_secs(15))
3795 .text("keepalive"),
3796 ))
3797 }
3798
3799 /// Fallback re-poll when no ledger-append wake arrives. Wakes cover every
3800 /// in-process append; the fallback heals missed wakes, out-of-process
3801 /// writers, and ledger compaction, which replaces rather than appends.
3802 const FLEET_SSE_FALLBACK_POLL: Duration = Duration::from_secs(5);
3803
3804 fn replay_live_fleet_events(
3805 state: RuntimeApiState,
3806 run_id: FleetRunId,
3807 mut after: Option<String>,
3808 limit: usize,
3809 initial: FleetEventReplay,
3810 appends: std::sync::Arc<tokio::sync::Notify>,
3811 ) -> impl futures_util::Stream<Item = Result<SseEvent, Infallible>> {
3812 stream! {
3813 let mut page = initial;
3814 loop {
3815 if page.history_truncated {
3816 yield Ok(sse_json(
3817 "fleet.replay.truncated",
3818 json!({
3819 "run_id": run_id.0.clone(),
3820 "reload_projection": true,
3821 }),
3822 ));
3823 }
3824 for event in page.events {
3825 after = Some(event.cursor.clone());
3826 yield Ok(fleet_sse_event(&event));
3827 }
3828 if !page.has_more {
3829 // Register interest before yielding to the runtime so an
3830 // append racing this wait still wakes us (#6211 R7b).
3831 let notified = appends.notified();
3832 tokio::pin!(notified);
3833 tokio::select! {
3834 _ = &mut notified => {}
3835 _ = tokio::time::sleep(FLEET_SSE_FALLBACK_POLL) => {}
3836 }
3837 }
3838 match load_fleet_event_replay(
3839 state.clone(),
3840 run_id.clone(),
3841 after.clone(),
3842 limit,
3843 )
3844 .await
3845 {
3846 Ok(next) => page = next,
3847 Err(FleetEventReplayError::CursorUnavailable { .. }) => {
3848 yield Ok(sse_json(
3849 "fleet.replay.cursor_unavailable",
3850 json!({
3851 "run_id": run_id.0.clone(),
3852 "reload_projection": true,
3853 }),
3854 ));
3855 return;
3856 }
3857 Err(error) => {
3858 tracing::warn!(
3859 run_id = %run_id.0,
3860 error = %error,
3861 "Fleet event stream stopped while reading durable history"
3862 );
3863 yield Ok(sse_json(
3864 "fleet.stream.error",
3865 json!({ "retryable": true }),
3866 ));
3867 return;
3868 }
3869 }
3870 }
3871 }
3872 }
3873
3874 async fn load_fleet_event_replay(
3875 state: RuntimeApiState,
3876 run_id: FleetRunId,
3877 after: Option<String>,
3878 limit: usize,
3879 ) -> std::result::Result<FleetEventReplay, FleetEventReplayError> {
3880 tokio::task::spawn_blocking(move || {
3881 let manager =
3882 open_fleet_manager(&state).map_err(|error| FleetEventReplayError::Storage {
3883 message: error.message,
3884 })?;
3885 manager.replay_events(&run_id, after.as_deref(), limit)
3886 })
3887 .await
3888 .map_err(|error| FleetEventReplayError::Storage {
3889 message: format!("Fleet replay worker failed: {error}"),
3890 })?
3891 }
3892
3893 fn validate_fleet_events_query(
3894 query: FleetEventsQuery,
3895 ) -> Result<(Option<String>, usize), ApiError> {
3896 let after = query
3897 .after
3898 .map(|cursor| cursor.trim().to_string())
3899 .filter(|cursor| !cursor.is_empty());
3900 if after.as_deref().is_some_and(|cursor| {
3901 cursor.len() > 96
3902 || !cursor.starts_with("fev1_")
3903 || !cursor
3904 .chars()
3905 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
3906 }) {
3907 return Err(ApiError::bad_request(
3908 "after is not a valid Fleet event cursor",
3909 ));
3910 }
3911 let limit = query.limit.unwrap_or(DEFAULT_FLEET_EVENT_REPLAY_LIMIT);
3912 if !(1..=MAX_FLEET_EVENT_REPLAY_LIMIT).contains(&limit) {
3913 return Err(ApiError::bad_request(format!(
3914 "limit must be between 1 and {MAX_FLEET_EVENT_REPLAY_LIMIT}"
3915 )));
3916 }
3917 Ok((after, limit))
3918 }
3919
3920 fn map_fleet_replay_error(error: FleetEventReplayError) -> ApiError {
3921 let message = error.to_string();
3922 match error {
3923 FleetEventReplayError::UnknownRun { .. } => ApiError::not_found(message),
3924 FleetEventReplayError::CursorUnavailable { .. } => ApiError::conflict(message),
3925 FleetEventReplayError::Storage { .. } => ApiError::internal(message),
3926 }
3927 }
3928
3929 fn fleet_sse_event(event: &FleetRuntimeEvent) -> SseEvent {
3930 let data = serde_json::to_string(event).unwrap_or_else(|_| "{}".to_string());
3931 SseEvent::default()
3932 .id(event.cursor.clone())
3933 .event(event.event.clone())
3934 .data(data)
3935 }
3936
3937 async fn list_fleet_runs(State(state): State<RuntimeApiState>) -> Result<Json<Value>, ApiError> {
3938 let manager = open_fleet_manager(&state)?;
3939 let ledger_state = manager
3940 .rebuild_state()
3941 .map_err(|err| ApiError::internal(format!("Failed to rebuild Fleet state: {err}")))?;
3942 let runs: Vec<_> = ledger_state
3943 .runs
3944 .values()
3945 .map(|run| fleet_run_summary_json(&manager, run, &ledger_state))
3946 .collect::<Result<Vec<_>, _>>()?;
3947 let status = manager
3948 .status()
3949 .map_err(|err| ApiError::internal(format!("Failed to read Fleet status: {err}")))?;
3950 Ok(Json(json!({
3951 "status": fleet_status_json(&status),
3952 "runs": runs,
3953 })))
3954 }
3955
3956 async fn get_fleet_run(
3957 State(state): State<RuntimeApiState>,
3958 Path(run_id): Path<String>,
3959 ) -> Result<Json<Value>, ApiError> {
3960 let manager = open_fleet_manager(&state)?;
3961 let ledger_state = manager
3962 .rebuild_state()
3963 .map_err(|err| ApiError::internal(format!("Failed to rebuild Fleet state: {err}")))?;
3964 let run = ledger_state
3965 .runs
3966 .get(&run_id)
3967 .ok_or_else(|| ApiError::not_found(format!("Fleet run '{run_id}' not found")))?;
3968 Ok(Json(fleet_run_detail_json(&manager, run, &ledger_state)?))
3969 }
3970
3971 async fn list_fleet_run_workers(
3972 State(state): State<RuntimeApiState>,
3973 Path(run_id): Path<String>,
3974 ) -> Result<Json<Value>, ApiError> {
3975 let manager = open_fleet_manager(&state)?;
3976 let ledger_state = manager
3977 .rebuild_state()
3978 .map_err(|err| ApiError::internal(format!("Failed to rebuild Fleet state: {err}")))?;
3979 let run = ledger_state
3980 .runs
3981 .get(&run_id)
3982 .ok_or_else(|| ApiError::not_found(format!("Fleet run '{run_id}' not found")))?;
3983 let workers = run
3984 .worker_specs
3985 .iter()
3986 .map(|worker| {
3987 manager
3988 .inspect_worker(&worker.id)
3989 .map(|inspection| fleet_worker_json(&inspection))
3990 .map_err(|err| {
3991 ApiError::internal(format!(
3992 "Failed to inspect Fleet worker {}: {err}",
3993 worker.id
3994 ))
3995 })
3996 })
3997 .collect::<Result<Vec<_>, _>>()?;
3998 Ok(Json(json!({
3999 "run_id": run_id,
4000 "workers": workers,
4001 })))
4002 }
4003
4004 async fn get_fleet_worker(
4005 State(state): State<RuntimeApiState>,
4006 Path(worker_id): Path<String>,
4007 ) -> Result<Json<Value>, ApiError> {
4008 let manager = open_fleet_manager(&state)?;
4009 let inspection = manager.inspect_worker(&worker_id).map_err(|err| {
4010 ApiError::not_found(format!("Fleet worker '{worker_id}' not found: {err}"))
4011 })?;
4012 Ok(Json(fleet_worker_json(&inspection)))
4013 }
4014
4015 async fn interrupt_fleet_worker(
4016 State(state): State<RuntimeApiState>,
4017 Path(worker_id): Path<String>,
4018 ) -> Result<Json<Value>, ApiError> {
4019 let manager = open_fleet_manager(&state)?;
4020 let inspection = manager.interrupt_worker(&worker_id).map_err(|err| {
4021 ApiError::bad_request(format!(
4022 "Failed to interrupt Fleet worker '{worker_id}': {err}"
4023 ))
4024 })?;
4025 Ok(Json(json!({
4026 "action": "interrupt",
4027 "worker": fleet_worker_json(&inspection),
4028 })))
4029 }
4030
4031 async fn stop_fleet_worker(
4032 State(state): State<RuntimeApiState>,
4033 Path(worker_id): Path<String>,
4034 ) -> Result<Json<Value>, ApiError> {
4035 let manager = open_fleet_manager(&state)?;
4036 let inspection = manager.interrupt_worker(&worker_id).map_err(|err| {
4037 ApiError::bad_request(format!("Failed to stop Fleet worker '{worker_id}': {err}"))
4038 })?;
4039 Ok(Json(json!({
4040 "action": "stop",
4041 "worker": fleet_worker_json(&inspection),
4042 })))
4043 }
4044
4045 async fn restart_fleet_worker(
4046 State(state): State<RuntimeApiState>,
4047 Path(worker_id): Path<String>,
4048 ) -> Result<Json<Value>, ApiError> {
4049 let manager = open_fleet_manager(&state)?;
4050 let report = manager.restart_worker(&worker_id).map_err(|err| {
4051 ApiError::bad_request(format!(
4052 "Failed to restart Fleet worker '{worker_id}': {err}"
4053 ))
4054 })?;
4055 let worker = fleet_worker_json(&report.inspection);
4056 let run_id = report.run_id.clone();
4057 let max_workers = report.max_workers;
4058 let workspace = state.workspace.clone();
4059 let codewhale_binary = state.fleet_codewhale_binary.clone();
4060 let sessions_dir = state.sessions_dir.clone();
4061 let workspace_scope = state.workspace_scope.clone();
4062 tokio::spawn(async move {
4063 let _workspace_scope = workspace_scope;
4064 let mut executor = FleetExecutor::new(&workspace).with_sessions_dir(sessions_dir);
4065 if let Err(err) = manager
4066 .run_to_completion(
4067 &run_id,
4068 max_workers,
4069 &mut executor,
4070 &codewhale_binary,
4071 None,
4072 Duration::from_millis(250),
4073 )
4074 .await
4075 {
4076 tracing::error!(
4077 run_id = %run_id.0,
4078 error = %err,
4079 "Runtime API Fleet restart manager exited with an error"
4080 );
4081 }
4082 });
4083 Ok(Json(json!({
4084 "action": "restart",
4085 "execution": "scheduled",
4086 "run_id": report.run_id.0,
4087 "worker": worker,
4088 })))
4089 }
4090
4091 async fn stop_fleet_run(
4092 State(state): State<RuntimeApiState>,
4093 Path(run_id): Path<String>,
4094 ) -> Result<Json<Value>, ApiError> {
4095 let manager = open_fleet_manager(&state)?;
4096 let run_id = FleetRunId::from(run_id);
4097 let stopped = manager.stop_run(&run_id).map_err(|err| {
4098 ApiError::bad_request(format!("Failed to stop Fleet run '{}': {err}", run_id.0))
4099 })?;
4100 let status = manager
4101 .run_status(&run_id)
4102 .map_err(|err| ApiError::internal(format!("Failed to read Fleet run status: {err}")))?;
4103 Ok(Json(json!({
4104 "action": "stop",
4105 "run_id": run_id.0,
4106 "stopped": stopped,
4107 "status": fleet_status_json(&status),
4108 })))
4109 }
4110
4111 /// Maximum bytes read from a receipt evidence file for the inspection endpoint.
4112 const MAX_RECEIPT_EVIDENCE_READ_BYTES: u64 = 65_536;
4113
4114 async fn list_fleet_run_receipts(
4115 State(state): State<RuntimeApiState>,
4116 Path(run_id): Path<String>,
4117 ) -> Result<Json<Value>, ApiError> {
4118 let manager = open_fleet_manager(&state)?;
4119 let ledger_state = manager
4120 .rebuild_state()
4121 .map_err(|err| ApiError::internal(format!("Failed to rebuild Fleet state: {err}")))?;
4122 if !ledger_state.runs.contains_key(&run_id) {
4123 return Err(ApiError::not_found(format!(
4124 "Fleet run '{run_id}' not found"
4125 )));
4126 }
4127 let run_id_parsed = FleetRunId::from(run_id.clone());
4128 let receipts: Vec<Value> = ledger_state
4129 .receipts
4130 .values()
4131 .filter(|r| r.run_id == run_id_parsed)
4132 .map(fleet_receipt_json)
4133 .collect();
4134 Ok(Json(json!({
4135 "run_id": run_id,
4136 "receipts": receipts,
4137 })))
4138 }
4139
4140 async fn get_fleet_run_receipt(
4141 State(state): State<RuntimeApiState>,
4142 Path((run_id, task_id)): Path<(String, String)>,
4143 ) -> Result<Json<Value>, ApiError> {
4144 let manager = open_fleet_manager(&state)?;
4145 let ledger_state = manager
4146 .rebuild_state()
4147 .map_err(|err| ApiError::internal(format!("Failed to rebuild Fleet state: {err}")))?;
4148 let key = format!("{run_id}:{task_id}");
4149 let receipt = ledger_state.receipts.get(&key).ok_or_else(|| {
4150 ApiError::not_found(format!(
4151 "no receipt found for run '{run_id}' task '{task_id}'"
4152 ))
4153 })?;
4154 Ok(Json(fleet_receipt_json(receipt)))
4155 }
4156
4157 async fn inspect_fleet_run_receipt_evidence(
4158 State(state): State<RuntimeApiState>,
4159 Path((run_id, task_id)): Path<(String, String)>,
4160 ) -> Result<Json<Value>, ApiError> {
4161 let manager = open_fleet_manager(&state)?;
4162 let ledger_state = manager
4163 .rebuild_state()
4164 .map_err(|err| ApiError::internal(format!("Failed to rebuild Fleet state: {err}")))?;
4165 let key = format!("{run_id}:{task_id}");
4166 let receipt = ledger_state.receipts.get(&key).ok_or_else(|| {
4167 ApiError::not_found(format!(
4168 "no receipt found for run '{run_id}' task '{task_id}'"
4169 ))
4170 })?;
4171 // Locate the most recent Receipt-kind artifact.
4172 let receipt_artifact = receipt
4173 .artifacts
4174 .iter()
4175 .rfind(|a| a.kind == FleetArtifactKind::Receipt)
4176 .ok_or_else(|| {
4177 ApiError::not_found(format!(
4178 "no verifier evidence file for run '{run_id}' task '{task_id}'"
4179 ))
4180 })?;
4181 // Receipt artifacts are workspace-relative paths recorded by the verifier;
4182 // reject absolute paths and `..` escapes before joining onto the workspace.
4183 // An EMPTY recorded path is not a path at all: joining it would resolve
4184 // to the workspace directory itself and read it as a file.
4185 if receipt_artifact.path.as_os_str().is_empty() {
4186 return Err(ApiError::not_found(format!(
4187 "no verifier evidence file recorded for run '{run_id}' task '{task_id}'"
4188 )));
4189 }
4190 if !crate::fleet::artifacts::path_is_confined(&receipt_artifact.path) {
4191 return Err(ApiError::bad_request(format!(
4192 "evidence path for run '{run_id}' task '{task_id}' escapes the workspace"
4193 )));
4194 }
4195 let (raw, size_bytes) = crate::fleet::artifacts::read_verified(
4196 &state.workspace,
4197 receipt_artifact,
4198 MAX_RECEIPT_EVIDENCE_READ_BYTES,
4199 )
4200 .map_err(|err| {
4201 ApiError::bad_request(format!("Receipt evidence could not be verified: {err}"))
4202 })?;
4203 let truncated = size_bytes > MAX_RECEIPT_EVIDENCE_READ_BYTES;
4204 // Parse as JSON if possible; fall back to a raw string representation.
4205 let content: Value = serde_json::from_slice(&raw)
4206 .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&raw).into_owned()));
4207 Ok(Json(json!({
4208 "run_id": run_id,
4209 "task_id": task_id,
4210 "path": receipt_artifact.path,
4211 "checksum": receipt_artifact.checksum,
4212 "size_bytes": size_bytes,
4213 "truncated": truncated,
4214 "content": content,
4215 })))
4216 }
4217
4218 fn open_fleet_manager(state: &RuntimeApiState) -> Result<FleetManager, ApiError> {
4219 let (exec_config, fleet_config, session_model, route_config) = {
4220 let config = state.config.read();
4221 let exec_config = config
4222 .fleet
4223 .as_ref()
4224 .map(|fleet| fleet.exec.clone())
4225 .unwrap_or_default();
4226 // The active session route is the operator: workers without a
4227 // task/profile model pin inherit the model the user picked in /model.
4228 (
4229 exec_config,
4230 config.fleet_config(),
4231 runtime_request_model(&config, None).ok(),
4232 config.clone(),
4233 )
4234 };
4235 FleetManager::open(&state.workspace)
4236 .map(|manager| {
4237 let manager = manager
4238 .with_exec_config(exec_config)
4239 .with_fleet_config(fleet_config)
4240 .with_sub_agent_manager(state.sub_agent_manager.clone())
4241 .with_route_config(route_config);
4242 match session_model {
4243 Some(model) => manager.with_session_model(model),
4244 None => manager,
4245 }
4246 })
4247 .map_err(|err| ApiError::internal(format!("Failed to open Fleet manager: {err}")))
4248 }
4249
4250 fn fleet_run_summary_json(
4251 manager: &FleetManager,
4252 run: &FleetRun,
4253 ledger_state: &FleetLedgerState,
4254 ) -> Result<Value, ApiError> {
4255 let status = manager
4256 .run_status(&run.id)
4257 .map_err(|err| ApiError::internal(format!("Failed to read Fleet run status: {err}")))?;
4258 let task_statuses = ledger_state
4259 .tasks
4260 .values()
4261 .filter(|task| task.entry.run_id == run.id)
4262 .map(|task| {
4263 json!({
4264 "task_id": task.entry.task_id.clone(),
4265 "status": fleet_task_status_label(task.status),
4266 "leased_to": task.leased_to.clone(),
4267 "attempts": task.entry.attempts,
4268 })
4269 })
4270 .collect::<Vec<_>>();
4271 Ok(json!({
4272 "id": run.id.0.clone(),
4273 "name": run.name.clone(),
4274 "lifecycle_status": ledger_state
4275 .run_status_overrides
4276 .get(&run.id.0)
4277 .unwrap_or(&run.status),
4278 "status": fleet_status_json(&status),
4279 "target": run.target,
4280 "workflow": run.workflow.clone(),
4281 "roles": run.roles.clone(),
4282 "task_count": run.task_specs.len(),
4283 "worker_count": run.worker_specs.len(),
4284 "tasks": task_statuses,
4285 "labels": run.labels.clone(),
4286 "created_at": run.created_at.clone(),
4287 "updated_at": run.updated_at.clone(),
4288 "completed_at": run.completed_at.clone(),
4289 }))
4290 }
4291
4292 fn fleet_run_detail_json(
4293 manager: &FleetManager,
4294 run: &FleetRun,
4295 ledger_state: &FleetLedgerState,
4296 ) -> Result<Value, ApiError> {
4297 let mut value = fleet_run_summary_json(manager, run, ledger_state)?;
4298 if let Some(map) = value.as_object_mut() {
4299 map.insert("task_specs".to_string(), json!(run.task_specs.clone()));
4300 map.insert("worker_specs".to_string(), json!(run.worker_specs.clone()));
4301 }
4302 Ok(value)
4303 }
4304
4305 fn fleet_status_json(status: &FleetStatusSnapshot) -> Value {
4306 json!({
4307 "runs": status.runs,
4308 "queued": status.queued,
4309 "running": status.running,
4310 "completed": status.completed,
4311 "partial": status.partial,
4312 "failed": status.failed,
4313 "restarted": status.restarted,
4314 "escalated": status.escalated,
4315 "transport_failed": status.transport_failed,
4316 "task_failed": status.task_failed,
4317 "verifier_failed": status.verifier_failed,
4318 "cancelled": status.cancelled,
4319 "stale": status.stale,
4320 "workers": status
4321 .workers
4322 .iter()
4323 .map(|(worker_id, status)| {
4324 (
4325 worker_id.clone(),
4326 Value::String(worker_status_label(status).to_string()),
4327 )
4328 })
4329 .collect::<serde_json::Map<String, Value>>(),
4330 })
4331 }
4332
4333 fn fleet_worker_json(inspection: &FleetWorkerInspection) -> Value {
4334 json!({
4335 "worker_id": inspection.worker_id.clone(),
4336 "status": worker_status_label(&inspection.status),
4337 "run_id": inspection.current_run_id.as_ref().map(|run_id| run_id.0.clone()),
4338 "task_id": inspection.current_task_id.clone(),
4339 "objective": inspection.objective.clone(),
4340 "role": inspection.role.clone(),
4341 "host": inspection.host.clone(),
4342 "latest_heartbeat_at": inspection.latest_heartbeat_at.clone(),
4343 "latest_event": inspection.latest_event.as_ref().map(fleet_event_json),
4344 "artifacts": inspection.artifacts.iter().map(fleet_artifact_json).collect::<Vec<_>>(),
4345 "last_error": inspection.last_error.clone(),
4346 "alert_state": inspection.alert_state.clone(),
4347 "runtime_state": inspection.runtime_state.as_ref().map(fleet_worker_runtime_json),
4348 })
4349 }
4350
4351 fn fleet_worker_runtime_json(runtime: &FleetWorkerRuntimeProjection) -> Value {
4352 json!({
4353 "agent_status": runtime.agent_status.clone(),
4354 "steps_taken": runtime.steps_taken,
4355 "latest_message": runtime.latest_message.clone(),
4356 "error": runtime.error.clone(),
4357 "result_summary": runtime.result_summary.clone(),
4358 "has_session": runtime.has_session,
4359 })
4360 }
4361
4362 fn fleet_artifact_json(artifact: &codewhale_protocol::fleet::FleetArtifactRef) -> Value {
4363 json!({
4364 "kind": artifact_kind_label(&artifact.kind),
4365 "path": artifact.path.clone(),
4366 "checksum": artifact.checksum.clone(),
4367 "mime_type": artifact.mime_type.clone(),
4368 "size_bytes": artifact.size_bytes,
4369 })
4370 }
4371
4372 fn fleet_receipt_json(receipt: &codewhale_protocol::fleet::FleetReceipt) -> Value {
4373 use codewhale_protocol::fleet::{FleetTaskFailureKind, FleetTaskResult};
4374
4375 let result_label = match receipt.result {
4376 FleetTaskResult::Pass => "pass",
4377 FleetTaskResult::Partial => "partial",
4378 FleetTaskResult::Fail => "fail",
4379 FleetTaskResult::Skip => "skip",
4380 FleetTaskResult::Timeout => "timeout",
4381 };
4382 let (failure_kind_label, failure_class, retry_eligible) = match receipt.failure_kind.as_ref() {
4383 Some(FleetTaskFailureKind::Transport) => (
4384 Some("transport"),
4385 Some("Infrastructure or network failure during task transport"),
4386 true,
4387 ),
4388 Some(FleetTaskFailureKind::Task) => (
4389 Some("task"),
4390 Some("Task logic exited unsuccessfully"),
4391 false,
4392 ),
4393 Some(FleetTaskFailureKind::Verifier) => (
4394 Some("verifier"),
4395 Some("Verifier rejected the task output; manual review or code change required"),
4396 false,
4397 ),
4398 None => (None, None, false),
4399 };
4400 let evidence_available = receipt
4401 .artifacts
4402 .iter()
4403 .any(|a| a.kind == FleetArtifactKind::Receipt);
4404 let score_json = receipt.score.as_ref().map(|s| {
4405 json!({
4406 "value": s.value,
4407 "max": s.max,
4408 "notes": s.notes,
4409 })
4410 });
4411 json!({
4412 "run_id": receipt.run_id.0.clone(),
4413 "task_id": receipt.task_id.clone(),
4414 "worker_id": receipt.worker_id.clone(),
4415 "attempt": receipt.attempt,
4416 "terminal_seq": receipt.terminal_seq,
4417 "completed_at": receipt.completed_at.clone(),
4418 "result": result_label,
4419 "failure_kind": failure_kind_label,
4420 "failure_class": failure_class,
4421 "retry_eligible": retry_eligible,
4422 "score": score_json,
4423 "artifacts": receipt.artifacts.iter().map(fleet_artifact_json).collect::<Vec<_>>(),
4424 "saved_session_id": receipt.saved_session_id.clone(),
4425 "evidence_available": evidence_available,
4426 })
4427 }
4428
4429 fn fleet_event_json(event: &codewhale_protocol::fleet::FleetWorkerEvent) -> Value {
4430 json!({
4431 "seq": event.seq,
4432 "run_id": event.run_id.0.clone(),
4433 "worker_id": event.worker_id.clone(),
4434 "task_id": event.task_id.clone(),
4435 "timestamp": event.timestamp.clone(),
4436 "label": fleet_event_label(&event.payload),
4437 "payload": event.payload.clone(),
4438 })
4439 }
4440
4441 fn worker_status_label(status: &FleetWorkerStatus) -> &'static str {
4442 match status {
4443 FleetWorkerStatus::Unknown => "unknown",
4444 FleetWorkerStatus::Online => "online",
4445 FleetWorkerStatus::Busy => "busy",
4446 FleetWorkerStatus::Offline => "offline",
4447 FleetWorkerStatus::Unhealthy => "unhealthy",
4448 FleetWorkerStatus::Draining => "draining",
4449 FleetWorkerStatus::Retired => "retired",
4450 }
4451 }
4452
4453 fn fleet_task_status_label(status: FleetTaskLedgerStatus) -> &'static str {
4454 match status {
4455 FleetTaskLedgerStatus::Enqueued => "enqueued",
4456 FleetTaskLedgerStatus::Leased => "leased",
4457 FleetTaskLedgerStatus::Completed => "completed",
4458 FleetTaskLedgerStatus::Failed => "failed",
4459 FleetTaskLedgerStatus::Cancelled => "cancelled",
4460 }
4461 }
4462
4463 fn artifact_kind_label(kind: &FleetArtifactKind) -> String {
4464 match kind {
4465 FleetArtifactKind::Log => "log".to_string(),
4466 FleetArtifactKind::Patch => "patch".to_string(),
4467 FleetArtifactKind::TestResult => "test_result".to_string(),
4468 FleetArtifactKind::Report => "report".to_string(),
4469 FleetArtifactKind::Checkpoint => "checkpoint".to_string(),
4470 FleetArtifactKind::Receipt => "receipt".to_string(),
4471 FleetArtifactKind::Other(value) => value.clone(),
4472 }
4473 }
4474
4475 /// Bound on the `Completed.summary` excerpt inside a lifecycle event label.
4476 const FLEET_EVENT_LABEL_SUMMARY_CHARS: usize = 160;
4477
4478 fn fleet_event_label(payload: &FleetWorkerEventPayload) -> String {
4479 match payload {
4480 FleetWorkerEventPayload::Queued => "queued".to_string(),
4481 FleetWorkerEventPayload::Leased { .. } => "leased".to_string(),
4482 FleetWorkerEventPayload::Starting => "starting".to_string(),
4483 FleetWorkerEventPayload::Running => "running".to_string(),
4484 FleetWorkerEventPayload::ModelWait { model } => model
4485 .as_ref()
4486 .map(|model| format!("model_wait model={model}"))
4487 .unwrap_or_else(|| "model_wait".to_string()),
4488 FleetWorkerEventPayload::RunningTool { tool, call_id } => call_id
4489 .as_ref()
4490 .map(|call_id| format!("running_tool tool={tool} call_id={call_id}"))
4491 .unwrap_or_else(|| format!("running_tool tool={tool}")),
4492 FleetWorkerEventPayload::WorkflowEvent {
4493 workflow_run_id,
4494 event,
4495 } => event
4496 .get("type")
4497 .and_then(serde_json::Value::as_str)
4498 .map(|kind| format!("workflow_event run_id={workflow_run_id} type={kind}"))
4499 .unwrap_or_else(|| format!("workflow_event run_id={workflow_run_id}")),
4500 FleetWorkerEventPayload::Heartbeat { .. } => "heartbeat".to_string(),
4501 FleetWorkerEventPayload::UsageReport {
4502 input_tokens,
4503 output_tokens,
4504 } => format!("usage_report input={input_tokens} output={output_tokens}"),
4505 FleetWorkerEventPayload::Artifact(artifact) => {
4506 format!("artifact kind={}", artifact_kind_label(&artifact.kind))
4507 }
4508 // `summary` may carry the worker's bounded final-answer excerpt (up
4509 // to a few thousand chars); the label is a one-line status surface,
4510 // so it gets a short excerpt while `payload` keeps the full text.
4511 FleetWorkerEventPayload::Completed { exit_code, summary } => match (
4512 exit_code,
4513 summary
4514 .as_deref()
4515 .map(|summary| truncate_text(summary, FLEET_EVENT_LABEL_SUMMARY_CHARS)),
4516 ) {
4517 (Some(code), Some(summary)) => format!("completed exit_code={code} {summary}"),
4518 (Some(code), None) => format!("completed exit_code={code}"),
4519 (None, Some(summary)) => format!("completed {summary}"),
4520 (None, None) => "completed".to_string(),
4521 },
4522 FleetWorkerEventPayload::Failed {
4523 reason,
4524 recoverable,
4525 } => {
4526 format!("failed recoverable={recoverable} reason={reason}")
4527 }
4528 FleetWorkerEventPayload::Cancelled { cancelled_by } => cancelled_by
4529 .as_ref()
4530 .map(|by| format!("cancelled by={by}"))
4531 .unwrap_or_else(|| "cancelled".to_string()),
4532 FleetWorkerEventPayload::Interrupted { signal } => signal
4533 .as_ref()
4534 .map(|signal| format!("interrupted signal={signal}"))
4535 .unwrap_or_else(|| "interrupted".to_string()),
4536 FleetWorkerEventPayload::Stale { last_heartbeat_at } => last_heartbeat_at
4537 .as_ref()
4538 .map(|ts| format!("stale last_heartbeat_at={ts}"))
4539 .unwrap_or_else(|| "stale".to_string()),
4540 FleetWorkerEventPayload::Restarted { restart_count } => {
4541 format!("restarted count={restart_count}")
4542 }
4543 FleetWorkerEventPayload::Escalated { channel, alert_id } => alert_id
4544 .as_ref()
4545 .map(|alert_id| format!("escalated channel={channel} alert_id={alert_id}"))
4546 .unwrap_or_else(|| format!("escalated channel={channel}")),
4547 }
4548 }
4549
4550 /// One entry in the served slash-command catalog (`GET /v1/commands`, #6178).
4551 ///
4552 /// Clients use this to complete and validate input without duplicating the
4553 /// registry: a `binding: "host"` row must never be submitted as a model
4554 /// prompt, and a user command shadowing a builtin name wins that spelling.
4555 #[derive(Debug, Serialize)]
4556 struct CommandCatalogEntry {
4557 name: String,
4558 aliases: Vec<String>,
4559 /// English source text; localizing is the client's surface.
4560 #[serde(skip_serializing_if = "Option::is_none")]
4561 summary: Option<String>,
4562 #[serde(skip_serializing_if = "Option::is_none")]
4563 usage: Option<String>,
4564 /// Literal verbs declared by the usage line (`/goal <block|complete|…>`).
4565 subcommands: Vec<String>,
4566 takes_arguments: bool,
4567 /// Composer argument shape, computed the way the TUI composer computes
4568 /// it so clients do not re-derive it from the usage string.
4569 /// Usage mentions any argument, required or optional.
4570 requires_argument: bool,
4571 /// Usage has a `<required>` argument outside every `[optional]` group.
4572 requires_required_argument: bool,
4573 /// Accepting the command leaves a trailing space for its arguments.
4574 composer_wants_trailing_space: bool,
4575 /// The palette runs the command on selection instead of pasting it.
4576 palette_runs_directly: bool,
4577 /// Listed when the slash menu opens with no filter text.
4578 show_in_empty_discovery: bool,
4579 /// `builtin` is registered code; `user` expands a stored template.
4580 kind: &'static str,
4581 /// `host` runs locally and never reaches the model; `prompt` expands into
4582 /// the request the model sees.
4583 binding: &'static str,
4584 /// `primary` | `advanced` | `compatibility` — builtins only; `hidden`
4585 /// covers rows the product does not advertise anywhere.
4586 #[serde(skip_serializing_if = "Option::is_none")]
4587 discovery: Option<&'static str>,
4588 hidden: bool,
4589 /// User command holding this builtin's canonical name.
4590 #[serde(skip_serializing_if = "Option::is_none")]
4591 shadowed_by: Option<String>,
4592 /// Alias spellings of this builtin taken by user commands.
4593 #[serde(skip_serializing_if = "Vec::is_empty")]
4594 shadowed_aliases: Vec<String>,
4595 }
4596
4597 #[derive(Debug, Serialize)]
4598 struct CommandsResponse {
4599 commands: Vec<CommandCatalogEntry>,
4600 }
4601
4602 fn command_catalog(
4603 user_commands: &crate::commands::user_registry::UserCommandRegistry,
4604 ) -> Vec<CommandCatalogEntry> {
4605 let mut commands = Vec::new();
4606 for info in crate::commands::command_infos() {
4607 let shadowed_by = user_commands
4608 .get(info.name)
4609 .map(|command| command.name.clone());
4610 let shadowed_aliases = info
4611 .aliases
4612 .iter()
4613 .filter(|alias| user_commands.get(alias).is_some())
4614 .map(|alias| (*alias).to_string())
4615 .collect();
4616 commands.push(CommandCatalogEntry {
4617 name: info.name.to_string(),
4618 aliases: info
4619 .aliases
4620 .iter()
4621 .map(|alias| (*alias).to_string())
4622 .collect(),
4623 summary: Some(
4624 info.description_for(codewhale_localization::Locale::En)
4625 .into_owned(),
4626 ),
4627 usage: Some(info.usage.to_string()),
4628 subcommands: crate::commands::traits::usage_subcommands(info.usage)
4629 .iter()
4630 .map(|token| (*token).to_string())
4631 .collect(),
4632 takes_arguments: crate::commands::user_registry::usage_describes_arguments(
4633 info.name, info.usage,
4634 ),
4635 requires_argument: info.requires_argument(),
4636 requires_required_argument: info.requires_required_argument(),
4637 composer_wants_trailing_space: info.composer_wants_trailing_space(),
4638 palette_runs_directly: info.palette_runs_directly(),
4639 show_in_empty_discovery: info.show_in_empty_discovery(),
4640 kind: "builtin",
4641 binding: "host",
4642 discovery: Some(match info.discovery() {
4643 crate::commands::traits::CommandDiscovery::Primary => "primary",
4644 crate::commands::traits::CommandDiscovery::Advanced => "advanced",
4645 crate::commands::traits::CommandDiscovery::Compatibility => "compatibility",
4646 }),
4647 hidden: crate::commands::traits::UNLISTED_COMMANDS.contains(&info.name),
4648 shadowed_by,
4649 shadowed_aliases,
4650 });
4651 }
4652 // Extension commands run in the extension host from the TUI's event loop;
4653 // a Runtime API client cannot run them, so they are not advertised here.
4654 for command in user_commands
4655 .iter()
4656 .filter(|command| command.extension.is_none())
4657 {
4658 let takes_arguments = command.takes_arguments();
4659 commands.push(CommandCatalogEntry {
4660 name: command.name.clone(),
4661 aliases: command.aliases.clone(),
4662 summary: command.description.clone(),
4663 usage: command.display_usage().map(str::to_string),
4664 subcommands: Vec::new(),
4665 takes_arguments,
4666 // A template may run bare, so its arguments are never required.
4667 requires_argument: takes_arguments,
4668 requires_required_argument: false,
4669 composer_wants_trailing_space: takes_arguments,
4670 palette_runs_directly: !takes_arguments,
4671 show_in_empty_discovery: !command.hidden,
4672 kind: "user",
4673 binding: "prompt",
4674 discovery: None,
4675 hidden: command.hidden,
4676 shadowed_by: None,
4677 shadowed_aliases: Vec::new(),
4678 });
4679 }
4680 commands
4681 }
4682
4683 async fn list_commands(
4684 State(state): State<RuntimeApiState>,
4685 ) -> Result<Json<CommandsResponse>, ApiError> {
4686 let commands = crate::commands::user_registry::with_registry_for_workspace(
4687 Some(state.workspace.as_path()),
4688 command_catalog,
4689 );
4690 Ok(Json(CommandsResponse { commands }))
4691 }
4692
4693 #[derive(Debug, Deserialize)]
4694 struct HooksQuery {
4695 /// Report the hooks a thread's engine runs (its workspace); defaults to
4696 /// the server workspace.
4697 thread_id: Option<String>,
4698 }
4699
4700 #[derive(Debug, Serialize)]
4701 struct HooksResponse {
4702 workspace: String,
4703 enabled: bool,
4704 hooks: Vec<HookEntry>,
4705 /// Hooks rejected or warned about at load, one redaction-safe line each.
4706 problems: Vec<String>,
4707 }
4708
4709 #[derive(Debug, Serialize)]
4710 struct HookEntry {
4711 name: Option<String>,
4712 event: &'static str,
4713 /// The shell command, with credential-shaped values masked.
4714 command: String,
4715 background: bool,
4716 timeout_secs: u64,
4717 /// `global` (user config), `plugin` (reviewed plugin) or `project`
4718 /// (trusted, approved `.codewhale/hooks.toml`).
4719 source: &'static str,
4720 }
4721
4722 /// Mask a hook command for `GET /v1/hooks`. Keyed and credential-shaped
4723 /// values go through the shared redactor; every URL additionally keeps only
4724 /// its scheme and host, because webhook secrets live in the path
4725 /// (`https://hooks.slack.com/services/T…/B…/<secret>`) where no key names
4726 /// them.
4727 fn redact_hook_command_for_listing(command: &str) -> String {
4728 let masked = codewhale_config::persistence::redact_secrets(command);
4729 masked
4730 .split(' ')
4731 .map(|word| match word.find("://") {
4732 Some(scheme_end) => {
4733 let rest = &word[scheme_end + 3..];
4734 let host_end = rest.find(['/', '?', '#', '"', '\'']).unwrap_or(rest.len());
4735 let host = &rest[..host_end];
4736 // Userinfo (`user:pass@host`) is a credential too.
4737 let host = host.rsplit_once('@').map_or(host, |(_, host)| host);
4738 let tail = &rest[host_end..];
4739 let quote = tail
4740 .chars()
4741 .last()
4742 .filter(|c| matches!(c, '"' | '\''))
4743 .map(String::from)
4744 .unwrap_or_default();
4745 let path = if tail.len() > quote.len() {
4746 "/[redacted]"
4747 } else {
4748 ""
4749 };
4750 format!("{}{host}{path}{quote}", &word[..scheme_end + 3])
4751 }
4752 None => word.to_string(),
4753 })
4754 .collect::<Vec<_>>()
4755 .join(" ")
4756 }
4757
4758 /// `GET /v1/hooks` (B4): the hook set Runtime API threads run, from the same
4759 /// loader their engines use, so clients show one truth instead of keeping a
4760 /// hook table of their own.
4761 async fn list_hooks(
4762 State(state): State<RuntimeApiState>,
4763 Query(query): Query<HooksQuery>,
4764 ) -> Result<Json<HooksResponse>, ApiError> {
4765 let workspace = match query.thread_id.as_deref() {
4766 Some(id) => {
4767 state
4768 .runtime_threads
4769 .get_thread(id)
4770 .await
4771 .map_err(map_thread_err)?
4772 .workspace
4773 }
4774 None => state.workspace.clone(),
4775 };
4776 let config = state.config.read().clone();
4777 let plugins = state.plugin_discovery.registry_for_workspace(&workspace);
4778 let executor = state.runtime_threads.hook_executor_for_workspace(
4779 &config,
4780 &workspace,
4781 Some(plugins.as_ref()),
4782 );
4783 let hooks_config = executor.config();
4784 let hooks = hooks_config
4785 .hooks
4786 .iter()
4787 .map(|hook| HookEntry {
4788 name: hook.name.clone(),
4789 event: hook.event.as_str(),
4790 command: redact_hook_command_for_listing(&hook.command),
4791 background: hook.background,
4792 timeout_secs: hook.timeout_secs,
4793 source: if hook.project_authority.is_some() {
4794 "project"
4795 } else if hook.plugin_authority.is_some() {
4796 "plugin"
4797 } else {
4798 "global"
4799 },
4800 })
4801 .collect();
4802 Ok(Json(HooksResponse {
4803 workspace: workspace.display().to_string(),
4804 enabled: hooks_config.enabled,
4805 hooks,
4806 problems: hooks_config
4807 .problems
4808 .iter()
4809 .map(crate::hooks::HookConfigProblem::summary)
4810 .collect(),
4811 }))
4812 }
4813
4814 async fn list_skills(
4815 State(state): State<RuntimeApiState>,
4816 ) -> Result<Json<SkillsResponse>, ApiError> {
4817 let (skills_dir, mode) = {
4818 let config = state.config.read();
4819 let skills_dir = resolve_skills_dir(&config, &state.workspace);
4820 let mode = crate::skills::SkillDiscoveryMode::from_config(&config.skills_config());
4821 (skills_dir, mode)
4822 };
4823 let plugin_registry = state
4824 .plugin_discovery
4825 .registry_for_workspace(&state.workspace);
4826 let (registry, directories) = discover_skills_for_runtime_api(
4827 &state.workspace,
4828 &skills_dir,
4829 mode,
4830 Some(plugin_registry.as_ref()),
4831 );
4832 let mut skill_state = state.skill_state.lock().await;
4833 skill_state
4834 .refresh()
4835 .map_err(|error| ApiError::internal(format!("refresh skill state: {error}")))?;
4836 let skills = registry
4837 .list()
4838 .iter()
4839 .map(|skill| {
4840 let (path, source, plugin_id, plugin_generation, plugin_content_hash) =
4841 match &skill.source {
4842 crate::skills::SkillSource::Native => (
4843 Some(skill.path.clone()),
4844 "native".to_string(),
4845 None,
4846 None,
4847 None,
4848 ),
4849 crate::skills::SkillSource::Plugin {
4850 plugin_id,
4851 plugin_name,
4852 authority,
4853 ..
4854 } => (
4855 None,
4856 format!("reviewed-plugin-snapshot:{plugin_name}"),
4857 Some(plugin_id.clone()),
4858 Some(authority.state_generation),
4859 Some(authority.content_hash.clone()),
4860 ),
4861 };
4862 SkillEntry {
4863 name: skill.name.clone(),
4864 description: skill.description.clone(),
4865 path,
4866 source,
4867 plugin_id,
4868 plugin_generation,
4869 plugin_content_hash,
4870 enabled: skill_state
4871 .is_enabled_with_legacy(&skill.name, skill.legacy_activation_name.as_deref()),
4872 is_bundled: skill_entry_is_bundled(skill, &skills_dir),
4873 }
4874 })
4875 .collect();
4876 Ok(Json(SkillsResponse {
4877 directory: skills_dir,
4878 directories,
4879 warnings: registry.warnings().to_vec(),
4880 skills,
4881 }))
4882 }
4883
4884 async fn set_skill_enabled(
4885 State(state): State<RuntimeApiState>,
4886 Path(name): Path<String>,
4887 Json(req): Json<SetSkillEnabledRequest>,
4888 ) -> Result<Json<SetSkillEnabledResponse>, ApiError> {
4889 let (skills_dir, mode) = {
4890 let config = state.config.read();
4891 let skills_dir = resolve_skills_dir(&config, &state.workspace);
4892 let mode = crate::skills::SkillDiscoveryMode::from_config(&config.skills_config());
4893 (skills_dir, mode)
4894 };
4895 let plugin_registry = state
4896 .plugin_discovery
4897 .registry_for_workspace(&state.workspace);
4898 let (registry, directories) = discover_skills_for_runtime_api(
4899 &state.workspace,
4900 &skills_dir,
4901 mode,
4902 Some(plugin_registry.as_ref()),
4903 );
4904 let exists = registry.list().iter().any(|skill| skill.name == name);
4905 if !exists {
4906 return Err(ApiError::not_found(format!(
4907 "skill '{name}' not found in searched directories: {}",
4908 format_skill_search_paths(&directories)
4909 )));
4910 }
4911
4912 let mut store = state.skill_state.lock().await;
4913 store
4914 .set_enabled(&name, req.enabled)
4915 .map_err(|err| ApiError::internal(format!("persist skill state: {err}")))?;
4916 Ok(Json(SetSkillEnabledResponse {
4917 name,
4918 enabled: req.enabled,
4919 }))
4920 }
4921
4922 // ─── Skill lifecycle helpers ────────────────────────────────────────────────
4923
4924 /// Build a [`crate::skills::mutation::MutationContext`] from the current
4925 /// server state. Reads the network policy and installer settings directly
4926 /// from the config already held in `state`.
4927 fn mutation_context_settings(
4928 state: &RuntimeApiState,
4929 ) -> (
4930 crate::network_policy::NetworkPolicy,
4931 u64,
4932 String,
4933 Option<PathBuf>,
4934 ) {
4935 use crate::skills::install::{DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL};
4936 let config = state.config.read();
4937 let network = config
4938 .network
4939 .clone()
4940 .map(|p| p.into_runtime())
4941 .unwrap_or_default();
4942 let skills_cfg = config.skills.as_ref();
4943 let max_size = skills_cfg
4944 .and_then(|s| s.max_install_size_bytes)
4945 .unwrap_or(DEFAULT_MAX_SIZE_BYTES);
4946 let registry_url = skills_cfg
4947 .and_then(|s| s.registry_url.clone())
4948 .unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string());
4949 let configured_skills_dir = config.skills_dir.as_ref().map(PathBuf::from);
4950 (network, max_size, registry_url, configured_skills_dir)
4951 }
4952
4953 fn parse_api_scope(
4954 scope: Option<&str>,
4955 ) -> Result<Option<crate::skills::mutation::SkillTargetScope>, ApiError> {
4956 match scope {
4957 None => Ok(None),
4958 Some("project") => Ok(Some(crate::skills::mutation::SkillTargetScope::Project)),
4959 Some("global") => Ok(Some(crate::skills::mutation::SkillTargetScope::Global)),
4960 Some(other) => Err(ApiError::bad_request(format!(
4961 "invalid scope '{other}'; expected \"project\" or \"global\""
4962 ))),
4963 }
4964 }
4965
4966 fn receipt_to_response(
4967 receipt: &crate::skills::mutation::SkillMutationReceipt,
4968 ) -> SkillMutationReceiptResponse {
4969 use crate::skills::mutation::SkillMutationOutcome;
4970 use crate::skills::roots::SkillScope;
4971
4972 const TRUST_NOTE: &str = "The .trusted marker is advisory and digest-bound; \
4973 it records your review intent but does not sandbox or auto-authorize scripts.";
4974
4975 let outcome: &'static str = match &receipt.outcome {
4976 SkillMutationOutcome::Installed => "installed",
4977 SkillMutationOutcome::Updated => "updated",
4978 SkillMutationOutcome::NoChange => "no_change",
4979 SkillMutationOutcome::Removed => "removed",
4980 SkillMutationOutcome::Trusted => "trusted",
4981 SkillMutationOutcome::Imported => "imported",
4982 SkillMutationOutcome::AlreadyPresent => "already_present",
4983 // NeedsApproval / NetworkDenied are returned as ApiError::forbidden
4984 // before reaching this conversion; they should not appear here.
4985 SkillMutationOutcome::NeedsApproval(_) => "needs_approval",
4986 SkillMutationOutcome::NetworkDenied(_) => "network_denied",
4987 };
4988 let scope = match receipt.scope {
4989 SkillScope::Project => "project".to_string(),
4990 SkillScope::Global => "global".to_string(),
4991 SkillScope::Logical => "logical".to_string(),
4992 };
4993 let trust_note = if receipt.outcome == SkillMutationOutcome::Trusted {
4994 Some(TRUST_NOTE)
4995 } else {
4996 None
4997 };
4998 SkillMutationReceiptResponse {
4999 name: receipt.name.clone(),
5000 outcome,
5001 scope,
5002 safe_target_path: receipt.safe_target_path.clone(),
5003 trust_note,
5004 }
5005 }
5006
5007 fn outcome_is_policy_error(outcome: &crate::skills::mutation::SkillMutationOutcome) -> bool {
5008 matches!(
5009 outcome,
5010 crate::skills::mutation::SkillMutationOutcome::NeedsApproval(_)
5011 | crate::skills::mutation::SkillMutationOutcome::NetworkDenied(_)
5012 )
5013 }
5014
5015 fn policy_error_message(outcome: &crate::skills::mutation::SkillMutationOutcome) -> String {
5016 match outcome {
5017 crate::skills::mutation::SkillMutationOutcome::NeedsApproval(host) => format!(
5018 "network access to '{host}' requires explicit approval; \
5019 approve the host in your network policy before installing this skill"
5020 ),
5021 crate::skills::mutation::SkillMutationOutcome::NetworkDenied(host) => {
5022 format!("network access to '{host}' was denied by the active network policy")
5023 }
5024 _ => "operation denied by policy".to_string(),
5025 }
5026 }
5027
5028 // ─── POST /v1/skills/install ────────────────────────────────────────────────
5029
5030 async fn install_skill_api(
5031 State(state): State<RuntimeApiState>,
5032 Json(req): Json<InstallSkillRequest>,
5033 ) -> Result<(StatusCode, Json<SkillMutationReceiptResponse>), ApiError> {
5034 use crate::skills::install::InstallSource;
5035 use crate::skills::mutation::{MutationContext, SkillMutationRequest, SkillTargetScope};
5036
5037 let source = InstallSource::parse(&req.source)
5038 .map_err(|err| ApiError::bad_request(format!("invalid install source: {err}")))?;
5039 let target = parse_api_scope(req.scope.as_deref())?.unwrap_or(SkillTargetScope::Global);
5040
5041 let (network, max_size, registry_url, configured_skills_dir) =
5042 mutation_context_settings(&state);
5043 let home = crate::config::effective_home_dir();
5044 let workspace = state.workspace.clone();
5045
5046 let receipt = crate::skills::mutation::execute(
5047 SkillMutationRequest::InstallRemote { source, target },
5048 &MutationContext {
5049 workspace: &workspace,
5050 home: home.as_deref(),
5051 configured_skills_dir: configured_skills_dir.as_deref(),
5052 network: &network,
5053 max_size,
5054 registry_url: &registry_url,
5055 },
5056 )
5057 .await
5058 .map_err(|err| ApiError::bad_request(format!("install failed: {err:#}")))?;
5059
5060 if outcome_is_policy_error(&receipt.outcome) {
5061 return Err(ApiError::forbidden(policy_error_message(&receipt.outcome)));
5062 }
5063
5064 let status = if receipt.outcome == crate::skills::mutation::SkillMutationOutcome::Installed {
5065 StatusCode::CREATED
5066 } else {
5067 StatusCode::OK
5068 };
5069 Ok((status, Json(receipt_to_response(&receipt))))
5070 }
5071
5072 // ─── POST /v1/skills/{name}/update ─────────────────────────────────────────
5073
5074 async fn update_skill_api(
5075 State(state): State<RuntimeApiState>,
5076 Path(name): Path<String>,
5077 Json(req): Json<UpdateSkillRequest>,
5078 ) -> Result<Json<SkillMutationReceiptResponse>, ApiError> {
5079 use crate::skills::mutation::{MutationContext, SkillMutationRequest};
5080
5081 let scope = parse_api_scope(req.scope.as_deref())?;
5082 let (network, max_size, registry_url, configured_skills_dir) =
5083 mutation_context_settings(&state);
5084 let home = crate::config::effective_home_dir();
5085 let workspace = state.workspace.clone();
5086
5087 let receipt = crate::skills::mutation::execute(
5088 SkillMutationRequest::UpdateByName {
5089 name: name.clone(),
5090 scope,
5091 expected_digest: req.expected_digest,
5092 },
5093 &MutationContext {
5094 workspace: &workspace,
5095 home: home.as_deref(),
5096 configured_skills_dir: configured_skills_dir.as_deref(),
5097 network: &network,
5098 max_size,
5099 registry_url: &registry_url,
5100 },
5101 )
5102 .await
5103 .map_err(|err| {
5104 let msg = err.to_string();
5105 if msg.contains("not found") {
5106 ApiError::not_found(format!("update failed: {err:#}"))
5107 } else {
5108 ApiError::bad_request(format!("update failed: {err:#}"))
5109 }
5110 })?;
5111
5112 if outcome_is_policy_error(&receipt.outcome) {
5113 return Err(ApiError::forbidden(policy_error_message(&receipt.outcome)));
5114 }
5115
5116 Ok(Json(receipt_to_response(&receipt)))
5117 }
5118
5119 // ─── DELETE /v1/skills/{name} (uninstall) ──────────────────────────────────
5120
5121 async fn uninstall_skill_api(
5122 State(state): State<RuntimeApiState>,
5123 Path(name): Path<String>,
5124 Query(query): Query<UninstallSkillQuery>,
5125 ) -> Result<Json<SkillMutationReceiptResponse>, ApiError> {
5126 use crate::skills::mutation::{MutationContext, SkillMutationRequest};
5127
5128 let scope = parse_api_scope(query.scope.as_deref())?;
5129 let (network, max_size, registry_url, configured_skills_dir) =
5130 mutation_context_settings(&state);
5131 let home = crate::config::effective_home_dir();
5132
5133 let receipt = crate::skills::mutation::execute_sync(
5134 SkillMutationRequest::RemoveByName {
5135 name: name.clone(),
5136 scope,
5137 expected_digest: query.expected_digest,
5138 },
5139 &MutationContext {
5140 workspace: &state.workspace,
5141 home: home.as_deref(),
5142 configured_skills_dir: configured_skills_dir.as_deref(),
5143 network: &network,
5144 max_size,
5145 registry_url: &registry_url,
5146 },
5147 )
5148 .map_err(|err| {
5149 let msg = err.to_string();
5150 if msg.contains("not found") {
5151 ApiError::not_found(format!("uninstall failed: {err:#}"))
5152 } else {
5153 ApiError::bad_request(format!("uninstall failed: {err:#}"))
5154 }
5155 })?;
5156
5157 Ok(Json(receipt_to_response(&receipt)))
5158 }
5159
5160 // ─── POST /v1/skills/{name}/trust ──────────────────────────────────────────
5161
5162 async fn trust_skill_api(
5163 State(state): State<RuntimeApiState>,
5164 Path(name): Path<String>,
5165 Json(req): Json<TrustSkillRequest>,
5166 ) -> Result<Json<SkillMutationReceiptResponse>, ApiError> {
5167 use crate::skills::mutation::{MutationContext, SkillMutationRequest};
5168
5169 let scope = parse_api_scope(req.scope.as_deref())?;
5170 let (network, max_size, registry_url, configured_skills_dir) =
5171 mutation_context_settings(&state);
5172 let home = crate::config::effective_home_dir();
5173
5174 let receipt = crate::skills::mutation::execute_sync(
5175 SkillMutationRequest::TrustByName {
5176 name: name.clone(),
5177 scope,
5178 expected_digest: req.expected_digest,
5179 },
5180 &MutationContext {
5181 workspace: &state.workspace,
5182 home: home.as_deref(),
5183 configured_skills_dir: configured_skills_dir.as_deref(),
5184 network: &network,
5185 max_size,
5186 registry_url: &registry_url,
5187 },
5188 )
5189 .map_err(|err| {
5190 let msg = err.to_string();
5191 if msg.contains("not found") {
5192 ApiError::not_found(format!("trust failed: {err:#}"))
5193 } else {
5194 ApiError::bad_request(format!("trust failed: {err:#}"))
5195 }
5196 })?;
5197
5198 Ok(Json(receipt_to_response(&receipt)))
5199 }
5200
5201 // ─── GET /v1/skills/{name}/audit ───────────────────────────────────────────
5202
5203 async fn audit_skill_api(
5204 State(state): State<RuntimeApiState>,
5205 Path(name): Path<String>,
5206 Query(query): Query<SkillScopeQuery>,
5207 ) -> Result<Json<SkillAuditResponse>, ApiError> {
5208 use crate::skills::audit::{
5209 AuditedSkill, DigestState, IntegrityState, SkillActionKind, SkillAuditMode,
5210 SkillAuditWarning, SkillSourceKind, TrustState, scan_with_configured,
5211 };
5212 use crate::skills::roots::SkillRootKind;
5213
5214 let scope_filter = parse_api_scope(query.scope.as_deref())?;
5215 let home = crate::config::effective_home_dir();
5216 let configured_skills_dir = {
5217 let config = state.config.read();
5218 config.skills_dir.as_ref().map(PathBuf::from)
5219 };
5220 let canonical = crate::skills::normalize_skill_name_for_lookup(&name);
5221
5222 let snap = scan_with_configured(
5223 &state.workspace,
5224 home.as_deref(),
5225 configured_skills_dir.as_deref(),
5226 SkillAuditMode::Compatible,
5227 None,
5228 );
5229
5230 let mut matches: Vec<&AuditedSkill> = snap
5231 .skills
5232 .iter()
5233 .filter(|s| s.id.canonical_name == canonical)
5234 .collect();
5235
5236 if let Some(scope) = scope_filter {
5237 let want = match scope {
5238 crate::skills::mutation::SkillTargetScope::Project => SkillRootKind::CodeWhaleProject,
5239 crate::skills::mutation::SkillTargetScope::Global => SkillRootKind::CodeWhaleGlobal,
5240 };
5241 matches.retain(|s| s.root.kind == want);
5242 }
5243
5244 if matches.is_empty() {
5245 return Err(ApiError::not_found(format!(
5246 "skill '{name}' not found in any audited root"
5247 )));
5248 }
5249
5250 let ambiguous = matches.len() > 1;
5251 let entries = matches
5252 .into_iter()
5253 .map(|skill| {
5254 let source_kind = match skill.source_kind {
5255 SkillSourceKind::CodeWhaleManaged => "codewhale_managed",
5256 SkillSourceKind::CodeWhaleManual => "codewhale_manual",
5257 SkillSourceKind::CompatibleExternal => "compatible_external",
5258 SkillSourceKind::BuiltIn => "built_in",
5259 SkillSourceKind::ReviewedPluginSnapshot => "reviewed_plugin_snapshot",
5260 SkillSourceKind::RegistryCache => "registry_cache",
5261 };
5262 let scope_str = match skill.root.kind {
5263 SkillRootKind::CodeWhaleProject => "project",
5264 SkillRootKind::CodeWhaleGlobal => "global",
5265 _ => "other",
5266 };
5267 let digest = match &skill.digest {
5268 DigestState::Known(v) => SkillAuditDigest {
5269 state: "known".to_string(),
5270 value: Some(v.clone()),
5271 },
5272 DigestState::Unknown(reason) => SkillAuditDigest {
5273 state: format!("unknown:{reason:?}").to_ascii_lowercase(),
5274 value: None,
5275 },
5276 };
5277 let trust = match &skill.trust {
5278 TrustState::TrustedForDigest(_) => "trusted_for_digest",
5279 TrustState::TrustStale => "trust_stale",
5280 TrustState::LegacyAdvisory => "legacy_advisory",
5281 TrustState::Untrusted => "untrusted",
5282 TrustState::NotApplicable => "not_applicable",
5283 TrustState::Unknown => "unknown",
5284 };
5285 let integrity = match &skill.integrity {
5286 IntegrityState::Healthy => "healthy",
5287 IntegrityState::LocalContentDrift => "local_content_drift",
5288 IntegrityState::BrokenManagedInstall => "broken_managed_install",
5289 IntegrityState::LegacyMetadataUnknown => "legacy_metadata_unknown",
5290 IntegrityState::Unknown => "unknown",
5291 };
5292 let available_actions = skill
5293 .available_actions
5294 .iter()
5295 .map(|a| match a {
5296 SkillActionKind::Install => "install",
5297 SkillActionKind::Import => "import",
5298 SkillActionKind::Update => "update",
5299 SkillActionKind::Remove => "remove",
5300 SkillActionKind::Trust => "trust",
5301 })
5302 .map(str::to_string)
5303 .collect();
5304 let warnings = skill
5305 .warnings
5306 .iter()
5307 .map(|w| match w {
5308 SkillAuditWarning::Message(m) => m.clone(),
5309 })
5310 .collect();
5311 SkillAuditEntry {
5312 name: skill.name.clone(),
5313 safe_display_path: skill.safe_display_path.clone(),
5314 source_kind: source_kind.to_string(),
5315 scope: scope_str.to_string(),
5316 digest,
5317 trust: trust.to_string(),
5318 integrity: integrity.to_string(),
5319 available_actions,
5320 warnings,
5321 }
5322 })
5323 .collect();
5324
5325 Ok(Json(SkillAuditResponse {
5326 ambiguous,
5327 skills: entries,
5328 }))
5329 }
5330
5331 #[derive(Debug, Deserialize)]
5332 struct ApprovalsQuery {
5333 limit: Option<usize>,
5334 }
5335
5336 /// One row of the account-wide approval history: what the agent asked
5337 /// permission to do and what was decided. `decided_at` is `None` while the
5338 /// ask is still pending.
5339 #[derive(Debug, Serialize)]
5340 struct ApprovalHistoryRow {
5341 approval_id: String,
5342 tool_name: String,
5343 outcome: String,
5344 /// Who resolved it: `user`, `session_rule`, `posture`, or `host`.
5345 /// Absent while pending and on records written before deciders were kept.
5346 #[serde(skip_serializing_if = "Option::is_none")]
5347 decided_by: Option<crate::approval_log::ApprovalDecider>,
5348 asked_at: chrono::DateTime<Utc>,
5349 decided_at: Option<chrono::DateTime<Utc>>,
5350 }
5351
5352 fn approval_outcome_label(outcome: &crate::approval_log::ApprovalOutcome) -> &'static str {
5353 use crate::approval_log::ApprovalOutcome;
5354 match outcome {
5355 ApprovalOutcome::ApprovedOnce => "allowed_once",
5356 ApprovalOutcome::Denied => "denied",
5357 ApprovalOutcome::Timeout => "timeout",
5358 ApprovalOutcome::Cancelled => "cancelled",
5359 ApprovalOutcome::Unavailable => "unavailable",
5360 ApprovalOutcome::RetryWithPolicy { .. } => "retry_with_policy",
5361 }
5362 }
5363
5364 /// Flatten one session's replay into history rows, newest ask first. Pending
5365 /// asks sort by asked time alongside decided rows — they are the newest
5366 /// entries while live, and sink into place once decided.
5367 fn approval_history_rows(replay: &crate::approval_log::ApprovalReplay) -> Vec<ApprovalHistoryRow> {
5368 let mut rows: Vec<ApprovalHistoryRow> = replay
5369 .completed
5370 .iter()
5371 .map(|completed| {
5372 let asked_at = completed.ask.created_at();
5373 ApprovalHistoryRow {
5374 approval_id: completed.ask.approval_id().to_string(),
5375 tool_name: completed.ask.tool_name().unwrap_or("unknown").to_string(),
5376 outcome: approval_outcome_label(&completed.outcome).to_string(),
5377 decided_by: completed.decided_by,
5378 asked_at,
5379 decided_at: Some(completed.decided_at),
5380 }
5381 })
5382 .chain(replay.unmatched_asks.iter().map(|ask| ApprovalHistoryRow {
5383 approval_id: ask.approval_id().to_string(),
5384 tool_name: ask.tool_name().unwrap_or("unknown").to_string(),
5385 outcome: "pending".to_string(),
5386 decided_by: None,
5387 asked_at: ask.created_at(),
5388 decided_at: None,
5389 }))
5390 .collect();
5391 rows.sort_by_key(|row| std::cmp::Reverse(row.asked_at));
5392 rows
5393 }
5394
5395 /// `GET /v1/approvals` — the read-only history behind the approvals log:
5396 /// every decided approval plus every still-pending ask, newest first, across
5397 /// all sessions. A corrupt session log is skipped with a warning, never a
5398 /// 500 for the whole history; the warn names the file to inspect (#5931).
5399 async fn list_approvals(
5400 State(state): State<RuntimeApiState>,
5401 Query(query): Query<ApprovalsQuery>,
5402 ) -> Result<Json<Vec<ApprovalHistoryRow>>, ApiError> {
5403 let limit = query.limit.unwrap_or(100).clamp(1, 500);
5404 let sessions_dir = state.sessions_dir.clone();
5405 let mut rows = tokio::task::spawn_blocking(move || {
5406 let store = crate::approval_log::ApprovalReceiptStore::new(sessions_dir);
5407 let mut rows = Vec::new();
5408 for session_id in store.sessions_with_logs() {
5409 match store.replay(&session_id) {
5410 Ok(replay) => rows.extend(approval_history_rows(&replay)),
5411 Err(error) => tracing::warn!(
5412 target: "approval",
5413 error_kind = ?error.kind(),
5414 %error,
5415 session_id,
5416 "skipping unreadable approval log in history listing",
5417 ),
5418 }
5419 }
5420 rows
5421 })
5422 .await
5423 .map_err(|error| ApiError::internal(format!("approval history read failed: {error}")))?;
5424 rows.sort_by_key(|row| std::cmp::Reverse(row.asked_at));
5425 rows.truncate(limit);
5426 Ok(Json(rows))
5427 }
5428
5429 async fn decide_approval(
5430 State(state): State<RuntimeApiState>,
5431 Path(approval_id): Path<String>,
5432 Json(req): Json<DecideApprovalBody>,
5433 ) -> Result<Json<DecideApprovalResponse>, ApiError> {
5434 let decision = match req.decision.as_str() {
5435 "allow" => ExternalApprovalDecision::Allow {
5436 remember: req.remember,
5437 },
5438 "deny" => ExternalApprovalDecision::Deny {
5439 remember: req.remember,
5440 },
5441 other => {
5442 return Err(ApiError::bad_request(format!(
5443 "invalid decision '{other}'; expected \"allow\" or \"deny\""
5444 )));
5445 }
5446 };
5447 let delivered = state
5448 .runtime_threads
5449 .deliver_external_approval(&approval_id, decision);
5450 if !delivered {
5451 return Err(ApiError::not_found(format!(
5452 "no pending approval with id '{approval_id}'"
5453 )));
5454 }
5455 Ok(Json(DecideApprovalResponse {
5456 ok: true,
5457 approval_id,
5458 decision: req.decision,
5459 delivered,
5460 }))
5461 }
5462
5463 /// `DELETE /v1/threads/{id}/approval-grants/{grant_id}` — revoke one
5464 /// "allow for this conversation" grant. The next matching call prompts again.
5465 async fn revoke_approval_grant(
5466 State(state): State<RuntimeApiState>,
5467 Path((thread_id, grant_id)): Path<(String, String)>,
5468 ) -> Result<Json<Value>, ApiError> {
5469 let revoked = state
5470 .runtime_threads
5471 .revoke_approval_grant(&thread_id, &grant_id)
5472 .await
5473 .map_err(map_thread_err)?;
5474 if !revoked {
5475 return Err(ApiError::not_found(format!(
5476 "no approval grant with id '{grant_id}' on thread '{thread_id}'"
5477 )));
5478 }
5479 Ok(Json(
5480 json!({ "ok": true, "grant_id": grant_id, "revoked": true }),
5481 ))
5482 }
5483
5484 async fn submit_user_input(
5485 State(state): State<RuntimeApiState>,
5486 Path((thread_id, input_id)): Path<(String, String)>,
5487 Json(req): Json<SubmitUserInputBody>,
5488 ) -> Result<Json<SubmitUserInputResponse>, ApiError> {
5489 use crate::tools::user_input::{UserInputAnswer, UserInputResponse};
5490 let answers: Vec<UserInputAnswer> = req
5491 .answers
5492 .into_iter()
5493 .map(|a| UserInputAnswer {
5494 id: a.id,
5495 label: a.label,
5496 value: a.value,
5497 })
5498 .collect();
5499 let response = UserInputResponse { answers };
5500 let delivered = state
5501 .runtime_threads
5502 .submit_user_input(&thread_id, &input_id, response)
5503 .await
5504 .map_err(map_thread_err)?;
5505 if !delivered {
5506 return Err(ApiError::not_found(format!(
5507 "no pending user-input request with id '{input_id}'"
5508 )));
5509 }
5510 Ok(Json(SubmitUserInputResponse {
5511 ok: true,
5512 input_id,
5513 delivered,
5514 }))
5515 }
5516
5517 async fn runtime_info(
5518 State(state): State<RuntimeApiState>,
5519 request: Request,
5520 ) -> Json<RuntimeInfoResponse> {
5521 let version = env!("CARGO_PKG_VERSION");
5522 let commit = option_env!("CODEWHALE_BUILD_COMMIT").unwrap_or("unknown");
5523 let api_base = runtime_account_api_base();
5524 let account = runtime_account_info_for_request(
5525 runtime_request_is_authorized(&request, &state),
5526 &api_base,
5527 || runtime_account_info(state.config_profile.as_deref(), &api_base),
5528 );
5529 Json(RuntimeInfoResponse {
5530 service: "codewhale-runtime-api",
5531 runtime_api_version: RUNTIME_API_VERSION,
5532 codewhale_version: version,
5533 codewhale_commit: commit,
5534 bind_host: state.bind_host.clone(),
5535 port: state.bind_port,
5536 auth_required: state.auth_required,
5537 transports: vec!["http", "sse"],
5538 capabilities: default_runtime_capabilities(),
5539 account,
5540 experimental: RuntimeExperimentalCapabilities::default(),
5541 version,
5542 })
5543 }
5544
5545 fn runtime_account_info(profile: Option<&str>, api_base: &str) -> RuntimeAccountInfo {
5546 #[cfg(test)]
5547 {
5548 let _ = profile;
5549 RuntimeAccountInfo::signed_out(api_base.to_string())
5550 }
5551
5552 #[cfg(not(test))]
5553 {
5554 secure_account_session_secrets()
5555 .and_then(|secrets| {
5556 AccountSessionStore::new(secrets, profile, api_base).runtime_info_at(Utc::now())
5557 })
5558 .unwrap_or_else(|_| RuntimeAccountInfo::signed_out(api_base.to_string()))
5559 }
5560 }
5561
5562 fn runtime_account_info_for_request(
5563 authorized: bool,
5564 api_base: &str,
5565 load: impl FnOnce() -> RuntimeAccountInfo,
5566 ) -> RuntimeAccountInfo {
5567 if authorized {
5568 load()
5569 } else {
5570 RuntimeAccountInfo::signed_out(api_base.to_string())
5571 }
5572 }
5573
5574 fn runtime_account_api_base() -> String {
5575 std::env::var(ACCOUNT_API_BASE_ENV)
5576 .ok()
5577 .and_then(|value| normalize_runtime_account_api_base(&value))
5578 .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string())
5579 }
5580
5581 fn normalize_runtime_account_api_base(value: &str) -> Option<String> {
5582 let mut url = reqwest::Url::parse(value.trim()).ok()?;
5583 if !url.username().is_empty()
5584 || url.password().is_some()
5585 || url.query().is_some()
5586 || url.fragment().is_some()
5587 || !matches!(url.path(), "" | "/")
5588 {
5589 return None;
5590 }
5591 let host = url.host_str()?;
5592 let loopback = host.eq_ignore_ascii_case("localhost")
5593 || host
5594 .trim_start_matches('[')
5595 .trim_end_matches(']')
5596 .parse::<IpAddr>()
5597 .is_ok_and(|address| address.is_loopback());
5598 if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {
5599 return None;
5600 }
5601 url.set_path("/");
5602 Some(url.as_str().trim_end_matches('/').to_string())
5603 }
5604
5605 /// Ownership is derived using the same trust/precedence as the existing MCP
5606 /// loader. A global editor must never silently change a shadowed project entry
5607 /// or manufacture an override for a reviewed plugin component.
5608 fn mcp_management_config(
5609 state: &RuntimeApiState,
5610 ) -> Result<
5611 (
5612 crate::mcp::McpConfig,
5613 std::collections::HashMap<String, &'static str>,
5614 ),
5615 ApiError,
5616 > {
5617 let global_path = state.config.read().mcp_config_path();
5618 let plugins = state
5619 .plugin_discovery
5620 .registry_for_workspace(&state.workspace);
5621 let config = crate::mcp::load_config_with_workspace_and_plugins(
5622 &global_path,
5623 &state.workspace,
5624 plugins.as_ref(),
5625 )
5626 .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?;
5627 let global = crate::mcp::load_config(&global_path)
5628 .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?;
5629 let project_path = crate::mcp::workspace_mcp_config_path(&state.workspace);
5630 let same_source = project_path == global_path
5631 || project_path
5632 .canonicalize()
5633 .ok()
5634 .zip(global_path.canonicalize().ok())
5635 .is_some_and(|(project, global)| project == global);
5636 let project = if !same_source && crate::config::is_workspace_trusted(&state.workspace) {
5637 crate::mcp::load_config(&project_path)
5638 .map_err(|e| ApiError::internal(format!("Failed to load project MCP config: {e}")))?
5639 } else {
5640 crate::mcp::McpConfig::default()
5641 };
5642 let origins = config
5643 .servers
5644 .iter()
5645 .map(|(name, server)| {
5646 let origin = if server.reviewed_plugin.is_some() {
5647 "plugin"
5648 } else if project.servers.contains_key(name) {
5649 "project"
5650 } else if global.servers.contains_key(name) {
5651 "global"
5652 } else {
5653 "unknown"
5654 };
5655 (name.clone(), origin)
5656 })
5657 .collect();
5658 Ok((config, origins))
5659 }
5660
5661 #[derive(Debug)]
5662 struct McpManagementFailure(ApiError);
5663 impl std::fmt::Display for McpManagementFailure {
5664 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5665 f.write_str(&self.0.message)
5666 }
5667 }
5668 impl std::error::Error for McpManagementFailure {}
5669
5670 fn mcp_mutation_error(error: anyhow::Error) -> ApiError {
5671 if error.is::<crate::mcp::McpRevisionConflict>() {
5672 ApiError {
5673 status: StatusCode::PRECONDITION_FAILED,
5674 message: error.to_string(),
5675 code: None,
5676 }
5677 } else if let Some(error) = error.downcast_ref::<McpManagementFailure>() {
5678 error.0.clone()
5679 } else {
5680 ApiError::internal(error.to_string())
5681 }
5682 }
5683
5684 fn mcp_expected_revision(headers: &axum::http::HeaderMap) -> Result<String, ApiError> {
5685 let value = headers
5686 .get(header::IF_MATCH)
5687 .ok_or_else(|| ApiError {
5688 status: StatusCode::PRECONDITION_REQUIRED,
5689 message: "Read the MCP configuration and send its revision in If-Match before saving"
5690 .into(),
5691 code: None,
5692 })?
5693 .to_str()
5694 .map_err(|_| ApiError::bad_request("Invalid MCP revision"))?
5695 .trim();
5696 let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
5697 &value[1..value.len() - 1]
5698 } else {
5699 value
5700 };
5701 if value != "mcp-v1-absent"
5702 && !value.strip_prefix("mcp-v1-").is_some_and(|hash| {
5703 hash.len() == 64
5704 && hash
5705 .bytes()
5706 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
5707 })
5708 {
5709 return Err(ApiError::bad_request("Invalid MCP revision"));
5710 }
5711 Ok(value.to_owned())
5712 }
5713
5714 async fn mutate_mcp_management<T: Send + 'static>(
5715 state: RuntimeApiState,
5716 headers: axum::http::HeaderMap,
5717 mutate: impl FnOnce(&RuntimeApiState, &mut crate::mcp::McpConfig) -> Result<T, ApiError>
5718 + Send
5719 + 'static,
5720 ) -> Result<(T, String), ApiError> {
5721 let expected = mcp_expected_revision(&headers)?;
5722 #[cfg(test)]
5723 let env_ticket = crate::test_support::env_scope_ticket();
5724 tokio::task::spawn_blocking(move || {
5725 #[cfg(test)]
5726 let _membership = crate::test_support::join_env_scope(env_ticket);
5727 state
5728 .workspace_scope
5729 .validate_sync()
5730 .map_err(|_| ApiError::conflict("selected workspace identity changed"))?;
5731 let path = state.config.read().mcp_config_path();
5732 let result = crate::mcp::mutate_config(&path, Some(&expected), |config| {
5733 mutate(&state, config).map_err(|error| anyhow::Error::new(McpManagementFailure(error)))
5734 })
5735 .map_err(mcp_mutation_error)?;
5736 state
5737 .workspace_scopes
5738 .mcp_generation
5739 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5740 Ok(result)
5741 })
5742 .await
5743 .map_err(|_| ApiError::internal("MCP configuration write failed"))?
5744 }
5745
5746 async fn mcp_management_snapshot(
5747 state: RuntimeApiState,
5748 ) -> Result<
5749 (
5750 (
5751 crate::mcp::McpConfig,
5752 std::collections::HashMap<String, &'static str>,
5753 ),
5754 String,
5755 ),
5756 ApiError,
5757 > {
5758 #[cfg(test)]
5759 let env_ticket = crate::test_support::env_scope_ticket();
5760 tokio::task::spawn_blocking(move || {
5761 #[cfg(test)]
5762 let _membership = crate::test_support::join_env_scope(env_ticket);
5763 let path = state.config.read().mcp_config_path();
5764 codewhale_config::with_config_write_lock(&path, |path| {
5765 let config = mcp_management_config(&state)
5766 .map_err(|error| anyhow::Error::new(McpManagementFailure(error)))?;
5767 Ok((config, crate::mcp::read_config_revision(path)?))
5768 })
5769 .map_err(mcp_mutation_error)
5770 })
5771 .await
5772 .map_err(|_| ApiError::internal("MCP configuration read failed"))?
5773 }
5774
5775 fn require_writable_mcp_server(state: &RuntimeApiState, name: &str) -> Result<(), ApiError> {
5776 let (_, origins) = mcp_management_config(state)?;
5777 match origins.get(name) {
5778 Some(&"global") => Ok(()),
5779 Some(origin) => Err(ApiError {
5780 status: StatusCode::CONFLICT,
5781 message: format!(
5782 "MCP server '{name}' is owned by {origin} configuration; manage it at its source"
5783 ),
5784 code: None,
5785 }),
5786 None => Err(ApiError::not_found(format!(
5787 "MCP server '{name}' not found"
5788 ))),
5789 }
5790 }
5791
5792 async fn mcp_pool_handle(
5793 state: &RuntimeApiState,
5794 create: bool,
5795 ) -> Result<Option<Arc<Mutex<McpPool>>>, ApiError> {
5796 state
5797 .workspace_scope
5798 .validate()
5799 .await
5800 .map_err(|_| ApiError::conflict("selected workspace identity changed"))?;
5801 let mut slot = state.workspace_scope.mcp.lock().await;
5802 state
5803 .workspace_scope
5804 .validate()
5805 .await
5806 .map_err(|_| ApiError::conflict("selected workspace identity changed"))?;
5807 loop {
5808 let generation = state
5809 .workspace_scopes
5810 .mcp_generation
5811 .load(std::sync::atomic::Ordering::SeqCst);
5812 if let Some((admitted, pool)) = slot.as_ref() {
5813 if *admitted == generation {
5814 return Ok(Some(pool.clone()));
5815 }
5816 let mut held = pool.clone().lock_owned().await;
5817 let current = state.clone();
5818 #[cfg(test)]
5819 let env_ticket = crate::test_support::env_scope_ticket();
5820 codewhale_app_server::daemon_socket::owner_work(move || {
5821 #[cfg(test)]
5822 let _membership = crate::test_support::join_env_scope(env_ticket);
5823 // Keep the exact scope/owner alive through synchronous disk
5824 // reload even if this request is cancelled after admission.
5825 current.workspace_scope.validate_sync()?;
5826 let path = current.config.read().mcp_config_path();
5827 let plugins = current
5828 .plugin_discovery
5829 .registry_for_workspace(&current.workspace);
5830 held.switch_workspace_config_source(&path, &current.workspace, plugins)
5831 })
5832 .await
5833 .map_err(|error| ApiError::internal(error.to_string()))?;
5834 slot.as_mut().expect("retained pool").0 = generation;
5835 } else if create {
5836 let current = state.clone();
5837 #[cfg(test)]
5838 let env_ticket = crate::test_support::env_scope_ticket();
5839 let pool = codewhale_app_server::daemon_socket::owner_work(move || {
5840 #[cfg(test)]
5841 let _membership = crate::test_support::join_env_scope(env_ticket);
5842 current.workspace_scope.validate_sync()?;
5843 let path = current.config.read().mcp_config_path();
5844 let plugins = current
5845 .plugin_discovery
5846 .registry_for_workspace(&current.workspace);
5847 let mut pool = McpPool::from_config_path_with_workspace_and_plugins(
5848 &path,
5849 &current.workspace,
5850 plugins,
5851 )?
5852 .with_backend(crate::mcp::McpBackend::from_config(&current.config.read()));
5853 pool.dynamic_servers = current.workspace_scopes.dynamic_servers.clone();
5854 Ok(pool)
5855 })
5856 .await
5857 .map_err(|error| ApiError::internal(format!("Failed to load MCP config: {error}")))?;
5858 *slot = Some((generation, Arc::new(Mutex::new(pool))));
5859 } else {
5860 return Ok(None);
5861 }
5862 // A concurrent global mutation may have settled during disk work.
5863 // Repeat validation under the same pool; no second connection owner.
5864 }
5865 }
5866
5867 fn mcp_connection_outcome(
5868 pool: &McpPool,
5869 server: &str,
5870 error: Option<&anyhow::Error>,
5871 ) -> McpConnectionOutcome {
5872 McpConnectionOutcome {
5873 server: server.to_owned(),
5874 connected: pool.connected_servers().contains(&server),
5875 auth_required: pool.server_needs_auth(server),
5876 error: error
5877 .map(|error| truncate_text(&crate::mcp::format_mcp_error_for_display(error), 2048)),
5878 }
5879 }
5880
5881 async fn list_mcp_servers(
5882 State(state): State<RuntimeApiState>,
5883 ) -> Result<Json<McpServersResponse>, ApiError> {
5884 let ((config, origins), revision) = mcp_management_snapshot(state.clone()).await?;
5885 let handle = mcp_pool_handle(&state, false).await?;
5886 let pool = match handle.as_ref() {
5887 Some(handle) => Some(handle.lock().await),
5888 None => None,
5889 };
5890 state
5891 .workspace_scope
5892 .validate()
5893 .await
5894 .map_err(|_| ApiError::conflict("selected workspace identity changed"))?;
5895 let mut servers = Vec::new();
5896 for (name, server_cfg) in config.servers {
5897 let origin = origins.get(&name).copied().unwrap_or("unknown");
5898 servers.push(McpServerEntry {
5899 name: name.clone(),
5900 origin,
5901 writable: origin == "global",
5902 auth_required: pool
5903 .as_ref()
5904 .is_some_and(|pool| pool.server_needs_auth(&name)),
5905 enabled: server_cfg.is_enabled(),
5906 required: server_cfg.required,
5907 command: server_cfg.command.clone(),
5908 url: server_cfg.url.clone(),
5909 connected: pool
5910 .as_ref()
5911 .is_some_and(|pool| pool.connected_servers().contains(&name.as_str())),
5912 enabled_tools: server_cfg.enabled_tools.clone(),
5913 disabled_tools: server_cfg.disabled_tools.clone(),
5914 });
5915 }
5916 servers.sort_by(|a, b| a.name.cmp(&b.name));
5917 Ok(Json(McpServersResponse { servers, revision }))
5918 }
5919
5920 async fn list_mcp_tools(
5921 State(state): State<RuntimeApiState>,
5922 Query(query): Query<McpToolsQuery>,
5923 ) -> Result<Json<McpToolsResponse>, ApiError> {
5924 // An explicit connection request must not inherit the tool dispatcher's
5925 // best-effort reload behavior: unreadable/revoked sources fail closed.
5926 let fresh_config = if query.connect {
5927 Some(mcp_management_config(&state)?.0)
5928 } else {
5929 None
5930 };
5931 let Some(pool_handle) = mcp_pool_handle(&state, query.connect).await? else {
5932 return Ok(Json(McpToolsResponse {
5933 tools: Vec::new(),
5934 connections: Vec::new(),
5935 }));
5936 };
5937 let mut pool = pool_handle.lock().await;
5938 state
5939 .workspace_scope
5940 .validate()
5941 .await
5942 .map_err(|_| ApiError::conflict("selected workspace identity changed"))?;
5943 if fresh_config
5944 .as_ref()
5945 .is_some_and(|config| !pool.config_matches(config))
5946 {
5947 let error =
5948 anyhow::anyhow!("MCP configuration changed; reload it before connecting this server");
5949 let names = query
5950 .server
5951 .clone()
5952 .map(|name| vec![name])
5953 .unwrap_or_else(|| pool.server_names());
5954 return Ok(Json(McpToolsResponse {
5955 tools: Vec::new(),
5956 connections: names
5957 .iter()
5958 .map(|name| mcp_connection_outcome(&pool, name, Some(&error)))
5959 .collect(),
5960 }));
5961 }
5962 let errors = if query.connect {
5963 if let Some(server) = query.server.as_deref() {
5964 match pool.get_or_connect(server).await {
5965 Ok(_) => Vec::new(),
5966 Err(error) => vec![(server.to_owned(), error)],
5967 }
5968 } else {
5969 pool.connect_all().await
5970 }
5971 } else {
5972 Vec::new()
5973 };
5974 let mut names = query
5975 .server
5976 .clone()
5977 .map(|name| vec![name])
5978 .unwrap_or_else(|| pool.server_names());
5979 for (server, _) in &errors {
5980 if !names.contains(server) {
5981 names.push(server.clone());
5982 }
5983 }
5984 names.sort();
5985 let connections = names
5986 .iter()
5987 .map(|name| {
5988 mcp_connection_outcome(
5989 &pool,
5990 name,
5991 errors
5992 .iter()
5993 .find(|(server, _)| server == name)
5994 .map(|(_, error)| error),
5995 )
5996 })
5997 .collect();
5998
5999 let mut tools = Vec::new();
6000 for (prefixed_name, tool) in pool.all_tools() {
6001 let Ok((server, name)) = pool.parse_prefixed_name(&prefixed_name) else {
6002 continue;
6003 };
6004
6005 if let Some(filter) = query.server.as_deref()
6006 && server != filter
6007 {
6008 continue;
6009 }
6010
6011 tools.push(McpToolEntry {
6012 server: server.to_string(),
6013 name: name.to_string(),
6014 prefixed_name,
6015 description: tool.description.clone(),
6016 input_schema: tool.input_schema.clone(),
6017 });
6018 }
6019
6020 tools.sort_by(|a, b| a.server.cmp(&b.server).then_with(|| a.name.cmp(&b.name)));
6021
6022 Ok(Json(McpToolsResponse { tools, connections }))
6023 }
6024
6025 /// `GET /v1/apps/mcp/servers/{name}` — fetch a single server's redacted config.
6026 async fn get_mcp_server(
6027 State(state): State<RuntimeApiState>,
6028 Path(name): Path<String>,
6029 ) -> Result<Json<McpServerDetail>, ApiError> {
6030 let ((config, origins), revision) = mcp_management_snapshot(state.clone()).await?;
6031 let server_cfg = config
6032 .servers
6033 .get(&name)
6034 .ok_or_else(|| ApiError::not_found(format!("MCP server '{name}' not found")))?;
6035 let handle = mcp_pool_handle(&state, false).await?;
6036 let pool = match handle.as_ref() {
6037 Some(handle) => Some(handle.lock().await),
6038 None => None,
6039 };
6040 state
6041 .workspace_scope
6042 .validate()
6043 .await
6044 .map_err(|_| ApiError::conflict("selected workspace identity changed"))?;
6045 let connected = pool
6046 .as_ref()
6047 .is_some_and(|pool| pool.connected_servers().contains(&name.as_str()));
6048 let mut detail = McpServerDetail::from_config(&name, server_cfg, connected, revision);
6049 detail.origin = origins.get(&name).copied().unwrap_or("unknown");
6050 detail.writable = detail.origin == "global";
6051 detail.auth_required = pool
6052 .as_ref()
6053 .is_some_and(|pool| pool.server_needs_auth(&name));
6054 Ok(Json(detail))
6055 }
6056
6057 /// `POST /v1/apps/mcp/servers` — add a new server to the persistent config.
6058 ///
6059 /// Body: JSON object with all `McpServerWriteRequest` fields **plus** a
6060 /// required top-level `"name"` string that will be the server key.
6061 async fn create_mcp_server(
6062 State(state): State<RuntimeApiState>,
6063 headers: axum::http::HeaderMap,
6064 Json(body): Json<serde_json::Value>,
6065 ) -> Result<(StatusCode, Json<McpServerDetail>), ApiError> {
6066 let name = body
6067 .get("name")
6068 .and_then(|v| v.as_str())
6069 .ok_or_else(|| ApiError::bad_request("'name' is required"))?
6070 .to_string();
6071
6072 if name.trim().is_empty() {
6073 return Err(ApiError::bad_request("'name' must not be empty"));
6074 }
6075
6076 let req: McpServerWriteRequest = serde_json::from_value(body)
6077 .map_err(|e| ApiError::bad_request(format!("Invalid request body: {e}")))?;
6078
6079 if req.command.as_ref().and_then(Option::as_ref).is_none()
6080 && req.url.as_ref().and_then(Option::as_ref).is_none()
6081 {
6082 return Err(ApiError::bad_request(
6083 "Either 'command' or 'url' is required to create an MCP server",
6084 ));
6085 }
6086
6087 if let Some(Some(transport)) = &req.transport {
6088 crate::mcp::validate_mcp_transport(Some(transport.as_str()))
6089 .map_err(|e| ApiError::bad_request(e.to_string()))?;
6090 }
6091
6092 let new_cfg = mcp_server_config_from_write_request(req, None);
6093 let target_name = name.clone();
6094 let (new_cfg, revision) =
6095 mutate_mcp_management(state.clone(), headers, move |state, config| {
6096 if mcp_management_config(state)?
6097 .0
6098 .servers
6099 .contains_key(&target_name)
6100 {
6101 return Err(ApiError {
6102 status: StatusCode::CONFLICT,
6103 message: format!(
6104 "MCP server '{target_name}' already exists in the effective configuration"
6105 ),
6106 code: None,
6107 });
6108 }
6109 config.servers.insert(target_name, new_cfg.clone());
6110 Ok(new_cfg)
6111 })
6112 .await?;
6113
6114 // Invalidate the in-memory pool so the next tool call reloads from disk.
6115
6116 Ok((
6117 StatusCode::CREATED,
6118 Json(McpServerDetail::from_config(
6119 &name, &new_cfg, false, revision,
6120 )),
6121 ))
6122 }
6123
6124 /// `PATCH /v1/apps/mcp/servers/{name}` — update an existing server's config.
6125 async fn update_mcp_server(
6126 State(state): State<RuntimeApiState>,
6127 Path(name): Path<String>,
6128 headers: axum::http::HeaderMap,
6129 Json(req): Json<McpServerWriteRequest>,
6130 ) -> Result<Json<McpServerDetail>, ApiError> {
6131 if let Some(Some(transport)) = &req.transport {
6132 crate::mcp::validate_mcp_transport(Some(transport.as_str()))
6133 .map_err(|e| ApiError::bad_request(e.to_string()))?;
6134 }
6135
6136 let target_name = name.clone();
6137 let (updated_cfg, revision) = mutate_mcp_management(state.clone(), headers, move |state, cfg| {
6138 let name = target_name;
6139 require_writable_mcp_server(state, &name)?;
6140 let existing = cfg
6141 .servers
6142 .get_mut(&name)
6143 .ok_or_else(|| ApiError::not_found(format!("MCP server '{name}' not found")))?;
6144 let previous_target = (
6145 existing.command.clone(),
6146 existing.args.clone(),
6147 existing.url.clone(),
6148 existing.transport.clone(),
6149 );
6150 apply_write_request_to_config(req, existing);
6151 let target_changed = previous_target
6152 != (
6153 existing.command.clone(),
6154 existing.args.clone(),
6155 existing.url.clone(),
6156 existing.transport.clone(),
6157 );
6158 if target_changed && mcp_credential_configured(existing) {
6159 return Err(ApiError {
6160 status: StatusCode::CONFLICT,
6161 message: "Clear this connector's credential configuration before changing its command, arguments, URL, or transport; retained credentials cannot be forwarded to a different target".to_owned(),
6162 code: None,
6163 });
6164 }
6165 if existing.command.is_none() && existing.url.is_none() {
6166 return Err(ApiError::bad_request(
6167 "Either 'command' or 'url' must remain configured for an MCP server",
6168 ));
6169 }
6170 Ok(existing.clone())
6171 }).await?;
6172
6173 // Invalidate the in-memory pool.
6174
6175 Ok(Json(McpServerDetail::from_config(
6176 &name,
6177 &updated_cfg,
6178 false,
6179 revision,
6180 )))
6181 }
6182
6183 /// `DELETE /v1/apps/mcp/servers/{name}` — remove a server from the persistent config.
6184 async fn delete_mcp_server(
6185 State(state): State<RuntimeApiState>,
6186 Path(name): Path<String>,
6187 headers: axum::http::HeaderMap,
6188 ) -> Result<Json<McpServerActionReceipt>, ApiError> {
6189 let target_name = name.clone();
6190 let (_, revision) = mutate_mcp_management(state.clone(), headers, move |state, cfg| {
6191 require_writable_mcp_server(state, &target_name)?;
6192 cfg.servers
6193 .remove(&target_name)
6194 .ok_or_else(|| ApiError::not_found("MCP server not found"))?;
6195 Ok(())
6196 })
6197 .await?;
6198
6199 // Invalidate the in-memory pool.
6200
6201 Ok(Json(McpServerActionReceipt {
6202 revision: Some(revision),
6203 name,
6204 action: "deleted",
6205 ok: true,
6206 connection: None,
6207 }))
6208 }
6209
6210 /// `POST /v1/apps/mcp/servers/{name}/enable` — enable a configured server.
6211 async fn enable_mcp_server(
6212 State(state): State<RuntimeApiState>,
6213 Path(name): Path<String>,
6214 headers: axum::http::HeaderMap,
6215 ) -> Result<Json<McpServerActionReceipt>, ApiError> {
6216 let target_name = name.clone();
6217 let (_, revision) = mutate_mcp_management(state.clone(), headers, move |state, cfg| {
6218 require_writable_mcp_server(state, &target_name)?;
6219 let server = cfg
6220 .servers
6221 .get_mut(&target_name)
6222 .ok_or_else(|| ApiError::not_found("MCP server not found"))?;
6223 server.enabled = true;
6224 server.disabled = false;
6225 Ok(())
6226 })
6227 .await?;
6228
6229 // Invalidate the in-memory pool so the enabled server participates next time.
6230
6231 Ok(Json(McpServerActionReceipt {
6232 revision: Some(revision),
6233 name,
6234 action: "enabled",
6235 ok: true,
6236 connection: None,
6237 }))
6238 }
6239
6240 /// `POST /v1/apps/mcp/servers/{name}/disable` — disable a configured server.
6241 async fn disable_mcp_server(
6242 State(state): State<RuntimeApiState>,
6243 Path(name): Path<String>,
6244 headers: axum::http::HeaderMap,
6245 ) -> Result<Json<McpServerActionReceipt>, ApiError> {
6246 let target_name = name.clone();
6247 let (_, revision) = mutate_mcp_management(state.clone(), headers, move |state, cfg| {
6248 require_writable_mcp_server(state, &target_name)?;
6249 let server = cfg
6250 .servers
6251 .get_mut(&target_name)
6252 .ok_or_else(|| ApiError::not_found("MCP server not found"))?;
6253 server.enabled = false;
6254 server.disabled = true;
6255 Ok(())
6256 })
6257 .await?;
6258
6259 // Invalidate the in-memory pool so the disabled server is excluded next time.
6260
6261 Ok(Json(McpServerActionReceipt {
6262 revision: Some(revision),
6263 name,
6264 action: "disabled",
6265 ok: true,
6266 connection: None,
6267 }))
6268 }
6269
6270 /// `POST /v1/apps/mcp/servers/{name}/reconnect` — retry only this server and
6271 /// return the actual result without replacing healthy sibling connections.
6272 async fn reconnect_mcp_server(
6273 State(state): State<RuntimeApiState>,
6274 Path(name): Path<String>,
6275 ) -> Result<Json<McpServerActionReceipt>, ApiError> {
6276 let (config, _) = mcp_management_config(&state)?;
6277 if !config.servers.contains_key(&name) {
6278 return Err(ApiError::not_found(format!(
6279 "MCP server '{name}' not found"
6280 )));
6281 }
6282 let handle = mcp_pool_handle(&state, true)
6283 .await?
6284 .ok_or_else(|| ApiError::internal("MCP pool unavailable"))?;
6285 let mut pool = handle.lock().await;
6286 state
6287 .workspace_scope
6288 .validate()
6289 .await
6290 .map_err(|_| ApiError::conflict("selected workspace identity changed"))?;
6291 let error = if !config.servers[&name].is_enabled() {
6292 Some(anyhow::anyhow!("MCP server '{name}' is disabled"))
6293 } else if !pool.config_matches(&config) {
6294 Some(anyhow::anyhow!(
6295 "MCP configuration changed; reload it before retrying this server"
6296 ))
6297 } else {
6298 pool.retry_connection(&name).await.err()
6299 };
6300 let connection = mcp_connection_outcome(&pool, &name, error.as_ref());
6301 Ok(Json(McpServerActionReceipt {
6302 revision: None,
6303 name,
6304 action: if error.is_none() {
6305 "reconnected"
6306 } else {
6307 "reconnect_failed"
6308 },
6309 ok: error.is_none() && connection.connected,
6310 connection: Some(connection),
6311 }))
6312 }
6313
6314 /// Build a fresh [`McpServerConfig`] from a create request.
6315 fn mcp_server_config_from_write_request(
6316 req: McpServerWriteRequest,
6317 _existing: Option<&crate::mcp::McpServerConfig>,
6318 ) -> crate::mcp::McpServerConfig {
6319 let enabled = req.enabled.unwrap_or(true);
6320 crate::mcp::McpServerConfig {
6321 command: req.command.flatten(),
6322 args: req.args.unwrap_or_default(),
6323 env: req.env.unwrap_or_default(),
6324 cwd: None,
6325 url: req.url.flatten(),
6326 transport: req.transport.flatten(),
6327 connect_timeout: req.connect_timeout.flatten(),
6328 execute_timeout: req.execute_timeout.flatten(),
6329 read_timeout: req.read_timeout.flatten(),
6330 disabled: !enabled,
6331 enabled,
6332 required: req.required.unwrap_or(false),
6333 enabled_tools: req.enabled_tools.unwrap_or_default(),
6334 disabled_tools: req.disabled_tools.unwrap_or_default(),
6335 headers: std::collections::HashMap::new(),
6336 env_headers: req.env_headers.unwrap_or_default(),
6337 bearer_token_env_var: req.bearer_token_env_var.flatten(),
6338 scopes: req.scopes.unwrap_or_default(),
6339 oauth: None,
6340 oauth_resource: req.oauth_resource.flatten(),
6341 reviewed_plugin: None,
6342 runtime_added: false,
6343 allow_private_network: false,
6344 }
6345 }
6346
6347 /// Nonsecret indicator and retargeting guard. Treat environment and OAuth
6348 /// configuration as authority even when it only references a credential.
6349 fn mcp_credential_configured(cfg: &crate::mcp::McpServerConfig) -> bool {
6350 !cfg.env.is_empty()
6351 || !cfg.headers.is_empty()
6352 || !cfg.env_headers.is_empty()
6353 || cfg.bearer_token_env_var.is_some()
6354 || cfg.oauth.is_some()
6355 || !cfg.scopes.is_empty()
6356 || cfg.oauth_resource.is_some()
6357 }
6358
6359 /// Apply a partial update from a PATCH request onto an existing config entry.
6360 fn apply_write_request_to_config(
6361 req: McpServerWriteRequest,
6362 cfg: &mut crate::mcp::McpServerConfig,
6363 ) {
6364 if let Some(v) = req.command {
6365 cfg.command = v;
6366 }
6367 if let Some(v) = req.args {
6368 cfg.args = v;
6369 }
6370 if let Some(v) = req.env {
6371 cfg.env = v;
6372 }
6373 if let Some(v) = req.url {
6374 cfg.url = v;
6375 }
6376 if let Some(v) = req.transport {
6377 cfg.transport = v;
6378 }
6379 if let Some(v) = req.connect_timeout {
6380 cfg.connect_timeout = v;
6381 }
6382 if let Some(v) = req.execute_timeout {
6383 cfg.execute_timeout = v;
6384 }
6385 if let Some(v) = req.read_timeout {
6386 cfg.read_timeout = v;
6387 }
6388 if let Some(v) = req.enabled {
6389 cfg.enabled = v;
6390 cfg.disabled = !v;
6391 }
6392 if let Some(v) = req.required {
6393 cfg.required = v;
6394 }
6395 if let Some(v) = req.enabled_tools {
6396 cfg.enabled_tools = v;
6397 }
6398 if let Some(v) = req.disabled_tools {
6399 cfg.disabled_tools = v;
6400 }
6401 if let Some(v) = req.env_headers {
6402 cfg.env_headers = v;
6403 }
6404 if let Some(v) = req.bearer_token_env_var {
6405 cfg.bearer_token_env_var = v;
6406 }
6407 if let Some(v) = req.scopes {
6408 cfg.scopes = v;
6409 }
6410 if let Some(v) = req.oauth_resource {
6411 cfg.oauth_resource = v;
6412 }
6413 }
6414
6415 async fn list_automations(
6416 State(state): State<RuntimeApiState>,
6417 ) -> Result<Json<Vec<AutomationRecord>>, ApiError> {
6418 let manager = state.automations.lock().await;
6419 let automations = manager
6420 .list_automations()
6421 .map_err(|e| ApiError::internal(format!("Failed to list automations: {e}")))?;
6422 Ok(Json(automations))
6423 }
6424
6425 async fn create_automation(
6426 State(state): State<RuntimeApiState>,
6427 Json(req): Json<CreateAutomationRequest>,
6428 ) -> Result<(StatusCode, Json<AutomationRecord>), ApiError> {
6429 let manager = state.automations.lock().await;
6430 let automation = manager
6431 .create_automation(req)
6432 .map_err(|e| ApiError::bad_request(e.to_string()))?;
6433 Ok((StatusCode::CREATED, Json(automation)))
6434 }
6435
6436 async fn get_automation(
6437 State(state): State<RuntimeApiState>,
6438 Path(id): Path<String>,
6439 ) -> Result<Json<AutomationRecord>, ApiError> {
6440 let manager = state.automations.lock().await;
6441 let automation = manager.get_automation(&id).map_err(map_automation_err)?;
6442 Ok(Json(automation))
6443 }
6444
6445 async fn update_automation(
6446 State(state): State<RuntimeApiState>,
6447 Path(id): Path<String>,
6448 Json(req): Json<UpdateAutomationRequest>,
6449 ) -> Result<Json<AutomationRecord>, ApiError> {
6450 let manager = state.automations.lock().await;
6451 let automation = manager
6452 .update_automation(&id, req)
6453 .map_err(map_automation_err)?;
6454 Ok(Json(automation))
6455 }
6456
6457 async fn delete_automation(
6458 State(state): State<RuntimeApiState>,
6459 Path(id): Path<String>,
6460 ) -> Result<Json<AutomationRecord>, ApiError> {
6461 let manager = state.automations.lock().await;
6462 let automation = manager.delete_automation(&id).map_err(map_automation_err)?;
6463 Ok(Json(automation))
6464 }
6465
6466 async fn run_automation(
6467 State(state): State<RuntimeApiState>,
6468 Path(id): Path<String>,
6469 ) -> Result<Json<AutomationRunRecord>, ApiError> {
6470 // run_now_shared drops the manager mutex across the task-manager await so
6471 // other automation endpoints stay responsive behind a slow enqueue.
6472 let run =
6473 crate::automation_manager::run_now_shared(&state.automations, &id, &state.task_manager)
6474 .await
6475 .map_err(map_automation_err)?;
6476 Ok(Json(run))
6477 }
6478
6479 async fn pause_automation(
6480 State(state): State<RuntimeApiState>,
6481 Path(id): Path<String>,
6482 ) -> Result<Json<AutomationRecord>, ApiError> {
6483 let manager = state.automations.lock().await;
6484 let automation = manager.pause_automation(&id).map_err(map_automation_err)?;
6485 Ok(Json(automation))
6486 }
6487
6488 async fn resume_automation(
6489 State(state): State<RuntimeApiState>,
6490 Path(id): Path<String>,
6491 ) -> Result<Json<AutomationRecord>, ApiError> {
6492 let manager = state.automations.lock().await;
6493 let automation = manager.resume_automation(&id).map_err(map_automation_err)?;
6494 Ok(Json(automation))
6495 }
6496
6497 async fn list_automation_runs(
6498 State(state): State<RuntimeApiState>,
6499 Path(id): Path<String>,
6500 Query(query): Query<AutomationRunsQuery>,
6501 ) -> Result<Json<Vec<AutomationRunRecord>>, ApiError> {
6502 let manager = state.automations.lock().await;
6503 let runs = manager
6504 .list_runs(&id, query.limit)
6505 .map_err(map_automation_err)?;
6506 Ok(Json(runs))
6507 }
6508
6509 #[derive(Debug, Deserialize, Default)]
6510 #[serde(rename_all = "camelCase")]
6511 struct StartOperateRequest {
6512 #[serde(default)]
6513 direction: Option<String>,
6514 /// CWC `OperateBurnRate` object, positive $/hr number, or null (unbounded).
6515 #[serde(default)]
6516 burn_rate: Option<serde_json::Value>,
6517 }
6518
6519 #[derive(Debug, Deserialize, Default)]
6520 #[serde(rename_all = "camelCase")]
6521 struct KeepAliveOperateRequest {
6522 #[serde(default)]
6523 spent_usd: Option<f64>,
6524 #[serde(default)]
6525 observed_burn_usd_per_hour: Option<f64>,
6526 #[serde(default)]
6527 credentials_present: Option<bool>,
6528 #[serde(default)]
6529 human_gated: Option<bool>,
6530 }
6531
6532 #[derive(Debug, Serialize)]
6533 struct OperateView {
6534 /// `None` until an operation is actually started — a GET before that
6535 /// must not fabricate an identity the client can never mutate.
6536 operation: Option<crate::operate::Operation>,
6537 board: String,
6538 }
6539
6540 fn operate_store() -> Result<crate::operate::OperationStore, ApiError> {
6541 crate::operate::OperationStore::open(crate::operate::default_operate_dir())
6542 .map_err(|e| ApiError::internal(format!("Failed to open operate store: {e}")))
6543 }
6544
6545 async fn operate_readiness(state: &RuntimeApiState) -> Result<(String, bool), ApiError> {
6546 let config = state.config.read().clone();
6547 let manager = state.automations.lock().await;
6548 crate::operate::keepalive_readiness(&manager, &config, None)
6549 .map_err(|error| ApiError::bad_request(format!("Operate route unavailable: {error}")))
6550 }
6551
6552 fn operate_view(operation: crate::operate::Operation) -> Json<OperateView> {
6553 Json(OperateView {
6554 board: crate::operate::render_plan_board(&operation),
6555 operation: Some(operation),
6556 })
6557 }
6558
6559 fn load_operate(
6560 store: &crate::operate::OperationStore,
6561 ) -> Result<Option<crate::operate::Operation>, ApiError> {
6562 store
6563 .load()
6564 .map_err(|e| ApiError::internal(format!("Failed to load operate: {e}")))
6565 }
6566
6567 fn parse_request_burn_rate(value: Option<&serde_json::Value>) -> Result<Option<f64>, ApiError> {
6568 Ok(crate::operate::parse_burn_rate(value)
6569 .map_err(|e| ApiError::bad_request(e.to_string()))?
6570 .map(|rate| rate.amount_usd_per_hour))
6571 }
6572
6573 async fn get_operate(State(_state): State<RuntimeApiState>) -> Result<Json<OperateView>, ApiError> {
6574 let store = operate_store()?;
6575 match load_operate(&store)? {
6576 Some(operation) => Ok(operate_view(operation)),
6577 // No operation has been started: a fabricated `Operation::new` would
6578 // mint a fresh id and timestamps on every poll — phantom records the
6579 // client can neither patch nor cancel. `operation: null` is the
6580 // stable no-operation answer.
6581 None => Ok(Json(OperateView {
6582 operation: None,
6583 board: String::new(),
6584 })),
6585 }
6586 }
6587
6588 async fn start_operate(
6589 State(state): State<RuntimeApiState>,
6590 Json(req): Json<StartOperateRequest>,
6591 ) -> Result<Json<OperateView>, ApiError> {
6592 let store = operate_store()?;
6593 let burn = parse_request_burn_rate(req.burn_rate.as_ref())?;
6594 // Keepalive first: a persisted operation without its keepalive is not
6595 // always-on, and a fresh operation has no lead plan yet — kick the first
6596 // lead run to the next scheduler tick instead of waiting out the hourly
6597 // recurrence.
6598 let config = state.config.read().clone();
6599 let (model, credentials) = {
6600 let manager = state.automations.lock().await;
6601 crate::operate::upsert_keepalive(&manager, &state.workspace, true, &config, None)
6602 .map_err(|e| ApiError::bad_request(format!("Failed to keep operate alive: {e}")))?
6603 };
6604 let operation = crate::operate::start_operation(
6605 &store,
6606 &state.workspace,
6607 req.direction,
6608 burn,
6609 credentials,
6610 &model,
6611 )
6612 .map_err(|e| ApiError::bad_request(e.to_string()))?;
6613 Ok(operate_view(operation))
6614 }
6615
6616 async fn patch_operate(
6617 State(state): State<RuntimeApiState>,
6618 Json(patch): Json<serde_json::Value>,
6619 ) -> Result<Json<OperateView>, ApiError> {
6620 let store = operate_store()?;
6621 let (model, credentials) = operate_readiness(&state).await?;
6622 // Read-merge-write under the operate store lock: a concurrent keepalive
6623 // or plan save can no longer be lost by a stale read.
6624 let direction_changed = std::cell::Cell::new(false);
6625 let operation = store
6626 .mutate(|op| {
6627 let before = op.direction.clone();
6628 crate::operate::apply_operate_patch(op, &patch)?;
6629 direction_changed.set(op.direction != before);
6630 op.set_lead_model(&model);
6631 op.credentials_present = credentials;
6632 op.project();
6633 Ok(())
6634 })
6635 .map_err(|e| {
6636 if e.to_string().contains("cancelled") {
6637 ApiError::conflict(e.to_string())
6638 } else {
6639 ApiError::bad_request(e.to_string())
6640 }
6641 })?
6642 .ok_or_else(|| ApiError::not_found("Unknown Operation."))?;
6643 // A changed direction invalidated the lead plan; pull the keepalive lead
6644 // run forward so the operation does not idle until the next recurrence.
6645 if direction_changed.get() {
6646 let manager = state.automations.lock().await;
6647 crate::operate::kick_keepalive(&manager)
6648 .map_err(|e| ApiError::internal(format!("Failed to reschedule operate: {e}")))?;
6649 }
6650 Ok(operate_view(operation))
6651 }
6652
6653 async fn keepalive_operate(
6654 State(state): State<RuntimeApiState>,
6655 Json(req): Json<KeepAliveOperateRequest>,
6656 ) -> Result<Json<OperateView>, ApiError> {
6657 let store = operate_store()?;
6658 let (model, credentials) = match req.credentials_present {
6659 Some(observed) => (None, observed),
6660 None => {
6661 let (model, credentials) = operate_readiness(&state).await?;
6662 (Some(model), credentials)
6663 }
6664 };
6665 let operation = store
6666 .mutate(|op| {
6667 if let Some(model) = &model {
6668 op.set_lead_model(model);
6669 }
6670 crate::operate::keep_alive_observation(
6671 op,
6672 req.observed_burn_usd_per_hour,
6673 req.spent_usd,
6674 Some(credentials),
6675 req.human_gated,
6676 );
6677 Ok(())
6678 })
6679 .map_err(|e| ApiError::internal(format!("Failed to keep operate alive: {e}")))?
6680 .ok_or_else(|| ApiError::not_found("Unknown Operation."))?;
6681 Ok(operate_view(operation))
6682 }
6683
6684 async fn put_operate_plan(
6685 Json(plan): Json<serde_json::Value>,
6686 ) -> Result<Json<OperateView>, ApiError> {
6687 let store = operate_store()?;
6688 let patch = serde_json::json!({ "leadPlan": plan });
6689 let operation = store
6690 .mutate(|op| crate::operate::apply_operate_patch(op, &patch))
6691 .map_err(|e| {
6692 if e.to_string().contains("cancelled") {
6693 ApiError::conflict(e.to_string())
6694 } else if e.to_string().contains("leadPlan") {
6695 ApiError::bad_request(e.to_string())
6696 } else {
6697 ApiError::internal(format!("Failed to save operate plan: {e}"))
6698 }
6699 })?
6700 .ok_or_else(|| ApiError::not_found("Unknown Operation."))?;
6701 Ok(operate_view(operation))
6702 }
6703
6704 async fn cancel_operate(
6705 State(state): State<RuntimeApiState>,
6706 ) -> Result<Json<OperateView>, ApiError> {
6707 let store = operate_store()?;
6708 let operation = crate::operate::cancel_operation(&store)
6709 .map_err(|e| ApiError::internal(format!("Failed to cancel operate: {e}")))?
6710 .ok_or_else(|| ApiError::not_found("Unknown Operation."))?;
6711 // Cancel tears down the keepalive too: an unattended hourly lead run
6712 // after cancel is pure cost.
6713 {
6714 let manager = state.automations.lock().await;
6715 crate::operate::pause_keepalive(&manager)
6716 .map_err(|e| ApiError::internal(format!("Failed to pause operate keepalive: {e}")))?;
6717 }
6718 Ok(operate_view(operation))
6719 }
6720
6721 #[derive(Debug, Deserialize)]
6722 struct OperateAutoMergeCheckRequest {
6723 repo: String,
6724 pr: String,
6725 agent: String,
6726 }
6727
6728 #[derive(Debug, Serialize)]
6729 #[serde(rename_all = "camelCase")]
6730 struct OperateAutoMergeCheckView {
6731 allow: bool,
6732 reason: Option<String>,
6733 checker: Option<String>,
6734 check_args: Vec<String>,
6735 merge_args: Vec<String>,
6736 }
6737
6738 async fn check_operate_auto_merge(
6739 State(state): State<RuntimeApiState>,
6740 Json(req): Json<OperateAutoMergeCheckRequest>,
6741 ) -> Result<Json<OperateAutoMergeCheckView>, ApiError> {
6742 crate::operate::validate_auto_merge_request(&crate::operate::AutoMergeRequest {
6743 repo: &req.repo,
6744 pr: &req.pr,
6745 role: &req.agent,
6746 })
6747 .map_err(ApiError::bad_request)?;
6748 let checker = crate::operate::discover_auto_merge_checker(&state.workspace);
6749 let repo = req.repo.clone();
6750 let pr = req.pr.clone();
6751 let agent = req.agent.clone();
6752 let checker_for_task = checker.clone();
6753 // The checker shells out synchronously (`python3 …; .status()`); run it on
6754 // the blocking pool so a slow `gh`/network wait cannot pin a Tokio worker.
6755 let decision = tokio::task::spawn_blocking(move || {
6756 crate::operate::evaluate_auto_merge(
6757 crate::operate::AutoMergeRequest {
6758 repo: &repo,
6759 pr: &pr,
6760 role: &agent,
6761 },
6762 checker_for_task.as_deref(),
6763 )
6764 })
6765 .await
6766 .map_err(|e| ApiError::internal(format!("auto-merge check join failed: {e}")))?;
6767 let (allow, reason) = match decision {
6768 crate::operate::AutoMergeDecision::Allow => (true, None),
6769 crate::operate::AutoMergeDecision::Deny { reason } => (false, Some(reason)),
6770 };
6771 Ok(Json(OperateAutoMergeCheckView {
6772 allow,
6773 reason,
6774 checker: checker.as_ref().map(|path| path.display().to_string()),
6775 check_args: crate::operate::check_auto_merge_args(&req.repo, &req.pr, &req.agent),
6776 merge_args: crate::operate::auto_merge_pr_args(&req.repo, &req.pr, &req.agent),
6777 }))
6778 }
6779
6780 async fn get_thread(
6781 State(state): State<RuntimeApiState>,
6782 Path(id): Path<String>,
6783 ) -> Result<Json<ThreadDetail>, ApiError> {
6784 let detail = state
6785 .runtime_threads
6786 .get_thread_detail(&id)
6787 .await
6788 .map_err(map_thread_err)?;
6789 Ok(Json(detail))
6790 }
6791
6792 /// Response for `GET /v1/threads/{id}/usage`.
6793 ///
6794 /// Thin adapter over `RuntimeThreadManager::aggregate_usage_for_thread`: the
6795 /// GUI's session-cost surface reads provider-aware, recorded-time pricing in
6796 /// both published currencies from the same accumulation that powers
6797 /// `/v1/usage`, instead of reimplementing rate tables client-side.
6798 #[derive(Debug, Serialize)]
6799 struct ThreadUsageResponse {
6800 thread_id: String,
6801 totals: UsageTotals,
6802 }
6803
6804 async fn get_thread_usage(
6805 State(state): State<RuntimeApiState>,
6806 Path(id): Path<String>,
6807 ) -> Result<Json<ThreadUsageResponse>, ApiError> {
6808 let totals = state
6809 .runtime_threads
6810 .aggregate_usage_for_thread(&id)
6811 .await
6812 .map_err(map_thread_err)?
6813 .combined();
6814 Ok(Json(ThreadUsageResponse {
6815 thread_id: id,
6816 totals,
6817 }))
6818 }
6819
6820 /// `GET /v1/threads/{id}/receipt` — what the thread did, built by the one
6821 /// receipt builder from the thread snapshot and its `approval.*` events
6822 /// (`docs/RECEIPTS.md`). Read-only.
6823 async fn get_thread_receipt(
6824 State(state): State<RuntimeApiState>,
6825 Path(id): Path<String>,
6826 ) -> Result<Json<crate::receipts::Receipt>, ApiError> {
6827 thread_receipt(&state, &id, None).await.map(Json)
6828 }
6829
6830 /// `GET /v1/threads/{id}/turns/{turn_id}/receipt` — the same receipt scoped
6831 /// to one turn.
6832 async fn get_turn_receipt(
6833 State(state): State<RuntimeApiState>,
6834 Path((id, turn_id)): Path<(String, String)>,
6835 ) -> Result<Json<crate::receipts::Receipt>, ApiError> {
6836 thread_receipt(&state, &id, Some(&turn_id)).await.map(Json)
6837 }
6838
6839 async fn thread_receipt(
6840 state: &RuntimeApiState,
6841 id: &str,
6842 turn: Option<&str>,
6843 ) -> Result<crate::receipts::Receipt, ApiError> {
6844 let detail = state
6845 .runtime_threads
6846 .get_thread_detail(id)
6847 .await
6848 .map_err(map_thread_err)?;
6849 let events = state
6850 .runtime_threads
6851 .events_since_async(id, None)
6852 .await
6853 .map_err(map_thread_err)?;
6854 crate::receipts::thread_receipt(&detail.thread, &detail.turns, &detail.items, &events, turn)
6855 .map_err(|error| ApiError::not_found(error.to_string()))
6856 }
6857
6858 async fn update_thread(
6859 State(state): State<RuntimeApiState>,
6860 Path(id): Path<String>,
6861 Json(req): Json<UpdateThreadRequest>,
6862 ) -> Result<Json<ThreadRecord>, ApiError> {
6863 let thread = state
6864 .runtime_threads
6865 .update_thread_with_shell_policy(
6866 &id,
6867 req,
6868 state.config_path.as_deref(),
6869 state.config_profile.as_deref(),
6870 )
6871 .await
6872 .map_err(map_thread_err)?;
6873 Ok(Json(thread))
6874 }
6875
6876 async fn resume_thread(
6877 State(state): State<RuntimeApiState>,
6878 Path(id): Path<String>,
6879 ) -> Result<Json<ThreadRecord>, ApiError> {
6880 let thread = state
6881 .runtime_threads
6882 .resume_thread(&id)
6883 .await
6884 .map_err(map_thread_err)?;
6885 Ok(Json(thread))
6886 }
6887
6888 async fn fork_thread(
6889 State(state): State<RuntimeApiState>,
6890 Path(id): Path<String>,
6891 ) -> Result<(StatusCode, Json<ThreadRecord>), ApiError> {
6892 let thread = state
6893 .runtime_threads
6894 .fork_thread_in_sessions_dir(&id, &state.sessions_dir)
6895 .await
6896 .map_err(map_thread_err)?;
6897 Ok((StatusCode::CREATED, Json(thread)))
6898 }
6899
6900 #[derive(Debug, Deserialize)]
6901 struct UndoTurnRequest {
6902 /// How many turns back to undo (default 0 = last turn only).
6903 #[serde(default)]
6904 depth: Option<usize>,
6905 }
6906
6907 #[derive(Debug, Serialize)]
6908 struct UndoTurnResponse {
6909 /// The new forked thread (with the last N turns removed).
6910 thread: ThreadRecord,
6911 /// The original user message text from the first dropped turn,
6912 /// so the GUI can pre-populate the input box.
6913 original_user_text: Option<String>,
6914 #[serde(skip_serializing_if = "Vec::is_empty")]
6915 original_user_images: Vec<codewhale_protocol::runtime::RuntimeImageInput>,
6916 }
6917
6918 async fn undo_thread_turn(
6919 State(state): State<RuntimeApiState>,
6920 Path(id): Path<String>,
6921 Json(req): Json<UndoTurnRequest>,
6922 ) -> Result<(StatusCode, Json<UndoTurnResponse>), ApiError> {
6923 let depth = req.depth.unwrap_or(0);
6924 let (forked_thread, original_user_text, original_user_images, _) = state
6925 .runtime_threads
6926 .fork_at_user_message_in_sessions_dir(&id, depth, &state.sessions_dir)
6927 .await
6928 .map_err(map_thread_err)?;
6929 Ok((
6930 StatusCode::CREATED,
6931 Json(UndoTurnResponse {
6932 thread: forked_thread,
6933 original_user_text,
6934 original_user_images,
6935 }),
6936 ))
6937 }
6938
6939 #[derive(Debug, Deserialize)]
6940 struct ForkAtTurnRequest {
6941 /// The user turn to fork at, as `GET /v1/threads/{id}` reports it. The
6942 /// fork keeps that turn and every turn before it, and drops the rest.
6943 turn_id: String,
6944 }
6945
6946 /// Fork a thread at one named user turn — the client-side "continue from this
6947 /// turn" affordance, which carries on in the new thread.
6948 ///
6949 /// The fork keeps the named turn and everything before it, so the branch point
6950 /// is the answer a person is looking at rather than the question above it;
6951 /// naming the last turn keeps the whole conversation. The receipt is
6952 /// deliberately the undo receipt: the first dropped turn's prompt comes back
6953 /// with the new thread, so a client can put what was asked next into the
6954 /// composer and let the person edit or replace it. The source thread, its
6955 /// session document and the workspace are untouched — no file rollback happens
6956 /// here, because the branch that was left behind shares the workspace.
6957 async fn fork_thread_at_turn(
6958 State(state): State<RuntimeApiState>,
6959 Path(id): Path<String>,
6960 Json(req): Json<ForkAtTurnRequest>,
6961 ) -> Result<(StatusCode, Json<UndoTurnResponse>), ApiError> {
6962 let (forked_thread, original_user_text, original_user_images, _) = state
6963 .runtime_threads
6964 .fork_at_user_turn_in_sessions_dir(&id, &req.turn_id, &state.sessions_dir)
6965 .await
6966 .map_err(map_thread_err)?;
6967 Ok((
6968 StatusCode::CREATED,
6969 Json(UndoTurnResponse {
6970 thread: forked_thread,
6971 original_user_text,
6972 original_user_images,
6973 }),
6974 ))
6975 }
6976
6977 /// Result of the snapshot-based file rollback step of patch-undo, reported
6978 /// alongside the new forked thread.
6979 #[derive(Debug, Serialize)]
6980 struct PatchUndoResult {
6981 /// Whether files were restored from a snapshot.
6982 files_restored: bool,
6983 /// Human-readable summary: one `<action> <path>` line per restored file,
6984 /// or why nothing needed restoring.
6985 summary: Option<String>,
6986 /// Label of the pre-turn snapshot the files went back to (e.g.
6987 /// "pre-turn:3: fix the parser").
6988 snapshot_label: Option<String>,
6989 }
6990
6991 #[derive(Debug, Serialize)]
6992 struct PatchUndoResponse {
6993 /// Result of the snapshot-based file rollback step.
6994 patch_result: PatchUndoResult,
6995 /// The new forked thread (with the last turn removed).
6996 thread: ThreadRecord,
6997 /// The original user text from the removed turn (for re-editing).
6998 original_user_text: Option<String>,
6999 #[serde(skip_serializing_if = "Vec::is_empty")]
7000 original_user_images: Vec<codewhale_protocol::runtime::RuntimeImageInput>,
7001 }
7002
7003 async fn patch_undo_thread_turn(
7004 State(state): State<RuntimeApiState>,
7005 Path(id): Path<String>,
7006 Json(req): Json<UndoTurnRequest>,
7007 ) -> Result<(StatusCode, Json<PatchUndoResponse>), ApiError> {
7008 let depth = req.depth.unwrap_or(0);
7009 // Admission first, then the thread record under it: trust, session
7010 // binding and workspace are the values that hold while files change.
7011 // Active turns in an overlapping workspace are rejected (409). The wait
7012 // for admission stays on the request so a client that gives up while
7013 // queued cancels its undo instead of leaving it queued behind the next
7014 // one and walking the workspace back twice.
7015 let (reservation, thread) = state
7016 .runtime_threads
7017 .thread_restore_guard(&id)
7018 .await
7019 .map_err(map_thread_err)?;
7020 // Once admitted, own the operation even when the HTTP caller disconnects:
7021 // the reservation must outlive both the file mutation and the fork
7022 // publication, so a dropped connection cannot release it mid-Git.
7023 #[cfg(test)]
7024 let env_ticket = crate::test_support::env_scope_ticket();
7025 tokio::spawn(async move {
7026 let reservation = reservation;
7027 // Validate depth/history before touching any file, so an invalid
7028 // undo request cannot leave a half-applied workspace.
7029 let prepared = state
7030 .runtime_threads
7031 .prepare_fork_at_user_message_in_sessions_dir(&id, depth, &state.sessions_dir)
7032 .await
7033 .map_err(map_thread_err)?;
7034 // File rollback is a workspace mutation, so it needs the trust the
7035 // TUI's `/undo` requires. Read from the thread's own record: the
7036 // client does not get to assert it.
7037 let trusted = thread.trust_mode || thread.auto_approve;
7038 let workspace = thread.workspace.clone();
7039 // The restore points come from the dropped turns' own records, not
7040 // from the thread's saved-session binding or a scan of the shared
7041 // snapshot store: those are the snapshots this thread owns.
7042 let dropped_turns = prepared.dropped_turns().to_vec();
7043 // Step 1: snapshot-based file rollback. The `?` is deliberate: a
7044 // refusal or a failed restore aborts *before* the conversation is
7045 // forked, so the turn never disappears while its file changes stay.
7046 let patch_result = tokio::task::spawn_blocking(move || {
7047 #[cfg(test)]
7048 let _membership = crate::test_support::join_env_scope(env_ticket);
7049 patch_undo_workspace_files(&workspace, &dropped_turns, trusted)
7050 })
7051 .await
7052 .map_err(|e| ApiError::internal(format!("Patch undo task failed: {e}")))??;
7053 // Step 2: publish the already-validated fork.
7054 let (forked_thread, original_user_text, original_user_images, _) = state
7055 .runtime_threads
7056 .publish_prepared_fork(prepared)
7057 .await
7058 .map_err(|error| {
7059 if patch_result.files_restored {
7060 ApiError::internal(format!(
7061 "Workspace files were restored from snapshot {}, but the conversation fork could not be saved: {error}. The original thread still holds the undone turn; the `pre-restore:` safety snapshot holds the files as they were before this undo.",
7062 patch_result
7063 .snapshot_label
7064 .as_deref()
7065 .unwrap_or("(unknown)")
7066 ))
7067 } else {
7068 map_thread_err(error)
7069 }
7070 })?;
7071 drop(reservation);
7072 Ok((
7073 StatusCode::CREATED,
7074 Json(PatchUndoResponse {
7075 patch_result,
7076 thread: forked_thread,
7077 original_user_text,
7078 original_user_images,
7079 }),
7080 ))
7081 })
7082 .await
7083 .map_err(|e| ApiError::internal(format!("Patch undo task failed: {e}")))?
7084 }
7085
7086 /// Error codes a patch-undo refusal carries in `error.code`. A client offers a
7087 /// conversation-only `POST /v1/threads/{id}/undo` for the first four.
7088 const PATCH_UNDO_NO_RESTORE_POINT: &str = "restore_point_unavailable";
7089 const PATCH_UNDO_RESTORE_POINT_PRUNED: &str = "restore_point_pruned";
7090 const PATCH_UNDO_PATH_NOT_SNAPSHOTTED: &str = "path_not_snapshotted";
7091 const PATCH_UNDO_WORKSPACE_CHANGED: &str = "workspace_changed_since_turn";
7092 const PATCH_UNDO_UNTRUSTED: &str = "restore_requires_trust";
7093 const PATCH_UNDO_WORKSPACE_UNAVAILABLE: &str = "workspace_unavailable";
7094
7095 /// One pre-turn → post-turn window of a dropped turn, resolved to the trees
7096 /// the snapshot store still holds.
7097 struct UndoSegment {
7098 turn_id: String,
7099 pre: crate::snapshot::SnapshotId,
7100 post: crate::snapshot::SnapshotId,
7101 pre_label: String,
7102 /// Paths that changed in the window only while one of the turn's own
7103 /// tool calls was running: the turn's changes.
7104 owned: std::collections::BTreeSet<PathBuf>,
7105 /// Paths that changed in the window while none of the turn's tools could
7106 /// have written them: someone else's changes.
7107 foreign: std::collections::BTreeSet<PathBuf>,
7108 }
7109
7110 /// Pair each `pre_turn` receipt of a turn with the `post_turn` receipt that
7111 /// closes it, in recorded order, as index ranges into `snapshots` (the
7112 /// `tool`/`post_tool` receipts between them are the window's inner spans). A
7113 /// turn can hold more than one window (a shell turn and a model turn under
7114 /// one runtime turn); a `post_turn` with no open window cannot belong to this
7115 /// turn and is skipped. `None` means a window the engine never closed (the
7116 /// turn died before its post-turn snapshot) or no window at all.
7117 fn turn_snapshot_windows(
7118 snapshots: &[crate::snapshot::WorkspaceSnapshotRef],
7119 ) -> Option<Vec<std::ops::RangeInclusive<usize>>> {
7120 use crate::snapshot::WorkspaceSnapshotKind;
7121 let mut windows = Vec::new();
7122 let mut open = None;
7123 for (index, snapshot) in snapshots.iter().enumerate() {
7124 match snapshot.kind {
7125 WorkspaceSnapshotKind::PreTurn => {
7126 if open.is_some() {
7127 return None;
7128 }
7129 open = Some(index);
7130 }
7131 WorkspaceSnapshotKind::PostTurn => {
7132 if let Some(pre) = open.take() {
7133 windows.push(pre..=index);
7134 }
7135 }
7136 WorkspaceSnapshotKind::Tool | WorkspaceSnapshotKind::PostTool => {}
7137 }
7138 }
7139 (open.is_none() && !windows.is_empty()).then_some(windows)
7140 }
7141
7142 /// A path a file tool declared it writes, as a workspace-relative path the
7143 /// snapshots would hold, or `None` when it is outside the workspace.
7144 fn declared_write_path(workspace: &FsPath, raw: &str) -> Option<PathBuf> {
7145 use std::path::Component;
7146 let candidate = FsPath::new(raw);
7147 let rel = if candidate.is_absolute() {
7148 let canonical_workspace = workspace.canonicalize().ok();
7149 let canonical_candidate = candidate
7150 .parent()
7151 .and_then(|parent| parent.canonicalize().ok())
7152 .zip(candidate.file_name())
7153 .map(|(parent, name)| parent.join(name));
7154 [Some(workspace), canonical_workspace.as_deref()]
7155 .into_iter()
7156 .flatten()
7157 .find_map(|root| {
7158 candidate
7159 .strip_prefix(root)
7160 .ok()
7161 .or_else(|| canonical_candidate.as_deref()?.strip_prefix(root).ok())
7162 .map(FsPath::to_path_buf)
7163 })?
7164 } else {
7165 candidate.to_path_buf()
7166 };
7167 let rel: PathBuf = rel
7168 .components()
7169 .filter(|component| !matches!(component, Component::CurDir))
7170 .collect();
7171 crate::snapshot::workspace_relative_path(workspace, rel.to_str()?)
7172 }
7173
7174 /// Who could have changed the workspace in the span after one receipt.
7175 enum SpanWriter {
7176 /// None of the turn's tool calls was running.
7177 Nobody,
7178 /// A call whose writes are not declared (a shell command, a program).
7179 Undeclared,
7180 /// A file tool that declared exactly these paths.
7181 Declared(std::collections::BTreeSet<PathBuf>),
7182 }
7183
7184 /// Split a window's changes into the turn's own and everyone else's, from
7185 /// the spans its receipts bound: a path belongs to the turn only if it
7186 /// changed while one of the turn's tool calls was running and, for a file
7187 /// tool, is one the call declared. A span whose changes were not recorded
7188 /// (a snapshot in it failed) cannot be attributed and fails closed.
7189 fn attribute_window(
7190 workspace: &FsPath,
7191 turn_id: &str,
7192 receipts: &[crate::snapshot::WorkspaceSnapshotRef],
7193 ) -> Result<
7194 (
7195 std::collections::BTreeSet<PathBuf>,
7196 std::collections::BTreeSet<PathBuf>,
7197 ),
7198 ApiError,
7199 > {
7200 use crate::snapshot::WorkspaceSnapshotKind;
7201 let mut owned = std::collections::BTreeSet::new();
7202 let mut foreign = std::collections::BTreeSet::new();
7203 // Undeclared calls whose `post_tool` receipt is still ahead: everything
7204 // up to it (a program's nested calls included) is theirs.
7205 let mut open_undeclared: Vec<&str> = Vec::new();
7206 let mut writer = SpanWriter::Nobody;
7207 for (index, receipt) in receipts.iter().enumerate() {
7208 if index > 0 {
7209 let Some(changed) = receipt.changed_paths.as_ref() else {
7210 return Err(no_restore_point(
7211 turn_id,
7212 "has an incomplete record of what changed while it ran (a snapshot during the turn failed), so its changes cannot be told apart from anyone else's",
7213 ));
7214 };
7215 for path in changed {
7216 let path = PathBuf::from(path);
7217 match &writer {
7218 SpanWriter::Undeclared => {
7219 owned.insert(path);
7220 }
7221 SpanWriter::Declared(declared) if declared.contains(&path) => {
7222 owned.insert(path);
7223 }
7224 SpanWriter::Declared(_) | SpanWriter::Nobody => {
7225 foreign.insert(path);
7226 }
7227 }
7228 }
7229 }
7230 match receipt.kind {
7231 WorkspaceSnapshotKind::Tool => {
7232 if receipt.write_paths.is_none()
7233 && let Some(call) = receipt.tool_call_id.as_deref()
7234 && receipts[index + 1..].iter().any(|later| {
7235 later.kind == WorkspaceSnapshotKind::PostTool
7236 && later.tool_call_id.as_deref() == Some(call)
7237 })
7238 {
7239 open_undeclared.push(call);
7240 }
7241 }
7242 WorkspaceSnapshotKind::PostTool => {
7243 if let Some(call) = receipt.tool_call_id.as_deref() {
7244 open_undeclared.retain(|open| *open != call);
7245 }
7246 }
7247 WorkspaceSnapshotKind::PreTurn | WorkspaceSnapshotKind::PostTurn => {}
7248 }
7249 writer = if !open_undeclared.is_empty() {
7250 SpanWriter::Undeclared
7251 } else {
7252 match receipt.kind {
7253 // A shell turn: its command runs from the pre-turn snapshot.
7254 WorkspaceSnapshotKind::PreTurn if receipt.tool_call_id.is_some() => {
7255 SpanWriter::Undeclared
7256 }
7257 WorkspaceSnapshotKind::Tool => match receipt.write_paths.as_ref() {
7258 Some(paths) => SpanWriter::Declared(
7259 paths
7260 .iter()
7261 .filter_map(|raw| declared_write_path(workspace, raw))
7262 .collect(),
7263 ),
7264 None => SpanWriter::Undeclared,
7265 },
7266 _ => SpanWriter::Nobody,
7267 }
7268 };
7269 }
7270 Ok((owned, foreign))
7271 }
7272
7273 fn no_restore_point(turn_id: &str, why: &str) -> ApiError {
7274 ApiError::conflict(format!(
7275 "Turn {turn_id} {why}, so its workspace changes cannot be restored; nothing was changed. \
7276 Use POST /v1/threads/{{id}}/undo for a conversation-only undo."
7277 ))
7278 .with_code(PATCH_UNDO_NO_RESTORE_POINT)
7279 }
7280
7281 /// Roll the workspace files back to where they were before the first dropped
7282 /// turn — only the files the dropped turns changed, and only when nothing
7283 /// else changed them since.
7284 ///
7285 /// # Ownership
7286 ///
7287 /// The restore points are the `pre_turn`/`post_turn` receipts recorded on the
7288 /// dropped turns themselves (`TurnRecord::workspace_snapshots`), resolved by
7289 /// tree and session tag against the snapshot store. Nothing is selected by
7290 /// scanning the shared store, so another thread's (or the TUI's) snapshots in
7291 /// the same workspace are never candidates, and a fork restores the turns it
7292 /// inherited because it carries their records.
7293 ///
7294 /// # What is restored
7295 ///
7296 /// For each dropped turn's window, the paths that differ between its
7297 /// pre-turn and post-turn snapshots are candidates, and each must be the
7298 /// turn's own: it changed only while one of the turn's tool calls was running
7299 /// (the engine bounds every call that may write with a `tool` and a
7300 /// `post_tool` snapshot and records what changed in each span), and, for a
7301 /// file tool, it is a path the call declared. A path that changed while none
7302 /// of the turn's tools could have written it — another thread, an editor, a
7303 /// background process — is someone else's change: the undo is refused rather
7304 /// than revert it. Each of the turn's paths goes back to its content before
7305 /// the first dropped turn that changed it, and nothing outside that set is
7306 /// touched, so later work survives. The whole turn goes, not just its last
7307 /// write.
7308 ///
7309 /// A path a file tool declared that the snapshots cannot hold (ignored by
7310 /// `.gitignore` or the built-in exclusions, or outside the workspace) is
7311 /// refused too: no snapshot can put it back, so "nothing to restore" would
7312 /// be a lie.
7313 ///
7314 /// # The rollback contract
7315 ///
7316 /// `Ok` is a decision the conversation fork may proceed on: either the files
7317 /// were restored, or there was *provably* nothing to restore (the dropped
7318 /// turns ran here without tools, changed no files, or the files are back at
7319 /// their pre-turn state). `Err` aborts the whole undo, and the caller must not
7320 /// fork either: a turn that has no recorded restore point, a pruned restore
7321 /// point, or a path changed since the turn is a `409` with a stable
7322 /// `error.code`, never a `201` that forks while the files stay changed.
7323 ///
7324 /// `trusted` mirrors the gate the TUI's `patch_undo()` applies
7325 /// (`yolo || trust_mode`), evaluated once a real change is known.
7326 fn patch_undo_workspace_files(
7327 workspace: &FsPath,
7328 dropped_turns: &[crate::runtime_threads::DroppedTurnSnapshots],
7329 trusted: bool,
7330 ) -> Result<PatchUndoResult, ApiError> {
7331 // An unreadable workspace directory (unmounted volume, disconnected
7332 // share, permissions) proves nothing about the files a turn changed, so
7333 // the conversation is not forked away from them.
7334 if !workspace.is_dir() {
7335 return Err(ApiError::conflict(format!(
7336 "Workspace directory {} is not available; mount or restore it before undoing files, or use /undo for a conversation-only undo.",
7337 workspace.display()
7338 ))
7339 .with_code(PATCH_UNDO_WORKSPACE_UNAVAILABLE));
7340 }
7341
7342 // Which windows matter. A turn that ran no tool changed no file; a turn
7343 // that did must have recorded where it started and ended.
7344 let mut windows = Vec::new();
7345 for turn in dropped_turns {
7346 if !turn.may_change_files {
7347 continue;
7348 }
7349 if turn.snapshots.is_empty() {
7350 return Err(no_restore_point(
7351 &turn.turn_id,
7352 "has no recorded workspace restore point (it predates restore-point receipts, was imported from a saved session, or ran with snapshots off or unavailable)",
7353 ));
7354 }
7355 let Some(turn_windows) = turn_snapshot_windows(&turn.snapshots) else {
7356 return Err(no_restore_point(
7357 &turn.turn_id,
7358 "has no complete pre-turn/post-turn restore point (its snapshot failed or the turn stopped before it was taken)",
7359 ));
7360 };
7361 windows.extend(
7362 turn_windows
7363 .into_iter()
7364 .map(|range| (turn, &turn.snapshots[range])),
7365 );
7366 }
7367 if windows.is_empty() {
7368 return Ok(PatchUndoResult {
7369 files_restored: false,
7370 summary: Some(
7371 "The undone turn(s) ran no tools, so they changed no workspace files; nothing to restore."
7372 .to_string(),
7373 ),
7374 snapshot_label: None,
7375 });
7376 }
7377
7378 // Every repository failure is operational and aborts: "nothing to
7379 // restore" cannot be proven while Git is unavailable.
7380 let repo = crate::snapshot::SnapshotRepo::open_or_init(workspace).map_err(|e| {
7381 ApiError::internal(format!(
7382 "Snapshot repo unavailable; conversation preserved: {e}"
7383 ))
7384 })?;
7385 // Resolve by id against the whole store — no listing cap, so an old but
7386 // retained restore point is never mistaken for a pruned one.
7387 let listed = repo
7388 .list(usize::MAX)
7389 .map_err(|e| ApiError::internal(format!("Failed to list snapshots: {e}")))?;
7390 let resolve = |turn_id: &str,
7391 receipt: &crate::snapshot::WorkspaceSnapshotRef|
7392 -> Result<(crate::snapshot::SnapshotId, String), ApiError> {
7393 listed
7394 .iter()
7395 .find(|snapshot| receipt.matches(snapshot))
7396 .map(|snapshot| (snapshot.tree.clone(), snapshot.label.clone()))
7397 .ok_or_else(|| {
7398 ApiError::conflict(format!(
7399 "The {} restore point of turn {turn_id} is no longer in the snapshot store (pruned, or its session tag changed), so its workspace changes cannot be restored; nothing was changed. Use POST /v1/threads/{{id}}/undo for a conversation-only undo.",
7400 receipt.kind.label_prefix().trim_end_matches(':')
7401 ))
7402 .with_code(PATCH_UNDO_RESTORE_POINT_PRUNED)
7403 })
7404 };
7405
7406 // Every path a dropped file-tool call declared must be one the snapshots
7407 // hold; otherwise its change is invisible to them and cannot be undone.
7408 let mut not_snapshotted = std::collections::BTreeSet::new();
7409 for turn in dropped_turns.iter().filter(|turn| turn.may_change_files) {
7410 let receipt_writes = turn
7411 .snapshots
7412 .iter()
7413 .filter(|receipt| {
7414 receipt
7415 .tool_call_id
7416 .as_ref()
7417 .is_none_or(|call| !turn.unrun_tool_calls.contains(call))
7418 })
7419 .filter_map(|receipt| receipt.write_paths.as_ref())
7420 .flatten();
7421 for raw in turn.declared_writes.iter().chain(receipt_writes) {
7422 let covered = match declared_write_path(workspace, raw) {
7423 Some(rel) => !repo.path_is_excluded(&rel).map_err(|e| {
7424 ApiError::internal(format!(
7425 "Failed to check snapshot coverage; conversation preserved: {e}"
7426 ))
7427 })?,
7428 None => false,
7429 };
7430 if !covered {
7431 not_snapshotted.insert(raw.clone());
7432 }
7433 }
7434 }
7435 if !not_snapshotted.is_empty() {
7436 return Err(ApiError::conflict(format!(
7437 "The undone turn(s) wrote {}, which workspace snapshots do not hold (ignored by .gitignore or the built-in snapshot exclusions, or outside the workspace), so those changes cannot be restored; nothing was changed. Use POST /v1/threads/{{id}}/undo for a conversation-only undo and restore those files yourself.",
7438 not_snapshotted.into_iter().collect::<Vec<_>>().join(", ")
7439 ))
7440 .with_code(PATCH_UNDO_PATH_NOT_SNAPSHOTTED));
7441 }
7442
7443 let mut segments = Vec::with_capacity(windows.len());
7444 for (turn, receipts) in windows {
7445 let (pre, pre_label) = resolve(&turn.turn_id, &receipts[0])?;
7446 let (post, _) = resolve(&turn.turn_id, &receipts[receipts.len() - 1])?;
7447 let (owned, foreign) = attribute_window(workspace, &turn.turn_id, receipts)?;
7448 segments.push(UndoSegment {
7449 turn_id: turn.turn_id.clone(),
7450 pre,
7451 post,
7452 pre_label,
7453 owned,
7454 foreign,
7455 });
7456 }
7457
7458 let compare_err = |e: std::io::Error| {
7459 if e.kind() == std::io::ErrorKind::InvalidInput {
7460 ApiError::conflict(format!(
7461 "A path the undone turn(s) changed cannot be restored file by file: {e}. Nothing was changed; use /restore for a whole-workspace rollback."
7462 ))
7463 .with_code(PATCH_UNDO_WORKSPACE_CHANGED)
7464 } else {
7465 ApiError::internal(format!(
7466 "Failed to compare snapshots; conversation preserved: {e}"
7467 ))
7468 }
7469 };
7470
7471 // path -> (content to restore, content the dropped turns left)
7472 let mut plan: std::collections::BTreeMap<
7473 PathBuf,
7474 (crate::snapshot::SnapshotId, crate::snapshot::SnapshotId),
7475 > = std::collections::BTreeMap::new();
7476 for segment in &segments {
7477 let changed = repo
7478 .changed_paths_between(&segment.pre, &segment.post)
7479 .map_err(compare_err)?;
7480 // A path someone else changed while the turn ran cannot be told
7481 // apart from the turn's own change to it, and reverting it would
7482 // erase their work: refuse instead of guessing.
7483 let not_owned: Vec<String> = changed
7484 .iter()
7485 .filter(|path| segment.foreign.contains(*path) || !segment.owned.contains(*path))
7486 .map(|path| path.display().to_string())
7487 .collect();
7488 if !not_owned.is_empty() {
7489 return Err(ApiError::conflict(format!(
7490 "{} changed while turn {} ran but outside its own tool calls (another thread, an editor or a background process), so undoing the turn would revert changes it did not make; nothing was changed. Revert the turn's files individually with file-revert, or use /undo for a conversation-only undo.",
7491 not_owned.join(", "),
7492 segment.turn_id
7493 ))
7494 .with_code(PATCH_UNDO_WORKSPACE_CHANGED));
7495 }
7496 for path in changed {
7497 match plan.get_mut(&path) {
7498 None => {
7499 plan.insert(path, (segment.pre.clone(), segment.post.clone()));
7500 }
7501 Some((_, left)) => {
7502 // Between two dropped turns that both changed this path,
7503 // something else changed it too; restoring the earlier
7504 // content would erase that change.
7505 if !repo
7506 .path_same_in_snapshots(left, &segment.pre, &path)
7507 .map_err(compare_err)?
7508 {
7509 return Err(ApiError::conflict(format!(
7510 "'{}' was changed outside turn {} between the undone turns; undoing would erase that change. Nothing was changed.",
7511 path.display(),
7512 segment.turn_id
7513 ))
7514 .with_code(PATCH_UNDO_WORKSPACE_CHANGED));
7515 }
7516 *left = segment.post.clone();
7517 }
7518 }
7519 }
7520 }
7521
7522 // Compare each changed path with the workspace now: already back at its
7523 // pre-turn content (skip), still as the turns left it (restore), or
7524 // changed since by someone else (refuse — never clobber later work).
7525 let mut to_restore = Vec::new();
7526 let mut changed_since = Vec::new();
7527 for (path, (before, after)) in &plan {
7528 if repo
7529 .path_matches_snapshot(before, path)
7530 .map_err(compare_err)?
7531 {
7532 continue;
7533 }
7534 if repo
7535 .path_matches_snapshot(after, path)
7536 .map_err(compare_err)?
7537 {
7538 to_restore.push((path.clone(), before.clone(), after.clone()));
7539 } else {
7540 changed_since.push(path.display().to_string());
7541 }
7542 }
7543 if !changed_since.is_empty() {
7544 return Err(ApiError::conflict(format!(
7545 "These files changed after the undone turn(s): {}. Undoing would overwrite those changes; nothing was changed. Revert individual files with file-revert, or use /undo for a conversation-only undo.",
7546 changed_since.join(", ")
7547 ))
7548 .with_code(PATCH_UNDO_WORKSPACE_CHANGED));
7549 }
7550 let first = &segments[0];
7551 if to_restore.is_empty() {
7552 return Ok(PatchUndoResult {
7553 files_restored: false,
7554 summary: Some(if plan.is_empty() {
7555 "The undone turn(s) left workspace files unchanged; nothing to restore.".to_string()
7556 } else {
7557 format!(
7558 "The files the undone turn(s) changed are already at their state before turn {}; nothing to restore.",
7559 first.turn_id
7560 )
7561 }),
7562 snapshot_label: None,
7563 });
7564 }
7565
7566 // Restoring is a workspace mutation. Gate it exactly where the TUI gates
7567 // it — after a real, owned change is known — so the two surfaces cannot
7568 // drift into "one refuses, the other half-undoes".
7569 if !trusted {
7570 return Err(ApiError::conflict(
7571 "Refusing to undo workspace files outside trusted mode. \
7572 Turn on /trust or switch this thread to Full Access, then undo again.",
7573 )
7574 .with_code(PATCH_UNDO_UNTRUSTED));
7575 }
7576
7577 let restore_plan: Vec<(PathBuf, crate::snapshot::SnapshotId)> = to_restore
7578 .iter()
7579 .map(|(path, before, _)| (path.clone(), before.clone()))
7580 .collect();
7581 let short = &first.pre.as_str()[..first.pre.as_str().len().min(12)];
7582 let outcomes = repo
7583 .restore_path_plan(&restore_plan, &format!("pre-restore:{short}"), true, || {
7584 // Re-verify immediately before the first mutation, after the
7585 // safety snapshot: a write that landed meanwhile is refused.
7586 for (path, _, after) in &to_restore {
7587 if !repo.path_matches_snapshot(after, path)? {
7588 return Err(std::io::Error::new(
7589 std::io::ErrorKind::WouldBlock,
7590 format!(
7591 "'{}' changed while the undo was being prepared; nothing was changed.",
7592 path.display()
7593 ),
7594 ));
7595 }
7596 }
7597 Ok(())
7598 })
7599 .map_err(|e| match e.kind() {
7600 std::io::ErrorKind::WouldBlock => {
7601 ApiError::conflict(e.to_string()).with_code(PATCH_UNDO_WORKSPACE_CHANGED)
7602 }
7603 std::io::ErrorKind::InvalidInput => compare_err(e),
7604 _ => ApiError::internal(format!("Restore failed: {e}")),
7605 })?;
7606
7607 let lines: Vec<String> = outcomes
7608 .iter()
7609 .map(|outcome| format!("{} {}", outcome.action.as_str(), outcome.path.display()))
7610 .collect();
7611 Ok(PatchUndoResult {
7612 files_restored: true,
7613 summary: Some(format!(
7614 "Restored {} file(s) to their state before turn {} (snapshot '{}'):\n{}",
7615 outcomes.len(),
7616 first.turn_id,
7617 first.pre_label,
7618 lines.join("\n")
7619 )),
7620 snapshot_label: Some(first.pre_label.clone()),
7621 })
7622 }
7623
7624 #[derive(Debug, Deserialize)]
7625 struct RevertThreadFileRequest {
7626 /// The single file to restore, relative to the thread's workspace.
7627 /// Absolute paths inside the workspace are accepted and normalized.
7628 path: String,
7629 /// Exact pre-tool/pre-turn restore point from the change the user
7630 /// selected: a commit id from `GET /v1/snapshots`, or the `snapshot_id` /
7631 /// `tree_id` of a receipt in the thread's `workspace_snapshots`.
7632 snapshot_id: String,
7633 /// SHA-256 of the bytes reviewed by the client, or `absent` for deletion.
7634 expected_hash: String,
7635 }
7636
7637 #[derive(Debug, Serialize)]
7638 struct RevertThreadFileResponse {
7639 /// Workspace-relative path that was restored.
7640 path: String,
7641 /// What the restore did to the working tree: `modified`, `recreated`, or
7642 /// `removed`.
7643 action: String,
7644 /// Snapshot the file came from.
7645 snapshot_id: String,
7646 snapshot_label: String,
7647 }
7648
7649 /// Restore one deliberately selected file revision.
7650 ///
7651 /// The file-scoped counterpart of `patch-undo`. Where `patch-undo` checks out
7652 /// a whole snapshot tree, this restores exactly one regular file, so unrelated
7653 /// working-tree changes are never rolled back. The client names the exact
7654 /// `tool:`/`pre-turn:` snapshot from the change record it displayed and the
7655 /// hash of the bytes it reviewed; the server never guesses a "newest differing"
7656 /// snapshot, because an unrelated newer snapshot can erase later user edits.
7657 ///
7658 /// Ownership: only the `tool:`/`pre-turn:` restore points recorded on this
7659 /// thread's own turns (`TurnRecord::workspace_snapshots`, fork-inherited turns
7660 /// included) are candidates, never another thread's or the TUI's snapshots in
7661 /// the same workspace, and the thread must be in trusted mode or Full Access. Nothing to revert is a `409`, not a silent
7662 /// success, so the GUI can tell the user why the button did nothing.
7663 async fn revert_thread_file(
7664 State(state): State<RuntimeApiState>,
7665 Path(id): Path<String>,
7666 Json(req): Json<RevertThreadFileRequest>,
7667 ) -> Result<Json<RevertThreadFileResponse>, ApiError> {
7668 if !snapshot_id_is_well_formed(&req.snapshot_id) {
7669 return Err(ApiError::bad_request(
7670 "snapshot_id must be the exact hexadecimal id reported by GET /v1/snapshots",
7671 ));
7672 }
7673 if !expected_hash_is_well_formed(&req.expected_hash) {
7674 return Err(ApiError::bad_request(
7675 "expected_hash must be `sha256:<64 lowercase hex digits>` of the reviewed file bytes, or `absent` for a file the client saw as deleted",
7676 ));
7677 }
7678 // Admission first, then the thread record under it. Active turns in an
7679 // overlapping workspace are rejected instead of raced.
7680 let (reservation, thread) = state
7681 .runtime_threads
7682 .thread_restore_guard(&id)
7683 .await
7684 .map_err(map_thread_err)?;
7685 if !(thread.trust_mode || thread.auto_approve) {
7686 return Err(ApiError::conflict(
7687 "Refusing to restore workspace files outside trusted mode. Turn on /trust or switch this thread to Full Access, then retry.",
7688 ));
7689 }
7690 // The restore points this thread owns: the receipts on its own turns
7691 // (including turns a fork cloned). Read under the restore reservation, so
7692 // no turn is recording one meanwhile.
7693 let owned = state
7694 .runtime_threads
7695 .thread_workspace_snapshots(&thread.id)
7696 .map_err(map_thread_err)?;
7697 let workspace = thread.workspace;
7698 // The worker owns the reservation: a client disconnect cannot release it
7699 // while Git is still changing files. Snapshot listing, diffing and
7700 // checkout all shell out to git; keep that off the async workers.
7701 #[cfg(test)]
7702 let env_ticket = crate::test_support::env_scope_ticket();
7703 let response = tokio::task::spawn_blocking(move || {
7704 #[cfg(test)]
7705 let _membership = crate::test_support::join_env_scope(env_ticket);
7706 let _reservation = reservation;
7707 revert_file_from_snapshot(&workspace, &owned, &req)
7708 })
7709 .await
7710 .map_err(|e| ApiError::internal(format!("file restore task failed: {e}")))??;
7711 Ok(Json(response))
7712 }
7713
7714 fn snapshot_id_is_well_formed(id: &str) -> bool {
7715 crate::snapshot::SnapshotId::is_well_formed(id)
7716 }
7717
7718 fn expected_hash_is_well_formed(hash: &str) -> bool {
7719 hash == "absent"
7720 || hash.strip_prefix("sha256:").is_some_and(|digest| {
7721 digest.len() == 64
7722 && digest
7723 .bytes()
7724 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
7725 })
7726 }
7727
7728 fn revert_file_from_snapshot(
7729 workspace: &FsPath,
7730 owned: &[crate::snapshot::WorkspaceSnapshotRef],
7731 req: &RevertThreadFileRequest,
7732 ) -> Result<RevertThreadFileResponse, ApiError> {
7733 // Every caller-supplied path passes through this one gate. It accepts a
7734 // workspace-relative path or an absolute path inside the workspace and
7735 // rejects everything else (`..`, empty, or outside the work tree). The
7736 // name is used literally: brackets, spaces and glob characters are part
7737 // of the filename, never a pattern.
7738 let rel = crate::snapshot::workspace_relative_path(workspace, &req.path).ok_or_else(|| {
7739 ApiError::bad_request(format!(
7740 "path must name a regular file inside the thread workspace {}; got '{}'",
7741 workspace.display(),
7742 req.path
7743 ))
7744 })?;
7745 if !workspace.is_dir() {
7746 return Err(ApiError::conflict(format!(
7747 "Workspace directory {} is not available; mount or restore it before restoring files.",
7748 workspace.display()
7749 )));
7750 }
7751 let repo = crate::snapshot::SnapshotRepo::open_or_init(workspace)
7752 .map_err(|e| ApiError::internal(format!("Snapshot repo unavailable: {e}")))?;
7753 repo.validate_restore_file(&rel)
7754 .map_err(map_file_restore_err)?;
7755 let snapshots = repo
7756 .list(usize::MAX)
7757 .map_err(|e| ApiError::internal(format!("Failed to list snapshots: {e}")))?;
7758 // Exact identity only: the snapshot must still exist, be a tool/pre-turn
7759 // restore point recorded on one of this thread's turns, and still carry
7760 // the session tag it was recorded with. The client may name it by the
7761 // commit id `GET /v1/snapshots` lists now, or by the `snapshot_id` or
7762 // `tree_id` its turn record holds (a prune rewrites commit ids but keeps
7763 // trees). A foreign, unrecorded or pruned id is a conflict the client
7764 // resolves by refreshing its change record.
7765 let restore_points: Vec<&crate::snapshot::WorkspaceSnapshotRef> = owned
7766 .iter()
7767 .filter(|receipt| {
7768 matches!(
7769 receipt.kind,
7770 crate::snapshot::WorkspaceSnapshotKind::Tool
7771 | crate::snapshot::WorkspaceSnapshotKind::PreTurn
7772 )
7773 })
7774 .collect();
7775 let target = match snapshots
7776 .iter()
7777 .find(|snapshot| snapshot.id.as_str() == req.snapshot_id)
7778 {
7779 Some(listed) => restore_points
7780 .iter()
7781 .any(|receipt| receipt.matches(listed))
7782 .then_some(listed),
7783 None => restore_points
7784 .iter()
7785 .find(|receipt| {
7786 receipt.snapshot_id == req.snapshot_id || receipt.tree_id == req.snapshot_id
7787 })
7788 .and_then(|receipt| snapshots.iter().find(|listed| receipt.matches(listed))),
7789 }
7790 .ok_or_else(|| {
7791 ApiError::conflict(
7792 "Selected restore point is unavailable or belongs to another thread; refresh the change record and select the change again.",
7793 )
7794 })?;
7795
7796 if !repo
7797 .path_differs_from_snapshot(&target.id, &rel)
7798 .map_err(map_file_restore_err)?
7799 {
7800 return Err(ApiError::conflict(format!(
7801 "'{}' already matches snapshot '{}'; nothing to revert.",
7802 rel.display(),
7803 target.label
7804 )));
7805 }
7806 let outcomes = repo
7807 .restore_file_if_unchanged(&target.id, &rel, &req.expected_hash)
7808 .map_err(map_file_restore_err)?;
7809 let outcome = outcomes
7810 .into_iter()
7811 .next()
7812 .ok_or_else(|| ApiError::conflict("Nothing was restored."))?;
7813 Ok(RevertThreadFileResponse {
7814 path: outcome.path.to_string_lossy().into_owned(),
7815 action: outcome.action.as_str().to_string(),
7816 snapshot_id: target.id.as_str().to_string(),
7817 snapshot_label: target.label.clone(),
7818 })
7819 }
7820
7821 fn map_file_restore_err(error: std::io::Error) -> ApiError {
7822 if error.kind() == std::io::ErrorKind::InvalidInput {
7823 ApiError::bad_request(error.to_string())
7824 } else if error.kind() == std::io::ErrorKind::WouldBlock {
7825 ApiError::conflict(error.to_string())
7826 } else {
7827 ApiError::internal(format!("File restore failed: {error}"))
7828 }
7829 }
7830
7831 #[derive(Debug, Deserialize)]
7832 struct RetryTurnRequest {
7833 /// How many turns back to retry (default 0 = last turn only).
7834 #[serde(default)]
7835 depth: Option<usize>,
7836 /// Override the user message text. If omitted, the original text
7837 /// from the dropped turn is re-used.
7838 #[serde(default)]
7839 prompt: Option<String>,
7840 /// Client-executed tools the retried turn offers, as on a fresh turn.
7841 /// Dynamic tools are per-turn and answered by the client that sent
7842 /// them, so a retry the client starts must re-send them; without it the
7843 /// retried turn silently lost tools such as the desktop's `open_in_app`.
7844 #[serde(default)]
7845 dynamic_tools: Vec<codewhale_protocol::runtime::DynamicToolSpec>,
7846 }
7847
7848 #[derive(Debug, Serialize)]
7849 struct RetryTurnResponse {
7850 /// The new forked thread (with the last N turns removed).
7851 thread: ThreadRecord,
7852 /// The turn created by the retry.
7853 turn: TurnRecord,
7854 }
7855
7856 async fn retry_thread_turn(
7857 State(state): State<RuntimeApiState>,
7858 Path(id): Path<String>,
7859 Json(req): Json<RetryTurnRequest>,
7860 ) -> Result<(StatusCode, Json<RetryTurnResponse>), ApiError> {
7861 let depth = req.depth.unwrap_or(0);
7862 let (forked_thread, original_user_text, original_user_images, max_output_tokens) = state
7863 .runtime_threads
7864 .fork_at_user_message_in_sessions_dir(&id, depth, &state.sessions_dir)
7865 .await
7866 .map_err(map_thread_err)?;
7867
7868 let retry_prompt = req.prompt.or(original_user_text).unwrap_or_default();
7869 if retry_prompt.trim().is_empty() {
7870 return Err(ApiError::bad_request(
7871 "No user message to retry — the dropped turn had no user text",
7872 ));
7873 }
7874
7875 let turn = state
7876 .runtime_threads
7877 .start_turn_from_stored_images(
7878 &forked_thread.id,
7879 StartTurnRequest {
7880 expected_workspace: None,
7881 max_output_tokens,
7882 prompt: retry_prompt,
7883 images: original_user_images,
7884 operation_key: None,
7885 input_summary: None,
7886 model: None,
7887 reasoning_effort: None,
7888 allowed_tools: None,
7889 mode: None,
7890 permission_posture: None,
7891 allow_shell: None,
7892 trust_mode: None,
7893 auto_approve: None,
7894 dynamic_tools: req.dynamic_tools,
7895 environment_id: None,
7896 model_provider: None,
7897 model_provider_id: None,
7898 },
7899 )
7900 .await
7901 .map_err(map_thread_err)?;
7902
7903 Ok((
7904 StatusCode::CREATED,
7905 Json(RetryTurnResponse {
7906 thread: forked_thread,
7907 turn,
7908 }),
7909 ))
7910 }
7911
7912 async fn start_thread_turn(
7913 State(state): State<RuntimeApiState>,
7914 Path(id): Path<String>,
7915 Json(req): Json<StartTurnRequest>,
7916 ) -> Result<(StatusCode, Json<StartTurnResponse>), ApiError> {
7917 let (turn, replayed) = state
7918 .runtime_threads
7919 .start_turn_reporting_replay(&id, req)
7920 .await
7921 .map_err(map_thread_err)?;
7922 let thread = state
7923 .runtime_threads
7924 .get_thread(&id)
7925 .await
7926 .map_err(map_thread_err)?;
7927 // A replay acknowledges work already accepted rather than admitting new
7928 // work: 200 tells the client "this is the turn I already started", which
7929 // is what lets an ambiguous submit resolve without duplicate messages or
7930 // tools. A fresh admission stays 201.
7931 let status = if replayed {
7932 StatusCode::OK
7933 } else {
7934 StatusCode::CREATED
7935 };
7936 Ok((
7937 status,
7938 Json(StartTurnResponse {
7939 thread,
7940 turn,
7941 idempotent_replay: replayed,
7942 }),
7943 ))
7944 }
7945
7946 async fn get_thread_turn_operation(
7947 State(state): State<RuntimeApiState>,
7948 Path((id, operation_key)): Path<(String, String)>,
7949 ) -> Result<Json<TurnRecord>, ApiError> {
7950 use crate::runtime_threads::RuntimeTurnOperationLookupError;
7951 let turn = state
7952 .runtime_threads
7953 .lookup_turn_operation(&id, &operation_key)
7954 .map_err(|error| match error {
7955 RuntimeTurnOperationLookupError::InvalidRequest => {
7956 ApiError::bad_request(error.to_string())
7957 }
7958 RuntimeTurnOperationLookupError::Incomplete => ApiError::conflict(error.to_string()),
7959 RuntimeTurnOperationLookupError::Unavailable => ApiError::internal(error.to_string()),
7960 })?
7961 .ok_or_else(|| ApiError::not_found("Turn operation not found"))?;
7962 Ok(Json(turn))
7963 }
7964
7965 #[derive(Debug, Serialize)]
7966 struct AgentMailDeliveryResponse {
7967 envelope: AgentMailEnvelope,
7968 #[serde(skip_serializing_if = "Option::is_none")]
7969 turn: Option<TurnRecord>,
7970 }
7971
7972 async fn send_agent_mail(
7973 State(state): State<RuntimeApiState>,
7974 Json(request): Json<AgentMailSendRequest>,
7975 ) -> Result<(StatusCode, Json<AgentMailSendResponse>), ApiError> {
7976 let mut response = state
7977 .runtime_threads
7978 .queue_agent_mail(request)
7979 .await
7980 .map_err(map_agent_mail_err)?;
7981 if response.envelope.delivery_mode == AgentMailDeliveryMode::WakeAtSafeBoundary
7982 && response.envelope.trigger_turn
7983 {
7984 let (envelope, _) = state
7985 .runtime_threads
7986 .deliver_agent_mail(
7987 &response.envelope.destination.thread_id,
7988 &response.envelope.message_id,
7989 )
7990 .await
7991 .map_err(map_agent_mail_err)?;
7992 response.envelope = envelope;
7993 }
7994 let status = if response.idempotent_replay {
7995 StatusCode::OK
7996 } else {
7997 StatusCode::CREATED
7998 };
7999 Ok((status, Json(response)))
8000 }
8001
8002 async fn list_agent_mail(
8003 State(state): State<RuntimeApiState>,
8004 Path(id): Path<String>,
8005 ) -> Result<Json<Vec<AgentMailEnvelope>>, ApiError> {
8006 let inbox = state
8007 .runtime_threads
8008 .list_agent_mail_for_thread(&id)
8009 .await
8010 .map_err(map_agent_mail_err)?;
8011 Ok(Json(inbox))
8012 }
8013
8014 async fn deliver_agent_mail(
8015 State(state): State<RuntimeApiState>,
8016 Path((id, message_id)): Path<(String, String)>,
8017 ) -> Result<Json<AgentMailDeliveryResponse>, ApiError> {
8018 let message_id = AgentMailMessageId::parse(message_id)
8019 .map_err(|error| ApiError::bad_request(error.to_string()))?;
8020 let (envelope, turn) = state
8021 .runtime_threads
8022 .deliver_agent_mail(&id, &message_id)
8023 .await
8024 .map_err(map_agent_mail_err)?;
8025 Ok(Json(AgentMailDeliveryResponse { envelope, turn }))
8026 }
8027
8028 async fn mark_agent_mail_read(
8029 State(state): State<RuntimeApiState>,
8030 Path((id, message_id)): Path<(String, String)>,
8031 ) -> Result<Json<AgentMailEnvelope>, ApiError> {
8032 let message_id = AgentMailMessageId::parse(message_id)
8033 .map_err(|error| ApiError::bad_request(error.to_string()))?;
8034 let envelope = state
8035 .runtime_threads
8036 .mark_agent_mail_read(&id, &message_id)
8037 .await
8038 .map_err(map_agent_mail_err)?;
8039 Ok(Json(envelope))
8040 }
8041
8042 /// Withdraw a queued envelope before delivery (#6176). Idempotent: a
8043 /// re-cancel returns the stored envelope; mail that already left `queued`
8044 /// is a 409, never silently dropped.
8045 async fn cancel_agent_mail(
8046 State(state): State<RuntimeApiState>,
8047 Path((id, message_id)): Path<(String, String)>,
8048 ) -> Result<Json<AgentMailEnvelope>, ApiError> {
8049 let message_id = AgentMailMessageId::parse(message_id)
8050 .map_err(|error| ApiError::bad_request(error.to_string()))?;
8051 let envelope = state
8052 .runtime_threads
8053 .cancel_agent_mail(&id, &message_id)
8054 .await
8055 .map_err(map_agent_mail_err)?;
8056 Ok(Json(envelope))
8057 }
8058
8059 async fn steer_thread_turn(
8060 State(state): State<RuntimeApiState>,
8061 Path((id, turn_id)): Path<(String, String)>,
8062 Json(req): Json<SteerTurnRequest>,
8063 ) -> Result<Json<TurnRecord>, ApiError> {
8064 let turn = state
8065 .runtime_threads
8066 .steer_turn(&id, &turn_id, req)
8067 .await
8068 .map_err(map_thread_err)?;
8069 Ok(Json(turn))
8070 }
8071
8072 async fn interrupt_thread_turn(
8073 State(state): State<RuntimeApiState>,
8074 Path((id, turn_id)): Path<(String, String)>,
8075 ) -> Result<Json<TurnRecord>, ApiError> {
8076 let turn = state
8077 .runtime_threads
8078 .interrupt_turn(&id, &turn_id)
8079 .await
8080 .map_err(map_thread_err)?;
8081 Ok(Json(turn))
8082 }
8083
8084 async fn deliver_dynamic_tool_result(
8085 State(state): State<RuntimeApiState>,
8086 Path((id, turn_id, call_id)): Path<(String, String, String)>,
8087 Json(result): Json<DynamicToolCallResult>,
8088 ) -> Result<StatusCode, ApiError> {
8089 state
8090 .runtime_threads
8091 .get_thread(&id)
8092 .await
8093 .map_err(map_thread_err)?;
8094 if state
8095 .runtime_threads
8096 .deliver_dynamic_tool_result(&id, &turn_id, &call_id, result)
8097 .await
8098 .map_err(|error| ApiError::internal(error.to_string()))?
8099 {
8100 Ok(StatusCode::ACCEPTED)
8101 } else {
8102 Err(ApiError::not_found(format!(
8103 "No pending dynamic tool call '{call_id}'"
8104 )))
8105 }
8106 }
8107
8108 async fn compact_thread(
8109 State(state): State<RuntimeApiState>,
8110 Path(id): Path<String>,
8111 Json(req): Json<CompactThreadRequest>,
8112 ) -> Result<(StatusCode, Json<StartTurnResponse>), ApiError> {
8113 let turn = state
8114 .runtime_threads
8115 .compact_thread(&id, req)
8116 .await
8117 .map_err(map_thread_err)?;
8118 let thread = state
8119 .runtime_threads
8120 .get_thread(&id)
8121 .await
8122 .map_err(map_thread_err)?;
8123 Ok((
8124 StatusCode::ACCEPTED,
8125 Json(StartTurnResponse {
8126 thread,
8127 turn,
8128 idempotent_replay: false,
8129 }),
8130 ))
8131 }
8132
8133 // ---------------------------------------------------------------------------
8134 // Thread goal endpoints
8135 // ---------------------------------------------------------------------------
8136
8137 /// `GET /v1/threads/{id}/goal` — return the persistent goal for a thread, or
8138 /// 404 if the thread has no goal.
8139 async fn get_thread_goal(
8140 State(state): State<RuntimeApiState>,
8141 Path(id): Path<String>,
8142 ) -> Result<Json<codewhale_protocol::ThreadGoal>, ApiError> {
8143 // Verify the thread exists so we can return a clean 404 for unknown threads.
8144 state
8145 .runtime_threads
8146 .get_thread(&id)
8147 .await
8148 .map_err(map_thread_err)?;
8149 let goal = state
8150 .runtime_threads
8151 .get_goal(&id)
8152 .await
8153 .map_err(|e| ApiError::internal(e.to_string()))?
8154 .ok_or_else(|| ApiError::not_found(format!("thread '{id}' has no goal")))?;
8155 Ok(Json(goal))
8156 }
8157
8158 #[derive(Debug, Deserialize)]
8159 struct UpsertThreadGoalRequest {
8160 objective: String,
8161 #[serde(default)]
8162 token_budget: Option<i64>,
8163 }
8164
8165 /// `PUT /v1/threads/{id}/goal` — create or replace the persistent goal for a
8166 /// thread. Only `Active` goals may be created through this route; lifecycle
8167 /// transitions (`complete`, `block`) have dedicated action endpoints.
8168 async fn upsert_thread_goal(
8169 State(state): State<RuntimeApiState>,
8170 Path(id): Path<String>,
8171 Json(req): Json<UpsertThreadGoalRequest>,
8172 ) -> Result<(StatusCode, Json<codewhale_protocol::ThreadGoal>), ApiError> {
8173 if req.objective.trim().is_empty() {
8174 return Err(ApiError::bad_request("objective must not be blank"));
8175 }
8176 // Verify the thread exists.
8177 state
8178 .runtime_threads
8179 .get_thread(&id)
8180 .await
8181 .map_err(map_thread_err)?;
8182 let now = chrono::Utc::now().timestamp();
8183 let existing = state
8184 .runtime_threads
8185 .get_goal(&id)
8186 .await
8187 .map_err(|e| ApiError::internal(e.to_string()))?;
8188 let is_new = existing.is_none();
8189 let goal = codewhale_protocol::ThreadGoal {
8190 thread_id: id.clone(),
8191 goal_id: format!("goal-{}", uuid::Uuid::new_v4()),
8192 objective: req.objective.clone(),
8193 status: codewhale_protocol::ThreadGoalStatus::Active,
8194 token_budget: req.token_budget,
8195 tokens_used: 0,
8196 time_used_seconds: 0,
8197 continuation_count: 0,
8198 last_gap_fingerprint: None,
8199 repeated_gap_count: 0,
8200 last_gap_pass: None,
8201 pause_reason: None,
8202 created_at: now,
8203 updated_at: now,
8204 };
8205 state
8206 .runtime_threads
8207 .save_goal(goal.clone())
8208 .await
8209 .map_err(|e| ApiError::internal(e.to_string()))?;
8210 let status_code = if is_new {
8211 StatusCode::CREATED
8212 } else {
8213 StatusCode::OK
8214 };
8215 // Emit a replayable goal-updated event so SSE subscribers can react.
8216 let _ = state
8217 .runtime_threads
8218 .emit_goal_updated_event(&id, goal.clone())
8219 .await;
8220 // Inject the goal into a cached engine (if any) and dispatch the kickoff
8221 // turn while the thread is idle. Errors are advisory: the goal record is
8222 // already durable and a subsequent turn still carries it.
8223 if let Err(err) = state.runtime_threads.activate_thread_goal(&id).await {
8224 tracing::warn!("failed to activate goal for thread '{id}': {err}");
8225 }
8226 Ok((status_code, Json(goal)))
8227 }
8228
8229 /// `DELETE /v1/threads/{id}/goal` — remove the persistent goal from a thread.
8230 /// Returns 204 No Content on success, 404 if there was no goal.
8231 async fn delete_thread_goal(
8232 State(state): State<RuntimeApiState>,
8233 Path(id): Path<String>,
8234 ) -> Result<StatusCode, ApiError> {
8235 state
8236 .runtime_threads
8237 .get_thread(&id)
8238 .await
8239 .map_err(map_thread_err)?;
8240 let deleted = state
8241 .runtime_threads
8242 .remove_goal(&id)
8243 .await
8244 .map_err(|e| ApiError::internal(e.to_string()))?;
8245 if !deleted {
8246 return Err(ApiError::not_found(format!("thread '{id}' has no goal")));
8247 }
8248 let _ = state.runtime_threads.emit_goal_cleared_event(&id).await;
8249 state
8250 .runtime_threads
8251 .sync_engine_goal_status(&id)
8252 .await
8253 .map_err(|e| ApiError::internal(e.to_string()))?;
8254 Ok(StatusCode::NO_CONTENT)
8255 }
8256
8257 /// `POST /v1/threads/{id}/goal/complete` — transition the goal to `Complete`.
8258 /// Only valid from a non-terminal status; returns 409 Conflict if the goal is
8259 /// already in a terminal state, and 404 if the thread has no goal.
8260 async fn complete_thread_goal(
8261 State(state): State<RuntimeApiState>,
8262 Path(id): Path<String>,
8263 ) -> Result<Json<codewhale_protocol::ThreadGoal>, ApiError> {
8264 state
8265 .runtime_threads
8266 .get_thread(&id)
8267 .await
8268 .map_err(map_thread_err)?;
8269 let goal = state
8270 .runtime_threads
8271 .get_goal(&id)
8272 .await
8273 .map_err(|e| ApiError::internal(e.to_string()))?
8274 .ok_or_else(|| ApiError::not_found(format!("thread '{id}' has no goal")))?;
8275 if matches!(goal.status, codewhale_protocol::ThreadGoalStatus::Complete) {
8276 return Err(ApiError {
8277 status: StatusCode::CONFLICT,
8278 message: format!("goal for thread '{id}' is already complete"),
8279 code: None,
8280 });
8281 }
8282 let updated = state
8283 .runtime_threads
8284 .transition_goal_status(
8285 &id,
8286 &goal.goal_id,
8287 goal.status.clone(),
8288 codewhale_protocol::ThreadGoalStatus::Complete,
8289 )
8290 .await
8291 .map_err(|e| ApiError::internal(e.to_string()))?
8292 .ok_or_else(|| ApiError {
8293 status: StatusCode::CONFLICT,
8294 message: format!("goal for thread '{id}' changed concurrently; retry"),
8295 code: None,
8296 })?;
8297 let _ = state
8298 .runtime_threads
8299 .emit_goal_updated_event(&id, updated.clone())
8300 .await;
8301 state
8302 .runtime_threads
8303 .sync_engine_goal_status(&id)
8304 .await
8305 .map_err(|e| ApiError::internal(e.to_string()))?;
8306 Ok(Json(updated))
8307 }
8308
8309 /// `POST /v1/threads/{id}/goal/block` — transition the goal to `Blocked`.
8310 /// Rejects transitions from terminal states (returns 409).
8311 async fn block_thread_goal(
8312 State(state): State<RuntimeApiState>,
8313 Path(id): Path<String>,
8314 ) -> Result<Json<codewhale_protocol::ThreadGoal>, ApiError> {
8315 state
8316 .runtime_threads
8317 .get_thread(&id)
8318 .await
8319 .map_err(map_thread_err)?;
8320 let goal = state
8321 .runtime_threads
8322 .get_goal(&id)
8323 .await
8324 .map_err(|e| ApiError::internal(e.to_string()))?
8325 .ok_or_else(|| ApiError::not_found(format!("thread '{id}' has no goal")))?;
8326 if matches!(goal.status, codewhale_protocol::ThreadGoalStatus::Complete) {
8327 return Err(ApiError {
8328 status: StatusCode::CONFLICT,
8329 message: format!(
8330 "goal for thread '{id}' is already complete; cannot transition to blocked"
8331 ),
8332 code: None,
8333 });
8334 }
8335 let updated = state
8336 .runtime_threads
8337 .transition_goal_status(
8338 &id,
8339 &goal.goal_id,
8340 goal.status.clone(),
8341 codewhale_protocol::ThreadGoalStatus::Blocked,
8342 )
8343 .await
8344 .map_err(|e| ApiError::internal(e.to_string()))?
8345 .ok_or_else(|| ApiError {
8346 status: StatusCode::CONFLICT,
8347 message: format!("goal for thread '{id}' changed concurrently; retry"),
8348 code: None,
8349 })?;
8350 let _ = state
8351 .runtime_threads
8352 .emit_goal_updated_event(&id, updated.clone())
8353 .await;
8354 state
8355 .runtime_threads
8356 .sync_engine_goal_status(&id)
8357 .await
8358 .map_err(|e| ApiError::internal(e.to_string()))?;
8359 Ok(Json(updated))
8360 }
8361
8362 /// Runtime-authenticated administrative task inventory.
8363 ///
8364 /// Unlike in-session TUI/model controls, the Runtime API token authorizes the
8365 /// caller for the whole host runtime, so these endpoints intentionally span
8366 /// sessions. Running with `--insecure` explicitly opts out of that host boundary.
8367 async fn list_tasks(
8368 State(state): State<RuntimeApiState>,
8369 Query(query): Query<TasksQuery>,
8370 ) -> Result<Json<TasksResponse>, ApiError> {
8371 let tasks = match query.workspace.as_deref() {
8372 Some(workspace) => {
8373 state
8374 .task_manager
8375 .list_tasks_scoped(query.limit, Some(workspace))
8376 .await
8377 }
8378 None => state.task_manager.list_tasks(query.limit).await,
8379 }
8380 .map_err(|error| ApiError::internal(format!("Task inventory unavailable: {error}")))?;
8381 let counts = state
8382 .task_manager
8383 .counts()
8384 .await
8385 .map_err(|error| ApiError::internal(format!("Task inventory unavailable: {error}")))?;
8386 Ok(Json(TasksResponse { tasks, counts }))
8387 }
8388
8389 /// Runtime-authenticated administrative task lookup across host sessions.
8390 async fn get_task(
8391 State(state): State<RuntimeApiState>,
8392 Path(id): Path<String>,
8393 ) -> Result<Json<TaskRecord>, ApiError> {
8394 let task = state
8395 .task_manager
8396 .get_task(&id)
8397 .await
8398 .map_err(map_task_err)?;
8399 Ok(Json(task))
8400 }
8401
8402 /// Runtime-authenticated administrative task cancellation across host sessions.
8403 async fn cancel_task(
8404 State(state): State<RuntimeApiState>,
8405 Path(id): Path<String>,
8406 ) -> Result<Json<TaskRecord>, ApiError> {
8407 let cancellation = state
8408 .task_manager
8409 .cancel_task(&id)
8410 .await
8411 .map_err(map_task_err)?;
8412 Ok(Json(cancellation.task))
8413 }
8414
8415 async fn stream_thread_events(
8416 State(state): State<RuntimeApiState>,
8417 Path(id): Path<String>,
8418 Query(query): Query<ThreadEventsQuery>,
8419 headers: HeaderMap,
8420 ) -> Result<Response, ApiError> {
8421 let _ = state
8422 .runtime_threads
8423 .get_thread(&id)
8424 .await
8425 .map_err(map_thread_err)?;
8426
8427 // Two clients, two cursors. A browser `EventSource` can only replay through
8428 // the `Last-Event-ID` header it sets on reconnect (the ids now ride the
8429 // journal frames below); every other client passes `since_seq`. An explicit
8430 // query cursor wins over the header, so a deliberate replay-from-zero is
8431 // never silently overridden by a stale header — the header is the fallback
8432 // when no cursor was asked for.
8433 let since_seq = query.since_seq.or_else(|| last_event_id(&headers));
8434
8435 // Subscribe before reading durable history. An event emitted while replay
8436 // is loaded is then present in both places (and deduped below) or queued
8437 // live, never in an uncovered handoff window.
8438 let live = state.runtime_threads.subscribe_events();
8439 if query
8440 .replay_limit
8441 .is_some_and(|limit| limit > MAX_RUNTIME_EVENT_REPLAY_TAIL)
8442 {
8443 return Err(ApiError::bad_request(format!(
8444 "replay_limit cannot exceed {MAX_RUNTIME_EVENT_REPLAY_TAIL}"
8445 )));
8446 }
8447 let replay = state
8448 .runtime_threads
8449 .replay_events(&id, since_seq, query.replay_limit)
8450 .await
8451 .map_err(|e| ApiError::internal(e.to_string()))?;
8452
8453 let stream = replay_live_thread_events(
8454 state.runtime_threads.clone(),
8455 id,
8456 replay.base_seq,
8457 replay.batches,
8458 live,
8459 query.progress,
8460 state.shutdown.requested.clone(),
8461 );
8462
8463 let mut response = Sse::new(stream)
8464 .keep_alive(
8465 KeepAlive::new()
8466 .interval(Duration::from_secs(15))
8467 .text("keepalive"),
8468 )
8469 .into_response();
8470 // Every server-initiated end of this stream is a `stream.end` frame. The
8471 // header lets a client tell that EOF without one is transport loss, which
8472 // an older Runtime cannot promise.
8473 response
8474 .headers_mut()
8475 .insert("x-codewhale-stream-end", HeaderValue::from_static("1"));
8476 if query.progress {
8477 response
8478 .headers_mut()
8479 .insert("x-codewhale-event-progress", HeaderValue::from_static("1"));
8480 }
8481 Ok(response)
8482 }
8483
8484 /// Opt-in transport frame at the existing journal cursor. It carries the same
8485 /// envelope identity (`schema_version`, `event`, `kind`, `thread_id`) as the
8486 /// journal and `stream.end`, but never a journal `seq` of its own.
8487 fn thread_stream_progress(thread_id: &str, seq: u64, live: bool) -> SseEvent {
8488 sse_json(
8489 "stream.progress",
8490 json!({
8491 "schema_version": RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION,
8492 "event": "stream.progress", "kind": "stream.progress",
8493 "thread_id": thread_id, "seq": seq,
8494 "state": if live { "live" } else { "replaying" },
8495 }),
8496 )
8497 }
8498
8499 /// Why the server ended a thread event stream it had already opened. Every
8500 /// server-initiated end is one of these, sent as the final `stream.end` frame;
8501 /// an EOF without that frame is the transport or the process dying, never the
8502 /// server choosing to stop.
8503 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
8504 enum ThreadStreamEnd {
8505 /// The durable history read that feeds the opening replay failed.
8506 ReplayFailed,
8507 /// The durable re-read after broadcast lag could not be opened or failed.
8508 CatchUpFailed,
8509 /// The Runtime API server is stopping (a terminating signal).
8510 RuntimeShutdown,
8511 }
8512
8513 impl ThreadStreamEnd {
8514 const fn reason(self) -> &'static str {
8515 match self {
8516 Self::ReplayFailed => "replay_failed",
8517 Self::CatchUpFailed => "catch_up_failed",
8518 Self::RuntimeShutdown => "runtime_shutdown",
8519 }
8520 }
8521
8522 /// Every current end is resumable from `last_seq`. The flag exists so the
8523 /// client rule keys on it rather than on the reason list: a future
8524 /// non-resumable end stops clients without a client change.
8525 const fn retryable(self) -> bool {
8526 match self {
8527 Self::ReplayFailed | Self::CatchUpFailed | Self::RuntimeShutdown => true,
8528 }
8529 }
8530 }
8531
8532 /// The final frame of a server-ended thread stream. It has no `seq` and no SSE
8533 /// `id:` because it is not a journal event: seq-keyed consumers skip it, and a
8534 /// browser `EventSource` keeps `Last-Event-ID` on the last real event.
8535 /// `last_seq` is exactly the `since_seq` that resumes without loss or repeats.
8536 /// The underlying error stays in the server log; it can carry store paths.
8537 fn thread_stream_end(thread_id: &str, end: ThreadStreamEnd, last_seq: u64) -> SseEvent {
8538 sse_json(
8539 "stream.end",
8540 json!({
8541 "schema_version": RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION,
8542 "event": "stream.end", "kind": "stream.end",
8543 "thread_id": thread_id, "reason": end.reason(),
8544 "last_seq": last_seq, "retryable": end.retryable(),
8545 }),
8546 )
8547 }
8548
8549 /// The journal frame for `event` on this thread's stream, advancing the
8550 /// connection cursor. `None` for another thread's event or one already sent.
8551 fn thread_journal_frame(
8552 thread_id: &str,
8553 last_seq: &mut u64,
8554 event: crate::runtime_threads::RuntimeEventRecord,
8555 ) -> Option<SseEvent> {
8556 if event.thread_id != thread_id || event.seq <= *last_seq {
8557 return None;
8558 }
8559 let previous_seq = std::mem::replace(last_seq, event.seq);
8560 let event_name = event.event.clone();
8561 Some(
8562 sse_json(
8563 &event_name,
8564 runtime_event_payload_with_previous(event, previous_seq),
8565 )
8566 .id(last_seq.to_string()),
8567 )
8568 }
8569
8570 type ThreadReplayBatches = tokio::sync::mpsc::Receiver<
8571 std::result::Result<Vec<crate::runtime_threads::RuntimeEventRecord>, String>,
8572 >;
8573
8574 enum ThreadReplayStep {
8575 Events(Vec<crate::runtime_threads::RuntimeEventRecord>),
8576 Complete,
8577 Failed(String),
8578 Shutdown,
8579 }
8580
8581 /// The next durable-history batch, unless the server starts stopping first.
8582 async fn next_thread_replay_step(
8583 shutdown: &CancellationToken,
8584 batches: &mut ThreadReplayBatches,
8585 ) -> ThreadReplayStep {
8586 tokio::select! {
8587 biased;
8588 () = shutdown.cancelled() => ThreadReplayStep::Shutdown,
8589 batch = batches.recv() => match batch {
8590 None => ThreadReplayStep::Complete,
8591 Some(Ok(events)) => ThreadReplayStep::Events(events),
8592 Some(Err(error)) => ThreadReplayStep::Failed(error),
8593 },
8594 }
8595 }
8596
8597 fn replay_live_thread_events(
8598 runtime_threads: SharedRuntimeThreadManager,
8599 thread_id: String,
8600 mut last_seq: u64,
8601 mut backlog: ThreadReplayBatches,
8602 mut live: tokio::sync::broadcast::Receiver<crate::runtime_threads::RuntimeEventRecord>,
8603 progress: bool,
8604 shutdown: CancellationToken,
8605 ) -> impl futures_util::Stream<Item = Result<SseEvent, Infallible>> {
8606 // Every exit below is a `stream.end` followed by `return`; the live loop
8607 // never breaks. An EOF this stream produces is therefore never silent.
8608 stream! {
8609 if progress { yield Ok(thread_stream_progress(&thread_id, last_seq, false)); }
8610 loop {
8611 match next_thread_replay_step(&shutdown, &mut backlog).await {
8612 ThreadReplayStep::Events(events) => {
8613 for event in events {
8614 if let Some(frame) = thread_journal_frame(&thread_id, &mut last_seq, event) {
8615 yield Ok(frame);
8616 }
8617 }
8618 }
8619 ThreadReplayStep::Complete => break,
8620 ThreadReplayStep::Failed(error) => {
8621 tracing::warn!(
8622 thread_id = %thread_id,
8623 last_seq,
8624 %error,
8625 "Failed to replay Runtime web event stream from durable history"
8626 );
8627 yield Ok(thread_stream_end(&thread_id, ThreadStreamEnd::ReplayFailed, last_seq));
8628 return;
8629 }
8630 ThreadReplayStep::Shutdown => {
8631 yield Ok(thread_stream_end(&thread_id, ThreadStreamEnd::RuntimeShutdown, last_seq));
8632 return;
8633 }
8634 }
8635 }
8636
8637 // Backlog completion alone is insufficient: a request may have been
8638 // answered while history was read. Drain the already-queued live tail
8639 // before declaring the observation current. These opt-in frames carry
8640 // transport progress, never new journal events or sequence numbers.
8641 let mut replaying = progress;
8642 loop {
8643 if shutdown.is_cancelled() {
8644 yield Ok(thread_stream_end(&thread_id, ThreadStreamEnd::RuntimeShutdown, last_seq));
8645 return;
8646 }
8647 let next = if replaying {
8648 use tokio::sync::broadcast::error::{RecvError, TryRecvError};
8649 match live.try_recv() {
8650 Ok(event) => Ok(event),
8651 Err(TryRecvError::Empty) => {
8652 yield Ok(thread_stream_progress(&thread_id, last_seq, true));
8653 replaying = false;
8654 continue;
8655 }
8656 Err(TryRecvError::Lagged(skipped)) => Err(RecvError::Lagged(skipped)),
8657 Err(TryRecvError::Closed) => Err(RecvError::Closed),
8658 }
8659 } else {
8660 let received = tokio::select! {
8661 biased;
8662 () = shutdown.cancelled() => None,
8663 received = live.recv() => Some(received),
8664 };
8665 // Shutdown is answered by the check at the top of the loop.
8666 let Some(received) = received else { continue };
8667 received
8668 };
8669 match next {
8670 Ok(event) => {
8671 if let Some(frame) = thread_journal_frame(&thread_id, &mut last_seq, event) {
8672 yield Ok(frame);
8673 }
8674 }
8675 Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
8676 if progress {
8677 yield Ok(thread_stream_progress(&thread_id, last_seq, false));
8678 replaying = true;
8679 }
8680 // Broadcast is only a wake-up path; durable history remains
8681 // authoritative. Catch up from the last delivered cursor so
8682 // receiver pressure cannot turn into a silent prompt loss.
8683 let mut recovered = match runtime_threads
8684 .replay_events(&thread_id, Some(last_seq), None)
8685 .await
8686 {
8687 Ok(replay) => replay.batches,
8688 Err(error) => {
8689 tracing::warn!(
8690 thread_id = %thread_id,
8691 last_seq,
8692 skipped,
8693 %error,
8694 "Failed to recover lagged Runtime web event stream from durable history"
8695 );
8696 yield Ok(thread_stream_end(&thread_id, ThreadStreamEnd::CatchUpFailed, last_seq));
8697 return;
8698 }
8699 };
8700 loop {
8701 match next_thread_replay_step(&shutdown, &mut recovered).await {
8702 ThreadReplayStep::Events(events) => {
8703 for event in events {
8704 if let Some(frame) = thread_journal_frame(&thread_id, &mut last_seq, event) {
8705 yield Ok(frame);
8706 }
8707 }
8708 }
8709 ThreadReplayStep::Complete => break,
8710 ThreadReplayStep::Failed(error) => {
8711 tracing::warn!(
8712 thread_id = %thread_id,
8713 last_seq,
8714 skipped,
8715 %error,
8716 "Failed to recover lagged Runtime web event stream from durable history"
8717 );
8718 yield Ok(thread_stream_end(&thread_id, ThreadStreamEnd::CatchUpFailed, last_seq));
8719 return;
8720 }
8721 // Answered by the check at the top of the live loop.
8722 ThreadReplayStep::Shutdown => break,
8723 }
8724 }
8725 }
8726 // The sender is owned by the `RuntimeThreadManager` this stream
8727 // holds an `Arc` of, so it cannot close while the stream runs.
8728 // If that ever changes, the live source is gone only because
8729 // the Runtime is: say so rather than end silently.
8730 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
8731 yield Ok(thread_stream_end(&thread_id, ThreadStreamEnd::RuntimeShutdown, last_seq));
8732 return;
8733 }
8734 }
8735 }
8736 }
8737 }
8738
8739 async fn stream_turn(
8740 State(state): State<RuntimeApiState>,
8741 Json(req): Json<StreamTurnRequest>,
8742 ) -> Result<Sse<impl futures_util::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
8743 if req.prompt.trim().is_empty() {
8744 return Err(ApiError::bad_request("prompt is required"));
8745 }
8746
8747 crate::image_attach::prepare_runtime_images(&req.images).map_err(map_thread_err)?;
8748
8749 let model = runtime_request_model(&state.config.read(), req.model.as_deref())?;
8750 if req.max_output_tokens.is_some() {
8751 let config = state.config.read();
8752 let identity = config
8753 .active_provider_identity()
8754 .map_err(ApiError::bad_request)?;
8755 if model.eq_ignore_ascii_case("auto")
8756 || provider_model_output_token_limit_for_api(&config, &identity, &model)
8757 != codewhale_config::route::CapabilityState::Supported
8758 {
8759 return Err(ApiError::bad_request(
8760 "maxOutputTokens requires an exact model with output-limit support",
8761 ));
8762 }
8763 }
8764 let workspace = req
8765 .workspace
8766 .clone()
8767 .unwrap_or_else(|| state.workspace.clone());
8768 let mode = req.mode.clone().unwrap_or_else(|| "agent".to_string());
8769 let permission_posture = req.permission_posture.clone();
8770 let allow_shell = req.allow_shell.unwrap_or(state.config.read().allow_shell());
8771 let trust_mode = req.trust_mode.unwrap_or(false);
8772 let auto_approve = req.auto_approve.unwrap_or(false);
8773 let prompt = req.prompt;
8774
8775 let thread = state
8776 .runtime_threads
8777 .create_thread(CreateThreadRequest {
8778 model: Some(model.clone()),
8779 workspace: Some(workspace.clone()),
8780 mode: Some(mode.clone()),
8781 permission_posture: permission_posture.clone(),
8782 allow_shell: Some(allow_shell),
8783 trust_mode: Some(trust_mode),
8784 auto_approve: Some(auto_approve),
8785 archived: true,
8786 system_prompt: None,
8787 task_id: None,
8788 ..Default::default()
8789 })
8790 .await
8791 .map_err(|e| ApiError::internal(format!("Failed to create stream thread: {e}")))?;
8792
8793 #[cfg(test)]
8794 if let Some(hook) = &state.compat_stream_test_hook {
8795 let (resume, wait_for_resume) = tokio::sync::oneshot::channel();
8796 hook.send(CompatStreamTestPoint::ThreadCreated {
8797 thread_id: thread.id.clone(),
8798 resume,
8799 })
8800 .map_err(|_| ApiError::internal("Compatibility stream test hook closed"))?;
8801 wait_for_resume
8802 .await
8803 .map_err(|_| ApiError::internal("Compatibility stream test hook dropped resume"))?;
8804 }
8805
8806 let turn_result = state
8807 .runtime_threads
8808 .start_turn(
8809 &thread.id,
8810 StartTurnRequest {
8811 max_output_tokens: req.max_output_tokens,
8812 prompt,
8813 images: req.images,
8814 input_summary: None,
8815 model: Some(model.clone()),
8816 mode: Some(mode.clone()),
8817 permission_posture,
8818 allow_shell: Some(allow_shell),
8819 trust_mode: Some(trust_mode),
8820 auto_approve: Some(auto_approve),
8821 ..Default::default()
8822 },
8823 )
8824 .await;
8825 let turn = match turn_result {
8826 Ok(turn) => turn,
8827 Err(error) => {
8828 // This helper refuses loaded threads and any thread owning a turn.
8829 // A failed/uncertain handoff must remain recoverable; only an empty,
8830 // never-loaded admission can be discarded.
8831 if let Err(cleanup_error) = state.runtime_threads.discard_empty_thread(&thread.id).await
8832 {
8833 tracing::warn!(thread_id = %thread.id, %cleanup_error, "Retained stream thread after failed admission");
8834 }
8835 return Err(map_thread_err(error));
8836 }
8837 };
8838
8839 // Subscribe before reading the durable replay. Events produced while the
8840 // replay is loaded then exist in at least one source, and the sequence
8841 // cursor below removes overlap without dropping the handoff edge.
8842 let mut live = state.runtime_threads.subscribe_events();
8843 let thread_id = thread.id.clone();
8844 let turn_id = turn.id.clone();
8845
8846 #[cfg(test)]
8847 if let Some(hook) = &state.compat_stream_test_hook {
8848 let (resume, wait_for_resume) = tokio::sync::oneshot::channel();
8849 hook.send(CompatStreamTestPoint::SubscribedBeforeReplay {
8850 thread_id: thread_id.clone(),
8851 turn_id: turn_id.clone(),
8852 resume,
8853 })
8854 .map_err(|_| ApiError::internal("Compatibility stream test hook closed"))?;
8855 wait_for_resume
8856 .await
8857 .map_err(|_| ApiError::internal("Compatibility stream test hook dropped resume"))?;
8858 }
8859
8860 let mut backlog = state
8861 .runtime_threads
8862 .replay_events(&thread.id, None, None)
8863 .await
8864 .map_err(|e| ApiError::internal(format!("Failed to load stream backlog: {e}")))?;
8865
8866 #[cfg(test)]
8867 if let Some(hook) = &state.compat_stream_test_hook {
8868 let (resume, wait_for_resume) = tokio::sync::oneshot::channel();
8869 hook.send(CompatStreamTestPoint::ReplayLoaded {
8870 thread_id: thread_id.clone(),
8871 turn_id: turn_id.clone(),
8872 resume,
8873 })
8874 .map_err(|_| ApiError::internal("Compatibility stream test hook closed"))?;
8875 wait_for_resume
8876 .await
8877 .map_err(|_| ApiError::internal("Compatibility stream test hook dropped resume"))?;
8878 }
8879
8880 let stream = stream! {
8881 let mut last_seq = 0;
8882 yield Ok(sse_json("turn.started", json!({
8883 "thread_id": thread.id,
8884 "turn_id": turn.id,
8885 "model": model,
8886 "mode": mode,
8887 "workspace": workspace,
8888 })));
8889
8890 while let Some(batch) = backlog.batches.recv().await {
8891 let events = match batch {
8892 Ok(events) => events,
8893 Err(error) => {
8894 tracing::warn!(
8895 thread_id = %thread_id,
8896 turn_id = %turn_id,
8897 %error,
8898 "Failed to replay compatibility stream from durable history"
8899 );
8900 yield Ok(sse_json("error", json!({
8901 "message": "failed to replay durable event stream",
8902 })));
8903 return;
8904 }
8905 };
8906 for event in events {
8907 let Some((mapped, terminal)) = take_compat_turn_event(
8908 &event,
8909 &thread_id,
8910 &turn_id,
8911 &mut last_seq,
8912 ) else {
8913 continue;
8914 };
8915 if let Some(mapped) = mapped {
8916 yield Ok(mapped);
8917 }
8918 if terminal {
8919 yield Ok(sse_json("done", json!({})));
8920 return;
8921 }
8922 }
8923 }
8924
8925 loop {
8926 match live.recv().await {
8927 Ok(event) => {
8928 let Some((mapped, terminal)) = take_compat_turn_event(
8929 &event,
8930 &thread_id,
8931 &turn_id,
8932 &mut last_seq,
8933 ) else {
8934 continue;
8935 };
8936 if let Some(mapped) = mapped {
8937 yield Ok(mapped);
8938 }
8939 if terminal {
8940 yield Ok(sse_json("done", json!({})));
8941 return;
8942 }
8943 }
8944 Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
8945 let mut recovered = match state.runtime_threads
8946 .replay_events(&thread_id, Some(last_seq), None)
8947 .await
8948 {
8949 Ok(replay) => replay.batches,
8950 Err(error) => {
8951 tracing::warn!(
8952 thread_id = %thread_id,
8953 turn_id = %turn_id,
8954 last_seq,
8955 skipped,
8956 %error,
8957 "Failed to recover lagged compatibility stream from durable history"
8958 );
8959 yield Ok(sse_json("error", json!({
8960 "message": "failed to recover lagged event stream",
8961 })));
8962 return;
8963 }
8964 };
8965 while let Some(batch) = recovered.recv().await {
8966 let events = match batch {
8967 Ok(events) => events,
8968 Err(error) => {
8969 tracing::warn!(
8970 thread_id = %thread_id,
8971 turn_id = %turn_id,
8972 last_seq,
8973 skipped,
8974 %error,
8975 "Failed to recover lagged compatibility stream from durable history"
8976 );
8977 yield Ok(sse_json("error", json!({
8978 "message": "failed to recover lagged event stream",
8979 })));
8980 return;
8981 }
8982 };
8983 for event in events {
8984 let Some((mapped, terminal)) = take_compat_turn_event(
8985 &event,
8986 &thread_id,
8987 &turn_id,
8988 &mut last_seq,
8989 ) else {
8990 continue;
8991 };
8992 if let Some(mapped) = mapped {
8993 yield Ok(mapped);
8994 }
8995 if terminal {
8996 yield Ok(sse_json("done", json!({})));
8997 return;
8998 }
8999 }
9000 }
9001 }
9002 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
9003 yield Ok(sse_json("error", json!({ "message": "event channel closed" })));
9004 return;
9005 }
9006 }
9007 }
9008 };
9009
9010 Ok(Sse::new(stream).keep_alive(
9011 KeepAlive::new()
9012 .interval(Duration::from_secs(15))
9013 .text("keepalive"),
9014 ))
9015 }
9016
9017 fn take_compat_turn_event(
9018 event: &crate::runtime_threads::RuntimeEventRecord,
9019 thread_id: &str,
9020 turn_id: &str,
9021 last_seq: &mut u64,
9022 ) -> Option<(Option<SseEvent>, bool)> {
9023 if event.thread_id != thread_id
9024 || event.turn_id.as_deref() != Some(turn_id)
9025 || event.seq <= *last_seq
9026 {
9027 return None;
9028 }
9029 *last_seq = event.seq;
9030 Some((
9031 map_compat_stream_event(event),
9032 event.event == "turn.completed",
9033 ))
9034 }
9035
9036 fn runtime_event_payload(event: crate::runtime_threads::RuntimeEventRecord) -> serde_json::Value {
9037 let event_name = event.event.clone();
9038 let timestamp = event.timestamp.to_rfc3339();
9039 let schema_version = RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION;
9040 let envelope = RuntimeEventEnvelope {
9041 schema_version,
9042 seq: event.seq,
9043 event: event_name.clone(),
9044 kind: event_name,
9045 thread_id: event.thread_id,
9046 turn_id: event.turn_id,
9047 item_id: event.item_id,
9048 timestamp: timestamp.clone(),
9049 created_at: Some(timestamp),
9050 payload: event.payload,
9051 extra: Default::default(),
9052 };
9053 serde_json::to_value(envelope).expect("serialize runtime event envelope")
9054 }
9055
9056 fn runtime_event_payload_with_previous(
9057 event: crate::runtime_threads::RuntimeEventRecord,
9058 previous_seq: u64,
9059 ) -> serde_json::Value {
9060 let mut payload = runtime_event_payload(event);
9061 if let Some(object) = payload.as_object_mut() {
9062 object.insert("previous_seq".to_string(), json!(previous_seq));
9063 }
9064 payload
9065 }
9066
9067 fn map_compat_stream_event(event: &crate::runtime_threads::RuntimeEventRecord) -> Option<SseEvent> {
9068 let payload = &event.payload;
9069 match event.event.as_str() {
9070 "item.delta" => {
9071 let kind = payload
9072 .get("kind")
9073 .and_then(|v| v.as_str())
9074 .unwrap_or_default();
9075 if kind == "agent_message" {
9076 let content = payload
9077 .get("delta")
9078 .and_then(|v| v.as_str())
9079 .unwrap_or_default();
9080 Some(sse_json("message.delta", json!({ "content": content })))
9081 } else if kind == "tool_call" {
9082 let output = payload
9083 .get("delta")
9084 .and_then(|v| v.as_str())
9085 .unwrap_or_default();
9086 Some(sse_json("tool.progress", json!({ "output": output })))
9087 } else {
9088 None
9089 }
9090 }
9091 "item.started" => {
9092 let tool = payload.get("tool")?;
9093 let id = tool.get("id").cloned().unwrap_or(Value::Null);
9094 let name = tool.get("name").cloned().unwrap_or(Value::Null);
9095 let input = tool.get("input").cloned().unwrap_or(Value::Null);
9096 Some(sse_json(
9097 "tool.started",
9098 json!({
9099 "id": id,
9100 "name": name,
9101 "input": input,
9102 }),
9103 ))
9104 }
9105 "item.completed" | "item.failed" => {
9106 let item = payload.get("item")?;
9107 let kind = item
9108 .get("kind")
9109 .and_then(|v| v.as_str())
9110 .unwrap_or_default();
9111 if kind == "tool_call" || kind == "file_change" || kind == "command_execution" {
9112 let id = item.get("id").cloned().unwrap_or(Value::Null);
9113 let success = event.event == "item.completed";
9114 let output = item.get("detail").cloned().unwrap_or_else(|| {
9115 Value::String(
9116 item.get("summary")
9117 .and_then(|v| v.as_str())
9118 .unwrap_or_default()
9119 .to_string(),
9120 )
9121 });
9122 Some(sse_json(
9123 "tool.completed",
9124 json!({
9125 "id": id,
9126 "success": success,
9127 "output": output,
9128 }),
9129 ))
9130 } else if kind == "status" {
9131 let message = item
9132 .get("detail")
9133 .and_then(|v| v.as_str())
9134 .or_else(|| item.get("summary").and_then(|v| v.as_str()))
9135 .unwrap_or_default();
9136 Some(sse_json("status", json!({ "message": message })))
9137 } else if kind == "error" {
9138 let message = item
9139 .get("detail")
9140 .and_then(|v| v.as_str())
9141 .or_else(|| item.get("summary").and_then(|v| v.as_str()))
9142 .unwrap_or_default();
9143 Some(sse_json("error", json!({ "message": message })))
9144 } else {
9145 None
9146 }
9147 }
9148 "approval.required" => {
9149 let approval_id = payload
9150 .get("approval_id")
9151 .or_else(|| payload.get("id"))?
9152 .clone();
9153 Some(sse_json(
9154 "approval.required",
9155 json!({
9156 "id": approval_id,
9157 "approval_id": approval_id,
9158 "tool_call_id": payload.get("tool_call_id"),
9159 "thread_id": event.thread_id,
9160 "turn_id": event.turn_id,
9161 "tool_name": payload.get("tool_name"),
9162 "description": payload.get("description"),
9163 "intent_summary": payload.get("intent_summary"),
9164 }),
9165 ))
9166 }
9167 "approval.decided" => {
9168 let approval_id = payload
9169 .get("approval_id")
9170 .or_else(|| payload.get("id"))?
9171 .clone();
9172 Some(sse_json(
9173 "approval.decided",
9174 json!({
9175 "id": approval_id,
9176 "approval_id": approval_id,
9177 "tool_call_id": payload.get("tool_call_id"),
9178 "thread_id": event.thread_id,
9179 "turn_id": event.turn_id,
9180 "decision": payload.get("decision"),
9181 "remember": payload.get("remember"),
9182 "auto": payload.get("auto"),
9183 "timeout": payload.get("timeout"),
9184 // Set when the decision was forced by a turn interrupt
9185 // or turn teardown: no user selection was made,
9186 // so clients must clear the pending prompt instead of
9187 // reporting a refusal.
9188 "cancelled": payload.get("cancelled"),
9189 }),
9190 ))
9191 }
9192 "approval.timeout" => {
9193 let approval_id = payload
9194 .get("approval_id")
9195 .or_else(|| payload.get("id"))?
9196 .clone();
9197 Some(sse_json(
9198 "approval.timeout",
9199 json!({
9200 "id": approval_id,
9201 "approval_id": approval_id,
9202 "tool_call_id": payload.get("tool_call_id"),
9203 "thread_id": event.thread_id,
9204 "turn_id": event.turn_id,
9205 "timeout_secs": payload.get("timeout_secs"),
9206 }),
9207 ))
9208 }
9209 "user_input.required" => {
9210 let input_id = payload
9211 .get("input_id")
9212 .or_else(|| payload.get("id"))?
9213 .clone();
9214 let request = payload.get("request")?.clone();
9215 Some(sse_json(
9216 "user_input.required",
9217 json!({
9218 "id": input_id,
9219 "input_id": input_id,
9220 "thread_id": event.thread_id,
9221 "turn_id": event.turn_id,
9222 "status": "required",
9223 "request": request,
9224 }),
9225 ))
9226 }
9227 "user_input.answered" | "user_input.canceled" => {
9228 let input_id = payload
9229 .get("input_id")
9230 .or_else(|| payload.get("id"))?
9231 .clone();
9232 let status = if event.event == "user_input.answered" {
9233 "submitted"
9234 } else {
9235 "canceled"
9236 };
9237 Some(sse_json(
9238 &event.event,
9239 json!({
9240 "id": input_id,
9241 "input_id": input_id,
9242 "thread_id": event.thread_id,
9243 "turn_id": event.turn_id,
9244 "status": status,
9245 "terminal": payload.get("terminal").and_then(Value::as_bool).unwrap_or(false),
9246 }),
9247 ))
9248 }
9249 "sandbox.denied" => Some(sse_json("sandbox.denied", payload.clone())),
9250 // The operator's own store failed; the payload names the file and
9251 // the next action, so compat clients see it too (#5931).
9252 crate::runtime_threads::RUNTIME_STORE_FAILURE_EVENT => Some(sse_json(
9253 crate::runtime_threads::RUNTIME_STORE_FAILURE_EVENT,
9254 payload.clone(),
9255 )),
9256 "turn.completed" => {
9257 let usage = payload
9258 .get("turn")
9259 .and_then(|turn| turn.get("usage"))
9260 .cloned()
9261 .unwrap_or(json!(null));
9262 Some(sse_json("turn.completed", json!({ "usage": usage })))
9263 }
9264 _ => None,
9265 }
9266 }
9267
9268 fn sse_json(event: &str, payload: serde_json::Value) -> SseEvent {
9269 let data = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
9270 SseEvent::default().event(event).data(data)
9271 }
9272
9273 /// Read a `Last-Event-ID` cursor off the request.
9274 ///
9275 /// Only a decimal sequence number is ours. Anything else is ignored rather
9276 /// than rejected: an opaque id from a proxy or an older client should start
9277 /// the stream from the durable head, not fail to open it — a refused stream
9278 /// looks like an outage to a reconnecting client.
9279 fn last_event_id(headers: &HeaderMap) -> Option<u64> {
9280 headers
9281 .get("last-event-id")
9282 .and_then(|value| value.to_str().ok())
9283 .and_then(|value| value.trim().parse::<u64>().ok())
9284 }
9285
9286 fn truncate_text(text: &str, max_chars: usize) -> String {
9287 let char_count = text.chars().count();
9288 if char_count <= max_chars {
9289 return text.to_string();
9290 }
9291 let truncated: String = text.chars().take(max_chars.saturating_sub(3)).collect();
9292 format!("{truncated}...")
9293 }
9294
9295 fn resolve_skills_dir(config: &Config, workspace: &std::path::Path) -> PathBuf {
9296 if config.skills_config().scan_codewhale_only() {
9297 if config.skills_dir.is_some() {
9298 return config.skills_dir();
9299 }
9300 if let Some(codewhale_skills_dir) = crate::skills::codewhale_workspace_skills_dir(workspace)
9301 && crate::skills::skills_dir_allowed_by_workspace_trust(
9302 workspace,
9303 &codewhale_skills_dir,
9304 )
9305 && let Ok(canonical_skills) = fs::canonicalize(&codewhale_skills_dir)
9306 {
9307 return canonical_skills;
9308 }
9309 return config.skills_dir();
9310 }
9311
9312 // Canonicalize the workspace once so the symlink-containment check below
9313 // compares like-for-like. If the workspace can't be canonicalized at all
9314 // (e.g. it doesn't exist on disk yet) fall back to the configured global
9315 // skills dir rather than risk constructing paths from a non-existent root.
9316 let canonical_workspace = match fs::canonicalize(workspace) {
9317 Ok(path) => path,
9318 Err(_) => return config.skills_dir(),
9319 };
9320 for candidate in [
9321 canonical_workspace.join(".codewhale/skills"),
9322 canonical_workspace.join(".agents/skills"),
9323 canonical_workspace.join(".claude/skills"),
9324 canonical_workspace.join(".opencode/skills"),
9325 canonical_workspace.join(".cursor/skills"),
9326 ] {
9327 // Re-canonicalize the candidate so a `.agents/skills` symlink to e.g.
9328 // `/etc` cannot promote arbitrary filesystem locations into the
9329 // skills directory. The candidate must still resolve under the
9330 // canonicalized workspace root after symlink expansion.
9331 if let Ok(canon) = fs::canonicalize(&candidate)
9332 && canon.starts_with(&canonical_workspace)
9333 && canon.is_dir()
9334 && crate::skills::skills_dir_allowed_by_workspace_trust(workspace, &canon)
9335 {
9336 return canon;
9337 }
9338 }
9339 let flat = canonical_workspace.join("skills");
9340 if config.skills_config().flat_workspace_root()
9341 && let Ok(canonical) = fs::canonicalize(&flat)
9342 && canonical.starts_with(&canonical_workspace)
9343 && canonical.is_dir()
9344 && crate::skills::skills_dir_allowed_by_workspace_trust(workspace, &canonical)
9345 {
9346 return canonical;
9347 }
9348 config.skills_dir()
9349 }
9350
9351 fn skills_search_directories(
9352 workspace: &FsPath,
9353 skills_dir: &FsPath,
9354 mode: crate::skills::SkillDiscoveryMode,
9355 ) -> Vec<PathBuf> {
9356 crate::skills::skill_directories_for_workspace_and_dir(workspace, skills_dir, mode)
9357 }
9358
9359 fn discover_skills_for_runtime_api(
9360 workspace: &FsPath,
9361 skills_dir: &FsPath,
9362 mode: crate::skills::SkillDiscoveryMode,
9363 plugins: Option<&crate::plugins::PluginRegistry>,
9364 ) -> (crate::skills::SkillRegistry, Vec<PathBuf>) {
9365 let directories = skills_search_directories(workspace, skills_dir, mode);
9366 let registry = crate::skills::discover_from_directories_in_workspace(
9367 directories.clone(),
9368 Some(workspace),
9369 plugins,
9370 );
9371 (registry, directories)
9372 }
9373
9374 fn skill_entry_is_bundled(skill: &crate::skills::Skill, skills_dir: &FsPath) -> bool {
9375 if !crate::skills::is_bundled_skill_name(&skill.name) {
9376 return false;
9377 }
9378
9379 let expected_path = skills_dir.join(&skill.name).join("SKILL.md");
9380 paths_refer_to_same_file(&skill.path, &expected_path)
9381 }
9382
9383 fn paths_refer_to_same_file(left: &FsPath, right: &FsPath) -> bool {
9384 match (fs::canonicalize(left), fs::canonicalize(right)) {
9385 (Ok(left), Ok(right)) => left == right,
9386 _ => left == right,
9387 }
9388 }
9389
9390 fn format_skill_search_paths(directories: &[PathBuf]) -> String {
9391 if directories.is_empty() {
9392 return "<none>".to_string();
9393 }
9394 directories
9395 .iter()
9396 .map(|path| path.display().to_string())
9397 .collect::<Vec<_>>()
9398 .join(", ")
9399 }
9400
9401 #[derive(Debug, Deserialize)]
9402 struct UsageQuery {
9403 /// ISO-8601 lower bound (inclusive). When omitted, no lower bound.
9404 since: Option<String>,
9405 /// ISO-8601 upper bound (inclusive). When omitted, no upper bound.
9406 until: Option<String>,
9407 /// Bucket key. One of `day` (default), `model`, `provider`, `thread`.
9408 group_by: Option<String>,
9409 }
9410
9411 fn parse_iso8601(raw: &str, field: &str) -> Result<chrono::DateTime<Utc>, ApiError> {
9412 chrono::DateTime::parse_from_rfc3339(raw)
9413 .map(|dt| dt.with_timezone(&Utc))
9414 .map_err(|e| ApiError::bad_request(format!("Invalid {field} (expected RFC 3339): {e}")))
9415 }
9416
9417 async fn get_usage(
9418 State(state): State<RuntimeApiState>,
9419 Query(query): Query<UsageQuery>,
9420 ) -> Result<Json<Value>, ApiError> {
9421 let since = match query.since.as_deref() {
9422 Some(raw) => Some(parse_iso8601(raw, "since")?),
9423 None => None,
9424 };
9425 let until = match query.until.as_deref() {
9426 Some(raw) => Some(parse_iso8601(raw, "until")?),
9427 None => None,
9428 };
9429 if let (Some(s), Some(u)) = (since, until)
9430 && s > u
9431 {
9432 return Err(ApiError::bad_request("since must be <= until".to_string()));
9433 }
9434 let group_by = match query.group_by.as_deref().unwrap_or("day") {
9435 "day" => UsageGroupBy::Day,
9436 "model" => UsageGroupBy::Model,
9437 "provider" => UsageGroupBy::Provider,
9438 "thread" => UsageGroupBy::Thread,
9439 other => {
9440 return Err(ApiError::bad_request(format!(
9441 "Unsupported group_by '{other}': expected one of day, model, provider, thread"
9442 )));
9443 }
9444 };
9445
9446 let aggregation = state
9447 .runtime_threads
9448 .aggregate_usage(since, until, group_by)
9449 .await
9450 .map_err(|e| ApiError::internal(e.to_string()))?;
9451 Ok(Json(json!(aggregation)))
9452 }
9453
9454 #[derive(Debug, Deserialize)]
9455 struct SnapshotsQuery {
9456 /// Maximum number of snapshots to return. Mirrors `/restore list [N]`.
9457 limit: Option<usize>,
9458 }
9459
9460 #[derive(Debug, Serialize)]
9461 struct SnapshotEntry {
9462 id: String,
9463 label: String,
9464 timestamp: i64,
9465 }
9466
9467 async fn list_snapshots(
9468 State(state): State<RuntimeApiState>,
9469 Query(query): Query<SnapshotsQuery>,
9470 ) -> Result<Json<Vec<SnapshotEntry>>, ApiError> {
9471 Ok(Json(snapshot_entries_for_workspace(
9472 &state.workspace,
9473 query,
9474 )?))
9475 }
9476
9477 async fn restore_snapshot(
9478 State(state): State<RuntimeApiState>,
9479 Path(id): Path<String>,
9480 ) -> Result<Json<Value>, ApiError> {
9481 if !snapshot_id_is_well_formed(&id) {
9482 return Err(ApiError::bad_request(
9483 "snapshot id must be the exact hexadecimal id reported by GET /v1/snapshots",
9484 ));
9485 }
9486 let reservation = state
9487 .runtime_threads
9488 .workspace_restore_guard(&state.workspace)
9489 .await
9490 .map_err(map_thread_err)?;
9491 let restored_id = id.clone();
9492 tokio::task::spawn_blocking(move || {
9493 let _reservation = reservation;
9494 restore_snapshot_for_workspace(&state.workspace, &restored_id)
9495 })
9496 .await
9497 .map_err(|e| ApiError::internal(format!("Restore task failed: {e}")))??;
9498 Ok(Json(json!({
9499 "restored": id,
9500 })))
9501 }
9502
9503 fn restore_snapshot_for_workspace(workspace: &FsPath, id: &str) -> Result<(), ApiError> {
9504 let repo = crate::snapshot::SnapshotRepo::open_or_init(workspace)
9505 .map_err(|e| ApiError::internal(format!("Snapshot repo init failed: {e}")))?;
9506 let snapshot_id = crate::snapshot::SnapshotId::parse(id)
9507 .map_err(|e| ApiError::bad_request(format!("Invalid snapshot id: {e}")))?;
9508 repo.restore(&snapshot_id)
9509 .map_err(|e| ApiError::internal(format!("Snapshot restore failed: {e}")))
9510 }
9511
9512 fn snapshot_entries_for_workspace(
9513 workspace: &FsPath,
9514 query: SnapshotsQuery,
9515 ) -> Result<Vec<SnapshotEntry>, ApiError> {
9516 const DEFAULT_LIMIT: usize = 20;
9517 const MAX_LIMIT: usize = 100;
9518
9519 let limit = match query.limit.unwrap_or(DEFAULT_LIMIT) {
9520 1..=MAX_LIMIT => query.limit.unwrap_or(DEFAULT_LIMIT),
9521 other => {
9522 return Err(ApiError::bad_request(format!(
9523 "limit must be between 1 and {MAX_LIMIT}; got {other}",
9524 )));
9525 }
9526 };
9527 let repo = crate::snapshot::SnapshotRepo::open_or_init(workspace)
9528 .map_err(|e| ApiError::internal(format!("Snapshot repo unavailable: {e}")))?;
9529 let snapshots = repo
9530 .list(limit)
9531 .map_err(|e| ApiError::internal(format!("Failed to list snapshots: {e}")))?;
9532 Ok(snapshots
9533 .into_iter()
9534 .map(|snapshot| SnapshotEntry {
9535 id: snapshot.id.as_str().to_string(),
9536 label: snapshot.label,
9537 timestamp: snapshot.timestamp,
9538 })
9539 .collect())
9540 }
9541
9542 // ── Provider / Model catalog endpoints ──
9543
9544 /// Entry in `GET /v1/providers`.
9545 ///
9546 /// Exposes the static provider registry so the GUI can render a dynamic
9547 /// provider picker instead of hard-coding `deepseek` only, plus one entry per
9548 /// user-defined `[providers.<name>]` route (#1519) — the same routes the TUI's
9549 /// own provider picker lists, so a route configured in one surface is not
9550 /// missing from the other. The `id` matches `ProviderKind::as_str()`; callers
9551 /// must also preserve `model_provider_id` when present. Both can be pinned to
9552 /// one new thread via `POST /v1/threads` without mutating the runtime's global
9553 /// provider configuration.
9554 #[derive(Debug, Clone, Serialize)]
9555 struct ProviderEntry {
9556 /// Stable generic provider kind — matches `ProviderKind::as_str()` and is
9557 /// suitable for `CreateThreadRequest.model_provider`. This is not always
9558 /// the exact configured route id: named custom routes also require
9559 /// `model_provider_id` below.
9560 id: String,
9561 /// Exact configured provider key for the active route, when one exists.
9562 /// A named custom route such as `lm-studio` is represented as generic
9563 /// `id = "custom"` plus `model_provider_id = "lm-studio"` so a new
9564 /// thread never collapses back to the legacy root custom route.
9565 model_provider_id: Option<String>,
9566 /// Human-friendly name for picker UIs (e.g. "DeepSeek", "OpenAI").
9567 display_name: String,
9568 /// Default model id for this provider, if any. Empty for pass-through
9569 /// providers (Ollama / Custom) that expose no built-in catalog.
9570 default_model: String,
9571 /// Whether this provider exposes a built-in model list. When false, the
9572 /// GUI should render a free-text input instead of calling
9573 /// `/v1/providers/{id}/models`.
9574 has_model_catalog: bool,
9575 /// Sanitized structural credential classification for the exact route.
9576 /// This deliberately contains no credential, endpoint, path, environment
9577 /// variable, consent-source, or token metadata.
9578 #[serde(rename = "credentialState")]
9579 credential_state: ProviderCredentialState,
9580 /// Which *class* of source owns this route's credential (#6179). A class,
9581 /// never a value, a path, or an environment variable name — the guarantee
9582 /// above still holds. Clients need it to tell "you have no key" apart from
9583 /// "your key is owned elsewhere and this control cannot change it".
9584 #[serde(rename = "credentialSource")]
9585 credential_source: secrets::ProviderCredentialSource,
9586 /// Whether `PUT`/`DELETE /v1/providers/{id}/key` will act on this route.
9587 /// False means the write would be refused, so the control should be
9588 /// disabled rather than allowed to fail late.
9589 #[serde(rename = "credentialWritable")]
9590 credential_writable: bool,
9591 /// Why a write is refused, as user-facing copy. Present only when
9592 /// `credentialWritable` is false.
9593 #[serde(
9594 rename = "credentialWritableReason",
9595 skip_serializing_if = "Option::is_none"
9596 )]
9597 credential_writable_reason: Option<&'static str>,
9598 }
9599
9600 /// Stable, non-secret wire projection of provider readiness.
9601 ///
9602 /// The richer internal classification remains private to the Runtime. In
9603 /// particular, saved API keys and imported tokens collapse to `configured`,
9604 /// while login and external-consent states collapse to `login_required`.
9605 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
9606 #[serde(rename_all = "snake_case")]
9607 enum ProviderCredentialState {
9608 Configured,
9609 LoginRequired,
9610 Missing,
9611 NoAuth,
9612 Local,
9613 Legacy,
9614 }
9615
9616 impl From<crate::provider_readiness::CredentialState> for ProviderCredentialState {
9617 fn from(value: crate::provider_readiness::CredentialState) -> Self {
9618 use crate::provider_readiness::CredentialState;
9619
9620 match value {
9621 CredentialState::Saved | CredentialState::ImportedToken => Self::Configured,
9622 CredentialState::MissingLogin | CredentialState::ExternalConsent => Self::LoginRequired,
9623 CredentialState::MissingKey => Self::Missing,
9624 CredentialState::NoAuth => Self::NoAuth,
9625 CredentialState::Local => Self::Local,
9626 CredentialState::Legacy => Self::Legacy,
9627 }
9628 }
9629 }
9630
9631 #[derive(Debug, Clone, Serialize)]
9632 struct ProvidersResponse {
9633 /// Currently active provider id (matches `GET /v1/config`'s `provider`).
9634 current: String,
9635 /// Exact configured id of the active route, when it has one — the same
9636 /// additive identity a [`ProviderEntry`] carries as `model_provider_id`.
9637 /// A named custom route reports `current = "custom"` plus this field, so a
9638 /// client marks the one route that is actually selected instead of
9639 /// whichever entry happens to share the generic kind.
9640 current_provider_id: Option<String>,
9641 providers: Vec<ProviderEntry>,
9642 }
9643
9644 /// Entry in `GET /v1/providers/{id}/models`.
9645 #[derive(Debug, Clone, Serialize)]
9646 struct ProviderModelEntry {
9647 /// Canonical model id suitable for `POST /v1/threads`'s `model` field.
9648 id: String,
9649 /// Image-input support reported by the exact resolved provider/model
9650 /// offering. Unknown stays unknown: the API never guesses from a model
9651 /// name or transport protocol.
9652 image_input: codewhale_config::route::CapabilityState,
9653 output_token_limit: codewhale_config::route::CapabilityState,
9654 reasoning_effort: codewhale_config::route::CapabilityState,
9655 reasoning_effort_levels: Vec<String>,
9656 reasoning_effort_source: Option<&'static str>,
9657 }
9658
9659 #[derive(Debug, Clone, Serialize)]
9660 struct ProviderModelsResponse {
9661 provider: String,
9662 #[serde(skip_serializing_if = "Option::is_none")]
9663 model_provider_id: Option<String>,
9664 models: Vec<ProviderModelEntry>,
9665 total: usize,
9666 #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
9667 next_cursor: Option<String>,
9668 }
9669
9670 const DEFAULT_PROVIDER_MODELS_PAGE_SIZE: usize = 100;
9671 const MAX_PROVIDER_MODELS_PAGE_SIZE: usize = 250;
9672 const MAX_PROVIDER_MODELS_CATALOG_SIZE: usize = 10_000;
9673 const PROVIDER_MODELS_CURSOR_VERSION: u8 = 1;
9674 const MAX_PROVIDER_MODELS_CURSOR_BYTES: usize = 1_024;
9675 const MAX_PROVIDER_MODELS_FILTER_CHARS: usize = 128;
9676
9677 #[derive(Debug, Clone, Serialize, Deserialize)]
9678 struct ProviderModelsCursor {
9679 version: u8,
9680 provider: String,
9681 filter: String,
9682 catalog_fingerprint: String,
9683 #[serde(default, skip_serializing_if = "Option::is_none")]
9684 route_fingerprint: Option<String>,
9685 offset: usize,
9686 }
9687
9688 fn normalized_provider_model_filter(filter: Option<&str>) -> Result<String, ApiError> {
9689 let filter = filter.unwrap_or_default().trim();
9690 if filter.chars().count() > MAX_PROVIDER_MODELS_FILTER_CHARS {
9691 return Err(ApiError::bad_request(format!(
9692 "Provider model filter exceeds {MAX_PROVIDER_MODELS_FILTER_CHARS} characters"
9693 )));
9694 }
9695 Ok(filter.to_lowercase())
9696 }
9697
9698 fn encode_provider_models_cursor(cursor: &ProviderModelsCursor) -> Result<String, ApiError> {
9699 let bytes = serde_json::to_vec(cursor)
9700 .map_err(|error| ApiError::internal(format!("Could not encode model cursor: {error}")))?;
9701 if bytes.len() > MAX_PROVIDER_MODELS_CURSOR_BYTES {
9702 return Err(ApiError::internal(
9703 "Provider model cursor exceeds the safe size limit",
9704 ));
9705 }
9706 Ok(URL_SAFE_NO_PAD.encode(bytes))
9707 }
9708
9709 fn decode_provider_models_cursor(value: &str) -> Result<ProviderModelsCursor, ApiError> {
9710 if value.is_empty() || value.len() > MAX_PROVIDER_MODELS_CURSOR_BYTES.div_ceil(3) * 4 {
9711 return Err(ApiError::bad_request("Invalid provider model cursor"));
9712 }
9713 let bytes = URL_SAFE_NO_PAD
9714 .decode(value)
9715 .map_err(|_| ApiError::bad_request("Invalid provider model cursor"))?;
9716 if bytes.len() > MAX_PROVIDER_MODELS_CURSOR_BYTES {
9717 return Err(ApiError::bad_request("Invalid provider model cursor"));
9718 }
9719 let cursor: ProviderModelsCursor = serde_json::from_slice(&bytes)
9720 .map_err(|_| ApiError::bad_request("Invalid provider model cursor"))?;
9721 if cursor.version != PROVIDER_MODELS_CURSOR_VERSION
9722 || cursor.provider.is_empty()
9723 || cursor.offset == 0
9724 || cursor.offset > MAX_PROVIDER_MODELS_CATALOG_SIZE
9725 || cursor.catalog_fingerprint.len() != 64
9726 || !cursor
9727 .catalog_fingerprint
9728 .bytes()
9729 .all(|byte| byte.is_ascii_hexdigit())
9730 {
9731 return Err(ApiError::bad_request("Invalid provider model cursor"));
9732 }
9733 Ok(cursor)
9734 }
9735
9736 fn paginate_provider_models(
9737 provider: &str,
9738 mut models: Vec<ProviderModelEntry>,
9739 params: &ListProviderModelsParams,
9740 route_fingerprint: Option<String>,
9741 ) -> Result<ProviderModelsResponse, ApiError> {
9742 let filter = normalized_provider_model_filter(params.filter.as_deref())?;
9743 let limit = params.limit.unwrap_or(DEFAULT_PROVIDER_MODELS_PAGE_SIZE);
9744 if limit == 0 || limit > MAX_PROVIDER_MODELS_PAGE_SIZE {
9745 return Err(ApiError::bad_request(format!(
9746 "Provider model page limit must be between 1 and {MAX_PROVIDER_MODELS_PAGE_SIZE}"
9747 )));
9748 }
9749
9750 models.sort_by(|left, right| {
9751 left.id
9752 .to_lowercase()
9753 .cmp(&right.id.to_lowercase())
9754 .then_with(|| left.id.cmp(&right.id))
9755 });
9756 models.dedup_by(|left, right| left.id.eq_ignore_ascii_case(&right.id));
9757 if models.len() > MAX_PROVIDER_MODELS_CATALOG_SIZE {
9758 return Err(ApiError::internal(format!(
9759 "Provider model catalog exceeds the safe {MAX_PROVIDER_MODELS_CATALOG_SIZE}-row limit"
9760 )));
9761 }
9762 if !filter.is_empty() {
9763 models.retain(|entry| entry.id.to_lowercase().contains(&filter));
9764 }
9765
9766 // A live catalog can refresh between requests. Bind the opaque position
9767 // to the exact sorted projection so additions before the cursor cannot
9768 // disappear silently from a multi-page response.
9769 let catalog_bytes = serde_json::to_vec(&models).map_err(|error| {
9770 ApiError::internal(format!("Could not fingerprint model catalog: {error}"))
9771 })?;
9772 let catalog_fingerprint = Sha256::digest(catalog_bytes)
9773 .iter()
9774 .map(|byte| format!("{byte:02x}"))
9775 .collect::<String>();
9776 let start = if let Some(encoded) = params.cursor.as_deref() {
9777 let cursor = decode_provider_models_cursor(encoded)?;
9778 if cursor.provider != provider
9779 || cursor.filter != filter
9780 || cursor.route_fingerprint != route_fingerprint
9781 {
9782 return Err(ApiError::bad_request(
9783 "Provider model cursor does not match this provider, configured route, and filter",
9784 ));
9785 }
9786 if cursor.catalog_fingerprint != catalog_fingerprint {
9787 return Err(ApiError::bad_request(
9788 "Provider model cursor is stale; restart from the first page",
9789 ));
9790 }
9791 cursor.offset
9792 } else {
9793 0
9794 };
9795 let total = models.len();
9796 let end = start.saturating_add(limit).min(total);
9797 let page = models
9798 .get(start..end)
9799 .ok_or_else(|| ApiError::bad_request("Provider model cursor is outside the catalog"))?
9800 .to_vec();
9801 let next_cursor = if end < total {
9802 Some(encode_provider_models_cursor(&ProviderModelsCursor {
9803 version: PROVIDER_MODELS_CURSOR_VERSION,
9804 provider: provider.to_string(),
9805 filter,
9806 catalog_fingerprint,
9807 route_fingerprint,
9808 offset: end,
9809 })?)
9810 } else {
9811 None
9812 };
9813
9814 Ok(ProviderModelsResponse {
9815 provider: provider.to_string(),
9816 model_provider_id: params.model_provider_id.clone(),
9817 models: page,
9818 total,
9819 next_cursor,
9820 })
9821 }
9822
9823 fn push_unique_model(models: &mut Vec<String>, model: &str) {
9824 let model = model.trim();
9825 if !model.is_empty()
9826 && !models
9827 .iter()
9828 .any(|existing| existing.eq_ignore_ascii_case(model))
9829 {
9830 models.push(model.to_string());
9831 }
9832 }
9833
9834 fn provider_models_for_api(
9835 config: &Config,
9836 identity: &crate::config::ProviderIdentity,
9837 ) -> Vec<String> {
9838 if config.verify_provider_identity(identity).is_err() {
9839 return Vec::new();
9840 }
9841 let provider = identity.provider;
9842 let mut models = Vec::new();
9843 if let Some(model) = config
9844 .provider_config_for(identity)
9845 .and_then(|entry| entry.model.as_deref())
9846 {
9847 push_unique_model(&mut models, model);
9848 }
9849 if config.active_provider_identity().ok().as_ref() == Some(identity) {
9850 let active_model = provider_default_model_for_api(config, identity);
9851 if !active_model.trim().eq_ignore_ascii_case("auto") {
9852 push_unique_model(&mut models, &active_model);
9853 }
9854 }
9855 let exact_catalog = crate::provider_catalog_live::cached_entry_for_route(
9856 provider,
9857 identity.key.as_str(),
9858 &config.base_url_for_route(identity),
9859 )
9860 .ok()
9861 .flatten()
9862 .is_some_and(|entry| entry.fetched_at > 0);
9863 // A pass-through provider normally lists only what its own live catalog
9864 // returned. When that catalog cannot exist (an OAuth route), the catalog
9865 // lake's next layers (Models.dev, then the bundled snapshot) answer.
9866 if !config.model_ids_pass_through_for_provider(identity)
9867 || exact_catalog
9868 || crate::provider_lake::live_catalog_unavailable(config, identity)
9869 {
9870 for model in crate::provider_lake::models_for_provider(config, identity) {
9871 push_unique_model(&mut models, &model);
9872 }
9873 }
9874 for model in config.custom_models.as_deref().unwrap_or_default() {
9875 if crate::provider_lake::configured_model_for_route(
9876 config,
9877 provider,
9878 identity.key.as_str(),
9879 &config.base_url_for_route(identity),
9880 &model.id,
9881 )
9882 .is_some()
9883 && !models.contains(&model.id)
9884 {
9885 models.push(model.id.clone());
9886 }
9887 }
9888 if provider == ProviderKind::Ollama {
9889 models.retain(|model| !crate::config::is_unresolved_local_ollama_model(model));
9890 }
9891 models
9892 }
9893
9894 fn provider_model_image_input_for_api(
9895 config: &Config,
9896 identity: &crate::config::ProviderIdentity,
9897 model: &str,
9898 ) -> codewhale_config::route::CapabilityState {
9899 crate::route_runtime::resolve_runtime_route_for_identity(config, identity, Some(model))
9900 .map(|route| route.candidate.capabilities().image_input)
9901 .unwrap_or_default()
9902 }
9903
9904 fn provider_model_output_token_limit_for_api(
9905 config: &Config,
9906 identity: &crate::config::ProviderIdentity,
9907 model: &str,
9908 ) -> codewhale_config::route::CapabilityState {
9909 use codewhale_config::route::CapabilityState;
9910 crate::route_runtime::resolve_runtime_route_for_identity(config, identity, Some(model))
9911 .map(|route| {
9912 if crate::route_budget::route_supports_output_token_limit(
9913 route.identity.provider,
9914 route.candidate.protocol(),
9915 ) {
9916 CapabilityState::Supported
9917 } else {
9918 CapabilityState::Unsupported
9919 }
9920 })
9921 .unwrap_or_default()
9922 }
9923
9924 fn provider_model_entry_for_api(
9925 config: &Config,
9926 identity: &crate::config::ProviderIdentity,
9927 model: String,
9928 ) -> ProviderModelEntry {
9929 use crate::reasoning_preference::ReasoningEffort;
9930 use codewhale_config::route::CapabilityState;
9931
9932 let provider = identity.provider;
9933 let mut entry = ProviderModelEntry {
9934 image_input: provider_model_image_input_for_api(config, identity, &model),
9935 output_token_limit: provider_model_output_token_limit_for_api(config, identity, &model),
9936 id: model,
9937 reasoning_effort: CapabilityState::Unknown,
9938 reasoning_effort_levels: Vec::new(),
9939 reasoning_effort_source: None,
9940 };
9941 // A provider kind and a familiar model name do not establish the
9942 // capabilities of a different endpoint or named compatible route.
9943 if provider == ProviderKind::Custom || config.provider_uses_custom_endpoint(identity) {
9944 return entry;
9945 }
9946 if provider == ProviderKind::OpenaiCodex {
9947 let roster = crate::codex_model_cache::model_roster_for(config);
9948 if roster.freshness != crate::codex_model_cache::CodexModelCacheFreshness::Fresh {
9949 return entry;
9950 }
9951 let Some(metadata) = roster.metadata_for(&entry.id) else {
9952 return entry;
9953 };
9954 for effort in metadata
9955 .efforts
9956 .iter()
9957 .filter_map(|raw| ReasoningEffort::from_catalog_token(raw))
9958 // This API advertises active effort controls. Apps currently
9959 // treats off as omission, not a provider's explicit none value.
9960 .filter(|effort| *effort != ReasoningEffort::Off)
9961 // Native compatibility still aliases minimal to low (and auto
9962 // to medium). Do not advertise a manual tier the wire changes.
9963 .filter(|effort| effort.api_value_for_provider(provider) == Some(effort.as_setting()))
9964 {
9965 let level = effort.as_setting().to_string();
9966 if !entry.reasoning_effort_levels.contains(&level) {
9967 entry.reasoning_effort_levels.push(level);
9968 }
9969 }
9970 entry.reasoning_effort_source = Some(roster.source);
9971 if metadata.reasoning == Some(false) {
9972 entry.reasoning_effort = CapabilityState::Unsupported;
9973 }
9974 } else if let Some(efforts) = ReasoningEffort::catalog_effort_values(provider, &entry.id) {
9975 entry.reasoning_effort_levels = efforts
9976 .into_iter()
9977 .filter(|effort| *effort != ReasoningEffort::Off)
9978 .map(|effort| effort.as_setting().to_string())
9979 .collect();
9980 entry.reasoning_effort_source = Some("catalog");
9981 } else if crate::route_runtime::resolve_runtime_route_for_identity(
9982 config,
9983 identity,
9984 Some(&entry.id),
9985 )
9986 .is_ok_and(|route| route.candidate.capabilities().reasoning == CapabilityState::Unsupported)
9987 {
9988 entry.reasoning_effort = CapabilityState::Unsupported;
9989 entry.reasoning_effort_source = Some("catalog");
9990 }
9991 if !entry.reasoning_effort_levels.is_empty() {
9992 entry.reasoning_effort = CapabilityState::Supported;
9993 }
9994 entry
9995 }
9996
9997 fn provider_default_model_for_api(
9998 config: &Config,
9999 identity: &crate::config::ProviderIdentity,
10000 ) -> String {
10001 let provider = identity.provider;
10002 let model = crate::model_inventory::provider_default_model(config, identity);
10003 if provider == ProviderKind::Ollama && crate::config::is_unresolved_local_ollama_model(&model) {
10004 String::new()
10005 } else {
10006 model
10007 }
10008 }
10009
10010 pub(crate) fn runtime_chat_model_id_is_safe(value: &str) -> bool {
10011 let sanitized = crate::cost_status::sanitize_persisted_route_label(value);
10012 value == value.trim()
10013 && !value.is_empty()
10014 && value.len() <= 256
10015 && value
10016 .bytes()
10017 .next()
10018 .is_some_and(|byte| byte.is_ascii_alphanumeric())
10019 && !value.contains("..")
10020 && !value.contains("://")
10021 // Runtime Chat publishes a non-secret selector, never an endpoint or
10022 // userinfo-bearing authority. Model families that need revisions can
10023 // use their ordinary slash/dash ids; `@` is intentionally excluded at
10024 // this trust boundary because `user:password@host:port/path` otherwise
10025 // passes the generic route-label sanitizer.
10026 && !value.contains('@')
10027 && !runtime_chat_model_id_looks_like_host_port(value)
10028 && !value.starts_with("redacted-")
10029 && sanitized == value
10030 && value.bytes().all(|byte| {
10031 byte.is_ascii_alphanumeric()
10032 || matches!(byte, b'.' | b'_' | b':' | b'/' | b'@' | b'+' | b'-')
10033 })
10034 }
10035
10036 fn runtime_chat_model_id_looks_like_host_port(value: &str) -> bool {
10037 let authority = value.split('/').next().unwrap_or(value);
10038 let Some((host, port)) = authority.rsplit_once(':') else {
10039 return false;
10040 };
10041 !host.is_empty() && !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit())
10042 }
10043
10044 pub(crate) fn runtime_chat_route_id_is_safe(value: &str) -> bool {
10045 let sanitized = crate::cost_status::sanitize_persisted_route_label(value);
10046 value == value.trim()
10047 && !value.is_empty()
10048 && value.len() <= 128
10049 && value
10050 .bytes()
10051 .next()
10052 .is_some_and(|byte| byte.is_ascii_alphanumeric())
10053 && !value.contains("..")
10054 && !value.starts_with("redacted-")
10055 && sanitized == value
10056 && value
10057 .bytes()
10058 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
10059 }
10060
10061 fn runtime_chat_safe_models(mut models: Vec<String>) -> Result<Vec<String>, String> {
10062 models.retain(|model| runtime_chat_model_id_is_safe(model));
10063 models.sort();
10064 models.dedup();
10065 if models.len() > MAX_PROVIDER_MODELS_CATALOG_SIZE {
10066 return Err(format!(
10067 "The active Runtime provider catalog exceeds the safe {MAX_PROVIDER_MODELS_CATALOG_SIZE}-model relay limit."
10068 ));
10069 }
10070 if models.is_empty() {
10071 return Err("The active Runtime provider has no safe model catalog.".to_string());
10072 }
10073 Ok(models)
10074 }
10075
10076 /// Build the deliberately narrow provider projection used by the account-owned
10077 /// Runtime Chat relay. This is the same active-route truth exposed by the
10078 /// authenticated native `/v1/runtime/info`, `/v1/providers`, and
10079 /// `/v1/providers/{id}/models` endpoints, collapsed to the one exact route the
10080 /// current Runtime can use without moving credentials across the relay.
10081 pub(crate) fn runtime_chat_relay_catalog(
10082 config: &Config,
10083 challenge: &str,
10084 ) -> Result<Value, String> {
10085 use crate::provider_readiness::CredentialState;
10086
10087 const PROTOCOL: &str = "codewhale.runtime-chat-relay.v1";
10088 if !(32..=128).contains(&challenge.len())
10089 || !challenge
10090 .bytes()
10091 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
10092 {
10093 return Err("Codewhale returned an invalid Runtime Chat relay challenge.".to_string());
10094 }
10095
10096 let identity = config
10097 .active_provider_identity()
10098 .map_err(|_| "The active Runtime provider identity is invalid.".to_string())?;
10099 let provider = identity.provider;
10100 let credential_state =
10101 match crate::provider_readiness::credential_state_for_provider(config, &identity) {
10102 CredentialState::Saved | CredentialState::ImportedToken => "configured",
10103 CredentialState::Local => "local",
10104 CredentialState::NoAuth => "no_auth",
10105 CredentialState::MissingKey
10106 | CredentialState::MissingLogin
10107 | CredentialState::ExternalConsent
10108 | CredentialState::Legacy => {
10109 return Err("The active Runtime provider is not ready for Chat.".to_string());
10110 }
10111 };
10112
10113 let models = runtime_chat_safe_models(provider_models_for_api(config, &identity))?;
10114 let requested_default = provider_default_model_for_api(config, &identity);
10115 if provider == ProviderKind::Ollama && requested_default.is_empty() {
10116 return Err("The active local provider has no fresh default model catalog.".to_string());
10117 }
10118 let default_model = models
10119 .iter()
10120 .find(|model| model.as_str() == requested_default)
10121 .cloned()
10122 .unwrap_or_else(|| models[0].clone());
10123 let model_provider_id = identity
10124 .persisted_id()
10125 .filter(|value| !value.trim().is_empty())
10126 .unwrap_or_else(|| provider.as_str())
10127 .to_string();
10128 if !runtime_chat_route_id_is_safe(&model_provider_id) {
10129 return Err("The active Runtime model-provider identity is invalid.".to_string());
10130 }
10131
10132 Ok(json!({
10133 "protocol": PROTOCOL,
10134 "challenge": challenge,
10135 "runtime": {
10136 "service": "codewhale-runtime-api",
10137 "apiVersion": RUNTIME_API_VERSION,
10138 "codewhaleVersion": env!("CARGO_PKG_VERSION"),
10139 "authRequired": true,
10140 "capabilities": {
10141 "relay_chat_v1": true,
10142 "isolated_chat_threads": true,
10143 "turn_operation_idempotency": true,
10144 "turn_image_inputs": true,
10145 "turn_output_token_limit": true,
10146 "tool_execution": false,
10147 "stable_event_ids": true,
10148 },
10149 },
10150 "providers": [{
10151 "id": provider.as_str(),
10152 "modelProviderId": model_provider_id,
10153 "displayName": identity.compatibility().map(|row| row.label).unwrap_or(identity.key.as_str()),
10154 "defaultModel": default_model,
10155 "credentialState": credential_state,
10156 "models": models.into_iter().map(|model| {
10157 let entry = provider_model_entry_for_api(config, &identity, model);
10158 json!({
10159 "imageInput": entry.image_input,
10160 "outputTokenLimit": entry.output_token_limit,
10161 "id": entry.id,
10162 "reasoningEffort": entry.reasoning_effort,
10163 "reasoningEffortLevels": entry.reasoning_effort_levels,
10164 "reasoningEffortSource": entry.reasoning_effort_source,
10165 })
10166 }).collect::<Vec<_>>(),
10167 }],
10168 }))
10169 }
10170
10171 /// Names of the user-defined `[providers.<name>]` routes this runtime can
10172 /// route to, sorted case-insensitively.
10173 ///
10174 /// Mirrors the TUI provider picker's own row filter
10175 /// (`custom_provider_dashboard_rows`), so the routes one surface offers are the
10176 /// routes the other offers. A table without the `openai-compatible` kind is not
10177 /// a routable custom route and stays out of both.
10178 fn configured_custom_provider_routes(config: &Config) -> Vec<String> {
10179 let Some(providers) = config.providers.as_ref() else {
10180 return Vec::new();
10181 };
10182 let mut names: Vec<String> = providers
10183 .custom
10184 .iter()
10185 .filter(|(_, entry)| entry.is_openai_compatible_custom())
10186 .map(|(name, _)| name.clone())
10187 .collect();
10188 names.sort_by_key(|name| name.to_ascii_lowercase());
10189 names
10190 }
10191
10192 /// Project one provider route into the `GET /v1/providers` wire shape.
10193 ///
10194 /// `exact_route` names the user-defined `[providers.<name>]` entry being
10195 /// listed, and `config` must already be scoped to it. The entry then describes
10196 /// that route — its own name and its own catalog — while `id` stays the generic
10197 /// kind every other endpoint addresses it by. Without it, the entry describes
10198 /// the built-in provider as before.
10199 fn provider_entry_for_api(
10200 config: &Config,
10201 identity: &crate::config::ProviderIdentity,
10202 ) -> ProviderEntry {
10203 let writeability = secrets::credential_writeability(config, identity);
10204 ProviderEntry {
10205 id: identity.persisted_kind().to_string(),
10206 model_provider_id: identity.persisted_id().map(str::to_string),
10207 display_name: identity
10208 .compatibility()
10209 .map(|row| row.label.to_string())
10210 .unwrap_or_else(|| format!("{} (custom)", identity.key)),
10211 default_model: provider_default_model_for_api(config, identity),
10212 has_model_catalog: !provider_models_for_api(config, identity).is_empty(),
10213 credential_state: crate::provider_readiness::credential_state_for_provider(
10214 config, identity,
10215 )
10216 .into(),
10217 credential_source: writeability.source,
10218 credential_writable: writeability.writable,
10219 credential_writable_reason: writeability.reason,
10220 }
10221 }
10222
10223 async fn list_providers(
10224 State(state): State<RuntimeApiState>,
10225 ) -> Result<Json<ProvidersResponse>, ApiError> {
10226 #[cfg(test)]
10227 let env_ticket = crate::test_support::env_scope_ticket();
10228 tokio::task::spawn_blocking(move || {
10229 #[cfg(test)]
10230 let _membership = crate::test_support::join_env_scope(env_ticket);
10231 let config = state.config.read().clone();
10232 secrets::invalidate_stale_account_catalog(&config);
10233 let active_identity = config.active_provider_identity().ok();
10234 let current = active_identity.as_ref().map_or_else(|| config.provider.clone().unwrap_or_else(|| "unavailable".into()), |identity| identity.persisted_kind().to_string());
10235 let mut providers = config.provider_identities().iter().filter(|identity| identity.provider != ProviderKind::Antigravity).map(|identity| provider_entry_for_api(&config, identity)).collect::<Vec<_>>();
10236 providers.extend(config.unadmitted_provider_keys().into_iter().map(|key| ProviderEntry {
10237 id: key.to_string(), model_provider_id: Some(key.to_string()), display_name: format!("{key} (unavailable)"),
10238 default_model: String::new(), has_model_catalog: false,
10239 credential_state: ProviderCredentialState::Legacy, credential_source: secrets::ProviderCredentialSource::None,
10240 credential_writable: false, credential_writable_reason: Some("This configured route is unavailable; repair its exact provider definition before using it."),
10241 }));
10242 Ok(Json(ProvidersResponse {
10243 current_provider_id: active_identity.as_ref().and_then(|identity| identity.persisted_id()).map(str::to_string),
10244 current,
10245 providers,
10246 }))
10247 })
10248 .await
10249 .map_err(|_| ApiError::internal("Provider listing failed"))?
10250 }
10251
10252 #[derive(Debug, Default, Deserialize)]
10253 struct ListProviderModelsParams {
10254 /// Exact configured provider identity; omission retains the legacy projection.
10255 #[serde(default)]
10256 model_provider_id: Option<String>,
10257 /// Optional case-insensitive substring filter applied before pagination.
10258 #[serde(default)]
10259 filter: Option<String>,
10260 /// Opaque continuation cursor returned as `nextCursor` by the prior page.
10261 #[serde(default)]
10262 cursor: Option<String>,
10263 /// Page size. The bounded default is 100 and the maximum is 250.
10264 #[serde(default)]
10265 limit: Option<usize>,
10266 }
10267
10268 fn provider_models_identity(
10269 config: &Config,
10270 id: &str,
10271 exact_id: Option<&str>,
10272 ) -> Result<crate::config::ProviderIdentity, ApiError> {
10273 if id.is_empty() || id != id.trim() || id.chars().any(char::is_control) {
10274 return Err(ApiError::bad_request("provider must be an exact selection"));
10275 }
10276 if exact_id.is_some_and(|value| {
10277 value.is_empty() || value != value.trim() || value.chars().any(char::is_control)
10278 }) {
10279 return Err(ApiError::bad_request(
10280 "model_provider_id must be an exact configured identity",
10281 ));
10282 }
10283 // A supplied pair is re-admitted exactly. An omitted id denotes the named
10284 // selection, with literal custom using only its parse-proven root origin.
10285 let identity = match exact_id {
10286 Some(exact_id) => config.resolve_persisted_provider_identity(Some(id), Some(exact_id)),
10287 None => config.legacy_selection_identity(id),
10288 }
10289 .map_err(ApiError::bad_request)?;
10290 config
10291 .verify_provider_identity(&identity)
10292 .map_err(ApiError::bad_request)?;
10293 Ok(identity)
10294 }
10295
10296 async fn list_provider_models(
10297 State(state): State<RuntimeApiState>,
10298 Path(id): Path<String>,
10299 Query(params): Query<ListProviderModelsParams>,
10300 ) -> Result<Json<ProviderModelsResponse>, ApiError> {
10301 #[cfg(test)]
10302 let env_ticket = crate::test_support::env_scope_ticket();
10303 tokio::task::spawn_blocking(move || {
10304 #[cfg(test)]
10305 let _membership = crate::test_support::join_env_scope(env_ticket);
10306 let config = state.config.read().clone();
10307 secrets::invalidate_stale_account_catalog(&config);
10308 let identity = provider_models_identity(&config, &id, params.model_provider_id.as_deref())?;
10309 let route = serde_json::to_vec(&(
10310 identity.persisted_kind(),
10311 identity.persisted_id(),
10312 config.base_url_for_route(&identity),
10313 ))
10314 .map_err(|error| {
10315 ApiError::internal(format!("Could not fingerprint provider route: {error}"))
10316 })?;
10317 let route_fingerprint = Some(crate::hashing::sha256_hex(route));
10318 let models = provider_models_for_api(&config, &identity)
10319 .into_iter()
10320 .map(|id| provider_model_entry_for_api(&config, &identity, id))
10321 .collect();
10322 paginate_provider_models(
10323 identity.persisted_kind(),
10324 models,
10325 &params,
10326 route_fingerprint,
10327 )
10328 .map(Json)
10329 })
10330 .await
10331 .map_err(|_| ApiError::internal("Provider model listing failed"))?
10332 }
10333
10334 #[derive(Deserialize)]
10335 #[serde(deny_unknown_fields)]
10336 struct RefreshProviderModelsParams {
10337 model_provider_id: Option<String>,
10338 }
10339
10340 async fn refresh_provider_models(
10341 State(state): State<RuntimeApiState>,
10342 Path(id): Path<String>,
10343 Query(params): Query<RefreshProviderModelsParams>,
10344 ) -> Result<Json<crate::provider_lake::CatalogUpdateReceipt>, ApiError> {
10345 let runtime = tokio::runtime::Handle::current();
10346 #[cfg(test)]
10347 let env_ticket = crate::test_support::env_scope_ticket();
10348 tokio::task::spawn_blocking(move || {
10349 #[cfg(test)]
10350 let _membership = crate::test_support::join_env_scope(env_ticket);
10351 let config = state.config.read().clone();
10352 let identity = provider_models_identity(&config, &id, params.model_provider_id.as_deref())?;
10353 Ok(Json(runtime.block_on(
10354 crate::provider_lake::update_provider_catalog(&config, &identity),
10355 )))
10356 })
10357 .await
10358 .map_err(|_| ApiError::internal("Provider model refresh failed"))?
10359 }
10360
10361 /// Request body for `POST /v1/providers/{id}/switch`.
10362 ///
10363 /// Mirrors the TUI's `AppAction::SwitchProvider { provider, model }` payload
10364 /// (see `tui/ui.rs::switch_provider`). `model` is optional: when omitted,
10365 /// the runtime resolves the active model from `[providers.<id>].model` (or
10366 /// the provider's built-in default) and **does not** persist a `model` key,
10367 /// so the user's per-provider config is preserved. When provided, the model
10368 /// is normalized and persisted in the target provider's canonical model slot.
10369 #[derive(Debug, Deserialize, Default)]
10370 struct SwitchProviderRequest {
10371 #[serde(default)]
10372 model: Option<String>,
10373 /// Exact configured provider id for the target route.
10374 ///
10375 /// Named `[providers.<name>]` routes are addressed the same way this API
10376 /// addresses them everywhere else: the generic kind in the id (`custom`)
10377 /// plus this additive exact id, exactly as `ProviderEntry`
10378 /// `model_provider_id` and `POST /v1/threads` already carry it. Omitted
10379 /// keeps the pre-existing meaning — the built-in id, or the literal
10380 /// `[providers.custom]` route (where the older top-level custom route
10381 /// lives since #6394).
10382 #[serde(default)]
10383 model_provider_id: Option<String>,
10384 }
10385
10386 /// Response for `POST /v1/providers/{id}/switch`.
10387 #[derive(Debug, Serialize)]
10388 struct SwitchProviderResponse {
10389 /// The provider id that was switched to (echoes the path).
10390 provider: String,
10391 /// The resolved active model after the switch. This is the model the
10392 /// runtime will use for new turns — either the user-supplied override
10393 /// or the value resolved from `[providers.<id>].model` / the
10394 /// provider's built-in default. The GUI should display *this* value,
10395 /// not `ProviderEntry.default_model`, to avoid showing the catalog
10396 /// default when the user has configured a different model.
10397 model: String,
10398 /// False while the selected local endpoint has no executable default.
10399 model_available: bool,
10400 /// Human-readable status message for logging/toasts.
10401 message: String,
10402 /// Whether the new provider + model were persisted to config.toml.
10403 persisted: bool,
10404 }
10405
10406 /// `POST /v1/providers/{id}/switch` — switch the active provider, optionally
10407 /// overriding the model.
10408 ///
10409 /// `{id}` is the generic provider kind (`custom` for every user-defined route).
10410 /// A named `[providers.<name>]` route is named by `model_provider_id` in the
10411 /// body, not by the path: the path keeps naming the kind, so one endpoint
10412 /// cannot disagree with `GET /v1/providers` about what an entry's `id` means.
10413 ///
10414 /// This is the GUI-facing counterpart of the TUI's `/provider` slash command
10415 /// (`commands/groups/core/provider.rs`) and `AppAction::SwitchProvider`
10416 /// (`tui/ui.rs::switch_provider`). It exists so the GUI does not have to
10417 /// simulate the switch with multiple `POST /v1/config` calls + a reload,
10418 /// which historically led to two bugs:
10419 ///
10420 /// 1. The GUI persisted `model = <catalog default>` even when the user
10421 /// clicked the picker without choosing a model, clobbering a user-set
10422 /// `[providers.<id>].model` (e.g. `glm-2` overwritten with
10423 /// `deepseek-v4-pro`).
10424 /// 2. The GUI then displayed the catalog default instead of the actually
10425 /// resolved model, because it never asked the backend what model was
10426 /// selected.
10427 ///
10428 /// Persistence mirrors `switch_provider` (ui.rs:9390-9410):
10429 /// - `provider` is always persisted (root `provider` key).
10430 /// - `model` is persisted **only** when `model_override.is_some()`, via
10431 /// `persist_provider_model_key` (writes `[providers.<id>].model`, retaining
10432 /// the root field only for a legacy literal custom route). Provider and model
10433 /// are committed together through the canonical Config writer.
10434 /// - Config is reloaded from disk and synced to active engines via
10435 /// `runtime_threads.reload_config`, exactly like `POST /v1/config/reload`.
10436 /// - A reload that fails or is rejected rolls the persisted selection back
10437 /// (only while the file still holds what this write left), so disk and the
10438 /// running config never disagree about the provider. The error says which.
10439 async fn switch_provider(
10440 State(state): State<RuntimeApiState>,
10441 Path(id): Path<String>,
10442 Json(req): Json<SwitchProviderRequest>,
10443 ) -> Result<Json<SwitchProviderResponse>, ApiError> {
10444 use crate::config_persistence;
10445
10446 let trimmed_id = id.trim();
10447 let _target = ProviderKind::parse_config_identity(trimmed_id).ok_or_else(|| {
10448 // A configured `[providers.<name>]` route is a route this runtime can
10449 // and does switch to — but only through the generic kind, because the
10450 // same name is what `GET /v1/providers` reports as
10451 // `model_provider_id`. Tell the caller exactly that instead of
10452 // pretending the route does not exist.
10453 let named_route = configured_custom_provider_routes(&state.config.read())
10454 .iter()
10455 .any(|route| route == trimmed_id);
10456 if named_route {
10457 ApiError::bad_request(format!(
10458 "'{trimmed_id}' is a user-defined route: switch to the generic 'custom' kind with model_provider_id = \"{trimmed_id}\" instead"
10459 ))
10460 } else {
10461 ApiError::bad_request(format!(
10462 "Unknown provider id '{trimmed_id}'. Call GET /v1/providers for the list of supported ids."
10463 ))
10464 }
10465 })?;
10466 // Reject the legacy deepseek-cn alias — same guard as list_provider_models.
10467 if codewhale_config::descriptors::compatibility_for_selector(trimmed_id)
10468 .is_some_and(|row| row.id == codewhale_config::descriptors::LEGACY_DEEPSEEK_CN.id)
10469 {
10470 return Err(ApiError::bad_request(
10471 "provider 'deepseek-cn' is a legacy alias; use 'deepseek' instead",
10472 ));
10473 }
10474 let exact_provider_id = req.model_provider_id.as_deref();
10475 if exact_provider_id.is_some_and(|value| value.trim().is_empty() || value != value.trim()) {
10476 return Err(ApiError::bad_request(
10477 "model_provider_id must be a nonempty exact id",
10478 ));
10479 }
10480
10481 // Normalize the optional model override against the *target* provider.
10482 // Mirrors `set_config`'s `model` branch, which validates against the
10483 // active route — except here we validate against the target provider,
10484 // because the active route is about to change.
10485 // Read normalization and persistence identity from the same route snapshot.
10486 let (model_override, provider_identity) = {
10487 let config = state.config.read();
10488 // An additive exact id is the stronger selector: it names one
10489 // configured route, so it resolves through the same pinned-identity
10490 // path saved threads use. Absent, the id keeps its previous meaning.
10491 let identity = match exact_provider_id {
10492 Some(exact) => config
10493 .resolve_persisted_provider_identity(Some(&id), Some(exact))
10494 .map_err(ApiError::bad_request)?,
10495 None => config
10496 .resolve_provider_pin_identity(&id)
10497 .map_err(ApiError::bad_request)?,
10498 };
10499 let mut scoped = config.clone();
10500 scoped
10501 .scope_to_provider_identity(&identity)
10502 .map_err(ApiError::bad_request)?;
10503 let model = match req.model.as_deref().map(str::trim) {
10504 None | Some("") => None,
10505 Some(raw) => Some(normalize_runtime_config_model(&scoped, &identity, raw)?),
10506 };
10507 (model, identity)
10508 };
10509
10510 // Persist `provider` (always) + `model` (only when explicitly given).
10511 // This is the critical TUI-parity rule: a bare `/provider <id>` (no
10512 // model arg) MUST NOT write a `model` key, otherwise the user's
10513 // per-provider `[providers.<id>].model` config gets overwritten with
10514 // whatever the runtime resolves as the default.
10515 // The save, the reload and the undo of a refused save run as one task
10516 // detached from this request: a client that disconnects or times out
10517 // mid-reload drops only its wait, never the undo, and never leaves the
10518 // engines on the new config while `state.config` keeps the old one.
10519 let task_state = state.clone();
10520 let task_identity = provider_identity.clone();
10521 let task_model = model_override.clone();
10522 let runtime = tokio::runtime::Handle::current();
10523 #[cfg(test)]
10524 let env_ticket = crate::test_support::env_scope_ticket();
10525 let (active_provider, active_model) = tokio::spawn(async move {
10526 let state = task_state;
10527 let _one_switch_at_a_time = state.provider_switches.lock().await;
10528 // Keep the cancellation-safe owned switch, while all filesystem and
10529 // keyring work runs off the async worker under the same serialization.
10530 tokio::task::spawn_blocking(move || {
10531 #[cfg(test)]
10532 let _membership = crate::test_support::join_env_scope(env_ticket);
10533 let (config_toml, undo) = config_persistence::persist_provider_selection(
10534 state.config_path.as_deref(),
10535 &task_identity,
10536 task_model.as_deref(),
10537 )
10538 .map_err(|e| ApiError::internal(format!("Failed to persist provider selection: {e}")))?;
10539
10540 // Reload config from disk and sync to active engines. This matches
10541 // `POST /v1/config/reload` exactly: load → validate thread routes →
10542 // swap in the new config. A failure here means an active thread's
10543 // route is invalid under the new provider — surface it so the GUI can
10544 // tell the user to fix their config.
10545 let applied =
10546 match Config::load(state.config_path.clone(), state.config_profile.as_deref()) {
10547 Ok(mut reloaded) => {
10548 reloaded.account_model_access =
10549 state.config.read().account_model_access.clone();
10550 match runtime.block_on(state.runtime_threads.reload_config(reloaded.clone())) {
10551 Ok(_) => Ok(reloaded),
10552 Err(err) => Err(ApiError::bad_request(format!(
10553 "Config reload rejected: {err}"
10554 ))),
10555 }
10556 }
10557 Err(e) => Err(ApiError::internal(format!("Failed to reload config: {e}"))),
10558 };
10559 match applied {
10560 // Report the route this switch applied, not whatever a later
10561 // switch leaves in `state.config` by the time this reply is built.
10562 Ok(reloaded) => {
10563 let applied_identity = reloaded.active_provider_identity().map_err(ApiError::bad_request)?;
10564 reloaded.verify_provider_identity(&task_identity).map_err(ApiError::conflict)?;
10565 if applied_identity != task_identity { return Err(ApiError::conflict("Persisted provider selection differs from the captured switch.")); }
10566 let provider = applied_identity.provider;
10567 let model = provider_default_model_for_api(&reloaded, &applied_identity);
10568 *state.config.write() = reloaded;
10569 Ok::<_, ApiError>((provider, model))
10570 }
10571 // A rejected switch must not stay on disk, or the next restart or
10572 // reload silently applies the switch this response reports as
10573 // refused. Only this save is taken back; a newer one wins.
10574 Err(mut error) => {
10575 let path = config_toml.display();
10576 match undo.undo() {
10577 Ok(true) => {}
10578 Ok(false) => {
10579 error.message = format!(
10580 "{}; {path} changed after this switch was saved, so the newer contents were kept",
10581 error.message
10582 );
10583 }
10584 Err(restore) => {
10585 error.message = format!(
10586 "{}; the provider selection could not be reverted in {path}: {restore}",
10587 error.message
10588 );
10589 }
10590 }
10591 Err(error)
10592 }
10593 }
10594 })
10595 .await
10596 .map_err(|_| ApiError::internal("provider switch blocking task failed"))?
10597 })
10598 .await
10599 .map_err(|_| ApiError::internal("provider switch task failed"))??;
10600
10601 let model_available = !active_model.is_empty();
10602 // Name the route the user selected, not the kind it routes through: a
10603 // named custom route reports `custom` as its kind, which names nothing the
10604 // user ever typed. Every other route keeps reporting its canonical kind.
10605 let active_label = if active_provider == ProviderKind::Custom {
10606 provider_identity.key.to_string()
10607 } else {
10608 active_provider.as_str().to_string()
10609 };
10610 let message = if !model_available {
10611 format!(
10612 "Provider switched to {active_label}; refresh its catalog or select an explicit model."
10613 )
10614 } else if model_override.is_some() {
10615 format!("Provider switched to {active_label} (model: {active_model}).")
10616 } else {
10617 format!(
10618 "Provider switched to {active_label} (model: {active_model}, resolved from config)."
10619 )
10620 };
10621
10622 Ok(Json(SwitchProviderResponse {
10623 provider: active_label,
10624 model: active_model,
10625 model_available,
10626 message,
10627 persisted: true,
10628 }))
10629 }
10630
10631 // ── Config endpoints ──
10632
10633 /// GUI-relevant config snapshot returned by `GET /v1/config`.
10634 #[derive(Debug, Clone, Serialize)]
10635 struct GuiConfigResponse {
10636 model: String,
10637 model_available: bool,
10638 provider: String,
10639 approval_mode: String,
10640 reasoning_effort: String,
10641 auto_compact: bool,
10642 cost_currency: String,
10643 default_mode: String,
10644 default_model: String,
10645 base_url: String,
10646 allow_shell: bool,
10647 mcp_config_path: String,
10648 subagents_enabled: bool,
10649 subagents_max_depth: u32,
10650 show_thinking: bool,
10651 thinking_default_expanded: bool,
10652 thinking_highlight: bool,
10653 show_tool_details: bool,
10654 inline_diffs: String,
10655 locale: String,
10656 max_history: usize,
10657 workspace_follow_symlinks: bool,
10658 calm_mode: bool,
10659 sandbox_mode: String,
10660 strict_tool_mode: bool,
10661 memory_enabled: bool,
10662 search_provider: String,
10663 /// How `search_provider` was chosen: `default` / `config` /
10664 /// `env override` / `tavily key`. Runtime-only — never persisted.
10665 search_provider_source: String,
10666 prompt_suggestion: bool,
10667 /// Effective device settings, using the same leaf vocabulary as CLI/TUI.
10668 notifications: std::collections::BTreeMap<String, String>,
10669 }
10670
10671 /// Request body for `POST /v1/config` (set a single config key).
10672 #[derive(Debug, Deserialize)]
10673 struct SetConfigRequest {
10674 key: String,
10675 value: String,
10676 #[serde(default)]
10677 persist: bool,
10678 }
10679
10680 /// Response for `POST /v1/config` (set a single config key).
10681 #[derive(Debug, Serialize)]
10682 struct SetConfigResponse {
10683 key: String,
10684 value: String,
10685 message: String,
10686 persisted: bool,
10687 requires_reload: bool,
10688 }
10689
10690 fn persist_runtime_tui_setting(key: &str, value: &str) -> Result<(), ApiError> {
10691 // Validate against a throwaway copy first, so an invalid value is still a
10692 // 400 rather than an internal error raised from inside the transaction.
10693 let mut probe = crate::settings::Settings::load_persisted()
10694 .map_err(|e| ApiError::internal(format!("Failed to load settings: {e}")))?;
10695 probe
10696 .set(key, value)
10697 .map_err(|e| ApiError::bad_request(e.to_string()))?;
10698 // The write itself re-applies the key inside `Settings::transact`, so it
10699 // cannot save the stale snapshot above over a concurrent writer's field.
10700 crate::settings::Settings::transact(|settings| settings.set(key, value))
10701 .map_err(|e| ApiError::internal(format!("Failed to save settings: {e}")))
10702 }
10703
10704 /// Response for `POST /v1/config/reload`.
10705 #[derive(Debug, Serialize)]
10706 struct ReloadConfigResponse {
10707 message: String,
10708 }
10709
10710 async fn get_config(
10711 State(state): State<RuntimeApiState>,
10712 ) -> Result<Json<GuiConfigResponse>, ApiError> {
10713 let config = state.config.read();
10714 let settings = crate::settings::Settings::load_persisted().unwrap_or_default();
10715 let mcp_config_path = config.mcp_config_path().display().to_string();
10716
10717 let resolved_model = runtime_request_model(&config, None);
10718 let model_available = resolved_model.is_ok();
10719 let model = resolved_model.unwrap_or_default();
10720
10721 let active_identity = config
10722 .active_provider_identity()
10723 .map_err(ApiError::bad_request)?;
10724 let provider = active_identity.key.to_string();
10725
10726 let approval_mode = config
10727 .approval_policy
10728 .as_deref()
10729 .unwrap_or("suggest")
10730 .to_string();
10731 let reasoning_effort = config.reasoning_effort().unwrap_or("auto").to_string();
10732 let cost_currency = settings.cost_currency.clone();
10733 let default_mode = settings.default_mode.as_str().to_string();
10734 // This field remains the DeepSeek preference even when another provider
10735 // is active, and follows the CN slot while the CN route is active — the CN
10736 // route resolves `[providers.deepseek_cn].model`, not the primary slot.
10737 // The root field is a legacy fallback for unmigrated configs.
10738 let identity =
10739 if active_identity.key.as_str() == codewhale_config::descriptors::LEGACY_DEEPSEEK_CN.id {
10740 active_identity.clone()
10741 } else {
10742 config
10743 .builtin_provider_identity(ProviderKind::Deepseek)
10744 .map_err(ApiError::bad_request)?
10745 };
10746 let mut deepseek_config = config.clone();
10747 deepseek_config
10748 .scope_to_provider_identity(&identity)
10749 .map_err(ApiError::bad_request)?;
10750 let default_model = deepseek_config.default_model();
10751 let base_url = config.active_route_base_url().to_string();
10752
10753 Ok(Json(GuiConfigResponse {
10754 model,
10755 model_available,
10756 provider,
10757 approval_mode,
10758 reasoning_effort,
10759 auto_compact: settings.auto_compact,
10760 cost_currency,
10761 default_mode,
10762 default_model,
10763 base_url,
10764 allow_shell: config.allow_shell(),
10765 mcp_config_path,
10766 subagents_enabled: config.subagents_enabled(),
10767 subagents_max_depth: config.subagent_max_spawn_depth(),
10768 show_thinking: settings.show_thinking,
10769 thinking_default_expanded: settings.thinking_default_expanded,
10770 thinking_highlight: settings.thinking_highlight,
10771 show_tool_details: settings.show_tool_details,
10772 inline_diffs: settings.inline_diffs.clone(),
10773 locale: settings.locale.clone(),
10774 max_history: settings.max_input_history,
10775 workspace_follow_symlinks: settings.workspace_follow_symlinks,
10776 calm_mode: settings.calm_mode,
10777 sandbox_mode: config
10778 .sandbox_mode
10779 .clone()
10780 .unwrap_or_else(|| "workspace-write".to_string()),
10781 strict_tool_mode: config.strict_tool_mode.unwrap_or(false),
10782 memory_enabled: config.memory_enabled(),
10783 search_provider: config.search_provider().as_str().to_string(),
10784 search_provider_source: config
10785 .search_provider_resolution()
10786 .source
10787 .as_str()
10788 .to_string(),
10789 prompt_suggestion: config.prompt_suggestion_enabled(),
10790 notifications: codewhale_config::notifications::NotificationSetting::ALL
10791 .into_iter()
10792 .map(|setting| {
10793 (
10794 setting.key().to_string(),
10795 config.notifications_config().display(setting),
10796 )
10797 })
10798 .collect(),
10799 }))
10800 }
10801
10802 async fn set_config(
10803 State(state): State<RuntimeApiState>,
10804 Json(req): Json<SetConfigRequest>,
10805 ) -> Result<Json<SetConfigResponse>, ApiError> {
10806 use crate::config_persistence;
10807
10808 let key = req.key.to_lowercase();
10809 let mut value = req.value;
10810 let persist = req.persist;
10811
10812 // Reuse the shared validator and locked leaf writer, including the active
10813 // profile's existing owner. Dry runs validate too; a typo must never look
10814 // like an accepted device setting. Reload remains the existing apply step.
10815 if codewhale_config::notifications::in_namespace(&key) {
10816 use codewhale_config::notifications::{NotificationConfigUpdate, NotificationSetting};
10817 let setting = NotificationSetting::required(&key)
10818 .map_err(|error| ApiError::bad_request(error.to_string()))?;
10819 let update = NotificationConfigUpdate::parse(setting, &value)
10820 .map_err(|error| ApiError::bad_request(error.to_string()))?;
10821 if persist {
10822 let path = config_persistence::config_toml_path(state.config_path.as_deref()).map_err(
10823 |error| ApiError::internal(format!("Failed to resolve config: {error}")),
10824 )?;
10825 update
10826 .persist_for_profile(&path, state.config_profile.as_deref())
10827 .map_err(|error| {
10828 ApiError::internal(format!("Failed to persist notification setting: {error}"))
10829 })?;
10830 }
10831 return Ok(Json(SetConfigResponse {
10832 key: format!("notifications.{}", setting.key()),
10833 value: update.display(),
10834 message: if persist {
10835 "Config persisted. Call /v1/config/reload to apply."
10836 } else {
10837 "Config not persisted (add persist: true to save)"
10838 }
10839 .to_string(),
10840 persisted: persist,
10841 requires_reload: persist,
10842 }));
10843 }
10844
10845 // Validate model keys even for dry-run requests. Model ids are provider
10846 // owned; accepting a DeepSeek id while Z.ai is active creates a saved
10847 // route that cannot execute after reload.
10848 let active_route = {
10849 let config = state.config.read();
10850 match key.as_str() {
10851 "model" | "base_url" | "provider_url" | "provider_base_url" => Some(
10852 config
10853 .active_provider_identity()
10854 .map_err(ApiError::bad_request)?,
10855 ),
10856 "default_model" => {
10857 let active = config.active_provider_identity().ok();
10858 Some(
10859 match active.filter(|identity| {
10860 identity.key.as_str()
10861 == codewhale_config::descriptors::LEGACY_DEEPSEEK_CN.id
10862 }) {
10863 Some(identity) => identity,
10864 None => config
10865 .builtin_provider_identity(ProviderKind::Deepseek)
10866 .map_err(ApiError::bad_request)?,
10867 },
10868 )
10869 }
10870 _ => None,
10871 }
10872 };
10873 if matches!(key.as_str(), "model" | "default_model") {
10874 let config = state.config.read();
10875 let identity = active_route
10876 .as_ref()
10877 .ok_or_else(|| ApiError::bad_request("No admitted model route"))?;
10878 value = normalize_runtime_config_model(&config, identity, &value)?;
10879 }
10880
10881 // All persisted config keys require a reload to take effect in the
10882 // runtime (including syncing to active engines). The caller should
10883 // POST /v1/config/reload after persisting.
10884 let requires_reload = persist;
10885
10886 // Handle persistence directly via config_persistence.
10887 // The runtime's in-memory state is NOT mutated here; the caller
10888 // should POST /v1/config/reload after persisting to apply changes.
10889 if persist {
10890 let config_path = state.config_path.as_deref();
10891 let result: anyhow::Result<PathBuf> = match key.as_str() {
10892 "model" | "default_model" => config_persistence::persist_provider_model_key(
10893 config_path,
10894 active_route
10895 .as_ref()
10896 .ok_or_else(|| ApiError::bad_request("No admitted model route"))?,
10897 &value,
10898 ),
10899 "reasoning_effort" => {
10900 config_persistence::persist_root_string_key(config_path, "reasoning_effort", &value)
10901 }
10902 "approval_mode" | "approval_policy" => {
10903 config_persistence::persist_root_string_key(config_path, "approval_policy", &value)
10904 }
10905 "base_url" | "provider_url" | "provider_base_url" => {
10906 config_persistence::persist_route_base_url(
10907 config_path,
10908 active_route
10909 .as_ref()
10910 .ok_or_else(|| ApiError::bad_request("No admitted endpoint route"))?,
10911 &value,
10912 )
10913 }
10914 "provider" => {
10915 // Validate the provider id against the static registry so the
10916 // GUI gets a clear error instead of silently persisting an
10917 // unknown value that `Config::api_provider()` would later
10918 // ignore (falling back to DeepSeek). A user-defined
10919 // `[providers.<name>]` route is a valid selection as well: the
10920 // persistent `provider` key holds exactly that name, and
10921 // `Config::resolve_provider_identity` resolves it back to the
10922 // route, so refusing it here would refuse a value the runtime
10923 // honours. Anything else is still refused.
10924 let identity = state
10925 .config
10926 .read()
10927 .resolve_provider_selection_identity(&value)
10928 .map_err(ApiError::bad_request)?;
10929 let result =
10930 config_persistence::persist_provider_selection(config_path, &identity, None)
10931 .map(|(path, _undo)| path);
10932 if result.is_ok() {
10933 state.config.write().provider = Some(
10934 identity
10935 .persisted_id()
10936 .unwrap_or(identity.key.as_str())
10937 .to_string(),
10938 );
10939 }
10940 result
10941 }
10942 "cost_currency"
10943 | "default_mode"
10944 | "auto_compact"
10945 | "show_thinking"
10946 | "thinking_default_expanded"
10947 | "thinking_highlight"
10948 | "show_tool_details"
10949 | "inline_diffs"
10950 | "calm_mode"
10951 | "workspace_follow_symlinks"
10952 | "locale"
10953 | "max_history" => {
10954 persist_runtime_tui_setting(&key, &value)?;
10955 return Ok(Json(SetConfigResponse {
10956 key,
10957 value,
10958 message: "Config persisted. Call /v1/config/reload to apply.".to_string(),
10959 persisted: true,
10960 requires_reload,
10961 }));
10962 }
10963 "allow_shell" => {
10964 let enabled = value.parse::<bool>().map_err(|_| {
10965 ApiError::bad_request(format!(
10966 "Invalid value '{value}' for allow_shell: expected 'true' or 'false'"
10967 ))
10968 })?;
10969 config_persistence::persist_root_bool_key(config_path, "allow_shell", enabled)
10970 }
10971 "mcp_config_path" => {
10972 config_persistence::persist_root_string_key(config_path, "mcp_config_path", &value)
10973 }
10974 "subagents_enabled" => {
10975 let enabled = value.parse::<bool>().map_err(|_| {
10976 ApiError::bad_request(format!(
10977 "Invalid value '{value}' for subagents_enabled: expected 'true' or 'false'"
10978 ))
10979 })?;
10980 config_persistence::persist_subagents_bool_key(config_path, "enabled", enabled)
10981 }
10982 "subagents_max_depth" => {
10983 let raw = value.parse::<u64>().map_err(|_| {
10984 ApiError::bad_request(format!(
10985 "Invalid value '{value}' for subagents_max_depth: expected a non-negative integer"
10986 ))
10987 })?;
10988 let clamped = raw.min(u64::from(codewhale_config::MAX_SPAWN_DEPTH_CEILING));
10989 config_persistence::persist_subagents_integer_key(config_path, "max_depth", clamped)
10990 }
10991 "sandbox_mode" => {
10992 let normalized = match value.to_lowercase().as_str() {
10993 "none" | "off" | "disabled" => "none".to_string(),
10994 "opensandbox" | "external-sandbox" | "external" => "opensandbox".to_string(),
10995 "workspace-write" | "workspace_write" => "workspace-write".to_string(),
10996 "read-only" | "read_only" => "read-only".to_string(),
10997 "danger-full-access" | "danger_full_access" | "full" => {
10998 "danger-full-access".to_string()
10999 }
11000 "workspace" | "workspace-read-write" | "workspace_read_write" => {
11001 "workspace-write".to_string()
11002 }
11003 _ => {
11004 return Err(ApiError::bad_request(format!(
11005 "Invalid sandbox_mode '{value}'. Supported: none, read-only, workspace-write, danger-full-access, opensandbox"
11006 )));
11007 }
11008 };
11009 config_persistence::persist_root_string_key(
11010 config_path,
11011 "sandbox_mode",
11012 &normalized,
11013 )
11014 }
11015 "strict_tool_mode" => {
11016 let enabled = value.parse::<bool>().map_err(|_| {
11017 ApiError::bad_request(format!(
11018 "Invalid value '{value}' for strict_tool_mode: expected 'true' or 'false'"
11019 ))
11020 })?;
11021 config_persistence::persist_root_bool_key(config_path, "strict_tool_mode", enabled)
11022 }
11023 "memory_enabled" => {
11024 let enabled = value.parse::<bool>().map_err(|_| {
11025 ApiError::bad_request(format!(
11026 "Invalid value '{value}' for memory_enabled: expected 'true' or 'false'"
11027 ))
11028 })?;
11029 config_persistence::persist_table_bool_key(
11030 config_path,
11031 "memory",
11032 "enabled",
11033 enabled,
11034 )
11035 }
11036 "search_provider" => {
11037 let normalized = value.to_lowercase();
11038 // GET returns the *resolved* provider. A settings save that
11039 // round-trips that value must not turn autodetect (or the
11040 // Firecrawl default) into a disk pin — `provider = "firecrawl"`
11041 // would flip the source to `config` and permanently block a
11042 // later Tavily key. A POST that differs from the resolved
11043 // provider is an explicit change and still persists.
11044 let resolution = state.config.read().search_provider_resolution();
11045 let posted = crate::config::SearchProvider::parse(&normalized);
11046 if posted == Some(resolution.provider)
11047 && matches!(
11048 resolution.source,
11049 crate::config::SearchProviderSource::Default
11050 | crate::config::SearchProviderSource::TavilyKey
11051 )
11052 {
11053 return Ok(Json(SetConfigResponse {
11054 key,
11055 value,
11056 message: format!(
11057 "Config not persisted: '{}' is the resolved {} (source: {}), not a pin. Set a different provider, or pin it in config.toml.",
11058 normalized,
11059 resolution.provider.as_str(),
11060 resolution.source.as_str()
11061 ),
11062 persisted: false,
11063 requires_reload: true,
11064 }));
11065 }
11066 config_persistence::persist_table_string_key(
11067 config_path,
11068 "search",
11069 "provider",
11070 &normalized,
11071 )
11072 }
11073 "prompt_suggestion" => {
11074 let enabled = value.parse::<bool>().map_err(|_| {
11075 ApiError::bad_request(format!(
11076 "Invalid value '{value}' for prompt_suggestion: expected 'true' or 'false'"
11077 ))
11078 })?;
11079 config_persistence::persist_root_bool_key(config_path, "prompt_suggestion", enabled)
11080 }
11081 _ => {
11082 // Every other declared settings.toml key persists through the
11083 // shared validator rather than a curated list — the schema
11084 // route advertises them, so a known setting must not die
11085 // here. Unknown keys still 400 through `Settings::set`.
11086 persist_runtime_tui_setting(&key, &value)?;
11087 return Ok(Json(SetConfigResponse {
11088 key,
11089 value,
11090 message: "Config persisted. Call /v1/config/reload to apply.".to_string(),
11091 persisted: true,
11092 requires_reload,
11093 }));
11094 }
11095 };
11096
11097 if let Err(e) = result {
11098 return Err(ApiError::internal(format!(
11099 "Failed to persist config key '{key}': {e}"
11100 )));
11101 }
11102 }
11103
11104 Ok(Json(SetConfigResponse {
11105 key,
11106 value,
11107 message: if persist {
11108 "Config persisted. Call /v1/config/reload to apply.".to_string()
11109 } else {
11110 "Config not persisted (add persist: true to save)".to_string()
11111 },
11112 persisted: persist,
11113 requires_reload,
11114 }))
11115 }
11116
11117 /// `GET /v1/settings/schema` — the Engine-declared settings surface.
11118 ///
11119 /// `codewhale_config::SETTINGS_SCHEMA` is the single declaration table: one
11120 /// entry per setting with kind, closed value set, default, and placement.
11121 /// This route projects it for HTTP clients — current values resolved from
11122 /// the owning store (settings.toml via [`crate::settings::Settings`],
11123 /// config.toml, or the notifications table), labels and descriptions
11124 /// resolved through the locale pack. Writes stay on `POST /v1/config`;
11125 /// this route never invents a value, an option, or a validator.
11126 #[derive(Debug, Serialize)]
11127 struct SettingsSchemaResponse {
11128 /// Payload version. Additive fields may appear without a bump; clients
11129 /// must ignore fields and `kind`/`row` values they do not know.
11130 version: u32,
11131 tabs: Vec<SettingsSchemaTab>,
11132 settings: Vec<SettingsSchemaRow>,
11133 }
11134
11135 #[derive(Debug, Serialize)]
11136 struct SettingsSchemaTab {
11137 id: String,
11138 /// Humanized tab id — tab labels have no message keys in the schema.
11139 label: String,
11140 }
11141
11142 #[derive(Debug, Serialize)]
11143 struct SettingsSchemaRow {
11144 key: &'static str,
11145 /// `bool` | `int` | `enum` | `string`. Unknown kinds degrade to a text
11146 /// field on the client; writes still validate server-side.
11147 kind: &'static str,
11148 tab: &'static str,
11149 group: &'static str,
11150 label: String,
11151 description: String,
11152 default: &'static str,
11153 /// `setting` | `action` | `diagnostic` | `session` — from
11154 /// [`codewhale_config::SettingRowKind`].
11155 row: &'static str,
11156 /// Current value in written-to-disk string form, when a store resolves
11157 /// it. Absent for actions, unresolvable diagnostics, and session rows
11158 /// the headless runtime cannot read.
11159 #[serde(skip_serializing_if = "Option::is_none")]
11160 value: Option<String>,
11161 /// Whether the value is a persisted user choice rather than an
11162 /// inherited default. Absent where no store can prove either way.
11163 #[serde(skip_serializing_if = "Option::is_none")]
11164 persisted: Option<bool>,
11165 /// Whether a generic client may offer a write control. Action,
11166 /// diagnostic and session rows are never editable through this surface;
11167 /// conditional rows (managed policy wins) report false.
11168 editable: bool,
11169 /// False for the hidden member of a conditional pair — e.g. a
11170 /// `managed_*` row when no managed policy applies, or `base_url` when
11171 /// the active route reads `provider_url`. Clients should not render
11172 /// invisible rows.
11173 visible: bool,
11174 #[serde(skip_serializing_if = "Vec::is_empty")]
11175 options: Vec<SettingsSchemaOption>,
11176 }
11177
11178 #[derive(Debug, Serialize)]
11179 struct SettingsSchemaOption {
11180 value: &'static str,
11181 #[serde(skip_serializing_if = "String::is_empty")]
11182 label: String,
11183 #[serde(skip_serializing_if = "String::is_empty")]
11184 description: String,
11185 }
11186
11187 /// "Turn a schema key or tab id into a title-case label" — the same
11188 /// humanization the TUI applies to rows declared without a label message.
11189 fn humanize_schema_key(key: &str) -> String {
11190 key.split(['.', '_', '-'])
11191 .filter(|part| !part.is_empty())
11192 .map(|part| {
11193 let mut chars = part.chars();
11194 let Some(first) = chars.next() else {
11195 return String::new();
11196 };
11197 let mut word = first.to_uppercase().collect::<String>();
11198 word.push_str(chars.as_str());
11199 word
11200 })
11201 .collect::<Vec<_>>()
11202 .join(" ")
11203 }
11204
11205 /// config.toml-owned keys `POST /v1/config` persists through curated arms.
11206 /// Kept beside `set_config`'s match: a schema Setting row outside this list
11207 /// and outside `Settings` has no write path and reports `editable: false`.
11208 const RUNTIME_CONFIG_KEYS: &[&str] = &[
11209 "model",
11210 "default_model",
11211 "reasoning_effort",
11212 "approval_mode",
11213 "approval_policy",
11214 "base_url",
11215 "provider",
11216 "provider_url",
11217 "provider_base_url",
11218 "cost_currency",
11219 "max_history",
11220 "allow_shell",
11221 "mcp_config_path",
11222 "subagents_enabled",
11223 "subagents_max_depth",
11224 "sandbox_mode",
11225 "strict_tool_mode",
11226 "memory_enabled",
11227 "search_provider",
11228 "prompt_suggestion",
11229 ];
11230
11231 /// A dotted-path lookup over a TOML document — used to decide `persisted`
11232 /// for config.toml-owned rows without trusting a decorated display string.
11233 fn toml_value_at_path<'a>(document: &'a toml::Value, segments: &[&str]) -> Option<&'a toml::Value> {
11234 let mut current = document;
11235 for segment in segments {
11236 current = current.as_table()?.get(*segment)?;
11237 }
11238 Some(current)
11239 }
11240
11241 async fn get_settings_schema(
11242 State(state): State<RuntimeApiState>,
11243 ) -> Result<Json<SettingsSchemaResponse>, ApiError> {
11244 use codewhale_config::notifications::NotificationSetting;
11245 use codewhale_config::settings_schema::{
11246 SettingKind, SettingRowKind, schema_rows, schema_tabs,
11247 };
11248 use codewhale_localization::{MessageId, resolve_locale, tr, tr_key};
11249
11250 let config = state.config.read().clone();
11251 let settings = crate::settings::Settings::load_persisted().unwrap_or_default();
11252 let locale = resolve_locale(&settings.locale);
11253 let notifications = config.notifications_config();
11254
11255 // Conditional pairs share the TUI's rule: exactly one member is shown,
11256 // chosen by which store or policy owns the fact right now.
11257 let permission_control = config.approval_policy_control(
11258 state.config_path.as_deref(),
11259 state.config_profile.as_deref(),
11260 &state.workspace,
11261 );
11262 let shell_control = config.allow_shell_control(
11263 state.config_path.as_deref(),
11264 state.config_profile.as_deref(),
11265 &state.workspace,
11266 );
11267 let base_url_row_key = if config
11268 .active_provider_identity()
11269 .ok()
11270 .is_some_and(|identity| identity.key.as_str() == ProviderKind::Deepseek.as_str())
11271 {
11272 "base_url"
11273 } else {
11274 "provider_url"
11275 };
11276 let visible = |key: &str| -> bool {
11277 match key {
11278 "permission_posture" => matches!(
11279 permission_control,
11280 crate::config::ApprovalPolicyControl::Unset
11281 ),
11282 "approval_policy" => matches!(
11283 permission_control,
11284 crate::config::ApprovalPolicyControl::RootConfig
11285 ),
11286 "managed_approval_policy" => !matches!(
11287 permission_control,
11288 crate::config::ApprovalPolicyControl::Unset
11289 | crate::config::ApprovalPolicyControl::RootConfig
11290 ),
11291 "allow_shell" => shell_control.editable_root(),
11292 "managed_allow_shell" => !shell_control.editable_root(),
11293 "base_url" | "provider_url" => key == base_url_row_key,
11294 _ => true,
11295 }
11296 };
11297
11298 // Raw config.toml for `persisted` on config-owned rows. A missing or
11299 // unparsable file means nothing was persisted there — the live config
11300 // still serves defaults through `value`. This is an async axum route, so
11301 // the read rides the blocking pool instead of parking a Tokio worker
11302 // (#6149).
11303 let config_document = match state.config_path.as_deref() {
11304 Some(path) => tokio::fs::read_to_string(path)
11305 .await
11306 .ok()
11307 .and_then(|body| toml::from_str::<toml::Value>(&body).ok()),
11308 None => None,
11309 };
11310 let notifications_persisted = |key: &str| -> Option<bool> {
11311 let setting = NotificationSetting::parse(key)?;
11312 let document = config_document.as_ref()?;
11313 Some(
11314 toml_value_at_path(document, &setting.segments()).is_some()
11315 // Legacy location the loader still honors.
11316 || (matches!(setting, NotificationSetting::Condition)
11317 && toml_value_at_path(document, &["tui", "notification_condition"]).is_some()),
11318 )
11319 };
11320
11321 let tabs = schema_tabs()
11322 .into_iter()
11323 .map(|id| SettingsSchemaTab {
11324 id: id.to_string(),
11325 label: humanize_schema_key(id),
11326 })
11327 .collect();
11328
11329 let settings_rows = schema_rows()
11330 .map(|def| {
11331 let ui = def.ui.as_ref().expect("schema_rows filters on ui");
11332 let kind = match def.kind {
11333 SettingKind::Bool(_) => "bool",
11334 SettingKind::Int => "int",
11335 SettingKind::Float => "float",
11336 SettingKind::Enum(_) => "enum",
11337 SettingKind::String => "string",
11338 };
11339 let row = match ui.row {
11340 SettingRowKind::Setting => "setting",
11341 SettingRowKind::Action => "action",
11342 SettingRowKind::Diagnostic => "diagnostic",
11343 SettingRowKind::Session => "session",
11344 };
11345 let options = match def.kind {
11346 SettingKind::Bool(options) | SettingKind::Enum(options) => options
11347 .iter()
11348 .map(|option| SettingsSchemaOption {
11349 value: option.value,
11350 label: if option.label.is_empty() {
11351 String::new()
11352 } else {
11353 tr_key(locale, option.label).into_owned()
11354 },
11355 description: if option.description.is_empty() {
11356 String::new()
11357 } else {
11358 tr_key(locale, option.description).into_owned()
11359 },
11360 })
11361 .collect(),
11362 SettingKind::Int | SettingKind::String | SettingKind::Float => Vec::new(),
11363 };
11364 // Bool rows with an empty option slice carry the surface's
11365 // default on/off labels — emit the bare values so clients can
11366 // still build a labeled control.
11367 let options = if options.is_empty() && matches!(def.kind, SettingKind::Bool(_)) {
11368 vec![
11369 SettingsSchemaOption {
11370 value: "false",
11371 label: tr_key(locale, "ConfigValueOff").into_owned(),
11372 description: String::new(),
11373 },
11374 SettingsSchemaOption {
11375 value: "true",
11376 label: tr_key(locale, "ConfigValueOn").into_owned(),
11377 description: String::new(),
11378 },
11379 ]
11380 } else {
11381 options
11382 };
11383
11384 let notification_owned = NotificationSetting::parse(def.key).is_some();
11385 // `Settings::set` is the authority on which keys settings.toml
11386 // owns — including `Option` fields whose unset value serializes
11387 // to nothing (e.g. permission_posture). The probe reuses the
11388 // write validator on the declared default, so `editable` cannot
11389 // claim a key the real write path would reject.
11390 let settings_writable = crate::settings::Settings::default()
11391 .set(def.key, def.default)
11392 .is_ok();
11393 let (value, persisted) = if notification_owned {
11394 let setting = NotificationSetting::parse(def.key).expect("checked above");
11395 (
11396 Some(notifications.display(setting)),
11397 notifications_persisted(def.key),
11398 )
11399 } else if settings_writable {
11400 // Effective = the persisted value or the declared default;
11401 // `is_set` says which.
11402 (
11403 Some(
11404 settings
11405 .value(def.key)
11406 .unwrap_or_else(|| def.default.to_string()),
11407 ),
11408 Some(settings.is_set(def.key)),
11409 )
11410 } else if let Some(feature_key) = def.key.strip_prefix("features.") {
11411 // Feature rows are diagnostics: the effective flag state plus
11412 // whether config.toml names the leaf — no decorated phrasing,
11413 // the client owns presentation of default-vs-configured.
11414 let value = crate::features::FEATURES
11415 .iter()
11416 .find(|spec| spec.key == feature_key)
11417 .map(|spec| config.features().enabled(spec.id).to_string());
11418 let persisted = config_document.as_ref().map(|document| {
11419 toml_value_at_path(document, &["features", feature_key]).is_some()
11420 });
11421 (value, persisted)
11422 } else {
11423 // Managed-policy receipts name the winning source rather than
11424 // a writable value; everything else resolves from config.toml
11425 // or stays absent for a diagnostic the runtime cannot read.
11426 let managed_value = match def.key {
11427 "managed_approval_policy" => match permission_control {
11428 crate::config::ApprovalPolicyControl::Unset
11429 | crate::config::ApprovalPolicyControl::RootConfig => None,
11430 source => Some(source.label().to_string()),
11431 },
11432 "managed_allow_shell" if !shell_control.editable_root() => Some(format!(
11433 "{} · {}",
11434 config.allow_shell(),
11435 shell_control.label()
11436 )),
11437 _ => None,
11438 };
11439 (
11440 managed_value.or_else(|| config_schema_value(def.key, &config)),
11441 None,
11442 )
11443 };
11444
11445 // `editable` means POST /v1/config accepts the key today:
11446 // notifications.* through the namespace branch, settings.toml
11447 // keys through the Settings::set fallthrough, and the curated
11448 // config.toml arm list. A Setting row without a write path
11449 // (e.g. telemetry, which persists through its own notice
11450 // module) renders read-only rather than promising a 400. The
11451 // endpoint rows stay receipts: writing a live route's base URL
11452 // cannot mutate an already-running client, so the TUI marks
11453 // them read-only and the schema agrees.
11454 let endpoint_receipt = matches!(def.key, "base_url" | "provider_url");
11455 // A managed or profile-owned approval policy freezes the
11456 // session-level mode switch too, not just the saved row.
11457 let session_locked = def.key == "approval_mode"
11458 && !matches!(
11459 permission_control,
11460 crate::config::ApprovalPolicyControl::Unset
11461 );
11462 let editable = !endpoint_receipt
11463 && !session_locked
11464 && matches!(ui.row, SettingRowKind::Setting | SettingRowKind::Session)
11465 && visible(def.key)
11466 && (notification_owned
11467 || settings_writable
11468 || RUNTIME_CONFIG_KEYS.contains(&def.key));
11469
11470 SettingsSchemaRow {
11471 key: def.key,
11472 kind,
11473 tab: ui.tab,
11474 group: ui.group,
11475 label: if !ui.label.is_empty() {
11476 tr_key(locale, ui.label).into_owned()
11477 } else if def.key.starts_with("features.") {
11478 tr(locale, MessageId::ConfigLabelFeaturePrefix).replace(
11479 "{name}",
11480 &humanize_schema_key(def.key.rsplit('.').next().unwrap_or(def.key)),
11481 )
11482 } else {
11483 humanize_schema_key(def.key.rsplit('.').next().unwrap_or(def.key))
11484 },
11485 description: if ui.description.is_empty() {
11486 String::new()
11487 } else {
11488 tr_key(locale, ui.description).into_owned()
11489 },
11490 default: def.default,
11491 row,
11492 value,
11493 persisted,
11494 editable,
11495 visible: visible(def.key),
11496 options,
11497 }
11498 })
11499 .collect();
11500
11501 Ok(Json(SettingsSchemaResponse {
11502 version: 1,
11503 tabs,
11504 settings: settings_rows,
11505 }))
11506 }
11507
11508 /// Current value of a config.toml-owned schema row, when one resolves
11509 /// cheaply. Diagnostics that need per-route or credential computation are
11510 /// omitted rather than approximated.
11511 fn config_schema_value(key: &str, config: &Config) -> Option<String> {
11512 match key {
11513 "provider" => config
11514 .active_provider_identity()
11515 .ok()
11516 .map(|identity| identity.key.to_string()),
11517 "model" => runtime_request_model(config, None).ok(),
11518 "approval_policy" => config
11519 .approval_policy
11520 .clone()
11521 .or_else(|| Some("suggest".to_string())),
11522 "telemetry" => Some(crate::telemetry_notice::saved_preference_enabled(config).to_string()),
11523 "allow_shell" => Some(config.allow_shell().to_string()),
11524 "base_url" => config
11525 .active_provider_identity()
11526 .ok()
11527 .map(|identity| config.base_url_for_route(&identity)),
11528 "provider_url" => config
11529 .active_provider_identity()
11530 .ok()
11531 .map(|identity| config.base_url_for_route(&identity)),
11532 "mcp_config_path" => Some(config.mcp_config_path().display().to_string()),
11533 "sandbox_mode" => config.sandbox_mode.clone(),
11534 "fleet.exec.max_spawn_depth" => Some(config.subagent_max_spawn_depth().to_string()),
11535 "reasoning_effort" => Some(config.reasoning_effort().unwrap_or("auto").to_string()),
11536 _ => None,
11537 }
11538 }
11539
11540 fn normalize_runtime_config_model(
11541 config: &Config,
11542 identity: &crate::config::ProviderIdentity,
11543 value: &str,
11544 ) -> Result<String, ApiError> {
11545 config
11546 .verify_provider_identity(identity)
11547 .map_err(ApiError::bad_request)?;
11548 let provider = identity.provider;
11549 let value = value.trim();
11550 if crate::provider_lake::configured_model_for_route(
11551 config,
11552 provider,
11553 identity.key.as_str(),
11554 &config.base_url_for_route(identity),
11555 value,
11556 )
11557 .is_some()
11558 {
11559 // The shared resolver preserves exact declarations only after its
11560 // protocol and provider allowlist guards. Metadata cannot bypass them.
11561 return crate::route_runtime::resolve_runtime_route_for_identity(
11562 config,
11563 identity,
11564 Some(value),
11565 )
11566 .map(|route| route.model)
11567 .map_err(ApiError::bad_request);
11568 }
11569 validate_route(provider, value).map_err(ApiError::bad_request)?;
11570 if value.eq_ignore_ascii_case("auto") {
11571 return Ok("auto".to_string());
11572 }
11573 normalize_model_name_for_provider(provider, value).ok_or_else(|| {
11574 ApiError::bad_request(format!(
11575 "Invalid model '{value}' for provider '{}'.",
11576 provider.as_str()
11577 ))
11578 })
11579 }
11580
11581 async fn reload_config(
11582 State(state): State<RuntimeApiState>,
11583 ) -> Result<Json<ReloadConfigResponse>, ApiError> {
11584 let mut reloaded = Config::load(state.config_path.clone(), state.config_profile.as_deref())
11585 .map_err(|e| ApiError::internal(format!("Failed to reload config: {e}")))?;
11586 reloaded.account_model_access = state.config.read().account_model_access.clone();
11587 state
11588 .runtime_threads
11589 .reload_config(reloaded.clone())
11590 .await
11591 .map_err(|err| ApiError::bad_request(format!("Config reload rejected: {err}")))?;
11592 {
11593 let mut config = state.config.write();
11594 *config = reloaded;
11595 }
11596 Ok(Json(ReloadConfigResponse {
11597 message: "Config reloaded from disk; new turns will resolve the updated provider routes"
11598 .to_string(),
11599 }))
11600 }
11601
11602 // ── Memory inspection and lifecycle endpoints ──
11603
11604 /// Maximum summary length returned per entry. Bounds the API surface so raw
11605 /// private text cannot exfiltrate through JSON responses.
11606 const MEMORY_SUMMARY_MAX_CHARS: usize = 300;
11607 /// Default result cap for `GET /v1/memory`.
11608 const MEMORY_LIST_DEFAULT_LIMIT: usize = 50;
11609 /// Hard ceiling — protects against oversized responses.
11610 const MEMORY_LIST_MAX_LIMIT: usize = 200;
11611
11612 /// Typed, redacted projection of a single native memory entry.
11613 ///
11614 /// Raw file-system paths are never exposed; `scope` and `workspace_id` (a
11615 /// SHA-256 digest of the repository origin URL, not a local path) give
11616 /// managed clients enough provenance to reason about each entry.
11617 #[derive(Debug, Serialize)]
11618 struct MemoryEntryRecord {
11619 /// SQLite row id. Stable across reindexes unless the source Markdown
11620 /// file is cleared and rewritten.
11621 id: i64,
11622 /// `"global"` or `"workspace"`.
11623 scope: &'static str,
11624 /// SHA-256 digest of the repository origin URL for workspace-scoped
11625 /// entries; `null` for global entries.
11626 workspace_id: Option<String>,
11627 /// Bounded plain-text summary (max `MEMORY_SUMMARY_MAX_CHARS` chars).
11628 /// Truncated with `…` when the source text is longer. Never contains
11629 /// raw prompt or turn content.
11630 summary: String,
11631 /// `true` when the source Markdown file has been modified since the
11632 /// entry was last indexed.
11633 stale: bool,
11634 /// 1-based start line in the source Markdown file.
11635 line_start: usize,
11636 /// 1-based end line in the source Markdown file.
11637 line_end: usize,
11638 /// `"active"` or `"stale"` (human-readable alias for `stale`).
11639 status: &'static str,
11640 }
11641
11642 #[derive(Debug, Deserialize)]
11643 struct ListMemoryQuery {
11644 /// Filter by scope: `"global"`, `"workspace"`, or `"all"` (default).
11645 scope: Option<String>,
11646 /// FTS search query (max 256 chars). When absent all entries for the
11647 /// requested scope are returned in insertion order.
11648 q: Option<String>,
11649 /// Maximum entries to return (default 50, max 200).
11650 limit: Option<usize>,
11651 }
11652
11653 /// Request body for `POST /v1/memory`.
11654 #[derive(Debug, Deserialize)]
11655 struct CreateMemoryRequest {
11656 /// The memory note text (max 64 KiB after normalisation).
11657 text: String,
11658 /// `"global"` (default) or `"workspace"`.
11659 #[serde(default)]
11660 scope: String,
11661 }
11662
11663 /// Query params for `DELETE /v1/memory`.
11664 #[derive(Debug, Deserialize)]
11665 struct ClearMemoryQuery {
11666 /// One of `"global"`, `"workspace"`, or `"all"`. Required.
11667 scope: String,
11668 }
11669
11670 /// Build a `NativeMemoryStore` rooted at the same location the TUI uses.
11671 /// Mirrors `native_store()` in `commands/groups/memory/memory.rs`.
11672 fn native_store_for_state(state: &RuntimeApiState) -> crate::native_memory::NativeMemoryStore {
11673 let memory_path = state.config.read().memory_path();
11674 crate::native_memory::NativeMemoryStore::from_memory_anchor(&memory_path)
11675 }
11676
11677 /// Derive a scope label from a source path relative to the store root.
11678 /// Returns `"global"`, `"workspace"`, or `"unknown"`.
11679 fn scope_label_for_source(source: &FsPath, store_root: &FsPath) -> &'static str {
11680 let Ok(rel) = source.strip_prefix(store_root) else {
11681 return "unknown";
11682 };
11683 match rel.components().next().and_then(|c| c.as_os_str().to_str()) {
11684 Some("global") => "global",
11685 Some("workspace") => "workspace",
11686 _ => "unknown",
11687 }
11688 }
11689
11690 /// Extract the workspace_id component from a workspace-scoped source path.
11691 fn workspace_id_for_source(source: &FsPath, store_root: &FsPath) -> Option<String> {
11692 let rel = source.strip_prefix(store_root).ok()?;
11693 let mut comps = rel.components();
11694 if comps.next()?.as_os_str().to_str()? != "workspace" {
11695 return None;
11696 }
11697 Some(comps.next()?.as_os_str().to_str()?.to_string())
11698 }
11699
11700 /// Convert a `MemoryHit` into a redacted, bounded `MemoryEntryRecord`.
11701 fn memory_hit_to_record(
11702 hit: crate::native_memory::MemoryHit,
11703 store_root: &FsPath,
11704 ) -> MemoryEntryRecord {
11705 let scope = scope_label_for_source(&hit.source, store_root);
11706 let workspace_id = workspace_id_for_source(&hit.source, store_root);
11707 let summary = truncate_text(&hit.text, MEMORY_SUMMARY_MAX_CHARS);
11708 let status = if hit.stale { "stale" } else { "active" };
11709 MemoryEntryRecord {
11710 id: hit.id,
11711 scope,
11712 workspace_id,
11713 summary,
11714 stale: hit.stale,
11715 line_start: hit.line_start,
11716 line_end: hit.line_end,
11717 status,
11718 }
11719 }
11720
11721 /// Resolve a scope query parameter into a `MemoryScope` filter and an
11722 /// optional workspace_id. `"all"` / absent → `(None, None)`: each caller
11723 /// decides what "all" spans (see `list_memory` and `clear_memory`).
11724 fn resolve_memory_scope(
11725 scope_param: &Option<String>,
11726 workspace: &FsPath,
11727 ) -> Result<(Option<crate::native_memory::MemoryScope>, Option<String>), ApiError> {
11728 match scope_param.as_deref().unwrap_or("all").trim() {
11729 "all" | "" => Ok((None, None)),
11730 "global" => Ok((Some(crate::native_memory::MemoryScope::Global), None)),
11731 "workspace" => {
11732 let workspace_id = crate::native_memory::NativeMemoryStore::workspace_id(workspace)
11733 .map_err(|e| ApiError::internal(format!("resolve workspace id: {e}")))?
11734 .ok_or_else(|| {
11735 ApiError::bad_request(
11736 "workspace scope requires a git repository with a remote origin",
11737 )
11738 })?;
11739 Ok((
11740 Some(crate::native_memory::MemoryScope::Workspace),
11741 Some(workspace_id),
11742 ))
11743 }
11744 other => Err(ApiError::bad_request(format!(
11745 "Invalid scope '{other}': expected one of all, global, workspace"
11746 ))),
11747 }
11748 }
11749
11750 /// `GET /v1/memory` — list memory entries with optional scope and FTS
11751 /// filtering.
11752 ///
11753 /// Query params:
11754 /// - `scope` — `"global"`, `"workspace"`, or `"all"` (default: global memory
11755 /// plus this repository's workspace memory)
11756 /// - `q` — FTS search query (max 256 chars; omit to list all)
11757 /// - `limit` — max results (default 50, max 200)
11758 async fn list_memory(
11759 State(state): State<RuntimeApiState>,
11760 Query(query): Query<ListMemoryQuery>,
11761 ) -> Result<Json<Value>, ApiError> {
11762 let limit = match query.limit.unwrap_or(MEMORY_LIST_DEFAULT_LIMIT) {
11763 0 => {
11764 return Err(ApiError::bad_request("limit must be at least 1"));
11765 }
11766 n if n > MEMORY_LIST_MAX_LIMIT => {
11767 return Err(ApiError::bad_request(format!(
11768 "limit must be at most {MEMORY_LIST_MAX_LIMIT}; got {n}"
11769 )));
11770 }
11771 n => n,
11772 };
11773
11774 let store = native_store_for_state(&state);
11775 let root = store.root().to_path_buf();
11776 let (scope_filter, mut workspace_id) = resolve_memory_scope(&query.scope, &state.workspace)?;
11777 if scope_filter.is_none() {
11778 // "all" is global memory plus this repository's. With no identity
11779 // (no origin remote, or git unavailable) there is no workspace memory
11780 // to show, and the listing still serves global memory.
11781 workspace_id = crate::native_memory::NativeMemoryStore::workspace_id(&state.workspace)
11782 .unwrap_or_else(|error| {
11783 tracing::warn!("memory list shows global memory only: {error}");
11784 None
11785 });
11786 }
11787
11788 let hits = if let Some(ref q) = query.q {
11789 let q = q.trim();
11790 if q.is_empty() || q.chars().count() > 256 {
11791 return Err(ApiError::bad_request("q must be 1–256 characters"));
11792 }
11793 match scope_filter {
11794 None if workspace_id.is_some() => {
11795 store.search_in_workspace(workspace_id.as_deref(), &state.workspace, q, limit)
11796 }
11797 None => store.search(q, limit),
11798 Some(crate::native_memory::MemoryScope::Global) => store.search(q, limit).map(|h| {
11799 h.into_iter()
11800 .filter(|h| scope_label_for_source(&h.source, &root) == "global")
11801 .collect()
11802 }),
11803 Some(crate::native_memory::MemoryScope::Workspace) => store
11804 .search_for_workspace(&state.workspace, q, limit)
11805 .map(|h| {
11806 h.into_iter()
11807 .filter(|h| scope_label_for_source(&h.source, &root) == "workspace")
11808 .collect()
11809 }),
11810 }
11811 } else {
11812 store.list_all(scope_filter, workspace_id.as_deref(), limit)
11813 }
11814 .map_err(|e| ApiError::internal(format!("memory list error: {e}")))?;
11815
11816 let entries: Vec<MemoryEntryRecord> = hits
11817 .into_iter()
11818 .map(|h| memory_hit_to_record(h, &root))
11819 .collect();
11820 let total = entries.len();
11821 Ok(Json(json!({ "entries": entries, "total": total })))
11822 }
11823
11824 /// `GET /v1/memory/{id}` — inspect a single memory entry.
11825 ///
11826 /// The lookup is scoped to global memory plus the current repository's
11827 /// workspace memory; numeric IDs from a different machine or repository
11828 /// will not resolve.
11829 async fn get_memory_entry(
11830 State(state): State<RuntimeApiState>,
11831 Path(id): Path<i64>,
11832 ) -> Result<Json<Value>, ApiError> {
11833 let store = native_store_for_state(&state);
11834 let root = store.root().to_path_buf();
11835 let hit = store
11836 .get_for_workspace(&state.workspace, id)
11837 .map_err(|e| ApiError::internal(format!("memory lookup error: {e}")))?
11838 .ok_or_else(|| ApiError::not_found(format!("memory entry '{id}' not found")))?;
11839 let entry = memory_hit_to_record(hit, &root);
11840 Ok(Json(json!({ "entry": entry })))
11841 }
11842
11843 /// `POST /v1/memory` — append a new memory entry.
11844 ///
11845 /// The note is treated as user data (lower authority than instructions).
11846 /// Requires the standard Runtime auth token when auth is configured.
11847 async fn create_memory_entry(
11848 State(state): State<RuntimeApiState>,
11849 Json(req): Json<CreateMemoryRequest>,
11850 ) -> Result<(StatusCode, Json<Value>), ApiError> {
11851 let scope_str = if req.scope.is_empty() {
11852 "global"
11853 } else {
11854 req.scope.as_str()
11855 };
11856 let scope = match scope_str.trim() {
11857 "global" => crate::native_memory::MemoryScope::Global,
11858 "workspace" => crate::native_memory::MemoryScope::Workspace,
11859 other => {
11860 return Err(ApiError::bad_request(format!(
11861 "Invalid scope '{other}': expected 'global' or 'workspace'"
11862 )));
11863 }
11864 };
11865 let workspace_id = if scope == crate::native_memory::MemoryScope::Workspace {
11866 let id = crate::native_memory::NativeMemoryStore::workspace_id(&state.workspace)
11867 .map_err(|e| ApiError::internal(format!("resolve workspace id: {e}")))?
11868 .ok_or_else(|| {
11869 ApiError::bad_request(
11870 "workspace scope requires a git repository with a remote origin",
11871 )
11872 })?;
11873 Some(id)
11874 } else {
11875 None
11876 };
11877 let store = native_store_for_state(&state);
11878 let root = store.root().to_path_buf();
11879 // This endpoint is an authenticated operator surface: the explicit request
11880 // is the review, so the entry lands active — matching the Lens remember
11881 // action. Model-reachable capture stays candidate-only.
11882 let hit = store
11883 .remember_reviewed(scope, workspace_id.as_deref(), &req.text)
11884 .map_err(|e| ApiError::bad_request(format!("memory create error: {e}")))?;
11885 let entry = memory_hit_to_record(hit, &root);
11886 Ok((StatusCode::CREATED, Json(json!({ "entry": entry }))))
11887 }
11888
11889 /// `DELETE /v1/memory` — clear all memory entries for the given scope.
11890 ///
11891 /// The `scope` query parameter is required: `"global"`, `"workspace"`, or
11892 /// `"all"`. `"all"` clears every local scope, including other repositories'
11893 /// workspace memory, which is wider than what `GET` lists for `"all"`. This
11894 /// is a destructive, non-reversible operation.
11895 async fn clear_memory(
11896 State(state): State<RuntimeApiState>,
11897 Query(query): Query<ClearMemoryQuery>,
11898 ) -> Result<Json<Value>, ApiError> {
11899 let (scope_filter, workspace_id) = resolve_memory_scope(&Some(query.scope), &state.workspace)?;
11900 let store = native_store_for_state(&state);
11901 store
11902 .delete_all(scope_filter, workspace_id.as_deref())
11903 .map_err(|e| ApiError::internal(format!("memory clear error: {e}")))?;
11904 Ok(Json(json!({ "cleared": true })))
11905 }
11906
11907 const MOBILE_HTML: &str = include_str!("runtime_mobile.html");
11908
11909 // Only stream statuses are localized here; the rest of the mobile shell is
11910 // still English. Reload the page after changing the Runtime's UI locale.
11911 fn mobile_html(locale: codewhale_localization::Locale) -> String {
11912 use codewhale_localization::{MessageId, tr};
11913
11914 let messages = json!({
11915 "replay_failed": tr(locale, MessageId::MobileStreamReplayFailed),
11916 "catch_up_failed": tr(locale, MessageId::MobileStreamCatchUpFailed),
11917 "runtime_shutdown": tr(locale, MessageId::MobileStreamRuntimeShutdown),
11918 "ended": tr(locale, MessageId::MobileStreamEnded),
11919 "closed": tr(locale, MessageId::MobileStreamClosed),
11920 "reconnecting": tr(locale, MessageId::MobileStreamReconnecting),
11921 "connected": tr(locale, MessageId::MobileStreamConnected),
11922 });
11923 // JSON quoting protects JS strings; escaping '<' also prevents a catalog
11924 // value from closing the enclosing script element.
11925 MOBILE_HTML.replace(
11926 "__CODEWHALE_STREAM_MESSAGES__",
11927 &messages.to_string().replace('<', "\\u003c"),
11928 )
11929 }
11930
11931 /// Built-in dev origins always allowed by the runtime API (whalescale#255).
11932 const DEFAULT_CORS_ORIGINS: &[&str] = &[
11933 "http://localhost:3000",
11934 "http://127.0.0.1:3000",
11935 "http://localhost:1420",
11936 "http://127.0.0.1:1420",
11937 "tauri://localhost",
11938 ];
11939
11940 fn cors_layer(extra_origins: &[String]) -> CorsLayer {
11941 let mut origins: Vec<HeaderValue> = DEFAULT_CORS_ORIGINS
11942 .iter()
11943 .filter_map(|o| HeaderValue::from_str(o).ok())
11944 .collect();
11945 for raw in extra_origins {
11946 let trimmed = raw.trim();
11947 if trimmed.is_empty() {
11948 continue;
11949 }
11950 match HeaderValue::from_str(trimmed) {
11951 Ok(value) if !origins.contains(&value) => origins.push(value),
11952 Ok(_) => {}
11953 Err(err) => tracing::warn!(
11954 "Ignoring invalid CORS origin '{trimmed}': {err}; expected scheme://host[:port]"
11955 ),
11956 }
11957 }
11958 CorsLayer::new()
11959 .allow_origin(origins)
11960 .allow_methods([
11961 Method::GET,
11962 Method::POST,
11963 Method::PUT,
11964 Method::PATCH,
11965 Method::DELETE,
11966 Method::OPTIONS,
11967 ])
11968 .allow_headers([
11969 header::AUTHORIZATION,
11970 header::CONTENT_TYPE,
11971 header::ACCEPT,
11972 header::IF_MATCH,
11973 HeaderName::from_static("x-codewhale-runtime-token"),
11974 HeaderName::from_static("x-deepseek-runtime-token"),
11975 ])
11976 .expose_headers([
11977 HeaderName::from_static("x-codewhale-stream-end"),
11978 HeaderName::from_static("x-codewhale-event-progress"),
11979 ])
11980 }
11981
11982 fn map_task_err(err: anyhow::Error) -> ApiError {
11983 let message = err.to_string();
11984 if message.contains("not found") {
11985 ApiError::not_found(message)
11986 } else {
11987 ApiError::bad_request(message)
11988 }
11989 }
11990
11991 fn map_automation_err(err: anyhow::Error) -> ApiError {
11992 let message = err.to_string();
11993 if message.contains("Failed to read automation")
11994 || message.contains("No such file or directory")
11995 {
11996 ApiError::not_found(message)
11997 } else {
11998 ApiError::bad_request(message)
11999 }
12000 }
12001
12002 fn map_thread_err(err: anyhow::Error) -> ApiError {
12003 let message = err.to_string();
12004 let lower = message.to_ascii_lowercase();
12005 if (lower.starts_with("thread '") && lower.ends_with("' not found"))
12006 || lower.starts_with("thread not found:")
12007 {
12008 ApiError::not_found(message)
12009 } else if message.starts_with("shell commands are restricted by ") {
12010 ApiError::forbidden(message)
12011 } else if message.contains("already has an active turn")
12012 || message.contains("thread permissions changed during update")
12013 || message.contains("No active turn")
12014 || message.contains("is not active")
12015 // A steer the engine dropped: the turn moved on before the model saw
12016 // it. 409 lets a client keep the text and resend rather than trust a
12017 // delivery that never happened (#6276).
12018 || message.contains("moved on before the steer")
12019 || lower.contains("operation_key is already bound")
12020 || lower.contains("operation_key binding is incomplete")
12021 || lower.contains("operation_key binding does not match")
12022 {
12023 ApiError::conflict(message)
12024 } else {
12025 ApiError::bad_request(message)
12026 }
12027 }
12028
12029 fn map_agent_mail_err(err: anyhow::Error) -> ApiError {
12030 let message = err.to_string();
12031 let lower = message.to_ascii_lowercase();
12032 if lower.contains("ownership denied") {
12033 ApiError::forbidden(message)
12034 } else if lower.contains("already exists with different delivery intent")
12035 || lower.contains("can be canceled only while queued")
12036 {
12037 ApiError::conflict(message)
12038 } else if (lower.contains("failed to read agent mail envelope")
12039 && (lower.contains("no such file")
12040 || err.chain().skip(1).any(|cause| {
12041 cause
12042 .downcast_ref::<std::io::Error>()
12043 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
12044 })))
12045 || (lower.starts_with("thread '") && lower.ends_with("' not found"))
12046 {
12047 ApiError::not_found(message)
12048 } else {
12049 ApiError::bad_request(message)
12050 }
12051 }
12052
12053 #[derive(Debug, Clone)]
12054 pub(crate) struct ApiError {
12055 status: StatusCode,
12056 pub(crate) message: String,
12057 /// Stable machine-readable reason, serialized as `error.code` when set,
12058 /// for refusals a client must branch on rather than show.
12059 code: Option<&'static str>,
12060 }
12061
12062 impl ApiError {
12063 fn bad_request(message: impl Into<String>) -> Self {
12064 Self {
12065 status: StatusCode::BAD_REQUEST,
12066 message: message.into(),
12067 code: None,
12068 }
12069 }
12070
12071 fn not_found(message: impl Into<String>) -> Self {
12072 Self {
12073 status: StatusCode::NOT_FOUND,
12074 message: message.into(),
12075 code: None,
12076 }
12077 }
12078
12079 fn conflict(message: impl Into<String>) -> Self {
12080 Self {
12081 status: StatusCode::CONFLICT,
12082 message: message.into(),
12083 code: None,
12084 }
12085 }
12086
12087 fn not_implemented(message: impl Into<String>) -> Self {
12088 Self {
12089 status: StatusCode::NOT_IMPLEMENTED,
12090 message: message.into(),
12091 code: None,
12092 }
12093 }
12094
12095 fn internal(message: impl Into<String>) -> Self {
12096 Self {
12097 status: StatusCode::INTERNAL_SERVER_ERROR,
12098 message: message.into(),
12099 code: None,
12100 }
12101 }
12102
12103 fn forbidden(message: impl Into<String>) -> Self {
12104 Self {
12105 status: StatusCode::FORBIDDEN,
12106 message: message.into(),
12107 code: None,
12108 }
12109 }
12110
12111 fn payload_too_large(message: impl Into<String>) -> Self {
12112 Self {
12113 status: StatusCode::PAYLOAD_TOO_LARGE,
12114 message: message.into(),
12115 code: None,
12116 }
12117 }
12118
12119 fn gone(message: impl Into<String>) -> Self {
12120 Self {
12121 status: StatusCode::GONE,
12122 message: message.into(),
12123 code: None,
12124 }
12125 }
12126
12127 fn with_code(mut self, code: &'static str) -> Self {
12128 self.code = Some(code);
12129 self
12130 }
12131 }
12132
12133 impl IntoResponse for ApiError {
12134 fn into_response(self) -> Response {
12135 (
12136 self.status,
12137 Json(match self.code {
12138 Some(code) => json!({
12139 "error": {
12140 "message": self.message,
12141 "status": self.status.as_u16(),
12142 "code": code,
12143 }
12144 }),
12145 None => json!({
12146 "error": {
12147 "message": self.message,
12148 "status": self.status.as_u16(),
12149 }
12150 }),
12151 }),
12152 )
12153 .into_response()
12154 }
12155 }
12156
12157 #[cfg(test)]
12158 mod tests;
12159
12160 #[cfg(test)]
12161 mod configured_model_api_tests {
12162 use super::*;
12163 use crate::test_support::{EnvVarGuard, lock_test_env};
12164
12165 fn fixture(provider: &str, model: &str) -> String {
12166 format!(
12167 r#"provider = "{provider}"
12168 default_text_model = "deepseek-v4-pro"
12169 telemetry = false
12170
12171 [[custom_models]]
12172 provider = "{provider}"
12173 base_url = "http://127.0.0.1:9/v1"
12174 id = "{model}"
12175 limit = {{ context = 96000, input = 88000, output = 8000 }}
12176 cost = {{ input = 0.4, output = 1.6 }}
12177 reasoning = false
12178 tool_call = false
12179
12180 [providers.{provider}]
12181 base_url = "http://127.0.0.1:9/v1"
12182 "#
12183 )
12184 }
12185
12186 fn isolate_model_environment() -> Vec<EnvVarGuard> {
12187 let mut guards: Vec<_> = [
12188 "CODEWHALE_CONFIG_PATH",
12189 "DEEPSEEK_CONFIG_PATH",
12190 "CODEWHALE_BASE_URL",
12191 "DEEPSEEK_BASE_URL",
12192 "CODEWHALE_PROVIDER",
12193 "DEEPSEEK_PROVIDER",
12194 "CODEWHALE_MODEL",
12195 "DEEPSEEK_MODEL",
12196 "DEEPSEEK_DEFAULT_TEXT_MODEL",
12197 "OPENROUTER_BASE_URL",
12198 "OPENROUTER_MODEL",
12199 "TOGETHER_BASE_URL",
12200 "TOGETHER_MODEL",
12201 "CODEWHALE_PROFILE",
12202 "DEEPSEEK_PROFILE",
12203 "OLLAMA_MODEL",
12204 "OLLAMA_CLOUD_MODEL",
12205 "OLLAMA_BASE_URL",
12206 "OLLAMA_CLOUD_BASE_URL",
12207 ]
12208 .into_iter()
12209 .map(EnvVarGuard::remove)
12210 .collect();
12211 guards.push(EnvVarGuard::set("CODEWHALE_DISABLE_CLOUD_FACTS", "1"));
12212 guards
12213 }
12214
12215 async fn serve_fixture(
12216 config_path: PathBuf,
12217 ) -> Result<(SocketAddr, RuntimeApiState, tokio::task::JoinHandle<()>)> {
12218 let root = config_path.parent().expect("fixture root");
12219 let workspace = root.join("workspace");
12220 fs::create_dir_all(&workspace)?;
12221 let config = Config::load(Some(config_path.clone()), None)?;
12222 let sessions_dir = root.join("sessions");
12223 let mut manager_config =
12224 RuntimeThreadManagerConfig::from_task_data_dir(root.join("runtime"));
12225 manager_config.sessions_dir = Some(sessions_dir.clone());
12226 let runtime_threads = Arc::new(RuntimeThreadManager::open_with_plugin_registry(
12227 config.clone(),
12228 workspace.clone(),
12229 manager_config,
12230 Arc::new(crate::plugins::PluginRegistry::empty(&workspace)),
12231 )?);
12232 let sessions_dir = runtime_threads.sessions_dir().to_path_buf();
12233 let task_manager = TaskManager::start_with_runtime_manager(
12234 TaskManagerConfig {
12235 data_dir: root.join("tasks"),
12236 worker_count: 1,
12237 default_workspace: workspace.clone(),
12238 default_model: "auto".to_string(),
12239 default_mode: "agent".to_string(),
12240 allow_shell: false,
12241 trust_mode: false,
12242 execution_limits: Default::default(),
12243 },
12244 config.clone(),
12245 runtime_threads.clone(),
12246 )
12247 .await?;
12248 let listener = TcpListener::bind("127.0.0.1:0").await?;
12249 let addr = listener.local_addr()?;
12250 let sub_agent_manager = runtime_api_sub_agent_manager(&workspace, 2);
12251 let workspace_scopes =
12252 RuntimeWorkspaceScopes::new(runtime_threads.clone(), sub_agent_manager.clone());
12253 let workspace_scope = workspace_scopes.admit(workspace.clone()).await?;
12254 let state = RuntimeApiState {
12255 config: Arc::new(parking_lot::RwLock::new(config)),
12256 workspace: workspace.clone(),
12257 plugin_discovery: crate::plugins::PluginDiscoveryContext::capture_pre_dotenv(),
12258 task_manager,
12259 runtime_threads,
12260 cors_origins: Vec::new(),
12261 sessions_dir,
12262 config_path: Some(config_path.clone()),
12263 config_profile: None,
12264 automations: Arc::new(Mutex::new(AutomationManager::open_for_test(
12265 root.join("automations"),
12266 )?)),
12267 sub_agent_manager,
12268 runtime_token: None,
12269 skill_state: Arc::new(Mutex::new(SkillStateStore::load_from(
12270 root.join("skills_state.toml"),
12271 )?)),
12272 auth_required: false,
12273 bind_host: "127.0.0.1".to_string(),
12274 bind_port: addr.port(),
12275 mobile_enabled: false,
12276 mobile: None,
12277 web: None,
12278 fleet_codewhale_binary: "unused-test-binary".to_string(),
12279 workspace_scopes,
12280 workspace_scope,
12281 computer: computer_display::ComputerState::from_env(),
12282 shutdown: RuntimeServerShutdown::default(),
12283 git_writes: Arc::new(tokio::sync::Mutex::new(())),
12284 provider_switches: Arc::new(tokio::sync::Mutex::new(())),
12285 compat_stream_test_hook: None,
12286 };
12287 let router = build_router(state.clone());
12288 let server = tokio::spawn(async move {
12289 axum::serve(
12290 listener,
12291 router.into_make_service_with_connect_info::<SocketAddr>(),
12292 )
12293 .await
12294 .expect("local fixture server");
12295 });
12296 Ok((addr, state, server))
12297 }
12298
12299 async fn post_json(addr: SocketAddr, path: &str, body: Value) -> Result<Value> {
12300 let response = crate::tls::reqwest_client()
12301 .post(format!("http://{addr}{path}"))
12302 .json(&body)
12303 .send()
12304 .await?;
12305 let status = response.status();
12306 let body = response.json::<Value>().await?;
12307 assert_eq!(status, StatusCode::OK, "{path}: {body}");
12308 Ok(body)
12309 }
12310
12311 fn assert_declared_route(config_path: &FsPath, provider: ProviderKind, model: &str) {
12312 let config = Config::load(Some(config_path.to_path_buf()), None).expect("reloaded config");
12313 let persisted = config
12314 .provider_config_for(&config.test_identity_for_kind(provider))
12315 .and_then(|entry| entry.model.as_deref());
12316 assert_eq!(persisted, Some(model));
12317 let selected =
12318 provider_default_model_for_api(&config, &(config).test_identity_for_kind(provider));
12319 assert_eq!(selected, model);
12320 let route = crate::route_runtime::resolve_runtime_route(&config, provider, Some(&selected))
12321 .expect("saved declared route");
12322 assert_eq!(route.model, model);
12323 assert!(route.candidate.canonical_model().is_none());
12324 assert_eq!(route.candidate.limits().context_tokens, Some(96_000));
12325 assert_eq!(
12326 route.context_window.source,
12327 crate::route_runtime::ContextWindowSource::UserDeclared
12328 );
12329 }
12330
12331 fn write_remembered_selection_fixture(home: &FsPath, config_path: &FsPath) -> Result<()> {
12332 fs::create_dir_all(home)?;
12333 fs::create_dir_all(config_path.parent().expect("config parent"))?;
12334 fs::write(
12335 home.join("settings.toml"),
12336 "default_provider = \"zai\"\n[provider_models]\nzai = \"GLM-5.3\"\n",
12337 )?;
12338 fs::write(
12339 config_path,
12340 r#"provider = "deepseek"
12341 default_text_model = "deepseek-v4-pro"
12342 telemetry = false
12343
12344 [cloud_facts]
12345 enabled = false
12346
12347 [providers.zai]
12348 base_url = "https://api.z.ai/api/coding/paas/v4"
12349 model = "GLM-5.2"
12350 "#,
12351 )?;
12352 Ok(())
12353 }
12354
12355 async fn assert_catalog_and_new_thread_selection(
12356 addr: SocketAddr,
12357 provider: &str,
12358 model: &str,
12359 ) -> Result<Value> {
12360 let client = crate::tls::reqwest_client();
12361 let catalog = client
12362 .get(format!("http://{addr}/v1/providers"))
12363 .send()
12364 .await?
12365 .error_for_status()?
12366 .json::<Value>()
12367 .await?;
12368 assert_eq!(catalog["current"], provider);
12369 let entry = catalog["providers"]
12370 .as_array()
12371 .expect("provider catalog")
12372 .iter()
12373 .find(|entry| entry["id"] == provider)
12374 .expect("selected provider");
12375 assert_eq!(entry["default_model"], model);
12376 let config = client
12377 .get(format!("http://{addr}/v1/config"))
12378 .send()
12379 .await?
12380 .error_for_status()?
12381 .json::<Value>()
12382 .await?;
12383 assert_eq!(config["model"], model);
12384 if provider == "deepseek" {
12385 assert_eq!(config["default_model"], model);
12386 }
12387 // Creation only saves the route; this fixture never starts a turn or
12388 // contacts any provider, including the official catalog URLs above.
12389 let response = client
12390 .post(format!("http://{addr}/v1/threads"))
12391 .json(&json!({}))
12392 .send()
12393 .await?;
12394 let status = response.status();
12395 let thread = response.json::<Value>().await?;
12396 assert_eq!(status, StatusCode::CREATED, "{thread}");
12397 assert_eq!(thread["model_provider"], provider);
12398 assert_eq!(thread["model"], model);
12399 assert_eq!(thread["model_provider_id"], entry["model_provider_id"]);
12400 Ok(thread)
12401 }
12402
12403 #[tokio::test(flavor = "current_thread")]
12404 async fn remembered_selection_aligns_catalog_and_new_thread_after_load() -> Result<()> {
12405 let _env = lock_test_env();
12406 let _live = crate::provider_lake::lock_live_snapshot();
12407 let root = tempfile::tempdir()?;
12408 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
12409 let _model_environment = isolate_model_environment();
12410 crate::provider_catalog_live::reset_cache_for_test();
12411 crate::provider_lake::clear_live_snapshot();
12412 let config_path = root.path().join("config.toml");
12413 write_remembered_selection_fixture(root.path(), &config_path)?;
12414 let original_config = fs::read(&config_path)?;
12415 let original_settings = fs::read(root.path().join("settings.toml"))?;
12416 let (addr, state, server) = serve_fixture(config_path.clone()).await?;
12417 let _shutdown = state.task_manager.shutdown_guard();
12418 assert_catalog_and_new_thread_selection(addr, "zai", "GLM-5.3").await?;
12419 post_json(addr, "/v1/config/reload", json!({})).await?;
12420 assert_catalog_and_new_thread_selection(addr, "zai", "GLM-5.3").await?;
12421 assert_eq!(fs::read(&config_path)?, original_config);
12422 assert_eq!(
12423 fs::read(root.path().join("settings.toml"))?,
12424 original_settings
12425 );
12426 server.abort();
12427 state.task_manager.shutdown_and_wait().await?;
12428 Ok(())
12429 }
12430
12431 #[tokio::test(flavor = "current_thread")]
12432 async fn explicit_runtime_selections_migrate_legacy_memory_into_config_once() -> Result<()> {
12433 let _env = lock_test_env();
12434 let _live = crate::provider_lake::lock_live_snapshot();
12435 let root = tempfile::tempdir()?;
12436 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
12437 let _model_environment = isolate_model_environment();
12438 crate::provider_catalog_live::reset_cache_for_test();
12439 crate::provider_lake::clear_live_snapshot();
12440 let config_path = root.path().join("config.toml");
12441 write_remembered_selection_fixture(root.path(), &config_path)?;
12442 let (addr, state, server) = serve_fixture(config_path.clone()).await?;
12443 let _shutdown = state.task_manager.shutdown_guard();
12444 let original_thread =
12445 assert_catalog_and_new_thread_selection(addr, "zai", "GLM-5.3").await?;
12446 let original_settings = fs::read(root.path().join("settings.toml"))?;
12447 post_json(
12448 addr,
12449 "/v1/config",
12450 json!({ "key": "model", "value": "GLM-5.2", "persist": false }),
12451 )
12452 .await?;
12453 assert_eq!(
12454 fs::read(root.path().join("settings.toml"))?,
12455 original_settings
12456 );
12457 post_json(
12458 addr,
12459 "/v1/config",
12460 json!({ "key": "model", "value": "GLM-5.2", "persist": true }),
12461 )
12462 .await?;
12463 assert_eq!(
12464 runtime_request_model(&state.config.read(), None).expect("current default"),
12465 "GLM-5.3"
12466 );
12467 let migrated: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?;
12468 assert_eq!(migrated["route_preferences_version"].as_integer(), Some(1));
12469 assert_eq!(migrated["provider"].as_str(), Some("zai"));
12470 assert_eq!(
12471 migrated["providers"]["zai"]["model"].as_str(),
12472 Some("GLM-5.2")
12473 );
12474 post_json(addr, "/v1/config/reload", json!({})).await?;
12475 assert_catalog_and_new_thread_selection(addr, "zai", "GLM-5.2").await?;
12476 let saved_thread = state
12477 .runtime_threads
12478 .get_thread(original_thread["id"].as_str().expect("thread id"))
12479 .await?;
12480 assert_eq!(saved_thread.model, "GLM-5.3");
12481
12482 post_json(
12483 addr,
12484 "/v1/providers/deepseek/switch",
12485 json!({ "model": "deepseek-v4-flash" }),
12486 )
12487 .await?;
12488 assert_catalog_and_new_thread_selection(addr, "deepseek", "deepseek-v4-flash").await?;
12489 post_json(
12490 addr,
12491 "/v1/config",
12492 json!({ "key": "default_model", "value": "deepseek-v4-pro", "persist": true }),
12493 )
12494 .await?;
12495 post_json(addr, "/v1/config/reload", json!({})).await?;
12496 assert_catalog_and_new_thread_selection(addr, "deepseek", "deepseek-v4-pro").await?;
12497
12498 for (key, value) in [("provider", "zai"), ("model", "GLM-5.3")] {
12499 post_json(
12500 addr,
12501 "/v1/config",
12502 json!({ "key": key, "value": value, "persist": true }),
12503 )
12504 .await?;
12505 }
12506 post_json(addr, "/v1/config/reload", json!({})).await?;
12507 assert_catalog_and_new_thread_selection(addr, "zai", "GLM-5.3").await?;
12508 post_json(addr, "/v1/providers/deepseek/switch", json!({})).await?;
12509 post_json(addr, "/v1/config/reload", json!({})).await?;
12510 assert_catalog_and_new_thread_selection(addr, "deepseek", "deepseek-v4-pro").await?;
12511 // The old Settings selection remains unchanged and cannot reassert
12512 // itself once Config owns the migrated route preferences.
12513 assert_eq!(
12514 fs::read(root.path().join("settings.toml"))?,
12515 original_settings
12516 );
12517 server.abort();
12518 state.task_manager.shutdown_and_wait().await?;
12519 Ok(())
12520 }
12521
12522 #[tokio::test(flavor = "current_thread")]
12523 async fn provider_switch_migrates_legacy_selection_before_explicit_choice() -> Result<()> {
12524 let _env = lock_test_env();
12525 let _live = crate::provider_lake::lock_live_snapshot();
12526 let root = tempfile::tempdir()?;
12527 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
12528 let _model_environment = isolate_model_environment();
12529 crate::provider_catalog_live::reset_cache_for_test();
12530 crate::provider_lake::clear_live_snapshot();
12531 let config_path = root.path().join("config.toml");
12532 write_remembered_selection_fixture(root.path(), &config_path)?;
12533 let original_settings = fs::read(root.path().join("settings.toml"))?;
12534 let (addr, state, server) = serve_fixture(config_path.clone()).await?;
12535 let _shutdown = state.task_manager.shutdown_guard();
12536 post_json(
12537 addr,
12538 "/v1/providers/deepseek/switch",
12539 json!({ "model": "deepseek-v4-flash" }),
12540 )
12541 .await?;
12542 let migrated: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?;
12543 assert_eq!(migrated["route_preferences_version"].as_integer(), Some(1));
12544 assert_eq!(migrated["provider"].as_str(), Some("deepseek"));
12545 assert_eq!(
12546 migrated["providers"]["deepseek"]["model"].as_str(),
12547 Some("deepseek-v4-flash")
12548 );
12549 assert_eq!(
12550 migrated["providers"]["zai"]["model"].as_str(),
12551 Some("GLM-5.3")
12552 );
12553 post_json(addr, "/v1/config/reload", json!({})).await?;
12554 assert_catalog_and_new_thread_selection(addr, "deepseek", "deepseek-v4-flash").await?;
12555 assert_eq!(
12556 fs::read(root.path().join("settings.toml"))?,
12557 original_settings
12558 );
12559 server.abort();
12560 state.task_manager.shutdown_and_wait().await?;
12561 Ok(())
12562 }
12563
12564 #[tokio::test(flavor = "current_thread")]
12565 async fn runtime_model_writes_keep_legacy_hosted_ollama_identity() -> Result<()> {
12566 #[derive(Deserialize)]
12567 struct Selection {
12568 provider: String,
12569 model: String,
12570 persisted: bool,
12571 }
12572
12573 let _env = lock_test_env();
12574 let _live = crate::provider_lake::lock_live_snapshot();
12575 let home = tempfile::tempdir()?;
12576 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
12577 let _model_environment = isolate_model_environment();
12578 crate::provider_catalog_live::reset_cache_for_test();
12579 crate::provider_lake::clear_live_snapshot();
12580 let config_path = home.path().join("config.toml");
12581 fs::write(
12582 &config_path,
12583 "provider = 'ollama'\ntelemetry = false\n[providers.ollama]\nbase_url = 'https://ollama.com/v1'\nmodel = 'old-cloud-model'\n[providers.ollama_cloud]\nmodel = 'explicit-cloud-model'\n",
12584 )?;
12585 let settings = "default_provider = 'ollama'\n[provider_models]\nollama-cloud = 'remembered-cloud-model'\n";
12586 fs::write(home.path().join("settings.toml"), settings)?;
12587 let (addr, state, server) = serve_fixture(config_path.clone()).await?;
12588 let _shutdown = state.task_manager.shutdown_guard();
12589 assert_eq!(
12590 state.config.read().default_model(),
12591 "remembered-cloud-model"
12592 );
12593 post_json(
12594 addr,
12595 "/v1/config",
12596 json!({"key": "model", "value": "current-cloud-model", "persist": true}),
12597 )
12598 .await?;
12599 post_json(addr, "/v1/config/reload", json!({})).await?;
12600 assert_eq!(state.config.read().default_model(), "current-cloud-model");
12601 for (selector, model) in [
12602 ("ollama", "legacy-choice"),
12603 ("ollama-cloud", "explicit-choice"),
12604 ("ollama", "legacy-final"),
12605 ] {
12606 let selection: Selection = serde_json::from_value(
12607 post_json(
12608 addr,
12609 &format!("/v1/providers/{selector}/switch"),
12610 json!({"model": model}),
12611 )
12612 .await?,
12613 )?;
12614 assert_eq!(selection.provider, "ollama-cloud");
12615 assert_eq!(selection.model, model);
12616 assert!(selection.persisted);
12617 post_json(addr, "/v1/config/reload", json!({})).await?;
12618 let config = state.config.read();
12619 let identity = config
12620 .active_provider_identity()
12621 .map_err(anyhow::Error::msg)?;
12622 assert_eq!(identity.persisted_id(), Some(selector));
12623 assert_eq!(config.default_model(), model);
12624 }
12625 let document: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?;
12626 assert_eq!(document["route_preferences_version"].as_integer(), Some(1));
12627 assert_eq!(document["provider"].as_str(), Some("ollama"));
12628 assert_eq!(
12629 document["providers"]["ollama"]["model"].as_str(),
12630 Some("legacy-final")
12631 );
12632 assert_eq!(
12633 document["providers"]["ollama_cloud"]["model"].as_str(),
12634 Some("explicit-choice")
12635 );
12636 assert_eq!(
12637 fs::read_to_string(home.path().join("settings.toml"))?,
12638 settings
12639 );
12640 let thread =
12641 assert_catalog_and_new_thread_selection(addr, "ollama-cloud", "legacy-final").await?;
12642 assert_eq!(thread["model_provider_id"], "ollama");
12643 server.abort();
12644 state.task_manager.shutdown_and_wait().await?;
12645 Ok(())
12646 }
12647
12648 #[tokio::test(flavor = "current_thread")]
12649 async fn scoped_runtime_selections_leave_device_memory_unchanged() -> Result<()> {
12650 let _env = lock_test_env();
12651 let _live = crate::provider_lake::lock_live_snapshot();
12652 let root = tempfile::tempdir()?;
12653 let home = root.path().join("home");
12654 let _home = EnvVarGuard::set("CODEWHALE_HOME", &home);
12655 let _model_environment = isolate_model_environment();
12656 crate::provider_catalog_live::reset_cache_for_test();
12657 crate::provider_lake::clear_live_snapshot();
12658 let config_path = root.path().join("project/config.toml");
12659 write_remembered_selection_fixture(&home, &config_path)?;
12660 let original_settings = fs::read(home.join("settings.toml"))?;
12661 let (addr, state, server) = serve_fixture(config_path).await?;
12662 let _shutdown = state.task_manager.shutdown_guard();
12663 assert_catalog_and_new_thread_selection(addr, "deepseek", "deepseek-v4-pro").await?;
12664 for (key, value) in [
12665 ("provider", "zai"),
12666 ("model", "GLM-5.1"),
12667 ("provider", "deepseek"),
12668 ("default_model", "deepseek-v4-flash"),
12669 ] {
12670 post_json(
12671 addr,
12672 "/v1/config",
12673 json!({ "key": key, "value": value, "persist": true }),
12674 )
12675 .await?;
12676 }
12677 post_json(
12678 addr,
12679 "/v1/providers/zai/switch",
12680 json!({ "model": "GLM-5.2" }),
12681 )
12682 .await?;
12683 post_json(addr, "/v1/config/reload", json!({})).await?;
12684 assert_catalog_and_new_thread_selection(addr, "zai", "GLM-5.2").await?;
12685 assert_eq!(fs::read(home.join("settings.toml"))?, original_settings);
12686 server.abort();
12687 state.task_manager.shutdown_and_wait().await?;
12688 Ok(())
12689 }
12690
12691 #[tokio::test(flavor = "current_thread")]
12692 async fn declared_model_posts_preserve_exact_identity_after_reload() -> Result<()> {
12693 let _env = lock_test_env();
12694 let _live = crate::provider_lake::lock_live_snapshot();
12695 let home = tempfile::tempdir()?;
12696 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
12697 let _model_environment = isolate_model_environment();
12698 crate::provider_catalog_live::reset_cache_for_test();
12699 crate::provider_lake::clear_live_snapshot();
12700 for (provider, model) in [
12701 (ProviderKind::Deepseek, "deepseek-v4pro"),
12702 (ProviderKind::Openrouter, "deepseek-v4-pro"),
12703 (ProviderKind::Together, "deepseek-v4-pro"),
12704 ] {
12705 let root = tempfile::tempdir()?;
12706 let config_path = root.path().join("config.toml");
12707 fs::write(&config_path, fixture(provider.as_str(), model))?;
12708 let (addr, state, server) = serve_fixture(config_path.clone()).await?;
12709 let _shutdown = state.task_manager.shutdown_guard();
12710 let body = post_json(
12711 addr,
12712 &format!("/v1/providers/{}/switch", provider.as_str()),
12713 json!({ "model": model }),
12714 )
12715 .await?;
12716 assert_eq!(body["model"], model);
12717 assert_declared_route(&config_path, provider, model);
12718 let keys = if provider == ProviderKind::Deepseek {
12719 vec!["model", "default_model"]
12720 } else {
12721 vec!["model"]
12722 };
12723 for key in keys {
12724 let body = post_json(
12725 addr,
12726 "/v1/config",
12727 json!({ "key": key, "value": model, "persist": true }),
12728 )
12729 .await?;
12730 assert_eq!(body["value"], model);
12731 post_json(addr, "/v1/config/reload", json!({})).await?;
12732 assert_declared_route(&config_path, provider, model);
12733 let reloaded = state.config.read();
12734 let selected = provider_default_model_for_api(
12735 &reloaded,
12736 &(reloaded).test_identity_for_kind(provider),
12737 );
12738 let route = crate::route_runtime::resolve_runtime_route(
12739 &reloaded,
12740 provider,
12741 Some(&selected),
12742 )
12743 .expect("active reloaded route");
12744 assert_eq!(route.model, model);
12745 assert_eq!(route.candidate.limits().context_tokens, Some(96_000));
12746 }
12747 server.abort();
12748 state.task_manager.shutdown_and_wait().await?;
12749 }
12750 Ok(())
12751 }
12752
12753 #[tokio::test(flavor = "current_thread")]
12754 async fn declared_model_posts_do_not_preserve_alias_at_wrong_endpoint() -> Result<()> {
12755 let _env = lock_test_env();
12756 let _live = crate::provider_lake::lock_live_snapshot();
12757 let root = tempfile::tempdir()?;
12758 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
12759 let _model_environment = isolate_model_environment();
12760 crate::provider_catalog_live::reset_cache_for_test();
12761 crate::provider_lake::clear_live_snapshot();
12762 let config_path = root.path().join("config.toml");
12763 let fixture = fixture("deepseek", "deepseek-v4pro").replace(
12764 "[providers.deepseek]\nbase_url = \"http://127.0.0.1:9/v1\"",
12765 "[providers.deepseek]\nbase_url = \"http://127.0.0.1:10/v1\"",
12766 );
12767 fs::write(&config_path, fixture)?;
12768 let (addr, state, server) = serve_fixture(config_path.clone()).await?;
12769 let _shutdown = state.task_manager.shutdown_guard();
12770 let body = post_json(
12771 addr,
12772 "/v1/providers/deepseek/switch",
12773 json!({ "model": "deepseek-v4pro" }),
12774 )
12775 .await?;
12776 assert_eq!(body["model"], "deepseek-v4-pro");
12777 let body = post_json(
12778 addr,
12779 "/v1/config",
12780 json!({ "key": "model", "value": "deepseek-v4pro", "persist": true }),
12781 )
12782 .await?;
12783 assert_eq!(body["value"], "deepseek-v4-pro");
12784 post_json(addr, "/v1/config/reload", json!({})).await?;
12785 let config = Config::load(Some(config_path), None)?;
12786 assert_eq!(
12787 config
12788 .provider_config_for(&config.test_identity_for_kind(ProviderKind::Deepseek))
12789 .and_then(|provider| provider.model.as_deref()),
12790 Some("deepseek-v4-pro")
12791 );
12792 let selected = provider_default_model_for_api(
12793 &config,
12794 &(config).test_identity_for_kind(ProviderKind::Deepseek),
12795 );
12796 let route = crate::route_runtime::resolve_runtime_route(
12797 &config,
12798 ProviderKind::Deepseek,
12799 Some(&selected),
12800 )
12801 .expect("ordinary saved route");
12802 assert_eq!(route.model, "deepseek-v4-pro");
12803 assert_ne!(route.candidate.limits().context_tokens, Some(96_000));
12804 assert_ne!(
12805 route.context_window.source,
12806 crate::route_runtime::ContextWindowSource::UserDeclared
12807 );
12808 server.abort();
12809 state.task_manager.shutdown_and_wait().await?;
12810 Ok(())
12811 }
12812
12813 #[test]
12814 fn declared_model_normalization_keeps_identity_and_protocol_guards() {
12815 let _env = lock_test_env();
12816 let _live = crate::provider_lake::lock_live_snapshot();
12817 let root = tempfile::tempdir().expect("test root");
12818 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
12819 let _model_environment = isolate_model_environment();
12820 crate::provider_catalog_live::reset_cache_for_test();
12821 crate::provider_lake::clear_live_snapshot();
12822 let mut config: Config =
12823 toml::from_str(&fixture("deepseek", "deepseek-v4pro")).expect("fixture config");
12824 config.custom_models.as_mut().unwrap()[0].provider = "other".to_string();
12825 assert_eq!(
12826 normalize_runtime_config_model(
12827 &config,
12828 &(config).test_identity_for_kind(ProviderKind::Deepseek),
12829 "deepseek-v4pro"
12830 )
12831 .expect("legacy alias remains accepted"),
12832 "deepseek-v4-pro"
12833 );
12834 for provider in [ProviderKind::OpencodeGo, ProviderKind::OpencodeZen] {
12835 let config: Config = toml::from_str(&fixture(provider.as_str(), "unlisted-model"))
12836 .expect("fixture config");
12837 assert!(
12838 normalize_runtime_config_model(
12839 &config,
12840 &(config).test_identity_for_kind(provider),
12841 "unlisted-model"
12842 )
12843 .is_err(),
12844 "a declaration cannot expand the {provider:?} protocol roster"
12845 );
12846 }
12847 }
12848
12849 #[tokio::test(flavor = "current_thread")]
12850 async fn runtime_default_model_reads_and_writes_the_deepseek_cn_slot() -> Result<()> {
12851 let _env = lock_test_env();
12852 let _live = crate::provider_lake::lock_live_snapshot();
12853 let root = tempfile::tempdir()?;
12854 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
12855 let _model_environment = isolate_model_environment();
12856 crate::provider_catalog_live::reset_cache_for_test();
12857 crate::provider_lake::clear_live_snapshot();
12858 let config_path = root.path().join("config.toml");
12859 fs::write(
12860 &config_path,
12861 "provider = 'deepseek-cn'\ntelemetry = false\n[cloud_facts]\nenabled = false\n[providers.deepseek_cn]\nmodel = 'deepseek-v4-pro'\n",
12862 )?;
12863 let (addr, state, server) = serve_fixture(config_path.clone()).await?;
12864 let _shutdown = state.task_manager.shutdown_guard();
12865 let client = crate::tls::reqwest_client();
12866 // The active CN route resolves `[providers.deepseek_cn].model`; the
12867 // runtime default_model surface must report that slot, not the primary.
12868 let reported: Value = client
12869 .get(format!("http://{addr}/v1/config"))
12870 .send()
12871 .await?
12872 .error_for_status()?
12873 .json()
12874 .await?;
12875 assert_eq!(reported["default_model"], "deepseek-v4-pro");
12876
12877 post_json(
12878 addr,
12879 "/v1/config",
12880 json!({ "key": "default_model", "value": "deepseek-v4-flash", "persist": true }),
12881 )
12882 .await?;
12883 let saved: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?;
12884 assert_eq!(
12885 saved["providers"]["deepseek_cn"]["model"].as_str(),
12886 Some("deepseek-v4-flash")
12887 );
12888 assert!(
12889 saved
12890 .get("providers")
12891 .and_then(|providers| providers.get("deepseek"))
12892 .and_then(|deepseek| deepseek.get("model"))
12893 .is_none(),
12894 "the CN write must not create an unread primary slot: {saved}"
12895 );
12896
12897 post_json(addr, "/v1/config/reload", json!({})).await?;
12898 let reported: Value = client
12899 .get(format!("http://{addr}/v1/config"))
12900 .send()
12901 .await?
12902 .error_for_status()?
12903 .json()
12904 .await?;
12905 assert_eq!(reported["default_model"], "deepseek-v4-flash");
12906 post_json(
12907 addr,
12908 "/v1/config",
12909 json!({ "key": "default_model", "value": "auto", "persist": true }),
12910 )
12911 .await?;
12912 post_json(addr, "/v1/config/reload", json!({})).await?;
12913 let reported: Value = client
12914 .get(format!("http://{addr}/v1/config"))
12915 .send()
12916 .await?
12917 .error_for_status()?
12918 .json()
12919 .await?;
12920 assert_eq!(reported["default_model"], "auto");
12921 server.abort();
12922 state.task_manager.shutdown_and_wait().await?;
12923 Ok(())
12924 }
12925 }
12926
12926 lines RUST