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 },
94 ApprovalDecided {
96 task: String,
98 call: String,
100 decision: String,
102 scope: Option<String>,
104 by: String,
106 how: String,
108 },
109 ToolCall {
111 task: String,
113 turn: u32,
115 tool: String,
117 target: String,
119 decision: String,
121 ok: Option<bool>,
123 tail: Vec<String>,
125 },
126 Usage {
128 task: String,
130 input_tokens: u64,
132 output_tokens: u64,
134 cache_read_tokens: u64,
136 cache_write_tokens: u64,
138 usd: Option<f64>,
140 },
141 TaskFinished {
143 task: String,
145 outcome: String,
147 message: String,
149 run: Option<String>,
151 input_tokens: u64,
153 output_tokens: u64,
155 cache_read_tokens: u64,
157 cache_write_tokens: u64,
159 usd: Option<f64>,
161 },
162 Said {
166 task: String,
168 turn: u32,
170 text: String,
172 },
173 Notice {
175 text: String,
177 },
178}
179
180pub trait Progress: Send + Sync + std::fmt::Debug {
182 fn event(&self, event: &Event);
184}
185
186#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
188pub struct LiveState {
189 pub pid: u32,
191 pub feature: String,
193 pub task: String,
195 pub title: String,
197 #[serde(default)]
199 pub stage: Option<Stage>,
200 #[serde(default)]
202 pub stage_detail: Option<String>,
203 pub last_call: Option<String>,
205 #[serde(default)]
207 pub said: Option<String>,
208 #[serde(default)]
210 pub recent: Vec<String>,
211 pub input_tokens: u64,
213 pub output_tokens: u64,
215 pub usd: Option<f64>,
217 pub finished: Option<String>,
219 pub started_at: u64,
221 pub updated_at: u64,
223}
224
225fn now() -> u64 {
226 std::time::SystemTime::now()
227 .duration_since(std::time::UNIX_EPOCH)
228 .map_or(0, |d| d.as_secs())
229}
230
231#[must_use]
233pub fn usd(
234 prices: Option<Prices>,
235 input: u64,
236 output: u64,
237 cache_read: u64,
238 cache_write: u64,
239) -> Option<f64> {
240 prices.map(|p| p.usd(input, output, cache_read, cache_write))
241}
242
243#[derive(Debug)]
245pub struct Live {
246 file: PathBuf,
247 prices: Option<Prices>,
248 sink: Option<Arc<dyn Progress>>,
249 state: Mutex<LiveState>,
250}
251
252impl Live {
253 #[must_use]
255 pub fn new(dir: &Path, prices: Option<Prices>, sink: Option<Arc<dyn Progress>>) -> Self {
256 let _ = std::fs::create_dir_all(dir);
257 Self {
258 file: dir.join(FILE),
259 prices,
260 sink,
261 state: Mutex::new(LiveState {
262 pid: std::process::id(),
263 ..LiveState::default()
264 }),
265 }
266 }
267
268 fn write(&self, state: &LiveState) {
269 if let Ok(text) = serde_json::to_string_pretty(state) {
270 let tmp = self.file.with_extension("json.tmp");
271 if std::fs::write(&tmp, text).is_ok() {
272 let _ = std::fs::rename(&tmp, &self.file);
273 }
274 }
275 }
276}
277
278impl Progress for Live {
279 fn event(&self, event: &Event) {
280 let mut event = event.clone();
281 match &mut event {
283 Event::Usage {
284 input_tokens,
285 output_tokens,
286 cache_read_tokens,
287 cache_write_tokens,
288 usd: price,
289 ..
290 } => {
291 *price = usd(
292 self.prices,
293 *input_tokens,
294 *output_tokens,
295 *cache_read_tokens,
296 *cache_write_tokens,
297 )
298 }
299 Event::TaskFinished {
300 input_tokens,
301 output_tokens,
302 cache_read_tokens,
303 cache_write_tokens,
304 usd: price,
305 ..
306 } => {
307 *price = usd(
308 self.prices,
309 *input_tokens,
310 *output_tokens,
311 *cache_read_tokens,
312 *cache_write_tokens,
313 )
314 }
315 _ => {}
316 }
317 if let Ok(mut s) = self.state.lock() {
318 match &event {
319 Event::TaskStarted {
320 feature,
321 task,
322 title,
323 } => {
324 *s = LiveState {
325 pid: s.pid,
326 feature: feature.clone(),
327 task: task.clone(),
328 title: title.clone(),
329 started_at: now(),
330 ..LiveState::default()
331 };
332 }
333 Event::Stage {
334 stage,
335 state: StageState::Started,
336 detail,
337 ..
338 } => {
339 s.stage = Some(*stage);
340 s.stage_detail.clone_from(detail);
341 }
342 Event::Said { text, .. } => {
343 s.said = text
344 .lines()
345 .find(|l| !l.trim().is_empty())
346 .map(|l| l.chars().take(160).collect());
347 }
348 Event::Stage { .. }
349 | Event::Resumed { .. }
350 | Event::ApprovalRequested { .. }
351 | Event::ApprovalDecided { .. } => {}
352 Event::ToolCall {
353 turn,
354 tool,
355 target,
356 decision,
357 ..
358 } => {
359 let call = format!("{tool} {target} ({decision})");
360 s.recent.push(format!("turn {turn} {call}"));
361 if s.recent.len() > RECENT {
362 s.recent.remove(0);
363 }
364 s.last_call = Some(call);
365 }
366 Event::Usage {
367 input_tokens,
368 output_tokens,
369 usd,
370 ..
371 } => {
372 s.input_tokens = *input_tokens;
373 s.output_tokens = *output_tokens;
374 s.usd = *usd;
375 }
376 Event::TaskFinished {
377 outcome, message, ..
378 } => s.finished = Some(format!("{outcome}: {message}")),
379 Event::Notice { .. } | Event::RunStarted { .. } => {}
380 }
381 s.updated_at = now();
382 self.write(&s);
383 }
384 if let Some(sink) = &self.sink {
385 sink.event(&event);
386 }
387 }
388}
389
390#[must_use]
392pub fn target(tool: &str, args: &serde_json::Value) -> String {
393 let text = match tool {
394 "run_command" => args["argv"]
395 .as_array()
396 .map(|a| {
397 a.iter()
398 .filter_map(|v| v.as_str())
399 .collect::<Vec<_>>()
400 .join(" ")
401 })
402 .unwrap_or_default(),
403 "search_code" => args["pattern"].as_str().unwrap_or_default().to_owned(),
404 name if name.starts_with(crate::mcp::PREFIX) => args
406 .as_object()
407 .and_then(|o| o.values().find_map(|v| v.as_str().map(str::to_owned)))
408 .unwrap_or_default(),
409 _ => args["path"].as_str().unwrap_or_default().to_owned(),
410 };
411 let one: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
412 if one.chars().count() > 60 {
413 format!("{}…", one.chars().take(59).collect::<String>())
414 } else {
415 one
416 }
417}
418
419#[must_use]
423pub fn read_live(path: &Path, state: &Path) -> Option<(LiveState, bool)> {
424 let root = match crate::inspect(path, None).data() {
425 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
426 _ => return None,
427 };
428 let text = std::fs::read_to_string(crate::worktree::project_state_dir(state, &root).join(FILE))
429 .ok()?;
430 let live: LiveState = serde_json::from_str(&text).ok()?;
431 let running = live.finished.is_none() && alive(live.pid);
432 Some((live, running))
433}
434
435pub fn alive(pid: u32) -> bool {
437 i32::try_from(pid)
438 .is_ok_and(|p| nix::sys::signal::kill(nix::unistd::Pid::from_raw(p), None).is_ok())
439}
440
441pub fn status_with(path: &Path, state: &Path) -> CommandResult {
444 let inspected = crate::inspect(path, None);
446 let root = match inspected.data() {
447 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
448 _ => return CommandResult::carried("report", &inspected),
449 };
450 let plane_dir = crate::worktree::project_state_dir(state, &root);
451 let res = status_inner(&plane_dir);
452 let lines = waiting_lines(&plane_dir);
453 if lines.is_empty() {
454 return res;
455 }
456 let mut next = res.next().to_vec();
457 next.insert(
458 0,
459 speccoder_core::next::NextStep {
460 command: "speccoder report --approvals".to_owned(),
461 why: "see the waiting calls, then approve or reject them".to_owned(),
462 },
463 );
464 let message = format!("{}\n{}", res.message(), lines.join("\n"));
465 let status = res.status();
466 res.reworded(status, &message).with_next(next)
467}
468
469fn waiting_lines(plane_dir: &Path) -> Vec<String> {
471 if !plane_dir.join(crate::substrate::JOURNAL).exists() {
473 return Vec::new();
474 }
475 let Ok(rt) = crate::tokio_runtime() else {
476 return Vec::new();
477 };
478 let Ok(plane) = crate::substrate::Plane::open_for_decisions(plane_dir) else {
479 return Vec::new();
480 };
481 let now = agentplane::core::Timestamp::now_utc();
482 rt.block_on(plane.open_tasks())
483 .unwrap_or_default()
484 .into_iter()
485 .filter_map(|t| {
486 let request = crate::approvals::tasks::request_of(&t.justification.proposed_action)?;
487 let since = (now - t.created_at).whole_minutes().max(0);
488 let until = t
489 .due_at
490 .map(|d| {
491 format!(
492 ", denied as expired in {} h",
493 (d - now).whole_hours().max(0)
494 )
495 })
496 .unwrap_or_default();
497 Some(format!(
498 "waiting for a person: {} — {} (for {since} min{until})",
499 request.task, request.covers
500 ))
501 })
502 .collect()
503}
504
505fn status_inner(plane_dir: &Path) -> CommandResult {
506 let plane_dir = plane_dir.to_path_buf();
507 if let Some(s) = crate::in_place::Session::load(&plane_dir) {
509 let mode = s.mode_line();
510 if !alive(s.pid) {
511 let step = |command: &str, why: &str| speccoder_core::next::NextStep {
512 command: command.to_owned(),
513 why: why.to_owned(),
514 };
515 return CommandResult::new(
516 "status",
517 CommandStatus::Ok,
518 &format!(
519 "interrupted in-place run of {} (pid {} is gone): the checkout is still on {}\n{mode}",
520 s.feature, s.pid, s.branch
521 ),
522 )
523 .with_next(vec![
524 step("speccoder run --in-place", "resume where it stopped"),
525 step(
526 "speccoder run --in-place --return",
527 "save what it left and switch back without running",
528 ),
529 ]);
530 }
531 let res = status_live(&plane_dir);
532 let message = format!("{}\n{mode}", res.message());
533 let status = res.status();
534 return res.reworded(status, &message);
535 }
536 status_live(&plane_dir)
537}
538
539fn status_live(plane_dir: &Path) -> CommandResult {
541 let file = plane_dir.join(FILE);
542 let live: LiveState = match std::fs::read_to_string(&file)
543 .ok()
544 .and_then(|t| serde_json::from_str(&t).ok())
545 {
546 Some(l) => l,
547 None => {
548 return CommandResult::new(
549 "status",
550 CommandStatus::Ok,
551 "no run has been recorded for this project yet",
552 );
553 }
554 };
555 let ago = now().saturating_sub(live.updated_at);
556 let tokens = format!(
557 "{} in · {} out{}",
558 live.input_tokens,
559 live.output_tokens,
560 live.usd.map(|u| format!(" · ${u:.4}")).unwrap_or_default()
561 );
562 use speccoder_core::next::{Outcome, next};
563 let outcome = match (&live.finished, alive(live.pid)) {
564 (None, true) => Some(Outcome::Running),
565 (None, false) => Some(Outcome::Interrupted {
566 task: live.task.clone(),
567 }),
568 (Some(_), _) => None,
569 };
570 let message = match (&live.finished, alive(live.pid)) {
571 (None, true) => format!(
572 "running (pid {}): {} {} — {} for {}s; last: {}; {tokens}{}",
573 live.pid,
574 live.feature,
575 live.task,
576 stage_word(&live),
577 now().saturating_sub(live.started_at),
578 live.last_call.as_deref().unwrap_or("-"),
579 live.said
580 .as_deref()
581 .map(|s| format!("\nthe model says (a claim): “{s}”"))
582 .unwrap_or_default(),
583 ),
584 (None, false) => format!(
585 "interrupted: {} {} stopped during {} {ago}s ago (pid {} is gone); the next `speccoder run` resumes it from its journal",
586 live.feature,
587 live.task,
588 stage_word(&live),
589 live.pid
590 ),
591 (Some(outcome), _) => format!(
592 "last run: {} {} — {outcome} ({ago}s ago); {tokens}",
593 live.feature, live.task
594 ),
595 };
596 CommandResult::new("status", CommandStatus::Ok, &message)
597 .with_next(outcome.map(|o| next(&o, None)).unwrap_or_default())
598}
599
600#[must_use]
602pub fn stage_word(live: &LiveState) -> String {
603 match (live.stage, &live.stage_detail) {
604 (Some(st), Some(d)) => format!("{} {d}", st.name()),
605 (Some(st), None) => st.name().to_owned(),
606 (None, _) => "starting".to_owned(),
607 }
608}
609
610#[must_use]
613pub fn tail(text: &str) -> Vec<String> {
614 let lines: Vec<String> = text
615 .lines()
616 .map(|l| {
617 l.chars()
618 .filter(|c| !c.is_control())
619 .take(200)
620 .collect::<String>()
621 })
622 .filter(|l| !l.trim().is_empty())
623 .collect();
624 lines[lines.len().saturating_sub(5)..].to_vec()
625}
626
627#[must_use]
629pub fn status(path: &Path) -> CommandResult {
630 match crate::worktree::state_dir() {
631 Some(state) => status_with(path, &state),
632 None => CommandResult::new(
633 "status",
634 CommandStatus::ConfigError,
635 "no state directory (set SPECCODER_STATE_DIR or HOME)",
636 )
637 .with_code("NO-STATE-DIR"),
638 }
639}