Skip to main content

codewhale_core/
lib.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use std::time::Duration;
6
7use anyhow::Result;
8use codewhale_agent::ModelRegistry;
9use codewhale_config::{CliRuntimeOverrides, ConfigToml, ProviderKind};
10use codewhale_execpolicy::{
11    AskForApproval, ExecApprovalRequirement, ExecPolicyContext, ExecPolicyDecision,
12    ExecPolicyEngine,
13};
14use codewhale_hooks::{HookDispatcher, HookEvent};
15use codewhale_mcp::{
16    McpManager, McpStartupCompleteEvent, McpStartupStatus as McpManagerStartupStatus,
17};
18use codewhale_protocol::{
19    AppResponse, EventFrame, ExecApprovalRequestEvent, PromptRequest, PromptResponse,
20    ResponseChannel, ReviewDecision, Status, Thread, ThreadForkParams, ThreadGoal,
21    ThreadGoalClearParams, ThreadGoalGetParams, ThreadGoalProgressParams, ThreadGoalSetParams,
22    ThreadGoalStatus, ThreadListParams, ThreadReadParams, ThreadRequest, ThreadResponse,
23    ThreadResumeParams, ThreadSetNameParams, ThreadStatus, ToolPayload, UserInputRequestEvent,
24};
25use codewhale_state::{
26    JobStateRecord, JobStateStatus, SessionSource, StateStore, ThreadGoalRecord,
27    ThreadGoalStatus as PersistedThreadGoalStatus, ThreadListFilters, ThreadMetadata,
28    ThreadStatus as PersistedThreadStatus,
29};
30use codewhale_tools::{ToolCall, ToolRegistry};
31use serde_json::{Value, json};
32use tokio::time;
33use uuid::Uuid;
34
35/// Per-tool dispatch budget for the headless runtime. Matches the generous
36/// subagent default so long-running tools are not cut off prematurely.
37fn tool_dispatch_timeout() -> Duration {
38    if cfg!(test) {
39        Duration::from_millis(50)
40    } else {
41        Duration::from_secs(300)
42    }
43}
44
45/// How a new thread's conversation history is initialized.
46#[derive(Debug, Clone)]
47pub enum InitialHistory {
48    /// Start with an empty conversation.
49    New,
50    /// Forked from an existing thread with the given history items.
51    Forked(Vec<Value>),
52    /// Resumed from a persisted thread with its full history.
53    Resumed {
54        conversation_id: String,
55        history: Vec<Value>,
56        rollout_path: PathBuf,
57    },
58}
59
60/// Result of spawning or resuming a thread.
61#[derive(Debug, Clone)]
62pub struct NewThread {
63    /// The thread metadata.
64    pub thread: Thread,
65    /// Resolved model identifier.
66    pub model: String,
67    /// Provider that serves the model.
68    pub model_provider: String,
69    /// Working directory for the thread.
70    pub cwd: PathBuf,
71    /// Approval policy override, if any.
72    pub approval_policy: Option<String>,
73    /// Sandbox mode override, if any.
74    pub sandbox: Option<String>,
75}
76
77/// Status of a background job.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum JobStatus {
80    /// Waiting to be picked up.
81    Queued,
82    /// Currently executing.
83    Running,
84    /// Temporarily paused.
85    Paused,
86    /// Finished successfully.
87    Completed,
88    /// Finished with an error.
89    Failed,
90    /// Cancelled by the user.
91    Cancelled,
92}
93
94impl Status for JobStatus {
95    fn is_terminal(&self) -> bool {
96        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
97    }
98    fn is_active(&self) -> bool {
99        matches!(self, Self::Queued | Self::Running)
100    }
101    fn is_paused(&self) -> bool {
102        matches!(self, Self::Paused)
103    }
104}
105
106const JOB_DETAIL_SCHEMA_VERSION: u8 = 1;
107const DEFAULT_JOB_MAX_ATTEMPTS: u32 = 3;
108const DEFAULT_JOB_BACKOFF_BASE_MS: u64 = 500;
109const MAX_JOB_HISTORY_ENTRIES: usize = 64;
110
111/// Retry state for a job that failed and may be retried.
112#[derive(Debug, Clone)]
113pub struct JobRetryMetadata {
114    /// Current attempt number (0 = not yet retried).
115    pub attempt: u32,
116    /// Maximum number of retry attempts before giving up.
117    pub max_attempts: u32,
118    /// Base delay in milliseconds for exponential backoff.
119    pub backoff_base_ms: u64,
120    /// Computed delay in milliseconds until the next retry.
121    pub next_backoff_ms: u64,
122    /// Timestamp when the next retry should be attempted.
123    pub next_retry_at: Option<i64>,
124}
125
126impl Default for JobRetryMetadata {
127    fn default() -> Self {
128        Self {
129            attempt: 0,
130            max_attempts: DEFAULT_JOB_MAX_ATTEMPTS,
131            backoff_base_ms: DEFAULT_JOB_BACKOFF_BASE_MS,
132            next_backoff_ms: 0,
133            next_retry_at: None,
134        }
135    }
136}
137
138/// A single entry in a job's history log.
139#[derive(Debug, Clone)]
140pub struct JobHistoryEntry {
141    /// Timestamp when this entry was recorded.
142    pub at: i64,
143    /// Phase name (e.g., "created", "running", "failed").
144    pub phase: String,
145    /// Job status at this point in time.
146    pub status: JobStatus,
147    /// Progress percentage at this point, if available.
148    pub progress: Option<u8>,
149    /// Human-readable detail message.
150    pub detail: Option<String>,
151    /// Retry state snapshot at this point.
152    pub retry: JobRetryMetadata,
153}
154
155#[derive(Debug, Clone)]
156struct PersistedJobDetail {
157    pub status: JobStatus,
158    pub detail: Option<String>,
159    pub retry: JobRetryMetadata,
160    pub history: Vec<JobHistoryEntry>,
161}
162
163/// A complete job record with all metadata and history.
164#[derive(Debug, Clone)]
165pub struct JobRecord {
166    /// Unique job identifier.
167    pub id: String,
168    /// Human-readable job name.
169    pub name: String,
170    /// Current job status.
171    pub status: JobStatus,
172    /// Current progress percentage (0-100).
173    pub progress: Option<u8>,
174    /// Human-readable detail about the current state.
175    pub detail: Option<String>,
176    /// Retry state for failed jobs.
177    pub retry: JobRetryMetadata,
178    /// Chronological history of state transitions.
179    pub history: Vec<JobHistoryEntry>,
180    /// Timestamp when the job was created.
181    pub created_at: i64,
182    /// Timestamp of the last state change.
183    pub updated_at: i64,
184}
185
186/// Map a durable [`JobRecord`] to the dependency-neutral run read model.
187///
188/// Pure projection of the record as persisted: unknown budgets stay unset and
189/// nothing is fabricated. `updated_at` (epoch seconds) provides the terminal
190/// timestamp because the job manager records no separate end time. The
191/// free-form job detail is intentionally omitted because this owner does not
192/// classify it as safe for a cross-surface read model.
193#[must_use]
194pub fn job_record_to_agent_run(
195    record: &JobRecord,
196) -> codewhale_protocol::agent_run::AgentRunSnapshot {
197    use codewhale_protocol::agent_run::{
198        AgentRunSnapshot, BudgetSummary, RunSource, RunState, TerminalOutcome, TerminalSummary,
199    };
200
201    let (state, terminal) = match record.status {
202        JobStatus::Queued => (RunState::Queued, None),
203        JobStatus::Running => (RunState::Running, None),
204        JobStatus::Paused => (RunState::Paused, None),
205        JobStatus::Completed | JobStatus::Failed | JobStatus::Cancelled => {
206            let outcome = match record.status {
207                JobStatus::Completed => TerminalOutcome::Completed,
208                JobStatus::Failed => TerminalOutcome::Failed,
209                _ => TerminalOutcome::Cancelled,
210            };
211            (
212                RunState::Terminal,
213                Some(TerminalSummary {
214                    outcome,
215                    ended_at_ms: record.updated_at.checked_mul(1000),
216                    detail: None,
217                }),
218            )
219        }
220    };
221
222    AgentRunSnapshot {
223        run_id: record.id.clone(),
224        parent: None,
225        source: RunSource::CoreJob,
226        state,
227        budget: BudgetSummary::default(),
228        terminal,
229        refs: Vec::new(),
230    }
231}
232
233/// Manages background jobs with retry logic and persistence.
234#[derive(Debug, Default)]
235pub struct JobManager {
236    jobs: HashMap<String, JobRecord>,
237}
238
239impl JobManager {
240    fn now_ts() -> i64 {
241        chrono::Utc::now().timestamp()
242    }
243
244    fn deterministic_backoff_ms(retry: &JobRetryMetadata) -> u64 {
245        if retry.attempt == 0 {
246            return 0;
247        }
248        let exponent = retry.attempt.saturating_sub(1).min(20);
249        let multiplier = 1u64.checked_shl(exponent).unwrap_or(u64::MAX);
250        retry.backoff_base_ms.saturating_mul(multiplier)
251    }
252
253    fn clear_retry_schedule(retry: &mut JobRetryMetadata) {
254        retry.next_backoff_ms = 0;
255        retry.next_retry_at = None;
256    }
257
258    fn push_history(job: &mut JobRecord, phase: &str) {
259        job.history.push(JobHistoryEntry {
260            at: job.updated_at,
261            phase: phase.to_string(),
262            status: job.status,
263            progress: job.progress,
264            detail: job.detail.clone(),
265            retry: job.retry.clone(),
266        });
267        if job.history.len() > MAX_JOB_HISTORY_ENTRIES {
268            let to_drain = job.history.len() - MAX_JOB_HISTORY_ENTRIES;
269            job.history.drain(0..to_drain);
270        }
271    }
272
273    fn parse_persisted_detail(raw: Option<&str>) -> Option<PersistedJobDetail> {
274        let raw = raw?;
275        let parsed: Value = serde_json::from_str(raw).ok()?;
276        let status = parsed
277            .get("status")
278            .and_then(Value::as_str)
279            .and_then(job_status_from_str)?;
280        let detail = parsed.get("detail").and_then(json_optional_string);
281        let retry = parse_retry_metadata(parsed.get("retry"));
282        let history = parsed
283            .get("history")
284            .and_then(Value::as_array)
285            .map(|items| {
286                items
287                    .iter()
288                    .filter_map(parse_history_entry)
289                    .collect::<Vec<_>>()
290            })
291            .unwrap_or_default();
292        Some(PersistedJobDetail {
293            status,
294            detail,
295            retry,
296            history,
297        })
298    }
299
300    fn encode_persisted_detail(job: &JobRecord) -> Result<Option<String>> {
301        let encoded = json!({
302            "schema_version": JOB_DETAIL_SCHEMA_VERSION,
303            "status": job_status_to_str(job.status),
304            "detail": job.detail.clone(),
305            "retry": job_retry_to_value(&job.retry),
306            "history": job.history.iter().map(job_history_to_value).collect::<Vec<_>>()
307        })
308        .to_string();
309        Ok(Some(encoded))
310    }
311
312    /// Enqueues a new job and returns its record.
313    pub fn enqueue(&mut self, name: impl Into<String>) -> JobRecord {
314        let now = Self::now_ts();
315        let id = format!("job-{}", Uuid::new_v4());
316        let mut job = JobRecord {
317            id: id.clone(),
318            name: name.into(),
319            status: JobStatus::Queued,
320            progress: Some(0),
321            detail: None,
322            retry: JobRetryMetadata::default(),
323            history: Vec::new(),
324            created_at: now,
325            updated_at: now,
326        };
327        Self::push_history(&mut job, "created");
328        self.jobs.insert(id, job.clone());
329        job
330    }
331
332    /// Transitions a job to running and clears its retry schedule.
333    pub fn set_running(&mut self, id: &str) {
334        if let Some(job) = self.jobs.get_mut(id) {
335            job.status = JobStatus::Running;
336            Self::clear_retry_schedule(&mut job.retry);
337            job.updated_at = Self::now_ts();
338            Self::push_history(job, "running");
339        }
340    }
341
342    /// Updates a job's progress (clamped to 100) and optional detail message.
343    pub fn update_progress(&mut self, id: &str, progress: u8, detail: Option<String>) {
344        if let Some(job) = self.jobs.get_mut(id) {
345            job.progress = Some(progress.min(100));
346            job.detail = detail;
347            job.updated_at = Self::now_ts();
348            Self::push_history(job, "progress_updated");
349        }
350    }
351
352    /// Marks a job as completed with 100% progress and clears its retry schedule.
353    pub fn complete(&mut self, id: &str) {
354        if let Some(job) = self.jobs.get_mut(id) {
355            job.status = JobStatus::Completed;
356            job.progress = Some(100);
357            Self::clear_retry_schedule(&mut job.retry);
358            job.updated_at = Self::now_ts();
359            Self::push_history(job, "completed");
360        }
361    }
362
363    /// Marks a job as failed and schedules a retry if attempts remain.
364    pub fn fail(&mut self, id: &str, detail: impl Into<String>) {
365        if let Some(job) = self.jobs.get_mut(id) {
366            let now = Self::now_ts();
367            job.status = JobStatus::Failed;
368            job.detail = Some(detail.into());
369            if job.retry.attempt < job.retry.max_attempts {
370                job.retry.attempt += 1;
371                job.retry.next_backoff_ms = Self::deterministic_backoff_ms(&job.retry);
372                let delay_secs = ((job.retry.next_backoff_ms.saturating_add(999)) / 1000)
373                    .min(i64::MAX as u64) as i64;
374                job.retry.next_retry_at = Some(now.saturating_add(delay_secs));
375            } else {
376                Self::clear_retry_schedule(&mut job.retry);
377            }
378            job.updated_at = now;
379            Self::push_history(job, "failed");
380        }
381    }
382
383    /// Cancels a job and clears any pending retry schedule.
384    pub fn cancel(&mut self, id: &str) {
385        if let Some(job) = self.jobs.get_mut(id) {
386            job.status = JobStatus::Cancelled;
387            Self::clear_retry_schedule(&mut job.retry);
388            job.updated_at = Self::now_ts();
389            Self::push_history(job, "cancelled");
390        }
391    }
392
393    /// Pauses a job, optionally updating its detail message.
394    pub fn pause(&mut self, id: &str, detail: Option<String>) {
395        if let Some(job) = self.jobs.get_mut(id) {
396            job.status = JobStatus::Paused;
397            if detail.is_some() {
398                job.detail = detail;
399            }
400            job.updated_at = Self::now_ts();
401            Self::push_history(job, "paused");
402        }
403    }
404
405    /// Resumes a paused or failed job back to running status.
406    pub fn resume(&mut self, id: &str, detail: Option<String>) {
407        if let Some(job) = self.jobs.get_mut(id) {
408            job.status = JobStatus::Running;
409            if detail.is_some() {
410                job.detail = detail;
411            }
412            Self::clear_retry_schedule(&mut job.retry);
413            job.updated_at = Self::now_ts();
414            Self::push_history(job, "resumed");
415        }
416    }
417
418    /// Returns all jobs sorted by most recently updated first.
419    pub fn list(&self) -> Vec<JobRecord> {
420        let mut out = self.jobs.values().cloned().collect::<Vec<_>>();
421        out.sort_by_key(|job| std::cmp::Reverse(job.updated_at));
422        out
423    }
424
425    /// Returns the history entries for a job, or an empty vec if not found.
426    pub fn history(&self, id: &str) -> Vec<JobHistoryEntry> {
427        self.jobs
428            .get(id)
429            .map(|job| job.history.clone())
430            .unwrap_or_default()
431    }
432
433    /// Resets queued or running jobs back to queued on application resume.
434    pub fn resume_pending(&mut self) -> Vec<JobRecord> {
435        let mut resumed = Vec::new();
436        for job in self.jobs.values_mut() {
437            if matches!(job.status, JobStatus::Queued | JobStatus::Running) {
438                job.status = JobStatus::Queued;
439                job.updated_at = Self::now_ts();
440                Self::push_history(job, "queued_after_resume");
441                resumed.push(job.clone());
442            }
443        }
444        resumed
445    }
446
447    /// Loads jobs from the state store, deserializing extended detail when available.
448    pub fn load_from_store(&mut self, store: &StateStore) -> Result<()> {
449        let persisted = store.list_jobs(Some(500))?;
450        for job in persisted {
451            let fallback_status = job_state_status_to_runtime(job.status);
452            let parsed = Self::parse_persisted_detail(job.detail.as_deref());
453            let (status, detail, retry, history) = if let Some(detail_state) = parsed {
454                (
455                    detail_state.status,
456                    detail_state.detail,
457                    detail_state.retry,
458                    detail_state.history,
459                )
460            } else {
461                (
462                    fallback_status,
463                    job.detail,
464                    JobRetryMetadata::default(),
465                    Vec::new(),
466                )
467            };
468            self.jobs.insert(
469                job.id.clone(),
470                JobRecord {
471                    id: job.id,
472                    name: job.name,
473                    status,
474                    progress: job.progress,
475                    detail,
476                    retry,
477                    history,
478                    created_at: job.created_at,
479                    updated_at: job.updated_at,
480                },
481            );
482        }
483        Ok(())
484    }
485
486    /// Persists a single job's current state to the state store.
487    pub fn persist_job(&self, store: &StateStore, id: &str) -> Result<()> {
488        let Some(job) = self.jobs.get(id) else {
489            return Ok(());
490        };
491        let encoded_detail = Self::encode_persisted_detail(job)?;
492        store.upsert_job(&JobStateRecord {
493            id: job.id.clone(),
494            name: job.name.clone(),
495            status: runtime_status_to_job_state(job.status),
496            progress: job.progress,
497            detail: encoded_detail,
498            created_at: job.created_at,
499            updated_at: job.updated_at,
500        })
501    }
502
503    /// Persists all in-memory jobs to the state store.
504    pub fn persist_all(&self, store: &StateStore) -> Result<()> {
505        for id in self.jobs.keys() {
506            self.persist_job(store, id)?;
507        }
508        Ok(())
509    }
510}
511
512/// Manages thread lifecycle: spawn, resume, fork, archive, and persistence.
513pub struct ThreadManager {
514    store: StateStore,
515    running_threads: HashMap<String, Thread>,
516    cli_version: String,
517}
518
519impl ThreadManager {
520    /// Creates a new `ThreadManager` backed by the given state store.
521    pub fn new(store: StateStore) -> Self {
522        Self {
523            store,
524            running_threads: HashMap::new(),
525            cli_version: env!("CARGO_PKG_VERSION").to_string(),
526        }
527    }
528
529    /// Returns a reference to the underlying state store.
530    pub fn state_store(&self) -> &StateStore {
531        &self.store
532    }
533
534    /// Spawns a new thread with the given initial history and persists it.
535    pub fn spawn_thread_with_history(
536        &mut self,
537        model_provider: String,
538        cwd: PathBuf,
539        initial_history: InitialHistory,
540        persist_extended_history: bool,
541    ) -> Result<NewThread> {
542        let id = format!("thread-{}", Uuid::new_v4());
543        let now = chrono::Utc::now().timestamp();
544        let preview = preview_from_initial_history(&initial_history);
545        let source = match initial_history {
546            InitialHistory::New => SessionSource::Interactive,
547            InitialHistory::Forked(_) => SessionSource::Fork,
548            InitialHistory::Resumed { .. } => SessionSource::Resume,
549        };
550        let thread = Thread {
551            id: id.clone(),
552            preview,
553            ephemeral: !persist_extended_history,
554            model_provider: model_provider.clone(),
555            created_at: now,
556            updated_at: now,
557            status: ThreadStatus::Running,
558            path: None,
559            cwd: cwd.clone(),
560            cli_version: self.cli_version.clone(),
561            source: match source {
562                SessionSource::Interactive => codewhale_protocol::SessionSource::Interactive,
563                SessionSource::Resume => codewhale_protocol::SessionSource::Resume,
564                SessionSource::Fork => codewhale_protocol::SessionSource::Fork,
565                SessionSource::Api => codewhale_protocol::SessionSource::Api,
566                SessionSource::Unknown => codewhale_protocol::SessionSource::Unknown,
567            },
568            name: None,
569        };
570        self.persist_thread(&thread, None)?;
571        match &initial_history {
572            InitialHistory::Forked(items) => {
573                for item in items {
574                    self.store.append_message(
575                        &thread.id,
576                        "history",
577                        &item.to_string(),
578                        Some(item.clone()),
579                    )?;
580                }
581            }
582            InitialHistory::Resumed { history, .. } => {
583                for item in history {
584                    self.store.append_message(
585                        &thread.id,
586                        "history",
587                        &item.to_string(),
588                        Some(item.clone()),
589                    )?;
590                }
591            }
592            InitialHistory::New => {}
593        }
594        self.running_threads
595            .insert(thread.id.clone(), thread.clone());
596        Ok(NewThread {
597            thread,
598            model: "auto".to_string(),
599            model_provider,
600            cwd,
601            approval_policy: None,
602            sandbox: None,
603        })
604    }
605
606    /// Resumes an existing thread, returning `None` if not found.
607    pub fn resume_thread_with_history(
608        &mut self,
609        params: &ThreadResumeParams,
610        fallback_cwd: &Path,
611        model_provider: String,
612    ) -> Result<Option<NewThread>> {
613        if params.history.is_none()
614            && let Some(thread) = self.running_threads.get(&params.thread_id).cloned()
615        {
616            return Ok(Some(NewThread {
617                model: params.model.clone().unwrap_or_else(|| "auto".to_string()),
618                model_provider: params.model_provider.clone().unwrap_or(model_provider),
619                cwd: params.cwd.clone().unwrap_or_else(|| thread.cwd.clone()),
620                approval_policy: params.approval_policy.clone(),
621                sandbox: params.sandbox.clone(),
622                thread,
623            }));
624        }
625
626        let persisted = self.store.get_thread(&params.thread_id)?;
627        let Some(metadata) = persisted else {
628            return Ok(None);
629        };
630        let mut thread = to_protocol_thread(metadata);
631        thread.status = ThreadStatus::Running;
632        thread.updated_at = chrono::Utc::now().timestamp();
633        thread.cwd = params
634            .cwd
635            .clone()
636            .unwrap_or_else(|| fallback_cwd.to_path_buf());
637        self.persist_thread(&thread, None)?;
638        self.running_threads
639            .insert(thread.id.clone(), thread.clone());
640        if let Some(history) = params.history.as_ref() {
641            for item in history {
642                self.store.append_message(
643                    &thread.id,
644                    "history",
645                    &item.to_string(),
646                    Some(item.clone()),
647                )?;
648            }
649        }
650
651        Ok(Some(NewThread {
652            model: params.model.clone().unwrap_or_else(|| "auto".to_string()),
653            model_provider: params.model_provider.clone().unwrap_or(model_provider),
654            cwd: thread.cwd.clone(),
655            approval_policy: params.approval_policy.clone(),
656            sandbox: params.sandbox.clone(),
657            thread,
658        }))
659    }
660
661    /// Forks an existing thread into a new one, inheriting the parent's provider.
662    pub fn fork_thread(
663        &mut self,
664        params: &ThreadForkParams,
665        fallback_cwd: &Path,
666    ) -> Result<Option<NewThread>> {
667        let parent = self.store.get_thread(&params.thread_id)?;
668        let Some(parent) = parent else {
669            return Ok(None);
670        };
671        let parent_thread = to_protocol_thread(parent);
672        let new = self.spawn_thread_with_history(
673            params
674                .model_provider
675                .clone()
676                .unwrap_or_else(|| parent_thread.model_provider.clone()),
677            params
678                .cwd
679                .clone()
680                .unwrap_or_else(|| fallback_cwd.to_path_buf()),
681            InitialHistory::Forked(vec![json!({
682                "type": "fork",
683                "from_thread_id": parent_thread.id
684            })]),
685            params.persist_extended_history,
686        )?;
687        Ok(Some(new))
688    }
689
690    /// Lists threads matching the given filter parameters.
691    pub fn list_threads(&self, params: &ThreadListParams) -> Result<Vec<Thread>> {
692        let list = self.store.list_threads(ThreadListFilters {
693            include_archived: params.include_archived,
694            limit: params.limit,
695        })?;
696        Ok(list.into_iter().map(to_protocol_thread).collect())
697    }
698
699    /// Reads a single thread by id, or `None` if not found.
700    pub fn read_thread(&self, params: &ThreadReadParams) -> Result<Option<Thread>> {
701        Ok(self
702            .store
703            .get_thread(&params.thread_id)?
704            .map(to_protocol_thread))
705    }
706
707    /// Sets the display name for a thread, returning the updated thread or `None`.
708    pub fn set_thread_name(&mut self, params: &ThreadSetNameParams) -> Result<Option<Thread>> {
709        let Some(mut metadata) = self.store.get_thread(&params.thread_id)? else {
710            return Ok(None);
711        };
712        metadata.name = Some(params.name.clone());
713        metadata.updated_at = chrono::Utc::now().timestamp();
714        self.store.upsert_thread(&metadata)?;
715        let updated = to_protocol_thread(metadata);
716        self.running_threads
717            .insert(updated.id.clone(), updated.clone());
718        Ok(Some(updated))
719    }
720
721    /// Sets or replaces the persisted goal for a thread.
722    pub fn set_thread_goal(&mut self, params: &ThreadGoalSetParams) -> Result<Option<ThreadGoal>> {
723        if self.store.get_thread(&params.thread_id)?.is_none() {
724            return Ok(None);
725        }
726        let now = chrono::Utc::now().timestamp();
727        let goal = ThreadGoalRecord {
728            thread_id: params.thread_id.clone(),
729            goal_id: format!("goal-{}", Uuid::new_v4()),
730            objective: params.objective.clone(),
731            status: PersistedThreadGoalStatus::Active,
732            token_budget: params.token_budget,
733            tokens_used: 0,
734            time_used_seconds: 0,
735            continuation_count: 0,
736            created_at: now,
737            updated_at: now,
738        };
739        self.store.upsert_thread_goal(&goal)?;
740        Ok(Some(to_protocol_goal(goal)))
741    }
742
743    /// Reads the persisted goal for a thread.
744    pub fn get_thread_goal(&self, params: &ThreadGoalGetParams) -> Result<Option<ThreadGoal>> {
745        Ok(self
746            .store
747            .get_thread_goal(&params.thread_id)?
748            .map(to_protocol_goal))
749    }
750
751    /// Accrues durable per-goal usage and/or a continuation pass for a thread.
752    pub fn record_thread_goal_progress(
753        &mut self,
754        params: &ThreadGoalProgressParams,
755    ) -> Result<Option<ThreadGoal>> {
756        if self.store.get_thread(&params.thread_id)?.is_none() {
757            return Ok(None);
758        }
759
760        let now = chrono::Utc::now().timestamp();
761        let mut goal = if params.token_delta != 0 || params.time_delta_seconds != 0 {
762            self.store.record_thread_goal_usage(
763                &params.thread_id,
764                params.token_delta,
765                params.time_delta_seconds,
766                now,
767            )?
768        } else {
769            self.store.get_thread_goal(&params.thread_id)?
770        };
771
772        if params.record_continuation {
773            goal = self
774                .store
775                .record_thread_goal_continuation(&params.thread_id, now)?;
776        }
777
778        Ok(goal.map(to_protocol_goal))
779    }
780
781    /// Clears the persisted goal for a thread, returning whether one existed.
782    pub fn clear_thread_goal(&mut self, params: &ThreadGoalClearParams) -> Result<bool> {
783        self.store.delete_thread_goal(&params.thread_id)
784    }
785
786    /// Archives a thread so it no longer appears in default listings.
787    pub fn archive_thread(&mut self, thread_id: &str) -> Result<()> {
788        self.store.mark_archived(thread_id)?;
789        if let Some(thread) = self.running_threads.get_mut(thread_id) {
790            thread.status = ThreadStatus::Archived;
791        }
792        Ok(())
793    }
794
795    /// Restores an archived thread to active status.
796    pub fn unarchive_thread(&mut self, thread_id: &str) -> Result<()> {
797        self.store.mark_unarchived(thread_id)?;
798        if let Some(metadata) = self.store.get_thread(thread_id)? {
799            let thread = to_protocol_thread(metadata);
800            if let Some(cached) = self.running_threads.get_mut(thread_id) {
801                *cached = thread;
802            }
803        }
804        Ok(())
805    }
806
807    /// Records a user message in a thread and updates its preview and timestamp.
808    pub fn touch_message(&mut self, thread_id: &str, input: &str) -> Result<()> {
809        let Some(mut metadata) = self.store.get_thread(thread_id)? else {
810            return Ok(());
811        };
812        metadata.updated_at = chrono::Utc::now().timestamp();
813        metadata.preview = truncate_preview(input);
814        metadata.status = PersistedThreadStatus::Running;
815        self.store.upsert_thread(&metadata)?;
816        if let Some(thread) = self.running_threads.get_mut(thread_id) {
817            thread.updated_at = metadata.updated_at;
818            thread.preview = metadata.preview;
819            thread.status = ThreadStatus::Running;
820        }
821        let message_id = self.store.append_message(thread_id, "user", input, None)?;
822        self.store.save_checkpoint(
823            thread_id,
824            "latest",
825            &json!({
826                "reason": "thread_message",
827                "message_id": message_id,
828                "role": "user",
829                "preview": truncate_preview(input),
830                "updated_at": metadata.updated_at
831            }),
832        )?;
833        Ok(())
834    }
835
836    fn persist_thread(&self, thread: &Thread, rollout_path: Option<PathBuf>) -> Result<()> {
837        self.store.upsert_thread(&ThreadMetadata {
838            id: thread.id.clone(),
839            rollout_path,
840            preview: thread.preview.clone(),
841            ephemeral: thread.ephemeral,
842            model_provider: thread.model_provider.clone(),
843            created_at: thread.created_at,
844            updated_at: thread.updated_at,
845            status: to_persisted_status(&thread.status),
846            path: thread.path.clone(),
847            cwd: thread.cwd.clone(),
848            cli_version: thread.cli_version.clone(),
849            source: to_persisted_source(&thread.source),
850            name: thread.name.clone(),
851            sandbox_policy: None,
852            approval_mode: None,
853            archived: matches!(thread.status, ThreadStatus::Archived),
854            archived_at: None,
855            git_sha: None,
856            git_branch: None,
857            git_origin_url: None,
858            memory_mode: None,
859            current_leaf_id: None,
860        })
861    }
862}
863
864/// Top-level runtime combining config, model registry, threads, tools, MCP, and hooks.
865pub struct Runtime {
866    /// Resolved application configuration.
867    pub config: ConfigToml,
868    /// Registry of available model providers.
869    pub model_registry: ModelRegistry,
870    /// Manages conversation thread lifecycle.
871    pub thread_manager: ThreadManager,
872    /// Registry of callable tools.
873    pub tool_registry: Arc<ToolRegistry>,
874    /// Manager for MCP server connections.
875    pub mcp_manager: Arc<McpManager>,
876    /// Engine for evaluating execution policy decisions.
877    pub exec_policy: ExecPolicyEngine,
878    /// Dispatcher for lifecycle hooks.
879    pub hooks: HookDispatcher,
880    /// Manager for background job lifecycle.
881    pub jobs: JobManager,
882}
883
884impl Runtime {
885    /// Constructs a new `Runtime`, loading existing jobs from the state store.
886    pub fn new(
887        config: ConfigToml,
888        model_registry: ModelRegistry,
889        state: StateStore,
890        tool_registry: Arc<ToolRegistry>,
891        mcp_manager: Arc<McpManager>,
892        exec_policy: ExecPolicyEngine,
893        hooks: HookDispatcher,
894    ) -> Self {
895        let mut jobs = JobManager::default();
896        if let Err(e) = jobs.load_from_store(&state) {
897            tracing::warn!("Failed to load job store, starting with empty job list: {e}");
898        }
899        Self {
900            config,
901            model_registry,
902            thread_manager: ThreadManager::new(state),
903            tool_registry,
904            mcp_manager,
905            exec_policy,
906            hooks,
907            jobs,
908        }
909    }
910
911    /// Update the live configuration in-place so the next turn picks up
912    /// changes without a restart.  Called by the app-server after
913    /// `ConfigSet` or `ConfigUnset`.
914    ///
915    /// Only `config.toml` is touched by those operations, so the sibling
916    /// `permissions.toml` (and therefore `exec_policy`) is left unchanged.
917    ///
918    /// Fields that the TUI caches on its `App` struct (`api_provider`,
919    /// `reasoning_effort`, `mcp_config_path`, `skills_dir`, …) are read
920    /// live from `self.config` here via `resolve_runtime_options`, so they
921    /// take effect on the next prompt turn without any extra plumbing.
922    pub fn update_config(&mut self, config: ConfigToml) {
923        self.config = config;
924    }
925
926    /// Reload the live configuration **and** the exec policy from a
927    /// freshly-loaded `ConfigStore`.  Used by the app-server's
928    /// `ConfigReload` request, which re-reads both `config.toml` and the
929    /// sibling `permissions.toml` from disk.
930    ///
931    /// Unlike `update_config`, this also refreshes `self.exec_policy` so
932    /// externally edited permission rules take effect without a restart.
933    ///
934    /// Mirrors the TUI `reload_runtime_config` codepath for everything
935    /// that is reachable from the headless `Runtime`. The TUI-only caches
936    /// (`last_effective_reasoning_effort`, `model_compaction_budget`,
937    /// `ui_locale`, …) do not exist on `Runtime` and need no work here.
938    ///
939    /// **Not** refreshed by this call:
940    /// * `mcp_manager` — MCP server connections are loaded once at
941    ///   startup from `mcp_config_path`. Changing `mcp_config_path` or the
942    ///   referenced `mcp.json` still requires a headless-runtime restart;
943    ///   the TUI owns a separate explicit `/mcp reload` operation.
944    /// * `tool_registry` — built once at startup.
945    /// * `model_registry` — static catalog.
946    pub fn reload_config_and_policy(&mut self, config: ConfigToml, exec_policy: ExecPolicyEngine) {
947        self.config = config;
948        self.exec_policy = exec_policy;
949    }
950
951    fn persisted_thread_data(&self, thread_id: &str) -> Result<Value> {
952        let history = self
953            .thread_manager
954            .state_store()
955            .list_messages(thread_id, Some(500))?
956            .into_iter()
957            .map(|message| {
958                json!({
959                    "id": message.id,
960                    "role": message.role,
961                    "content": message.content,
962                    "item": message.item,
963                    "created_at": message.created_at
964                })
965            })
966            .collect::<Vec<_>>();
967
968        let checkpoint = self
969            .thread_manager
970            .state_store()
971            .load_checkpoint(thread_id, None)?
972            .map(|record| {
973                json!({
974                    "checkpoint_id": record.checkpoint_id,
975                    "state": record.state,
976                    "created_at": record.created_at
977                })
978            });
979
980        let goal = self
981            .thread_manager
982            .state_store()
983            .get_thread_goal(thread_id)?
984            .map(to_protocol_goal);
985
986        Ok(json!({
987            "history": history,
988            "checkpoint": checkpoint,
989            "goal": goal
990        }))
991    }
992
993    fn persist_latest_checkpoint(&self, thread_id: &str, reason: &str, state: Value) -> Result<()> {
994        self.thread_manager.state_store().save_checkpoint(
995            thread_id,
996            "latest",
997            &json!({
998                "reason": reason,
999                "saved_at": chrono::Utc::now().timestamp(),
1000                "state": state
1001            }),
1002        )
1003    }
1004
1005    /// Dispatches a thread request (create, start, resume, fork, list, read, etc.).
1006    pub async fn handle_thread(&mut self, req: ThreadRequest) -> Result<ThreadResponse> {
1007        match req {
1008            ThreadRequest::Create { .. } => {
1009                let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1010                let new = self.thread_manager.spawn_thread_with_history(
1011                    "deepseek".to_string(),
1012                    cwd,
1013                    InitialHistory::New,
1014                    false,
1015                )?;
1016                let mut response = thread_response_from_new("created", new);
1017                response.data = self.persisted_thread_data(&response.thread_id)?;
1018                Ok(response)
1019            }
1020            ThreadRequest::Start(params) => {
1021                let cwd = params.cwd.clone().unwrap_or_else(|| {
1022                    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1023                });
1024                let new = self.thread_manager.spawn_thread_with_history(
1025                    params
1026                        .model_provider
1027                        .clone()
1028                        .unwrap_or_else(|| "deepseek".to_string()),
1029                    cwd,
1030                    InitialHistory::New,
1031                    params.persist_extended_history,
1032                )?;
1033                let mut response = thread_response_from_new("started", new);
1034                response.data = self.persisted_thread_data(&response.thread_id)?;
1035                Ok(response)
1036            }
1037            ThreadRequest::Resume(params) => {
1038                let fallback_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1039                if let Some(new) = self.thread_manager.resume_thread_with_history(
1040                    &params,
1041                    &fallback_cwd,
1042                    "deepseek".to_string(),
1043                )? {
1044                    let mut response = thread_response_from_new("resumed", new);
1045                    response.data = self.persisted_thread_data(&response.thread_id)?;
1046                    Ok(response)
1047                } else {
1048                    Ok(ThreadResponse {
1049                        thread_id: params.thread_id,
1050                        status: "missing".to_string(),
1051                        thread: None,
1052                        threads: Vec::new(),
1053                        goal: None,
1054                        model: None,
1055                        model_provider: None,
1056                        cwd: None,
1057                        approval_policy: params.approval_policy,
1058                        sandbox: params.sandbox,
1059                        events: Vec::new(),
1060                        data: json!({"error":"thread not found"}),
1061                    })
1062                }
1063            }
1064            ThreadRequest::Fork(params) => {
1065                let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1066                if let Some(new) = self.thread_manager.fork_thread(&params, &cwd)? {
1067                    let mut response = thread_response_from_new("forked", new);
1068                    response.data = self.persisted_thread_data(&response.thread_id)?;
1069                    Ok(response)
1070                } else {
1071                    Ok(ThreadResponse {
1072                        thread_id: params.thread_id,
1073                        status: "missing".to_string(),
1074                        thread: None,
1075                        threads: Vec::new(),
1076                        goal: None,
1077                        model: None,
1078                        model_provider: None,
1079                        cwd: None,
1080                        approval_policy: params.approval_policy,
1081                        sandbox: params.sandbox,
1082                        events: Vec::new(),
1083                        data: json!({"error":"thread not found"}),
1084                    })
1085                }
1086            }
1087            ThreadRequest::List(params) => Ok(ThreadResponse {
1088                thread_id: "list".to_string(),
1089                status: "ok".to_string(),
1090                thread: None,
1091                threads: self.thread_manager.list_threads(&params)?,
1092                goal: None,
1093                model: None,
1094                model_provider: None,
1095                cwd: None,
1096                approval_policy: None,
1097                sandbox: None,
1098                events: Vec::new(),
1099                data: json!({}),
1100            }),
1101            ThreadRequest::Read(params) => {
1102                let id = params.thread_id.clone();
1103                let data = self.persisted_thread_data(&id)?;
1104                Ok(ThreadResponse {
1105                    thread_id: id,
1106                    status: "ok".to_string(),
1107                    thread: self.thread_manager.read_thread(&params)?,
1108                    threads: Vec::new(),
1109                    goal: self.thread_manager.get_thread_goal(&ThreadGoalGetParams {
1110                        thread_id: params.thread_id,
1111                    })?,
1112                    model: None,
1113                    model_provider: None,
1114                    cwd: None,
1115                    approval_policy: None,
1116                    sandbox: None,
1117                    events: Vec::new(),
1118                    data,
1119                })
1120            }
1121            ThreadRequest::SetName(params) => Ok(ThreadResponse {
1122                thread_id: params.thread_id.clone(),
1123                status: "ok".to_string(),
1124                thread: self.thread_manager.set_thread_name(&params)?,
1125                threads: Vec::new(),
1126                goal: None,
1127                model: None,
1128                model_provider: None,
1129                cwd: None,
1130                approval_policy: None,
1131                sandbox: None,
1132                events: Vec::new(),
1133                data: json!({}),
1134            }),
1135            ThreadRequest::GoalSet(params) => {
1136                let thread_id = params.thread_id.clone();
1137                if let Some(goal) = self.thread_manager.set_thread_goal(&params)? {
1138                    Ok(ThreadResponse {
1139                        thread_id,
1140                        status: "ok".to_string(),
1141                        thread: None,
1142                        threads: Vec::new(),
1143                        goal: Some(goal.clone()),
1144                        model: None,
1145                        model_provider: None,
1146                        cwd: None,
1147                        approval_policy: None,
1148                        sandbox: None,
1149                        events: vec![EventFrame::ThreadGoalUpdated { goal: goal.clone() }],
1150                        data: json!({ "goal": goal }),
1151                    })
1152                } else {
1153                    Ok(ThreadResponse {
1154                        thread_id,
1155                        status: "missing".to_string(),
1156                        thread: None,
1157                        threads: Vec::new(),
1158                        goal: None,
1159                        model: None,
1160                        model_provider: None,
1161                        cwd: None,
1162                        approval_policy: None,
1163                        sandbox: None,
1164                        events: Vec::new(),
1165                        data: json!({"error":"thread not found"}),
1166                    })
1167                }
1168            }
1169            ThreadRequest::GoalGet(params) => {
1170                let goal = self.thread_manager.get_thread_goal(&params)?;
1171                Ok(ThreadResponse {
1172                    thread_id: params.thread_id,
1173                    status: "ok".to_string(),
1174                    thread: None,
1175                    threads: Vec::new(),
1176                    goal: goal.clone(),
1177                    model: None,
1178                    model_provider: None,
1179                    cwd: None,
1180                    approval_policy: None,
1181                    sandbox: None,
1182                    events: Vec::new(),
1183                    data: json!({ "goal": goal }),
1184                })
1185            }
1186            ThreadRequest::GoalClear(params) => {
1187                let thread_id = params.thread_id.clone();
1188                let cleared = self.thread_manager.clear_thread_goal(&params)?;
1189                Ok(ThreadResponse {
1190                    thread_id: thread_id.clone(),
1191                    status: if cleared { "cleared" } else { "empty" }.to_string(),
1192                    thread: None,
1193                    threads: Vec::new(),
1194                    goal: None,
1195                    model: None,
1196                    model_provider: None,
1197                    cwd: None,
1198                    approval_policy: None,
1199                    sandbox: None,
1200                    events: if cleared {
1201                        vec![EventFrame::ThreadGoalCleared { thread_id }]
1202                    } else {
1203                        Vec::new()
1204                    },
1205                    data: json!({ "cleared": cleared }),
1206                })
1207            }
1208            ThreadRequest::GoalRecordProgress(params) => {
1209                let thread_id = params.thread_id.clone();
1210                if let Some(goal) = self.thread_manager.record_thread_goal_progress(&params)? {
1211                    Ok(ThreadResponse {
1212                        thread_id,
1213                        status: "ok".to_string(),
1214                        thread: None,
1215                        threads: Vec::new(),
1216                        goal: Some(goal.clone()),
1217                        model: None,
1218                        model_provider: None,
1219                        cwd: None,
1220                        approval_policy: None,
1221                        sandbox: None,
1222                        events: vec![EventFrame::ThreadGoalUpdated { goal: goal.clone() }],
1223                        data: json!({ "goal": goal }),
1224                    })
1225                } else {
1226                    Ok(ThreadResponse {
1227                        thread_id,
1228                        status: "missing".to_string(),
1229                        thread: None,
1230                        threads: Vec::new(),
1231                        goal: None,
1232                        model: None,
1233                        model_provider: None,
1234                        cwd: None,
1235                        approval_policy: None,
1236                        sandbox: None,
1237                        events: Vec::new(),
1238                        data: json!({"error":"thread or goal not found"}),
1239                    })
1240                }
1241            }
1242            ThreadRequest::Archive { thread_id } => {
1243                self.thread_manager.archive_thread(&thread_id)?;
1244                Ok(ThreadResponse {
1245                    thread_id,
1246                    status: "archived".to_string(),
1247                    thread: None,
1248                    threads: Vec::new(),
1249                    goal: None,
1250                    model: None,
1251                    model_provider: None,
1252                    cwd: None,
1253                    approval_policy: None,
1254                    sandbox: None,
1255                    events: Vec::new(),
1256                    data: json!({}),
1257                })
1258            }
1259            ThreadRequest::Unarchive { thread_id } => {
1260                self.thread_manager.unarchive_thread(&thread_id)?;
1261                Ok(ThreadResponse {
1262                    thread_id,
1263                    status: "unarchived".to_string(),
1264                    thread: None,
1265                    threads: Vec::new(),
1266                    goal: None,
1267                    model: None,
1268                    model_provider: None,
1269                    cwd: None,
1270                    approval_policy: None,
1271                    sandbox: None,
1272                    events: Vec::new(),
1273                    data: json!({}),
1274                })
1275            }
1276            ThreadRequest::Message { thread_id, input } => {
1277                self.thread_manager.touch_message(&thread_id, &input)?;
1278                let response_id = format!("{thread_id}:{}", input.len());
1279                self.hooks
1280                    .emit(HookEvent::ResponseStart {
1281                        response_id: response_id.clone(),
1282                    })
1283                    .await;
1284                self.hooks
1285                    .emit(HookEvent::ResponseEnd {
1286                        response_id: response_id.clone(),
1287                    })
1288                    .await;
1289
1290                Ok(ThreadResponse {
1291                    thread_id,
1292                    status: "accepted".to_string(),
1293                    thread: None,
1294                    threads: Vec::new(),
1295                    goal: None,
1296                    model: None,
1297                    model_provider: None,
1298                    cwd: None,
1299                    approval_policy: None,
1300                    sandbox: None,
1301                    events: vec![
1302                        EventFrame::ResponseStart {
1303                            response_id: response_id.clone(),
1304                        },
1305                        EventFrame::ResponseDelta {
1306                            response_id: response_id.clone(),
1307                            delta: "queued".to_string(),
1308                            channel: ResponseChannel::Text,
1309                        },
1310                        EventFrame::ResponseEnd { response_id },
1311                    ],
1312                    data: json!({}),
1313                })
1314            }
1315        }
1316    }
1317
1318    /// Resolves the model for a prompt, records the message, and returns the response.
1319    pub async fn handle_prompt(
1320        &mut self,
1321        req: PromptRequest,
1322        cli_overrides: &CliRuntimeOverrides,
1323    ) -> Result<PromptResponse> {
1324        let resolved = self.config.resolve_runtime_options(cli_overrides);
1325        let requested_model = req.model.clone().unwrap_or_else(|| resolved.model.clone());
1326        let selection = self
1327            .model_registry
1328            .resolve(Some(&requested_model), Some(resolved.provider));
1329        let resolved_model = selection.resolved.id.clone();
1330        let response_id = format!("resp-{}", Uuid::new_v4());
1331
1332        self.hooks
1333            .emit(HookEvent::ResponseStart {
1334                response_id: response_id.clone(),
1335            })
1336            .await;
1337        self.hooks
1338            .emit(HookEvent::ResponseDelta {
1339                response_id: response_id.clone(),
1340                delta: "model-selected".to_string(),
1341            })
1342            .await;
1343        self.hooks
1344            .emit(HookEvent::ResponseEnd {
1345                response_id: response_id.clone(),
1346            })
1347            .await;
1348
1349        let payload = json!({
1350            "provider": resolved.provider.as_str(),
1351            "model": resolved_model.clone(),
1352            "prompt": req.prompt,
1353            "telemetry": resolved.telemetry,
1354            "base_url": resolved.base_url,
1355            "has_api_key": resolved.api_key.as_ref().is_some_and(|k| !k.trim().is_empty()),
1356            "approval_policy": resolved.approval_policy,
1357            "sandbox_mode": resolved.sandbox_mode
1358        });
1359        if let Some(thread_id) = req.thread_id.as_ref() {
1360            self.thread_manager.touch_message(thread_id, &req.prompt)?;
1361            let assistant_message_id = self.thread_manager.store.append_message(
1362                thread_id,
1363                "assistant",
1364                &payload.to_string(),
1365                Some(payload.clone()),
1366            )?;
1367            self.persist_latest_checkpoint(
1368                thread_id,
1369                "prompt_response",
1370                json!({
1371                    "response_id": response_id.clone(),
1372                    "model": resolved_model.clone(),
1373                    "provider": resolved.provider.as_str(),
1374                    "assistant_message_id": assistant_message_id
1375                }),
1376            )?;
1377        }
1378
1379        Ok(PromptResponse {
1380            output: payload.to_string(),
1381            model: resolved_model,
1382            events: vec![
1383                EventFrame::ResponseStart {
1384                    response_id: response_id.clone(),
1385                },
1386                EventFrame::ResponseDelta {
1387                    response_id: response_id.clone(),
1388                    delta: "model-selected".to_string(),
1389                    channel: ResponseChannel::Text,
1390                },
1391                EventFrame::ResponseEnd { response_id },
1392            ],
1393        })
1394    }
1395
1396    /// Evaluates execution policy and dispatches a tool call.
1397    pub async fn invoke_tool(
1398        &self,
1399        call: ToolCall,
1400        approval_mode: AskForApproval,
1401        cwd: &Path,
1402    ) -> Result<Value> {
1403        let fallback_cwd = cwd.display().to_string();
1404        let (command, policy_cwd, execution_kind) = call.execution_subject(&fallback_cwd);
1405        let policy_tool = match &call.payload {
1406            ToolPayload::LocalShell { .. } => "exec_shell",
1407            _ => call.name.as_str(),
1408        };
1409        let policy_path = permission_path_for_call(&call);
1410        let decision = self.exec_policy.check(ExecPolicyContext {
1411            command: &command,
1412            cwd: &policy_cwd,
1413            tool: Some(policy_tool),
1414            path: policy_path.as_deref(),
1415            ask_for_approval: approval_mode,
1416            sandbox_mode: None,
1417        })?;
1418        let precheck = policy_precheck_payload(&decision, &command, &policy_cwd, execution_kind);
1419        let response_id = format!("tool-{}", Uuid::new_v4());
1420        let call_id = call
1421            .raw_tool_call_id
1422            .clone()
1423            .unwrap_or_else(|| format!("tool-call-{}", Uuid::new_v4()));
1424        self.hooks
1425            .emit(HookEvent::ToolLifecycle {
1426                response_id: response_id.clone(),
1427                tool_name: call.name.clone(),
1428                phase: "precheck".to_string(),
1429                payload: precheck.clone(),
1430            })
1431            .await;
1432
1433        if !decision.allow {
1434            let reason = decision.reason().to_string();
1435            let approval_id = format!("approval-{}", Uuid::new_v4());
1436            let error_frame = EventFrame::Error {
1437                response_id: response_id.clone(),
1438                message: reason.clone(),
1439            };
1440            self.hooks
1441                .emit(HookEvent::ApprovalLifecycle {
1442                    approval_id,
1443                    phase: "denied".to_string(),
1444                    reason: Some(reason.clone()),
1445                })
1446                .await;
1447            self.hooks
1448                .emit(HookEvent::GenericEventFrame {
1449                    frame: Box::new(error_frame.clone()),
1450                })
1451                .await;
1452            return Ok(json!({
1453                "ok": false,
1454                "status": "denied",
1455                "execution_kind": execution_kind,
1456                "response_id": response_id,
1457                "precheck": precheck,
1458                "error": reason,
1459                "events": [event_frame_payload(&error_frame)],
1460            }));
1461        }
1462
1463        if decision.requires_approval {
1464            let approval_id = format!("approval-{}", Uuid::new_v4());
1465            let reason = decision.reason().to_string();
1466            let maybe_approval_frame = approval_request_frame(
1467                &decision.requirement,
1468                decision.matched_rule.as_deref(),
1469                call_id,
1470                approval_id.clone(),
1471                response_id.clone(),
1472                command.clone(),
1473                policy_cwd.clone(),
1474            );
1475            self.hooks
1476                .emit(HookEvent::ApprovalLifecycle {
1477                    approval_id: approval_id.clone(),
1478                    phase: "requested".to_string(),
1479                    reason: Some(reason.clone()),
1480                })
1481                .await;
1482            let mut events = Vec::new();
1483            if let Some(frame) = maybe_approval_frame {
1484                self.hooks
1485                    .emit(HookEvent::GenericEventFrame {
1486                        frame: Box::new(frame.clone()),
1487                    })
1488                    .await;
1489                events.push(event_frame_payload(&frame));
1490            }
1491            return Ok(json!({
1492                "ok": false,
1493                "status": "approval_required",
1494                "execution_kind": execution_kind,
1495                "response_id": response_id,
1496                "approval_id": approval_id,
1497                "precheck": precheck,
1498                "error": reason,
1499                "events": events,
1500            }));
1501        }
1502
1503        // Headless `request_user_input`: mirror the approval fire-and-return
1504        // branch (issue #3102). The TUI intercepts this tool by name before
1505        // dispatch and blocks on a reply channel; the headless runtime instead
1506        // emits a typed `UserInputRequest` frame and returns a
1507        // `user_input_required` status so the client can render the question
1508        // and POST answers back via `AppRequest::SubmitUserInput`. It does NOT
1509        // block — consistent with the headless approval model, which has no
1510        // resume channel either.
1511        if call.name == REQUEST_USER_INPUT_TOOL_NAME {
1512            let request_id = format!("user-input-{}", Uuid::new_v4());
1513            let arguments = match &call.payload {
1514                ToolPayload::Function { arguments } => arguments.as_str(),
1515                // Custom/Mcp/LocalShell can't carry a user_input payload; fall
1516                // through to the generic dispatch error below.
1517                _ => "",
1518            };
1519            let maybe_frame = user_input_request_frame(
1520                call_id.clone(),
1521                response_id.clone(),
1522                request_id.clone(),
1523                arguments,
1524            );
1525            let mut events = Vec::new();
1526            if let Some(frame) = maybe_frame {
1527                self.hooks
1528                    .emit(HookEvent::GenericEventFrame {
1529                        frame: Box::new(frame.clone()),
1530                    })
1531                    .await;
1532                events.push(event_frame_payload(&frame));
1533            }
1534            return Ok(json!({
1535                "ok": false,
1536                "status": "user_input_required",
1537                "execution_kind": execution_kind,
1538                "response_id": response_id,
1539                "request_id": request_id,
1540                "precheck": precheck,
1541                "events": events,
1542            }));
1543        }
1544
1545        let start_frame = EventFrame::ToolCallStart {
1546            response_id: response_id.clone(),
1547            tool_name: call.name.clone(),
1548            arguments: tool_payload_value(&call.payload),
1549        };
1550        self.hooks
1551            .emit(HookEvent::GenericEventFrame {
1552                frame: Box::new(start_frame.clone()),
1553            })
1554            .await;
1555        self.hooks
1556            .emit(HookEvent::ToolLifecycle {
1557                response_id: response_id.clone(),
1558                tool_name: call.name.clone(),
1559                phase: "dispatching".to_string(),
1560                payload: json!({
1561                    "call_id": call_id,
1562                    "execution_kind": execution_kind
1563                }),
1564            })
1565            .await;
1566
1567        match time::timeout(
1568            tool_dispatch_timeout(),
1569            self.tool_registry.dispatch(call.clone(), true),
1570        )
1571        .await
1572        {
1573            Ok(Ok(tool_output)) => {
1574                let result_frame = EventFrame::ToolCallResult {
1575                    response_id: response_id.clone(),
1576                    tool_name: call.name.clone(),
1577                    output: tool_output_value(&tool_output),
1578                };
1579                self.hooks
1580                    .emit(HookEvent::GenericEventFrame {
1581                        frame: Box::new(result_frame.clone()),
1582                    })
1583                    .await;
1584                self.hooks
1585                    .emit(HookEvent::ToolLifecycle {
1586                        response_id: response_id.clone(),
1587                        tool_name: call.name,
1588                        phase: "completed".to_string(),
1589                        payload: json!({ "ok": true }),
1590                    })
1591                    .await;
1592                Ok(json!({
1593                    "ok": true,
1594                    "status": "completed",
1595                    "execution_kind": execution_kind,
1596                    "response_id": response_id,
1597                    "precheck": precheck,
1598                    "output": tool_output,
1599                    "events": [
1600                        event_frame_payload(&start_frame),
1601                        event_frame_payload(&result_frame)
1602                    ]
1603                }))
1604            }
1605            Ok(Err(err)) => {
1606                let message = format!("{err:?}");
1607                let error_frame = EventFrame::Error {
1608                    response_id: response_id.clone(),
1609                    message: message.clone(),
1610                };
1611                self.hooks
1612                    .emit(HookEvent::GenericEventFrame {
1613                        frame: Box::new(error_frame.clone()),
1614                    })
1615                    .await;
1616                self.hooks
1617                    .emit(HookEvent::ToolLifecycle {
1618                        response_id: response_id.clone(),
1619                        tool_name: call.name,
1620                        phase: "failed".to_string(),
1621                        payload: json!({ "error": message.clone() }),
1622                    })
1623                    .await;
1624                Ok(json!({
1625                    "ok": false,
1626                    "status": "failed",
1627                    "execution_kind": execution_kind,
1628                    "response_id": response_id,
1629                    "precheck": precheck,
1630                    "error": message,
1631                    "events": [
1632                        event_frame_payload(&start_frame),
1633                        event_frame_payload(&error_frame)
1634                    ]
1635                }))
1636            }
1637            Err(_elapsed) => {
1638                let seconds = tool_dispatch_timeout().as_secs().max(1);
1639                let message = format!("Tool '{}' timed out after {seconds}s", call.name);
1640                let error_frame = EventFrame::Error {
1641                    response_id: response_id.clone(),
1642                    message: message.clone(),
1643                };
1644                self.hooks
1645                    .emit(HookEvent::GenericEventFrame {
1646                        frame: Box::new(error_frame.clone()),
1647                    })
1648                    .await;
1649                self.hooks
1650                    .emit(HookEvent::ToolLifecycle {
1651                        response_id: response_id.clone(),
1652                        tool_name: call.name,
1653                        phase: "failed".to_string(),
1654                        payload: json!({ "error": message.clone(), "timeout": true }),
1655                    })
1656                    .await;
1657                Ok(json!({
1658                    "ok": false,
1659                    "status": "timeout",
1660                    "execution_kind": execution_kind,
1661                    "response_id": response_id,
1662                    "precheck": precheck,
1663                    "error": message,
1664                    "events": [
1665                        event_frame_payload(&start_frame),
1666                        event_frame_payload(&error_frame)
1667                    ]
1668                }))
1669            }
1670        }
1671    }
1672
1673    /// Starts all configured MCP servers and emits startup events via hooks.
1674    pub async fn mcp_startup(&self) -> McpStartupCompleteEvent {
1675        let mut updates = Vec::new();
1676        let summary = self.mcp_manager.start_all(|update| {
1677            updates.push(update);
1678        });
1679        for update in updates {
1680            let status = match update.status {
1681                McpManagerStartupStatus::Starting => codewhale_protocol::McpStartupStatus::Starting,
1682                McpManagerStartupStatus::Ready => codewhale_protocol::McpStartupStatus::Ready,
1683                McpManagerStartupStatus::Failed { error } => {
1684                    codewhale_protocol::McpStartupStatus::Failed { error }
1685                }
1686                McpManagerStartupStatus::Cancelled => {
1687                    codewhale_protocol::McpStartupStatus::Cancelled
1688                }
1689            };
1690            self.hooks
1691                .emit(HookEvent::GenericEventFrame {
1692                    frame: Box::new(EventFrame::McpStartupUpdate {
1693                        update: codewhale_protocol::McpStartupUpdateEvent {
1694                            server_name: update.server_name,
1695                            status,
1696                        },
1697                    }),
1698                })
1699                .await;
1700        }
1701        self.hooks
1702            .emit(HookEvent::GenericEventFrame {
1703                frame: Box::new(EventFrame::McpStartupComplete {
1704                    summary: codewhale_protocol::McpStartupCompleteEvent {
1705                        ready: summary.ready.clone(),
1706                        failed: summary
1707                            .failed
1708                            .iter()
1709                            .map(|f| codewhale_protocol::McpStartupFailure {
1710                                server_name: f.server_name.clone(),
1711                                error: f.error.clone(),
1712                            })
1713                            .collect(),
1714                        cancelled: summary.cancelled.clone(),
1715                    },
1716                }),
1717            })
1718            .await;
1719        summary
1720    }
1721
1722    /// Returns the current application status including all jobs and their history.
1723    pub fn app_status(&self) -> AppResponse {
1724        let jobs = self.jobs.list();
1725        let events = jobs
1726            .iter()
1727            .flat_map(|job| {
1728                job.history.iter().map(|entry| EventFrame::ResponseDelta {
1729                    response_id: job.id.clone(),
1730                    delta: json!({
1731                        "kind": "job_transition",
1732                        "job_id": job.id.clone(),
1733                        "phase": entry.phase.clone(),
1734                        "status": job_status_to_str(entry.status),
1735                        "progress": entry.progress,
1736                        "detail": entry.detail.clone(),
1737                        "retry": job_retry_to_value(&entry.retry),
1738                        "at": entry.at
1739                    })
1740                    .to_string(),
1741                    channel: ResponseChannel::Text,
1742                })
1743            })
1744            .collect::<Vec<_>>();
1745        AppResponse {
1746            ok: true,
1747            data: json!({
1748                "jobs": jobs.into_iter().map(|job| {
1749                    json!({
1750                        "id": job.id,
1751                        "name": job.name,
1752                        "status": job_status_to_str(job.status),
1753                        "progress": job.progress,
1754                        "detail": job.detail,
1755                        "retry": job_retry_to_value(&job.retry),
1756                        "history": job.history.iter().map(job_history_to_value).collect::<Vec<_>>()
1757                    })
1758                }).collect::<Vec<_>>()
1759            }),
1760            events,
1761        }
1762    }
1763
1764    /// Returns the default model provider from the resolved configuration.
1765    pub fn provider_default(&self) -> ProviderKind {
1766        self.config.provider
1767    }
1768
1769    /// Saves a named checkpoint for a thread.
1770    pub fn save_thread_checkpoint(
1771        &self,
1772        thread_id: &str,
1773        checkpoint_id: &str,
1774        state: &Value,
1775    ) -> Result<()> {
1776        self.thread_manager
1777            .state_store()
1778            .save_checkpoint(thread_id, checkpoint_id, state)
1779    }
1780
1781    /// Loads a checkpoint for a thread. Pass `None` for the latest.
1782    pub fn load_thread_checkpoint(
1783        &self,
1784        thread_id: &str,
1785        checkpoint_id: Option<&str>,
1786    ) -> Result<Option<Value>> {
1787        Ok(self
1788            .thread_manager
1789            .state_store()
1790            .load_checkpoint(thread_id, checkpoint_id)?
1791            .map(|checkpoint| checkpoint.state))
1792    }
1793
1794    /// Enqueues a new background job and persists it immediately.
1795    pub fn enqueue_job(&mut self, name: impl Into<String>) -> Result<JobRecord> {
1796        let job = self.jobs.enqueue(name);
1797        self.jobs
1798            .persist_job(self.thread_manager.state_store(), &job.id)?;
1799        Ok(job)
1800    }
1801
1802    /// Transitions a job to running and persists the change.
1803    pub fn set_job_running(&mut self, job_id: &str) -> Result<()> {
1804        self.jobs.set_running(job_id);
1805        self.jobs
1806            .persist_job(self.thread_manager.state_store(), job_id)
1807    }
1808
1809    /// Updates a job's progress and persists the change.
1810    pub fn update_job_progress(
1811        &mut self,
1812        job_id: &str,
1813        progress: u8,
1814        detail: Option<String>,
1815    ) -> Result<()> {
1816        self.jobs.update_progress(job_id, progress, detail);
1817        self.jobs
1818            .persist_job(self.thread_manager.state_store(), job_id)
1819    }
1820
1821    /// Marks a job as completed and persists the change.
1822    pub fn complete_job(&mut self, job_id: &str) -> Result<()> {
1823        self.jobs.complete(job_id);
1824        self.jobs
1825            .persist_job(self.thread_manager.state_store(), job_id)
1826    }
1827
1828    /// Marks a job as failed and persists the change.
1829    pub fn fail_job(&mut self, job_id: &str, detail: impl Into<String>) -> Result<()> {
1830        self.jobs.fail(job_id, detail);
1831        self.jobs
1832            .persist_job(self.thread_manager.state_store(), job_id)
1833    }
1834
1835    /// Cancels a job and persists the change.
1836    pub fn cancel_job(&mut self, job_id: &str) -> Result<()> {
1837        self.jobs.cancel(job_id);
1838        self.jobs
1839            .persist_job(self.thread_manager.state_store(), job_id)
1840    }
1841
1842    /// Pauses a job and persists the change.
1843    pub fn pause_job(&mut self, job_id: &str, detail: Option<String>) -> Result<()> {
1844        self.jobs.pause(job_id, detail);
1845        self.jobs
1846            .persist_job(self.thread_manager.state_store(), job_id)
1847    }
1848
1849    /// Resumes a paused job and persists the change.
1850    pub fn resume_job(&mut self, job_id: &str, detail: Option<String>) -> Result<()> {
1851        self.jobs.resume(job_id, detail);
1852        self.jobs
1853            .persist_job(self.thread_manager.state_store(), job_id)
1854    }
1855
1856    /// Returns the state-transition history for a job.
1857    pub fn job_history(&self, job_id: &str) -> Vec<JobHistoryEntry> {
1858        self.jobs.history(job_id)
1859    }
1860}
1861
1862fn thread_response_from_new(status: &str, new: NewThread) -> ThreadResponse {
1863    ThreadResponse {
1864        thread_id: new.thread.id.clone(),
1865        status: status.to_string(),
1866        thread: Some(new.thread),
1867        threads: Vec::new(),
1868        goal: None,
1869        model: Some(new.model),
1870        model_provider: Some(new.model_provider),
1871        cwd: Some(new.cwd),
1872        approval_policy: new.approval_policy,
1873        sandbox: new.sandbox,
1874        events: Vec::new(),
1875        data: json!({}),
1876    }
1877}
1878
1879fn preview_from_initial_history(initial_history: &InitialHistory) -> String {
1880    match initial_history {
1881        InitialHistory::New => "New conversation".to_string(),
1882        InitialHistory::Forked(items) => truncate_preview(
1883            &items
1884                .first()
1885                .map(Value::to_string)
1886                .unwrap_or_else(|| "Forked conversation".to_string()),
1887        ),
1888        InitialHistory::Resumed { history, .. } => truncate_preview(
1889            &history
1890                .first()
1891                .map(Value::to_string)
1892                .unwrap_or_else(|| "Resumed conversation".to_string()),
1893        ),
1894    }
1895}
1896
1897fn permission_path_for_call(call: &ToolCall) -> Option<String> {
1898    match &call.payload {
1899        ToolPayload::Function { arguments } => serde_json::from_str::<Value>(arguments)
1900            .ok()
1901            .and_then(|value| {
1902                value
1903                    .get("path")
1904                    .and_then(Value::as_str)
1905                    .map(str::to_string)
1906            }),
1907        ToolPayload::Mcp { raw_arguments, .. } => raw_arguments
1908            .get("path")
1909            .and_then(Value::as_str)
1910            .map(str::to_string),
1911        ToolPayload::Custom { .. } | ToolPayload::LocalShell { .. } => None,
1912    }
1913}
1914
1915fn truncate_preview(value: &str) -> String {
1916    value.chars().take(120).collect()
1917}
1918
1919fn to_protocol_thread(thread: ThreadMetadata) -> Thread {
1920    Thread {
1921        id: thread.id,
1922        preview: thread.preview,
1923        ephemeral: thread.ephemeral,
1924        model_provider: thread.model_provider,
1925        created_at: thread.created_at,
1926        updated_at: thread.updated_at,
1927        status: match thread.status {
1928            PersistedThreadStatus::Running => ThreadStatus::Running,
1929            PersistedThreadStatus::Idle => ThreadStatus::Idle,
1930            PersistedThreadStatus::Completed => ThreadStatus::Completed,
1931            PersistedThreadStatus::Failed => ThreadStatus::Failed,
1932            PersistedThreadStatus::Paused => ThreadStatus::Paused,
1933            PersistedThreadStatus::Archived => ThreadStatus::Archived,
1934        },
1935        path: thread.path,
1936        cwd: thread.cwd,
1937        cli_version: thread.cli_version,
1938        source: match thread.source {
1939            SessionSource::Interactive => codewhale_protocol::SessionSource::Interactive,
1940            SessionSource::Resume => codewhale_protocol::SessionSource::Resume,
1941            SessionSource::Fork => codewhale_protocol::SessionSource::Fork,
1942            SessionSource::Api => codewhale_protocol::SessionSource::Api,
1943            SessionSource::Unknown => codewhale_protocol::SessionSource::Unknown,
1944        },
1945        name: thread.name,
1946    }
1947}
1948
1949fn to_protocol_goal(goal: ThreadGoalRecord) -> ThreadGoal {
1950    ThreadGoal {
1951        thread_id: goal.thread_id,
1952        goal_id: goal.goal_id,
1953        objective: goal.objective,
1954        status: to_protocol_goal_status(goal.status),
1955        token_budget: goal.token_budget,
1956        tokens_used: goal.tokens_used,
1957        time_used_seconds: goal.time_used_seconds,
1958        continuation_count: goal.continuation_count,
1959        created_at: goal.created_at,
1960        updated_at: goal.updated_at,
1961    }
1962}
1963
1964fn to_protocol_goal_status(status: PersistedThreadGoalStatus) -> ThreadGoalStatus {
1965    match status {
1966        PersistedThreadGoalStatus::Active => ThreadGoalStatus::Active,
1967        PersistedThreadGoalStatus::Paused => ThreadGoalStatus::Paused,
1968        PersistedThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked,
1969        PersistedThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited,
1970        PersistedThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited,
1971        PersistedThreadGoalStatus::Complete => ThreadGoalStatus::Complete,
1972    }
1973}
1974
1975fn to_persisted_status(status: &ThreadStatus) -> PersistedThreadStatus {
1976    match status {
1977        ThreadStatus::Running => PersistedThreadStatus::Running,
1978        ThreadStatus::Idle => PersistedThreadStatus::Idle,
1979        ThreadStatus::Completed => PersistedThreadStatus::Completed,
1980        ThreadStatus::Failed => PersistedThreadStatus::Failed,
1981        ThreadStatus::Paused => PersistedThreadStatus::Paused,
1982        ThreadStatus::Archived => PersistedThreadStatus::Archived,
1983    }
1984}
1985
1986fn to_persisted_source(source: &codewhale_protocol::SessionSource) -> SessionSource {
1987    match source {
1988        codewhale_protocol::SessionSource::Interactive => SessionSource::Interactive,
1989        codewhale_protocol::SessionSource::Resume => SessionSource::Resume,
1990        codewhale_protocol::SessionSource::Fork => SessionSource::Fork,
1991        codewhale_protocol::SessionSource::Api => SessionSource::Api,
1992        codewhale_protocol::SessionSource::Unknown => SessionSource::Unknown,
1993    }
1994}
1995
1996fn approval_request_frame(
1997    requirement: &ExecApprovalRequirement,
1998    matched_rule: Option<&str>,
1999    call_id: String,
2000    approval_id: String,
2001    turn_id: String,
2002    command: String,
2003    cwd: String,
2004) -> Option<EventFrame> {
2005    let ExecApprovalRequirement::NeedsApproval {
2006        reason,
2007        proposed_execpolicy_amendment,
2008        proposed_network_policy_amendments,
2009    } = requirement
2010    else {
2011        return None;
2012    };
2013
2014    let mut available_decisions = vec![
2015        ReviewDecision::Approved,
2016        ReviewDecision::ApprovedForSession,
2017        ReviewDecision::Denied,
2018        ReviewDecision::Abort,
2019    ];
2020    if proposed_execpolicy_amendment
2021        .as_ref()
2022        .is_some_and(|amendment| !amendment.prefixes.is_empty())
2023    {
2024        available_decisions.push(ReviewDecision::ApprovedExecpolicyAmendment);
2025    }
2026    available_decisions.extend(proposed_network_policy_amendments.iter().cloned().map(
2027        |amendment| ReviewDecision::NetworkPolicyAmendment {
2028            host: amendment.host,
2029            action: amendment.action,
2030        },
2031    ));
2032
2033    Some(EventFrame::ExecApprovalRequest {
2034        request: ExecApprovalRequestEvent {
2035            call_id,
2036            approval_id,
2037            turn_id,
2038            command,
2039            cwd,
2040            reason: reason.clone(),
2041            matched_rule: matched_rule.map(|rule| rule.to_string().into_boxed_str()),
2042            network_approval_context: None,
2043            proposed_execpolicy_amendment: proposed_execpolicy_amendment
2044                .as_ref()
2045                .map(|amendment| amendment.prefixes.clone())
2046                .unwrap_or_default(),
2047            proposed_network_policy_amendments: proposed_network_policy_amendments.clone(),
2048            additional_permissions: Vec::new(),
2049            available_decisions,
2050        },
2051    })
2052}
2053
2054/// Build an [`EventFrame::UserInputRequest`] for a headless
2055/// `request_user_input` tool call, mirroring [`approval_request_frame`].
2056///
2057/// `arguments` is the raw JSON arguments string the model supplied to the
2058/// `request_user_input` tool (a `ToolPayload::Function` body). On parse
2059/// failure we return `None` so the caller falls through to the generic tool
2060/// error path rather than silently dropping the request.
2061fn user_input_request_frame(
2062    call_id: String,
2063    turn_id: String,
2064    request_id: String,
2065    arguments: &str,
2066) -> Option<EventFrame> {
2067    let parsed: Value = serde_json::from_str(arguments).ok()?;
2068    // Extract the `questions` array and lift it into the headless event
2069    // shape. We tolerate missing `allow_free_text`/`multi_select` (default
2070    // false) and extra fields, matching the lenient TUI `from_value` path.
2071    let questions = parsed.get("questions").cloned().filter(Value::is_array)?;
2072    let request = UserInputRequestEvent {
2073        call_id,
2074        turn_id,
2075        request_id,
2076        questions: serde_json::from_value(questions).ok()?,
2077    };
2078    Some(EventFrame::UserInputRequest { request })
2079}
2080
2081fn approval_requirement_payload(requirement: &ExecApprovalRequirement) -> Value {
2082    match requirement {
2083        ExecApprovalRequirement::Skip {
2084            bypass_sandbox,
2085            proposed_execpolicy_amendment,
2086        } => json!({
2087            "type": "skip",
2088            "bypass_sandbox": bypass_sandbox,
2089            "reason": requirement.reason(),
2090            "proposed_execpolicy_amendment": proposed_execpolicy_amendment
2091                .as_ref()
2092                .map(|amendment| amendment.prefixes.clone())
2093                .unwrap_or_default()
2094        }),
2095        ExecApprovalRequirement::NeedsApproval {
2096            reason,
2097            proposed_execpolicy_amendment,
2098            proposed_network_policy_amendments,
2099        } => json!({
2100            "type": "needs_approval",
2101            "reason": reason,
2102            "proposed_execpolicy_amendment": proposed_execpolicy_amendment
2103                .as_ref()
2104                .map(|amendment| amendment.prefixes.clone())
2105                .unwrap_or_default(),
2106            "proposed_network_policy_amendments": proposed_network_policy_amendments
2107        }),
2108        ExecApprovalRequirement::Forbidden { reason } => json!({
2109            "type": "forbidden",
2110            "reason": reason
2111        }),
2112    }
2113}
2114
2115fn policy_precheck_payload(
2116    decision: &ExecPolicyDecision,
2117    command: &str,
2118    cwd: &str,
2119    execution_kind: &str,
2120) -> Value {
2121    json!({
2122        "execution_kind": execution_kind,
2123        "command": command,
2124        "cwd": cwd,
2125        "allow": decision.allow,
2126        "requires_approval": decision.requires_approval,
2127        "matched_rule": decision.matched_rule.clone(),
2128        "phase": decision.requirement.phase(),
2129        "reason": decision.reason(),
2130        "requirement": approval_requirement_payload(&decision.requirement)
2131    })
2132}
2133
2134fn tool_payload_value(payload: &ToolPayload) -> Value {
2135    serde_json::to_value(payload).unwrap_or_else(
2136        |_| json!({"type":"serialization_error","message":"tool payload unavailable"}),
2137    )
2138}
2139
2140fn tool_output_value(output: &codewhale_protocol::ToolOutput) -> Value {
2141    serde_json::to_value(output).unwrap_or_else(
2142        |_| json!({"type":"serialization_error","message":"tool output unavailable"}),
2143    )
2144}
2145
2146fn event_frame_payload(frame: &EventFrame) -> Value {
2147    serde_json::to_value(frame)
2148        .unwrap_or_else(|_| json!({"event":"error","message":"failed to encode event frame"}))
2149}
2150
2151/// Tool name that triggers the headless clarification-question flow.
2152///
2153/// Mirrors the TUI's `REQUEST_USER_INPUT_NAME`
2154/// (`crates/tui/src/core/engine/tool_catalog.rs`); duplicated here rather than
2155/// depended on across crates so `core` stays free of `tui` imports.
2156const REQUEST_USER_INPUT_TOOL_NAME: &str = "request_user_input";
2157
2158fn json_optional_string(value: &Value) -> Option<String> {
2159    if value.is_null() {
2160        None
2161    } else {
2162        value.as_str().map(ToString::to_string)
2163    }
2164}
2165
2166fn parse_retry_metadata(value: Option<&Value>) -> JobRetryMetadata {
2167    let Some(value) = value else {
2168        return JobRetryMetadata::default();
2169    };
2170    JobRetryMetadata {
2171        attempt: value
2172            .get("attempt")
2173            .and_then(Value::as_u64)
2174            .unwrap_or(0)
2175            .min(u32::MAX as u64) as u32,
2176        max_attempts: value
2177            .get("max_attempts")
2178            .and_then(Value::as_u64)
2179            .unwrap_or(DEFAULT_JOB_MAX_ATTEMPTS as u64)
2180            .min(u32::MAX as u64) as u32,
2181        backoff_base_ms: value
2182            .get("backoff_base_ms")
2183            .and_then(Value::as_u64)
2184            .unwrap_or(DEFAULT_JOB_BACKOFF_BASE_MS),
2185        next_backoff_ms: value
2186            .get("next_backoff_ms")
2187            .and_then(Value::as_u64)
2188            .unwrap_or(0),
2189        next_retry_at: value.get("next_retry_at").and_then(Value::as_i64),
2190    }
2191}
2192
2193fn parse_history_entry(value: &Value) -> Option<JobHistoryEntry> {
2194    let status = value
2195        .get("status")
2196        .and_then(Value::as_str)
2197        .and_then(job_status_from_str)?;
2198    Some(JobHistoryEntry {
2199        at: value.get("at").and_then(Value::as_i64).unwrap_or(0),
2200        phase: value
2201            .get("phase")
2202            .and_then(Value::as_str)
2203            .unwrap_or("unknown")
2204            .to_string(),
2205        status,
2206        progress: value
2207            .get("progress")
2208            .and_then(Value::as_u64)
2209            .map(|v| v.min(u8::MAX as u64) as u8),
2210        detail: value.get("detail").and_then(json_optional_string),
2211        retry: parse_retry_metadata(value.get("retry")),
2212    })
2213}
2214
2215fn job_status_to_str(status: JobStatus) -> &'static str {
2216    match status {
2217        JobStatus::Queued => "queued",
2218        JobStatus::Running => "running",
2219        JobStatus::Paused => "paused",
2220        JobStatus::Completed => "completed",
2221        JobStatus::Failed => "failed",
2222        JobStatus::Cancelled => "cancelled",
2223    }
2224}
2225
2226fn job_status_from_str(value: &str) -> Option<JobStatus> {
2227    match value {
2228        "queued" => Some(JobStatus::Queued),
2229        "running" => Some(JobStatus::Running),
2230        "paused" => Some(JobStatus::Paused),
2231        "completed" => Some(JobStatus::Completed),
2232        "failed" => Some(JobStatus::Failed),
2233        "cancelled" => Some(JobStatus::Cancelled),
2234        _ => None,
2235    }
2236}
2237
2238fn job_retry_to_value(retry: &JobRetryMetadata) -> Value {
2239    json!({
2240        "attempt": retry.attempt,
2241        "max_attempts": retry.max_attempts,
2242        "backoff_base_ms": retry.backoff_base_ms,
2243        "next_backoff_ms": retry.next_backoff_ms,
2244        "next_retry_at": retry.next_retry_at
2245    })
2246}
2247
2248fn job_history_to_value(entry: &JobHistoryEntry) -> Value {
2249    json!({
2250        "at": entry.at,
2251        "phase": entry.phase.clone(),
2252        "status": job_status_to_str(entry.status),
2253        "progress": entry.progress,
2254        "detail": entry.detail.clone(),
2255        "retry": job_retry_to_value(&entry.retry)
2256    })
2257}
2258
2259fn runtime_status_to_job_state(status: JobStatus) -> JobStateStatus {
2260    match status {
2261        JobStatus::Queued => JobStateStatus::Queued,
2262        JobStatus::Running => JobStateStatus::Running,
2263        JobStatus::Paused => JobStateStatus::Paused,
2264        JobStatus::Completed => JobStateStatus::Completed,
2265        JobStatus::Failed => JobStateStatus::Failed,
2266        JobStatus::Cancelled => JobStateStatus::Cancelled,
2267    }
2268}
2269
2270fn job_state_status_to_runtime(status: JobStateStatus) -> JobStatus {
2271    match status {
2272        JobStateStatus::Queued => JobStatus::Queued,
2273        JobStateStatus::Running => JobStatus::Running,
2274        JobStateStatus::Paused => JobStatus::Paused,
2275        JobStateStatus::Completed => JobStatus::Completed,
2276        JobStateStatus::Failed => JobStatus::Failed,
2277        JobStateStatus::Cancelled => JobStatus::Cancelled,
2278    }
2279}
2280
2281#[cfg(test)]
2282mod tests {
2283    use super::*;
2284    use codewhale_protocol::ThreadResumeParams;
2285    use codewhale_tools::ToolCallSource;
2286
2287    fn temp_core_state(name: &str) -> StateStore {
2288        let dir =
2289            std::env::temp_dir().join(format!("codewhale-core-{name}-{}", Uuid::new_v4().simple()));
2290        std::fs::create_dir_all(&dir).expect("create temp state dir");
2291        StateStore::open(Some(dir.join("state.db"))).expect("open state store")
2292    }
2293
2294    fn test_thread_metadata(id: &str) -> ThreadMetadata {
2295        ThreadMetadata {
2296            id: id.to_string(),
2297            rollout_path: None,
2298            preview: "test thread".to_string(),
2299            ephemeral: false,
2300            model_provider: "deepseek".to_string(),
2301            created_at: 10,
2302            updated_at: 10,
2303            status: PersistedThreadStatus::Running,
2304            path: None,
2305            cwd: PathBuf::from("/tmp/codewhale"),
2306            cli_version: "0.0.0-test".to_string(),
2307            source: SessionSource::Interactive,
2308            name: None,
2309            sandbox_policy: None,
2310            approval_mode: None,
2311            archived: false,
2312            archived_at: None,
2313            git_sha: None,
2314            git_branch: None,
2315            git_origin_url: None,
2316            memory_mode: None,
2317            current_leaf_id: None,
2318        }
2319    }
2320
2321    // ── JobManager: lifecycle ──────────────────────────────────────────
2322
2323    #[test]
2324    fn permission_path_for_call_extracts_function_path_argument() {
2325        let call = ToolCall {
2326            name: "read_file".to_string(),
2327            payload: ToolPayload::Function {
2328                arguments: json!({ "path": "README.md" }).to_string(),
2329            },
2330            source: ToolCallSource::Direct,
2331            raw_tool_call_id: None,
2332        };
2333
2334        assert_eq!(
2335            permission_path_for_call(&call).as_deref(),
2336            Some("README.md")
2337        );
2338    }
2339
2340    #[test]
2341    fn permission_path_for_call_extracts_mcp_path_argument() {
2342        let call = ToolCall {
2343            name: "mcp_fs_read".to_string(),
2344            payload: ToolPayload::Mcp {
2345                server: "fs".to_string(),
2346                tool: "read".to_string(),
2347                raw_arguments: json!({ "path": "secrets/token.txt" }),
2348                raw_tool_call_id: None,
2349            },
2350            source: ToolCallSource::Direct,
2351            raw_tool_call_id: None,
2352        };
2353
2354        assert_eq!(
2355            permission_path_for_call(&call).as_deref(),
2356            Some("secrets/token.txt")
2357        );
2358    }
2359
2360    #[test]
2361    fn permission_path_for_call_ignores_shell_payload() {
2362        let call = ToolCall {
2363            name: "exec_shell".to_string(),
2364            payload: ToolPayload::LocalShell {
2365                params: codewhale_protocol::LocalShellParams {
2366                    command: "cargo test".to_string(),
2367                    cwd: None,
2368                    timeout_ms: None,
2369                },
2370            },
2371            source: ToolCallSource::Direct,
2372            raw_tool_call_id: None,
2373        };
2374
2375        assert_eq!(permission_path_for_call(&call), None);
2376    }
2377
2378    #[test]
2379    fn thread_goal_progress_accumulates_durable_accounting() {
2380        let store = temp_core_state("thread-goal-progress");
2381        store
2382            .upsert_thread(&test_thread_metadata("thread-1"))
2383            .expect("upsert thread");
2384        let mut manager = ThreadManager::new(store);
2385        manager
2386            .set_thread_goal(&ThreadGoalSetParams {
2387                thread_id: "thread-1".to_string(),
2388                objective: "Carry the goal across turns".to_string(),
2389                token_budget: Some(2_000),
2390            })
2391            .expect("set goal")
2392            .expect("goal exists");
2393
2394        let updated = manager
2395            .record_thread_goal_progress(&ThreadGoalProgressParams {
2396                thread_id: "thread-1".to_string(),
2397                token_delta: 750,
2398                time_delta_seconds: 12,
2399                record_continuation: true,
2400            })
2401            .expect("record progress")
2402            .expect("goal exists");
2403
2404        assert_eq!(updated.tokens_used, 750);
2405        assert_eq!(updated.time_used_seconds, 12);
2406        assert_eq!(updated.continuation_count, 1);
2407
2408        let persisted = manager
2409            .get_thread_goal(&ThreadGoalGetParams {
2410                thread_id: "thread-1".to_string(),
2411            })
2412            .expect("read goal")
2413            .expect("goal exists");
2414        assert_eq!(persisted.tokens_used, 750);
2415        assert_eq!(persisted.time_used_seconds, 12);
2416        assert_eq!(persisted.continuation_count, 1);
2417    }
2418
2419    #[test]
2420    fn approval_request_frame_includes_matched_rule() {
2421        let requirement = ExecApprovalRequirement::NeedsApproval {
2422            reason: "Typed ask rule 'tool=exec_shell command=cargo test' requires approval."
2423                .to_string(),
2424            proposed_execpolicy_amendment: None,
2425            proposed_network_policy_amendments: Vec::new(),
2426        };
2427
2428        let frame = approval_request_frame(
2429            &requirement,
2430            Some("tool=exec_shell command=cargo test"),
2431            "call-1".to_string(),
2432            "approval-1".to_string(),
2433            "turn-1".to_string(),
2434            "cargo test --workspace".to_string(),
2435            "/repo".to_string(),
2436        )
2437        .expect("approval frame");
2438
2439        let EventFrame::ExecApprovalRequest { request } = frame else {
2440            panic!("expected exec approval request frame");
2441        };
2442        assert_eq!(
2443            request.matched_rule.as_deref(),
2444            Some("tool=exec_shell command=cargo test")
2445        );
2446        assert_eq!(request.reason, requirement.reason());
2447    }
2448
2449    #[test]
2450    fn user_input_request_frame_lifts_questions_from_arguments() {
2451        // issue #3102: the headless frame constructor must parse the model's
2452        // `request_user_input` arguments and lift the questions into the
2453        // UserInputRequestEvent, defaulting the boolean flags when omitted.
2454        let arguments = r#"{"questions":[{"header":"Scope","id":"scope","question":"Which?","options":[{"label":"A","description":"a"},{"label":"B","description":"b"}],"allow_free_text":true}]}"#;
2455        let frame = user_input_request_frame(
2456            "call-1".to_string(),
2457            "turn-1".to_string(),
2458            "ui-1".to_string(),
2459            arguments,
2460        )
2461        .expect("user input frame");
2462
2463        let EventFrame::UserInputRequest { request } = frame else {
2464            panic!("expected user_input_request frame");
2465        };
2466        assert_eq!(request.call_id, "call-1");
2467        assert_eq!(request.turn_id, "turn-1");
2468        assert_eq!(request.request_id, "ui-1");
2469        assert_eq!(request.questions.len(), 1);
2470        assert_eq!(request.questions[0].id, "scope");
2471        assert!(request.questions[0].allow_free_text);
2472        // multi_select omitted in the payload → defaults to false.
2473        assert!(!request.questions[0].multi_select);
2474        assert_eq!(request.questions[0].options.len(), 2);
2475    }
2476
2477    #[test]
2478    fn user_input_request_frame_returns_none_on_invalid_arguments() {
2479        // On parse failure the constructor returns None so invoke_tool falls
2480        // through to the generic tool error path instead of silently dropping.
2481        let frame = user_input_request_frame(
2482            "call-1".to_string(),
2483            "turn-1".to_string(),
2484            "ui-1".to_string(),
2485            "not json",
2486        );
2487        assert!(frame.is_none());
2488
2489        // Valid JSON but missing the questions array is also rejected.
2490        let frame = user_input_request_frame(
2491            "call-1".to_string(),
2492            "turn-1".to_string(),
2493            "ui-1".to_string(),
2494            r#"{"foo":"bar"}"#,
2495        );
2496        assert!(frame.is_none());
2497    }
2498
2499    #[test]
2500    fn enqueue_creates_queued_job_with_zero_progress() {
2501        let mut jm = JobManager::default();
2502        let job = jm.enqueue("build");
2503        assert_eq!(job.name, "build");
2504        assert_eq!(job.status, JobStatus::Queued);
2505        assert_eq!(job.progress, Some(0));
2506        assert!(job.detail.is_none());
2507        assert_eq!(job.history.len(), 1);
2508        assert_eq!(job.history[0].phase, "created");
2509    }
2510
2511    #[test]
2512    fn set_running_transitions_from_queued() {
2513        let mut jm = JobManager::default();
2514        let job = jm.enqueue("deploy");
2515        let id = job.id.clone();
2516        jm.set_running(&id);
2517        let jobs = jm.list();
2518        let updated = jobs.iter().find(|j| j.id == id).unwrap();
2519        assert_eq!(updated.status, JobStatus::Running);
2520        assert_eq!(updated.history.last().unwrap().phase, "running");
2521    }
2522
2523    #[test]
2524    fn update_progress_clamps_to_100() {
2525        let mut jm = JobManager::default();
2526        let job = jm.enqueue("task");
2527        let id = job.id.clone();
2528        jm.update_progress(&id, 150, Some("over".to_string()));
2529        let jobs = jm.list();
2530        let updated = jobs.iter().find(|j| j.id == id).unwrap();
2531        assert_eq!(updated.progress, Some(100));
2532    }
2533
2534    #[test]
2535    fn complete_sets_progress_to_100() {
2536        let mut jm = JobManager::default();
2537        let job = jm.enqueue("task");
2538        let id = job.id.clone();
2539        jm.set_running(&id);
2540        jm.complete(&id);
2541        let jobs = jm.list();
2542        let updated = jobs.iter().find(|j| j.id == id).unwrap();
2543        assert_eq!(updated.status, JobStatus::Completed);
2544        assert_eq!(updated.progress, Some(100));
2545    }
2546
2547    #[test]
2548    fn fail_increments_attempt_and_sets_backoff() {
2549        let mut jm = JobManager::default();
2550        let job = jm.enqueue("fragile");
2551        let id = job.id.clone();
2552        jm.set_running(&id);
2553        jm.fail(&id, "crashed");
2554        let jobs = jm.list();
2555        let updated = jobs.iter().find(|j| j.id == id).unwrap();
2556        assert_eq!(updated.status, JobStatus::Failed);
2557        assert_eq!(updated.retry.attempt, 1);
2558        assert!(updated.retry.next_backoff_ms > 0);
2559        assert!(updated.retry.next_retry_at.is_some());
2560        assert_eq!(updated.detail.as_deref(), Some("crashed"));
2561    }
2562
2563    #[test]
2564    fn fail_clears_retry_after_max_attempts() {
2565        let mut jm = JobManager::default();
2566        let job = jm.enqueue("fragile");
2567        let id = job.id.clone();
2568        for _ in 0..=DEFAULT_JOB_MAX_ATTEMPTS {
2569            jm.set_running(&id);
2570            jm.fail(&id, "boom");
2571        }
2572        let jobs = jm.list();
2573        let updated = jobs.iter().find(|j| j.id == id).unwrap();
2574        assert_eq!(updated.retry.attempt, DEFAULT_JOB_MAX_ATTEMPTS);
2575        assert_eq!(updated.retry.next_backoff_ms, 0);
2576        assert!(updated.retry.next_retry_at.is_none());
2577    }
2578
2579    #[test]
2580    fn cancel_sets_status_and_clears_retry() {
2581        let mut jm = JobManager::default();
2582        let job = jm.enqueue("task");
2583        let id = job.id.clone();
2584        jm.cancel(&id);
2585        let jobs = jm.list();
2586        let updated = jobs.iter().find(|j| j.id == id).unwrap();
2587        assert_eq!(updated.status, JobStatus::Cancelled);
2588        assert_eq!(updated.retry.next_backoff_ms, 0);
2589    }
2590
2591    #[test]
2592    fn pause_and_resume_round_trip() {
2593        let mut jm = JobManager::default();
2594        let job = jm.enqueue("task");
2595        let id = job.id.clone();
2596        jm.set_running(&id);
2597        jm.pause(&id, Some("waiting".to_string()));
2598        let jobs = jm.list();
2599        let paused = jobs.iter().find(|j| j.id == id).unwrap();
2600        assert_eq!(paused.status, JobStatus::Paused);
2601        assert_eq!(paused.detail.as_deref(), Some("waiting"));
2602
2603        jm.resume(&id, None);
2604        let jobs = jm.list();
2605        let resumed = jobs.iter().find(|j| j.id == id).unwrap();
2606        assert_eq!(resumed.status, JobStatus::Running);
2607        assert_eq!(resumed.history.last().unwrap().phase, "resumed");
2608    }
2609
2610    #[test]
2611    fn list_returns_jobs_sorted_by_updated_at_desc() {
2612        let mut jm = JobManager::default();
2613        jm.enqueue("first");
2614        jm.enqueue("second");
2615        jm.enqueue("third");
2616        let jobs = jm.list();
2617        assert_eq!(jobs.len(), 3);
2618        for window in jobs.windows(2) {
2619            assert!(window[0].updated_at >= window[1].updated_at);
2620        }
2621    }
2622
2623    #[test]
2624    fn history_returns_entries_for_existing_job() {
2625        let mut jm = JobManager::default();
2626        let job = jm.enqueue("task");
2627        let id = job.id.clone();
2628        jm.set_running(&id);
2629        jm.complete(&id);
2630        let history = jm.history(&id);
2631        assert_eq!(history.len(), 3); // created, running, completed
2632        assert_eq!(history[0].phase, "created");
2633        assert_eq!(history[1].phase, "running");
2634        assert_eq!(history[2].phase, "completed");
2635    }
2636
2637    #[test]
2638    fn history_returns_empty_for_unknown_job() {
2639        let jm = JobManager::default();
2640        assert!(jm.history("nonexistent").is_empty());
2641    }
2642
2643    #[test]
2644    fn resume_pending_requeues_running_and_queued() {
2645        let mut jm = JobManager::default();
2646        let _j1 = jm.enqueue("queued_task");
2647        let j2 = jm.enqueue("running_task");
2648        let j3 = jm.enqueue("completed_task");
2649        let id2 = j2.id.clone();
2650        let id3 = j3.id.clone();
2651        jm.set_running(&id2);
2652        jm.set_running(&id3);
2653        jm.complete(&id3);
2654
2655        let resumed = jm.resume_pending();
2656        assert_eq!(resumed.len(), 2);
2657        for job in &resumed {
2658            assert_eq!(job.status, JobStatus::Queued);
2659        }
2660    }
2661
2662    // ── JobManager: backoff ────────────────────────────────────────────
2663
2664    #[test]
2665    fn deterministic_backoff_zero_on_first_attempt() {
2666        let retry = JobRetryMetadata {
2667            attempt: 0,
2668            ..Default::default()
2669        };
2670        assert_eq!(JobManager::deterministic_backoff_ms(&retry), 0);
2671    }
2672
2673    #[test]
2674    fn deterministic_backoff_exponential_growth() {
2675        let base = DEFAULT_JOB_BACKOFF_BASE_MS;
2676        for attempt in 1..=5 {
2677            let retry = JobRetryMetadata {
2678                attempt,
2679                backoff_base_ms: base,
2680                ..Default::default()
2681            };
2682            let expected = base * 2u64.pow(attempt.saturating_sub(1).min(20));
2683            assert_eq!(
2684                JobManager::deterministic_backoff_ms(&retry),
2685                expected,
2686                "attempt {attempt}"
2687            );
2688        }
2689    }
2690
2691    #[test]
2692    fn deterministic_backoff_saturates_at_high_exponent() {
2693        let retry = JobRetryMetadata {
2694            attempt: 63,
2695            backoff_base_ms: 1000,
2696            ..Default::default()
2697        };
2698        // Should not panic; result saturates
2699        let _ = JobManager::deterministic_backoff_ms(&retry);
2700    }
2701
2702    // ── JobManager: history truncation ─────────────────────────────────
2703
2704    #[test]
2705    fn push_history_truncates_beyond_max() {
2706        let mut jm = JobManager::default();
2707        let job = jm.enqueue("task");
2708        let id = job.id.clone();
2709        // Generate more history entries than the limit
2710        for i in 0..(MAX_JOB_HISTORY_ENTRIES + 20) {
2711            jm.update_progress(&id, (i % 100) as u8, Some(format!("step {i}")));
2712        }
2713        let history = jm.history(&id);
2714        assert_eq!(history.len(), MAX_JOB_HISTORY_ENTRIES);
2715    }
2716
2717    // ── JobManager: persistence encoding/parsing ───────────────────────
2718
2719    #[test]
2720    fn encode_and_parse_persisted_detail_round_trip() {
2721        let mut jm = JobManager::default();
2722        let job = jm.enqueue("task");
2723        let id = job.id.clone();
2724        jm.set_running(&id);
2725        jm.fail(&id, "oops");
2726        let job = jm.list().into_iter().find(|j| j.id == id).unwrap();
2727
2728        let encoded = JobManager::encode_persisted_detail(&job).unwrap().unwrap();
2729        let parsed = JobManager::parse_persisted_detail(Some(&encoded)).unwrap();
2730
2731        assert_eq!(parsed.status, job.status);
2732        assert_eq!(parsed.detail, job.detail);
2733        assert_eq!(parsed.retry.attempt, job.retry.attempt);
2734        assert_eq!(parsed.history.len(), job.history.len());
2735    }
2736
2737    #[test]
2738    fn parse_persisted_detail_returns_none_for_none_input() {
2739        assert!(JobManager::parse_persisted_detail(None).is_none());
2740    }
2741
2742    #[test]
2743    fn parse_persisted_detail_returns_none_for_invalid_json() {
2744        assert!(JobManager::parse_persisted_detail(Some("not json")).is_none());
2745    }
2746
2747    // ── Helper functions ───────────────────────────────────────────────
2748
2749    #[test]
2750    fn job_status_round_trip_str() {
2751        let statuses = [
2752            JobStatus::Queued,
2753            JobStatus::Running,
2754            JobStatus::Paused,
2755            JobStatus::Completed,
2756            JobStatus::Failed,
2757            JobStatus::Cancelled,
2758        ];
2759        for status in &statuses {
2760            let s = job_status_to_str(*status);
2761            let parsed = job_status_from_str(s);
2762            assert_eq!(parsed, Some(*status), "round-trip failed for {s:?}");
2763        }
2764    }
2765
2766    #[test]
2767    fn job_status_from_str_returns_none_for_unknown() {
2768        assert_eq!(job_status_from_str("unknown"), None);
2769        assert_eq!(job_status_from_str(""), None);
2770    }
2771
2772    #[test]
2773    fn truncate_preview_limits_to_120_chars() {
2774        let long = "a".repeat(200);
2775        let truncated = truncate_preview(&long);
2776        assert_eq!(truncated.len(), 120);
2777    }
2778
2779    #[test]
2780    fn truncate_preview_preserves_short_strings() {
2781        let short = "hello";
2782        assert_eq!(truncate_preview(short), "hello");
2783    }
2784
2785    #[test]
2786    fn runtime_status_to_job_state_maps_correctly() {
2787        assert_eq!(
2788            runtime_status_to_job_state(JobStatus::Queued),
2789            JobStateStatus::Queued
2790        );
2791        assert_eq!(
2792            runtime_status_to_job_state(JobStatus::Running),
2793            JobStateStatus::Running
2794        );
2795        assert_eq!(
2796            runtime_status_to_job_state(JobStatus::Paused),
2797            JobStateStatus::Paused
2798        );
2799        assert_eq!(
2800            runtime_status_to_job_state(JobStatus::Completed),
2801            JobStateStatus::Completed
2802        );
2803        assert_eq!(
2804            runtime_status_to_job_state(JobStatus::Failed),
2805            JobStateStatus::Failed
2806        );
2807        assert_eq!(
2808            runtime_status_to_job_state(JobStatus::Cancelled),
2809            JobStateStatus::Cancelled
2810        );
2811    }
2812
2813    #[test]
2814    fn job_state_status_to_runtime_maps_correctly() {
2815        assert_eq!(
2816            job_state_status_to_runtime(JobStateStatus::Queued),
2817            JobStatus::Queued
2818        );
2819        assert_eq!(
2820            job_state_status_to_runtime(JobStateStatus::Running),
2821            JobStatus::Running
2822        );
2823        assert_eq!(
2824            job_state_status_to_runtime(JobStateStatus::Paused),
2825            JobStatus::Paused
2826        );
2827        assert_eq!(
2828            job_state_status_to_runtime(JobStateStatus::Completed),
2829            JobStatus::Completed
2830        );
2831        assert_eq!(
2832            job_state_status_to_runtime(JobStateStatus::Failed),
2833            JobStatus::Failed
2834        );
2835        assert_eq!(
2836            job_state_status_to_runtime(JobStateStatus::Cancelled),
2837            JobStatus::Cancelled
2838        );
2839    }
2840
2841    #[test]
2842    fn preview_from_initial_history_new() {
2843        let preview = preview_from_initial_history(&InitialHistory::New);
2844        assert_eq!(preview, "New conversation");
2845    }
2846
2847    #[test]
2848    fn preview_from_initial_history_forked() {
2849        let preview = preview_from_initial_history(&InitialHistory::Forked(vec![json!("hello")]));
2850        assert!(preview.contains("hello"));
2851    }
2852
2853    #[test]
2854    fn preview_from_initial_history_resumed() {
2855        let preview = preview_from_initial_history(&InitialHistory::Resumed {
2856            conversation_id: "test".to_string(),
2857            history: vec![json!("world")],
2858            rollout_path: PathBuf::from("/tmp/test"),
2859        });
2860        assert!(preview.contains("world"));
2861    }
2862
2863    #[test]
2864    fn json_optional_string_handles_null() {
2865        assert!(json_optional_string(&Value::Null).is_none());
2866    }
2867
2868    #[test]
2869    fn json_optional_string_handles_string() {
2870        assert_eq!(
2871            json_optional_string(&Value::String("hello".to_string())),
2872            Some("hello".to_string())
2873        );
2874    }
2875
2876    #[test]
2877    fn json_optional_string_handles_non_string() {
2878        assert!(json_optional_string(&json!(42)).is_none());
2879    }
2880
2881    #[test]
2882    fn parse_retry_metadata_returns_default_for_none() {
2883        let retry = parse_retry_metadata(None);
2884        assert_eq!(retry.attempt, 0);
2885        assert_eq!(retry.max_attempts, DEFAULT_JOB_MAX_ATTEMPTS);
2886        assert_eq!(retry.backoff_base_ms, DEFAULT_JOB_BACKOFF_BASE_MS);
2887    }
2888
2889    #[test]
2890    fn parse_retry_metadata_parses_fields() {
2891        let value = json!({
2892            "attempt": 2,
2893            "max_attempts": 5,
2894            "backoff_base_ms": 1000,
2895            "next_backoff_ms": 2000,
2896            "next_retry_at": 1234567890i64
2897        });
2898        let retry = parse_retry_metadata(Some(&value));
2899        assert_eq!(retry.attempt, 2);
2900        assert_eq!(retry.max_attempts, 5);
2901        assert_eq!(retry.backoff_base_ms, 1000);
2902        assert_eq!(retry.next_backoff_ms, 2000);
2903        assert_eq!(retry.next_retry_at, Some(1234567890));
2904    }
2905
2906    #[test]
2907    fn parse_history_entry_returns_none_without_status() {
2908        let value = json!({"at": 1, "phase": "test"});
2909        assert!(parse_history_entry(&value).is_none());
2910    }
2911
2912    #[test]
2913    fn parse_history_entry_parses_valid_entry() {
2914        let value = json!({
2915            "at": 100,
2916            "phase": "running",
2917            "status": "running",
2918            "progress": 50,
2919            "detail": "working",
2920            "retry": {"attempt": 0, "max_attempts": 3, "backoff_base_ms": 500}
2921        });
2922        let entry = parse_history_entry(&value).unwrap();
2923        assert_eq!(entry.at, 100);
2924        assert_eq!(entry.phase, "running");
2925        assert_eq!(entry.status, JobStatus::Running);
2926        assert_eq!(entry.progress, Some(50));
2927        assert_eq!(entry.detail.as_deref(), Some("working"));
2928    }
2929
2930    #[test]
2931    fn paused_job_persists_as_paused_not_running() {
2932        let store = temp_core_state("paused-persist");
2933        let mut jm = JobManager::default();
2934        let job = jm.enqueue("task");
2935        let id = job.id.clone();
2936        jm.set_running(&id);
2937        jm.pause(&id, Some("waiting".to_string()));
2938        jm.persist_job(&store, &id).expect("persist paused job");
2939
2940        let persisted = store.list_jobs(Some(10)).expect("list jobs");
2941        let record = persisted.iter().find(|job| job.id == id).unwrap();
2942        assert_eq!(record.status, JobStateStatus::Paused);
2943
2944        let mut reloaded = JobManager::default();
2945        reloaded.load_from_store(&store).expect("reload jobs");
2946        let jobs = reloaded.list();
2947        let reloaded_job = jobs.iter().find(|job| job.id == id).unwrap();
2948        assert_eq!(reloaded_job.status, JobStatus::Paused);
2949    }
2950
2951    // ── O1: JobRecord → AgentRunSnapshot adapter ────────────────────────
2952
2953    fn sample_job_record(status: JobStatus, detail: Option<&str>) -> JobRecord {
2954        JobRecord {
2955            id: "job-o1-1".to_string(),
2956            name: "sample".to_string(),
2957            status,
2958            progress: None,
2959            detail: detail.map(str::to_string),
2960            retry: JobRetryMetadata {
2961                attempt: 0,
2962                max_attempts: DEFAULT_JOB_MAX_ATTEMPTS,
2963                backoff_base_ms: DEFAULT_JOB_BACKOFF_BASE_MS,
2964                next_backoff_ms: 0,
2965                next_retry_at: None,
2966            },
2967            history: Vec::new(),
2968            created_at: 1_700_000_000,
2969            updated_at: 1_700_000_042,
2970        }
2971    }
2972
2973    #[test]
2974    fn job_record_to_agent_run_maps_non_terminal_states() {
2975        use codewhale_protocol::agent_run::RunState;
2976
2977        for (status, expected) in [
2978            (JobStatus::Queued, RunState::Queued),
2979            (JobStatus::Running, RunState::Running),
2980            (JobStatus::Paused, RunState::Paused),
2981        ] {
2982            let snapshot = job_record_to_agent_run(&sample_job_record(status, None));
2983            assert!(snapshot.is_coherent());
2984            assert_eq!(snapshot.run_id, "job-o1-1");
2985            assert_eq!(snapshot.parent, None);
2986            assert_eq!(
2987                snapshot.source,
2988                codewhale_protocol::agent_run::RunSource::CoreJob
2989            );
2990            assert_eq!(snapshot.state, expected);
2991            assert!(snapshot.terminal.is_none());
2992            assert!(snapshot.refs.is_empty());
2993            assert_eq!(
2994                snapshot.budget,
2995                codewhale_protocol::agent_run::BudgetSummary::default()
2996            );
2997        }
2998    }
2999
3000    #[test]
3001    fn job_record_to_agent_run_maps_terminal_states_without_fabricating_fields() {
3002        use codewhale_protocol::agent_run::{RunState, TerminalOutcome};
3003
3004        let cases = [
3005            (
3006                JobStatus::Completed,
3007                TerminalOutcome::Completed,
3008                Some("done"),
3009            ),
3010            (JobStatus::Failed, TerminalOutcome::Failed, Some("boom")),
3011            (JobStatus::Cancelled, TerminalOutcome::Cancelled, None),
3012        ];
3013
3014        for (status, outcome, detail) in cases {
3015            let snapshot = job_record_to_agent_run(&sample_job_record(status, detail));
3016            assert!(snapshot.is_coherent());
3017            assert_eq!(snapshot.state, RunState::Terminal);
3018            let terminal = snapshot.terminal.expect("terminal summary");
3019            assert_eq!(terminal.outcome, outcome);
3020            assert_eq!(terminal.ended_at_ms, Some(1_700_000_042_000));
3021            assert_eq!(terminal.detail, None);
3022            assert_eq!(
3023                snapshot.budget,
3024                codewhale_protocol::agent_run::BudgetSummary::default()
3025            );
3026            assert!(snapshot.refs.is_empty());
3027            assert_eq!(snapshot.parent, None);
3028        }
3029    }
3030
3031    #[test]
3032    fn job_record_to_agent_run_does_not_export_unclassified_detail() {
3033        let record = sample_job_record(JobStatus::Failed, Some("owner-private diagnostic"));
3034        let snapshot = job_record_to_agent_run(&record);
3035        let terminal = snapshot.terminal.as_ref().expect("terminal summary");
3036        assert_eq!(terminal.detail, None);
3037        let serialized = serde_json::to_string(&snapshot).expect("serialize snapshot");
3038        assert!(!serialized.contains("owner-private diagnostic"));
3039    }
3040
3041    #[test]
3042    fn job_record_to_agent_run_omits_ended_at_on_updated_at_overflow() {
3043        let mut record = sample_job_record(JobStatus::Completed, Some("ok"));
3044        record.updated_at = i64::MAX;
3045        let snapshot = job_record_to_agent_run(&record);
3046        assert!(snapshot.is_coherent());
3047        let terminal = snapshot.terminal.expect("terminal summary");
3048        assert_eq!(terminal.ended_at_ms, None);
3049    }
3050
3051    #[test]
3052    fn unarchive_thread_updates_running_threads_cache() {
3053        let store = temp_core_state("unarchive-cache");
3054        let mut manager = ThreadManager::new(store);
3055        let spawned = manager
3056            .spawn_thread_with_history(
3057                "deepseek".to_string(),
3058                PathBuf::from("/tmp/codewhale"),
3059                InitialHistory::New,
3060                true,
3061            )
3062            .expect("spawn thread");
3063        let thread_id = spawned.thread.id.clone();
3064        let resume_params = ThreadResumeParams {
3065            thread_id: thread_id.clone(),
3066            history: None,
3067            path: None,
3068            model: None,
3069            model_provider: None,
3070            cwd: None,
3071            approval_policy: None,
3072            sandbox: None,
3073            config: None,
3074            base_instructions: None,
3075            developer_instructions: None,
3076            personality: None,
3077            persist_extended_history: false,
3078        };
3079
3080        manager.archive_thread(&thread_id).expect("archive thread");
3081        let archived = manager
3082            .resume_thread_with_history(
3083                &resume_params,
3084                Path::new("/tmp/codewhale"),
3085                "deepseek".to_string(),
3086            )
3087            .expect("resume archived thread")
3088            .expect("thread in cache");
3089        assert_eq!(archived.thread.status, ThreadStatus::Archived);
3090
3091        manager
3092            .unarchive_thread(&thread_id)
3093            .expect("unarchive thread");
3094        let restored = manager
3095            .resume_thread_with_history(
3096                &resume_params,
3097                Path::new("/tmp/codewhale"),
3098                "deepseek".to_string(),
3099            )
3100            .expect("resume unarchived thread")
3101            .expect("thread in cache");
3102        assert_eq!(restored.thread.status, ThreadStatus::Idle);
3103    }
3104
3105    #[tokio::test]
3106    async fn invoke_tool_returns_timeout_status_for_slow_tools() {
3107        use async_trait::async_trait;
3108        use codewhale_agent::ModelRegistry;
3109        use codewhale_config::ConfigToml;
3110        use codewhale_execpolicy::{AskForApproval, ExecPolicyEngine};
3111        use codewhale_hooks::HookDispatcher;
3112        use codewhale_mcp::McpManager;
3113        use codewhale_protocol::{ToolKind, ToolOutput, ToolPayload};
3114        use codewhale_tools::{FunctionCallError, ToolDescriptor, ToolHandler, ToolInvocation};
3115
3116        struct SlowTool;
3117        #[async_trait]
3118        impl ToolHandler for SlowTool {
3119            fn kind(&self) -> ToolKind {
3120                ToolKind::Function
3121            }
3122
3123            async fn handle(
3124                &self,
3125                _invocation: ToolInvocation,
3126            ) -> std::result::Result<ToolOutput, FunctionCallError> {
3127                time::sleep(Duration::from_millis(200)).await;
3128                Ok(ToolOutput::Function {
3129                    body: Some(json!("late")),
3130                    success: true,
3131                })
3132            }
3133        }
3134
3135        let mut registry = ToolRegistry::default();
3136        registry
3137            .register(
3138                ToolDescriptor {
3139                    name: "slow_tool".to_string(),
3140                    input_schema: json!({"type":"object"}),
3141                    output_schema: json!({"type":"object"}),
3142                    supports_parallel_tool_calls: true,
3143                    timeout_ms: None,
3144                },
3145                Arc::new(SlowTool),
3146            )
3147            .expect("register slow tool");
3148
3149        let runtime = Runtime::new(
3150            ConfigToml::default(),
3151            ModelRegistry::default(),
3152            temp_core_state("invoke-tool-timeout"),
3153            Arc::new(registry),
3154            Arc::new(McpManager::default()),
3155            ExecPolicyEngine::new(vec![], vec![]),
3156            HookDispatcher::default(),
3157        );
3158
3159        let result = runtime
3160            .invoke_tool(
3161                ToolCall {
3162                    name: "slow_tool".to_string(),
3163                    payload: ToolPayload::Function {
3164                        arguments: "{}".to_string(),
3165                    },
3166                    source: ToolCallSource::Direct,
3167                    raw_tool_call_id: None,
3168                },
3169                AskForApproval::Never,
3170                Path::new("/tmp/codewhale"),
3171            )
3172            .await
3173            .expect("invoke tool");
3174
3175        assert_eq!(result["status"], "timeout");
3176        assert_eq!(result["ok"], false);
3177    }
3178}