devmemory-cli 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. devmemory/__about__.py +3 -0
  2. devmemory/__init__.py +14 -0
  3. devmemory/__main__.py +6 -0
  4. devmemory/adapters/__init__.py +6 -0
  5. devmemory/adapters/databricks.py +346 -0
  6. devmemory/adapters/entire.py +444 -0
  7. devmemory/adapters/git.py +408 -0
  8. devmemory/adapters/graph.py +251 -0
  9. devmemory/adapters/metrics.py +150 -0
  10. devmemory/adapters/tests.py +227 -0
  11. devmemory/analysis/__init__.py +19 -0
  12. devmemory/analysis/base.py +128 -0
  13. devmemory/analysis/chain.py +53 -0
  14. devmemory/analysis/llm.py +236 -0
  15. devmemory/analysis/rules.py +110 -0
  16. devmemory/api/__init__.py +10 -0
  17. devmemory/api/app.py +390 -0
  18. devmemory/api/mappers.py +187 -0
  19. devmemory/api/schemas.py +201 -0
  20. devmemory/cli/__init__.py +1 -0
  21. devmemory/cli/_errors.py +36 -0
  22. devmemory/cli/_render.py +79 -0
  23. devmemory/cli/analytics.py +136 -0
  24. devmemory/cli/analyze.py +58 -0
  25. devmemory/cli/app.py +163 -0
  26. devmemory/cli/checkpoint.py +199 -0
  27. devmemory/cli/compare.py +104 -0
  28. devmemory/cli/doctor.py +151 -0
  29. devmemory/cli/history.py +56 -0
  30. devmemory/cli/impact.py +95 -0
  31. devmemory/cli/init.py +91 -0
  32. devmemory/cli/mcp.py +66 -0
  33. devmemory/cli/memory.py +70 -0
  34. devmemory/cli/restore.py +91 -0
  35. devmemory/cli/search.py +48 -0
  36. devmemory/cli/serve.py +64 -0
  37. devmemory/cli/show.py +139 -0
  38. devmemory/cli/status.py +72 -0
  39. devmemory/cli/task.py +333 -0
  40. devmemory/config.py +302 -0
  41. devmemory/domain/__init__.py +5 -0
  42. devmemory/domain/enums.py +151 -0
  43. devmemory/domain/errors.py +188 -0
  44. devmemory/domain/models.py +452 -0
  45. devmemory/domain/taskloop.py +212 -0
  46. devmemory/environment.py +67 -0
  47. devmemory/logging.py +148 -0
  48. devmemory/mcp/__init__.py +12 -0
  49. devmemory/mcp/server.py +225 -0
  50. devmemory/paths.py +112 -0
  51. devmemory/pipeline/__init__.py +7 -0
  52. devmemory/pipeline/checkpoint.py +443 -0
  53. devmemory/pipeline/feature_detect.py +53 -0
  54. devmemory/pipeline/regression.py +141 -0
  55. devmemory/pipeline/runlog.py +73 -0
  56. devmemory/pipeline/status_rules.py +44 -0
  57. devmemory/py.typed +0 -0
  58. devmemory/services/__init__.py +9 -0
  59. devmemory/services/agent_context.py +287 -0
  60. devmemory/services/analysis.py +116 -0
  61. devmemory/services/analytics.py +328 -0
  62. devmemory/services/brief.py +53 -0
  63. devmemory/services/context.py +88 -0
  64. devmemory/services/databricks_sync.py +121 -0
  65. devmemory/services/features.py +85 -0
  66. devmemory/services/impact.py +47 -0
  67. devmemory/services/memory.py +212 -0
  68. devmemory/services/projects.py +226 -0
  69. devmemory/services/restore.py +194 -0
  70. devmemory/services/taskloop/__init__.py +39 -0
  71. devmemory/services/taskloop/collectors.py +263 -0
  72. devmemory/services/taskloop/engine.py +426 -0
  73. devmemory/services/taskloop/requirements.py +358 -0
  74. devmemory/services/trace.py +152 -0
  75. devmemory/services/versions.py +287 -0
  76. devmemory/storage/__init__.py +9 -0
  77. devmemory/storage/artifacts.py +113 -0
  78. devmemory/storage/db.py +205 -0
  79. devmemory/storage/graph_impacts.py +63 -0
  80. devmemory/storage/migrations/0001_init.sql +15 -0
  81. devmemory/storage/migrations/0002_versions.sql +210 -0
  82. devmemory/storage/migrations/0003_graph.sql +14 -0
  83. devmemory/storage/migrations/0004_taskloop.sql +82 -0
  84. devmemory/storage/migrations/0005_project_brief.sql +12 -0
  85. devmemory/storage/repositories.py +286 -0
  86. devmemory/storage/tasks.py +342 -0
  87. devmemory/storage/versions.py +604 -0
  88. devmemory/web/static/assets/index-CbV5njRH.js +78 -0
  89. devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
  90. devmemory/web/static/index.html +18 -0
  91. devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
  92. devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
  93. devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
  94. devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
  95. devmemory_cli-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,287 @@
