///|
/// Journal → HTML: render any workflow journal as a self-contained
/// report — the attempt ledger (status, steps, token costs) plus a
/// DERIVED dataflow graph: an edge exists where the JSON-escaped first
/// 50 characters of an answer longer than 60 appear inside a later
/// agent's input (a prefix probe, not a whole-output match). Dynamic
/// workflows declare no graph, so none is stored; content recovers it.
///
///   moon run viz --  [out.html] [--watch]
///   moonx moonbitlang/workflow/viz  [out.html] [--watch]
///
/// `--watch` re-renders every 2 seconds and the page auto-refreshes —
/// a live view of a RUNNING workflow's completions.
async fn main {
  run() catch {
    error => println("viz failed: \{error}")
  }
}

///|
async fn run() -> Unit {
  let spec = @argparse.Command(
    "viz",
    about="Render a workflow journal as a self-contained HTML report.",
    arg_required_else_help=true,
    flags=[
      @argparse.FlagArg(
        "watch",
        about="re-render every 2 seconds; the page auto-refreshes",
      ),
    ],
    positionals=[
      @argparse.PositionArg(
        "journal",
        about="the journal to read (JSONL)",
        num_args=@argparse.ValueRange(lower=1, upper=1),
      ),
      @argparse.PositionArg(
        "out",
        about="page to write (default: .html)",
      ),
    ],
  )
  // Parse failures and `--help` carry their own contextual help text.
  let args = spec.parse() catch {
    error => {
      println("\{error}")
      return
    }
  }
  guard first(args, "journal") is Some(path) else { return }
  let out = first(args, "out").unwrap_or("\{path}.html")
  let watch = args.flags.get("watch").unwrap_or(false)
  for ;; {
    render(path, out, watch) catch {
      error =>
        if watch {
          println("(\{error} — retrying)")
        } else {
          raise error
        }
    }
    guard watch else { break }
    @async.sleep(2_000)
  }
}

///|
/// The first value parsed for an argument, if it was given at all.
fn first(args : @argparse.Matches, name : String) -> String? {
  match args.values.get(name) {
    Some([value, ..]) => Some(value)
    _ => None
  }
}

///|
/// READ-ONLY journal parse. Deliberately not `Journal::load`: load
/// REPAIRS torn tails on disk, and a watcher must never rewrite a file
/// a running workflow is appending to. Unparsable lines (the live
/// writer's torn tail) are simply skipped for this tick.
async fn read_entries(path : String) -> Array[@workflow.JournalEntry] {
  let entries : Array[@workflow.JournalEntry] = []
  guard @fs.exists(path) else { return entries }
  let text = @fs.read_file(path).text() catch { _ => return entries }
  for line in text.split("\n") {
    let line = line.trim()
    if line.is_empty() {
      continue
    }
    let parsed : @workflow.JournalEntry? = try {
      match @json.parse(line.to_owned()) {
        { "v": Number(1, ..), "e": entry, .. } => Some(@json.from_json(entry))
        _ => None
      }
    } catch {
      _ => None
    }
    if parsed is Some(entry) {
      entries.push(entry)
    }
  }
  entries
}

