use crate::util::{fmt_duration, fmt_int, short_id, Wait};
use anyhow::{bail, Context, Result};
use cv_core::ir::truncate;
pub(crate) struct WorkflowArgs<'a> {
pub id: &'a str,
pub run_id: Option<String>,
pub harness: Option<String>,
pub json: bool,
pub script: bool,
pub results: bool,
pub follow: bool,
pub for_: Option<String>,
pub revive: bool,
pub revive_all: bool,
}
#[derive(Clone, Copy)]
struct Render {
json: bool,
script: bool,
results: bool,
revive: bool,
revive_all: bool,
}
pub(crate) fn cmd_workflow(args: WorkflowArgs<'_>) -> Result<()> {
let WorkflowArgs {
id,
run_id,
harness,
json,
script,
results,
follow,
for_,
revive,
revive_all,
} = args;
let run_flags: Vec<&str> = [
("--script", script),
("--results", results),
("--follow", follow),
("--for", for_.is_some()),
("--revive", revive),
("--revive-all", revive_all),
]
.into_iter()
.filter_map(|(name, set)| set.then_some(name))
.collect();
let render = Render {
json,
script,
results,
revive,
revive_all,
};
let (want, id) = crate::util::split_harness_id(id, crate::util::parse_harness(&harness)?);
let found = match cv_core::find_cheap(id, want) {
Ok(found) => found,
Err(e) => return crate::util::resolve_found(Err(e), id, want).map(|_| ()),
};
let Some((r, _adapter)) = found else {
return workflow_by_name_fleetwide(id, want, render, &run_flags);
};
let Some(run_id) = run_id else {
refuse_run_flags(&run_flags, "a session's run list")?;
return list_workflows(&r, json);
};
if follow || for_.is_some() {
return follow_workflow(&r, &run_id, render, Wait::resolve(true, for_.as_deref())?);
}
let mut wf = cv_core::workflow_of(&r, &run_id).with_context(|| {
let runs = cv_core::workflows_of(&r);
let names: Vec<&str> = runs.iter().filter_map(|w| w.name.as_deref()).collect();
format!(
"no workflow {run_id:?} in {} — {} run(s): {}",
short_id(&r.id),
runs.len(),
if names.is_empty() {
"(unnamed)".into()
} else {
names.join(", ")
},
)
})?;
wf.attach_journal(&r.path);
emit_workflow(&wf, render, Some(r.path.as_path()))
}
fn refuse_run_flags(flags: &[&str], listing: &str) -> Result<()> {
match flags {
[] => Ok(()),
_ => crate::util::usage(format!(
"{} act on one run, but this prints {listing}; name the run: `cv workflow <session> <run_id> …`",
flags.join(", ")
)),
}
}
fn state_glyph(state: Option<&str>, fallback: &'static str) -> &'static str {
match state {
Some("done") => "✓",
Some("error") => "✗",
Some("progress") => "…",
Some("start") => "▸",
_ => fallback,
}
}
fn follow_workflow(r: &cv_core::SessionRef, key: &str, render: Render, wait: Wait) -> Result<()> {
use std::collections::HashMap;
let mut seen: HashMap<u64, String> = HashMap::new(); wait.announce("workflow", &format!("run {key:?}"));
loop {
let Some(wf) = cv_core::workflow_of(r, key) else {
if !wait.pause(std::time::Duration::from_secs(2)) {
eprintln!("cv workflow: run {key:?} never registered");
return Ok(());
}
continue;
};
let now = crate::util::fmt_local(chrono::Utc::now(), "%H:%M:%S");
for a in wf.phases.iter().flat_map(|p| &p.agents).chain(&wf.orphan_agents) {
let state = a.state.clone().unwrap_or_default();
if seen.get(&a.index).is_some_and(|s| *s == state) {
continue;
}
seen.insert(a.index, state.clone());
let glyph = state_glyph(Some(&state), "▸");
let tok = a.tokens.map(|t| format!(" ({} tok)", fmt_int(t))).unwrap_or_default();
println!(
"{now} {glyph} {} → {state}{tok}",
a.label.as_deref().unwrap_or("(agent)")
);
}
let live = matches!(wf.status.as_deref(), None | Some("running") | Some("started"));
if live && wait.pause(std::time::Duration::from_secs(2)) {
continue;
}
let status = wf.status.as_deref().unwrap_or("?");
if live {
println!("\n── --for elapsed, run still {status} ──\n");
} else {
println!("\n── run reached {status} ──\n");
}
let mut wf = wf;
wf.attach_journal(&r.path);
let render = Render {
revive: false,
revive_all: false,
..render
};
return emit_workflow(&wf, render, Some(r.path.as_path()));
}
}
fn emit_workflow(wf: &cv_core::Workflow, render: Render, session_path: Option<&std::path::Path>) -> Result<()> {
let Render {
json,
script,
results,
revive,
revive_all,
} = render;
if revive {
return render_revive(wf, revive_all, session_path);
}
if json {
println!("{}", serde_json::to_string_pretty(wf)?);
return Ok(());
}
render_workflow(wf, script)?;
if results {
render_journal_results(wf);
} else if wf
.phases
.iter()
.flat_map(|p| &p.agents)
.chain(&wf.orphan_agents)
.any(|a| a.journal_result.is_some())
{
println!("→ `--results` for each agent's FULL journaled return (previews above are ~400 chars)");
}
Ok(())
}
fn render_journal_results(w: &cv_core::Workflow) {
let agents: Vec<&cv_core::WorkflowAgent> = w
.phases
.iter()
.flat_map(|p| &p.agents)
.chain(&w.orphan_agents)
.filter(|a| a.journal_result.is_some())
.collect();
if agents.is_empty() {
println!("\n(no journaled per-agent results — the run's journal is absent or empty)");
return;
}
println!("\n## full per-agent returns ({} journaled)", agents.len());
for a in agents {
println!(
"\n### {} {}",
a.label.as_deref().unwrap_or("(agent)"),
a.agent_id.as_deref().map(short_id).unwrap_or_default(),
);
let r = a.journal_result.as_ref().unwrap();
println!("{}", serde_json::to_string_pretty(r).unwrap_or_else(|_| r.to_string()));
}
}
fn workflow_by_name_fleetwide(
name: &str,
want: Option<cv_core::ir::Harness>,
render: Render,
run_flags: &[&str],
) -> Result<()> {
let hits = cv_core::find_workflows_by_name(name);
if hits.is_empty() {
let ghosts = cv_core::find_ghost_launches_by_name(name);
if ghosts.is_empty() {
match cv_core::find(name, want) {
Ok(Some((r, _adapter))) => {
refuse_run_flags(run_flags, "a session's run list")?;
return list_workflows(&r, render.json);
}
Ok(None) => {}
Err(e) => return crate::util::resolve_found(Err(e), name, want).map(|_| ()),
}
} else {
refuse_run_flags(run_flags, "ghost launches")?;
println!(
"no recorded run named {name:?} — but {} GHOST launch(es) match (state never persisted; crash/kill before write?):\n",
ghosts.len()
);
for (r, g) in &ghosts {
println!(
" {} — session {} ({})",
ghost_line(g),
short_id(&r.id),
r.title.as_deref().unwrap_or("untitled"),
);
}
println!("\n→ debris dirs live under the session dir's subagents/workflows/<runId>/ (`cv show <agent-id>` opens a transcript)");
return Ok(());
}
bail!("no session and no workflow name matching {name:?} (try `cv ls -q 'workflow:{name}'`)");
}
if hits.len() == 1 {
if run_flags.iter().any(|f| matches!(*f, "--follow" | "--for")) {
return crate::util::usage(format!(
"--follow/--for need the run named by session: `cv workflow {} {} --follow`",
short_id(&hits[0].0.id),
hits[0].1.run_id
));
}
let (r, wf) = &hits[0];
let mut wf = wf.clone();
wf.attach_journal(&r.path);
if !render.json {
println!(
"(in session {} — {})\n",
short_id(&r.id),
r.title.as_deref().unwrap_or("untitled")
);
}
return emit_workflow(&wf, render, Some(r.path.as_path()));
}
refuse_run_flags(run_flags, "the runs matching a name")?;
println!("# {} workflow run(s) matching {name:?}:\n", hits.len());
for (r, w) in &hits {
println!(
"cv workflow {} {} # {} · {} · {} agent(s)",
short_id(&r.id),
w.run_id,
w.name.as_deref().unwrap_or("(unnamed)"),
w.status.as_deref().unwrap_or("?"),
w.agent_count,
);
}
Ok(())
}
fn list_workflows(r: &cv_core::SessionRef, json: bool) -> Result<()> {
let runs = cv_core::workflows_of(r);
let ghosts = cv_core::workflow_ghosts_of(r);
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({"runs": runs, "ghost_launches": ghosts}))?
);
return Ok(());
}
if runs.is_empty() && ghosts.is_empty() {
println!("no workflows launched by {}", short_id(&r.id));
return Ok(());
}
if runs.is_empty() {
println!("# 0 recorded workflow(s) in {}", short_id(&r.id));
} else {
println!("# {} workflow(s) in {}\n", runs.len(), short_id(&r.id));
}
for w in &runs {
let status = w.status.as_deref().unwrap_or("?");
let states = w.state_counts();
let state_str = if states.is_empty() {
String::new()
} else {
let parts: Vec<String> = states.iter().map(|(k, n)| format!("{n} {k}")).collect();
format!(" [{}]", parts.join(", "))
};
println!(
"{} {} {} · {} phase(s) · {} agent(s){}",
w.run_id,
w.name.as_deref().unwrap_or("(unnamed)"),
status,
w.phases.len(),
w.agent_count,
state_str,
);
if let Some(s) = &w.summary {
println!(" {}", truncate(s, 120));
}
}
if !ghosts.is_empty() {
println!(
"\n⚠ {} launch(es) with NO recorded run — state never persisted (crash/kill before write?):",
ghosts.len()
);
for g in &ghosts {
println!(" {}", ghost_line(g));
}
println!(" → debris dirs live under the session dir's subagents/workflows/<runId>/ (`cv show <agent-id>` opens a transcript)");
}
println!("\n→ `cv workflow {} <runId>` for one run's phase tree", short_id(&r.id));
Ok(())
}
fn ghost_line(g: &cv_core::WorkflowLaunch) -> String {
let when =
g.ts.map(|t| crate::util::fmt_local(t, "%Y-%m-%d %H:%M"))
.unwrap_or_else(|| "(no timestamp)".into());
let mut line = format!("{} launched {when}", g.name.as_deref().unwrap_or("(unnamed)"));
if let Some(rid) = &g.run_id {
line.push_str(&format!(" · run {rid}"));
}
match (g.debris_agents, g.journal_result_count) {
(Some(0), Some(0)) => line.push_str(" · no debris"),
(Some(a), Some(j)) => line.push_str(&format!(" · DEBRIS: {a} agent transcript(s), {j} journaled result(s)")),
_ => {}
}
line
}
fn render_workflow(w: &cv_core::Workflow, show_script: bool) -> Result<()> {
println!(
"# workflow {} ({})",
w.name.as_deref().unwrap_or("(unnamed)"),
w.run_id
);
let dur = w
.duration_ms
.map(|ms| format!(" · {}", fmt_duration(ms)))
.unwrap_or_default();
let started = w
.started_at
.map(|t| format!(" · started {}", crate::util::fmt_local(t, "%Y-%m-%d %H:%M")))
.unwrap_or_default();
println!(
"{} · {} agent(s) · {} phase(s) · {} tokens · {} tool calls{}{}",
w.status.as_deref().unwrap_or("?"),
w.agent_count,
w.phases.len(),
fmt_int(w.total_tokens),
fmt_int(w.total_tool_calls),
dur,
started,
);
if let Some(m) = &w.default_model {
println!("model: {m}");
}
if let Some(t) = &w.task_id {
println!("task: {t}");
}
if let Some(a) = &w.args {
println!(
"args: {}",
cv_core::projection::Head::of_json(cv_core::JsonView::from(a), 200)?
);
}
if let Some(rf) = &w.resume_from {
println!("resumed from: {rf}");
}
if let Some(e) = &w.error {
println!("⚠ error: {}", truncate(e, 200));
}
if let Some(s) = &w.summary {
println!("\n{}", truncate(s, 600));
}
for p in &w.phases {
let title = p.title.as_deref().unwrap_or("(phase)");
println!(
"\n## phase {} · {}{}",
p.index,
title,
p.detail
.as_deref()
.map(|d| format!(" — {}", truncate(d, 80)))
.unwrap_or_default()
);
if p.agents.is_empty() {
println!(" (no agents)");
}
for a in &p.agents {
render_workflow_agent(a);
}
}
if !w.orphan_agents.is_empty() {
println!("\n## (agents with no matching phase)");
for a in &w.orphan_agents {
render_workflow_agent(a);
}
}
if !w.logs.is_empty() {
const TAIL: usize = 8;
println!("\n## log ({} line(s))", w.logs.len());
if w.logs.len() > TAIL {
println!(" … {} earlier (--json for all)", w.logs.len() - TAIL);
}
for l in w.logs.iter().rev().take(TAIL).rev() {
println!(" {}", truncate(l, 160));
}
}
match &w.result {
Some(r) => {
let (preview, truncated) = workflow_result_preview(r)?;
println!("\n## result");
if truncated {
println!("{}\n(truncated — `--json` for the full result)", preview);
} else {
println!("{preview}");
}
}
None => println!("\n(no result recorded — the run returned nothing or died before finishing)"),
}
if show_script {
match &w.script {
Some(src) => {
println!(
"\n## script{}",
w.script_path
.as_ref()
.map(|p| format!(" ({})", p.display()))
.unwrap_or_default()
);
println!("```js\n{src}\n```");
}
None => println!("\n(no script recorded)"),
}
} else if w.script.is_some() {
println!("\n→ `--script` to print the driving workflow script");
}
Ok(())
}
fn workflow_result_preview(value: &serde_json::Value) -> Result<(String, bool)> {
let (head, stopped) = cv_core::JsonView::from(value).pretty_prefix(4 * 2001)?;
let truncated = stopped || head.len() > 2000;
Ok((if truncated { truncate(&head, 2000) } else { head }, truncated))
}
fn render_workflow_agent(a: &cv_core::WorkflowAgent) {
let glyph = state_glyph(a.state.as_deref(), "•");
let id = a.agent_id.as_deref().map(short_id).unwrap_or_else(|| "—".into());
let label = a.label.as_deref().unwrap_or("(agent)");
let mut tele = Vec::new();
if let Some(t) = a.tokens {
tele.push(format!("{} tok", fmt_int(t)));
}
if let Some(tc) = a.tool_calls {
tele.push(format!("{tc} calls"));
}
if let Some(ms) = a.duration_ms {
tele.push(fmt_duration(ms));
}
if a.cached {
tele.push("cached".into());
}
if let Some(n) = a.attempt.filter(|n| *n > 1) {
tele.push(format!("attempt {n}"));
}
let tele = if tele.is_empty() {
String::new()
} else {
format!(" ({})", tele.join(", "))
};
println!(" {glyph} {label} {id}{tele}");
if let Some(e) = &a.error {
println!(" ✗ {}", truncate(e, 120));
}
if !matches!(a.state.as_deref(), Some("done")) {
if let Some(tool) = &a.last_tool_name {
let when = a
.last_progress_at
.map(|t| format!(" @ {}", crate::util::fmt_local(t, "%m-%d %H:%M")))
.unwrap_or_default();
println!(
" ↪ last: {tool} · {}{when}",
a.last_tool_summary
.as_deref()
.map(|s| truncate(s, 100))
.unwrap_or_default(),
);
}
}
if let Some(rp) = &a.result_preview {
println!(" ↩ {}", truncate(rp, 200));
}
}
struct Revivable {
label: String,
agent_id: Option<String>,
phase: u64,
state: String,
error: Option<String>,
tokens: u64,
tool_calls: u64,
prompt: Option<String>,
files: Vec<String>,
commands: Vec<String>,
last_note: Option<String>,
unread: Option<String>,
}
fn render_revive(w: &cv_core::Workflow, all: bool, session_path: Option<&std::path::Path>) -> Result<()> {
let agents: Vec<&cv_core::WorkflowAgent> = w
.phases
.iter()
.flat_map(|p| &p.agents)
.chain(&w.orphan_agents)
.filter(|a| all || a.state.as_deref() != Some("done"))
.collect();
if agents.is_empty() {
println!(
"no revivable lanes in {} (every agent completed; `--revive --all` to include them)",
w.run_id
);
return Ok(());
}
println!("# revive {} — {} lane(s)", w.run_id, agents.len());
if let Some(e) = &w.error {
println!("run died: {}", truncate(e, 160));
}
println!(
"\nEach block below is a standalone Agent prompt. The lane's own transcript supplied the\n\
full task; the work log is mined from its tool calls so the revived lane does not redo it.\n"
);
for a in agents {
let rev = mine_lane(a, &w.run_id, session_path);
print_revive_block(&rev);
}
Ok(())
}
fn mine_lane(a: &cv_core::WorkflowAgent, run_id: &str, session_path: Option<&std::path::Path>) -> Revivable {
let mut rev = Revivable {
label: a.label.clone().unwrap_or_else(|| format!("agent#{}", a.index)),
agent_id: a.agent_id.clone(),
phase: a.phase_index,
state: a.state.clone().unwrap_or_else(|| "unknown".into()),
error: a.error.clone(),
tokens: a.tokens.unwrap_or(0),
tool_calls: a.tool_calls.unwrap_or(0),
prompt: a.prompt_preview.clone(),
files: Vec::new(),
commands: Vec::new(),
last_note: None,
unread: None,
};
let (Some(id), Some(sp)) = (a.agent_id.as_deref(), session_path) else {
return rev;
};
let Some(dir) = cv_core::harness::claude::sidecar::workflow_run_dir(sp, run_id) else {
return rev;
};
let bare = id.strip_prefix("agent-").unwrap_or(id);
let path = dir.join(format!("agent-{bare}.jsonl"));
apply(cv_core::LaneWork::mine(&path), &mut rev);
rev
}
fn apply(work: cv_core::LaneWork, rev: &mut Revivable) {
if work.prompt.is_some() {
rev.prompt = work.prompt;
}
rev.files = work.files;
rev.commands = work.commands;
rev.last_note = work.last_note;
rev.unread = work.unread;
}
#[cfg(test)]
fn mine_transcript(text: &str, rev: &mut Revivable) {
apply(cv_core::LaneWork::mine_bytes(text.as_bytes()), rev);
}
fn print_revive_block(rev: &Revivable) {
println!("\n{}", "=".repeat(78));
println!("## lane: {}", rev.label);
print!(
"state={} phase={} tokens={} tools={}",
rev.state,
rev.phase,
fmt_int(rev.tokens),
fmt_int(rev.tool_calls)
);
if let Some(id) = &rev.agent_id {
print!(" agent={}", short_id(id));
}
println!();
if let Some(e) = &rev.error {
println!("died: {}", truncate(e, 200));
}
if let Some(why) = &rev.unread {
println!(
"transcript read stopped early (the work log may be partial): {}",
truncate(why, 200)
);
}
if rev.prompt.is_none() {
println!("\n(no prompt recoverable — neither transcript nor preview; re-issue by hand)");
return;
}
let did_work = !rev.files.is_empty() || !rev.commands.is_empty();
println!("\n--- 8< --- paste the block below as an Agent prompt --- 8< ---\n");
println!("{}", rev.prompt.as_deref().unwrap_or_default());
if did_work {
println!(
"\n\n--- RESUMING AN INTERRUPTED RUN ---\n\
A previous run of this exact lane was cut short ({}) after {} tool calls. Its work is\n\
still on disk. Do NOT start over — verify what is listed below, then continue from there.",
rev.error
.as_deref()
.map(|e| truncate(e, 90))
.unwrap_or_else(|| rev.state.clone()),
fmt_int(rev.tool_calls),
);
if !rev.files.is_empty() {
println!("\nFiles it already wrote or edited ({}):", rev.files.len());
for f in rev.files.iter().take(40) {
println!(" {}", f);
}
if rev.files.len() > 40 {
println!(" … and {} more", rev.files.len() - 40);
}
}
if !rev.commands.is_empty() {
println!("\nCommands it ran ({} distinct, first 15):", rev.commands.len());
for c in rev.commands.iter().take(15) {
println!(" $ {}", c);
}
}
if let Some(note) = &rev.last_note {
println!(
"\nIts own last note, which usually says where it had got to:\n{}",
truncate(note, 900)
);
}
println!("\n--- END RESUME CONTEXT ---");
}
println!("\n--- >8 --- end prompt --- >8 ---");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bounded_workflow_result_preview_preserves_old_byte_threshold_and_character_cut() {
for size in [0, 499, 500, 501, 1998, 1999, 2000, 2001, 100000] {
for text in ["x", "🌙", "\"\\\n"] {
let value = serde_json::json!({"nested":[{},[],text.repeat(size)], "after":true});
let old = serde_json::to_string_pretty(&value).unwrap();
let truncated = old.len() > 2000;
let expected = if truncated { truncate(&old, 2000) } else { old };
assert_eq!(workflow_result_preview(&value).unwrap(), (expected, truncated));
let args = cv_core::projection::Head::of_json(cv_core::JsonView::from(&value), 200).unwrap();
assert_eq!(args, truncate(&value.to_string(), 200));
}
}
}
#[test]
fn revive_reader_recovers_valid_records_after_corrupt_utf8_and_large_ignored_results() {
let mut input = Vec::new();
input.extend_from_slice(b"invalid-json\n\xff\n");
let prompt = serde_json::json!({"message":{"role":"user","content":"full task"}});
let ignored = serde_json::json!({"message":{"role":"user","content":[{"type":"tool_result","content":"x".repeat(1100000)}]}});
let write = serde_json::json!({"message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{"file_path":"recovered.rs"}}]}});
for value in [prompt, ignored, write] {
input.extend_from_slice(format!("{value}\n").as_bytes());
}
let mut rev = Revivable {
label: "reader".into(),
agent_id: None,
phase: 0,
state: "error".into(),
error: None,
tokens: 0,
tool_calls: 0,
prompt: Some("preview".into()),
files: vec![],
commands: vec![],
last_note: None,
unread: None,
};
apply(cv_core::LaneWork::mine_bytes(&input), &mut rev);
assert_eq!(rev.prompt.as_deref(), Some("full task"));
assert_eq!(rev.files, vec!["recovered.rs"]);
}
#[test]
fn revive_mines_prompt_files_commands_and_last_note() {
let lines = [
r#"{"type":"user","message":{"role":"user","content":"Full lane task: implement the widget end to end."}}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Starting; I will scaffold first and then wire the tests, keeping the public surface unchanged for now."},{"type":"tool_use","name":"Write","input":{"file_path":"/repo/src/widget.rs","content":"..."}}]}}"#,
r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"cargo test -p widget\necho done"}},{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/src/widget.rs"}},{"type":"tool_use","name":"Bash","input":{"command":"cargo test -p widget"}}]}}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Scaffold and tests landed in widget.rs; the remaining piece is the CLI flag, which I had not started when interrupted."}]}}"#,
]
.join("\n");
let mut rev = Revivable {
label: "widget".into(),
agent_id: Some("agent-1".into()),
phase: 0,
state: "error".into(),
error: Some("session limit".into()),
tokens: 0,
tool_calls: 4,
prompt: Some("Full lane task: impl…".into()), files: vec![],
commands: vec![],
last_note: None,
unread: None,
};
mine_transcript(&lines, &mut rev);
assert_eq!(
rev.prompt.as_deref(),
Some("Full lane task: implement the widget end to end."),
"the FULL first user prompt replaces the preview"
);
assert_eq!(
rev.files,
vec!["/repo/src/widget.rs"],
"Write + Edit of one file dedupe"
);
assert_eq!(rev.commands, vec!["cargo test -p widget"], "first line only, deduped");
assert!(rev
.last_note
.as_deref()
.is_some_and(|n| n.starts_with("Scaffold and tests landed")));
}
}