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.
@@ -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()