workgraph 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
workgraph/show.py ADDED
@@ -0,0 +1,676 @@
1
+ """Read the run record: show-node and show-journal, with or without --follow."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from collections.abc import Iterator, Sequence
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from rich.text import Text
12
+
13
+ from workgraph.harness import Harness, find_harness, split_lines
14
+ from workgraph.run import (
15
+ GREY,
16
+ JOURNAL_FILE,
17
+ build_output_path,
18
+ format_duration,
19
+ format_running_line,
20
+ format_stop_line,
21
+ is_in_progress,
22
+ parse_node_name,
23
+ read_state,
24
+ )
25
+ from workgraph.workflow import load_workflow, resolve_agent_settings
26
+
27
+ # No node run name ends with '#'.
28
+ WORKGRAPH_ORIGIN = "workgraph#"
29
+ DECISION_STYLE = {"accept": "green", "reject": "red"}
30
+ # Seconds between two polls of the run record under follow.
31
+ POLL_INTERVAL = 0.5
32
+
33
+ Event = dict[str, Any]
34
+ # A str prints verbatim; a Text renders through rich.
35
+ Line = Text | str
36
+
37
+
38
+ class StderrLine(str):
39
+ """A line that prints verbatim on stderr: the followed node run's stderr."""
40
+
41
+
42
+ class RecordError(Exception):
43
+ """The run record does not hold what the command asks for."""
44
+
45
+
46
+ class _LineReader:
47
+ """Read the complete lines a file gains between calls; hold a trailing partial line.
48
+
49
+ The file stays open: a new `run` unlinks it, and os.fstat reports that.
50
+ """
51
+
52
+ def __init__(self, path: Path) -> None:
53
+ self.path = path
54
+ self.file = path.open("rb")
55
+ self.partial_line = b""
56
+
57
+ def check_replaced(self) -> None:
58
+ """Raise when the file was unlinked or shrank: it belongs to a replaced run."""
59
+ file_stat = os.fstat(self.file.fileno())
60
+ if file_stat.st_nlink == 0 or file_stat.st_size < self.file.tell():
61
+ raise RecordError("the run was replaced")
62
+
63
+ def read_lines(self, include_partial: bool = False) -> list[str]:
64
+ """Return the lines completed since the last call. Bytes that are not UTF-8 read as `�`.
65
+
66
+ With include_partial, a trailing partial line returns as a line: the writer has exited.
67
+ """
68
+ self.check_replaced()
69
+ *lines, self.partial_line = (self.partial_line + self.file.read()).split(b"\n")
70
+ if include_partial and self.partial_line:
71
+ lines.append(self.partial_line)
72
+ self.partial_line = b""
73
+ return [line.decode(errors="replace") for line in lines]
74
+
75
+
76
+ class _RunRecord:
77
+ """The workflow nodes and start node, and the journal events read so far, indexed by node run.
78
+
79
+ read_events() appends the events the run wrote since; a follow calls it on every poll.
80
+ """
81
+
82
+ def __init__(self, directory: Path) -> None:
83
+ self.directory = directory
84
+ no_run_error = RecordError(f"no run in {directory}")
85
+ try:
86
+ self.journal_reader = _LineReader(directory / JOURNAL_FILE)
87
+ except FileNotFoundError:
88
+ raise no_run_error from None
89
+ self.events: list[Event] = []
90
+ self.start_events: dict[str, Event] = {}
91
+ self.end_events: dict[str, Event] = {}
92
+ self.fallback_events: dict[str, Event] = {}
93
+ self.read_events()
94
+ if not self.events:
95
+ raise no_run_error
96
+ workflow = load_workflow(self.events[0]["workflow"])
97
+ self.nodes: dict[str, dict[str, Any]] = workflow["nodes"]
98
+ self.start_node: str = workflow["start"]
99
+ self.defaults: dict[str, Any] = workflow.get("defaults", {})
100
+
101
+ def read_events(self) -> None:
102
+ """Append the events written since the last read; sample the lock first.
103
+
104
+ The run writes its stop before it releases the lock, and a resume takes the lock
105
+ before it writes its event. A read that brings events after an absent lock samples again.
106
+ """
107
+ while True:
108
+ self.in_progress = is_in_progress(self.directory)
109
+ new_events = [json.loads(line) for line in self.journal_reader.read_lines()]
110
+ for event in new_events:
111
+ if event["event"] == "start":
112
+ self.start_events[event["node"]] = event
113
+ elif event["event"] == "end":
114
+ self.end_events[event["node"]] = event
115
+ elif event["event"] == "fallback":
116
+ self.fallback_events[event["node"]] = event
117
+ self.events += new_events
118
+ if self.in_progress or not new_events:
119
+ return
120
+
121
+ def poll(self) -> None:
122
+ """Sleep one poll interval, then read. An interrupted run ends the follow."""
123
+ self.check_interrupted()
124
+ time.sleep(POLL_INTERVAL)
125
+ self.read_events()
126
+
127
+ def check_interrupted(self) -> None:
128
+ """Raise when the run exited without writing its stop event."""
129
+ if not self.in_progress and not self.stop_event:
130
+ raise RecordError("the run stopped without a stop event")
131
+
132
+ def find_predecessor_name(self, node_run_name: str) -> str | None:
133
+ """Return the node run whose end carried the agent session this node run resumed.
134
+
135
+ Return the session identifier when no earlier end carries it, and None when the
136
+ node run resumed no session.
137
+ """
138
+ start_event = self.start_events[node_run_name]
139
+ session = start_event.get("session")
140
+ if session is None:
141
+ return None
142
+ predecessor_names = [
143
+ event["node"]
144
+ for event in self.events[: self.events.index(start_event)]
145
+ if event["event"] == "end" and event.get("session") == session
146
+ ]
147
+ return predecessor_names[-1] if predecessor_names else str(session)
148
+
149
+ def find_node_definition(self, node_run_name: str) -> dict[str, Any]:
150
+ return self.nodes[parse_node_name(node_run_name)]
151
+
152
+ def find_transcript_harness(self, node_run_name: str, raw: bool) -> Harness | None:
153
+ """Return the harness that renders a node run's stdout; None when it renders unchanged."""
154
+ node_definition = self.find_node_definition(node_run_name)
155
+ if raw or "agent" not in node_definition:
156
+ return None
157
+ return find_harness(resolve_agent_settings(node_definition, self.defaults)["harness"])
158
+
159
+ @property
160
+ def stop_event(self) -> Event | None:
161
+ """Return the stop event when the run has stopped."""
162
+ last_event = self.events[-1]
163
+ return last_event if last_event["event"] == "stop" else None
164
+
165
+ @property
166
+ def now(self) -> datetime | None:
167
+ """Return the time a running duration ends at; None unless the run is in progress."""
168
+ return datetime.now(UTC) if self.in_progress and not self.stop_event else None
169
+
170
+
171
+ def show_node(directory: Path, node_run_identifier: str, raw: bool) -> list[Line]:
172
+ """Render one node run: a header, then the input, stdout, stderr, outcome, and handoff."""
173
+ record = _RunRecord(directory)
174
+ return _render_node_run(
175
+ record, _resolve_node_run(node_run_identifier, record.start_events), raw
176
+ )
177
+
178
+
179
+ def follow_node(directory: Path, node_run_identifier: str, raw: bool) -> Iterator[Line]:
180
+ """Yield the name, start, and input, the output as it arrives, then the end and the outcome.
181
+
182
+ The node run's stdout renders as show_node renders it; its stderr lines yield as StderrLine.
183
+ For an ended node run, yield show_node's lines.
184
+ """
185
+ record = _RunRecord(directory)
186
+ node_run_name = _resolve_node_run(node_run_identifier, record.start_events)
187
+ if node_run_name in record.end_events or record.stop_event is not None:
188
+ yield from _render_node_run(record, node_run_name, raw)
189
+ return
190
+ start_event = record.start_events[node_run_name]
191
+ yield from _render_header(record, node_run_name)
192
+ yield Text()
193
+ yield from _render_section(
194
+ "input", _render_input(record.events[0]["input"], start_event["handoff"])
195
+ )
196
+ yield _render_heading("stdout")
197
+ output_readers = (
198
+ None
199
+ if "map" in record.find_node_definition(node_run_name)
200
+ else _open_outputs(
201
+ record, node_run_name, resumed_spawn=node_run_name in record.fallback_events
202
+ )
203
+ )
204
+ if output_readers is None:
205
+ yield Text("(none: map node)", GREY)
206
+ transcript_harness = record.find_transcript_harness(node_run_name, raw)
207
+ markers_rendered = False
208
+ while True:
209
+ fallback_event = record.fallback_events.get(node_run_name)
210
+ if output_readers is not None and fallback_event is not None and not markers_rendered:
211
+ yield from _render_new_output(output_readers, transcript_harness, True)
212
+ yield _render_fallback_text(fallback_event, with_error=True)
213
+ yield StderrLine(_render_fallback_text(fallback_event, with_error=False).plain + "\n")
214
+ output_readers = _open_outputs(record, node_run_name)
215
+ markers_rendered = True
216
+ output_complete = node_run_name in record.end_events or record.stop_event is not None
217
+ if output_readers is not None:
218
+ yield from _render_new_output(output_readers, transcript_harness, output_complete)
219
+ if output_complete:
220
+ break
221
+ record.poll()
222
+ now = record.now
223
+ yield Text()
224
+ yield from _render_status(record, node_run_name, now)
225
+ yield Text()
226
+ yield from _render_footer(record, node_run_name, now)
227
+
228
+
229
+ def _render_new_output(
230
+ output_readers: tuple[_LineReader, _LineReader],
231
+ transcript_harness: Harness | None,
232
+ include_partial: bool,
233
+ ) -> Iterator[Line]:
234
+ """Yield the new stdout lines rendered, then the new stderr lines as one StderrLine."""
235
+ stdout_reader, stderr_reader = output_readers
236
+ yield from _render_output_lines(stdout_reader.read_lines(include_partial), transcript_harness)
237
+ stderr_lines = stderr_reader.read_lines(include_partial)
238
+ if stderr_lines:
239
+ yield StderrLine("\n".join(stderr_lines) + "\n")
240
+
241
+
242
+ def _render_node_run(record: _RunRecord, node_run_name: str, raw: bool) -> list[Line]:
243
+ start_event, now = record.start_events[node_run_name], record.now
244
+ stdout_body: Sequence[Line]
245
+ stderr_body: Sequence[Line]
246
+ if "map" in record.find_node_definition(node_run_name):
247
+ stdout_body = stderr_body = [Text("(none: map node)", GREY)]
248
+ else:
249
+ stdout_body = _render_stream_body(
250
+ record, node_run_name, "stdout", record.find_transcript_harness(node_run_name, raw)
251
+ )
252
+ stderr_body = _render_stream_body(record, node_run_name, "stderr", None)
253
+ return [
254
+ *_render_header(record, node_run_name),
255
+ *_render_status(record, node_run_name, now),
256
+ Text(),
257
+ *_render_section("input", _render_input(record.events[0]["input"], start_event["handoff"])),
258
+ *_render_section("stdout", stdout_body),
259
+ *_render_section("stderr", stderr_body),
260
+ *_render_footer(record, node_run_name, now),
261
+ ]
262
+
263
+
264
+ def _render_header(record: _RunRecord, node_run_name: str) -> list[Text]:
265
+ """Render the node run name, its start time, and the node run it resumed the session of."""
266
+ start_event = record.start_events[node_run_name]
267
+ resumed_suffix = format_resumed_suffix(record, node_run_name)
268
+ return [
269
+ Text(_format_display_name(start_event), "bold"),
270
+ Text(f"started {_format_local_time(start_event['time'])}{resumed_suffix}", GREY),
271
+ ]
272
+
273
+
274
+ def format_resumed_suffix(record: _RunRecord, node_run_name: str) -> str:
275
+ """Return ` resumed <predecessor>`; empty when the node run resumed no session."""
276
+ predecessor_name = record.find_predecessor_name(node_run_name)
277
+ return f" resumed {predecessor_name}" if predecessor_name else ""
278
+
279
+
280
+ def _render_status(record: _RunRecord, node_run_name: str, now: datetime | None) -> list[Text]:
281
+ """Render the fallback, the end time, duration, cost, and session.
282
+
283
+ Without an end: `running…`, or `interrupted` in a run that holds no lock.
284
+ """
285
+ start_event, end_event = (
286
+ record.start_events[node_run_name],
287
+ record.end_events.get(node_run_name),
288
+ )
289
+ status_lines = []
290
+ fallback_event = record.fallback_events.get(node_run_name)
291
+ if fallback_event is not None:
292
+ fallback_time = _format_local_time(fallback_event["time"])
293
+ status_lines.append(Text(f"fallback {fallback_time} {fallback_event['error']}", GREY))
294
+ if end_event is None:
295
+ status_text = (
296
+ "interrupted" if now is None else f"running {_format_elapsed(start_event, now)}…"
297
+ )
298
+ return [*status_lines, Text(status_text, GREY)]
299
+ cost_line = f"cost ${end_event['cost']:.2f}"
300
+ if "spent_cost" in end_event:
301
+ cost_line += f" spent ${end_event['spent_cost']:.2f}"
302
+ status_lines += [
303
+ Text(
304
+ f"ended {_format_local_time(end_event['time'])} {_format_event_duration(start_event, end_event)}",
305
+ GREY,
306
+ ),
307
+ Text(cost_line, GREY),
308
+ ]
309
+ if "session" in end_event:
310
+ status_lines.append(Text(f"session {end_event['session']}", GREY))
311
+ return status_lines
312
+
313
+
314
+ def _render_stream_body(
315
+ record: _RunRecord, node_run_name: str, stream: str, transcript_harness: Harness | None
316
+ ) -> Sequence[Line]:
317
+ """Return one stream of a node run whole; after a fallback, both spawns around the marker.
318
+
319
+ An empty file renders as `(empty)`; after a fallback, a spawn that wrote nothing renders
320
+ as no line at all.
321
+ """
322
+ output_lines = _open_output(record, node_run_name, stream).read_lines(include_partial=True)
323
+ fallback_event = record.fallback_events.get(node_run_name)
324
+ if fallback_event is None:
325
+ if not output_lines:
326
+ return [Text("(empty)", GREY)]
327
+ return _render_output_lines(output_lines, transcript_harness)
328
+ resumed_lines = _open_output(record, node_run_name, stream, resumed_spawn=True).read_lines(
329
+ include_partial=True
330
+ )
331
+ return [
332
+ *_render_output_lines(resumed_lines, transcript_harness),
333
+ _render_fallback_text(fallback_event, with_error=stream == "stdout"),
334
+ *_render_output_lines(output_lines, transcript_harness),
335
+ ]
336
+
337
+
338
+ def _render_footer(record: _RunRecord, node_run_name: str, now: datetime | None) -> list[Line]:
339
+ """Render the outcome and handoff sections."""
340
+ end_event = record.end_events.get(node_run_name)
341
+ return [
342
+ *_render_section("outcome", _render_outcome(record, node_run_name, now)),
343
+ *_render_section("handoff", split_lines(end_event["handoff"] if end_event else None)),
344
+ ]
345
+
346
+
347
+ def show_journal(directory: Path, with_nodes: bool, raw: bool) -> list[Text]:
348
+ """Render one line per journal event, then the untimestamped running or interrupted line.
349
+
350
+ With `with_nodes`, every line starts with its origin. A node run's output precedes
351
+ its end line; the output of a node run in progress precedes the last line.
352
+ """
353
+ renderer = _JournalRenderer(directory, with_nodes, raw)
354
+ lines = list(renderer.render_lines(include_partial=True))
355
+ if not renderer.record.stop_event:
356
+ last_line = _render_last_line(renderer.record)
357
+ lines.append(
358
+ _render_origin(WORKGRAPH_ORIGIN).append_text(last_line) if with_nodes else last_line
359
+ )
360
+ return lines
361
+
362
+
363
+ def follow_journal(directory: Path, with_nodes: bool, raw: bool, until_end: bool) -> Iterator[Text]:
364
+ """Yield the journal lines as the run writes them; end after the stop line.
365
+
366
+ With `until_end`, only a stop with reason end ends the follow.
367
+ """
368
+ renderer = _JournalRenderer(directory, with_nodes, raw)
369
+ while True:
370
+ yield from renderer.render_lines()
371
+ stop_event = renderer.record.stop_event
372
+ if stop_event and (stop_event["reason"] == "end" or not until_end):
373
+ return
374
+ renderer.record.poll()
375
+
376
+
377
+ class _JournalRenderer:
378
+ """Render the journal: one line per event, with the node run output under --with-nodes."""
379
+
380
+ def __init__(self, directory: Path, with_nodes: bool, raw: bool) -> None:
381
+ self.record = _RunRecord(directory)
382
+ self.with_nodes = with_nodes
383
+ self.raw = raw
384
+ self.rendered_event_count = 0
385
+ self.spent_amounts: dict[str, Any] = {}
386
+ self.stopped_at_node = ""
387
+ # The output readers of the node runs in progress, in start order.
388
+ self.output_readers: dict[str, tuple[_LineReader, _LineReader]] = {}
389
+
390
+ def render_lines(self, include_partial: bool = False) -> Iterator[Text]:
391
+ """Render the new events, then the new output of the node runs in progress.
392
+
393
+ The remaining output of a node run, a trailing partial line included, precedes its
394
+ end line, or the resume or stop line that follows its interruption. With include_partial,
395
+ a trailing partial line of a node run in progress renders as a line. The resumed
396
+ spawn's remaining output precedes the fallback marker.
397
+ """
398
+ for event in self.record.events[self.rendered_event_count :]:
399
+ if not self.with_nodes:
400
+ yield self._render_row(event)
401
+ continue
402
+ match event["event"]:
403
+ case "end":
404
+ closing_node_runs = [event["node"]]
405
+ case "resume" | "stop":
406
+ closing_node_runs = list(self.output_readers)
407
+ case "fallback":
408
+ yield from self._render_resumed_spawn_after_fallback(event["node"])
409
+ closing_node_runs = []
410
+ case _:
411
+ closing_node_runs = []
412
+ for node_run_name in closing_node_runs:
413
+ if node_run_name in self.output_readers:
414
+ yield from self._render_output(
415
+ node_run_name, self.output_readers[node_run_name], include_partial=True
416
+ )
417
+ del self.output_readers[node_run_name]
418
+ yield _render_origin(WORKGRAPH_ORIGIN).append_text(self._render_row(event))
419
+ if event["event"] == "start" and "map" not in self.record.find_node_definition(
420
+ event["node"]
421
+ ):
422
+ self.output_readers[event["node"]] = _open_outputs(
423
+ self.record,
424
+ event["node"],
425
+ resumed_spawn=event["node"] in self.record.fallback_events,
426
+ )
427
+ self.rendered_event_count = len(self.record.events)
428
+ for node_run_name, output_readers in self.output_readers.items():
429
+ yield from self._render_output(node_run_name, output_readers, include_partial)
430
+
431
+ def _render_resumed_spawn_after_fallback(self, node_run_name: str) -> Iterator[Text]:
432
+ """Render the resumed spawn's remaining output, then hold the fresh spawn's files."""
433
+ yield from self._render_output(
434
+ node_run_name, self.output_readers[node_run_name], include_partial=True
435
+ )
436
+ self.output_readers[node_run_name] = _open_outputs(self.record, node_run_name)
437
+
438
+ def _render_output(
439
+ self,
440
+ node_run_name: str,
441
+ output_readers: tuple[_LineReader, _LineReader],
442
+ include_partial: bool,
443
+ ) -> Iterator[Text]:
444
+ """Render the new lines of a node run's output: stdout, then stderr, each with its origin.
445
+
446
+ With include_partial, a trailing partial line renders as a line.
447
+ """
448
+ stdout_reader, stderr_reader = output_readers
449
+ origin = _format_display_name(self.record.start_events[node_run_name])
450
+ stdout_lines = stdout_reader.read_lines(include_partial)
451
+ transcript_harness = self.record.find_transcript_harness(node_run_name, self.raw)
452
+ if transcript_harness is not None:
453
+ for transcript_row in transcript_harness.render_transcript(stdout_lines):
454
+ yield _render_origin(origin).append_text(transcript_row)
455
+ else:
456
+ for stdout_line in stdout_lines:
457
+ yield _render_origin(origin).append(stdout_line)
458
+ for stderr_line in stderr_reader.read_lines(include_partial):
459
+ yield _render_origin(f"{origin} stderr").append(stderr_line)
460
+
461
+ def _render_row(self, event: Event) -> Text:
462
+ """Render one event as `<local time> <event text>`."""
463
+ record = self.record
464
+ match event["event"]:
465
+ case "run":
466
+ event_text = Text(f'run: {event["workflow"]} "{event["input"]}"')
467
+ case "start":
468
+ resumed_suffix = format_resumed_suffix(record, event["node"])
469
+ event_text = Text(f"{_format_display_name(event)}: started{resumed_suffix}", GREY)
470
+ case "end":
471
+ # A fanned-out end carries no spent amounts.
472
+ self.spent_amounts = {
473
+ key: event[key] for key in ("spent_time", "spent_cost") if key in event
474
+ } or self.spent_amounts
475
+ event_text = Text(f"{_format_display_name(event)}: ").append_text(
476
+ _render_end_text(record, event["node"])
477
+ )
478
+ event_text.append(
479
+ f" {_format_event_duration(record.start_events[event['node']], event)}", GREY
480
+ )
481
+ if "agent" in record.find_node_definition(event["node"]) and "failure" not in event:
482
+ event_text.append(f" ${event['cost']:.2f}", GREY)
483
+ case "limit":
484
+ event_text = Text(f"{event['node']}: LIMIT → {event['target']}", "yellow")
485
+ case "fallback":
486
+ event_text = _render_fallback_text(
487
+ event, with_error=True, prefix=f"{event['node']}: "
488
+ )
489
+ case "resume":
490
+ if event.get("decision"):
491
+ event_text = Text(
492
+ f"{self.stopped_at_node}: {event['decision']}",
493
+ DECISION_STYLE[event["decision"]],
494
+ )
495
+ else:
496
+ event_text = Text("resumed", GREY)
497
+ if "add_time" in event:
498
+ event_text.append(f" +{format_duration(event['add_time'])}", GREY)
499
+ if "add_cost" in event:
500
+ event_text.append(f" +${event['add_cost']:.2f}", GREY)
501
+ case "stop":
502
+ run_state = {"node": event["node"], **self.spent_amounts}
503
+ question = record.nodes[event["node"]].get("gate")
504
+ self.stopped_at_node = event["node"]
505
+ event_text = format_stop_line(run_state, event["reason"], question)
506
+ return Text().append(_format_time_column(event["time"]), GREY).append_text(event_text)
507
+
508
+
509
+ def _open_outputs(
510
+ record: _RunRecord, node_run_name: str, resumed_spawn: bool = False
511
+ ) -> tuple[_LineReader, _LineReader]:
512
+ """Return the readers of a node run's stdout and stderr, or of its resumed spawn's."""
513
+ return (
514
+ _open_output(record, node_run_name, "stdout", resumed_spawn),
515
+ _open_output(record, node_run_name, "stderr", resumed_spawn),
516
+ )
517
+
518
+
519
+ def _open_output(
520
+ record: _RunRecord, node_run_name: str, stream: str, resumed_spawn: bool = False
521
+ ) -> _LineReader:
522
+ """Return the reader of one node run output file; a missing file is an error."""
523
+ output_path = build_output_path(
524
+ record.directory, node_run_name, f"resume.{stream}" if resumed_spawn else stream
525
+ )
526
+ try:
527
+ return _LineReader(output_path)
528
+ except FileNotFoundError:
529
+ record.journal_reader.check_replaced()
530
+ raise RecordError(f"no output file {output_path}") from None
531
+
532
+
533
+ def _format_time_column(journal_time: str) -> str:
534
+ return f"{_format_local_time(journal_time)} "
535
+
536
+
537
+ def _render_last_line(record: _RunRecord) -> Text:
538
+ """Render the untimestamped last line of a run without a stop: running, or interrupted.
539
+
540
+ A run interrupted before it wrote its state is at the workflow's start node.
541
+ """
542
+ run_state = read_state(record.directory) or {"node": record.start_node}
543
+ last_line = (
544
+ format_running_line(run_state, record.events)
545
+ if record.in_progress
546
+ else format_stop_line(run_state, "interrupted")
547
+ )
548
+ return Text(" " * len(_format_time_column(record.events[0]["time"]))).append_text(last_line)
549
+
550
+
551
+ def _render_origin(origin: str) -> Text:
552
+ return Text(f"[{origin}] ", GREY)
553
+
554
+
555
+ def _resolve_node_run(node_run_identifier: str, start_events: dict[str, Event]) -> str:
556
+ """Return the node run the identifier names.
557
+
558
+ The identifier is either a node run name `<node>#<n>`, which must be in the record,
559
+ or a node name `<node>`, which names the last node run of that node.
560
+ """
561
+ if node_run_identifier in start_events:
562
+ return node_run_identifier
563
+ _, separator, node_run_count = node_run_identifier.rpartition("#")
564
+ if separator and node_run_count.isdigit():
565
+ raise RecordError(f"no node run '{node_run_identifier}'")
566
+ node_runs = [name for name in start_events if parse_node_name(name) == node_run_identifier]
567
+ if not node_runs:
568
+ raise RecordError(f"no node run of '{node_run_identifier}'")
569
+ return node_runs[-1]
570
+
571
+
572
+ def _format_display_name(event: Event) -> str:
573
+ """Return the node run name; `<map>/<node>#<n>` for a fanned-out node run.
574
+
575
+ The run's progress line names a fanned-out node run the same way.
576
+ """
577
+ return f"{event['map']}/{event['node']}" if event.get("map") else str(event["node"])
578
+
579
+
580
+ def _format_local_time(journal_time: str) -> str:
581
+ """Return a journal time as local ISO 8601 with the offset."""
582
+ return datetime.fromisoformat(journal_time).astimezone().isoformat(timespec="seconds")
583
+
584
+
585
+ def _format_event_duration(start_event: Event, end_event: Event) -> str:
586
+ """Return the wall-clock between the two events as `12s`, `3m05s`, or `1h02m`."""
587
+ return _format_elapsed(start_event, datetime.fromisoformat(end_event["time"]))
588
+
589
+
590
+ def _format_elapsed(start_event: Event, end_time: datetime) -> str:
591
+ return format_duration((end_time - datetime.fromisoformat(start_event["time"])).total_seconds())
592
+
593
+
594
+ def _render_heading(title: str) -> Text:
595
+ return Text(f"── {title} ──", GREY)
596
+
597
+
598
+ def _render_section(title: str, body: Sequence[Line]) -> list[Line]:
599
+ return [_render_heading(title), *(body or [Text("(none)", GREY)]), Text()]
600
+
601
+
602
+ def _render_input(run_input: str, handoff: dict[str, str] | None) -> list[Line]:
603
+ """Return the run input, then the delivered handoff."""
604
+ lines: list[Line] = [Text(run_input)]
605
+ if handoff:
606
+ lines += [Text(), Text(f"Handoff from {handoff['source']}:", "bold")]
607
+ lines += split_lines(handoff["text"])
608
+ return lines
609
+
610
+
611
+ def _render_output_lines(
612
+ lines: Sequence[str], transcript_harness: Harness | None
613
+ ) -> Sequence[Line]:
614
+ """Render output lines: a transcript, or the lines unchanged as one str ending in a newline."""
615
+ if transcript_harness is not None:
616
+ return transcript_harness.render_transcript(lines)
617
+ return ["\n".join(lines) + "\n"] if lines else []
618
+
619
+
620
+ def _render_outcome(record: _RunRecord, node_run_name: str, now: datetime | None) -> list[Line]:
621
+ """Render the end text; a map node run lists its fanned-out node runs."""
622
+ outcome_lines: list[Line] = [_render_outcome_text(record, node_run_name, now)]
623
+ if "map" in record.find_node_definition(node_run_name):
624
+ # The fanned-out node runs are the ones started between the map node run's start and end.
625
+ first_index = record.events.index(record.start_events[node_run_name]) + 1
626
+ last_index = (
627
+ record.events.index(record.end_events[node_run_name])
628
+ if node_run_name in record.end_events
629
+ else len(record.events)
630
+ )
631
+ fanned_out_starts = (
632
+ event for event in record.events[first_index:last_index] if event["event"] == "start"
633
+ )
634
+ for fanned_out_start in fanned_out_starts:
635
+ fanned_out_line = Text(f" {_format_display_name(fanned_out_start)} ").append_text(
636
+ _render_outcome_text(record, fanned_out_start["node"], now)
637
+ )
638
+ if fanned_out_start["node"] in record.end_events:
639
+ fanned_out_end = record.end_events[fanned_out_start["node"]]
640
+ fanned_out_line.append(
641
+ f" {_format_event_duration(fanned_out_start, fanned_out_end)}", GREY
642
+ )
643
+ outcome_lines.append(fanned_out_line)
644
+ return outcome_lines
645
+
646
+
647
+ def _render_outcome_text(record: _RunRecord, node_run_name: str, now: datetime | None) -> Text:
648
+ """Render the end text. Without an end: `running <running time>…`, or `interrupted` without a lock."""
649
+ if node_run_name in record.end_events:
650
+ return _render_end_text(record, node_run_name)
651
+ return Text(
652
+ "interrupted"
653
+ if now is None
654
+ else f"running {_format_elapsed(record.start_events[node_run_name], now)}…",
655
+ "bold",
656
+ )
657
+
658
+
659
+ def _render_fallback_text(fallback_event: Event, with_error: bool, prefix: str = "") -> Text:
660
+ """Render the fallback marker; with_error appends the resumed spawn's error."""
661
+ fallback_text = Text(f"{prefix}FALLBACK → fresh spawn", "yellow")
662
+ if with_error:
663
+ fallback_text.append(f" {fallback_event['error']}", GREY)
664
+ return fallback_text
665
+
666
+
667
+ def _render_end_text(record: _RunRecord, node_run_name: str) -> Text:
668
+ """Render the outcome and target. Color only coded outcomes: pass, fail, and failure."""
669
+ end_event = record.end_events[node_run_name]
670
+ if "failure" in end_event:
671
+ return Text(f"failure: {end_event['failure']}", "red")
672
+ outcome = str(end_event["outcome"])
673
+ text = f"{outcome} → {end_event['target']}" if end_event["target"] else outcome
674
+ if "agent" in record.find_node_definition(node_run_name):
675
+ return Text(text)
676
+ return Text(text, "green" if outcome == "pass" else "red")