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 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 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 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 pub fn provider_default(&self) -> ProviderKind {
1766 self.config.provider
1767 }
1768
1769 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 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 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 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 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 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 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 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 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 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 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
2054fn 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 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
2151const 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 #[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 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 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 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 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); 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 #[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 let _ = JobManager::deterministic_backoff_ms(&retry);
2700 }
2701
2702 #[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 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 #[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 #[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 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}