pgs-runtime 0.3.1__tar.gz → 0.4.0__tar.gz
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.
- {pgs_runtime-0.3.1/pgs_runtime.egg-info → pgs_runtime-0.4.0}/PKG-INFO +1 -1
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/cli.py +73 -3
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/dispatcher.py +1 -3
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/evidence.py +3 -3
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/scheduler.py +3 -3
- pgs_runtime-0.4.0/pgs_runtime/trace_viz.py +264 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0/pgs_runtime.egg-info}/PKG-INFO +1 -1
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime.egg-info/SOURCES.txt +1 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pyproject.toml +1 -1
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/LICENSE +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/MANIFEST.in +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/NOTICE +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/README.md +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/__init__.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/conformance.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/ct_errors.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/ct_execute.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/ct_executor.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/examine/__init__.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/examine/classifier.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/examine/cli.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/examine/conformance_test.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/examine/hint_engine.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/examine/locator.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/examine/parser.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/examine/reporter.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/loader.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/memory.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime/server.py +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime.egg-info/dependency_links.txt +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime.egg-info/entry_points.txt +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/pgs_runtime.egg-info/top_level.txt +0 -0
- {pgs_runtime-0.3.1 → pgs_runtime-0.4.0}/setup.cfg +0 -0
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
cli.py — Token-native CLI entry point for the pgs_runtime.
|
|
3
3
|
|
|
4
4
|
Commands:
|
|
5
|
-
run
|
|
6
|
-
examine
|
|
5
|
+
run — Execute a workflow against the tokenized snapshot.
|
|
6
|
+
examine — Analyze a completed trace file and print a diagnostic report.
|
|
7
|
+
visualize — Render execution-path PNG from a completed trace file.
|
|
7
8
|
|
|
8
9
|
Execution path (run):
|
|
9
10
|
1. Load tokenized snapshot for the domain via loader.load_domain()
|
|
10
11
|
— verifies topology hash against trust attestation; fails hard on mismatch
|
|
11
12
|
2. Generate deterministic trace ID from (domain, wf_fqdn, payload)
|
|
12
|
-
3. Open TraceWriter at traces/<domain>/<trace_id>/
|
|
13
|
+
3. Open TraceWriter at traces/<domain>/<wf_code>/<trace_id>/
|
|
13
14
|
4. Drive workflow topology via scheduler.run_wf()
|
|
14
15
|
5. Print result summary; exit 1 on non-SUCCESS
|
|
16
|
+
6. If --visualize: invoke evidence projection (trace_viz) to render PNG
|
|
15
17
|
|
|
16
18
|
All runtime behavior comes from the compiled tokenized_snapshot.
|
|
17
19
|
The CLI does not implement any domain logic.
|
|
@@ -69,6 +71,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
69
71
|
"tokenized_snapshot/ and traces/ live here",
|
|
70
72
|
)
|
|
71
73
|
|
|
74
|
+
# ── run: optional visualization flag ─────────────────────────
|
|
75
|
+
run_p.add_argument(
|
|
76
|
+
"--visualize",
|
|
77
|
+
action="store_true",
|
|
78
|
+
help="Render execution-path PNG after run (requires graphviz)",
|
|
79
|
+
)
|
|
80
|
+
|
|
72
81
|
# ── examine ───────────────────────────────────────────────────
|
|
73
82
|
ex_p = subs.add_parser("examine", help="Analyze a completed trace file")
|
|
74
83
|
ex_p.add_argument(
|
|
@@ -77,6 +86,22 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
77
86
|
help="Path to a completed .jsonl trace file",
|
|
78
87
|
)
|
|
79
88
|
|
|
89
|
+
# ── visualize ─────────────────────────────────────────────────
|
|
90
|
+
viz_p = subs.add_parser(
|
|
91
|
+
"visualize",
|
|
92
|
+
help="Render execution-path PNG from a completed trace file",
|
|
93
|
+
)
|
|
94
|
+
viz_p.add_argument(
|
|
95
|
+
"trace_file",
|
|
96
|
+
metavar="FILE",
|
|
97
|
+
help="Path to a completed .jsonl trace file",
|
|
98
|
+
)
|
|
99
|
+
viz_p.add_argument(
|
|
100
|
+
"--workspace",
|
|
101
|
+
metavar="PATH",
|
|
102
|
+
help="Absolute path to pgs_workspace root (or set PGS_WORKSPACE)",
|
|
103
|
+
)
|
|
104
|
+
|
|
80
105
|
return parser
|
|
81
106
|
|
|
82
107
|
|
|
@@ -161,6 +186,12 @@ def _handle_run(args: argparse.Namespace) -> None:
|
|
|
161
186
|
|
|
162
187
|
duration_ms = int((time.monotonic() - t0) * 1000)
|
|
163
188
|
|
|
189
|
+
# Evidence projection: render execution-path PNG if requested
|
|
190
|
+
trace_path = trace_dir / f"{trace_id}.jsonl"
|
|
191
|
+
png_path = None
|
|
192
|
+
if args.visualize:
|
|
193
|
+
png_path = _render_visualization(workspace, trace_path)
|
|
194
|
+
|
|
164
195
|
print("=" * 60)
|
|
165
196
|
print("[pgs_runtime] Workflow Complete")
|
|
166
197
|
print("=" * 60)
|
|
@@ -170,12 +201,39 @@ def _handle_run(args: argparse.Namespace) -> None:
|
|
|
170
201
|
print(f"Duration: {duration_ms}ms")
|
|
171
202
|
if surface:
|
|
172
203
|
print(f"Output: {json.dumps(surface, separators=(',', ':'))}")
|
|
204
|
+
if png_path:
|
|
205
|
+
print(f"Visual: {png_path}")
|
|
206
|
+
elif args.visualize:
|
|
207
|
+
print("Visual: (graphviz not available — PNG skipped)")
|
|
173
208
|
print("=" * 60)
|
|
174
209
|
|
|
175
210
|
if result_status not in ("SUCCESS", "ALREADY_EXISTS"):
|
|
176
211
|
sys.exit(1)
|
|
177
212
|
|
|
178
213
|
|
|
214
|
+
def _handle_visualize(args: argparse.Namespace) -> None:
|
|
215
|
+
trace_path = Path(args.trace_file)
|
|
216
|
+
if not trace_path.exists():
|
|
217
|
+
_fatal(f"Trace file not found: {args.trace_file}")
|
|
218
|
+
|
|
219
|
+
workspace_str = args.workspace or os.environ.get("PGS_WORKSPACE")
|
|
220
|
+
if not workspace_str:
|
|
221
|
+
_fatal("--workspace PATH or PGS_WORKSPACE is required")
|
|
222
|
+
workspace = Path(workspace_str)
|
|
223
|
+
if not workspace.is_absolute():
|
|
224
|
+
_fatal(f"--workspace must be an absolute path, got: {workspace_str}")
|
|
225
|
+
|
|
226
|
+
png_path = _render_visualization(workspace, trace_path)
|
|
227
|
+
if png_path:
|
|
228
|
+
print(f"[pgs_runtime] Execution path PNG: {png_path}")
|
|
229
|
+
else:
|
|
230
|
+
print(
|
|
231
|
+
"[pgs_runtime] Visualization skipped — graphviz (dot) not available.",
|
|
232
|
+
file=sys.stderr,
|
|
233
|
+
)
|
|
234
|
+
sys.exit(1)
|
|
235
|
+
|
|
236
|
+
|
|
179
237
|
def _handle_examine(args: argparse.Namespace) -> None:
|
|
180
238
|
trace_path = Path(args.trace_file)
|
|
181
239
|
if not trace_path.exists():
|
|
@@ -205,6 +263,16 @@ def _handle_examine(args: argparse.Namespace) -> None:
|
|
|
205
263
|
# Utilities
|
|
206
264
|
# ---------------------------------------------------------------------------
|
|
207
265
|
|
|
266
|
+
def _render_visualization(workspace: Path, trace_path: Path) -> "Path | None":
|
|
267
|
+
"""Invoke evidence projection to render execution-path PNG. Best-effort."""
|
|
268
|
+
from pgs_runtime.trace_viz import render_trace_png
|
|
269
|
+
try:
|
|
270
|
+
return render_trace_png(workspace, trace_path)
|
|
271
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
272
|
+
print(f"[pgs_runtime] Visualization error: {exc}", file=sys.stderr)
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
208
276
|
def _load_payload(payload_path: str | None) -> dict:
|
|
209
277
|
if not payload_path:
|
|
210
278
|
return {}
|
|
@@ -234,6 +302,8 @@ def main() -> None:
|
|
|
234
302
|
_handle_run(args)
|
|
235
303
|
elif args.command == "examine":
|
|
236
304
|
_handle_examine(args)
|
|
305
|
+
elif args.command == "visualize":
|
|
306
|
+
_handle_visualize(args)
|
|
237
307
|
|
|
238
308
|
|
|
239
309
|
if __name__ == "__main__":
|
|
@@ -202,9 +202,7 @@ def _execute_cs_step(
|
|
|
202
202
|
# Resolve per-RB policy config for this CS
|
|
203
203
|
rb_cs_map = pkg.handlers.rb_policy.get(rb_addr, {})
|
|
204
204
|
policy_entry = rb_cs_map.get(cs_addr, {})
|
|
205
|
-
|
|
206
|
-
# the structure; accept both to remain forward/backward compatible.
|
|
207
|
-
policy_raw = policy_entry.get("policy") or policy_entry.get("config") or {}
|
|
205
|
+
policy_raw = policy_entry.get("policy") or {}
|
|
208
206
|
policy = _expand_policy(policy_raw, data_root)
|
|
209
207
|
|
|
210
208
|
# Instantiate CS runtime
|
|
@@ -15,12 +15,12 @@ Each execution event is a self-contained JSON line with:
|
|
|
15
15
|
- ts_ns — monotonic nanosecond timestamp
|
|
16
16
|
|
|
17
17
|
The trace file is written to:
|
|
18
|
-
<traces_root>/<domain>/<
|
|
18
|
+
<traces_root>/<domain>/<wf_code>/<trace_id>/<trace_id>.jsonl
|
|
19
19
|
|
|
20
20
|
subdomain is not known to this module — caller passes the full trace dir path.
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
This module produces the raw JSONL evidence only. Evidence projection
|
|
23
|
+
(execution-path PNG overlay) is handled separately in trace_viz.py.
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
from __future__ import annotations
|
|
@@ -20,8 +20,8 @@ Topology traversal rules:
|
|
|
20
20
|
|
|
21
21
|
Boundary nodes (IN_, EXIT_):
|
|
22
22
|
Nodes without a pipeline entry (not in dispatch.pipeline) are boundary nodes.
|
|
23
|
-
IN_ nodes perform admission gating;
|
|
24
|
-
|
|
23
|
+
IN_ nodes perform admission gating; prior to admission_snapshot integration,
|
|
24
|
+
they pass through as ACK. The routing table routes ACK forward.
|
|
25
25
|
EXIT_ nodes (no routing) terminate the loop naturally.
|
|
26
26
|
|
|
27
27
|
Bindings path grammar (WF-level, compiler-emitted):
|
|
@@ -122,7 +122,7 @@ def run_wf(
|
|
|
122
122
|
|
|
123
123
|
else:
|
|
124
124
|
# Boundary node (IN_, EXIT_) — no pipeline
|
|
125
|
-
#
|
|
125
|
+
# admission_snapshot not yet integrated; IN_ nodes pass as ACK
|
|
126
126
|
result_status = "ACK"
|
|
127
127
|
|
|
128
128
|
# Resolve result_status → condition address and route to next node
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
trace_viz.py — Evidence projection: execution path visualization.
|
|
3
|
+
|
|
4
|
+
Reads a completed trace (.jsonl) and the compiled workflow graph (.graph.json)
|
|
5
|
+
from the protocol snapshot visualization directory, then generates a PNG with
|
|
6
|
+
the actual execution path overlaid in red on the static compiled graph.
|
|
7
|
+
|
|
8
|
+
This is Evidence Projection — not a runtime execution feature:
|
|
9
|
+
|
|
10
|
+
topology (graph.json)
|
|
11
|
+
+
|
|
12
|
+
evidence (trace.jsonl)
|
|
13
|
+
───────────────────────
|
|
14
|
+
→ execution path PNG
|
|
15
|
+
|
|
16
|
+
Inputs (both already materialized, read-only):
|
|
17
|
+
protocol_snapshot/visualization/<WF_CODE>/<WF_CODE>.graph.json
|
|
18
|
+
traces/<domain>/<wf_code>/<trace_id>/<trace_id>.jsonl
|
|
19
|
+
|
|
20
|
+
Output:
|
|
21
|
+
traces/<domain>/<wf_code>/<trace_id>/<trace_id>.png
|
|
22
|
+
|
|
23
|
+
Architectural invariant:
|
|
24
|
+
This module reads ONLY from:
|
|
25
|
+
- protocol_snapshot/visualization/ (compiled graph artifacts)
|
|
26
|
+
- the caller-supplied trace .jsonl (execution evidence)
|
|
27
|
+
It does NOT walk protocol_snapshot/artifacts/ or any other canonical
|
|
28
|
+
protocol location. Visualization is overlay-only — no protocol interpretation.
|
|
29
|
+
|
|
30
|
+
Uses graphviz (dot) — returns None silently if dot is not available.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import subprocess
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Optional
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Public API
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def render_trace_png(
|
|
46
|
+
workspace: Path,
|
|
47
|
+
trace_path: Path,
|
|
48
|
+
) -> Optional[Path]:
|
|
49
|
+
"""
|
|
50
|
+
Generate execution-path overlay PNG for a completed trace.
|
|
51
|
+
|
|
52
|
+
Reads CC_COMPLETE events from trace_path to reconstruct the actual
|
|
53
|
+
execution path, then overlays it (red) on the compiled workflow graph.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
workspace: Absolute path to pgs_workspace root.
|
|
57
|
+
trace_path: Path to the completed .jsonl trace file.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Path to the generated PNG, or None if graphviz is unavailable.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
FileNotFoundError: trace_path or graph.json does not exist.
|
|
64
|
+
ValueError: Trace is empty or missing WF_START event.
|
|
65
|
+
"""
|
|
66
|
+
if not trace_path.exists():
|
|
67
|
+
raise FileNotFoundError(f"Trace file not found: {trace_path}")
|
|
68
|
+
|
|
69
|
+
# Parse trace events
|
|
70
|
+
events: list[dict] = []
|
|
71
|
+
for line in trace_path.read_text(encoding="utf-8").splitlines():
|
|
72
|
+
line = line.strip()
|
|
73
|
+
if line:
|
|
74
|
+
events.append(json.loads(line))
|
|
75
|
+
|
|
76
|
+
if not events:
|
|
77
|
+
raise ValueError(f"Trace file is empty: {trace_path}")
|
|
78
|
+
|
|
79
|
+
# Extract WF code from WF_START event
|
|
80
|
+
wf_start = next((e for e in events if e["event_type"] == "WF_START"), None)
|
|
81
|
+
if wf_start is None:
|
|
82
|
+
raise ValueError(f"No WF_START event found in: {trace_path}")
|
|
83
|
+
|
|
84
|
+
wf_fqdn = wf_start["detail"]["wf_fqdn"]
|
|
85
|
+
wf_code = wf_fqdn.split("::")[-1] # e.g. "WF_REGISTER_ACTOR_UNVERIFIED_V0"
|
|
86
|
+
|
|
87
|
+
# Load compiled graph from protocol_snapshot/visualization/
|
|
88
|
+
graph_path = (
|
|
89
|
+
workspace
|
|
90
|
+
/ "protocol_snapshot"
|
|
91
|
+
/ "visualization"
|
|
92
|
+
/ wf_code
|
|
93
|
+
/ f"{wf_code}.graph.json"
|
|
94
|
+
)
|
|
95
|
+
if not graph_path.exists():
|
|
96
|
+
raise FileNotFoundError(
|
|
97
|
+
f"Compiled graph not found: {graph_path}\n"
|
|
98
|
+
f"Re-run the compiler to regenerate visualization artifacts."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
graph = json.loads(graph_path.read_text(encoding="utf-8"))
|
|
102
|
+
|
|
103
|
+
# Reconstruct actual execution path from trace events + graph edges
|
|
104
|
+
path = _extract_execution_path(events, graph)
|
|
105
|
+
|
|
106
|
+
# Build visited node and taken edge sets
|
|
107
|
+
visited_nodes: set[str] = set()
|
|
108
|
+
taken_edges: set[tuple[str, str, str]] = set() # (from_node, to_node, condition)
|
|
109
|
+
for from_node, condition, to_node in path:
|
|
110
|
+
visited_nodes.add(from_node)
|
|
111
|
+
visited_nodes.add(to_node)
|
|
112
|
+
taken_edges.add((from_node, to_node, condition))
|
|
113
|
+
|
|
114
|
+
# Generate DOT source with execution-path overlay
|
|
115
|
+
dot_content = _generate_dot(graph, visited_nodes, taken_edges)
|
|
116
|
+
|
|
117
|
+
# Render: write DOT, invoke graphviz, clean up DOT
|
|
118
|
+
png_path = trace_path.with_suffix(".png")
|
|
119
|
+
dot_path = trace_path.with_suffix(".dot")
|
|
120
|
+
dot_path.write_text(dot_content, encoding="utf-8")
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
subprocess.run(
|
|
124
|
+
["dot", "-Tpng", str(dot_path), "-o", str(png_path)],
|
|
125
|
+
check=True,
|
|
126
|
+
capture_output=True,
|
|
127
|
+
)
|
|
128
|
+
dot_path.unlink()
|
|
129
|
+
return png_path
|
|
130
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
131
|
+
dot_path.unlink(missing_ok=True)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Path reconstruction
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def _extract_execution_path(
|
|
140
|
+
events: list[dict],
|
|
141
|
+
graph: dict,
|
|
142
|
+
) -> list[tuple[str, str, str]]:
|
|
143
|
+
"""
|
|
144
|
+
Reconstruct [(from_node, condition, to_node), ...] from trace events.
|
|
145
|
+
|
|
146
|
+
Uses CC_COMPLETE events (in emission order) and graph edges to walk
|
|
147
|
+
the actual execution path. IN_ boundary nodes always yield ACK in
|
|
148
|
+
the current runtime (admission_snapshot not yet integrated).
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
events: Parsed JSONL trace events.
|
|
152
|
+
graph: Compiled graph dict (from graph.json).
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Ordered list of (from_node_id, condition, to_node_id) tuples.
|
|
156
|
+
"""
|
|
157
|
+
entry_node: str = graph["entry"] # e.g. "IN_ACTOR_REGISTERED_V0"
|
|
158
|
+
|
|
159
|
+
# (from_node, condition) → to_node
|
|
160
|
+
edge_map: dict[tuple[str, str], str] = {
|
|
161
|
+
(e["from"], e["condition"]): e["to"]
|
|
162
|
+
for e in graph["edges"]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
cc_completions = [e for e in events if e["event_type"] == "CC_COMPLETE"]
|
|
166
|
+
|
|
167
|
+
if not cc_completions:
|
|
168
|
+
# No CC nodes executed — IN_ gated as NACK and routed to EXIT
|
|
169
|
+
to_node = edge_map.get((entry_node, "NACK"), "EXIT")
|
|
170
|
+
return [(entry_node, "NACK", to_node)]
|
|
171
|
+
|
|
172
|
+
path: list[tuple[str, str, str]] = []
|
|
173
|
+
|
|
174
|
+
# IN_ → first CC: always ACK (admission passes through in current runtime)
|
|
175
|
+
first_cc_code = cc_completions[0]["detail"]["cc_fqdn"].split("::")[-1]
|
|
176
|
+
path.append((entry_node, "ACK", first_cc_code))
|
|
177
|
+
|
|
178
|
+
# CC → CC (or EXIT) — follow result_status routing
|
|
179
|
+
for cc_event in cc_completions:
|
|
180
|
+
cc_code = cc_event["detail"]["cc_fqdn"].split("::")[-1]
|
|
181
|
+
result_status = cc_event["result_status"]
|
|
182
|
+
to_node = edge_map.get((cc_code, result_status))
|
|
183
|
+
if to_node is None:
|
|
184
|
+
break # no further routing — terminal
|
|
185
|
+
path.append((cc_code, result_status, to_node))
|
|
186
|
+
|
|
187
|
+
return path
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# DOT generation
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def _generate_dot(
|
|
195
|
+
graph: dict,
|
|
196
|
+
visited_nodes: set[str],
|
|
197
|
+
taken_edges: set[tuple[str, str, str]],
|
|
198
|
+
) -> str:
|
|
199
|
+
"""
|
|
200
|
+
Generate Graphviz DOT with actual execution path highlighted in red.
|
|
201
|
+
|
|
202
|
+
Visited nodes: red border + solid red fill.
|
|
203
|
+
Taken edges: red, bold, red label.
|
|
204
|
+
Unvisited: standard fill, grey border, grey edges.
|
|
205
|
+
"""
|
|
206
|
+
lines = [
|
|
207
|
+
f'digraph "{graph["wf_id"]}" {{',
|
|
208
|
+
" rankdir=LR;",
|
|
209
|
+
' node [fontname="Arial"];',
|
|
210
|
+
"",
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
# --- Nodes ---
|
|
214
|
+
for node in graph["nodes"]:
|
|
215
|
+
node_id = node["id"]
|
|
216
|
+
node_type = node["type"]
|
|
217
|
+
visited = node_id in visited_nodes
|
|
218
|
+
|
|
219
|
+
if node_type == "IN":
|
|
220
|
+
fill = "tomato" if visited else "lightblue"
|
|
221
|
+
shape = "ellipse"
|
|
222
|
+
elif node_type == "CC":
|
|
223
|
+
fill = "tomato" if visited else "lightgreen"
|
|
224
|
+
shape = "box"
|
|
225
|
+
elif node_type == "EXIT":
|
|
226
|
+
fill = "tomato" if visited else "lightcoral"
|
|
227
|
+
shape = "ellipse"
|
|
228
|
+
else:
|
|
229
|
+
fill = "white"
|
|
230
|
+
shape = "box"
|
|
231
|
+
|
|
232
|
+
if visited:
|
|
233
|
+
lines.append(
|
|
234
|
+
f' "{node_id}" [label="{node_id}", shape={shape},'
|
|
235
|
+
f" style=filled, fillcolor={fill}, color=red, penwidth=2.5];"
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
lines.append(
|
|
239
|
+
f' "{node_id}" [label="{node_id}", shape={shape},'
|
|
240
|
+
f" style=filled, fillcolor={fill}, color=gray];"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
lines.append("")
|
|
244
|
+
|
|
245
|
+
# --- Edges ---
|
|
246
|
+
for edge in graph["edges"]:
|
|
247
|
+
from_id = edge["from"]
|
|
248
|
+
to_id = edge["to"]
|
|
249
|
+
condition = edge["condition"]
|
|
250
|
+
taken = (from_id, to_id, condition) in taken_edges
|
|
251
|
+
|
|
252
|
+
if taken:
|
|
253
|
+
lines.append(
|
|
254
|
+
f' "{from_id}" -> "{to_id}"'
|
|
255
|
+
f' [label="{condition}", color=red, penwidth=2.5, fontcolor=red];'
|
|
256
|
+
)
|
|
257
|
+
else:
|
|
258
|
+
lines.append(
|
|
259
|
+
f' "{from_id}" -> "{to_id}"'
|
|
260
|
+
f' [label="{condition}", color=gray, fontcolor=gray];'
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
lines.append("}")
|
|
264
|
+
return "\n".join(lines)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|