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.
- devmemory/__about__.py +3 -0
- devmemory/__init__.py +14 -0
- devmemory/__main__.py +6 -0
- devmemory/adapters/__init__.py +6 -0
- devmemory/adapters/databricks.py +346 -0
- devmemory/adapters/entire.py +444 -0
- devmemory/adapters/git.py +408 -0
- devmemory/adapters/graph.py +251 -0
- devmemory/adapters/metrics.py +150 -0
- devmemory/adapters/tests.py +227 -0
- devmemory/analysis/__init__.py +19 -0
- devmemory/analysis/base.py +128 -0
- devmemory/analysis/chain.py +53 -0
- devmemory/analysis/llm.py +236 -0
- devmemory/analysis/rules.py +110 -0
- devmemory/api/__init__.py +10 -0
- devmemory/api/app.py +390 -0
- devmemory/api/mappers.py +187 -0
- devmemory/api/schemas.py +201 -0
- devmemory/cli/__init__.py +1 -0
- devmemory/cli/_errors.py +36 -0
- devmemory/cli/_render.py +79 -0
- devmemory/cli/analytics.py +136 -0
- devmemory/cli/analyze.py +58 -0
- devmemory/cli/app.py +163 -0
- devmemory/cli/checkpoint.py +199 -0
- devmemory/cli/compare.py +104 -0
- devmemory/cli/doctor.py +151 -0
- devmemory/cli/history.py +56 -0
- devmemory/cli/impact.py +95 -0
- devmemory/cli/init.py +91 -0
- devmemory/cli/mcp.py +66 -0
- devmemory/cli/memory.py +70 -0
- devmemory/cli/restore.py +91 -0
- devmemory/cli/search.py +48 -0
- devmemory/cli/serve.py +64 -0
- devmemory/cli/show.py +139 -0
- devmemory/cli/status.py +72 -0
- devmemory/cli/task.py +333 -0
- devmemory/config.py +302 -0
- devmemory/domain/__init__.py +5 -0
- devmemory/domain/enums.py +151 -0
- devmemory/domain/errors.py +188 -0
- devmemory/domain/models.py +452 -0
- devmemory/domain/taskloop.py +212 -0
- devmemory/environment.py +67 -0
- devmemory/logging.py +148 -0
- devmemory/mcp/__init__.py +12 -0
- devmemory/mcp/server.py +225 -0
- devmemory/paths.py +112 -0
- devmemory/pipeline/__init__.py +7 -0
- devmemory/pipeline/checkpoint.py +443 -0
- devmemory/pipeline/feature_detect.py +53 -0
- devmemory/pipeline/regression.py +141 -0
- devmemory/pipeline/runlog.py +73 -0
- devmemory/pipeline/status_rules.py +44 -0
- devmemory/py.typed +0 -0
- devmemory/services/__init__.py +9 -0
- devmemory/services/agent_context.py +287 -0
- devmemory/services/analysis.py +116 -0
- devmemory/services/analytics.py +328 -0
- devmemory/services/brief.py +53 -0
- devmemory/services/context.py +88 -0
- devmemory/services/databricks_sync.py +121 -0
- devmemory/services/features.py +85 -0
- devmemory/services/impact.py +47 -0
- devmemory/services/memory.py +212 -0
- devmemory/services/projects.py +226 -0
- devmemory/services/restore.py +194 -0
- devmemory/services/taskloop/__init__.py +39 -0
- devmemory/services/taskloop/collectors.py +263 -0
- devmemory/services/taskloop/engine.py +426 -0
- devmemory/services/taskloop/requirements.py +358 -0
- devmemory/services/trace.py +152 -0
- devmemory/services/versions.py +287 -0
- devmemory/storage/__init__.py +9 -0
- devmemory/storage/artifacts.py +113 -0
- devmemory/storage/db.py +205 -0
- devmemory/storage/graph_impacts.py +63 -0
- devmemory/storage/migrations/0001_init.sql +15 -0
- devmemory/storage/migrations/0002_versions.sql +210 -0
- devmemory/storage/migrations/0003_graph.sql +14 -0
- devmemory/storage/migrations/0004_taskloop.sql +82 -0
- devmemory/storage/migrations/0005_project_brief.sql +12 -0
- devmemory/storage/repositories.py +286 -0
- devmemory/storage/tasks.py +342 -0
- devmemory/storage/versions.py +604 -0
- devmemory/web/static/assets/index-CbV5njRH.js +78 -0
- devmemory/web/static/assets/index-DD-7ceZx.css +1 -0
- devmemory/web/static/index.html +18 -0
- devmemory_cli-0.1.0.dev0.dist-info/METADATA +174 -0
- devmemory_cli-0.1.0.dev0.dist-info/RECORD +95 -0
- devmemory_cli-0.1.0.dev0.dist-info/WHEEL +4 -0
- devmemory_cli-0.1.0.dev0.dist-info/entry_points.txt +3 -0
- 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"]
|
devmemory/storage/db.py
ADDED
|
@@ -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
|
+
);
|