devmem-agents 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devmem/__init__.py +5 -0
- devmem/api.py +257 -0
- devmem/config.py +34 -0
- devmem/embeddings.py +119 -0
- devmem/ingest.py +184 -0
- devmem/live_backend.py +344 -0
- devmem/main.py +11 -0
- devmem/models.py +157 -0
- devmem/retrieval_eval.py +145 -0
- devmem/service.py +280 -0
- devmem/storage/__init__.py +4 -0
- devmem/storage/milvus_store.py +321 -0
- devmem/storage/neptune_store.py +194 -0
- devmem/storage/record_store.py +974 -0
- devmem_agents-0.1.0.dist-info/METADATA +100 -0
- devmem_agents-0.1.0.dist-info/RECORD +19 -0
- devmem_agents-0.1.0.dist-info/WHEEL +5 -0
- devmem_agents-0.1.0.dist-info/licenses/LICENSE +21 -0
- devmem_agents-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
"""Persistent record store for devmem sessions, artifacts, decisions, etc.
|
|
2
|
+
|
|
3
|
+
Two backends:
|
|
4
|
+
|
|
5
|
+
- `MemoryRecordStore`: process-local lists. Default when no DSN is configured.
|
|
6
|
+
Fine for tests; loses all state on restart.
|
|
7
|
+
- `SqlRecordStore`: SQLAlchemy-backed persistence (Postgres/Aurora, SQLite).
|
|
8
|
+
Used when `DEVMEM_RECORD_STORE_DSN` is set. Survives restarts.
|
|
9
|
+
|
|
10
|
+
Both implement the same interface so `DevMemService` is agnostic to the backend.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import threading
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from devmem.embeddings import cosine_similarity, embed_one
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _now_iso() -> str:
|
|
28
|
+
return datetime.now(timezone.utc).isoformat()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _iso_to_ts(value: str | None) -> float:
|
|
32
|
+
if not value:
|
|
33
|
+
return 0.0
|
|
34
|
+
try:
|
|
35
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
|
36
|
+
except Exception:
|
|
37
|
+
return 0.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RecordStore(ABC):
|
|
41
|
+
"""Abstract persistence layer for session-scoped records."""
|
|
42
|
+
|
|
43
|
+
# ---- sessions --------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def create_session(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
session_id: str,
|
|
50
|
+
namespace: str,
|
|
51
|
+
project: str,
|
|
52
|
+
repo: str,
|
|
53
|
+
branch: str,
|
|
54
|
+
agent: str,
|
|
55
|
+
task: str,
|
|
56
|
+
started_at: str,
|
|
57
|
+
task_embedding: list[float] | None,
|
|
58
|
+
) -> None:
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def list_sessions(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
namespace: str,
|
|
66
|
+
project: str | None = None,
|
|
67
|
+
repo: str | None = None,
|
|
68
|
+
limit: int = 100,
|
|
69
|
+
) -> list[dict[str, Any]]:
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
# ---- artifacts -------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def insert_artifact(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
76
|
+
...
|
|
77
|
+
|
|
78
|
+
@abstractmethod
|
|
79
|
+
def query_artifacts(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
namespace: str,
|
|
83
|
+
project: str | None = None,
|
|
84
|
+
repo: str | None = None,
|
|
85
|
+
session_id: str | None = None,
|
|
86
|
+
artifact_type: str | None = None,
|
|
87
|
+
since: str | None = None,
|
|
88
|
+
limit: int = 50,
|
|
89
|
+
offset: int = 0,
|
|
90
|
+
) -> list[dict[str, Any]]:
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
# ---- decisions -------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
@abstractmethod
|
|
96
|
+
def insert_decision(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
97
|
+
...
|
|
98
|
+
|
|
99
|
+
@abstractmethod
|
|
100
|
+
def query_decisions(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
namespace: str,
|
|
104
|
+
project: str | None = None,
|
|
105
|
+
repo: str | None = None,
|
|
106
|
+
session_id: str | None = None,
|
|
107
|
+
since: str | None = None,
|
|
108
|
+
limit: int = 50,
|
|
109
|
+
offset: int = 0,
|
|
110
|
+
) -> list[dict[str, Any]]:
|
|
111
|
+
...
|
|
112
|
+
|
|
113
|
+
# ---- handoffs --------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
@abstractmethod
|
|
116
|
+
def insert_handoff(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
117
|
+
...
|
|
118
|
+
|
|
119
|
+
@abstractmethod
|
|
120
|
+
def query_handoffs(
|
|
121
|
+
self,
|
|
122
|
+
*,
|
|
123
|
+
namespace: str,
|
|
124
|
+
project: str | None = None,
|
|
125
|
+
repo: str | None = None,
|
|
126
|
+
session_id: str | None = None,
|
|
127
|
+
since: str | None = None,
|
|
128
|
+
limit: int = 50,
|
|
129
|
+
offset: int = 0,
|
|
130
|
+
) -> list[dict[str, Any]]:
|
|
131
|
+
...
|
|
132
|
+
|
|
133
|
+
# ---- misc write endpoints -------------------------------------------
|
|
134
|
+
|
|
135
|
+
@abstractmethod
|
|
136
|
+
def insert_fact(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
137
|
+
...
|
|
138
|
+
|
|
139
|
+
@abstractmethod
|
|
140
|
+
def insert_task_update(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
141
|
+
...
|
|
142
|
+
|
|
143
|
+
@abstractmethod
|
|
144
|
+
def insert_feedback(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
145
|
+
...
|
|
146
|
+
|
|
147
|
+
# ---- similarity ------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
@abstractmethod
|
|
150
|
+
def similar_tasks(
|
|
151
|
+
self,
|
|
152
|
+
*,
|
|
153
|
+
namespace: str,
|
|
154
|
+
task_embedding: list[float],
|
|
155
|
+
project: str | None = None,
|
|
156
|
+
repo: str | None = None,
|
|
157
|
+
top_k: int = 5,
|
|
158
|
+
) -> list[dict[str, Any]]:
|
|
159
|
+
...
|
|
160
|
+
|
|
161
|
+
# ---- atomic commit ---------------------------------------------------
|
|
162
|
+
|
|
163
|
+
@abstractmethod
|
|
164
|
+
def commit_session(
|
|
165
|
+
self,
|
|
166
|
+
*,
|
|
167
|
+
namespace: str,
|
|
168
|
+
session_id: str,
|
|
169
|
+
artifacts: list[dict[str, Any]],
|
|
170
|
+
decisions: list[dict[str, Any]],
|
|
171
|
+
handoff: dict[str, Any] | None,
|
|
172
|
+
task_update: dict[str, Any] | None,
|
|
173
|
+
client_commit_id: str | None,
|
|
174
|
+
) -> dict[str, Any]:
|
|
175
|
+
"""Atomically insert all session artifacts/decisions/handoff/task_update.
|
|
176
|
+
|
|
177
|
+
If `client_commit_id` is provided and was previously committed in this
|
|
178
|
+
namespace, return the cached result WITHOUT re-inserting (idempotent
|
|
179
|
+
retry). If any insert raises, the entire batch rolls back.
|
|
180
|
+
"""
|
|
181
|
+
...
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --------------------------------------------------------------------------
|
|
185
|
+
# In-memory backend
|
|
186
|
+
# --------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class MemoryRecordStore(RecordStore):
|
|
190
|
+
"""Process-local store. Thread-safe; loses data on restart."""
|
|
191
|
+
|
|
192
|
+
def __init__(self) -> None:
|
|
193
|
+
self._lock = threading.Lock()
|
|
194
|
+
self._sessions: dict[str, dict[str, Any]] = {}
|
|
195
|
+
self._artifacts: list[dict[str, Any]] = []
|
|
196
|
+
self._facts: list[dict[str, Any]] = []
|
|
197
|
+
self._decisions: list[dict[str, Any]] = []
|
|
198
|
+
self._handoffs: list[dict[str, Any]] = []
|
|
199
|
+
self._updates: list[dict[str, Any]] = []
|
|
200
|
+
self._feedback: list[dict[str, Any]] = []
|
|
201
|
+
# (namespace, client_commit_id) -> cached commit response
|
|
202
|
+
self._commit_cache: dict[tuple[str, str], dict[str, Any]] = {}
|
|
203
|
+
|
|
204
|
+
# ---- sessions --------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def create_session(
|
|
207
|
+
self,
|
|
208
|
+
*,
|
|
209
|
+
session_id: str,
|
|
210
|
+
namespace: str,
|
|
211
|
+
project: str,
|
|
212
|
+
repo: str,
|
|
213
|
+
branch: str,
|
|
214
|
+
agent: str,
|
|
215
|
+
task: str,
|
|
216
|
+
started_at: str,
|
|
217
|
+
task_embedding: list[float] | None,
|
|
218
|
+
) -> None:
|
|
219
|
+
with self._lock:
|
|
220
|
+
self._sessions[session_id] = {
|
|
221
|
+
"session_id": session_id,
|
|
222
|
+
"namespace": namespace,
|
|
223
|
+
"project": project,
|
|
224
|
+
"repo": repo,
|
|
225
|
+
"branch": branch,
|
|
226
|
+
"agent": agent,
|
|
227
|
+
"task": task,
|
|
228
|
+
"started_at": started_at,
|
|
229
|
+
"task_embedding": task_embedding,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
def list_sessions(
|
|
233
|
+
self,
|
|
234
|
+
*,
|
|
235
|
+
namespace: str,
|
|
236
|
+
project: str | None = None,
|
|
237
|
+
repo: str | None = None,
|
|
238
|
+
limit: int = 100,
|
|
239
|
+
) -> list[dict[str, Any]]:
|
|
240
|
+
with self._lock:
|
|
241
|
+
rows = [
|
|
242
|
+
{k: v for k, v in s.items() if k != "task_embedding"}
|
|
243
|
+
for s in self._sessions.values()
|
|
244
|
+
if s["namespace"] == namespace
|
|
245
|
+
and (project is None or s["project"] == project)
|
|
246
|
+
and (repo is None or s["repo"] == repo)
|
|
247
|
+
]
|
|
248
|
+
rows.sort(key=lambda r: _iso_to_ts(r.get("started_at")), reverse=True)
|
|
249
|
+
return rows[:limit]
|
|
250
|
+
|
|
251
|
+
# ---- artifacts -------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def insert_artifact(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
254
|
+
with self._lock:
|
|
255
|
+
record = {**payload, "namespace": namespace, "created_at": _now_iso()}
|
|
256
|
+
self._artifacts.append(record)
|
|
257
|
+
record_with_id = {"artifact_id": len(self._artifacts), "record": record}
|
|
258
|
+
record["artifact_id"] = record_with_id["artifact_id"]
|
|
259
|
+
return record_with_id
|
|
260
|
+
|
|
261
|
+
def query_artifacts(
|
|
262
|
+
self,
|
|
263
|
+
*,
|
|
264
|
+
namespace: str,
|
|
265
|
+
project: str | None = None,
|
|
266
|
+
repo: str | None = None,
|
|
267
|
+
session_id: str | None = None,
|
|
268
|
+
artifact_type: str | None = None,
|
|
269
|
+
since: str | None = None,
|
|
270
|
+
limit: int = 50,
|
|
271
|
+
offset: int = 0,
|
|
272
|
+
) -> list[dict[str, Any]]:
|
|
273
|
+
since_ts = _iso_to_ts(since) if since else 0.0
|
|
274
|
+
with self._lock:
|
|
275
|
+
rows = [
|
|
276
|
+
r
|
|
277
|
+
for r in self._artifacts
|
|
278
|
+
if r.get("namespace") == namespace
|
|
279
|
+
and (project is None or r.get("project") == project)
|
|
280
|
+
and (repo is None or r.get("repo") == repo)
|
|
281
|
+
and (session_id is None or r.get("session_id") == session_id)
|
|
282
|
+
and (artifact_type is None or r.get("artifact_type") == artifact_type)
|
|
283
|
+
and (not since_ts or _iso_to_ts(r.get("created_at")) >= since_ts)
|
|
284
|
+
]
|
|
285
|
+
rows.sort(key=lambda r: _iso_to_ts(r.get("created_at")), reverse=True)
|
|
286
|
+
return rows[offset : offset + limit]
|
|
287
|
+
|
|
288
|
+
# ---- decisions -------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
def insert_decision(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
291
|
+
with self._lock:
|
|
292
|
+
record = {**payload, "namespace": namespace, "created_at": _now_iso()}
|
|
293
|
+
self._decisions.append(record)
|
|
294
|
+
record_with_id = {"decision_id": len(self._decisions), "record": record}
|
|
295
|
+
record["decision_id"] = record_with_id["decision_id"]
|
|
296
|
+
return record_with_id
|
|
297
|
+
|
|
298
|
+
def query_decisions(
|
|
299
|
+
self,
|
|
300
|
+
*,
|
|
301
|
+
namespace: str,
|
|
302
|
+
project: str | None = None,
|
|
303
|
+
repo: str | None = None,
|
|
304
|
+
session_id: str | None = None,
|
|
305
|
+
since: str | None = None,
|
|
306
|
+
limit: int = 50,
|
|
307
|
+
offset: int = 0,
|
|
308
|
+
) -> list[dict[str, Any]]:
|
|
309
|
+
since_ts = _iso_to_ts(since) if since else 0.0
|
|
310
|
+
with self._lock:
|
|
311
|
+
rows = [
|
|
312
|
+
r
|
|
313
|
+
for r in self._decisions
|
|
314
|
+
if r.get("namespace") == namespace
|
|
315
|
+
and (project is None or r.get("project") == project)
|
|
316
|
+
and (repo is None or r.get("repo") == repo)
|
|
317
|
+
and (session_id is None or r.get("session_id") == session_id)
|
|
318
|
+
and (not since_ts or _iso_to_ts(r.get("created_at")) >= since_ts)
|
|
319
|
+
]
|
|
320
|
+
rows.sort(key=lambda r: _iso_to_ts(r.get("created_at")), reverse=True)
|
|
321
|
+
return rows[offset : offset + limit]
|
|
322
|
+
|
|
323
|
+
# ---- handoffs --------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
def insert_handoff(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
326
|
+
with self._lock:
|
|
327
|
+
record = {**payload, "namespace": namespace, "created_at": _now_iso()}
|
|
328
|
+
self._handoffs.append(record)
|
|
329
|
+
record_with_id = {"handoff_id": len(self._handoffs), "record": record}
|
|
330
|
+
record["handoff_id"] = record_with_id["handoff_id"]
|
|
331
|
+
return record_with_id
|
|
332
|
+
|
|
333
|
+
def query_handoffs(
|
|
334
|
+
self,
|
|
335
|
+
*,
|
|
336
|
+
namespace: str,
|
|
337
|
+
project: str | None = None,
|
|
338
|
+
repo: str | None = None,
|
|
339
|
+
session_id: str | None = None,
|
|
340
|
+
since: str | None = None,
|
|
341
|
+
limit: int = 50,
|
|
342
|
+
offset: int = 0,
|
|
343
|
+
) -> list[dict[str, Any]]:
|
|
344
|
+
since_ts = _iso_to_ts(since) if since else 0.0
|
|
345
|
+
with self._lock:
|
|
346
|
+
rows = [
|
|
347
|
+
r
|
|
348
|
+
for r in self._handoffs
|
|
349
|
+
if r.get("namespace") == namespace
|
|
350
|
+
and (project is None or r.get("project") == project)
|
|
351
|
+
and (repo is None or r.get("repo") == repo)
|
|
352
|
+
and (session_id is None or r.get("session_id") == session_id)
|
|
353
|
+
and (not since_ts or _iso_to_ts(r.get("created_at")) >= since_ts)
|
|
354
|
+
]
|
|
355
|
+
rows.sort(key=lambda r: _iso_to_ts(r.get("created_at")), reverse=True)
|
|
356
|
+
return rows[offset : offset + limit]
|
|
357
|
+
|
|
358
|
+
# ---- misc ------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
def insert_fact(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
361
|
+
with self._lock:
|
|
362
|
+
record = {**payload, "namespace": namespace, "created_at": _now_iso()}
|
|
363
|
+
self._facts.append(record)
|
|
364
|
+
return {"fact_id": len(self._facts), "record": record}
|
|
365
|
+
|
|
366
|
+
def insert_task_update(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
367
|
+
with self._lock:
|
|
368
|
+
record = {**payload, "namespace": namespace, "updated_at": _now_iso()}
|
|
369
|
+
self._updates.append(record)
|
|
370
|
+
return {"update_id": len(self._updates), "record": record}
|
|
371
|
+
|
|
372
|
+
def insert_feedback(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
373
|
+
with self._lock:
|
|
374
|
+
record = {**payload, "namespace": namespace, "created_at": _now_iso()}
|
|
375
|
+
self._feedback.append(record)
|
|
376
|
+
return {"feedback_id": len(self._feedback), "record": record}
|
|
377
|
+
|
|
378
|
+
def similar_tasks(
|
|
379
|
+
self,
|
|
380
|
+
*,
|
|
381
|
+
namespace: str,
|
|
382
|
+
task_embedding: list[float],
|
|
383
|
+
project: str | None = None,
|
|
384
|
+
repo: str | None = None,
|
|
385
|
+
top_k: int = 5,
|
|
386
|
+
) -> list[dict[str, Any]]:
|
|
387
|
+
with self._lock:
|
|
388
|
+
candidates = [
|
|
389
|
+
s
|
|
390
|
+
for s in self._sessions.values()
|
|
391
|
+
if s["namespace"] == namespace
|
|
392
|
+
and (project is None or s["project"] == project)
|
|
393
|
+
and (repo is None or s["repo"] == repo)
|
|
394
|
+
and s.get("task_embedding")
|
|
395
|
+
]
|
|
396
|
+
scored = [
|
|
397
|
+
(cosine_similarity(task_embedding, s["task_embedding"]), s) for s in candidates
|
|
398
|
+
]
|
|
399
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
400
|
+
return [
|
|
401
|
+
{
|
|
402
|
+
"score": float(score),
|
|
403
|
+
"session_id": s["session_id"],
|
|
404
|
+
"project": s["project"],
|
|
405
|
+
"repo": s["repo"],
|
|
406
|
+
"branch": s["branch"],
|
|
407
|
+
"agent": s["agent"],
|
|
408
|
+
"task": s["task"],
|
|
409
|
+
"started_at": s["started_at"],
|
|
410
|
+
}
|
|
411
|
+
for score, s in scored[:top_k]
|
|
412
|
+
]
|
|
413
|
+
|
|
414
|
+
# ---- atomic commit ---------------------------------------------------
|
|
415
|
+
|
|
416
|
+
def commit_session(
|
|
417
|
+
self,
|
|
418
|
+
*,
|
|
419
|
+
namespace: str,
|
|
420
|
+
session_id: str,
|
|
421
|
+
artifacts: list[dict[str, Any]],
|
|
422
|
+
decisions: list[dict[str, Any]],
|
|
423
|
+
handoff: dict[str, Any] | None,
|
|
424
|
+
task_update: dict[str, Any] | None,
|
|
425
|
+
client_commit_id: str | None,
|
|
426
|
+
) -> dict[str, Any]:
|
|
427
|
+
cache_key = (namespace, client_commit_id) if client_commit_id else None
|
|
428
|
+
|
|
429
|
+
with self._lock:
|
|
430
|
+
if cache_key and cache_key in self._commit_cache:
|
|
431
|
+
return {**self._commit_cache[cache_key], "idempotent_replay": True}
|
|
432
|
+
|
|
433
|
+
# Snapshot list lengths for rollback on failure. Since list.append
|
|
434
|
+
# is nothrow in CPython, rollback is a safety belt for unexpected
|
|
435
|
+
# errors (e.g. KeyboardInterrupt).
|
|
436
|
+
snapshot = (
|
|
437
|
+
len(self._artifacts),
|
|
438
|
+
len(self._decisions),
|
|
439
|
+
len(self._handoffs),
|
|
440
|
+
len(self._updates),
|
|
441
|
+
)
|
|
442
|
+
try:
|
|
443
|
+
artifact_ids: list[int] = []
|
|
444
|
+
for a in artifacts:
|
|
445
|
+
payload = {**a, "session_id": session_id}
|
|
446
|
+
rec = {**payload, "namespace": namespace, "created_at": _now_iso()}
|
|
447
|
+
self._artifacts.append(rec)
|
|
448
|
+
new_id = len(self._artifacts)
|
|
449
|
+
rec["artifact_id"] = new_id
|
|
450
|
+
artifact_ids.append(new_id)
|
|
451
|
+
|
|
452
|
+
decision_ids: list[int] = []
|
|
453
|
+
for d in decisions:
|
|
454
|
+
payload = {**d, "session_id": session_id}
|
|
455
|
+
rec = {**payload, "namespace": namespace, "created_at": _now_iso()}
|
|
456
|
+
self._decisions.append(rec)
|
|
457
|
+
new_id = len(self._decisions)
|
|
458
|
+
rec["decision_id"] = new_id
|
|
459
|
+
decision_ids.append(new_id)
|
|
460
|
+
|
|
461
|
+
handoff_id: int | None = None
|
|
462
|
+
if handoff:
|
|
463
|
+
payload = {**handoff, "session_id": session_id}
|
|
464
|
+
rec = {**payload, "namespace": namespace, "created_at": _now_iso()}
|
|
465
|
+
self._handoffs.append(rec)
|
|
466
|
+
handoff_id = len(self._handoffs)
|
|
467
|
+
rec["handoff_id"] = handoff_id
|
|
468
|
+
|
|
469
|
+
task_update_id: int | None = None
|
|
470
|
+
status = None
|
|
471
|
+
if task_update:
|
|
472
|
+
status = task_update.get("status")
|
|
473
|
+
payload = {**task_update, "session_id": session_id}
|
|
474
|
+
rec = {**payload, "namespace": namespace, "updated_at": _now_iso()}
|
|
475
|
+
self._updates.append(rec)
|
|
476
|
+
task_update_id = len(self._updates)
|
|
477
|
+
except Exception:
|
|
478
|
+
# Roll back partial inserts.
|
|
479
|
+
a0, d0, h0, u0 = snapshot
|
|
480
|
+
del self._artifacts[a0:]
|
|
481
|
+
del self._decisions[d0:]
|
|
482
|
+
del self._handoffs[h0:]
|
|
483
|
+
del self._updates[u0:]
|
|
484
|
+
raise
|
|
485
|
+
|
|
486
|
+
result = {
|
|
487
|
+
"session_id": session_id,
|
|
488
|
+
"artifact_ids": artifact_ids,
|
|
489
|
+
"decision_ids": decision_ids,
|
|
490
|
+
"handoff_id": handoff_id,
|
|
491
|
+
"task_update_id": task_update_id,
|
|
492
|
+
"status": status,
|
|
493
|
+
"idempotent_replay": False,
|
|
494
|
+
}
|
|
495
|
+
if cache_key:
|
|
496
|
+
# Cache the "not-a-replay" variant so the replay flag flips
|
|
497
|
+
# correctly on retries.
|
|
498
|
+
self._commit_cache[cache_key] = {**result, "idempotent_replay": False}
|
|
499
|
+
return result
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# --------------------------------------------------------------------------
|
|
503
|
+
# SQLAlchemy backend (Aurora / Postgres / SQLite)
|
|
504
|
+
# --------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
class SqlRecordStore(RecordStore):
|
|
508
|
+
"""SQLAlchemy-backed persistence for devmem records."""
|
|
509
|
+
|
|
510
|
+
def __init__(self, dsn: str) -> None:
|
|
511
|
+
from sqlalchemy import (
|
|
512
|
+
JSON,
|
|
513
|
+
Column,
|
|
514
|
+
DateTime,
|
|
515
|
+
Integer,
|
|
516
|
+
MetaData,
|
|
517
|
+
String,
|
|
518
|
+
Table,
|
|
519
|
+
Text,
|
|
520
|
+
create_engine,
|
|
521
|
+
select,
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
self._sa_select = select
|
|
525
|
+
self.engine = create_engine(dsn, future=True, pool_pre_ping=True)
|
|
526
|
+
self.metadata = MetaData()
|
|
527
|
+
|
|
528
|
+
self.sessions = Table(
|
|
529
|
+
"devmem_sessions",
|
|
530
|
+
self.metadata,
|
|
531
|
+
Column("session_id", String(64), primary_key=True),
|
|
532
|
+
Column("namespace", String(128), index=True, nullable=False),
|
|
533
|
+
Column("project", String(256), index=True, nullable=False),
|
|
534
|
+
Column("repo", String(256), index=True, nullable=False),
|
|
535
|
+
Column("branch", String(256), nullable=False),
|
|
536
|
+
Column("agent", String(64), nullable=False),
|
|
537
|
+
Column("task", Text, nullable=False),
|
|
538
|
+
Column("task_embedding", JSON, nullable=True),
|
|
539
|
+
Column("started_at", DateTime(timezone=True), nullable=False),
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def record_table(name: str) -> Table:
|
|
543
|
+
return Table(
|
|
544
|
+
name,
|
|
545
|
+
self.metadata,
|
|
546
|
+
Column("id", Integer, primary_key=True, autoincrement=True),
|
|
547
|
+
Column("namespace", String(128), index=True, nullable=False),
|
|
548
|
+
Column("session_id", String(64), index=True, nullable=True),
|
|
549
|
+
Column("project", String(256), index=True, nullable=True),
|
|
550
|
+
Column("repo", String(256), index=True, nullable=True),
|
|
551
|
+
Column("created_at", DateTime(timezone=True), index=True, nullable=False),
|
|
552
|
+
Column("payload", JSON, nullable=False),
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
self.artifacts = record_table("devmem_artifacts")
|
|
556
|
+
self.decisions = record_table("devmem_decisions")
|
|
557
|
+
self.handoffs = record_table("devmem_handoffs")
|
|
558
|
+
self.facts = record_table("devmem_facts")
|
|
559
|
+
self.task_updates = record_table("devmem_task_updates")
|
|
560
|
+
self.feedback = record_table("devmem_feedback")
|
|
561
|
+
|
|
562
|
+
from sqlalchemy import UniqueConstraint
|
|
563
|
+
|
|
564
|
+
self.commits = Table(
|
|
565
|
+
"devmem_commits",
|
|
566
|
+
self.metadata,
|
|
567
|
+
Column("id", Integer, primary_key=True, autoincrement=True),
|
|
568
|
+
Column("namespace", String(128), index=True, nullable=False),
|
|
569
|
+
Column("client_commit_id", String(128), nullable=False),
|
|
570
|
+
Column("session_id", String(64), index=True, nullable=False),
|
|
571
|
+
Column("created_at", DateTime(timezone=True), nullable=False),
|
|
572
|
+
Column("result", JSON, nullable=False),
|
|
573
|
+
UniqueConstraint("namespace", "client_commit_id", name="uq_devmem_commit_idem"),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
self.metadata.create_all(self.engine)
|
|
577
|
+
|
|
578
|
+
# ---- helpers ---------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
def _insert_record(
|
|
581
|
+
self,
|
|
582
|
+
table,
|
|
583
|
+
*,
|
|
584
|
+
namespace: str,
|
|
585
|
+
payload: dict[str, Any],
|
|
586
|
+
) -> dict[str, Any]:
|
|
587
|
+
now = datetime.now(timezone.utc)
|
|
588
|
+
row = {
|
|
589
|
+
"namespace": namespace,
|
|
590
|
+
"session_id": payload.get("session_id"),
|
|
591
|
+
"project": payload.get("project"),
|
|
592
|
+
"repo": payload.get("repo"),
|
|
593
|
+
"created_at": now,
|
|
594
|
+
"payload": payload,
|
|
595
|
+
}
|
|
596
|
+
with self.engine.begin() as conn:
|
|
597
|
+
result = conn.execute(table.insert().values(**row))
|
|
598
|
+
new_id = result.inserted_primary_key[0]
|
|
599
|
+
record = {**payload, "namespace": namespace, "created_at": now.isoformat()}
|
|
600
|
+
return new_id, record
|
|
601
|
+
|
|
602
|
+
def _query_records(
|
|
603
|
+
self,
|
|
604
|
+
table,
|
|
605
|
+
*,
|
|
606
|
+
namespace: str,
|
|
607
|
+
project: str | None,
|
|
608
|
+
repo: str | None,
|
|
609
|
+
session_id: str | None,
|
|
610
|
+
since: str | None,
|
|
611
|
+
limit: int,
|
|
612
|
+
offset: int,
|
|
613
|
+
extra_filters: dict[str, Any] | None = None,
|
|
614
|
+
) -> list[dict[str, Any]]:
|
|
615
|
+
stmt = self._sa_select(table).where(table.c.namespace == namespace)
|
|
616
|
+
if project is not None:
|
|
617
|
+
stmt = stmt.where(table.c.project == project)
|
|
618
|
+
if repo is not None:
|
|
619
|
+
stmt = stmt.where(table.c.repo == repo)
|
|
620
|
+
if session_id is not None:
|
|
621
|
+
stmt = stmt.where(table.c.session_id == session_id)
|
|
622
|
+
if since:
|
|
623
|
+
try:
|
|
624
|
+
since_dt = datetime.fromisoformat(since.replace("Z", "+00:00"))
|
|
625
|
+
stmt = stmt.where(table.c.created_at >= since_dt)
|
|
626
|
+
except Exception:
|
|
627
|
+
logger.debug("invalid since filter: %s", since)
|
|
628
|
+
stmt = stmt.order_by(table.c.created_at.desc()).limit(limit).offset(offset)
|
|
629
|
+
with self.engine.connect() as conn:
|
|
630
|
+
rows = list(conn.execute(stmt))
|
|
631
|
+
out = []
|
|
632
|
+
for r in rows:
|
|
633
|
+
payload = dict(r.payload) if r.payload else {}
|
|
634
|
+
payload["created_at"] = r.created_at.isoformat()
|
|
635
|
+
payload["namespace"] = r.namespace
|
|
636
|
+
if extra_filters and not all(payload.get(k) == v for k, v in extra_filters.items()):
|
|
637
|
+
continue
|
|
638
|
+
out.append(payload)
|
|
639
|
+
return out
|
|
640
|
+
|
|
641
|
+
# ---- sessions --------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
def create_session(
|
|
644
|
+
self,
|
|
645
|
+
*,
|
|
646
|
+
session_id: str,
|
|
647
|
+
namespace: str,
|
|
648
|
+
project: str,
|
|
649
|
+
repo: str,
|
|
650
|
+
branch: str,
|
|
651
|
+
agent: str,
|
|
652
|
+
task: str,
|
|
653
|
+
started_at: str,
|
|
654
|
+
task_embedding: list[float] | None,
|
|
655
|
+
) -> None:
|
|
656
|
+
try:
|
|
657
|
+
started_dt = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
658
|
+
except Exception:
|
|
659
|
+
started_dt = datetime.now(timezone.utc)
|
|
660
|
+
with self.engine.begin() as conn:
|
|
661
|
+
conn.execute(
|
|
662
|
+
self.sessions.insert().values(
|
|
663
|
+
session_id=session_id,
|
|
664
|
+
namespace=namespace,
|
|
665
|
+
project=project,
|
|
666
|
+
repo=repo,
|
|
667
|
+
branch=branch,
|
|
668
|
+
agent=agent,
|
|
669
|
+
task=task,
|
|
670
|
+
task_embedding=task_embedding,
|
|
671
|
+
started_at=started_dt,
|
|
672
|
+
)
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
def list_sessions(
|
|
676
|
+
self,
|
|
677
|
+
*,
|
|
678
|
+
namespace: str,
|
|
679
|
+
project: str | None = None,
|
|
680
|
+
repo: str | None = None,
|
|
681
|
+
limit: int = 100,
|
|
682
|
+
) -> list[dict[str, Any]]:
|
|
683
|
+
stmt = self._sa_select(self.sessions).where(self.sessions.c.namespace == namespace)
|
|
684
|
+
if project is not None:
|
|
685
|
+
stmt = stmt.where(self.sessions.c.project == project)
|
|
686
|
+
if repo is not None:
|
|
687
|
+
stmt = stmt.where(self.sessions.c.repo == repo)
|
|
688
|
+
stmt = stmt.order_by(self.sessions.c.started_at.desc()).limit(limit)
|
|
689
|
+
with self.engine.connect() as conn:
|
|
690
|
+
rows = list(conn.execute(stmt))
|
|
691
|
+
return [
|
|
692
|
+
{
|
|
693
|
+
"session_id": r.session_id,
|
|
694
|
+
"namespace": r.namespace,
|
|
695
|
+
"project": r.project,
|
|
696
|
+
"repo": r.repo,
|
|
697
|
+
"branch": r.branch,
|
|
698
|
+
"agent": r.agent,
|
|
699
|
+
"task": r.task,
|
|
700
|
+
"started_at": r.started_at.isoformat(),
|
|
701
|
+
}
|
|
702
|
+
for r in rows
|
|
703
|
+
]
|
|
704
|
+
|
|
705
|
+
# ---- artifact/decision/handoff/fact/update/feedback ------------------
|
|
706
|
+
|
|
707
|
+
def insert_artifact(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
708
|
+
new_id, record = self._insert_record(self.artifacts, namespace=namespace, payload=payload)
|
|
709
|
+
return {"artifact_id": new_id, "record": record}
|
|
710
|
+
|
|
711
|
+
def query_artifacts(
|
|
712
|
+
self,
|
|
713
|
+
*,
|
|
714
|
+
namespace: str,
|
|
715
|
+
project: str | None = None,
|
|
716
|
+
repo: str | None = None,
|
|
717
|
+
session_id: str | None = None,
|
|
718
|
+
artifact_type: str | None = None,
|
|
719
|
+
since: str | None = None,
|
|
720
|
+
limit: int = 50,
|
|
721
|
+
offset: int = 0,
|
|
722
|
+
) -> list[dict[str, Any]]:
|
|
723
|
+
extra = {"artifact_type": artifact_type} if artifact_type else None
|
|
724
|
+
return self._query_records(
|
|
725
|
+
self.artifacts,
|
|
726
|
+
namespace=namespace,
|
|
727
|
+
project=project,
|
|
728
|
+
repo=repo,
|
|
729
|
+
session_id=session_id,
|
|
730
|
+
since=since,
|
|
731
|
+
limit=limit,
|
|
732
|
+
offset=offset,
|
|
733
|
+
extra_filters=extra,
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
def insert_decision(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
737
|
+
new_id, record = self._insert_record(self.decisions, namespace=namespace, payload=payload)
|
|
738
|
+
return {"decision_id": new_id, "record": record}
|
|
739
|
+
|
|
740
|
+
def query_decisions(
|
|
741
|
+
self,
|
|
742
|
+
*,
|
|
743
|
+
namespace: str,
|
|
744
|
+
project: str | None = None,
|
|
745
|
+
repo: str | None = None,
|
|
746
|
+
session_id: str | None = None,
|
|
747
|
+
since: str | None = None,
|
|
748
|
+
limit: int = 50,
|
|
749
|
+
offset: int = 0,
|
|
750
|
+
) -> list[dict[str, Any]]:
|
|
751
|
+
return self._query_records(
|
|
752
|
+
self.decisions,
|
|
753
|
+
namespace=namespace,
|
|
754
|
+
project=project,
|
|
755
|
+
repo=repo,
|
|
756
|
+
session_id=session_id,
|
|
757
|
+
since=since,
|
|
758
|
+
limit=limit,
|
|
759
|
+
offset=offset,
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
def insert_handoff(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
763
|
+
new_id, record = self._insert_record(self.handoffs, namespace=namespace, payload=payload)
|
|
764
|
+
return {"handoff_id": new_id, "record": record}
|
|
765
|
+
|
|
766
|
+
def query_handoffs(
|
|
767
|
+
self,
|
|
768
|
+
*,
|
|
769
|
+
namespace: str,
|
|
770
|
+
project: str | None = None,
|
|
771
|
+
repo: str | None = None,
|
|
772
|
+
session_id: str | None = None,
|
|
773
|
+
since: str | None = None,
|
|
774
|
+
limit: int = 50,
|
|
775
|
+
offset: int = 0,
|
|
776
|
+
) -> list[dict[str, Any]]:
|
|
777
|
+
return self._query_records(
|
|
778
|
+
self.handoffs,
|
|
779
|
+
namespace=namespace,
|
|
780
|
+
project=project,
|
|
781
|
+
repo=repo,
|
|
782
|
+
session_id=session_id,
|
|
783
|
+
since=since,
|
|
784
|
+
limit=limit,
|
|
785
|
+
offset=offset,
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
def insert_fact(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
789
|
+
new_id, record = self._insert_record(self.facts, namespace=namespace, payload=payload)
|
|
790
|
+
return {"fact_id": new_id, "record": record}
|
|
791
|
+
|
|
792
|
+
def insert_task_update(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
793
|
+
new_id, record = self._insert_record(self.task_updates, namespace=namespace, payload=payload)
|
|
794
|
+
return {"update_id": new_id, "record": record}
|
|
795
|
+
|
|
796
|
+
def insert_feedback(self, *, namespace: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
797
|
+
new_id, record = self._insert_record(self.feedback, namespace=namespace, payload=payload)
|
|
798
|
+
return {"feedback_id": new_id, "record": record}
|
|
799
|
+
|
|
800
|
+
def similar_tasks(
|
|
801
|
+
self,
|
|
802
|
+
*,
|
|
803
|
+
namespace: str,
|
|
804
|
+
task_embedding: list[float],
|
|
805
|
+
project: str | None = None,
|
|
806
|
+
repo: str | None = None,
|
|
807
|
+
top_k: int = 5,
|
|
808
|
+
) -> list[dict[str, Any]]:
|
|
809
|
+
stmt = self._sa_select(self.sessions).where(
|
|
810
|
+
self.sessions.c.namespace == namespace,
|
|
811
|
+
self.sessions.c.task_embedding.is_not(None),
|
|
812
|
+
)
|
|
813
|
+
if project is not None:
|
|
814
|
+
stmt = stmt.where(self.sessions.c.project == project)
|
|
815
|
+
if repo is not None:
|
|
816
|
+
stmt = stmt.where(self.sessions.c.repo == repo)
|
|
817
|
+
with self.engine.connect() as conn:
|
|
818
|
+
rows = list(conn.execute(stmt))
|
|
819
|
+
scored: list[tuple[float, Any]] = []
|
|
820
|
+
for r in rows:
|
|
821
|
+
emb = r.task_embedding
|
|
822
|
+
if not emb:
|
|
823
|
+
continue
|
|
824
|
+
scored.append((cosine_similarity(task_embedding, emb), r))
|
|
825
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
826
|
+
return [
|
|
827
|
+
{
|
|
828
|
+
"score": float(score),
|
|
829
|
+
"session_id": r.session_id,
|
|
830
|
+
"project": r.project,
|
|
831
|
+
"repo": r.repo,
|
|
832
|
+
"branch": r.branch,
|
|
833
|
+
"agent": r.agent,
|
|
834
|
+
"task": r.task,
|
|
835
|
+
"started_at": r.started_at.isoformat(),
|
|
836
|
+
}
|
|
837
|
+
for score, r in scored[:top_k]
|
|
838
|
+
]
|
|
839
|
+
|
|
840
|
+
# ---- atomic commit ---------------------------------------------------
|
|
841
|
+
|
|
842
|
+
def commit_session(
|
|
843
|
+
self,
|
|
844
|
+
*,
|
|
845
|
+
namespace: str,
|
|
846
|
+
session_id: str,
|
|
847
|
+
artifacts: list[dict[str, Any]],
|
|
848
|
+
decisions: list[dict[str, Any]],
|
|
849
|
+
handoff: dict[str, Any] | None,
|
|
850
|
+
task_update: dict[str, Any] | None,
|
|
851
|
+
client_commit_id: str | None,
|
|
852
|
+
) -> dict[str, Any]:
|
|
853
|
+
now = datetime.now(timezone.utc)
|
|
854
|
+
|
|
855
|
+
# Idempotency check (pre-transaction): cheaper to short-circuit before
|
|
856
|
+
# opening a write transaction.
|
|
857
|
+
if client_commit_id:
|
|
858
|
+
stmt = self._sa_select(self.commits).where(
|
|
859
|
+
self.commits.c.namespace == namespace,
|
|
860
|
+
self.commits.c.client_commit_id == client_commit_id,
|
|
861
|
+
)
|
|
862
|
+
with self.engine.connect() as conn:
|
|
863
|
+
existing = conn.execute(stmt).first()
|
|
864
|
+
if existing is not None:
|
|
865
|
+
cached = dict(existing.result)
|
|
866
|
+
return {**cached, "idempotent_replay": True}
|
|
867
|
+
|
|
868
|
+
with self.engine.begin() as conn:
|
|
869
|
+
artifact_ids: list[int] = []
|
|
870
|
+
for a in artifacts:
|
|
871
|
+
payload = {**a, "session_id": session_id}
|
|
872
|
+
row = {
|
|
873
|
+
"namespace": namespace,
|
|
874
|
+
"session_id": session_id,
|
|
875
|
+
"project": payload.get("project"),
|
|
876
|
+
"repo": payload.get("repo"),
|
|
877
|
+
"created_at": now,
|
|
878
|
+
"payload": payload,
|
|
879
|
+
}
|
|
880
|
+
res = conn.execute(self.artifacts.insert().values(**row))
|
|
881
|
+
artifact_ids.append(res.inserted_primary_key[0])
|
|
882
|
+
|
|
883
|
+
decision_ids: list[int] = []
|
|
884
|
+
for d in decisions:
|
|
885
|
+
payload = {**d, "session_id": session_id}
|
|
886
|
+
row = {
|
|
887
|
+
"namespace": namespace,
|
|
888
|
+
"session_id": session_id,
|
|
889
|
+
"project": payload.get("project"),
|
|
890
|
+
"repo": payload.get("repo"),
|
|
891
|
+
"created_at": now,
|
|
892
|
+
"payload": payload,
|
|
893
|
+
}
|
|
894
|
+
res = conn.execute(self.decisions.insert().values(**row))
|
|
895
|
+
decision_ids.append(res.inserted_primary_key[0])
|
|
896
|
+
|
|
897
|
+
handoff_id: int | None = None
|
|
898
|
+
if handoff:
|
|
899
|
+
payload = {**handoff, "session_id": session_id}
|
|
900
|
+
row = {
|
|
901
|
+
"namespace": namespace,
|
|
902
|
+
"session_id": session_id,
|
|
903
|
+
"project": payload.get("project"),
|
|
904
|
+
"repo": payload.get("repo"),
|
|
905
|
+
"created_at": now,
|
|
906
|
+
"payload": payload,
|
|
907
|
+
}
|
|
908
|
+
res = conn.execute(self.handoffs.insert().values(**row))
|
|
909
|
+
handoff_id = res.inserted_primary_key[0]
|
|
910
|
+
|
|
911
|
+
task_update_id: int | None = None
|
|
912
|
+
status = None
|
|
913
|
+
if task_update:
|
|
914
|
+
status = task_update.get("status")
|
|
915
|
+
payload = {**task_update, "session_id": session_id}
|
|
916
|
+
row = {
|
|
917
|
+
"namespace": namespace,
|
|
918
|
+
"session_id": session_id,
|
|
919
|
+
"project": None,
|
|
920
|
+
"repo": None,
|
|
921
|
+
"created_at": now,
|
|
922
|
+
"payload": payload,
|
|
923
|
+
}
|
|
924
|
+
res = conn.execute(self.task_updates.insert().values(**row))
|
|
925
|
+
task_update_id = res.inserted_primary_key[0]
|
|
926
|
+
|
|
927
|
+
result = {
|
|
928
|
+
"session_id": session_id,
|
|
929
|
+
"artifact_ids": artifact_ids,
|
|
930
|
+
"decision_ids": decision_ids,
|
|
931
|
+
"handoff_id": handoff_id,
|
|
932
|
+
"task_update_id": task_update_id,
|
|
933
|
+
"status": status,
|
|
934
|
+
"idempotent_replay": False,
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if client_commit_id:
|
|
938
|
+
# Racing clients may both attempt to insert the same commit
|
|
939
|
+
# key; the UNIQUE constraint turns the loser into an
|
|
940
|
+
# IntegrityError that rolls back the whole transaction.
|
|
941
|
+
conn.execute(
|
|
942
|
+
self.commits.insert().values(
|
|
943
|
+
namespace=namespace,
|
|
944
|
+
client_commit_id=client_commit_id,
|
|
945
|
+
session_id=session_id,
|
|
946
|
+
created_at=now,
|
|
947
|
+
result=result,
|
|
948
|
+
)
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
return result
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
# --------------------------------------------------------------------------
|
|
955
|
+
# Factory
|
|
956
|
+
# --------------------------------------------------------------------------
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def build_record_store(dsn: str | None) -> RecordStore:
|
|
960
|
+
"""Return a SqlRecordStore if DSN is set, else in-memory."""
|
|
961
|
+
if not dsn:
|
|
962
|
+
return MemoryRecordStore()
|
|
963
|
+
try:
|
|
964
|
+
store = SqlRecordStore(dsn)
|
|
965
|
+
logger.info("Using SQL record store dsn=<redacted>")
|
|
966
|
+
return store
|
|
967
|
+
except Exception as exc:
|
|
968
|
+
logger.error(
|
|
969
|
+
"Failed to initialize SQL record store (%s); falling back to in-memory. "
|
|
970
|
+
"Data will NOT persist across restarts.",
|
|
971
|
+
exc,
|
|
972
|
+
exc_info=True,
|
|
973
|
+
)
|
|
974
|
+
return MemoryRecordStore()
|