pgc-runtime 2.0.0__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.
- pgc_runtime-2.0.0.dist-info/METADATA +181 -0
- pgc_runtime-2.0.0.dist-info/RECORD +28 -0
- pgc_runtime-2.0.0.dist-info/WHEEL +5 -0
- pgc_runtime-2.0.0.dist-info/entry_points.txt +2 -0
- pgc_runtime-2.0.0.dist-info/licenses/LICENSE +67 -0
- pgc_runtime-2.0.0.dist-info/licenses/NOTICE +11 -0
- pgc_runtime-2.0.0.dist-info/top_level.txt +1 -0
- runtime/__init__.py +0 -0
- runtime/api.py +71 -0
- runtime/boot.py +147 -0
- runtime/cli.py +387 -0
- runtime/conformance.py +249 -0
- runtime/ct_errors.py +33 -0
- runtime/ct_execute.py +84 -0
- runtime/ct_executor.py +312 -0
- runtime/dispatcher.py +448 -0
- runtime/evidence.py +245 -0
- runtime/examine/__init__.py +126 -0
- runtime/examine/classifier.py +350 -0
- runtime/examine/cli.py +35 -0
- runtime/examine/hint_engine.py +245 -0
- runtime/examine/locator.py +148 -0
- runtime/examine/parser.py +223 -0
- runtime/examine/reporter.py +110 -0
- runtime/loader.py +360 -0
- runtime/memory.py +125 -0
- runtime/scheduler.py +264 -0
- runtime/trace_viz.py +276 -0
runtime/scheduler.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
scheduler.py — WF-level topology driver for the token-native runtime.
|
|
3
|
+
|
|
4
|
+
Traverses the compiled execution topology for a single workflow invocation.
|
|
5
|
+
Drives CC execution in declared order, resolves WF-level input bindings from
|
|
6
|
+
the ExecutionContext, routes between nodes on result status, and emits
|
|
7
|
+
WF-level trace events.
|
|
8
|
+
|
|
9
|
+
The scheduler is a blind executor:
|
|
10
|
+
- All routing is read from dispatch.routing (compiled by S2/S3)
|
|
11
|
+
- All CC input bindings are read from dispatch.bindings (compiled by S6)
|
|
12
|
+
- Condition resolution uses the vocab (transition:: / outcome:: addresses)
|
|
13
|
+
- No domain logic, no semantic inference, no path construction
|
|
14
|
+
|
|
15
|
+
Topology traversal rules:
|
|
16
|
+
- Entry point is dispatch.entry[wf_addr]["start"]
|
|
17
|
+
- Each CC produces a result_status; that status resolves to a condition address
|
|
18
|
+
- The condition address is looked up in dispatch.routing[wf_addr][cc_addr] → next node
|
|
19
|
+
- Traversal ends when no routing entry exists for the current (cc_addr, condition)
|
|
20
|
+
|
|
21
|
+
Boundary nodes (IN_, EXIT_):
|
|
22
|
+
Nodes without a pipeline entry (not in dispatch.pipeline) are boundary nodes.
|
|
23
|
+
IN_ nodes perform admission gating; prior to admission_snapshot integration,
|
|
24
|
+
they pass through as ACK. The routing table routes ACK forward.
|
|
25
|
+
EXIT_ nodes (no routing) terminate the loop naturally.
|
|
26
|
+
|
|
27
|
+
Bindings path grammar (WF-level, compiler-emitted):
|
|
28
|
+
$.payload.<field> — from the original payload
|
|
29
|
+
$.inputs.<field> — alias for $.payload.<field>
|
|
30
|
+
$.results.<cc_addr>.<field>— from a prior CC's result surface (int cc_addr)
|
|
31
|
+
<literal> — returned as-is
|
|
32
|
+
|
|
33
|
+
Result:
|
|
34
|
+
(result_status, surface) from the last CC executed.
|
|
35
|
+
result_status: the WF terminal outcome string (e.g. "SUCCESS", "VIOLATION")
|
|
36
|
+
surface: the last CC's output dict (for transport/egress use)
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
from runtime.dispatcher import execute_cc
|
|
44
|
+
from runtime.evidence import TraceWriter
|
|
45
|
+
from runtime.loader import RuntimePackage
|
|
46
|
+
from runtime.memory import ExecutionContext
|
|
47
|
+
|
|
48
|
+
# Guard against pathological graphs (cycles, runaway traversal)
|
|
49
|
+
_MAX_HOPS = 64
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Public API
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def _admit(payload: dict, contract: dict) -> str:
|
|
57
|
+
"""Determine admission against the gate's declared input contract — ACK or NACK.
|
|
58
|
+
|
|
59
|
+
Determined from what the IN declares and nothing else: a required field absent, or a declared
|
|
60
|
+
type unsatisfied, is NACK. The workflow routes on that outcome exactly as it routes on any other,
|
|
61
|
+
so a refusal here is carried by the topology rather than raised past it.
|
|
62
|
+
|
|
63
|
+
The IN also carries prose `extensions.admission_rules` ("each element must be a positive
|
|
64
|
+
integer"). Prose determines nothing (MB-1) and is not consulted. Where a gate must enforce more
|
|
65
|
+
than its declared contract, the contract is what needs to say so.
|
|
66
|
+
"""
|
|
67
|
+
_TYPES = {"array": list, "string": str, "integer": int, "number": (int, float),
|
|
68
|
+
"boolean": bool, "object": dict}
|
|
69
|
+
for field, spec in contract.items():
|
|
70
|
+
present = field in payload
|
|
71
|
+
if spec.get("required") and not present:
|
|
72
|
+
return "NACK"
|
|
73
|
+
if not present:
|
|
74
|
+
continue
|
|
75
|
+
expected = _TYPES.get(spec.get("type"))
|
|
76
|
+
if expected is not None and not isinstance(payload[field], expected):
|
|
77
|
+
return "NACK"
|
|
78
|
+
return "ACK"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class UnroutedOutcomeError(RuntimeError):
|
|
82
|
+
"""An outcome with neither declared routing nor a declared ending (EX-5, RT-9)."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_wf(
|
|
86
|
+
wf_fqdn: str,
|
|
87
|
+
payload: dict[str, Any],
|
|
88
|
+
pkg: RuntimePackage,
|
|
89
|
+
writer: TraceWriter,
|
|
90
|
+
data_root: str,
|
|
91
|
+
) -> tuple[str, dict[str, Any]]:
|
|
92
|
+
"""
|
|
93
|
+
Execute a workflow topology and return (result_status, surface).
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
wf_fqdn: Fully-qualified name of the workflow (e.g. "blockchain::WF_...").
|
|
97
|
+
payload: Inbound payload dict (already normalized by transport layer).
|
|
98
|
+
pkg: Frozen RuntimePackage (loader output for this domain).
|
|
99
|
+
writer: TraceWriter for this execution trace.
|
|
100
|
+
data_root: Absolute data directory root for CS path expansion.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
(result_status, surface) where:
|
|
104
|
+
result_status — terminal WF outcome (e.g. "SUCCESS", "VIOLATION")
|
|
105
|
+
surface — last CC output dict (passed to transport egress)
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
KeyError: WF FQDN not in vocab or entry table.
|
|
109
|
+
RuntimeError: Hop limit exceeded (indicates a compiler-emitted cycle).
|
|
110
|
+
"""
|
|
111
|
+
wf_addr = pkg.vocab.addr(wf_fqdn)
|
|
112
|
+
|
|
113
|
+
entry = pkg.dispatch.entry.get(wf_addr)
|
|
114
|
+
if entry is None:
|
|
115
|
+
raise RuntimeError(
|
|
116
|
+
f"No entry point for WF {wf_fqdn!r} (addr {wf_addr}) — "
|
|
117
|
+
f"snapshot may be stale or domain mismatch"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
rb_addr = entry.get("rb", -1) # -1 = no runtime binding (CT-only workflow, no CS to govern)
|
|
121
|
+
current_addr: int | None = entry["start"]
|
|
122
|
+
current_node_key: str = entry.get("start_key", "")
|
|
123
|
+
actor_context = entry.get("actor") # Authority: actor FQDN bound to this WF
|
|
124
|
+
|
|
125
|
+
# Bind the actor into the execution context — genuinely propagated through the run (not merely
|
|
126
|
+
# logged), then attributed in the trace. No authorization is enforced (authority model TBD).
|
|
127
|
+
ctx = ExecutionContext(payload, actor=actor_context)
|
|
128
|
+
writer.wf_start(payload, actor=ctx.actor)
|
|
129
|
+
|
|
130
|
+
result_status = "SUCCESS"
|
|
131
|
+
surface: dict[str, Any] = {}
|
|
132
|
+
hops = 0
|
|
133
|
+
|
|
134
|
+
while current_addr is not None:
|
|
135
|
+
if hops >= _MAX_HOPS:
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
f"WF {wf_fqdn!r} exceeded {_MAX_HOPS} topology hops — "
|
|
138
|
+
f"possible cycle in compiled routing"
|
|
139
|
+
)
|
|
140
|
+
hops += 1
|
|
141
|
+
|
|
142
|
+
if current_addr in pkg.dispatch.pipeline:
|
|
143
|
+
# CC node — resolve WF-level bindings and execute.
|
|
144
|
+
# Bindings are keyed by node_key (not CC addr) so that distinct WF
|
|
145
|
+
# usages of the same CC (e.g. four denial audit nodes) each carry
|
|
146
|
+
# their own literal inputs (e.g. different denial_reason values).
|
|
147
|
+
wf_bindings = (
|
|
148
|
+
pkg.dispatch.bindings
|
|
149
|
+
.get(wf_addr, {})
|
|
150
|
+
.get(current_node_key, {})
|
|
151
|
+
)
|
|
152
|
+
cc_inputs = ctx.resolve_inputs(wf_bindings)
|
|
153
|
+
|
|
154
|
+
result_status, surface = execute_cc(
|
|
155
|
+
current_addr, rb_addr, cc_inputs, pkg, writer, data_root, wf_addr
|
|
156
|
+
)
|
|
157
|
+
ctx.record_result(current_addr, surface)
|
|
158
|
+
|
|
159
|
+
# Observation: a CC outcome that routes to an announcing exit states the moments that
|
|
160
|
+
# act completed, in the order the composition sealed. The order is normative — it is what
|
|
161
|
+
# a reader of the account sees — so the sequence is announced as sealed and never
|
|
162
|
+
# reordered here. A sequence of one is the ordinary case.
|
|
163
|
+
announced = pkg.dispatch.emits.get(wf_addr, {}).get(current_addr, {}).get(result_status)
|
|
164
|
+
if announced:
|
|
165
|
+
# A single moment was sealed as a string before announcements could be plural. Read
|
|
166
|
+
# here rather than refused, so a snapshot built by an older compiler still runs.
|
|
167
|
+
if isinstance(announced, str):
|
|
168
|
+
announced = [announced]
|
|
169
|
+
for ev_fqdn in announced:
|
|
170
|
+
if not ev_fqdn:
|
|
171
|
+
# A moment declared for this transition and absent from what was sealed. The
|
|
172
|
+
# act says so rather than announcing nothing: silence is indistinguishable
|
|
173
|
+
# from a moment nobody declared, which is the failure this exists to end.
|
|
174
|
+
writer.event("", {"unannounceable": True, "transition": result_status})
|
|
175
|
+
continue
|
|
176
|
+
writer.event(ev_fqdn, surface)
|
|
177
|
+
|
|
178
|
+
else:
|
|
179
|
+
# Boundary node — an IN admission gate. EXIT nodes carry no address and are reached as
|
|
180
|
+
# a declared ending rather than traversed, so this branch is IN only.
|
|
181
|
+
#
|
|
182
|
+
# This returned an unconditional "ACK". A declared admission point that determines
|
|
183
|
+
# nothing produces the same outcome as one that permits, which is `1c` AI-6 — the
|
|
184
|
+
# invariant whose breach is least visible, because the system behaves like a governed
|
|
185
|
+
# one until the case arrives that governance would have refused.
|
|
186
|
+
contract = pkg.dispatch.admission.get(current_addr)
|
|
187
|
+
if contract is None:
|
|
188
|
+
writer.error("no admission contract", node=current_addr)
|
|
189
|
+
writer.wf_complete("VIOLATION")
|
|
190
|
+
raise UnroutedOutcomeError(
|
|
191
|
+
f"admission gate {current_addr} declares no input contract — there is nothing "
|
|
192
|
+
f"to determine admission against, and absence is not permission (1c AI-6)."
|
|
193
|
+
)
|
|
194
|
+
result_status = _admit(payload, contract)
|
|
195
|
+
writer.cc_step(current_addr, current_addr, pkg.vocab.fqdn(current_addr),
|
|
196
|
+
"ADMIT", {"outcome": result_status})
|
|
197
|
+
|
|
198
|
+
# Resolve result_status → condition address and route to next node.
|
|
199
|
+
# Routing values are {"addr": int, "key": str} — addr is the next CC address,
|
|
200
|
+
# key is the next node_key for bindings disambiguation.
|
|
201
|
+
previous_addr = current_addr
|
|
202
|
+
condition_addr = _condition_addr(result_status, pkg)
|
|
203
|
+
routing = pkg.dispatch.routing.get(wf_addr, {}).get(current_addr, {})
|
|
204
|
+
next_entry = routing.get(condition_addr)
|
|
205
|
+
|
|
206
|
+
if isinstance(next_entry, dict):
|
|
207
|
+
current_addr = next_entry.get("addr")
|
|
208
|
+
current_node_key = next_entry.get("key", "")
|
|
209
|
+
elif next_entry is not None:
|
|
210
|
+
current_addr = next_entry # bare int (legacy)
|
|
211
|
+
current_node_key = ""
|
|
212
|
+
else:
|
|
213
|
+
# No continuation. Two cases that were one, and reporting success for both is what
|
|
214
|
+
# `3a` EX-5 and `3c` RT-9 forbid: an outcome the declarations do not answer for MUST
|
|
215
|
+
# refuse, and ending the traversal instead made a dead end indistinguishable from a
|
|
216
|
+
# declared ending. Termination is now declared (`dispatch.terminal`); its absence is
|
|
217
|
+
# the dead end.
|
|
218
|
+
ending = pkg.dispatch.terminal.get(wf_addr, {}).get(current_addr, {}).get(condition_addr)
|
|
219
|
+
if ending is None:
|
|
220
|
+
writer.error(
|
|
221
|
+
"unrouted outcome",
|
|
222
|
+
node=current_addr, outcome=result_status, condition_addr=condition_addr,
|
|
223
|
+
)
|
|
224
|
+
writer.route(from_addr=current_addr, condition=result_status, to_addr=None)
|
|
225
|
+
writer.wf_complete("VIOLATION")
|
|
226
|
+
raise UnroutedOutcomeError(
|
|
227
|
+
f"outcome {result_status!r} from node {current_addr} has neither declared "
|
|
228
|
+
f"routing nor a declared ending in this workflow — the declarations do not "
|
|
229
|
+
f"answer for it (3a EX-5, 3c RT-9)."
|
|
230
|
+
)
|
|
231
|
+
current_addr = None
|
|
232
|
+
current_node_key = ""
|
|
233
|
+
declared_ending = ending.get("exit", "")
|
|
234
|
+
|
|
235
|
+
# Evidence the routing determination, not only its effect (`3e` §3.1 point 4).
|
|
236
|
+
writer.route(from_addr=previous_addr, condition=result_status, to_addr=current_addr)
|
|
237
|
+
|
|
238
|
+
writer.wf_complete(result_status)
|
|
239
|
+
return result_status, surface
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# Internal helpers
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def _condition_addr(result_status: str, pkg: RuntimePackage) -> int:
|
|
247
|
+
"""
|
|
248
|
+
Resolve a result_status string to its transition address integer.
|
|
249
|
+
|
|
250
|
+
Lookup order:
|
|
251
|
+
1. transition::<result_status> (primary — WF routing namespace)
|
|
252
|
+
2. outcome::<result_status> (fallback — CC outcome namespace)
|
|
253
|
+
|
|
254
|
+
Returns -1 if the status has no registered address (no routing will match).
|
|
255
|
+
"""
|
|
256
|
+
try:
|
|
257
|
+
return pkg.vocab.addr(f"transition::{result_status}")
|
|
258
|
+
except KeyError:
|
|
259
|
+
pass
|
|
260
|
+
try:
|
|
261
|
+
return pkg.vocab.addr(f"outcome::{result_status}")
|
|
262
|
+
except KeyError:
|
|
263
|
+
pass
|
|
264
|
+
return -1
|
runtime/trace_viz.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
trace_viz.py — Evidence projection: execution path behavior logic.
|
|
3
|
+
|
|
4
|
+
Reads a completed trace (.jsonl) and the compiled workflow graph (.graph.json)
|
|
5
|
+
from the protocol snapshot behavior_logic 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/behavior_logic/<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/behavior_logic/ (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. Behavior logic overlay is read-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/behavior_logic/
|
|
88
|
+
graph_path = (
|
|
89
|
+
workspace
|
|
90
|
+
/ "protocol_snapshot"
|
|
91
|
+
/ "behavior_logic"
|
|
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 behavior_logic 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
|
+
# Filter to top-level workflow CC events only.
|
|
166
|
+
# Sub-workflows emit CC_COMPLETE events with the same wf_addr as the
|
|
167
|
+
# top-level workflow (the runtime reuses the same address), so wf_addr
|
|
168
|
+
# filtering is insufficient. Instead, restrict to CCs that are declared
|
|
169
|
+
# nodes in the top-level graph — sub-workflow CCs won't appear there.
|
|
170
|
+
graph_cc_nodes: set[str] = {
|
|
171
|
+
n["id"] for n in graph["nodes"] if n["type"] == "CC"
|
|
172
|
+
}
|
|
173
|
+
cc_completions = [
|
|
174
|
+
e for e in events
|
|
175
|
+
if e["event_type"] == "CC_COMPLETE"
|
|
176
|
+
and e["detail"]["cc_fqdn"].split("::")[-1] in graph_cc_nodes
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
if not cc_completions:
|
|
180
|
+
# No CC nodes executed — IN_ gated as NACK and routed to EXIT
|
|
181
|
+
to_node = edge_map.get((entry_node, "NACK"), "EXIT")
|
|
182
|
+
return [(entry_node, "NACK", to_node)]
|
|
183
|
+
|
|
184
|
+
path: list[tuple[str, str, str]] = []
|
|
185
|
+
|
|
186
|
+
# IN_ → first CC: always ACK (admission passes through in current runtime)
|
|
187
|
+
first_cc_code = cc_completions[0]["detail"]["cc_fqdn"].split("::")[-1]
|
|
188
|
+
path.append((entry_node, "ACK", first_cc_code))
|
|
189
|
+
|
|
190
|
+
# CC → CC (or EXIT) — follow result_status routing
|
|
191
|
+
for cc_event in cc_completions:
|
|
192
|
+
cc_code = cc_event["detail"]["cc_fqdn"].split("::")[-1]
|
|
193
|
+
result_status = cc_event["result_status"]
|
|
194
|
+
to_node = edge_map.get((cc_code, result_status))
|
|
195
|
+
if to_node is None:
|
|
196
|
+
break # no further routing — terminal
|
|
197
|
+
path.append((cc_code, result_status, to_node))
|
|
198
|
+
|
|
199
|
+
return path
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# DOT generation
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def _generate_dot(
|
|
207
|
+
graph: dict,
|
|
208
|
+
visited_nodes: set[str],
|
|
209
|
+
taken_edges: set[tuple[str, str, str]],
|
|
210
|
+
) -> str:
|
|
211
|
+
"""
|
|
212
|
+
Generate Graphviz DOT with actual execution path highlighted in red.
|
|
213
|
+
|
|
214
|
+
Visited nodes: red border + solid red fill.
|
|
215
|
+
Taken edges: red, bold, red label.
|
|
216
|
+
Unvisited: standard fill, grey border, grey edges.
|
|
217
|
+
"""
|
|
218
|
+
lines = [
|
|
219
|
+
f'digraph "{graph["wf_id"]}" {{',
|
|
220
|
+
" rankdir=LR;",
|
|
221
|
+
' node [fontname="Arial"];',
|
|
222
|
+
"",
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
# --- Nodes ---
|
|
226
|
+
for node in graph["nodes"]:
|
|
227
|
+
node_id = node["id"]
|
|
228
|
+
node_type = node["type"]
|
|
229
|
+
visited = node_id in visited_nodes
|
|
230
|
+
|
|
231
|
+
if node_type == "IN":
|
|
232
|
+
fill = "tomato" if visited else "lightblue"
|
|
233
|
+
shape = "ellipse"
|
|
234
|
+
elif node_type == "CC":
|
|
235
|
+
fill = "tomato" if visited else "lightgreen"
|
|
236
|
+
shape = "box"
|
|
237
|
+
elif node_type == "EXIT":
|
|
238
|
+
fill = "tomato" if visited else "lightcoral"
|
|
239
|
+
shape = "ellipse"
|
|
240
|
+
else:
|
|
241
|
+
fill = "white"
|
|
242
|
+
shape = "box"
|
|
243
|
+
|
|
244
|
+
if visited:
|
|
245
|
+
lines.append(
|
|
246
|
+
f' "{node_id}" [label="{node_id}", shape={shape},'
|
|
247
|
+
f" style=filled, fillcolor={fill}, color=red, penwidth=2.5];"
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
lines.append(
|
|
251
|
+
f' "{node_id}" [label="{node_id}", shape={shape},'
|
|
252
|
+
f" style=filled, fillcolor={fill}, color=gray];"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
lines.append("")
|
|
256
|
+
|
|
257
|
+
# --- Edges ---
|
|
258
|
+
for edge in graph["edges"]:
|
|
259
|
+
from_id = edge["from"]
|
|
260
|
+
to_id = edge["to"]
|
|
261
|
+
condition = edge["condition"]
|
|
262
|
+
taken = (from_id, to_id, condition) in taken_edges
|
|
263
|
+
|
|
264
|
+
if taken:
|
|
265
|
+
lines.append(
|
|
266
|
+
f' "{from_id}" -> "{to_id}"'
|
|
267
|
+
f' [label="{condition}", color=red, penwidth=2.5, fontcolor=red];'
|
|
268
|
+
)
|
|
269
|
+
else:
|
|
270
|
+
lines.append(
|
|
271
|
+
f' "{from_id}" -> "{to_id}"'
|
|
272
|
+
f' [label="{condition}", color=gray, fontcolor=gray];'
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
lines.append("}")
|
|
276
|
+
return "\n".join(lines)
|