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
devmemory/api/app.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""FastAPI app: the dashboard's JSON API plus the static single-page frontend.
|
|
2
|
+
|
|
3
|
+
Note: this module deliberately does *not* use ``from __future__ import
|
|
4
|
+
annotations`` - FastAPI needs the dependency ``Annotated[...]`` aliases to be real
|
|
5
|
+
objects at decoration time, and the local ``Ctx`` alias would not resolve as a
|
|
6
|
+
string.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import AsyncIterator
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Annotated
|
|
14
|
+
|
|
15
|
+
from fastapi import Depends, FastAPI, HTTPException, Query
|
|
16
|
+
from fastapi.responses import FileResponse, PlainTextResponse
|
|
17
|
+
from fastapi.staticfiles import StaticFiles
|
|
18
|
+
|
|
19
|
+
from devmemory.__about__ import __version__
|
|
20
|
+
from devmemory.adapters.graph import GraphImpact
|
|
21
|
+
from devmemory.api import mappers
|
|
22
|
+
from devmemory.api.schemas import (
|
|
23
|
+
AgentCheckRequest,
|
|
24
|
+
ComparisonResponse,
|
|
25
|
+
FeatureDetail,
|
|
26
|
+
IssueRequest,
|
|
27
|
+
ProjectBriefDoc,
|
|
28
|
+
ProjectBriefRequest,
|
|
29
|
+
ProjectSummary,
|
|
30
|
+
RequirementUpdateRequest,
|
|
31
|
+
SearchResponse,
|
|
32
|
+
SnapshotSummary,
|
|
33
|
+
TaskCreateRequest,
|
|
34
|
+
TaskSummary,
|
|
35
|
+
VersionListItem,
|
|
36
|
+
)
|
|
37
|
+
from devmemory.domain.enums import RequirementStatus
|
|
38
|
+
from devmemory.domain.errors import DevMemoryError
|
|
39
|
+
from devmemory.domain.models import (
|
|
40
|
+
Analysis,
|
|
41
|
+
CheckpointReference,
|
|
42
|
+
DevelopmentVersion,
|
|
43
|
+
EntireStatus,
|
|
44
|
+
)
|
|
45
|
+
from devmemory.domain.taskloop import NormalizedState
|
|
46
|
+
from devmemory.services import brief as briefsvc
|
|
47
|
+
from devmemory.services import taskloop
|
|
48
|
+
from devmemory.services.agent_context import (
|
|
49
|
+
ChangeGuidance,
|
|
50
|
+
ProjectBrief,
|
|
51
|
+
VersionBrief,
|
|
52
|
+
change_guidance,
|
|
53
|
+
project_brief,
|
|
54
|
+
recent_history,
|
|
55
|
+
)
|
|
56
|
+
from devmemory.services.analysis import analyze_version
|
|
57
|
+
from devmemory.services.analytics import AnalyticsSummary, analytics_summary
|
|
58
|
+
from devmemory.services.context import ProjectContext
|
|
59
|
+
from devmemory.services.features import get_feature, list_features
|
|
60
|
+
from devmemory.services.impact import version_impact
|
|
61
|
+
from devmemory.services.memory import MemoryQuery, PreviousAttempt, previous_attempts
|
|
62
|
+
from devmemory.services.projects import project_status
|
|
63
|
+
from devmemory.services.restore import (
|
|
64
|
+
RestorePreview,
|
|
65
|
+
RestoreResult,
|
|
66
|
+
restore_preview,
|
|
67
|
+
restore_version,
|
|
68
|
+
)
|
|
69
|
+
from devmemory.services.trace import DevelopmentTrace, development_trace
|
|
70
|
+
from devmemory.services.versions import (
|
|
71
|
+
get_version,
|
|
72
|
+
list_versions,
|
|
73
|
+
search_versions,
|
|
74
|
+
version_diff,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# The dashboard is a Vite/React app. Its source lives in src/devmemory/web/frontend/
|
|
78
|
+
# and the built bundle is committed to src/devmemory/web/static/ (the wheel ships
|
|
79
|
+
# it, so `devmemory serve` needs no Node). Run `npm run build` there to refresh it.
|
|
80
|
+
_FRONTEND_DIR = Path(__file__).parents[1] / "web" / "static"
|
|
81
|
+
_PROBE_TTL = 20.0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _ProbeCache:
|
|
85
|
+
"""The Entire probe shells out; cache it briefly so the dashboard stays snappy."""
|
|
86
|
+
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self._at = 0.0
|
|
89
|
+
self._value: EntireStatus | None = None
|
|
90
|
+
|
|
91
|
+
def get(self, ctx: ProjectContext) -> EntireStatus:
|
|
92
|
+
now = time.monotonic()
|
|
93
|
+
if self._value is None or now - self._at > _PROBE_TTL:
|
|
94
|
+
self._value = ctx.entire.probe()
|
|
95
|
+
self._at = now
|
|
96
|
+
return self._value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def create_app(repo_path: Path | str | None = None, *, enable_restore: bool = False) -> FastAPI:
|
|
100
|
+
resolved = Path(repo_path) if repo_path else None
|
|
101
|
+
holder: dict[str, ProjectContext] = {}
|
|
102
|
+
|
|
103
|
+
@asynccontextmanager
|
|
104
|
+
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
|
105
|
+
holder["ctx"] = ProjectContext.load(resolved, thread_safe=True)
|
|
106
|
+
try:
|
|
107
|
+
yield
|
|
108
|
+
finally:
|
|
109
|
+
holder.pop("ctx").close()
|
|
110
|
+
|
|
111
|
+
app = FastAPI(
|
|
112
|
+
title="DevMemory",
|
|
113
|
+
version=__version__,
|
|
114
|
+
summary="Development-memory and version-intelligence dashboard API",
|
|
115
|
+
docs_url="/api/docs",
|
|
116
|
+
openapi_url="/api/openapi.json",
|
|
117
|
+
lifespan=lifespan,
|
|
118
|
+
)
|
|
119
|
+
app.state.enable_restore = enable_restore
|
|
120
|
+
probe_cache = _ProbeCache()
|
|
121
|
+
|
|
122
|
+
def get_ctx() -> ProjectContext:
|
|
123
|
+
# One shared, thread-safe context for the life of the server.
|
|
124
|
+
if "ctx" not in holder: # pragma: no cover - only outside the lifespan
|
|
125
|
+
holder["ctx"] = ProjectContext.load(resolved, thread_safe=True)
|
|
126
|
+
return holder["ctx"]
|
|
127
|
+
|
|
128
|
+
Ctx = Annotated[ProjectContext, Depends(get_ctx)] # noqa: N806 - a type alias
|
|
129
|
+
|
|
130
|
+
@app.exception_handler(DevMemoryError)
|
|
131
|
+
async def _domain_error(_request: object, exc: DevMemoryError) -> PlainTextResponse:
|
|
132
|
+
code = 404 if exc.exit_code in (3, 9) else 400
|
|
133
|
+
return PlainTextResponse(exc.message, status_code=code)
|
|
134
|
+
|
|
135
|
+
# -- project / status ------------------------------------------------
|
|
136
|
+
|
|
137
|
+
@app.get("/api/health")
|
|
138
|
+
def health() -> dict[str, str]:
|
|
139
|
+
return {"status": "ok", "version": __version__}
|
|
140
|
+
|
|
141
|
+
@app.get("/api/project", response_model=ProjectSummary)
|
|
142
|
+
@app.get("/api/status", response_model=ProjectSummary)
|
|
143
|
+
def project(ctx: Ctx) -> ProjectSummary:
|
|
144
|
+
return mappers.project_summary(project_status(ctx, entire_probe=probe_cache.get(ctx)))
|
|
145
|
+
|
|
146
|
+
@app.get("/api/project/brief", response_model=ProjectBriefDoc)
|
|
147
|
+
def get_project_brief(ctx: Ctx) -> ProjectBriefDoc:
|
|
148
|
+
doc = briefsvc.get_brief(ctx)
|
|
149
|
+
return ProjectBriefDoc(content=doc.content, updated_at=doc.updated_at)
|
|
150
|
+
|
|
151
|
+
@app.put("/api/project/brief", response_model=ProjectBriefDoc)
|
|
152
|
+
def put_project_brief(ctx: Ctx, body: ProjectBriefRequest) -> ProjectBriefDoc:
|
|
153
|
+
doc = briefsvc.set_brief(ctx, body.content)
|
|
154
|
+
return ProjectBriefDoc(content=doc.content, updated_at=doc.updated_at)
|
|
155
|
+
|
|
156
|
+
# -- versions ------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
@app.get("/api/versions", response_model=list[VersionListItem])
|
|
159
|
+
def versions(
|
|
160
|
+
ctx: Ctx,
|
|
161
|
+
limit: Annotated[int, Query(ge=1, le=1000)] = 200,
|
|
162
|
+
offset: Annotated[int, Query(ge=0)] = 0,
|
|
163
|
+
) -> list[VersionListItem]:
|
|
164
|
+
items = list_versions(ctx, limit=limit, offset=offset, ascending=True)
|
|
165
|
+
return [mappers.version_list_item(v) for v in items]
|
|
166
|
+
|
|
167
|
+
@app.get("/api/versions/{ref}", response_model=DevelopmentVersion)
|
|
168
|
+
def version(ctx: Ctx, ref: str) -> DevelopmentVersion:
|
|
169
|
+
return get_version(ctx, ref)
|
|
170
|
+
|
|
171
|
+
@app.get("/api/versions/{ref}/diff", response_class=PlainTextResponse)
|
|
172
|
+
def version_diff_text(ctx: Ctx, ref: str) -> str:
|
|
173
|
+
v = get_version(ctx, ref)
|
|
174
|
+
return ctx.git.diff_text(v.parent_commit, v.git_commit)
|
|
175
|
+
|
|
176
|
+
@app.get("/api/versions/{ref}/checkpoint", response_model=CheckpointReference)
|
|
177
|
+
def version_checkpoint(ctx: Ctx, ref: str) -> CheckpointReference:
|
|
178
|
+
v = get_version(ctx, ref)
|
|
179
|
+
if v.primary_checkpoint is None:
|
|
180
|
+
raise HTTPException(status_code=404, detail="no checkpoint linked to this version")
|
|
181
|
+
return v.primary_checkpoint
|
|
182
|
+
|
|
183
|
+
@app.get("/api/versions/{ref}/trace", response_model=DevelopmentTrace)
|
|
184
|
+
def version_trace(ctx: Ctx, ref: str) -> DevelopmentTrace:
|
|
185
|
+
return development_trace(ctx, ref)
|
|
186
|
+
|
|
187
|
+
@app.post("/api/versions/{ref}/analysis", response_model=Analysis)
|
|
188
|
+
def regenerate_analysis(ctx: Ctx, ref: str) -> Analysis:
|
|
189
|
+
"""Re-run the analysis provider chain for a version (interpretation only)."""
|
|
190
|
+
return analyze_version(ctx, ref)
|
|
191
|
+
|
|
192
|
+
@app.get("/api/versions/{ref}/impact", response_model=GraphImpact)
|
|
193
|
+
def version_impact_endpoint(ctx: Ctx, ref: str) -> GraphImpact:
|
|
194
|
+
impact = version_impact(ctx, ref, compute_if_missing=ctx.config.graph.enabled)
|
|
195
|
+
if impact is None:
|
|
196
|
+
raise HTTPException(
|
|
197
|
+
status_code=404,
|
|
198
|
+
detail="no change-impact analysis for this version "
|
|
199
|
+
"(install the Entire `graph` plugin)",
|
|
200
|
+
)
|
|
201
|
+
return impact
|
|
202
|
+
|
|
203
|
+
# -- comparison --------------------------------------------------
|
|
204
|
+
|
|
205
|
+
@app.get("/api/compare", response_model=ComparisonResponse)
|
|
206
|
+
def compare(
|
|
207
|
+
ctx: Ctx,
|
|
208
|
+
from_: Annotated[str, Query(alias="from")],
|
|
209
|
+
to: Annotated[str, Query(alias="to")],
|
|
210
|
+
) -> ComparisonResponse:
|
|
211
|
+
return mappers.comparison_response(version_diff(ctx, from_, to))
|
|
212
|
+
|
|
213
|
+
# -- features ---------------------------------------------------
|
|
214
|
+
|
|
215
|
+
@app.get("/api/features", response_model=list[FeatureDetail])
|
|
216
|
+
def features(ctx: Ctx) -> list[FeatureDetail]:
|
|
217
|
+
return [mappers.feature_detail(f) for f in list_features(ctx)]
|
|
218
|
+
|
|
219
|
+
@app.get("/api/features/{ref}", response_model=FeatureDetail)
|
|
220
|
+
def feature(ctx: Ctx, ref: str) -> FeatureDetail:
|
|
221
|
+
return mappers.feature_detail(get_feature(ctx, ref))
|
|
222
|
+
|
|
223
|
+
# -- analytics ---------------------------------------------
|
|
224
|
+
|
|
225
|
+
@app.get("/api/analytics", response_model=AnalyticsSummary)
|
|
226
|
+
def analytics(ctx: Ctx) -> AnalyticsSummary:
|
|
227
|
+
return analytics_summary(ctx)
|
|
228
|
+
|
|
229
|
+
# -- agent context (the REST half of the MCP surface) ------
|
|
230
|
+
|
|
231
|
+
@app.get("/api/agent/context", response_model=ProjectBrief)
|
|
232
|
+
def agent_context(ctx: Ctx) -> ProjectBrief:
|
|
233
|
+
return project_brief(ctx)
|
|
234
|
+
|
|
235
|
+
@app.get("/api/agent/history", response_model=list[VersionBrief])
|
|
236
|
+
def agent_history(
|
|
237
|
+
ctx: Ctx,
|
|
238
|
+
limit: Annotated[int, Query(ge=1, le=200)] = 20,
|
|
239
|
+
feature: Annotated[str | None, Query()] = None,
|
|
240
|
+
) -> list[VersionBrief]:
|
|
241
|
+
return recent_history(ctx, limit=limit, feature=feature)
|
|
242
|
+
|
|
243
|
+
@app.post("/api/agent/check", response_model=ChangeGuidance)
|
|
244
|
+
def agent_check(ctx: Ctx, body: AgentCheckRequest) -> ChangeGuidance:
|
|
245
|
+
return change_guidance(ctx, files=body.files, intent=body.intent, feature=body.feature)
|
|
246
|
+
|
|
247
|
+
# -- the state-aware coding loop -----------------------------------
|
|
248
|
+
|
|
249
|
+
@app.get("/api/tasks", response_model=list[TaskSummary])
|
|
250
|
+
def tasks(ctx: Ctx) -> list[TaskSummary]:
|
|
251
|
+
return [mappers.task_summary(t) for t in taskloop.list_tasks(ctx)]
|
|
252
|
+
|
|
253
|
+
@app.post("/api/tasks", response_model=NormalizedState, status_code=201)
|
|
254
|
+
def create_task(ctx: Ctx, body: TaskCreateRequest) -> NormalizedState:
|
|
255
|
+
task = taskloop.create_task(ctx, goal=body.goal, test_command=body.test_command)
|
|
256
|
+
return taskloop.get_state(ctx, task.id)
|
|
257
|
+
|
|
258
|
+
@app.get("/api/tasks/{task_id}/state", response_model=NormalizedState)
|
|
259
|
+
def task_state(ctx: Ctx, task_id: str) -> NormalizedState:
|
|
260
|
+
return taskloop.get_state(ctx, task_id)
|
|
261
|
+
|
|
262
|
+
@app.post("/api/tasks/{task_id}/refresh", response_model=NormalizedState)
|
|
263
|
+
def task_refresh(ctx: Ctx, task_id: str) -> NormalizedState:
|
|
264
|
+
return taskloop.refresh_state(ctx, task_id)
|
|
265
|
+
|
|
266
|
+
@app.post("/api/tasks/{task_id}/complete", response_model=NormalizedState)
|
|
267
|
+
def task_complete(ctx: Ctx, task_id: str) -> NormalizedState:
|
|
268
|
+
return taskloop.mark_complete(ctx, task_id)
|
|
269
|
+
|
|
270
|
+
@app.get("/api/tasks/{task_id}/snapshots", response_model=list[SnapshotSummary])
|
|
271
|
+
def task_snapshots(
|
|
272
|
+
ctx: Ctx,
|
|
273
|
+
task_id: str,
|
|
274
|
+
limit: Annotated[int, Query(ge=1, le=200)] = 40,
|
|
275
|
+
) -> list[SnapshotSummary]:
|
|
276
|
+
return [
|
|
277
|
+
mappers.snapshot_summary(s) for s in taskloop.list_snapshots(ctx, task_id, limit=limit)
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
@app.post("/api/tasks/{task_id}/issues", response_model=NormalizedState)
|
|
281
|
+
def task_issue(ctx: Ctx, task_id: str, body: IssueRequest) -> NormalizedState:
|
|
282
|
+
taskloop.report_issue(
|
|
283
|
+
ctx, task_id=task_id, description=body.description, blocking=body.blocking
|
|
284
|
+
)
|
|
285
|
+
return taskloop.refresh_state(ctx, task_id)
|
|
286
|
+
|
|
287
|
+
@app.post("/api/tasks/{task_id}/issues/{issue_id}/resolve", response_model=NormalizedState)
|
|
288
|
+
def task_issue_resolve(ctx: Ctx, task_id: str, issue_id: int) -> NormalizedState:
|
|
289
|
+
taskloop.resolve_issue(ctx, issue_id=issue_id)
|
|
290
|
+
return taskloop.refresh_state(ctx, task_id)
|
|
291
|
+
|
|
292
|
+
@app.post("/api/tasks/{task_id}/requirements/{req_id}", response_model=NormalizedState)
|
|
293
|
+
def task_set_requirement(
|
|
294
|
+
ctx: Ctx, task_id: str, req_id: str, body: RequirementUpdateRequest
|
|
295
|
+
) -> NormalizedState:
|
|
296
|
+
try:
|
|
297
|
+
status = RequirementStatus(body.status.upper())
|
|
298
|
+
except ValueError as exc:
|
|
299
|
+
raise HTTPException(status_code=422, detail=f"invalid status {body.status!r}") from exc
|
|
300
|
+
return taskloop.set_requirement_status(
|
|
301
|
+
ctx, task_id=task_id, requirement_id=req_id, status=status, note=body.note
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# -- restore ------------------------------------------------
|
|
305
|
+
|
|
306
|
+
@app.get("/api/versions/{ref}/restore/preview", response_model=RestorePreview)
|
|
307
|
+
def restore_preview_endpoint(ctx: Ctx, ref: str) -> RestorePreview:
|
|
308
|
+
return restore_preview(ctx, ref)
|
|
309
|
+
|
|
310
|
+
@app.post("/api/versions/{ref}/restore", response_model=RestoreResult)
|
|
311
|
+
def restore_endpoint(
|
|
312
|
+
ctx: Ctx,
|
|
313
|
+
ref: str,
|
|
314
|
+
mode: Annotated[str, Query(pattern="^(detach|hard)$")] = "detach",
|
|
315
|
+
allow_dirty: Annotated[bool, Query()] = False,
|
|
316
|
+
) -> RestoreResult:
|
|
317
|
+
if not app.state.enable_restore:
|
|
318
|
+
raise HTTPException(
|
|
319
|
+
status_code=403,
|
|
320
|
+
detail="restore is disabled; start the server with `devmemory serve --enable-restore`",
|
|
321
|
+
)
|
|
322
|
+
return restore_version(ctx, ref, mode=mode, allow_dirty=allow_dirty)
|
|
323
|
+
|
|
324
|
+
# -- development memory --------------------------------------
|
|
325
|
+
|
|
326
|
+
@app.get("/api/attempts", response_model=list[PreviousAttempt])
|
|
327
|
+
def attempts(
|
|
328
|
+
ctx: Ctx,
|
|
329
|
+
feature: Annotated[str | None, Query()] = None,
|
|
330
|
+
q: Annotated[str | None, Query()] = None,
|
|
331
|
+
file: Annotated[list[str] | None, Query()] = None,
|
|
332
|
+
include_successes: Annotated[bool, Query()] = False,
|
|
333
|
+
limit: Annotated[int, Query(ge=1, le=100)] = 25,
|
|
334
|
+
) -> list[PreviousAttempt]:
|
|
335
|
+
return previous_attempts(
|
|
336
|
+
ctx,
|
|
337
|
+
MemoryQuery(
|
|
338
|
+
files=file or [],
|
|
339
|
+
feature=feature,
|
|
340
|
+
intent=q,
|
|
341
|
+
include_successes=include_successes,
|
|
342
|
+
limit=limit,
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
@app.get("/api/versions/{ref}/attempts", response_model=list[PreviousAttempt])
|
|
347
|
+
def version_attempts(ctx: Ctx, ref: str) -> list[PreviousAttempt]:
|
|
348
|
+
v = get_version(ctx, ref)
|
|
349
|
+
found = previous_attempts(
|
|
350
|
+
ctx,
|
|
351
|
+
MemoryQuery(
|
|
352
|
+
files=[f.path for f in v.changed_files],
|
|
353
|
+
feature=v.feature_id.split(":", 1)[-1] if v.feature_id else None,
|
|
354
|
+
intent=v.intent,
|
|
355
|
+
limit=6,
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
return [a for a in found if a.version_id != v.version_id]
|
|
359
|
+
|
|
360
|
+
# -- search ---------------------------------------------------
|
|
361
|
+
|
|
362
|
+
@app.get("/api/search", response_model=SearchResponse)
|
|
363
|
+
def search(
|
|
364
|
+
ctx: Ctx,
|
|
365
|
+
q: Annotated[str, Query(min_length=1)],
|
|
366
|
+
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
367
|
+
) -> SearchResponse:
|
|
368
|
+
hits = [mappers.search_hit(v) for v in search_versions(ctx, q, limit=limit)]
|
|
369
|
+
return SearchResponse(query=q, count=len(hits), results=hits)
|
|
370
|
+
|
|
371
|
+
# -- frontend (hash-routed SPA, no build step) ----------------
|
|
372
|
+
|
|
373
|
+
if (_FRONTEND_DIR / "index.html").is_file():
|
|
374
|
+
app.mount("/static", StaticFiles(directory=_FRONTEND_DIR), name="static")
|
|
375
|
+
|
|
376
|
+
@app.get("/", include_in_schema=False)
|
|
377
|
+
def index() -> FileResponse:
|
|
378
|
+
# Never let a browser hold a stale index.html: it points at
|
|
379
|
+
# content-hashed asset filenames that change on every rebuild, so a
|
|
380
|
+
# cached copy boots old JS against the current API. The hashed
|
|
381
|
+
# /static/assets/* are immutable and cache freely.
|
|
382
|
+
return FileResponse(
|
|
383
|
+
_FRONTEND_DIR / "index.html",
|
|
384
|
+
headers={"Cache-Control": "no-cache, must-revalidate"},
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
return app
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
__all__ = ["create_app"]
|
devmemory/api/mappers.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Domain models -> API response schemas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from devmemory.api.schemas import (
|
|
6
|
+
ComparisonResponse,
|
|
7
|
+
FeatureDetail,
|
|
8
|
+
FeatureHistoryPoint,
|
|
9
|
+
MetricChange,
|
|
10
|
+
ProjectSummary,
|
|
11
|
+
SearchHit,
|
|
12
|
+
SnapshotSummary,
|
|
13
|
+
TaskSummary,
|
|
14
|
+
VersionListItem,
|
|
15
|
+
)
|
|
16
|
+
from devmemory.domain.enums import RequirementStatus
|
|
17
|
+
from devmemory.domain.models import DevelopmentVersion
|
|
18
|
+
from devmemory.domain.taskloop import StateSnapshot, Task
|
|
19
|
+
from devmemory.services.features import FeatureWithHistory
|
|
20
|
+
from devmemory.services.projects import ProjectStatusReport
|
|
21
|
+
from devmemory.services.versions import VersionDiff
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _iso(value: object) -> str | None:
|
|
25
|
+
return value.isoformat() if hasattr(value, "isoformat") else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def project_summary(report: ProjectStatusReport) -> ProjectSummary:
|
|
29
|
+
return ProjectSummary(
|
|
30
|
+
project_id=report.project.project_id,
|
|
31
|
+
name=report.project.name,
|
|
32
|
+
repo_path=report.project.repo_path,
|
|
33
|
+
branch=report.branch,
|
|
34
|
+
head_sha=report.head_sha,
|
|
35
|
+
head_subject=report.head_subject,
|
|
36
|
+
working_tree_clean=report.working_tree_clean,
|
|
37
|
+
version_count=report.version_count,
|
|
38
|
+
latest_version_id=report.latest_version_id,
|
|
39
|
+
latest_status=report.latest_status,
|
|
40
|
+
latest_intent=report.latest_intent,
|
|
41
|
+
head_has_version=report.head_has_version,
|
|
42
|
+
last_regression_id=report.last_regression_id,
|
|
43
|
+
open_features=report.open_features,
|
|
44
|
+
latest_metrics=report.latest_metrics,
|
|
45
|
+
entire_installed=report.entire.installed,
|
|
46
|
+
entire_enabled=report.entire.enabled,
|
|
47
|
+
entire_version=report.entire.cli_version,
|
|
48
|
+
entire_agents=report.entire.agents,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def version_list_item(v: DevelopmentVersion) -> VersionListItem:
|
|
53
|
+
cp = v.primary_checkpoint
|
|
54
|
+
return VersionListItem(
|
|
55
|
+
version_id=v.version_id,
|
|
56
|
+
version_number=v.version_number,
|
|
57
|
+
status=v.status.value,
|
|
58
|
+
intent=v.intent,
|
|
59
|
+
agent=v.agent,
|
|
60
|
+
model=v.model,
|
|
61
|
+
feature=v.feature_id.split(":", 1)[-1] if v.feature_id else None,
|
|
62
|
+
git_commit=v.git_commit,
|
|
63
|
+
branch=v.branch,
|
|
64
|
+
files_changed=v.files_changed,
|
|
65
|
+
lines_added=v.lines_added,
|
|
66
|
+
lines_removed=v.lines_removed,
|
|
67
|
+
checkpoint_id=cp.checkpoint_id if cp else None,
|
|
68
|
+
association_method=v.entire_association_method.value,
|
|
69
|
+
association_confidence=v.entire_association_confidence,
|
|
70
|
+
metrics={m.name: m.after for m in v.metrics},
|
|
71
|
+
tests_passed=v.tests.passed if v.tests and v.tests.ran else None,
|
|
72
|
+
tests_failed=v.tests.failed if v.tests and v.tests.ran else None,
|
|
73
|
+
has_regression=bool(v.regressions),
|
|
74
|
+
committed_at=_iso(v.committed_at),
|
|
75
|
+
created_at=v.created_at.isoformat(),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def search_hit(v: DevelopmentVersion) -> SearchHit:
|
|
80
|
+
return SearchHit(
|
|
81
|
+
version_id=v.version_id,
|
|
82
|
+
version_number=v.version_number,
|
|
83
|
+
status=v.status.value,
|
|
84
|
+
intent=v.intent,
|
|
85
|
+
agent=v.agent,
|
|
86
|
+
feature=v.feature_id.split(":", 1)[-1] if v.feature_id else None,
|
|
87
|
+
git_commit=v.git_commit,
|
|
88
|
+
snippet=(v.analysis.summary if v.analysis and v.analysis.summary else v.intent),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def comparison_response(diff: VersionDiff) -> ComparisonResponse:
|
|
93
|
+
return ComparisonResponse(
|
|
94
|
+
from_version=diff.from_version_id,
|
|
95
|
+
to_version=diff.to_version_id,
|
|
96
|
+
from_commit=diff.from_commit,
|
|
97
|
+
to_commit=diff.to_commit,
|
|
98
|
+
stat=diff.stat,
|
|
99
|
+
files=diff.files,
|
|
100
|
+
diff_text=diff.diff_text,
|
|
101
|
+
metric_changes=[
|
|
102
|
+
MetricChange(
|
|
103
|
+
name=name,
|
|
104
|
+
before=change["before"],
|
|
105
|
+
after=change["after"],
|
|
106
|
+
delta=change["delta"],
|
|
107
|
+
unit=None,
|
|
108
|
+
direction="higher_is_better",
|
|
109
|
+
)
|
|
110
|
+
for name, change in diff.metric_changes.items()
|
|
111
|
+
],
|
|
112
|
+
test_changes=diff.test_changes,
|
|
113
|
+
status_from=diff.status_from.value,
|
|
114
|
+
status_to=diff.status_to.value,
|
|
115
|
+
feature_from=diff.feature_from,
|
|
116
|
+
feature_to=diff.feature_to,
|
|
117
|
+
checkpoint_from=diff.checkpoint_from,
|
|
118
|
+
checkpoint_to=diff.checkpoint_to,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def feature_detail(fh: FeatureWithHistory) -> FeatureDetail:
|
|
123
|
+
latest_metrics: dict[str, float | None] = {}
|
|
124
|
+
if fh.versions:
|
|
125
|
+
latest_metrics = {m.name: m.after for m in fh.versions[-1].metrics}
|
|
126
|
+
return FeatureDetail(
|
|
127
|
+
feature_id=fh.feature.feature_id,
|
|
128
|
+
name=fh.feature.name,
|
|
129
|
+
status=fh.rolled_up_status.value,
|
|
130
|
+
derived_from=fh.feature.derived_from,
|
|
131
|
+
version_count=len(fh.versions),
|
|
132
|
+
latest_metrics=latest_metrics,
|
|
133
|
+
history=[
|
|
134
|
+
FeatureHistoryPoint(
|
|
135
|
+
version_id=v.version_id,
|
|
136
|
+
version_number=v.version_number,
|
|
137
|
+
status=v.status.value,
|
|
138
|
+
metrics={m.name: m.after for m in v.metrics},
|
|
139
|
+
committed_at=_iso(v.committed_at),
|
|
140
|
+
)
|
|
141
|
+
for v in fh.versions
|
|
142
|
+
],
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def task_summary(task: Task) -> TaskSummary:
|
|
147
|
+
return TaskSummary(
|
|
148
|
+
id=task.id,
|
|
149
|
+
goal=task.goal,
|
|
150
|
+
status=task.status.value,
|
|
151
|
+
branch=task.branch,
|
|
152
|
+
requirements_total=len(task.requirements),
|
|
153
|
+
requirements_complete=sum(
|
|
154
|
+
1 for r in task.requirements if r.status is RequirementStatus.COMPLETE
|
|
155
|
+
),
|
|
156
|
+
test_command=task.test_command,
|
|
157
|
+
updated_at=_iso(task.updated_at),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def snapshot_summary(snap: StateSnapshot) -> SnapshotSummary:
|
|
162
|
+
s = snap.state
|
|
163
|
+
return SnapshotSummary(
|
|
164
|
+
id=snap.id,
|
|
165
|
+
overall_status=snap.overall_status.value,
|
|
166
|
+
commit_sha=s.git.commit_sha,
|
|
167
|
+
checkpoint_id=s.checkpoint.current_id,
|
|
168
|
+
tests_passed=s.tests.passed,
|
|
169
|
+
tests_failed=s.tests.failed,
|
|
170
|
+
tests_status=s.tests.status.value,
|
|
171
|
+
requirements_total=len(s.requirements),
|
|
172
|
+
requirements_complete=sum(
|
|
173
|
+
1 for r in s.requirements if r.status is RequirementStatus.COMPLETE
|
|
174
|
+
),
|
|
175
|
+
created_at=_iso(snap.created_at),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
__all__ = [
|
|
180
|
+
"comparison_response",
|
|
181
|
+
"feature_detail",
|
|
182
|
+
"project_summary",
|
|
183
|
+
"search_hit",
|
|
184
|
+
"snapshot_summary",
|
|
185
|
+
"task_summary",
|
|
186
|
+
"version_list_item",
|
|
187
|
+
]
|