1use std::path::{Path, PathBuf};
10use std::sync::{Arc, Mutex};
11
12use serde::{Deserialize, Serialize};
13use speccoder_core::stage::{Stage, StageState};
14use speccoder_core::{CommandData, CommandResult, CommandStatus};
15
16use crate::substrate::model_config::Prices;
17
18pub const FILE: &str = "live.json";
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23pub struct PlannedTask {
24 pub id: String,
26 pub title: String,
28 pub state: String,
30}
31
32pub const RECENT: usize = 12;
34
35#[derive(Debug, Clone, PartialEq, Serialize)]
37#[serde(tag = "event", rename_all = "snake_case")]
38pub enum Event {
39 RunStarted {
42 feature: String,
44 tasks: Vec<PlannedTask>,
46 },
47 TaskStarted {
49 feature: String,
51 task: String,
53 title: String,
55 },
56 Stage {
58 task: String,
60 stage: Stage,
62 state: StageState,
64 detail: Option<String>,
66 elapsed_ms: Option<u64>,
68 tail: Vec<String>,
70 },
71 Resumed {
73 task: String,
75 stage: Option<Stage>,
77 last_call: Option<String>,
79 },
80 ApprovalRequested {
82 task: String,
84 call: String,
86 tool: String,
88 what: String,
90 class: String,
93 object: String,
96 },
97 ApprovalDecided {
99 task: String,
101 call: String,
103 decision: String,
105 scope: Option<String>,
107 by: String,
109 how: String,
111 class: String,
113 object: String,
115 },
116 ToolCall {
118 task: String,
120 turn: u32,
122 tool: String,
124 target: String,
126 decision: String,
128 ok: Option<bool>,
130 tail: Vec<String>,
132 },
133 Usage {
135 task: String,
137 input_tokens: u64,
139 output_tokens: u64,
141 cache_read_tokens: u64,
143 cache_write_tokens: u64,
145 usd: Option<f64>,
147 },
148 TaskFinished {
150 task: String,
152 outcome: String,
154 message: String,
156 run: Option<String>,
158 input_tokens: u64,
160 output_tokens: u64,
162 cache_read_tokens: u64,
164 cache_write_tokens: u64,
166 usd: Option<f64>,
168 },
169 Said {
173 task: String,
175 turn: u32,
177 text: String,
179 },
180 Reasoning {
184 task: String,
186 turn: u32,
188 text: String,
190 },
191 Notice {
193 text: String,
195 },
196 Bug {
198 task: String,
200 step: BugEvent,
202 },
203 Oracle {
206 step: OracleEvent,
208 },
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
213#[serde(tag = "kind", rename_all = "snake_case")]
214pub enum OracleEvent {
215 Stage {
217 requirement: String,
219 stage: String,
221 n: u32,
223 of: u32,
225 },
226 Frozen {
228 unit: String,
230 requirement: String,
232 manifest_sha256: String,
234 approval_sha256: String,
236 visible_count: usize,
238 held_out_count: usize,
240 by: String,
242 via: String,
244 },
245 Lapsed {
247 unit: String,
249 requirement: String,
251 frozen_wording_sha256: String,
253 current_wording_sha256: String,
255 },
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
260pub struct StatementClass {
261 pub statement: String,
263 pub class: String,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
269pub struct ShownReproduction {
270 pub statement: String,
272 pub file: String,
274 pub test: String,
276 pub digest: String,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
282pub struct ShownSpecialCase {
283 pub statement: String,
285 pub rejected: bool,
287}
288
289#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
291#[serde(tag = "kind", rename_all = "snake_case")]
292pub enum BugEvent {
293 Recorded {
295 home: String,
297 unit: String,
299 requirement: String,
301 triad_digest: String,
303 via: String,
305 },
306 ReproduceTry {
308 n: u32,
310 of: u32,
312 per_statement: Vec<StatementClass>,
314 },
315 Reproduced {
317 reproductions: Vec<ShownReproduction>,
319 },
320 ConfirmWaiting {
322 digest: String,
324 next: String,
326 },
327 Confirmed {
329 digest: String,
331 by: String,
333 via: String,
335 basis: String,
337 },
338 ConfirmRejected {
340 digest: String,
342 reason: String,
344 },
345 UnchangedSelected {
347 state: String,
349 count: usize,
351 },
352 Discrimination {
354 revert: String,
356 special_case: Vec<ShownSpecialCase>,
358 },
359 Outcome {
361 outcome: String,
363 verdict: Option<String>,
365 code: Option<String>,
367 next: Vec<String>,
369 },
370}
371
372impl BugEvent {
373 #[must_use]
376 pub const fn name(&self) -> &'static str {
377 match self {
378 Self::Recorded { .. } => "bug.recorded",
379 Self::ReproduceTry { .. } => "bug.reproduce_try",
380 Self::Reproduced { .. } => "bug.reproduced",
381 Self::ConfirmWaiting { .. } => "bug.confirm_waiting",
382 Self::Confirmed { .. } => "bug.confirmed",
383 Self::ConfirmRejected { .. } => "bug.confirm_rejected",
384 Self::UnchangedSelected { .. } => "bug.unchanged_selected",
385 Self::Discrimination { .. } => "bug.discrimination",
386 Self::Outcome { .. } => "bug.outcome",
387 }
388 }
389
390 #[must_use]
392 pub fn line(&self) -> String {
393 match self {
394 Self::Recorded {
395 unit, requirement, ..
396 } => format!("bug recorded as {requirement} in {unit}"),
397 Self::ReproduceTry {
398 n,
399 of,
400 per_statement,
401 } => format!(
402 "reproduce try {n}/{of}: {}",
403 per_statement
404 .iter()
405 .map(|s| format!("{} {}", s.statement, s.class))
406 .collect::<Vec<_>>()
407 .join(", ")
408 ),
409 Self::Reproduced { reproductions } => format!(
410 "reproduced: {} test(s) fail on an assertion",
411 reproductions
412 .iter()
413 .filter(|r| r.statement.starts_with('E'))
414 .count()
415 ),
416 Self::ConfirmWaiting { next, .. } => format!("the reproductions wait for you: {next}"),
417 Self::Confirmed { by, basis, .. } => {
418 format!("reproductions confirmed by {by} ({basis})")
419 }
420 Self::ConfirmRejected { reason, .. } => format!("reproductions rejected: {reason}"),
421 Self::UnchangedSelected { state, count } => {
422 format!("pass-to-pass: {count} test(s) ({state})")
423 }
424 Self::Discrimination {
425 revert,
426 special_case,
427 } => format!(
428 "revert {revert}; {} special case(s), {} rejected",
429 special_case.len(),
430 special_case.iter().filter(|s| s.rejected).count()
431 ),
432 Self::Outcome {
433 outcome, verdict, ..
434 } => match verdict {
435 Some(v) => format!("bug {outcome}: {v}"),
436 None => format!("bug {outcome}"),
437 },
438 }
439 }
440}
441
442pub trait Progress: Send + Sync + std::fmt::Debug {
444 fn event(&self, event: &Event);
446}
447
448#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
450pub struct LiveState {
451 pub pid: u32,
453 pub feature: String,
455 pub task: String,
457 pub title: String,
459 #[serde(default)]
461 pub stage: Option<Stage>,
462 #[serde(default)]
464 pub stage_detail: Option<String>,
465 pub last_call: Option<String>,
467 #[serde(default)]
469 pub said: Option<String>,
470 #[serde(default)]
472 pub thinking: Option<String>,
473 #[serde(default)]
475 pub recent: Vec<String>,
476 pub input_tokens: u64,
478 pub output_tokens: u64,
480 pub usd: Option<f64>,
482 pub finished: Option<String>,
484 pub started_at: u64,
486 pub updated_at: u64,
488}
489
490fn now() -> u64 {
491 std::time::SystemTime::now()
492 .duration_since(std::time::UNIX_EPOCH)
493 .map_or(0, |d| d.as_secs())
494}
495
496#[must_use]
498pub fn usd(
499 prices: Option<Prices>,
500 input: u64,
501 output: u64,
502 cache_read: u64,
503 cache_write: u64,
504) -> Option<f64> {
505 prices.map(|p| p.usd(input, output, cache_read, cache_write))
506}
507
508#[derive(Debug)]
510pub struct Live {
511 file: PathBuf,
512 prices: Option<Prices>,
513 sink: Option<Arc<dyn Progress>>,
514 state: Mutex<LiveState>,
515}
516
517impl Live {
518 #[must_use]
520 pub fn new(dir: &Path, prices: Option<Prices>, sink: Option<Arc<dyn Progress>>) -> Self {
521 let _ = std::fs::create_dir_all(dir);
522 Self {
523 file: dir.join(FILE),
524 prices,
525 sink,
526 state: Mutex::new(LiveState {
527 pid: std::process::id(),
528 ..LiveState::default()
529 }),
530 }
531 }
532
533 fn write(&self, state: &LiveState) {
534 if let Ok(text) = serde_json::to_string_pretty(state) {
535 let tmp = self.file.with_extension("json.tmp");
536 if std::fs::write(&tmp, text).is_ok() {
537 let _ = std::fs::rename(&tmp, &self.file);
538 }
539 }
540 }
541}
542
543impl Progress for Live {
544 fn event(&self, event: &Event) {
545 let mut event = event.clone();
546 match &mut event {
548 Event::Usage {
549 input_tokens,
550 output_tokens,
551 cache_read_tokens,
552 cache_write_tokens,
553 usd: price,
554 ..
555 } => {
556 *price = usd(
557 self.prices,
558 *input_tokens,
559 *output_tokens,
560 *cache_read_tokens,
561 *cache_write_tokens,
562 )
563 }
564 Event::TaskFinished {
565 input_tokens,
566 output_tokens,
567 cache_read_tokens,
568 cache_write_tokens,
569 usd: price,
570 ..
571 } => {
572 *price = usd(
573 self.prices,
574 *input_tokens,
575 *output_tokens,
576 *cache_read_tokens,
577 *cache_write_tokens,
578 )
579 }
580 _ => {}
581 }
582 if let Ok(mut s) = self.state.lock() {
583 match &event {
584 Event::TaskStarted {
585 feature,
586 task,
587 title,
588 } => {
589 *s = LiveState {
590 pid: s.pid,
591 feature: feature.clone(),
592 task: task.clone(),
593 title: title.clone(),
594 started_at: now(),
595 ..LiveState::default()
596 };
597 }
598 Event::Stage {
599 stage,
600 state: StageState::Started,
601 detail,
602 ..
603 } => {
604 s.stage = Some(*stage);
605 s.stage_detail.clone_from(detail);
606 }
607 Event::Said { text, .. } => {
608 s.said = text
609 .lines()
610 .find(|l| !l.trim().is_empty())
611 .map(|l| l.chars().take(160).collect());
612 }
613 Event::Reasoning { text, .. } => {
615 s.thinking = text
616 .lines()
617 .rev()
618 .find(|l| !l.trim().is_empty())
619 .map(|l| l.chars().take(160).collect());
620 }
621 Event::Stage { .. }
622 | Event::Resumed { .. }
623 | Event::ApprovalRequested { .. }
624 | Event::ApprovalDecided { .. } => {}
625 Event::ToolCall {
626 turn,
627 tool,
628 target,
629 decision,
630 ..
631 } => {
632 let call = format!("{tool} {target} ({decision})");
633 s.recent.push(format!("turn {turn} {call}"));
634 if s.recent.len() > RECENT {
635 s.recent.remove(0);
636 }
637 s.last_call = Some(call);
638 }
639 Event::Usage {
640 input_tokens,
641 output_tokens,
642 usd,
643 ..
644 } => {
645 s.input_tokens = *input_tokens;
646 s.output_tokens = *output_tokens;
647 s.usd = *usd;
648 }
649 Event::TaskFinished {
650 outcome, message, ..
651 } => s.finished = Some(format!("{outcome}: {message}")),
652 Event::Notice { .. }
653 | Event::RunStarted { .. }
654 | Event::Bug { .. }
655 | Event::Oracle { .. } => {}
656 }
657 s.updated_at = now();
658 self.write(&s);
659 }
660 if let Some(sink) = &self.sink {
661 sink.event(&event);
662 }
663 }
664}
665
666#[must_use]
668pub fn target(tool: &str, args: &serde_json::Value) -> String {
669 let text = match tool {
670 "run_command" => args["argv"]
671 .as_array()
672 .map(|a| {
673 a.iter()
674 .filter_map(|v| v.as_str())
675 .collect::<Vec<_>>()
676 .join(" ")
677 })
678 .unwrap_or_default(),
679 "search_code" => args["pattern"].as_str().unwrap_or_default().to_owned(),
680 name if name.starts_with(crate::mcp::PREFIX) => args
682 .as_object()
683 .and_then(|o| o.values().find_map(|v| v.as_str().map(str::to_owned)))
684 .unwrap_or_default(),
685 _ => args["path"].as_str().unwrap_or_default().to_owned(),
686 };
687 let one: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
688 if one.chars().count() > 60 {
689 format!("{}…", one.chars().take(59).collect::<String>())
690 } else {
691 one
692 }
693}
694
695#[must_use]
699pub fn read_live(path: &Path, state: &Path) -> Option<(LiveState, bool)> {
700 let root = match crate::inspect(path, None).data() {
701 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
702 _ => return None,
703 };
704 let text = std::fs::read_to_string(crate::worktree::project_state_dir(state, &root).join(FILE))
705 .ok()?;
706 let live: LiveState = serde_json::from_str(&text).ok()?;
707 let running = live.finished.is_none() && alive(live.pid);
708 Some((live, running))
709}
710
711pub fn alive(pid: u32) -> bool {
713 i32::try_from(pid)
714 .is_ok_and(|p| nix::sys::signal::kill(nix::unistd::Pid::from_raw(p), None).is_ok())
715}
716
717pub fn status_with(path: &Path, state: &Path) -> CommandResult {
720 let inspected = crate::inspect(path, None);
722 let root = match inspected.data() {
723 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
724 _ => return CommandResult::carried("report", &inspected),
725 };
726 let plane_dir = crate::worktree::project_state_dir(state, &root);
727 let res = status_inner(&plane_dir);
728 let lines = waiting_lines(&plane_dir);
729 if lines.is_empty() {
730 return res;
731 }
732 let mut next = res.next().to_vec();
733 next.insert(
734 0,
735 speccoder_core::next::NextStep {
736 command: "speccoder report --approvals".to_owned(),
737 why: "see the waiting calls, then approve or reject them".to_owned(),
738 },
739 );
740 let message = format!("{}\n{}", res.message(), lines.join("\n"));
741 let status = res.status();
742 res.reworded(status, &message).with_next(next)
743}
744
745fn waiting_lines(plane_dir: &Path) -> Vec<String> {
747 if !plane_dir.join(crate::substrate::JOURNAL).exists() {
749 return Vec::new();
750 }
751 let unknown = |why: String| vec![format!("waiting for a person: unknown — {why}")];
753 let rt = match crate::tokio_runtime() {
754 Ok(rt) => rt,
755 Err(e) => return unknown(e),
756 };
757 let plane = match crate::substrate::Plane::open_for_decisions(plane_dir) {
758 Ok(p) => p,
759 Err(e) => return unknown(e.0),
760 };
761 let now = agentplane::core::Timestamp::now_utc();
762 let open = match rt.block_on(plane.open_tasks()) {
763 Ok(open) => open,
764 Err(e) => return unknown(e.0),
765 };
766 open.into_iter()
767 .filter_map(|t| {
768 let request = crate::approvals::tasks::request_of(&t.justification.proposed_action)?;
769 let since = (now - t.created_at).whole_minutes().max(0);
770 let until = t
771 .due_at
772 .map(|d| {
773 format!(
774 ", denied as expired in {} h",
775 (d - now).whole_hours().max(0)
776 )
777 })
778 .unwrap_or_default();
779 Some(format!(
780 "waiting for a person: {} — {} (for {since} min{until})",
781 request.task,
782 request.covers()
783 ))
784 })
785 .collect()
786}
787
788fn status_inner(plane_dir: &Path) -> CommandResult {
789 let plane_dir = plane_dir.to_path_buf();
790 if let Some(s) = crate::in_place::Session::load(&plane_dir) {
792 let mode = s.mode_line();
793 if !alive(s.pid) {
794 let step = |command: &str, why: &str| speccoder_core::next::NextStep {
795 command: command.to_owned(),
796 why: why.to_owned(),
797 };
798 return CommandResult::new(
799 "status",
800 CommandStatus::Ok,
801 &format!(
802 "interrupted in-place run of {} (pid {} is gone): the checkout is still on {}\n{mode}",
803 s.feature, s.pid, s.branch
804 ),
805 )
806 .with_next(vec![
807 step("speccoder run --in-place", "resume where it stopped"),
808 step(
809 "speccoder run --in-place --return",
810 "save what it left and switch back without running",
811 ),
812 ]);
813 }
814 let res = status_live(&plane_dir);
815 let message = format!("{}\n{mode}", res.message());
816 let status = res.status();
817 return res.reworded(status, &message);
818 }
819 status_live(&plane_dir)
820}
821
822fn status_live(plane_dir: &Path) -> CommandResult {
824 let file = plane_dir.join(FILE);
825 let live: LiveState = match std::fs::read_to_string(&file)
826 .ok()
827 .and_then(|t| serde_json::from_str(&t).ok())
828 {
829 Some(l) => l,
830 None => {
831 return CommandResult::new(
832 "status",
833 CommandStatus::Ok,
834 "no run has been recorded for this project yet",
835 );
836 }
837 };
838 let ago = now().saturating_sub(live.updated_at);
839 let tokens = format!(
840 "{} in · {} out{}",
841 live.input_tokens,
842 live.output_tokens,
843 live.usd.map(|u| format!(" · ${u:.4}")).unwrap_or_default()
844 );
845 use speccoder_core::next::{Outcome, next};
846 let outcome = match (&live.finished, alive(live.pid)) {
847 (None, true) => Some(Outcome::Running),
848 (None, false) => Some(Outcome::Interrupted {
849 task: live.task.clone(),
850 }),
851 (Some(_), _) => None,
852 };
853 let message = match (&live.finished, alive(live.pid)) {
854 (None, true) => format!(
855 "running (pid {}): {} {} — {} for {}s; last: {}; {tokens}{}{}",
856 live.pid,
857 live.feature,
858 live.task,
859 stage_word(&live),
860 now().saturating_sub(live.started_at),
861 live.last_call.as_deref().unwrap_or("-"),
862 live.thinking
863 .as_deref()
864 .map(|s| format!("\nthe model thinks (a claim): {s}"))
865 .unwrap_or_default(),
866 live.said
867 .as_deref()
868 .map(|s| format!("\nthe model says (a claim): “{s}”"))
869 .unwrap_or_default(),
870 ),
871 (None, false) => format!(
872 "interrupted: {} {} stopped during {} {ago}s ago (pid {} is gone); the next `speccoder run` resumes it from its journal",
873 live.feature,
874 live.task,
875 stage_word(&live),
876 live.pid
877 ),
878 (Some(outcome), _) => format!(
879 "last run: {} {} — {outcome} ({ago}s ago); {tokens}",
880 live.feature, live.task
881 ),
882 };
883 CommandResult::new("status", CommandStatus::Ok, &message)
884 .with_next(outcome.map(|o| next(&o, None)).unwrap_or_default())
885}
886
887#[must_use]
889pub fn stage_word(live: &LiveState) -> String {
890 match (live.stage, &live.stage_detail) {
891 (Some(st), Some(d)) => format!("{} {d}", st.name()),
892 (Some(st), None) => st.name().to_owned(),
893 (None, _) => "starting".to_owned(),
894 }
895}
896
897#[must_use]
900pub fn tail(text: &str) -> Vec<String> {
901 let lines: Vec<String> = text
902 .lines()
903 .map(|l| {
904 l.chars()
905 .filter(|c| !c.is_control())
906 .take(200)
907 .collect::<String>()
908 })
909 .filter(|l| !l.trim().is_empty())
910 .collect();
911 lines[lines.len().saturating_sub(5)..].to_vec()
912}
913
914#[must_use]
916pub fn status(path: &Path) -> CommandResult {
917 match crate::worktree::state_dir() {
918 Some(state) => status_with(path, &state),
919 None => CommandResult::new(
920 "status",
921 CommandStatus::ConfigError,
922 "no state directory (set SPECCODER_STATE_DIR or HOME)",
923 )
924 .with_code("NO-STATE-DIR"),
925 }
926}