1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use anyhow::Result;
6use codewhale_agent::ModelRegistry;
7use codewhale_config::{CliRuntimeOverrides, ConfigToml, ProviderKind};
8use codewhale_execpolicy::{
9 AskForApproval, ExecApprovalRequirement, ExecPolicyContext, ExecPolicyDecision,
10 ExecPolicyEngine,
11};
12use codewhale_hooks::{HookDispatcher, HookEvent};
13use codewhale_mcp::{
14 McpManager, McpStartupCompleteEvent, McpStartupStatus as McpManagerStartupStatus,
15};
16use codewhale_protocol::{
17 AppResponse, EventFrame, ExecApprovalRequestEvent, PromptRequest, PromptResponse,
18 ResponseChannel, ReviewDecision, Thread, ThreadForkParams, ThreadGoal, ThreadGoalClearParams,
19 ThreadGoalGetParams, ThreadGoalProgressParams, ThreadGoalSetParams, ThreadGoalStatus,
20 ThreadListParams, ThreadReadParams, ThreadRequest, ThreadResponse, ThreadResumeParams,
21 ThreadSetNameParams, ThreadStatus, ToolPayload, UserInputRequestEvent,
22};
23use codewhale_state::{
24 JobStateRecord, JobStateStatus, SessionSource, StateStore, ThreadGoalRecord,
25 ThreadGoalStatus as PersistedThreadGoalStatus, ThreadListFilters, ThreadMetadata,
26 ThreadStatus as PersistedThreadStatus,
27};
28use codewhale_tools::{ToolCall, ToolRegistry};
29use serde_json::{Value, json};
30use uuid::Uuid;
31
32#[derive(Debug, Clone)]
34pub enum InitialHistory {
35 New,
37 Forked(Vec<Value>),
39 Resumed {
41 conversation_id: String,
42 history: Vec<Value>,
43 rollout_path: PathBuf,
44 },
45}
46
47#[derive(Debug, Clone)]
49pub struct NewThread {
50 pub thread: Thread,
52 pub model: String,
54 pub model_provider: String,
56 pub cwd: PathBuf,
58 pub approval_policy: Option<String>,
60 pub sandbox: Option<String>,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum JobStatus {
67 Queued,
69 Running,
71 Paused,
73 Completed,
75 Failed,
77 Cancelled,
79}
80
81const JOB_DETAIL_SCHEMA_VERSION: u8 = 1;
82const DEFAULT_JOB_MAX_ATTEMPTS: u32 = 3;
83const DEFAULT_JOB_BACKOFF_BASE_MS: u64 = 500;
84const MAX_JOB_HISTORY_ENTRIES: usize = 64;
85
86#[derive(Debug, Clone)]
88pub struct JobRetryMetadata {
89 pub attempt: u32,
91 pub max_attempts: u32,
93 pub backoff_base_ms: u64,
95 pub next_backoff_ms: u64,
97 pub next_retry_at: Option<i64>,
99}
100
101impl Default for JobRetryMetadata {
102 fn default() -> Self {
103 Self {
104 attempt: 0,
105 max_attempts: DEFAULT_JOB_MAX_ATTEMPTS,
106 backoff_base_ms: DEFAULT_JOB_BACKOFF_BASE_MS,
107 next_backoff_ms: 0,
108 next_retry_at: None,
109 }
110 }
111}
112
113#[derive(Debug, Clone)]
115pub struct JobHistoryEntry {
116 pub at: i64,
118 pub phase: String,
120 pub status: JobStatus,
122 pub progress: Option<u8>,
124 pub detail: Option<String>,
126 pub retry: JobRetryMetadata,
128}
129
130#[derive(Debug, Clone)]
131struct PersistedJobDetail {
132 pub status: JobStatus,
133 pub detail: Option<String>,
134 pub retry: JobRetryMetadata,
135 pub history: Vec<JobHistoryEntry>,
136}
137
138#[derive(Debug, Clone)]
140pub struct JobRecord {
141 pub id: String,
143 pub name: String,
145 pub status: JobStatus,
147 pub progress: Option<u8>,
149 pub detail: Option<String>,
151 pub retry: JobRetryMetadata,
153 pub history: Vec<JobHistoryEntry>,
155 pub created_at: i64,
157 pub updated_at: i64,
159}
160
161#[derive(Debug, Default)]
163pub struct JobManager {
164 jobs: HashMap<String, JobRecord>,
165}
166
167impl JobManager {
168 fn now_ts() -> i64 {
169 chrono::Utc::now().timestamp()
170 }
171
172 fn deterministic_backoff_ms(retry: &JobRetryMetadata) -> u64 {
173 if retry.attempt == 0 {
174 return 0;
175 }
176 let exponent = retry.attempt.saturating_sub(1).min(20);
177 let multiplier = 1u64.checked_shl(exponent).unwrap_or(u64::MAX);
178 retry.backoff_base_ms.saturating_mul(multiplier)
179 }
180
181 fn clear_retry_schedule(retry: &mut JobRetryMetadata) {
182 retry.next_backoff_ms = 0;
183 retry.next_retry_at = None;
184 }
185
186 fn push_history(job: &mut JobRecord, phase: &str) {
187 job.history.push(JobHistoryEntry {
188 at: job.updated_at,
189 phase: phase.to_string(),
190 status: job.status,
191 progress: job.progress,
192 detail: job.detail.clone(),
193 retry: job.retry.clone(),
194 });
195 if job.history.len() > MAX_JOB_HISTORY_ENTRIES {
196 let to_drain = job.history.len() - MAX_JOB_HISTORY_ENTRIES;
197 job.history.drain(0..to_drain);
198 }
199 }
200
201 fn parse_persisted_detail(raw: Option<&str>) -> Option<PersistedJobDetail> {
202 let raw = raw?;
203 let parsed: Value = serde_json::from_str(raw).ok()?;
204 let status = parsed
205 .get("status")
206 .and_then(Value::as_str)
207 .and_then(job_status_from_str)?;
208 let detail = parsed.get("detail").and_then(json_optional_string);
209 let retry = parse_retry_metadata(parsed.get("retry"));
210 let history = parsed
211 .get("history")
212 .and_then(Value::as_array)
213 .map(|items| {
214 items
215 .iter()
216 .filter_map(parse_history_entry)
217 .collect::<Vec<_>>()
218 })
219 .unwrap_or_default();
220 Some(PersistedJobDetail {
221 status,
222 detail,
223 retry,
224 history,
225 })
226 }
227
228 fn encode_persisted_detail(job: &JobRecord) -> Result<Option<String>> {
229 let encoded = json!({
230 "schema_version": JOB_DETAIL_SCHEMA_VERSION,
231 "status": job_status_to_str(job.status),
232 "detail": job.detail.clone(),
233 "retry": job_retry_to_value(&job.retry),
234 "history": job.history.iter().map(job_history_to_value).collect::<Vec<_>>()
235 })
236 .to_string();
237 Ok(Some(encoded))
238 }
239
240 pub fn enqueue(&mut self, name: impl Into<String>) -> JobRecord {
242 let now = Self::now_ts();
243 let id = format!("job-{}", Uuid::new_v4());
244 let mut job = JobRecord {
245 id: id.clone(),
246 name: name.into(),
247 status: JobStatus::Queued,
248 progress: Some(0),
249 detail: None,
250 retry: JobRetryMetadata::default(),
251 history: Vec::new(),
252 created_at: now,
253 updated_at: now,
254 };
255 Self::push_history(&mut job, "created");
256 self.jobs.insert(id, job.clone());
257 job
258 }
259
260 pub fn set_running(&mut self, id: &str) {
262 if let Some(job) = self.jobs.get_mut(id) {
263 job.status = JobStatus::Running;
264 Self::clear_retry_schedule(&mut job.retry);
265 job.updated_at = Self::now_ts();
266 Self::push_history(job, "running");
267 }
268 }
269
270 pub fn update_progress(&mut self, id: &str, progress: u8, detail: Option<String>) {
272 if let Some(job) = self.jobs.get_mut(id) {
273 job.progress = Some(progress.min(100));
274 job.detail = detail;
275 job.updated_at = Self::now_ts();
276 Self::push_history(job, "progress_updated");
277 }
278 }
279
280 pub fn complete(&mut self, id: &str) {
282 if let Some(job) = self.jobs.get_mut(id) {
283 job.status = JobStatus::Completed;
284 job.progress = Some(100);
285 Self::clear_retry_schedule(&mut job.retry);
286 job.updated_at = Self::now_ts();
287 Self::push_history(job, "completed");
288 }
289 }
290
291 pub fn fail(&mut self, id: &str, detail: impl Into<String>) {
293 if let Some(job) = self.jobs.get_mut(id) {
294 let now = Self::now_ts();
295 job.status = JobStatus::Failed;
296 job.detail = Some(detail.into());
297 if job.retry.attempt < job.retry.max_attempts {
298 job.retry.attempt += 1;
299 job.retry.next_backoff_ms = Self::deterministic_backoff_ms(&job.retry);
300 let delay_secs = ((job.retry.next_backoff_ms.saturating_add(999)) / 1000)
301 .min(i64::MAX as u64) as i64;
302 job.retry.next_retry_at = Some(now.saturating_add(delay_secs));
303 } else {
304 Self::clear_retry_schedule(&mut job.retry);
305 }
306 job.updated_at = now;
307 Self::push_history(job, "failed");
308 }
309 }
310
311 pub fn cancel(&mut self, id: &str) {
313 if let Some(job) = self.jobs.get_mut(id) {
314 job.status = JobStatus::Cancelled;
315 Self::clear_retry_schedule(&mut job.retry);
316 job.updated_at = Self::now_ts();
317 Self::push_history(job, "cancelled");
318 }
319 }
320
321 pub fn pause(&mut self, id: &str, detail: Option<String>) {
323 if let Some(job) = self.jobs.get_mut(id) {
324 job.status = JobStatus::Paused;
325 if detail.is_some() {
326 job.detail = detail;
327 }
328 job.updated_at = Self::now_ts();
329 Self::push_history(job, "paused");
330 }
331 }
332
333 pub fn resume(&mut self, id: &str, detail: Option<String>) {
335 if let Some(job) = self.jobs.get_mut(id) {
336 job.status = JobStatus::Running;
337 if detail.is_some() {
338 job.detail = detail;
339 }
340 Self::clear_retry_schedule(&mut job.retry);
341 job.updated_at = Self::now_ts();
342 Self::push_history(job, "resumed");
343 }
344 }
345
346 pub fn list(&self) -> Vec<JobRecord> {
348 let mut out = self.jobs.values().cloned().collect::<Vec<_>>();
349 out.sort_by_key(|job| std::cmp::Reverse(job.updated_at));
350 out
351 }
352
353 pub fn history(&self, id: &str) -> Vec<JobHistoryEntry> {
355 self.jobs
356 .get(id)
357 .map(|job| job.history.clone())
358 .unwrap_or_default()
359 }
360
361 pub fn resume_pending(&mut self) -> Vec<JobRecord> {
363 let mut resumed = Vec::new();
364 for job in self.jobs.values_mut() {
365 if matches!(job.status, JobStatus::Queued | JobStatus::Running) {
366 job.status = JobStatus::Queued;
367 job.updated_at = Self::now_ts();
368 Self::push_history(job, "queued_after_resume");
369 resumed.push(job.clone());
370 }
371 }
372 resumed
373 }
374
375 pub fn load_from_store(&mut self, store: &StateStore) -> Result<()> {
377 let persisted = store.list_jobs(Some(500))?;
378 for job in persisted {
379 let fallback_status = job_state_status_to_runtime(job.status);
380 let parsed = Self::parse_persisted_detail(job.detail.as_deref());
381 let (status, detail, retry, history) = if let Some(detail_state) = parsed {
382 (
383 detail_state.status,
384 detail_state.detail,
385 detail_state.retry,
386 detail_state.history,
387 )
388 } else {
389 (
390 fallback_status,
391 job.detail,
392 JobRetryMetadata::default(),
393 Vec::new(),
394 )
395 };
396 self.jobs.insert(
397 job.id.clone(),
398 JobRecord {
399 id: job.id,
400 name: job.name,
401 status,
402 progress: job.progress,
403 detail,
404 retry,
405 history,
406 created_at: job.created_at,
407 updated_at: job.updated_at,
408 },
409 );
410 }
411 Ok(())
412 }
413
414 pub fn persist_job(&self, store: &StateStore, id: &str) -> Result<()> {
416 let Some(job) = self.jobs.get(id) else {
417 return Ok(());
418 };
419 let encoded_detail = Self::encode_persisted_detail(job)?;
420 store.upsert_job(&JobStateRecord {
421 id: job.id.clone(),
422 name: job.name.clone(),
423 status: runtime_status_to_job_state(job.status),
424 progress: job.progress,
425 detail: encoded_detail,
426 created_at: job.created_at,
427 updated_at: job.updated_at,
428 })
429 }
430
431 pub fn persist_all(&self, store: &StateStore) -> Result<()> {
433 for id in self.jobs.keys() {
434 self.persist_job(store, id)?;
435 }
436 Ok(())
437 }
438}
439
440pub struct ThreadManager {
442 store: StateStore,
443 running_threads: HashMap<String, Thread>,
444 cli_version: String,
445}
446
447impl ThreadManager {
448 pub fn new(store: StateStore) -> Self {
450 Self {
451 store,
452 running_threads: HashMap::new(),
453 cli_version: env!("CARGO_PKG_VERSION").to_string(),
454 }
455 }
456
457 pub fn state_store(&self) -> &StateStore {
459 &self.store
460 }
461
462 pub fn spawn_thread_with_history(
464 &mut self,
465 model_provider: String,
466 cwd: PathBuf,
467 initial_history: InitialHistory,
468 persist_extended_history: bool,
469 ) -> Result<NewThread> {
470 let id = format!("thread-{}", Uuid::new_v4());
471 let now = chrono::Utc::now().timestamp();
472 let preview = preview_from_initial_history(&initial_history);
473 let source = match initial_history {
474 InitialHistory::New => SessionSource::Interactive,
475 InitialHistory::Forked(_) => SessionSource::Fork,
476 InitialHistory::Resumed { .. } => SessionSource::Resume,
477 };
478 let thread = Thread {
479 id: id.clone(),
480 preview,
481 ephemeral: !persist_extended_history,
482 model_provider: model_provider.clone(),
483 created_at: now,
484 updated_at: now,
485 status: ThreadStatus::Running,
486 path: None,
487 cwd: cwd.clone(),
488 cli_version: self.cli_version.clone(),
489 source: match source {
490 SessionSource::Interactive => codewhale_protocol::SessionSource::Interactive,
491 SessionSource::Resume => codewhale_protocol::SessionSource::Resume,
492 SessionSource::Fork => codewhale_protocol::SessionSource::Fork,
493 SessionSource::Api => codewhale_protocol::SessionSource::Api,
494 SessionSource::Unknown => codewhale_protocol::SessionSource::Unknown,
495 },
496 name: None,
497 };
498 self.persist_thread(&thread, None)?;
499 match &initial_history {
500 InitialHistory::Forked(items) => {
501 for item in items {
502 self.store.append_message(
503 &thread.id,
504 "history",
505 &item.to_string(),
506 Some(item.clone()),
507 )?;
508 }
509 }
510 InitialHistory::Resumed { history, .. } => {
511 for item in history {
512 self.store.append_message(
513 &thread.id,
514 "history",
515 &item.to_string(),
516 Some(item.clone()),
517 )?;
518 }
519 }
520 InitialHistory::New => {}
521 }
522 self.running_threads
523 .insert(thread.id.clone(), thread.clone());
524 Ok(NewThread {
525 thread,
526 model: "auto".to_string(),
527 model_provider,
528 cwd,
529 approval_policy: None,
530 sandbox: None,
531 })
532 }
533
534 pub fn resume_thread_with_history(
536 &mut self,
537 params: &ThreadResumeParams,
538 fallback_cwd: &Path,
539 model_provider: String,
540 ) -> Result<Option<NewThread>> {
541 if params.history.is_none()
542 && let Some(thread) = self.running_threads.get(¶ms.thread_id).cloned()
543 {
544 return Ok(Some(NewThread {
545 model: params.model.clone().unwrap_or_else(|| "auto".to_string()),
546 model_provider: params.model_provider.clone().unwrap_or(model_provider),
547 cwd: params.cwd.clone().unwrap_or_else(|| thread.cwd.clone()),
548 approval_policy: params.approval_policy.clone(),
549 sandbox: params.sandbox.clone(),
550 thread,
551 }));
552 }
553
554 let persisted = self.store.get_thread(¶ms.thread_id)?;
555 let Some(metadata) = persisted else {
556 return Ok(None);
557 };
558 let mut thread = to_protocol_thread(metadata);
559 thread.status = ThreadStatus::Running;
560 thread.updated_at = chrono::Utc::now().timestamp();
561 thread.cwd = params
562 .cwd
563 .clone()
564 .unwrap_or_else(|| fallback_cwd.to_path_buf());
565 self.persist_thread(&thread, None)?;
566 self.running_threads
567 .insert(thread.id.clone(), thread.clone());
568 if let Some(history) = params.history.as_ref() {
569 for item in history {
570 self.store.append_message(
571 &thread.id,
572 "history",
573 &item.to_string(),
574 Some(item.clone()),
575 )?;
576 }
577 }
578
579 Ok(Some(NewThread {
580 model: params.model.clone().unwrap_or_else(|| "auto".to_string()),
581 model_provider: params.model_provider.clone().unwrap_or(model_provider),
582 cwd: thread.cwd.clone(),
583 approval_policy: params.approval_policy.clone(),
584 sandbox: params.sandbox.clone(),
585 thread,
586 }))
587 }
588
589 pub fn fork_thread(
591 &mut self,
592 params: &ThreadForkParams,
593 fallback_cwd: &Path,
594 ) -> Result<Option<NewThread>> {
595 let parent = self.store.get_thread(¶ms.thread_id)?;
596 let Some(parent) = parent else {
597 return Ok(None);
598 };
599 let parent_thread = to_protocol_thread(parent);
600 let new = self.spawn_thread_with_history(
601 params
602 .model_provider
603 .clone()
604 .unwrap_or_else(|| parent_thread.model_provider.clone()),
605 params
606 .cwd
607 .clone()
608 .unwrap_or_else(|| fallback_cwd.to_path_buf()),
609 InitialHistory::Forked(vec![json!({
610 "type": "fork",
611 "from_thread_id": parent_thread.id
612 })]),
613 params.persist_extended_history,
614 )?;
615 Ok(Some(new))
616 }
617
618 pub fn list_threads(&self, params: &ThreadListParams) -> Result<Vec<Thread>> {
620 let list = self.store.list_threads(ThreadListFilters {
621 include_archived: params.include_archived,
622 limit: params.limit,
623 })?;
624 Ok(list.into_iter().map(to_protocol_thread).collect())
625 }
626
627 pub fn read_thread(&self, params: &ThreadReadParams) -> Result<Option<Thread>> {
629 Ok(self
630 .store
631 .get_thread(¶ms.thread_id)?
632 .map(to_protocol_thread))
633 }
634
635 pub fn set_thread_name(&mut self, params: &ThreadSetNameParams) -> Result<Option<Thread>> {
637 let Some(mut metadata) = self.store.get_thread(¶ms.thread_id)? else {
638 return Ok(None);
639 };
640 metadata.name = Some(params.name.clone());
641 metadata.updated_at = chrono::Utc::now().timestamp();
642 self.store.upsert_thread(&metadata)?;
643 let updated = to_protocol_thread(metadata);
644 self.running_threads
645 .insert(updated.id.clone(), updated.clone());
646 Ok(Some(updated))
647 }
648
649 pub fn set_thread_goal(&mut self, params: &ThreadGoalSetParams) -> Result<Option<ThreadGoal>> {
651 if self.store.get_thread(¶ms.thread_id)?.is_none() {
652 return Ok(None);
653 }
654 let now = chrono::Utc::now().timestamp();
655 let goal = ThreadGoalRecord {
656 thread_id: params.thread_id.clone(),
657 goal_id: format!("goal-{}", Uuid::new_v4()),
658 objective: params.objective.clone(),
659 status: PersistedThreadGoalStatus::Active,
660 token_budget: params.token_budget,
661 tokens_used: 0,
662 time_used_seconds: 0,
663 continuation_count: 0,
664 created_at: now,
665 updated_at: now,
666 };
667 self.store.upsert_thread_goal(&goal)?;
668 Ok(Some(to_protocol_goal(goal)))
669 }
670
671 pub fn get_thread_goal(&self, params: &ThreadGoalGetParams) -> Result<Option<ThreadGoal>> {
673 Ok(self
674 .store
675 .get_thread_goal(¶ms.thread_id)?
676 .map(to_protocol_goal))
677 }
678
679 pub fn record_thread_goal_progress(
681 &mut self,
682 params: &ThreadGoalProgressParams,
683 ) -> Result<Option<ThreadGoal>> {
684 if self.store.get_thread(¶ms.thread_id)?.is_none() {
685 return Ok(None);
686 }
687
688 let now = chrono::Utc::now().timestamp();
689 let mut goal = if params.token_delta != 0 || params.time_delta_seconds != 0 {
690 self.store.record_thread_goal_usage(
691 ¶ms.thread_id,
692 params.token_delta,
693 params.time_delta_seconds,
694 now,
695 )?
696 } else {
697 self.store.get_thread_goal(¶ms.thread_id)?
698 };
699
700 if params.record_continuation {
701 goal = self
702 .store
703 .record_thread_goal_continuation(¶ms.thread_id, now)?;
704 }
705
706 Ok(goal.map(to_protocol_goal))
707 }
708
709 pub fn clear_thread_goal(&mut self, params: &ThreadGoalClearParams) -> Result<bool> {
711 self.store.delete_thread_goal(¶ms.thread_id)
712 }
713
714 pub fn archive_thread(&mut self, thread_id: &str) -> Result<()> {
716 self.store.mark_archived(thread_id)?;
717 if let Some(thread) = self.running_threads.get_mut(thread_id) {
718 thread.status = ThreadStatus::Archived;
719 }
720 Ok(())
721 }
722
723 pub fn unarchive_thread(&mut self, thread_id: &str) -> Result<()> {
725 self.store.mark_unarchived(thread_id)?;
726 Ok(())
727 }
728
729 pub fn touch_message(&mut self, thread_id: &str, input: &str) -> Result<()> {
731 let Some(mut metadata) = self.store.get_thread(thread_id)? else {
732 return Ok(());
733 };
734 metadata.updated_at = chrono::Utc::now().timestamp();
735 metadata.preview = truncate_preview(input);
736 metadata.status = PersistedThreadStatus::Running;
737 self.store.upsert_thread(&metadata)?;
738 if let Some(thread) = self.running_threads.get_mut(thread_id) {
739 thread.updated_at = metadata.updated_at;
740 thread.preview = metadata.preview;
741 thread.status = ThreadStatus::Running;
742 }
743 let message_id = self.store.append_message(thread_id, "user", input, None)?;
744 self.store.save_checkpoint(
745 thread_id,
746 "latest",
747 &json!({
748 "reason": "thread_message",
749 "message_id": message_id,
750 "role": "user",
751 "preview": truncate_preview(input),
752 "updated_at": metadata.updated_at
753 }),
754 )?;
755 Ok(())
756 }
757
758 fn persist_thread(&self, thread: &Thread, rollout_path: Option<PathBuf>) -> Result<()> {
759 self.store.upsert_thread(&ThreadMetadata {
760 id: thread.id.clone(),
761 rollout_path,
762 preview: thread.preview.clone(),
763 ephemeral: thread.ephemeral,
764 model_provider: thread.model_provider.clone(),
765 created_at: thread.created_at,
766 updated_at: thread.updated_at,
767 status: to_persisted_status(&thread.status),
768 path: thread.path.clone(),
769 cwd: thread.cwd.clone(),
770 cli_version: thread.cli_version.clone(),
771 source: to_persisted_source(&thread.source),
772 name: thread.name.clone(),
773 sandbox_policy: None,
774 approval_mode: None,
775 archived: matches!(thread.status, ThreadStatus::Archived),
776 archived_at: None,
777 git_sha: None,
778 git_branch: None,
779 git_origin_url: None,
780 memory_mode: None,
781 current_leaf_id: None,
782 })
783 }
784}
785
786pub struct Runtime {
788 pub config: ConfigToml,
790 pub model_registry: ModelRegistry,
792 pub thread_manager: ThreadManager,
794 pub tool_registry: Arc<ToolRegistry>,
796 pub mcp_manager: Arc<McpManager>,
798 pub exec_policy: ExecPolicyEngine,
800 pub hooks: HookDispatcher,
802 pub jobs: JobManager,
804}
805
806impl Runtime {
807 pub fn new(
809 config: ConfigToml,
810 model_registry: ModelRegistry,
811 state: StateStore,
812 tool_registry: Arc<ToolRegistry>,
813 mcp_manager: Arc<McpManager>,
814 exec_policy: ExecPolicyEngine,
815 hooks: HookDispatcher,
816 ) -> Self {
817 let mut jobs = JobManager::default();
818 if let Err(e) = jobs.load_from_store(&state) {
819 tracing::warn!("Failed to load job store, starting with empty job list: {e}");
820 }
821 Self {
822 config,
823 model_registry,
824 thread_manager: ThreadManager::new(state),
825 tool_registry,
826 mcp_manager,
827 exec_policy,
828 hooks,
829 jobs,
830 }
831 }
832
833 pub fn update_config(&mut self, config: ConfigToml) {
845 self.config = config;
846 }
847
848 pub fn reload_config_and_policy(&mut self, config: ConfigToml, exec_policy: ExecPolicyEngine) {
869 self.config = config;
870 self.exec_policy = exec_policy;
871 }
872
873 fn persisted_thread_data(&self, thread_id: &str) -> Result<Value> {
874 let history = self
875 .thread_manager
876 .state_store()
877 .list_messages(thread_id, Some(500))?
878 .into_iter()
879 .map(|message| {
880 json!({
881 "id": message.id,
882 "role": message.role,
883 "content": message.content,
884 "item": message.item,
885 "created_at": message.created_at
886 })
887 })
888 .collect::<Vec<_>>();
889
890 let checkpoint = self
891 .thread_manager
892 .state_store()
893 .load_checkpoint(thread_id, None)?
894 .map(|record| {
895 json!({
896 "checkpoint_id": record.checkpoint_id,
897 "state": record.state,
898 "created_at": record.created_at
899 })
900 });
901
902 let goal = self
903 .thread_manager
904 .state_store()
905 .get_thread_goal(thread_id)?
906 .map(to_protocol_goal);
907
908 Ok(json!({
909 "history": history,
910 "checkpoint": checkpoint,
911 "goal": goal
912 }))
913 }
914
915 fn persist_latest_checkpoint(&self, thread_id: &str, reason: &str, state: Value) -> Result<()> {
916 self.thread_manager.state_store().save_checkpoint(
917 thread_id,
918 "latest",
919 &json!({
920 "reason": reason,
921 "saved_at": chrono::Utc::now().timestamp(),
922 "state": state
923 }),
924 )
925 }
926
927 pub async fn handle_thread(&mut self, req: ThreadRequest) -> Result<ThreadResponse> {
929 match req {
930 ThreadRequest::Create { .. } => {
931 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
932 let new = self.thread_manager.spawn_thread_with_history(
933 "deepseek".to_string(),
934 cwd,
935 InitialHistory::New,
936 false,
937 )?;
938 let mut response = thread_response_from_new("created", new);
939 response.data = self.persisted_thread_data(&response.thread_id)?;
940 Ok(response)
941 }
942 ThreadRequest::Start(params) => {
943 let cwd = params.cwd.clone().unwrap_or_else(|| {
944 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
945 });
946 let new = self.thread_manager.spawn_thread_with_history(
947 params
948 .model_provider
949 .clone()
950 .unwrap_or_else(|| "deepseek".to_string()),
951 cwd,
952 InitialHistory::New,
953 params.persist_extended_history,
954 )?;
955 let mut response = thread_response_from_new("started", new);
956 response.data = self.persisted_thread_data(&response.thread_id)?;
957 Ok(response)
958 }
959 ThreadRequest::Resume(params) => {
960 let fallback_cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
961 if let Some(new) = self.thread_manager.resume_thread_with_history(
962 ¶ms,
963 &fallback_cwd,
964 "deepseek".to_string(),
965 )? {
966 let mut response = thread_response_from_new("resumed", new);
967 response.data = self.persisted_thread_data(&response.thread_id)?;
968 Ok(response)
969 } else {
970 Ok(ThreadResponse {
971 thread_id: params.thread_id,
972 status: "missing".to_string(),
973 thread: None,
974 threads: Vec::new(),
975 goal: None,
976 model: None,
977 model_provider: None,
978 cwd: None,
979 approval_policy: params.approval_policy,
980 sandbox: params.sandbox,
981 events: Vec::new(),
982 data: json!({"error":"thread not found"}),
983 })
984 }
985 }
986 ThreadRequest::Fork(params) => {
987 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
988 if let Some(new) = self.thread_manager.fork_thread(¶ms, &cwd)? {
989 let mut response = thread_response_from_new("forked", new);
990 response.data = self.persisted_thread_data(&response.thread_id)?;
991 Ok(response)
992 } else {
993 Ok(ThreadResponse {
994 thread_id: params.thread_id,
995 status: "missing".to_string(),
996 thread: None,
997 threads: Vec::new(),
998 goal: None,
999 model: None,
1000 model_provider: None,
1001 cwd: None,
1002 approval_policy: params.approval_policy,
1003 sandbox: params.sandbox,
1004 events: Vec::new(),
1005 data: json!({"error":"thread not found"}),
1006 })
1007 }
1008 }
1009 ThreadRequest::List(params) => Ok(ThreadResponse {
1010 thread_id: "list".to_string(),
1011 status: "ok".to_string(),
1012 thread: None,
1013 threads: self.thread_manager.list_threads(¶ms)?,
1014 goal: None,
1015 model: None,
1016 model_provider: None,
1017 cwd: None,
1018 approval_policy: None,
1019 sandbox: None,
1020 events: Vec::new(),
1021 data: json!({}),
1022 }),
1023 ThreadRequest::Read(params) => {
1024 let id = params.thread_id.clone();
1025 let data = self.persisted_thread_data(&id)?;
1026 Ok(ThreadResponse {
1027 thread_id: id,
1028 status: "ok".to_string(),
1029 thread: self.thread_manager.read_thread(¶ms)?,
1030 threads: Vec::new(),
1031 goal: self.thread_manager.get_thread_goal(&ThreadGoalGetParams {
1032 thread_id: params.thread_id,
1033 })?,
1034 model: None,
1035 model_provider: None,
1036 cwd: None,
1037 approval_policy: None,
1038 sandbox: None,
1039 events: Vec::new(),
1040 data,
1041 })
1042 }
1043 ThreadRequest::SetName(params) => Ok(ThreadResponse {
1044 thread_id: params.thread_id.clone(),
1045 status: "ok".to_string(),
1046 thread: self.thread_manager.set_thread_name(¶ms)?,
1047 threads: Vec::new(),
1048 goal: None,
1049 model: None,
1050 model_provider: None,
1051 cwd: None,
1052 approval_policy: None,
1053 sandbox: None,
1054 events: Vec::new(),
1055 data: json!({}),
1056 }),
1057 ThreadRequest::GoalSet(params) => {
1058 let thread_id = params.thread_id.clone();
1059 if let Some(goal) = self.thread_manager.set_thread_goal(¶ms)? {
1060 Ok(ThreadResponse {
1061 thread_id,
1062 status: "ok".to_string(),
1063 thread: None,
1064 threads: Vec::new(),
1065 goal: Some(goal.clone()),
1066 model: None,
1067 model_provider: None,
1068 cwd: None,
1069 approval_policy: None,
1070 sandbox: None,
1071 events: vec![EventFrame::ThreadGoalUpdated { goal: goal.clone() }],
1072 data: json!({ "goal": goal }),
1073 })
1074 } else {
1075 Ok(ThreadResponse {
1076 thread_id,
1077 status: "missing".to_string(),
1078 thread: None,
1079 threads: Vec::new(),
1080 goal: None,
1081 model: None,
1082 model_provider: None,
1083 cwd: None,
1084 approval_policy: None,
1085 sandbox: None,
1086 events: Vec::new(),
1087 data: json!({"error":"thread not found"}),
1088 })
1089 }
1090 }
1091 ThreadRequest::GoalGet(params) => {
1092 let goal = self.thread_manager.get_thread_goal(¶ms)?;
1093 Ok(ThreadResponse {
1094 thread_id: params.thread_id,
1095 status: "ok".to_string(),
1096 thread: None,
1097 threads: Vec::new(),
1098 goal: goal.clone(),
1099 model: None,
1100 model_provider: None,
1101 cwd: None,
1102 approval_policy: None,
1103 sandbox: None,
1104 events: Vec::new(),
1105 data: json!({ "goal": goal }),
1106 })
1107 }
1108 ThreadRequest::GoalClear(params) => {
1109 let thread_id = params.thread_id.clone();
1110 let cleared = self.thread_manager.clear_thread_goal(¶ms)?;
1111 Ok(ThreadResponse {
1112 thread_id: thread_id.clone(),
1113 status: if cleared { "cleared" } else { "empty" }.to_string(),
1114 thread: None,
1115 threads: Vec::new(),
1116 goal: None,
1117 model: None,
1118 model_provider: None,
1119 cwd: None,
1120 approval_policy: None,
1121 sandbox: None,
1122 events: if cleared {
1123 vec![EventFrame::ThreadGoalCleared { thread_id }]
1124 } else {
1125 Vec::new()
1126 },
1127 data: json!({ "cleared": cleared }),
1128 })
1129 }
1130 ThreadRequest::GoalRecordProgress(params) => {
1131 let thread_id = params.thread_id.clone();
1132 if let Some(goal) = self.thread_manager.record_thread_goal_progress(¶ms)? {
1133 Ok(ThreadResponse {
1134 thread_id,
1135 status: "ok".to_string(),
1136 thread: None,
1137 threads: Vec::new(),
1138 goal: Some(goal.clone()),
1139 model: None,
1140 model_provider: None,
1141 cwd: None,
1142 approval_policy: None,
1143 sandbox: None,
1144 events: vec![EventFrame::ThreadGoalUpdated { goal: goal.clone() }],
1145 data: json!({ "goal": goal }),
1146 })
1147 } else {
1148 Ok(ThreadResponse {
1149 thread_id,
1150 status: "missing".to_string(),
1151 thread: None,
1152 threads: Vec::new(),
1153 goal: None,
1154 model: None,
1155 model_provider: None,
1156 cwd: None,
1157 approval_policy: None,
1158 sandbox: None,
1159 events: Vec::new(),
1160 data: json!({"error":"thread or goal not found"}),
1161 })
1162 }
1163 }
1164 ThreadRequest::Archive { thread_id } => {
1165 self.thread_manager.archive_thread(&thread_id)?;
1166 Ok(ThreadResponse {
1167 thread_id,
1168 status: "archived".to_string(),
1169 thread: None,
1170 threads: Vec::new(),
1171 goal: None,
1172 model: None,
1173 model_provider: None,
1174 cwd: None,
1175 approval_policy: None,
1176 sandbox: None,
1177 events: Vec::new(),
1178 data: json!({}),
1179 })
1180 }
1181 ThreadRequest::Unarchive { thread_id } => {
1182 self.thread_manager.unarchive_thread(&thread_id)?;
1183 Ok(ThreadResponse {
1184 thread_id,
1185 status: "unarchived".to_string(),
1186 thread: None,
1187 threads: Vec::new(),
1188 goal: None,
1189 model: None,
1190 model_provider: None,
1191 cwd: None,
1192 approval_policy: None,
1193 sandbox: None,
1194 events: Vec::new(),
1195 data: json!({}),
1196 })
1197 }
1198 ThreadRequest::Message { thread_id, input } => {
1199 self.thread_manager.touch_message(&thread_id, &input)?;
1200 let response_id = format!("{thread_id}:{}", input.len());
1201 self.hooks
1202 .emit(HookEvent::ResponseStart {
1203 response_id: response_id.clone(),
1204 })
1205 .await;
1206 self.hooks
1207 .emit(HookEvent::ResponseEnd {
1208 response_id: response_id.clone(),
1209 })
1210 .await;
1211
1212 Ok(ThreadResponse {
1213 thread_id,
1214 status: "accepted".to_string(),
1215 thread: None,
1216 threads: Vec::new(),
1217 goal: None,
1218 model: None,
1219 model_provider: None,
1220 cwd: None,
1221 approval_policy: None,
1222 sandbox: None,
1223 events: vec![
1224 EventFrame::ResponseStart {
1225 response_id: response_id.clone(),
1226 },
1227 EventFrame::ResponseDelta {
1228 response_id: response_id.clone(),
1229 delta: "queued".to_string(),
1230 channel: ResponseChannel::Text,
1231 },
1232 EventFrame::ResponseEnd { response_id },
1233 ],
1234 data: json!({}),
1235 })
1236 }
1237 }
1238 }
1239
1240 pub async fn handle_prompt(
1242 &mut self,
1243 req: PromptRequest,
1244 cli_overrides: &CliRuntimeOverrides,
1245 ) -> Result<PromptResponse> {
1246 let resolved = self.config.resolve_runtime_options(cli_overrides);
1247 let requested_model = req.model.clone().unwrap_or_else(|| resolved.model.clone());
1248 let selection = self
1249 .model_registry
1250 .resolve(Some(&requested_model), Some(resolved.provider));
1251 let resolved_model = selection.resolved.id.clone();
1252 let response_id = format!("resp-{}", Uuid::new_v4());
1253
1254 self.hooks
1255 .emit(HookEvent::ResponseStart {
1256 response_id: response_id.clone(),
1257 })
1258 .await;
1259 self.hooks
1260 .emit(HookEvent::ResponseDelta {
1261 response_id: response_id.clone(),
1262 delta: "model-selected".to_string(),
1263 })
1264 .await;
1265 self.hooks
1266 .emit(HookEvent::ResponseEnd {
1267 response_id: response_id.clone(),
1268 })
1269 .await;
1270
1271 let payload = json!({
1272 "provider": resolved.provider.as_str(),
1273 "model": resolved_model.clone(),
1274 "prompt": req.prompt,
1275 "telemetry": resolved.telemetry,
1276 "base_url": resolved.base_url,
1277 "has_api_key": resolved.api_key.as_ref().is_some_and(|k| !k.trim().is_empty()),
1278 "approval_policy": resolved.approval_policy,
1279 "sandbox_mode": resolved.sandbox_mode
1280 });
1281 if let Some(thread_id) = req.thread_id.as_ref() {
1282 self.thread_manager.touch_message(thread_id, &req.prompt)?;
1283 let assistant_message_id = self.thread_manager.store.append_message(
1284 thread_id,
1285 "assistant",
1286 &payload.to_string(),
1287 Some(payload.clone()),
1288 )?;
1289 self.persist_latest_checkpoint(
1290 thread_id,
1291 "prompt_response",
1292 json!({
1293 "response_id": response_id.clone(),
1294 "model": resolved_model.clone(),
1295 "provider": resolved.provider.as_str(),
1296 "assistant_message_id": assistant_message_id
1297 }),
1298 )?;
1299 }
1300
1301 Ok(PromptResponse {
1302 output: payload.to_string(),
1303 model: resolved_model,
1304 events: vec![
1305 EventFrame::ResponseStart {
1306 response_id: response_id.clone(),
1307 },
1308 EventFrame::ResponseDelta {
1309 response_id: response_id.clone(),
1310 delta: "model-selected".to_string(),
1311 channel: ResponseChannel::Text,
1312 },
1313 EventFrame::ResponseEnd { response_id },
1314 ],
1315 })
1316 }
1317
1318 pub async fn invoke_tool(
1320 &self,
1321 call: ToolCall,
1322 approval_mode: AskForApproval,
1323 cwd: &Path,
1324 ) -> Result<Value> {
1325 let fallback_cwd = cwd.display().to_string();
1326 let (command, policy_cwd, execution_kind) = call.execution_subject(&fallback_cwd);
1327 let policy_tool = match &call.payload {
1328 ToolPayload::LocalShell { .. } => "exec_shell",
1329 _ => call.name.as_str(),
1330 };
1331 let policy_path = permission_path_for_call(&call);
1332 let decision = self.exec_policy.check(ExecPolicyContext {
1333 command: &command,
1334 cwd: &policy_cwd,
1335 tool: Some(policy_tool),
1336 path: policy_path.as_deref(),
1337 ask_for_approval: approval_mode,
1338 sandbox_mode: None,
1339 })?;
1340 let precheck = policy_precheck_payload(&decision, &command, &policy_cwd, execution_kind);
1341 let response_id = format!("tool-{}", Uuid::new_v4());
1342 let call_id = call
1343 .raw_tool_call_id
1344 .clone()
1345 .unwrap_or_else(|| format!("tool-call-{}", Uuid::new_v4()));
1346 self.hooks
1347 .emit(HookEvent::ToolLifecycle {
1348 response_id: response_id.clone(),
1349 tool_name: call.name.clone(),
1350 phase: "precheck".to_string(),
1351 payload: precheck.clone(),
1352 })
1353 .await;
1354
1355 if !decision.allow {
1356 let reason = decision.reason().to_string();
1357 let approval_id = format!("approval-{}", Uuid::new_v4());
1358 let error_frame = EventFrame::Error {
1359 response_id: response_id.clone(),
1360 message: reason.clone(),
1361 };
1362 self.hooks
1363 .emit(HookEvent::ApprovalLifecycle {
1364 approval_id,
1365 phase: "denied".to_string(),
1366 reason: Some(reason.clone()),
1367 })
1368 .await;
1369 self.hooks
1370 .emit(HookEvent::GenericEventFrame {
1371 frame: Box::new(error_frame.clone()),
1372 })
1373 .await;
1374 return Ok(json!({
1375 "ok": false,
1376 "status": "denied",
1377 "execution_kind": execution_kind,
1378 "response_id": response_id,
1379 "precheck": precheck,
1380 "error": reason,
1381 "events": [event_frame_payload(&error_frame)],
1382 }));
1383 }
1384
1385 if decision.requires_approval {
1386 let approval_id = format!("approval-{}", Uuid::new_v4());
1387 let reason = decision.reason().to_string();
1388 let maybe_approval_frame = approval_request_frame(
1389 &decision.requirement,
1390 decision.matched_rule.as_deref(),
1391 call_id,
1392 approval_id.clone(),
1393 response_id.clone(),
1394 command.clone(),
1395 policy_cwd.clone(),
1396 );
1397 self.hooks
1398 .emit(HookEvent::ApprovalLifecycle {
1399 approval_id: approval_id.clone(),
1400 phase: "requested".to_string(),
1401 reason: Some(reason.clone()),
1402 })
1403 .await;
1404 let mut events = Vec::new();
1405 if let Some(frame) = maybe_approval_frame {
1406 self.hooks
1407 .emit(HookEvent::GenericEventFrame {
1408 frame: Box::new(frame.clone()),
1409 })
1410 .await;
1411 events.push(event_frame_payload(&frame));
1412 }
1413 return Ok(json!({
1414 "ok": false,
1415 "status": "approval_required",
1416 "execution_kind": execution_kind,
1417 "response_id": response_id,
1418 "approval_id": approval_id,
1419 "precheck": precheck,
1420 "error": reason,
1421 "events": events,
1422 }));
1423 }
1424
1425 if call.name == REQUEST_USER_INPUT_TOOL_NAME {
1434 let request_id = format!("user-input-{}", Uuid::new_v4());
1435 let arguments = match &call.payload {
1436 ToolPayload::Function { arguments } => arguments.as_str(),
1437 _ => "",
1440 };
1441 let maybe_frame = user_input_request_frame(
1442 call_id.clone(),
1443 response_id.clone(),
1444 request_id.clone(),
1445 arguments,
1446 );
1447 let mut events = Vec::new();
1448 if let Some(frame) = maybe_frame {
1449 self.hooks
1450 .emit(HookEvent::GenericEventFrame {
1451 frame: Box::new(frame.clone()),
1452 })
1453 .await;
1454 events.push(event_frame_payload(&frame));
1455 }
1456 return Ok(json!({
1457 "ok": false,
1458 "status": "user_input_required",
1459 "execution_kind": execution_kind,
1460 "response_id": response_id,
1461 "request_id": request_id,
1462 "precheck": precheck,
1463 "events": events,
1464 }));
1465 }
1466
1467 let start_frame = EventFrame::ToolCallStart {
1468 response_id: response_id.clone(),
1469 tool_name: call.name.clone(),
1470 arguments: tool_payload_value(&call.payload),
1471 };
1472 self.hooks
1473 .emit(HookEvent::GenericEventFrame {
1474 frame: Box::new(start_frame.clone()),
1475 })
1476 .await;
1477 self.hooks
1478 .emit(HookEvent::ToolLifecycle {
1479 response_id: response_id.clone(),
1480 tool_name: call.name.clone(),
1481 phase: "dispatching".to_string(),
1482 payload: json!({
1483 "call_id": call_id,
1484 "execution_kind": execution_kind
1485 }),
1486 })
1487 .await;
1488
1489 match self.tool_registry.dispatch(call.clone(), true).await {
1490 Ok(tool_output) => {
1491 let result_frame = EventFrame::ToolCallResult {
1492 response_id: response_id.clone(),
1493 tool_name: call.name.clone(),
1494 output: tool_output_value(&tool_output),
1495 };
1496 self.hooks
1497 .emit(HookEvent::GenericEventFrame {
1498 frame: Box::new(result_frame.clone()),
1499 })
1500 .await;
1501 self.hooks
1502 .emit(HookEvent::ToolLifecycle {
1503 response_id: response_id.clone(),
1504 tool_name: call.name,
1505 phase: "completed".to_string(),
1506 payload: json!({ "ok": true }),
1507 })
1508 .await;
1509 Ok(json!({
1510 "ok": true,
1511 "status": "completed",
1512 "execution_kind": execution_kind,
1513 "response_id": response_id,
1514 "precheck": precheck,
1515 "output": tool_output,
1516 "events": [
1517 event_frame_payload(&start_frame),
1518 event_frame_payload(&result_frame)
1519 ]
1520 }))
1521 }
1522 Err(err) => {
1523 let message = format!("{err:?}");
1524 let error_frame = EventFrame::Error {
1525 response_id: response_id.clone(),
1526 message: message.clone(),
1527 };
1528 self.hooks
1529 .emit(HookEvent::GenericEventFrame {
1530 frame: Box::new(error_frame.clone()),
1531 })
1532 .await;
1533 self.hooks
1534 .emit(HookEvent::ToolLifecycle {
1535 response_id: response_id.clone(),
1536 tool_name: call.name,
1537 phase: "failed".to_string(),
1538 payload: json!({ "error": message.clone() }),
1539 })
1540 .await;
1541 Ok(json!({
1542 "ok": false,
1543 "status": "failed",
1544 "execution_kind": execution_kind,
1545 "response_id": response_id,
1546 "precheck": precheck,
1547 "error": message,
1548 "events": [
1549 event_frame_payload(&start_frame),
1550 event_frame_payload(&error_frame)
1551 ]
1552 }))
1553 }
1554 }
1555 }
1556
1557 pub async fn mcp_startup(&self) -> McpStartupCompleteEvent {
1559 let mut updates = Vec::new();
1560 let summary = self.mcp_manager.start_all(|update| {
1561 updates.push(update);
1562 });
1563 for update in updates {
1564 let status = match update.status {
1565 McpManagerStartupStatus::Starting => codewhale_protocol::McpStartupStatus::Starting,
1566 McpManagerStartupStatus::Ready => codewhale_protocol::McpStartupStatus::Ready,
1567 McpManagerStartupStatus::Failed { error } => {
1568 codewhale_protocol::McpStartupStatus::Failed { error }
1569 }
1570 McpManagerStartupStatus::Cancelled => {
1571 codewhale_protocol::McpStartupStatus::Cancelled
1572 }
1573 };
1574 self.hooks
1575 .emit(HookEvent::GenericEventFrame {
1576 frame: Box::new(EventFrame::McpStartupUpdate {
1577 update: codewhale_protocol::McpStartupUpdateEvent {
1578 server_name: update.server_name,
1579 status,
1580 },
1581 }),
1582 })
1583 .await;
1584 }
1585 self.hooks
1586 .emit(HookEvent::GenericEventFrame {
1587 frame: Box::new(EventFrame::McpStartupComplete {
1588 summary: codewhale_protocol::McpStartupCompleteEvent {
1589 ready: summary.ready.clone(),
1590 failed: summary
1591 .failed
1592 .iter()
1593 .map(|f| codewhale_protocol::McpStartupFailure {
1594 server_name: f.server_name.clone(),
1595 error: f.error.clone(),
1596 })
1597 .collect(),
1598 cancelled: summary.cancelled.clone(),
1599 },
1600 }),
1601 })
1602 .await;
1603 summary
1604 }
1605
1606 pub fn app_status(&self) -> AppResponse {
1608 let jobs = self.jobs.list();
1609 let events = jobs
1610 .iter()
1611 .flat_map(|job| {
1612 job.history.iter().map(|entry| EventFrame::ResponseDelta {
1613 response_id: job.id.clone(),
1614 delta: json!({
1615 "kind": "job_transition",
1616 "job_id": job.id.clone(),
1617 "phase": entry.phase.clone(),
1618 "status": job_status_to_str(entry.status),
1619 "progress": entry.progress,
1620 "detail": entry.detail.clone(),
1621 "retry": job_retry_to_value(&entry.retry),
1622 "at": entry.at
1623 })
1624 .to_string(),
1625 channel: ResponseChannel::Text,
1626 })
1627 })
1628 .collect::<Vec<_>>();
1629 AppResponse {
1630 ok: true,
1631 data: json!({
1632 "jobs": jobs.into_iter().map(|job| {
1633 json!({
1634 "id": job.id,
1635 "name": job.name,
1636 "status": job_status_to_str(job.status),
1637 "progress": job.progress,
1638 "detail": job.detail,
1639 "retry": job_retry_to_value(&job.retry),
1640 "history": job.history.iter().map(job_history_to_value).collect::<Vec<_>>()
1641 })
1642 }).collect::<Vec<_>>()
1643 }),
1644 events,
1645 }
1646 }
1647
1648 pub fn provider_default(&self) -> ProviderKind {
1650 self.config.provider
1651 }
1652
1653 pub fn save_thread_checkpoint(
1655 &self,
1656 thread_id: &str,
1657 checkpoint_id: &str,
1658 state: &Value,
1659 ) -> Result<()> {
1660 self.thread_manager
1661 .state_store()
1662 .save_checkpoint(thread_id, checkpoint_id, state)
1663 }
1664
1665 pub fn load_thread_checkpoint(
1667 &self,
1668 thread_id: &str,
1669 checkpoint_id: Option<&str>,
1670 ) -> Result<Option<Value>> {
1671 Ok(self
1672 .thread_manager
1673 .state_store()
1674 .load_checkpoint(thread_id, checkpoint_id)?
1675 .map(|checkpoint| checkpoint.state))
1676 }
1677
1678 pub fn enqueue_job(&mut self, name: impl Into<String>) -> Result<JobRecord> {
1680 let job = self.jobs.enqueue(name);
1681 self.jobs
1682 .persist_job(self.thread_manager.state_store(), &job.id)?;
1683 Ok(job)
1684 }
1685
1686 pub fn set_job_running(&mut self, job_id: &str) -> Result<()> {
1688 self.jobs.set_running(job_id);
1689 self.jobs
1690 .persist_job(self.thread_manager.state_store(), job_id)
1691 }
1692
1693 pub fn update_job_progress(
1695 &mut self,
1696 job_id: &str,
1697 progress: u8,
1698 detail: Option<String>,
1699 ) -> Result<()> {
1700 self.jobs.update_progress(job_id, progress, detail);
1701 self.jobs
1702 .persist_job(self.thread_manager.state_store(), job_id)
1703 }
1704
1705 pub fn complete_job(&mut self, job_id: &str) -> Result<()> {
1707 self.jobs.complete(job_id);
1708 self.jobs
1709 .persist_job(self.thread_manager.state_store(), job_id)
1710 }
1711
1712 pub fn fail_job(&mut self, job_id: &str, detail: impl Into<String>) -> Result<()> {
1714 self.jobs.fail(job_id, detail);
1715 self.jobs
1716 .persist_job(self.thread_manager.state_store(), job_id)
1717 }
1718
1719 pub fn cancel_job(&mut self, job_id: &str) -> Result<()> {
1721 self.jobs.cancel(job_id);
1722 self.jobs
1723 .persist_job(self.thread_manager.state_store(), job_id)
1724 }
1725
1726 pub fn pause_job(&mut self, job_id: &str, detail: Option<String>) -> Result<()> {
1728 self.jobs.pause(job_id, detail);
1729 self.jobs
1730 .persist_job(self.thread_manager.state_store(), job_id)
1731 }
1732
1733 pub fn resume_job(&mut self, job_id: &str, detail: Option<String>) -> Result<()> {
1735 self.jobs.resume(job_id, detail);
1736 self.jobs
1737 .persist_job(self.thread_manager.state_store(), job_id)
1738 }
1739
1740 pub fn job_history(&self, job_id: &str) -> Vec<JobHistoryEntry> {
1742 self.jobs.history(job_id)
1743 }
1744}
1745
1746fn thread_response_from_new(status: &str, new: NewThread) -> ThreadResponse {
1747 ThreadResponse {
1748 thread_id: new.thread.id.clone(),
1749 status: status.to_string(),
1750 thread: Some(new.thread),
1751 threads: Vec::new(),
1752 goal: None,
1753 model: Some(new.model),
1754 model_provider: Some(new.model_provider),
1755 cwd: Some(new.cwd),
1756 approval_policy: new.approval_policy,
1757 sandbox: new.sandbox,
1758 events: Vec::new(),
1759 data: json!({}),
1760 }
1761}
1762
1763fn preview_from_initial_history(initial_history: &InitialHistory) -> String {
1764 match initial_history {
1765 InitialHistory::New => "New conversation".to_string(),
1766 InitialHistory::Forked(items) => truncate_preview(
1767 &items
1768 .first()
1769 .map(Value::to_string)
1770 .unwrap_or_else(|| "Forked conversation".to_string()),
1771 ),
1772 InitialHistory::Resumed { history, .. } => truncate_preview(
1773 &history
1774 .first()
1775 .map(Value::to_string)
1776 .unwrap_or_else(|| "Resumed conversation".to_string()),
1777 ),
1778 }
1779}
1780
1781fn permission_path_for_call(call: &ToolCall) -> Option<String> {
1782 match &call.payload {
1783 ToolPayload::Function { arguments } => serde_json::from_str::<Value>(arguments)
1784 .ok()
1785 .and_then(|value| {
1786 value
1787 .get("path")
1788 .and_then(Value::as_str)
1789 .map(str::to_string)
1790 }),
1791 ToolPayload::Mcp { raw_arguments, .. } => raw_arguments
1792 .get("path")
1793 .and_then(Value::as_str)
1794 .map(str::to_string),
1795 ToolPayload::Custom { .. } | ToolPayload::LocalShell { .. } => None,
1796 }
1797}
1798
1799fn truncate_preview(value: &str) -> String {
1800 value.chars().take(120).collect()
1801}
1802
1803fn to_protocol_thread(thread: ThreadMetadata) -> Thread {
1804 Thread {
1805 id: thread.id,
1806 preview: thread.preview,
1807 ephemeral: thread.ephemeral,
1808 model_provider: thread.model_provider,
1809 created_at: thread.created_at,
1810 updated_at: thread.updated_at,
1811 status: match thread.status {
1812 PersistedThreadStatus::Running => ThreadStatus::Running,
1813 PersistedThreadStatus::Idle => ThreadStatus::Idle,
1814 PersistedThreadStatus::Completed => ThreadStatus::Completed,
1815 PersistedThreadStatus::Failed => ThreadStatus::Failed,
1816 PersistedThreadStatus::Paused => ThreadStatus::Paused,
1817 PersistedThreadStatus::Archived => ThreadStatus::Archived,
1818 },
1819 path: thread.path,
1820 cwd: thread.cwd,
1821 cli_version: thread.cli_version,
1822 source: match thread.source {
1823 SessionSource::Interactive => codewhale_protocol::SessionSource::Interactive,
1824 SessionSource::Resume => codewhale_protocol::SessionSource::Resume,
1825 SessionSource::Fork => codewhale_protocol::SessionSource::Fork,
1826 SessionSource::Api => codewhale_protocol::SessionSource::Api,
1827 SessionSource::Unknown => codewhale_protocol::SessionSource::Unknown,
1828 },
1829 name: thread.name,
1830 }
1831}
1832
1833fn to_protocol_goal(goal: ThreadGoalRecord) -> ThreadGoal {
1834 ThreadGoal {
1835 thread_id: goal.thread_id,
1836 goal_id: goal.goal_id,
1837 objective: goal.objective,
1838 status: to_protocol_goal_status(goal.status),
1839 token_budget: goal.token_budget,
1840 tokens_used: goal.tokens_used,
1841 time_used_seconds: goal.time_used_seconds,
1842 continuation_count: goal.continuation_count,
1843 created_at: goal.created_at,
1844 updated_at: goal.updated_at,
1845 }
1846}
1847
1848fn to_protocol_goal_status(status: PersistedThreadGoalStatus) -> ThreadGoalStatus {
1849 match status {
1850 PersistedThreadGoalStatus::Active => ThreadGoalStatus::Active,
1851 PersistedThreadGoalStatus::Paused => ThreadGoalStatus::Paused,
1852 PersistedThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked,
1853 PersistedThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited,
1854 PersistedThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited,
1855 PersistedThreadGoalStatus::Complete => ThreadGoalStatus::Complete,
1856 }
1857}
1858
1859fn to_persisted_status(status: &ThreadStatus) -> PersistedThreadStatus {
1860 match status {
1861 ThreadStatus::Running => PersistedThreadStatus::Running,
1862 ThreadStatus::Idle => PersistedThreadStatus::Idle,
1863 ThreadStatus::Completed => PersistedThreadStatus::Completed,
1864 ThreadStatus::Failed => PersistedThreadStatus::Failed,
1865 ThreadStatus::Paused => PersistedThreadStatus::Paused,
1866 ThreadStatus::Archived => PersistedThreadStatus::Archived,
1867 }
1868}
1869
1870fn to_persisted_source(source: &codewhale_protocol::SessionSource) -> SessionSource {
1871 match source {
1872 codewhale_protocol::SessionSource::Interactive => SessionSource::Interactive,
1873 codewhale_protocol::SessionSource::Resume => SessionSource::Resume,
1874 codewhale_protocol::SessionSource::Fork => SessionSource::Fork,
1875 codewhale_protocol::SessionSource::Api => SessionSource::Api,
1876 codewhale_protocol::SessionSource::Unknown => SessionSource::Unknown,
1877 }
1878}
1879
1880fn approval_request_frame(
1881 requirement: &ExecApprovalRequirement,
1882 matched_rule: Option<&str>,
1883 call_id: String,
1884 approval_id: String,
1885 turn_id: String,
1886 command: String,
1887 cwd: String,
1888) -> Option<EventFrame> {
1889 let ExecApprovalRequirement::NeedsApproval {
1890 reason,
1891 proposed_execpolicy_amendment,
1892 proposed_network_policy_amendments,
1893 } = requirement
1894 else {
1895 return None;
1896 };
1897
1898 let mut available_decisions = vec![
1899 ReviewDecision::Approved,
1900 ReviewDecision::ApprovedForSession,
1901 ReviewDecision::Denied,
1902 ReviewDecision::Abort,
1903 ];
1904 if proposed_execpolicy_amendment
1905 .as_ref()
1906 .is_some_and(|amendment| !amendment.prefixes.is_empty())
1907 {
1908 available_decisions.push(ReviewDecision::ApprovedExecpolicyAmendment);
1909 }
1910 available_decisions.extend(proposed_network_policy_amendments.iter().cloned().map(
1911 |amendment| ReviewDecision::NetworkPolicyAmendment {
1912 host: amendment.host,
1913 action: amendment.action,
1914 },
1915 ));
1916
1917 Some(EventFrame::ExecApprovalRequest {
1918 request: ExecApprovalRequestEvent {
1919 call_id,
1920 approval_id,
1921 turn_id,
1922 command,
1923 cwd,
1924 reason: reason.clone(),
1925 matched_rule: matched_rule.map(|rule| rule.to_string().into_boxed_str()),
1926 network_approval_context: None,
1927 proposed_execpolicy_amendment: proposed_execpolicy_amendment
1928 .as_ref()
1929 .map(|amendment| amendment.prefixes.clone())
1930 .unwrap_or_default(),
1931 proposed_network_policy_amendments: proposed_network_policy_amendments.clone(),
1932 additional_permissions: Vec::new(),
1933 available_decisions,
1934 },
1935 })
1936}
1937
1938fn user_input_request_frame(
1946 call_id: String,
1947 turn_id: String,
1948 request_id: String,
1949 arguments: &str,
1950) -> Option<EventFrame> {
1951 let parsed: Value = serde_json::from_str(arguments).ok()?;
1952 let questions = parsed.get("questions").cloned().filter(Value::is_array)?;
1956 let request = UserInputRequestEvent {
1957 call_id,
1958 turn_id,
1959 request_id,
1960 questions: serde_json::from_value(questions).ok()?,
1961 };
1962 Some(EventFrame::UserInputRequest { request })
1963}
1964
1965fn approval_requirement_payload(requirement: &ExecApprovalRequirement) -> Value {
1966 match requirement {
1967 ExecApprovalRequirement::Skip {
1968 bypass_sandbox,
1969 proposed_execpolicy_amendment,
1970 } => json!({
1971 "type": "skip",
1972 "bypass_sandbox": bypass_sandbox,
1973 "reason": requirement.reason(),
1974 "proposed_execpolicy_amendment": proposed_execpolicy_amendment
1975 .as_ref()
1976 .map(|amendment| amendment.prefixes.clone())
1977 .unwrap_or_default()
1978 }),
1979 ExecApprovalRequirement::NeedsApproval {
1980 reason,
1981 proposed_execpolicy_amendment,
1982 proposed_network_policy_amendments,
1983 } => json!({
1984 "type": "needs_approval",
1985 "reason": reason,
1986 "proposed_execpolicy_amendment": proposed_execpolicy_amendment
1987 .as_ref()
1988 .map(|amendment| amendment.prefixes.clone())
1989 .unwrap_or_default(),
1990 "proposed_network_policy_amendments": proposed_network_policy_amendments
1991 }),
1992 ExecApprovalRequirement::Forbidden { reason } => json!({
1993 "type": "forbidden",
1994 "reason": reason
1995 }),
1996 }
1997}
1998
1999fn policy_precheck_payload(
2000 decision: &ExecPolicyDecision,
2001 command: &str,
2002 cwd: &str,
2003 execution_kind: &str,
2004) -> Value {
2005 json!({
2006 "execution_kind": execution_kind,
2007 "command": command,
2008 "cwd": cwd,
2009 "allow": decision.allow,
2010 "requires_approval": decision.requires_approval,
2011 "matched_rule": decision.matched_rule.clone(),
2012 "phase": decision.requirement.phase(),
2013 "reason": decision.reason(),
2014 "requirement": approval_requirement_payload(&decision.requirement)
2015 })
2016}
2017
2018fn tool_payload_value(payload: &ToolPayload) -> Value {
2019 serde_json::to_value(payload).unwrap_or_else(
2020 |_| json!({"type":"serialization_error","message":"tool payload unavailable"}),
2021 )
2022}
2023
2024fn tool_output_value(output: &codewhale_protocol::ToolOutput) -> Value {
2025 serde_json::to_value(output).unwrap_or_else(
2026 |_| json!({"type":"serialization_error","message":"tool output unavailable"}),
2027 )
2028}
2029
2030fn event_frame_payload(frame: &EventFrame) -> Value {
2031 serde_json::to_value(frame)
2032 .unwrap_or_else(|_| json!({"event":"error","message":"failed to encode event frame"}))
2033}
2034
2035const REQUEST_USER_INPUT_TOOL_NAME: &str = "request_user_input";
2041
2042fn json_optional_string(value: &Value) -> Option<String> {
2043 if value.is_null() {
2044 None
2045 } else {
2046 value.as_str().map(ToString::to_string)
2047 }
2048}
2049
2050fn parse_retry_metadata(value: Option<&Value>) -> JobRetryMetadata {
2051 let Some(value) = value else {
2052 return JobRetryMetadata::default();
2053 };
2054 JobRetryMetadata {
2055 attempt: value
2056 .get("attempt")
2057 .and_then(Value::as_u64)
2058 .unwrap_or(0)
2059 .min(u32::MAX as u64) as u32,
2060 max_attempts: value
2061 .get("max_attempts")
2062 .and_then(Value::as_u64)
2063 .unwrap_or(DEFAULT_JOB_MAX_ATTEMPTS as u64)
2064 .min(u32::MAX as u64) as u32,
2065 backoff_base_ms: value
2066 .get("backoff_base_ms")
2067 .and_then(Value::as_u64)
2068 .unwrap_or(DEFAULT_JOB_BACKOFF_BASE_MS),
2069 next_backoff_ms: value
2070 .get("next_backoff_ms")
2071 .and_then(Value::as_u64)
2072 .unwrap_or(0),
2073 next_retry_at: value.get("next_retry_at").and_then(Value::as_i64),
2074 }
2075}
2076
2077fn parse_history_entry(value: &Value) -> Option<JobHistoryEntry> {
2078 let status = value
2079 .get("status")
2080 .and_then(Value::as_str)
2081 .and_then(job_status_from_str)?;
2082 Some(JobHistoryEntry {
2083 at: value.get("at").and_then(Value::as_i64).unwrap_or(0),
2084 phase: value
2085 .get("phase")
2086 .and_then(Value::as_str)
2087 .unwrap_or("unknown")
2088 .to_string(),
2089 status,
2090 progress: value
2091 .get("progress")
2092 .and_then(Value::as_u64)
2093 .map(|v| v.min(u8::MAX as u64) as u8),
2094 detail: value.get("detail").and_then(json_optional_string),
2095 retry: parse_retry_metadata(value.get("retry")),
2096 })
2097}
2098
2099fn job_status_to_str(status: JobStatus) -> &'static str {
2100 match status {
2101 JobStatus::Queued => "queued",
2102 JobStatus::Running => "running",
2103 JobStatus::Paused => "paused",
2104 JobStatus::Completed => "completed",
2105 JobStatus::Failed => "failed",
2106 JobStatus::Cancelled => "cancelled",
2107 }
2108}
2109
2110fn job_status_from_str(value: &str) -> Option<JobStatus> {
2111 match value {
2112 "queued" => Some(JobStatus::Queued),
2113 "running" => Some(JobStatus::Running),
2114 "paused" => Some(JobStatus::Paused),
2115 "completed" => Some(JobStatus::Completed),
2116 "failed" => Some(JobStatus::Failed),
2117 "cancelled" => Some(JobStatus::Cancelled),
2118 _ => None,
2119 }
2120}
2121
2122fn job_retry_to_value(retry: &JobRetryMetadata) -> Value {
2123 json!({
2124 "attempt": retry.attempt,
2125 "max_attempts": retry.max_attempts,
2126 "backoff_base_ms": retry.backoff_base_ms,
2127 "next_backoff_ms": retry.next_backoff_ms,
2128 "next_retry_at": retry.next_retry_at
2129 })
2130}
2131
2132fn job_history_to_value(entry: &JobHistoryEntry) -> Value {
2133 json!({
2134 "at": entry.at,
2135 "phase": entry.phase.clone(),
2136 "status": job_status_to_str(entry.status),
2137 "progress": entry.progress,
2138 "detail": entry.detail.clone(),
2139 "retry": job_retry_to_value(&entry.retry)
2140 })
2141}
2142
2143fn runtime_status_to_job_state(status: JobStatus) -> JobStateStatus {
2144 match status {
2145 JobStatus::Queued => JobStateStatus::Queued,
2146 JobStatus::Running => JobStateStatus::Running,
2147 JobStatus::Paused => JobStateStatus::Running,
2148 JobStatus::Completed => JobStateStatus::Completed,
2149 JobStatus::Failed => JobStateStatus::Failed,
2150 JobStatus::Cancelled => JobStateStatus::Cancelled,
2151 }
2152}
2153
2154fn job_state_status_to_runtime(status: JobStateStatus) -> JobStatus {
2155 match status {
2156 JobStateStatus::Queued => JobStatus::Queued,
2157 JobStateStatus::Running => JobStatus::Running,
2158 JobStateStatus::Completed => JobStatus::Completed,
2159 JobStateStatus::Failed => JobStatus::Failed,
2160 JobStateStatus::Cancelled => JobStatus::Cancelled,
2161 }
2162}
2163
2164#[cfg(test)]
2165mod tests {
2166 use super::*;
2167 use codewhale_tools::ToolCallSource;
2168
2169 fn temp_core_state(name: &str) -> StateStore {
2170 let dir =
2171 std::env::temp_dir().join(format!("codewhale-core-{name}-{}", Uuid::new_v4().simple()));
2172 std::fs::create_dir_all(&dir).expect("create temp state dir");
2173 StateStore::open(Some(dir.join("state.db"))).expect("open state store")
2174 }
2175
2176 fn test_thread_metadata(id: &str) -> ThreadMetadata {
2177 ThreadMetadata {
2178 id: id.to_string(),
2179 rollout_path: None,
2180 preview: "test thread".to_string(),
2181 ephemeral: false,
2182 model_provider: "deepseek".to_string(),
2183 created_at: 10,
2184 updated_at: 10,
2185 status: PersistedThreadStatus::Running,
2186 path: None,
2187 cwd: PathBuf::from("/tmp/codewhale"),
2188 cli_version: "0.0.0-test".to_string(),
2189 source: SessionSource::Interactive,
2190 name: None,
2191 sandbox_policy: None,
2192 approval_mode: None,
2193 archived: false,
2194 archived_at: None,
2195 git_sha: None,
2196 git_branch: None,
2197 git_origin_url: None,
2198 memory_mode: None,
2199 current_leaf_id: None,
2200 }
2201 }
2202
2203 #[test]
2206 fn permission_path_for_call_extracts_function_path_argument() {
2207 let call = ToolCall {
2208 name: "read_file".to_string(),
2209 payload: ToolPayload::Function {
2210 arguments: json!({ "path": "README.md" }).to_string(),
2211 },
2212 source: ToolCallSource::Direct,
2213 raw_tool_call_id: None,
2214 };
2215
2216 assert_eq!(
2217 permission_path_for_call(&call).as_deref(),
2218 Some("README.md")
2219 );
2220 }
2221
2222 #[test]
2223 fn permission_path_for_call_extracts_mcp_path_argument() {
2224 let call = ToolCall {
2225 name: "mcp_fs_read".to_string(),
2226 payload: ToolPayload::Mcp {
2227 server: "fs".to_string(),
2228 tool: "read".to_string(),
2229 raw_arguments: json!({ "path": "secrets/token.txt" }),
2230 raw_tool_call_id: None,
2231 },
2232 source: ToolCallSource::Direct,
2233 raw_tool_call_id: None,
2234 };
2235
2236 assert_eq!(
2237 permission_path_for_call(&call).as_deref(),
2238 Some("secrets/token.txt")
2239 );
2240 }
2241
2242 #[test]
2243 fn permission_path_for_call_ignores_shell_payload() {
2244 let call = ToolCall {
2245 name: "exec_shell".to_string(),
2246 payload: ToolPayload::LocalShell {
2247 params: codewhale_protocol::LocalShellParams {
2248 command: "cargo test".to_string(),
2249 cwd: None,
2250 timeout_ms: None,
2251 },
2252 },
2253 source: ToolCallSource::Direct,
2254 raw_tool_call_id: None,
2255 };
2256
2257 assert_eq!(permission_path_for_call(&call), None);
2258 }
2259
2260 #[test]
2261 fn thread_goal_progress_accumulates_durable_accounting() {
2262 let store = temp_core_state("thread-goal-progress");
2263 store
2264 .upsert_thread(&test_thread_metadata("thread-1"))
2265 .expect("upsert thread");
2266 let mut manager = ThreadManager::new(store);
2267 manager
2268 .set_thread_goal(&ThreadGoalSetParams {
2269 thread_id: "thread-1".to_string(),
2270 objective: "Carry the goal across turns".to_string(),
2271 token_budget: Some(2_000),
2272 })
2273 .expect("set goal")
2274 .expect("goal exists");
2275
2276 let updated = manager
2277 .record_thread_goal_progress(&ThreadGoalProgressParams {
2278 thread_id: "thread-1".to_string(),
2279 token_delta: 750,
2280 time_delta_seconds: 12,
2281 record_continuation: true,
2282 })
2283 .expect("record progress")
2284 .expect("goal exists");
2285
2286 assert_eq!(updated.tokens_used, 750);
2287 assert_eq!(updated.time_used_seconds, 12);
2288 assert_eq!(updated.continuation_count, 1);
2289
2290 let persisted = manager
2291 .get_thread_goal(&ThreadGoalGetParams {
2292 thread_id: "thread-1".to_string(),
2293 })
2294 .expect("read goal")
2295 .expect("goal exists");
2296 assert_eq!(persisted.tokens_used, 750);
2297 assert_eq!(persisted.time_used_seconds, 12);
2298 assert_eq!(persisted.continuation_count, 1);
2299 }
2300
2301 #[test]
2302 fn approval_request_frame_includes_matched_rule() {
2303 let requirement = ExecApprovalRequirement::NeedsApproval {
2304 reason: "Typed ask rule 'tool=exec_shell command=cargo test' requires approval."
2305 .to_string(),
2306 proposed_execpolicy_amendment: None,
2307 proposed_network_policy_amendments: Vec::new(),
2308 };
2309
2310 let frame = approval_request_frame(
2311 &requirement,
2312 Some("tool=exec_shell command=cargo test"),
2313 "call-1".to_string(),
2314 "approval-1".to_string(),
2315 "turn-1".to_string(),
2316 "cargo test --workspace".to_string(),
2317 "/repo".to_string(),
2318 )
2319 .expect("approval frame");
2320
2321 let EventFrame::ExecApprovalRequest { request } = frame else {
2322 panic!("expected exec approval request frame");
2323 };
2324 assert_eq!(
2325 request.matched_rule.as_deref(),
2326 Some("tool=exec_shell command=cargo test")
2327 );
2328 assert_eq!(request.reason, requirement.reason());
2329 }
2330
2331 #[test]
2332 fn user_input_request_frame_lifts_questions_from_arguments() {
2333 let arguments = r#"{"questions":[{"header":"Scope","id":"scope","question":"Which?","options":[{"label":"A","description":"a"},{"label":"B","description":"b"}],"allow_free_text":true}]}"#;
2337 let frame = user_input_request_frame(
2338 "call-1".to_string(),
2339 "turn-1".to_string(),
2340 "ui-1".to_string(),
2341 arguments,
2342 )
2343 .expect("user input frame");
2344
2345 let EventFrame::UserInputRequest { request } = frame else {
2346 panic!("expected user_input_request frame");
2347 };
2348 assert_eq!(request.call_id, "call-1");
2349 assert_eq!(request.turn_id, "turn-1");
2350 assert_eq!(request.request_id, "ui-1");
2351 assert_eq!(request.questions.len(), 1);
2352 assert_eq!(request.questions[0].id, "scope");
2353 assert!(request.questions[0].allow_free_text);
2354 assert!(!request.questions[0].multi_select);
2356 assert_eq!(request.questions[0].options.len(), 2);
2357 }
2358
2359 #[test]
2360 fn user_input_request_frame_returns_none_on_invalid_arguments() {
2361 let frame = user_input_request_frame(
2364 "call-1".to_string(),
2365 "turn-1".to_string(),
2366 "ui-1".to_string(),
2367 "not json",
2368 );
2369 assert!(frame.is_none());
2370
2371 let frame = user_input_request_frame(
2373 "call-1".to_string(),
2374 "turn-1".to_string(),
2375 "ui-1".to_string(),
2376 r#"{"foo":"bar"}"#,
2377 );
2378 assert!(frame.is_none());
2379 }
2380
2381 #[test]
2382 fn enqueue_creates_queued_job_with_zero_progress() {
2383 let mut jm = JobManager::default();
2384 let job = jm.enqueue("build");
2385 assert_eq!(job.name, "build");
2386 assert_eq!(job.status, JobStatus::Queued);
2387 assert_eq!(job.progress, Some(0));
2388 assert!(job.detail.is_none());
2389 assert_eq!(job.history.len(), 1);
2390 assert_eq!(job.history[0].phase, "created");
2391 }
2392
2393 #[test]
2394 fn set_running_transitions_from_queued() {
2395 let mut jm = JobManager::default();
2396 let job = jm.enqueue("deploy");
2397 let id = job.id.clone();
2398 jm.set_running(&id);
2399 let jobs = jm.list();
2400 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2401 assert_eq!(updated.status, JobStatus::Running);
2402 assert_eq!(updated.history.last().unwrap().phase, "running");
2403 }
2404
2405 #[test]
2406 fn update_progress_clamps_to_100() {
2407 let mut jm = JobManager::default();
2408 let job = jm.enqueue("task");
2409 let id = job.id.clone();
2410 jm.update_progress(&id, 150, Some("over".to_string()));
2411 let jobs = jm.list();
2412 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2413 assert_eq!(updated.progress, Some(100));
2414 }
2415
2416 #[test]
2417 fn complete_sets_progress_to_100() {
2418 let mut jm = JobManager::default();
2419 let job = jm.enqueue("task");
2420 let id = job.id.clone();
2421 jm.set_running(&id);
2422 jm.complete(&id);
2423 let jobs = jm.list();
2424 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2425 assert_eq!(updated.status, JobStatus::Completed);
2426 assert_eq!(updated.progress, Some(100));
2427 }
2428
2429 #[test]
2430 fn fail_increments_attempt_and_sets_backoff() {
2431 let mut jm = JobManager::default();
2432 let job = jm.enqueue("fragile");
2433 let id = job.id.clone();
2434 jm.set_running(&id);
2435 jm.fail(&id, "crashed");
2436 let jobs = jm.list();
2437 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2438 assert_eq!(updated.status, JobStatus::Failed);
2439 assert_eq!(updated.retry.attempt, 1);
2440 assert!(updated.retry.next_backoff_ms > 0);
2441 assert!(updated.retry.next_retry_at.is_some());
2442 assert_eq!(updated.detail.as_deref(), Some("crashed"));
2443 }
2444
2445 #[test]
2446 fn fail_clears_retry_after_max_attempts() {
2447 let mut jm = JobManager::default();
2448 let job = jm.enqueue("fragile");
2449 let id = job.id.clone();
2450 for _ in 0..=DEFAULT_JOB_MAX_ATTEMPTS {
2451 jm.set_running(&id);
2452 jm.fail(&id, "boom");
2453 }
2454 let jobs = jm.list();
2455 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2456 assert_eq!(updated.retry.attempt, DEFAULT_JOB_MAX_ATTEMPTS);
2457 assert_eq!(updated.retry.next_backoff_ms, 0);
2458 assert!(updated.retry.next_retry_at.is_none());
2459 }
2460
2461 #[test]
2462 fn cancel_sets_status_and_clears_retry() {
2463 let mut jm = JobManager::default();
2464 let job = jm.enqueue("task");
2465 let id = job.id.clone();
2466 jm.cancel(&id);
2467 let jobs = jm.list();
2468 let updated = jobs.iter().find(|j| j.id == id).unwrap();
2469 assert_eq!(updated.status, JobStatus::Cancelled);
2470 assert_eq!(updated.retry.next_backoff_ms, 0);
2471 }
2472
2473 #[test]
2474 fn pause_and_resume_round_trip() {
2475 let mut jm = JobManager::default();
2476 let job = jm.enqueue("task");
2477 let id = job.id.clone();
2478 jm.set_running(&id);
2479 jm.pause(&id, Some("waiting".to_string()));
2480 let jobs = jm.list();
2481 let paused = jobs.iter().find(|j| j.id == id).unwrap();
2482 assert_eq!(paused.status, JobStatus::Paused);
2483 assert_eq!(paused.detail.as_deref(), Some("waiting"));
2484
2485 jm.resume(&id, None);
2486 let jobs = jm.list();
2487 let resumed = jobs.iter().find(|j| j.id == id).unwrap();
2488 assert_eq!(resumed.status, JobStatus::Running);
2489 assert_eq!(resumed.history.last().unwrap().phase, "resumed");
2490 }
2491
2492 #[test]
2493 fn list_returns_jobs_sorted_by_updated_at_desc() {
2494 let mut jm = JobManager::default();
2495 jm.enqueue("first");
2496 jm.enqueue("second");
2497 jm.enqueue("third");
2498 let jobs = jm.list();
2499 assert_eq!(jobs.len(), 3);
2500 for window in jobs.windows(2) {
2501 assert!(window[0].updated_at >= window[1].updated_at);
2502 }
2503 }
2504
2505 #[test]
2506 fn history_returns_entries_for_existing_job() {
2507 let mut jm = JobManager::default();
2508 let job = jm.enqueue("task");
2509 let id = job.id.clone();
2510 jm.set_running(&id);
2511 jm.complete(&id);
2512 let history = jm.history(&id);
2513 assert_eq!(history.len(), 3); assert_eq!(history[0].phase, "created");
2515 assert_eq!(history[1].phase, "running");
2516 assert_eq!(history[2].phase, "completed");
2517 }
2518
2519 #[test]
2520 fn history_returns_empty_for_unknown_job() {
2521 let jm = JobManager::default();
2522 assert!(jm.history("nonexistent").is_empty());
2523 }
2524
2525 #[test]
2526 fn resume_pending_requeues_running_and_queued() {
2527 let mut jm = JobManager::default();
2528 let _j1 = jm.enqueue("queued_task");
2529 let j2 = jm.enqueue("running_task");
2530 let j3 = jm.enqueue("completed_task");
2531 let id2 = j2.id.clone();
2532 let id3 = j3.id.clone();
2533 jm.set_running(&id2);
2534 jm.set_running(&id3);
2535 jm.complete(&id3);
2536
2537 let resumed = jm.resume_pending();
2538 assert_eq!(resumed.len(), 2);
2539 for job in &resumed {
2540 assert_eq!(job.status, JobStatus::Queued);
2541 }
2542 }
2543
2544 #[test]
2547 fn deterministic_backoff_zero_on_first_attempt() {
2548 let retry = JobRetryMetadata {
2549 attempt: 0,
2550 ..Default::default()
2551 };
2552 assert_eq!(JobManager::deterministic_backoff_ms(&retry), 0);
2553 }
2554
2555 #[test]
2556 fn deterministic_backoff_exponential_growth() {
2557 let base = DEFAULT_JOB_BACKOFF_BASE_MS;
2558 for attempt in 1..=5 {
2559 let retry = JobRetryMetadata {
2560 attempt,
2561 backoff_base_ms: base,
2562 ..Default::default()
2563 };
2564 let expected = base * 2u64.pow(attempt.saturating_sub(1).min(20));
2565 assert_eq!(
2566 JobManager::deterministic_backoff_ms(&retry),
2567 expected,
2568 "attempt {attempt}"
2569 );
2570 }
2571 }
2572
2573 #[test]
2574 fn deterministic_backoff_saturates_at_high_exponent() {
2575 let retry = JobRetryMetadata {
2576 attempt: 63,
2577 backoff_base_ms: 1000,
2578 ..Default::default()
2579 };
2580 let _ = JobManager::deterministic_backoff_ms(&retry);
2582 }
2583
2584 #[test]
2587 fn push_history_truncates_beyond_max() {
2588 let mut jm = JobManager::default();
2589 let job = jm.enqueue("task");
2590 let id = job.id.clone();
2591 for i in 0..(MAX_JOB_HISTORY_ENTRIES + 20) {
2593 jm.update_progress(&id, (i % 100) as u8, Some(format!("step {i}")));
2594 }
2595 let history = jm.history(&id);
2596 assert_eq!(history.len(), MAX_JOB_HISTORY_ENTRIES);
2597 }
2598
2599 #[test]
2602 fn encode_and_parse_persisted_detail_round_trip() {
2603 let mut jm = JobManager::default();
2604 let job = jm.enqueue("task");
2605 let id = job.id.clone();
2606 jm.set_running(&id);
2607 jm.fail(&id, "oops");
2608 let job = jm.list().into_iter().find(|j| j.id == id).unwrap();
2609
2610 let encoded = JobManager::encode_persisted_detail(&job).unwrap().unwrap();
2611 let parsed = JobManager::parse_persisted_detail(Some(&encoded)).unwrap();
2612
2613 assert_eq!(parsed.status, job.status);
2614 assert_eq!(parsed.detail, job.detail);
2615 assert_eq!(parsed.retry.attempt, job.retry.attempt);
2616 assert_eq!(parsed.history.len(), job.history.len());
2617 }
2618
2619 #[test]
2620 fn parse_persisted_detail_returns_none_for_none_input() {
2621 assert!(JobManager::parse_persisted_detail(None).is_none());
2622 }
2623
2624 #[test]
2625 fn parse_persisted_detail_returns_none_for_invalid_json() {
2626 assert!(JobManager::parse_persisted_detail(Some("not json")).is_none());
2627 }
2628
2629 #[test]
2632 fn job_status_round_trip_str() {
2633 let statuses = [
2634 JobStatus::Queued,
2635 JobStatus::Running,
2636 JobStatus::Paused,
2637 JobStatus::Completed,
2638 JobStatus::Failed,
2639 JobStatus::Cancelled,
2640 ];
2641 for status in &statuses {
2642 let s = job_status_to_str(*status);
2643 let parsed = job_status_from_str(s);
2644 assert_eq!(parsed, Some(*status), "round-trip failed for {s:?}");
2645 }
2646 }
2647
2648 #[test]
2649 fn job_status_from_str_returns_none_for_unknown() {
2650 assert_eq!(job_status_from_str("unknown"), None);
2651 assert_eq!(job_status_from_str(""), None);
2652 }
2653
2654 #[test]
2655 fn truncate_preview_limits_to_120_chars() {
2656 let long = "a".repeat(200);
2657 let truncated = truncate_preview(&long);
2658 assert_eq!(truncated.len(), 120);
2659 }
2660
2661 #[test]
2662 fn truncate_preview_preserves_short_strings() {
2663 let short = "hello";
2664 assert_eq!(truncate_preview(short), "hello");
2665 }
2666
2667 #[test]
2668 fn runtime_status_to_job_state_maps_correctly() {
2669 assert_eq!(
2670 runtime_status_to_job_state(JobStatus::Queued),
2671 JobStateStatus::Queued
2672 );
2673 assert_eq!(
2674 runtime_status_to_job_state(JobStatus::Running),
2675 JobStateStatus::Running
2676 );
2677 assert_eq!(
2678 runtime_status_to_job_state(JobStatus::Paused),
2679 JobStateStatus::Running
2680 );
2681 assert_eq!(
2682 runtime_status_to_job_state(JobStatus::Completed),
2683 JobStateStatus::Completed
2684 );
2685 assert_eq!(
2686 runtime_status_to_job_state(JobStatus::Failed),
2687 JobStateStatus::Failed
2688 );
2689 assert_eq!(
2690 runtime_status_to_job_state(JobStatus::Cancelled),
2691 JobStateStatus::Cancelled
2692 );
2693 }
2694
2695 #[test]
2696 fn job_state_status_to_runtime_maps_correctly() {
2697 assert_eq!(
2698 job_state_status_to_runtime(JobStateStatus::Queued),
2699 JobStatus::Queued
2700 );
2701 assert_eq!(
2702 job_state_status_to_runtime(JobStateStatus::Running),
2703 JobStatus::Running
2704 );
2705 assert_eq!(
2706 job_state_status_to_runtime(JobStateStatus::Completed),
2707 JobStatus::Completed
2708 );
2709 assert_eq!(
2710 job_state_status_to_runtime(JobStateStatus::Failed),
2711 JobStatus::Failed
2712 );
2713 assert_eq!(
2714 job_state_status_to_runtime(JobStateStatus::Cancelled),
2715 JobStatus::Cancelled
2716 );
2717 }
2718
2719 #[test]
2720 fn preview_from_initial_history_new() {
2721 let preview = preview_from_initial_history(&InitialHistory::New);
2722 assert_eq!(preview, "New conversation");
2723 }
2724
2725 #[test]
2726 fn preview_from_initial_history_forked() {
2727 let preview = preview_from_initial_history(&InitialHistory::Forked(vec![json!("hello")]));
2728 assert!(preview.contains("hello"));
2729 }
2730
2731 #[test]
2732 fn preview_from_initial_history_resumed() {
2733 let preview = preview_from_initial_history(&InitialHistory::Resumed {
2734 conversation_id: "test".to_string(),
2735 history: vec![json!("world")],
2736 rollout_path: PathBuf::from("/tmp/test"),
2737 });
2738 assert!(preview.contains("world"));
2739 }
2740
2741 #[test]
2742 fn json_optional_string_handles_null() {
2743 assert!(json_optional_string(&Value::Null).is_none());
2744 }
2745
2746 #[test]
2747 fn json_optional_string_handles_string() {
2748 assert_eq!(
2749 json_optional_string(&Value::String("hello".to_string())),
2750 Some("hello".to_string())
2751 );
2752 }
2753
2754 #[test]
2755 fn json_optional_string_handles_non_string() {
2756 assert!(json_optional_string(&json!(42)).is_none());
2757 }
2758
2759 #[test]
2760 fn parse_retry_metadata_returns_default_for_none() {
2761 let retry = parse_retry_metadata(None);
2762 assert_eq!(retry.attempt, 0);
2763 assert_eq!(retry.max_attempts, DEFAULT_JOB_MAX_ATTEMPTS);
2764 assert_eq!(retry.backoff_base_ms, DEFAULT_JOB_BACKOFF_BASE_MS);
2765 }
2766
2767 #[test]
2768 fn parse_retry_metadata_parses_fields() {
2769 let value = json!({
2770 "attempt": 2,
2771 "max_attempts": 5,
2772 "backoff_base_ms": 1000,
2773 "next_backoff_ms": 2000,
2774 "next_retry_at": 1234567890i64
2775 });
2776 let retry = parse_retry_metadata(Some(&value));
2777 assert_eq!(retry.attempt, 2);
2778 assert_eq!(retry.max_attempts, 5);
2779 assert_eq!(retry.backoff_base_ms, 1000);
2780 assert_eq!(retry.next_backoff_ms, 2000);
2781 assert_eq!(retry.next_retry_at, Some(1234567890));
2782 }
2783
2784 #[test]
2785 fn parse_history_entry_returns_none_without_status() {
2786 let value = json!({"at": 1, "phase": "test"});
2787 assert!(parse_history_entry(&value).is_none());
2788 }
2789
2790 #[test]
2791 fn parse_history_entry_parses_valid_entry() {
2792 let value = json!({
2793 "at": 100,
2794 "phase": "running",
2795 "status": "running",
2796 "progress": 50,
2797 "detail": "working",
2798 "retry": {"attempt": 0, "max_attempts": 3, "backoff_base_ms": 500}
2799 });
2800 let entry = parse_history_entry(&value).unwrap();
2801 assert_eq!(entry.at, 100);
2802 assert_eq!(entry.phase, "running");
2803 assert_eq!(entry.status, JobStatus::Running);
2804 assert_eq!(entry.progress, Some(50));
2805 assert_eq!(entry.detail.as_deref(), Some("working"));
2806 }
2807}