///|
/// 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}
$|
),
)
}
let graph = StringBuilder()
let row_h = 40
let height = entries.length() * row_h + 16
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
$|# agent phase outcome
$|steps ms tokens
),
)
page.write_string(rows.to_string())
page.write_string(
(
$|
$|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)),
)
}