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/__init__.py +1 -0
- workgraph/claude.py +106 -0
- workgraph/cli.py +379 -0
- workgraph/codex.py +232 -0
- workgraph/definitions/agents/wg_code-review.md +14 -0
- workgraph/definitions/agents/wg_design.md +57 -0
- workgraph/definitions/agents/wg_implement.md +39 -0
- workgraph/definitions/agents/wg_overengineering-review.md +13 -0
- workgraph/definitions/agents/wg_plan.md +79 -0
- workgraph/definitions/agents/wg_pr.md +33 -0
- workgraph/definitions/agents/wg_summarize-review.md +10 -0
- workgraph/definitions/workflows/wg.toml +101 -0
- workgraph/definitions/workflows/wg_codex.toml +106 -0
- workgraph/graph.py +360 -0
- workgraph/harness.py +112 -0
- workgraph/run.py +942 -0
- workgraph/show.py +676 -0
- workgraph/workflow.py +263 -0
- workgraph-0.3.3.dist-info/METADATA +139 -0
- workgraph-0.3.3.dist-info/RECORD +23 -0
- workgraph-0.3.3.dist-info/WHEEL +4 -0
- workgraph-0.3.3.dist-info/entry_points.txt +3 -0
- workgraph-0.3.3.dist-info/licenses/LICENSE +21 -0
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")
|