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
35fn tool_dispatch_timeout() -> Duration {
38 if cfg!(test) {
39 Duration::from_millis(50)
40 } else {
41 Duration::from_secs(300)
42 }
43}
44
45#[derive(Debug, Clone)]
47pub enum InitialHistory {
48 New,
50 Forked(Vec<Value>),
52 Resumed {
54 conversation_id: String,
55 history: Vec<Value>,
56 rollout_path: PathBuf,
57 },
58}
59
60#[derive(Debug, Clone)]
62pub struct NewThread {
63 pub thread: Thread,
65 pub model: String,
67 pub model_provider: String,
69 pub cwd: PathBuf,
71 pub approval_policy: Option<String>,
73 pub sandbox: Option<String>,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum JobStatus {
80 Queued,
82 Running,
84 Paused,
86 Completed,
88 Failed,
90 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#[derive(Debug, Clone)]
113pub struct JobRetryMetadata {
114 pub attempt: u32,
116 pub max_attempts: u32,
118 pub backoff_base_ms: u64,
120 pub next_backoff_ms: u64,
122 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#[derive(Debug, Clone)]
140pub struct JobHistoryEntry {
141 pub at: i64,
143 pub phase: String,
145 pub status: JobStatus,
147 pub progress: Option<u8>,
149 pub detail: Option<String>,
151 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#[derive(Debug, Clone)]
165pub struct JobRecord {
166 pub id: String,
168 pub name: String,
170 pub status: JobStatus,
172 pub progress: Option<u8>,
174 pub detail: Option<String>,
176 pub retry: JobRetryMetadata,
178 pub history: Vec<JobHistoryEntry>,
180 pub created_at: i64,
182 pub updated_at: i64,
184}
185
186#[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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
512pub struct ThreadManager {
514 store: StateStore,
515 running_threads: HashMap<String, Thread>,
516 cli_version: String,
517}
518
519impl ThreadManager {
520 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 pub fn state_store(&self) -> &StateStore {
531 &self.store
532 }
533
534 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 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(¶ms.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(¶ms.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 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(¶ms.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 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 pub fn read_thread(&self, params: &ThreadReadParams) -> Result<Option<Thread>> {
701 Ok(self
702 .store
703 .get_thread(¶ms.thread_id)?
704 .map(to_protocol_thread))
705 }
706
707 pub fn set_thread_name(&mut self, params: &ThreadSetNameParams) -> Result<Option<Thread>> {
709 let Some(mut metadata) = self.store.get_thread(¶ms.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 pub fn set_thread_goal(&mut self, params: &ThreadGoalSetParams) -> Result<Option<ThreadGoal>> {
723 if self.store.get_thread(¶ms.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 pub fn get_thread_goal(&self, params: &ThreadGoalGetParams) -> Result<Option<ThreadGoal>> {
745 Ok(self
746 .store
747 .get_thread_goal(¶ms.thread_id)?
748 .map(to_protocol_goal))
749 }
750
751 pub fn record_thread_goal_progress(
753 &mut self,
754 params: &ThreadGoalProgressParams,
755 ) -> Result<Option<ThreadGoal>> {
756 if self.store.get_thread(¶ms.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 ¶ms.thread_id,
764 params.token_delta,
765 params.time_delta_seconds,
766 now,
767 )?
768 } else {
769 self.store.get_thread_goal(¶ms.thread_id)?
770 };
771
772 if params.record_continuation {
773 goal = self
774 .store
775 .record_thread_goal_continuation(¶ms.thread_id, now)?;
776 }
777
778 Ok(goal.map(to_protocol_goal))
779 }
780
781 pub fn clear_thread_goal(&mut self, params: &ThreadGoalClearParams) -> Result<bool> {
783 self.store.delete_thread_goal(¶ms.thread_id)
784 }
785
786 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 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 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
864pub struct Runtime {
866 pub config: ConfigToml,
868 pub model_registry: ModelRegistry,
870 pub thread_manager: ThreadManager,
872 pub tool_registry: Arc<ToolRegistry>,
874 pub mcp_manager: Arc<McpManager>,
876 pub exec_policy: ExecPolicyEngine,
878 pub hooks: HookDispatcher,
880 pub jobs: JobManager,
882}
883
884impl Runtime {
885 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 pub fn update_config(&mut self, config: ConfigToml) {
923 self.config = config;
924 }
925
926 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 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 ¶ms,
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(¶ms, &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(¶ms)?,
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(¶ms)?,
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(¶ms)?,
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(¶ms)? {
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(¶ms)?;
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(¶ms)?;
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(¶ms)? {
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 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 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 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 _ => "",
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 success = tool_output.success();
1575 let status = if success { "completed" } else { "failed" };
1576 let result_frame = EventFrame::ToolCallResult {
1577 response_id: response_id.clone(),
1578 tool_name: call.name.clone(),
1579 output: tool_output_value(&tool_output),
1580 };
1581 self.hooks
1582 .emit(HookEvent::GenericEventFrame {
1583 frame: Box::new(result_frame.clone()),
1584 })
1585 .await;
1586 self.hooks
1587 .emit(HookEvent::ToolLifecycle {
1588 response_id: response_id.clone(),
1589 tool_name: call.name,
1590 phase: status.to_string(),
1591 payload: json!({ "ok": success }),
1592 })
1593 .await;
1594 Ok(json!({
1595 "ok": success,
1596 "status": status,
1597 "execution_kind": execution_kind,
1598 "response_id": response_id,
1599 "precheck": precheck,
1600 "output": tool_output,
1601 "events": [
1602 event_frame_payload(&start_frame),
1603 event_frame_payload(&result_frame)
1604 ]
1605 }))
1606 }
1607 Ok(Err(err)) => {
1608 let message = format!("{err:?}");
1609 let error_frame = EventFrame::Error {
1610 response_id: response_id.clone(),
1611 message: message.clone(),
1612 };
1613 self.hooks
1614 .emit(HookEvent::GenericEventFrame {
1615 frame: Box::new(error_frame.clone()),
1616 })
1617 .await;
1618 self.hooks
1619 .emit(HookEvent::ToolLifecycle {
1620 response_id: response_id.clone(),
1621 tool_name: call.name,
1622 phase: "failed".to_string(),
1623 payload: json!({ "error": message.clone() }),
1624 })
1625 .await;
1626 Ok(json!({
1627 "ok": false,
1628 "status": "failed",
1629 "execution_kind": execution_kind,
1630 "response_id": response_id,
1631 "precheck": precheck,
1632 "error": message,
1633 "events": [
1634 event_frame_payload(&start_frame),
1635 event_frame_payload(&error_frame)
1636 ]
1637 }))
1638 }
1639 Err(_elapsed) => {
1640 let seconds = tool_dispatch_timeout().as_secs().max(1);
1641 let message = format!("Tool '{}' timed out after {seconds}s", call.name);
1642 let error_frame = EventFrame::Error {
1643 response_id: response_id.clone(),
1644 message: message.clone(),
1645 };
1646 self.hooks
1647 .emit(HookEvent::GenericEventFrame {
1648 frame: Box::new(error_frame.clone()),
1649 })
1650 .await;
1651 self.hooks
1652 .emit(HookEvent::ToolLifecycle {
1653 response_id: response_id.clone(),
1654 tool_name: call.name,
1655 phase: "failed".to_string(),
1656 payload: json!({ "error": message.clone(), "timeout": true }),
1657 })
1658 .await;
1659 Ok(json!({
1660 "ok": false,
1661 "status": "timeout",
1662 "execution_kind": execution_kind,
1663 "response_id": response_id,
1664 "precheck": precheck,
1665 "error": message,
1666 "events": [
1667 event_frame_payload(&start_frame),
1668 event_frame_payload(&error_frame)
1669 ]
1670 }))
1671 }
1672 }
1673 }
1674
1675 pub async fn mcp_startup(&self) -> McpStartupCompleteEvent {
1677 let mut updates = Vec::new();
1678 let summary = self.mcp_manager.start_all(|update| {
1679 updates.push(update);
1680 });
1681 for update in updates {
1682 let status = match update.status {
1683 McpManagerStartupStatus::Starting => codewhale_protocol::McpStartupStatus::Starting,
1684 McpManagerStartupStatus::Ready => codewhale_protocol::McpStartupStatus::Ready,
1685 McpManagerStartupStatus::Failed { error } => {
1686 codewhale_protocol::McpStartupStatus::Failed { error }
1687 }
1688 McpManagerStartupStatus::Cancelled => {
1689 codewhale_protocol::McpStartupStatus::Cancelled
1690 }
1691 };
1692 self.hooks
1693 .emit(HookEvent::GenericEventFrame {
1694 frame: Box::new(EventFrame::McpStartupUpdate {
1695 update: codewhale_protocol::McpStartupUpdateEvent {
1696 server_name: update.server_name,
1697 status,
1698 },
1699 }),
1700 })
1701 .await;
1702 }
1703 self.hooks
1704 .emit(HookEvent::GenericEventFrame {
1705 frame: Box::new(EventFrame::McpStartupComplete {
1706 summary: codewhale_protocol::McpStartupCompleteEvent {
1707 ready: summary.ready.clone(),
1708 failed: summary
1709 .failed
1710 .iter()
1711 .map(|f| codewhale_protocol::McpStartupFailure {
1712 server_name: f.server_name.clone(),
1713 error: f.error.clone(),
1714 })
1715 .collect(),
1716 cancelled: summary.cancelled.clone(),
1717 },
1718 }),
1719 })
1720 .await;
1721 summary
1722 }
1723
1724 pub fn app_status(&self) -> AppResponse {
1726 let jobs = self.jobs.list();
1727 let events = jobs
1728 .iter()
1729 .flat_map(|job| {
1730 job.history.iter().map(|entry| EventFrame::ResponseDelta {
1731 response_id: job.id.clone(),
1732 delta: json!({
1733 "kind": "job_transition",
1734 "job_id": job.id.clone(),
1735 "phase": entry.phase.clone(),
1736 "status": job_status_to_str(entry.status),
1737 "progress": entry.progress,
1738 "detail": entry.detail.clone(),
1739 "retry": job_retry_to_value(&entry.retry),
1740 "at": entry.at
1741 })
1742 .to_string(),
1743 channel: ResponseChannel::Text,
1744 })
1745 })
1746 .collect::<Vec<_>>();
1747 AppResponse {
1748 ok: true,
1749 data: json!({
1750 "jobs": jobs.into_iter().map(|job| {
1751 json!({
1752 "id": job.id,
1753 "name": job.name,
1754 "status": job_status_to_str(job.status),
1755 "progress": job.progress,
1756 "detail": job.detail,
1757 "retry": job_retry_to_value(&job.retry),
1758 "history": job.history.iter().map(job_history_to_value).collect::<Vec<_>>()
1759 })
1760 }).collect::<Vec<_>>()
1761 }),
1762 events,
1763 }
1764 }
1765
1766 pub fn provider_default(&self) -> ProviderKind {
1768 self.config.provider
1769 }
1770
1771 pub fn save_thread_checkpoint(
1773 &self,
1774 thread_id: &str,
1775 checkpoint_id: &str,
1776 state: &Value,
1777 ) -> Result<()> {
1778 self.thread_manager
1779 .state_store()
1780 .save_checkpoint(thread_id, checkpoint_id, state)
1781 }
1782
1783 pub fn load_thread_checkpoint(
1785 &self,
1786 thread_id: &str,
1787 checkpoint_id: Option<&str>,
1788 ) -> Result<Option<Value>> {
1789 Ok(self
1790 .thread_manager
1791 .state_store()
1792 .load_checkpoint(thread_id, checkpoint_id)?
1793 .map(|checkpoint| checkpoint.state))
1794 }
1795
1796 pub fn enqueue_job(&mut self, name: impl Into<String>) -> Result<JobRecord> {
1798 let job = self.jobs.enqueue(name);
1799 self.jobs
1800 .persist_job(self.thread_manager.state_store(), &job.id)?;
1801 Ok(job)
1802 }
1803
1804 pub fn set_job_running(&mut self, job_id: &str) -> Result<()> {
1806 self.jobs.set_running(job_id);
1807 self.jobs
1808 .persist_job(self.thread_manager.state_store(), job_id)
1809 }
1810
1811 pub fn update_job_progress(
1813 &mut self,
1814 job_id: &str,
1815 progress: u8,
1816 detail: Option<String>,
1817 ) -> Result<()> {
1818 self.jobs.update_progress(job_id, progress, detail);
1819 self.jobs
1820 .persist_job(self.thread_manager.state_store(), job_id)
1821 }
1822
1823 pub fn complete_job(&mut self, job_id: &str) -> Result<()> {
1825 self.jobs.complete(job_id);
1826 self.jobs
1827 .persist_job(self.thread_manager.state_store(), job_id)
1828 }
1829
1830 pub fn fail_job(&mut self, job_id: &str, detail: impl Into<String>) -> Result<()> {
1832 self.jobs.fail(job_id, detail);
1833 self.jobs
1834 .persist_job(self.thread_manager.state_store(), job_id)
1835 }
1836
1837 pub fn cancel_job(&mut self, job_id: &str) -> Result<()> {
1839 self.jobs.cancel(job_id);
1840 self.jobs
1841 .persist_job(self.thread_manager.state_store(), job_id)
1842 }
1843
1844 pub fn pause_job(&mut self, job_id: &str, detail: Option<String>) -> Result<()> {
1846 self.jobs.pause(job_id, detail);
1847 self.jobs
1848 .persist_job(self.thread_manager.state_store(), job_id)
1849 }
1850
1851 pub fn resume_job(&mut self, job_id: &str, detail: Option<String>) -> Result<()> {
1853 self.jobs.resume(job_id, detail);
1854 self.jobs
1855 .persist_job(self.thread_manager.state_store(), job_id)
1856 }
1857
1858 pub fn job_history(&self, job_id: &str) -> Vec<JobHistoryEntry> {
1860 self.jobs.history(job_id)
1861 }
1862}
1863
1864fn thread_response_from_new(status: &str, new: NewThread) -> ThreadResponse {
1865 ThreadResponse {
1866 thread_id: new.thread.id.clone(),
1867 status: status.to_string(),
1868 thread: Some(new.thread),
1869 threads: Vec::new(),
1870 goal: None,
1871 model: Some(new.model),
1872 model_provider: Some(new.model_provider),
1873 cwd: Some(new.cwd),
1874 approval_policy: new.approval_policy,
1875 sandbox: new.sandbox,
1876 events: Vec::new(),
1877 data: json!({}),
1878 }
1879}
1880
1881fn preview_from_initial_history(initial_history: &InitialHistory) -> String {
1882 match initial_history {
1883 InitialHistory::New => "New conversation".to_string(),
1884 InitialHistory::Forked(items) => truncate_preview(
1885 &items
1886 .first()
1887 .map(Value::to_string)
1888 .unwrap_or_else(|| "Forked conversation".to_string()),
1889 ),
1890 InitialHistory::Resumed { history, .. } => truncate_preview(
1891 &history
1892 .first()
1893 .map(Value::to_string)
1894 .unwrap_or_else(|| "Resumed conversation".to_string()),
1895 ),
1896 }
1897}
1898
1899fn permission_path_for_call(call: &ToolCall) -> Option<String> {
1900 match &call.payload {
1901 ToolPayload::Function { arguments } => serde_json::from_str::<Value>(arguments)
1902 .ok()
1903 .and_then(|value| {
1904 value
1905 .get("path")
1906 .and_then(Value::as_str)
1907 .map(str::to_string)
1908 }),
1909 ToolPayload::Mcp { raw_arguments, .. } => raw_arguments
1910 .get("path")
1911 .and_then(Value::as_str)
1912 .map(str::to_string),
1913 ToolPayload::Custom { .. } | ToolPayload::LocalShell { .. } => None,
1914 }
1915}
1916
1917fn truncate_preview(value: &str) -> String {
1918 value.chars().take(120).collect()
1919}
1920
1921fn to_protocol_thread(thread: ThreadMetadata) -> Thread {
1922 Thread {
1923 id: thread.id,
1924 preview: thread.preview,
1925 ephemeral: thread.ephemeral,
1926 model_provider: thread.model_provider,
1927 created_at: thread.created_at,
1928 updated_at: thread.updated_at,
1929 status: match thread.status {
1930 PersistedThreadStatus::Running => ThreadStatus::Running,
1931 PersistedThreadStatus::Idle => ThreadStatus::Idle,
1932 PersistedThreadStatus::Completed => ThreadStatus::Completed,
1933 PersistedThreadStatus::Failed => ThreadStatus::Failed,
1934 PersistedThreadStatus::Paused => ThreadStatus::Paused,
1935 PersistedThreadStatus::Archived => ThreadStatus::Archived,
1936 },
1937 path: thread.path,
1938 cwd: thread.cwd,
1939 cli_version: thread.cli_version,
1940 source: match thread.source {
1941 SessionSource::Interactive => codewhale_protocol::SessionSource::Interactive,
1942 SessionSource::Resume => codewhale_protocol::SessionSource::Resume,
1943 SessionSource::Fork => codewhale_protocol::SessionSource::Fork,
1944 SessionSource::Api => codewhale_protocol::SessionSource::Api,
1945 SessionSource::Unknown => codewhale_protocol::SessionSource::Unknown,
1946 },
1947 name: thread.name,
1948 }
1949}
1950
1951fn to_protocol_goal(goal: ThreadGoalRecord) -> ThreadGoal {
1952 ThreadGoal {
1953 thread_id: goal.thread_id,
1954 goal_id: goal.goal_id,
1955 objective: goal.objective,
1956 status: to_protocol_goal_status(goal.status),
1957 token_budget: goal.token_budget,
1958 tokens_used: goal.tokens_used,
1959 time_used_seconds: goal.time_used_seconds,
1960 continuation_count: goal.continuation_count,
1961 created_at: goal.created_at,
1962 updated_at: goal.updated_at,
1963 }
1964}
1965
1966fn to_protocol_goal_status(status: PersistedThreadGoalStatus) -> ThreadGoalStatus {
1967 match status {
1968 PersistedThreadGoalStatus::Active => ThreadGoalStatus::Active,
1969 PersistedThreadGoalStatus::Paused => ThreadGoalStatus::Paused,
1970 PersistedThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked,
1971 PersistedThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited,
1972 PersistedThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited,
1973 PersistedThreadGoalStatus::Complete => ThreadGoalStatus::Complete,
1974 }
1975}
1976
1977fn to_persisted_status(status: &ThreadStatus) -> PersistedThreadStatus {
1978 match status {
1979 ThreadStatus::Running => PersistedThreadStatus::Running,
1980 ThreadStatus::Idle => PersistedThreadStatus::Idle,
1981 ThreadStatus::Completed => PersistedThreadStatus::Completed,
1982 ThreadStatus::Failed => PersistedThreadStatus::Failed,
1983 ThreadStatus::Paused => PersistedThreadStatus::Paused,
1984 ThreadStatus::Archived => PersistedThreadStatus::Archived,
1985 }
1986}
1987
1988fn to_persisted_source(source: &codewhale_protocol::SessionSource) -> SessionSource {
1989 match source {
1990 codewhale_protocol::SessionSource::Interactive => SessionSource::Interactive,
1991 codewhale_protocol::SessionSource::Resume => SessionSource::Resume,
1992 codewhale_protocol::SessionSource::Fork => SessionSource::Fork,
1993 codewhale_protocol::SessionSource::Api => SessionSource::Api,
1994 codewhale_protocol::SessionSource::Unknown => SessionSource::Unknown,
1995 }
1996}
1997
1998fn approval_request_frame(
1999 requirement: &ExecApprovalRequirement,
2000 matched_rule: Option<&str>,
2001 call_id: String,
2002 approval_id: String,
2003 turn_id: String,
2004 command: String,
2005 cwd: String,
2006) -> Option<EventFrame> {
2007 let ExecApprovalRequirement::NeedsApproval {
2008 reason,
2009 proposed_execpolicy_amendment,
2010 proposed_network_policy_amendments,
2011 } = requirement
2012 else {
2013 return None;
2014 };
2015
2016 let mut available_decisions = vec![
2017 ReviewDecision::Approved,
2018 ReviewDecision::ApprovedForSession,
2019 ReviewDecision::Denied,
2020 ReviewDecision::Abort,
2021 ];
2022 if proposed_execpolicy_amendment
2023 .as_ref()
2024 .is_some_and(|amendment| !amendment.prefixes.is_empty())
2025 {
2026 available_decisions.push(ReviewDecision::ApprovedExecpolicyAmendment);
2027 }
2028 available_decisions.extend(proposed_network_policy_amendments.iter().cloned().map(
2029 |amendment| ReviewDecision::NetworkPolicyAmendment {
2030 host: amendment.host,
2031 action: amendment.action,
2032 },
2033 ));
2034
2035 Some(EventFrame::ExecApprovalRequest {
2036 request: ExecApprovalRequestEvent {
2037 call_id,
2038 approval_id,
2039 turn_id,
2040 command,
2041 cwd,
2042 reason: reason.clone(),
2043 matched_rule: matched_rule.map(|rule| rule.to_string().into_boxed_str()),
2044 network_approval_context: None,
2045 proposed_execpolicy_amendment: proposed_execpolicy_amendment
2046 .as_ref()
2047 .map(|amendment| amendment.prefixes.clone())
2048 .unwrap_or_default(),
2049 proposed_network_policy_amendments: proposed_network_policy_amendments.clone(),
2050 additional_permissions: Vec::new(),
2051 available_decisions,
2052 },
2053 })
2054}
2055
2056fn user_input_request_frame(
2064 call_id: String,
2065 turn_id: String,
2066 request_id: String,
2067 arguments: &str,
2068) -> Option<EventFrame> {
2069 let parsed: Value = serde_json::from_str(arguments).ok()?;
2070 let questions = parsed.get("questions").cloned().filter(Value::is_array)?;
2074 let request = UserInputRequestEvent {
2075 call_id,
2076 turn_id,
2077 request_id,
2078 questions: serde_json::from_value(questions).ok()?,
2079 };
2080 Some(EventFrame::UserInputRequest { request })
2081}
2082
2083fn approval_requirement_payload(requirement: &ExecApprovalRequirement) -> Value {
2084 match requirement {
2085 ExecApprovalRequirement::Skip {
2086 bypass_sandbox,
2087 proposed_execpolicy_amendment,
2088 } => json!({
2089 "type": "skip",
2090 "bypass_sandbox": bypass_sandbox,
2091 "reason": requirement.reason(),
2092 "proposed_execpolicy_amendment": proposed_execpolicy_amendment
2093 .as_ref()
2094 .map(|amendment| amendment.prefixes.clone())
2095 .unwrap_or_default()
2096 }),
2097 ExecApprovalRequirement::NeedsApproval {
2098 reason,
2099 proposed_execpolicy_amendment,
2100 proposed_network_policy_amendments,
2101 } => json!({
2102 "type": "needs_approval",
2103 "reason": reason,
2104 "proposed_execpolicy_amendment": proposed_execpolicy_amendment
2105 .as_ref()
2106 .map(|amendment| amendment.prefixes.clone())
2107 .unwrap_or_default(),
2108 "proposed_network_policy_amendments": proposed_network_policy_amendments
2109 }),
2110 ExecApprovalRequirement::Forbidden { reason } => json!({
2111 "type": "forbidden",
2112 "reason": reason
2113 }),
2114 }
2115}
2116
2117fn policy_precheck_payload(
2118 decision: &ExecPolicyDecision,
2119 command: &str,
2120 cwd: &str,
2121 execution_kind: &str,
2122) -> Value {
2123 json!({
2124 "execution_kind": execution_kind,
2125 "command": command,
2126 "cwd": cwd,
2127 "allow": decision.allow,
2128 "requires_approval": decision.requires_approval,
2129 "matched_rule": decision.matched_rule.clone(),
2130 "phase": decision.requirement.phase(),
2131 "reason": decision.reason(),
2132 "requirement": approval_requirement_payload(&decision.requirement)
2133 })
2134}
2135
2136fn tool_payload_value(payload: &ToolPayload) -> Value {
2137 serde_json::to_value(payload).unwrap_or_else(
2138 |_| json!({"type":"serialization_error","message":"tool payload unavailable"}),
2139 )
2140}
2141
2142fn tool_output_value(output: &codewhale_protocol::ToolOutput) -> Value {
2143 serde_json::to_value(output).unwrap_or_else(
2144 |_| json!({"type":"serialization_error","message":"tool output unavailable"}),
2145 )
2146}
2147
2148fn event_frame_payload(frame: &EventFrame) -> Value {
2149 serde_json::to_value(frame)
2150 .unwrap_or_else(|_| json!({"event":"error","message":"failed to encode event frame"}))
2151}
2152
2153const REQUEST_USER_INPUT_TOOL_NAME: &str = "request_user_input";
2159
2160fn json_optional_string(value: &Value) -> Option<String> {
2161 if value.is_null() {
2162 None
2163 } else {
2164 value.as_str().map(ToString::to_string)
2165 }
2166}
2167
2168fn parse_retry_metadata(value: Option<&Value>) -> JobRetryMetadata {
2169 let Some(value) = value else {
2170 return JobRetryMetadata::default();
2171 };
2172 JobRetryMetadata {
2173 attempt: value
2174 .get("attempt")
2175 .and_then(Value::as_u64)
2176 .unwrap_or(0)
2177 .min(u32::MAX as u64) as u32,
2178 max_attempts: value
2179 .get("max_attempts")
2180 .and_then(Value::as_u64)
2181 .unwrap_or(DEFAULT_JOB_MAX_ATTEMPTS as u64)
2182 .min(u32::MAX as u64) as u32,
2183 backoff_base_ms: value
2184 .get("backoff_base_ms")
2185 .and_then(Value::as_u64)
2186 .unwrap_or(DEFAULT_JOB_BACKOFF_BASE_MS),
2187 next_backoff_ms: value
2188 .get("next_backoff_ms")
2189 .and_then(Value::as_u64)
2190 .unwrap_or(0),
2191 next_retry_at: value.get("next_retry_at").and_then(Value::as_i64),
2192 }
2193}
2194
2195fn parse_history_entry(value: &Value) -> Option<JobHistoryEntry> {
2196 let status = value
2197 .get("status")
2198 .and_then(Value::as_str)
2199 .and_then(job_status_from_str)?;
2200 Some(JobHistoryEntry {
2201 at: value.get("at").and_then(Value::as_i64).unwrap_or(0),
2202 phase: value
2203 .get("phase")
2204 .and_then(Value::as_str)
2205 .unwrap_or("unknown")
2206 .to_string(),
2207 status,
2208 progress: value
2209 .get("progress")
2210 .and_then(Value::as_u64)
2211 .map(|v| v.min(u8::MAX as u64) as u8),
2212 detail: value.get("detail").and_then(json_optional_string),
2213 retry: parse_retry_metadata(value.get("retry")),
2214 })
2215}
2216
2217fn job_status_to_str(status: JobStatus) -> &'static str {
2218 match status {
2219 JobStatus::Queued => "queued",
2220 JobStatus::Running => "running",
2221 JobStatus::Paused => "paused",
2222 JobStatus::Completed => "completed",
2223 JobStatus::Failed => "failed",
2224 JobStatus::Cancelled => "cancelled",
2225 }
2226}
2227
2228fn job_status_from_str(value: &str) -> Option<JobStatus> {
2229 match value {
2230 "queued" => Some(JobStatus::Queued),
2231 "running" => Some(JobStatus::Running),
2232 "paused" => Some(JobStatus::Paused),
2233 "completed" => Some(JobStatus::Completed),
2234 "failed" => Some(JobStatus::Failed),
2235 "cancelled" => Some(JobStatus::Cancelled),
2236 _ => None,
2237 }
2238}
2239
2240fn job_retry_to_value(retry: &JobRetryMetadata) -> Value {
2241 json!({
2242 "attempt": retry.attempt,
2243 "max_attempts": retry.max_attempts,
2244 "backoff_base_ms": retry.backoff_base_ms,
2245 "next_backoff_ms": retry.next_backoff_ms,
2246 "next_retry_at": retry.next_retry_at
2247 })
2248}
2249
2250fn job_history_to_value(entry: &JobHistoryEntry) -> Value {
2251 json!({
2252 "at": entry.at,
2253 "phase": entry.phase.clone(),
2254 "status": job_status_to_str(entry.status),
2255 "progress": entry.progress,
2256 "detail": entry.detail.clone(),
2257 "retry": job_retry_to_value(&entry.retry)
2258 })
2259}
2260
2261fn runtime_status_to_job_state(status: JobStatus) -> JobStateStatus {
2262 match status {
2263 JobStatus::Queued => JobStateStatus::Queued,
2264 JobStatus::Running => JobStateStatus::Running,
2265 JobStatus::Paused => JobStateStatus::Paused,
2266 JobStatus::Completed => JobStateStatus::Completed,
2267 JobStatus::Failed => JobStateStatus::Failed,
2268 JobStatus::Cancelled => JobStateStatus::Cancelled,
2269 }
2270}
2271
2272fn job_state_status_to_runtime(status: JobStateStatus) -> JobStatus {
2273 match status {
2274 JobStateStatus::Queued => JobStatus::Queued,
2275 JobStateStatus::Running => JobStatus::Running,
2276 JobStateStatus::Paused => JobStatus::Paused,
2277 JobStateStatus::Completed => JobStatus::Completed,
2278 JobStateStatus::Failed => JobStatus::Failed,
2279 JobStateStatus::Cancelled => JobStatus::Cancelled,
2280 }
2281}
2282
2283#[cfg(test)]
2284mod tests {
2285 use super::*;
2286 use codewhale_protocol::ThreadResumeParams;
2287 use codewhale_tools::ToolCallSource;
2288
2289 fn temp_core_state(name: &str) -> StateStore {
2290 let dir =
2291 std::env::temp_dir().join(format!("codewhale-core-{name}-{}", Uuid::new_v4().simple()));
2292 std::fs::create_dir_all(&dir).expect("create temp state dir");
2293 StateStore::open(Some(dir.join("state.db"))).expect("open state store")
2294 }
2295
2296 fn test_thread_metadata(id: &str) -> ThreadMetadata {
2297 ThreadMetadata {
2298 id: id.to_string(),
2299 rollout_path: None,
2300 preview: "test thread".to_string(),
2301 ephemeral: false,
2302 model_provider: "deepseek".to_string(),
2303 created_at: 10,
2304 updated_at: 10,
2305 status: PersistedThreadStatus::Running,
2306 path: None,
2307 cwd: PathBuf::from("/tmp/codewhale"),
2308 cli_version: "0.0.0-test".to_string(),
2309 source: SessionSource::Interactive,
2310 name: None,
2311 sandbox_policy: None,
2312 approval_mode: None,
2313 archived: false,
2314 archived_at: None,
2315 git_sha: None,
2316 git_branch: None,
2317 git_origin_url: None,
2318 memory_mode: None,
2319 current_leaf_id: None,
2320 }
2321 }
2322
2323 #[test]
2326 fn permission_path_for_call_extracts_function_path_argument() {
2327 let call = ToolCall {
2328 name: "read_file".to_string(),
2329 payload: ToolPayload::Function {
2330 arguments: json!({ "path": "README.md" }).to_string(),
2331 },
2332 source: ToolCallSource::Direct,
2333 raw_tool_call_id: None,
2334 };
2335
2336 assert_eq!(
2337 permission_path_for_call(&call).as_deref(),
2338 Some("README.md")
2339 );
2340 }
2341
2342 #[test]
2343 fn permission_path_for_call_extracts_mcp_path_argument() {
2344 let call = ToolCall {
2345 name: "mcp_fs_read".to_string(),
2346 payload: ToolPayload::Mcp {
2347 server: "fs".to_string(),
2348 tool: "read".to_string(),
2349 raw_arguments: json!({ "path": "secrets/token.txt" }),
2350 raw_tool_call_id: None,
2351 },
2352 source: ToolCallSource::Direct,
2353 raw_tool_call_id: None,
2354 };
2355
2356 assert_eq!(
2357 permission_path_for_call(&call).as_deref(),
2358 Some("secrets/token.txt")
2359 );
2360 }
2361
2362 #[test]
2363 fn permission_path_for_call_ignores_shell_payload() {
2364 let call = ToolCall {
2365 name: "exec_shell".to_string(),
2366 payload: ToolPayload::LocalShell {
2367 params: codewhale_protocol::LocalShellParams {
2368 command: "cargo test".to_string(),
2369 cwd: None,
2370 timeout_ms: None,
2371 },
2372 },
2373 source: ToolCallSource::Direct,
2374 raw_tool_call_id: None,
2375 };
2376
2377 assert_eq!(permission_path_for_call(&call), None);
2378 }
2379
2380 #[test]
2381 fn thread_goal_progress_accumulates_durable_accounting() {
2382 let store = temp_core_state("thread-goal-progress");
2383 store
2384 .upsert_thread(&test_thread_metadata("thread-1"))
2385 .expect("upsert thread");
2386 let mut manager = ThreadManager::new(store);
2387 manager
2388 .set_thread_goal(&ThreadGoalSetParams {
2389 thread_id: "thread-1".to_string(),
2390 objective: "Carry the goal across turns".to_string(),
2391 token_budget: Some(2_000),
2392 })
2393 .expect("set goal")
2394 .expect("goal exists");
2395
2396 let updated = manager
2397 .record_thread_goal_progress(&ThreadGoalProgressParams {
2398 thread_id: "thread-1".to_string(),
2399 token_delta: 750,
2400 time_delta_seconds: 12,
2401 record_continuation: true,
2402 })
2403 .expect("record progress")
2404 .expect("goal exists");
2405
2406 assert_eq!(updated.tokens_used, 750);
2407 assert_eq!(updated.time_used_seconds, 12);
2408 assert_eq!(updated.continuation_count, 1);
2409
2410 let persisted = manager
2411 .get_thread_goal(&ThreadGoalGetParams {
2412 thread_id: "thread-1".to_string(),
2413 })
2414 .expect("read goal")
2415 .expect("goal exists");
2416 assert_eq!(persisted.tokens_used, 750);
2417 assert_eq!(persisted.time_used_seconds, 12);
2418 assert_eq!(persisted.continuation_count, 1);
2419 }
2420
2421 #[test]
2422 fn approval_request_frame_includes_matched_rule() {
2423 let requirement = ExecApprovalRequirement::NeedsApproval {
2424 reason: "Typed ask rule 'tool=exec_shell command=cargo test' requires approval."
2425 .to_string(),
2426 proposed_execpolicy_amendment: None,
2427 proposed_network_policy_amendments: Vec::new(),
2428 };
2429
2430 let frame = approval_request_frame(
2431 &requirement,
2432 Some("tool=exec_shell command=cargo test"),
2433 "call-1".to_string(),
2434 "approval-1".to_string(),
2435 "turn-1".to_string(),
2436 "cargo test --workspace".to_string(),
2437 "/repo".to_string(),
2438 )
2439 .expect("approval frame");
2440
2441 let EventFrame::ExecApprovalRequest { request } = frame else {
2442 panic!("expected exec approval request frame");
2443 };
2444 assert_eq!(
2445 request.matched_rule.as_deref(),
2446 Some("tool=exec_shell command=cargo test")
2447 );
2448 assert_eq!(request.reason, requirement.reason());
2449 }
2450
2451 #[test]
2452 fn user_input_request_frame_lifts_questions_from_arguments() {
2453 let arguments = r#"{"questions":[{"header":"Scope","id":"scope","question":"Which?","options":[{"label":"A","description":"a"},{"label":"B","description":"b"}],"allow_free_text":true}]}"#;
2457 let frame = user_input_request_frame(
2458 "call-1".to_string(),
2459 "turn-1".to_string(),
2460 "ui-1".to_string(),
2461 arguments,
2462 )
2463 .expect("user input frame");
2464
2465 let EventFrame::UserInputRequest { request } = frame else {
2466 panic!("expected user_input_request frame");
2467 };
2468 assert_eq!(request.call_id, "call-1");
2469 assert_eq!(request.turn_id, "turn-1");
2470 assert_eq!(request.request_id, "ui-1");
2471 assert_eq!(request.questions.len(), 1);
2472 assert_eq!(request.questions[0].id, "scope");
2473 assert!(request.questions[0].allow_free_text);
2474 assert!(!request.questions[0].multi_select);
2476 assert_eq!(request.questions[0].options.len(), 2);
2477 }
2478
2479 #[test]
2480 fn user_input_request_frame_returns_none_on_invalid_arguments() {
2481 let frame = user_input_request_frame(
2484 "call-1".to_string(),
2485 "turn-1".to_string(),
2486 "ui-1".to_string(),
2487 "not json",
2488 );
2489 assert!(frame.is_none());
2490
2491 let frame = user_input_request_frame(
2493 "call-1".to_string(),
2494 "turn-1".to_string(),
2495 "ui-1".to_string(),
2496 r#"{"foo":"bar"}"#,
2497 );
2498 assert!(frame.is_none());
2499 }
2500
2501 #[test]
2502 fn enqueue_creates_queued_job_with_zero_progress() {
2503 let mut jm = JobManager::default();
2504 let job = jm.enqueue("build");
2505 assert_eq!(job.name, "build");
2506 assert_eq!(job.status, JobStatus::Queued);
2507 assert_eq!(job.progress, Some(0));
2508 assert!(job.detail.is_none());
2509 assert_eq!(job.history.len(), 1);
2510 assert_eq!(job.history[0].phase, "created");
2511 }
2512
2513 #[test]
2514 fn set_running_transitions_from_queued() {
2515 let mut jm = JobManager::default();
2516 let job = jm.enqueue("deploy");
2517 let id = job.id.clone();
2518 jm.set_running(&id);
2519 let jobs = jm.list();
2520 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2521 assert_eq!(updated.status, JobStatus::Running);
2522 assert_eq!(updated.history.last().unwrap().phase, "running");
2523 }
2524
2525 #[test]
2526 fn update_progress_clamps_to_100() {
2527 let mut jm = JobManager::default();
2528 let job = jm.enqueue("task");
2529 let id = job.id.clone();
2530 jm.update_progress(&id, 150, Some("over".to_string()));
2531 let jobs = jm.list();
2532 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2533 assert_eq!(updated.progress, Some(100));
2534 }
2535
2536 #[test]
2537 fn complete_sets_progress_to_100() {
2538 let mut jm = JobManager::default();
2539 let job = jm.enqueue("task");
2540 let id = job.id.clone();
2541 jm.set_running(&id);
2542 jm.complete(&id);
2543 let jobs = jm.list();
2544 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2545 assert_eq!(updated.status, JobStatus::Completed);
2546 assert_eq!(updated.progress, Some(100));
2547 }
2548
2549 #[test]
2550 fn fail_increments_attempt_and_sets_backoff() {
2551 let mut jm = JobManager::default();
2552 let job = jm.enqueue("fragile");
2553 let id = job.id.clone();
2554 jm.set_running(&id);
2555 jm.fail(&id, "crashed");
2556 let jobs = jm.list();
2557 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2558 assert_eq!(updated.status, JobStatus::Failed);
2559 assert_eq!(updated.retry.attempt, 1);
2560 assert!(updated.retry.next_backoff_ms > 0);
2561 assert!(updated.retry.next_retry_at.is_some());
2562 assert_eq!(updated.detail.as_deref(), Some("crashed"));
2563 }
2564
2565 #[test]
2566 fn fail_clears_retry_after_max_attempts() {
2567 let mut jm = JobManager::default();
2568 let job = jm.enqueue("fragile");
2569 let id = job.id.clone();
2570 for _ in 0..=DEFAULT_JOB_MAX_ATTEMPTS {
2571 jm.set_running(&id);
2572 jm.fail(&id, "boom");
2573 }
2574 let jobs = jm.list();
2575 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2576 assert_eq!(updated.retry.attempt, DEFAULT_JOB_MAX_ATTEMPTS);
2577 assert_eq!(updated.retry.next_backoff_ms, 0);
2578 assert!(updated.retry.next_retry_at.is_none());
2579 }
2580
2581 #[test]
2582 fn cancel_sets_status_and_clears_retry() {
2583 let mut jm = JobManager::default();
2584 let job = jm.enqueue("task");
2585 let id = job.id.clone();
2586 jm.cancel(&id);
2587 let jobs = jm.list();
2588 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2589 assert_eq!(updated.status, JobStatus::Cancelled);
2590 assert_eq!(updated.retry.next_backoff_ms, 0);
2591 }
2592
2593 #[test]
2594 fn pause_and_resume_round_trip() {
2595 let mut jm = JobManager::default();
2596 let job = jm.enqueue("task");
2597 let id = job.id.clone();
2598 jm.set_running(&id);
2599 jm.pause(&id, Some("waiting".to_string()));
2600 let jobs = jm.list();
2601 let paused = jobs.iter().find(|j| j.id == id).unwrap();
2602 assert_eq!(paused.status, JobStatus::Paused);
2603 assert_eq!(paused.detail.as_deref(), Some("waiting"));
2604
2605 jm.resume(&id, None);
2606 let jobs = jm.list();
2607 let resumed = jobs.iter().find(|j| j.id == id).unwrap();
2608 assert_eq!(resumed.status, JobStatus::Running);
2609 assert_eq!(resumed.history.last().unwrap().phase, "resumed");
2610 }
2611
2612 #[test]
2613 fn list_returns_jobs_sorted_by_updated_at_desc() {
2614 let mut jm = JobManager::default();
2615 jm.enqueue("first");
2616 jm.enqueue("second");
2617 jm.enqueue("third");
2618 let jobs = jm.list();
2619 assert_eq!(jobs.len(), 3);
2620 for window in jobs.windows(2) {
2621 assert!(window[0].updated_at >= window[1].updated_at);
2622 }
2623 }
2624
2625 #[test]
2626 fn history_returns_entries_for_existing_job() {
2627 let mut jm = JobManager::default();
2628 let job = jm.enqueue("task");
2629 let id = job.id.clone();
2630 jm.set_running(&id);
2631 jm.complete(&id);
2632 let history = jm.history(&id);
2633 assert_eq!(history.len(), 3); assert_eq!(history[0].phase, "created");
2635 assert_eq!(history[1].phase, "running");
2636 assert_eq!(history[2].phase, "completed");
2637 }
2638
2639 #[test]
2640 fn history_returns_empty_for_unknown_job() {
2641 let jm = JobManager::default();
2642 assert!(jm.history("nonexistent").is_empty());
2643 }
2644
2645 #[test]
2646 fn resume_pending_requeues_running_and_queued() {
2647 let mut jm = JobManager::default();
2648 let _j1 = jm.enqueue("queued_task");
2649 let j2 = jm.enqueue("running_task");
2650 let j3 = jm.enqueue("completed_task");
2651 let id2 = j2.id.clone();
2652 let id3 = j3.id.clone();
2653 jm.set_running(&id2);
2654 jm.set_running(&id3);
2655 jm.complete(&id3);
2656
2657 let resumed = jm.resume_pending();
2658 assert_eq!(resumed.len(), 2);
2659 for job in &resumed {
2660 assert_eq!(job.status, JobStatus::Queued);
2661 }
2662 }
2663
2664 #[test]
2667 fn deterministic_backoff_zero_on_first_attempt() {
2668 let retry = JobRetryMetadata {
2669 attempt: 0,
2670 ..Default::default()
2671 };
2672 assert_eq!(JobManager::deterministic_backoff_ms(&retry), 0);
2673 }
2674
2675 #[test]
2676 fn deterministic_backoff_exponential_growth() {
2677 let base = DEFAULT_JOB_BACKOFF_BASE_MS;
2678 for attempt in 1..=5 {
2679 let retry = JobRetryMetadata {
2680 attempt,
2681 backoff_base_ms: base,
2682 ..Default::default()
2683 };
2684 let expected = base * 2u64.pow(attempt.saturating_sub(1).min(20));
2685 assert_eq!(
2686 JobManager::deterministic_backoff_ms(&retry),
2687 expected,
2688 "attempt {attempt}"
2689 );
2690 }
2691 }
2692
2693 #[test]
2694 fn deterministic_backoff_saturates_at_high_exponent() {
2695 let retry = JobRetryMetadata {
2696 attempt: 63,
2697 backoff_base_ms: 1000,
2698 ..Default::default()
2699 };
2700 let _ = JobManager::deterministic_backoff_ms(&retry);
2702 }
2703
2704 #[test]
2707 fn push_history_truncates_beyond_max() {
2708 let mut jm = JobManager::default();
2709 let job = jm.enqueue("task");
2710 let id = job.id.clone();
2711 for i in 0..(MAX_JOB_HISTORY_ENTRIES + 20) {
2713 jm.update_progress(&id, (i % 100) as u8, Some(format!("step {i}")));
2714 }
2715 let history = jm.history(&id);
2716 assert_eq!(history.len(), MAX_JOB_HISTORY_ENTRIES);
2717 }
2718
2719 #[test]
2722 fn encode_and_parse_persisted_detail_round_trip() {
2723 let mut jm = JobManager::default();
2724 let job = jm.enqueue("task");
2725 let id = job.id.clone();
2726 jm.set_running(&id);
2727 jm.fail(&id, "oops");
2728 let job = jm.list().into_iter().find(|j| j.id == id).unwrap();
2729
2730 let encoded = JobManager::encode_persisted_detail(&job).unwrap().unwrap();
2731 let parsed = JobManager::parse_persisted_detail(Some(&encoded)).unwrap();
2732
2733 assert_eq!(parsed.status, job.status);
2734 assert_eq!(parsed.detail, job.detail);
2735 assert_eq!(parsed.retry.attempt, job.retry.attempt);
2736 assert_eq!(parsed.history.len(), job.history.len());
2737 }
2738
2739 #[test]
2740 fn parse_persisted_detail_returns_none_for_none_input() {
2741 assert!(JobManager::parse_persisted_detail(None).is_none());
2742 }
2743
2744 #[test]
2745 fn parse_persisted_detail_returns_none_for_invalid_json() {
2746 assert!(JobManager::parse_persisted_detail(Some("not json")).is_none());
2747 }
2748
2749 #[test]
2752 fn job_status_round_trip_str() {
2753 let statuses = [
2754 JobStatus::Queued,
2755 JobStatus::Running,
2756 JobStatus::Paused,
2757 JobStatus::Completed,
2758 JobStatus::Failed,
2759 JobStatus::Cancelled,
2760 ];
2761 for status in &statuses {
2762 let s = job_status_to_str(*status);
2763 let parsed = job_status_from_str(s);
2764 assert_eq!(parsed, Some(*status), "round-trip failed for {s:?}");
2765 }
2766 }
2767
2768 #[test]
2769 fn job_status_from_str_returns_none_for_unknown() {
2770 assert_eq!(job_status_from_str("unknown"), None);
2771 assert_eq!(job_status_from_str(""), None);
2772 }
2773
2774 #[test]
2775 fn truncate_preview_limits_to_120_chars() {
2776 let long = "a".repeat(200);
2777 let truncated = truncate_preview(&long);
2778 assert_eq!(truncated.len(), 120);
2779 }
2780
2781 #[test]
2782 fn truncate_preview_preserves_short_strings() {
2783 let short = "hello";
2784 assert_eq!(truncate_preview(short), "hello");
2785 }
2786
2787 #[test]
2788 fn runtime_status_to_job_state_maps_correctly() {
2789 assert_eq!(
2790 runtime_status_to_job_state(JobStatus::Queued),
2791 JobStateStatus::Queued
2792 );
2793 assert_eq!(
2794 runtime_status_to_job_state(JobStatus::Running),
2795 JobStateStatus::Running
2796 );
2797 assert_eq!(
2798 runtime_status_to_job_state(JobStatus::Paused),
2799 JobStateStatus::Paused
2800 );
2801 assert_eq!(
2802 runtime_status_to_job_state(JobStatus::Completed),
2803 JobStateStatus::Completed
2804 );
2805 assert_eq!(
2806 runtime_status_to_job_state(JobStatus::Failed),
2807 JobStateStatus::Failed
2808 );
2809 assert_eq!(
2810 runtime_status_to_job_state(JobStatus::Cancelled),
2811 JobStateStatus::Cancelled
2812 );
2813 }
2814
2815 #[test]
2816 fn job_state_status_to_runtime_maps_correctly() {
2817 assert_eq!(
2818 job_state_status_to_runtime(JobStateStatus::Queued),
2819 JobStatus::Queued
2820 );
2821 assert_eq!(
2822 job_state_status_to_runtime(JobStateStatus::Running),
2823 JobStatus::Running
2824 );
2825 assert_eq!(
2826 job_state_status_to_runtime(JobStateStatus::Paused),
2827 JobStatus::Paused
2828 );
2829 assert_eq!(
2830 job_state_status_to_runtime(JobStateStatus::Completed),
2831 JobStatus::Completed
2832 );
2833 assert_eq!(
2834 job_state_status_to_runtime(JobStateStatus::Failed),
2835 JobStatus::Failed
2836 );
2837 assert_eq!(
2838 job_state_status_to_runtime(JobStateStatus::Cancelled),
2839 JobStatus::Cancelled
2840 );
2841 }
2842
2843 #[test]
2844 fn preview_from_initial_history_new() {
2845 let preview = preview_from_initial_history(&InitialHistory::New);
2846 assert_eq!(preview, "New conversation");
2847 }
2848
2849 #[test]
2850 fn preview_from_initial_history_forked() {
2851 let preview = preview_from_initial_history(&InitialHistory::Forked(vec![json!("hello")]));
2852 assert!(preview.contains("hello"));
2853 }
2854
2855 #[test]
2856 fn preview_from_initial_history_resumed() {
2857 let preview = preview_from_initial_history(&InitialHistory::Resumed {
2858 conversation_id: "test".to_string(),
2859 history: vec![json!("world")],
2860 rollout_path: PathBuf::from("/tmp/test"),
2861 });
2862 assert!(preview.contains("world"));
2863 }
2864
2865 #[test]
2866 fn json_optional_string_handles_null() {
2867 assert!(json_optional_string(&Value::Null).is_none());
2868 }
2869
2870 #[test]
2871 fn json_optional_string_handles_string() {
2872 assert_eq!(
2873 json_optional_string(&Value::String("hello".to_string())),
2874 Some("hello".to_string())
2875 );
2876 }
2877
2878 #[test]
2879 fn json_optional_string_handles_non_string() {
2880 assert!(json_optional_string(&json!(42)).is_none());
2881 }
2882
2883 #[test]
2884 fn parse_retry_metadata_returns_default_for_none() {
2885 let retry = parse_retry_metadata(None);
2886 assert_eq!(retry.attempt, 0);
2887 assert_eq!(retry.max_attempts, DEFAULT_JOB_MAX_ATTEMPTS);
2888 assert_eq!(retry.backoff_base_ms, DEFAULT_JOB_BACKOFF_BASE_MS);
2889 }
2890
2891 #[test]
2892 fn parse_retry_metadata_parses_fields() {
2893 let value = json!({
2894 "attempt": 2,
2895 "max_attempts": 5,
2896 "backoff_base_ms": 1000,
2897 "next_backoff_ms": 2000,
2898 "next_retry_at": 1234567890i64
2899 });
2900 let retry = parse_retry_metadata(Some(&value));
2901 assert_eq!(retry.attempt, 2);
2902 assert_eq!(retry.max_attempts, 5);
2903 assert_eq!(retry.backoff_base_ms, 1000);
2904 assert_eq!(retry.next_backoff_ms, 2000);
2905 assert_eq!(retry.next_retry_at, Some(1234567890));
2906 }
2907
2908 #[test]
2909 fn parse_history_entry_returns_none_without_status() {
2910 let value = json!({"at": 1, "phase": "test"});
2911 assert!(parse_history_entry(&value).is_none());
2912 }
2913
2914 #[test]
2915 fn parse_history_entry_parses_valid_entry() {
2916 let value = json!({
2917 "at": 100,
2918 "phase": "running",
2919 "status": "running",
2920 "progress": 50,
2921 "detail": "working",
2922 "retry": {"attempt": 0, "max_attempts": 3, "backoff_base_ms": 500}
2923 });
2924 let entry = parse_history_entry(&value).unwrap();
2925 assert_eq!(entry.at, 100);
2926 assert_eq!(entry.phase, "running");
2927 assert_eq!(entry.status, JobStatus::Running);
2928 assert_eq!(entry.progress, Some(50));
2929 assert_eq!(entry.detail.as_deref(), Some("working"));
2930 }
2931
2932 #[test]
2933 fn paused_job_persists_as_paused_not_running() {
2934 let store = temp_core_state("paused-persist");
2935 let mut jm = JobManager::default();
2936 let job = jm.enqueue("task");
2937 let id = job.id.clone();
2938 jm.set_running(&id);
2939 jm.pause(&id, Some("waiting".to_string()));
2940 jm.persist_job(&store, &id).expect("persist paused job");
2941
2942 let persisted = store.list_jobs(Some(10)).expect("list jobs");
2943 let record = persisted.iter().find(|job| job.id == id).unwrap();
2944 assert_eq!(record.status, JobStateStatus::Paused);
2945
2946 let mut reloaded = JobManager::default();
2947 reloaded.load_from_store(&store).expect("reload jobs");
2948 let jobs = reloaded.list();
2949 let reloaded_job = jobs.iter().find(|job| job.id == id).unwrap();
2950 assert_eq!(reloaded_job.status, JobStatus::Paused);
2951 }
2952
2953 fn sample_job_record(status: JobStatus, detail: Option<&str>) -> JobRecord {
2956 JobRecord {
2957 id: "job-o1-1".to_string(),
2958 name: "sample".to_string(),
2959 status,
2960 progress: None,
2961 detail: detail.map(str::to_string),
2962 retry: JobRetryMetadata {
2963 attempt: 0,
2964 max_attempts: DEFAULT_JOB_MAX_ATTEMPTS,
2965 backoff_base_ms: DEFAULT_JOB_BACKOFF_BASE_MS,
2966 next_backoff_ms: 0,
2967 next_retry_at: None,
2968 },
2969 history: Vec::new(),
2970 created_at: 1_700_000_000,
2971 updated_at: 1_700_000_042,
2972 }
2973 }
2974
2975 #[test]
2976 fn job_record_to_agent_run_maps_non_terminal_states() {
2977 use codewhale_protocol::agent_run::RunState;
2978
2979 for (status, expected) in [
2980 (JobStatus::Queued, RunState::Queued),
2981 (JobStatus::Running, RunState::Running),
2982 (JobStatus::Paused, RunState::Paused),
2983 ] {
2984 let snapshot = job_record_to_agent_run(&sample_job_record(status, None));
2985 assert!(snapshot.is_coherent());
2986 assert_eq!(snapshot.run_id, "job-o1-1");
2987 assert_eq!(snapshot.parent, None);
2988 assert_eq!(
2989 snapshot.source,
2990 codewhale_protocol::agent_run::RunSource::CoreJob
2991 );
2992 assert_eq!(snapshot.state, expected);
2993 assert!(snapshot.terminal.is_none());
2994 assert!(snapshot.refs.is_empty());
2995 assert_eq!(
2996 snapshot.budget,
2997 codewhale_protocol::agent_run::BudgetSummary::default()
2998 );
2999 }
3000 }
3001
3002 #[test]
3003 fn job_record_to_agent_run_maps_terminal_states_without_fabricating_fields() {
3004 use codewhale_protocol::agent_run::{RunState, TerminalOutcome};
3005
3006 let cases = [
3007 (
3008 JobStatus::Completed,
3009 TerminalOutcome::Completed,
3010 Some("done"),
3011 ),
3012 (JobStatus::Failed, TerminalOutcome::Failed, Some("boom")),
3013 (JobStatus::Cancelled, TerminalOutcome::Cancelled, None),
3014 ];
3015
3016 for (status, outcome, detail) in cases {
3017 let snapshot = job_record_to_agent_run(&sample_job_record(status, detail));
3018 assert!(snapshot.is_coherent());
3019 assert_eq!(snapshot.state, RunState::Terminal);
3020 let terminal = snapshot.terminal.expect("terminal summary");
3021 assert_eq!(terminal.outcome, outcome);
3022 assert_eq!(terminal.ended_at_ms, Some(1_700_000_042_000));
3023 assert_eq!(terminal.detail, None);
3024 assert_eq!(
3025 snapshot.budget,
3026 codewhale_protocol::agent_run::BudgetSummary::default()
3027 );
3028 assert!(snapshot.refs.is_empty());
3029 assert_eq!(snapshot.parent, None);
3030 }
3031 }
3032
3033 #[test]
3034 fn job_record_to_agent_run_does_not_export_unclassified_detail() {
3035 let record = sample_job_record(JobStatus::Failed, Some("owner-private diagnostic"));
3036 let snapshot = job_record_to_agent_run(&record);
3037 let terminal = snapshot.terminal.as_ref().expect("terminal summary");
3038 assert_eq!(terminal.detail, None);
3039 let serialized = serde_json::to_string(&snapshot).expect("serialize snapshot");
3040 assert!(!serialized.contains("owner-private diagnostic"));
3041 }
3042
3043 #[test]
3044 fn job_record_to_agent_run_omits_ended_at_on_updated_at_overflow() {
3045 let mut record = sample_job_record(JobStatus::Completed, Some("ok"));
3046 record.updated_at = i64::MAX;
3047 let snapshot = job_record_to_agent_run(&record);
3048 assert!(snapshot.is_coherent());
3049 let terminal = snapshot.terminal.expect("terminal summary");
3050 assert_eq!(terminal.ended_at_ms, None);
3051 }
3052
3053 #[test]
3054 fn unarchive_thread_updates_running_threads_cache() {
3055 let store = temp_core_state("unarchive-cache");
3056 let mut manager = ThreadManager::new(store);
3057 let spawned = manager
3058 .spawn_thread_with_history(
3059 "deepseek".to_string(),
3060 PathBuf::from("/tmp/codewhale"),
3061 InitialHistory::New,
3062 true,
3063 )
3064 .expect("spawn thread");
3065 let thread_id = spawned.thread.id.clone();
3066 let resume_params = ThreadResumeParams {
3067 thread_id: thread_id.clone(),
3068 history: None,
3069 path: None,
3070 model: None,
3071 model_provider: None,
3072 cwd: None,
3073 approval_policy: None,
3074 sandbox: None,
3075 config: None,
3076 base_instructions: None,
3077 developer_instructions: None,
3078 personality: None,
3079 persist_extended_history: false,
3080 };
3081
3082 manager.archive_thread(&thread_id).expect("archive thread");
3083 let archived = manager
3084 .resume_thread_with_history(
3085 &resume_params,
3086 Path::new("/tmp/codewhale"),
3087 "deepseek".to_string(),
3088 )
3089 .expect("resume archived thread")
3090 .expect("thread in cache");
3091 assert_eq!(archived.thread.status, ThreadStatus::Archived);
3092
3093 manager
3094 .unarchive_thread(&thread_id)
3095 .expect("unarchive thread");
3096 let restored = manager
3097 .resume_thread_with_history(
3098 &resume_params,
3099 Path::new("/tmp/codewhale"),
3100 "deepseek".to_string(),
3101 )
3102 .expect("resume unarchived thread")
3103 .expect("thread in cache");
3104 assert_eq!(restored.thread.status, ThreadStatus::Idle);
3105 }
3106
3107 #[tokio::test]
3108 async fn invoke_tool_returns_timeout_status_for_slow_tools() {
3109 use async_trait::async_trait;
3110 use codewhale_agent::ModelRegistry;
3111 use codewhale_config::ConfigToml;
3112 use codewhale_execpolicy::{AskForApproval, ExecPolicyEngine};
3113 use codewhale_hooks::HookDispatcher;
3114 use codewhale_mcp::McpManager;
3115 use codewhale_protocol::{ToolKind, ToolOutput, ToolPayload};
3116 use codewhale_tools::{FunctionCallError, ToolDescriptor, ToolHandler, ToolInvocation};
3117
3118 struct SlowTool;
3119 #[async_trait]
3120 impl ToolHandler for SlowTool {
3121 fn kind(&self) -> ToolKind {
3122 ToolKind::Function
3123 }
3124
3125 async fn handle(
3126 &self,
3127 _invocation: ToolInvocation,
3128 ) -> std::result::Result<ToolOutput, FunctionCallError> {
3129 time::sleep(Duration::from_millis(200)).await;
3130 Ok(ToolOutput::Function {
3131 body: Some(json!("late")),
3132 success: true,
3133 })
3134 }
3135 }
3136
3137 let mut registry = ToolRegistry::default();
3138 registry
3139 .register(
3140 ToolDescriptor {
3141 name: "slow_tool".to_string(),
3142 input_schema: json!({"type":"object"}),
3143 output_schema: json!({"type":"object"}),
3144 supports_parallel_tool_calls: true,
3145 timeout_ms: None,
3146 },
3147 Arc::new(SlowTool),
3148 )
3149 .expect("register slow tool");
3150
3151 let runtime = Runtime::new(
3152 ConfigToml::default(),
3153 ModelRegistry::default(),
3154 temp_core_state("invoke-tool-timeout"),
3155 Arc::new(registry),
3156 Arc::new(McpManager::default()),
3157 ExecPolicyEngine::new(vec![], vec![]),
3158 HookDispatcher::default(),
3159 );
3160
3161 let result = runtime
3162 .invoke_tool(
3163 ToolCall {
3164 name: "slow_tool".to_string(),
3165 payload: ToolPayload::Function {
3166 arguments: "{}".to_string(),
3167 },
3168 source: ToolCallSource::Direct,
3169 raw_tool_call_id: None,
3170 },
3171 AskForApproval::Never,
3172 Path::new("/tmp/codewhale"),
3173 )
3174 .await
3175 .expect("invoke tool");
3176
3177 assert_eq!(result["status"], "timeout");
3178 assert_eq!(result["ok"], false);
3179 }
3180}