返回 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};
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::{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::{ApiProvider, Config, 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, TurnItemKind, TurnRecord, UpdateThreadRequest, UsageGroupBy, UsageTotals,
76 };
77 #[cfg(test)]
78 pub(super) use crate::runtime_threads::{RuntimeTurnStatus, TurnItemLifecycleStatus};
79 use crate::session_manager::default_sessions_dir;
80 #[cfg(test)]
81 pub(super) use crate::session_manager::{SavedSession, SessionMetadata};
82 use crate::skill_state::SkillStateStore;
83 use crate::task_manager::{
84 NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskRecord, TaskSummary,
85 };
86 use crate::tools::subagent::{
87 AgentWorkerRecord, SharedSubAgentManager, load_persisted_agent_worker_records,
88 new_shared_subagent_manager_with_timeout,
89 };
90 #[cfg(test)]
91 pub(super) use codewhale_models::{ContentBlock, Message};
92 use codewhale_protocol::fleet::{
93 FleetArtifactKind, FleetEventReplay, FleetRun, FleetRunId, FleetRuntimeEvent,
94 FleetRuntimeTarget, FleetSecurityPolicy, FleetTaskSpec, FleetWorkerEventPayload,
95 FleetWorkerSpec, FleetWorkerStatus, FleetWorkflowDescriptor, FleetWorkflowKind,
96 };
97
98 mod auth;
99 mod context;
100 mod diagnostics;
101 mod git;
102 mod jobs;
103 mod lsp;
104 mod mcp_import;
105 mod memory_lens;
106 mod mobile;
107 mod plans;
108 mod plugins;
109 mod secrets;
110 mod sessions;
111 mod targets;
112 mod voice;
113 mod web;
114 mod workspace;
115 #[cfg(test)]
116 use self::auth::ResolvedRuntimeAuth;
117 use self::auth::{
118 require_runtime_token, resolve_runtime_auth, runtime_auth_status_lines,
119 runtime_request_is_authorized,
120 };
121 use self::sessions::{
122 create_session_from_thread, delete_session, get_session, list_session_artifacts, list_sessions,
123 list_sessions_summary, patch_session, read_session_artifact, resume_session_thread,
124 save_current_session,
125 };
126 #[cfg(test)]
127 use self::sessions::{messages_from_thread_detail, session_to_detail};
128 #[cfg(test)]
129 use self::workspace::collect_workspace_status;
130 use self::workspace::{
131 collect_workspace_git_metadata, workspace_file_read, workspace_file_search,
132 workspace_file_write, workspace_files_list, workspace_instructions, workspace_status,
133 };
134
135 const RUNTIME_TOKEN_ENV: &str = "CODEWHALE_RUNTIME_TOKEN";
136 const LEGACY_RUNTIME_TOKEN_ENV: &str = "DEEPSEEK_RUNTIME_TOKEN";
137 const LEGACY_RUNTIME_TOKEN_WARNING: &str = "Warning: DEEPSEEK_RUNTIME_TOKEN is deprecated; use \
138 CODEWHALE_RUNTIME_TOKEN (the legacy alias is removed in 0.10.0).";
139
140 struct RuntimeTokenEnvironment {
141 token: Option<String>,
142 legacy_alias_used: bool,
143 }
144
145 fn runtime_token_environment(lookup: &dyn Fn(&str) -> Option<String>) -> RuntimeTokenEnvironment {
146 let nonblank = |name| {
147 lookup(name)
148 .map(|value| value.trim().to_string())
149 .filter(|value| !value.is_empty())
150 };
151
152 if let Some(token) = nonblank(RUNTIME_TOKEN_ENV) {
153 return RuntimeTokenEnvironment {
154 token: Some(token),
155 legacy_alias_used: false,
156 };
157 }
158
159 let token = nonblank(LEGACY_RUNTIME_TOKEN_ENV);
160 RuntimeTokenEnvironment {
161 legacy_alias_used: token.is_some(),
162 token,
163 }
164 }
165
166 fn runtime_token_alias_warning(
167 cli_token: Option<&str>,
168 environment: &RuntimeTokenEnvironment,
169 ) -> Option<&'static str> {
170 let cli_token_is_used = cli_token.is_some_and(|token| !token.trim().is_empty());
171 (!cli_token_is_used && environment.legacy_alias_used).then_some(LEGACY_RUNTIME_TOKEN_WARNING)
172 }
173
174 #[derive(Clone)]
175 pub struct RuntimeApiState {
176 config: Arc<parking_lot::RwLock<Config>>,
177 workspace: PathBuf,
178 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
179 task_manager: SharedTaskManager,
180 runtime_threads: SharedRuntimeThreadManager,
181 cors_origins: Vec<String>,
182 sessions_dir: PathBuf,
183 /// Original `--config` path (if any) used to load the initial config.
184 /// Passed to `Config::load` on reload and to persistence helpers so
185 /// GUI-driven config changes target the same file the server was
186 /// started with, instead of falling back to the default discovery.
187 config_path: Option<PathBuf>,
188 /// Effective initial profile (`--profile` or `DEEPSEEK_PROFILE`).
189 /// Reload must retain this overlay so profile-scoped routes do not vanish.
190 config_profile: Option<String>,
191 automations: SharedAutomationManager,
192 sub_agent_manager: SharedSubAgentManager,
193 runtime_token: Option<String>,
194 skill_state: Arc<Mutex<SkillStateStore>>,
195 auth_required: bool,
196 bind_host: String,
197 bind_port: u16,
198 mobile_enabled: bool,
199 mobile: Option<mobile::RuntimeMobileState>,
200 web: Option<web::RuntimeWebState>,
201 /// Executable used by Runtime API-owned Fleet manager loops. Stored on
202 /// state so tests and embedded callers can provide a hermetic worker.
203 fleet_codewhale_binary: String,
204 /// Shared McpPool reused for explicit live MCP discovery. Passive API
205 /// calls do not initialize this pool so dashboards cannot accidentally
206 /// become a second stdio-process owner. The outer mutex guards only the
207 /// lazily-initialized slot; slow per-pool work (connect_all) runs under
208 /// the inner handle so it cannot block slot reads.
209 mcp_pool: Arc<Mutex<Option<Arc<Mutex<McpPool>>>>>,
210 /// Workspace-level LSP client for the HTTP surface (APPS-93): diagnostics
211 /// and semantic queries on files a client views. Engines keep their own
212 /// per-thread managers; this one serves the file view and is built lazily
213 /// so a server without LSP use never spawns a language server.
214 lsp_manager: Arc<std::sync::OnceLock<Arc<crate::lsp::LspManager>>>,
215 #[cfg(test)]
216 compat_stream_test_hook: Option<tokio::sync::mpsc::UnboundedSender<CompatStreamTestPoint>>,
217 }
218
219 #[cfg(test)]
220 enum CompatStreamTestPoint {
221 ThreadCreated {
222 thread_id: String,
223 resume: tokio::sync::oneshot::Sender<()>,
224 },
225 SubscribedBeforeReplay {
226 thread_id: String,
227 turn_id: String,
228 resume: tokio::sync::oneshot::Sender<()>,
229 },
230 ReplayLoaded {
231 thread_id: String,
232 turn_id: String,
233 resume: tokio::sync::oneshot::Sender<()>,
234 },
235 }
236
237 #[derive(Debug, Clone)]
238 pub struct RuntimeApiOptions {
239 pub host: String,
240 pub port: u16,
241 pub workers: usize,
242 /// Additional CORS origins to allow on top of the built-in defaults
243 /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`,
244 /// `tauri://localhost`). Populated by `--cors-origin` (repeatable),
245 /// `CODEWHALE_CORS_ORIGINS` (comma-separated, `DEEPSEEK_CORS_ORIGINS`
246 /// as alias), and `[runtime_api] cors_origins` in `config.toml`.
247 /// Whalescale#255 / #561.
248 pub cors_origins: Vec<String>,
249 /// Optional bearer token required for `/v1/*` routes. If omitted here,
250 /// `run_http_server` checks `CODEWHALE_RUNTIME_TOKEN`, then
251 /// `DEEPSEEK_RUNTIME_TOKEN` as an alias.
252 pub auth_token: Option<String>,
253 /// Allow `/v1/*` routes without auth when no token is configured.
254 pub insecure_no_auth: bool,
255 /// Enables the built-in mobile control page at `/mobile`.
256 pub mobile: bool,
257 /// Enables the embedded local browser client and opens it after binding.
258 /// Web mode is always loopback-only and uses a one-time bootstrap cookie
259 /// exchange rather than exposing the Runtime token to the browser URL.
260 pub web: bool,
261 /// Show a QR code for the mobile URL in the terminal.
262 pub show_qr: bool,
263 /// Original `--config` path used to load the initial config. When
264 /// `Some`, GUI-driven config reloads and persistence target this file
265 /// instead of the default discovery path.
266 pub config_path: Option<PathBuf>,
267 /// Effective profile used to load the server's initial Config.
268 pub config_profile: Option<String>,
269 }
270
271 impl Default for RuntimeApiOptions {
272 fn default() -> Self {
273 Self {
274 host: "127.0.0.1".to_string(),
275 port: 7878,
276 workers: 2,
277 cors_origins: Vec::new(),
278 auth_token: None,
279 insecure_no_auth: false,
280 mobile: false,
281 web: false,
282 show_qr: false,
283 config_path: None,
284 config_profile: None,
285 }
286 }
287 }
288
289 #[derive(Debug, Deserialize)]
290 struct StreamTurnRequest {
291 #[serde(default, rename = "maxOutputTokens", alias = "max_output_tokens")]
292 max_output_tokens: Option<std::num::NonZeroU32>,
293 prompt: String,
294 #[serde(default)]
295 images: Vec<codewhale_protocol::runtime::RuntimeImageInput>,
296 model: Option<String>,
297 mode: Option<String>,
298 permission_posture: Option<String>,
299 workspace: Option<PathBuf>,
300 allow_shell: Option<bool>,
301 trust_mode: Option<bool>,
302 auto_approve: Option<bool>,
303 }
304
305 #[derive(Debug, Serialize)]
306 struct HealthResponse {
307 status: &'static str,
308 service: &'static str,
309 mode: &'static str,
310 }
311
312 #[derive(Debug, Serialize)]
313 struct TasksResponse {
314 tasks: Vec<TaskSummary>,
315 counts: crate::task_manager::TaskCounts,
316 }
317
318 #[derive(Debug, Deserialize)]
319 struct TasksQuery {
320 limit: Option<usize>,
321 workspace: Option<PathBuf>,
322 }
323
324 #[derive(Debug, Deserialize)]
325 struct ThreadsQuery {
326 limit: Option<usize>,
327 include_archived: Option<bool>,
328 /// When `true`, returns archived threads only (overrides `include_archived`).
329 /// Whalescale#260 / #563.
330 archived_only: Option<bool>,
331 }
332
333 #[derive(Debug, Deserialize)]
334 struct ThreadSummaryQuery {
335 limit: Option<usize>,
336 search: Option<String>,
337 include_archived: Option<bool>,
338 /// When `true`, returns archived threads only (overrides `include_archived`).
339 /// Whalescale#260 / #563.
340 archived_only: Option<bool>,
341 }
342
343 fn resolve_thread_filter(
344 include_archived: Option<bool>,
345 archived_only: Option<bool>,
346 ) -> ThreadListFilter {
347 if archived_only.unwrap_or(false) {
348 ThreadListFilter::ArchivedOnly
349 } else if include_archived.unwrap_or(false) {
350 ThreadListFilter::IncludeArchived
351 } else {
352 ThreadListFilter::ActiveOnly
353 }
354 }
355
356 #[derive(Debug, Serialize)]
357 struct ThreadSummary {
358 id: String,
359 title: String,
360 preview: String,
361 model: String,
362 mode: String,
363 workspace: PathBuf,
364 branch: Option<String>,
365 head: Option<String>,
366 dirty: bool,
367 archived: bool,
368 updated_at: chrono::DateTime<Utc>,
369 latest_turn_id: Option<String>,
370 latest_turn_status: Option<String>,
371 /// Pending approvals plus pending user-input requests in the canonical
372 /// thread snapshot. Clients use this typed fact for attention grouping;
373 /// lifecycle prose and turn-status strings are not an authority signal.
374 pending_attention_count: usize,
375 }
376
377 #[derive(Debug, Serialize)]
378 struct SkillEntry {
379 name: String,
380 description: String,
381 /// Native Skill locator. Reviewed plugin paths are deliberately omitted;
382 /// their bodies are available only through the authority-bound snapshot.
383 path: Option<PathBuf>,
384 source: String,
385 plugin_id: Option<String>,
386 plugin_generation: Option<u64>,
387 plugin_content_hash: Option<String>,
388 enabled: bool,
389 is_bundled: bool,
390 }
391
392 #[derive(Debug, Serialize)]
393 struct SkillsResponse {
394 directory: PathBuf,
395 directories: Vec<PathBuf>,
396 warnings: Vec<String>,
397 skills: Vec<SkillEntry>,
398 }
399
400 #[derive(Debug, Serialize)]
401 struct AgentRunsResponse {
402 runs: Vec<AgentWorkerRecord>,
403 }
404
405 #[derive(Debug, Deserialize)]
406 struct SetSkillEnabledRequest {
407 enabled: bool,
408 }
409
410 #[derive(Debug, Serialize)]
411 struct SetSkillEnabledResponse {
412 name: String,
413 enabled: bool,
414 }
415
416 // ─── Skill lifecycle request/response types ────────────────────────────────
417
418 #[derive(Debug, Deserialize)]
419 struct InstallSkillRequest {
420 /// Remote source spec: `github:owner/repo`, `https://…`, or a registry name.
421 source: String,
422 /// `"project"` or `"global"` (default: `"global"`).
423 #[serde(default)]
424 scope: Option<String>,
425 }
426
427 #[derive(Debug, Deserialize)]
428 struct UpdateSkillRequest {
429 /// `"project"`, `"global"`, or `null` (auto-detect).
430 #[serde(default)]
431 scope: Option<String>,
432 /// Digest the caller observed before requesting the update. The mutation
433 /// will fail if the on-disk digest has changed since.
434 #[serde(default)]
435 expected_digest: Option<String>,
436 }
437
438 #[derive(Debug, Deserialize)]
439 struct UninstallSkillQuery {
440 /// `"project"`, `"global"`, or `null` (auto-detect).
441 #[serde(default)]
442 scope: Option<String>,
443 /// Digest the caller observed. The mutation will fail if it has drifted.
444 #[serde(default)]
445 expected_digest: Option<String>,
446 }
447
448 #[derive(Debug, Deserialize)]
449 struct TrustSkillRequest {
450 /// `"project"`, `"global"`, or `null` (auto-detect).
451 #[serde(default)]
452 scope: Option<String>,
453 /// Digest the caller reviewed. The mutation will fail if it has drifted.
454 #[serde(default)]
455 expected_digest: Option<String>,
456 }
457
458 /// Scope query parameter used by the audit endpoint.
459 #[derive(Debug, Deserialize, Default)]
460 struct SkillScopeQuery {
461 /// `"project"` or `"global"` to restrict to one root.
462 scope: Option<String>,
463 }
464
465 #[derive(Debug, Serialize)]
466 struct SkillMutationReceiptResponse {
467 /// Skill name as recorded by the mutation.
468 name: String,
469 /// Human-readable action performed: `"installed"`, `"updated"`, `"removed"`,
470 /// `"trusted"`, `"no_change"`, etc.
471 outcome: &'static str,
472 /// Resolved install scope: `"project"` or `"global"`.
473 scope: String,
474 /// Display path of the skill package (may be redacted for plugin snapshots).
475 safe_target_path: String,
476 /// Trust advisory note, present only for `"trusted"` outcomes.
477 #[serde(skip_serializing_if = "Option::is_none")]
478 trust_note: Option<&'static str>,
479 }
480
481 /// Read-only audit receipt for a single installed skill.
482 #[derive(Debug, Serialize)]
483 struct SkillAuditEntry {
484 name: String,
485 safe_display_path: String,
486 source_kind: String,
487 scope: String,
488 digest: SkillAuditDigest,
489 trust: String,
490 integrity: String,
491 available_actions: Vec<String>,
492 warnings: Vec<String>,
493 }
494
495 #[derive(Debug, Serialize)]
496 struct SkillAuditDigest {
497 state: String,
498 /// Hex digest value; absent when the digest is unknown.
499 #[serde(skip_serializing_if = "Option::is_none")]
500 value: Option<String>,
501 }
502
503 #[derive(Debug, Serialize)]
504 struct SkillAuditResponse {
505 /// `true` when multiple owned copies with the same name exist. The
506 /// caller should re-request with an explicit `scope` parameter.
507 ambiguous: bool,
508 skills: Vec<SkillAuditEntry>,
509 }
510
511 #[derive(Debug, Deserialize)]
512 struct DecideApprovalBody {
513 decision: String,
514 #[serde(default)]
515 remember: bool,
516 }
517
518 #[derive(Debug, Serialize)]
519 struct DecideApprovalResponse {
520 ok: bool,
521 approval_id: String,
522 decision: String,
523 delivered: bool,
524 }
525
526 #[derive(Debug, Deserialize)]
527 struct SubmitUserInputBody {
528 answers: Vec<UserInputAnswerBody>,
529 }
530
531 #[derive(Debug, Deserialize)]
532 struct UserInputAnswerBody {
533 id: String,
534 label: String,
535 value: String,
536 }
537
538 #[derive(Debug, Serialize)]
539 struct SubmitUserInputResponse {
540 ok: bool,
541 input_id: String,
542 delivered: bool,
543 }
544
545 #[derive(Debug, Serialize)]
546 struct RuntimeInfoResponse {
547 service: &'static str,
548 runtime_api_version: &'static str,
549 codewhale_version: &'static str,
550 /// Full 40-character source commit embedded by the shared build script.
551 /// Desktop compatibility intentionally rejects `unknown` and abbreviated
552 /// values, so source archives without build provenance fail closed.
553 codewhale_commit: &'static str,
554 bind_host: String,
555 port: u16,
556 auth_required: bool,
557 transports: Vec<&'static str>,
558 capabilities: RuntimeCapabilities,
559 account: RuntimeAccountInfo,
560 experimental: RuntimeExperimentalCapabilities,
561 // Backward-compatible alias kept for existing clients.
562 version: &'static str,
563 }
564
565 fn default_runtime_capabilities() -> RuntimeCapabilities {
566 RuntimeCapabilities {
567 account_session: true,
568 threads: true,
569 thread_shell_consent: true,
570 turns: true,
571 turn_operation_idempotency: true,
572 turn_operation_lookup: true,
573 turn_image_inputs: true,
574 turn_output_token_limit: true,
575 turn_steer: true,
576 turn_interrupt: true,
577 event_replay: true,
578 external_tools: true,
579 environments: false,
580 worker_runtime: true,
581 fleet_run_create: true,
582 fleet_run_start: true,
583 fleet_event_replay: true,
584 fleet_event_stream: true,
585 fleet_local_target: true,
586 thread_goals: true,
587 memory: true,
588 mcp_server_management: true,
589 skill_lifecycle: true,
590 plugin_management: true,
591 agent_mail: true,
592 }
593 }
594
595 fn runtime_api_sub_agent_manager(workspace: &FsPath, workers: usize) -> SharedSubAgentManager {
596 let max_agents = workers.max(1);
597 new_shared_subagent_manager_with_timeout(
598 workspace.to_path_buf(),
599 max_agents,
600 max_agents,
601 Duration::from_secs(crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS),
602 max_agents,
603 )
604 }
605
606 #[derive(Debug, Serialize)]
607 struct McpServerEntry {
608 name: String,
609 origin: &'static str,
610 writable: bool,
611 auth_required: bool,
612 enabled: bool,
613 required: bool,
614 command: Option<String>,
615 url: Option<String>,
616 connected: bool,
617 enabled_tools: Vec<String>,
618 disabled_tools: Vec<String>,
619 }
620
621 #[derive(Debug, Serialize)]
622 struct McpServersResponse {
623 revision: String,
624 servers: Vec<McpServerEntry>,
625 }
626
627 #[derive(Debug, Deserialize)]
628 struct McpToolsQuery {
629 server: Option<String>,
630 #[serde(default)]
631 connect: bool,
632 }
633
634 #[derive(Debug, Serialize)]
635 struct McpToolEntry {
636 server: String,
637 name: String,
638 prefixed_name: String,
639 description: Option<String>,
640 input_schema: Value,
641 }
642
643 #[derive(Debug, Serialize)]
644 struct McpToolsResponse {
645 tools: Vec<McpToolEntry>,
646 connections: Vec<McpConnectionOutcome>,
647 }
648
649 #[derive(Debug, Serialize)]
650 struct McpConnectionOutcome {
651 server: String,
652 connected: bool,
653 auth_required: bool,
654 #[serde(skip_serializing_if = "Option::is_none")]
655 error: Option<String>,
656 }
657
658 /// Request body for `POST /v1/apps/mcp/servers` (create) and
659 /// `PATCH /v1/apps/mcp/servers/{name}` (update).
660 ///
661 /// Either `command` **or** `url` must be set on create. On update, only
662 /// supplied fields are applied; absent fields leave the existing value in
663 /// place.
664 #[derive(Debug, Deserialize)]
665 struct McpServerWriteRequest {
666 /// stdio command binary (e.g. `"npx"`).
667 #[serde(default, deserialize_with = "deserialize_present_nullable")]
668 command: Option<Option<String>>,
669 /// Arguments for the stdio command.
670 args: Option<Vec<String>>,
671 /// Environment variables injected into the stdio child process.
672 /// Values are stored as-is; use `${VAR}` syntax to reference environment
673 /// variables at runtime instead of embedding secrets here.
674 env: Option<std::collections::HashMap<String, String>>,
675 /// HTTP(S) endpoint for streamable-HTTP or SSE MCP servers.
676 #[serde(default, deserialize_with = "deserialize_present_nullable")]
677 url: Option<Option<String>>,
678 /// Explicit transport override (`"sse"` or `"streamable_http"`).
679 #[serde(default, deserialize_with = "deserialize_present_nullable")]
680 transport: Option<Option<String>>,
681 /// Override the server-level connect timeout in seconds.
682 #[serde(default, deserialize_with = "deserialize_present_nullable")]
683 connect_timeout: Option<Option<u64>>,
684 /// Override the server-level execute timeout in seconds.
685 #[serde(default, deserialize_with = "deserialize_present_nullable")]
686 execute_timeout: Option<Option<u64>>,
687 /// Override the server-level read timeout in seconds.
688 #[serde(default, deserialize_with = "deserialize_present_nullable")]
689 read_timeout: Option<Option<u64>>,
690 /// Whether the server is enabled. Defaults to `true` on create.
691 enabled: Option<bool>,
692 /// Whether a connection failure for this server is fatal.
693 required: Option<bool>,
694 /// Allowlist of tool names to expose (empty = expose all).
695 enabled_tools: Option<Vec<String>>,
696 /// Denylist of tool names to hide.
697 disabled_tools: Option<Vec<String>>,
698 /// Variable names whose runtime values are injected as HTTP headers.
699 /// The key in this map is the HTTP header name; the value is the
700 /// environment variable whose value supplies the header value at
701 /// request time. Credentials remain in the environment, not on disk.
702 env_headers: Option<std::collections::HashMap<String, String>>,
703 /// Environment variable that contains a bearer token for URL-based servers.
704 #[serde(default, deserialize_with = "deserialize_present_nullable")]
705 bearer_token_env_var: Option<Option<String>>,
706 /// OAuth scopes requested during `codewhale mcp login`.
707 scopes: Option<Vec<String>>,
708 /// RFC 8707 resource parameter for the OAuth authorization URL.
709 #[serde(default, deserialize_with = "deserialize_present_nullable")]
710 oauth_resource: Option<Option<String>>,
711 }
712
713 /// Preserve the difference between an omitted PATCH field and an explicit
714 /// `null`: serde only calls this decoder when the field is present.
715 fn deserialize_present_nullable<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
716 where
717 D: serde::Deserializer<'de>,
718 T: Deserialize<'de>,
719 {
720 Option::<T>::deserialize(deserializer).map(Some)
721 }
722
723 /// Response returned by MCP server management endpoints.
724 ///
725 /// Sensitive fields (`headers`, `env_headers`, `bearer_token_env_var`,
726 /// `env`, OAuth client secrets) are intentionally omitted or redacted so
727 /// the API never echoes credentials back to callers.
728 #[derive(Debug, Serialize)]
729 struct McpServerDetail {
730 revision: String,
731 name: String,
732 credential_configured: bool,
733 origin: &'static str,
734 writable: bool,
735 auth_required: bool,
736 enabled: bool,
737 required: bool,
738 command: Option<String>,
739 args: Vec<String>,
740 /// Environment variable names injected into the process.
741 /// Values are **not** returned — callers see only the keys.
742 env_keys: Vec<String>,
743 url: Option<String>,
744 transport: Option<String>,
745 connect_timeout: Option<u64>,
746 execute_timeout: Option<u64>,
747 read_timeout: Option<u64>,
748 enabled_tools: Vec<String>,
749 disabled_tools: Vec<String>,
750 /// HTTP header names that are read from environment variables.
751 /// The corresponding environment variable values are **not** returned.
752 env_header_keys: Vec<String>,
753 /// Whether a `bearer_token_env_var` is configured (value not returned).
754 has_bearer_token_env_var: bool,
755 scopes: Vec<String>,
756 oauth_resource: Option<String>,
757 /// Live connection state from the in-memory pool (if the pool is active).
758 connected: bool,
759 }
760
761 impl McpServerDetail {
762 fn from_config(
763 name: &str,
764 cfg: &crate::mcp::McpServerConfig,
765 connected: bool,
766 revision: String,
767 ) -> Self {
768 let mut env_keys: Vec<String> = cfg.env.keys().cloned().collect();
769 env_keys.sort();
770 let mut env_header_keys: Vec<String> = cfg.env_headers.keys().cloned().collect();
771 env_header_keys.sort();
772 Self {
773 revision,
774 name: name.to_string(),
775 credential_configured: mcp_credential_configured(cfg),
776 origin: "global",
777 writable: true,
778 auth_required: false,
779 enabled: cfg.is_enabled(),
780 required: cfg.required,
781 command: cfg.command.clone(),
782 args: cfg.args.clone(),
783 env_keys,
784 url: cfg.url.clone(),
785 transport: cfg.transport.clone(),
786 connect_timeout: cfg.connect_timeout,
787 execute_timeout: cfg.execute_timeout,
788 read_timeout: cfg.read_timeout,
789 enabled_tools: cfg.enabled_tools.clone(),
790 disabled_tools: cfg.disabled_tools.clone(),
791 env_header_keys,
792 has_bearer_token_env_var: cfg.bearer_token_env_var.is_some(),
793 scopes: cfg.scopes.clone(),
794 oauth_resource: cfg.oauth_resource.clone(),
795 connected,
796 }
797 }
798 }
799
800 #[derive(Debug, Serialize)]
801 struct McpServerActionReceipt {
802 #[serde(skip_serializing_if = "Option::is_none")]
803 revision: Option<String>,
804 name: String,
805 action: &'static str,
806 ok: bool,
807 #[serde(skip_serializing_if = "Option::is_none")]
808 connection: Option<McpConnectionOutcome>,
809 }
810
811 #[derive(Debug, Deserialize)]
812 struct AutomationRunsQuery {
813 limit: Option<usize>,
814 }
815
816 #[derive(Debug, Deserialize)]
817 struct ThreadEventsQuery {
818 since_seq: Option<u64>,
819 replay_limit: Option<usize>,
820 #[serde(default)]
821 progress: bool,
822 }
823
824 const DEFAULT_FLEET_EVENT_REPLAY_LIMIT: usize = 250;
825 const MAX_FLEET_EVENT_REPLAY_LIMIT: usize = 1_000;
826
827 #[derive(Debug, Deserialize)]
828 #[serde(deny_unknown_fields)]
829 struct CreateFleetRunRequest {
830 #[serde(default)]
831 name: Option<String>,
832 target: FleetRuntimeTarget,
833 roles: Vec<ManagedFleetRoleRequest>,
834 workflow: ManagedFleetWorkflowRequest,
835 #[serde(default, alias = "workers")]
836 worker_specs: Vec<FleetWorkerSpec>,
837 #[serde(default)]
838 labels: BTreeMap<String, String>,
839 #[serde(default)]
840 security_policy: Option<FleetSecurityPolicy>,
841 #[serde(default)]
842 max_workers: Option<usize>,
843 /// Optional run-wide usage ceiling (R6, #5567).
844 #[serde(default)]
845 usage_ceiling: Option<codewhale_protocol::fleet::FleetUsageCeiling>,
846 }
847
848 #[derive(Debug, Deserialize)]
849 #[serde(deny_unknown_fields)]
850 struct ManagedFleetRoleRequest {
851 name: String,
852 #[serde(default)]
853 agent_profile: Option<String>,
854 }
855
856 #[derive(Debug, Deserialize)]
857 #[serde(deny_unknown_fields)]
858 struct ManagedFleetWorkflowRequest {
859 id: String,
860 kind: FleetWorkflowKind,
861 #[serde(alias = "task_specs")]
862 tasks: Vec<FleetTaskSpec>,
863 }
864
865 #[derive(Debug, Deserialize)]
866 struct FleetEventsQuery {
867 after: Option<String>,
868 limit: Option<usize>,
869 }
870
871 #[derive(Debug, Serialize)]
872 struct StartTurnResponse {
873 thread: ThreadRecord,
874 turn: TurnRecord,
875 }
876
877 fn install_runtime_server_workshop_budgets(
878 config: &Config,
879 ) -> crate::tools::large_output_router::WorkshopConfig {
880 crate::tools::large_output_router::WorkshopConfig::install_active(config.workshop.as_ref())
881 }
882
883 fn open_runtime_threads_for_server(
884 config: &Config,
885 workspace: PathBuf,
886 manager_config: RuntimeThreadManagerConfig,
887 plugin_registry: Arc<crate::plugins::PluginRegistry>,
888 ) -> Result<(
889 SharedRuntimeThreadManager,
890 crate::tools::large_output_router::WorkshopConfig,
891 )> {
892 // The Runtime API lazily creates engines after the HTTP/Web server starts.
893 // Install the resolved process-wide read/tool byte limits before the
894 // thread manager can spawn any of those engines, matching interactive and
895 // headless exec startup.
896 let workshop_activation = install_runtime_server_workshop_budgets(config);
897 let manager = Arc::new(RuntimeThreadManager::open_with_plugin_registry(
898 config.clone(),
899 workspace,
900 manager_config,
901 plugin_registry,
902 )?);
903 // Publish the same exact endpoint-scoped catalog as interactive startup
904 // before the server admits turns. A cached model list alone does not make
905 // its capabilities available to route resolution.
906 crate::provider_catalog_live::maybe_load_persisted_cache_for_config(config);
907 Ok((manager, workshop_activation))
908 }
909
910 /// Start the runtime API server.
911 pub async fn run_http_server(
912 config: Config,
913 workspace: PathBuf,
914 plugin_discovery: Arc<crate::plugins::PluginDiscoveryContext>,
915 options: RuntimeApiOptions,
916 ) -> Result<()> {
917 validate_runtime_listener_security(&options)?;
918
919 // Keep the server usable before a local catalog arrives. Omitted API
920 // requests are checked at admission; background tasks keep the auto sentinel.
921 let task_default_model = runtime_request_model(&config, None).unwrap_or_else(|_| "auto".into());
922 let task_cfg = TaskManagerConfig::from_runtime(
923 &config,
924 workspace.clone(),
925 Some(task_default_model),
926 Some(options.workers),
927 );
928 let (runtime_threads, _workshop_activation) = open_runtime_threads_for_server(
929 &config,
930 workspace.clone(),
931 RuntimeThreadManagerConfig::from_task_data_dir(task_cfg.data_dir.clone()),
932 plugin_discovery.registry_for_workspace(&workspace),
933 )?;
934 let task_manager =
935 TaskManager::start_with_runtime_manager(task_cfg, config.clone(), runtime_threads.clone())
936 .await?;
937 let _task_shutdown = task_manager.shutdown_guard();
938 let mut automation_service = AutomationManager::default_location()?;
939 automation_service.bind_task_manager(&task_manager)?;
940 let automations = Arc::new(Mutex::new(automation_service));
941 runtime_threads.attach_automation_manager(automations.clone());
942 let scheduler_cancel = CancellationToken::new();
943 let scheduler_handle = spawn_scheduler(
944 automations.clone(),
945 task_manager.clone(),
946 scheduler_cancel.clone(),
947 AutomationSchedulerConfig::default(),
948 );
949
950 let sessions_dir = default_sessions_dir().unwrap_or_else(|_| fallback_sessions_dir());
951 let runtime_token_env = runtime_token_environment(&|name| std::env::var(name).ok());
952 let runtime_token_alias_warning =
953 runtime_token_alias_warning(options.auth_token.as_deref(), &runtime_token_env);
954 let resolved_auth = resolve_runtime_auth(
955 options.auth_token.clone(),
956 runtime_token_env.token,
957 options.insecure_no_auth,
958 );
959 let runtime_token = resolved_auth.token.clone();
960 let auth_enabled = runtime_token.is_some();
961 let (web, web_bootstrap) = if options.web {
962 runtime_token
963 .as_ref()
964 .context("Codewhale web requires a Runtime authentication token")?;
965 let (web, bootstrap) = web::RuntimeWebState::new();
966 (Some(web), Some(bootstrap))
967 } else {
968 (None, None)
969 };
970 let (mobile, mobile_bootstrap) = if options.mobile && auth_enabled {
971 let (mobile, bootstrap) = mobile::RuntimeMobileState::new();
972 (Some(mobile), Some(bootstrap))
973 } else {
974 (None, None)
975 };
976 let skill_state = SkillStateStore::load_default()
977 .context("load persistent Skill activation state for Runtime API")?;
978 let sub_agent_manager = runtime_api_sub_agent_manager(&workspace, options.workers);
979 let state = RuntimeApiState {
980 config: Arc::new(parking_lot::RwLock::new(config.clone())),
981 workspace,
982 plugin_discovery,
983 task_manager: task_manager.clone(),
984 runtime_threads,
985 cors_origins: options.cors_origins.clone(),
986 sessions_dir,
987 config_path: options.config_path.clone(),
988 config_profile: options.config_profile.clone(),
989 automations,
990 sub_agent_manager,
991 runtime_token: runtime_token.clone(),
992 skill_state: Arc::new(Mutex::new(skill_state)),
993 auth_required: auth_enabled,
994 bind_host: options.host.clone(),
995 bind_port: options.port,
996 mobile_enabled: options.mobile,
997 mobile,
998 web,
999 fleet_codewhale_binary: configured_codewhale_binary(),
1000 mcp_pool: Arc::new(Mutex::new(None)),
1001 lsp_manager: Arc::new(std::sync::OnceLock::new()),
1002 #[cfg(test)]
1003 compat_stream_test_hook: None,
1004 };
1005 let app = build_router(state);
1006
1007 let addr = runtime_bind_address(&options.host, options.port)?;
1008 let listener = TcpListener::bind(addr)
1009 .await
1010 .with_context(|| format!("Failed to bind {addr}"))?;
1011
1012 let bound_addr = listener
1013 .local_addr()
1014 .context("Failed to read Runtime API listener address")?;
1015 println!("Runtime API listening on http://{bound_addr}");
1016 for line in runtime_auth_status_lines(&resolved_auth) {
1017 println!("{line}");
1018 }
1019 if let Some(warning) = runtime_token_alias_warning {
1020 println!("{warning}");
1021 }
1022 if options.mobile {
1023 print_mobile_urls(
1024 bound_addr,
1025 auth_enabled,
1026 resolved_auth.generated,
1027 options.show_qr,
1028 mobile_bootstrap.as_deref(),
1029 );
1030 }
1031 if let Some(bootstrap) = web_bootstrap {
1032 println!("Codewhale web enabled at http://{bound_addr}/");
1033 let bootstrap_url = web::bootstrap_url(bound_addr, &bootstrap);
1034 println!(
1035 "Codewhale web bootstrap (single-use, expires in {} min): {bootstrap_url}",
1036 web::BOOTSTRAP_TTL.as_secs() / 60
1037 );
1038 if let Some(warning) = web_launcher_warning(crate::utils::open_url(&bootstrap_url)) {
1039 println!("{warning}");
1040 }
1041 }
1042 let is_loopback = is_loopback_bind_host(&options.host);
1043 if is_loopback {
1044 println!("Security: this server is local-first. Do not expose it to untrusted networks.");
1045 } else {
1046 println!(
1047 "Security: bound to {host}; reachable from any peer that can route to this address.",
1048 host = options.host
1049 );
1050 if !auth_enabled {
1051 println!(
1052 " WARNING: auth is disabled. Anyone on the network can call /v1/* without authentication."
1053 );
1054 }
1055 println!(
1056 " /v1/runtime/info reports bind_host={host:?}, port={port}, auth_required={auth}.",
1057 host = options.host,
1058 port = options.port,
1059 auth = auth_enabled,
1060 );
1061 }
1062 let serve_result = axum::serve(
1063 listener,
1064 app.into_make_service_with_connect_info::<SocketAddr>(),
1065 )
1066 .await
1067 .map_err(|e| anyhow!("Runtime API server error: {e}"));
1068 scheduler_cancel.cancel();
1069 scheduler_handle.abort();
1070 task_manager.shutdown_and_wait().await?;
1071 serve_result
1072 }
1073
1074 /// Mobile control uses plain HTTP only on loopback. It has no TLS or verified
1075 /// overlay transport, so a non-loopback listener would expose the Runtime API
1076 /// to peers that can observe or replay browser traffic.
1077 fn validate_runtime_listener_security(options: &RuntimeApiOptions) -> Result<()> {
1078 if options.port == 0 {
1079 bail!("Port must be > 0");
1080 }
1081 if options.web && options.host != "127.0.0.1" {
1082 bail!("Codewhale web is loopback-only and must bind to 127.0.0.1");
1083 }
1084 if options.web && options.insecure_no_auth {
1085 bail!("Codewhale web requires Runtime authentication; remove --insecure");
1086 }
1087 if options.mobile && !is_loopback_bind_host(&options.host) {
1088 bail!(
1089 "Codewhale mobile is loopback-only without TLS or a verified overlay; bind to 127.0.0.1 or ::1"
1090 );
1091 }
1092 if options.insecure_no_auth && !is_loopback_bind_host(&options.host) {
1093 bail!(
1094 "Unauthenticated Runtime access is loopback-only; remove --insecure or bind to 127.0.0.1 or ::1"
1095 );
1096 }
1097 Ok(())
1098 }
1099
1100 fn is_loopback_bind_host(host: &str) -> bool {
1101 host.parse::<IpAddr>()
1102 .is_ok_and(|address| address.is_loopback())
1103 }
1104
1105 fn runtime_bind_address(host: &str, port: u16) -> Result<SocketAddr> {
1106 let address = match host.parse::<IpAddr>() {
1107 Ok(IpAddr::V6(_)) => format!("[{host}]:{port}"),
1108 _ => format!("{host}:{port}"),
1109 };
1110 address
1111 .parse()
1112 .with_context(|| format!("Invalid bind address '{host}:{port}'"))
1113 }
1114
1115 fn web_launcher_warning(result: Result<()>) -> Option<String> {
1116 result.err().map(|error| {
1117 format!(
1118 "warning: could not open the default browser ({error}); open the bootstrap URL above manually"
1119 )
1120 })
1121 }
1122
1123 fn fallback_sessions_dir() -> PathBuf {
1124 if let Some(home) = codewhale_paths::codewhale_home_override().ok().flatten() {
1125 return home.join("sessions");
1126 }
1127 codewhale_paths::legacy_deepseek_home()
1128 .unwrap_or_else(|| PathBuf::from(codewhale_paths::LEGACY_APP_DIR))
1129 .join("sessions")
1130 }
1131
1132 pub fn build_router(state: RuntimeApiState) -> Router {
1133 diagnostics::mark_server_started();
1134 let api_routes = Router::new()
1135 .route(
1136 "/v1/sessions",
1137 get(list_sessions)
1138 .post(create_session_from_thread)
1139 .put(save_current_session),
1140 )
1141 .route("/v1/sessions/summary", get(list_sessions_summary))
1142 .route(
1143 "/v1/sessions/{id}",
1144 get(get_session).patch(patch_session).delete(delete_session),
1145 )
1146 .route(
1147 "/v1/sessions/{id}/resume-thread",
1148 post(resume_session_thread),
1149 )
1150 .route("/v1/sessions/{id}/artifacts", get(list_session_artifacts))
1151 .route(
1152 "/v1/sessions/{id}/artifacts/{artifact_id}",
1153 get(read_session_artifact),
1154 )
1155 .route("/v1/workspace/status", get(workspace_status))
1156 .route("/v1/workspace/files/search", get(workspace_file_search))
1157 .route(
1158 "/v1/workspace/files",
1159 get(workspace_files_list)
1160 .put(workspace_file_write)
1161 .layer(DefaultBodyLimit::max(
1162 self::workspace::FILE_WRITE_BODY_LIMIT_BYTES,
1163 )),
1164 )
1165 .route("/v1/workspace/files/read", get(workspace_file_read))
1166 .route("/v1/workspace/instructions", get(workspace_instructions))
1167 .route("/v1/agent-runs", get(list_agent_runs))
1168 .route("/v1/agent-runs/{run_id}", get(get_agent_run))
1169 .route("/v1/fleet/profiles", get(list_fleet_profiles))
1170 .route(
1171 "/v1/fleet/runs",
1172 get(list_fleet_runs).post(create_fleet_run),
1173 )
1174 .route("/v1/fleet/runs/{run_id}", get(get_fleet_run))
1175 .route(
1176 "/v1/fleet/runs/{run_id}/workers",
1177 get(list_fleet_run_workers),
1178 )
1179 .route("/v1/fleet/runs/{run_id}/start", post(start_fleet_run))
1180 .route("/v1/fleet/runs/{run_id}/events", get(stream_fleet_events))
1181 .route(
1182 "/v1/fleet/runs/{run_id}/events/replay",
1183 get(replay_fleet_events),
1184 )
1185 .route("/v1/fleet/runs/{run_id}/stop", post(stop_fleet_run))
1186 .route(
1187 "/v1/fleet/runs/{run_id}/receipts",
1188 get(list_fleet_run_receipts),
1189 )
1190 .route(
1191 "/v1/fleet/runs/{run_id}/receipts/{task_id}",
1192 get(get_fleet_run_receipt),
1193 )
1194 .route(
1195 "/v1/fleet/runs/{run_id}/receipts/{task_id}/evidence",
1196 get(inspect_fleet_run_receipt_evidence),
1197 )
1198 .route("/v1/fleet/workers/{worker_id}", get(get_fleet_worker))
1199 .route(
1200 "/v1/fleet/workers/{worker_id}/interrupt",
1201 post(interrupt_fleet_worker),
1202 )
1203 .route(
1204 "/v1/fleet/workers/{worker_id}/stop",
1205 post(stop_fleet_worker),
1206 )
1207 .route(
1208 "/v1/fleet/workers/{worker_id}/restart",
1209 post(restart_fleet_worker),
1210 )
1211 .route(
1212 "/v1/stream",
1213 post(stream_turn).layer(DefaultBodyLimit::max(
1214 codewhale_protocol::runtime::MAX_RUNTIME_IMAGE_BODY_BYTES,
1215 )),
1216 )
1217 .route("/v1/git", get(git::git_status_detail))
1218 .route("/v1/changes", get(git::git_changes))
1219 .route("/v1/diff", get(git::git_diff))
1220 .route("/v1/workspace/diff", get(git::workspace_diff))
1221 .route("/v1/git/graph", get(git::git_graph))
1222 .route("/v1/git/stage", post(git::git_stage))
1223 .route("/v1/git/unstage", post(git::git_unstage))
1224 .route("/v1/git/discard", post(git::git_discard))
1225 .route("/v1/git/commit", post(git::git_commit))
1226 .route("/v1/git/push", post(git::git_push))
1227 .route("/v1/git/branch", post(git::git_branch))
1228 .route("/v1/logs", get(diagnostics::list_logs))
1229 .route("/v1/logs/{name}", get(diagnostics::read_log))
1230 .route("/v1/crashes", get(diagnostics::list_crashes))
1231 .route("/v1/crashes/{name}", get(diagnostics::read_crash))
1232 .route("/v1/process", get(diagnostics::process_info))
1233 .route("/v1/jobs", get(jobs::list_jobs))
1234 .route("/v1/threads", get(list_threads).post(create_thread))
1235 .route("/v1/threads/summary", get(list_threads_summary))
1236 .route("/v1/threads/running", get(list_running_threads))
1237 .route("/v1/threads/{id}/notices", get(list_thread_notices))
1238 .route(
1239 "/v1/threads/{id}/notices/{notice_id}",
1240 delete(ack_thread_notice),
1241 )
1242 .route("/v1/threads/{id}", get(get_thread).patch(update_thread))
1243 .route(
1244 "/v1/threads/{id}/jobs",
1245 get(jobs::list_thread_jobs).post(jobs::create_thread_job),
1246 )
1247 .route("/v1/threads/{id}/jobs/{job_id}", get(jobs::get_thread_job))
1248 .route(
1249 "/v1/threads/{id}/jobs/{job_id}/output",
1250 get(jobs::get_thread_job_output),
1251 )
1252 .route(
1253 "/v1/threads/{id}/jobs/{job_id}/stdin",
1254 post(jobs::write_thread_job_stdin),
1255 )
1256 .route(
1257 "/v1/threads/{id}/jobs/{job_id}/kill",
1258 post(jobs::kill_thread_job),
1259 )
1260 .route(
1261 "/v1/threads/{id}/jobs/{job_id}/resize",
1262 post(jobs::resize_thread_job),
1263 )
1264 .route("/v1/threads/{id}/context", get(context::get_thread_context))
1265 .route("/v1/threads/{id}/plan", get(plans::get_thread_plan))
1266 .route("/v1/threads/{id}/todo", get(plans::get_thread_todo))
1267 .route("/v1/plan", get(plans::latest_plan))
1268 .route("/v1/todo", get(plans::latest_todo_route))
1269 .route("/v1/plans", get(plans::list_plans))
1270 .route("/v1/todos", get(plans::list_todos))
1271 .route(
1272 "/v1/targets",
1273 get(targets::list_targets).post(targets::create_target),
1274 )
1275 .route("/v1/targets/switch", post(targets::switch_target))
1276 .route("/v1/remote", get(targets::remote_status))
1277 .route("/v1/remote/connect", post(targets::remote_connect))
1278 .route(
1279 "/v1/ssh",
1280 get(targets::ssh_status).post(targets::ssh_connect),
1281 )
1282 .route("/v1/ssh/connect", post(targets::ssh_connect))
1283 .route(
1284 "/v1/cloud",
1285 get(targets::cloud_status).post(targets::cloud_attach),
1286 )
1287 .route("/v1/cloud/attach", post(targets::cloud_attach))
1288 .route("/v1/lsp", get(lsp::lsp_status))
1289 .route("/v1/diagnostics", get(lsp::lsp_diagnostics))
1290 .route("/v1/definition", get(lsp::lsp_definition))
1291 .route("/v1/references", get(lsp::lsp_references))
1292 .route("/v1/symbols", get(lsp::lsp_symbols))
1293 .route("/v1/voice", get(voice::voice_status))
1294 .route("/v1/voice/dictate", post(voice::voice_dictate))
1295 .route("/v1/voice/send", post(voice::voice_send))
1296 .route("/v1/voice/control", post(voice::voice_control))
1297 .route("/v1/threads/{id}/resume", post(resume_thread))
1298 .route("/v1/threads/{id}/fork", post(fork_thread))
1299 .route("/v1/threads/{id}/undo", post(undo_thread_turn))
1300 .route("/v1/threads/{id}/patch-undo", post(patch_undo_thread_turn))
1301 .route("/v1/threads/{id}/file-revert", post(revert_thread_file))
1302 .route("/v1/threads/{id}/retry", post(retry_thread_turn))
1303 .route(
1304 "/v1/threads/{id}/turn-operations/{operation_key}",
1305 get(get_thread_turn_operation),
1306 )
1307 .route(
1308 "/v1/threads/{id}/turns",
1309 post(start_thread_turn).layer(DefaultBodyLimit::max(
1310 codewhale_protocol::runtime::MAX_RUNTIME_IMAGE_BODY_BYTES,
1311 )),
1312 )
1313 .route(
1314 "/v1/threads/{id}/turns/{turn_id}/steer",
1315 post(steer_thread_turn),
1316 )
1317 .route(
1318 "/v1/threads/{id}/turns/{turn_id}/interrupt",
1319 post(interrupt_thread_turn),
1320 )
1321 .route(
1322 "/v1/threads/{id}/turns/{turn_id}/tool-calls/{call_id}/result",
1323 post(deliver_dynamic_tool_result),
1324 )
1325 .route("/v1/threads/{id}/compact", post(compact_thread))
1326 .route("/v1/threads/{id}/usage", get(get_thread_usage))
1327 .route("/v1/threads/{id}/events", get(stream_thread_events))
1328 .route("/v1/agent-mail", post(send_agent_mail))
1329 .route("/v1/threads/{id}/agent-mail", get(list_agent_mail))
1330 .route(
1331 "/v1/threads/{id}/agent-mail/{message_id}/deliver",
1332 post(deliver_agent_mail),
1333 )
1334 .route(
1335 "/v1/threads/{id}/agent-mail/{message_id}/read",
1336 post(mark_agent_mail_read),
1337 )
1338 .route(
1339 "/v1/threads/{id}/agent-mail/{message_id}/cancel",
1340 post(cancel_agent_mail),
1341 )
1342 .route(
1343 "/v1/threads/{id}/goal",
1344 get(get_thread_goal)
1345 .put(upsert_thread_goal)
1346 .delete(delete_thread_goal),
1347 )
1348 .route("/v1/threads/{id}/goal/complete", post(complete_thread_goal))
1349 .route("/v1/threads/{id}/goal/block", post(block_thread_goal))
1350 .route("/v1/approvals", get(list_approvals))
1351 .route("/v1/approvals/{approval_id}", post(decide_approval))
1352 .route(
1353 "/v1/user-input/{thread_id}/{input_id}",
1354 post(submit_user_input),
1355 )
1356 .route("/v1/tasks", get(list_tasks).post(create_task))
1357 .route("/v1/tasks/{id}", get(get_task))
1358 .route("/v1/tasks/{id}/cancel", post(cancel_task))
1359 .route("/v1/skills", get(list_skills))
1360 .route("/v1/commands", get(list_commands))
1361 .route(
1362 "/v1/skills/{name}",
1363 post(set_skill_enabled).delete(uninstall_skill_api),
1364 )
1365 .route(
1366 "/v1/apps/mcp/imports",
1367 get(mcp_import::preview).post(mcp_import::apply),
1368 )
1369 .route(
1370 "/v1/apps/mcp/servers",
1371 get(list_mcp_servers).post(create_mcp_server),
1372 )
1373 .route(
1374 "/v1/apps/mcp/servers/{name}",
1375 get(get_mcp_server)
1376 .patch(update_mcp_server)
1377 .delete(delete_mcp_server),
1378 )
1379 .route(
1380 "/v1/apps/mcp/servers/{name}/enable",
1381 post(enable_mcp_server),
1382 )
1383 .route(
1384 "/v1/apps/mcp/servers/{name}/disable",
1385 post(disable_mcp_server),
1386 )
1387 .route(
1388 "/v1/apps/mcp/servers/{name}/reconnect",
1389 post(reconnect_mcp_server),
1390 )
1391 .route("/v1/skills/install", post(install_skill_api))
1392 .route("/v1/skills/{name}/update", post(update_skill_api))
1393 .route("/v1/skills/{name}/trust", post(trust_skill_api))
1394 .route("/v1/skills/{name}/audit", get(audit_skill_api))
1395 .route("/v1/apps/mcp/tools", get(list_mcp_tools))
1396 .route("/v1/apps/plugins", get(plugins::list_plugins))
1397 .route(
1398 "/v1/apps/plugins/install",
1399 post(plugins::install_plugin_api),
1400 )
1401 .route(
1402 "/v1/apps/plugins/{selector}",
1403 get(plugins::get_plugin).delete(plugins::uninstall_plugin_api),
1404 )
1405 .route(
1406 "/v1/apps/plugins/{selector}/update",
1407 post(plugins::update_plugin_api),
1408 )
1409 .route(
1410 "/v1/apps/plugins/{selector}/trust",
1411 post(plugins::trust_plugin_api),
1412 )
1413 .route(
1414 "/v1/apps/plugins/{selector}/enable",
1415 post(plugins::enable_plugin_api),
1416 )
1417 .route(
1418 "/v1/apps/plugins/{selector}/disable",
1419 post(plugins::disable_plugin_api),
1420 )
1421 .route(
1422 "/v1/apps/plugins/{selector}/revoke",
1423 post(plugins::revoke_plugin_api),
1424 )
1425 .route(
1426 "/v1/apps/marketplaces",
1427 get(plugins::list_marketplaces).post(plugins::add_marketplace),
1428 )
1429 .route(
1430 "/v1/apps/marketplaces/{name}",
1431 get(plugins::get_marketplace).delete(plugins::remove_marketplace),
1432 )
1433 .route(
1434 "/v1/apps/marketplaces/{name}/install",
1435 post(plugins::install_marketplace_candidate_api),
1436 )
1437 .route(
1438 "/v1/automations",
1439 get(list_automations).post(create_automation),
1440 )
1441 .route(
1442 "/v1/automations/{id}",
1443 get(get_automation)
1444 .patch(update_automation)
1445 .delete(delete_automation),
1446 )
1447 .route("/v1/automations/{id}/run", post(run_automation))
1448 .route("/v1/automations/{id}/pause", post(pause_automation))
1449 .route("/v1/automations/{id}/resume", post(resume_automation))
1450 .route("/v1/automations/{id}/runs", get(list_automation_runs))
1451 .route(
1452 "/v1/operate",
1453 get(get_operate).post(start_operate).patch(patch_operate),
1454 )
1455 .route("/v1/operate/keepalive", post(keepalive_operate))
1456 .route("/v1/operate/plan", put(put_operate_plan))
1457 .route("/v1/operate/cancel", post(cancel_operate))
1458 .route("/v1/operate/stop", post(cancel_operate))
1459 .route(
1460 "/v1/operate/auto-merge/check",
1461 post(check_operate_auto_merge),
1462 )
1463 .route("/v1/usage", get(get_usage))
1464 .route("/v1/snapshots", get(list_snapshots))
1465 .route("/v1/snapshots/{id}/restore", post(restore_snapshot))
1466 .route(
1467 "/v1/account/model-access",
1468 get(secrets::get_account_model_access)
1469 .put(secrets::set_account_model_access)
1470 .delete(secrets::clear_account_model_access)
1471 .layer(DefaultBodyLimit::max(
1472 secrets::PROVIDER_KEY_BODY_LIMIT_BYTES,
1473 )),
1474 )
1475 .route("/v1/providers", get(list_providers))
1476 .route("/v1/providers/{id}/models", get(list_provider_models))
1477 .route(
1478 "/v1/providers/{id}/models/refresh",
1479 post(refresh_provider_models),
1480 )
1481 .route("/v1/providers/{id}/switch", post(switch_provider))
1482 .route(
1483 "/v1/providers/{id}/key",
1484 put(secrets::set_provider_key)
1485 .delete(secrets::clear_provider_key)
1486 .layer(DefaultBodyLimit::max(
1487 secrets::PROVIDER_KEY_BODY_LIMIT_BYTES,
1488 )),
1489 )
1490 .route("/v1/config", get(get_config).post(set_config))
1491 .route("/v1/config/reload", post(reload_config))
1492 .route("/v1/settings/schema", get(get_settings_schema))
1493 .route(
1494 "/v1/threads/{id}/notifications/prepare",
1495 post(notification_delivery::prepare),
1496 )
1497 .route(
1498 "/v1/memory",
1499 get(list_memory)
1500 .post(create_memory_entry)
1501 .delete(clear_memory),
1502 )
1503 .route("/v1/memory/{id}", get(get_memory_entry))
1504 .merge(memory_lens::routes())
1505 .route_layer(middleware::from_fn_with_state(
1506 state.clone(),
1507 require_runtime_token,
1508 ));
1509
1510 Router::new()
1511 .route("/", get(web::web_page))
1512 .route("/assets/codewhale-web.css", get(web::web_styles))
1513 .route("/assets/codewhale-web.js", get(web::web_script))
1514 .route("/assets/codewhale-192.png", get(web::web_icon))
1515 .route(
1516 "/__codewhale/bootstrap/{nonce}",
1517 get(web::exchange_bootstrap),
1518 )
1519 .route(
1520 "/__codewhale/mobile/bootstrap/{nonce}",
1521 get(exchange_mobile_bootstrap),
1522 )
1523 .route("/__codewhale/mobile/session", post(exchange_mobile_session))
1524 .route(
1525 "/__codewhale/mobile/stream-ticket",
1526 post(refresh_mobile_stream_ticket),
1527 )
1528 .route("/health", get(health))
1529 .route("/mobile", get(mobile_page))
1530 .route("/mobile/", get(mobile_page))
1531 .route("/v1/runtime/info", get(runtime_info))
1532 .merge(api_routes)
1533 .layer(cors_layer(&state.cors_origins))
1534 .with_state(state)
1535 }
1536
1537 async fn mobile_page(State(state): State<RuntimeApiState>, req: Request) -> Response {
1538 if !state.mobile_enabled {
1539 return (
1540 StatusCode::NOT_FOUND,
1541 "mobile control is disabled; start with `codewhale serve --mobile`",
1542 )
1543 .into_response();
1544 }
1545 let _ = req;
1546 let mut response = Html(MOBILE_HTML).into_response();
1547 secure_mobile_response(&mut response);
1548 response
1549 }
1550
1551 #[derive(Serialize)]
1552 struct MobileSessionResponse {
1553 request_proof: String,
1554 stream_ticket: String,
1555 session_expires_in_seconds: u64,
1556 stream_ticket_expires_in_seconds: u64,
1557 }
1558
1559 async fn exchange_mobile_bootstrap(
1560 State(state): State<RuntimeApiState>,
1561 ConnectInfo(peer): ConnectInfo<SocketAddr>,
1562 Path(nonce): Path<String>,
1563 ) -> Response {
1564 let Some(mobile_state) = state.mobile.as_ref() else {
1565 return mobile_not_found();
1566 };
1567 let session = match mobile_state.consume_bootstrap(&nonce, peer.ip()) {
1568 Ok(session) => session,
1569 Err(mobile::BootstrapError::NonLoopback) => {
1570 return secured_mobile_text(StatusCode::FORBIDDEN, "bootstrap unavailable");
1571 }
1572 Err(mobile::BootstrapError::Invalid | mobile::BootstrapError::Expired) => {
1573 return secured_mobile_text(StatusCode::UNAUTHORIZED, "bootstrap unavailable");
1574 }
1575 };
1576
1577 let location = format!(
1578 "/mobile#request_proof={}&stream_ticket={}",
1579 session.request_proof, session.stream_ticket
1580 );
1581 let cookie = mobile::mobile_session_cookie(&session.session_cookie);
1582 let mut response = (StatusCode::SEE_OTHER, "").into_response();
1583 response.headers_mut().insert(
1584 header::LOCATION,
1585 HeaderValue::from_str(&location).expect("generated mobile fragment is a valid header"),
1586 );
1587 response.headers_mut().insert(
1588 header::SET_COOKIE,
1589 HeaderValue::from_str(&cookie).expect("generated mobile cookie is a valid header"),
1590 );
1591 secure_mobile_response(&mut response);
1592 response
1593 }
1594
1595 async fn exchange_mobile_session(State(state): State<RuntimeApiState>, req: Request) -> Response {
1596 let Some(mobile_state) = state.mobile.as_ref() else {
1597 return mobile_not_found();
1598 };
1599 let Some(expected) = state.runtime_token.as_deref() else {
1600 return mobile_not_found();
1601 };
1602 if !auth::request_has_header_runtime_token(&req, expected) {
1603 return mobile_unauthorized();
1604 }
1605 mobile_session_response(mobile_state.issue_session())
1606 }
1607
1608 async fn refresh_mobile_stream_ticket(
1609 State(state): State<RuntimeApiState>,
1610 req: Request,
1611 ) -> Response {
1612 let Some(mobile_state) = state.mobile.as_ref() else {
1613 return mobile_not_found();
1614 };
1615 if !auth::mobile_session_request_is_authorized(&req, &state, mobile_state) {
1616 return mobile_unauthorized();
1617 }
1618 let ticket = mobile_state.refresh_stream_ticket(
1619 req.headers()
1620 .get(header::COOKIE)
1621 .and_then(|value| value.to_str().ok()),
1622 req.headers()
1623 .get(mobile::MOBILE_REQUEST_HEADER)
1624 .and_then(|value| value.to_str().ok()),
1625 );
1626 let Some(ticket) = ticket else {
1627 return mobile_unauthorized();
1628 };
1629 let mut response = Json(json!({
1630 "stream_ticket": ticket.ticket,
1631 "expires_in_seconds": ticket.expires_in_seconds,
1632 }))
1633 .into_response();
1634 secure_mobile_response(&mut response);
1635 response
1636 }
1637
1638 fn mobile_session_response(session: mobile::MobileSessionBootstrap) -> Response {
1639 let cookie = mobile::mobile_session_cookie(&session.session_cookie);
1640 let mut response = Json(MobileSessionResponse {
1641 request_proof: session.request_proof,
1642 stream_ticket: session.stream_ticket,
1643 session_expires_in_seconds: session.session_ttl_seconds,
1644 stream_ticket_expires_in_seconds: session.stream_ticket_ttl_seconds,
1645 })
1646 .into_response();
1647 response.headers_mut().insert(
1648 header::SET_COOKIE,
1649 HeaderValue::from_str(&cookie).expect("generated mobile cookie is a valid header"),
1650 );
1651 secure_mobile_response(&mut response);
1652 response
1653 }
1654
1655 fn mobile_not_found() -> Response {
1656 secured_mobile_text(StatusCode::NOT_FOUND, "not found")
1657 }
1658
1659 fn mobile_unauthorized() -> Response {
1660 let mut response = auth::runtime_token_required_response();
1661 secure_mobile_response(&mut response);
1662 response
1663 }
1664
1665 fn secured_mobile_text(status: StatusCode, body: &'static str) -> Response {
1666 let mut response = (status, body).into_response();
1667 secure_mobile_response(&mut response);
1668 response
1669 }
1670
1671 fn secure_mobile_response(response: &mut Response) {
1672 let headers = response.headers_mut();
1673 headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
1674 headers.insert(
1675 header::CONTENT_SECURITY_POLICY,
1676 HeaderValue::from_static(
1677 "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'",
1678 ),
1679 );
1680 headers.insert(
1681 header::X_CONTENT_TYPE_OPTIONS,
1682 HeaderValue::from_static("nosniff"),
1683 );
1684 headers.insert(
1685 header::REFERRER_POLICY,
1686 HeaderValue::from_static("no-referrer"),
1687 );
1688 }
1689
1690 fn print_mobile_urls(
1691 addr: SocketAddr,
1692 auth_enabled: bool,
1693 generated_auth: bool,
1694 show_qr: bool,
1695 mobile_bootstrap: Option<&str>,
1696 ) {
1697 println!("Mobile control page enabled.");
1698
1699 let url = format!("http://{addr}/mobile");
1700 println!(" URL: {url}");
1701 if auth_enabled {
1702 if let Some(bootstrap) = mobile_bootstrap {
1703 let bootstrap_url = mobile::bootstrap_url(addr, bootstrap);
1704 println!(
1705 " Bootstrap (single-use, expires in {} min): {bootstrap_url}",
1706 mobile::BOOTSTRAP_TTL.as_secs() / 60
1707 );
1708 } else if generated_auth {
1709 println!(
1710 " Auth uses an unprinted generated token; open the bootstrap URL printed above."
1711 );
1712 } else {
1713 println!(
1714 " Use the bootstrap URL; the page also supports one-time bearer entry without storing it."
1715 );
1716 }
1717 }
1718 println!(
1719 "Mobile security: loopback-only; no LAN/VPN device access without a verified transport boundary."
1720 );
1721
1722 if show_qr {
1723 println!(" QR is loopback-only and cannot pair another device.");
1724 match qrcode::QrCode::new(url.as_bytes()) {
1725 Ok(qr) => {
1726 let qr_str = qr.render::<qrcode::render::unicode::Dense1x2>().build();
1727 println!("\n{qr_str}");
1728 }
1729 Err(e) => {
1730 eprintln!("Warning: could not generate QR code: {e}");
1731 }
1732 }
1733 }
1734 }
1735
1736 async fn health() -> Json<HealthResponse> {
1737 Json(HealthResponse {
1738 status: "ok",
1739 service: "codewhale-runtime-api",
1740 mode: "local",
1741 })
1742 }
1743
1744 fn runtime_request_model(config: &Config, requested: Option<&str>) -> Result<String, ApiError> {
1745 if let Some(model) = requested {
1746 return Ok(model.to_string());
1747 }
1748 let provider = config.api_provider();
1749 let model = provider_default_model_for_api(config, provider, provider);
1750 if model.is_empty() {
1751 return Err(ApiError::bad_request(
1752 "The active provider has no available default model; refresh its catalog or select an explicit model.",
1753 ));
1754 }
1755 Ok(model)
1756 }
1757
1758 async fn create_task(
1759 State(state): State<RuntimeApiState>,
1760 Json(mut req): Json<NewTaskRequest>,
1761 ) -> Result<(StatusCode, Json<TaskRecord>), ApiError> {
1762 if req.prompt.trim().is_empty() {
1763 return Err(ApiError::bad_request("prompt is required"));
1764 }
1765 if req.workspace.is_none() {
1766 req.workspace = Some(state.workspace.clone());
1767 }
1768 if req.model.is_none() && req.model_provider.is_none() && req.model_provider_id.is_none() {
1769 req.model = Some(runtime_request_model(&state.config.read(), None)?);
1770 }
1771 let task = state
1772 .task_manager
1773 .add_task(req)
1774 .await
1775 .map_err(|e| ApiError::bad_request(e.to_string()))?;
1776 Ok((StatusCode::CREATED, Json(task)))
1777 }
1778
1779 async fn create_thread(
1780 State(state): State<RuntimeApiState>,
1781 Json(mut req): Json<CreateThreadRequest>,
1782 ) -> Result<(StatusCode, Json<ThreadRecord>), ApiError> {
1783 if req.workspace.is_none() {
1784 req.workspace = Some(state.workspace.clone());
1785 }
1786 if req.mode.as_ref().is_none_or(|m| m.trim().is_empty()) {
1787 req.mode = Some("agent".to_string());
1788 }
1789
1790 let thread = state
1791 .runtime_threads
1792 .create_thread(req)
1793 .await
1794 .map_err(|e| ApiError::bad_request(e.to_string()))?;
1795 Ok((StatusCode::CREATED, Json(thread)))
1796 }
1797
1798 async fn list_threads(
1799 State(state): State<RuntimeApiState>,
1800 Query(query): Query<ThreadsQuery>,
1801 ) -> Result<Json<Vec<ThreadRecord>>, ApiError> {
1802 let filter = resolve_thread_filter(query.include_archived, query.archived_only);
1803 let threads = state
1804 .runtime_threads
1805 .list_threads(filter, query.limit)
1806 .await
1807 .map_err(|e| ApiError::internal(e.to_string()))?;
1808 Ok(Json(threads))
1809 }
1810
1811 /// Threads with queued or in-progress turns, for quit/background
1812 /// accounting (#6180). One call, no inference from latest-turn status.
1813 async fn list_running_threads(
1814 State(state): State<RuntimeApiState>,
1815 ) -> Result<Json<Vec<crate::runtime_threads::RunningThread>>, ApiError> {
1816 let running = state
1817 .runtime_threads
1818 .running_threads()
1819 .await
1820 .map_err(|e| ApiError::internal(e.to_string()))?;
1821 Ok(Json(running))
1822 }
1823
1824 /// Active notices on one thread (#6180): the TUI-visible conditions a
1825 /// watch-only client must surface — subagent-terminal, elevation-needed,
1826 /// model-notify — each with turn identity for targeting.
1827 async fn list_thread_notices(
1828 State(state): State<RuntimeApiState>,
1829 Path(id): Path<String>,
1830 ) -> Result<Json<Vec<crate::runtime_threads::ActiveNotice>>, ApiError> {
1831 state
1832 .runtime_threads
1833 .get_thread(&id)
1834 .await
1835 .map_err(map_thread_err)?;
1836 Ok(Json(state.runtime_threads.list_notices(&id)))
1837 }
1838
1839 /// Acknowledge (clear) one notice. Terminal/notify kinds clear only here;
1840 /// elevation additionally auto-clears when its tool call completes.
1841 async fn ack_thread_notice(
1842 State(state): State<RuntimeApiState>,
1843 Path((id, notice_id)): Path<(String, String)>,
1844 ) -> Result<StatusCode, ApiError> {
1845 state
1846 .runtime_threads
1847 .get_thread(&id)
1848 .await
1849 .map_err(map_thread_err)?;
1850 if !state.runtime_threads.ack_notice(&id, &notice_id) {
1851 return Err(ApiError::not_found(format!(
1852 "thread '{id}' has no notice '{notice_id}'"
1853 )));
1854 }
1855 Ok(StatusCode::NO_CONTENT)
1856 }
1857
1858 async fn list_threads_summary(
1859 State(state): State<RuntimeApiState>,
1860 Query(query): Query<ThreadSummaryQuery>,
1861 ) -> Result<Json<Vec<ThreadSummary>>, ApiError> {
1862 let limit = query.limit.unwrap_or(50).clamp(1, 500);
1863 let search = query.search.as_deref().map(str::to_ascii_lowercase);
1864 let filter = resolve_thread_filter(query.include_archived, query.archived_only);
1865 // `limit` bounds the rows this route returns, not how far a search looks.
1866 // Passing it to the store read as well matched only inside the newest
1867 // `limit` threads, so any older match — the row the caller typed the query
1868 // to find — was invisible. Unsearched listings keep the cheap bounded read;
1869 // a search scans in newest-first order and stops at `limit` matches.
1870 //
1871 // Match on the thread record *before* `get_thread_detail`. Detail is a
1872 // whole-store turns+items walk, so loading it for every thread made a
1873 // non-matching dashboard keystroke O(threads × (all_turns + all_items))
1874 // JSON reads. Preview is filled only for matches; it is not a search key.
1875 let scan_limit = if search.is_some() { None } else { Some(limit) };
1876 let threads = state
1877 .runtime_threads
1878 .list_threads(filter, scan_limit)
1879 .await
1880 .map_err(|e| ApiError::internal(e.to_string()))?;
1881
1882 let mut summaries = Vec::new();
1883 for thread in threads {
1884 if summaries.len() >= limit {
1885 break;
1886 }
1887 if let Some(search) = &search
1888 && !state
1889 .runtime_threads
1890 .thread_matches_summary_search(&thread, search)
1891 {
1892 continue;
1893 }
1894 let detail = state
1895 .runtime_threads
1896 .get_thread_detail(&thread.id)
1897 .await
1898 .map_err(map_thread_err)?;
1899 let latest_turn = detail.turns.last();
1900 let latest_status =
1901 latest_turn.map(|turn| format!("{:?}", turn.status).to_ascii_lowercase());
1902 let pending_attention_count = detail
1903 .pending_approvals
1904 .len()
1905 .saturating_add(detail.pending_user_inputs.len());
1906
1907 let title = thread
1908 .title
1909 .as_deref()
1910 .map(str::trim)
1911 .filter(|t| !t.is_empty())
1912 .map(|t| truncate_text(t, 72))
1913 .unwrap_or_else(|| {
1914 latest_turn
1915 .map(|turn| {
1916 if turn.input_summary.trim().is_empty() {
1917 "New Thread".to_string()
1918 } else {
1919 truncate_text(&turn.input_summary, 72)
1920 }
1921 })
1922 .unwrap_or_else(|| "New Thread".to_string())
1923 });
1924
1925 let preview = detail
1926 .items
1927 .iter()
1928 .rev()
1929 .find_map(|item| match item.kind {
1930 TurnItemKind::AgentMessage | TurnItemKind::UserMessage => {
1931 let text = item.detail.clone().unwrap_or_else(|| item.summary.clone());
1932 if text.trim().is_empty() {
1933 None
1934 } else {
1935 Some(truncate_text(&text, 140))
1936 }
1937 }
1938 _ => None,
1939 })
1940 .unwrap_or_else(|| title.clone());
1941
1942 let workspace_git = collect_workspace_git_metadata(&thread.workspace);
1943 summaries.push(ThreadSummary {
1944 id: thread.id,
1945 title,
1946 preview,
1947 model: thread.model,
1948 mode: thread.mode,
1949 branch: workspace_git.branch,
1950 head: workspace_git.head,
1951 dirty: workspace_git.dirty,
1952 workspace: thread.workspace,
1953 archived: thread.archived,
1954 updated_at: thread.updated_at,
1955 latest_turn_id: thread.latest_turn_id,
1956 latest_turn_status: latest_status,
1957 pending_attention_count,
1958 });
1959 }
1960
1961 Ok(Json(summaries))
1962 }
1963
1964 async fn list_agent_runs(
1965 State(state): State<RuntimeApiState>,
1966 ) -> Result<Json<AgentRunsResponse>, ApiError> {
1967 let runs = load_persisted_agent_worker_records(&state.workspace).map_err(|err| {
1968 ApiError::internal(format!("Failed to load persisted agent run records: {err}"))
1969 })?;
1970 Ok(Json(AgentRunsResponse { runs }))
1971 }
1972
1973 async fn get_agent_run(
1974 State(state): State<RuntimeApiState>,
1975 Path(run_id): Path<String>,
1976 ) -> Result<Json<AgentWorkerRecord>, ApiError> {
1977 let runs = load_persisted_agent_worker_records(&state.workspace).map_err(|err| {
1978 ApiError::internal(format!("Failed to load persisted agent run records: {err}"))
1979 })?;
1980 let run = runs
1981 .into_iter()
1982 .find(|record| {
1983 let effective_run_id = if record.spec.run_id.is_empty() {
1984 record.spec.worker_id.as_str()
1985 } else {
1986 record.spec.run_id.as_str()
1987 };
1988 effective_run_id == run_id || record.spec.worker_id == run_id
1989 })
1990 .ok_or_else(|| ApiError::not_found(format!("agent run '{run_id}' not found")))?;
1991 Ok(Json(run))
1992 }
1993
1994 async fn list_fleet_profiles(
1995 State(state): State<RuntimeApiState>,
1996 ) -> Result<Json<Value>, ApiError> {
1997 let manager = open_fleet_manager(&state)?;
1998 // Same roster path the manager uses to validate `agent_profile` ids on
1999 // run creation, so GUI pickers can never offer a profile the runtime
2000 // would reject.
2001 let roster = manager.agent_roster();
2002 let profiles = roster
2003 .members()
2004 .iter()
2005 .map(|member| {
2006 json!({
2007 "id": member.id.clone(),
2008 "display_name": member.display_name.clone(),
2009 "description": member.description.clone(),
2010 "origin": member.origin.to_string(),
2011 })
2012 })
2013 .collect::<Vec<_>>();
2014 Ok(Json(json!({
2015 "profiles": profiles,
2016 "load_error": roster.load_error().map(str::to_string),
2017 })))
2018 }
2019
2020 async fn create_fleet_run(
2021 State(state): State<RuntimeApiState>,
2022 Json(request): Json<CreateFleetRunRequest>,
2023 ) -> Result<(StatusCode, Json<Value>), ApiError> {
2024 if request.target != FleetRuntimeTarget::ThisComputer {
2025 return Err(ApiError::not_implemented(format!(
2026 "Fleet target {:?} is not available in this local Runtime; choose this_computer",
2027 request.target
2028 )));
2029 }
2030 let (document, descriptor, max_workers) = prepare_managed_fleet_run(request)?;
2031 let manager = open_fleet_manager(&state)?;
2032 let report = manager
2033 .create_queued_run_with_descriptor(document, max_workers, descriptor)
2034 .map_err(|error| ApiError::bad_request(format!("Failed to create Fleet run: {error}")))?;
2035 let ledger_state = manager
2036 .rebuild_state()
2037 .map_err(|error| ApiError::internal(format!("Failed to rebuild Fleet state: {error}")))?;
2038 let run = ledger_state
2039 .runs
2040 .get(&report.run_id.0)
2041 .ok_or_else(|| ApiError::internal("Created Fleet run was missing from its ledger"))?;
2042 Ok((
2043 StatusCode::CREATED,
2044 Json(json!({
2045 "execution": "awaiting_start",
2046 "run": fleet_run_detail_json(&manager, run, &ledger_state)?,
2047 "warnings": report.warnings,
2048 })),
2049 ))
2050 }
2051
2052 fn prepare_managed_fleet_run(
2053 request: CreateFleetRunRequest,
2054 ) -> Result<(FleetTaskSpecDocument, ManagedFleetRunDescriptor, usize), ApiError> {
2055 if request.security_policy.is_some() {
2056 return Err(ApiError::not_implemented(
2057 "Managed Fleet security_policy overrides are not executable yet; use named roles and bounded task workspace/tool scopes",
2058 ));
2059 }
2060 if !request.worker_specs.is_empty() {
2061 return Err(ApiError::not_implemented(
2062 "Managed Fleet custom worker_specs are not available yet; local Runtime worker IDs are generated per run so worker controls cannot collide across Fleets",
2063 ));
2064 }
2065 if request.roles.is_empty() {
2066 return Err(ApiError::bad_request(
2067 "roles must declare at least one named Fleet role",
2068 ));
2069 }
2070 if request.roles.len() > 128 {
2071 return Err(ApiError::bad_request(
2072 "roles cannot contain more than 128 entries",
2073 ));
2074 }
2075 let workflow_id = managed_fleet_token("workflow.id", &request.workflow.id)?;
2076 let workflow_kind = request.workflow.kind;
2077 let name = request
2078 .name
2079 .as_deref()
2080 .map(str::trim)
2081 .filter(|name| !name.is_empty())
2082 .unwrap_or(workflow_id.as_str())
2083 .to_string();
2084 if name.len() > 256 || name.chars().any(char::is_control) {
2085 return Err(ApiError::bad_request(
2086 "name must be one printable line no longer than 256 bytes",
2087 ));
2088 }
2089
2090 let mut roles = BTreeMap::new();
2091 for role in request.roles {
2092 let normalized = canonical_public_role_name(&managed_fleet_token("role.name", &role.name)?);
2093 let agent_profile = role
2094 .agent_profile
2095 .as_deref()
2096 .map(|profile| managed_fleet_token("role.agent_profile", profile))
2097 .transpose()?;
2098 if roles.insert(normalized.clone(), agent_profile).is_some() {
2099 return Err(ApiError::bad_request(format!(
2100 "duplicate Fleet role '{normalized}'"
2101 )));
2102 }
2103 }
2104
2105 let mut tasks = request.workflow.tasks;
2106 let mut used_roles = BTreeSet::new();
2107 for task in &mut tasks {
2108 let worker = task.worker.as_mut().ok_or_else(|| {
2109 ApiError::bad_request(format!(
2110 "Fleet task '{}' must select one named role through worker.role",
2111 task.id
2112 ))
2113 })?;
2114 let role = worker.role.as_deref().ok_or_else(|| {
2115 ApiError::bad_request(format!(
2116 "Fleet task '{}' must select one named role through worker.role",
2117 task.id
2118 ))
2119 })?;
2120 let role = canonical_public_role_name(&managed_fleet_token("task.worker.role", role)?);
2121 let declared_profile = roles.get(&role).ok_or_else(|| {
2122 ApiError::bad_request(format!(
2123 "Fleet task '{}' references undeclared role '{role}'",
2124 task.id
2125 ))
2126 })?;
2127 if let Some(profile) = declared_profile {
2128 match worker.agent_profile.as_deref() {
2129 Some(task_profile) if task_profile != profile => {
2130 return Err(ApiError::bad_request(format!(
2131 "Fleet task '{}' overrides role '{role}' agent_profile '{profile}' with '{task_profile}'",
2132 task.id
2133 )));
2134 }
2135 None => worker.agent_profile = Some(profile.clone()),
2136 Some(_) => {}
2137 }
2138 }
2139 worker.role = Some(role.clone());
2140 used_roles.insert(role);
2141 }
2142 let unused_roles = roles
2143 .keys()
2144 .filter(|role| !used_roles.contains(*role))
2145 .cloned()
2146 .collect::<Vec<_>>();
2147 if !unused_roles.is_empty() {
2148 return Err(ApiError::bad_request(format!(
2149 "Every declared Fleet role must own a Workflow task; unused roles: {}",
2150 unused_roles.join(", ")
2151 )));
2152 }
2153 reject_parallel_write_collisions(&tasks)?;
2154
2155 let default_workers = roles.len().min(tasks.len()).max(1);
2156 let max_workers = request.max_workers.unwrap_or(default_workers);
2157 if !(1..=128).contains(&max_workers) {
2158 return Err(ApiError::bad_request(
2159 "max_workers must be between 1 and 128",
2160 ));
2161 }
2162 let role_names = roles.into_keys().collect::<Vec<_>>();
2163 Ok((
2164 FleetTaskSpecDocument {
2165 name: Some(name),
2166 labels: request.labels,
2167 security_policy: None,
2168 workers: Vec::new(),
2169 tasks,
2170 usage_ceiling: request.usage_ceiling,
2171 },
2172 ManagedFleetRunDescriptor {
2173 target: Some(request.target),
2174 workflow: Some(FleetWorkflowDescriptor {
2175 id: workflow_id,
2176 kind: workflow_kind,
2177 }),
2178 roles: role_names,
2179 },
2180 max_workers,
2181 ))
2182 }
2183
2184 fn managed_fleet_token(field: &str, value: &str) -> Result<String, ApiError> {
2185 let value = value.trim();
2186 if value.is_empty()
2187 || value.len() > 128
2188 || !value
2189 .chars()
2190 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
2191 {
2192 return Err(ApiError::bad_request(format!(
2193 "{field} must be a simple ASCII token no longer than 128 bytes"
2194 )));
2195 }
2196 Ok(value.to_string())
2197 }
2198
2199 fn reject_parallel_write_collisions(tasks: &[FleetTaskSpec]) -> Result<(), ApiError> {
2200 let mut claims: Vec<(String, String)> = Vec::new();
2201 for task in tasks {
2202 let write_roots = fleet_write_roots(task).map_err(|error| {
2203 ApiError::bad_request(format!(
2204 "Fleet task '{}' has an invalid write scope: {error}",
2205 task.id
2206 ))
2207 })?;
2208 for normalized in write_roots {
2209 for (owner, existing) in &claims {
2210 if owner != &task.id && managed_paths_overlap(existing.as_str(), &normalized) {
2211 return Err(ApiError::bad_request(format!(
2212 "Parallel Workflow write scope collision: tasks '{owner}' and '{}' both claim overlapping paths",
2213 task.id
2214 )));
2215 }
2216 }
2217 claims.push((task.id.clone(), normalized));
2218 }
2219 }
2220 Ok(())
2221 }
2222
2223 fn managed_paths_overlap(left: &str, right: &str) -> bool {
2224 // `normalize_fleet_relative_path` collapses the workspace root to ".", so
2225 // a task claiming the whole tree presents as "." rather than as a textual
2226 // prefix of its siblings. String containment alone never matched it, and
2227 // two workers could be admitted to write the same tree in parallel.
2228 if left == "." || right == "." {
2229 return true;
2230 }
2231 left == right
2232 || left
2233 .strip_prefix(right)
2234 .is_some_and(|suffix| suffix.starts_with('/'))
2235 || right
2236 .strip_prefix(left)
2237 .is_some_and(|suffix| suffix.starts_with('/'))
2238 }
2239
2240 async fn start_fleet_run(
2241 State(state): State<RuntimeApiState>,
2242 Path(run_id): Path<String>,
2243 ) -> Result<(StatusCode, Json<Value>), ApiError> {
2244 let manager = open_fleet_manager(&state)?;
2245 let durable = manager
2246 .rebuild_state()
2247 .map_err(|error| ApiError::internal(format!("Failed to rebuild Fleet state: {error}")))?;
2248 let run = durable
2249 .runs
2250 .get(&run_id)
2251 .ok_or_else(|| ApiError::not_found(format!("Fleet run '{run_id}' not found")))?;
2252 match run.target {
2253 Some(FleetRuntimeTarget::ThisComputer) => {}
2254 Some(target) => {
2255 return Err(ApiError::not_implemented(format!(
2256 "Fleet target {target:?} is not available in this local Runtime"
2257 )));
2258 }
2259 None => {
2260 return Err(ApiError::bad_request(
2261 "Fleet run has no explicit Runtime target and cannot be started through the managed API",
2262 ));
2263 }
2264 }
2265 if run.workflow.is_none() || run.roles.is_empty() {
2266 return Err(ApiError::bad_request(
2267 "Fleet run has no managed Workflow/role descriptor and cannot be started through the managed API",
2268 ));
2269 }
2270 let run_id = FleetRunId::from(run_id);
2271 let report = manager.activate_run(&run_id).map_err(|error| {
2272 let message = format!("Failed to start Fleet run '{}': {error}", run_id.0);
2273 if message.contains("already terminal") {
2274 ApiError::conflict(message)
2275 } else {
2276 ApiError::bad_request(message)
2277 }
2278 })?;
2279 let max_workers = durable
2280 .runs
2281 .get(&run_id.0)
2282 .and_then(|run| run.max_workers)
2283 .unwrap_or_else(|| report.worker_ids.len().max(1));
2284 let workspace = state.workspace.clone();
2285 let codewhale_binary = state.fleet_codewhale_binary.clone();
2286 let sessions_dir = state.sessions_dir.clone();
2287 let execution_run_id = run_id.clone();
2288 tokio::spawn(async move {
2289 let mut executor = FleetExecutor::new(&workspace).with_sessions_dir(sessions_dir);
2290 if let Err(error) = manager
2291 .run_to_completion(
2292 &execution_run_id,
2293 max_workers,
2294 &mut executor,
2295 &codewhale_binary,
2296 None,
2297 Duration::from_millis(250),
2298 )
2299 .await
2300 {
2301 tracing::error!(
2302 run_id = %execution_run_id.0,
2303 error = %error,
2304 "Runtime API Fleet manager exited with an error"
2305 );
2306 }
2307 });
2308 Ok((
2309 StatusCode::ACCEPTED,
2310 Json(json!({
2311 "action": "start",
2312 "execution": "scheduled",
2313 "run_id": run_id.0,
2314 "target": "this_computer",
2315 "leased": report.leased,
2316 "queued": report.queued,
2317 "worker_ids": report.worker_ids,
2318 })),
2319 ))
2320 }
2321
2322 async fn replay_fleet_events(
2323 State(state): State<RuntimeApiState>,
2324 Path(run_id): Path<String>,
2325 Query(query): Query<FleetEventsQuery>,
2326 ) -> Result<Json<FleetEventReplay>, ApiError> {
2327 let (after, limit) = validate_fleet_events_query(query)?;
2328 let replay = load_fleet_event_replay(state, FleetRunId::from(run_id), after, limit)
2329 .await
2330 .map_err(map_fleet_replay_error)?;
2331 Ok(Json(replay))
2332 }
2333
2334 async fn stream_fleet_events(
2335 State(state): State<RuntimeApiState>,
2336 Path(run_id): Path<String>,
2337 Query(query): Query<FleetEventsQuery>,
2338 ) -> Result<Sse<impl futures_util::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
2339 let (after, limit) = validate_fleet_events_query(query)?;
2340 let run_id = FleetRunId::from(run_id);
2341 // Subscribe before the initial load so no append between the load and the
2342 // first wait is missed for longer than the fallback poll (#6211 R7b).
2343 let appends = subscribe_fleet_ledger_appends(&fleet_ledger_path(&state.workspace));
2344 let initial = load_fleet_event_replay(state.clone(), run_id.clone(), after.clone(), limit)
2345 .await
2346 .map_err(map_fleet_replay_error)?;
2347 let event_stream = replay_live_fleet_events(state, run_id, after, limit, initial, appends);
2348 Ok(Sse::new(event_stream).keep_alive(
2349 KeepAlive::new()
2350 .interval(Duration::from_secs(15))
2351 .text("keepalive"),
2352 ))
2353 }
2354
2355 /// Fallback re-poll when no ledger-append wake arrives. Wakes cover every
2356 /// in-process append; the fallback heals missed wakes, out-of-process
2357 /// writers, and ledger compaction, which replaces rather than appends.
2358 const FLEET_SSE_FALLBACK_POLL: Duration = Duration::from_secs(5);
2359
2360 fn replay_live_fleet_events(
2361 state: RuntimeApiState,
2362 run_id: FleetRunId,
2363 mut after: Option<String>,
2364 limit: usize,
2365 initial: FleetEventReplay,
2366 appends: std::sync::Arc<tokio::sync::Notify>,
2367 ) -> impl futures_util::Stream<Item = Result<SseEvent, Infallible>> {
2368 stream! {
2369 let mut page = initial;
2370 loop {
2371 if page.history_truncated {
2372 yield Ok(sse_json(
2373 "fleet.replay.truncated",
2374 json!({
2375 "run_id": run_id.0.clone(),
2376 "reload_projection": true,
2377 }),
2378 ));
2379 }
2380 for event in page.events {
2381 after = Some(event.cursor.clone());
2382 yield Ok(fleet_sse_event(&event));
2383 }
2384 if !page.has_more {
2385 // Register interest before yielding to the runtime so an
2386 // append racing this wait still wakes us (#6211 R7b).
2387 let notified = appends.notified();
2388 tokio::pin!(notified);
2389 tokio::select! {
2390 _ = &mut notified => {}
2391 _ = tokio::time::sleep(FLEET_SSE_FALLBACK_POLL) => {}
2392 }
2393 }
2394 match load_fleet_event_replay(
2395 state.clone(),
2396 run_id.clone(),
2397 after.clone(),
2398 limit,
2399 )
2400 .await
2401 {
2402 Ok(next) => page = next,
2403 Err(FleetEventReplayError::CursorUnavailable { .. }) => {
2404 yield Ok(sse_json(
2405 "fleet.replay.cursor_unavailable",
2406 json!({
2407 "run_id": run_id.0.clone(),
2408 "reload_projection": true,
2409 }),
2410 ));
2411 return;
2412 }
2413 Err(error) => {
2414 tracing::warn!(
2415 run_id = %run_id.0,
2416 error = %error,
2417 "Fleet event stream stopped while reading durable history"
2418 );
2419 yield Ok(sse_json(
2420 "fleet.stream.error",
2421 json!({ "retryable": true }),
2422 ));
2423 return;
2424 }
2425 }
2426 }
2427 }
2428 }
2429
2430 async fn load_fleet_event_replay(
2431 state: RuntimeApiState,
2432 run_id: FleetRunId,
2433 after: Option<String>,
2434 limit: usize,
2435 ) -> std::result::Result<FleetEventReplay, FleetEventReplayError> {
2436 tokio::task::spawn_blocking(move || {
2437 let manager =
2438 open_fleet_manager(&state).map_err(|error| FleetEventReplayError::Storage {
2439 message: error.message,
2440 })?;
2441 manager.replay_events(&run_id, after.as_deref(), limit)
2442 })
2443 .await
2444 .map_err(|error| FleetEventReplayError::Storage {
2445 message: format!("Fleet replay worker failed: {error}"),
2446 })?
2447 }
2448
2449 fn validate_fleet_events_query(
2450 query: FleetEventsQuery,
2451 ) -> Result<(Option<String>, usize), ApiError> {
2452 let after = query
2453 .after
2454 .map(|cursor| cursor.trim().to_string())
2455 .filter(|cursor| !cursor.is_empty());
2456 if after.as_deref().is_some_and(|cursor| {
2457 cursor.len() > 96
2458 || !cursor.starts_with("fev1_")
2459 || !cursor
2460 .chars()
2461 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2462 }) {
2463 return Err(ApiError::bad_request(
2464 "after is not a valid Fleet event cursor",
2465 ));
2466 }
2467 let limit = query.limit.unwrap_or(DEFAULT_FLEET_EVENT_REPLAY_LIMIT);
2468 if !(1..=MAX_FLEET_EVENT_REPLAY_LIMIT).contains(&limit) {
2469 return Err(ApiError::bad_request(format!(
2470 "limit must be between 1 and {MAX_FLEET_EVENT_REPLAY_LIMIT}"
2471 )));
2472 }
2473 Ok((after, limit))
2474 }
2475
2476 fn map_fleet_replay_error(error: FleetEventReplayError) -> ApiError {
2477 let message = error.to_string();
2478 match error {
2479 FleetEventReplayError::UnknownRun { .. } => ApiError::not_found(message),
2480 FleetEventReplayError::CursorUnavailable { .. } => ApiError::conflict(message),
2481 FleetEventReplayError::Storage { .. } => ApiError::internal(message),
2482 }
2483 }
2484
2485 fn fleet_sse_event(event: &FleetRuntimeEvent) -> SseEvent {
2486 let data = serde_json::to_string(event).unwrap_or_else(|_| "{}".to_string());
2487 SseEvent::default()
2488 .id(event.cursor.clone())
2489 .event(event.event.clone())
2490 .data(data)
2491 }
2492
2493 async fn list_fleet_runs(State(state): State<RuntimeApiState>) -> Result<Json<Value>, ApiError> {
2494 let manager = open_fleet_manager(&state)?;
2495 let ledger_state = manager
2496 .rebuild_state()
2497 .map_err(|err| ApiError::internal(format!("Failed to rebuild Fleet state: {err}")))?;
2498 let runs: Vec<_> = ledger_state
2499 .runs
2500 .values()
2501 .map(|run| fleet_run_summary_json(&manager, run, &ledger_state))
2502 .collect::<Result<Vec<_>, _>>()?;
2503 let status = manager
2504 .status()
2505 .map_err(|err| ApiError::internal(format!("Failed to read Fleet status: {err}")))?;
2506 Ok(Json(json!({
2507 "status": fleet_status_json(&status),
2508 "runs": runs,
2509 })))
2510 }
2511
2512 async fn get_fleet_run(
2513 State(state): State<RuntimeApiState>,
2514 Path(run_id): Path<String>,
2515 ) -> Result<Json<Value>, ApiError> {
2516 let manager = open_fleet_manager(&state)?;
2517 let ledger_state = manager
2518 .rebuild_state()
2519 .map_err(|err| ApiError::internal(format!("Failed to rebuild Fleet state: {err}")))?;
2520 let run = ledger_state
2521 .runs
2522 .get(&run_id)
2523 .ok_or_else(|| ApiError::not_found(format!("Fleet run '{run_id}' not found")))?;
2524 Ok(Json(fleet_run_detail_json(&manager, run, &ledger_state)?))
2525 }
2526
2527 async fn list_fleet_run_workers(
2528 State(state): State<RuntimeApiState>,
2529 Path(run_id): Path<String>,
2530 ) -> Result<Json<Value>, ApiError> {
2531 let manager = open_fleet_manager(&state)?;
2532 let ledger_state = manager
2533 .rebuild_state()
2534 .map_err(|err| ApiError::internal(format!("Failed to rebuild Fleet state: {err}")))?;
2535 let run = ledger_state
2536 .runs
2537 .get(&run_id)
2538 .ok_or_else(|| ApiError::not_found(format!("Fleet run '{run_id}' not found")))?;
2539 let workers = run
2540 .worker_specs
2541 .iter()
2542 .map(|worker| {
2543 manager
2544 .inspect_worker(&worker.id)
2545 .map(|inspection| fleet_worker_json(&inspection))
2546 .map_err(|err| {
2547 ApiError::internal(format!(
2548 "Failed to inspect Fleet worker {}: {err}",
2549 worker.id
2550 ))
2551 })
2552 })
2553 .collect::<Result<Vec<_>, _>>()?;
2554 Ok(Json(json!({
2555 "run_id": run_id,
2556 "workers": workers,
2557 })))
2558 }
2559
2560 async fn get_fleet_worker(
2561 State(state): State<RuntimeApiState>,
2562 Path(worker_id): Path<String>,
2563 ) -> Result<Json<Value>, ApiError> {
2564 let manager = open_fleet_manager(&state)?;
2565 let inspection = manager.inspect_worker(&worker_id).map_err(|err| {
2566 ApiError::not_found(format!("Fleet worker '{worker_id}' not found: {err}"))
2567 })?;
2568 Ok(Json(fleet_worker_json(&inspection)))
2569 }
2570
2571 async fn interrupt_fleet_worker(
2572 State(state): State<RuntimeApiState>,
2573 Path(worker_id): Path<String>,
2574 ) -> Result<Json<Value>, ApiError> {
2575 let manager = open_fleet_manager(&state)?;
2576 let inspection = manager.interrupt_worker(&worker_id).map_err(|err| {
2577 ApiError::bad_request(format!(
2578 "Failed to interrupt Fleet worker '{worker_id}': {err}"
2579 ))
2580 })?;
2581 Ok(Json(json!({
2582 "action": "interrupt",
2583 "worker": fleet_worker_json(&inspection),
2584 })))
2585 }
2586
2587 async fn stop_fleet_worker(
2588 State(state): State<RuntimeApiState>,
2589 Path(worker_id): Path<String>,
2590 ) -> Result<Json<Value>, ApiError> {
2591 let manager = open_fleet_manager(&state)?;
2592 let inspection = manager.interrupt_worker(&worker_id).map_err(|err| {
2593 ApiError::bad_request(format!("Failed to stop Fleet worker '{worker_id}': {err}"))
2594 })?;
2595 Ok(Json(json!({
2596 "action": "stop",
2597 "worker": fleet_worker_json(&inspection),
2598 })))
2599 }
2600
2601 async fn restart_fleet_worker(
2602 State(state): State<RuntimeApiState>,
2603 Path(worker_id): Path<String>,
2604 ) -> Result<Json<Value>, ApiError> {
2605 let manager = open_fleet_manager(&state)?;
2606 let report = manager.restart_worker(&worker_id).map_err(|err| {
2607 ApiError::bad_request(format!(
2608 "Failed to restart Fleet worker '{worker_id}': {err}"
2609 ))
2610 })?;
2611 let worker = fleet_worker_json(&report.inspection);
2612 let run_id = report.run_id.clone();
2613 let max_workers = report.max_workers;
2614 let workspace = state.workspace.clone();
2615 let codewhale_binary = state.fleet_codewhale_binary.clone();
2616 let sessions_dir = state.sessions_dir.clone();
2617 tokio::spawn(async move {
2618 let mut executor = FleetExecutor::new(&workspace).with_sessions_dir(sessions_dir);
2619 if let Err(err) = manager
2620 .run_to_completion(
2621 &run_id,
2622 max_workers,
2623 &mut executor,
2624 &codewhale_binary,
2625 None,
2626 Duration::from_millis(250),
2627 )
2628 .await
2629 {
2630 tracing::error!(
2631 run_id = %run_id.0,
2632 error = %err,
2633 "Runtime API Fleet restart manager exited with an error"
2634 );
2635 }
2636 });
2637 Ok(Json(json!({
2638 "action": "restart",
2639 "execution": "scheduled",
2640 "run_id": report.run_id.0,
2641 "worker": worker,
2642 })))
2643 }
2644
2645 async fn stop_fleet_run(
2646 State(state): State<RuntimeApiState>,
2647 Path(run_id): Path<String>,
2648 ) -> Result<Json<Value>, ApiError> {
2649 let manager = open_fleet_manager(&state)?;
2650 let run_id = FleetRunId::from(run_id);
2651 let stopped = manager.stop_run(&run_id).map_err(|err| {
2652 ApiError::bad_request(format!("Failed to stop Fleet run '{}': {err}", run_id.0))
2653 })?;
2654 let status = manager
2655 .run_status(&run_id)
2656 .map_err(|err| ApiError::internal(format!("Failed to read Fleet run status: {err}")))?;
2657 Ok(Json(json!({
2658 "action": "stop",
2659 "run_id": run_id.0,
2660 "stopped": stopped,
2661 "status": fleet_status_json(&status),
2662 })))
2663 }
2664
2665 /// Maximum bytes read from a receipt evidence file for the inspection endpoint.
2666 const MAX_RECEIPT_EVIDENCE_READ_BYTES: u64 = 65_536;
2667
2668 async fn list_fleet_run_receipts(
2669 State(state): State<RuntimeApiState>,
2670 Path(run_id): Path<String>,
2671 ) -> Result<Json<Value>, ApiError> {
2672 let manager = open_fleet_manager(&state)?;
2673 let ledger_state = manager
2674 .rebuild_state()
2675 .map_err(|err| ApiError::internal(format!("Failed to rebuild Fleet state: {err}")))?;
2676 if !ledger_state.runs.contains_key(&run_id) {
2677 return Err(ApiError::not_found(format!(
2678 "Fleet run '{run_id}' not found"
2679 )));
2680 }
2681 let run_id_parsed = FleetRunId::from(run_id.clone());
2682 let receipts: Vec<Value> = ledger_state
2683 .receipts
2684 .values()
2685 .filter(|r| r.run_id == run_id_parsed)
2686 .map(fleet_receipt_json)
2687 .collect();
2688 Ok(Json(json!({
2689 "run_id": run_id,
2690 "receipts": receipts,
2691 })))
2692 }
2693
2694 async fn get_fleet_run_receipt(
2695 State(state): State<RuntimeApiState>,
2696 Path((run_id, task_id)): Path<(String, String)>,
2697 ) -> Result<Json<Value>, ApiError> {
2698 let manager = open_fleet_manager(&state)?;
2699 let ledger_state = manager
2700 .rebuild_state()
2701 .map_err(|err| ApiError::internal(format!("Failed to rebuild Fleet state: {err}")))?;
2702 let key = format!("{run_id}:{task_id}");
2703 let receipt = ledger_state.receipts.get(&key).ok_or_else(|| {
2704 ApiError::not_found(format!(
2705 "no receipt found for run '{run_id}' task '{task_id}'"
2706 ))
2707 })?;
2708 Ok(Json(fleet_receipt_json(receipt)))
2709 }
2710
2711 async fn inspect_fleet_run_receipt_evidence(
2712 State(state): State<RuntimeApiState>,
2713 Path((run_id, task_id)): Path<(String, String)>,
2714 ) -> Result<Json<Value>, ApiError> {
2715 let manager = open_fleet_manager(&state)?;
2716 let ledger_state = manager
2717 .rebuild_state()
2718 .map_err(|err| ApiError::internal(format!("Failed to rebuild Fleet state: {err}")))?;
2719 let key = format!("{run_id}:{task_id}");
2720 let receipt = ledger_state.receipts.get(&key).ok_or_else(|| {
2721 ApiError::not_found(format!(
2722 "no receipt found for run '{run_id}' task '{task_id}'"
2723 ))
2724 })?;
2725 // Locate the most recent Receipt-kind artifact.
2726 let receipt_artifact = receipt
2727 .artifacts
2728 .iter()
2729 .rfind(|a| a.kind == FleetArtifactKind::Receipt)
2730 .ok_or_else(|| {
2731 ApiError::not_found(format!(
2732 "no verifier evidence file for run '{run_id}' task '{task_id}'"
2733 ))
2734 })?;
2735 // Receipt artifacts are workspace-relative paths recorded by the verifier;
2736 // reject absolute paths and `..` escapes before joining onto the workspace.
2737 // An EMPTY recorded path is not a path at all: joining it would resolve
2738 // to the workspace directory itself and read it as a file.
2739 if receipt_artifact.path.as_os_str().is_empty() {
2740 return Err(ApiError::not_found(format!(
2741 "no verifier evidence file recorded for run '{run_id}' task '{task_id}'"
2742 )));
2743 }
2744 if !crate::fleet::artifacts::path_is_confined(&receipt_artifact.path) {
2745 return Err(ApiError::bad_request(format!(
2746 "evidence path for run '{run_id}' task '{task_id}' escapes the workspace"
2747 )));
2748 }
2749 let (raw, size_bytes) = crate::fleet::artifacts::read_verified(
2750 &state.workspace,
2751 receipt_artifact,
2752 MAX_RECEIPT_EVIDENCE_READ_BYTES,
2753 )
2754 .map_err(|err| {
2755 ApiError::bad_request(format!("Receipt evidence could not be verified: {err}"))
2756 })?;
2757 let truncated = size_bytes > MAX_RECEIPT_EVIDENCE_READ_BYTES;
2758 // Parse as JSON if possible; fall back to a raw string representation.
2759 let content: Value = serde_json::from_slice(&raw)
2760 .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&raw).into_owned()));
2761 Ok(Json(json!({
2762 "run_id": run_id,
2763 "task_id": task_id,
2764 "path": receipt_artifact.path,
2765 "checksum": receipt_artifact.checksum,
2766 "size_bytes": size_bytes,
2767 "truncated": truncated,
2768 "content": content,
2769 })))
2770 }
2771
2772 fn open_fleet_manager(state: &RuntimeApiState) -> Result<FleetManager, ApiError> {
2773 let (exec_config, fleet_config, session_model, route_config) = {
2774 let config = state.config.read();
2775 let exec_config = config
2776 .fleet
2777 .as_ref()
2778 .map(|fleet| fleet.exec.clone())
2779 .unwrap_or_default();
2780 // The active session route is the operator: workers without a
2781 // task/profile model pin inherit the model the user picked in /model.
2782 (
2783 exec_config,
2784 config.fleet_config(),
2785 runtime_request_model(&config, None).ok(),
2786 config.clone(),
2787 )
2788 };
2789 FleetManager::open(&state.workspace)
2790 .map(|manager| {
2791 let manager = manager
2792 .with_exec_config(exec_config)
2793 .with_fleet_config(fleet_config)
2794 .with_sub_agent_manager(state.sub_agent_manager.clone())
2795 .with_route_config(route_config);
2796 match session_model {
2797 Some(model) => manager.with_session_model(model),
2798 None => manager,
2799 }
2800 })
2801 .map_err(|err| ApiError::internal(format!("Failed to open Fleet manager: {err}")))
2802 }
2803
2804 fn fleet_run_summary_json(
2805 manager: &FleetManager,
2806 run: &FleetRun,
2807 ledger_state: &FleetLedgerState,
2808 ) -> Result<Value, ApiError> {
2809 let status = manager
2810 .run_status(&run.id)
2811 .map_err(|err| ApiError::internal(format!("Failed to read Fleet run status: {err}")))?;
2812 let task_statuses = ledger_state
2813 .tasks
2814 .values()
2815 .filter(|task| task.entry.run_id == run.id)
2816 .map(|task| {
2817 json!({
2818 "task_id": task.entry.task_id.clone(),
2819 "status": fleet_task_status_label(task.status),
2820 "leased_to": task.leased_to.clone(),
2821 "attempts": task.entry.attempts,
2822 })
2823 })
2824 .collect::<Vec<_>>();
2825 Ok(json!({
2826 "id": run.id.0.clone(),
2827 "name": run.name.clone(),
2828 "lifecycle_status": ledger_state
2829 .run_status_overrides
2830 .get(&run.id.0)
2831 .unwrap_or(&run.status),
2832 "status": fleet_status_json(&status),
2833 "target": run.target,
2834 "workflow": run.workflow.clone(),
2835 "roles": run.roles.clone(),
2836 "task_count": run.task_specs.len(),
2837 "worker_count": run.worker_specs.len(),
2838 "tasks": task_statuses,
2839 "labels": run.labels.clone(),
2840 "created_at": run.created_at.clone(),
2841 "updated_at": run.updated_at.clone(),
2842 "completed_at": run.completed_at.clone(),
2843 }))
2844 }
2845
2846 fn fleet_run_detail_json(
2847 manager: &FleetManager,
2848 run: &FleetRun,
2849 ledger_state: &FleetLedgerState,
2850 ) -> Result<Value, ApiError> {
2851 let mut value = fleet_run_summary_json(manager, run, ledger_state)?;
2852 if let Some(map) = value.as_object_mut() {
2853 map.insert("task_specs".to_string(), json!(run.task_specs.clone()));
2854 map.insert("worker_specs".to_string(), json!(run.worker_specs.clone()));
2855 }
2856 Ok(value)
2857 }
2858
2859 fn fleet_status_json(status: &FleetStatusSnapshot) -> Value {
2860 json!({
2861 "runs": status.runs,
2862 "queued": status.queued,
2863 "running": status.running,
2864 "completed": status.completed,
2865 "partial": status.partial,
2866 "failed": status.failed,
2867 "restarted": status.restarted,
2868 "escalated": status.escalated,
2869 "transport_failed": status.transport_failed,
2870 "task_failed": status.task_failed,
2871 "verifier_failed": status.verifier_failed,
2872 "cancelled": status.cancelled,
2873 "stale": status.stale,
2874 "workers": status
2875 .workers
2876 .iter()
2877 .map(|(worker_id, status)| {
2878 (
2879 worker_id.clone(),
2880 Value::String(worker_status_label(status).to_string()),
2881 )
2882 })
2883 .collect::<serde_json::Map<String, Value>>(),
2884 })
2885 }
2886
2887 fn fleet_worker_json(inspection: &FleetWorkerInspection) -> Value {
2888 json!({
2889 "worker_id": inspection.worker_id.clone(),
2890 "status": worker_status_label(&inspection.status),
2891 "run_id": inspection.current_run_id.as_ref().map(|run_id| run_id.0.clone()),
2892 "task_id": inspection.current_task_id.clone(),
2893 "objective": inspection.objective.clone(),
2894 "role": inspection.role.clone(),
2895 "host": inspection.host.clone(),
2896 "latest_heartbeat_at": inspection.latest_heartbeat_at.clone(),
2897 "latest_event": inspection.latest_event.as_ref().map(fleet_event_json),
2898 "artifacts": inspection.artifacts.iter().map(fleet_artifact_json).collect::<Vec<_>>(),
2899 "last_error": inspection.last_error.clone(),
2900 "alert_state": inspection.alert_state.clone(),
2901 "runtime_state": inspection.runtime_state.as_ref().map(fleet_worker_runtime_json),
2902 })
2903 }
2904
2905 fn fleet_worker_runtime_json(runtime: &FleetWorkerRuntimeProjection) -> Value {
2906 json!({
2907 "agent_status": runtime.agent_status.clone(),
2908 "steps_taken": runtime.steps_taken,
2909 "latest_message": runtime.latest_message.clone(),
2910 "error": runtime.error.clone(),
2911 "result_summary": runtime.result_summary.clone(),
2912 "has_session": runtime.has_session,
2913 })
2914 }
2915
2916 fn fleet_artifact_json(artifact: &codewhale_protocol::fleet::FleetArtifactRef) -> Value {
2917 json!({
2918 "kind": artifact_kind_label(&artifact.kind),
2919 "path": artifact.path.clone(),
2920 "checksum": artifact.checksum.clone(),
2921 "mime_type": artifact.mime_type.clone(),
2922 "size_bytes": artifact.size_bytes,
2923 })
2924 }
2925
2926 fn fleet_receipt_json(receipt: &codewhale_protocol::fleet::FleetReceipt) -> Value {
2927 use codewhale_protocol::fleet::{FleetTaskFailureKind, FleetTaskResult};
2928
2929 let result_label = match receipt.result {
2930 FleetTaskResult::Pass => "pass",
2931 FleetTaskResult::Partial => "partial",
2932 FleetTaskResult::Fail => "fail",
2933 FleetTaskResult::Skip => "skip",
2934 FleetTaskResult::Timeout => "timeout",
2935 };
2936 let (failure_kind_label, failure_class, retry_eligible) = match receipt.failure_kind.as_ref() {
2937 Some(FleetTaskFailureKind::Transport) => (
2938 Some("transport"),
2939 Some("Infrastructure or network failure during task transport"),
2940 true,
2941 ),
2942 Some(FleetTaskFailureKind::Task) => (
2943 Some("task"),
2944 Some("Task logic exited unsuccessfully"),
2945 false,
2946 ),
2947 Some(FleetTaskFailureKind::Verifier) => (
2948 Some("verifier"),
2949 Some("Verifier rejected the task output; manual review or code change required"),
2950 false,
2951 ),
2952 None => (None, None, false),
2953 };
2954 let evidence_available = receipt
2955 .artifacts
2956 .iter()
2957 .any(|a| a.kind == FleetArtifactKind::Receipt);
2958 let score_json = receipt.score.as_ref().map(|s| {
2959 json!({
2960 "value": s.value,
2961 "max": s.max,
2962 "notes": s.notes,
2963 })
2964 });
2965 json!({
2966 "run_id": receipt.run_id.0.clone(),
2967 "task_id": receipt.task_id.clone(),
2968 "worker_id": receipt.worker_id.clone(),
2969 "attempt": receipt.attempt,
2970 "terminal_seq": receipt.terminal_seq,
2971 "completed_at": receipt.completed_at.clone(),
2972 "result": result_label,
2973 "failure_kind": failure_kind_label,
2974 "failure_class": failure_class,
2975 "retry_eligible": retry_eligible,
2976 "score": score_json,
2977 "artifacts": receipt.artifacts.iter().map(fleet_artifact_json).collect::<Vec<_>>(),
2978 "saved_session_id": receipt.saved_session_id.clone(),
2979 "evidence_available": evidence_available,
2980 })
2981 }
2982
2983 fn fleet_event_json(event: &codewhale_protocol::fleet::FleetWorkerEvent) -> Value {
2984 json!({
2985 "seq": event.seq,
2986 "run_id": event.run_id.0.clone(),
2987 "worker_id": event.worker_id.clone(),
2988 "task_id": event.task_id.clone(),
2989 "timestamp": event.timestamp.clone(),
2990 "label": fleet_event_label(&event.payload),
2991 "payload": event.payload.clone(),
2992 })
2993 }
2994
2995 fn worker_status_label(status: &FleetWorkerStatus) -> &'static str {
2996 match status {
2997 FleetWorkerStatus::Unknown => "unknown",
2998 FleetWorkerStatus::Online => "online",
2999 FleetWorkerStatus::Busy => "busy",
3000 FleetWorkerStatus::Offline => "offline",
3001 FleetWorkerStatus::Unhealthy => "unhealthy",
3002 FleetWorkerStatus::Draining => "draining",
3003 FleetWorkerStatus::Retired => "retired",
3004 }
3005 }
3006
3007 fn fleet_task_status_label(status: FleetTaskLedgerStatus) -> &'static str {
3008 match status {
3009 FleetTaskLedgerStatus::Enqueued => "enqueued",
3010 FleetTaskLedgerStatus::Leased => "leased",
3011 FleetTaskLedgerStatus::Completed => "completed",
3012 FleetTaskLedgerStatus::Failed => "failed",
3013 FleetTaskLedgerStatus::Cancelled => "cancelled",
3014 }
3015 }
3016
3017 fn artifact_kind_label(kind: &FleetArtifactKind) -> String {
3018 match kind {
3019 FleetArtifactKind::Log => "log".to_string(),
3020 FleetArtifactKind::Patch => "patch".to_string(),
3021 FleetArtifactKind::TestResult => "test_result".to_string(),
3022 FleetArtifactKind::Report => "report".to_string(),
3023 FleetArtifactKind::Checkpoint => "checkpoint".to_string(),
3024 FleetArtifactKind::Receipt => "receipt".to_string(),
3025 FleetArtifactKind::Other(value) => value.clone(),
3026 }
3027 }
3028
3029 /// Bound on the `Completed.summary` excerpt inside a lifecycle event label.
3030 const FLEET_EVENT_LABEL_SUMMARY_CHARS: usize = 160;
3031
3032 fn fleet_event_label(payload: &FleetWorkerEventPayload) -> String {
3033 match payload {
3034 FleetWorkerEventPayload::Queued => "queued".to_string(),
3035 FleetWorkerEventPayload::Leased { .. } => "leased".to_string(),
3036 FleetWorkerEventPayload::Starting => "starting".to_string(),
3037 FleetWorkerEventPayload::Running => "running".to_string(),
3038 FleetWorkerEventPayload::ModelWait { model } => model
3039 .as_ref()
3040 .map(|model| format!("model_wait model={model}"))
3041 .unwrap_or_else(|| "model_wait".to_string()),
3042 FleetWorkerEventPayload::RunningTool { tool, call_id } => call_id
3043 .as_ref()
3044 .map(|call_id| format!("running_tool tool={tool} call_id={call_id}"))
3045 .unwrap_or_else(|| format!("running_tool tool={tool}")),
3046 FleetWorkerEventPayload::WorkflowEvent {
3047 workflow_run_id,
3048 event,
3049 } => event
3050 .get("type")
3051 .and_then(serde_json::Value::as_str)
3052 .map(|kind| format!("workflow_event run_id={workflow_run_id} type={kind}"))
3053 .unwrap_or_else(|| format!("workflow_event run_id={workflow_run_id}")),
3054 FleetWorkerEventPayload::Heartbeat { .. } => "heartbeat".to_string(),
3055 FleetWorkerEventPayload::UsageReport {
3056 input_tokens,
3057 output_tokens,
3058 } => format!("usage_report input={input_tokens} output={output_tokens}"),
3059 FleetWorkerEventPayload::Artifact(artifact) => {
3060 format!("artifact kind={}", artifact_kind_label(&artifact.kind))
3061 }
3062 // `summary` may carry the worker's bounded final-answer excerpt (up
3063 // to a few thousand chars); the label is a one-line status surface,
3064 // so it gets a short excerpt while `payload` keeps the full text.
3065 FleetWorkerEventPayload::Completed { exit_code, summary } => match (
3066 exit_code,
3067 summary
3068 .as_deref()
3069 .map(|summary| truncate_text(summary, FLEET_EVENT_LABEL_SUMMARY_CHARS)),
3070 ) {
3071 (Some(code), Some(summary)) => format!("completed exit_code={code} {summary}"),
3072 (Some(code), None) => format!("completed exit_code={code}"),
3073 (None, Some(summary)) => format!("completed {summary}"),
3074 (None, None) => "completed".to_string(),
3075 },
3076 FleetWorkerEventPayload::Failed {
3077 reason,
3078 recoverable,
3079 } => {
3080 format!("failed recoverable={recoverable} reason={reason}")
3081 }
3082 FleetWorkerEventPayload::Cancelled { cancelled_by } => cancelled_by
3083 .as_ref()
3084 .map(|by| format!("cancelled by={by}"))
3085 .unwrap_or_else(|| "cancelled".to_string()),
3086 FleetWorkerEventPayload::Interrupted { signal } => signal
3087 .as_ref()
3088 .map(|signal| format!("interrupted signal={signal}"))
3089 .unwrap_or_else(|| "interrupted".to_string()),
3090 FleetWorkerEventPayload::Stale { last_heartbeat_at } => last_heartbeat_at
3091 .as_ref()
3092 .map(|ts| format!("stale last_heartbeat_at={ts}"))
3093 .unwrap_or_else(|| "stale".to_string()),
3094 FleetWorkerEventPayload::Restarted { restart_count } => {
3095 format!("restarted count={restart_count}")
3096 }
3097 FleetWorkerEventPayload::Escalated { channel, alert_id } => alert_id
3098 .as_ref()
3099 .map(|alert_id| format!("escalated channel={channel} alert_id={alert_id}"))
3100 .unwrap_or_else(|| format!("escalated channel={channel}")),
3101 }
3102 }
3103
3104 /// One entry in the served slash-command catalog (`GET /v1/commands`, #6178).
3105 ///
3106 /// Clients use this to complete and validate input without duplicating the
3107 /// registry: a `binding: "host"` row must never be submitted as a model
3108 /// prompt, and a user command shadowing a builtin name wins that spelling.
3109 #[derive(Debug, Serialize)]
3110 struct CommandCatalogEntry {
3111 name: String,
3112 aliases: Vec<String>,
3113 /// English source text; localizing is the client's surface.
3114 #[serde(skip_serializing_if = "Option::is_none")]
3115 summary: Option<String>,
3116 #[serde(skip_serializing_if = "Option::is_none")]
3117 usage: Option<String>,
3118 /// Literal verbs declared by the usage line (`/goal <block|complete|…>`).
3119 subcommands: Vec<String>,
3120 takes_arguments: bool,
3121 /// `builtin` is registered code; `user` expands a stored template.
3122 kind: &'static str,
3123 /// `host` runs locally and never reaches the model; `prompt` expands into
3124 /// the request the model sees.
3125 binding: &'static str,
3126 /// `primary` | `advanced` | `compatibility` — builtins only; `hidden`
3127 /// covers rows the product does not advertise anywhere.
3128 #[serde(skip_serializing_if = "Option::is_none")]
3129 discovery: Option<&'static str>,
3130 hidden: bool,
3131 /// User command holding this builtin's canonical name.
3132 #[serde(skip_serializing_if = "Option::is_none")]
3133 shadowed_by: Option<String>,
3134 /// Alias spellings of this builtin taken by user commands.
3135 #[serde(skip_serializing_if = "Vec::is_empty")]
3136 shadowed_aliases: Vec<String>,
3137 }
3138
3139 #[derive(Debug, Serialize)]
3140 struct CommandsResponse {
3141 commands: Vec<CommandCatalogEntry>,
3142 }
3143
3144 fn command_catalog(
3145 user_commands: &crate::commands::user_registry::UserCommandRegistry,
3146 ) -> Vec<CommandCatalogEntry> {
3147 let mut commands = Vec::new();
3148 for info in crate::commands::command_infos() {
3149 let shadowed_by = user_commands
3150 .get(info.name)
3151 .map(|command| command.name.clone());
3152 let shadowed_aliases = info
3153 .aliases
3154 .iter()
3155 .filter(|alias| user_commands.get(alias).is_some())
3156 .map(|alias| (*alias).to_string())
3157 .collect();
3158 commands.push(CommandCatalogEntry {
3159 name: info.name.to_string(),
3160 aliases: info
3161 .aliases
3162 .iter()
3163 .map(|alias| (*alias).to_string())
3164 .collect(),
3165 summary: Some(
3166 info.description_for(codewhale_localization::Locale::En)
3167 .into_owned(),
3168 ),
3169 usage: Some(info.usage.to_string()),
3170 subcommands: crate::commands::traits::usage_subcommands(info.usage)
3171 .iter()
3172 .map(|token| (*token).to_string())
3173 .collect(),
3174 takes_arguments: crate::commands::user_registry::usage_describes_arguments(
3175 info.name, info.usage,
3176 ),
3177 kind: "builtin",
3178 binding: "host",
3179 discovery: Some(match info.discovery() {
3180 crate::commands::traits::CommandDiscovery::Primary => "primary",
3181 crate::commands::traits::CommandDiscovery::Advanced => "advanced",
3182 crate::commands::traits::CommandDiscovery::Compatibility => "compatibility",
3183 }),
3184 hidden: crate::commands::traits::UNLISTED_COMMANDS.contains(&info.name),
3185 shadowed_by,
3186 shadowed_aliases,
3187 });
3188 }
3189 for command in user_commands.iter() {
3190 commands.push(CommandCatalogEntry {
3191 name: command.name.clone(),
3192 aliases: command.aliases.clone(),
3193 summary: command.description.clone(),
3194 usage: command.display_usage().map(str::to_string),
3195 subcommands: Vec::new(),
3196 takes_arguments: command.takes_arguments(),
3197 kind: "user",
3198 binding: "prompt",
3199 discovery: None,
3200 hidden: command.hidden,
3201 shadowed_by: None,
3202 shadowed_aliases: Vec::new(),
3203 });
3204 }
3205 commands
3206 }
3207
3208 async fn list_commands(
3209 State(state): State<RuntimeApiState>,
3210 ) -> Result<Json<CommandsResponse>, ApiError> {
3211 let commands = crate::commands::user_registry::with_registry_for_workspace(
3212 Some(state.workspace.as_path()),
3213 command_catalog,
3214 );
3215 Ok(Json(CommandsResponse { commands }))
3216 }
3217
3218 async fn list_skills(
3219 State(state): State<RuntimeApiState>,
3220 ) -> Result<Json<SkillsResponse>, ApiError> {
3221 let (skills_dir, mode) = {
3222 let config = state.config.read();
3223 let skills_dir = resolve_skills_dir(&config, &state.workspace);
3224 let mode = crate::skills::SkillDiscoveryMode::from_codewhale_only(
3225 config.skills_config().scan_codewhale_only(),
3226 );
3227 (skills_dir, mode)
3228 };
3229 let plugin_registry = state
3230 .plugin_discovery
3231 .registry_for_workspace(&state.workspace);
3232 let (registry, directories) = discover_skills_for_runtime_api(
3233 &state.workspace,
3234 &skills_dir,
3235 mode,
3236 Some(plugin_registry.as_ref()),
3237 );
3238 let mut skill_state = state.skill_state.lock().await;
3239 skill_state
3240 .refresh()
3241 .map_err(|error| ApiError::internal(format!("refresh skill state: {error}")))?;
3242 let skills = registry
3243 .list()
3244 .iter()
3245 .map(|skill| {
3246 let (path, source, plugin_id, plugin_generation, plugin_content_hash) =
3247 match &skill.source {
3248 crate::skills::SkillSource::Native => (
3249 Some(skill.path.clone()),
3250 "native".to_string(),
3251 None,
3252 None,
3253 None,
3254 ),
3255 crate::skills::SkillSource::Plugin {
3256 plugin_id,
3257 plugin_name,
3258 authority,
3259 } => (
3260 None,
3261 format!("reviewed-plugin-snapshot:{plugin_name}"),
3262 Some(plugin_id.clone()),
3263 Some(authority.state_generation),
3264 Some(authority.content_hash.clone()),
3265 ),
3266 };
3267 SkillEntry {
3268 name: skill.name.clone(),
3269 description: skill.description.clone(),
3270 path,
3271 source,
3272 plugin_id,
3273 plugin_generation,
3274 plugin_content_hash,
3275 enabled: skill_state.is_enabled(&skill.name),
3276 is_bundled: skill_entry_is_bundled(skill, &skills_dir),
3277 }
3278 })
3279 .collect();
3280 Ok(Json(SkillsResponse {
3281 directory: skills_dir,
3282 directories,
3283 warnings: registry.warnings().to_vec(),
3284 skills,
3285 }))
3286 }
3287
3288 async fn set_skill_enabled(
3289 State(state): State<RuntimeApiState>,
3290 Path(name): Path<String>,
3291 Json(req): Json<SetSkillEnabledRequest>,
3292 ) -> Result<Json<SetSkillEnabledResponse>, ApiError> {
3293 let (skills_dir, mode) = {
3294 let config = state.config.read();
3295 let skills_dir = resolve_skills_dir(&config, &state.workspace);
3296 let mode = crate::skills::SkillDiscoveryMode::from_codewhale_only(
3297 config.skills_config().scan_codewhale_only(),
3298 );
3299 (skills_dir, mode)
3300 };
3301 let plugin_registry = state
3302 .plugin_discovery
3303 .registry_for_workspace(&state.workspace);
3304 let (registry, directories) = discover_skills_for_runtime_api(
3305 &state.workspace,
3306 &skills_dir,
3307 mode,
3308 Some(plugin_registry.as_ref()),
3309 );
3310 let exists = registry.list().iter().any(|skill| skill.name == name);
3311 if !exists {
3312 return Err(ApiError::not_found(format!(
3313 "skill '{name}' not found in searched directories: {}",
3314 format_skill_search_paths(&directories)
3315 )));
3316 }
3317
3318 let mut store = state.skill_state.lock().await;
3319 store
3320 .set_enabled(&name, req.enabled)
3321 .map_err(|err| ApiError::internal(format!("persist skill state: {err}")))?;
3322 Ok(Json(SetSkillEnabledResponse {
3323 name,
3324 enabled: req.enabled,
3325 }))
3326 }
3327
3328 // ─── Skill lifecycle helpers ────────────────────────────────────────────────
3329
3330 /// Build a [`crate::skills::mutation::MutationContext`] from the current
3331 /// server state. Reads the network policy and installer settings directly
3332 /// from the config already held in `state`.
3333 fn mutation_context_settings(
3334 state: &RuntimeApiState,
3335 ) -> (
3336 crate::network_policy::NetworkPolicy,
3337 u64,
3338 String,
3339 Option<PathBuf>,
3340 ) {
3341 use crate::skills::install::{DEFAULT_MAX_SIZE_BYTES, DEFAULT_REGISTRY_URL};
3342 let config = state.config.read();
3343 let network = config
3344 .network
3345 .clone()
3346 .map(|p| p.into_runtime())
3347 .unwrap_or_default();
3348 let skills_cfg = config.skills.as_ref();
3349 let max_size = skills_cfg
3350 .and_then(|s| s.max_install_size_bytes)
3351 .unwrap_or(DEFAULT_MAX_SIZE_BYTES);
3352 let registry_url = skills_cfg
3353 .and_then(|s| s.registry_url.clone())
3354 .unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string());
3355 let configured_skills_dir = config.skills_dir.as_ref().map(PathBuf::from);
3356 (network, max_size, registry_url, configured_skills_dir)
3357 }
3358
3359 fn parse_api_scope(
3360 scope: Option<&str>,
3361 ) -> Result<Option<crate::skills::mutation::SkillTargetScope>, ApiError> {
3362 match scope {
3363 None => Ok(None),
3364 Some("project") => Ok(Some(crate::skills::mutation::SkillTargetScope::Project)),
3365 Some("global") => Ok(Some(crate::skills::mutation::SkillTargetScope::Global)),
3366 Some(other) => Err(ApiError::bad_request(format!(
3367 "invalid scope '{other}'; expected \"project\" or \"global\""
3368 ))),
3369 }
3370 }
3371
3372 fn receipt_to_response(
3373 receipt: &crate::skills::mutation::SkillMutationReceipt,
3374 ) -> SkillMutationReceiptResponse {
3375 use crate::skills::mutation::SkillMutationOutcome;
3376 use crate::skills::roots::SkillScope;
3377
3378 const TRUST_NOTE: &str = "The .trusted marker is advisory and digest-bound; \
3379 it records your review intent but does not sandbox or auto-authorize scripts.";
3380
3381 let outcome: &'static str = match &receipt.outcome {
3382 SkillMutationOutcome::Installed => "installed",
3383 SkillMutationOutcome::Updated => "updated",
3384 SkillMutationOutcome::NoChange => "no_change",
3385 SkillMutationOutcome::Removed => "removed",
3386 SkillMutationOutcome::Trusted => "trusted",
3387 SkillMutationOutcome::Imported => "imported",
3388 SkillMutationOutcome::AlreadyPresent => "already_present",
3389 // NeedsApproval / NetworkDenied are returned as ApiError::forbidden
3390 // before reaching this conversion; they should not appear here.
3391 SkillMutationOutcome::NeedsApproval(_) => "needs_approval",
3392 SkillMutationOutcome::NetworkDenied(_) => "network_denied",
3393 };
3394 let scope = match receipt.scope {
3395 SkillScope::Project => "project".to_string(),
3396 SkillScope::Global => "global".to_string(),
3397 SkillScope::Logical => "logical".to_string(),
3398 };
3399 let trust_note = if receipt.outcome == SkillMutationOutcome::Trusted {
3400 Some(TRUST_NOTE)
3401 } else {
3402 None
3403 };
3404 SkillMutationReceiptResponse {
3405 name: receipt.name.clone(),
3406 outcome,
3407 scope,
3408 safe_target_path: receipt.safe_target_path.clone(),
3409 trust_note,
3410 }
3411 }
3412
3413 fn outcome_is_policy_error(outcome: &crate::skills::mutation::SkillMutationOutcome) -> bool {
3414 matches!(
3415 outcome,
3416 crate::skills::mutation::SkillMutationOutcome::NeedsApproval(_)
3417 | crate::skills::mutation::SkillMutationOutcome::NetworkDenied(_)
3418 )
3419 }
3420
3421 fn policy_error_message(outcome: &crate::skills::mutation::SkillMutationOutcome) -> String {
3422 match outcome {
3423 crate::skills::mutation::SkillMutationOutcome::NeedsApproval(host) => format!(
3424 "network access to '{host}' requires explicit approval; \
3425 approve the host in your network policy before installing this skill"
3426 ),
3427 crate::skills::mutation::SkillMutationOutcome::NetworkDenied(host) => {
3428 format!("network access to '{host}' was denied by the active network policy")
3429 }
3430 _ => "operation denied by policy".to_string(),
3431 }
3432 }
3433
3434 // ─── POST /v1/skills/install ────────────────────────────────────────────────
3435
3436 async fn install_skill_api(
3437 State(state): State<RuntimeApiState>,
3438 Json(req): Json<InstallSkillRequest>,
3439 ) -> Result<(StatusCode, Json<SkillMutationReceiptResponse>), ApiError> {
3440 use crate::skills::install::InstallSource;
3441 use crate::skills::mutation::{MutationContext, SkillMutationRequest, SkillTargetScope};
3442
3443 let source = InstallSource::parse(&req.source)
3444 .map_err(|err| ApiError::bad_request(format!("invalid install source: {err}")))?;
3445 let target = parse_api_scope(req.scope.as_deref())?.unwrap_or(SkillTargetScope::Global);
3446
3447 let (network, max_size, registry_url, configured_skills_dir) =
3448 mutation_context_settings(&state);
3449 let home = crate::config::effective_home_dir();
3450 let workspace = state.workspace.clone();
3451
3452 let receipt = crate::skills::mutation::execute(
3453 SkillMutationRequest::InstallRemote { source, target },
3454 &MutationContext {
3455 workspace: &workspace,
3456 home: home.as_deref(),
3457 configured_skills_dir: configured_skills_dir.as_deref(),
3458 network: &network,
3459 max_size,
3460 registry_url: &registry_url,
3461 },
3462 )
3463 .await
3464 .map_err(|err| ApiError::bad_request(format!("install failed: {err:#}")))?;
3465
3466 if outcome_is_policy_error(&receipt.outcome) {
3467 return Err(ApiError::forbidden(policy_error_message(&receipt.outcome)));
3468 }
3469
3470 let status = if receipt.outcome == crate::skills::mutation::SkillMutationOutcome::Installed {
3471 StatusCode::CREATED
3472 } else {
3473 StatusCode::OK
3474 };
3475 Ok((status, Json(receipt_to_response(&receipt))))
3476 }
3477
3478 // ─── POST /v1/skills/{name}/update ─────────────────────────────────────────
3479
3480 async fn update_skill_api(
3481 State(state): State<RuntimeApiState>,
3482 Path(name): Path<String>,
3483 Json(req): Json<UpdateSkillRequest>,
3484 ) -> Result<Json<SkillMutationReceiptResponse>, ApiError> {
3485 use crate::skills::mutation::{MutationContext, SkillMutationRequest};
3486
3487 let scope = parse_api_scope(req.scope.as_deref())?;
3488 let (network, max_size, registry_url, configured_skills_dir) =
3489 mutation_context_settings(&state);
3490 let home = crate::config::effective_home_dir();
3491 let workspace = state.workspace.clone();
3492
3493 let receipt = crate::skills::mutation::execute(
3494 SkillMutationRequest::UpdateByName {
3495 name: name.clone(),
3496 scope,
3497 expected_digest: req.expected_digest,
3498 },
3499 &MutationContext {
3500 workspace: &workspace,
3501 home: home.as_deref(),
3502 configured_skills_dir: configured_skills_dir.as_deref(),
3503 network: &network,
3504 max_size,
3505 registry_url: &registry_url,
3506 },
3507 )
3508 .await
3509 .map_err(|err| {
3510 let msg = err.to_string();
3511 if msg.contains("not found") {
3512 ApiError::not_found(format!("update failed: {err:#}"))
3513 } else {
3514 ApiError::bad_request(format!("update failed: {err:#}"))
3515 }
3516 })?;
3517
3518 if outcome_is_policy_error(&receipt.outcome) {
3519 return Err(ApiError::forbidden(policy_error_message(&receipt.outcome)));
3520 }
3521
3522 Ok(Json(receipt_to_response(&receipt)))
3523 }
3524
3525 // ─── DELETE /v1/skills/{name} (uninstall) ──────────────────────────────────
3526
3527 async fn uninstall_skill_api(
3528 State(state): State<RuntimeApiState>,
3529 Path(name): Path<String>,
3530 Query(query): Query<UninstallSkillQuery>,
3531 ) -> Result<Json<SkillMutationReceiptResponse>, ApiError> {
3532 use crate::skills::mutation::{MutationContext, SkillMutationRequest};
3533
3534 let scope = parse_api_scope(query.scope.as_deref())?;
3535 let (network, max_size, registry_url, configured_skills_dir) =
3536 mutation_context_settings(&state);
3537 let home = crate::config::effective_home_dir();
3538
3539 let receipt = crate::skills::mutation::execute_sync(
3540 SkillMutationRequest::RemoveByName {
3541 name: name.clone(),
3542 scope,
3543 expected_digest: query.expected_digest,
3544 },
3545 &MutationContext {
3546 workspace: &state.workspace,
3547 home: home.as_deref(),
3548 configured_skills_dir: configured_skills_dir.as_deref(),
3549 network: &network,
3550 max_size,
3551 registry_url: &registry_url,
3552 },
3553 )
3554 .map_err(|err| {
3555 let msg = err.to_string();
3556 if msg.contains("not found") {
3557 ApiError::not_found(format!("uninstall failed: {err:#}"))
3558 } else {
3559 ApiError::bad_request(format!("uninstall failed: {err:#}"))
3560 }
3561 })?;
3562
3563 Ok(Json(receipt_to_response(&receipt)))
3564 }
3565
3566 // ─── POST /v1/skills/{name}/trust ──────────────────────────────────────────
3567
3568 async fn trust_skill_api(
3569 State(state): State<RuntimeApiState>,
3570 Path(name): Path<String>,
3571 Json(req): Json<TrustSkillRequest>,
3572 ) -> Result<Json<SkillMutationReceiptResponse>, ApiError> {
3573 use crate::skills::mutation::{MutationContext, SkillMutationRequest};
3574
3575 let scope = parse_api_scope(req.scope.as_deref())?;
3576 let (network, max_size, registry_url, configured_skills_dir) =
3577 mutation_context_settings(&state);
3578 let home = crate::config::effective_home_dir();
3579
3580 let receipt = crate::skills::mutation::execute_sync(
3581 SkillMutationRequest::TrustByName {
3582 name: name.clone(),
3583 scope,
3584 expected_digest: req.expected_digest,
3585 },
3586 &MutationContext {
3587 workspace: &state.workspace,
3588 home: home.as_deref(),
3589 configured_skills_dir: configured_skills_dir.as_deref(),
3590 network: &network,
3591 max_size,
3592 registry_url: &registry_url,
3593 },
3594 )
3595 .map_err(|err| {
3596 let msg = err.to_string();
3597 if msg.contains("not found") {
3598 ApiError::not_found(format!("trust failed: {err:#}"))
3599 } else {
3600 ApiError::bad_request(format!("trust failed: {err:#}"))
3601 }
3602 })?;
3603
3604 Ok(Json(receipt_to_response(&receipt)))
3605 }
3606
3607 // ─── GET /v1/skills/{name}/audit ───────────────────────────────────────────
3608
3609 async fn audit_skill_api(
3610 State(state): State<RuntimeApiState>,
3611 Path(name): Path<String>,
3612 Query(query): Query<SkillScopeQuery>,
3613 ) -> Result<Json<SkillAuditResponse>, ApiError> {
3614 use crate::skills::audit::{
3615 AuditedSkill, DigestState, IntegrityState, SkillActionKind, SkillAuditMode,
3616 SkillAuditWarning, SkillSourceKind, TrustState, scan_with_configured,
3617 };
3618 use crate::skills::roots::SkillRootKind;
3619
3620 let scope_filter = parse_api_scope(query.scope.as_deref())?;
3621 let home = crate::config::effective_home_dir();
3622 let configured_skills_dir = {
3623 let config = state.config.read();
3624 config.skills_dir.as_ref().map(PathBuf::from)
3625 };
3626 let canonical = crate::skills::normalize_skill_name_for_lookup(&name);
3627
3628 let snap = scan_with_configured(
3629 &state.workspace,
3630 home.as_deref(),
3631 configured_skills_dir.as_deref(),
3632 SkillAuditMode::Compatible,
3633 None,
3634 );
3635
3636 let mut matches: Vec<&AuditedSkill> = snap
3637 .skills
3638 .iter()
3639 .filter(|s| s.id.canonical_name == canonical)
3640 .collect();
3641
3642 if let Some(scope) = scope_filter {
3643 let want = match scope {
3644 crate::skills::mutation::SkillTargetScope::Project => SkillRootKind::CodeWhaleProject,
3645 crate::skills::mutation::SkillTargetScope::Global => SkillRootKind::CodeWhaleGlobal,
3646 };
3647 matches.retain(|s| s.root.kind == want);
3648 }
3649
3650 if matches.is_empty() {
3651 return Err(ApiError::not_found(format!(
3652 "skill '{name}' not found in any audited root"
3653 )));
3654 }
3655
3656 let ambiguous = matches.len() > 1;
3657 let entries = matches
3658 .into_iter()
3659 .map(|skill| {
3660 let source_kind = match skill.source_kind {
3661 SkillSourceKind::CodeWhaleManaged => "codewhale_managed",
3662 SkillSourceKind::CodeWhaleManual => "codewhale_manual",
3663 SkillSourceKind::CompatibleExternal => "compatible_external",
3664 SkillSourceKind::BuiltIn => "built_in",
3665 SkillSourceKind::ReviewedPluginSnapshot => "reviewed_plugin_snapshot",
3666 SkillSourceKind::RegistryCache => "registry_cache",
3667 };
3668 let scope_str = match skill.root.kind {
3669 SkillRootKind::CodeWhaleProject => "project",
3670 SkillRootKind::CodeWhaleGlobal => "global",
3671 _ => "other",
3672 };
3673 let digest = match &skill.digest {
3674 DigestState::Known(v) => SkillAuditDigest {
3675 state: "known".to_string(),
3676 value: Some(v.clone()),
3677 },
3678 DigestState::Unknown(reason) => SkillAuditDigest {
3679 state: format!("unknown:{reason:?}").to_ascii_lowercase(),
3680 value: None,
3681 },
3682 };
3683 let trust = match &skill.trust {
3684 TrustState::TrustedForDigest(_) => "trusted_for_digest",
3685 TrustState::TrustStale => "trust_stale",
3686 TrustState::LegacyAdvisory => "legacy_advisory",
3687 TrustState::Untrusted => "untrusted",
3688 TrustState::NotApplicable => "not_applicable",
3689 TrustState::Unknown => "unknown",
3690 };
3691 let integrity = match &skill.integrity {
3692 IntegrityState::Healthy => "healthy",
3693 IntegrityState::LocalContentDrift => "local_content_drift",
3694 IntegrityState::BrokenManagedInstall => "broken_managed_install",
3695 IntegrityState::LegacyMetadataUnknown => "legacy_metadata_unknown",
3696 IntegrityState::Unknown => "unknown",
3697 };
3698 let available_actions = skill
3699 .available_actions
3700 .iter()
3701 .map(|a| match a {
3702 SkillActionKind::Install => "install",
3703 SkillActionKind::Import => "import",
3704 SkillActionKind::Update => "update",
3705 SkillActionKind::Remove => "remove",
3706 SkillActionKind::Trust => "trust",
3707 })
3708 .map(str::to_string)
3709 .collect();
3710 let warnings = skill
3711 .warnings
3712 .iter()
3713 .map(|w| match w {
3714 SkillAuditWarning::Message(m) => m.clone(),
3715 })
3716 .collect();
3717 SkillAuditEntry {
3718 name: skill.name.clone(),
3719 safe_display_path: skill.safe_display_path.clone(),
3720 source_kind: source_kind.to_string(),
3721 scope: scope_str.to_string(),
3722 digest,
3723 trust: trust.to_string(),
3724 integrity: integrity.to_string(),
3725 available_actions,
3726 warnings,
3727 }
3728 })
3729 .collect();
3730
3731 Ok(Json(SkillAuditResponse {
3732 ambiguous,
3733 skills: entries,
3734 }))
3735 }
3736
3737 #[derive(Debug, Deserialize)]
3738 struct ApprovalsQuery {
3739 limit: Option<usize>,
3740 }
3741
3742 /// One row of the account-wide approval history: what the agent asked
3743 /// permission to do and what was decided. `decided_at` is `None` while the
3744 /// ask is still pending.
3745 #[derive(Debug, Serialize)]
3746 struct ApprovalHistoryRow {
3747 approval_id: String,
3748 tool_name: String,
3749 outcome: String,
3750 asked_at: chrono::DateTime<Utc>,
3751 decided_at: Option<chrono::DateTime<Utc>>,
3752 }
3753
3754 fn approval_outcome_label(outcome: &crate::approval_log::ApprovalOutcome) -> &'static str {
3755 use crate::approval_log::ApprovalOutcome;
3756 match outcome {
3757 ApprovalOutcome::ApprovedOnce => "allowed_once",
3758 ApprovalOutcome::Denied => "denied",
3759 ApprovalOutcome::Timeout => "timeout",
3760 ApprovalOutcome::Cancelled => "cancelled",
3761 ApprovalOutcome::Unavailable => "unavailable",
3762 ApprovalOutcome::RetryWithPolicy { .. } => "retry_with_policy",
3763 }
3764 }
3765
3766 /// Flatten one session's replay into history rows, newest ask first. Pending
3767 /// asks sort by asked time alongside decided rows — they are the newest
3768 /// entries while live, and sink into place once decided.
3769 fn approval_history_rows(replay: &crate::approval_log::ApprovalReplay) -> Vec<ApprovalHistoryRow> {
3770 let mut rows: Vec<ApprovalHistoryRow> = replay
3771 .completed
3772 .iter()
3773 .map(|completed| {
3774 let asked_at = completed.ask.created_at();
3775 ApprovalHistoryRow {
3776 approval_id: completed.ask.approval_id().to_string(),
3777 tool_name: completed.ask.tool_name().unwrap_or("unknown").to_string(),
3778 outcome: approval_outcome_label(&completed.outcome).to_string(),
3779 asked_at,
3780 decided_at: Some(completed.decided_at),
3781 }
3782 })
3783 .chain(replay.unmatched_asks.iter().map(|ask| ApprovalHistoryRow {
3784 approval_id: ask.approval_id().to_string(),
3785 tool_name: ask.tool_name().unwrap_or("unknown").to_string(),
3786 outcome: "pending".to_string(),
3787 asked_at: ask.created_at(),
3788 decided_at: None,
3789 }))
3790 .collect();
3791 rows.sort_by_key(|row| std::cmp::Reverse(row.asked_at));
3792 rows
3793 }
3794
3795 /// `GET /v1/approvals` — the read-only history behind the approvals log:
3796 /// every decided approval plus every still-pending ask, newest first, across
3797 /// all sessions. A corrupt session log is skipped with a warning, never a
3798 /// 500 for the whole history; the warn names the file to inspect (#5931).
3799 async fn list_approvals(
3800 State(state): State<RuntimeApiState>,
3801 Query(query): Query<ApprovalsQuery>,
3802 ) -> Result<Json<Vec<ApprovalHistoryRow>>, ApiError> {
3803 let limit = query.limit.unwrap_or(100).clamp(1, 500);
3804 let sessions_dir = state.sessions_dir.clone();
3805 let mut rows = tokio::task::spawn_blocking(move || {
3806 let store = crate::approval_log::ApprovalReceiptStore::new(sessions_dir);
3807 let mut rows = Vec::new();
3808 for session_id in store.sessions_with_logs() {
3809 match store.replay(&session_id) {
3810 Ok(replay) => rows.extend(approval_history_rows(&replay)),
3811 Err(error) => tracing::warn!(
3812 target: "approval",
3813 error_kind = ?error.kind(),
3814 %error,
3815 session_id,
3816 "skipping unreadable approval log in history listing",
3817 ),
3818 }
3819 }
3820 rows
3821 })
3822 .await
3823 .map_err(|error| ApiError::internal(format!("approval history read failed: {error}")))?;
3824 rows.sort_by_key(|row| std::cmp::Reverse(row.asked_at));
3825 rows.truncate(limit);
3826 Ok(Json(rows))
3827 }
3828
3829 async fn decide_approval(
3830 State(state): State<RuntimeApiState>,
3831 Path(approval_id): Path<String>,
3832 Json(req): Json<DecideApprovalBody>,
3833 ) -> Result<Json<DecideApprovalResponse>, ApiError> {
3834 let decision = match req.decision.as_str() {
3835 "allow" => ExternalApprovalDecision::Allow {
3836 remember: req.remember,
3837 },
3838 "deny" => ExternalApprovalDecision::Deny {
3839 remember: req.remember,
3840 },
3841 other => {
3842 return Err(ApiError::bad_request(format!(
3843 "invalid decision '{other}'; expected \"allow\" or \"deny\""
3844 )));
3845 }
3846 };
3847 let delivered = state
3848 .runtime_threads
3849 .deliver_external_approval(&approval_id, decision);
3850 if !delivered {
3851 return Err(ApiError::not_found(format!(
3852 "no pending approval with id '{approval_id}'"
3853 )));
3854 }
3855 Ok(Json(DecideApprovalResponse {
3856 ok: true,
3857 approval_id,
3858 decision: req.decision,
3859 delivered,
3860 }))
3861 }
3862
3863 async fn submit_user_input(
3864 State(state): State<RuntimeApiState>,
3865 Path((thread_id, input_id)): Path<(String, String)>,
3866 Json(req): Json<SubmitUserInputBody>,
3867 ) -> Result<Json<SubmitUserInputResponse>, ApiError> {
3868 use crate::tools::user_input::{UserInputAnswer, UserInputResponse};
3869 let answers: Vec<UserInputAnswer> = req
3870 .answers
3871 .into_iter()
3872 .map(|a| UserInputAnswer {
3873 id: a.id,
3874 label: a.label,
3875 value: a.value,
3876 })
3877 .collect();
3878 let response = UserInputResponse { answers };
3879 let delivered = state
3880 .runtime_threads
3881 .submit_user_input(&thread_id, &input_id, response)
3882 .await
3883 .map_err(map_thread_err)?;
3884 if !delivered {
3885 return Err(ApiError::not_found(format!(
3886 "no pending user-input request with id '{input_id}'"
3887 )));
3888 }
3889 Ok(Json(SubmitUserInputResponse {
3890 ok: true,
3891 input_id,
3892 delivered,
3893 }))
3894 }
3895
3896 async fn runtime_info(
3897 State(state): State<RuntimeApiState>,
3898 request: Request,
3899 ) -> Json<RuntimeInfoResponse> {
3900 let version = env!("CARGO_PKG_VERSION");
3901 let commit = option_env!("CODEWHALE_BUILD_COMMIT").unwrap_or("unknown");
3902 let api_base = runtime_account_api_base();
3903 let account = runtime_account_info_for_request(
3904 runtime_request_is_authorized(&request, &state),
3905 &api_base,
3906 || runtime_account_info(state.config_profile.as_deref(), &api_base),
3907 );
3908 Json(RuntimeInfoResponse {
3909 service: "codewhale-runtime-api",
3910 runtime_api_version: RUNTIME_API_VERSION,
3911 codewhale_version: version,
3912 codewhale_commit: commit,
3913 bind_host: state.bind_host.clone(),
3914 port: state.bind_port,
3915 auth_required: state.auth_required,
3916 transports: vec!["http", "sse"],
3917 capabilities: default_runtime_capabilities(),
3918 account,
3919 experimental: RuntimeExperimentalCapabilities::default(),
3920 version,
3921 })
3922 }
3923
3924 fn runtime_account_info(profile: Option<&str>, api_base: &str) -> RuntimeAccountInfo {
3925 #[cfg(test)]
3926 {
3927 let _ = profile;
3928 RuntimeAccountInfo::signed_out(api_base.to_string())
3929 }
3930
3931 #[cfg(not(test))]
3932 {
3933 secure_account_session_secrets()
3934 .and_then(|secrets| {
3935 AccountSessionStore::new(secrets, profile, api_base).runtime_info_at(Utc::now())
3936 })
3937 .unwrap_or_else(|_| RuntimeAccountInfo::signed_out(api_base.to_string()))
3938 }
3939 }
3940
3941 fn runtime_account_info_for_request(
3942 authorized: bool,
3943 api_base: &str,
3944 load: impl FnOnce() -> RuntimeAccountInfo,
3945 ) -> RuntimeAccountInfo {
3946 if authorized {
3947 load()
3948 } else {
3949 RuntimeAccountInfo::signed_out(api_base.to_string())
3950 }
3951 }
3952
3953 fn runtime_account_api_base() -> String {
3954 std::env::var(ACCOUNT_API_BASE_ENV)
3955 .ok()
3956 .and_then(|value| normalize_runtime_account_api_base(&value))
3957 .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string())
3958 }
3959
3960 fn normalize_runtime_account_api_base(value: &str) -> Option<String> {
3961 let mut url = reqwest::Url::parse(value.trim()).ok()?;
3962 if !url.username().is_empty()
3963 || url.password().is_some()
3964 || url.query().is_some()
3965 || url.fragment().is_some()
3966 || !matches!(url.path(), "" | "/")
3967 {
3968 return None;
3969 }
3970 let host = url.host_str()?;
3971 let loopback = host.eq_ignore_ascii_case("localhost")
3972 || host
3973 .trim_start_matches('[')
3974 .trim_end_matches(']')
3975 .parse::<IpAddr>()
3976 .is_ok_and(|address| address.is_loopback());
3977 if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {
3978 return None;
3979 }
3980 url.set_path("/");
3981 Some(url.as_str().trim_end_matches('/').to_string())
3982 }
3983
3984 /// Ownership is derived using the same trust/precedence as the existing MCP
3985 /// loader. A global editor must never silently change a shadowed project entry
3986 /// or manufacture an override for a reviewed plugin component.
3987 fn mcp_management_config(
3988 state: &RuntimeApiState,
3989 ) -> Result<
3990 (
3991 crate::mcp::McpConfig,
3992 std::collections::HashMap<String, &'static str>,
3993 ),
3994 ApiError,
3995 > {
3996 let global_path = state.config.read().mcp_config_path();
3997 let plugins = state
3998 .plugin_discovery
3999 .registry_for_workspace(&state.workspace);
4000 let config = crate::mcp::load_config_with_workspace_and_plugins(
4001 &global_path,
4002 &state.workspace,
4003 plugins.as_ref(),
4004 )
4005 .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?;
4006 let global = crate::mcp::load_config(&global_path)
4007 .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?;
4008 let project_path = crate::mcp::workspace_mcp_config_path(&state.workspace);
4009 let same_source = project_path == global_path
4010 || project_path
4011 .canonicalize()
4012 .ok()
4013 .zip(global_path.canonicalize().ok())
4014 .is_some_and(|(project, global)| project == global);
4015 let project = if !same_source && crate::config::is_workspace_trusted(&state.workspace) {
4016 crate::mcp::load_config(&project_path)
4017 .map_err(|e| ApiError::internal(format!("Failed to load project MCP config: {e}")))?
4018 } else {
4019 crate::mcp::McpConfig::default()
4020 };
4021 let origins = config
4022 .servers
4023 .iter()
4024 .map(|(name, server)| {
4025 let origin = if server.reviewed_plugin.is_some() {
4026 "plugin"
4027 } else if project.servers.contains_key(name) {
4028 "project"
4029 } else if global.servers.contains_key(name) {
4030 "global"
4031 } else {
4032 "unknown"
4033 };
4034 (name.clone(), origin)
4035 })
4036 .collect();
4037 Ok((config, origins))
4038 }
4039
4040 #[derive(Debug)]
4041 struct McpManagementFailure(ApiError);
4042 impl std::fmt::Display for McpManagementFailure {
4043 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4044 f.write_str(&self.0.message)
4045 }
4046 }
4047 impl std::error::Error for McpManagementFailure {}
4048
4049 fn mcp_mutation_error(error: anyhow::Error) -> ApiError {
4050 if error.is::<crate::mcp::McpRevisionConflict>() {
4051 ApiError {
4052 status: StatusCode::PRECONDITION_FAILED,
4053 message: error.to_string(),
4054 }
4055 } else if let Some(error) = error.downcast_ref::<McpManagementFailure>() {
4056 error.0.clone()
4057 } else {
4058 ApiError::internal(error.to_string())
4059 }
4060 }
4061
4062 fn mcp_expected_revision(headers: &axum::http::HeaderMap) -> Result<String, ApiError> {
4063 let value = headers
4064 .get(header::IF_MATCH)
4065 .ok_or_else(|| ApiError {
4066 status: StatusCode::PRECONDITION_REQUIRED,
4067 message: "Read the MCP configuration and send its revision in If-Match before saving"
4068 .into(),
4069 })?
4070 .to_str()
4071 .map_err(|_| ApiError::bad_request("Invalid MCP revision"))?
4072 .trim();
4073 let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
4074 &value[1..value.len() - 1]
4075 } else {
4076 value
4077 };
4078 if value != "mcp-v1-absent"
4079 && !value.strip_prefix("mcp-v1-").is_some_and(|hash| {
4080 hash.len() == 64
4081 && hash
4082 .bytes()
4083 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
4084 })
4085 {
4086 return Err(ApiError::bad_request("Invalid MCP revision"));
4087 }
4088 Ok(value.to_owned())
4089 }
4090
4091 async fn mutate_mcp_management<T: Send + 'static>(
4092 state: RuntimeApiState,
4093 headers: axum::http::HeaderMap,
4094 mutate: impl FnOnce(&RuntimeApiState, &mut crate::mcp::McpConfig) -> Result<T, ApiError>
4095 + Send
4096 + 'static,
4097 ) -> Result<(T, String), ApiError> {
4098 let expected = mcp_expected_revision(&headers)?;
4099 #[cfg(test)]
4100 let env_ticket = crate::test_support::env_scope_ticket();
4101 tokio::task::spawn_blocking(move || {
4102 #[cfg(test)]
4103 let _membership = crate::test_support::join_env_scope(env_ticket);
4104 let path = state.config.read().mcp_config_path();
4105 crate::mcp::mutate_config(&path, Some(&expected), |config| {
4106 mutate(&state, config).map_err(|error| anyhow::Error::new(McpManagementFailure(error)))
4107 })
4108 .map_err(mcp_mutation_error)
4109 })
4110 .await
4111 .map_err(|_| ApiError::internal("MCP configuration write failed"))?
4112 }
4113
4114 async fn mcp_management_snapshot(
4115 state: RuntimeApiState,
4116 ) -> Result<
4117 (
4118 (
4119 crate::mcp::McpConfig,
4120 std::collections::HashMap<String, &'static str>,
4121 ),
4122 String,
4123 ),
4124 ApiError,
4125 > {
4126 #[cfg(test)]
4127 let env_ticket = crate::test_support::env_scope_ticket();
4128 tokio::task::spawn_blocking(move || {
4129 #[cfg(test)]
4130 let _membership = crate::test_support::join_env_scope(env_ticket);
4131 let path = state.config.read().mcp_config_path();
4132 codewhale_config::with_config_write_lock(&path, |path| {
4133 let config = mcp_management_config(&state)
4134 .map_err(|error| anyhow::Error::new(McpManagementFailure(error)))?;
4135 Ok((config, crate::mcp::read_config_revision(path)?))
4136 })
4137 .map_err(mcp_mutation_error)
4138 })
4139 .await
4140 .map_err(|_| ApiError::internal("MCP configuration read failed"))?
4141 }
4142
4143 fn require_writable_mcp_server(state: &RuntimeApiState, name: &str) -> Result<(), ApiError> {
4144 let (_, origins) = mcp_management_config(state)?;
4145 match origins.get(name) {
4146 Some(&"global") => Ok(()),
4147 Some(origin) => Err(ApiError {
4148 status: StatusCode::CONFLICT,
4149 message: format!(
4150 "MCP server '{name}' is owned by {origin} configuration; manage it at its source"
4151 ),
4152 }),
4153 None => Err(ApiError::not_found(format!(
4154 "MCP server '{name}' not found"
4155 ))),
4156 }
4157 }
4158
4159 async fn mcp_pool_handle(
4160 state: &RuntimeApiState,
4161 create: bool,
4162 ) -> Result<Option<Arc<Mutex<McpPool>>>, ApiError> {
4163 let mut slot = state.mcp_pool.lock().await;
4164 if slot.is_none() && create {
4165 let path = state.config.read().mcp_config_path();
4166 let plugins = state
4167 .plugin_discovery
4168 .registry_for_workspace(&state.workspace);
4169 let pool =
4170 McpPool::from_config_path_with_workspace_and_plugins(&path, &state.workspace, plugins)
4171 .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?;
4172 *slot = Some(Arc::new(Mutex::new(pool)));
4173 }
4174 Ok(slot.clone())
4175 }
4176
4177 fn mcp_connection_outcome(
4178 pool: &McpPool,
4179 server: &str,
4180 error: Option<&anyhow::Error>,
4181 ) -> McpConnectionOutcome {
4182 McpConnectionOutcome {
4183 server: server.to_owned(),
4184 connected: pool.connected_servers().contains(&server),
4185 auth_required: pool.server_needs_auth(server),
4186 error: error
4187 .map(|error| truncate_text(&crate::mcp::format_mcp_error_for_display(error), 2048)),
4188 }
4189 }
4190
4191 async fn list_mcp_servers(
4192 State(state): State<RuntimeApiState>,
4193 ) -> Result<Json<McpServersResponse>, ApiError> {
4194 let ((config, origins), revision) = mcp_management_snapshot(state.clone()).await?;
4195 let handle = mcp_pool_handle(&state, false).await?;
4196 let pool = match handle.as_ref() {
4197 Some(handle) => Some(handle.lock().await),
4198 None => None,
4199 };
4200 let mut servers = Vec::new();
4201 for (name, server_cfg) in config.servers {
4202 let origin = origins.get(&name).copied().unwrap_or("unknown");
4203 servers.push(McpServerEntry {
4204 name: name.clone(),
4205 origin,
4206 writable: origin == "global",
4207 auth_required: pool
4208 .as_ref()
4209 .is_some_and(|pool| pool.server_needs_auth(&name)),
4210 enabled: server_cfg.is_enabled(),
4211 required: server_cfg.required,
4212 command: server_cfg.command.clone(),
4213 url: server_cfg.url.clone(),
4214 connected: pool
4215 .as_ref()
4216 .is_some_and(|pool| pool.connected_servers().contains(&name.as_str())),
4217 enabled_tools: server_cfg.enabled_tools.clone(),
4218 disabled_tools: server_cfg.disabled_tools.clone(),
4219 });
4220 }
4221 servers.sort_by(|a, b| a.name.cmp(&b.name));
4222 Ok(Json(McpServersResponse { servers, revision }))
4223 }
4224
4225 async fn list_mcp_tools(
4226 State(state): State<RuntimeApiState>,
4227 Query(query): Query<McpToolsQuery>,
4228 ) -> Result<Json<McpToolsResponse>, ApiError> {
4229 // An explicit connection request must not inherit the tool dispatcher's
4230 // best-effort reload behavior: unreadable/revoked sources fail closed.
4231 let fresh_config = if query.connect {
4232 Some(mcp_management_config(&state)?.0)
4233 } else {
4234 None
4235 };
4236 let Some(pool_handle) = mcp_pool_handle(&state, query.connect).await? else {
4237 return Ok(Json(McpToolsResponse {
4238 tools: Vec::new(),
4239 connections: Vec::new(),
4240 }));
4241 };
4242 let mut pool = pool_handle.lock().await;
4243 if fresh_config
4244 .as_ref()
4245 .is_some_and(|config| !pool.config_matches(config))
4246 {
4247 let error =
4248 anyhow::anyhow!("MCP configuration changed; reload it before connecting this server");
4249 let names = query
4250 .server
4251 .clone()
4252 .map(|name| vec![name])
4253 .unwrap_or_else(|| pool.server_names());
4254 return Ok(Json(McpToolsResponse {
4255 tools: Vec::new(),
4256 connections: names
4257 .iter()
4258 .map(|name| mcp_connection_outcome(&pool, name, Some(&error)))
4259 .collect(),
4260 }));
4261 }
4262 let errors = if query.connect {
4263 if let Some(server) = query.server.as_deref() {
4264 match pool.get_or_connect(server).await {
4265 Ok(_) => Vec::new(),
4266 Err(error) => vec![(server.to_owned(), error)],
4267 }
4268 } else {
4269 pool.connect_all().await
4270 }
4271 } else {
4272 Vec::new()
4273 };
4274 let mut names = query
4275 .server
4276 .clone()
4277 .map(|name| vec![name])
4278 .unwrap_or_else(|| pool.server_names());
4279 for (server, _) in &errors {
4280 if !names.contains(server) {
4281 names.push(server.clone());
4282 }
4283 }
4284 names.sort();
4285 let connections = names
4286 .iter()
4287 .map(|name| {
4288 mcp_connection_outcome(
4289 &pool,
4290 name,
4291 errors
4292 .iter()
4293 .find(|(server, _)| server == name)
4294 .map(|(_, error)| error),
4295 )
4296 })
4297 .collect();
4298
4299 let mut tools = Vec::new();
4300 for (prefixed_name, tool) in pool.all_tools() {
4301 let Ok((server, name)) = pool.parse_prefixed_name(&prefixed_name) else {
4302 continue;
4303 };
4304
4305 if let Some(filter) = query.server.as_deref()
4306 && server != filter
4307 {
4308 continue;
4309 }
4310
4311 tools.push(McpToolEntry {
4312 server: server.to_string(),
4313 name: name.to_string(),
4314 prefixed_name,
4315 description: tool.description.clone(),
4316 input_schema: tool.input_schema.clone(),
4317 });
4318 }
4319
4320 tools.sort_by(|a, b| a.server.cmp(&b.server).then_with(|| a.name.cmp(&b.name)));
4321
4322 Ok(Json(McpToolsResponse { tools, connections }))
4323 }
4324
4325 /// `GET /v1/apps/mcp/servers/{name}` — fetch a single server's redacted config.
4326 async fn get_mcp_server(
4327 State(state): State<RuntimeApiState>,
4328 Path(name): Path<String>,
4329 ) -> Result<Json<McpServerDetail>, ApiError> {
4330 let ((config, origins), revision) = mcp_management_snapshot(state.clone()).await?;
4331 let server_cfg = config
4332 .servers
4333 .get(&name)
4334 .ok_or_else(|| ApiError::not_found(format!("MCP server '{name}' not found")))?;
4335 let handle = mcp_pool_handle(&state, false).await?;
4336 let pool = match handle.as_ref() {
4337 Some(handle) => Some(handle.lock().await),
4338 None => None,
4339 };
4340 let connected = pool
4341 .as_ref()
4342 .is_some_and(|pool| pool.connected_servers().contains(&name.as_str()));
4343 let mut detail = McpServerDetail::from_config(&name, server_cfg, connected, revision);
4344 detail.origin = origins.get(&name).copied().unwrap_or("unknown");
4345 detail.writable = detail.origin == "global";
4346 detail.auth_required = pool
4347 .as_ref()
4348 .is_some_and(|pool| pool.server_needs_auth(&name));
4349 Ok(Json(detail))
4350 }
4351
4352 /// `POST /v1/apps/mcp/servers` — add a new server to the persistent config.
4353 ///
4354 /// Body: JSON object with all `McpServerWriteRequest` fields **plus** a
4355 /// required top-level `"name"` string that will be the server key.
4356 async fn create_mcp_server(
4357 State(state): State<RuntimeApiState>,
4358 headers: axum::http::HeaderMap,
4359 Json(body): Json<serde_json::Value>,
4360 ) -> Result<(StatusCode, Json<McpServerDetail>), ApiError> {
4361 let name = body
4362 .get("name")
4363 .and_then(|v| v.as_str())
4364 .ok_or_else(|| ApiError::bad_request("'name' is required"))?
4365 .to_string();
4366
4367 if name.trim().is_empty() {
4368 return Err(ApiError::bad_request("'name' must not be empty"));
4369 }
4370
4371 let req: McpServerWriteRequest = serde_json::from_value(body)
4372 .map_err(|e| ApiError::bad_request(format!("Invalid request body: {e}")))?;
4373
4374 if req.command.as_ref().and_then(Option::as_ref).is_none()
4375 && req.url.as_ref().and_then(Option::as_ref).is_none()
4376 {
4377 return Err(ApiError::bad_request(
4378 "Either 'command' or 'url' is required to create an MCP server",
4379 ));
4380 }
4381
4382 if let Some(Some(transport)) = &req.transport {
4383 crate::mcp::validate_mcp_transport(Some(transport.as_str()))
4384 .map_err(|e| ApiError::bad_request(e.to_string()))?;
4385 }
4386
4387 let new_cfg = mcp_server_config_from_write_request(req, None);
4388 let target_name = name.clone();
4389 let (new_cfg, revision) =
4390 mutate_mcp_management(state.clone(), headers, move |state, config| {
4391 if mcp_management_config(state)?
4392 .0
4393 .servers
4394 .contains_key(&target_name)
4395 {
4396 return Err(ApiError {
4397 status: StatusCode::CONFLICT,
4398 message: format!(
4399 "MCP server '{target_name}' already exists in the effective configuration"
4400 ),
4401 });
4402 }
4403 config.servers.insert(target_name, new_cfg.clone());
4404 Ok(new_cfg)
4405 })
4406 .await?;
4407
4408 // Invalidate the in-memory pool so the next tool call reloads from disk.
4409 {
4410 let mut pool_slot = state.mcp_pool.lock().await;
4411 *pool_slot = None;
4412 }
4413
4414 Ok((
4415 StatusCode::CREATED,
4416 Json(McpServerDetail::from_config(
4417 &name, &new_cfg, false, revision,
4418 )),
4419 ))
4420 }
4421
4422 /// `PATCH /v1/apps/mcp/servers/{name}` — update an existing server's config.
4423 async fn update_mcp_server(
4424 State(state): State<RuntimeApiState>,
4425 Path(name): Path<String>,
4426 headers: axum::http::HeaderMap,
4427 Json(req): Json<McpServerWriteRequest>,
4428 ) -> Result<Json<McpServerDetail>, ApiError> {
4429 if let Some(Some(transport)) = &req.transport {
4430 crate::mcp::validate_mcp_transport(Some(transport.as_str()))
4431 .map_err(|e| ApiError::bad_request(e.to_string()))?;
4432 }
4433
4434 let target_name = name.clone();
4435 let (updated_cfg, revision) = mutate_mcp_management(state.clone(), headers, move |state, cfg| {
4436 let name = target_name;
4437 require_writable_mcp_server(state, &name)?;
4438 let existing = cfg
4439 .servers
4440 .get_mut(&name)
4441 .ok_or_else(|| ApiError::not_found(format!("MCP server '{name}' not found")))?;
4442 let previous_target = (
4443 existing.command.clone(),
4444 existing.args.clone(),
4445 existing.url.clone(),
4446 existing.transport.clone(),
4447 );
4448 apply_write_request_to_config(req, existing);
4449 let target_changed = previous_target
4450 != (
4451 existing.command.clone(),
4452 existing.args.clone(),
4453 existing.url.clone(),
4454 existing.transport.clone(),
4455 );
4456 if target_changed && mcp_credential_configured(existing) {
4457 return Err(ApiError {
4458 status: StatusCode::CONFLICT,
4459 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(),
4460 });
4461 }
4462 if existing.command.is_none() && existing.url.is_none() {
4463 return Err(ApiError::bad_request(
4464 "Either 'command' or 'url' must remain configured for an MCP server",
4465 ));
4466 }
4467 Ok(existing.clone())
4468 }).await?;
4469
4470 // Invalidate the in-memory pool.
4471 {
4472 let mut pool_slot = state.mcp_pool.lock().await;
4473 *pool_slot = None;
4474 }
4475
4476 Ok(Json(McpServerDetail::from_config(
4477 &name,
4478 &updated_cfg,
4479 false,
4480 revision,
4481 )))
4482 }
4483
4484 /// `DELETE /v1/apps/mcp/servers/{name}` — remove a server from the persistent config.
4485 async fn delete_mcp_server(
4486 State(state): State<RuntimeApiState>,
4487 Path(name): Path<String>,
4488 headers: axum::http::HeaderMap,
4489 ) -> Result<Json<McpServerActionReceipt>, ApiError> {
4490 let target_name = name.clone();
4491 let (_, revision) = mutate_mcp_management(state.clone(), headers, move |state, cfg| {
4492 require_writable_mcp_server(state, &target_name)?;
4493 cfg.servers
4494 .remove(&target_name)
4495 .ok_or_else(|| ApiError::not_found("MCP server not found"))?;
4496 Ok(())
4497 })
4498 .await?;
4499
4500 // Invalidate the in-memory pool.
4501 {
4502 let mut pool_slot = state.mcp_pool.lock().await;
4503 *pool_slot = None;
4504 }
4505
4506 Ok(Json(McpServerActionReceipt {
4507 revision: Some(revision),
4508 name,
4509 action: "deleted",
4510 ok: true,
4511 connection: None,
4512 }))
4513 }
4514
4515 /// `POST /v1/apps/mcp/servers/{name}/enable` — enable a configured server.
4516 async fn enable_mcp_server(
4517 State(state): State<RuntimeApiState>,
4518 Path(name): Path<String>,
4519 headers: axum::http::HeaderMap,
4520 ) -> Result<Json<McpServerActionReceipt>, ApiError> {
4521 let target_name = name.clone();
4522 let (_, revision) = mutate_mcp_management(state.clone(), headers, move |state, cfg| {
4523 require_writable_mcp_server(state, &target_name)?;
4524 let server = cfg
4525 .servers
4526 .get_mut(&target_name)
4527 .ok_or_else(|| ApiError::not_found("MCP server not found"))?;
4528 server.enabled = true;
4529 server.disabled = false;
4530 Ok(())
4531 })
4532 .await?;
4533
4534 // Invalidate the in-memory pool so the enabled server participates next time.
4535 {
4536 let mut pool_slot = state.mcp_pool.lock().await;
4537 *pool_slot = None;
4538 }
4539
4540 Ok(Json(McpServerActionReceipt {
4541 revision: Some(revision),
4542 name,
4543 action: "enabled",
4544 ok: true,
4545 connection: None,
4546 }))
4547 }
4548
4549 /// `POST /v1/apps/mcp/servers/{name}/disable` — disable a configured server.
4550 async fn disable_mcp_server(
4551 State(state): State<RuntimeApiState>,
4552 Path(name): Path<String>,
4553 headers: axum::http::HeaderMap,
4554 ) -> Result<Json<McpServerActionReceipt>, ApiError> {
4555 let target_name = name.clone();
4556 let (_, revision) = mutate_mcp_management(state.clone(), headers, move |state, cfg| {
4557 require_writable_mcp_server(state, &target_name)?;
4558 let server = cfg
4559 .servers
4560 .get_mut(&target_name)
4561 .ok_or_else(|| ApiError::not_found("MCP server not found"))?;
4562 server.enabled = false;
4563 server.disabled = true;
4564 Ok(())
4565 })
4566 .await?;
4567
4568 // Invalidate the in-memory pool so the disabled server is excluded next time.
4569 {
4570 let mut pool_slot = state.mcp_pool.lock().await;
4571 *pool_slot = None;
4572 }
4573
4574 Ok(Json(McpServerActionReceipt {
4575 revision: Some(revision),
4576 name,
4577 action: "disabled",
4578 ok: true,
4579 connection: None,
4580 }))
4581 }
4582
4583 /// `POST /v1/apps/mcp/servers/{name}/reconnect` — retry only this server and
4584 /// return the actual result without replacing healthy sibling connections.
4585 async fn reconnect_mcp_server(
4586 State(state): State<RuntimeApiState>,
4587 Path(name): Path<String>,
4588 ) -> Result<Json<McpServerActionReceipt>, ApiError> {
4589 let (config, _) = mcp_management_config(&state)?;
4590 if !config.servers.contains_key(&name) {
4591 return Err(ApiError::not_found(format!(
4592 "MCP server '{name}' not found"
4593 )));
4594 }
4595 let handle = mcp_pool_handle(&state, true)
4596 .await?
4597 .ok_or_else(|| ApiError::internal("MCP pool unavailable"))?;
4598 let mut pool = handle.lock().await;
4599 let error = if !config.servers[&name].is_enabled() {
4600 Some(anyhow::anyhow!("MCP server '{name}' is disabled"))
4601 } else if !pool.config_matches(&config) {
4602 Some(anyhow::anyhow!(
4603 "MCP configuration changed; reload it before retrying this server"
4604 ))
4605 } else {
4606 pool.retry_connection(&name).await.err()
4607 };
4608 let connection = mcp_connection_outcome(&pool, &name, error.as_ref());
4609 Ok(Json(McpServerActionReceipt {
4610 revision: None,
4611 name,
4612 action: if error.is_none() {
4613 "reconnected"
4614 } else {
4615 "reconnect_failed"
4616 },
4617 ok: error.is_none() && connection.connected,
4618 connection: Some(connection),
4619 }))
4620 }
4621
4622 /// Build a fresh [`McpServerConfig`] from a create request.
4623 fn mcp_server_config_from_write_request(
4624 req: McpServerWriteRequest,
4625 _existing: Option<&crate::mcp::McpServerConfig>,
4626 ) -> crate::mcp::McpServerConfig {
4627 let enabled = req.enabled.unwrap_or(true);
4628 crate::mcp::McpServerConfig {
4629 command: req.command.flatten(),
4630 args: req.args.unwrap_or_default(),
4631 env: req.env.unwrap_or_default(),
4632 cwd: None,
4633 url: req.url.flatten(),
4634 transport: req.transport.flatten(),
4635 connect_timeout: req.connect_timeout.flatten(),
4636 execute_timeout: req.execute_timeout.flatten(),
4637 read_timeout: req.read_timeout.flatten(),
4638 disabled: !enabled,
4639 enabled,
4640 required: req.required.unwrap_or(false),
4641 enabled_tools: req.enabled_tools.unwrap_or_default(),
4642 disabled_tools: req.disabled_tools.unwrap_or_default(),
4643 headers: std::collections::HashMap::new(),
4644 env_headers: req.env_headers.unwrap_or_default(),
4645 bearer_token_env_var: req.bearer_token_env_var.flatten(),
4646 scopes: req.scopes.unwrap_or_default(),
4647 oauth: None,
4648 oauth_resource: req.oauth_resource.flatten(),
4649 reviewed_plugin: None,
4650 runtime_added: false,
4651 allow_private_network: false,
4652 }
4653 }
4654
4655 /// Nonsecret indicator and retargeting guard. Treat environment and OAuth
4656 /// configuration as authority even when it only references a credential.
4657 fn mcp_credential_configured(cfg: &crate::mcp::McpServerConfig) -> bool {
4658 !cfg.env.is_empty()
4659 || !cfg.headers.is_empty()
4660 || !cfg.env_headers.is_empty()
4661 || cfg.bearer_token_env_var.is_some()
4662 || cfg.oauth.is_some()
4663 || !cfg.scopes.is_empty()
4664 || cfg.oauth_resource.is_some()
4665 }
4666
4667 /// Apply a partial update from a PATCH request onto an existing config entry.
4668 fn apply_write_request_to_config(
4669 req: McpServerWriteRequest,
4670 cfg: &mut crate::mcp::McpServerConfig,
4671 ) {
4672 if let Some(v) = req.command {
4673 cfg.command = v;
4674 }
4675 if let Some(v) = req.args {
4676 cfg.args = v;
4677 }
4678 if let Some(v) = req.env {
4679 cfg.env = v;
4680 }
4681 if let Some(v) = req.url {
4682 cfg.url = v;
4683 }
4684 if let Some(v) = req.transport {
4685 cfg.transport = v;
4686 }
4687 if let Some(v) = req.connect_timeout {
4688 cfg.connect_timeout = v;
4689 }
4690 if let Some(v) = req.execute_timeout {
4691 cfg.execute_timeout = v;
4692 }
4693 if let Some(v) = req.read_timeout {
4694 cfg.read_timeout = v;
4695 }
4696 if let Some(v) = req.enabled {
4697 cfg.enabled = v;
4698 cfg.disabled = !v;
4699 }
4700 if let Some(v) = req.required {
4701 cfg.required = v;
4702 }
4703 if let Some(v) = req.enabled_tools {
4704 cfg.enabled_tools = v;
4705 }
4706 if let Some(v) = req.disabled_tools {
4707 cfg.disabled_tools = v;
4708 }
4709 if let Some(v) = req.env_headers {
4710 cfg.env_headers = v;
4711 }
4712 if let Some(v) = req.bearer_token_env_var {
4713 cfg.bearer_token_env_var = v;
4714 }
4715 if let Some(v) = req.scopes {
4716 cfg.scopes = v;
4717 }
4718 if let Some(v) = req.oauth_resource {
4719 cfg.oauth_resource = v;
4720 }
4721 }
4722
4723 async fn list_automations(
4724 State(state): State<RuntimeApiState>,
4725 ) -> Result<Json<Vec<AutomationRecord>>, ApiError> {
4726 let manager = state.automations.lock().await;
4727 let automations = manager
4728 .list_automations()
4729 .map_err(|e| ApiError::internal(format!("Failed to list automations: {e}")))?;
4730 Ok(Json(automations))
4731 }
4732
4733 async fn create_automation(
4734 State(state): State<RuntimeApiState>,
4735 Json(req): Json<CreateAutomationRequest>,
4736 ) -> Result<(StatusCode, Json<AutomationRecord>), ApiError> {
4737 let manager = state.automations.lock().await;
4738 let automation = manager
4739 .create_automation(req)
4740 .map_err(|e| ApiError::bad_request(e.to_string()))?;
4741 Ok((StatusCode::CREATED, Json(automation)))
4742 }
4743
4744 async fn get_automation(
4745 State(state): State<RuntimeApiState>,
4746 Path(id): Path<String>,
4747 ) -> Result<Json<AutomationRecord>, ApiError> {
4748 let manager = state.automations.lock().await;
4749 let automation = manager.get_automation(&id).map_err(map_automation_err)?;
4750 Ok(Json(automation))
4751 }
4752
4753 async fn update_automation(
4754 State(state): State<RuntimeApiState>,
4755 Path(id): Path<String>,
4756 Json(req): Json<UpdateAutomationRequest>,
4757 ) -> Result<Json<AutomationRecord>, ApiError> {
4758 let manager = state.automations.lock().await;
4759 let automation = manager
4760 .update_automation(&id, req)
4761 .map_err(map_automation_err)?;
4762 Ok(Json(automation))
4763 }
4764
4765 async fn delete_automation(
4766 State(state): State<RuntimeApiState>,
4767 Path(id): Path<String>,
4768 ) -> Result<Json<AutomationRecord>, ApiError> {
4769 let manager = state.automations.lock().await;
4770 let automation = manager.delete_automation(&id).map_err(map_automation_err)?;
4771 Ok(Json(automation))
4772 }
4773
4774 async fn run_automation(
4775 State(state): State<RuntimeApiState>,
4776 Path(id): Path<String>,
4777 ) -> Result<Json<AutomationRunRecord>, ApiError> {
4778 // run_now_shared drops the manager mutex across the task-manager await so
4779 // other automation endpoints stay responsive behind a slow enqueue.
4780 let run =
4781 crate::automation_manager::run_now_shared(&state.automations, &id, &state.task_manager)
4782 .await
4783 .map_err(map_automation_err)?;
4784 Ok(Json(run))
4785 }
4786
4787 async fn pause_automation(
4788 State(state): State<RuntimeApiState>,
4789 Path(id): Path<String>,
4790 ) -> Result<Json<AutomationRecord>, ApiError> {
4791 let manager = state.automations.lock().await;
4792 let automation = manager.pause_automation(&id).map_err(map_automation_err)?;
4793 Ok(Json(automation))
4794 }
4795
4796 async fn resume_automation(
4797 State(state): State<RuntimeApiState>,
4798 Path(id): Path<String>,
4799 ) -> Result<Json<AutomationRecord>, ApiError> {
4800 let manager = state.automations.lock().await;
4801 let automation = manager.resume_automation(&id).map_err(map_automation_err)?;
4802 Ok(Json(automation))
4803 }
4804
4805 async fn list_automation_runs(
4806 State(state): State<RuntimeApiState>,
4807 Path(id): Path<String>,
4808 Query(query): Query<AutomationRunsQuery>,
4809 ) -> Result<Json<Vec<AutomationRunRecord>>, ApiError> {
4810 let manager = state.automations.lock().await;
4811 let runs = manager
4812 .list_runs(&id, query.limit)
4813 .map_err(map_automation_err)?;
4814 Ok(Json(runs))
4815 }
4816
4817 #[derive(Debug, Deserialize, Default)]
4818 #[serde(rename_all = "camelCase")]
4819 struct StartOperateRequest {
4820 #[serde(default)]
4821 direction: Option<String>,
4822 /// CWC `OperateBurnRate` object, positive $/hr number, or null (unbounded).
4823 #[serde(default)]
4824 burn_rate: Option<serde_json::Value>,
4825 }
4826
4827 #[derive(Debug, Deserialize, Default)]
4828 #[serde(rename_all = "camelCase")]
4829 struct KeepAliveOperateRequest {
4830 #[serde(default)]
4831 spent_usd: Option<f64>,
4832 #[serde(default)]
4833 observed_burn_usd_per_hour: Option<f64>,
4834 #[serde(default)]
4835 credentials_present: Option<bool>,
4836 #[serde(default)]
4837 human_gated: Option<bool>,
4838 }
4839
4840 #[derive(Debug, Serialize)]
4841 struct OperateView {
4842 /// `None` until an operation is actually started — a GET before that
4843 /// must not fabricate an identity the client can never mutate.
4844 operation: Option<crate::operate::Operation>,
4845 board: String,
4846 }
4847
4848 fn operate_store() -> Result<crate::operate::OperationStore, ApiError> {
4849 crate::operate::OperationStore::open(crate::operate::default_operate_dir())
4850 .map_err(|e| ApiError::internal(format!("Failed to open operate store: {e}")))
4851 }
4852
4853 async fn operate_readiness(state: &RuntimeApiState) -> Result<(String, bool), ApiError> {
4854 let config = state.config.read().clone();
4855 let manager = state.automations.lock().await;
4856 crate::operate::keepalive_readiness(&manager, &config, None)
4857 .map_err(|error| ApiError::bad_request(format!("Operate route unavailable: {error}")))
4858 }
4859
4860 fn operate_view(operation: crate::operate::Operation) -> Json<OperateView> {
4861 Json(OperateView {
4862 board: crate::operate::render_plan_board(&operation),
4863 operation: Some(operation),
4864 })
4865 }
4866
4867 fn load_operate(
4868 store: &crate::operate::OperationStore,
4869 ) -> Result<Option<crate::operate::Operation>, ApiError> {
4870 store
4871 .load()
4872 .map_err(|e| ApiError::internal(format!("Failed to load operate: {e}")))
4873 }
4874
4875 fn parse_request_burn_rate(value: Option<&serde_json::Value>) -> Result<Option<f64>, ApiError> {
4876 Ok(crate::operate::parse_burn_rate(value)
4877 .map_err(|e| ApiError::bad_request(e.to_string()))?
4878 .map(|rate| rate.amount_usd_per_hour))
4879 }
4880
4881 async fn get_operate(State(_state): State<RuntimeApiState>) -> Result<Json<OperateView>, ApiError> {
4882 let store = operate_store()?;
4883 match load_operate(&store)? {
4884 Some(operation) => Ok(operate_view(operation)),
4885 // No operation has been started: a fabricated `Operation::new` would
4886 // mint a fresh id and timestamps on every poll — phantom records the
4887 // client can neither patch nor cancel. `operation: null` is the
4888 // stable no-operation answer.
4889 None => Ok(Json(OperateView {
4890 operation: None,
4891 board: String::new(),
4892 })),
4893 }
4894 }
4895
4896 async fn start_operate(
4897 State(state): State<RuntimeApiState>,
4898 Json(req): Json<StartOperateRequest>,
4899 ) -> Result<Json<OperateView>, ApiError> {
4900 let store = operate_store()?;
4901 let burn = parse_request_burn_rate(req.burn_rate.as_ref())?;
4902 // Keepalive first: a persisted operation without its keepalive is not
4903 // always-on, and a fresh operation has no lead plan yet — kick the first
4904 // lead run to the next scheduler tick instead of waiting out the hourly
4905 // recurrence.
4906 let config = state.config.read().clone();
4907 let (model, credentials) = {
4908 let manager = state.automations.lock().await;
4909 crate::operate::upsert_keepalive(&manager, &state.workspace, true, &config, None)
4910 .map_err(|e| ApiError::bad_request(format!("Failed to keep operate alive: {e}")))?
4911 };
4912 let operation = crate::operate::start_operation(
4913 &store,
4914 &state.workspace,
4915 req.direction,
4916 burn,
4917 credentials,
4918 &model,
4919 )
4920 .map_err(|e| ApiError::bad_request(e.to_string()))?;
4921 Ok(operate_view(operation))
4922 }
4923
4924 async fn patch_operate(
4925 State(state): State<RuntimeApiState>,
4926 Json(patch): Json<serde_json::Value>,
4927 ) -> Result<Json<OperateView>, ApiError> {
4928 let store = operate_store()?;
4929 let (model, credentials) = operate_readiness(&state).await?;
4930 // Read-merge-write under the operate store lock: a concurrent keepalive
4931 // or plan save can no longer be lost by a stale read.
4932 let direction_changed = std::cell::Cell::new(false);
4933 let operation = store
4934 .mutate(|op| {
4935 let before = op.direction.clone();
4936 crate::operate::apply_operate_patch(op, &patch)?;
4937 direction_changed.set(op.direction != before);
4938 op.set_lead_model(&model);
4939 op.credentials_present = credentials;
4940 op.project();
4941 Ok(())
4942 })
4943 .map_err(|e| {
4944 if e.to_string().contains("cancelled") {
4945 ApiError::conflict(e.to_string())
4946 } else {
4947 ApiError::bad_request(e.to_string())
4948 }
4949 })?
4950 .ok_or_else(|| ApiError::not_found("Unknown Operation."))?;
4951 // A changed direction invalidated the lead plan; pull the keepalive lead
4952 // run forward so the operation does not idle until the next recurrence.
4953 if direction_changed.get() {
4954 let manager = state.automations.lock().await;
4955 crate::operate::kick_keepalive(&manager)
4956 .map_err(|e| ApiError::internal(format!("Failed to reschedule operate: {e}")))?;
4957 }
4958 Ok(operate_view(operation))
4959 }
4960
4961 async fn keepalive_operate(
4962 State(state): State<RuntimeApiState>,
4963 Json(req): Json<KeepAliveOperateRequest>,
4964 ) -> Result<Json<OperateView>, ApiError> {
4965 let store = operate_store()?;
4966 let (model, credentials) = match req.credentials_present {
4967 Some(observed) => (None, observed),
4968 None => {
4969 let (model, credentials) = operate_readiness(&state).await?;
4970 (Some(model), credentials)
4971 }
4972 };
4973 let operation = store
4974 .mutate(|op| {
4975 if let Some(model) = &model {
4976 op.set_lead_model(model);
4977 }
4978 crate::operate::keep_alive_observation(
4979 op,
4980 req.observed_burn_usd_per_hour,
4981 req.spent_usd,
4982 Some(credentials),
4983 req.human_gated,
4984 );
4985 Ok(())
4986 })
4987 .map_err(|e| ApiError::internal(format!("Failed to keep operate alive: {e}")))?
4988 .ok_or_else(|| ApiError::not_found("Unknown Operation."))?;
4989 Ok(operate_view(operation))
4990 }
4991
4992 async fn put_operate_plan(
4993 Json(plan): Json<serde_json::Value>,
4994 ) -> Result<Json<OperateView>, ApiError> {
4995 let store = operate_store()?;
4996 let patch = serde_json::json!({ "leadPlan": plan });
4997 let operation = store
4998 .mutate(|op| crate::operate::apply_operate_patch(op, &patch))
4999 .map_err(|e| {
5000 if e.to_string().contains("cancelled") {
5001 ApiError::conflict(e.to_string())
5002 } else if e.to_string().contains("leadPlan") {
5003 ApiError::bad_request(e.to_string())
5004 } else {
5005 ApiError::internal(format!("Failed to save operate plan: {e}"))
5006 }
5007 })?
5008 .ok_or_else(|| ApiError::not_found("Unknown Operation."))?;
5009 Ok(operate_view(operation))
5010 }
5011
5012 async fn cancel_operate(
5013 State(state): State<RuntimeApiState>,
5014 ) -> Result<Json<OperateView>, ApiError> {
5015 let store = operate_store()?;
5016 let operation = crate::operate::cancel_operation(&store)
5017 .map_err(|e| ApiError::internal(format!("Failed to cancel operate: {e}")))?
5018 .ok_or_else(|| ApiError::not_found("Unknown Operation."))?;
5019 // Cancel tears down the keepalive too: an unattended hourly lead run
5020 // after cancel is pure cost.
5021 {
5022 let manager = state.automations.lock().await;
5023 crate::operate::pause_keepalive(&manager)
5024 .map_err(|e| ApiError::internal(format!("Failed to pause operate keepalive: {e}")))?;
5025 }
5026 Ok(operate_view(operation))
5027 }
5028
5029 #[derive(Debug, Deserialize)]
5030 struct OperateAutoMergeCheckRequest {
5031 repo: String,
5032 pr: String,
5033 agent: String,
5034 }
5035
5036 #[derive(Debug, Serialize)]
5037 #[serde(rename_all = "camelCase")]
5038 struct OperateAutoMergeCheckView {
5039 allow: bool,
5040 reason: Option<String>,
5041 checker: Option<String>,
5042 check_args: Vec<String>,
5043 merge_args: Vec<String>,
5044 }
5045
5046 async fn check_operate_auto_merge(
5047 State(state): State<RuntimeApiState>,
5048 Json(req): Json<OperateAutoMergeCheckRequest>,
5049 ) -> Result<Json<OperateAutoMergeCheckView>, ApiError> {
5050 let checker = crate::operate::discover_auto_merge_checker(&state.workspace);
5051 let repo = req.repo.clone();
5052 let pr = req.pr.clone();
5053 let agent = req.agent.clone();
5054 let checker_for_task = checker.clone();
5055 // The checker shells out synchronously (`python3 …; .status()`); run it on
5056 // the blocking pool so a slow `gh`/network wait cannot pin a Tokio worker.
5057 let decision = tokio::task::spawn_blocking(move || {
5058 crate::operate::evaluate_auto_merge(
5059 crate::operate::AutoMergeRequest {
5060 repo: &repo,
5061 pr: &pr,
5062 role: &agent,
5063 },
5064 checker_for_task.as_deref(),
5065 )
5066 })
5067 .await
5068 .map_err(|e| ApiError::internal(format!("auto-merge check join failed: {e}")))?;
5069 let (allow, reason) = match decision {
5070 crate::operate::AutoMergeDecision::Allow => (true, None),
5071 crate::operate::AutoMergeDecision::Deny { reason } => (false, Some(reason)),
5072 };
5073 Ok(Json(OperateAutoMergeCheckView {
5074 allow,
5075 reason,
5076 checker: checker.as_ref().map(|path| path.display().to_string()),
5077 check_args: crate::operate::check_auto_merge_args(&req.repo, &req.pr, &req.agent),
5078 merge_args: crate::operate::auto_merge_pr_args(&req.repo, &req.pr, &req.agent),
5079 }))
5080 }
5081
5082 async fn get_thread(
5083 State(state): State<RuntimeApiState>,
5084 Path(id): Path<String>,
5085 ) -> Result<Json<ThreadDetail>, ApiError> {
5086 let detail = state
5087 .runtime_threads
5088 .get_thread_detail(&id)
5089 .await
5090 .map_err(map_thread_err)?;
5091 Ok(Json(detail))
5092 }
5093
5094 /// Response for `GET /v1/threads/{id}/usage`.
5095 ///
5096 /// Thin adapter over `RuntimeThreadManager::aggregate_usage_for_thread`: the
5097 /// GUI's session-cost surface reads provider-aware, recorded-time pricing in
5098 /// both published currencies from the same accumulation that powers
5099 /// `/v1/usage`, instead of reimplementing rate tables client-side.
5100 #[derive(Debug, Serialize)]
5101 struct ThreadUsageResponse {
5102 thread_id: String,
5103 totals: UsageTotals,
5104 }
5105
5106 async fn get_thread_usage(
5107 State(state): State<RuntimeApiState>,
5108 Path(id): Path<String>,
5109 ) -> Result<Json<ThreadUsageResponse>, ApiError> {
5110 let totals = state
5111 .runtime_threads
5112 .aggregate_usage_for_thread(&id)
5113 .await
5114 .map_err(map_thread_err)?
5115 .combined();
5116 Ok(Json(ThreadUsageResponse {
5117 thread_id: id,
5118 totals,
5119 }))
5120 }
5121
5122 async fn update_thread(
5123 State(state): State<RuntimeApiState>,
5124 Path(id): Path<String>,
5125 Json(req): Json<UpdateThreadRequest>,
5126 ) -> Result<Json<ThreadRecord>, ApiError> {
5127 let thread = state
5128 .runtime_threads
5129 .update_thread_with_shell_policy(
5130 &id,
5131 req,
5132 state.config_path.as_deref(),
5133 state.config_profile.as_deref(),
5134 )
5135 .await
5136 .map_err(map_thread_err)?;
5137 Ok(Json(thread))
5138 }
5139
5140 async fn resume_thread(
5141 State(state): State<RuntimeApiState>,
5142 Path(id): Path<String>,
5143 ) -> Result<Json<ThreadRecord>, ApiError> {
5144 let thread = state
5145 .runtime_threads
5146 .resume_thread(&id)
5147 .await
5148 .map_err(map_thread_err)?;
5149 Ok(Json(thread))
5150 }
5151
5152 async fn fork_thread(
5153 State(state): State<RuntimeApiState>,
5154 Path(id): Path<String>,
5155 ) -> Result<(StatusCode, Json<ThreadRecord>), ApiError> {
5156 let thread = state
5157 .runtime_threads
5158 .fork_thread(&id)
5159 .await
5160 .map_err(map_thread_err)?;
5161 Ok((StatusCode::CREATED, Json(thread)))
5162 }
5163
5164 #[derive(Debug, Deserialize)]
5165 struct UndoTurnRequest {
5166 /// How many turns back to undo (default 0 = last turn only).
5167 #[serde(default)]
5168 depth: Option<usize>,
5169 }
5170
5171 #[derive(Debug, Serialize)]
5172 struct UndoTurnResponse {
5173 /// The new forked thread (with the last N turns removed).
5174 thread: ThreadRecord,
5175 /// The original user message text from the first dropped turn,
5176 /// so the GUI can pre-populate the input box.
5177 original_user_text: Option<String>,
5178 #[serde(skip_serializing_if = "Vec::is_empty")]
5179 original_user_images: Vec<codewhale_protocol::runtime::RuntimeImageInput>,
5180 }
5181
5182 async fn undo_thread_turn(
5183 State(state): State<RuntimeApiState>,
5184 Path(id): Path<String>,
5185 Json(req): Json<UndoTurnRequest>,
5186 ) -> Result<(StatusCode, Json<UndoTurnResponse>), ApiError> {
5187 let depth = req.depth.unwrap_or(0);
5188 let (forked_thread, original_user_text, original_user_images, _) = state
5189 .runtime_threads
5190 .fork_at_user_message(&id, depth)
5191 .await
5192 .map_err(map_thread_err)?;
5193 Ok((
5194 StatusCode::CREATED,
5195 Json(UndoTurnResponse {
5196 thread: forked_thread,
5197 original_user_text,
5198 original_user_images,
5199 }),
5200 ))
5201 }
5202
5203 /// Result of the snapshot-based file rollback step of patch-undo, reported
5204 /// alongside the new forked thread.
5205 #[derive(Debug, Serialize)]
5206 struct PatchUndoResult {
5207 /// Whether files were restored from a snapshot.
5208 files_restored: bool,
5209 /// Human-readable summary of what was restored (diff stat).
5210 summary: Option<String>,
5211 /// The label of the restored snapshot (e.g. "tool:apply_patch" or "pre-turn:3").
5212 snapshot_label: Option<String>,
5213 }
5214
5215 #[derive(Debug, Serialize)]
5216 struct PatchUndoResponse {
5217 /// Result of the snapshot-based file rollback step.
5218 patch_result: PatchUndoResult,
5219 /// The new forked thread (with the last turn removed).
5220 thread: ThreadRecord,
5221 /// The original user text from the removed turn (for re-editing).
5222 original_user_text: Option<String>,
5223 #[serde(skip_serializing_if = "Vec::is_empty")]
5224 original_user_images: Vec<codewhale_protocol::runtime::RuntimeImageInput>,
5225 }
5226
5227 async fn patch_undo_thread_turn(
5228 State(state): State<RuntimeApiState>,
5229 Path(id): Path<String>,
5230 Json(req): Json<UndoTurnRequest>,
5231 ) -> Result<(StatusCode, Json<PatchUndoResponse>), ApiError> {
5232 let depth = req.depth.unwrap_or(0);
5233 // Admission first, then the thread record under it: trust, session
5234 // binding and workspace are the values that hold while files change.
5235 // Active turns in an overlapping workspace are rejected (409). The wait
5236 // for admission stays on the request so a client that gives up while
5237 // queued cancels its undo instead of leaving it queued behind the next
5238 // one and walking the workspace back twice.
5239 let (reservation, thread) = state
5240 .runtime_threads
5241 .thread_restore_guard(&id)
5242 .await
5243 .map_err(map_thread_err)?;
5244 // Once admitted, own the operation even when the HTTP caller disconnects:
5245 // the reservation must outlive both the file mutation and the fork
5246 // publication, so a dropped connection cannot release it mid-Git.
5247 tokio::spawn(async move {
5248 let reservation = reservation;
5249 // Validate depth/history before touching any file, so an invalid
5250 // undo request cannot leave a half-applied workspace.
5251 let prepared = state
5252 .runtime_threads
5253 .prepare_fork_at_user_message(&id, depth)
5254 .await
5255 .map_err(map_thread_err)?;
5256 // File rollback is a workspace mutation, so it needs the trust the
5257 // TUI's `/undo` requires. Read from the thread's own record: the
5258 // client does not get to assert it.
5259 let trusted = thread.trust_mode || thread.auto_approve;
5260 let workspace = thread.workspace.clone();
5261 let session_id = thread.session_id.clone();
5262 // Step 1: snapshot-based file rollback. The `?` is deliberate: a
5263 // refusal or a failed restore aborts *before* the conversation is
5264 // forked, so the turn never disappears while its file changes stay.
5265 let patch_result = tokio::task::spawn_blocking(move || {
5266 patch_undo_workspace_files(&workspace, session_id.as_deref(), trusted)
5267 })
5268 .await
5269 .map_err(|e| ApiError::internal(format!("Patch undo task failed: {e}")))??;
5270 // Step 2: publish the already-validated fork.
5271 let (forked_thread, original_user_text, original_user_images, _) = state
5272 .runtime_threads
5273 .publish_prepared_fork(prepared)
5274 .await
5275 .map_err(|error| {
5276 if patch_result.files_restored {
5277 ApiError::internal(format!(
5278 "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.",
5279 patch_result
5280 .snapshot_label
5281 .as_deref()
5282 .unwrap_or("(unknown)")
5283 ))
5284 } else {
5285 map_thread_err(error)
5286 }
5287 })?;
5288 drop(reservation);
5289 Ok((
5290 StatusCode::CREATED,
5291 Json(PatchUndoResponse {
5292 patch_result,
5293 thread: forked_thread,
5294 original_user_text,
5295 original_user_images,
5296 }),
5297 ))
5298 })
5299 .await
5300 .map_err(|e| ApiError::internal(format!("Patch undo task failed: {e}")))?
5301 }
5302
5303 /// Restore the newest `tool:` or `pre-turn:` snapshot that differs from the
5304 /// current workspace — same target selection as the TUI's `patch_undo`.
5305 ///
5306 /// # The rollback contract
5307 ///
5308 /// `Ok` is a decision the conversation fork may proceed on: either the files
5309 /// were restored, or there was *provably* nothing to restore. `Err` aborts the
5310 /// whole undo, and the caller must not fork either — dropping the turn while
5311 /// leaving its file changes on disk hands the user a workspace the transcript
5312 /// can no longer account for, which is worse than refusing outright.
5313 ///
5314 /// `trusted` mirrors the gate the TUI's `patch_undo()` applies
5315 /// (`yolo || trust_mode`). It is evaluated *after* a real target is found, so
5316 /// that "there was nothing to revert" still undoes the conversation, while
5317 /// "there is something to revert but you are not trusted" aborts.
5318 fn patch_undo_workspace_files(
5319 workspace: &FsPath,
5320 current_session_id: Option<&str>,
5321 trusted: bool,
5322 ) -> Result<PatchUndoResult, ApiError> {
5323 // An unreadable workspace directory (unmounted volume, disconnected
5324 // share, permissions) proves nothing about the files a turn changed, so
5325 // the conversation is not forked away from them. Every repository
5326 // failure is operational and aborts for the same reason: "no snapshots"
5327 // cannot be proven while Git is unavailable.
5328 if !workspace.is_dir() {
5329 return Err(ApiError::conflict(format!(
5330 "Workspace directory {} is not available; mount or restore it before undoing files, or use /undo for a conversation-only undo.",
5331 workspace.display()
5332 )));
5333 }
5334 let repo = crate::snapshot::SnapshotRepo::open_or_init(workspace).map_err(|e| {
5335 ApiError::internal(format!(
5336 "Snapshot repo unavailable; conversation preserved: {e}"
5337 ))
5338 })?;
5339 let Some(current_session_id) = current_session_id else {
5340 return Ok(PatchUndoResult {
5341 files_restored: false,
5342 summary: Some(
5343 "No current session is bound to this thread; workspace files were not changed."
5344 .to_string(),
5345 ),
5346 snapshot_label: None,
5347 });
5348 };
5349 let snapshots = repo
5350 .list(100)
5351 .map_err(|e| ApiError::internal(format!("Failed to list snapshots: {e}")))?;
5352 let mut target = None;
5353 for snapshot in snapshots
5354 .iter()
5355 .filter(|s| s.label.starts_with("tool:") || s.label.starts_with("pre-turn:"))
5356 .filter(|s| s.session_id.as_deref() == Some(current_session_id))
5357 {
5358 if !repo.work_tree_matches_snapshot(&snapshot.id).map_err(|e| {
5359 ApiError::internal(format!(
5360 "Failed to compare snapshot; conversation preserved: {e}"
5361 ))
5362 })? {
5363 target = Some(snapshot);
5364 break;
5365 }
5366 }
5367 let Some(target) = target else {
5368 return Ok(PatchUndoResult {
5369 files_restored: false,
5370 summary: Some(
5371 "No current-session tool or pre-turn snapshots differ from the current workspace."
5372 .to_string(),
5373 ),
5374 snapshot_label: None,
5375 });
5376 };
5377
5378 // Restoring is a workspace mutation. Gate it exactly where the TUI gates
5379 // it — after a real, current-session target is known — so the two surfaces
5380 // cannot drift into "one refuses, the other half-undoes".
5381 if !trusted {
5382 return Err(ApiError::conflict(
5383 "Refusing to undo workspace files outside trusted mode. \
5384 Turn on /trust or switch this thread to Full Access, then undo again.",
5385 ));
5386 }
5387
5388 // Capture what this restore is about to change *before* it runs: after the
5389 // checkout the work tree matches the snapshot, so a post-restore stat would
5390 // always be empty. Runs against the side repo, not the user's — the user's
5391 // `git diff --stat` reports their own uncommitted work, which is not what
5392 // the undo changed.
5393 let diff_stat = match repo.snapshot_diff_stat(&target.id) {
5394 Ok(stat) => stat,
5395 Err(e) => {
5396 tracing::warn!(
5397 target: "snapshot",
5398 "diff stat for the patch-undo summary failed: {e}"
5399 );
5400 None
5401 }
5402 };
5403
5404 repo.restore(&target.id)
5405 .map_err(|e| ApiError::internal(format!("Restore failed: {e}")))?;
5406
5407 let short = &target.id.as_str()[..target.id.as_str().len().min(8)];
5408 let summary = match diff_stat {
5409 Some(ref stat) => format!(
5410 "Restored snapshot '{}' ({}). Files affected:\n{stat}",
5411 target.label, short
5412 ),
5413 None => format!(
5414 "Restored snapshot '{}' ({}). No diff stat available.",
5415 target.label, short
5416 ),
5417 };
5418 Ok(PatchUndoResult {
5419 files_restored: true,
5420 summary: Some(summary),
5421 snapshot_label: Some(target.label.clone()),
5422 })
5423 }
5424
5425 #[derive(Debug, Deserialize)]
5426 struct RevertThreadFileRequest {
5427 /// The single file to restore, relative to the thread's workspace.
5428 /// Absolute paths inside the workspace are accepted and normalized.
5429 path: String,
5430 /// Exact pre-tool/pre-turn snapshot from the change the user selected.
5431 snapshot_id: String,
5432 /// SHA-256 of the bytes reviewed by the client, or `absent` for deletion.
5433 expected_hash: String,
5434 }
5435
5436 #[derive(Debug, Serialize)]
5437 struct RevertThreadFileResponse {
5438 /// Workspace-relative path that was restored.
5439 path: String,
5440 /// What the restore did to the working tree: `modified`, `recreated`, or
5441 /// `removed`.
5442 action: String,
5443 /// Snapshot the file came from.
5444 snapshot_id: String,
5445 snapshot_label: String,
5446 }
5447
5448 /// Restore one deliberately selected file revision.
5449 ///
5450 /// The file-scoped counterpart of `patch-undo`. Where `patch-undo` checks out
5451 /// a whole snapshot tree, this restores exactly one regular file, so unrelated
5452 /// working-tree changes are never rolled back. The client names the exact
5453 /// `tool:`/`pre-turn:` snapshot from the change record it displayed and the
5454 /// hash of the bytes it reviewed; the server never guesses a "newest differing"
5455 /// snapshot, because an unrelated newer snapshot can erase later user edits.
5456 ///
5457 /// Ownership follows the rule the TUI's `/undo` applies: only snapshots tagged
5458 /// with this thread's own session are candidates, and the thread must be in
5459 /// trusted mode or Full Access. Nothing to revert is a `409`, not a silent
5460 /// success, so the GUI can tell the user why the button did nothing.
5461 async fn revert_thread_file(
5462 State(state): State<RuntimeApiState>,
5463 Path(id): Path<String>,
5464 Json(req): Json<RevertThreadFileRequest>,
5465 ) -> Result<Json<RevertThreadFileResponse>, ApiError> {
5466 if !snapshot_id_is_well_formed(&req.snapshot_id) {
5467 return Err(ApiError::bad_request(
5468 "snapshot_id must be the exact hexadecimal id reported by GET /v1/snapshots",
5469 ));
5470 }
5471 if !expected_hash_is_well_formed(&req.expected_hash) {
5472 return Err(ApiError::bad_request(
5473 "expected_hash must be `sha256:<64 lowercase hex digits>` of the reviewed file bytes, or `absent` for a file the client saw as deleted",
5474 ));
5475 }
5476 // Admission first, then the thread record under it. Active turns in an
5477 // overlapping workspace are rejected instead of raced.
5478 let (reservation, thread) = state
5479 .runtime_threads
5480 .thread_restore_guard(&id)
5481 .await
5482 .map_err(map_thread_err)?;
5483 if !(thread.trust_mode || thread.auto_approve) {
5484 return Err(ApiError::conflict(
5485 "Refusing to restore workspace files outside trusted mode. Turn on /trust or switch this thread to Full Access, then retry.",
5486 ));
5487 }
5488 let Some(session_id) = thread.session_id else {
5489 return Err(ApiError::conflict(
5490 "Thread has no bound session, so no snapshot can be proven to own this file.",
5491 ));
5492 };
5493 let workspace = thread.workspace;
5494 // The worker owns the reservation: a client disconnect cannot release it
5495 // while Git is still changing files. Snapshot listing, diffing and
5496 // checkout all shell out to git; keep that off the async workers.
5497 let response = tokio::task::spawn_blocking(move || {
5498 let _reservation = reservation;
5499 revert_file_from_snapshot(&workspace, &session_id, &req)
5500 })
5501 .await
5502 .map_err(|e| ApiError::internal(format!("file restore task failed: {e}")))??;
5503 Ok(Json(response))
5504 }
5505
5506 fn snapshot_id_is_well_formed(id: &str) -> bool {
5507 matches!(id.len(), 40 | 64) && id.bytes().all(|b| b.is_ascii_hexdigit())
5508 }
5509
5510 fn expected_hash_is_well_formed(hash: &str) -> bool {
5511 hash == "absent"
5512 || hash.strip_prefix("sha256:").is_some_and(|digest| {
5513 digest.len() == 64
5514 && digest
5515 .bytes()
5516 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
5517 })
5518 }
5519
5520 fn revert_file_from_snapshot(
5521 workspace: &FsPath,
5522 session_id: &str,
5523 req: &RevertThreadFileRequest,
5524 ) -> Result<RevertThreadFileResponse, ApiError> {
5525 // Every caller-supplied path passes through this one gate. It accepts a
5526 // workspace-relative path or an absolute path inside the workspace and
5527 // rejects everything else (`..`, empty, or outside the work tree). The
5528 // name is used literally: brackets, spaces and glob characters are part
5529 // of the filename, never a pattern.
5530 let rel = crate::snapshot::workspace_relative_path(workspace, &req.path).ok_or_else(|| {
5531 ApiError::bad_request(format!(
5532 "path must name a regular file inside the thread workspace {}; got '{}'",
5533 workspace.display(),
5534 req.path
5535 ))
5536 })?;
5537 if !workspace.is_dir() {
5538 return Err(ApiError::conflict(format!(
5539 "Workspace directory {} is not available; mount or restore it before restoring files.",
5540 workspace.display()
5541 )));
5542 }
5543 let repo = crate::snapshot::SnapshotRepo::open_or_init(workspace)
5544 .map_err(|e| ApiError::internal(format!("Snapshot repo unavailable: {e}")))?;
5545 repo.validate_restore_file(&rel)
5546 .map_err(map_file_restore_err)?;
5547 let snapshots = repo
5548 .list(usize::MAX)
5549 .map_err(|e| ApiError::internal(format!("Failed to list snapshots: {e}")))?;
5550 // Exact identity only: the snapshot must exist, be owned by this thread's
5551 // session and be a tool/pre-turn restore point. A stale or foreign id is
5552 // a conflict the client resolves by refreshing its change record.
5553 let target = snapshots
5554 .iter()
5555 .find(|snapshot| {
5556 snapshot.id.as_str() == req.snapshot_id
5557 && snapshot.session_id.as_deref() == Some(session_id)
5558 && (snapshot.label.starts_with("tool:")
5559 || snapshot.label.starts_with("pre-turn:"))
5560 })
5561 .ok_or_else(|| {
5562 ApiError::conflict(
5563 "Selected restore point is unavailable or belongs to another session; refresh the change record and select the change again.",
5564 )
5565 })?;
5566
5567 if !repo
5568 .path_differs_from_snapshot(&target.id, &rel)
5569 .map_err(map_file_restore_err)?
5570 {
5571 return Err(ApiError::conflict(format!(
5572 "'{}' already matches snapshot '{}'; nothing to revert.",
5573 rel.display(),
5574 target.label
5575 )));
5576 }
5577 let outcomes = repo
5578 .restore_file_if_unchanged(&target.id, &rel, &req.expected_hash)
5579 .map_err(map_file_restore_err)?;
5580 let outcome = outcomes
5581 .into_iter()
5582 .next()
5583 .ok_or_else(|| ApiError::conflict("Nothing was restored."))?;
5584 Ok(RevertThreadFileResponse {
5585 path: outcome.path.to_string_lossy().into_owned(),
5586 action: outcome.action.as_str().to_string(),
5587 snapshot_id: target.id.as_str().to_string(),
5588 snapshot_label: target.label.clone(),
5589 })
5590 }
5591
5592 fn map_file_restore_err(error: std::io::Error) -> ApiError {
5593 if error.kind() == std::io::ErrorKind::InvalidInput {
5594 ApiError::bad_request(error.to_string())
5595 } else if error.kind() == std::io::ErrorKind::WouldBlock {
5596 ApiError::conflict(error.to_string())
5597 } else {
5598 ApiError::internal(format!("File restore failed: {error}"))
5599 }
5600 }
5601
5602 #[derive(Debug, Deserialize)]
5603 struct RetryTurnRequest {
5604 /// How many turns back to retry (default 0 = last turn only).
5605 #[serde(default)]
5606 depth: Option<usize>,
5607 /// Override the user message text. If omitted, the original text
5608 /// from the dropped turn is re-used.
5609 #[serde(default)]
5610 prompt: Option<String>,
5611 }
5612
5613 #[derive(Debug, Serialize)]
5614 struct RetryTurnResponse {
5615 /// The new forked thread (with the last N turns removed).
5616 thread: ThreadRecord,
5617 /// The turn created by the retry.
5618 turn: TurnRecord,
5619 }
5620
5621 async fn retry_thread_turn(
5622 State(state): State<RuntimeApiState>,
5623 Path(id): Path<String>,
5624 Json(req): Json<RetryTurnRequest>,
5625 ) -> Result<(StatusCode, Json<RetryTurnResponse>), ApiError> {
5626 let depth = req.depth.unwrap_or(0);
5627 let (forked_thread, original_user_text, original_user_images, max_output_tokens) = state
5628 .runtime_threads
5629 .fork_at_user_message(&id, depth)
5630 .await
5631 .map_err(map_thread_err)?;
5632
5633 let retry_prompt = req.prompt.or(original_user_text).unwrap_or_default();
5634 if retry_prompt.trim().is_empty() {
5635 return Err(ApiError::bad_request(
5636 "No user message to retry — the dropped turn had no user text",
5637 ));
5638 }
5639
5640 let turn = state
5641 .runtime_threads
5642 .start_turn_from_stored_images(
5643 &forked_thread.id,
5644 StartTurnRequest {
5645 max_output_tokens,
5646 prompt: retry_prompt,
5647 images: original_user_images,
5648 operation_key: None,
5649 input_summary: None,
5650 model: None,
5651 reasoning_effort: None,
5652 allowed_tools: None,
5653 mode: None,
5654 permission_posture: None,
5655 allow_shell: None,
5656 trust_mode: None,
5657 auto_approve: None,
5658 dynamic_tools: Vec::new(),
5659 environment_id: None,
5660 },
5661 )
5662 .await
5663 .map_err(map_thread_err)?;
5664
5665 Ok((
5666 StatusCode::CREATED,
5667 Json(RetryTurnResponse {
5668 thread: forked_thread,
5669 turn,
5670 }),
5671 ))
5672 }
5673
5674 async fn start_thread_turn(
5675 State(state): State<RuntimeApiState>,
5676 Path(id): Path<String>,
5677 Json(req): Json<StartTurnRequest>,
5678 ) -> Result<(StatusCode, Json<StartTurnResponse>), ApiError> {
5679 let turn = state
5680 .runtime_threads
5681 .start_turn(&id, req)
5682 .await
5683 .map_err(map_thread_err)?;
5684 let thread = state
5685 .runtime_threads
5686 .get_thread(&id)
5687 .await
5688 .map_err(map_thread_err)?;
5689 Ok((
5690 StatusCode::CREATED,
5691 Json(StartTurnResponse { thread, turn }),
5692 ))
5693 }
5694
5695 async fn get_thread_turn_operation(
5696 State(state): State<RuntimeApiState>,
5697 Path((id, operation_key)): Path<(String, String)>,
5698 ) -> Result<Json<TurnRecord>, ApiError> {
5699 use crate::runtime_threads::RuntimeTurnOperationLookupError;
5700 let turn = state
5701 .runtime_threads
5702 .lookup_turn_operation(&id, &operation_key)
5703 .map_err(|error| match error {
5704 RuntimeTurnOperationLookupError::InvalidRequest => {
5705 ApiError::bad_request(error.to_string())
5706 }
5707 RuntimeTurnOperationLookupError::Incomplete => ApiError::conflict(error.to_string()),
5708 RuntimeTurnOperationLookupError::Unavailable => ApiError::internal(error.to_string()),
5709 })?
5710 .ok_or_else(|| ApiError::not_found("Turn operation not found"))?;
5711 Ok(Json(turn))
5712 }
5713
5714 #[derive(Debug, Serialize)]
5715 struct AgentMailDeliveryResponse {
5716 envelope: AgentMailEnvelope,
5717 #[serde(skip_serializing_if = "Option::is_none")]
5718 turn: Option<TurnRecord>,
5719 }
5720
5721 async fn send_agent_mail(
5722 State(state): State<RuntimeApiState>,
5723 Json(request): Json<AgentMailSendRequest>,
5724 ) -> Result<(StatusCode, Json<AgentMailSendResponse>), ApiError> {
5725 let mut response = state
5726 .runtime_threads
5727 .queue_agent_mail(request)
5728 .await
5729 .map_err(map_agent_mail_err)?;
5730 if response.envelope.delivery_mode == AgentMailDeliveryMode::WakeAtSafeBoundary
5731 && response.envelope.trigger_turn
5732 {
5733 let (envelope, _) = state
5734 .runtime_threads
5735 .deliver_agent_mail(
5736 &response.envelope.destination.thread_id,
5737 &response.envelope.message_id,
5738 )
5739 .await
5740 .map_err(map_agent_mail_err)?;
5741 response.envelope = envelope;
5742 }
5743 let status = if response.idempotent_replay {
5744 StatusCode::OK
5745 } else {
5746 StatusCode::CREATED
5747 };
5748 Ok((status, Json(response)))
5749 }
5750
5751 async fn list_agent_mail(
5752 State(state): State<RuntimeApiState>,
5753 Path(id): Path<String>,
5754 ) -> Result<Json<Vec<AgentMailEnvelope>>, ApiError> {
5755 let inbox = state
5756 .runtime_threads
5757 .list_agent_mail_for_thread(&id)
5758 .await
5759 .map_err(map_agent_mail_err)?;
5760 Ok(Json(inbox))
5761 }
5762
5763 async fn deliver_agent_mail(
5764 State(state): State<RuntimeApiState>,
5765 Path((id, message_id)): Path<(String, String)>,
5766 ) -> Result<Json<AgentMailDeliveryResponse>, ApiError> {
5767 let message_id = AgentMailMessageId::parse(message_id)
5768 .map_err(|error| ApiError::bad_request(error.to_string()))?;
5769 let (envelope, turn) = state
5770 .runtime_threads
5771 .deliver_agent_mail(&id, &message_id)
5772 .await
5773 .map_err(map_agent_mail_err)?;
5774 Ok(Json(AgentMailDeliveryResponse { envelope, turn }))
5775 }
5776
5777 async fn mark_agent_mail_read(
5778 State(state): State<RuntimeApiState>,
5779 Path((id, message_id)): Path<(String, String)>,
5780 ) -> Result<Json<AgentMailEnvelope>, ApiError> {
5781 let message_id = AgentMailMessageId::parse(message_id)
5782 .map_err(|error| ApiError::bad_request(error.to_string()))?;
5783 let envelope = state
5784 .runtime_threads
5785 .mark_agent_mail_read(&id, &message_id)
5786 .await
5787 .map_err(map_agent_mail_err)?;
5788 Ok(Json(envelope))
5789 }
5790
5791 /// Withdraw a queued envelope before delivery (#6176). Idempotent: a
5792 /// re-cancel returns the stored envelope; mail that already left `queued`
5793 /// is a 409, never silently dropped.
5794 async fn cancel_agent_mail(
5795 State(state): State<RuntimeApiState>,
5796 Path((id, message_id)): Path<(String, String)>,
5797 ) -> Result<Json<AgentMailEnvelope>, ApiError> {
5798 let message_id = AgentMailMessageId::parse(message_id)
5799 .map_err(|error| ApiError::bad_request(error.to_string()))?;
5800 let envelope = state
5801 .runtime_threads
5802 .cancel_agent_mail(&id, &message_id)
5803 .await
5804 .map_err(map_agent_mail_err)?;
5805 Ok(Json(envelope))
5806 }
5807
5808 async fn steer_thread_turn(
5809 State(state): State<RuntimeApiState>,
5810 Path((id, turn_id)): Path<(String, String)>,
5811 Json(req): Json<SteerTurnRequest>,
5812 ) -> Result<Json<TurnRecord>, ApiError> {
5813 let turn = state
5814 .runtime_threads
5815 .steer_turn(&id, &turn_id, req)
5816 .await
5817 .map_err(map_thread_err)?;
5818 Ok(Json(turn))
5819 }
5820
5821 async fn interrupt_thread_turn(
5822 State(state): State<RuntimeApiState>,
5823 Path((id, turn_id)): Path<(String, String)>,
5824 ) -> Result<Json<TurnRecord>, ApiError> {
5825 let turn = state
5826 .runtime_threads
5827 .interrupt_turn(&id, &turn_id)
5828 .await
5829 .map_err(map_thread_err)?;
5830 Ok(Json(turn))
5831 }
5832
5833 async fn deliver_dynamic_tool_result(
5834 State(state): State<RuntimeApiState>,
5835 Path((id, turn_id, call_id)): Path<(String, String, String)>,
5836 Json(result): Json<DynamicToolCallResult>,
5837 ) -> Result<StatusCode, ApiError> {
5838 state
5839 .runtime_threads
5840 .get_thread(&id)
5841 .await
5842 .map_err(map_thread_err)?;
5843 if state
5844 .runtime_threads
5845 .deliver_dynamic_tool_result(&id, &turn_id, &call_id, result)
5846 .await
5847 .map_err(|error| ApiError::internal(error.to_string()))?
5848 {
5849 Ok(StatusCode::ACCEPTED)
5850 } else {
5851 Err(ApiError::not_found(format!(
5852 "No pending dynamic tool call '{call_id}'"
5853 )))
5854 }
5855 }
5856
5857 async fn compact_thread(
5858 State(state): State<RuntimeApiState>,
5859 Path(id): Path<String>,
5860 Json(req): Json<CompactThreadRequest>,
5861 ) -> Result<(StatusCode, Json<StartTurnResponse>), ApiError> {
5862 let turn = state
5863 .runtime_threads
5864 .compact_thread(&id, req)
5865 .await
5866 .map_err(map_thread_err)?;
5867 let thread = state
5868 .runtime_threads
5869 .get_thread(&id)
5870 .await
5871 .map_err(map_thread_err)?;
5872 Ok((
5873 StatusCode::ACCEPTED,
5874 Json(StartTurnResponse { thread, turn }),
5875 ))
5876 }
5877
5878 // ---------------------------------------------------------------------------
5879 // Thread goal endpoints
5880 // ---------------------------------------------------------------------------
5881
5882 /// `GET /v1/threads/{id}/goal` — return the persistent goal for a thread, or
5883 /// 404 if the thread has no goal.
5884 async fn get_thread_goal(
5885 State(state): State<RuntimeApiState>,
5886 Path(id): Path<String>,
5887 ) -> Result<Json<codewhale_protocol::ThreadGoal>, ApiError> {
5888 // Verify the thread exists so we can return a clean 404 for unknown threads.
5889 state
5890 .runtime_threads
5891 .get_thread(&id)
5892 .await
5893 .map_err(map_thread_err)?;
5894 let goal = state
5895 .runtime_threads
5896 .get_goal(&id)
5897 .await
5898 .map_err(|e| ApiError::internal(e.to_string()))?
5899 .ok_or_else(|| ApiError::not_found(format!("thread '{id}' has no goal")))?;
5900 Ok(Json(goal))
5901 }
5902
5903 #[derive(Debug, Deserialize)]
5904 struct UpsertThreadGoalRequest {
5905 objective: String,
5906 #[serde(default)]
5907 token_budget: Option<i64>,
5908 }
5909
5910 /// `PUT /v1/threads/{id}/goal` — create or replace the persistent goal for a
5911 /// thread. Only `Active` goals may be created through this route; lifecycle
5912 /// transitions (`complete`, `block`) have dedicated action endpoints.
5913 async fn upsert_thread_goal(
5914 State(state): State<RuntimeApiState>,
5915 Path(id): Path<String>,
5916 Json(req): Json<UpsertThreadGoalRequest>,
5917 ) -> Result<(StatusCode, Json<codewhale_protocol::ThreadGoal>), ApiError> {
5918 if req.objective.trim().is_empty() {
5919 return Err(ApiError::bad_request("objective must not be blank"));
5920 }
5921 // Verify the thread exists.
5922 state
5923 .runtime_threads
5924 .get_thread(&id)
5925 .await
5926 .map_err(map_thread_err)?;
5927 let now = chrono::Utc::now().timestamp();
5928 let existing = state
5929 .runtime_threads
5930 .get_goal(&id)
5931 .await
5932 .map_err(|e| ApiError::internal(e.to_string()))?;
5933 let is_new = existing.is_none();
5934 let goal = codewhale_protocol::ThreadGoal {
5935 thread_id: id.clone(),
5936 goal_id: format!("goal-{}", uuid::Uuid::new_v4()),
5937 objective: req.objective.clone(),
5938 status: codewhale_protocol::ThreadGoalStatus::Active,
5939 token_budget: req.token_budget,
5940 tokens_used: 0,
5941 time_used_seconds: 0,
5942 continuation_count: 0,
5943 last_gap_fingerprint: None,
5944 repeated_gap_count: 0,
5945 last_gap_pass: None,
5946 pause_reason: None,
5947 created_at: now,
5948 updated_at: now,
5949 };
5950 state
5951 .runtime_threads
5952 .save_goal(goal.clone())
5953 .await
5954 .map_err(|e| ApiError::internal(e.to_string()))?;
5955 let status_code = if is_new {
5956 StatusCode::CREATED
5957 } else {
5958 StatusCode::OK
5959 };
5960 // Emit a replayable goal-updated event so SSE subscribers can react.
5961 let _ = state
5962 .runtime_threads
5963 .emit_goal_updated_event(&id, goal.clone())
5964 .await;
5965 // Inject the goal into a cached engine (if any) and dispatch the kickoff
5966 // turn while the thread is idle. Errors are advisory: the goal record is
5967 // already durable and a subsequent turn still carries it.
5968 if let Err(err) = state.runtime_threads.activate_thread_goal(&id).await {
5969 tracing::warn!("failed to activate goal for thread '{id}': {err}");
5970 }
5971 Ok((status_code, Json(goal)))
5972 }
5973
5974 /// `DELETE /v1/threads/{id}/goal` — remove the persistent goal from a thread.
5975 /// Returns 204 No Content on success, 404 if there was no goal.
5976 async fn delete_thread_goal(
5977 State(state): State<RuntimeApiState>,
5978 Path(id): Path<String>,
5979 ) -> Result<StatusCode, ApiError> {
5980 state
5981 .runtime_threads
5982 .get_thread(&id)
5983 .await
5984 .map_err(map_thread_err)?;
5985 let deleted = state
5986 .runtime_threads
5987 .remove_goal(&id)
5988 .await
5989 .map_err(|e| ApiError::internal(e.to_string()))?;
5990 if !deleted {
5991 return Err(ApiError::not_found(format!("thread '{id}' has no goal")));
5992 }
5993 let _ = state.runtime_threads.emit_goal_cleared_event(&id).await;
5994 state
5995 .runtime_threads
5996 .sync_engine_goal_status(&id, crate::tools::goal::GoalStatus::Active, true)
5997 .await;
5998 Ok(StatusCode::NO_CONTENT)
5999 }
6000
6001 /// `POST /v1/threads/{id}/goal/complete` — transition the goal to `Complete`.
6002 /// Only valid from a non-terminal status; returns 409 Conflict if the goal is
6003 /// already in a terminal state, and 404 if the thread has no goal.
6004 async fn complete_thread_goal(
6005 State(state): State<RuntimeApiState>,
6006 Path(id): Path<String>,
6007 ) -> Result<Json<codewhale_protocol::ThreadGoal>, ApiError> {
6008 state
6009 .runtime_threads
6010 .get_thread(&id)
6011 .await
6012 .map_err(map_thread_err)?;
6013 let goal = state
6014 .runtime_threads
6015 .get_goal(&id)
6016 .await
6017 .map_err(|e| ApiError::internal(e.to_string()))?
6018 .ok_or_else(|| ApiError::not_found(format!("thread '{id}' has no goal")))?;
6019 if matches!(goal.status, codewhale_protocol::ThreadGoalStatus::Complete) {
6020 return Err(ApiError {
6021 status: StatusCode::CONFLICT,
6022 message: format!("goal for thread '{id}' is already complete"),
6023 });
6024 }
6025 let updated = state
6026 .runtime_threads
6027 .transition_goal_status(
6028 &id,
6029 &goal.goal_id,
6030 codewhale_protocol::ThreadGoalStatus::Complete,
6031 )
6032 .await
6033 .map_err(|e| ApiError::internal(e.to_string()))?
6034 .ok_or_else(|| ApiError {
6035 status: StatusCode::CONFLICT,
6036 message: format!("goal for thread '{id}' changed concurrently; retry"),
6037 })?;
6038 let _ = state
6039 .runtime_threads
6040 .emit_goal_updated_event(&id, updated.clone())
6041 .await;
6042 state
6043 .runtime_threads
6044 .sync_engine_goal_status(&id, crate::tools::goal::GoalStatus::Complete, false)
6045 .await;
6046 Ok(Json(updated))
6047 }
6048
6049 /// `POST /v1/threads/{id}/goal/block` — transition the goal to `Blocked`.
6050 /// Rejects transitions from terminal states (returns 409).
6051 async fn block_thread_goal(
6052 State(state): State<RuntimeApiState>,
6053 Path(id): Path<String>,
6054 ) -> Result<Json<codewhale_protocol::ThreadGoal>, ApiError> {
6055 state
6056 .runtime_threads
6057 .get_thread(&id)
6058 .await
6059 .map_err(map_thread_err)?;
6060 let goal = state
6061 .runtime_threads
6062 .get_goal(&id)
6063 .await
6064 .map_err(|e| ApiError::internal(e.to_string()))?
6065 .ok_or_else(|| ApiError::not_found(format!("thread '{id}' has no goal")))?;
6066 if matches!(goal.status, codewhale_protocol::ThreadGoalStatus::Complete) {
6067 return Err(ApiError {
6068 status: StatusCode::CONFLICT,
6069 message: format!(
6070 "goal for thread '{id}' is already complete; cannot transition to blocked"
6071 ),
6072 });
6073 }
6074 let updated = state
6075 .runtime_threads
6076 .transition_goal_status(
6077 &id,
6078 &goal.goal_id,
6079 codewhale_protocol::ThreadGoalStatus::Blocked,
6080 )
6081 .await
6082 .map_err(|e| ApiError::internal(e.to_string()))?
6083 .ok_or_else(|| ApiError {
6084 status: StatusCode::CONFLICT,
6085 message: format!("goal for thread '{id}' changed concurrently; retry"),
6086 })?;
6087 let _ = state
6088 .runtime_threads
6089 .emit_goal_updated_event(&id, updated.clone())
6090 .await;
6091 state
6092 .runtime_threads
6093 .sync_engine_goal_status(&id, crate::tools::goal::GoalStatus::Blocked, false)
6094 .await;
6095 Ok(Json(updated))
6096 }
6097
6098 /// Runtime-authenticated administrative task inventory.
6099 ///
6100 /// Unlike in-session TUI/model controls, the Runtime API token authorizes the
6101 /// caller for the whole host runtime, so these endpoints intentionally span
6102 /// sessions. Running with `--insecure` explicitly opts out of that host boundary.
6103 async fn list_tasks(
6104 State(state): State<RuntimeApiState>,
6105 Query(query): Query<TasksQuery>,
6106 ) -> Result<Json<TasksResponse>, ApiError> {
6107 let tasks = match query.workspace.as_deref() {
6108 Some(workspace) => {
6109 state
6110 .task_manager
6111 .list_tasks_scoped(query.limit, Some(workspace))
6112 .await
6113 }
6114 None => state.task_manager.list_tasks(query.limit).await,
6115 }
6116 .map_err(|error| ApiError::internal(format!("Task inventory unavailable: {error}")))?;
6117 let counts = state
6118 .task_manager
6119 .counts()
6120 .await
6121 .map_err(|error| ApiError::internal(format!("Task inventory unavailable: {error}")))?;
6122 Ok(Json(TasksResponse { tasks, counts }))
6123 }
6124
6125 /// Runtime-authenticated administrative task lookup across host sessions.
6126 async fn get_task(
6127 State(state): State<RuntimeApiState>,
6128 Path(id): Path<String>,
6129 ) -> Result<Json<TaskRecord>, ApiError> {
6130 let task = state
6131 .task_manager
6132 .get_task(&id)
6133 .await
6134 .map_err(map_task_err)?;
6135 Ok(Json(task))
6136 }
6137
6138 /// Runtime-authenticated administrative task cancellation across host sessions.
6139 async fn cancel_task(
6140 State(state): State<RuntimeApiState>,
6141 Path(id): Path<String>,
6142 ) -> Result<Json<TaskRecord>, ApiError> {
6143 let cancellation = state
6144 .task_manager
6145 .cancel_task(&id)
6146 .await
6147 .map_err(map_task_err)?;
6148 Ok(Json(cancellation.task))
6149 }
6150
6151 async fn stream_thread_events(
6152 State(state): State<RuntimeApiState>,
6153 Path(id): Path<String>,
6154 Query(query): Query<ThreadEventsQuery>,
6155 ) -> Result<Response, ApiError> {
6156 let _ = state
6157 .runtime_threads
6158 .get_thread(&id)
6159 .await
6160 .map_err(map_thread_err)?;
6161
6162 // Subscribe before reading durable history. An event emitted while replay
6163 // is loaded is then present in both places (and deduped below) or queued
6164 // live, never in an uncovered handoff window.
6165 let live = state.runtime_threads.subscribe_events();
6166 if query
6167 .replay_limit
6168 .is_some_and(|limit| limit > MAX_RUNTIME_EVENT_REPLAY_TAIL)
6169 {
6170 return Err(ApiError::bad_request(format!(
6171 "replay_limit cannot exceed {MAX_RUNTIME_EVENT_REPLAY_TAIL}"
6172 )));
6173 }
6174 let replay = state
6175 .runtime_threads
6176 .replay_events(&id, query.since_seq, query.replay_limit)
6177 .await
6178 .map_err(|e| ApiError::internal(e.to_string()))?;
6179
6180 let stream = replay_live_thread_events(
6181 state.runtime_threads.clone(),
6182 id,
6183 replay.base_seq,
6184 replay.batches,
6185 live,
6186 query.progress,
6187 );
6188
6189 let mut response = Sse::new(stream)
6190 .keep_alive(
6191 KeepAlive::new()
6192 .interval(Duration::from_secs(15))
6193 .text("keepalive"),
6194 )
6195 .into_response();
6196 if query.progress {
6197 response
6198 .headers_mut()
6199 .insert("x-codewhale-event-progress", HeaderValue::from_static("1"));
6200 }
6201 Ok(response)
6202 }
6203
6204 fn thread_stream_progress(thread_id: &str, seq: u64, live: bool) -> SseEvent {
6205 sse_json(
6206 "stream.progress",
6207 json!({
6208 "event": "stream.progress", "thread_id": thread_id, "seq": seq,
6209 "state": if live { "live" } else { "replaying" },
6210 }),
6211 )
6212 }
6213
6214 fn replay_live_thread_events(
6215 runtime_threads: SharedRuntimeThreadManager,
6216 thread_id: String,
6217 mut last_seq: u64,
6218 mut backlog: tokio::sync::mpsc::Receiver<
6219 std::result::Result<Vec<crate::runtime_threads::RuntimeEventRecord>, String>,
6220 >,
6221 mut live: tokio::sync::broadcast::Receiver<crate::runtime_threads::RuntimeEventRecord>,
6222 progress: bool,
6223 ) -> impl futures_util::Stream<Item = Result<SseEvent, Infallible>> {
6224 stream! {
6225 if progress { yield Ok(thread_stream_progress(&thread_id, last_seq, false)); }
6226 while let Some(batch) = backlog.recv().await {
6227 let events = match batch {
6228 Ok(events) => events,
6229 Err(error) => {
6230 tracing::warn!(
6231 thread_id = %thread_id,
6232 last_seq,
6233 %error,
6234 "Failed to replay Runtime web event stream from durable history"
6235 );
6236 return;
6237 }
6238 };
6239 for event in events {
6240 if event.thread_id != thread_id || event.seq <= last_seq {
6241 continue;
6242 }
6243 let previous_seq = last_seq;
6244 last_seq = event.seq;
6245 let event_name = event.event.clone();
6246 yield Ok(sse_json(
6247 &event_name,
6248 runtime_event_payload_with_previous(event, previous_seq),
6249 ));
6250 }
6251 }
6252
6253 // Backlog completion alone is insufficient: a request may have been
6254 // answered while history was read. Drain the already-queued live tail
6255 // before declaring the observation current. These opt-in frames carry
6256 // transport progress, never new journal events or sequence numbers.
6257 let mut replaying = progress;
6258 'live: loop {
6259 let next = if replaying {
6260 use tokio::sync::broadcast::error::{RecvError, TryRecvError};
6261 match live.try_recv() {
6262 Ok(event) => Ok(event),
6263 Err(TryRecvError::Empty) => {
6264 yield Ok(thread_stream_progress(&thread_id, last_seq, true));
6265 replaying = false;
6266 continue;
6267 }
6268 Err(TryRecvError::Lagged(skipped)) => Err(RecvError::Lagged(skipped)),
6269 Err(TryRecvError::Closed) => Err(RecvError::Closed),
6270 }
6271 } else { live.recv().await };
6272 match next {
6273 Ok(event) => {
6274 if event.thread_id != thread_id || event.seq <= last_seq {
6275 continue;
6276 }
6277 let previous_seq = last_seq;
6278 last_seq = event.seq;
6279 let event_name = event.event.clone();
6280 yield Ok(sse_json(
6281 &event_name,
6282 runtime_event_payload_with_previous(event, previous_seq),
6283 ));
6284 }
6285 Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
6286 if progress {
6287 yield Ok(thread_stream_progress(&thread_id, last_seq, false));
6288 replaying = true;
6289 }
6290 // Broadcast is only a wake-up path; durable history remains
6291 // authoritative. Catch up from the last delivered cursor so
6292 // receiver pressure cannot turn into a silent prompt loss.
6293 let mut recovered = match runtime_threads
6294 .replay_events(&thread_id, Some(last_seq), None)
6295 .await
6296 {
6297 Ok(replay) => replay.batches,
6298 Err(error) => {
6299 tracing::warn!(
6300 thread_id = %thread_id,
6301 last_seq,
6302 skipped,
6303 %error,
6304 "Failed to recover lagged Runtime web event stream from durable history"
6305 );
6306 break 'live;
6307 }
6308 };
6309 while let Some(batch) = recovered.recv().await {
6310 let events = match batch {
6311 Ok(events) => events,
6312 Err(error) => {
6313 tracing::warn!(
6314 thread_id = %thread_id,
6315 last_seq,
6316 skipped,
6317 %error,
6318 "Failed to recover lagged Runtime web event stream from durable history"
6319 );
6320 break 'live;
6321 }
6322 };
6323 for event in events {
6324 if event.thread_id != thread_id || event.seq <= last_seq {
6325 continue;
6326 }
6327 let previous_seq = last_seq;
6328 last_seq = event.seq;
6329 let event_name = event.event.clone();
6330 yield Ok(sse_json(
6331 &event_name,
6332 runtime_event_payload_with_previous(event, previous_seq),
6333 ));
6334 }
6335 }
6336 }
6337 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
6338 }
6339 }
6340 }
6341 }
6342
6343 async fn stream_turn(
6344 State(state): State<RuntimeApiState>,
6345 Json(req): Json<StreamTurnRequest>,
6346 ) -> Result<Sse<impl futures_util::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
6347 if req.prompt.trim().is_empty() {
6348 return Err(ApiError::bad_request("prompt is required"));
6349 }
6350
6351 crate::image_attach::prepare_runtime_images(&req.images).map_err(map_thread_err)?;
6352
6353 let model = runtime_request_model(&state.config.read(), req.model.as_deref())?;
6354 if req.max_output_tokens.is_some() {
6355 let config = state.config.read();
6356 if model.eq_ignore_ascii_case("auto")
6357 || provider_model_output_token_limit_for_api(&config, config.api_provider(), &model)
6358 != codewhale_config::route::CapabilityState::Supported
6359 {
6360 return Err(ApiError::bad_request(
6361 "maxOutputTokens requires an exact model with output-limit support",
6362 ));
6363 }
6364 }
6365 let workspace = req
6366 .workspace
6367 .clone()
6368 .unwrap_or_else(|| state.workspace.clone());
6369 let mode = req.mode.clone().unwrap_or_else(|| "agent".to_string());
6370 let permission_posture = req.permission_posture.clone();
6371 let allow_shell = req.allow_shell.unwrap_or(state.config.read().allow_shell());
6372 let trust_mode = req.trust_mode.unwrap_or(false);
6373 let auto_approve = req.auto_approve.unwrap_or(false);
6374 let prompt = req.prompt;
6375
6376 let thread = state
6377 .runtime_threads
6378 .create_thread(CreateThreadRequest {
6379 model: Some(model.clone()),
6380 workspace: Some(workspace.clone()),
6381 mode: Some(mode.clone()),
6382 permission_posture: permission_posture.clone(),
6383 allow_shell: Some(allow_shell),
6384 trust_mode: Some(trust_mode),
6385 auto_approve: Some(auto_approve),
6386 archived: true,
6387 system_prompt: None,
6388 task_id: None,
6389 ..Default::default()
6390 })
6391 .await
6392 .map_err(|e| ApiError::internal(format!("Failed to create stream thread: {e}")))?;
6393
6394 #[cfg(test)]
6395 if let Some(hook) = &state.compat_stream_test_hook {
6396 let (resume, wait_for_resume) = tokio::sync::oneshot::channel();
6397 hook.send(CompatStreamTestPoint::ThreadCreated {
6398 thread_id: thread.id.clone(),
6399 resume,
6400 })
6401 .map_err(|_| ApiError::internal("Compatibility stream test hook closed"))?;
6402 wait_for_resume
6403 .await
6404 .map_err(|_| ApiError::internal("Compatibility stream test hook dropped resume"))?;
6405 }
6406
6407 let turn_result = state
6408 .runtime_threads
6409 .start_turn(
6410 &thread.id,
6411 StartTurnRequest {
6412 max_output_tokens: req.max_output_tokens,
6413 prompt,
6414 images: req.images,
6415 input_summary: None,
6416 model: Some(model.clone()),
6417 mode: Some(mode.clone()),
6418 permission_posture,
6419 allow_shell: Some(allow_shell),
6420 trust_mode: Some(trust_mode),
6421 auto_approve: Some(auto_approve),
6422 ..Default::default()
6423 },
6424 )
6425 .await;
6426 let turn = match turn_result {
6427 Ok(turn) => turn,
6428 Err(error) => {
6429 // This helper refuses loaded threads and any thread owning a turn.
6430 // A failed/uncertain handoff must remain recoverable; only an empty,
6431 // never-loaded admission can be discarded.
6432 if let Err(cleanup_error) = state.runtime_threads.discard_empty_thread(&thread.id).await
6433 {
6434 tracing::warn!(thread_id = %thread.id, %cleanup_error, "Retained stream thread after failed admission");
6435 }
6436 return Err(map_thread_err(error));
6437 }
6438 };
6439
6440 // Subscribe before reading the durable replay. Events produced while the
6441 // replay is loaded then exist in at least one source, and the sequence
6442 // cursor below removes overlap without dropping the handoff edge.
6443 let mut live = state.runtime_threads.subscribe_events();
6444 let thread_id = thread.id.clone();
6445 let turn_id = turn.id.clone();
6446
6447 #[cfg(test)]
6448 if let Some(hook) = &state.compat_stream_test_hook {
6449 let (resume, wait_for_resume) = tokio::sync::oneshot::channel();
6450 hook.send(CompatStreamTestPoint::SubscribedBeforeReplay {
6451 thread_id: thread_id.clone(),
6452 turn_id: turn_id.clone(),
6453 resume,
6454 })
6455 .map_err(|_| ApiError::internal("Compatibility stream test hook closed"))?;
6456 wait_for_resume
6457 .await
6458 .map_err(|_| ApiError::internal("Compatibility stream test hook dropped resume"))?;
6459 }
6460
6461 let mut backlog = state
6462 .runtime_threads
6463 .replay_events(&thread.id, None, None)
6464 .await
6465 .map_err(|e| ApiError::internal(format!("Failed to load stream backlog: {e}")))?;
6466
6467 #[cfg(test)]
6468 if let Some(hook) = &state.compat_stream_test_hook {
6469 let (resume, wait_for_resume) = tokio::sync::oneshot::channel();
6470 hook.send(CompatStreamTestPoint::ReplayLoaded {
6471 thread_id: thread_id.clone(),
6472 turn_id: turn_id.clone(),
6473 resume,
6474 })
6475 .map_err(|_| ApiError::internal("Compatibility stream test hook closed"))?;
6476 wait_for_resume
6477 .await
6478 .map_err(|_| ApiError::internal("Compatibility stream test hook dropped resume"))?;
6479 }
6480
6481 let stream = stream! {
6482 let mut last_seq = 0;
6483 yield Ok(sse_json("turn.started", json!({
6484 "thread_id": thread.id,
6485 "turn_id": turn.id,
6486 "model": model,
6487 "mode": mode,
6488 "workspace": workspace,
6489 })));
6490
6491 while let Some(batch) = backlog.batches.recv().await {
6492 let events = match batch {
6493 Ok(events) => events,
6494 Err(error) => {
6495 tracing::warn!(
6496 thread_id = %thread_id,
6497 turn_id = %turn_id,
6498 %error,
6499 "Failed to replay compatibility stream from durable history"
6500 );
6501 yield Ok(sse_json("error", json!({
6502 "message": "failed to replay durable event stream",
6503 })));
6504 return;
6505 }
6506 };
6507 for event in events {
6508 let Some((mapped, terminal)) = take_compat_turn_event(
6509 &event,
6510 &thread_id,
6511 &turn_id,
6512 &mut last_seq,
6513 ) else {
6514 continue;
6515 };
6516 if let Some(mapped) = mapped {
6517 yield Ok(mapped);
6518 }
6519 if terminal {
6520 yield Ok(sse_json("done", json!({})));
6521 return;
6522 }
6523 }
6524 }
6525
6526 loop {
6527 match live.recv().await {
6528 Ok(event) => {
6529 let Some((mapped, terminal)) = take_compat_turn_event(
6530 &event,
6531 &thread_id,
6532 &turn_id,
6533 &mut last_seq,
6534 ) else {
6535 continue;
6536 };
6537 if let Some(mapped) = mapped {
6538 yield Ok(mapped);
6539 }
6540 if terminal {
6541 yield Ok(sse_json("done", json!({})));
6542 return;
6543 }
6544 }
6545 Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
6546 let mut recovered = match state.runtime_threads
6547 .replay_events(&thread_id, Some(last_seq), None)
6548 .await
6549 {
6550 Ok(replay) => replay.batches,
6551 Err(error) => {
6552 tracing::warn!(
6553 thread_id = %thread_id,
6554 turn_id = %turn_id,
6555 last_seq,
6556 skipped,
6557 %error,
6558 "Failed to recover lagged compatibility stream from durable history"
6559 );
6560 yield Ok(sse_json("error", json!({
6561 "message": "failed to recover lagged event stream",
6562 })));
6563 return;
6564 }
6565 };
6566 while let Some(batch) = recovered.recv().await {
6567 let events = match batch {
6568 Ok(events) => events,
6569 Err(error) => {
6570 tracing::warn!(
6571 thread_id = %thread_id,
6572 turn_id = %turn_id,
6573 last_seq,
6574 skipped,
6575 %error,
6576 "Failed to recover lagged compatibility stream from durable history"
6577 );
6578 yield Ok(sse_json("error", json!({
6579 "message": "failed to recover lagged event stream",
6580 })));
6581 return;
6582 }
6583 };
6584 for event in events {
6585 let Some((mapped, terminal)) = take_compat_turn_event(
6586 &event,
6587 &thread_id,
6588 &turn_id,
6589 &mut last_seq,
6590 ) else {
6591 continue;
6592 };
6593 if let Some(mapped) = mapped {
6594 yield Ok(mapped);
6595 }
6596 if terminal {
6597 yield Ok(sse_json("done", json!({})));
6598 return;
6599 }
6600 }
6601 }
6602 }
6603 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
6604 yield Ok(sse_json("error", json!({ "message": "event channel closed" })));
6605 return;
6606 }
6607 }
6608 }
6609 };
6610
6611 Ok(Sse::new(stream).keep_alive(
6612 KeepAlive::new()
6613 .interval(Duration::from_secs(15))
6614 .text("keepalive"),
6615 ))
6616 }
6617
6618 fn take_compat_turn_event(
6619 event: &crate::runtime_threads::RuntimeEventRecord,
6620 thread_id: &str,
6621 turn_id: &str,
6622 last_seq: &mut u64,
6623 ) -> Option<(Option<SseEvent>, bool)> {
6624 if event.thread_id != thread_id
6625 || event.turn_id.as_deref() != Some(turn_id)
6626 || event.seq <= *last_seq
6627 {
6628 return None;
6629 }
6630 *last_seq = event.seq;
6631 Some((
6632 map_compat_stream_event(event),
6633 event.event == "turn.completed",
6634 ))
6635 }
6636
6637 fn runtime_event_payload(event: crate::runtime_threads::RuntimeEventRecord) -> serde_json::Value {
6638 let event_name = event.event.clone();
6639 let timestamp = event.timestamp.to_rfc3339();
6640 let schema_version = RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION;
6641 let envelope = RuntimeEventEnvelope {
6642 schema_version,
6643 seq: event.seq,
6644 event: event_name.clone(),
6645 kind: event_name,
6646 thread_id: event.thread_id,
6647 turn_id: event.turn_id,
6648 item_id: event.item_id,
6649 timestamp: timestamp.clone(),
6650 created_at: Some(timestamp),
6651 payload: event.payload,
6652 extra: Default::default(),
6653 };
6654 serde_json::to_value(envelope).expect("serialize runtime event envelope")
6655 }
6656
6657 fn runtime_event_payload_with_previous(
6658 event: crate::runtime_threads::RuntimeEventRecord,
6659 previous_seq: u64,
6660 ) -> serde_json::Value {
6661 let mut payload = runtime_event_payload(event);
6662 if let Some(object) = payload.as_object_mut() {
6663 object.insert("previous_seq".to_string(), json!(previous_seq));
6664 }
6665 payload
6666 }
6667
6668 fn map_compat_stream_event(event: &crate::runtime_threads::RuntimeEventRecord) -> Option<SseEvent> {
6669 let payload = &event.payload;
6670 match event.event.as_str() {
6671 "item.delta" => {
6672 let kind = payload
6673 .get("kind")
6674 .and_then(|v| v.as_str())
6675 .unwrap_or_default();
6676 if kind == "agent_message" {
6677 let content = payload
6678 .get("delta")
6679 .and_then(|v| v.as_str())
6680 .unwrap_or_default();
6681 Some(sse_json("message.delta", json!({ "content": content })))
6682 } else if kind == "tool_call" {
6683 let output = payload
6684 .get("delta")
6685 .and_then(|v| v.as_str())
6686 .unwrap_or_default();
6687 Some(sse_json("tool.progress", json!({ "output": output })))
6688 } else {
6689 None
6690 }
6691 }
6692 "item.started" => {
6693 let tool = payload.get("tool")?;
6694 let id = tool.get("id").cloned().unwrap_or(Value::Null);
6695 let name = tool.get("name").cloned().unwrap_or(Value::Null);
6696 let input = tool.get("input").cloned().unwrap_or(Value::Null);
6697 Some(sse_json(
6698 "tool.started",
6699 json!({
6700 "id": id,
6701 "name": name,
6702 "input": input,
6703 }),
6704 ))
6705 }
6706 "item.completed" | "item.failed" => {
6707 let item = payload.get("item")?;
6708 let kind = item
6709 .get("kind")
6710 .and_then(|v| v.as_str())
6711 .unwrap_or_default();
6712 if kind == "tool_call" || kind == "file_change" || kind == "command_execution" {
6713 let id = item.get("id").cloned().unwrap_or(Value::Null);
6714 let success = event.event == "item.completed";
6715 let output = item.get("detail").cloned().unwrap_or_else(|| {
6716 Value::String(
6717 item.get("summary")
6718 .and_then(|v| v.as_str())
6719 .unwrap_or_default()
6720 .to_string(),
6721 )
6722 });
6723 Some(sse_json(
6724 "tool.completed",
6725 json!({
6726 "id": id,
6727 "success": success,
6728 "output": output,
6729 }),
6730 ))
6731 } else if kind == "status" {
6732 let message = item
6733 .get("detail")
6734 .and_then(|v| v.as_str())
6735 .or_else(|| item.get("summary").and_then(|v| v.as_str()))
6736 .unwrap_or_default();
6737 Some(sse_json("status", json!({ "message": message })))
6738 } else if kind == "error" {
6739 let message = item
6740 .get("detail")
6741 .and_then(|v| v.as_str())
6742 .or_else(|| item.get("summary").and_then(|v| v.as_str()))
6743 .unwrap_or_default();
6744 Some(sse_json("error", json!({ "message": message })))
6745 } else {
6746 None
6747 }
6748 }
6749 "approval.required" => {
6750 let approval_id = payload
6751 .get("approval_id")
6752 .or_else(|| payload.get("id"))?
6753 .clone();
6754 Some(sse_json(
6755 "approval.required",
6756 json!({
6757 "id": approval_id,
6758 "approval_id": approval_id,
6759 "tool_call_id": payload.get("tool_call_id"),
6760 "thread_id": event.thread_id,
6761 "turn_id": event.turn_id,
6762 "tool_name": payload.get("tool_name"),
6763 "description": payload.get("description"),
6764 "intent_summary": payload.get("intent_summary"),
6765 }),
6766 ))
6767 }
6768 "approval.decided" => {
6769 let approval_id = payload
6770 .get("approval_id")
6771 .or_else(|| payload.get("id"))?
6772 .clone();
6773 Some(sse_json(
6774 "approval.decided",
6775 json!({
6776 "id": approval_id,
6777 "approval_id": approval_id,
6778 "tool_call_id": payload.get("tool_call_id"),
6779 "thread_id": event.thread_id,
6780 "turn_id": event.turn_id,
6781 "decision": payload.get("decision"),
6782 "remember": payload.get("remember"),
6783 "auto": payload.get("auto"),
6784 "timeout": payload.get("timeout"),
6785 }),
6786 ))
6787 }
6788 "approval.timeout" => {
6789 let approval_id = payload
6790 .get("approval_id")
6791 .or_else(|| payload.get("id"))?
6792 .clone();
6793 Some(sse_json(
6794 "approval.timeout",
6795 json!({
6796 "id": approval_id,
6797 "approval_id": approval_id,
6798 "tool_call_id": payload.get("tool_call_id"),
6799 "thread_id": event.thread_id,
6800 "turn_id": event.turn_id,
6801 "timeout_secs": payload.get("timeout_secs"),
6802 }),
6803 ))
6804 }
6805 "user_input.required" => {
6806 let input_id = payload
6807 .get("input_id")
6808 .or_else(|| payload.get("id"))?
6809 .clone();
6810 let request = payload.get("request")?.clone();
6811 Some(sse_json(
6812 "user_input.required",
6813 json!({
6814 "id": input_id,
6815 "input_id": input_id,
6816 "thread_id": event.thread_id,
6817 "turn_id": event.turn_id,
6818 "status": "required",
6819 "request": request,
6820 }),
6821 ))
6822 }
6823 "user_input.answered" | "user_input.canceled" => {
6824 let input_id = payload
6825 .get("input_id")
6826 .or_else(|| payload.get("id"))?
6827 .clone();
6828 let status = if event.event == "user_input.answered" {
6829 "submitted"
6830 } else {
6831 "canceled"
6832 };
6833 Some(sse_json(
6834 &event.event,
6835 json!({
6836 "id": input_id,
6837 "input_id": input_id,
6838 "thread_id": event.thread_id,
6839 "turn_id": event.turn_id,
6840 "status": status,
6841 "terminal": payload.get("terminal").and_then(Value::as_bool).unwrap_or(false),
6842 }),
6843 ))
6844 }
6845 "sandbox.denied" => Some(sse_json("sandbox.denied", payload.clone())),
6846 // The operator's own store failed; the payload names the file and
6847 // the next action, so compat clients see it too (#5931).
6848 crate::runtime_threads::RUNTIME_STORE_FAILURE_EVENT => Some(sse_json(
6849 crate::runtime_threads::RUNTIME_STORE_FAILURE_EVENT,
6850 payload.clone(),
6851 )),
6852 "turn.completed" => {
6853 let usage = payload
6854 .get("turn")
6855 .and_then(|turn| turn.get("usage"))
6856 .cloned()
6857 .unwrap_or(json!(null));
6858 Some(sse_json("turn.completed", json!({ "usage": usage })))
6859 }
6860 _ => None,
6861 }
6862 }
6863
6864 fn sse_json(event: &str, payload: serde_json::Value) -> SseEvent {
6865 let data = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
6866 SseEvent::default().event(event).data(data)
6867 }
6868
6869 fn truncate_text(text: &str, max_chars: usize) -> String {
6870 let char_count = text.chars().count();
6871 if char_count <= max_chars {
6872 return text.to_string();
6873 }
6874 let truncated: String = text.chars().take(max_chars.saturating_sub(3)).collect();
6875 format!("{truncated}...")
6876 }
6877
6878 fn resolve_skills_dir(config: &Config, workspace: &std::path::Path) -> PathBuf {
6879 if config.skills_config().scan_codewhale_only() {
6880 if config.skills_dir.is_some() {
6881 return config.skills_dir();
6882 }
6883 if let Some(codewhale_skills_dir) = crate::skills::codewhale_workspace_skills_dir(workspace)
6884 && let Ok(canonical_skills) = fs::canonicalize(&codewhale_skills_dir)
6885 {
6886 return canonical_skills;
6887 }
6888 return config.skills_dir();
6889 }
6890
6891 // Canonicalize the workspace once so the symlink-containment check below
6892 // compares like-for-like. If the workspace can't be canonicalized at all
6893 // (e.g. it doesn't exist on disk yet) fall back to the configured global
6894 // skills dir rather than risk constructing paths from a non-existent root.
6895 let canonical_workspace = match fs::canonicalize(workspace) {
6896 Ok(path) => path,
6897 Err(_) => return config.skills_dir(),
6898 };
6899 for candidate in [
6900 canonical_workspace.join(".agents").join("skills"),
6901 canonical_workspace.join("skills"),
6902 ] {
6903 // Re-canonicalize the candidate so a `.agents/skills` symlink to e.g.
6904 // `/etc` cannot promote arbitrary filesystem locations into the
6905 // skills directory. The candidate must still resolve under the
6906 // canonicalized workspace root after symlink expansion.
6907 if let Ok(canon) = fs::canonicalize(&candidate)
6908 && canon.starts_with(&canonical_workspace)
6909 && canon.is_dir()
6910 {
6911 return canon;
6912 }
6913 }
6914 config.skills_dir()
6915 }
6916
6917 fn skills_search_directories(
6918 workspace: &FsPath,
6919 skills_dir: &FsPath,
6920 mode: crate::skills::SkillDiscoveryMode,
6921 ) -> Vec<PathBuf> {
6922 crate::skills::skill_directories_for_workspace_and_dir(workspace, skills_dir, mode)
6923 }
6924
6925 fn discover_skills_for_runtime_api(
6926 workspace: &FsPath,
6927 skills_dir: &FsPath,
6928 mode: crate::skills::SkillDiscoveryMode,
6929 plugins: Option<&crate::plugins::PluginRegistry>,
6930 ) -> (crate::skills::SkillRegistry, Vec<PathBuf>) {
6931 let directories = skills_search_directories(workspace, skills_dir, mode);
6932 let registry =
6933 crate::skills::discover_from_directories_with_plugins(directories.clone(), plugins);
6934 (registry, directories)
6935 }
6936
6937 fn skill_entry_is_bundled(skill: &crate::skills::Skill, skills_dir: &FsPath) -> bool {
6938 if !crate::skills::is_bundled_skill_name(&skill.name) {
6939 return false;
6940 }
6941
6942 let expected_path = skills_dir.join(&skill.name).join("SKILL.md");
6943 paths_refer_to_same_file(&skill.path, &expected_path)
6944 }
6945
6946 fn paths_refer_to_same_file(left: &FsPath, right: &FsPath) -> bool {
6947 match (fs::canonicalize(left), fs::canonicalize(right)) {
6948 (Ok(left), Ok(right)) => left == right,
6949 _ => left == right,
6950 }
6951 }
6952
6953 fn format_skill_search_paths(directories: &[PathBuf]) -> String {
6954 if directories.is_empty() {
6955 return "<none>".to_string();
6956 }
6957 directories
6958 .iter()
6959 .map(|path| path.display().to_string())
6960 .collect::<Vec<_>>()
6961 .join(", ")
6962 }
6963
6964 #[derive(Debug, Deserialize)]
6965 struct UsageQuery {
6966 /// ISO-8601 lower bound (inclusive). When omitted, no lower bound.
6967 since: Option<String>,
6968 /// ISO-8601 upper bound (inclusive). When omitted, no upper bound.
6969 until: Option<String>,
6970 /// Bucket key. One of `day` (default), `model`, `provider`, `thread`.
6971 group_by: Option<String>,
6972 }
6973
6974 fn parse_iso8601(raw: &str, field: &str) -> Result<chrono::DateTime<Utc>, ApiError> {
6975 chrono::DateTime::parse_from_rfc3339(raw)
6976 .map(|dt| dt.with_timezone(&Utc))
6977 .map_err(|e| ApiError::bad_request(format!("Invalid {field} (expected RFC 3339): {e}")))
6978 }
6979
6980 async fn get_usage(
6981 State(state): State<RuntimeApiState>,
6982 Query(query): Query<UsageQuery>,
6983 ) -> Result<Json<Value>, ApiError> {
6984 let since = match query.since.as_deref() {
6985 Some(raw) => Some(parse_iso8601(raw, "since")?),
6986 None => None,
6987 };
6988 let until = match query.until.as_deref() {
6989 Some(raw) => Some(parse_iso8601(raw, "until")?),
6990 None => None,
6991 };
6992 if let (Some(s), Some(u)) = (since, until)
6993 && s > u
6994 {
6995 return Err(ApiError::bad_request("since must be <= until".to_string()));
6996 }
6997 let group_by = match query.group_by.as_deref().unwrap_or("day") {
6998 "day" => UsageGroupBy::Day,
6999 "model" => UsageGroupBy::Model,
7000 "provider" => UsageGroupBy::Provider,
7001 "thread" => UsageGroupBy::Thread,
7002 other => {
7003 return Err(ApiError::bad_request(format!(
7004 "Unsupported group_by '{other}': expected one of day, model, provider, thread"
7005 )));
7006 }
7007 };
7008
7009 let aggregation = state
7010 .runtime_threads
7011 .aggregate_usage(since, until, group_by)
7012 .await
7013 .map_err(|e| ApiError::internal(e.to_string()))?;
7014 Ok(Json(json!(aggregation)))
7015 }
7016
7017 #[derive(Debug, Deserialize)]
7018 struct SnapshotsQuery {
7019 /// Maximum number of snapshots to return. Mirrors `/restore list [N]`.
7020 limit: Option<usize>,
7021 }
7022
7023 #[derive(Debug, Serialize)]
7024 struct SnapshotEntry {
7025 id: String,
7026 label: String,
7027 timestamp: i64,
7028 }
7029
7030 async fn list_snapshots(
7031 State(state): State<RuntimeApiState>,
7032 Query(query): Query<SnapshotsQuery>,
7033 ) -> Result<Json<Vec<SnapshotEntry>>, ApiError> {
7034 Ok(Json(snapshot_entries_for_workspace(
7035 &state.workspace,
7036 query,
7037 )?))
7038 }
7039
7040 async fn restore_snapshot(
7041 State(state): State<RuntimeApiState>,
7042 Path(id): Path<String>,
7043 ) -> Result<Json<Value>, ApiError> {
7044 if !snapshot_id_is_well_formed(&id) {
7045 return Err(ApiError::bad_request(
7046 "snapshot id must be the exact hexadecimal id reported by GET /v1/snapshots",
7047 ));
7048 }
7049 let reservation = state
7050 .runtime_threads
7051 .workspace_restore_guard(&state.workspace)
7052 .await
7053 .map_err(map_thread_err)?;
7054 let restored_id = id.clone();
7055 tokio::task::spawn_blocking(move || {
7056 let _reservation = reservation;
7057 restore_snapshot_for_workspace(&state.workspace, &restored_id)
7058 })
7059 .await
7060 .map_err(|e| ApiError::internal(format!("Restore task failed: {e}")))??;
7061 Ok(Json(json!({
7062 "restored": id,
7063 })))
7064 }
7065
7066 fn restore_snapshot_for_workspace(workspace: &FsPath, id: &str) -> Result<(), ApiError> {
7067 let repo = crate::snapshot::SnapshotRepo::open_or_init(workspace)
7068 .map_err(|e| ApiError::internal(format!("Snapshot repo init failed: {e}")))?;
7069 let snapshot_id = crate::snapshot::SnapshotId(id.to_string());
7070 repo.restore(&snapshot_id)
7071 .map_err(|e| ApiError::internal(format!("Snapshot restore failed: {e}")))
7072 }
7073
7074 fn snapshot_entries_for_workspace(
7075 workspace: &FsPath,
7076 query: SnapshotsQuery,
7077 ) -> Result<Vec<SnapshotEntry>, ApiError> {
7078 const DEFAULT_LIMIT: usize = 20;
7079 const MAX_LIMIT: usize = 100;
7080
7081 let limit = match query.limit.unwrap_or(DEFAULT_LIMIT) {
7082 1..=MAX_LIMIT => query.limit.unwrap_or(DEFAULT_LIMIT),
7083 other => {
7084 return Err(ApiError::bad_request(format!(
7085 "limit must be between 1 and {MAX_LIMIT}; got {other}",
7086 )));
7087 }
7088 };
7089 let repo = crate::snapshot::SnapshotRepo::open_or_init(workspace)
7090 .map_err(|e| ApiError::internal(format!("Snapshot repo unavailable: {e}")))?;
7091 let snapshots = repo
7092 .list(limit)
7093 .map_err(|e| ApiError::internal(format!("Failed to list snapshots: {e}")))?;
7094 Ok(snapshots
7095 .into_iter()
7096 .map(|snapshot| SnapshotEntry {
7097 id: snapshot.id.as_str().to_string(),
7098 label: snapshot.label,
7099 timestamp: snapshot.timestamp,
7100 })
7101 .collect())
7102 }
7103
7104 // ── Provider / Model catalog endpoints ──
7105
7106 /// Entry in `GET /v1/providers`.
7107 ///
7108 /// Exposes the static provider registry so the GUI can render a dynamic
7109 /// provider picker instead of hard-coding `deepseek` only. The `id` matches
7110 /// `ApiProvider::as_str()`; callers must also preserve `model_provider_id`
7111 /// when present. Both can be pinned to one new thread via `POST /v1/threads`
7112 /// without mutating the runtime's global provider configuration.
7113 #[derive(Debug, Clone, Serialize)]
7114 struct ProviderEntry {
7115 /// Stable generic provider kind — matches `ApiProvider::as_str()` and is
7116 /// suitable for `CreateThreadRequest.model_provider`. This is not always
7117 /// the exact configured route id: named custom routes also require
7118 /// `model_provider_id` below.
7119 id: String,
7120 /// Exact configured provider key for the active route, when one exists.
7121 /// A named custom route such as `lm-studio` is represented as generic
7122 /// `id = "custom"` plus `model_provider_id = "lm-studio"` so a new
7123 /// thread never collapses back to the legacy root custom route.
7124 model_provider_id: Option<String>,
7125 /// Human-friendly name for picker UIs (e.g. "DeepSeek", "OpenAI").
7126 display_name: String,
7127 /// Default model id for this provider, if any. Empty for pass-through
7128 /// providers (Ollama / Custom) that expose no built-in catalog.
7129 default_model: String,
7130 /// Whether this provider exposes a built-in model list. When false, the
7131 /// GUI should render a free-text input instead of calling
7132 /// `/v1/providers/{id}/models`.
7133 has_model_catalog: bool,
7134 /// Sanitized structural credential classification for the exact route.
7135 /// This deliberately contains no credential, endpoint, path, environment
7136 /// variable, consent-source, or token metadata.
7137 #[serde(rename = "credentialState")]
7138 credential_state: ProviderCredentialState,
7139 /// Which *class* of source owns this route's credential (#6179). A class,
7140 /// never a value, a path, or an environment variable name — the guarantee
7141 /// above still holds. Clients need it to tell "you have no key" apart from
7142 /// "your key is owned elsewhere and this control cannot change it".
7143 #[serde(rename = "credentialSource")]
7144 credential_source: secrets::ProviderCredentialSource,
7145 /// Whether `PUT`/`DELETE /v1/providers/{id}/key` will act on this route.
7146 /// False means the write would be refused, so the control should be
7147 /// disabled rather than allowed to fail late.
7148 #[serde(rename = "credentialWritable")]
7149 credential_writable: bool,
7150 /// Why a write is refused, as user-facing copy. Present only when
7151 /// `credentialWritable` is false.
7152 #[serde(
7153 rename = "credentialWritableReason",
7154 skip_serializing_if = "Option::is_none"
7155 )]
7156 credential_writable_reason: Option<&'static str>,
7157 }
7158
7159 /// Stable, non-secret wire projection of provider readiness.
7160 ///
7161 /// The richer internal classification remains private to the Runtime. In
7162 /// particular, saved API keys and imported tokens collapse to `configured`,
7163 /// while login and external-consent states collapse to `login_required`.
7164 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
7165 #[serde(rename_all = "snake_case")]
7166 enum ProviderCredentialState {
7167 Configured,
7168 LoginRequired,
7169 Missing,
7170 NoAuth,
7171 Local,
7172 Legacy,
7173 }
7174
7175 impl From<crate::provider_readiness::CredentialState> for ProviderCredentialState {
7176 fn from(value: crate::provider_readiness::CredentialState) -> Self {
7177 use crate::provider_readiness::CredentialState;
7178
7179 match value {
7180 CredentialState::Saved | CredentialState::ImportedToken => Self::Configured,
7181 CredentialState::MissingLogin | CredentialState::ExternalConsent => Self::LoginRequired,
7182 CredentialState::MissingKey => Self::Missing,
7183 CredentialState::NoAuth => Self::NoAuth,
7184 CredentialState::Local => Self::Local,
7185 CredentialState::Legacy => Self::Legacy,
7186 }
7187 }
7188 }
7189
7190 #[derive(Debug, Clone, Serialize)]
7191 struct ProvidersResponse {
7192 /// Currently active provider id (matches `GET /v1/config`'s `provider`).
7193 current: String,
7194 providers: Vec<ProviderEntry>,
7195 }
7196
7197 /// Entry in `GET /v1/providers/{id}/models`.
7198 #[derive(Debug, Clone, Serialize)]
7199 struct ProviderModelEntry {
7200 /// Canonical model id suitable for `POST /v1/threads`'s `model` field.
7201 id: String,
7202 /// Image-input support reported by the exact resolved provider/model
7203 /// offering. Unknown stays unknown: the API never guesses from a model
7204 /// name or transport protocol.
7205 image_input: codewhale_config::route::CapabilityState,
7206 output_token_limit: codewhale_config::route::CapabilityState,
7207 reasoning_effort: codewhale_config::route::CapabilityState,
7208 reasoning_effort_levels: Vec<String>,
7209 reasoning_effort_source: Option<&'static str>,
7210 }
7211
7212 #[derive(Debug, Clone, Serialize)]
7213 struct ProviderModelsResponse {
7214 provider: String,
7215 #[serde(skip_serializing_if = "Option::is_none")]
7216 model_provider_id: Option<String>,
7217 models: Vec<ProviderModelEntry>,
7218 total: usize,
7219 #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
7220 next_cursor: Option<String>,
7221 }
7222
7223 const DEFAULT_PROVIDER_MODELS_PAGE_SIZE: usize = 100;
7224 const MAX_PROVIDER_MODELS_PAGE_SIZE: usize = 250;
7225 const MAX_PROVIDER_MODELS_CATALOG_SIZE: usize = 10_000;
7226 const PROVIDER_MODELS_CURSOR_VERSION: u8 = 1;
7227 const MAX_PROVIDER_MODELS_CURSOR_BYTES: usize = 1_024;
7228 const MAX_PROVIDER_MODELS_FILTER_CHARS: usize = 128;
7229
7230 #[derive(Debug, Clone, Serialize, Deserialize)]
7231 struct ProviderModelsCursor {
7232 version: u8,
7233 provider: String,
7234 filter: String,
7235 catalog_fingerprint: String,
7236 #[serde(default, skip_serializing_if = "Option::is_none")]
7237 route_fingerprint: Option<String>,
7238 offset: usize,
7239 }
7240
7241 fn normalized_provider_model_filter(filter: Option<&str>) -> Result<String, ApiError> {
7242 let filter = filter.unwrap_or_default().trim();
7243 if filter.chars().count() > MAX_PROVIDER_MODELS_FILTER_CHARS {
7244 return Err(ApiError::bad_request(format!(
7245 "Provider model filter exceeds {MAX_PROVIDER_MODELS_FILTER_CHARS} characters"
7246 )));
7247 }
7248 Ok(filter.to_lowercase())
7249 }
7250
7251 fn encode_provider_models_cursor(cursor: &ProviderModelsCursor) -> Result<String, ApiError> {
7252 let bytes = serde_json::to_vec(cursor)
7253 .map_err(|error| ApiError::internal(format!("Could not encode model cursor: {error}")))?;
7254 if bytes.len() > MAX_PROVIDER_MODELS_CURSOR_BYTES {
7255 return Err(ApiError::internal(
7256 "Provider model cursor exceeds the safe size limit",
7257 ));
7258 }
7259 Ok(URL_SAFE_NO_PAD.encode(bytes))
7260 }
7261
7262 fn decode_provider_models_cursor(value: &str) -> Result<ProviderModelsCursor, ApiError> {
7263 if value.is_empty() || value.len() > MAX_PROVIDER_MODELS_CURSOR_BYTES.div_ceil(3) * 4 {
7264 return Err(ApiError::bad_request("Invalid provider model cursor"));
7265 }
7266 let bytes = URL_SAFE_NO_PAD
7267 .decode(value)
7268 .map_err(|_| ApiError::bad_request("Invalid provider model cursor"))?;
7269 if bytes.len() > MAX_PROVIDER_MODELS_CURSOR_BYTES {
7270 return Err(ApiError::bad_request("Invalid provider model cursor"));
7271 }
7272 let cursor: ProviderModelsCursor = serde_json::from_slice(&bytes)
7273 .map_err(|_| ApiError::bad_request("Invalid provider model cursor"))?;
7274 if cursor.version != PROVIDER_MODELS_CURSOR_VERSION
7275 || cursor.provider.is_empty()
7276 || cursor.offset == 0
7277 || cursor.offset > MAX_PROVIDER_MODELS_CATALOG_SIZE
7278 || cursor.catalog_fingerprint.len() != 64
7279 || !cursor
7280 .catalog_fingerprint
7281 .bytes()
7282 .all(|byte| byte.is_ascii_hexdigit())
7283 {
7284 return Err(ApiError::bad_request("Invalid provider model cursor"));
7285 }
7286 Ok(cursor)
7287 }
7288
7289 fn paginate_provider_models(
7290 provider: &str,
7291 mut models: Vec<ProviderModelEntry>,
7292 params: &ListProviderModelsParams,
7293 route_fingerprint: Option<String>,
7294 ) -> Result<ProviderModelsResponse, ApiError> {
7295 let filter = normalized_provider_model_filter(params.filter.as_deref())?;
7296 let limit = params.limit.unwrap_or(DEFAULT_PROVIDER_MODELS_PAGE_SIZE);
7297 if limit == 0 || limit > MAX_PROVIDER_MODELS_PAGE_SIZE {
7298 return Err(ApiError::bad_request(format!(
7299 "Provider model page limit must be between 1 and {MAX_PROVIDER_MODELS_PAGE_SIZE}"
7300 )));
7301 }
7302
7303 models.sort_by(|left, right| {
7304 left.id
7305 .to_lowercase()
7306 .cmp(&right.id.to_lowercase())
7307 .then_with(|| left.id.cmp(&right.id))
7308 });
7309 models.dedup_by(|left, right| left.id.eq_ignore_ascii_case(&right.id));
7310 if models.len() > MAX_PROVIDER_MODELS_CATALOG_SIZE {
7311 return Err(ApiError::internal(format!(
7312 "Provider model catalog exceeds the safe {MAX_PROVIDER_MODELS_CATALOG_SIZE}-row limit"
7313 )));
7314 }
7315 if !filter.is_empty() {
7316 models.retain(|entry| entry.id.to_lowercase().contains(&filter));
7317 }
7318
7319 // A live catalog can refresh between requests. Bind the opaque position
7320 // to the exact sorted projection so additions before the cursor cannot
7321 // disappear silently from a multi-page response.
7322 let catalog_bytes = serde_json::to_vec(&models).map_err(|error| {
7323 ApiError::internal(format!("Could not fingerprint model catalog: {error}"))
7324 })?;
7325 let catalog_fingerprint = Sha256::digest(catalog_bytes)
7326 .iter()
7327 .map(|byte| format!("{byte:02x}"))
7328 .collect::<String>();
7329 let start = if let Some(encoded) = params.cursor.as_deref() {
7330 let cursor = decode_provider_models_cursor(encoded)?;
7331 if cursor.provider != provider
7332 || cursor.filter != filter
7333 || cursor.route_fingerprint != route_fingerprint
7334 {
7335 return Err(ApiError::bad_request(
7336 "Provider model cursor does not match this provider, configured route, and filter",
7337 ));
7338 }
7339 if cursor.catalog_fingerprint != catalog_fingerprint {
7340 return Err(ApiError::bad_request(
7341 "Provider model cursor is stale; restart from the first page",
7342 ));
7343 }
7344 cursor.offset
7345 } else {
7346 0
7347 };
7348 let total = models.len();
7349 let end = start.saturating_add(limit).min(total);
7350 let page = models
7351 .get(start..end)
7352 .ok_or_else(|| ApiError::bad_request("Provider model cursor is outside the catalog"))?
7353 .to_vec();
7354 let next_cursor = if end < total {
7355 Some(encode_provider_models_cursor(&ProviderModelsCursor {
7356 version: PROVIDER_MODELS_CURSOR_VERSION,
7357 provider: provider.to_string(),
7358 filter,
7359 catalog_fingerprint,
7360 route_fingerprint,
7361 offset: end,
7362 })?)
7363 } else {
7364 None
7365 };
7366
7367 Ok(ProviderModelsResponse {
7368 provider: provider.to_string(),
7369 model_provider_id: params.model_provider_id.clone(),
7370 models: page,
7371 total,
7372 next_cursor,
7373 })
7374 }
7375
7376 fn push_unique_model(models: &mut Vec<String>, model: &str) {
7377 let model = model.trim();
7378 if !model.is_empty()
7379 && !models
7380 .iter()
7381 .any(|existing| existing.eq_ignore_ascii_case(model))
7382 {
7383 models.push(model.to_string());
7384 }
7385 }
7386
7387 fn provider_models_for_api(
7388 config: &Config,
7389 active_provider: ApiProvider,
7390 provider: ApiProvider,
7391 ) -> Vec<String> {
7392 let mut models = Vec::new();
7393 if let Some(model) = config
7394 .provider_config_for(provider)
7395 .and_then(|entry| entry.model.as_deref())
7396 {
7397 push_unique_model(&mut models, model);
7398 }
7399 if provider == active_provider {
7400 let active_model = provider_default_model_for_api(config, active_provider, provider);
7401 if !active_model.trim().eq_ignore_ascii_case("auto") {
7402 push_unique_model(&mut models, &active_model);
7403 }
7404 }
7405 let exact_catalog = crate::provider_catalog_live::cached_entry_for_route(
7406 provider,
7407 &config.provider_identity_for(provider),
7408 &config.base_url_for_route(provider),
7409 )
7410 .ok()
7411 .flatten()
7412 .is_some_and(|entry| entry.fetched_at > 0);
7413 if !config.model_ids_pass_through_for_provider(provider) || exact_catalog {
7414 for model in crate::provider_lake::models_for_provider(config, active_provider, provider) {
7415 push_unique_model(&mut models, &model);
7416 }
7417 }
7418 for model in config.custom_models.as_deref().unwrap_or_default() {
7419 if crate::provider_lake::configured_model_for_route(
7420 config,
7421 provider,
7422 &config.provider_identity_for(provider),
7423 &config.base_url_for_route(provider),
7424 &model.id,
7425 )
7426 .is_some()
7427 && !models.contains(&model.id)
7428 {
7429 models.push(model.id.clone());
7430 }
7431 }
7432 if provider == ApiProvider::Ollama {
7433 models.retain(|model| !crate::config::is_unresolved_local_ollama_model(model));
7434 }
7435 models
7436 }
7437
7438 fn provider_model_image_input_for_api(
7439 config: &Config,
7440 provider: ApiProvider,
7441 model: &str,
7442 ) -> codewhale_config::route::CapabilityState {
7443 crate::route_runtime::resolve_runtime_route(config, provider, Some(model))
7444 .map(|route| route.candidate.capabilities().image_input)
7445 .unwrap_or_default()
7446 }
7447
7448 fn provider_model_output_token_limit_for_api(
7449 config: &Config,
7450 provider: ApiProvider,
7451 model: &str,
7452 ) -> codewhale_config::route::CapabilityState {
7453 use codewhale_config::route::CapabilityState;
7454 crate::route_runtime::resolve_runtime_route(config, provider, Some(model))
7455 .map(|route| {
7456 if crate::route_budget::route_supports_output_token_limit(
7457 route.identity.provider,
7458 route.candidate.protocol(),
7459 ) {
7460 CapabilityState::Supported
7461 } else {
7462 CapabilityState::Unsupported
7463 }
7464 })
7465 .unwrap_or_default()
7466 }
7467
7468 fn provider_model_entry_for_api(
7469 config: &Config,
7470 provider: ApiProvider,
7471 model: String,
7472 ) -> ProviderModelEntry {
7473 use crate::reasoning_preference::ReasoningEffort;
7474 use codewhale_config::route::CapabilityState;
7475
7476 let mut entry = ProviderModelEntry {
7477 image_input: provider_model_image_input_for_api(config, provider, &model),
7478 output_token_limit: provider_model_output_token_limit_for_api(config, provider, &model),
7479 id: model,
7480 reasoning_effort: CapabilityState::Unknown,
7481 reasoning_effort_levels: Vec::new(),
7482 reasoning_effort_source: None,
7483 };
7484 // A provider kind and a familiar model name do not establish the
7485 // capabilities of a different endpoint or named compatible route.
7486 if provider == ApiProvider::Custom || config.provider_uses_custom_endpoint(provider) {
7487 return entry;
7488 }
7489 if provider == ApiProvider::OpenaiCodex {
7490 let roster = crate::codex_model_cache::model_roster();
7491 if roster.freshness != crate::codex_model_cache::CodexModelCacheFreshness::Fresh {
7492 return entry;
7493 }
7494 let Some(metadata) = roster.metadata_for(&entry.id) else {
7495 return entry;
7496 };
7497 for effort in metadata
7498 .efforts
7499 .iter()
7500 .filter_map(|raw| ReasoningEffort::from_catalog_token(raw))
7501 // This API advertises active effort controls. Apps currently
7502 // treats off as omission, not a provider's explicit none value.
7503 .filter(|effort| *effort != ReasoningEffort::Off)
7504 // Native compatibility still aliases minimal to low (and auto
7505 // to medium). Do not advertise a manual tier the wire changes.
7506 .filter(|effort| effort.api_value_for_provider(provider) == Some(effort.as_setting()))
7507 {
7508 let level = effort.as_setting().to_string();
7509 if !entry.reasoning_effort_levels.contains(&level) {
7510 entry.reasoning_effort_levels.push(level);
7511 }
7512 }
7513 entry.reasoning_effort_source = Some(roster.source);
7514 if metadata.reasoning == Some(false) {
7515 entry.reasoning_effort = CapabilityState::Unsupported;
7516 }
7517 } else if let Some(efforts) = ReasoningEffort::catalog_effort_values(provider, &entry.id) {
7518 entry.reasoning_effort_levels = efforts
7519 .into_iter()
7520 .filter(|effort| *effort != ReasoningEffort::Off)
7521 .map(|effort| effort.as_setting().to_string())
7522 .collect();
7523 entry.reasoning_effort_source = Some("catalog");
7524 } else if crate::route_runtime::resolve_runtime_route(config, provider, Some(&entry.id))
7525 .is_ok_and(|route| route.candidate.capabilities().reasoning == CapabilityState::Unsupported)
7526 {
7527 entry.reasoning_effort = CapabilityState::Unsupported;
7528 entry.reasoning_effort_source = Some("catalog");
7529 }
7530 if !entry.reasoning_effort_levels.is_empty() {
7531 entry.reasoning_effort = CapabilityState::Supported;
7532 }
7533 entry
7534 }
7535
7536 fn provider_default_model_for_api(
7537 config: &Config,
7538 _active_provider: ApiProvider,
7539 provider: ApiProvider,
7540 ) -> String {
7541 let model = crate::model_inventory::provider_default_model(config, provider);
7542 if provider == ApiProvider::Ollama && crate::config::is_unresolved_local_ollama_model(&model) {
7543 String::new()
7544 } else {
7545 model
7546 }
7547 }
7548
7549 pub(crate) fn runtime_chat_model_id_is_safe(value: &str) -> bool {
7550 let sanitized = crate::cost_status::sanitize_persisted_route_label(value);
7551 value == value.trim()
7552 && !value.is_empty()
7553 && value.len() <= 256
7554 && value
7555 .bytes()
7556 .next()
7557 .is_some_and(|byte| byte.is_ascii_alphanumeric())
7558 && !value.contains("..")
7559 && !value.contains("://")
7560 // Runtime Chat publishes a non-secret selector, never an endpoint or
7561 // userinfo-bearing authority. Model families that need revisions can
7562 // use their ordinary slash/dash ids; `@` is intentionally excluded at
7563 // this trust boundary because `user:password@host:port/path` otherwise
7564 // passes the generic route-label sanitizer.
7565 && !value.contains('@')
7566 && !runtime_chat_model_id_looks_like_host_port(value)
7567 && !value.starts_with("redacted-")
7568 && sanitized == value
7569 && value.bytes().all(|byte| {
7570 byte.is_ascii_alphanumeric()
7571 || matches!(byte, b'.' | b'_' | b':' | b'/' | b'@' | b'+' | b'-')
7572 })
7573 }
7574
7575 fn runtime_chat_model_id_looks_like_host_port(value: &str) -> bool {
7576 let authority = value.split('/').next().unwrap_or(value);
7577 let Some((host, port)) = authority.rsplit_once(':') else {
7578 return false;
7579 };
7580 !host.is_empty() && !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit())
7581 }
7582
7583 pub(crate) fn runtime_chat_route_id_is_safe(value: &str) -> bool {
7584 let sanitized = crate::cost_status::sanitize_persisted_route_label(value);
7585 value == value.trim()
7586 && !value.is_empty()
7587 && value.len() <= 128
7588 && value
7589 .bytes()
7590 .next()
7591 .is_some_and(|byte| byte.is_ascii_alphanumeric())
7592 && !value.contains("..")
7593 && !value.starts_with("redacted-")
7594 && sanitized == value
7595 && value
7596 .bytes()
7597 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
7598 }
7599
7600 fn runtime_chat_safe_models(mut models: Vec<String>) -> Result<Vec<String>, String> {
7601 models.retain(|model| runtime_chat_model_id_is_safe(model));
7602 models.sort();
7603 models.dedup();
7604 if models.len() > MAX_PROVIDER_MODELS_CATALOG_SIZE {
7605 return Err(format!(
7606 "The active Runtime provider catalog exceeds the safe {MAX_PROVIDER_MODELS_CATALOG_SIZE}-model relay limit."
7607 ));
7608 }
7609 if models.is_empty() {
7610 return Err("The active Runtime provider has no safe model catalog.".to_string());
7611 }
7612 Ok(models)
7613 }
7614
7615 /// Build the deliberately narrow provider projection used by the account-owned
7616 /// Runtime Chat relay. This is the same active-route truth exposed by the
7617 /// authenticated native `/v1/runtime/info`, `/v1/providers`, and
7618 /// `/v1/providers/{id}/models` endpoints, collapsed to the one exact route the
7619 /// current Runtime can use without moving credentials across the relay.
7620 pub(crate) fn runtime_chat_relay_catalog(
7621 config: &Config,
7622 challenge: &str,
7623 ) -> Result<Value, String> {
7624 use crate::provider_readiness::CredentialState;
7625
7626 const PROTOCOL: &str = "codewhale.runtime-chat-relay.v1";
7627 if !(32..=128).contains(&challenge.len())
7628 || !challenge
7629 .bytes()
7630 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
7631 {
7632 return Err("Codewhale returned an invalid Runtime Chat relay challenge.".to_string());
7633 }
7634
7635 let provider = config.api_provider();
7636 let identity = config
7637 .active_provider_identity(provider)
7638 .map_err(|_| "The active Runtime provider identity is invalid.".to_string())?;
7639 let credential_state =
7640 match crate::provider_readiness::credential_state_for_provider(config, provider) {
7641 CredentialState::Saved | CredentialState::ImportedToken => "configured",
7642 CredentialState::Local => "local",
7643 CredentialState::NoAuth => "no_auth",
7644 CredentialState::MissingKey
7645 | CredentialState::MissingLogin
7646 | CredentialState::ExternalConsent
7647 | CredentialState::Legacy => {
7648 return Err("The active Runtime provider is not ready for Chat.".to_string());
7649 }
7650 };
7651
7652 let models = runtime_chat_safe_models(provider_models_for_api(config, provider, provider))?;
7653 let requested_default = provider_default_model_for_api(config, provider, provider);
7654 if provider == ApiProvider::Ollama && requested_default.is_empty() {
7655 return Err("The active local provider has no fresh default model catalog.".to_string());
7656 }
7657 let default_model = models
7658 .iter()
7659 .find(|model| model.as_str() == requested_default)
7660 .cloned()
7661 .unwrap_or_else(|| models[0].clone());
7662 let model_provider_id = identity
7663 .persisted_id()
7664 .filter(|value| !value.trim().is_empty())
7665 .unwrap_or_else(|| provider.as_str())
7666 .to_string();
7667 if !runtime_chat_route_id_is_safe(&model_provider_id) {
7668 return Err("The active Runtime model-provider identity is invalid.".to_string());
7669 }
7670
7671 Ok(json!({
7672 "protocol": PROTOCOL,
7673 "challenge": challenge,
7674 "runtime": {
7675 "service": "codewhale-runtime-api",
7676 "apiVersion": RUNTIME_API_VERSION,
7677 "codewhaleVersion": env!("CARGO_PKG_VERSION"),
7678 "authRequired": true,
7679 "capabilities": {
7680 "relay_chat_v1": true,
7681 "isolated_chat_threads": true,
7682 "turn_operation_idempotency": true,
7683 "turn_image_inputs": true,
7684 "turn_output_token_limit": true,
7685 "tool_execution": false,
7686 "stable_event_ids": true,
7687 },
7688 },
7689 "providers": [{
7690 "id": provider.as_str(),
7691 "modelProviderId": model_provider_id,
7692 "displayName": provider.display_name(),
7693 "defaultModel": default_model,
7694 "credentialState": credential_state,
7695 "models": models.into_iter().map(|model| {
7696 let entry = provider_model_entry_for_api(config, provider, model);
7697 json!({
7698 "imageInput": entry.image_input,
7699 "outputTokenLimit": entry.output_token_limit,
7700 "id": entry.id,
7701 "reasoningEffort": entry.reasoning_effort,
7702 "reasoningEffortLevels": entry.reasoning_effort_levels,
7703 "reasoningEffortSource": entry.reasoning_effort_source,
7704 })
7705 }).collect::<Vec<_>>(),
7706 }],
7707 }))
7708 }
7709
7710 async fn list_providers(
7711 State(state): State<RuntimeApiState>,
7712 ) -> Result<Json<ProvidersResponse>, ApiError> {
7713 #[cfg(test)]
7714 let env_ticket = crate::test_support::env_scope_ticket();
7715 tokio::task::spawn_blocking(move || {
7716 #[cfg(test)]
7717 let _membership = crate::test_support::join_env_scope(env_ticket);
7718 let config = state.config.read().clone();
7719 secrets::invalidate_stale_account_catalog(&config);
7720 let active_provider = config.api_provider();
7721 let active_identity = config
7722 .active_provider_identity(active_provider)
7723 .map_err(ApiError::bad_request)?;
7724 let current = active_provider.as_str().to_string();
7725 let mut providers = Vec::new();
7726 for api_provider in ApiProvider::sorted_for_display() {
7727 let default_model =
7728 provider_default_model_for_api(&config, active_provider, api_provider);
7729 let identity = config.provider_identity_for(api_provider);
7730 let base_url = config.base_url_for_route_identity(api_provider, &identity);
7731 let has_model_catalog = !crate::provider_lake::configured_catalog_models_for_route(
7732 &config,
7733 api_provider,
7734 &identity,
7735 &base_url,
7736 )
7737 .is_empty();
7738 let writeability = secrets::credential_writeability(&config, api_provider);
7739 providers.push(ProviderEntry {
7740 id: api_provider.as_str().to_string(),
7741 model_provider_id: (api_provider == active_provider)
7742 .then(|| active_identity.persisted_id().map(str::to_string))
7743 .flatten(),
7744 display_name: api_provider.display_name().to_string(),
7745 default_model,
7746 has_model_catalog,
7747 credential_state: crate::provider_readiness::credential_state_for_provider(
7748 &config,
7749 api_provider,
7750 )
7751 .into(),
7752 credential_source: writeability.source,
7753 credential_writable: writeability.writable,
7754 credential_writable_reason: writeability.reason,
7755 });
7756 }
7757 Ok(Json(ProvidersResponse { current, providers }))
7758 })
7759 .await
7760 .map_err(|_| ApiError::internal("Provider listing failed"))?
7761 }
7762
7763 #[derive(Debug, Default, Deserialize)]
7764 struct ListProviderModelsParams {
7765 /// Exact configured provider identity; omission retains the legacy projection.
7766 #[serde(default)]
7767 model_provider_id: Option<String>,
7768 /// Optional case-insensitive substring filter applied before pagination.
7769 #[serde(default)]
7770 filter: Option<String>,
7771 /// Opaque continuation cursor returned as `nextCursor` by the prior page.
7772 #[serde(default)]
7773 cursor: Option<String>,
7774 /// Page size. The bounded default is 100 and the maximum is 250.
7775 #[serde(default)]
7776 limit: Option<usize>,
7777 }
7778
7779 fn provider_models_identity(
7780 config: &Config,
7781 id: &str,
7782 exact_id: Option<&str>,
7783 ) -> Result<(ApiProvider, Option<crate::config::ProviderIdentity>), ApiError> {
7784 let api_provider = ApiProvider::parse(id)
7785 .ok_or_else(|| ApiError::bad_request(format!("Unknown provider id '{id}'")))?;
7786 // Reject requests for the legacy deepseek-cn alias that has no
7787 // ProviderKind metadata — the GUI should use `deepseek` instead.
7788 if api_provider == ApiProvider::DeepseekCN {
7789 return Err(ApiError::bad_request(
7790 "provider 'deepseek-cn' is a legacy alias; use 'deepseek' instead",
7791 ));
7792 }
7793 let identity = if let Some(exact_id) = exact_id {
7794 if exact_id.is_empty()
7795 || exact_id != exact_id.trim()
7796 || exact_id.chars().any(char::is_control)
7797 {
7798 return Err(ApiError::bad_request(
7799 "model_provider_id must be an exact configured identity",
7800 ));
7801 }
7802 let identity = config
7803 .resolve_persisted_provider_identity(Some(api_provider.as_str()), Some(exact_id))
7804 .map_err(ApiError::bad_request)?;
7805 if identity.provider != api_provider || identity.persisted_id() != Some(exact_id) {
7806 return Err(ApiError::bad_request(
7807 "model_provider_id does not match this provider route",
7808 ));
7809 }
7810 Some(identity)
7811 } else {
7812 None
7813 };
7814 Ok((api_provider, identity))
7815 }
7816
7817 async fn list_provider_models(
7818 State(state): State<RuntimeApiState>,
7819 Path(id): Path<String>,
7820 Query(params): Query<ListProviderModelsParams>,
7821 ) -> Result<Json<ProviderModelsResponse>, ApiError> {
7822 #[cfg(test)]
7823 let env_ticket = crate::test_support::env_scope_ticket();
7824 tokio::task::spawn_blocking(move || {
7825 #[cfg(test)]
7826 let _membership = crate::test_support::join_env_scope(env_ticket);
7827 let mut config = state.config.read().clone();
7828 secrets::invalidate_stale_account_catalog(&config);
7829 let (api_provider, identity) =
7830 provider_models_identity(&config, &id, params.model_provider_id.as_deref())?;
7831 let route_fingerprint = if let Some(identity) = identity {
7832 config.scope_to_provider_identity(&identity);
7833 let route = serde_json::to_vec(&(
7834 api_provider.as_str(),
7835 params.model_provider_id.as_deref(),
7836 config.base_url_for_route_identity(api_provider, &identity.key),
7837 ))
7838 .map_err(|error| {
7839 ApiError::internal(format!("Could not fingerprint provider route: {error}"))
7840 })?;
7841 Some(crate::hashing::sha256_hex(route))
7842 } else {
7843 None
7844 };
7845 let models = provider_models_for_api(&config, config.api_provider(), api_provider)
7846 .into_iter()
7847 .map(|id| provider_model_entry_for_api(&config, api_provider, id))
7848 .collect();
7849 paginate_provider_models(api_provider.as_str(), models, &params, route_fingerprint)
7850 .map(Json)
7851 })
7852 .await
7853 .map_err(|_| ApiError::internal("Provider model listing failed"))?
7854 }
7855
7856 #[derive(Deserialize)]
7857 #[serde(deny_unknown_fields)]
7858 struct RefreshProviderModelsParams {
7859 model_provider_id: Option<String>,
7860 }
7861
7862 async fn refresh_provider_models(
7863 State(state): State<RuntimeApiState>,
7864 Path(id): Path<String>,
7865 Query(params): Query<RefreshProviderModelsParams>,
7866 ) -> Result<Json<crate::provider_lake::CatalogUpdateReceipt>, ApiError> {
7867 let runtime = tokio::runtime::Handle::current();
7868 #[cfg(test)]
7869 let env_ticket = crate::test_support::env_scope_ticket();
7870 tokio::task::spawn_blocking(move || {
7871 #[cfg(test)]
7872 let _membership = crate::test_support::join_env_scope(env_ticket);
7873 let config = state.config.read().clone();
7874 let (provider, exact) =
7875 provider_models_identity(&config, &id, params.model_provider_id.as_deref())?;
7876 let identity = match exact {
7877 Some(identity) => identity,
7878 None => config
7879 .active_provider_identity(provider)
7880 .map_err(ApiError::bad_request)?,
7881 };
7882 Ok(Json(runtime.block_on(
7883 crate::provider_lake::update_provider_catalog(&config, &identity),
7884 )))
7885 })
7886 .await
7887 .map_err(|_| ApiError::internal("Provider model refresh failed"))?
7888 }
7889
7890 /// Request body for `POST /v1/providers/{id}/switch`.
7891 ///
7892 /// Mirrors the TUI's `AppAction::SwitchProvider { provider, model }` payload
7893 /// (see `tui/ui.rs::switch_provider`). `model` is optional: when omitted,
7894 /// the runtime resolves the active model from `[providers.<id>].model` (or
7895 /// the provider's built-in default) and **does not** persist a `model` key,
7896 /// so the user's per-provider config is preserved. When provided, the model
7897 /// is normalized and persisted in the target provider's canonical model slot.
7898 #[derive(Debug, Deserialize, Default)]
7899 struct SwitchProviderRequest {
7900 #[serde(default)]
7901 model: Option<String>,
7902 }
7903
7904 /// Response for `POST /v1/providers/{id}/switch`.
7905 #[derive(Debug, Serialize)]
7906 struct SwitchProviderResponse {
7907 /// The provider id that was switched to (echoes the path).
7908 provider: String,
7909 /// The resolved active model after the switch. This is the model the
7910 /// runtime will use for new turns — either the user-supplied override
7911 /// or the value resolved from `[providers.<id>].model` / the
7912 /// provider's built-in default. The GUI should display *this* value,
7913 /// not `ProviderEntry.default_model`, to avoid showing the catalog
7914 /// default when the user has configured a different model.
7915 model: String,
7916 /// False while the selected local endpoint has no executable default.
7917 model_available: bool,
7918 /// Human-readable status message for logging/toasts.
7919 message: String,
7920 /// Whether the new provider + model were persisted to config.toml.
7921 persisted: bool,
7922 }
7923
7924 /// `POST /v1/providers/{id}/switch` — switch the active provider, optionally
7925 /// overriding the model.
7926 ///
7927 /// This is the GUI-facing counterpart of the TUI's `/provider` slash command
7928 /// (`commands/groups/core/provider.rs`) and `AppAction::SwitchProvider`
7929 /// (`tui/ui.rs::switch_provider`). It exists so the GUI does not have to
7930 /// simulate the switch with multiple `POST /v1/config` calls + a reload,
7931 /// which historically led to two bugs:
7932 ///
7933 /// 1. The GUI persisted `model = <catalog default>` even when the user
7934 /// clicked the picker without choosing a model, clobbering a user-set
7935 /// `[providers.<id>].model` (e.g. `glm-2` overwritten with
7936 /// `deepseek-v4-pro`).
7937 /// 2. The GUI then displayed the catalog default instead of the actually
7938 /// resolved model, because it never asked the backend what model was
7939 /// selected.
7940 ///
7941 /// Persistence mirrors `switch_provider` (ui.rs:9390-9410):
7942 /// - `provider` is always persisted (root `provider` key).
7943 /// - `model` is persisted **only** when `model_override.is_some()`, via
7944 /// `persist_provider_model_key` (writes `[providers.<id>].model`, retaining
7945 /// the root field only for a legacy literal custom route). Provider and model
7946 /// are committed together through the canonical Config writer.
7947 /// - Config is reloaded from disk and synced to active engines via
7948 /// `runtime_threads.reload_config`, exactly like `POST /v1/config/reload`.
7949 async fn switch_provider(
7950 State(state): State<RuntimeApiState>,
7951 Path(id): Path<String>,
7952 Json(req): Json<SwitchProviderRequest>,
7953 ) -> Result<Json<SwitchProviderResponse>, ApiError> {
7954 use crate::config_persistence;
7955
7956 let target = ApiProvider::parse(&id)
7957 .ok_or_else(|| ApiError::bad_request(format!("Unknown provider id '{id}'")))?;
7958 // Reject the legacy deepseek-cn alias — same guard as list_provider_models.
7959 if target == ApiProvider::DeepseekCN {
7960 return Err(ApiError::bad_request(
7961 "provider 'deepseek-cn' is a legacy alias; use 'deepseek' instead",
7962 ));
7963 }
7964
7965 // Normalize the optional model override against the *target* provider.
7966 // Mirrors `set_config`'s `model` branch, which validates against the
7967 // active route — except here we validate against the target provider,
7968 // because the active route is about to change.
7969 // Read normalization and persistence identity from the same route snapshot.
7970 let (target, model_override, provider_identity) = {
7971 let config = state.config.read();
7972 let identity = config
7973 .resolve_provider_pin_identity(&id)
7974 .map_err(ApiError::bad_request)?;
7975 let mut scoped = config.clone();
7976 scoped.scope_to_provider_identity(&identity);
7977 let model = match req.model.as_deref().map(str::trim) {
7978 None | Some("") => None,
7979 Some(raw) => Some(normalize_runtime_config_model(
7980 &scoped,
7981 identity.provider,
7982 raw,
7983 )?),
7984 };
7985 (
7986 identity.provider,
7987 model,
7988 identity.persisted_id().unwrap_or(&identity.key).to_string(),
7989 )
7990 };
7991
7992 // Persist `provider` (always) + `model` (only when explicitly given).
7993 // This is the critical TUI-parity rule: a bare `/provider <id>` (no
7994 // model arg) MUST NOT write a `model` key, otherwise the user's
7995 // per-provider `[providers.<id>].model` config gets overwritten with
7996 // whatever the runtime resolves as the default.
7997 config_persistence::persist_provider_selection(
7998 state.config_path.as_deref(),
7999 target,
8000 &provider_identity,
8001 model_override.as_deref(),
8002 )
8003 .map_err(|e| ApiError::internal(format!("Failed to persist provider selection: {e}")))?;
8004
8005 // Reload config from disk and sync to active engines. This matches
8006 // `POST /v1/config/reload` exactly: load → validate thread routes →
8007 // swap in the new config. A failure here means an active thread's
8008 // route is invalid under the new provider — surface it so the GUI can
8009 // tell the user to fix their config.
8010 let mut reloaded = Config::load(state.config_path.clone(), state.config_profile.as_deref())
8011 .map_err(|e| ApiError::internal(format!("Failed to reload config: {e}")))?;
8012 reloaded.account_model_access = state.config.read().account_model_access.clone();
8013 state
8014 .runtime_threads
8015 .reload_config(reloaded.clone())
8016 .await
8017 .map_err(|err| ApiError::bad_request(format!("Config reload rejected: {err}")))?;
8018 {
8019 let mut config = state.config.write();
8020 *config = reloaded;
8021 }
8022
8023 // Read the resolved active model + provider from the freshly reloaded
8024 // config. This is the value the GUI must display — NOT the catalog
8025 // default and NOT the previously-active model.
8026 let (active_provider, active_model) = {
8027 let config = state.config.read();
8028 let provider = config.api_provider();
8029 (
8030 provider,
8031 provider_default_model_for_api(&config, provider, provider),
8032 )
8033 };
8034
8035 let model_available = !active_model.is_empty();
8036 let message = if !model_available {
8037 format!(
8038 "Provider switched to {}; refresh its catalog or select an explicit model.",
8039 active_provider.as_str()
8040 )
8041 } else if model_override.is_some() {
8042 format!(
8043 "Provider switched to {} (model: {}).",
8044 active_provider.as_str(),
8045 active_model
8046 )
8047 } else {
8048 format!(
8049 "Provider switched to {} (model: {}, resolved from config).",
8050 active_provider.as_str(),
8051 active_model
8052 )
8053 };
8054
8055 Ok(Json(SwitchProviderResponse {
8056 provider: active_provider.as_str().to_string(),
8057 model: active_model,
8058 model_available,
8059 message,
8060 persisted: true,
8061 }))
8062 }
8063
8064 // ── Config endpoints ──
8065
8066 /// GUI-relevant config snapshot returned by `GET /v1/config`.
8067 #[derive(Debug, Clone, Serialize)]
8068 struct GuiConfigResponse {
8069 model: String,
8070 model_available: bool,
8071 provider: String,
8072 approval_mode: String,
8073 reasoning_effort: String,
8074 auto_compact: bool,
8075 cost_currency: String,
8076 default_mode: String,
8077 default_model: String,
8078 base_url: String,
8079 allow_shell: bool,
8080 mcp_config_path: String,
8081 subagents_enabled: bool,
8082 subagents_max_depth: u32,
8083 show_thinking: bool,
8084 thinking_default_expanded: bool,
8085 thinking_highlight: bool,
8086 show_tool_details: bool,
8087 inline_diffs: String,
8088 locale: String,
8089 max_history: usize,
8090 workspace_follow_symlinks: bool,
8091 calm_mode: bool,
8092 sandbox_mode: String,
8093 strict_tool_mode: bool,
8094 memory_enabled: bool,
8095 search_provider: String,
8096 /// How `search_provider` was chosen: `default` / `config` /
8097 /// `env override` / `tavily key`. Runtime-only — never persisted.
8098 search_provider_source: String,
8099 prompt_suggestion: bool,
8100 /// Effective device settings, using the same leaf vocabulary as CLI/TUI.
8101 notifications: std::collections::BTreeMap<String, String>,
8102 }
8103
8104 /// Request body for `POST /v1/config` (set a single config key).
8105 #[derive(Debug, Deserialize)]
8106 struct SetConfigRequest {
8107 key: String,
8108 value: String,
8109 #[serde(default)]
8110 persist: bool,
8111 }
8112
8113 /// Response for `POST /v1/config` (set a single config key).
8114 #[derive(Debug, Serialize)]
8115 struct SetConfigResponse {
8116 key: String,
8117 value: String,
8118 message: String,
8119 persisted: bool,
8120 requires_reload: bool,
8121 }
8122
8123 fn persist_runtime_tui_setting(key: &str, value: &str) -> Result<(), ApiError> {
8124 // Validate against a throwaway copy first, so an invalid value is still a
8125 // 400 rather than an internal error raised from inside the transaction.
8126 let mut probe = crate::settings::Settings::load_persisted()
8127 .map_err(|e| ApiError::internal(format!("Failed to load settings: {e}")))?;
8128 probe
8129 .set(key, value)
8130 .map_err(|e| ApiError::bad_request(e.to_string()))?;
8131 // The write itself re-applies the key inside `Settings::transact`, so it
8132 // cannot save the stale snapshot above over a concurrent writer's field.
8133 crate::settings::Settings::transact(|settings| settings.set(key, value))
8134 .map_err(|e| ApiError::internal(format!("Failed to save settings: {e}")))
8135 }
8136
8137 /// Response for `POST /v1/config/reload`.
8138 #[derive(Debug, Serialize)]
8139 struct ReloadConfigResponse {
8140 message: String,
8141 }
8142
8143 async fn get_config(
8144 State(state): State<RuntimeApiState>,
8145 ) -> Result<Json<GuiConfigResponse>, ApiError> {
8146 let config = state.config.read();
8147 let settings = crate::settings::Settings::load_persisted().unwrap_or_default();
8148 let mcp_config_path = config.mcp_config_path().display().to_string();
8149
8150 let resolved_model = runtime_request_model(&config, None);
8151 let model_available = resolved_model.is_ok();
8152 let model = resolved_model.unwrap_or_default();
8153
8154 let provider = config.provider_identity_for(config.api_provider());
8155
8156 let approval_mode = config
8157 .approval_policy
8158 .as_deref()
8159 .unwrap_or("suggest")
8160 .to_string();
8161 let reasoning_effort = config.reasoning_effort().unwrap_or("auto").to_string();
8162 let cost_currency = settings.cost_currency.clone();
8163 let default_mode = settings.default_mode.as_str().to_string();
8164 // This field remains the DeepSeek preference even when another provider
8165 // is active, and follows the CN slot while the CN route is active — the CN
8166 // route resolves `[providers.deepseek_cn].model`, not the primary slot.
8167 // The root field is a legacy fallback for unmigrated configs.
8168 let default_provider = if config.api_provider() == ApiProvider::DeepseekCN {
8169 ApiProvider::DeepseekCN
8170 } else {
8171 ApiProvider::Deepseek
8172 };
8173 let identity = config
8174 .resolve_provider_pin_identity(default_provider.as_str())
8175 .map_err(ApiError::bad_request)?;
8176 let mut deepseek_config = config.clone();
8177 deepseek_config.scope_to_provider_identity(&identity);
8178 let default_model = deepseek_config.default_model();
8179 let base_url = config.active_route_base_url().to_string();
8180
8181 Ok(Json(GuiConfigResponse {
8182 model,
8183 model_available,
8184 provider,
8185 approval_mode,
8186 reasoning_effort,
8187 auto_compact: settings.auto_compact,
8188 cost_currency,
8189 default_mode,
8190 default_model,
8191 base_url,
8192 allow_shell: config.allow_shell(),
8193 mcp_config_path,
8194 subagents_enabled: config.subagents_enabled(),
8195 subagents_max_depth: config.subagent_max_spawn_depth(),
8196 show_thinking: settings.show_thinking,
8197 thinking_default_expanded: settings.thinking_default_expanded,
8198 thinking_highlight: settings.thinking_highlight,
8199 show_tool_details: settings.show_tool_details,
8200 inline_diffs: settings.inline_diffs.clone(),
8201 locale: settings.locale.clone(),
8202 max_history: settings.max_input_history,
8203 workspace_follow_symlinks: settings.workspace_follow_symlinks,
8204 calm_mode: settings.calm_mode,
8205 sandbox_mode: config
8206 .sandbox_mode
8207 .clone()
8208 .unwrap_or_else(|| "workspace-write".to_string()),
8209 strict_tool_mode: config.strict_tool_mode.unwrap_or(false),
8210 memory_enabled: config.memory_enabled(),
8211 search_provider: config.search_provider().as_str().to_string(),
8212 search_provider_source: config
8213 .search_provider_resolution()
8214 .source
8215 .as_str()
8216 .to_string(),
8217 prompt_suggestion: config.prompt_suggestion_enabled(),
8218 notifications: codewhale_config::notifications::NotificationSetting::ALL
8219 .into_iter()
8220 .map(|setting| {
8221 (
8222 setting.key().to_string(),
8223 config.notifications_config().display(setting),
8224 )
8225 })
8226 .collect(),
8227 }))
8228 }
8229
8230 async fn set_config(
8231 State(state): State<RuntimeApiState>,
8232 Json(req): Json<SetConfigRequest>,
8233 ) -> Result<Json<SetConfigResponse>, ApiError> {
8234 use crate::config_persistence;
8235
8236 let key = req.key.to_lowercase();
8237 let mut value = req.value;
8238 let persist = req.persist;
8239
8240 // Reuse the shared validator and locked leaf writer, including the active
8241 // profile's existing owner. Dry runs validate too; a typo must never look
8242 // like an accepted device setting. Reload remains the existing apply step.
8243 if codewhale_config::notifications::in_namespace(&key) {
8244 use codewhale_config::notifications::{NotificationConfigUpdate, NotificationSetting};
8245 let setting = NotificationSetting::required(&key)
8246 .map_err(|error| ApiError::bad_request(error.to_string()))?;
8247 let update = NotificationConfigUpdate::parse(setting, &value)
8248 .map_err(|error| ApiError::bad_request(error.to_string()))?;
8249 if persist {
8250 let path = config_persistence::config_toml_path(state.config_path.as_deref()).map_err(
8251 |error| ApiError::internal(format!("Failed to resolve config: {error}")),
8252 )?;
8253 update
8254 .persist_for_profile(&path, state.config_profile.as_deref())
8255 .map_err(|error| {
8256 ApiError::internal(format!("Failed to persist notification setting: {error}"))
8257 })?;
8258 }
8259 return Ok(Json(SetConfigResponse {
8260 key: format!("notifications.{}", setting.key()),
8261 value: update.display(),
8262 message: if persist {
8263 "Config persisted. Call /v1/config/reload to apply."
8264 } else {
8265 "Config not persisted (add persist: true to save)"
8266 }
8267 .to_string(),
8268 persisted: persist,
8269 requires_reload: persist,
8270 }));
8271 }
8272
8273 // Validate model keys even for dry-run requests. Model ids are provider
8274 // owned; accepting a DeepSeek id while Z.ai is active creates a saved
8275 // route that cannot execute after reload.
8276 let active_route = {
8277 let config = state.config.read();
8278 let provider = config.api_provider();
8279 match key.as_str() {
8280 "model" => {
8281 value = normalize_runtime_config_model(&config, provider, &value)?;
8282 }
8283 "default_model" => {
8284 let default_provider = if provider == ApiProvider::DeepseekCN {
8285 ApiProvider::DeepseekCN
8286 } else {
8287 ApiProvider::Deepseek
8288 };
8289 value = normalize_runtime_config_model(&config, default_provider, &value)?;
8290 }
8291 _ => {}
8292 }
8293 let identity = if key == "model" {
8294 let identity = config
8295 .active_provider_identity(provider)
8296 .map_err(ApiError::bad_request)?;
8297 identity.persisted_id().unwrap_or(&identity.key).to_string()
8298 } else {
8299 config.provider_identity_for(provider)
8300 };
8301 (provider, identity)
8302 };
8303
8304 // All persisted config keys require a reload to take effect in the
8305 // runtime (including syncing to active engines). The caller should
8306 // POST /v1/config/reload after persisting.
8307 let requires_reload = persist;
8308
8309 // Handle persistence directly via config_persistence.
8310 // The runtime's in-memory state is NOT mutated here; the caller
8311 // should POST /v1/config/reload after persisting to apply changes.
8312 if persist {
8313 let config_path = state.config_path.as_deref();
8314 let result: anyhow::Result<PathBuf> = match key.as_str() {
8315 "model" => config_persistence::persist_provider_model_key(
8316 config_path,
8317 active_route.0,
8318 &active_route.1,
8319 &value,
8320 ),
8321 "default_model" => {
8322 // The CN route reads its own `[providers.deepseek_cn]` slot;
8323 // writing the primary `deepseek` slot there would be unread.
8324 let (default_provider, default_identity) =
8325 if active_route.0 == ApiProvider::DeepseekCN {
8326 (ApiProvider::DeepseekCN, ApiProvider::DeepseekCN.as_str())
8327 } else {
8328 (ApiProvider::Deepseek, ApiProvider::Deepseek.as_str())
8329 };
8330 config_persistence::persist_provider_model_key(
8331 config_path,
8332 default_provider,
8333 default_identity,
8334 &value,
8335 )
8336 }
8337 "reasoning_effort" => {
8338 config_persistence::persist_root_string_key(config_path, "reasoning_effort", &value)
8339 }
8340 "approval_mode" | "approval_policy" => {
8341 config_persistence::persist_root_string_key(config_path, "approval_policy", &value)
8342 }
8343 "base_url" => config_persistence::persist_root_string_key(
8344 config_path,
8345 "active_route_base_url",
8346 &value,
8347 ),
8348 "provider" => {
8349 // Validate the provider id against the static registry so the
8350 // GUI gets a clear error instead of silently persisting an
8351 // unknown value that `Config::api_provider()` would later
8352 // ignore (falling back to DeepSeek).
8353 ApiProvider::parse(&value).ok_or_else(|| {
8354 ApiError::bad_request(format!(
8355 "Unknown provider '{value}'. Call GET /v1/providers for the list of supported ids."
8356 ))
8357 })?;
8358 let result =
8359 config_persistence::persist_root_string_key(config_path, "provider", &value);
8360 if result.is_ok() {
8361 // Keep the in-memory provider in step with the persisted
8362 // value so a following set_config(model) resolves the new
8363 // provider's table instead of clobbering the previous
8364 // provider's model slot (#4658 follow-up).
8365 state.config.write().provider = Some(value.clone());
8366 }
8367 result
8368 }
8369 "provider_url" | "provider_base_url" => {
8370 let provider = state.config.read().api_provider();
8371 config_persistence::persist_provider_base_url_key(config_path, provider, &value)
8372 }
8373 "cost_currency"
8374 | "default_mode"
8375 | "auto_compact"
8376 | "show_thinking"
8377 | "thinking_default_expanded"
8378 | "thinking_highlight"
8379 | "show_tool_details"
8380 | "inline_diffs"
8381 | "calm_mode"
8382 | "workspace_follow_symlinks"
8383 | "locale"
8384 | "max_history" => {
8385 persist_runtime_tui_setting(&key, &value)?;
8386 return Ok(Json(SetConfigResponse {
8387 key,
8388 value,
8389 message: "Config persisted. Call /v1/config/reload to apply.".to_string(),
8390 persisted: true,
8391 requires_reload,
8392 }));
8393 }
8394 "allow_shell" => {
8395 let enabled = value.parse::<bool>().map_err(|_| {
8396 ApiError::bad_request(format!(
8397 "Invalid value '{value}' for allow_shell: expected 'true' or 'false'"
8398 ))
8399 })?;
8400 config_persistence::persist_root_bool_key(config_path, "allow_shell", enabled)
8401 }
8402 "mcp_config_path" => {
8403 config_persistence::persist_root_string_key(config_path, "mcp_config_path", &value)
8404 }
8405 "subagents_enabled" => {
8406 let enabled = value.parse::<bool>().map_err(|_| {
8407 ApiError::bad_request(format!(
8408 "Invalid value '{value}' for subagents_enabled: expected 'true' or 'false'"
8409 ))
8410 })?;
8411 config_persistence::persist_subagents_bool_key(config_path, "enabled", enabled)
8412 }
8413 "subagents_max_depth" => {
8414 let raw = value.parse::<u64>().map_err(|_| {
8415 ApiError::bad_request(format!(
8416 "Invalid value '{value}' for subagents_max_depth: expected a non-negative integer"
8417 ))
8418 })?;
8419 let clamped = raw.min(u64::from(codewhale_config::MAX_SPAWN_DEPTH_CEILING));
8420 config_persistence::persist_subagents_integer_key(config_path, "max_depth", clamped)
8421 }
8422 "sandbox_mode" => {
8423 let normalized = match value.to_lowercase().as_str() {
8424 "none" | "off" | "disabled" => "none".to_string(),
8425 "opensandbox" | "external-sandbox" | "external" => "opensandbox".to_string(),
8426 "workspace-write" | "workspace_write" => "workspace-write".to_string(),
8427 "read-only" | "read_only" => "read-only".to_string(),
8428 "danger-full-access" | "danger_full_access" | "full" => {
8429 "danger-full-access".to_string()
8430 }
8431 "workspace" | "workspace-read-write" | "workspace_read_write" => {
8432 "workspace-write".to_string()
8433 }
8434 _ => {
8435 return Err(ApiError::bad_request(format!(
8436 "Invalid sandbox_mode '{value}'. Supported: none, read-only, workspace-write, danger-full-access, opensandbox"
8437 )));
8438 }
8439 };
8440 config_persistence::persist_root_string_key(
8441 config_path,
8442 "sandbox_mode",
8443 &normalized,
8444 )
8445 }
8446 "strict_tool_mode" => {
8447 let enabled = value.parse::<bool>().map_err(|_| {
8448 ApiError::bad_request(format!(
8449 "Invalid value '{value}' for strict_tool_mode: expected 'true' or 'false'"
8450 ))
8451 })?;
8452 config_persistence::persist_root_bool_key(config_path, "strict_tool_mode", enabled)
8453 }
8454 "memory_enabled" => {
8455 let enabled = value.parse::<bool>().map_err(|_| {
8456 ApiError::bad_request(format!(
8457 "Invalid value '{value}' for memory_enabled: expected 'true' or 'false'"
8458 ))
8459 })?;
8460 config_persistence::persist_table_bool_key(
8461 config_path,
8462 "memory",
8463 "enabled",
8464 enabled,
8465 )
8466 }
8467 "search_provider" => {
8468 let normalized = value.to_lowercase();
8469 // GET returns the *resolved* provider. A settings save that
8470 // round-trips that value must not turn autodetect (or the
8471 // Firecrawl default) into a disk pin — `provider = "firecrawl"`
8472 // would flip the source to `config` and permanently block a
8473 // later Tavily key. A POST that differs from the resolved
8474 // provider is an explicit change and still persists.
8475 let resolution = state.config.read().search_provider_resolution();
8476 let posted = crate::config::SearchProvider::parse(&normalized);
8477 if posted == Some(resolution.provider)
8478 && matches!(
8479 resolution.source,
8480 crate::config::SearchProviderSource::Default
8481 | crate::config::SearchProviderSource::TavilyKey
8482 )
8483 {
8484 return Ok(Json(SetConfigResponse {
8485 key,
8486 value,
8487 message: format!(
8488 "Config not persisted: '{}' is the resolved {} (source: {}), not a pin. Set a different provider, or pin it in config.toml.",
8489 normalized,
8490 resolution.provider.as_str(),
8491 resolution.source.as_str()
8492 ),
8493 persisted: false,
8494 requires_reload: true,
8495 }));
8496 }
8497 config_persistence::persist_table_string_key(
8498 config_path,
8499 "search",
8500 "provider",
8501 &normalized,
8502 )
8503 }
8504 "prompt_suggestion" => {
8505 let enabled = value.parse::<bool>().map_err(|_| {
8506 ApiError::bad_request(format!(
8507 "Invalid value '{value}' for prompt_suggestion: expected 'true' or 'false'"
8508 ))
8509 })?;
8510 config_persistence::persist_root_bool_key(config_path, "prompt_suggestion", enabled)
8511 }
8512 _ => {
8513 // Every other declared settings.toml key persists through the
8514 // shared validator rather than a curated list — the schema
8515 // route advertises them, so a known setting must not die
8516 // here. Unknown keys still 400 through `Settings::set`.
8517 persist_runtime_tui_setting(&key, &value)?;
8518 return Ok(Json(SetConfigResponse {
8519 key,
8520 value,
8521 message: "Config persisted. Call /v1/config/reload to apply.".to_string(),
8522 persisted: true,
8523 requires_reload,
8524 }));
8525 }
8526 };
8527
8528 if let Err(e) = result {
8529 return Err(ApiError::internal(format!(
8530 "Failed to persist config key '{key}': {e}"
8531 )));
8532 }
8533 }
8534
8535 Ok(Json(SetConfigResponse {
8536 key,
8537 value,
8538 message: if persist {
8539 "Config persisted. Call /v1/config/reload to apply.".to_string()
8540 } else {
8541 "Config not persisted (add persist: true to save)".to_string()
8542 },
8543 persisted: persist,
8544 requires_reload,
8545 }))
8546 }
8547
8548 /// `GET /v1/settings/schema` — the Engine-declared settings surface.
8549 ///
8550 /// `codewhale_config::SETTINGS_SCHEMA` is the single declaration table: one
8551 /// entry per setting with kind, closed value set, default, and placement.
8552 /// This route projects it for HTTP clients — current values resolved from
8553 /// the owning store (settings.toml via [`crate::settings::Settings`],
8554 /// config.toml, or the notifications table), labels and descriptions
8555 /// resolved through the locale pack. Writes stay on `POST /v1/config`;
8556 /// this route never invents a value, an option, or a validator.
8557 #[derive(Debug, Serialize)]
8558 struct SettingsSchemaResponse {
8559 /// Payload version. Additive fields may appear without a bump; clients
8560 /// must ignore fields and `kind`/`row` values they do not know.
8561 version: u32,
8562 tabs: Vec<SettingsSchemaTab>,
8563 settings: Vec<SettingsSchemaRow>,
8564 }
8565
8566 #[derive(Debug, Serialize)]
8567 struct SettingsSchemaTab {
8568 id: String,
8569 /// Humanized tab id — tab labels have no message keys in the schema.
8570 label: String,
8571 }
8572
8573 #[derive(Debug, Serialize)]
8574 struct SettingsSchemaRow {
8575 key: &'static str,
8576 /// `bool` | `int` | `enum` | `string`. Unknown kinds degrade to a text
8577 /// field on the client; writes still validate server-side.
8578 kind: &'static str,
8579 tab: &'static str,
8580 group: &'static str,
8581 label: String,
8582 description: String,
8583 default: &'static str,
8584 /// `setting` | `action` | `diagnostic` | `session` — from
8585 /// [`codewhale_config::SettingRowKind`].
8586 row: &'static str,
8587 /// Current value in written-to-disk string form, when a store resolves
8588 /// it. Absent for actions, unresolvable diagnostics, and session rows
8589 /// the headless runtime cannot read.
8590 #[serde(skip_serializing_if = "Option::is_none")]
8591 value: Option<String>,
8592 /// Whether the value is a persisted user choice rather than an
8593 /// inherited default. Absent where no store can prove either way.
8594 #[serde(skip_serializing_if = "Option::is_none")]
8595 persisted: Option<bool>,
8596 /// Whether a generic client may offer a write control. Action,
8597 /// diagnostic and session rows are never editable through this surface;
8598 /// conditional rows (managed policy wins) report false.
8599 editable: bool,
8600 /// False for the hidden member of a conditional pair — e.g. a
8601 /// `managed_*` row when no managed policy applies, or `base_url` when
8602 /// the active route reads `provider_url`. Clients should not render
8603 /// invisible rows.
8604 visible: bool,
8605 #[serde(skip_serializing_if = "Vec::is_empty")]
8606 options: Vec<SettingsSchemaOption>,
8607 }
8608
8609 #[derive(Debug, Serialize)]
8610 struct SettingsSchemaOption {
8611 value: &'static str,
8612 #[serde(skip_serializing_if = "String::is_empty")]
8613 label: String,
8614 #[serde(skip_serializing_if = "String::is_empty")]
8615 description: String,
8616 }
8617
8618 /// "Turn a schema key or tab id into a title-case label" — the same
8619 /// humanization the TUI applies to rows declared without a label message.
8620 fn humanize_schema_key(key: &str) -> String {
8621 key.split(['.', '_', '-'])
8622 .filter(|part| !part.is_empty())
8623 .map(|part| {
8624 let mut chars = part.chars();
8625 let Some(first) = chars.next() else {
8626 return String::new();
8627 };
8628 let mut word = first.to_uppercase().collect::<String>();
8629 word.push_str(chars.as_str());
8630 word
8631 })
8632 .collect::<Vec<_>>()
8633 .join(" ")
8634 }
8635
8636 /// config.toml-owned keys `POST /v1/config` persists through curated arms.
8637 /// Kept beside `set_config`'s match: a schema Setting row outside this list
8638 /// and outside `Settings` has no write path and reports `editable: false`.
8639 const RUNTIME_CONFIG_KEYS: &[&str] = &[
8640 "model",
8641 "default_model",
8642 "reasoning_effort",
8643 "approval_mode",
8644 "approval_policy",
8645 "base_url",
8646 "provider",
8647 "provider_url",
8648 "provider_base_url",
8649 "cost_currency",
8650 "max_history",
8651 "allow_shell",
8652 "mcp_config_path",
8653 "subagents_enabled",
8654 "subagents_max_depth",
8655 "sandbox_mode",
8656 "strict_tool_mode",
8657 "memory_enabled",
8658 "search_provider",
8659 "prompt_suggestion",
8660 ];
8661
8662 /// A dotted-path lookup over a TOML document — used to decide `persisted`
8663 /// for config.toml-owned rows without trusting a decorated display string.
8664 fn toml_value_at_path<'a>(document: &'a toml::Value, segments: &[&str]) -> Option<&'a toml::Value> {
8665 let mut current = document;
8666 for segment in segments {
8667 current = current.as_table()?.get(*segment)?;
8668 }
8669 Some(current)
8670 }
8671
8672 async fn get_settings_schema(
8673 State(state): State<RuntimeApiState>,
8674 ) -> Result<Json<SettingsSchemaResponse>, ApiError> {
8675 use codewhale_config::notifications::NotificationSetting;
8676 use codewhale_config::settings_schema::{
8677 SettingKind, SettingRowKind, schema_rows, schema_tabs,
8678 };
8679 use codewhale_localization::{MessageId, resolve_locale, tr, tr_key};
8680
8681 let config = state.config.read().clone();
8682 let settings = crate::settings::Settings::load_persisted().unwrap_or_default();
8683 let locale = resolve_locale(&settings.locale);
8684 let notifications = config.notifications_config();
8685
8686 // Conditional pairs share the TUI's rule: exactly one member is shown,
8687 // chosen by which store or policy owns the fact right now.
8688 let permission_control = config.approval_policy_control(
8689 state.config_path.as_deref(),
8690 state.config_profile.as_deref(),
8691 &state.workspace,
8692 );
8693 let shell_control = config.allow_shell_control(
8694 state.config_path.as_deref(),
8695 state.config_profile.as_deref(),
8696 &state.workspace,
8697 );
8698 let base_url_row_key = match config.api_provider() {
8699 ApiProvider::Deepseek | ApiProvider::DeepseekCN => "base_url",
8700 _ => "provider_url",
8701 };
8702 let visible = |key: &str| -> bool {
8703 match key {
8704 "permission_posture" => matches!(
8705 permission_control,
8706 crate::config::ApprovalPolicyControl::Unset
8707 ),
8708 "approval_policy" => matches!(
8709 permission_control,
8710 crate::config::ApprovalPolicyControl::RootConfig
8711 ),
8712 "managed_approval_policy" => !matches!(
8713 permission_control,
8714 crate::config::ApprovalPolicyControl::Unset
8715 | crate::config::ApprovalPolicyControl::RootConfig
8716 ),
8717 "allow_shell" => shell_control.editable_root(),
8718 "managed_allow_shell" => !shell_control.editable_root(),
8719 "base_url" | "provider_url" => key == base_url_row_key,
8720 _ => true,
8721 }
8722 };
8723
8724 // Raw config.toml for `persisted` on config-owned rows. A missing or
8725 // unparsable file means nothing was persisted there — the live config
8726 // still serves defaults through `value`. This is an async axum route, so
8727 // the read rides the blocking pool instead of parking a Tokio worker
8728 // (#6149).
8729 let config_document = match state.config_path.as_deref() {
8730 Some(path) => tokio::fs::read_to_string(path)
8731 .await
8732 .ok()
8733 .and_then(|body| toml::from_str::<toml::Value>(&body).ok()),
8734 None => None,
8735 };
8736 let notifications_persisted = |key: &str| -> Option<bool> {
8737 let setting = NotificationSetting::parse(key)?;
8738 let document = config_document.as_ref()?;
8739 Some(
8740 toml_value_at_path(document, &setting.segments()).is_some()
8741 // Legacy location the loader still honors.
8742 || (matches!(setting, NotificationSetting::Condition)
8743 && toml_value_at_path(document, &["tui", "notification_condition"]).is_some()),
8744 )
8745 };
8746
8747 let tabs = schema_tabs()
8748 .into_iter()
8749 .map(|id| SettingsSchemaTab {
8750 id: id.to_string(),
8751 label: humanize_schema_key(id),
8752 })
8753 .collect();
8754
8755 let settings_rows = schema_rows()
8756 .map(|def| {
8757 let ui = def.ui.as_ref().expect("schema_rows filters on ui");
8758 let kind = match def.kind {
8759 SettingKind::Bool(_) => "bool",
8760 SettingKind::Int => "int",
8761 SettingKind::Float => "float",
8762 SettingKind::Enum(_) => "enum",
8763 SettingKind::String => "string",
8764 };
8765 let row = match ui.row {
8766 SettingRowKind::Setting => "setting",
8767 SettingRowKind::Action => "action",
8768 SettingRowKind::Diagnostic => "diagnostic",
8769 SettingRowKind::Session => "session",
8770 };
8771 let options = match def.kind {
8772 SettingKind::Bool(options) | SettingKind::Enum(options) => options
8773 .iter()
8774 .map(|option| SettingsSchemaOption {
8775 value: option.value,
8776 label: if option.label.is_empty() {
8777 String::new()
8778 } else {
8779 tr_key(locale, option.label).into_owned()
8780 },
8781 description: if option.description.is_empty() {
8782 String::new()
8783 } else {
8784 tr_key(locale, option.description).into_owned()
8785 },
8786 })
8787 .collect(),
8788 SettingKind::Int | SettingKind::String | SettingKind::Float => Vec::new(),
8789 };
8790 // Bool rows with an empty option slice carry the surface's
8791 // default on/off labels — emit the bare values so clients can
8792 // still build a labeled control.
8793 let options = if options.is_empty() && matches!(def.kind, SettingKind::Bool(_)) {
8794 vec![
8795 SettingsSchemaOption {
8796 value: "false",
8797 label: tr_key(locale, "ConfigValueOff").into_owned(),
8798 description: String::new(),
8799 },
8800 SettingsSchemaOption {
8801 value: "true",
8802 label: tr_key(locale, "ConfigValueOn").into_owned(),
8803 description: String::new(),
8804 },
8805 ]
8806 } else {
8807 options
8808 };
8809
8810 let notification_owned = NotificationSetting::parse(def.key).is_some();
8811 // `Settings::set` is the authority on which keys settings.toml
8812 // owns — including `Option` fields whose unset value serializes
8813 // to nothing (e.g. permission_posture). The probe reuses the
8814 // write validator on the declared default, so `editable` cannot
8815 // claim a key the real write path would reject.
8816 let settings_writable = crate::settings::Settings::default()
8817 .set(def.key, def.default)
8818 .is_ok();
8819 let (value, persisted) = if notification_owned {
8820 let setting = NotificationSetting::parse(def.key).expect("checked above");
8821 (
8822 Some(notifications.display(setting)),
8823 notifications_persisted(def.key),
8824 )
8825 } else if settings_writable {
8826 // Effective = the persisted value or the declared default;
8827 // `is_set` says which.
8828 (
8829 Some(
8830 settings
8831 .value(def.key)
8832 .unwrap_or_else(|| def.default.to_string()),
8833 ),
8834 Some(settings.is_set(def.key)),
8835 )
8836 } else if let Some(feature_key) = def.key.strip_prefix("features.") {
8837 // Feature rows are diagnostics: the effective flag state plus
8838 // whether config.toml names the leaf — no decorated phrasing,
8839 // the client owns presentation of default-vs-configured.
8840 let value = crate::features::FEATURES
8841 .iter()
8842 .find(|spec| spec.key == feature_key)
8843 .map(|spec| config.features().enabled(spec.id).to_string());
8844 let persisted = config_document.as_ref().map(|document| {
8845 toml_value_at_path(document, &["features", feature_key]).is_some()
8846 });
8847 (value, persisted)
8848 } else {
8849 // Managed-policy receipts name the winning source rather than
8850 // a writable value; everything else resolves from config.toml
8851 // or stays absent for a diagnostic the runtime cannot read.
8852 let managed_value = match def.key {
8853 "managed_approval_policy" => match permission_control {
8854 crate::config::ApprovalPolicyControl::Unset
8855 | crate::config::ApprovalPolicyControl::RootConfig => None,
8856 source => Some(source.label().to_string()),
8857 },
8858 "managed_allow_shell" if !shell_control.editable_root() => Some(format!(
8859 "{} · {}",
8860 config.allow_shell(),
8861 shell_control.label()
8862 )),
8863 _ => None,
8864 };
8865 (
8866 managed_value.or_else(|| config_schema_value(def.key, &config)),
8867 None,
8868 )
8869 };
8870
8871 // `editable` means POST /v1/config accepts the key today:
8872 // notifications.* through the namespace branch, settings.toml
8873 // keys through the Settings::set fallthrough, and the curated
8874 // config.toml arm list. A Setting row without a write path
8875 // (e.g. telemetry, which persists through its own notice
8876 // module) renders read-only rather than promising a 400. The
8877 // endpoint rows stay receipts: writing a live route's base URL
8878 // cannot mutate an already-running client, so the TUI marks
8879 // them read-only and the schema agrees.
8880 let endpoint_receipt = matches!(def.key, "base_url" | "provider_url");
8881 // A managed or profile-owned approval policy freezes the
8882 // session-level mode switch too, not just the saved row.
8883 let session_locked = def.key == "approval_mode"
8884 && !matches!(
8885 permission_control,
8886 crate::config::ApprovalPolicyControl::Unset
8887 );
8888 let editable = !endpoint_receipt
8889 && !session_locked
8890 && matches!(ui.row, SettingRowKind::Setting | SettingRowKind::Session)
8891 && visible(def.key)
8892 && (notification_owned
8893 || settings_writable
8894 || RUNTIME_CONFIG_KEYS.contains(&def.key));
8895
8896 SettingsSchemaRow {
8897 key: def.key,
8898 kind,
8899 tab: ui.tab,
8900 group: ui.group,
8901 label: if !ui.label.is_empty() {
8902 tr_key(locale, ui.label).into_owned()
8903 } else if def.key.starts_with("features.") {
8904 tr(locale, MessageId::ConfigLabelFeaturePrefix).replace(
8905 "{name}",
8906 &humanize_schema_key(def.key.rsplit('.').next().unwrap_or(def.key)),
8907 )
8908 } else {
8909 humanize_schema_key(def.key.rsplit('.').next().unwrap_or(def.key))
8910 },
8911 description: if ui.description.is_empty() {
8912 String::new()
8913 } else {
8914 tr_key(locale, ui.description).into_owned()
8915 },
8916 default: def.default,
8917 row,
8918 value,
8919 persisted,
8920 editable,
8921 visible: visible(def.key),
8922 options,
8923 }
8924 })
8925 .collect();
8926
8927 Ok(Json(SettingsSchemaResponse {
8928 version: 1,
8929 tabs,
8930 settings: settings_rows,
8931 }))
8932 }
8933
8934 /// Current value of a config.toml-owned schema row, when one resolves
8935 /// cheaply. Diagnostics that need per-route or credential computation are
8936 /// omitted rather than approximated.
8937 fn config_schema_value(key: &str, config: &Config) -> Option<String> {
8938 match key {
8939 "provider" => Some(config.provider_identity_for(config.api_provider())),
8940 "model" => runtime_request_model(config, None).ok(),
8941 "approval_policy" => config
8942 .approval_policy
8943 .clone()
8944 .or_else(|| Some("suggest".to_string())),
8945 "telemetry" => Some(crate::telemetry_notice::saved_preference_enabled(config).to_string()),
8946 "allow_shell" => Some(config.allow_shell().to_string()),
8947 "base_url" => Some(config.base_url_for_route(config.api_provider())),
8948 "provider_url" => Some(config.base_url_for_route(config.api_provider())),
8949 "mcp_config_path" => Some(config.mcp_config_path().display().to_string()),
8950 "sandbox_mode" => config.sandbox_mode.clone(),
8951 "fleet.exec.max_spawn_depth" => Some(config.subagent_max_spawn_depth().to_string()),
8952 "reasoning_effort" => Some(config.reasoning_effort().unwrap_or("auto").to_string()),
8953 _ => None,
8954 }
8955 }
8956
8957 fn normalize_runtime_config_model(
8958 config: &Config,
8959 provider: ApiProvider,
8960 value: &str,
8961 ) -> Result<String, ApiError> {
8962 let value = value.trim();
8963 if crate::provider_lake::configured_model_for_route(
8964 config,
8965 provider,
8966 &config.provider_identity_for(provider),
8967 &config.base_url_for_route(provider),
8968 value,
8969 )
8970 .is_some()
8971 {
8972 // The shared resolver preserves exact declarations only after its
8973 // protocol and provider allowlist guards. Metadata cannot bypass them.
8974 return crate::route_runtime::resolve_runtime_route(config, provider, Some(value))
8975 .map(|route| route.model)
8976 .map_err(ApiError::bad_request);
8977 }
8978 validate_route(provider, value).map_err(ApiError::bad_request)?;
8979 if value.eq_ignore_ascii_case("auto") {
8980 return Ok("auto".to_string());
8981 }
8982 normalize_model_name_for_provider(provider, value).ok_or_else(|| {
8983 ApiError::bad_request(format!(
8984 "Invalid model '{value}' for provider '{}'.",
8985 provider.as_str()
8986 ))
8987 })
8988 }
8989
8990 async fn reload_config(
8991 State(state): State<RuntimeApiState>,
8992 ) -> Result<Json<ReloadConfigResponse>, ApiError> {
8993 let mut reloaded = Config::load(state.config_path.clone(), state.config_profile.as_deref())
8994 .map_err(|e| ApiError::internal(format!("Failed to reload config: {e}")))?;
8995 reloaded.account_model_access = state.config.read().account_model_access.clone();
8996 state
8997 .runtime_threads
8998 .reload_config(reloaded.clone())
8999 .await
9000 .map_err(|err| ApiError::bad_request(format!("Config reload rejected: {err}")))?;
9001 {
9002 let mut config = state.config.write();
9003 *config = reloaded;
9004 }
9005 Ok(Json(ReloadConfigResponse {
9006 message: "Config reloaded from disk; new turns will resolve the updated provider routes"
9007 .to_string(),
9008 }))
9009 }
9010
9011 // ── Memory inspection and lifecycle endpoints ──
9012
9013 /// Maximum summary length returned per entry. Bounds the API surface so raw
9014 /// private text cannot exfiltrate through JSON responses.
9015 const MEMORY_SUMMARY_MAX_CHARS: usize = 300;
9016 /// Default result cap for `GET /v1/memory`.
9017 const MEMORY_LIST_DEFAULT_LIMIT: usize = 50;
9018 /// Hard ceiling — protects against oversized responses.
9019 const MEMORY_LIST_MAX_LIMIT: usize = 200;
9020
9021 /// Typed, redacted projection of a single native memory entry.
9022 ///
9023 /// Raw file-system paths are never exposed; `scope` and `workspace_id` (a
9024 /// SHA-256 digest of the repository origin URL, not a local path) give
9025 /// managed clients enough provenance to reason about each entry.
9026 #[derive(Debug, Serialize)]
9027 struct MemoryEntryRecord {
9028 /// SQLite row id. Stable across reindexes unless the source Markdown
9029 /// file is cleared and rewritten.
9030 id: i64,
9031 /// `"global"` or `"workspace"`.
9032 scope: &'static str,
9033 /// SHA-256 digest of the repository origin URL for workspace-scoped
9034 /// entries; `null` for global entries.
9035 workspace_id: Option<String>,
9036 /// Bounded plain-text summary (max `MEMORY_SUMMARY_MAX_CHARS` chars).
9037 /// Truncated with `…` when the source text is longer. Never contains
9038 /// raw prompt or turn content.
9039 summary: String,
9040 /// `true` when the source Markdown file has been modified since the
9041 /// entry was last indexed.
9042 stale: bool,
9043 /// 1-based start line in the source Markdown file.
9044 line_start: usize,
9045 /// 1-based end line in the source Markdown file.
9046 line_end: usize,
9047 /// `"active"` or `"stale"` (human-readable alias for `stale`).
9048 status: &'static str,
9049 }
9050
9051 #[derive(Debug, Deserialize)]
9052 struct ListMemoryQuery {
9053 /// Filter by scope: `"global"`, `"workspace"`, or `"all"` (default).
9054 scope: Option<String>,
9055 /// FTS search query (max 256 chars). When absent all entries for the
9056 /// requested scope are returned in insertion order.
9057 q: Option<String>,
9058 /// Maximum entries to return (default 50, max 200).
9059 limit: Option<usize>,
9060 }
9061
9062 /// Request body for `POST /v1/memory`.
9063 #[derive(Debug, Deserialize)]
9064 struct CreateMemoryRequest {
9065 /// The memory note text (max 64 KiB after normalisation).
9066 text: String,
9067 /// `"global"` (default) or `"workspace"`.
9068 #[serde(default)]
9069 scope: String,
9070 }
9071
9072 /// Query params for `DELETE /v1/memory`.
9073 #[derive(Debug, Deserialize)]
9074 struct ClearMemoryQuery {
9075 /// One of `"global"`, `"workspace"`, or `"all"`. Required.
9076 scope: String,
9077 }
9078
9079 /// Build a `NativeMemoryStore` rooted at the same location the TUI uses.
9080 /// Mirrors `native_store()` in `commands/groups/memory/memory.rs`.
9081 fn native_store_for_state(state: &RuntimeApiState) -> crate::native_memory::NativeMemoryStore {
9082 let memory_path = state.config.read().memory_path();
9083 crate::native_memory::NativeMemoryStore::from_memory_anchor(&memory_path)
9084 }
9085
9086 /// Derive a scope label from a source path relative to the store root.
9087 /// Returns `"global"`, `"workspace"`, or `"unknown"`.
9088 fn scope_label_for_source(source: &FsPath, store_root: &FsPath) -> &'static str {
9089 let Ok(rel) = source.strip_prefix(store_root) else {
9090 return "unknown";
9091 };
9092 match rel.components().next().and_then(|c| c.as_os_str().to_str()) {
9093 Some("global") => "global",
9094 Some("workspace") => "workspace",
9095 _ => "unknown",
9096 }
9097 }
9098
9099 /// Extract the workspace_id component from a workspace-scoped source path.
9100 fn workspace_id_for_source(source: &FsPath, store_root: &FsPath) -> Option<String> {
9101 let rel = source.strip_prefix(store_root).ok()?;
9102 let mut comps = rel.components();
9103 if comps.next()?.as_os_str().to_str()? != "workspace" {
9104 return None;
9105 }
9106 Some(comps.next()?.as_os_str().to_str()?.to_string())
9107 }
9108
9109 /// Convert a `MemoryHit` into a redacted, bounded `MemoryEntryRecord`.
9110 fn memory_hit_to_record(
9111 hit: crate::native_memory::MemoryHit,
9112 store_root: &FsPath,
9113 ) -> MemoryEntryRecord {
9114 let scope = scope_label_for_source(&hit.source, store_root);
9115 let workspace_id = workspace_id_for_source(&hit.source, store_root);
9116 let summary = truncate_text(&hit.text, MEMORY_SUMMARY_MAX_CHARS);
9117 let status = if hit.stale { "stale" } else { "active" };
9118 MemoryEntryRecord {
9119 id: hit.id,
9120 scope,
9121 workspace_id,
9122 summary,
9123 stale: hit.stale,
9124 line_start: hit.line_start,
9125 line_end: hit.line_end,
9126 status,
9127 }
9128 }
9129
9130 /// Resolve a scope query parameter into a `MemoryScope` filter and an
9131 /// optional workspace_id. `"all"` / absent → `(None, None)`.
9132 fn resolve_memory_scope(
9133 scope_param: &Option<String>,
9134 workspace: &FsPath,
9135 ) -> Result<(Option<crate::native_memory::MemoryScope>, Option<String>), ApiError> {
9136 match scope_param.as_deref().unwrap_or("all").trim() {
9137 "all" | "" => Ok((None, None)),
9138 "global" => Ok((Some(crate::native_memory::MemoryScope::Global), None)),
9139 "workspace" => {
9140 let workspace_id = crate::native_memory::NativeMemoryStore::workspace_id(workspace)
9141 .map_err(|e| ApiError::internal(format!("resolve workspace id: {e}")))?;
9142 Ok((
9143 Some(crate::native_memory::MemoryScope::Workspace),
9144 workspace_id,
9145 ))
9146 }
9147 other => Err(ApiError::bad_request(format!(
9148 "Invalid scope '{other}': expected one of all, global, workspace"
9149 ))),
9150 }
9151 }
9152
9153 /// `GET /v1/memory` — list memory entries with optional scope and FTS
9154 /// filtering.
9155 ///
9156 /// Query params:
9157 /// - `scope` — `"global"`, `"workspace"`, or `"all"` (default)
9158 /// - `q` — FTS search query (max 256 chars; omit to list all)
9159 /// - `limit` — max results (default 50, max 200)
9160 async fn list_memory(
9161 State(state): State<RuntimeApiState>,
9162 Query(query): Query<ListMemoryQuery>,
9163 ) -> Result<Json<Value>, ApiError> {
9164 let limit = match query.limit.unwrap_or(MEMORY_LIST_DEFAULT_LIMIT) {
9165 0 => {
9166 return Err(ApiError::bad_request("limit must be at least 1"));
9167 }
9168 n if n > MEMORY_LIST_MAX_LIMIT => {
9169 return Err(ApiError::bad_request(format!(
9170 "limit must be at most {MEMORY_LIST_MAX_LIMIT}; got {n}"
9171 )));
9172 }
9173 n => n,
9174 };
9175
9176 let store = native_store_for_state(&state);
9177 let root = store.root().to_path_buf();
9178 let (scope_filter, workspace_id) = resolve_memory_scope(&query.scope, &state.workspace)?;
9179
9180 let hits = if let Some(ref q) = query.q {
9181 let q = q.trim();
9182 if q.is_empty() || q.chars().count() > 256 {
9183 return Err(ApiError::bad_request("q must be 1–256 characters"));
9184 }
9185 match scope_filter {
9186 None => store.search(q, limit),
9187 Some(crate::native_memory::MemoryScope::Global) => store.search(q, limit).map(|h| {
9188 h.into_iter()
9189 .filter(|h| scope_label_for_source(&h.source, &root) == "global")
9190 .collect()
9191 }),
9192 Some(crate::native_memory::MemoryScope::Workspace) => store
9193 .search_for_workspace(&state.workspace, q, limit)
9194 .map(|h| {
9195 h.into_iter()
9196 .filter(|h| scope_label_for_source(&h.source, &root) == "workspace")
9197 .collect()
9198 }),
9199 }
9200 } else {
9201 store.list_all(scope_filter, workspace_id.as_deref(), limit)
9202 }
9203 .map_err(|e| ApiError::internal(format!("memory list error: {e}")))?;
9204
9205 let entries: Vec<MemoryEntryRecord> = hits
9206 .into_iter()
9207 .map(|h| memory_hit_to_record(h, &root))
9208 .collect();
9209 let total = entries.len();
9210 Ok(Json(json!({ "entries": entries, "total": total })))
9211 }
9212
9213 /// `GET /v1/memory/{id}` — inspect a single memory entry.
9214 ///
9215 /// The lookup is scoped to global memory plus the current repository's
9216 /// workspace memory; numeric IDs from a different machine or repository
9217 /// will not resolve.
9218 async fn get_memory_entry(
9219 State(state): State<RuntimeApiState>,
9220 Path(id): Path<i64>,
9221 ) -> Result<Json<Value>, ApiError> {
9222 let store = native_store_for_state(&state);
9223 let root = store.root().to_path_buf();
9224 let hit = store
9225 .get_for_workspace(&state.workspace, id)
9226 .map_err(|e| ApiError::internal(format!("memory lookup error: {e}")))?
9227 .ok_or_else(|| ApiError::not_found(format!("memory entry '{id}' not found")))?;
9228 let entry = memory_hit_to_record(hit, &root);
9229 Ok(Json(json!({ "entry": entry })))
9230 }
9231
9232 /// `POST /v1/memory` — append a new memory entry.
9233 ///
9234 /// The note is treated as user data (lower authority than instructions).
9235 /// Requires the standard Runtime auth token when auth is configured.
9236 async fn create_memory_entry(
9237 State(state): State<RuntimeApiState>,
9238 Json(req): Json<CreateMemoryRequest>,
9239 ) -> Result<(StatusCode, Json<Value>), ApiError> {
9240 let scope_str = if req.scope.is_empty() {
9241 "global"
9242 } else {
9243 req.scope.as_str()
9244 };
9245 let scope = match scope_str.trim() {
9246 "global" => crate::native_memory::MemoryScope::Global,
9247 "workspace" => crate::native_memory::MemoryScope::Workspace,
9248 other => {
9249 return Err(ApiError::bad_request(format!(
9250 "Invalid scope '{other}': expected 'global' or 'workspace'"
9251 )));
9252 }
9253 };
9254 let workspace_id = if scope == crate::native_memory::MemoryScope::Workspace {
9255 let id = crate::native_memory::NativeMemoryStore::workspace_id(&state.workspace)
9256 .map_err(|e| ApiError::internal(format!("resolve workspace id: {e}")))?
9257 .ok_or_else(|| {
9258 ApiError::bad_request(
9259 "workspace scope requires a git repository with a remote origin",
9260 )
9261 })?;
9262 Some(id)
9263 } else {
9264 None
9265 };
9266 let store = native_store_for_state(&state);
9267 let root = store.root().to_path_buf();
9268 // This endpoint is an authenticated operator surface: the explicit request
9269 // is the review, so the entry lands active — matching the Lens remember
9270 // action. Model-reachable capture stays candidate-only.
9271 let hit = store
9272 .remember_reviewed(scope, workspace_id.as_deref(), &req.text)
9273 .map_err(|e| ApiError::bad_request(format!("memory create error: {e}")))?;
9274 let entry = memory_hit_to_record(hit, &root);
9275 Ok((StatusCode::CREATED, Json(json!({ "entry": entry }))))
9276 }
9277
9278 /// `DELETE /v1/memory` — clear all memory entries for the given scope.
9279 ///
9280 /// The `scope` query parameter is required: `"global"`, `"workspace"`, or
9281 /// `"all"`. This is a destructive, non-reversible operation.
9282 async fn clear_memory(
9283 State(state): State<RuntimeApiState>,
9284 Query(query): Query<ClearMemoryQuery>,
9285 ) -> Result<Json<Value>, ApiError> {
9286 let (scope_filter, workspace_id) = resolve_memory_scope(&Some(query.scope), &state.workspace)?;
9287 let store = native_store_for_state(&state);
9288 store
9289 .delete_all(scope_filter, workspace_id.as_deref())
9290 .map_err(|e| ApiError::internal(format!("memory clear error: {e}")))?;
9291 Ok(Json(json!({ "cleared": true })))
9292 }
9293
9294 const MOBILE_HTML: &str = include_str!("runtime_mobile.html");
9295
9296 /// Built-in dev origins always allowed by the runtime API (whalescale#255).
9297 const DEFAULT_CORS_ORIGINS: &[&str] = &[
9298 "http://localhost:3000",
9299 "http://127.0.0.1:3000",
9300 "http://localhost:1420",
9301 "http://127.0.0.1:1420",
9302 "tauri://localhost",
9303 ];
9304
9305 fn cors_layer(extra_origins: &[String]) -> CorsLayer {
9306 let mut origins: Vec<HeaderValue> = DEFAULT_CORS_ORIGINS
9307 .iter()
9308 .filter_map(|o| HeaderValue::from_str(o).ok())
9309 .collect();
9310 for raw in extra_origins {
9311 let trimmed = raw.trim();
9312 if trimmed.is_empty() {
9313 continue;
9314 }
9315 match HeaderValue::from_str(trimmed) {
9316 Ok(value) if !origins.contains(&value) => origins.push(value),
9317 Ok(_) => {}
9318 Err(err) => tracing::warn!(
9319 "Ignoring invalid CORS origin '{trimmed}': {err}; expected scheme://host[:port]"
9320 ),
9321 }
9322 }
9323 CorsLayer::new()
9324 .allow_origin(origins)
9325 .allow_methods([
9326 Method::GET,
9327 Method::POST,
9328 Method::PATCH,
9329 Method::DELETE,
9330 Method::OPTIONS,
9331 ])
9332 .allow_headers([
9333 header::AUTHORIZATION,
9334 header::CONTENT_TYPE,
9335 header::ACCEPT,
9336 header::IF_MATCH,
9337 HeaderName::from_static("x-codewhale-runtime-token"),
9338 HeaderName::from_static("x-deepseek-runtime-token"),
9339 ])
9340 }
9341
9342 fn map_task_err(err: anyhow::Error) -> ApiError {
9343 let message = err.to_string();
9344 if message.contains("not found") {
9345 ApiError::not_found(message)
9346 } else {
9347 ApiError::bad_request(message)
9348 }
9349 }
9350
9351 fn map_automation_err(err: anyhow::Error) -> ApiError {
9352 let message = err.to_string();
9353 if message.contains("Failed to read automation")
9354 || message.contains("No such file or directory")
9355 {
9356 ApiError::not_found(message)
9357 } else {
9358 ApiError::bad_request(message)
9359 }
9360 }
9361
9362 fn map_thread_err(err: anyhow::Error) -> ApiError {
9363 let message = err.to_string();
9364 let lower = message.to_ascii_lowercase();
9365 if (lower.starts_with("thread '") && lower.ends_with("' not found"))
9366 || lower.starts_with("thread not found:")
9367 {
9368 ApiError::not_found(message)
9369 } else if message.starts_with("shell commands are restricted by ") {
9370 ApiError::forbidden(message)
9371 } else if message.contains("already has an active turn")
9372 || message.contains("thread permissions changed during update")
9373 || message.contains("No active turn")
9374 || message.contains("is not active")
9375 // A steer the engine dropped: the turn moved on before the model saw
9376 // it. 409 lets a client keep the text and resend rather than trust a
9377 // delivery that never happened (#6276).
9378 || message.contains("moved on before the steer")
9379 || lower.contains("operation_key is already bound")
9380 || lower.contains("operation_key binding is incomplete")
9381 || lower.contains("operation_key binding does not match")
9382 {
9383 ApiError::conflict(message)
9384 } else {
9385 ApiError::bad_request(message)
9386 }
9387 }
9388
9389 fn map_agent_mail_err(err: anyhow::Error) -> ApiError {
9390 let message = err.to_string();
9391 let lower = message.to_ascii_lowercase();
9392 if lower.contains("ownership denied") {
9393 ApiError::forbidden(message)
9394 } else if lower.contains("already exists with different delivery intent")
9395 || lower.contains("can be canceled only while queued")
9396 {
9397 ApiError::conflict(message)
9398 } else if (lower.contains("failed to read agent mail envelope")
9399 && (lower.contains("no such file")
9400 || err.chain().skip(1).any(|cause| {
9401 cause
9402 .downcast_ref::<std::io::Error>()
9403 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
9404 })))
9405 || (lower.starts_with("thread '") && lower.ends_with("' not found"))
9406 {
9407 ApiError::not_found(message)
9408 } else {
9409 ApiError::bad_request(message)
9410 }
9411 }
9412
9413 #[derive(Debug, Clone)]
9414 struct ApiError {
9415 status: StatusCode,
9416 message: String,
9417 }
9418
9419 impl ApiError {
9420 fn bad_request(message: impl Into<String>) -> Self {
9421 Self {
9422 status: StatusCode::BAD_REQUEST,
9423 message: message.into(),
9424 }
9425 }
9426
9427 fn not_found(message: impl Into<String>) -> Self {
9428 Self {
9429 status: StatusCode::NOT_FOUND,
9430 message: message.into(),
9431 }
9432 }
9433
9434 fn conflict(message: impl Into<String>) -> Self {
9435 Self {
9436 status: StatusCode::CONFLICT,
9437 message: message.into(),
9438 }
9439 }
9440
9441 fn not_implemented(message: impl Into<String>) -> Self {
9442 Self {
9443 status: StatusCode::NOT_IMPLEMENTED,
9444 message: message.into(),
9445 }
9446 }
9447
9448 fn internal(message: impl Into<String>) -> Self {
9449 Self {
9450 status: StatusCode::INTERNAL_SERVER_ERROR,
9451 message: message.into(),
9452 }
9453 }
9454
9455 fn forbidden(message: impl Into<String>) -> Self {
9456 Self {
9457 status: StatusCode::FORBIDDEN,
9458 message: message.into(),
9459 }
9460 }
9461
9462 fn payload_too_large(message: impl Into<String>) -> Self {
9463 Self {
9464 status: StatusCode::PAYLOAD_TOO_LARGE,
9465 message: message.into(),
9466 }
9467 }
9468 }
9469
9470 impl IntoResponse for ApiError {
9471 fn into_response(self) -> Response {
9472 (
9473 self.status,
9474 Json(json!({
9475 "error": {
9476 "message": self.message,
9477 "status": self.status.as_u16(),
9478 }
9479 })),
9480 )
9481 .into_response()
9482 }
9483 }
9484
9485 #[cfg(test)]
9486 mod tests;
9487
9488 #[cfg(test)]
9489 mod configured_model_api_tests {
9490 use super::*;
9491 use crate::test_support::{EnvVarGuard, lock_test_env};
9492
9493 fn fixture(provider: &str, model: &str) -> String {
9494 format!(
9495 r#"provider = "{provider}"
9496 default_text_model = "deepseek-v4-pro"
9497 telemetry = false
9498
9499 [[custom_models]]
9500 provider = "{provider}"
9501 base_url = "http://127.0.0.1:9/v1"
9502 id = "{model}"
9503 limit = {{ context = 96000, input = 88000, output = 8000 }}
9504 cost = {{ input = 0.4, output = 1.6 }}
9505 reasoning = false
9506 tool_call = false
9507
9508 [providers.{provider}]
9509 base_url = "http://127.0.0.1:9/v1"
9510 "#
9511 )
9512 }
9513
9514 fn isolate_model_environment() -> Vec<EnvVarGuard> {
9515 let mut guards: Vec<_> = [
9516 "CODEWHALE_CONFIG_PATH",
9517 "DEEPSEEK_CONFIG_PATH",
9518 "CODEWHALE_BASE_URL",
9519 "DEEPSEEK_BASE_URL",
9520 "CODEWHALE_PROVIDER",
9521 "DEEPSEEK_PROVIDER",
9522 "CODEWHALE_MODEL",
9523 "DEEPSEEK_MODEL",
9524 "DEEPSEEK_DEFAULT_TEXT_MODEL",
9525 "OPENROUTER_BASE_URL",
9526 "OPENROUTER_MODEL",
9527 "TOGETHER_BASE_URL",
9528 "TOGETHER_MODEL",
9529 "CODEWHALE_PROFILE",
9530 "DEEPSEEK_PROFILE",
9531 "OLLAMA_MODEL",
9532 "OLLAMA_CLOUD_MODEL",
9533 "OLLAMA_BASE_URL",
9534 "OLLAMA_CLOUD_BASE_URL",
9535 ]
9536 .into_iter()
9537 .map(EnvVarGuard::remove)
9538 .collect();
9539 guards.push(EnvVarGuard::set("CODEWHALE_DISABLE_CLOUD_FACTS", "1"));
9540 guards
9541 }
9542
9543 async fn serve_fixture(
9544 config_path: PathBuf,
9545 ) -> Result<(SocketAddr, RuntimeApiState, tokio::task::JoinHandle<()>)> {
9546 let root = config_path.parent().expect("fixture root");
9547 let workspace = root.join("workspace");
9548 fs::create_dir_all(&workspace)?;
9549 let config = Config::load(Some(config_path.clone()), None)?;
9550 let runtime_threads = Arc::new(RuntimeThreadManager::open_with_plugin_registry(
9551 config.clone(),
9552 workspace.clone(),
9553 RuntimeThreadManagerConfig::from_task_data_dir(root.join("runtime")),
9554 Arc::new(crate::plugins::PluginRegistry::empty(&workspace)),
9555 )?);
9556 let task_manager = TaskManager::start_with_runtime_manager(
9557 TaskManagerConfig {
9558 data_dir: root.join("tasks"),
9559 worker_count: 1,
9560 default_workspace: workspace.clone(),
9561 default_model: "auto".to_string(),
9562 default_mode: "agent".to_string(),
9563 allow_shell: false,
9564 trust_mode: false,
9565 execution_limits: Default::default(),
9566 },
9567 config.clone(),
9568 runtime_threads.clone(),
9569 )
9570 .await?;
9571 let listener = TcpListener::bind("127.0.0.1:0").await?;
9572 let addr = listener.local_addr()?;
9573 let state = RuntimeApiState {
9574 config: Arc::new(parking_lot::RwLock::new(config)),
9575 workspace: workspace.clone(),
9576 plugin_discovery: crate::plugins::PluginDiscoveryContext::capture_pre_dotenv(),
9577 task_manager,
9578 runtime_threads,
9579 cors_origins: Vec::new(),
9580 sessions_dir: root.join("sessions"),
9581 config_path: Some(config_path.clone()),
9582 config_profile: None,
9583 automations: Arc::new(Mutex::new(AutomationManager::open_for_test(
9584 root.join("automations"),
9585 )?)),
9586 sub_agent_manager: runtime_api_sub_agent_manager(&workspace, 2),
9587 runtime_token: None,
9588 skill_state: Arc::new(Mutex::new(SkillStateStore::load_from(
9589 root.join("skills_state.toml"),
9590 )?)),
9591 auth_required: false,
9592 bind_host: "127.0.0.1".to_string(),
9593 bind_port: addr.port(),
9594 mobile_enabled: false,
9595 mobile: None,
9596 web: None,
9597 fleet_codewhale_binary: "unused-test-binary".to_string(),
9598 mcp_pool: Arc::new(Mutex::new(None)),
9599 lsp_manager: Arc::new(std::sync::OnceLock::new()),
9600 compat_stream_test_hook: None,
9601 };
9602 let router = build_router(state.clone());
9603 let server = tokio::spawn(async move {
9604 axum::serve(
9605 listener,
9606 router.into_make_service_with_connect_info::<SocketAddr>(),
9607 )
9608 .await
9609 .expect("local fixture server");
9610 });
9611 Ok((addr, state, server))
9612 }
9613
9614 async fn post_json(addr: SocketAddr, path: &str, body: Value) -> Result<Value> {
9615 let response = crate::tls::reqwest_client()
9616 .post(format!("http://{addr}{path}"))
9617 .json(&body)
9618 .send()
9619 .await?;
9620 let status = response.status();
9621 let body = response.json::<Value>().await?;
9622 assert_eq!(status, StatusCode::OK, "{path}: {body}");
9623 Ok(body)
9624 }
9625
9626 fn assert_declared_route(config_path: &FsPath, provider: ApiProvider, model: &str) {
9627 let config = Config::load(Some(config_path.to_path_buf()), None).expect("reloaded config");
9628 let persisted = config
9629 .provider_config_for(provider)
9630 .and_then(|entry| entry.model.as_deref());
9631 assert_eq!(persisted, Some(model));
9632 let selected = provider_default_model_for_api(&config, provider, provider);
9633 assert_eq!(selected, model);
9634 let route = crate::route_runtime::resolve_runtime_route(&config, provider, Some(&selected))
9635 .expect("saved declared route");
9636 assert_eq!(route.model, model);
9637 assert!(route.candidate.canonical_model().is_none());
9638 assert_eq!(route.candidate.limits().context_tokens, Some(96_000));
9639 assert_eq!(
9640 route.context_window.source,
9641 crate::route_runtime::ContextWindowSource::UserDeclared
9642 );
9643 }
9644
9645 fn write_remembered_selection_fixture(home: &FsPath, config_path: &FsPath) -> Result<()> {
9646 fs::create_dir_all(home)?;
9647 fs::create_dir_all(config_path.parent().expect("config parent"))?;
9648 fs::write(
9649 home.join("settings.toml"),
9650 "default_provider = \"zai\"\n[provider_models]\nzai = \"GLM-5.3\"\n",
9651 )?;
9652 fs::write(
9653 config_path,
9654 r#"provider = "deepseek"
9655 default_text_model = "deepseek-v4-pro"
9656 telemetry = false
9657
9658 [cloud_facts]
9659 enabled = false
9660
9661 [providers.zai]
9662 base_url = "https://api.z.ai/api/coding/paas/v4"
9663 model = "GLM-5.2"
9664 "#,
9665 )?;
9666 Ok(())
9667 }
9668
9669 async fn assert_catalog_and_new_thread_selection(
9670 addr: SocketAddr,
9671 provider: &str,
9672 model: &str,
9673 ) -> Result<Value> {
9674 let client = crate::tls::reqwest_client();
9675 let catalog = client
9676 .get(format!("http://{addr}/v1/providers"))
9677 .send()
9678 .await?
9679 .error_for_status()?
9680 .json::<Value>()
9681 .await?;
9682 assert_eq!(catalog["current"], provider);
9683 let entry = catalog["providers"]
9684 .as_array()
9685 .expect("provider catalog")
9686 .iter()
9687 .find(|entry| entry["id"] == provider)
9688 .expect("selected provider");
9689 assert_eq!(entry["default_model"], model);
9690 let config = client
9691 .get(format!("http://{addr}/v1/config"))
9692 .send()
9693 .await?
9694 .error_for_status()?
9695 .json::<Value>()
9696 .await?;
9697 assert_eq!(config["model"], model);
9698 if provider == "deepseek" {
9699 assert_eq!(config["default_model"], model);
9700 }
9701 // Creation only saves the route; this fixture never starts a turn or
9702 // contacts any provider, including the official catalog URLs above.
9703 let response = client
9704 .post(format!("http://{addr}/v1/threads"))
9705 .json(&json!({}))
9706 .send()
9707 .await?;
9708 let status = response.status();
9709 let thread = response.json::<Value>().await?;
9710 assert_eq!(status, StatusCode::CREATED, "{thread}");
9711 assert_eq!(thread["model_provider"], provider);
9712 assert_eq!(thread["model"], model);
9713 assert_eq!(thread["model_provider_id"], entry["model_provider_id"]);
9714 Ok(thread)
9715 }
9716
9717 #[tokio::test(flavor = "current_thread")]
9718 async fn remembered_selection_aligns_catalog_and_new_thread_after_load() -> Result<()> {
9719 let _env = lock_test_env();
9720 let _live = crate::provider_lake::lock_live_snapshot();
9721 let root = tempfile::tempdir()?;
9722 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
9723 let _model_environment = isolate_model_environment();
9724 crate::provider_catalog_live::reset_cache_for_test();
9725 crate::provider_lake::clear_live_snapshot();
9726 let config_path = root.path().join("config.toml");
9727 write_remembered_selection_fixture(root.path(), &config_path)?;
9728 let original_config = fs::read(&config_path)?;
9729 let original_settings = fs::read(root.path().join("settings.toml"))?;
9730 let (addr, state, server) = serve_fixture(config_path.clone()).await?;
9731 let _shutdown = state.task_manager.shutdown_guard();
9732 assert_catalog_and_new_thread_selection(addr, "zai", "GLM-5.3").await?;
9733 post_json(addr, "/v1/config/reload", json!({})).await?;
9734 assert_catalog_and_new_thread_selection(addr, "zai", "GLM-5.3").await?;
9735 assert_eq!(fs::read(&config_path)?, original_config);
9736 assert_eq!(
9737 fs::read(root.path().join("settings.toml"))?,
9738 original_settings
9739 );
9740 server.abort();
9741 state.task_manager.shutdown_and_wait().await?;
9742 Ok(())
9743 }
9744
9745 #[tokio::test(flavor = "current_thread")]
9746 async fn explicit_runtime_selections_migrate_legacy_memory_into_config_once() -> Result<()> {
9747 let _env = lock_test_env();
9748 let _live = crate::provider_lake::lock_live_snapshot();
9749 let root = tempfile::tempdir()?;
9750 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
9751 let _model_environment = isolate_model_environment();
9752 crate::provider_catalog_live::reset_cache_for_test();
9753 crate::provider_lake::clear_live_snapshot();
9754 let config_path = root.path().join("config.toml");
9755 write_remembered_selection_fixture(root.path(), &config_path)?;
9756 let (addr, state, server) = serve_fixture(config_path.clone()).await?;
9757 let _shutdown = state.task_manager.shutdown_guard();
9758 let original_thread =
9759 assert_catalog_and_new_thread_selection(addr, "zai", "GLM-5.3").await?;
9760 let original_settings = fs::read(root.path().join("settings.toml"))?;
9761 post_json(
9762 addr,
9763 "/v1/config",
9764 json!({ "key": "model", "value": "GLM-5.2", "persist": false }),
9765 )
9766 .await?;
9767 assert_eq!(
9768 fs::read(root.path().join("settings.toml"))?,
9769 original_settings
9770 );
9771 post_json(
9772 addr,
9773 "/v1/config",
9774 json!({ "key": "model", "value": "GLM-5.2", "persist": true }),
9775 )
9776 .await?;
9777 assert_eq!(
9778 runtime_request_model(&state.config.read(), None).expect("current default"),
9779 "GLM-5.3"
9780 );
9781 let migrated: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?;
9782 assert_eq!(migrated["route_preferences_version"].as_integer(), Some(1));
9783 assert_eq!(migrated["provider"].as_str(), Some("zai"));
9784 assert_eq!(
9785 migrated["providers"]["zai"]["model"].as_str(),
9786 Some("GLM-5.2")
9787 );
9788 post_json(addr, "/v1/config/reload", json!({})).await?;
9789 assert_catalog_and_new_thread_selection(addr, "zai", "GLM-5.2").await?;
9790 let saved_thread = state
9791 .runtime_threads
9792 .get_thread(original_thread["id"].as_str().expect("thread id"))
9793 .await?;
9794 assert_eq!(saved_thread.model, "GLM-5.3");
9795
9796 post_json(
9797 addr,
9798 "/v1/providers/deepseek/switch",
9799 json!({ "model": "deepseek-v4-flash" }),
9800 )
9801 .await?;
9802 assert_catalog_and_new_thread_selection(addr, "deepseek", "deepseek-v4-flash").await?;
9803 post_json(
9804 addr,
9805 "/v1/config",
9806 json!({ "key": "default_model", "value": "deepseek-v4-pro", "persist": true }),
9807 )
9808 .await?;
9809 post_json(addr, "/v1/config/reload", json!({})).await?;
9810 assert_catalog_and_new_thread_selection(addr, "deepseek", "deepseek-v4-pro").await?;
9811
9812 for (key, value) in [("provider", "zai"), ("model", "GLM-5.3")] {
9813 post_json(
9814 addr,
9815 "/v1/config",
9816 json!({ "key": key, "value": value, "persist": true }),
9817 )
9818 .await?;
9819 }
9820 post_json(addr, "/v1/config/reload", json!({})).await?;
9821 assert_catalog_and_new_thread_selection(addr, "zai", "GLM-5.3").await?;
9822 post_json(addr, "/v1/providers/deepseek/switch", json!({})).await?;
9823 post_json(addr, "/v1/config/reload", json!({})).await?;
9824 assert_catalog_and_new_thread_selection(addr, "deepseek", "deepseek-v4-pro").await?;
9825 // The old Settings selection remains unchanged and cannot reassert
9826 // itself once Config owns the migrated route preferences.
9827 assert_eq!(
9828 fs::read(root.path().join("settings.toml"))?,
9829 original_settings
9830 );
9831 server.abort();
9832 state.task_manager.shutdown_and_wait().await?;
9833 Ok(())
9834 }
9835
9836 #[tokio::test(flavor = "current_thread")]
9837 async fn provider_switch_migrates_legacy_selection_before_explicit_choice() -> Result<()> {
9838 let _env = lock_test_env();
9839 let _live = crate::provider_lake::lock_live_snapshot();
9840 let root = tempfile::tempdir()?;
9841 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
9842 let _model_environment = isolate_model_environment();
9843 crate::provider_catalog_live::reset_cache_for_test();
9844 crate::provider_lake::clear_live_snapshot();
9845 let config_path = root.path().join("config.toml");
9846 write_remembered_selection_fixture(root.path(), &config_path)?;
9847 let original_settings = fs::read(root.path().join("settings.toml"))?;
9848 let (addr, state, server) = serve_fixture(config_path.clone()).await?;
9849 let _shutdown = state.task_manager.shutdown_guard();
9850 post_json(
9851 addr,
9852 "/v1/providers/deepseek/switch",
9853 json!({ "model": "deepseek-v4-flash" }),
9854 )
9855 .await?;
9856 let migrated: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?;
9857 assert_eq!(migrated["route_preferences_version"].as_integer(), Some(1));
9858 assert_eq!(migrated["provider"].as_str(), Some("deepseek"));
9859 assert_eq!(
9860 migrated["providers"]["deepseek"]["model"].as_str(),
9861 Some("deepseek-v4-flash")
9862 );
9863 assert_eq!(
9864 migrated["providers"]["zai"]["model"].as_str(),
9865 Some("GLM-5.3")
9866 );
9867 post_json(addr, "/v1/config/reload", json!({})).await?;
9868 assert_catalog_and_new_thread_selection(addr, "deepseek", "deepseek-v4-flash").await?;
9869 assert_eq!(
9870 fs::read(root.path().join("settings.toml"))?,
9871 original_settings
9872 );
9873 server.abort();
9874 state.task_manager.shutdown_and_wait().await?;
9875 Ok(())
9876 }
9877
9878 #[tokio::test(flavor = "current_thread")]
9879 async fn runtime_model_writes_keep_legacy_hosted_ollama_identity() -> Result<()> {
9880 #[derive(Deserialize)]
9881 struct Selection {
9882 provider: String,
9883 model: String,
9884 persisted: bool,
9885 }
9886
9887 let _env = lock_test_env();
9888 let _live = crate::provider_lake::lock_live_snapshot();
9889 let home = tempfile::tempdir()?;
9890 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
9891 let _model_environment = isolate_model_environment();
9892 crate::provider_catalog_live::reset_cache_for_test();
9893 crate::provider_lake::clear_live_snapshot();
9894 let config_path = home.path().join("config.toml");
9895 fs::write(
9896 &config_path,
9897 "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",
9898 )?;
9899 let settings = "default_provider = 'ollama'\n[provider_models]\nollama-cloud = 'remembered-cloud-model'\n";
9900 fs::write(home.path().join("settings.toml"), settings)?;
9901 let (addr, state, server) = serve_fixture(config_path.clone()).await?;
9902 let _shutdown = state.task_manager.shutdown_guard();
9903 assert_eq!(
9904 state.config.read().default_model(),
9905 "remembered-cloud-model"
9906 );
9907 post_json(
9908 addr,
9909 "/v1/config",
9910 json!({"key": "model", "value": "current-cloud-model", "persist": true}),
9911 )
9912 .await?;
9913 post_json(addr, "/v1/config/reload", json!({})).await?;
9914 assert_eq!(state.config.read().default_model(), "current-cloud-model");
9915 for (selector, model) in [
9916 ("ollama", "legacy-choice"),
9917 ("ollama-cloud", "explicit-choice"),
9918 ("ollama", "legacy-final"),
9919 ] {
9920 let selection: Selection = serde_json::from_value(
9921 post_json(
9922 addr,
9923 &format!("/v1/providers/{selector}/switch"),
9924 json!({"model": model}),
9925 )
9926 .await?,
9927 )?;
9928 assert_eq!(selection.provider, "ollama-cloud");
9929 assert_eq!(selection.model, model);
9930 assert!(selection.persisted);
9931 post_json(addr, "/v1/config/reload", json!({})).await?;
9932 let config = state.config.read();
9933 let identity = config
9934 .active_provider_identity(ApiProvider::OllamaCloud)
9935 .map_err(anyhow::Error::msg)?;
9936 assert_eq!(identity.persisted_id(), Some(selector));
9937 assert_eq!(config.default_model(), model);
9938 }
9939 let document: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?;
9940 assert_eq!(document["route_preferences_version"].as_integer(), Some(1));
9941 assert_eq!(document["provider"].as_str(), Some("ollama"));
9942 assert_eq!(
9943 document["providers"]["ollama"]["model"].as_str(),
9944 Some("legacy-final")
9945 );
9946 assert_eq!(
9947 document["providers"]["ollama_cloud"]["model"].as_str(),
9948 Some("explicit-choice")
9949 );
9950 assert_eq!(
9951 fs::read_to_string(home.path().join("settings.toml"))?,
9952 settings
9953 );
9954 let thread =
9955 assert_catalog_and_new_thread_selection(addr, "ollama-cloud", "legacy-final").await?;
9956 assert_eq!(thread["model_provider_id"], "ollama");
9957 server.abort();
9958 state.task_manager.shutdown_and_wait().await?;
9959 Ok(())
9960 }
9961
9962 #[tokio::test(flavor = "current_thread")]
9963 async fn scoped_runtime_selections_leave_device_memory_unchanged() -> Result<()> {
9964 let _env = lock_test_env();
9965 let _live = crate::provider_lake::lock_live_snapshot();
9966 let root = tempfile::tempdir()?;
9967 let home = root.path().join("home");
9968 let _home = EnvVarGuard::set("CODEWHALE_HOME", &home);
9969 let _model_environment = isolate_model_environment();
9970 crate::provider_catalog_live::reset_cache_for_test();
9971 crate::provider_lake::clear_live_snapshot();
9972 let config_path = root.path().join("project/config.toml");
9973 write_remembered_selection_fixture(&home, &config_path)?;
9974 let original_settings = fs::read(home.join("settings.toml"))?;
9975 let (addr, state, server) = serve_fixture(config_path).await?;
9976 let _shutdown = state.task_manager.shutdown_guard();
9977 assert_catalog_and_new_thread_selection(addr, "deepseek", "deepseek-v4-pro").await?;
9978 for (key, value) in [
9979 ("provider", "zai"),
9980 ("model", "GLM-5.1"),
9981 ("provider", "deepseek"),
9982 ("default_model", "deepseek-v4-flash"),
9983 ] {
9984 post_json(
9985 addr,
9986 "/v1/config",
9987 json!({ "key": key, "value": value, "persist": true }),
9988 )
9989 .await?;
9990 }
9991 post_json(
9992 addr,
9993 "/v1/providers/zai/switch",
9994 json!({ "model": "GLM-5.2" }),
9995 )
9996 .await?;
9997 post_json(addr, "/v1/config/reload", json!({})).await?;
9998 assert_catalog_and_new_thread_selection(addr, "zai", "GLM-5.2").await?;
9999 assert_eq!(fs::read(home.join("settings.toml"))?, original_settings);
10000 server.abort();
10001 state.task_manager.shutdown_and_wait().await?;
10002 Ok(())
10003 }
10004
10005 #[tokio::test(flavor = "current_thread")]
10006 async fn declared_model_posts_preserve_exact_identity_after_reload() -> Result<()> {
10007 let _env = lock_test_env();
10008 let _live = crate::provider_lake::lock_live_snapshot();
10009 let home = tempfile::tempdir()?;
10010 let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
10011 let _model_environment = isolate_model_environment();
10012 crate::provider_catalog_live::reset_cache_for_test();
10013 crate::provider_lake::clear_live_snapshot();
10014 for (provider, model) in [
10015 (ApiProvider::Deepseek, "deepseek-v4pro"),
10016 (ApiProvider::Openrouter, "deepseek-v4-pro"),
10017 (ApiProvider::Together, "deepseek-v4-pro"),
10018 ] {
10019 let root = tempfile::tempdir()?;
10020 let config_path = root.path().join("config.toml");
10021 fs::write(&config_path, fixture(provider.as_str(), model))?;
10022 let (addr, state, server) = serve_fixture(config_path.clone()).await?;
10023 let _shutdown = state.task_manager.shutdown_guard();
10024 let body = post_json(
10025 addr,
10026 &format!("/v1/providers/{}/switch", provider.as_str()),
10027 json!({ "model": model }),
10028 )
10029 .await?;
10030 assert_eq!(body["model"], model);
10031 assert_declared_route(&config_path, provider, model);
10032 let keys = if provider == ApiProvider::Deepseek {
10033 vec!["model", "default_model"]
10034 } else {
10035 vec!["model"]
10036 };
10037 for key in keys {
10038 let body = post_json(
10039 addr,
10040 "/v1/config",
10041 json!({ "key": key, "value": model, "persist": true }),
10042 )
10043 .await?;
10044 assert_eq!(body["value"], model);
10045 post_json(addr, "/v1/config/reload", json!({})).await?;
10046 assert_declared_route(&config_path, provider, model);
10047 let reloaded = state.config.read();
10048 let selected = provider_default_model_for_api(&reloaded, provider, provider);
10049 let route = crate::route_runtime::resolve_runtime_route(
10050 &reloaded,
10051 provider,
10052 Some(&selected),
10053 )
10054 .expect("active reloaded route");
10055 assert_eq!(route.model, model);
10056 assert_eq!(route.candidate.limits().context_tokens, Some(96_000));
10057 }
10058 server.abort();
10059 state.task_manager.shutdown_and_wait().await?;
10060 }
10061 Ok(())
10062 }
10063
10064 #[tokio::test(flavor = "current_thread")]
10065 async fn declared_model_posts_do_not_preserve_alias_at_wrong_endpoint() -> Result<()> {
10066 let _env = lock_test_env();
10067 let _live = crate::provider_lake::lock_live_snapshot();
10068 let root = tempfile::tempdir()?;
10069 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
10070 let _model_environment = isolate_model_environment();
10071 crate::provider_catalog_live::reset_cache_for_test();
10072 crate::provider_lake::clear_live_snapshot();
10073 let config_path = root.path().join("config.toml");
10074 let fixture = fixture("deepseek", "deepseek-v4pro").replace(
10075 "[providers.deepseek]\nbase_url = \"http://127.0.0.1:9/v1\"",
10076 "[providers.deepseek]\nbase_url = \"http://127.0.0.1:10/v1\"",
10077 );
10078 fs::write(&config_path, fixture)?;
10079 let (addr, state, server) = serve_fixture(config_path.clone()).await?;
10080 let _shutdown = state.task_manager.shutdown_guard();
10081 let body = post_json(
10082 addr,
10083 "/v1/providers/deepseek/switch",
10084 json!({ "model": "deepseek-v4pro" }),
10085 )
10086 .await?;
10087 assert_eq!(body["model"], "deepseek-v4-pro");
10088 let body = post_json(
10089 addr,
10090 "/v1/config",
10091 json!({ "key": "model", "value": "deepseek-v4pro", "persist": true }),
10092 )
10093 .await?;
10094 assert_eq!(body["value"], "deepseek-v4-pro");
10095 post_json(addr, "/v1/config/reload", json!({})).await?;
10096 let config = Config::load(Some(config_path), None)?;
10097 assert_eq!(
10098 config
10099 .provider_config_for(ApiProvider::Deepseek)
10100 .and_then(|provider| provider.model.as_deref()),
10101 Some("deepseek-v4-pro")
10102 );
10103 let selected =
10104 provider_default_model_for_api(&config, ApiProvider::Deepseek, ApiProvider::Deepseek);
10105 let route = crate::route_runtime::resolve_runtime_route(
10106 &config,
10107 ApiProvider::Deepseek,
10108 Some(&selected),
10109 )
10110 .expect("ordinary saved route");
10111 assert_eq!(route.model, "deepseek-v4-pro");
10112 assert_ne!(route.candidate.limits().context_tokens, Some(96_000));
10113 assert_ne!(
10114 route.context_window.source,
10115 crate::route_runtime::ContextWindowSource::UserDeclared
10116 );
10117 server.abort();
10118 state.task_manager.shutdown_and_wait().await?;
10119 Ok(())
10120 }
10121
10122 #[test]
10123 fn declared_model_normalization_keeps_identity_and_protocol_guards() {
10124 let _env = lock_test_env();
10125 let _live = crate::provider_lake::lock_live_snapshot();
10126 let root = tempfile::tempdir().expect("test root");
10127 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
10128 let _model_environment = isolate_model_environment();
10129 crate::provider_catalog_live::reset_cache_for_test();
10130 crate::provider_lake::clear_live_snapshot();
10131 let mut config: Config =
10132 toml::from_str(&fixture("deepseek", "deepseek-v4pro")).expect("fixture config");
10133 config.custom_models.as_mut().unwrap()[0].provider = "other".to_string();
10134 assert_eq!(
10135 normalize_runtime_config_model(&config, ApiProvider::Deepseek, "deepseek-v4pro")
10136 .expect("legacy alias remains accepted"),
10137 "deepseek-v4-pro"
10138 );
10139 for provider in [ApiProvider::OpencodeGo, ApiProvider::OpencodeZen] {
10140 let config: Config = toml::from_str(&fixture(provider.as_str(), "unlisted-model"))
10141 .expect("fixture config");
10142 assert!(
10143 normalize_runtime_config_model(&config, provider, "unlisted-model").is_err(),
10144 "a declaration cannot expand the {provider:?} protocol roster"
10145 );
10146 }
10147 }
10148
10149 #[tokio::test(flavor = "current_thread")]
10150 async fn runtime_default_model_reads_and_writes_the_deepseek_cn_slot() -> Result<()> {
10151 let _env = lock_test_env();
10152 let _live = crate::provider_lake::lock_live_snapshot();
10153 let root = tempfile::tempdir()?;
10154 let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
10155 let _model_environment = isolate_model_environment();
10156 crate::provider_catalog_live::reset_cache_for_test();
10157 crate::provider_lake::clear_live_snapshot();
10158 let config_path = root.path().join("config.toml");
10159 fs::write(
10160 &config_path,
10161 "provider = 'deepseek-cn'\ntelemetry = false\n[cloud_facts]\nenabled = false\n[providers.deepseek_cn]\nmodel = 'deepseek-v4-pro'\n",
10162 )?;
10163 let (addr, state, server) = serve_fixture(config_path.clone()).await?;
10164 let _shutdown = state.task_manager.shutdown_guard();
10165 let client = crate::tls::reqwest_client();
10166 // The active CN route resolves `[providers.deepseek_cn].model`; the
10167 // runtime default_model surface must report that slot, not the primary.
10168 let reported: Value = client
10169 .get(format!("http://{addr}/v1/config"))
10170 .send()
10171 .await?
10172 .error_for_status()?
10173 .json()
10174 .await?;
10175 assert_eq!(reported["default_model"], "deepseek-v4-pro");
10176
10177 post_json(
10178 addr,
10179 "/v1/config",
10180 json!({ "key": "default_model", "value": "deepseek-v4-flash", "persist": true }),
10181 )
10182 .await?;
10183 let saved: toml::Value = toml::from_str(&fs::read_to_string(&config_path)?)?;
10184 assert_eq!(
10185 saved["providers"]["deepseek_cn"]["model"].as_str(),
10186 Some("deepseek-v4-flash")
10187 );
10188 assert!(
10189 saved
10190 .get("providers")
10191 .and_then(|providers| providers.get("deepseek"))
10192 .and_then(|deepseek| deepseek.get("model"))
10193 .is_none(),
10194 "the CN write must not create an unread primary slot: {saved}"
10195 );
10196
10197 post_json(addr, "/v1/config/reload", json!({})).await?;
10198 let reported: Value = client
10199 .get(format!("http://{addr}/v1/config"))
10200 .send()
10201 .await?
10202 .error_for_status()?
10203 .json()
10204 .await?;
10205 assert_eq!(reported["default_model"], "deepseek-v4-flash");
10206 post_json(
10207 addr,
10208 "/v1/config",
10209 json!({ "key": "default_model", "value": "auto", "persist": true }),
10210 )
10211 .await?;
10212 post_json(addr, "/v1/config/reload", json!({})).await?;
10213 let reported: Value = client
10214 .get(format!("http://{addr}/v1/config"))
10215 .send()
10216 .await?
10217 .error_for_status()?
10218 .json()
10219 .await?;
10220 assert_eq!(reported["default_model"], "auto");
10221 server.abort();
10222 state.task_manager.shutdown_and_wait().await?;
10223 Ok(())
10224 }
10225 }
10226
10226 lines RUST