loopgraph 0.2.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.
@@ -0,0 +1,443 @@
1
+ """Runtime execution state helpers for LoopGraph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, Optional, Set
7
+
8
+ from .._debug import (
9
+ log_branch,
10
+ log_loop_iteration,
11
+ log_parameter,
12
+ log_variable_change,
13
+ )
14
+ from .graph import Graph
15
+ from .types import NodeKind, NodeStatus, VisitOutcome
16
+
17
+
18
+ @dataclass
19
+ class NodeVisit:
20
+ """Track visit metadata for a node.
21
+
22
+ >>> visit = NodeVisit()
23
+ >>> visit.increment("evt-1").count
24
+ 1
25
+ """
26
+
27
+ count: int = 0
28
+ last_event_id: Optional[str] = None
29
+
30
+ def increment(self, event_id: Optional[str]) -> "NodeVisit":
31
+ """Return a new `NodeVisit` with an incremented count."""
32
+ func_name = "NodeVisit.increment"
33
+ log_parameter(func_name, event_id=event_id)
34
+ new_visit = NodeVisit(count=self.count + 1, last_event_id=event_id)
35
+ log_variable_change(func_name, "new_visit", new_visit)
36
+ return new_visit
37
+
38
+
39
+ @dataclass
40
+ class NodeRuntimeState:
41
+ """Mutable execution state for a single node."""
42
+
43
+ status: NodeStatus = NodeStatus.PENDING
44
+ outcome: Optional[VisitOutcome] = None
45
+ visits: NodeVisit = field(default_factory=NodeVisit)
46
+ upstream_completed: Set[str] = field(default_factory=set)
47
+ last_payload: Optional[Any] = None
48
+
49
+ def to_dict(self) -> Dict[str, Any]:
50
+ """Serialize the runtime state to a dictionary payload.
51
+
52
+ >>> state = NodeRuntimeState()
53
+ >>> state.status = NodeStatus.RUNNING
54
+ >>> payload = state.to_dict()
55
+ >>> payload["status"]
56
+ 'running'
57
+ """
58
+ func_name = "NodeRuntimeState.to_dict"
59
+ log_parameter(func_name)
60
+ payload = {
61
+ "status": self.status.value,
62
+ "outcome": None
63
+ if self.outcome is None
64
+ else {
65
+ "status": self.outcome.status.value,
66
+ "detail": self.outcome.detail,
67
+ "payload": self.outcome.payload,
68
+ },
69
+ "visits": {
70
+ "count": self.visits.count,
71
+ "last_event_id": self.visits.last_event_id,
72
+ },
73
+ "upstream_completed": sorted(self.upstream_completed),
74
+ "last_payload": self.last_payload,
75
+ }
76
+ log_variable_change(func_name, "payload", payload)
77
+ return payload
78
+
79
+ @classmethod
80
+ def from_dict(cls, payload: Dict[str, Any]) -> "NodeRuntimeState":
81
+ """Restore runtime state from a dictionary payload.
82
+
83
+ >>> original = NodeRuntimeState(status=NodeStatus.COMPLETED)
84
+ >>> restored = NodeRuntimeState.from_dict(original.to_dict())
85
+ >>> restored.status
86
+ <NodeStatus.COMPLETED: 'completed'>
87
+ """
88
+ func_name = "NodeRuntimeState.from_dict"
89
+ log_parameter(func_name, payload=payload)
90
+ status = NodeStatus(payload["status"])
91
+ log_variable_change(func_name, "status", status)
92
+ outcome_payload = payload.get("outcome")
93
+ if outcome_payload is None:
94
+ log_branch(func_name, "no_outcome")
95
+ outcome = None
96
+ else:
97
+ log_branch(func_name, "with_outcome")
98
+ outcome = VisitOutcome(
99
+ status=NodeStatus(outcome_payload["status"]),
100
+ detail=outcome_payload.get("detail"),
101
+ payload=outcome_payload.get("payload"),
102
+ )
103
+ log_variable_change(func_name, "outcome", outcome)
104
+ visits_payload = payload.get("visits", {})
105
+ log_variable_change(func_name, "visits_payload", visits_payload)
106
+ visits = NodeVisit(
107
+ count=int(visits_payload.get("count", 0)),
108
+ last_event_id=visits_payload.get("last_event_id"),
109
+ )
110
+ log_variable_change(func_name, "visits", visits)
111
+ upstream_completed = set(payload.get("upstream_completed", []))
112
+ log_variable_change(func_name, "upstream_completed", upstream_completed)
113
+ state = cls(
114
+ status=status,
115
+ outcome=outcome,
116
+ visits=visits,
117
+ upstream_completed=upstream_completed,
118
+ last_payload=payload.get("last_payload"),
119
+ )
120
+ log_variable_change(func_name, "state", state)
121
+ return state
122
+
123
+
124
+ class ExecutionState:
125
+ """Container for per-node runtime state tracking.
126
+
127
+ >>> from loopgraph.core.graph import Edge, Node
128
+ >>> from loopgraph.core.types import NodeKind
129
+ >>> graph = Graph(
130
+ ... nodes={
131
+ ... "a": Node(id="a", kind=NodeKind.TASK, handler="A"),
132
+ ... "b": Node(id="b", kind=NodeKind.TASK, handler="B"),
133
+ ... },
134
+ ... edges={"e": Edge(id="e", source="a", target="b")},
135
+ ... )
136
+ >>> runtime = ExecutionState()
137
+ >>> runtime.is_ready(graph, "a")
138
+ True
139
+ >>> runtime.mark_running("a")
140
+ >>> runtime.mark_complete("a", event_id="evt-1", outcome=VisitOutcome.success())
141
+ >>> runtime.note_upstream_completion("b", "a")
142
+ >>> runtime.is_ready(graph, "b")
143
+ True
144
+ >>> agg_graph = Graph(
145
+ ... nodes={
146
+ ... "x": Node(id="x", kind=NodeKind.TASK, handler="X"),
147
+ ... "y": Node(id="y", kind=NodeKind.TASK, handler="Y"),
148
+ ... "agg": Node(
149
+ ... id="agg",
150
+ ... kind=NodeKind.AGGREGATE,
151
+ ... handler="Aggregate",
152
+ ... config={"required": 2},
153
+ ... ),
154
+ ... },
155
+ ... edges={
156
+ ... "e1": Edge(id="e1", source="x", target="agg"),
157
+ ... "e2": Edge(id="e2", source="y", target="agg"),
158
+ ... },
159
+ ... )
160
+ >>> runtime_agg = ExecutionState()
161
+ >>> runtime_agg.note_upstream_completion("agg", "x")
162
+ >>> runtime_agg.is_ready(agg_graph, "agg")
163
+ False
164
+ >>> runtime_agg.note_upstream_completion("agg", "y")
165
+ >>> runtime_agg.is_ready(agg_graph, "agg")
166
+ True
167
+ """
168
+
169
+ def __init__(self) -> None:
170
+ func_name = "ExecutionState.__init__"
171
+ log_parameter(func_name)
172
+ self._states: Dict[str, NodeRuntimeState] = {}
173
+ log_variable_change(func_name, "self._states", self._states)
174
+ self._completed_nodes: Set[str] = set()
175
+ log_variable_change(func_name, "self._completed_nodes", self._completed_nodes)
176
+
177
+ def _ensure_state(self, node_id: str) -> NodeRuntimeState:
178
+ """Ensure a runtime state entry exists for the given node."""
179
+ func_name = "ExecutionState._ensure_state"
180
+ log_parameter(func_name, node_id=node_id)
181
+ if node_id not in self._states:
182
+ log_branch(func_name, "create_state")
183
+ self._states[node_id] = NodeRuntimeState()
184
+ log_variable_change(
185
+ func_name, f"self._states[{node_id!r}]", self._states[node_id]
186
+ )
187
+ else:
188
+ log_branch(func_name, "state_exists")
189
+ return self._states[node_id]
190
+
191
+ def is_ready(self, graph: Graph, node_id: str) -> bool:
192
+ """Check whether a node can be scheduled."""
193
+ func_name = "ExecutionState.is_ready"
194
+ log_parameter(func_name, node_id=node_id, graph=graph)
195
+ state = self._ensure_state(node_id)
196
+ log_variable_change(func_name, "state", state)
197
+ if state.status is not NodeStatus.PENDING:
198
+ log_branch(func_name, "not_pending")
199
+ return False
200
+ log_branch(func_name, "pending")
201
+
202
+ node = graph.nodes[node_id]
203
+ log_variable_change(func_name, "node", node)
204
+ if node.max_visits is not None and state.visits.count >= node.max_visits:
205
+ log_branch(func_name, "visit_limit_reached")
206
+ return False
207
+ log_branch(func_name, "visit_limit_available")
208
+
209
+ upstream_nodes = graph.upstream_nodes(node_id)
210
+ log_variable_change(func_name, "upstream_nodes", upstream_nodes)
211
+ if not upstream_nodes:
212
+ log_branch(func_name, "no_upstream")
213
+ return True
214
+
215
+ if node.kind is NodeKind.AGGREGATE:
216
+ log_branch(func_name, "aggregate_check")
217
+ required_raw = node.config.get("required")
218
+ log_variable_change(func_name, "required_raw", required_raw)
219
+ if isinstance(required_raw, int) and required_raw > 0:
220
+ required = required_raw
221
+ else:
222
+ required = len(upstream_nodes)
223
+ log_variable_change(func_name, "required", required)
224
+ completed_count = len(state.upstream_completed)
225
+ log_variable_change(func_name, "completed_count", completed_count)
226
+ if completed_count >= required:
227
+ log_branch(func_name, "aggregate_ready")
228
+ return True
229
+ log_branch(func_name, "aggregate_waiting")
230
+ return False
231
+
232
+ completed_required = True
233
+ log_variable_change(func_name, "completed_required", completed_required)
234
+ for iteration, upstream in enumerate(upstream_nodes):
235
+ log_loop_iteration(func_name, "upstream_required_check", iteration)
236
+ if upstream.id not in state.upstream_completed:
237
+ log_branch(func_name, "upstream_missing")
238
+ completed_required = False
239
+ log_variable_change(func_name, "completed_required", completed_required)
240
+ break
241
+ else:
242
+ log_branch(func_name, "all_marked_complete")
243
+ log_variable_change(func_name, "completed_required", completed_required)
244
+
245
+ if completed_required:
246
+ log_branch(func_name, "all_upstream_completed")
247
+ return True
248
+
249
+ if node.allow_partial_upstream:
250
+ log_branch(func_name, "allow_partial")
251
+ any_completed = False
252
+ log_variable_change(func_name, "any_completed", any_completed)
253
+ for iteration, upstream in enumerate(upstream_nodes):
254
+ log_loop_iteration(func_name, "upstream_partial_check", iteration)
255
+ if upstream.id in state.upstream_completed:
256
+ any_completed = True
257
+ log_variable_change(func_name, "any_completed", any_completed)
258
+ break
259
+ log_variable_change(func_name, "any_completed", any_completed)
260
+ return any_completed
261
+
262
+ log_branch(func_name, "upstream_incomplete")
263
+ return False
264
+
265
+ def mark_running(self, node_id: str) -> None:
266
+ """Transition a node into the running state.
267
+
268
+ >>> runtime = ExecutionState()
269
+ >>> runtime.mark_running("node-1")
270
+ >>> runtime.snapshot()["states"]["node-1"]["status"]
271
+ 'running'
272
+ """
273
+ func_name = "ExecutionState.mark_running"
274
+ log_parameter(func_name, node_id=node_id)
275
+ state = self._ensure_state(node_id)
276
+ log_variable_change(func_name, "state_before", state)
277
+ state.status = NodeStatus.RUNNING
278
+ log_variable_change(func_name, "state_after", state)
279
+
280
+ def mark_complete(
281
+ self,
282
+ node_id: str,
283
+ event_id: Optional[str],
284
+ outcome: VisitOutcome,
285
+ ) -> None:
286
+ """Mark a node as completed successfully.
287
+
288
+ >>> runtime = ExecutionState()
289
+ >>> runtime.mark_complete(
290
+ ... "node-1",
291
+ ... event_id="evt-1",
292
+ ... outcome=VisitOutcome.success({"value": 42}),
293
+ ... )
294
+ >>> runtime.snapshot()["completed_nodes"]
295
+ ['node-1']
296
+ """
297
+ func_name = "ExecutionState.mark_complete"
298
+ log_parameter(func_name, node_id=node_id, event_id=event_id, outcome=outcome)
299
+ state = self._ensure_state(node_id)
300
+ log_variable_change(func_name, "state_before", state)
301
+ state.status = NodeStatus.COMPLETED
302
+ log_variable_change(func_name, "state_status", state.status)
303
+ state.outcome = outcome
304
+ log_variable_change(func_name, "state_outcome", state.outcome)
305
+ state.last_payload = outcome.payload
306
+ log_variable_change(func_name, "state_last_payload", state.last_payload)
307
+ state.visits = state.visits.increment(event_id)
308
+ log_variable_change(func_name, "state_visits", state.visits)
309
+ self._completed_nodes.add(node_id)
310
+ log_variable_change(func_name, "self._completed_nodes", self._completed_nodes)
311
+
312
+ def mark_failed(
313
+ self,
314
+ node_id: str,
315
+ event_id: Optional[str],
316
+ outcome: VisitOutcome,
317
+ ) -> None:
318
+ """Mark a node as failed.
319
+
320
+ >>> runtime = ExecutionState()
321
+ >>> runtime.mark_failed(
322
+ ... "node-1",
323
+ ... event_id="evt-1",
324
+ ... outcome=VisitOutcome.failure("boom"),
325
+ ... )
326
+ >>> runtime.snapshot()["states"]["node-1"]["status"]
327
+ 'failed'
328
+ """
329
+ func_name = "ExecutionState.mark_failed"
330
+ log_parameter(func_name, node_id=node_id, event_id=event_id, outcome=outcome)
331
+ state = self._ensure_state(node_id)
332
+ log_variable_change(func_name, "state_before", state)
333
+ state.status = NodeStatus.FAILED
334
+ log_variable_change(func_name, "state_status", state.status)
335
+ state.outcome = outcome
336
+ log_variable_change(func_name, "state_outcome", state.outcome)
337
+ state.last_payload = outcome.payload
338
+ log_variable_change(func_name, "state_last_payload", state.last_payload)
339
+ state.visits = state.visits.increment(event_id)
340
+ log_variable_change(func_name, "state_visits", state.visits)
341
+
342
+ def reset_for_reentry(self, node_id: str) -> None:
343
+ """Reset a node to pending so it can be scheduled again.
344
+
345
+ >>> runtime = ExecutionState()
346
+ >>> runtime.mark_complete("node-1", "evt-1", VisitOutcome.success("ok"))
347
+ >>> before = runtime.snapshot()["states"]["node-1"]["visits"]["count"]
348
+ >>> runtime.reset_for_reentry("node-1")
349
+ >>> snapshot = runtime.snapshot()
350
+ >>> snapshot["states"]["node-1"]["status"]
351
+ 'pending'
352
+ >>> snapshot["states"]["node-1"]["upstream_completed"]
353
+ []
354
+ >>> snapshot["states"]["node-1"]["visits"]["count"] == before
355
+ True
356
+ >>> "node-1" in snapshot["completed_nodes"]
357
+ False
358
+ """
359
+ func_name = "ExecutionState.reset_for_reentry"
360
+ log_parameter(func_name, node_id=node_id)
361
+ state = self._ensure_state(node_id)
362
+ log_variable_change(func_name, "state_before", state)
363
+ state.status = NodeStatus.PENDING
364
+ log_variable_change(func_name, "state_status", state.status)
365
+ state.upstream_completed.clear()
366
+ log_variable_change(
367
+ func_name, "state_upstream_completed", state.upstream_completed
368
+ )
369
+ if node_id in self._completed_nodes:
370
+ log_branch(func_name, "remove_from_completed")
371
+ self._completed_nodes.remove(node_id)
372
+ else:
373
+ log_branch(func_name, "not_in_completed")
374
+ log_variable_change(func_name, "self._completed_nodes", self._completed_nodes)
375
+ log_variable_change(func_name, "state_after", state)
376
+
377
+ def note_upstream_completion(self, node_id: str, upstream_id: str) -> None:
378
+ """Record that an upstream dependency has completed.
379
+
380
+ >>> runtime = ExecutionState()
381
+ >>> runtime.note_upstream_completion("node-1", "up-1")
382
+ >>> runtime.snapshot()["states"]["node-1"]["upstream_completed"]
383
+ ['up-1']
384
+ """
385
+ func_name = "ExecutionState.note_upstream_completion"
386
+ log_parameter(func_name, node_id=node_id, upstream_id=upstream_id)
387
+ state = self._ensure_state(node_id)
388
+ log_variable_change(func_name, "state_before", state)
389
+ state.upstream_completed.add(upstream_id)
390
+ log_variable_change(func_name, "state_after", state)
391
+
392
+ def snapshot(self) -> Dict[str, Any]:
393
+ """Produce a JSON-serializable snapshot of execution state."""
394
+ func_name = "ExecutionState.snapshot"
395
+ log_parameter(func_name)
396
+ states_payload: Dict[str, Dict[str, Any]] = {}
397
+ log_variable_change(func_name, "states_payload", states_payload)
398
+ for iteration, (node_id, state) in enumerate(self._states.items()):
399
+ log_loop_iteration(func_name, "states", iteration)
400
+ states_payload[node_id] = state.to_dict()
401
+ log_variable_change(
402
+ func_name, f"states_payload[{node_id!r}]", states_payload[node_id]
403
+ )
404
+ payload = {
405
+ "states": states_payload,
406
+ "completed_nodes": sorted(self._completed_nodes),
407
+ }
408
+ log_variable_change(func_name, "payload", payload)
409
+ return payload
410
+
411
+ @classmethod
412
+ def restore(cls, payload: Dict[str, Any]) -> "ExecutionState":
413
+ """Restore execution state from a snapshot payload.
414
+
415
+ >>> runtime = ExecutionState()
416
+ >>> runtime.mark_complete(
417
+ ... "node-1",
418
+ ... event_id="evt-1",
419
+ ... outcome=VisitOutcome.success(),
420
+ ... )
421
+ >>> snapshot = runtime.snapshot()
422
+ >>> restored = ExecutionState.restore(snapshot)
423
+ >>> restored.snapshot() == snapshot
424
+ True
425
+ """
426
+ func_name = "ExecutionState.restore"
427
+ log_parameter(func_name, payload=payload)
428
+ instance = cls()
429
+ for iteration, (node_id, state_payload) in enumerate(
430
+ payload.get("states", {}).items()
431
+ ):
432
+ log_loop_iteration(func_name, "states", iteration)
433
+ state = NodeRuntimeState.from_dict(state_payload)
434
+ log_variable_change(func_name, "restored_state", state)
435
+ instance._states[node_id] = state
436
+ log_variable_change(
437
+ func_name, f"instance._states[{node_id!r}]", instance._states[node_id]
438
+ )
439
+ instance._completed_nodes = set(payload.get("completed_nodes", []))
440
+ log_variable_change(
441
+ func_name, "instance._completed_nodes", instance._completed_nodes
442
+ )
443
+ return instance
@@ -0,0 +1,72 @@
1
+ """Fundamental enums and dataclasses shared across LoopGraph modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import Any, Optional
8
+
9
+ from .._debug import log_parameter, log_variable_change
10
+
11
+
12
+ class NodeKind(str, Enum):
13
+ """Enumerated node kinds supported by LoopGraph."""
14
+
15
+ TASK = "task"
16
+ SWITCH = "switch"
17
+ AGGREGATE = "aggregate"
18
+ TERMINAL = "terminal"
19
+
20
+
21
+ class NodeStatus(str, Enum):
22
+ """Execution states tracked for each node."""
23
+
24
+ PENDING = "pending"
25
+ RUNNING = "running"
26
+ COMPLETED = "completed"
27
+ FAILED = "failed"
28
+ SKIPPED = "skipped"
29
+
30
+
31
+ class EventType(str, Enum):
32
+ """Event kinds emitted during workflow execution."""
33
+
34
+ NODE_SCHEDULED = "node_scheduled"
35
+ NODE_COMPLETED = "node_completed"
36
+ NODE_FAILED = "node_failed"
37
+ WORKFLOW_COMPLETED = "workflow_completed"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class VisitOutcome:
42
+ """Represents the result of a node execution attempt."""
43
+
44
+ status: NodeStatus
45
+ detail: Optional[str] = None
46
+ payload: Optional[Any] = None
47
+
48
+ @classmethod
49
+ def success(cls, payload: Optional[Any] = None) -> "VisitOutcome":
50
+ """Construct a successful outcome.
51
+
52
+ >>> VisitOutcome.success({"count": 2}).status
53
+ <NodeStatus.COMPLETED: 'completed'>
54
+ """
55
+ func_name = "VisitOutcome.success"
56
+ log_parameter(func_name, payload=payload)
57
+ outcome = cls(status=NodeStatus.COMPLETED, payload=payload)
58
+ log_variable_change(func_name, "outcome", outcome)
59
+ return outcome
60
+
61
+ @classmethod
62
+ def failure(cls, detail: Optional[str] = None) -> "VisitOutcome":
63
+ """Construct a failed outcome.
64
+
65
+ >>> VisitOutcome.failure("boom").detail
66
+ 'boom'
67
+ """
68
+ func_name = "VisitOutcome.failure"
69
+ log_parameter(func_name, detail=detail)
70
+ outcome = cls(status=NodeStatus.FAILED, detail=detail)
71
+ log_variable_change(func_name, "outcome", outcome)
72
+ return outcome
@@ -0,0 +1,5 @@
1
+ """Diagnostics helpers."""
2
+
3
+ from .inspect import describe_execution_state, describe_graph
4
+
5
+ __all__ = ["describe_execution_state", "describe_graph"]
@@ -0,0 +1,70 @@
1
+ """Diagnostics helpers for inspecting graphs and execution state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict
6
+
7
+ from .._debug import log_loop_iteration, log_parameter, log_variable_change
8
+ from ..core.graph import Graph
9
+ from ..core.state import ExecutionState
10
+
11
+
12
+ def describe_graph(graph: Graph) -> Dict[str, object]:
13
+ """Return a summary of the graph structure.
14
+
15
+ >>> from loopgraph.core.graph import Edge, Node
16
+ >>> from loopgraph.core.types import NodeKind
17
+ >>> graph = Graph(
18
+ ... nodes={
19
+ ... "start": Node(id="start", kind=NodeKind.TASK, handler="start_handler"),
20
+ ... "end": Node(id="end", kind=NodeKind.TERMINAL, handler="end_handler"),
21
+ ... },
22
+ ... edges={"e": Edge(id="e", source="start", target="end")},
23
+ ... )
24
+ >>> describe_graph(graph)["node_count"]
25
+ 2
26
+ """
27
+ func_name = "describe_graph"
28
+ log_parameter(func_name, graph=graph)
29
+ summary = {
30
+ "node_count": len(graph.nodes),
31
+ "edge_count": len(graph.edges),
32
+ "nodes": list(graph.nodes.keys()),
33
+ }
34
+ log_variable_change(func_name, "summary", summary)
35
+ return summary
36
+
37
+
38
+ def describe_execution_state(state: ExecutionState) -> Dict[str, object]:
39
+ """Summarise execution state snapshot data.
40
+
41
+ >>> from loopgraph.core.types import VisitOutcome, NodeStatus
42
+ >>> execution = ExecutionState()
43
+ >>> execution.mark_complete(
44
+ ... "node-1",
45
+ ... event_id="evt-1",
46
+ ... outcome=VisitOutcome.success(),
47
+ ... )
48
+ >>> describe_execution_state(execution)["completed"]
49
+ ['node-1']
50
+ """
51
+ func_name = "describe_execution_state"
52
+ log_parameter(func_name, state=state)
53
+ snapshot = state.snapshot()
54
+ log_variable_change(func_name, "snapshot", snapshot)
55
+ completed_nodes = list(snapshot["completed_nodes"])
56
+ log_variable_change(func_name, "completed_nodes", completed_nodes)
57
+ statuses: Dict[str, str] = {}
58
+ log_variable_change(func_name, "statuses", statuses)
59
+ for iteration, (node_id, node_state) in enumerate(snapshot["states"].items()):
60
+ log_loop_iteration(func_name, "states", iteration)
61
+ status = str(node_state["status"])
62
+ log_variable_change(func_name, "status", status)
63
+ statuses[node_id] = status
64
+ log_variable_change(func_name, f"statuses[{node_id!r}]", statuses[node_id])
65
+ summary: Dict[str, object] = {
66
+ "completed": completed_nodes,
67
+ "statuses": statuses,
68
+ }
69
+ log_variable_change(func_name, "summary", summary)
70
+ return summary
@@ -0,0 +1,6 @@
1
+ """Persistence interfaces for snapshots and event logs."""
2
+
3
+ from .event_log import EventLog, InMemoryEventLog
4
+ from .snapshot import InMemorySnapshotStore, SnapshotStore
5
+
6
+ __all__ = ["EventLog", "InMemoryEventLog", "SnapshotStore", "InMemorySnapshotStore"]
@@ -0,0 +1,63 @@
1
+ """Persistence interfaces and in-memory event log implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Iterable, List, Protocol
7
+
8
+ from .._debug import (
9
+ log_branch,
10
+ log_loop_iteration,
11
+ log_parameter,
12
+ log_variable_change,
13
+ )
14
+ from ..bus.eventbus import Event
15
+
16
+
17
+ class EventLog(Protocol):
18
+ """Protocol for append-only event logs.
19
+
20
+ >>> from loopgraph.core.types import EventType
21
+ >>> log = InMemoryEventLog()
22
+ >>> log.append(Event(id="evt", graph_id="g", node_id=None, type=EventType.NODE_COMPLETED))
23
+ >>> [evt.type for evt in log.iter("g")]
24
+ [<EventType.NODE_COMPLETED: 'node_completed'>]
25
+ """
26
+
27
+ def append(self, event: Event) -> None:
28
+ """Persist an event in the log."""
29
+
30
+ def iter(self, graph_id: str) -> Iterable[Event]:
31
+ """Replay events for a graph."""
32
+ ...
33
+
34
+
35
+ @dataclass
36
+ class InMemoryEventLog(EventLog):
37
+ """Simple in-memory event log.
38
+
39
+ >>> from ..core.types import EventType
40
+ >>> log = InMemoryEventLog()
41
+ >>> log.append(Event(id="evt-1", graph_id="graph", node_id=None, type=EventType.WORKFLOW_COMPLETED))
42
+ >>> [evt.id for evt in log.iter("graph")]
43
+ ['evt-1']
44
+ """
45
+
46
+ _events: List[Event] = field(default_factory=list)
47
+
48
+ def append(self, event: Event) -> None:
49
+ func_name = "InMemoryEventLog.append"
50
+ log_parameter(func_name, event=event)
51
+ self._events.append(event)
52
+ log_variable_change(func_name, "self._events", self._events)
53
+
54
+ def iter(self, graph_id: str) -> Iterable[Event]:
55
+ func_name = "InMemoryEventLog.iter"
56
+ log_parameter(func_name, graph_id=graph_id)
57
+ for iteration, event in enumerate(self._events):
58
+ log_loop_iteration(func_name, "events", iteration)
59
+ if event.graph_id == graph_id:
60
+ log_branch(func_name, "graph_match")
61
+ yield event
62
+ else:
63
+ log_branch(func_name, "graph_mismatch")