///|
async fn render(path : String, out : String, watch : Bool) -> Unit {
  let entries = read_entries(path)
  guard entries.length() > 0 else {
    println("journal \{path} holds no entries yet")
    return
  }

  // Derive dataflow: output-of(i) embedded in input-of(j), i < j. The
  // probe is JSON-escaped through the encoder itself, so it matches how
  // the text actually appears inside a stringified input.
  let probes = entries.map(entry => probe_of(entry.outcome))
  let inputs : Array[String] = entries.map(entry => entry.call.input.stringify())
  let edges : Array[(Int, Int)] = []
  for j, input in inputs {
    for i in 0..Finished
      )
    } else {
      (
        #|DidNotFinish
      )
    }
    let tokens = tokens_of(entry.outcome)
    let width = 100 * tokens / max_tokens
    let detail = escape(
      match entry.outcome {
        Finished(value~, ..) => clip(answer_of_value(value), 180)
        DidNotFinish(failure~, ..) => "\{failure}"
      },
    )
    let phase = escape(entry.phase.unwrap_or(""))
    let elapsed = match (entry.started_at, entry.finished_at) {
      (Some(from), Some(to)) => "\{to - from}"
      _ => ""
    }
    let fill = width.max(if tokens > 0 { 2 } else { 0 })
    rows.write_string(
      (
        $|
        $|\{index + 1}
        $|\{escape(entry.call.label)}
        $|\{phase}
        $|\{pill}
        $|\{steps_of(entry.outcome)}
        $|\{elapsed}
        $|
$|
$|\{tokens} $|
), ) } let graph = StringBuilder() let row_h = 40 let height = entries.length() * row_h + 16 graph.write_string( ( $| $| $| $| ), ) for edge in edges { let (a, b) = edge let y1 = 22 + a * row_h let y2 = 22 + b * row_h let spread = 40 + (b - a) * 26 graph.write_string( ( $| ), ) } for index, entry in entries { let y = 22 + index * row_h let color = if entry.outcome is Finished(..) { "var(--replay)" } else { "var(--fail)" } graph.write_string( ( $| $|\{escape(entry.call.label)} ), ) } graph.write_string("") let refresh = if watch { ( #| ) } else { "" } let outputs = StringBuilder() for index, entry in entries { let finished = entry.outcome is Finished(..) let body = escape( match entry.outcome { Finished(value~, ..) => answer_of_value(value) DidNotFinish(failure~, ..) => "\{failure}" }, ) let mark = if finished { "ok" } else { "bad" } let status = if finished { "Finished" } else { "DidNotFinish" } // The last entry is the one a watcher is waiting on: open it. let open = if index == entries.length() - 1 { " open" } else { "" } outputs.write_string( // `.out` is pre-wrap: its content must not gain stray newlines. ( $| $|\{index + 1} $|\{escape(entry.call.label)} $|\{status} $|
\{body}
), ) } let page = StringBuilder() page.write_string( ( $|\{refresh} $|journal: \{escape(path)} $|\{style} $|
$|

\{escape(path)}

$|

\{entries.length()} recorded attempt(s) · \{edges.length()} dataflow edge(s) $|derived from content. A journal records LIVE outcomes only — repeated labels are $|re-attempts or cascade generations; replays are served, never re-appended.

$|
$|

Ledger

$| $| ), ) page.write_string(rows.to_string()) page.write_string( ( $|
#agentphaseoutcomestepsmstokens
$|

Derived dataflow

), ) page.write_string(graph.to_string()) page.write_string( ( $|
$|

Outputs

), ) page.write_string(outputs.to_string()) page.write_string( ( #|
), ) @fs.write_file(out, page.to_string()) println( "wrote \{out}: \{entries.length()} entries, \{edges.length()} derived edge(s)", ) } ///| fn tokens_of(outcome : @workflow.AgentOutcome) -> Int { match outcome.attempt() { Some(attempt) => attempt.prompt_tokens + attempt.completion_tokens None => 0 } } ///| fn steps_of(outcome : @workflow.AgentOutcome) -> Int { match outcome.attempt() { Some(attempt) => attempt.steps_used None => 0 } } ///| fn probe_of(outcome : @workflow.AgentOutcome) -> String? { guard outcome is Finished(value~, ..) else { return None } let answer = answer_of_value(value) guard answer.length() > 60 else { return None } // Strip only the encoder's delimiters, preserving escapes in the probe. let quoted = Json::string(clip(answer, 50)).stringify() guard quoted.strip_prefix("\"") is Some(open) && open.strip_suffix("\"") is Some(probe) else { return None } Some(probe.to_owned()) } ///| fn answer_of_value(value : Json) -> String { match value { { "answer": String(text), .. } => text other => other.stringify() } } ///| /// The head of a report, for a display cell or a dataflow probe. /// `clamped_view` never cuts a surrogate pair in half, so the result is /// always well-formed text — half an emoji would be neither renderable /// HTML nor a probe that could match anything. fn clip(text : String, limit : Int) -> String { text.clamped_view(end=limit).to_owned() } ///| fn escape(text : String) -> String { let out = StringBuilder() for ch in text { match ch { '&' => out.write_string("&") '<' => out.write_string("<") '>' => out.write_string(">") '"' => out.write_string(""") _ => out.write_char(ch) } } out.to_string() } ///| let style : String = #| ///| test "dataflow probes preserve threshold, escaping and Unicode boundaries" { let attempt = @workflow.AgentAttempt( attempt_id="probe", steps_used=0, prompt_tokens=0, completion_tokens=0, ) fn finished(text : String) -> @workflow.AgentOutcome { @workflow.Finished(value={ "answer": text }, attempt~) } assert_eq( probe_of(@workflow.DidNotFinish(failure=@workflow.NoReport, attempt=None)), None, ) let sixty = "a".repeat(60) assert_eq(probe_of(finished(sixty)), None) assert_eq(probe_of(finished(sixty + "a")), Some("a".repeat(50))) assert_eq( probe_of(finished("a".repeat(49) + "🌙" + "b".repeat(20))), Some("a".repeat(49)), ) assert_eq( probe_of(finished("\"\\\n" + "x".repeat(60))), Some("\\\"\\\\\\n" + "x".repeat(47)), ) let object : Json = { "payload": sixty } assert_eq( probe_of(@workflow.Finished(value=object, attempt~)), Some("{\\\"payload\\\":\\\"" + "a".repeat(38)), ) }