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,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
+ ]