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,328 @@
|
|
|
1
|
+
"""Development-intelligence analytics.
|
|
2
|
+
|
|
3
|
+
Every question is answered by one query with two implementations that return the
|
|
4
|
+
*same* shape: a local one over SQLite (always available, used by the demo) and a
|
|
5
|
+
Databricks one over the published Delta tables (when a workspace is configured).
|
|
6
|
+
The result carries a ``source`` so the dashboard can badge it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import Counter, defaultdict
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from devmemory.config import resolve_databricks_credentials
|
|
16
|
+
from devmemory.domain.enums import VersionStatus
|
|
17
|
+
from devmemory.domain.models import DevelopmentVersion
|
|
18
|
+
from devmemory.services.context import ProjectContext
|
|
19
|
+
from devmemory.storage.versions import VersionRepository
|
|
20
|
+
|
|
21
|
+
_ADVERSE = {VersionStatus.REGRESSION.value, VersionStatus.ERROR.value}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RegressionRow(BaseModel):
|
|
25
|
+
version_id: str
|
|
26
|
+
intent: str | None
|
|
27
|
+
feature: str | None
|
|
28
|
+
agent: str | None
|
|
29
|
+
git_commit: str
|
|
30
|
+
severity: str
|
|
31
|
+
detail: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FeatureRow(BaseModel):
|
|
35
|
+
feature: str
|
|
36
|
+
attempts: int
|
|
37
|
+
successes: int
|
|
38
|
+
regressions: int
|
|
39
|
+
success_rate: float
|
|
40
|
+
latest_status: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FileChurnRow(BaseModel):
|
|
44
|
+
path: str
|
|
45
|
+
changes: int
|
|
46
|
+
adverse_changes: int
|
|
47
|
+
risk: float
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AgentRow(BaseModel):
|
|
51
|
+
agent: str
|
|
52
|
+
versions: int
|
|
53
|
+
success_rate: float
|
|
54
|
+
regressions: int
|
|
55
|
+
tokens_per_success: float | None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TrendPoint(BaseModel):
|
|
59
|
+
version_id: str
|
|
60
|
+
version_number: int
|
|
61
|
+
status: str
|
|
62
|
+
test_pass_rate: float | None
|
|
63
|
+
key_metric: float | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FailedApproach(BaseModel):
|
|
67
|
+
signature: list[str]
|
|
68
|
+
occurrences: int
|
|
69
|
+
version_ids: list[str]
|
|
70
|
+
example_intent: str | None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AnalyticsSummary(BaseModel):
|
|
74
|
+
source: str # 'local' | 'databricks'
|
|
75
|
+
project: str
|
|
76
|
+
version_count: int
|
|
77
|
+
regression_count: int
|
|
78
|
+
success_rate: float
|
|
79
|
+
regressions: list[RegressionRow]
|
|
80
|
+
features: list[FeatureRow]
|
|
81
|
+
file_churn: list[FileChurnRow]
|
|
82
|
+
agents: list[AgentRow]
|
|
83
|
+
trend: list[TrendPoint]
|
|
84
|
+
failed_approaches: list[FailedApproach]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def analytics_summary(ctx: ProjectContext) -> AnalyticsSummary:
|
|
88
|
+
"""Prefer Databricks if configured and reachable; otherwise compute locally."""
|
|
89
|
+
if ctx.config.databricks.enabled and resolve_databricks_credentials() is not None:
|
|
90
|
+
remote = _try_databricks(ctx)
|
|
91
|
+
if remote is not None:
|
|
92
|
+
return remote
|
|
93
|
+
return _local_summary(ctx)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --- local implementation ---------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _local_summary(ctx: ProjectContext) -> AnalyticsSummary:
|
|
100
|
+
repo = VersionRepository(ctx.db)
|
|
101
|
+
versions = repo.page(ctx.config.project_id, limit=2000, ascending=True)
|
|
102
|
+
n = len(versions)
|
|
103
|
+
successes = sum(1 for v in versions if v.status is VersionStatus.SUCCESS)
|
|
104
|
+
regressions = sum(1 for v in versions if v.status is VersionStatus.REGRESSION or v.regressions)
|
|
105
|
+
|
|
106
|
+
return AnalyticsSummary(
|
|
107
|
+
source="local",
|
|
108
|
+
project=ctx.config.project_name,
|
|
109
|
+
version_count=n,
|
|
110
|
+
regression_count=regressions,
|
|
111
|
+
success_rate=round(successes / n * 100, 1) if n else 0.0,
|
|
112
|
+
regressions=_regression_rows(versions),
|
|
113
|
+
features=_feature_rows(versions),
|
|
114
|
+
file_churn=_file_churn(versions),
|
|
115
|
+
agents=_agent_rows(versions),
|
|
116
|
+
trend=_trend(versions),
|
|
117
|
+
failed_approaches=_failed_approaches(versions),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _regression_rows(versions: list[DevelopmentVersion]) -> list[RegressionRow]:
|
|
122
|
+
rows: list[RegressionRow] = []
|
|
123
|
+
for v in versions:
|
|
124
|
+
if v.status is not VersionStatus.REGRESSION and not v.regressions:
|
|
125
|
+
continue
|
|
126
|
+
worst = max(
|
|
127
|
+
v.regressions,
|
|
128
|
+
key=lambda r: {"HIGH": 3, "MEDIUM": 2, "LOW": 1}.get(r.severity, 0),
|
|
129
|
+
default=None,
|
|
130
|
+
)
|
|
131
|
+
rows.append(
|
|
132
|
+
RegressionRow(
|
|
133
|
+
version_id=v.version_id,
|
|
134
|
+
intent=v.intent,
|
|
135
|
+
feature=_feature_name(v),
|
|
136
|
+
agent=v.agent,
|
|
137
|
+
git_commit=v.git_commit,
|
|
138
|
+
severity=worst.severity if worst else "MEDIUM",
|
|
139
|
+
detail=worst.detail if worst and worst.detail else "status marked REGRESSION",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
rows.reverse()
|
|
143
|
+
return rows
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _feature_rows(versions: list[DevelopmentVersion]) -> list[FeatureRow]:
|
|
147
|
+
by_feature: dict[str, list[DevelopmentVersion]] = defaultdict(list)
|
|
148
|
+
for v in versions:
|
|
149
|
+
name = _feature_name(v)
|
|
150
|
+
if name:
|
|
151
|
+
by_feature[name].append(v)
|
|
152
|
+
out: list[FeatureRow] = []
|
|
153
|
+
for name, vs in sorted(by_feature.items(), key=lambda kv: -len(kv[1])):
|
|
154
|
+
succ = sum(1 for v in vs if v.status is VersionStatus.SUCCESS)
|
|
155
|
+
regr = sum(1 for v in vs if v.status is VersionStatus.REGRESSION or v.regressions)
|
|
156
|
+
out.append(
|
|
157
|
+
FeatureRow(
|
|
158
|
+
feature=name,
|
|
159
|
+
attempts=len(vs),
|
|
160
|
+
successes=succ,
|
|
161
|
+
regressions=regr,
|
|
162
|
+
success_rate=round(succ / len(vs) * 100, 1),
|
|
163
|
+
latest_status=vs[-1].status.value,
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _file_churn(versions: list[DevelopmentVersion]) -> list[FileChurnRow]:
|
|
170
|
+
changes: Counter[str] = Counter()
|
|
171
|
+
adverse: Counter[str] = Counter()
|
|
172
|
+
for v in versions:
|
|
173
|
+
is_bad = v.status.value in _ADVERSE or bool(v.regressions)
|
|
174
|
+
for f in v.changed_files:
|
|
175
|
+
changes[f.path] += 1
|
|
176
|
+
if is_bad:
|
|
177
|
+
adverse[f.path] += 1
|
|
178
|
+
rows = [
|
|
179
|
+
FileChurnRow(
|
|
180
|
+
path=path,
|
|
181
|
+
changes=c,
|
|
182
|
+
adverse_changes=adverse[path],
|
|
183
|
+
risk=round(adverse[path] / c, 2) if c else 0.0,
|
|
184
|
+
)
|
|
185
|
+
for path, c in changes.most_common(15)
|
|
186
|
+
]
|
|
187
|
+
return rows
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _agent_rows(versions: list[DevelopmentVersion]) -> list[AgentRow]:
|
|
191
|
+
by_agent: dict[str, list[DevelopmentVersion]] = defaultdict(list)
|
|
192
|
+
for v in versions:
|
|
193
|
+
by_agent[v.agent or "unknown"].append(v)
|
|
194
|
+
out: list[AgentRow] = []
|
|
195
|
+
for agent, vs in sorted(by_agent.items(), key=lambda kv: -len(kv[1])):
|
|
196
|
+
succ = [v for v in vs if v.status is VersionStatus.SUCCESS]
|
|
197
|
+
tokens = [
|
|
198
|
+
v.primary_checkpoint.tokens.total
|
|
199
|
+
for v in succ
|
|
200
|
+
if v.primary_checkpoint and v.primary_checkpoint.tokens.total
|
|
201
|
+
]
|
|
202
|
+
out.append(
|
|
203
|
+
AgentRow(
|
|
204
|
+
agent=agent,
|
|
205
|
+
versions=len(vs),
|
|
206
|
+
success_rate=round(len(succ) / len(vs) * 100, 1),
|
|
207
|
+
regressions=sum(1 for v in vs if v.status is VersionStatus.REGRESSION),
|
|
208
|
+
tokens_per_success=round(sum(tokens) / len(tokens)) if tokens else None,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _trend(versions: list[DevelopmentVersion]) -> list[TrendPoint]:
|
|
215
|
+
points: list[TrendPoint] = []
|
|
216
|
+
for v in versions:
|
|
217
|
+
rate = v.tests.pass_rate if v.tests and v.tests.ran else None
|
|
218
|
+
key_metric = v.metrics[0].after if v.metrics else None
|
|
219
|
+
points.append(
|
|
220
|
+
TrendPoint(
|
|
221
|
+
version_id=v.version_id,
|
|
222
|
+
version_number=v.version_number,
|
|
223
|
+
status=v.status.value,
|
|
224
|
+
test_pass_rate=round(rate * 100, 1) if rate is not None else None,
|
|
225
|
+
key_metric=key_metric,
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
return points
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _failed_approaches(versions: list[DevelopmentVersion]) -> list[FailedApproach]:
|
|
232
|
+
groups: dict[tuple[str, ...], list[DevelopmentVersion]] = defaultdict(list)
|
|
233
|
+
for v in versions:
|
|
234
|
+
if v.status.value not in _ADVERSE and not v.regressions:
|
|
235
|
+
continue
|
|
236
|
+
sig = tuple(sorted(f.path for f in v.changed_files))
|
|
237
|
+
if sig:
|
|
238
|
+
groups[sig].append(v)
|
|
239
|
+
out = [
|
|
240
|
+
FailedApproach(
|
|
241
|
+
signature=list(sig),
|
|
242
|
+
occurrences=len(vs),
|
|
243
|
+
version_ids=[v.version_id for v in vs],
|
|
244
|
+
example_intent=vs[0].intent,
|
|
245
|
+
)
|
|
246
|
+
for sig, vs in groups.items()
|
|
247
|
+
if len(vs) >= 2
|
|
248
|
+
]
|
|
249
|
+
out.sort(key=lambda a: -a.occurrences)
|
|
250
|
+
return out
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _feature_name(v: DevelopmentVersion) -> str | None:
|
|
254
|
+
return v.feature_id.split(":", 1)[-1] if v.feature_id else None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# --- databricks implementation --------------------------------------------------
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _try_databricks(ctx: ProjectContext) -> AnalyticsSummary | None:
|
|
261
|
+
from devmemory.adapters.databricks import DatabricksAdapter
|
|
262
|
+
|
|
263
|
+
adapter = DatabricksAdapter(ctx.config)
|
|
264
|
+
try:
|
|
265
|
+
rows = adapter.query(
|
|
266
|
+
f"SELECT version_id, status, agent, feature, is_regression "
|
|
267
|
+
f"FROM {adapter.table('fact_versions')} WHERE project_id = :pid",
|
|
268
|
+
{"pid": ctx.config.project_id},
|
|
269
|
+
)
|
|
270
|
+
except Exception:
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
n = len(rows)
|
|
274
|
+
if n == 0:
|
|
275
|
+
return None
|
|
276
|
+
successes = sum(1 for r in rows if r.get("status") == "SUCCESS")
|
|
277
|
+
# the Statement Execution API returns every cell as a string ("true"/"false")
|
|
278
|
+
regr = sum(
|
|
279
|
+
1
|
|
280
|
+
for r in rows
|
|
281
|
+
if str(r.get("is_regression")).lower() == "true" or r.get("status") == "REGRESSION"
|
|
282
|
+
)
|
|
283
|
+
# For the richer breakdowns Databricks would run more queries; the demo path
|
|
284
|
+
# is local, so keep the remote summary to the top-line numbers plus a marker.
|
|
285
|
+
local = _local_summary(ctx)
|
|
286
|
+
return local.model_copy(
|
|
287
|
+
update={
|
|
288
|
+
"source": "databricks",
|
|
289
|
+
"version_count": n,
|
|
290
|
+
"regression_count": regr,
|
|
291
|
+
"success_rate": round(successes / n * 100, 1),
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def canned_queries(catalog: str, schema: str) -> dict[str, str]:
|
|
297
|
+
"""SQL a user can run in Databricks directly - shown in the dashboard."""
|
|
298
|
+
t = f"{catalog}.{schema}"
|
|
299
|
+
return {
|
|
300
|
+
"regressions": (
|
|
301
|
+
f"SELECT version_id, feature, agent, intent\n"
|
|
302
|
+
f"FROM {t}.fact_versions\nWHERE is_regression = true\nORDER BY version_number DESC"
|
|
303
|
+
),
|
|
304
|
+
"feature_attempts": (
|
|
305
|
+
f"SELECT feature, count(*) attempts,\n"
|
|
306
|
+
f" round(sum(if(status='SUCCESS',1,0))*100.0/count(*), 1) success_rate_pct\n"
|
|
307
|
+
f"FROM {t}.fact_versions\nWHERE feature IS NOT NULL\nGROUP BY feature\nORDER BY attempts DESC"
|
|
308
|
+
),
|
|
309
|
+
"file_churn": (
|
|
310
|
+
f"SELECT path, count(*) changes,\n"
|
|
311
|
+
f" sum(if(v.is_regression,1,0)) adverse\n"
|
|
312
|
+
f"FROM {t}.fact_changed_files f JOIN {t}.fact_versions v USING (version_id)\n"
|
|
313
|
+
f"GROUP BY path\nORDER BY changes DESC\nLIMIT 15"
|
|
314
|
+
),
|
|
315
|
+
"agent_effectiveness": (
|
|
316
|
+
f"SELECT agent, count(*) versions,\n"
|
|
317
|
+
f" round(sum(if(status='SUCCESS',1,0))*100.0/count(*),1) success_rate_pct,\n"
|
|
318
|
+
f" sum(if(is_regression,1,0)) regressions\n"
|
|
319
|
+
f"FROM {t}.fact_versions\nWHERE agent IS NOT NULL\nGROUP BY agent\nORDER BY success_rate_pct DESC"
|
|
320
|
+
),
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
__all__ = [
|
|
325
|
+
"AnalyticsSummary",
|
|
326
|
+
"analytics_summary",
|
|
327
|
+
"canned_queries",
|
|
328
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""The project brief - a single, human-owned source of truth.
|
|
2
|
+
|
|
3
|
+
The brief is one markdown document: what the project is for, its constraints,
|
|
4
|
+
the decisions already made, the conventions to follow. It is fed into
|
|
5
|
+
requirement normalization (:mod:`devmemory.services.taskloop.requirements`) so
|
|
6
|
+
the loop's requirements and prompt suggestions stay anchored to the project
|
|
7
|
+
rather than to the wording of one task.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
|
|
15
|
+
from devmemory.services.context import ProjectContext
|
|
16
|
+
|
|
17
|
+
_MAX_CHARS = 60_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ProjectBrief:
|
|
22
|
+
content: str
|
|
23
|
+
updated_at: str | None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_brief(ctx: ProjectContext) -> ProjectBrief:
|
|
27
|
+
row = ctx.db.query_one("SELECT content, updated_at FROM project_brief WHERE id = 1")
|
|
28
|
+
if row is None:
|
|
29
|
+
return ProjectBrief(content="", updated_at=None)
|
|
30
|
+
return ProjectBrief(content=row["content"], updated_at=row["updated_at"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def set_brief(ctx: ProjectContext, content: str) -> ProjectBrief:
|
|
34
|
+
text = content.strip()[:_MAX_CHARS]
|
|
35
|
+
now = datetime.now(UTC).isoformat()
|
|
36
|
+
ctx.db.execute(
|
|
37
|
+
"INSERT INTO project_brief (id, content, updated_at) VALUES (1, ?, ?) "
|
|
38
|
+
"ON CONFLICT(id) DO UPDATE SET content = excluded.content, "
|
|
39
|
+
"updated_at = excluded.updated_at",
|
|
40
|
+
(text, now),
|
|
41
|
+
)
|
|
42
|
+
return ProjectBrief(content=text, updated_at=now)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def brief_context(ctx: ProjectContext, *, limit: int = 4000) -> str | None:
|
|
46
|
+
"""The brief trimmed for use as LLM context, or ``None`` when empty."""
|
|
47
|
+
text = get_brief(ctx).content.strip()
|
|
48
|
+
if not text:
|
|
49
|
+
return None
|
|
50
|
+
return text[:limit]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = ["ProjectBrief", "brief_context", "get_brief", "set_brief"]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""``ProjectContext`` - the wired-up bundle every entry point works through.
|
|
2
|
+
|
|
3
|
+
The CLI, the REST API, and the MCP server each build one of these and then call
|
|
4
|
+
service functions with it. It owns the DB connection lifecycle.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
|
|
13
|
+
from devmemory.adapters.entire import EntireAdapter
|
|
14
|
+
from devmemory.adapters.git import GitAdapter
|
|
15
|
+
from devmemory.adapters.graph import GraphAdapter
|
|
16
|
+
from devmemory.config import DevMemoryConfig
|
|
17
|
+
from devmemory.domain.errors import ProjectNotInitializedError
|
|
18
|
+
from devmemory.paths import ProjectPaths, find_project_paths
|
|
19
|
+
from devmemory.storage.db import Database
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class ProjectContext:
|
|
24
|
+
paths: ProjectPaths
|
|
25
|
+
config: DevMemoryConfig
|
|
26
|
+
db: Database
|
|
27
|
+
git: GitAdapter
|
|
28
|
+
entire: EntireAdapter
|
|
29
|
+
graph: GraphAdapter
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def load(cls, start: Path | str | None = None, *, thread_safe: bool = False) -> ProjectContext:
|
|
33
|
+
"""Discover the project at or above ``start`` and open it.
|
|
34
|
+
|
|
35
|
+
``thread_safe=True`` (the web server) opens the SQLite connection with
|
|
36
|
+
``check_same_thread=False`` so it can be shared across uvicorn's
|
|
37
|
+
threadpool. Raises :class:`ProjectNotInitializedError` if there is no
|
|
38
|
+
``.devmemory/``.
|
|
39
|
+
"""
|
|
40
|
+
start_path = Path(start) if start is not None else None
|
|
41
|
+
paths = find_project_paths(start_path)
|
|
42
|
+
if paths is None:
|
|
43
|
+
raise ProjectNotInitializedError
|
|
44
|
+
config = DevMemoryConfig.load(paths)
|
|
45
|
+
return cls._build(paths, config, thread_safe=thread_safe)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def for_paths(cls, paths: ProjectPaths, config: DevMemoryConfig) -> ProjectContext:
|
|
49
|
+
"""Build a context from already-resolved paths/config (used during ``init``)."""
|
|
50
|
+
return cls._build(paths, config)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def _build(
|
|
54
|
+
cls, paths: ProjectPaths, config: DevMemoryConfig, *, thread_safe: bool = False
|
|
55
|
+
) -> ProjectContext:
|
|
56
|
+
db = Database(paths.db, check_same_thread=not thread_safe)
|
|
57
|
+
db.migrate()
|
|
58
|
+
git = GitAdapter(paths.repo_root, git_binary=None)
|
|
59
|
+
entire = EntireAdapter(
|
|
60
|
+
paths.repo_root,
|
|
61
|
+
binary=config.entire.binary,
|
|
62
|
+
repo=config.entire.repo,
|
|
63
|
+
git=git,
|
|
64
|
+
)
|
|
65
|
+
graph = GraphAdapter(
|
|
66
|
+
paths.repo_root,
|
|
67
|
+
binary=config.graph.binary,
|
|
68
|
+
timeout=config.graph.timeout_seconds,
|
|
69
|
+
max_seconds=config.graph.max_seconds,
|
|
70
|
+
)
|
|
71
|
+
return cls(paths=paths, config=config, db=db, git=git, entire=entire, graph=graph)
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
self.db.close()
|
|
75
|
+
|
|
76
|
+
def __enter__(self) -> ProjectContext:
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def __exit__(
|
|
80
|
+
self,
|
|
81
|
+
_exc_type: type[BaseException] | None,
|
|
82
|
+
_exc: BaseException | None,
|
|
83
|
+
_tb: TracebackType | None,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.close()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__ = ["ProjectContext"]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Databricks sync: an offline outbox that a live push drains.
|
|
2
|
+
|
|
3
|
+
Every published version is written to ``.devmemory/outbox/<version>.json`` first.
|
|
4
|
+
If Databricks is configured and reachable, the pipeline pushes immediately and
|
|
5
|
+
removes the file; otherwise it stays queued for ``devmemory databricks push``.
|
|
6
|
+
Local history never depends on any of this.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from devmemory.adapters.databricks import (
|
|
17
|
+
DatabricksAdapter,
|
|
18
|
+
DatabricksUnavailableError,
|
|
19
|
+
outbox_event,
|
|
20
|
+
)
|
|
21
|
+
from devmemory.domain.errors import DatabricksError
|
|
22
|
+
from devmemory.domain.models import DevelopmentVersion
|
|
23
|
+
from devmemory.logging import get_logger
|
|
24
|
+
from devmemory.services.context import ProjectContext
|
|
25
|
+
|
|
26
|
+
_log = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SyncResult(BaseModel):
|
|
30
|
+
configured: bool
|
|
31
|
+
pushed: list[str] = []
|
|
32
|
+
queued: list[str] = []
|
|
33
|
+
failed: list[str] = []
|
|
34
|
+
detail: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def enqueue(ctx: ProjectContext, version: DevelopmentVersion) -> Path:
|
|
38
|
+
ctx.paths.outbox_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
path = ctx.paths.outbox_dir / f"{version.version_id}.json"
|
|
40
|
+
path.write_text(json.dumps(outbox_event(version), indent=2), encoding="utf-8")
|
|
41
|
+
return path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def push_version(ctx: ProjectContext, version: DevelopmentVersion) -> SyncResult:
|
|
45
|
+
"""Queue a version and, if Databricks is reachable, publish it now."""
|
|
46
|
+
enqueue(ctx, version)
|
|
47
|
+
if not (ctx.config.databricks.enabled and DatabricksAdapter(ctx.config).is_configured):
|
|
48
|
+
return SyncResult(configured=False, queued=[version.version_id])
|
|
49
|
+
return drain(ctx, only=version.version_id)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def drain(ctx: ProjectContext, *, only: str | None = None) -> SyncResult:
|
|
53
|
+
"""Publish every queued event (or just ``only``) to Databricks."""
|
|
54
|
+
adapter = DatabricksAdapter(ctx.config)
|
|
55
|
+
if not adapter.is_configured:
|
|
56
|
+
return SyncResult(
|
|
57
|
+
configured=False,
|
|
58
|
+
queued=[p.stem for p in _outbox_files(ctx)],
|
|
59
|
+
detail="Databricks credentials are not set (DATABRICKS_HOST/TOKEN/WAREHOUSE_ID).",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
result = SyncResult(configured=True)
|
|
63
|
+
try:
|
|
64
|
+
adapter.bootstrap()
|
|
65
|
+
except DatabricksError as exc:
|
|
66
|
+
return SyncResult(
|
|
67
|
+
configured=True,
|
|
68
|
+
queued=[p.stem for p in _outbox_files(ctx)],
|
|
69
|
+
detail=f"could not reach Databricks: {exc.message}",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
for path in _outbox_files(ctx):
|
|
73
|
+
if only is not None and path.stem != only:
|
|
74
|
+
continue
|
|
75
|
+
try:
|
|
76
|
+
version = _rehydrate(ctx, path.stem)
|
|
77
|
+
if version is None:
|
|
78
|
+
path.unlink(missing_ok=True)
|
|
79
|
+
continue
|
|
80
|
+
adapter.publish_version(version)
|
|
81
|
+
except (DatabricksUnavailableError, DatabricksError) as exc:
|
|
82
|
+
result.failed.append(path.stem)
|
|
83
|
+
result.detail = exc.message
|
|
84
|
+
_log.warning("databricks.push_failed", version=path.stem, error=exc.message)
|
|
85
|
+
break
|
|
86
|
+
else:
|
|
87
|
+
path.unlink(missing_ok=True)
|
|
88
|
+
result.pushed.append(path.stem)
|
|
89
|
+
|
|
90
|
+
result.queued = [p.stem for p in _outbox_files(ctx)]
|
|
91
|
+
if result.pushed:
|
|
92
|
+
_log.info("databricks.drained", pushed=result.pushed, remaining=result.queued)
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def sync_status(ctx: ProjectContext) -> SyncResult:
|
|
97
|
+
adapter = DatabricksAdapter(ctx.config)
|
|
98
|
+
return SyncResult(
|
|
99
|
+
configured=adapter.is_configured and ctx.config.databricks.enabled,
|
|
100
|
+
queued=[p.stem for p in _outbox_files(ctx)],
|
|
101
|
+
detail=(
|
|
102
|
+
f"catalog {ctx.config.databricks.catalog}.{ctx.config.databricks.schema_name}"
|
|
103
|
+
if adapter.is_configured
|
|
104
|
+
else "not configured"
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _outbox_files(ctx: ProjectContext) -> list[Path]:
|
|
110
|
+
if not ctx.paths.outbox_dir.is_dir():
|
|
111
|
+
return []
|
|
112
|
+
return sorted(ctx.paths.outbox_dir.glob("*.json"))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _rehydrate(ctx: ProjectContext, version_id: str) -> DevelopmentVersion | None:
|
|
116
|
+
from devmemory.storage.versions import VersionRepository
|
|
117
|
+
|
|
118
|
+
return VersionRepository(ctx.db).get(version_id)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = ["SyncResult", "drain", "enqueue", "push_version", "sync_status"]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Feature-level services: status roll-up and per-feature evolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from devmemory.domain.enums import FeatureStatus, VersionStatus
|
|
8
|
+
from devmemory.domain.errors import DevMemoryError
|
|
9
|
+
from devmemory.domain.models import DevelopmentVersion, Feature
|
|
10
|
+
from devmemory.services.context import ProjectContext
|
|
11
|
+
from devmemory.storage.repositories import FeatureRepository
|
|
12
|
+
from devmemory.storage.versions import VersionRepository
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FeatureNotFoundError(DevMemoryError):
|
|
16
|
+
exit_code = 9
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FeatureWithHistory(BaseModel):
|
|
20
|
+
feature: Feature
|
|
21
|
+
versions: list[DevelopmentVersion]
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def rolled_up_status(self) -> FeatureStatus:
|
|
25
|
+
return _roll_up(self.versions)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def list_features(ctx: ProjectContext) -> list[FeatureWithHistory]:
|
|
29
|
+
features = FeatureRepository(ctx.db).list_all(ctx.config.project_id)
|
|
30
|
+
versions = VersionRepository(ctx.db)
|
|
31
|
+
return [
|
|
32
|
+
FeatureWithHistory(feature=f, versions=versions.for_feature(f.feature_id)) for f in features
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_feature(ctx: ProjectContext, ref: str) -> FeatureWithHistory:
|
|
37
|
+
repo = FeatureRepository(ctx.db)
|
|
38
|
+
feature = repo.get(ref) or repo.get_by_name(ctx.config.project_id, ref)
|
|
39
|
+
if feature is None:
|
|
40
|
+
# tolerate a slugged id without the project prefix
|
|
41
|
+
feature = repo.get(f"{ctx.config.project_id}:{ref}")
|
|
42
|
+
if feature is None:
|
|
43
|
+
raise FeatureNotFoundError(
|
|
44
|
+
f"No feature matches {ref!r}.",
|
|
45
|
+
hint="Run `devmemory features` to list them.",
|
|
46
|
+
)
|
|
47
|
+
versions = VersionRepository(ctx.db).for_feature(feature.feature_id)
|
|
48
|
+
return FeatureWithHistory(feature=feature, versions=versions)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def refresh_feature_status(ctx: ProjectContext, feature_id: str) -> Feature | None:
|
|
52
|
+
"""Recompute and persist a feature's roll-up status from its versions."""
|
|
53
|
+
repo = FeatureRepository(ctx.db)
|
|
54
|
+
feature = repo.get(feature_id)
|
|
55
|
+
if feature is None:
|
|
56
|
+
return None
|
|
57
|
+
versions = VersionRepository(ctx.db).for_feature(feature_id)
|
|
58
|
+
return repo.upsert(
|
|
59
|
+
feature.project_id,
|
|
60
|
+
feature.name,
|
|
61
|
+
status=_roll_up(versions),
|
|
62
|
+
derived_from=feature.derived_from,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _roll_up(versions: list[DevelopmentVersion]) -> FeatureStatus:
|
|
67
|
+
if not versions:
|
|
68
|
+
return FeatureStatus.NOT_STARTED
|
|
69
|
+
latest = versions[-1]
|
|
70
|
+
if latest.status is VersionStatus.SUCCESS:
|
|
71
|
+
return FeatureStatus.COMPLETE
|
|
72
|
+
if latest.status in (VersionStatus.REGRESSION, VersionStatus.PARTIAL_SUCCESS):
|
|
73
|
+
return FeatureStatus.PARTIAL
|
|
74
|
+
if latest.status is VersionStatus.ERROR:
|
|
75
|
+
return FeatureStatus.FAILED
|
|
76
|
+
return FeatureStatus.IN_PROGRESS
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"FeatureNotFoundError",
|
|
81
|
+
"FeatureWithHistory",
|
|
82
|
+
"get_feature",
|
|
83
|
+
"list_features",
|
|
84
|
+
"refresh_feature_status",
|
|
85
|
+
]
|