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.
- lorebase_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- 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]
|