activegraph 1.0.0rc2__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.
Files changed (82) hide show
  1. activegraph/__init__.py +222 -0
  2. activegraph/__main__.py +5 -0
  3. activegraph/behaviors/__init__.py +1 -0
  4. activegraph/behaviors/base.py +153 -0
  5. activegraph/behaviors/decorators.py +218 -0
  6. activegraph/cli/__init__.py +17 -0
  7. activegraph/cli/main.py +793 -0
  8. activegraph/cli/quickstart.py +556 -0
  9. activegraph/core/__init__.py +1 -0
  10. activegraph/core/clock.py +46 -0
  11. activegraph/core/event.py +33 -0
  12. activegraph/core/graph.py +846 -0
  13. activegraph/core/ids.py +135 -0
  14. activegraph/core/patch.py +47 -0
  15. activegraph/core/view.py +59 -0
  16. activegraph/errors.py +342 -0
  17. activegraph/frame.py +15 -0
  18. activegraph/llm/__init__.py +51 -0
  19. activegraph/llm/anthropic.py +339 -0
  20. activegraph/llm/cache.py +180 -0
  21. activegraph/llm/errors.py +257 -0
  22. activegraph/llm/prompt.py +420 -0
  23. activegraph/llm/provider.py +66 -0
  24. activegraph/llm/recorded.py +270 -0
  25. activegraph/llm/types.py +130 -0
  26. activegraph/observability/__init__.py +61 -0
  27. activegraph/observability/logging.py +205 -0
  28. activegraph/observability/metrics.py +219 -0
  29. activegraph/observability/migration.py +404 -0
  30. activegraph/observability/prometheus.py +101 -0
  31. activegraph/observability/status.py +83 -0
  32. activegraph/packs/__init__.py +999 -0
  33. activegraph/packs/diligence/__init__.py +86 -0
  34. activegraph/packs/diligence/behaviors.py +447 -0
  35. activegraph/packs/diligence/fixtures/__init__.py +364 -0
  36. activegraph/packs/diligence/fixtures/companies.py +511 -0
  37. activegraph/packs/diligence/object_types.py +163 -0
  38. activegraph/packs/diligence/settings.py +60 -0
  39. activegraph/packs/diligence/tools.py +124 -0
  40. activegraph/packs/loader.py +709 -0
  41. activegraph/packs/scaffold.py +317 -0
  42. activegraph/policy.py +20 -0
  43. activegraph/runtime/__init__.py +1 -0
  44. activegraph/runtime/behavior_graph.py +151 -0
  45. activegraph/runtime/budget.py +120 -0
  46. activegraph/runtime/config_errors.py +124 -0
  47. activegraph/runtime/diff.py +145 -0
  48. activegraph/runtime/errors.py +216 -0
  49. activegraph/runtime/exec_errors.py +232 -0
  50. activegraph/runtime/patterns.py +946 -0
  51. activegraph/runtime/queue.py +27 -0
  52. activegraph/runtime/registration_errors.py +291 -0
  53. activegraph/runtime/registry.py +111 -0
  54. activegraph/runtime/runtime.py +2441 -0
  55. activegraph/runtime/scheduler.py +206 -0
  56. activegraph/runtime/view_builder.py +65 -0
  57. activegraph/store/__init__.py +41 -0
  58. activegraph/store/base.py +66 -0
  59. activegraph/store/conformance.py +161 -0
  60. activegraph/store/errors.py +84 -0
  61. activegraph/store/memory.py +118 -0
  62. activegraph/store/postgres.py +609 -0
  63. activegraph/store/serde.py +202 -0
  64. activegraph/store/sqlite.py +446 -0
  65. activegraph/store/url.py +230 -0
  66. activegraph/tools/__init__.py +64 -0
  67. activegraph/tools/base.py +57 -0
  68. activegraph/tools/cache.py +157 -0
  69. activegraph/tools/context.py +48 -0
  70. activegraph/tools/decorators.py +70 -0
  71. activegraph/tools/errors.py +320 -0
  72. activegraph/tools/graph_query.py +94 -0
  73. activegraph/tools/recorded.py +205 -0
  74. activegraph/tools/web_fetch.py +80 -0
  75. activegraph/trace/__init__.py +1 -0
  76. activegraph/trace/causal.py +123 -0
  77. activegraph/trace/printer.py +495 -0
  78. activegraph-1.0.0rc2.dist-info/METADATA +228 -0
  79. activegraph-1.0.0rc2.dist-info/RECORD +82 -0
  80. activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
  81. activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
  82. activegraph-1.0.0rc2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,404 @@
