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 ADDED
@@ -0,0 +1 @@
1
+ """Graph workflow orchestrator."""
workgraph/claude.py ADDED
@@ -0,0 +1,106 @@
1
+ """The Claude Code harness: argv, result reading, and transcript rendering."""
2
+
3
+ import json
4
+ import textwrap
5
+ from collections.abc import Iterator, Sequence
6
+ from contextlib import contextmanager
7
+ from typing import Any
8
+
9
+ from rich.text import Text
10
+
11
+ from workgraph.harness import (
12
+ AgentInvocation,
13
+ NodeFailure,
14
+ iter_jsonl_events,
15
+ read_last_value,
16
+ split_lines,
17
+ )
18
+
19
+
20
+ @contextmanager
21
+ def build_argv(invocation: AgentInvocation) -> Iterator[list[str]]:
22
+ """Yield the argv that runs the agent through the Claude Code CLI."""
23
+ agent_definition = invocation.agent_definition
24
+ agents = {
25
+ invocation.agent_name: {
26
+ "description": agent_definition.get("description", ""),
27
+ "prompt": agent_definition["prompt"],
28
+ }
29
+ }
30
+ # No --bare: bare mode reads no OAuth credentials, so agent nodes cannot
31
+ # authenticate for subscription users. Accepted cost: hooks and plugins
32
+ # load on every spawn.
33
+ argv = [
34
+ "claude",
35
+ "-p",
36
+ invocation.prompt,
37
+ "--output-format",
38
+ "stream-json",
39
+ "--verbose",
40
+ "--json-schema",
41
+ json.dumps(invocation.outcome_schema),
42
+ "--agents",
43
+ json.dumps(agents),
44
+ "--agent",
45
+ invocation.agent_name,
46
+ "--permission-mode",
47
+ "dontAsk",
48
+ "--model",
49
+ invocation.model,
50
+ "--effort",
51
+ invocation.effort,
52
+ ]
53
+ allowed_tools = agent_definition.get("tools", invocation.allowed_tools)
54
+ if allowed_tools is not None:
55
+ argv += ["--allowedTools", allowed_tools]
56
+ if invocation.session is not None:
57
+ # The node run continues the session in place.
58
+ argv += ["--resume", invocation.session]
59
+ yield argv
60
+
61
+
62
+ def read_result(invocation: AgentInvocation, stdout_lines: Sequence[str]) -> tuple[Any, float]:
63
+ """Return the structured output of the last result event and the cost it reports."""
64
+ agent_node_name = invocation.agent_node_name
65
+ result_events = [
66
+ event for event in iter_jsonl_events(stdout_lines) if event.get("type") == "result"
67
+ ]
68
+ if not result_events:
69
+ raise NodeFailure(f"node '{agent_node_name}': agent output holds no result event")
70
+ result_event = result_events[-1]
71
+ try:
72
+ cost = float(result_event.get("total_cost_usd") or 0)
73
+ except (TypeError, ValueError):
74
+ # A malformed cost counts as zero; the run continues.
75
+ cost = 0.0
76
+ if result_event.get("is_error"):
77
+ raise NodeFailure(f"node '{agent_node_name}': agent reported an error", cost)
78
+ return result_event.get("structured_output"), cost
79
+
80
+
81
+ def read_session(stdout_lines: Sequence[str]) -> str | None:
82
+ """Return the session id of the last stream event that carries one."""
83
+ return read_last_value(iter_jsonl_events(stdout_lines), "session_id")
84
+
85
+
86
+ def render_transcript(stdout_lines: Sequence[str]) -> list[Text]:
87
+ """Render the text blocks and tool calls of stream-json lines. Drop every other line."""
88
+ transcript_rows: list[Text] = []
89
+ for stream_event in iter_jsonl_events(stdout_lines):
90
+ if stream_event.get("type") != "assistant":
91
+ continue
92
+ for block in stream_event["message"]["content"]:
93
+ if block["type"] == "text":
94
+ transcript_rows += split_lines(block["text"])
95
+ elif block["type"] == "tool_use" and block["name"] != "StructuredOutput":
96
+ transcript_rows.append(
97
+ Text(f"▸ {block['name']}: {_summarize_tool_input(block['input'])}", "bold")
98
+ )
99
+ return transcript_rows
100
+
101
+
102
+ def _summarize_tool_input(tool_input: dict[str, Any]) -> str:
103
+ for key in ("command", "file_path", "pattern", "url"):
104
+ if key in tool_input:
105
+ return str(tool_input[key])
106
+ return textwrap.shorten(json.dumps(tool_input), 100, placeholder="...")
workgraph/cli.py ADDED
@@ -0,0 +1,379 @@
1
+ """Command-line entry point."""
2
+
3
+ import argparse
4
+ import sys
5
+ from collections.abc import Callable, Iterable
6
+ from pathlib import Path
7
+
8
+ from rich.cells import cell_len
9
+ from rich.console import Console
10
+ from rich.text import Text
11
+ from termaid import render_rich
12
+ from termaid.renderer.themes import THEMES
13
+
14
+ from workgraph.graph import follow_graph, show_graph
15
+ from workgraph.harness import NodeFailure
16
+ from workgraph.run import (
17
+ BudgetStop,
18
+ DecisionError,
19
+ Escalation,
20
+ NothingToResume,
21
+ Park,
22
+ RunInProgress,
23
+ compute_cost_limit,
24
+ compute_time_limits,
25
+ echo,
26
+ format_review_material,
27
+ format_running_line,
28
+ format_stop_line,
29
+ is_in_progress,
30
+ load_state,
31
+ read_journal,
32
+ read_state,
33
+ resume_run,
34
+ run_workflow,
35
+ )
36
+ from workgraph.show import (
37
+ Line,
38
+ RecordError,
39
+ StderrLine,
40
+ follow_journal,
41
+ follow_node,
42
+ show_journal,
43
+ show_node,
44
+ )
45
+ from workgraph.workflow import (
46
+ END,
47
+ WorkflowError,
48
+ load_workflow,
49
+ parse_cost,
50
+ parse_duration,
51
+ render_mermaid,
52
+ )
53
+
54
+
55
+ def main(argv: list[str] | None = None) -> int:
56
+ """Run the workgraph command line."""
57
+ parser = _build_parser()
58
+ args = parser.parse_args(argv)
59
+ match args.command:
60
+ case "run":
61
+ return _run(args)
62
+ case "resume":
63
+ return _resume(args)
64
+ case "status":
65
+ return _print_status(args)
66
+ case "show-node":
67
+ return _report_exit_code(lambda: _print_lines(_get_node_lines(args)))
68
+ case "show-journal":
69
+ return _show_journal_command(args)
70
+ case "viz":
71
+ return _print_viz(args)
72
+ case _:
73
+ parser.print_help()
74
+ return 0
75
+
76
+
77
+ def _build_parser() -> argparse.ArgumentParser:
78
+ parser = argparse.ArgumentParser(prog="workgraph", description="Graph workflow orchestrator.")
79
+ parser.add_argument(
80
+ "--directory",
81
+ type=_parse_directory_argument,
82
+ default=Path("."),
83
+ help="Directory the run executes in and stores its state in;"
84
+ " workflow and agent files still resolve from the invocation directory.",
85
+ )
86
+ subparsers = parser.add_subparsers(dest="command")
87
+ _add_run_parser(subparsers)
88
+ _add_resume_parser(subparsers)
89
+ subparsers.add_parser("status", help="Report the state of the run in the directory.")
90
+ _add_show_node_parser(subparsers)
91
+ _add_show_journal_parser(subparsers)
92
+ _add_viz_parser(subparsers)
93
+ return parser
94
+
95
+
96
+ def _add_resume_parser(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
97
+ resume_parser = subparsers.add_parser(
98
+ "resume", help="Resume a stopped run, or deliver a decision to a parked one."
99
+ )
100
+ resume_parser.add_argument(
101
+ "--decision",
102
+ choices=["accept", "reject"],
103
+ help="Decision for the gate the run parked at.",
104
+ )
105
+ resume_parser.add_argument("--feedback", help="Feedback delivered with a reject.")
106
+ resume_parser.add_argument(
107
+ "--add-time",
108
+ type=_parse_duration_argument,
109
+ help="Grant the run more time: seconds, or a number with unit s, m, or h.",
110
+ )
111
+ resume_parser.add_argument(
112
+ "--add-cost", type=_parse_cost_argument, help="Grant the run more cost, in USD."
113
+ )
114
+
115
+
116
+ def _add_run_parser(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
117
+ run_parser = subparsers.add_parser("run", help="Run a workflow.")
118
+ run_parser.add_argument("workflow", help="Workflow name.")
119
+ run_parser.add_argument("input", help="Run input, typically an issue ref.")
120
+
121
+
122
+ def _add_show_node_parser(
123
+ subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
124
+ ) -> None:
125
+ show_node_parser = subparsers.add_parser(
126
+ "show-node", help="Review one node run of the run in the directory."
127
+ )
128
+ show_node_parser.add_argument("node_run", help="<node>#<n>, or <node> for its last node run.")
129
+ show_node_parser.add_argument(
130
+ "--raw", action="store_true", help="Print agent stdout as the harness's JSONL lines."
131
+ )
132
+ show_node_parser.add_argument(
133
+ "--follow",
134
+ action="store_true",
135
+ help="Keep printing the node run's output until it ends; its stderr goes to stderr.",
136
+ )
137
+
138
+
139
+ def _add_show_journal_parser(
140
+ subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
141
+ ) -> None:
142
+ show_journal_parser = subparsers.add_parser(
143
+ "show-journal", help="List the events of the run in the directory."
144
+ )
145
+ show_journal_parser.add_argument(
146
+ "--with-nodes",
147
+ action="store_true",
148
+ help="Print every node run's output before its end line, each line with its origin.",
149
+ )
150
+ show_journal_parser.add_argument(
151
+ "--raw", action="store_true", help="Print agent stdout as the harness's JSONL lines."
152
+ )
153
+ show_journal_parser.add_argument(
154
+ "--follow",
155
+ action="store_true",
156
+ help="Keep printing the events until the run stops.",
157
+ )
158
+ show_journal_parser.add_argument(
159
+ "--until-end",
160
+ action="store_true",
161
+ help="Follow through parks and other stops until END; implies --follow.",
162
+ )
163
+ show_journal_parser.add_argument(
164
+ "--graph",
165
+ action="store_true",
166
+ help="Draw the run's path as a chain instead of the event lines.",
167
+ )
168
+
169
+
170
+ def _add_viz_parser(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
171
+ viz_parser = subparsers.add_parser("viz", help="Print a workflow graph.")
172
+ viz_parser.add_argument("workflow", help="Workflow name.")
173
+ style_group = viz_parser.add_mutually_exclusive_group()
174
+ style_group.add_argument(
175
+ "--unicode",
176
+ dest="style",
177
+ action="store_const",
178
+ const="unicode",
179
+ help="Render with Unicode box drawing (default).",
180
+ )
181
+ style_group.add_argument(
182
+ "--ascii",
183
+ dest="style",
184
+ action="store_const",
185
+ const="ascii",
186
+ help="Render with ASCII characters.",
187
+ )
188
+ style_group.add_argument(
189
+ "--mermaid",
190
+ dest="style",
191
+ action="store_const",
192
+ const="mermaid",
193
+ help="Print the mermaid source.",
194
+ )
195
+ viz_parser.add_argument(
196
+ "--theme",
197
+ choices=sorted(THEMES),
198
+ default="default",
199
+ help="Color theme for the unicode and ascii styles.",
200
+ )
201
+ viz_parser.set_defaults(style="unicode")
202
+
203
+
204
+ def _parse_directory_argument(argument: str) -> Path:
205
+ path = Path(argument)
206
+ if not path.is_dir():
207
+ raise argparse.ArgumentTypeError(f"'{argument}' is not a directory")
208
+ return path
209
+
210
+
211
+ def _parse_duration_argument(argument: str) -> float:
212
+ try:
213
+ return parse_duration(argument)
214
+ except ValueError as error:
215
+ raise argparse.ArgumentTypeError(error) from error
216
+
217
+
218
+ def _parse_cost_argument(argument: str) -> float:
219
+ try:
220
+ return parse_cost(argument)
221
+ except ValueError as error:
222
+ raise argparse.ArgumentTypeError(error) from error
223
+
224
+
225
+ def _run(args: argparse.Namespace) -> int:
226
+ def command_action() -> None:
227
+ run_workflow(args.workflow, load_workflow(args.workflow), args.input, args.directory)
228
+
229
+ return _report_exit_code(command_action)
230
+
231
+
232
+ def _resume(args: argparse.Namespace) -> int:
233
+ def command_action() -> None:
234
+ state = load_state(args.directory)
235
+ resume_run(
236
+ load_workflow(state["workflow"]),
237
+ state,
238
+ args.directory,
239
+ args.decision,
240
+ args.feedback,
241
+ args.add_time,
242
+ args.add_cost,
243
+ )
244
+
245
+ return _report_exit_code(command_action)
246
+
247
+
248
+ def _print_status(args: argparse.Namespace) -> int:
249
+ def command_action() -> None:
250
+ state = read_state(args.directory)
251
+ if state is None:
252
+ raise NothingToResume(f"no run in {args.directory}")
253
+ journal = read_journal(args.directory)
254
+ if is_in_progress(args.directory):
255
+ echo(format_running_line(state, journal))
256
+ return
257
+ if state["node"] == END:
258
+ echo(format_stop_line(state, "end"))
259
+ return
260
+ workflow = load_workflow(state["workflow"])
261
+ last_event = journal[-1] if journal else {}
262
+ stop_reason = last_event["reason"] if last_event.get("event") == "stop" else "interrupted"
263
+ question = workflow["nodes"][state["node"]].get("gate")
264
+ echo(format_stop_line(state, stop_reason, question))
265
+ if "reason" in state:
266
+ print(state["reason"])
267
+ if stop_reason == "gate":
268
+ print(format_review_material(state.get("handoff")))
269
+ print(f"spent time: {state.get('spent_time', 0):.0f} s")
270
+ for limit_kind, limit in compute_time_limits(workflow, state).items():
271
+ print(f"{limit_kind} limit: {limit:g} s")
272
+ cost_limit = compute_cost_limit(workflow, state)
273
+ if cost_limit is not None:
274
+ print(f"spent cost: {state.get('spent_cost', 0):.2f} USD")
275
+ print(f"cost limit: {cost_limit:g} USD")
276
+
277
+ return _report_exit_code(command_action)
278
+
279
+
280
+ def _get_node_lines(args: argparse.Namespace) -> Iterable[Line]:
281
+ if args.follow:
282
+ return follow_node(args.directory, args.node_run, args.raw)
283
+ return show_node(args.directory, args.node_run, args.raw)
284
+
285
+
286
+ def _get_journal_lines(args: argparse.Namespace) -> Iterable[Line]:
287
+ if args.follow or args.until_end:
288
+ return follow_journal(args.directory, args.with_nodes, args.raw, args.until_end)
289
+ return show_journal(args.directory, args.with_nodes, args.raw)
290
+
291
+
292
+ def _show_journal_command(args: argparse.Namespace) -> int:
293
+ if not args.graph:
294
+ return _report_exit_code(lambda: _print_lines(_get_journal_lines(args)))
295
+ if not (args.follow or args.until_end):
296
+ return _report_exit_code(lambda: _print_lines(show_graph(args.directory)))
297
+ if not sys.stdout.isatty():
298
+ print("--graph --follow needs a terminal", file=sys.stderr)
299
+ return 1
300
+ return _report_exit_code(lambda: _print_frames(follow_graph(args.directory, args.until_end)))
301
+
302
+
303
+ def _print_frames(frames: Iterable[list[Text]]) -> None:
304
+ """Redraw each frame in place: cursor home, the lines each cleared to its end, clear below."""
305
+ console = Console()
306
+ sys.stdout.write("\x1b[2J")
307
+ for frame in frames:
308
+ sys.stdout.write("\x1b[H")
309
+ for line in frame:
310
+ console.print(line, soft_wrap=True, end="")
311
+ sys.stdout.write("\x1b[K\n")
312
+ sys.stdout.write("\x1b[J")
313
+ sys.stdout.flush()
314
+
315
+
316
+ def _print_lines(lines: Iterable[Line]) -> None:
317
+ """Print each line as it comes: a str verbatim, a StderrLine verbatim on stderr, a Text through rich."""
318
+ console = Console()
319
+ try:
320
+ for line in lines:
321
+ if isinstance(line, str):
322
+ stream = sys.stderr if isinstance(line, StderrLine) else sys.stdout
323
+ stream.write(line)
324
+ stream.flush()
325
+ else:
326
+ console.print(line, soft_wrap=True)
327
+ except BrokenPipeError:
328
+ # The reader closed the pipe: exit quietly, as rich does for a Text.
329
+ console.on_broken_pipe()
330
+
331
+
332
+ def _report_exit_code(command_action: Callable[[], None]) -> int:
333
+ try:
334
+ command_action()
335
+ except (WorkflowError, RunInProgress, NothingToResume, DecisionError, RecordError) as error:
336
+ print(error, file=sys.stderr)
337
+ return 1
338
+ except NodeFailure as error:
339
+ print(error, file=sys.stderr)
340
+ return 2
341
+ except Escalation as error:
342
+ print(error, file=sys.stderr)
343
+ return 3
344
+ except Park:
345
+ return 4
346
+ except BudgetStop as error:
347
+ print(error, file=sys.stderr)
348
+ return 5
349
+ except KeyboardInterrupt:
350
+ return 130
351
+ return 0
352
+
353
+
354
+ def _print_viz(args: argparse.Namespace) -> int:
355
+ try:
356
+ workflow = load_workflow(args.workflow)
357
+ except WorkflowError as error:
358
+ print(error, file=sys.stderr)
359
+ return 1
360
+ mermaid_source = render_mermaid(workflow)
361
+ if args.style == "mermaid":
362
+ print(mermaid_source)
363
+ return 0
364
+ use_ascii = args.style == "ascii"
365
+ console = Console()
366
+ # Widen node padding as far as the terminal width allows. Only padding_x
367
+ # scales: gap also grows the diagram vertically, and padding_y adds blank
368
+ # rows inside boxes, so both stay minimal to keep the graph short.
369
+ # ponytail: linear search over a handful of re-renders; switch to layout math if graphs get big.
370
+ diagram = render_rich(mermaid_source, use_ascii=use_ascii, theme=args.theme, padding_y=0)
371
+ for padding_x in range(6, 17, 2):
372
+ wider_diagram = render_rich(
373
+ mermaid_source, use_ascii=use_ascii, theme=args.theme, padding_x=padding_x, padding_y=0
374
+ )
375
+ if max(cell_len(line) for line in wider_diagram.plain.splitlines()) > console.width:
376
+ break
377
+ diagram = wider_diagram
378
+ console.print(diagram, soft_wrap=True)
379
+ return 0