1
+ """Development-version services: create from an event, read, list, diff, search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from datetime import UTC, datetime
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from devmemory.domain.enums import AssociationMethod, FeatureStatus, VersionStatus
12
+ from devmemory.domain.errors import DevMemoryError
13
+ from devmemory.domain.models import (
14
+ ChangedFile,
15
+ DevelopmentEvent,
16
+ DevelopmentVersion,
17
+ DiffStat,
18
+ Regression,
19
+ )
20
+ from devmemory.logging import get_logger
21
+ from devmemory.services.context import ProjectContext
22
+ from devmemory.storage.repositories import FeatureRepository
23
+ from devmemory.storage.versions import VersionRepository
24
+
25
+ _log = get_logger(__name__)
26
+
27
+
28
+ class VersionExistsError(DevMemoryError):
29
+ """A version already exists for this commit."""
30
+
31
+ exit_code = 8
32
+
33
+ def __init__(self, version: DevelopmentVersion) -> None:
34
+ super().__init__(
35
+ f"{version.version_id} already records commit {version.git_commit[:12]}.",
36
+ hint="Nothing to do. Use `--force` to supersede it (a later phase).",
37
+ )
38
+ self.version = version
39
+
40
+
41
+ class VersionNotFoundError(DevMemoryError):
42
+ exit_code = 9
43
+
44
+
45
+ class VersionDiff(BaseModel):
46
+ from_version_id: str
47
+ to_version_id: str
48
+ from_commit: str
49
+ to_commit: str
50
+ from_number: int
51
+ to_number: int
52
+ files: list[ChangedFile]
53
+ stat: DiffStat
54
+ diff_text: str
55
+ metric_changes: dict[str, dict[str, float | None]]
56
+ test_changes: dict[str, int | None]
57
+ status_from: VersionStatus
58
+ status_to: VersionStatus
59
+ feature_from: str | None = None
60
+ feature_to: str | None = None
61
+ checkpoint_from: str | None = None
62
+ checkpoint_to: str | None = None
63
+
64
+
65
+ def create_version_from_event(
66
+ ctx: ProjectContext,
67
+ event: DevelopmentEvent,
68
+ *,
69
+ force: bool = False,
70
+ regressions: list[Regression] | None = None,
71
+ ) -> DevelopmentVersion:
72
+ """Persist a normalized event as a new :class:`DevelopmentVersion`.
73
+
74
+ The checkpoint pipeline assembles ``event`` (status, tests, metrics) and the
75
+ detected ``regressions`` before calling in; the analysis layer is applied
76
+ afterwards via :func:`~devmemory.services.versions.set_analysis`.
77
+ """
78
+ repo = VersionRepository(ctx.db)
79
+
80
+ existing = repo.find_by_commit(event.project_id, event.commit.sha)
81
+ if existing is not None and not force:
82
+ raise VersionExistsError(existing)
83
+
84
+ if existing is not None:
85
+ number = existing.version_number
86
+ version_id = existing.version_id
87
+ else:
88
+ number = repo.next_version_number(event.project_id)
89
+ version_id = f"v{number}"
90
+
91
+ feature_id = _resolve_feature(ctx, event)
92
+
93
+ checkpoint = event.checkpoint
94
+ version = DevelopmentVersion(
95
+ version_id=version_id,
96
+ version_number=number,
97
+ project_id=event.project_id,
98
+ intent=event.intent or (checkpoint.intent if checkpoint else None) or event.commit.subject,
99
+ agent=event.agent or (checkpoint.agent if checkpoint else None),
100
+ model=event.model or (checkpoint.model if checkpoint else None),
101
+ git_commit=event.commit.sha,
102
+ parent_commit=event.parent_commit or event.commit.parent,
103
+ branch=event.branch,
104
+ feature_id=feature_id,
105
+ status=event.status or VersionStatus.NEEDS_REVIEW,
106
+ files_changed=len(event.changed_files),
107
+ lines_added=event.diff_stat.additions,
108
+ lines_removed=event.diff_stat.deletions,
109
+ changed_files=event.changed_files,
110
+ primary_checkpoint=checkpoint,
111
+ checkpoint_ids=[checkpoint.checkpoint_id] if checkpoint else [],
112
+ entire_association_method=(
113
+ checkpoint.association_method if checkpoint else AssociationMethod.NONE
114
+ ),
115
+ entire_association_confidence=(checkpoint.association_confidence if checkpoint else 0.0),
116
+ tests=event.tests,
117
+ metrics=event.metrics,
118
+ regressions=[r.model_copy(update={"version_id": version_id}) for r in (regressions or [])],
119
+ environment=event.environment,
120
+ run_id=event.run_id,
121
+ created_at=datetime.now(UTC),
122
+ committed_at=event.commit.committed_at,
123
+ )
124
+
125
+ replaced = existing is not None
126
+ stored = (
127
+ repo.replace(version, source_event=event)
128
+ if replaced
129
+ else repo.create(version, source_event=event)
130
+ )
131
+
132
+ _record_event(
133
+ ctx,
134
+ version_id=stored.version_id,
135
+ event_type="version.replaced" if replaced else "version.created",
136
+ payload={
137
+ "commit": stored.git_commit,
138
+ "status": stored.status.value,
139
+ "checkpoint": stored.primary_checkpoint.checkpoint_id
140
+ if stored.primary_checkpoint
141
+ else None,
142
+ "regressions": len(stored.regressions),
143
+ },
144
+ )
145
+ _log.info(
146
+ "version.replaced" if replaced else "version.created",
147
+ version=stored.version_id,
148
+ commit=stored.git_commit[:12],
149
+ status=stored.status.value,
150
+ checkpoint=stored.primary_checkpoint.checkpoint_id if stored.primary_checkpoint else None,
151
+ )
152
+ return stored
153
+
154
+
155
+ def get_version(ctx: ProjectContext, ref: str) -> DevelopmentVersion:
156
+ repo = VersionRepository(ctx.db)
157
+ project = _project_id(ctx)
158
+ version = repo.resolve(project, ref)
159
+ if version is None:
160
+ raise VersionNotFoundError(
161
+ f"No version matches {ref!r}.",
162
+ hint="Run `devmemory history` to list versions.",
163
+ )
164
+ return version
165
+
166
+
167
+ def list_versions(
168
+ ctx: ProjectContext, *, limit: int = 100, offset: int = 0, ascending: bool = True
169
+ ) -> list[DevelopmentVersion]:
170
+ return VersionRepository(ctx.db).page(
171
+ _project_id(ctx), limit=limit, offset=offset, ascending=ascending
172
+ )
173
+
174
+
175
+ def version_diff(ctx: ProjectContext, from_ref: str, to_ref: str) -> VersionDiff:
176
+ a = get_version(ctx, from_ref)
177
+ b = get_version(ctx, to_ref)
178
+
179
+ files = ctx.git.changed_files(a.git_commit, b.git_commit)
180
+ diff_text = ctx.git.diff_text(a.git_commit, b.git_commit)
181
+
182
+ metrics_a = {m.name: m for m in a.metrics}
183
+ metrics_b = {m.name: m for m in b.metrics}
184
+ metric_changes: dict[str, dict[str, float | None]] = {}
185
+ for name in sorted(set(metrics_a) | set(metrics_b)):
186
+ before = metrics_a[name].after if name in metrics_a else None
187
+ after = metrics_b[name].after if name in metrics_b else None
188
+ metric_changes[name] = {
189
+ "before": before,
190
+ "after": after,
191
+ "delta": (after - before) if before is not None and after is not None else None,
192
+ }
193
+
194
+ return VersionDiff(
195
+ from_version_id=a.version_id,
196
+ to_version_id=b.version_id,
197
+ from_commit=a.git_commit,
198
+ to_commit=b.git_commit,
199
+ from_number=a.version_number,
200
+ to_number=b.version_number,
201
+ files=files,
202
+ stat=DiffStat.from_files(files),
203
+ diff_text=diff_text,
204
+ metric_changes=metric_changes,
205
+ test_changes={
206
+ "passed": _delta(
207
+ a.tests.passed if a.tests else None, b.tests.passed if b.tests else None
208
+ ),
209
+ "failed": _delta(
210
+ a.tests.failed if a.tests else None, b.tests.failed if b.tests else None
211
+ ),
212
+ },
213
+ status_from=a.status,
214
+ status_to=b.status,
215
+ feature_from=a.feature_id.split(":", 1)[-1] if a.feature_id else None,
216
+ feature_to=b.feature_id.split(":", 1)[-1] if b.feature_id else None,
217
+ checkpoint_from=a.primary_checkpoint.checkpoint_id if a.primary_checkpoint else None,
218
+ checkpoint_to=b.primary_checkpoint.checkpoint_id if b.primary_checkpoint else None,
219
+ )
220
+
221
+
222
+ def search_versions(
223
+ ctx: ProjectContext, query: str, *, limit: int = 50
224
+ ) -> list[DevelopmentVersion]:
225
+ repo = VersionRepository(ctx.db)
226
+ project = _project_id(ctx)
227
+ ids = repo.search_ids(project, query, limit=limit)
228
+ return [v for vid in ids if (v := repo.get(vid)) is not None]
229
+
230
+
231
+ # --- helpers -----------------------------------------------------------------------
232
+
233
+
234
+ def _resolve_feature(ctx: ProjectContext, event: DevelopmentEvent) -> str | None:
235
+ if not event.feature:
236
+ return None
237
+ feature = FeatureRepository(ctx.db).upsert(
238
+ event.project_id,
239
+ event.feature,
240
+ status=FeatureStatus.IN_PROGRESS,
241
+ derived_from=event.feature_derived_from,
242
+ )
243
+ return feature.feature_id
244
+
245
+
246
+ def _record_event(
247
+ ctx: ProjectContext,
248
+ *,
249
+ version_id: str | None,
250
+ event_type: str,
251
+ payload: dict[str, object],
252
+ ) -> None:
253
+ with ctx.db.transaction() as conn:
254
+ conn.execute(
255
+ "INSERT INTO events (event_id, project_id, version_id, type, source, payload_json, "
256
+ "created_at) VALUES (?, ?, ?, ?, 'devmemory', ?, ?)",
257
+ (
258
+ uuid.uuid4().hex,
259
+ _project_id(ctx),
260
+ version_id,
261
+ event_type,
262
+ json.dumps(payload),
263
+ datetime.now(UTC).isoformat(),
264
+ ),
265
+ )
266
+
267
+
268
+ def _project_id(ctx: ProjectContext) -> str:
269
+ return ctx.config.project_id
270
+
271
+
272
+ def _delta(a: int | None, b: int | None) -> int | None:
273
+ if a is None or b is None:
274
+ return None
275
+ return b - a
276
+
277
+
278
+ __all__ = [
279
+ "VersionDiff",
280
+ "VersionExistsError",
281
+ "VersionNotFoundError",
282
+ "create_version_from_event",
283
+ "get_version",
284
+ "list_versions",
285
+ "search_versions",
286
+ "version_diff",
287
+ ]
@@ -0,0 +1,9 @@
1
+ """Local persistence: a single SQLite database under ``.devmemory/metadata.db``.
2
+
3
+ Only this package imports ``sqlite3``. Services talk to repositories; repositories
4
+ talk to :class:`~devmemory.storage.db.Database`.
5
+ """
6
+
7
+ from devmemory.storage.db import Database
8
+
9
+ __all__ = ["Database"]
@@ -0,0 +1,113 @@
1
+ """Project snapshots: a compressed archive of the source tree at a version's commit.
2
+
3
+ Built from ``git archive`` (so it is exactly the committed state - no working-tree
4
+ noise), then repacked through Python's ``tarfile`` to apply the configured
5
+ exclusions and record a content hash. Git remains the authoritative history; the
6
+ snapshot is a reproducibility convenience.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import io
13
+ import tarfile
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+
17
+ from devmemory.adapters.git import GitAdapter
18
+ from devmemory.domain.errors import StorageError
19
+ from devmemory.domain.models import Artifact
20
+ from devmemory.logging import get_logger
21
+
22
+ _log = get_logger(__name__)
23
+
24
+ _DEFAULT_EXCLUDE = (".git", ".devmemory", ".venv", "venv", "node_modules", "__pycache__")
25
+
26
+
27
+ class ArtifactStore:
28
+ def __init__(self, artifacts_dir: Path, git: GitAdapter) -> None:
29
+ self._dir = artifacts_dir
30
+ self._git = git
31
+
32
+ def create_snapshot(
33
+ self,
34
+ *,
35
+ version_id: str,
36
+ commit_sha: str,
37
+ exclude: list[str] | None = None,
38
+ ) -> Artifact:
39
+ self._dir.mkdir(parents=True, exist_ok=True)
40
+ patterns = tuple(exclude) if exclude is not None else _DEFAULT_EXCLUDE
41
+ raw = self._git.archive_tar(commit_sha)
42
+
43
+ out_path = self._dir / f"{version_id}_{commit_sha[:7]}.tar.gz"
44
+ digest = hashlib.sha256()
45
+ kept = 0
46
+ try:
47
+ with (
48
+ tarfile.open(fileobj=io.BytesIO(raw), mode="r:") as src,
49
+ tarfile.open(out_path, mode="w:gz") as dst,
50
+ ):
51
+ for member in src.getmembers():
52
+ if _excluded(member.name, patterns):
53
+ continue
54
+ extracted = src.extractfile(member) if member.isfile() else None
55
+ data = extracted.read() if extracted is not None else b""
56
+ if member.isfile():
57
+ digest.update(member.name.encode())
58
+ digest.update(data)
59
+ dst.addfile(member, io.BytesIO(data) if member.isfile() else None)
60
+ kept += 1
61
+ except (tarfile.TarError, OSError) as exc:
62
+ raise StorageError(f"could not build snapshot for {version_id}: {exc}") from exc
63
+
64
+ size = out_path.stat().st_size
65
+ _log.info(
66
+ "artifact.created", version=version_id, path=str(out_path), files=kept, bytes=size
67
+ )
68
+ return Artifact(
69
+ artifact_id=_artifact_id(version_id, commit_sha),
70
+ version_id=version_id,
71
+ path=str(out_path.relative_to(self._dir.parent.parent))
72
+ if _is_relative(out_path, self._dir.parent.parent)
73
+ else str(out_path),
74
+ type="project_snapshot",
75
+ size_bytes=size,
76
+ sha256=digest.hexdigest(),
77
+ created_at=datetime.now(UTC),
78
+ )
79
+
80
+ def extract(self, artifact: Artifact, dest: Path) -> None:
81
+ archive = self._resolve(artifact)
82
+ if not archive.is_file():
83
+ raise StorageError(f"artifact archive missing: {archive}")
84
+ dest.mkdir(parents=True, exist_ok=True)
85
+ try:
86
+ with tarfile.open(archive, mode="r:gz") as tf:
87
+ tf.extractall(dest, filter="data")
88
+ except (tarfile.TarError, OSError) as exc:
89
+ raise StorageError(f"could not extract {archive}: {exc}") from exc
90
+
91
+ def _resolve(self, artifact: Artifact) -> Path:
92
+ p = Path(artifact.path)
93
+ return p if p.is_absolute() else self._dir.parent.parent / p
94
+
95
+
96
+ def _excluded(name: str, patterns: tuple[str, ...]) -> bool:
97
+ parts = name.replace("\\", "/").split("/")
98
+ return any(p in parts for p in patterns)
99
+
100
+
101
+ def _artifact_id(version_id: str, commit_sha: str) -> str:
102
+ return hashlib.sha256(f"{version_id}:{commit_sha}".encode()).hexdigest()[:16]
103
+
104
+
105
+ def _is_relative(path: Path, base: Path) -> bool:
106
+ try:
107
+ path.relative_to(base)
108
+ except ValueError:
109
+ return False
110
+ return True
111
+
112
+
113
+ __all__ = ["ArtifactStore"]
@@ -0,0 +1,205 @@
1
+ """SQLite connection management and a forward-only schema migration runner.
2
+
3
+ Migrations are plain ``.sql`` files in ``migrations/`` named ``NNNN_slug.sql``.
4
+ They are applied in numeric order inside a transaction, and each is recorded in
5
+ ``schema_migrations`` so re-running ``migrate()`` is a no-op.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import re
12
+ import sqlite3
13
+ import threading
14
+ from collections.abc import Iterator
15
+ from contextlib import contextmanager
16
+ from dataclasses import dataclass
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+
20
+ from devmemory.domain.errors import MigrationError
21
+ from devmemory.logging import get_logger
22
+
23
+ _log = get_logger(__name__)
24
+
25
+ MIGRATIONS_DIR = Path(__file__).parent / "migrations"
26
+ _MIGRATION_RE = re.compile(r"^(\d{4})_([a-z0-9_]+)\.sql$")
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class Migration:
31
+ version: int
32
+ name: str
33
+ path: Path
34
+
35
+ @property
36
+ def sql(self) -> str:
37
+ return self.path.read_text(encoding="utf-8")
38
+
39
+
40
+ def discover_migrations(directory: Path = MIGRATIONS_DIR) -> list[Migration]:
41
+ """Return every migration file, ordered by version, validating the sequence."""
42
+ migrations: list[Migration] = []
43
+ for path in sorted(directory.glob("*.sql")):
44
+ match = _MIGRATION_RE.match(path.name)
45
+ if match is None:
46
+ raise MigrationError(
47
+ f"Migration file {path.name!r} does not match NNNN_slug.sql.",
48
+ )
49
+ migrations.append(Migration(int(match.group(1)), match.group(2), path))
50
+
51
+ for expected, migration in enumerate(migrations, start=1):
52
+ if migration.version != expected:
53
+ raise MigrationError(
54
+ f"Migration numbering gap: expected {expected:04d}, found "
55
+ f"{migration.version:04d} ({migration.name}).",
56
+ )
57
+ return migrations
58
+
59
+
60
+ class Database:
61
+ """Access to a project's metadata database.
62
+
63
+ A single connection, shared. Every read and write goes through the helpers
64
+ below, each guarded by a re-entrant lock, so the web server (uvicorn's
65
+ threadpool) and the CLI both use it safely. ``check_same_thread=False`` is set
66
+ for the server; access is still fully serialized by ``_lock``.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ path: Path,
72
+ *,
73
+ migrations_dir: Path = MIGRATIONS_DIR,
74
+ check_same_thread: bool = True,
75
+ ) -> None:
76
+ self.path = path
77
+ self._migrations_dir = migrations_dir
78
+ self._check_same_thread = check_same_thread
79
+ self._conn: sqlite3.Connection | None = None
80
+ self._lock = threading.RLock()
81
+
82
+ # -- connection ------------------------------------------------------------
83
+
84
+ @property
85
+ def connection(self) -> sqlite3.Connection:
86
+ if self._conn is None:
87
+ self.path.parent.mkdir(parents=True, exist_ok=True)
88
+ conn = sqlite3.connect(
89
+ self.path,
90
+ isolation_level=None, # autocommit; transactions are explicit
91
+ timeout=30.0,
92
+ check_same_thread=self._check_same_thread,
93
+ )
94
+ conn.row_factory = sqlite3.Row
95
+ conn.execute("PRAGMA journal_mode = WAL")
96
+ conn.execute("PRAGMA foreign_keys = ON")
97
+ conn.execute("PRAGMA busy_timeout = 30000")
98
+ self._conn = conn
99
+ return self._conn
100
+
101
+ def close(self) -> None:
102
+ with self._lock:
103
+ if self._conn is not None:
104
+ with contextlib.suppress(sqlite3.Error):
105
+ self._conn.close()
106
+ self._conn = None
107
+
108
+ def __enter__(self) -> Database:
109
+ return self
110
+
111
+ def __exit__(self, *_exc: object) -> None:
112
+ self.close()
113
+
114
+ # -- guarded access -----------------------------------------------------
115
+
116
+ def query(self, sql: str, params: tuple[object, ...] = ()) -> list[sqlite3.Row]:
117
+ with self._lock:
118
+ rows: list[sqlite3.Row] = self.connection.execute(sql, params).fetchall()
119
+ return rows
120
+
121
+ def query_one(self, sql: str, params: tuple[object, ...] = ()) -> sqlite3.Row | None:
122
+ with self._lock:
123
+ row: sqlite3.Row | None = self.connection.execute(sql, params).fetchone()
124
+ return row
125
+
126
+ def execute(self, sql: str, params: tuple[object, ...] = ()) -> None:
127
+ with self._lock:
128
+ self.connection.execute(sql, params)
129
+
130
+ @contextmanager
131
+ def transaction(self) -> Iterator[sqlite3.Connection]:
132
+ """Run a block inside ``BEGIN``/``COMMIT``, rolling back on any exception."""
133
+ with self._lock:
134
+ conn = self.connection
135
+ conn.execute("BEGIN")
136
+ try:
137
+ yield conn
138
+ except BaseException:
139
+ conn.execute("ROLLBACK")
140
+ raise
141
+ else:
142
+ conn.execute("COMMIT")
143
+
144
+ # -- migrations ----------------------------------------------------------
145
+
146
+ def _ensure_migrations_table(self) -> None:
147
+ self.execute(
148
+ """
149
+ CREATE TABLE IF NOT EXISTS schema_migrations (
150
+ version INTEGER PRIMARY KEY,
151
+ name TEXT NOT NULL,
152
+ applied_at TEXT NOT NULL
153
+ )
154
+ """
155
+ )
156
+
157
+ def applied_versions(self) -> set[int]:
158
+ self._ensure_migrations_table()
159
+ return {int(row["version"]) for row in self.query("SELECT version FROM schema_migrations")}
160
+
161
+ def migrate(self) -> list[Migration]:
162
+ """Apply every pending migration atomically. Returns the ones applied, in order.
163
+
164
+ Each migration's DDL and its ``schema_migrations`` row are committed together
165
+ inside a single ``BEGIN``/``COMMIT`` so a failure leaves no partial schema.
166
+ ``executescript`` is used (not per-statement ``execute``) so multi-statement
167
+ DDL - triggers, ``CREATE VIRTUAL TABLE``, FTS shadow tables - works.
168
+ """
169
+ with self._lock:
170
+ self._ensure_migrations_table()
171
+ applied = self.applied_versions()
172
+ pending = [
173
+ m for m in discover_migrations(self._migrations_dir) if m.version not in applied
174
+ ]
175
+ conn = self.connection
176
+ return self._apply_pending(conn, pending)
177
+
178
+ def _apply_pending(self, conn: sqlite3.Connection, pending: list[Migration]) -> list[Migration]:
179
+ for migration in pending:
180
+ _log.info("migration.apply", version=migration.version, name=migration.name)
181
+ applied_at = datetime.now(UTC).isoformat()
182
+ script = (
183
+ "BEGIN;\n"
184
+ f"{migration.sql.strip().rstrip(';')};\n"
185
+ "INSERT INTO schema_migrations (version, name, applied_at) "
186
+ f"VALUES ({migration.version}, '{migration.name}', '{applied_at}');\n"
187
+ "COMMIT;\n"
188
+ )
189
+ try:
190
+ conn.executescript(script)
191
+ except sqlite3.Error as exc:
192
+ with contextlib.suppress(sqlite3.Error):
193
+ conn.executescript("ROLLBACK;")
194
+ raise MigrationError(
195
+ f"Migration {migration.version:04d}_{migration.name} failed: {exc}",
196
+ ) from exc
197
+ return pending
198
+
199
+ def schema_version(self) -> int:
200
+ """Highest applied migration version, or 0 if none."""
201
+ versions = self.applied_versions()
202
+ return max(versions) if versions else 0
203
+
204
+
205
+ __all__ = ["Database", "Migration", "discover_migrations"]
@@ -0,0 +1,63 @@
1
+ """Persistence for graph change-impact results (Phase 12).
2
+
3
+ Kept out of the main version transaction: impact analysis is optional, slow, and
4
+ often backfilled after the fact.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from datetime import UTC, datetime
11
+
12
+ from devmemory.adapters.graph import GraphImpact
13
+ from devmemory.storage.db import Database
14
+
15
+
16
+ class GraphImpactRepository:
17
+ def __init__(self, db: Database) -> None:
18
+ self._db = db
19
+
20
+ def set(self, version_id: str, impact: GraphImpact) -> None:
21
+ generated = impact.generated_at or datetime.now(UTC).isoformat()
22
+ stored = impact.model_copy(update={"version_id": version_id, "generated_at": generated})
23
+ self._db.execute(
24
+ """
25
+ INSERT INTO graph_impacts
26
+ (version_id, base_commit, head_commit, entity_count, max_dependents,
27
+ generated_at, payload)
28
+ VALUES (?, ?, ?, ?, ?, ?, ?)
29
+ ON CONFLICT(version_id) DO UPDATE SET
30
+ base_commit = excluded.base_commit,
31
+ head_commit = excluded.head_commit,
32
+ entity_count = excluded.entity_count,
33
+ max_dependents = excluded.max_dependents,
34
+ generated_at = excluded.generated_at,
35
+ payload = excluded.payload
36
+ """,
37
+ (
38
+ version_id,
39
+ stored.base_commit,
40
+ stored.head_commit,
41
+ stored.entity_count,
42
+ stored.max_dependents,
43
+ generated,
44
+ stored.model_dump_json(),
45
+ ),
46
+ )
47
+
48
+ def get(self, version_id: str) -> GraphImpact | None:
49
+ row = self._db.query_one(
50
+ "SELECT payload FROM graph_impacts WHERE version_id = ?", (version_id,)
51
+ )
52
+ if row is None:
53
+ return None
54
+ try:
55
+ return GraphImpact.model_validate(json.loads(row["payload"]))
56
+ except (json.JSONDecodeError, ValueError):
57
+ return None
58
+
59
+ def delete(self, version_id: str) -> None:
60
+ self._db.execute("DELETE FROM graph_impacts WHERE version_id = ?", (version_id,))
61
+
62
+
63
+ __all__ = ["GraphImpactRepository"]
@@ -0,0 +1,15 @@
1
+ -- 0001_init: the project record.
2
+ --
3
+ -- One row per repository DevMemory tracks. The rich schema (versions,
4
+ -- changed_files, tests, metrics, features, regressions, entire_checkpoints,
5
+ -- analysis, artifacts, FTS) arrives in a later migration alongside the
6
+ -- persistence layer that fills it.
7
+
8
+ CREATE TABLE projects (
9
+ project_id TEXT PRIMARY KEY,
10
+ name TEXT NOT NULL,
11
+ repo_path TEXT NOT NULL,
12
+ created_at TEXT NOT NULL,
13
+ updated_at TEXT NOT NULL,
14
+ current_version_id INTEGER
15
+ );