1
+ """Cross-store migration. CONTRACT v0.8 #5 (revised: transaction-per-run),
2
+ + v1.0 CLI follow-on (--skip-corrupted).
3
+
4
+ Each run migrates in a single transaction against the destination. If a
5
+ run fails partway, that run's destination state is unchanged. Writes
6
+ use ``INSERT ... ON CONFLICT DO NOTHING`` against ``UNIQUE(id, run_id)``
7
+ so re-running after a failure is idempotent. Runs migrate independently
8
+ — a bad run does not block the others. A structured per-run report is
9
+ returned (and printed by the CLI; ``--json`` dumps the same shape).
10
+
11
+ Migration is one-directional and explicit. No sync mode, no rollback,
12
+ no automatic recovery. To go back, migrate the other direction.
13
+
14
+ v1.0 adds opt-in ``skip_corrupted`` mode: a corrupted-payload row no
15
+ longer halts a run's migration. The corrupt row is recorded in the
16
+ per-run report's ``skipped_events`` list; surrounding events still
17
+ migrate. Driver-specific raw row iteration is required because Python
18
+ generators die after raising — see ``_iter_*_skip_corrupted`` helpers.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Callable, Iterator, Optional
25
+
26
+ from activegraph.core.event import Event
27
+ from activegraph.store.base import RunRecord
28
+ from activegraph.store.errors import CorruptedEventPayloadError
29
+ from activegraph.store.serde import decode_event
30
+ from activegraph.store.url import parse_store_url
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class MigrationRunReport:
35
+ run_id: str
36
+ status: str # "ok" | "skipped" | "failed"
37
+ events_migrated: int
38
+ error: Optional[str] = None
39
+ # v1.0 CLI follow-on: event ids that could not be decoded and were
40
+ # skipped under --skip-corrupted. Empty on a clean migration.
41
+ skipped_events: tuple[str, ...] = ()
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class MigrationReport:
46
+ source_url: str
47
+ dest_url: str
48
+ runs: tuple[MigrationRunReport, ...]
49
+
50
+ @property
51
+ def ok(self) -> bool:
52
+ return all(r.status != "failed" for r in self.runs)
53
+
54
+ @property
55
+ def failures(self) -> tuple[MigrationRunReport, ...]:
56
+ return tuple(r for r in self.runs if r.status == "failed")
57
+
58
+
59
+ def migrate(
60
+ source_url: str,
61
+ dest_url: str,
62
+ *,
63
+ only_run_ids: Optional[list[str]] = None,
64
+ on_progress: Optional[Callable[[MigrationRunReport], None]] = None,
65
+ skip_corrupted: bool = False,
66
+ ) -> MigrationReport:
67
+ """Copy every run (or a subset) from ``source_url`` into ``dest_url``.
68
+
69
+ Args:
70
+ source_url: e.g. ``sqlite:///dev.db``
71
+ dest_url: e.g. ``postgres://localhost/prod``
72
+ only_run_ids: if given, migrate only these runs.
73
+ on_progress: called after each run finishes (success or failure)
74
+ with the ``MigrationRunReport`` for that run.
75
+ skip_corrupted: if True, rows whose payload fails JSON decode
76
+ are skipped (not migrated, not failing the run). The
77
+ skipped event ids appear in the per-run report's
78
+ ``skipped_events``. The resulting destination run is
79
+ **partial** — the operator is on notice.
80
+
81
+ Returns:
82
+ A ``MigrationReport``. The overall operation is considered
83
+ successful iff every run's status is ``"ok"`` or ``"skipped"``.
84
+ """
85
+ src = _resolve(source_url)
86
+ dst = _resolve(dest_url)
87
+
88
+ run_records = src.list_runs()
89
+ if only_run_ids is not None:
90
+ wanted = set(only_run_ids)
91
+ run_records = [r for r in run_records if r.run_id in wanted]
92
+
93
+ reports: list[MigrationRunReport] = []
94
+ for rec in run_records:
95
+ report = _migrate_one_run(src, dst, rec, skip_corrupted=skip_corrupted)
96
+ reports.append(report)
97
+ if on_progress is not None:
98
+ on_progress(report)
99
+
100
+ return MigrationReport(
101
+ source_url=source_url, dest_url=dest_url, runs=tuple(reports)
102
+ )
103
+
104
+
105
+ # ---- internal helpers ----------------------------------------------------
106
+
107
+
108
+ @dataclass
109
+ class _StoreFacade:
110
+ """Uniform surface over SQLite and Postgres for migration.
111
+
112
+ Hides the driver-specific call shapes (positional path vs URL) and
113
+ the per-run transaction semantics.
114
+ """
115
+
116
+ url: str
117
+ kind: str # "sqlite" | "postgres"
118
+ target: Any # path string (sqlite) or URL string (postgres)
119
+
120
+ def list_runs(self) -> list[RunRecord]:
121
+ if self.kind == "sqlite":
122
+ from activegraph.store.sqlite import SQLiteEventStore
123
+
124
+ return SQLiteEventStore.list_runs(self.target)
125
+ from activegraph.store.postgres import PostgresEventStore
126
+
127
+ return PostgresEventStore.list_runs(self.target)
128
+
129
+ def open_run(self, run_id: str):
130
+ if self.kind == "sqlite":
131
+ from activegraph.store.sqlite import SQLiteEventStore
132
+
133
+ return SQLiteEventStore(self.target, run_id=run_id)
134
+ from activegraph.store.postgres import PostgresEventStore
135
+
136
+ return PostgresEventStore(self.target, run_id=run_id)
137
+
138
+ def write_run_transactionally(
139
+ self, rec: RunRecord, events: list
140
+ ) -> int:
141
+ """Insert run row + events in one transaction.
142
+
143
+ Returns the number of events newly written (idempotent: rerunning
144
+ after a partial failure returns only the count of rows that were
145
+ actually inserted this time).
146
+ """
147
+ if self.kind == "sqlite":
148
+ return _write_run_sqlite(self.target, rec, events)
149
+ return _write_run_postgres(self.target, rec, events)
150
+
151
+
152
+ def _resolve(url: str) -> _StoreFacade:
153
+ parsed = parse_store_url(url)
154
+ if parsed.scheme == "sqlite":
155
+ return _StoreFacade(url=url, kind="sqlite", target=parsed.sqlite_path or "")
156
+ return _StoreFacade(url=url, kind="postgres", target=parsed.raw)
157
+
158
+
159
+ def _migrate_one_run(
160
+ src: _StoreFacade, dst: _StoreFacade, rec: RunRecord,
161
+ *, skip_corrupted: bool = False,
162
+ ) -> MigrationRunReport:
163
+ src_store = src.open_run(rec.run_id)
164
+ skipped_ids: list[str] = []
165
+ try:
166
+ if skip_corrupted:
167
+ events = []
168
+ for ev, err, raw_id in _iter_events_skip_corrupted(src, rec.run_id):
169
+ if err is None and ev is not None:
170
+ events.append(ev)
171
+ else:
172
+ skipped_ids.append(raw_id or "<unknown>")
173
+ else:
174
+ events = list(src_store.iter_events())
175
+ except Exception as e:
176
+ return MigrationRunReport(
177
+ run_id=rec.run_id,
178
+ status="failed",
179
+ events_migrated=0,
180
+ error=f"read failure: {e}",
181
+ skipped_events=tuple(skipped_ids),
182
+ )
183
+ finally:
184
+ src_store.close()
185
+
186
+ try:
187
+ n = dst.write_run_transactionally(rec, events)
188
+ except Exception as e:
189
+ return MigrationRunReport(
190
+ run_id=rec.run_id,
191
+ status="failed",
192
+ events_migrated=0,
193
+ error=f"write failure: {e}",
194
+ skipped_events=tuple(skipped_ids),
195
+ )
196
+
197
+ return MigrationRunReport(
198
+ run_id=rec.run_id,
199
+ status="ok",
200
+ events_migrated=n,
201
+ skipped_events=tuple(skipped_ids),
202
+ )
203
+
204
+
205
+ # ---- skip-corrupted iteration (v1.0 CLI follow-on) ----------------------
206
+ #
207
+ # Python generators die after raising, so iter_events() can't be wrapped
208
+ # in a per-row try/except to skip a corrupt event mid-stream. The
209
+ # skip-corrupted path iterates raw rows (driver-specific) and decodes
210
+ # each one individually, yielding (event, None, id) for clean rows and
211
+ # (None, error, id) for corrupted ones. Callers collect the skipped ids
212
+ # into the per-run report.
213
+
214
+
215
+ def _iter_events_skip_corrupted(
216
+ facade: _StoreFacade, run_id: str
217
+ ) -> Iterator[tuple[Optional[Event], Optional[CorruptedEventPayloadError], str]]:
218
+ if facade.kind == "sqlite":
219
+ yield from _iter_sqlite_skip_corrupted(facade.target, run_id)
220
+ return
221
+ yield from _iter_postgres_skip_corrupted(facade.target, run_id)
222
+
223
+
224
+ def _iter_sqlite_skip_corrupted(
225
+ path: str, run_id: str
226
+ ) -> Iterator[tuple[Optional[Event], Optional[CorruptedEventPayloadError], str]]:
227
+ import sqlite3
228
+
229
+ conn = sqlite3.connect(path)
230
+ conn.row_factory = sqlite3.Row
231
+ try:
232
+ for row in conn.execute(
233
+ "SELECT * FROM events WHERE run_id = ? ORDER BY seq", (run_id,)
234
+ ):
235
+ row_id = row["id"]
236
+ try:
237
+ yield decode_event({
238
+ "id": row["id"],
239
+ "type": row["type"],
240
+ "payload": row["payload"],
241
+ "actor": row["actor"],
242
+ "frame_id": row["frame_id"],
243
+ "caused_by": row["caused_by"],
244
+ "timestamp": row["timestamp"],
245
+ }), None, row_id
246
+ except CorruptedEventPayloadError as e:
247
+ yield None, e, row_id
248
+ finally:
249
+ conn.close()
250
+
251
+
252
+ def _iter_postgres_skip_corrupted(
253
+ url: str, run_id: str
254
+ ) -> Iterator[tuple[Optional[Event], Optional[CorruptedEventPayloadError], str]]:
255
+ from activegraph.store.postgres import _ConnectionSource
256
+
257
+ source = _ConnectionSource(url)
258
+ try:
259
+ with source.transaction() as conn:
260
+ with conn.cursor() as cur:
261
+ cur.execute(
262
+ """
263
+ SELECT id, type, actor, payload, frame_id, caused_by, timestamp
264
+ FROM events
265
+ WHERE run_id = %s
266
+ ORDER BY seq
267
+ """,
268
+ (run_id,),
269
+ )
270
+ for row in cur.fetchall():
271
+ row_id = row[0]
272
+ try:
273
+ # Postgres returns JSONB as Python dict already;
274
+ # decode_event expects a JSON string in payload.
275
+ # Re-encode if needed so the decode path is uniform.
276
+ payload = row[3]
277
+ if isinstance(payload, (dict, list)):
278
+ import json
279
+ payload_s = json.dumps(payload)
280
+ else:
281
+ payload_s = payload
282
+ yield decode_event({
283
+ "id": row[0],
284
+ "type": row[1],
285
+ "actor": row[2],
286
+ "payload": payload_s,
287
+ "frame_id": row[4],
288
+ "caused_by": row[5],
289
+ "timestamp": row[6],
290
+ }), None, row_id
291
+ except CorruptedEventPayloadError as e:
292
+ yield None, e, row_id
293
+ finally:
294
+ source.close()
295
+
296
+
297
+ # ---- driver-specific transactional writers ------------------------------
298
+
299
+
300
+ def _write_run_sqlite(path: str, rec: RunRecord, events: list) -> int:
301
+ """Single-transaction SQLite write with INSERT OR IGNORE for idempotency."""
302
+ import sqlite3
303
+
304
+ from activegraph.store.serde import encode_event
305
+ from activegraph.store.sqlite import _ensure_schema
306
+
307
+ conn = sqlite3.connect(path, isolation_level=None)
308
+ conn.row_factory = sqlite3.Row
309
+ _ensure_schema(conn)
310
+ n = 0
311
+ try:
312
+ conn.execute("BEGIN")
313
+ conn.execute(
314
+ """
315
+ INSERT INTO runs (run_id, parent_run_id, forked_at_event_id, label, created_at, goal, frame_id)
316
+ VALUES (?, ?, ?, ?, ?, ?, ?)
317
+ ON CONFLICT(run_id) DO NOTHING
318
+ """,
319
+ (
320
+ rec.run_id,
321
+ rec.parent_run_id,
322
+ rec.forked_at_event_id,
323
+ rec.label,
324
+ rec.created_at,
325
+ rec.goal,
326
+ rec.frame_id,
327
+ ),
328
+ )
329
+ for ev in events:
330
+ row = encode_event(ev)
331
+ cur = conn.execute(
332
+ """
333
+ INSERT INTO events (id, type, actor, payload, frame_id, caused_by, timestamp, run_id)
334
+ VALUES (:id, :type, :actor, :payload, :frame_id, :caused_by, :timestamp, :run_id)
335
+ ON CONFLICT(id, run_id) DO NOTHING
336
+ """,
337
+ {**row, "run_id": rec.run_id},
338
+ )
339
+ if cur.rowcount > 0:
340
+ n += 1
341
+ conn.execute("COMMIT")
342
+ except Exception:
343
+ conn.execute("ROLLBACK")
344
+ raise
345
+ finally:
346
+ conn.close()
347
+ return n
348
+
349
+
350
+ def _write_run_postgres(url: str, rec: RunRecord, events: list) -> int:
351
+ """Single-transaction Postgres write with ON CONFLICT DO NOTHING."""
352
+ import json
353
+
354
+ from activegraph.store.postgres import (
355
+ _EVENT_COLUMNS,
356
+ _ConnectionSource,
357
+ _ensure_schema,
358
+ )
359
+
360
+ source = _ConnectionSource(url)
361
+ try:
362
+ _ensure_schema(source)
363
+ with source.transaction() as conn:
364
+ with conn.cursor() as cur:
365
+ cur.execute(
366
+ """
367
+ INSERT INTO runs (run_id, parent_run_id, forked_at_event_id, label, created_at, goal, frame_id)
368
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
369
+ ON CONFLICT (run_id) DO NOTHING
370
+ """,
371
+ (
372
+ rec.run_id,
373
+ rec.parent_run_id,
374
+ rec.forked_at_event_id,
375
+ rec.label,
376
+ rec.created_at,
377
+ rec.goal,
378
+ rec.frame_id,
379
+ ),
380
+ )
381
+ n = 0
382
+ for ev in events:
383
+ cur.execute(
384
+ f"""
385
+ INSERT INTO events ({_EVENT_COLUMNS}, run_id)
386
+ VALUES (%s, %s, %s, %s::jsonb, %s, %s, %s, %s)
387
+ ON CONFLICT (id, run_id) DO NOTHING
388
+ """,
389
+ (
390
+ ev.id,
391
+ ev.type,
392
+ ev.actor,
393
+ json.dumps(ev.payload),
394
+ ev.frame_id,
395
+ ev.caused_by,
396
+ ev.timestamp,
397
+ rec.run_id,
398
+ ),
399
+ )
400
+ if cur.rowcount > 0:
401
+ n += 1
402
+ return n
403
+ finally:
404
+ source.close()
@@ -0,0 +1,101 @@
1
+ """Prometheus implementation of the Metrics protocol. CONTRACT v0.8 #10.
2
+
3
+ Lazy import of ``prometheus_client`` so the dep stays optional. Install
4
+ with ``pip install 'activegraph[prometheus]'``.
5
+
6
+ The implementation creates Counter / Histogram / Gauge instruments
7
+ on-demand, keyed by ``(name, sorted-tag-keys)``. Tag values become
8
+ label values. We use the documented metric names as-is — they already
9
+ follow Prometheus conventions (``_total`` for counters, ``_seconds`` /
10
+ ``_usd`` for histogram units, snake_case throughout).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+
18
+ class PrometheusMetrics:
19
+ """Drop-in Metrics implementation backed by prometheus_client.
20
+
21
+ Instruments are lazy. Tag keys for an instrument are fixed by the
22
+ first observation; subsequent observations with a different key set
23
+ raise (prometheus_client behavior). This matches the standard metric
24
+ list's fixed tag schemas.
25
+ """
26
+
27
+ def __init__(self, registry: Any | None = None) -> None:
28
+ client = _require_client()
29
+ self._client = client
30
+ self._registry = registry if registry is not None else client.REGISTRY
31
+ self._counters: dict[tuple, Any] = {}
32
+ self._histograms: dict[tuple, Any] = {}
33
+ self._gauges: dict[tuple, Any] = {}
34
+
35
+ @staticmethod
36
+ def available() -> bool:
37
+ try:
38
+ import prometheus_client # noqa: F401
39
+ except ImportError:
40
+ return False
41
+ return True
42
+
43
+ # ---- protocol ----
44
+
45
+ def counter(self, name: str, tags: dict[str, str], value: float = 1.0) -> None:
46
+ keys = tuple(sorted(tags.keys()))
47
+ key = (name, keys)
48
+ c = self._counters.get(key)
49
+ if c is None:
50
+ c = self._client.Counter(
51
+ name, name.replace("_", " "), labelnames=keys,
52
+ registry=self._registry,
53
+ )
54
+ self._counters[key] = c
55
+ if keys:
56
+ c.labels(**tags).inc(value)
57
+ else:
58
+ c.inc(value)
59
+
60
+ def histogram(self, name: str, tags: dict[str, str], value: float) -> None:
61
+ keys = tuple(sorted(tags.keys()))
62
+ key = (name, keys)
63
+ h = self._histograms.get(key)
64
+ if h is None:
65
+ h = self._client.Histogram(
66
+ name, name.replace("_", " "), labelnames=keys,
67
+ registry=self._registry,
68
+ )
69
+ self._histograms[key] = h
70
+ if keys:
71
+ h.labels(**tags).observe(value)
72
+ else:
73
+ h.observe(value)
74
+
75
+ def gauge(self, name: str, tags: dict[str, str], value: float) -> None:
76
+ keys = tuple(sorted(tags.keys()))
77
+ key = (name, keys)
78
+ g = self._gauges.get(key)
79
+ if g is None:
80
+ g = self._client.Gauge(
81
+ name, name.replace("_", " "), labelnames=keys,
82
+ registry=self._registry,
83
+ )
84
+ self._gauges[key] = g
85
+ if keys:
86
+ g.labels(**tags).set(value)
87
+ else:
88
+ g.set(value)
89
+
90
+
91
+ def _require_client() -> Any:
92
+ try:
93
+ import prometheus_client # type: ignore
94
+ except ImportError as e:
95
+ from activegraph.errors import MissingOptionalDependency
96
+ raise MissingOptionalDependency(
97
+ package="prometheus_client",
98
+ feature="PrometheusMetrics",
99
+ extras="prometheus",
100
+ ) from e
101
+ return prometheus_client
@@ -0,0 +1,83 @@
1
+ """Runtime introspection — RuntimeStatus and friends. CONTRACT v0.8 #11.
2
+
3
+ ``runtime.status(recent=N)`` returns a ``RuntimeStatus``: a frozen
4
+ snapshot. Cheap to call (no graph traversal, no event log scan), safe
5
+ from anywhere, returns immutable data. The CLI's ``inspect`` command is
6
+ a thin wrapper around this.
7
+
8
+ There is no ``last_error`` field. Errors are events; filter
9
+ ``recent_events`` for type ``behavior.failed``, or query the store for
10
+ a window-independent view. Convenience accessors that look the same as
11
+ the source of truth but mean different things are bug-bait.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import asdict, dataclass, field
17
+ from typing import Any, Literal, Optional
18
+
19
+
20
+ RuntimeState = Literal["idle", "running", "stopped", "exhausted"]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class BudgetSnapshot:
25
+ used: dict[str, float]
26
+ limits: dict[str, Optional[float]]
27
+ cost_used_usd: str
28
+ cost_limit_usd: Optional[str]
29
+ exhausted_by: Optional[str]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class FrameSnapshot:
34
+ id: Optional[str]
35
+ name: Optional[str]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class BehaviorInfo:
40
+ name: str
41
+ kind: str # "function" | "relation" | "llm"
42
+ subscribed_to: tuple[str, ...]
43
+ pattern: Optional[str] = None
44
+ activate_after: Optional[int] = None
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class EventSummary:
49
+ id: str
50
+ type: str
51
+ actor: Optional[str]
52
+ timestamp: str
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class RuntimeStatus:
57
+ run_id: str
58
+ state: RuntimeState
59
+ queue_depth: int
60
+ events_processed: int
61
+ budget: BudgetSnapshot
62
+ frame: Optional[FrameSnapshot]
63
+ registered_behaviors: tuple[BehaviorInfo, ...]
64
+ recent_events: tuple[EventSummary, ...]
65
+
66
+
67
+ def status_to_dict(status: RuntimeStatus) -> dict[str, Any]:
68
+ """Convert a RuntimeStatus to a plain JSON-serializable dict.
69
+
70
+ Used by the CLI's --json flag. Field names match the documented
71
+ schema; nested dataclasses become nested dicts.
72
+ """
73
+ return _asdict_with_tuples(status)
74
+
75
+
76
+ def _asdict_with_tuples(obj: Any) -> Any:
77
+ if hasattr(obj, "__dataclass_fields__"):
78
+ return {k: _asdict_with_tuples(v) for k, v in asdict(obj).items()}
79
+ if isinstance(obj, (list, tuple)):
80
+ return [_asdict_with_tuples(v) for v in obj]
81
+ if isinstance(obj, dict):
82
+ return {k: _asdict_with_tuples(v) for k, v in obj.items()}
83
+ return obj