Skip to content

Commit d3ca512

Browse files
committed
feat(library): list saved projects so you can reopen one without its id
The project store already persists each analysis to SQLite, but there was no way to enumerate it — you could only reopen a project if you'd kept its UUID. Add cache.list_projects() and a GET /api/projects endpoint that returns the saved library newest-first (id, name, file count, created_at), merging in any project held only in memory this process. Heavy file contents stay in the row and are read back only when a project is actually opened.
1 parent 6aa709d commit d3ca512

5 files changed

Lines changed: 152 additions & 0 deletions

File tree

backend/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,15 @@ class SecuritySummary(BaseModel):
306306
notes: list[str] = []
307307

308308

309+
class ProjectSummary(BaseModel):
310+
"""A lightweight entry in the project library list (no file contents)."""
311+
312+
id: str
313+
name: str
314+
total_files: int = 0
315+
created_at: float | None = None # unix seconds; None for in-memory-only
316+
317+
309318
class ProjectMeta(BaseModel):
310319
id: str
311320
name: str

backend/routers/project.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
PackageDependency,
4545
ProjectHealth,
4646
ProjectMeta,
47+
ProjectSummary,
4748
ReadingStep,
4849
RiskHotspot,
4950
SecurityFinding,
@@ -434,6 +435,30 @@ async def _resolve_project(project_id: str) -> dict | None:
434435
return proj
435436

436437

438+
@router.get("/projects", response_model=list[ProjectSummary])
439+
async def list_projects():
440+
"""List previously analyzed projects (the on-disk library), newest first.
441+
442+
Lets you reopen a past analysis without re-cloning or re-scanning. The
443+
persisted SQLite store is the source of truth; a project created in this
444+
process that isn't persisted yet (e.g. the cache DB is unavailable) is
445+
merged in so it still shows up.
446+
"""
447+
stored = await cache.list_projects()
448+
seen = {p["id"] for p in stored}
449+
summaries = [ProjectSummary(**p) for p in stored]
450+
summaries.extend(
451+
ProjectSummary(
452+
id=project_id,
453+
name=proj.get("name") or project_id,
454+
total_files=len(proj.get("files") or []),
455+
)
456+
for project_id, proj in _projects.items()
457+
if project_id not in seen
458+
)
459+
return summaries
460+
461+
437462
@router.get("/project/{project_id}")
438463
async def get_project(project_id: str):
439464
"""Get project metadata."""

backend/services/cache.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,44 @@ async def load_project(project_id: str) -> dict | None:
140140
await _db.commit()
141141
return None
142142
return json.loads(data)
143+
144+
145+
async def list_projects(limit: int = 50) -> list[dict]:
146+
"""List stored projects, newest first, for the on-disk library view.
147+
148+
Returns lightweight entries (id, name, total_files, created_at) so a user
149+
can reopen a past analysis without re-scanning. The heavy file contents stay
150+
in the row and are only read back when a project is actually opened via
151+
load_project(). Expired rows are skipped (and purged lazily, like get()).
152+
"""
153+
if _db is None:
154+
return []
155+
async with _db.execute(
156+
"SELECT id, data, created_at FROM projects ORDER BY created_at DESC"
157+
) as cursor:
158+
rows = await cursor.fetchall()
159+
now = time.time()
160+
expired: list[str] = []
161+
projects: list[dict] = []
162+
for project_id, data, created_at in rows:
163+
if now - created_at > _TTL_SECONDS:
164+
expired.append(project_id)
165+
continue
166+
if len(projects) >= limit:
167+
continue
168+
try:
169+
parsed = json.loads(data)
170+
except (json.JSONDecodeError, ValueError):
171+
continue
172+
projects.append(
173+
{
174+
"id": project_id,
175+
"name": parsed.get("name") or project_id,
176+
"total_files": len(parsed.get("files") or []),
177+
"created_at": created_at,
178+
}
179+
)
180+
if expired:
181+
await _db.executemany("DELETE FROM projects WHERE id = ?", [(pid,) for pid in expired])
182+
await _db.commit()
183+
return projects

tests/test_cache.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import time
67

78

89
def _with_cache(tmp_path, monkeypatch, scenario):
@@ -140,6 +141,47 @@ async def scenario(cache):
140141
assert remaining == 0
141142

142143

