From cdf6ba313024d386b9d6f5eb9c32346eb84cc2c9 Mon Sep 17 00:00:00 2001 From: Philippe Parage <69145356+pparage@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:58:06 +0200 Subject: [PATCH] feat(projects): POST /v1/projects/{id}/heartbeat (#106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProjectRepoAdapter.startHeartbeatWorker points a SharedWorker at this path on a timer; no route existed, so every tick 404'd and after the stale threshold each open editor was told its session had gone stale. Chose 'add the endpoint' over 'locking is client-only' because the two concerns are already separate: the edit lock lives in the git-backed project repo and is written by the client, while the worker only needs to know whether the backend is reachable and still knows the project. That is a real signal a client cannot derive on its own, and it is what the stale-session banner should reflect. Deliberately does no writes — it runs per open editor on a short interval, and carries no state the lock file does not already hold. 404 for an unknown project, which the worker treats as a failed tick. --- app/routes/v1/projects/crud.py | 33 ++++++++++ openapi.json | 36 +++++++++++ tests/routes/test_project_heartbeat.py | 85 ++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 tests/routes/test_project_heartbeat.py diff --git a/app/routes/v1/projects/crud.py b/app/routes/v1/projects/crud.py index 20b8acc..c1e7977 100644 --- a/app/routes/v1/projects/crud.py +++ b/app/routes/v1/projects/crud.py @@ -67,3 +67,36 @@ async def patch_project( await session.commit() await session.refresh(row) return ProjectOut.model_validate(row, from_attributes=True) + + +@router.post("/{project_id}/heartbeat", status_code=status.HTTP_204_NO_CONTENT) +async def project_heartbeat( + project_id: str, + session: AsyncSession = Depends(_session), +): + """Liveness ping for an open editing session (#106). + + The edit lock itself is **not** held here: it lives in the git-backed + project repo, written by the client (``ProjectRepoAdapter.writeLock``). + This endpoint answers one question for the SharedWorker that polls it — + "is the backend reachable and does it still know this project?" — so the + UI can distinguish a live session from a stale one. + + Deliberately does no writes: a heartbeat must stay cheap enough to run + on a short interval per open editor, and it carries no state the lock + file does not already hold. + + :returns: 204 when the project exists; 404 otherwise, which the worker + treats as a failed tick and eventually surfaces as a stale session. + """ + exists = ( + await session.execute(select(Project.id).where(Project.id == project_id)) + ).scalar_one_or_none() + if exists is None: + raise Range42Error( + error="not_found", + code="NOT_FOUND", + status=404, + message=f"Project {project_id} not found", + ) + return None diff --git a/openapi.json b/openapi.json index 4f380e5..46e4d4d 100644 --- a/openapi.json +++ b/openapi.json @@ -2548,6 +2548,42 @@ } } }, + "/v1/projects/{project_id}/heartbeat": { + "post": { + "tags": [ + "v1 projects" + ], + "summary": "Project Heartbeat", + "description": "Liveness ping for an open editing session (#106).\n\nThe edit lock itself is **not** held here: it lives in the git-backed\nproject repo, written by the client (``ProjectRepoAdapter.writeLock``).\nThis endpoint answers one question for the SharedWorker that polls it \u2014\n\"is the backend reachable and does it still know this project?\" \u2014 so the\nUI can distinguish a live session from a stale one.\n\nDeliberately does no writes: a heartbeat must stay cheap enough to run\non a short interval per open editor, and it carries no state the lock\nfile does not already hold.\n\n:returns: 204 when the project exists; 404 otherwise, which the worker\n treats as a failed tick and eventually surfaces as a stale session.", + "operationId": "project_heartbeat_v1_projects__project_id__heartbeat_post", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Project Id" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/v1/projects/{project_id}/compose": { "post": { "tags": [ diff --git a/tests/routes/test_project_heartbeat.py b/tests/routes/test_project_heartbeat.py new file mode 100644 index 0000000..b1812e8 --- /dev/null +++ b/tests/routes/test_project_heartbeat.py @@ -0,0 +1,85 @@ +"""POST /v1/projects/{id}/heartbeat — the endpoint the UI already polls (#106). + +`ProjectRepoAdapter.startHeartbeatWorker` points a SharedWorker at this path +on a timer. With no route it 404'd every tick, so after the stale threshold +every open editor was told its session had gone stale. +""" +import pytest +from httpx import ASGITransport, AsyncClient + + +async def _boot(tmp_path, monkeypatch): + monkeypatch.setenv("RANGE42_DB_URL", f"sqlite+aiosqlite:///{tmp_path / 't.db'}") + monkeypatch.setenv("RANGE42_WORKSPACE_ROOT", str(tmp_path)) + from importlib import reload + + from app.core import config as cfg + reload(cfg) + import app.core.db as dbmod + reload(dbmod) + from app.core.models import Base + + engine = dbmod.get_engine() + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + from app.main import create_app + return create_app(), dbmod + + +async def _seed_project(dbmod, project_id="p1"): + from app.core.models import Project, Source + async with dbmod.get_session_factory()() as s: + s.add(Source(id="s1", provider="github", + base_url="https://github.com", auth_kind="none")) + await s.commit() + async with dbmod.get_session_factory()() as s: + s.add(Project(id=project_id, name="proj", source_id="s1", + branch_strategy="shared_repo_subdir")) + await s.commit() + + +@pytest.mark.asyncio +async def test_heartbeat_returns_204_for_known_project(tmp_path, monkeypatch): + app, dbmod = await _boot(tmp_path, monkeypatch) + await _seed_project(dbmod) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://t" + ) as c: + r = await c.post("/v1/projects/p1/heartbeat") + assert r.status_code == 204 + assert r.content == b"" + + +@pytest.mark.asyncio +async def test_heartbeat_404s_for_unknown_project(tmp_path, monkeypatch): + app, _ = await _boot(tmp_path, monkeypatch) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://t" + ) as c: + r = await c.post("/v1/projects/nope/heartbeat") + assert r.status_code == 404 + assert r.json()["code"] == "NOT_FOUND" + + +@pytest.mark.asyncio +async def test_heartbeat_is_idempotent_and_writes_nothing(tmp_path, monkeypatch): + """It runs on a short interval per open editor — it must stay cheap.""" + app, dbmod = await _boot(tmp_path, monkeypatch) + await _seed_project(dbmod) + from app.core.models import Project + from sqlalchemy import select + + async with dbmod.get_session_factory()() as s: + before = (await s.execute(select(Project).where(Project.id == "p1"))).scalar_one() + snapshot = (before.name, before.source_id, before.branch_strategy) + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://t" + ) as c: + for _ in range(5): + assert (await c.post("/v1/projects/p1/heartbeat")).status_code == 204 + + async with dbmod.get_session_factory()() as s: + after = (await s.execute(select(Project).where(Project.id == "p1"))).scalar_one() + assert (after.name, after.source_id, after.branch_strategy) == snapshot