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,342 @@
|
|
|
1
|
+
"""Data access for the state-aware coding loop (migration 0004).
|
|
2
|
+
|
|
3
|
+
Follows the same rules as :mod:`devmemory.storage.repositories`: every SQL
|
|
4
|
+
statement for tasks / requirements / issues / test runs / state snapshots lives
|
|
5
|
+
here, and methods return :mod:`devmemory.domain.taskloop` models.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
|
|
14
|
+
from devmemory.domain.enums import IssueStatus, RequirementStatus, TaskStatus, TestRunStatus
|
|
15
|
+
from devmemory.domain.taskloop import (
|
|
16
|
+
Issue,
|
|
17
|
+
NormalizedState,
|
|
18
|
+
Requirement,
|
|
19
|
+
StateSnapshot,
|
|
20
|
+
Task,
|
|
21
|
+
TaskTestRun,
|
|
22
|
+
)
|
|
23
|
+
from devmemory.storage.db import Database
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _utcnow() -> str:
|
|
27
|
+
return datetime.now(UTC).isoformat()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _dt(value: str | None) -> datetime | None:
|
|
31
|
+
if not value:
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
return datetime.fromisoformat(value)
|
|
35
|
+
except ValueError:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TaskRepository:
|
|
40
|
+
def __init__(self, db: Database) -> None:
|
|
41
|
+
self._db = db
|
|
42
|
+
|
|
43
|
+
# -- tasks ---------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def next_task_id(self) -> str:
|
|
46
|
+
row = self._db.query_one("SELECT COUNT(*) AS n FROM tasks")
|
|
47
|
+
return f"TASK-{(int(row['n']) if row else 0) + 1:03d}"
|
|
48
|
+
|
|
49
|
+
def create(self, task: Task) -> Task:
|
|
50
|
+
now = _utcnow()
|
|
51
|
+
with self._db.transaction() as conn:
|
|
52
|
+
conn.execute(
|
|
53
|
+
"""
|
|
54
|
+
INSERT INTO tasks (id, goal, status, branch, base_commit, test_command,
|
|
55
|
+
push_policy, created_at, updated_at)
|
|
56
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
57
|
+
""",
|
|
58
|
+
(
|
|
59
|
+
task.id,
|
|
60
|
+
task.goal,
|
|
61
|
+
task.status.value,
|
|
62
|
+
task.branch,
|
|
63
|
+
task.base_commit,
|
|
64
|
+
task.test_command,
|
|
65
|
+
task.push_policy,
|
|
66
|
+
now,
|
|
67
|
+
now,
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
for pos, req in enumerate(task.requirements):
|
|
71
|
+
conn.execute(
|
|
72
|
+
"""
|
|
73
|
+
INSERT INTO requirements (task_id, req_id, description, status, reason,
|
|
74
|
+
evidence_json, evaluated_at, evaluated_by, position)
|
|
75
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
76
|
+
""",
|
|
77
|
+
(
|
|
78
|
+
task.id,
|
|
79
|
+
req.id,
|
|
80
|
+
req.description,
|
|
81
|
+
req.status.value,
|
|
82
|
+
req.reason,
|
|
83
|
+
json.dumps(req.evidence),
|
|
84
|
+
req.evaluated_at.isoformat() if req.evaluated_at else None,
|
|
85
|
+
req.evaluated_by,
|
|
86
|
+
pos,
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
return self.get(task.id) # type: ignore[return-value]
|
|
90
|
+
|
|
91
|
+
def get(self, task_id: str) -> Task | None:
|
|
92
|
+
row = self._db.query_one("SELECT * FROM tasks WHERE id = ?", (task_id,))
|
|
93
|
+
if row is None:
|
|
94
|
+
return None
|
|
95
|
+
return Task(
|
|
96
|
+
id=row["id"],
|
|
97
|
+
goal=row["goal"],
|
|
98
|
+
status=TaskStatus(row["status"]),
|
|
99
|
+
branch=row["branch"],
|
|
100
|
+
base_commit=row["base_commit"],
|
|
101
|
+
test_command=row["test_command"],
|
|
102
|
+
push_policy=row["push_policy"],
|
|
103
|
+
created_at=_dt(row["created_at"]),
|
|
104
|
+
updated_at=_dt(row["updated_at"]),
|
|
105
|
+
requirements=self.requirements(task_id),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def list_tasks(self) -> list[Task]:
|
|
109
|
+
rows = self._db.query("SELECT id FROM tasks ORDER BY created_at")
|
|
110
|
+
return [t for r in rows if (t := self.get(r["id"])) is not None]
|
|
111
|
+
|
|
112
|
+
def latest(self) -> Task | None:
|
|
113
|
+
row = self._db.query_one("SELECT id FROM tasks ORDER BY created_at DESC LIMIT 1")
|
|
114
|
+
return self.get(row["id"]) if row else None
|
|
115
|
+
|
|
116
|
+
def set_status(self, task_id: str, status: TaskStatus) -> None:
|
|
117
|
+
self._db.execute(
|
|
118
|
+
"UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?",
|
|
119
|
+
(status.value, _utcnow(), task_id),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# -- requirements ------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def requirements(self, task_id: str) -> list[Requirement]:
|
|
125
|
+
rows = self._db.query(
|
|
126
|
+
"SELECT * FROM requirements WHERE task_id = ? ORDER BY position, req_id",
|
|
127
|
+
(task_id,),
|
|
128
|
+
)
|
|
129
|
+
out: list[Requirement] = []
|
|
130
|
+
for row in rows:
|
|
131
|
+
try:
|
|
132
|
+
evidence = json.loads(row["evidence_json"]) or []
|
|
133
|
+
except (json.JSONDecodeError, TypeError):
|
|
134
|
+
evidence = []
|
|
135
|
+
out.append(
|
|
136
|
+
Requirement(
|
|
137
|
+
id=row["req_id"],
|
|
138
|
+
description=row["description"],
|
|
139
|
+
status=RequirementStatus(row["status"]),
|
|
140
|
+
reason=row["reason"],
|
|
141
|
+
evidence=list(evidence),
|
|
142
|
+
evaluated_at=_dt(row["evaluated_at"]),
|
|
143
|
+
evaluated_by=row["evaluated_by"],
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
def update_requirement(self, task_id: str, req: Requirement) -> None:
|
|
149
|
+
self._db.execute(
|
|
150
|
+
"""
|
|
151
|
+
UPDATE requirements
|
|
152
|
+
SET status = ?, reason = ?, evidence_json = ?, evaluated_at = ?, evaluated_by = ?
|
|
153
|
+
WHERE task_id = ? AND req_id = ?
|
|
154
|
+
""",
|
|
155
|
+
(
|
|
156
|
+
req.status.value,
|
|
157
|
+
req.reason,
|
|
158
|
+
json.dumps(req.evidence),
|
|
159
|
+
req.evaluated_at.isoformat() if req.evaluated_at else _utcnow(),
|
|
160
|
+
req.evaluated_by,
|
|
161
|
+
task_id,
|
|
162
|
+
req.id,
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# -- issues ----------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def add_issue(self, issue: Issue) -> Issue:
|
|
169
|
+
now = _utcnow()
|
|
170
|
+
with self._db.transaction() as conn:
|
|
171
|
+
cur = conn.execute(
|
|
172
|
+
"""
|
|
173
|
+
INSERT INTO issues (task_id, description, kind, blocking, status, created_at)
|
|
174
|
+
VALUES (?, ?, ?, ?, 'OPEN', ?)
|
|
175
|
+
""",
|
|
176
|
+
(issue.task_id, issue.description, issue.kind, int(issue.blocking), now),
|
|
177
|
+
)
|
|
178
|
+
issue_id = int(cur.lastrowid or 0)
|
|
179
|
+
return issue.model_copy(update={"id": issue_id, "created_at": _dt(now)})
|
|
180
|
+
|
|
181
|
+
def open_issues(self, task_id: str) -> list[Issue]:
|
|
182
|
+
rows = self._db.query(
|
|
183
|
+
"SELECT * FROM issues WHERE task_id = ? AND status = 'OPEN' ORDER BY created_at",
|
|
184
|
+
(task_id,),
|
|
185
|
+
)
|
|
186
|
+
return [
|
|
187
|
+
Issue(
|
|
188
|
+
id=row["id"],
|
|
189
|
+
task_id=row["task_id"],
|
|
190
|
+
description=row["description"],
|
|
191
|
+
kind=row["kind"],
|
|
192
|
+
blocking=bool(row["blocking"]),
|
|
193
|
+
status=IssueStatus(row["status"]),
|
|
194
|
+
created_at=_dt(row["created_at"]),
|
|
195
|
+
resolved_at=_dt(row["resolved_at"]),
|
|
196
|
+
)
|
|
197
|
+
for row in rows
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
def resolve_issue(self, issue_id: int) -> bool:
|
|
201
|
+
with self._db.transaction() as conn:
|
|
202
|
+
cur = conn.execute(
|
|
203
|
+
"UPDATE issues SET status = 'RESOLVED', resolved_at = ? WHERE id = ? AND status = 'OPEN'",
|
|
204
|
+
(_utcnow(), issue_id),
|
|
205
|
+
)
|
|
206
|
+
return cur.rowcount > 0
|
|
207
|
+
|
|
208
|
+
def resolve_issues_by_kind(self, task_id: str, kind: str) -> int:
|
|
209
|
+
"""Auto-close engine-raised issues of one kind before re-raising fresh ones."""
|
|
210
|
+
with self._db.transaction() as conn:
|
|
211
|
+
cur = conn.execute(
|
|
212
|
+
"UPDATE issues SET status = 'RESOLVED', resolved_at = ? "
|
|
213
|
+
"WHERE task_id = ? AND kind = ? AND status = 'OPEN'",
|
|
214
|
+
(_utcnow(), task_id, kind),
|
|
215
|
+
)
|
|
216
|
+
return cur.rowcount
|
|
217
|
+
|
|
218
|
+
# -- test runs -----------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def add_test_run(self, run: TaskTestRun) -> TaskTestRun:
|
|
221
|
+
now = _utcnow()
|
|
222
|
+
with self._db.transaction() as conn:
|
|
223
|
+
cur = conn.execute(
|
|
224
|
+
"""
|
|
225
|
+
INSERT INTO task_test_runs (task_id, command, status, passed, failed, skipped,
|
|
226
|
+
exit_code, duration_seconds, output_path, created_at)
|
|
227
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
228
|
+
""",
|
|
229
|
+
(
|
|
230
|
+
run.task_id,
|
|
231
|
+
run.command,
|
|
232
|
+
run.status.value,
|
|
233
|
+
run.passed,
|
|
234
|
+
run.failed,
|
|
235
|
+
run.skipped,
|
|
236
|
+
run.exit_code,
|
|
237
|
+
run.duration_seconds,
|
|
238
|
+
run.output_path,
|
|
239
|
+
now,
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
run_id = int(cur.lastrowid or 0)
|
|
243
|
+
return run.model_copy(update={"id": run_id, "created_at": _dt(now)})
|
|
244
|
+
|
|
245
|
+
def latest_test_run(self, task_id: str) -> TaskTestRun | None:
|
|
246
|
+
row = self._db.query_one(
|
|
247
|
+
"SELECT * FROM task_test_runs WHERE task_id = ? ORDER BY created_at DESC LIMIT 1",
|
|
248
|
+
(task_id,),
|
|
249
|
+
)
|
|
250
|
+
if row is None:
|
|
251
|
+
return None
|
|
252
|
+
return TaskTestRun(
|
|
253
|
+
id=row["id"],
|
|
254
|
+
task_id=row["task_id"],
|
|
255
|
+
command=row["command"],
|
|
256
|
+
status=TestRunStatus(row["status"]),
|
|
257
|
+
passed=row["passed"],
|
|
258
|
+
failed=row["failed"],
|
|
259
|
+
skipped=row["skipped"],
|
|
260
|
+
exit_code=row["exit_code"],
|
|
261
|
+
duration_seconds=row["duration_seconds"],
|
|
262
|
+
output_path=row["output_path"],
|
|
263
|
+
created_at=_dt(row["created_at"]),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# -- state snapshots (the observable loop) ------------------------
|
|
267
|
+
|
|
268
|
+
def append_snapshot(self, state: NormalizedState) -> StateSnapshot:
|
|
269
|
+
now = _utcnow()
|
|
270
|
+
with self._db.transaction() as conn:
|
|
271
|
+
cur = conn.execute(
|
|
272
|
+
"""
|
|
273
|
+
INSERT INTO state_snapshots (task_id, overall_status, commit_sha, checkpoint_id,
|
|
274
|
+
state_json, created_at)
|
|
275
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
276
|
+
""",
|
|
277
|
+
(
|
|
278
|
+
state.task.id,
|
|
279
|
+
state.overall_status.value,
|
|
280
|
+
state.git.commit_sha,
|
|
281
|
+
state.checkpoint.current_id,
|
|
282
|
+
state.model_dump_json(),
|
|
283
|
+
now,
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
snap_id = int(cur.lastrowid or 0)
|
|
287
|
+
return StateSnapshot(
|
|
288
|
+
id=snap_id,
|
|
289
|
+
task_id=state.task.id,
|
|
290
|
+
overall_status=state.overall_status,
|
|
291
|
+
state=state,
|
|
292
|
+
created_at=_dt(now),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def latest_snapshot(self, task_id: str) -> StateSnapshot | None:
|
|
296
|
+
row = self._db.query_one(
|
|
297
|
+
"SELECT * FROM state_snapshots WHERE task_id = ? ORDER BY created_at DESC LIMIT 1",
|
|
298
|
+
(task_id,),
|
|
299
|
+
)
|
|
300
|
+
return _snapshot_from_row(row)
|
|
301
|
+
|
|
302
|
+
def snapshots(self, task_id: str, *, limit: int = 20) -> list[StateSnapshot]:
|
|
303
|
+
rows = self._db.query(
|
|
304
|
+
"SELECT * FROM state_snapshots WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
|
|
305
|
+
(task_id, limit),
|
|
306
|
+
)
|
|
307
|
+
return [s for row in rows if (s := _snapshot_from_row(row)) is not None]
|
|
308
|
+
|
|
309
|
+
# -- task <-> commit index --------------------------------------
|
|
310
|
+
|
|
311
|
+
def link_commit(self, task_id: str, commit_sha: str) -> None:
|
|
312
|
+
self._db.execute(
|
|
313
|
+
"INSERT OR IGNORE INTO task_commits (task_id, commit_sha, created_at) VALUES (?, ?, ?)",
|
|
314
|
+
(task_id, commit_sha, _utcnow()),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def task_commits(self, task_id: str) -> list[str]:
|
|
318
|
+
rows = self._db.query(
|
|
319
|
+
"SELECT commit_sha FROM task_commits WHERE task_id = ? ORDER BY created_at",
|
|
320
|
+
(task_id,),
|
|
321
|
+
)
|
|
322
|
+
return [r["commit_sha"] for r in rows]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _snapshot_from_row(row: sqlite3.Row | None) -> StateSnapshot | None:
|
|
326
|
+
if row is None:
|
|
327
|
+
return None
|
|
328
|
+
try:
|
|
329
|
+
state = NormalizedState.model_validate_json(row["state_json"])
|
|
330
|
+
except ValueError:
|
|
331
|
+
return None
|
|
332
|
+
state.snapshot_id = row["id"] # the id is a property of the row, injected on read
|
|
333
|
+
return StateSnapshot(
|
|
334
|
+
id=row["id"],
|
|
335
|
+
task_id=row["task_id"],
|
|
336
|
+
overall_status=TaskStatus(row["overall_status"]),
|
|
337
|
+
state=state,
|
|
338
|
+
created_at=_dt(row["created_at"]),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
__all__ = ["TaskRepository"]
|