144+
def test_list_projects_newest_first_without_file_contents(tmp_path, monkeypatch):
145+
async def scenario(cache):
146+
await cache.save_project("old", {"name": "旧项目", "files": [1, 2]})
147+
await cache.save_project("new", {"name": "新项目", "files": [1, 2, 3]})
148+
# pin created_at so the ordering doesn't depend on wall-clock resolution
149+
base = time.time()
150+
await cache._db.execute(
151+
"UPDATE projects SET created_at = ? WHERE id = ?", (base - 10, "old")
152+
)
153+
await cache._db.execute(
154+
"UPDATE projects SET created_at = ? WHERE id = ?", (base - 5, "new")
155+
)
156+
await cache._db.commit()
157+
return await cache.list_projects()
158+
159+
projects = _with_cache(tmp_path, monkeypatch, scenario)
160+
assert [p["id"] for p in projects] == ["new", "old"] # newest first
161+
assert projects[0]["name"] == "新项目"
162+
assert projects[0]["total_files"] == 3
163+
assert "files" not in projects[0] # the heavy contents stay out of the listing
164+
165+
166+
def test_list_projects_skips_and_purges_expired(tmp_path, monkeypatch):
167+
async def scenario(cache):
168+
await cache.save_project("fresh", {"name": "在", "files": [1]})
169+
await cache.save_project("stale", {"name": "过期", "files": [1]})
170+
await cache._db.execute(
171+
"UPDATE projects SET created_at = ? WHERE id = ?",
172+
(time.time() - cache._TTL_SECONDS - 1, "stale"),
173+
)
174+
await cache._db.commit()
175+
listed = await cache.list_projects()
176+
async with cache._db.execute("SELECT COUNT(*) FROM projects") as cur:
177+
(remaining,) = await cur.fetchone()
178+
return [p["id"] for p in listed], remaining
179+
180+
ids, remaining = _with_cache(tmp_path, monkeypatch, scenario)
181+
assert ids == ["fresh"]
182+
assert remaining == 1 # the expired row was purged on listing
183+
184+
143185
def test_operations_are_safe_before_init(monkeypatch):
144186
"""Every accessor degrades gracefully when the DB was never initialised."""
145187
from backend.services import cache
@@ -151,6 +193,7 @@ async def scenario():
151193
await cache.put("k", {"v": 1}) # no-op, must not raise
152194
await cache.save_project("p", {"v": 1}) # no-op, must not raise
153195
assert await cache.load_project("p") is None
196+
assert await cache.list_projects() == []
154197
await cache.increment_rate_limit("ip") # no-op, must not raise
155198
# storage unavailable -> fail open: allow, with the full quota
156199
return await cache.check_rate_limit("ip")

tests/test_project.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,37 @@ def test_content_analyses_maps_knowledge_silos_from_ownership():
7373
assert len(extras["knowledge_silos"]) == 1
7474
silo = extras["knowledge_silos"][0]
7575
assert silo.path == "core.py" and silo.primary_author == "amy" and silo.bus_factor == 1
76+
77+
78+
def test_list_projects_endpoint_merges_persisted_and_in_memory(tmp_path, monkeypatch):
79+
# The library list should surface both projects saved to SQLite and any only
80+
# held in memory this process, without duplicating one that is in both.
81+
import asyncio
82+
83+
from backend.routers import project as project_router
84+
from backend.services import cache
85+
86+
monkeypatch.setattr(cache, "_DB_PATH", tmp_path / "cache.db")
87+
monkeypatch.setattr(project_router, "_projects", {})
88+
89+
async def main():
90+
await cache.init_db()
91+
try:
92+
await cache.save_project("persisted", {"name": "已存盘", "files": [1, 2]})
93+
project_router._projects["persisted"] = {"name": "已存盘", "files": [1, 2]}
94+
project_router._projects["memory-only"] = {"name": "仅内存", "files": [1]}
95+
return await project_router.list_projects()
96+
finally:
97+
if cache._db is not None:
98+
await cache._db.close()
99+
cache._db = None
100+
101+
summaries = asyncio.run(main())
102+
by_id = {s.id: s for s in summaries}
103+
104+
assert set(by_id) == {"persisted", "memory-only"} # persisted one not duplicated
105+
assert by_id["persisted"].name == "已存盘"
106+
assert by_id["persisted"].total_files == 2
107+
assert by_id["persisted"].created_at is not None
108+
assert by_id["memory-only"].name == "仅内存"
109+
assert by_id["memory-only"].created_at is None # not persisted yet

0 commit comments

Comments
 (0)