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.
@@ -0,0 +1,106 @@
1
+ start = "design"
2
+
3
+ [defaults]
4
+ harness = "claude"
5
+ model = "claude-fable-5-1"
6
+ effort = "high"
7
+
8
+ [nodes.design]
9
+ agent = "wg_design"
10
+ effort = "xhigh"
11
+ outcomes = ["done"]
12
+
13
+ [nodes.design.transitions]
14
+ done = "approve-design"
15
+
16
+ [nodes.approve-design]
17
+ gate = "Plan from this design?"
18
+
19
+ [nodes.approve-design.transitions]
20
+ accept = "plan"
21
+ reject = "design"
22
+
23
+ [nodes.plan]
24
+ agent = "wg_plan"
25
+ effort = "xhigh"
26
+ outcomes = ["done"]
27
+
28
+ [nodes.plan.transitions]
29
+ done = "approve-plan"
30
+
31
+ [nodes.approve-plan]
32
+ gate = "Implement this plan?"
33
+
34
+ [nodes.approve-plan.transitions]
35
+ accept = "implement"
36
+ reject = "plan"
37
+
38
+ [nodes.implement]
39
+ agent = "wg_implement"
40
+ harness = "codex"
41
+ model = "gpt-5.6-sol"
42
+ effort = "xhigh"
43
+ sandbox = "danger-full-access"
44
+ outcomes = ["done"]
45
+
46
+ [nodes.implement.transitions]
47
+ done = "test"
48
+
49
+ [nodes.test]
50
+ command = "sh -c 'uv run ruff check && uv run ruff format --check && uv run mypy && uv run pytest'"
51
+
52
+ [nodes.test.limits]
53
+ visits = 5
54
+ reset = "pass"
55
+
56
+ [nodes.test.transitions]
57
+ pass = "review"
58
+ fail = "implement"
59
+
60
+ [nodes.review]
61
+ map = ["code-review", "overengineering-review"]
62
+ resolve = "all"
63
+
64
+ [nodes.review.transitions]
65
+ pass = "pr"
66
+ fail = "review-loop"
67
+
68
+ # Holds the review limit so the LIMIT diversion carries the review findings.
69
+ [nodes.review-loop]
70
+ command = "true"
71
+
72
+ [nodes.review-loop.limits]
73
+ visits = 2
74
+
75
+ [nodes.review-loop.transitions]
76
+ pass = "implement"
77
+ fail = "implement"
78
+ LIMIT = "summary"
79
+
80
+ [nodes.code-review]
81
+ agent = "wg_code-review"
82
+ outcomes = ["pass", "fail"]
83
+
84
+ [nodes.overengineering-review]
85
+ agent = "wg_overengineering-review"
86
+ outcomes = ["pass", "fail"]
87
+
88
+ [nodes.summary]
89
+ agent = "wg_summarize-review"
90
+ harness = "codex"
91
+ model = "gpt-5.6-sol"
92
+ outcomes = ["done"]
93
+
94
+ [nodes.summary.transitions]
95
+ done = "pr"
96
+
97
+ [nodes.pr]
98
+ agent = "wg_pr"
99
+ harness = "codex"
100
+ model = "gpt-5.6-luna"
101
+ effort = "max"
102
+ sandbox = "danger-full-access"
103
+ outcomes = ["done"]
104
+
105
+ [nodes.pr.transitions]
106
+ done = "END"
workgraph/graph.py ADDED
@@ -0,0 +1,360 @@
1
+ """Draw the run's path as a vertical chain: show-journal --graph."""
2
+
3
+ import math
4
+ import time
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass, field
7
+ from datetime import UTC, datetime
8
+ from itertools import zip_longest
9
+ from pathlib import Path
10
+
11
+ from rich.text import Text
12
+
13
+ from workgraph import show
14
+ from workgraph.run import GREY, format_duration, parse_node_name, read_state
15
+ from workgraph.show import DECISION_STYLE, Event, _RunRecord, format_resumed_suffix
16
+ from workgraph.workflow import END
17
+
18
+ # Seconds between two redraws under follow; the journal poll keeps show.POLL_INTERVAL.
19
+ REDRAW_INTERVAL = 0.1
20
+ # Seconds per sine fade of the current glyph, from #606060 to white and back.
21
+ PULSE_PERIOD = 2.0
22
+
23
+ GLYPH_CURRENT = "◆"
24
+ GLYPH_PAST = "◇"
25
+ GLYPH_RESUMED = "↻"
26
+ GLYPH_PASS = "✓"
27
+ GLYPH_FAIL = "✗"
28
+ GLYPH_GATE = "⬡"
29
+ GLYPH_LIMIT = "┆"
30
+ GLYPH_WARN = "⚠"
31
+
32
+
33
+ @dataclass
34
+ class _NodeRun:
35
+ """One node run read from its start and end events; fanned_out_runs holds its fan-out."""
36
+
37
+ node_run_name: str
38
+ node_name: str
39
+ start_time: datetime
40
+ end_time: datetime | None = None
41
+ outcome: str | None = None
42
+ cost: float = 0.0
43
+ resumed_session: bool = False
44
+ fell_back: bool = False
45
+ fanned_out_runs: list["_NodeRun"] = field(default_factory=list)
46
+
47
+
48
+ # The chain in journal order: node runs, and the limit, stop, and resume events.
49
+ ChainEntry = _NodeRun | Event
50
+
51
+
52
+ @dataclass
53
+ class _Row:
54
+ """One line of the chain: the left column, and a fan-out part hanging to the right."""
55
+
56
+ left: Text
57
+ right: Text = field(default_factory=Text)
58
+ # The right part continues the chain, reached with a `─` fill.
59
+ connects: bool = False
60
+ # A stop row carries no right part, so it may run past the column.
61
+ overruns: bool = False
62
+
63
+
64
+ def show_graph(directory: Path) -> list[Text]:
65
+ """Render the run's path once: the header line, then the chain."""
66
+ return _render_graph(_RunRecord(directory), pulse=None)
67
+
68
+
69
+ def follow_graph(directory: Path, until_end: bool) -> Iterator[list[Text]]:
70
+ """Yield one frame per redraw until the run stops; the last frame shows the final state.
71
+
72
+ The current glyph fades with the frame count. The journal poll stays at show.POLL_INTERVAL,
73
+ one poll every POLL_INTERVAL / REDRAW_INTERVAL frames.
74
+ """
75
+ record = _RunRecord(directory)
76
+ frame_count = 0
77
+ while True:
78
+ pulse = frame_count * REDRAW_INTERVAL % PULSE_PERIOD / PULSE_PERIOD
79
+ yield _render_graph(record, pulse)
80
+ stop_event = record.stop_event
81
+ if stop_event and (stop_event["reason"] == "end" or not until_end):
82
+ return
83
+ record.check_interrupted()
84
+ time.sleep(REDRAW_INTERVAL)
85
+ frame_count += 1
86
+ if frame_count % round(show.POLL_INTERVAL / REDRAW_INTERVAL) == 0:
87
+ record.read_events()
88
+
89
+
90
+ def _render_graph(record: _RunRecord, pulse: float | None) -> list[Text]:
91
+ chain = _build_chain(record.events)
92
+ now = record.now
93
+ return [_render_header(record, chain, now), Text(), *_render_chain(record, chain, now, pulse)]
94
+
95
+
96
+ def _build_chain(events: list[Event]) -> list[ChainEntry]:
97
+ """Pair the start and end events into node runs, keeping the journal order.
98
+
99
+ A fanned-out node run hangs off its map node run instead of the chain.
100
+ """
101
+ chain: list[ChainEntry] = []
102
+ runs_by_name: dict[str, _NodeRun] = {}
103
+ last_run_of_node: dict[str, _NodeRun] = {}
104
+ for event in events:
105
+ match event["event"]:
106
+ case "start":
107
+ node_run = _NodeRun(
108
+ node_run_name=event["node"],
109
+ node_name=parse_node_name(event["node"]),
110
+ start_time=datetime.fromisoformat(event["time"]),
111
+ resumed_session=event.get("session") is not None,
112
+ )
113
+ if event.get("map"):
114
+ last_run_of_node[event["map"]].fanned_out_runs.append(node_run)
115
+ else:
116
+ chain.append(node_run)
117
+ last_run_of_node[node_run.node_name] = node_run
118
+ runs_by_name[node_run.node_run_name] = node_run
119
+ case "end":
120
+ node_run = runs_by_name[event["node"]]
121
+ node_run.end_time = datetime.fromisoformat(event["time"])
122
+ node_run.outcome = "failure" if "failure" in event else event["outcome"]
123
+ node_run.cost = event["cost"]
124
+ case "fallback":
125
+ runs_by_name[event["node"]].fell_back = True
126
+ case "limit" | "stop" | "resume":
127
+ chain.append(event)
128
+ return chain
129
+
130
+
131
+ def _render_header(record: _RunRecord, chain: list[ChainEntry], now: datetime | None) -> Text:
132
+ """Render `run: <workflow> "<input>" · spent <t> · $<c> · <status>`; $<c> only when non-zero."""
133
+ chain_node_runs = [entry for entry in chain if isinstance(entry, _NodeRun)]
134
+ spent_seconds = sum(
135
+ ((node_run.end_time or now or node_run.start_time) - node_run.start_time).total_seconds()
136
+ for node_run in chain_node_runs
137
+ )
138
+ spent_cost = round(sum(node_run.cost for node_run in chain_node_runs), 2)
139
+ run_event = record.events[0]
140
+ head = f'run: {run_event["workflow"]} "{run_event["input"]}"'
141
+ head += f" · spent {format_duration(spent_seconds)}"
142
+ if spent_cost:
143
+ head += f" · ${spent_cost:.2f}"
144
+ return Text(head + " · ").append_text(_render_run_status(record, chain, now))
145
+
146
+
147
+ def _render_run_status(record: _RunRecord, chain: list[ChainEntry], now: datetime | None) -> Text:
148
+ last_entry = chain[-1] if chain else None
149
+ if isinstance(last_entry, dict) and last_entry["event"] == "stop":
150
+ node_name, stop_reason = last_entry["node"], last_entry["reason"]
151
+ if stop_reason == "gate":
152
+ waited_seconds = (
153
+ datetime.now(UTC) - datetime.fromisoformat(last_entry["time"])
154
+ ).total_seconds()
155
+ question = record.nodes[node_name]["gate"]
156
+ return Text(
157
+ f"parked at {node_name} for {format_duration(waited_seconds)}: {question}",
158
+ "bold yellow",
159
+ )
160
+ if stop_reason == "end":
161
+ return Text(END, "green")
162
+ return Text(
163
+ f"{stop_reason} at {node_name}", "red" if stop_reason == "failure" else "bold yellow"
164
+ )
165
+ if now is None:
166
+ state = read_state(record.directory) or {"node": record.start_node}
167
+ return Text(f"interrupted at {state['node']}", "bold yellow")
168
+ in_progress_text = ", ".join(
169
+ f"{entry.node_run_name} {_format_node_run_duration(entry, now)}"
170
+ for entry in chain
171
+ if isinstance(entry, _NodeRun) and entry.end_time is None
172
+ )
173
+ return Text(f"running {in_progress_text or 'between nodes'}", "bold")
174
+
175
+
176
+ def _format_node_run_duration(node_run: _NodeRun, now: datetime | None) -> str:
177
+ """Format the node run's duration: `<elapsed>…` while it runs, empty when interrupted."""
178
+ if node_run.end_time is None:
179
+ if now is None:
180
+ return ""
181
+ return format_duration((now - node_run.start_time).total_seconds()) + "…"
182
+ return format_duration((node_run.end_time - node_run.start_time).total_seconds())
183
+
184
+
185
+ def _render_chain(
186
+ record: _RunRecord, chain: list[ChainEntry], now: datetime | None, pulse: float | None
187
+ ) -> list[Text]:
188
+ name_width = max(
189
+ (len(entry.node_run_name) for entry in chain if isinstance(entry, _NodeRun)), default=0
190
+ )
191
+ rows: list[_Row] = []
192
+ for index, entry in enumerate(chain):
193
+ if isinstance(entry, _NodeRun):
194
+ rows += _render_node_run(record, entry, now, pulse, name_width)
195
+ elif entry["event"] == "limit":
196
+ rows.append(_Row(Text(f"{GLYPH_LIMIT} {entry['node']} → LIMIT", "yellow")))
197
+ elif entry["event"] == "stop":
198
+ following_entry = chain[index + 1] if index + 1 < len(chain) else None
199
+ rows.append(_render_stop(record, entry, following_entry, name_width))
200
+ else:
201
+ rows.append(_Row(_render_resume(entry)))
202
+ return _join_columns(rows)
203
+
204
+
205
+ def _render_node_run(
206
+ record: _RunRecord,
207
+ node_run: _NodeRun,
208
+ now: datetime | None,
209
+ pulse: float | None,
210
+ name_width: int,
211
+ ) -> list[_Row]:
212
+ """Render a node run: its row and outcome edge on the left, its fan-out on the right."""
213
+ left = [_render_node_row(record, node_run, now, pulse, name_width)]
214
+ if node_run.end_time and node_run.outcome != "failure":
215
+ left.append(_render_edge(node_run.outcome or "", _pick_outcome_style(record, node_run)))
216
+ right = _render_fan_out(record, node_run, now, pulse)
217
+ while len(left) < len(right):
218
+ left.append(Text("│", GREY) if node_run.end_time else Text())
219
+ rows = [
220
+ _Row(left_part, right_part)
221
+ for left_part, right_part in zip_longest(left, right, fillvalue=Text())
222
+ ]
223
+ if right:
224
+ rows[0].connects = True
225
+ return rows
226
+
227
+
228
+ def _render_fan_out(
229
+ record: _RunRecord, node_run: _NodeRun, now: datetime | None, pulse: float | None
230
+ ) -> list[Text]:
231
+ fanned_out_runs = node_run.fanned_out_runs
232
+ rows = []
233
+ for index, fanned_out_run in enumerate(fanned_out_runs):
234
+ last_index = len(fanned_out_runs) - 1
235
+ connector = (
236
+ ("─" if last_index == 0 else "┬") if index == 0 else "└" if index == last_index else "├"
237
+ )
238
+ row = Text().append(connector + " ", GREY)
239
+ row.append_text(_render_node_row(record, fanned_out_run, now, pulse))
240
+ if fanned_out_run.outcome:
241
+ row.append(" " + fanned_out_run.outcome, _pick_outcome_style(record, fanned_out_run))
242
+ rows.append(row)
243
+ return rows
244
+
245
+
246
+ def _render_node_row(
247
+ record: _RunRecord,
248
+ node_run: _NodeRun,
249
+ now: datetime | None,
250
+ pulse: float | None,
251
+ name_width: int = 0,
252
+ ) -> Text:
253
+ row = Text()
254
+ # A fallback ends the resumed spawn, so the fresh spawn draws the regular glyphs.
255
+ resumed_glyph = GLYPH_RESUMED if node_run.resumed_session and not node_run.fell_back else None
256
+ if node_run.end_time is None:
257
+ row.append(
258
+ resumed_glyph or GLYPH_CURRENT,
259
+ "bold" if pulse is None else _pick_pulse_style(pulse),
260
+ )
261
+ row.append(
262
+ f" {node_run.node_run_name.ljust(name_width)} {_format_node_run_duration(node_run, now)}",
263
+ "bold",
264
+ )
265
+ return _append_resumed_suffix(record, row, node_run)
266
+ style = _pick_outcome_style(record, node_run)
267
+ glyph = {"green": GLYPH_PASS, "red": GLYPH_FAIL}.get(style, resumed_glyph or GLYPH_PAST)
268
+ row.append(f"{glyph} {node_run.node_run_name.ljust(name_width)}", style)
269
+ row.append(" " + _format_node_run_duration(node_run, now), GREY)
270
+ if "agent" in record.nodes[node_run.node_name]:
271
+ row.append(f" ${node_run.cost:.2f}", GREY)
272
+ return _append_resumed_suffix(record, row, node_run)
273
+
274
+
275
+ def _append_resumed_suffix(record: _RunRecord, row: Text, node_run: _NodeRun) -> Text:
276
+ """Append the node run the row resumed the session of, then its fallback."""
277
+ row.append(format_resumed_suffix(record, node_run.node_run_name), GREY)
278
+ if node_run.fell_back:
279
+ row.append(" fallback", "yellow")
280
+ return row
281
+
282
+
283
+ def _pick_outcome_style(record: _RunRecord, node_run: _NodeRun) -> str:
284
+ """Color only coded outcomes: command and map pass and fail, and a failure."""
285
+ if node_run.outcome == "failure":
286
+ return "red"
287
+ if "agent" in record.nodes[node_run.node_name]:
288
+ return ""
289
+ return "green" if node_run.outcome == "pass" else "red"
290
+
291
+
292
+ def _render_edge(label: str, style: str) -> Text:
293
+ return Text().append("│ ", GREY).append(label, style)
294
+
295
+
296
+ def _render_stop(
297
+ record: _RunRecord, event: Event, following_entry: ChainEntry | None, name_width: int
298
+ ) -> _Row:
299
+ stop_reason = event["reason"]
300
+ if stop_reason == "gate":
301
+ return _Row(_render_gate(event, following_entry, name_width))
302
+ if stop_reason == "end":
303
+ return _Row(Text(END, "green"))
304
+ if stop_reason == "failure":
305
+ message = next(
306
+ end_event["failure"]
307
+ for end_event in reversed(record.events)
308
+ if end_event["event"] == "end" and "failure" in end_event
309
+ )
310
+ return _Row(Text(f"{GLYPH_FAIL} failure: {message}", "red"), overruns=True)
311
+ return _Row(
312
+ Text(f"{GLYPH_WARN} {stop_reason} at {event['node']}", "bold yellow"), overruns=True
313
+ )
314
+
315
+
316
+ def _render_gate(event: Event, following_entry: ChainEntry | None, name_width: int) -> Text:
317
+ """Render the gate: its wait until the resume, or `parked <wait>` while it waits."""
318
+ stop_time = datetime.fromisoformat(event["time"])
319
+ if isinstance(following_entry, dict) and following_entry["event"] == "resume":
320
+ waited_seconds = (
321
+ datetime.fromisoformat(following_entry["time"]) - stop_time
322
+ ).total_seconds()
323
+ row = Text(
324
+ f"{GLYPH_GATE} {event['node'].ljust(name_width)}",
325
+ DECISION_STYLE[following_entry["decision"]],
326
+ )
327
+ return row.append(f" {format_duration(waited_seconds)}", GREY)
328
+ waited_seconds = (datetime.now(UTC) - stop_time).total_seconds()
329
+ return Text(
330
+ f"{GLYPH_GATE} {event['node'].ljust(name_width)} parked {format_duration(waited_seconds)}",
331
+ "bold yellow",
332
+ )
333
+
334
+
335
+ def _render_resume(event: Event) -> Text:
336
+ if event.get("decision"):
337
+ return _render_edge(event["decision"], DECISION_STYLE[event["decision"]])
338
+ return _render_edge("resumed", GREY)
339
+
340
+
341
+ def _join_columns(rows: list[_Row]) -> list[Text]:
342
+ """Chain the left column; reach a fan-out on the right with a `─` fill."""
343
+ left_column_width = max((row.left.cell_len for row in rows if not row.overruns), default=0) + 1
344
+ lines = []
345
+ for row in rows:
346
+ line = row.left.copy()
347
+ line.append(" ")
348
+ line.pad_right(left_column_width - line.cell_len, "─" if row.connects else " ")
349
+ if row.connects:
350
+ line.stylize(GREY, row.left.cell_len, left_column_width)
351
+ line.append_text(row.right)
352
+ line.rstrip()
353
+ lines.append(line)
354
+ return lines
355
+
356
+
357
+ def _pick_pulse_style(pulse: float) -> str:
358
+ """Grey level of the current glyph: a sine fade between #606060 and white."""
359
+ grey_level = int(96 + 159 * (0.5 + 0.5 * math.sin(2 * math.pi * pulse)))
360
+ return f"bold #{grey_level:02x}{grey_level:02x}{grey_level:02x}"
workgraph/harness.py ADDED
@@ -0,0 +1,112 @@
1
+ """The harness interface: the runtimes that execute an agent node run."""
2
+
3
+ import json
4
+ from collections.abc import Iterable, Iterator, Sequence
5
+ from contextlib import AbstractContextManager
6
+ from dataclasses import dataclass
7
+ from typing import Any, Protocol
8
+
9
+ from rich.text import Text
10
+
11
+ # The accepted harness names; find_harness maps each to the module that implements Harness.
12
+ HARNESS_NAMES: tuple[str, ...] = ("claude", "codex")
13
+
14
+
15
+ class NodeFailure(Exception):
16
+ """A node run ended without an outcome; the run stops."""
17
+
18
+ def __init__(self, message: str, cost: float = 0.0) -> None:
19
+ super().__init__(message)
20
+ # The cost the harness reported before the failure, so the run still counts it.
21
+ self.cost = cost
22
+ # The agent session the failed node run ended with, so a re-entry resumes it.
23
+ self.session: str | None = None
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class AgentInvocation:
28
+ """What a harness needs to run an agent node."""
29
+
30
+ agent_node_name: str
31
+ agent_name: str
32
+ agent_definition: dict[str, str]
33
+ prompt: str
34
+ model: str
35
+ effort: str
36
+ outcomes: list[str]
37
+ allowed_tools: str | None = None
38
+ sandbox: str = "workspace-write"
39
+ web_search: Any = None
40
+ # The agent session to resume; None spawns a fresh session.
41
+ session: str | None = None
42
+
43
+ @property
44
+ def outcome_schema(self) -> dict[str, Any]:
45
+ """The JSON schema of the structured output the agent reports."""
46
+ return {
47
+ "type": "object",
48
+ "properties": {
49
+ "outcome": {"enum": self.outcomes},
50
+ "handoff": {
51
+ "type": "string",
52
+ "description": "Optional free text delivered to the next node of the workflow.",
53
+ },
54
+ },
55
+ "required": ["outcome"],
56
+ }
57
+
58
+
59
+ class Harness(Protocol):
60
+ """What workgraph needs of a harness to run an agent node and render its output."""
61
+
62
+ def build_argv(self, invocation: AgentInvocation) -> AbstractContextManager[list[str]]:
63
+ """Yield the argv that runs the agent; a file the argv names exists for the with block."""
64
+
65
+ def read_result(
66
+ self, invocation: AgentInvocation, stdout_lines: Sequence[str]
67
+ ) -> tuple[Any, float]:
68
+ """Return the structured output the agent reported and the USD cost of the node run.
69
+
70
+ read_result raises NodeFailure when the output holds no result or the agent reported
71
+ an error; the failure carries the cost.
72
+ """
73
+
74
+ def read_session(self, stdout_lines: Sequence[str]) -> str | None:
75
+ """Return the agent session the output names; None when it names none."""
76
+
77
+ def render_transcript(self, stdout_lines: Sequence[str]) -> list[Text]:
78
+ """Render agent stdout lines as transcript rows."""
79
+
80
+
81
+ def iter_jsonl_events(lines: Iterable[str]) -> Iterator[dict[str, Any]]:
82
+ """Yield the JSON objects among JSONL lines; drop every other line."""
83
+ for line in lines:
84
+ try:
85
+ event = json.loads(line)
86
+ except json.JSONDecodeError:
87
+ continue
88
+ if isinstance(event, dict):
89
+ yield event
90
+
91
+
92
+ def read_last_value(events: Iterable[dict[str, Any]], key: str) -> str | None:
93
+ """Return the value of the key in the last event that carries it; None when none does."""
94
+ values = [event[key] for event in events if key in event]
95
+ return str(values[-1]) if values else None
96
+
97
+
98
+ def split_lines(text: str | None) -> list[Text]:
99
+ """Split on newlines only; a `\\r` or `\\f` stays in its line."""
100
+ lines = (text or "").split("\n")
101
+ if not lines[-1]:
102
+ lines.pop()
103
+ return [Text(line) for line in lines]
104
+
105
+
106
+ def find_harness(harness_name: str) -> Harness:
107
+ """Return the harness module the name selects."""
108
+ # The harness modules import this module, so the import waits for the call.
109
+ from workgraph import claude, codex
110
+
111
+ harnesses: dict[str, Harness] = {"claude": claude, "codex": codex}
112
+ return harnesses[harness_name]