lorebase-store 0.2.3__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 (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,505 @@
1
+ """Steps of traced agent sessions (`agent_trace_steps`, `keeper.trace`).
2
+
3
+ `TraceRepository` appends steps in batches (at most `max_per_session` steps per session and
4
+ actor are kept: later ones are not written), lists sessions and reads the timeline of one,
5
+ and prunes steps older than the retention period. Each batch also keeps the session rows:
6
+ `agent_trace_sessions` counts the steps of a session (the cap, checked and raised under the
7
+ row lock in the writing transaction, so it holds across Keepers) and
8
+ `agent_trace_session_domains` summarizes them per Domain (sessions are listed from it);
9
+ pruning lowers both by what it deletes. A step without a session id (`inferred`) joins the
10
+ inferred session of its actor and client fingerprint with a step within the idle timeout of
11
+ it (before or after: concurrent requests finish out of order), or starts a new one. Every
12
+ statement runs with row-level security bypassed: what a caller may see is decided by
13
+ `TraceVisibility`, which the caller builds from its permissions.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import uuid
19
+ from collections import Counter, defaultdict
20
+ from collections.abc import Sequence
21
+ from dataclasses import dataclass, field, replace
22
+ from datetime import datetime, timedelta
23
+ from typing import Any
24
+
25
+ from sqlalchemy import (
26
+ ColumnElement,
27
+ Select,
28
+ and_,
29
+ delete,
30
+ func,
31
+ insert,
32
+ or_,
33
+ select,
34
+ text,
35
+ true,
36
+ tuple_,
37
+ )
38
+ from sqlalchemy.ext.asyncio import AsyncSession
39
+ from sqlalchemy.orm import InstrumentedAttribute
40
+
41
+ from lorebase_store.auth.scope import rls_scope
42
+ from lorebase_store.models import AgentTraceSessionDomain, AgentTraceStep
43
+
44
+ INFERRED_PREFIX = "inferred-"
45
+ """Prefix of the ids of inferred sessions."""
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class NewTraceStep:
50
+ """A step to write. `type` is `call` (a traced request) or `note`."""
51
+
52
+ session_id: str
53
+ actor: str
54
+ type: str
55
+ at: datetime
56
+ principal_type: str | None = None
57
+ domain_id: str | None = None
58
+ tool: str | None = None
59
+ method: str | None = None
60
+ route: str | None = None
61
+ status: int | None = None
62
+ latency_ms: float | None = None
63
+ cache: str | None = None
64
+ branch: str | None = None
65
+ query: dict[str, Any] | None = None
66
+ result: dict[str, Any] | None = None
67
+ refs: dict[str, Any] | None = None
68
+ explain: dict[str, Any] | None = None
69
+ note: str | None = None
70
+ proposal_id: str | None = None
71
+ request_id: str | None = None
72
+ trace_id: str | None = None
73
+ inferred: bool = False
74
+ """`session_id` is assigned on write: the inferred session of `fingerprint` that saw a step
75
+ within the idle timeout, or a new one."""
76
+ fingerprint: str | None = None
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class TraceStepRecord(NewTraceStep):
81
+ """A stored step; `seq` orders steps written at the same instant."""
82
+
83
+ seq: int = 0
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class TraceSessionSummary:
88
+ """One session of one actor, over the steps the reader may see."""
89
+
90
+ session_id: str
91
+ actor: str
92
+ started_at: datetime
93
+ last_at: datetime
94
+ calls: int
95
+ notes: int
96
+ domains: tuple[str, ...] = ()
97
+ tools: tuple[str, ...] = ()
98
+ proposals: tuple[str, ...] = ()
99
+ inferred: bool = False
100
+
101
+
102
+ @dataclass(frozen=True, slots=True)
103
+ class TraceVisibility:
104
+ """Which steps a reader sees: its own (`owner`), and those of others in `domains` (None:
105
+ every Domain), plus steps outside any Domain (notes) when `domainless`. `owner_domains`
106
+ limits the reader's own steps to those Domains and to steps outside any Domain (an API key
107
+ limited to Domains); None: no limit."""
108
+
109
+ owner: str
110
+ domains: frozenset[str] | None = field(default_factory=frozenset)
111
+ domainless: bool = False
112
+ owner_domains: frozenset[str] | None = None
113
+
114
+ def condition(
115
+ self,
116
+ actor: InstrumentedAttribute[str] = AgentTraceStep.actor,
117
+ domain_id: InstrumentedAttribute[str | None] = AgentTraceStep.domain_id,
118
+ ) -> ColumnElement[bool]:
119
+ """The visible rows, over the actor and Domain columns of the steps (default) or of
120
+ the session summaries."""
121
+ others: list[ColumnElement[bool]] = []
122
+ if self.domains is None:
123
+ others.append(domain_id.is_not(None))
124
+ elif self.domains:
125
+ others.append(domain_id.in_(sorted(self.domains)))
126
+ if self.domainless:
127
+ others.append(domain_id.is_(None))
128
+ own: ColumnElement[bool] = actor == self.owner
129
+ if self.owner_domains is not None:
130
+ own = and_(
131
+ own,
132
+ or_(domain_id.is_(None), domain_id.in_(sorted(self.owner_domains))),
133
+ )
134
+ return or_(own, *others) if others else own
135
+
136
+
137
+ _FIELDS = tuple(name for name in NewTraceStep.__dataclass_fields__)
138
+
139
+
140
+ def _record(row: AgentTraceStep) -> TraceStepRecord:
141
+ return TraceStepRecord(seq=row.seq, **{name: getattr(row, name) for name in _FIELDS})
142
+
143
+
144
+ def _union(lists: Sequence[Sequence[str]] | None) -> tuple[str, ...]:
145
+ return tuple(sorted({item for items in lists or () for item in items}))
146
+
147
+
148
+ _INFERRED_LOCK = text("SELECT pg_advisory_xact_lock(hashtextextended(:k, 0))")
149
+
150
+ _HOLD_SESSION = text(
151
+ "INSERT INTO agent_trace_sessions AS s (actor, session_id, steps, last_at) "
152
+ "VALUES (:actor, :session_id, 0, :at) "
153
+ "ON CONFLICT (actor, session_id) DO UPDATE SET steps = s.steps "
154
+ "RETURNING s.steps"
155
+ )
156
+ """Creates the session row or locks it; returns the steps it holds."""
157
+
158
+ _GROW_SESSION = text(
159
+ "UPDATE agent_trace_sessions SET steps = steps + :n, last_at = greatest(last_at, :at) "
160
+ "WHERE actor = :actor AND session_id = :session_id"
161
+ )
162
+
163
+ _ADD_TO_DOMAIN = text(
164
+ "INSERT INTO agent_trace_session_domains AS d (actor, session_id, domain_id, started_at, "
165
+ "last_at, calls, notes, inferred, tools, proposals) "
166
+ "VALUES (:actor, :session_id, :domain_id, :started_at, :last_at, :calls, :notes, "
167
+ ":inferred, :tools, :proposals) "
168
+ "ON CONFLICT (actor, session_id, domain_id) DO UPDATE SET "
169
+ "started_at = least(d.started_at, excluded.started_at), "
170
+ "last_at = greatest(d.last_at, excluded.last_at), "
171
+ "calls = d.calls + excluded.calls, notes = d.notes + excluded.notes, "
172
+ "inferred = d.inferred OR excluded.inferred, "
173
+ "tools = ARRAY(SELECT DISTINCT u FROM unnest(d.tools || excluded.tools) AS u ORDER BY u), "
174
+ "proposals = ARRAY(SELECT DISTINCT u FROM unnest(d.proposals || excluded.proposals) AS u "
175
+ "ORDER BY u)"
176
+ )
177
+
178
+ _SHRINK_SESSION = text(
179
+ "UPDATE agent_trace_sessions SET steps = steps - :n "
180
+ "WHERE actor = :actor AND session_id = :session_id"
181
+ )
182
+
183
+ _DROP_SESSION = text(
184
+ "DELETE FROM agent_trace_sessions "
185
+ "WHERE actor = :actor AND session_id = :session_id AND steps <= 0"
186
+ )
187
+
188
+ _LOCK_DOMAIN = text(
189
+ "SELECT 1 FROM agent_trace_session_domains "
190
+ "WHERE actor = :actor AND session_id = :session_id "
191
+ "AND domain_id IS NOT DISTINCT FROM :domain_id FOR UPDATE"
192
+ )
193
+
194
+ _SHRINK_DOMAIN = text(
195
+ "UPDATE agent_trace_session_domains AS d SET calls = d.calls - :calls, "
196
+ "notes = d.notes - :notes, "
197
+ "started_at = coalesce(s.started_at, d.started_at), "
198
+ "last_at = coalesce(s.last_at, d.last_at), "
199
+ "inferred = coalesce(s.inferred, d.inferred), "
200
+ "tools = coalesce(s.tools, d.tools), "
201
+ "proposals = coalesce(s.proposals, d.proposals) "
202
+ "FROM (SELECT min(t.at) AS started_at, max(t.at) AS last_at, "
203
+ "bool_or(t.inferred) AS inferred, "
204
+ "array_remove(array_agg(DISTINCT t.tool), NULL) AS tools, "
205
+ "array_remove(array_agg(DISTINCT t.proposal_id), NULL) AS proposals "
206
+ "FROM agent_trace_steps AS t WHERE t.actor = :actor AND t.session_id = :session_id "
207
+ "AND t.domain_id IS NOT DISTINCT FROM :domain_id) AS s "
208
+ "WHERE d.actor = :actor AND d.session_id = :session_id "
209
+ "AND d.domain_id IS NOT DISTINCT FROM :domain_id"
210
+ )
211
+ """Recomputes the summary from the steps left. Run after `_LOCK_DOMAIN`, so its snapshot
212
+ holds the steps of writers that updated the row before."""
213
+
214
+ _DROP_DOMAIN = text(
215
+ "DELETE FROM agent_trace_session_domains "
216
+ "WHERE actor = :actor AND session_id = :session_id "
217
+ "AND domain_id IS NOT DISTINCT FROM :domain_id AND calls + notes <= 0"
218
+ )
219
+
220
+ _DomainKey = tuple[str, str, str | None]
221
+
222
+
223
+ @dataclass(slots=True)
224
+ class _DomainTally:
225
+ """What a batch adds to the summary of one session in one Domain."""
226
+
227
+ started_at: datetime
228
+ last_at: datetime
229
+ calls: int = 0
230
+ notes: int = 0
231
+ inferred: bool = False
232
+ tools: set[str] = field(default_factory=set)
233
+ proposals: set[str] = field(default_factory=set)
234
+
235
+ def add(self, step: NewTraceStep) -> None:
236
+ self.started_at = min(self.started_at, step.at)
237
+ self.last_at = max(self.last_at, step.at)
238
+ if step.type == "note":
239
+ self.notes += 1
240
+ else:
241
+ self.calls += 1
242
+ self.inferred = self.inferred or step.inferred
243
+ if step.tool is not None:
244
+ self.tools.add(step.tool)
245
+ if step.proposal_id is not None:
246
+ self.proposals.add(step.proposal_id)
247
+
248
+
249
+ def _domain_order(key: _DomainKey) -> tuple[str, str, bool, str]:
250
+ """Lock order of summary rows (the null Domain first)."""
251
+ actor, session_id, domain_id = key
252
+ return actor, session_id, domain_id is not None, domain_id or ""
253
+
254
+
255
+ class TraceRepository:
256
+ """Reads and writes `agent_trace_steps` and the session rows that count and summarize
257
+ them (see the module doc)."""
258
+
259
+ def __init__(self, session: AsyncSession) -> None:
260
+ self._session = session
261
+
262
+ async def append_many(
263
+ self,
264
+ steps: Sequence[NewTraceStep],
265
+ *,
266
+ max_per_session: int,
267
+ inferred_idle_s: float = 300.0,
268
+ ) -> int:
269
+ """Write the steps in order, skipping those past `max_per_session` steps in their
270
+ session (per actor); returns the number written. Inferred steps get their session
271
+ here (`_inferred_sessions`).
272
+
273
+ The cap is the session row's `steps`, read and raised under its row lock in this
274
+ transaction, so concurrent writers never pass it. Rows are locked in a fixed order
275
+ (session rows, then summaries, each sorted), so concurrent batches do not deadlock."""
276
+ if not steps:
277
+ return 0
278
+ async with rls_scope(self._session, bypass=True):
279
+ resolved = await self._inferred_sessions(steps, inferred_idle_s)
280
+ by_session: dict[tuple[str, str], list[int]] = defaultdict(list)
281
+ for index, step in enumerate(resolved):
282
+ by_session[(step.actor, step.session_id)].append(index)
283
+ kept: set[int] = set()
284
+ for actor, session_id in sorted(by_session):
285
+ indexes = by_session[(actor, session_id)]
286
+ keys = {"actor": actor, "session_id": session_id}
287
+ held = (
288
+ await self._session.execute(
289
+ _HOLD_SESSION, {**keys, "at": resolved[indexes[0]].at}
290
+ )
291
+ ).scalar_one()
292
+ allowed = indexes[: max(0, max_per_session - int(held))]
293
+ if not allowed:
294
+ continue
295
+ await self._session.execute(
296
+ _GROW_SESSION,
297
+ {**keys, "n": len(allowed), "at": max(resolved[i].at for i in allowed)},
298
+ )
299
+ kept.update(allowed)
300
+ written = [step for index, step in enumerate(resolved) if index in kept]
301
+ if not written:
302
+ return 0
303
+ await self._session.execute(
304
+ insert(AgentTraceStep),
305
+ [{name: getattr(step, name) for name in _FIELDS} for step in written],
306
+ )
307
+ tallies: dict[_DomainKey, _DomainTally] = {}
308
+ for step in written:
309
+ key = (step.actor, step.session_id, step.domain_id)
310
+ tallies.setdefault(key, _DomainTally(step.at, step.at)).add(step)
311
+ for key in sorted(tallies, key=_domain_order):
312
+ actor, session_id, domain_id = key
313
+ tally = tallies[key]
314
+ await self._session.execute(
315
+ _ADD_TO_DOMAIN,
316
+ {
317
+ "actor": actor,
318
+ "session_id": session_id,
319
+ "domain_id": domain_id,
320
+ "started_at": tally.started_at,
321
+ "last_at": tally.last_at,
322
+ "calls": tally.calls,
323
+ "notes": tally.notes,
324
+ "inferred": tally.inferred,
325
+ "tools": sorted(tally.tools),
326
+ "proposals": sorted(tally.proposals),
327
+ },
328
+ )
329
+ return len(written)
330
+
331
+ async def _inferred_sessions(
332
+ self, steps: Sequence[NewTraceStep], idle_s: float
333
+ ) -> list[NewTraceStep]:
334
+ """`steps` with the session of each inferred one: the session of the inferred step of
335
+ the same actor and fingerprint nearest in time, at most `idle_s` before or after it
336
+ (stored, or earlier in the batch), or a new session id.
337
+
338
+ Steps are queued as their requests finish but carry the time their request started,
339
+ so a client sending requests concurrently produces steps out of time order, within a
340
+ batch and across batches; the window is looked at on both sides, and the batch is
341
+ resolved in time order. Serialized per actor and fingerprint (locks taken in sorted
342
+ order), so Keepers writing at the same time agree."""
343
+ clients = sorted({(s.actor, s.fingerprint or "") for s in steps if s.inferred})
344
+ for actor, fingerprint in clients:
345
+ await self._session.execute(_INFERRED_LOCK, {"k": f"trace\x1f{actor}\x1f{fingerprint}"})
346
+ idle = timedelta(seconds=idle_s)
347
+ latest: dict[tuple[str, str | None], tuple[str, datetime]] = {}
348
+ out = list(steps)
349
+ order = sorted(
350
+ (i for i, step in enumerate(steps) if step.inferred), key=lambda i: steps[i].at
351
+ )
352
+ for index in order:
353
+ step = steps[index]
354
+ client = (step.actor, step.fingerprint)
355
+ candidates = [await self._stored_inferred_session(step, idle)]
356
+ seen = latest.get(client)
357
+ if seen is not None and abs(step.at - seen[1]) <= idle:
358
+ candidates.append(seen)
359
+ found = min(
360
+ (c for c in candidates if c is not None),
361
+ key=lambda c: abs(step.at - c[1]),
362
+ default=None,
363
+ )
364
+ session_id = found[0] if found else f"{INFERRED_PREFIX}{uuid.uuid4().hex[:16]}"
365
+ latest[client] = (session_id, step.at)
366
+ out[index] = replace(step, session_id=session_id)
367
+ return out
368
+
369
+ async def _stored_inferred_session(
370
+ self, step: NewTraceStep, idle: timedelta
371
+ ) -> tuple[str, datetime] | None:
372
+ """The session and time of the stored inferred step of the client of `step` nearest
373
+ to it in time, within `idle` on either side; None when there is none."""
374
+ t = AgentTraceStep
375
+ found = (
376
+ await self._session.execute(
377
+ select(t.session_id, t.at)
378
+ .where(
379
+ t.inferred,
380
+ t.actor == step.actor,
381
+ t.fingerprint == step.fingerprint,
382
+ t.at >= step.at - idle,
383
+ t.at <= step.at + idle,
384
+ )
385
+ .order_by(func.abs(func.extract("epoch", t.at - step.at)), t.seq.desc())
386
+ .limit(1)
387
+ )
388
+ ).first()
389
+ return None if found is None else (found.session_id, found.at)
390
+
391
+ async def prune(self, before: datetime, *, limit: int) -> int:
392
+ """Delete up to `limit` steps older than `before`, lower the session rows by what was
393
+ deleted and recompute the summaries from the steps left (a row left without steps
394
+ goes); the number deleted."""
395
+ t = AgentTraceStep
396
+ oldest = select(t.seq).where(t.at < before).order_by(t.at).limit(limit)
397
+ async with rls_scope(self._session, bypass=True):
398
+ deleted = (
399
+ await self._session.execute(
400
+ delete(t)
401
+ .where(t.seq.in_(oldest))
402
+ .returning(t.actor, t.session_id, t.domain_id, t.type)
403
+ )
404
+ ).all()
405
+ per_session: Counter[tuple[str, str]] = Counter()
406
+ per_domain: dict[_DomainKey, Counter[str]] = defaultdict(Counter)
407
+ for actor, session_id, domain_id, kind in deleted:
408
+ per_session[(actor, session_id)] += 1
409
+ per_domain[(actor, session_id, domain_id)][kind] += 1
410
+ for (actor, session_id), count in sorted(per_session.items()):
411
+ keys = {"actor": actor, "session_id": session_id}
412
+ await self._session.execute(_SHRINK_SESSION, {**keys, "n": count})
413
+ await self._session.execute(_DROP_SESSION, keys)
414
+ for key in sorted(per_domain, key=_domain_order):
415
+ actor, session_id, domain_id = key
416
+ scope = {"actor": actor, "session_id": session_id, "domain_id": domain_id}
417
+ counts = per_domain[key]
418
+ await self._session.execute(_LOCK_DOMAIN, scope)
419
+ await self._session.execute(
420
+ _SHRINK_DOMAIN, {**scope, "calls": counts["call"], "notes": counts["note"]}
421
+ )
422
+ await self._session.execute(_DROP_DOMAIN, scope)
423
+ return len(deleted)
424
+
425
+ async def sessions(
426
+ self,
427
+ visibility: TraceVisibility,
428
+ *,
429
+ limit: int,
430
+ actor: str | None = None,
431
+ domain_id: str | None = None,
432
+ after: tuple[datetime, str, str] | None = None,
433
+ ) -> list[TraceSessionSummary]:
434
+ """Sessions with a visible step, most recent activity first, read from the per-Domain
435
+ summaries (`agent_trace_session_domains`), not from the steps. `after` is the
436
+ `(last_at, session_id, actor)` of the last session of the previous page."""
437
+ d = AgentTraceSessionDomain
438
+ conditions = [visibility.condition(d.actor, d.domain_id)]
439
+ if actor is not None:
440
+ conditions.append(d.actor == actor)
441
+ if domain_id is not None:
442
+ conditions.append(d.domain_id == domain_id)
443
+ last_at = func.max(d.last_at)
444
+ statement: Select[Any] = (
445
+ select(
446
+ d.session_id,
447
+ d.actor,
448
+ func.min(d.started_at).label("started_at"),
449
+ last_at.label("last_at"),
450
+ func.sum(d.calls).label("calls"),
451
+ func.sum(d.notes).label("notes"),
452
+ func.bool_or(d.inferred).label("inferred"),
453
+ func.array_remove(func.array_agg(d.domain_id), None).label("domains"),
454
+ func.jsonb_agg(d.tools).label("tools"),
455
+ func.jsonb_agg(d.proposals).label("proposals"),
456
+ )
457
+ .where(*conditions)
458
+ .group_by(d.session_id, d.actor)
459
+ .order_by(last_at.desc(), d.session_id.desc(), d.actor.desc())
460
+ .limit(limit)
461
+ )
462
+ if after is not None:
463
+ statement = statement.having(tuple_(last_at, d.session_id, d.actor) < tuple_(*after))
464
+ async with rls_scope(self._session, bypass=True):
465
+ rows = (await self._session.execute(statement)).mappings().all()
466
+ return [
467
+ TraceSessionSummary(
468
+ session_id=row["session_id"],
469
+ actor=row["actor"],
470
+ started_at=row["started_at"],
471
+ last_at=row["last_at"],
472
+ calls=int(row["calls"]),
473
+ notes=int(row["notes"]),
474
+ domains=tuple(sorted(set(row["domains"] or ()))),
475
+ tools=_union(row["tools"]),
476
+ proposals=_union(row["proposals"]),
477
+ inferred=bool(row["inferred"]),
478
+ )
479
+ for row in rows
480
+ ]
481
+
482
+ async def steps(
483
+ self,
484
+ session_id: str,
485
+ visibility: TraceVisibility,
486
+ *,
487
+ limit: int,
488
+ actor: str | None = None,
489
+ ) -> list[TraceStepRecord]:
490
+ """The visible steps of a session in order (time, then write order), at most
491
+ `limit`."""
492
+ t = AgentTraceStep
493
+ statement = (
494
+ select(t)
495
+ .where(
496
+ t.session_id == session_id,
497
+ visibility.condition(),
498
+ t.actor == actor if actor is not None else true(),
499
+ )
500
+ .order_by(t.at, t.seq)
501
+ .limit(limit)
502
+ )
503
+ async with rls_scope(self._session, bypass=True):
504
+ rows = (await self._session.execute(statement)).scalars().all()
505
+ return [_record(row) for row in rows]