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.
- activegraph/__init__.py +222 -0
- activegraph/__main__.py +5 -0
- activegraph/behaviors/__init__.py +1 -0
- activegraph/behaviors/base.py +153 -0
- activegraph/behaviors/decorators.py +218 -0
- activegraph/cli/__init__.py +17 -0
- activegraph/cli/main.py +793 -0
- activegraph/cli/quickstart.py +556 -0
- activegraph/core/__init__.py +1 -0
- activegraph/core/clock.py +46 -0
- activegraph/core/event.py +33 -0
- activegraph/core/graph.py +846 -0
- activegraph/core/ids.py +135 -0
- activegraph/core/patch.py +47 -0
- activegraph/core/view.py +59 -0
- activegraph/errors.py +342 -0
- activegraph/frame.py +15 -0
- activegraph/llm/__init__.py +51 -0
- activegraph/llm/anthropic.py +339 -0
- activegraph/llm/cache.py +180 -0
- activegraph/llm/errors.py +257 -0
- activegraph/llm/prompt.py +420 -0
- activegraph/llm/provider.py +66 -0
- activegraph/llm/recorded.py +270 -0
- activegraph/llm/types.py +130 -0
- activegraph/observability/__init__.py +61 -0
- activegraph/observability/logging.py +205 -0
- activegraph/observability/metrics.py +219 -0
- activegraph/observability/migration.py +404 -0
- activegraph/observability/prometheus.py +101 -0
- activegraph/observability/status.py +83 -0
- activegraph/packs/__init__.py +999 -0
- activegraph/packs/diligence/__init__.py +86 -0
- activegraph/packs/diligence/behaviors.py +447 -0
- activegraph/packs/diligence/fixtures/__init__.py +364 -0
- activegraph/packs/diligence/fixtures/companies.py +511 -0
- activegraph/packs/diligence/object_types.py +163 -0
- activegraph/packs/diligence/settings.py +60 -0
- activegraph/packs/diligence/tools.py +124 -0
- activegraph/packs/loader.py +709 -0
- activegraph/packs/scaffold.py +317 -0
- activegraph/policy.py +20 -0
- activegraph/runtime/__init__.py +1 -0
- activegraph/runtime/behavior_graph.py +151 -0
- activegraph/runtime/budget.py +120 -0
- activegraph/runtime/config_errors.py +124 -0
- activegraph/runtime/diff.py +145 -0
- activegraph/runtime/errors.py +216 -0
- activegraph/runtime/exec_errors.py +232 -0
- activegraph/runtime/patterns.py +946 -0
- activegraph/runtime/queue.py +27 -0
- activegraph/runtime/registration_errors.py +291 -0
- activegraph/runtime/registry.py +111 -0
- activegraph/runtime/runtime.py +2441 -0
- activegraph/runtime/scheduler.py +206 -0
- activegraph/runtime/view_builder.py +65 -0
- activegraph/store/__init__.py +41 -0
- activegraph/store/base.py +66 -0
- activegraph/store/conformance.py +161 -0
- activegraph/store/errors.py +84 -0
- activegraph/store/memory.py +118 -0
- activegraph/store/postgres.py +609 -0
- activegraph/store/serde.py +202 -0
- activegraph/store/sqlite.py +446 -0
- activegraph/store/url.py +230 -0
- activegraph/tools/__init__.py +64 -0
- activegraph/tools/base.py +57 -0
- activegraph/tools/cache.py +157 -0
- activegraph/tools/context.py +48 -0
- activegraph/tools/decorators.py +70 -0
- activegraph/tools/errors.py +320 -0
- activegraph/tools/graph_query.py +94 -0
- activegraph/tools/recorded.py +205 -0
- activegraph/tools/web_fetch.py +80 -0
- activegraph/trace/__init__.py +1 -0
- activegraph/trace/causal.py +123 -0
- activegraph/trace/printer.py +495 -0
- activegraph-1.0.0rc2.dist-info/METADATA +228 -0
- activegraph-1.0.0rc2.dist-info/RECORD +82 -0
- activegraph-1.0.0rc2.dist-info/WHEEL +5 -0
- activegraph-1.0.0rc2.dist-info/entry_points.txt +5 -0
- 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
|