Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions app/routes/v1/projects/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
36 changes: 36 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
85 changes: 85 additions & 0 deletions tests/routes/test_project_heartbeat.py
Original file line number Diff line number Diff line change
@@ -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
Loading