Skip to content

Commit 545ac23

Browse files
tildesrcclaude
andcommitted
feat(taskservice): REST API, liveness protocol, artifact store, MCP contract
Slice 1, PR 3 of 4 — the task service API contract. - panopticon.taskservice.service.TaskService: deterministic orchestration over the repository — tasks, transition enforcement (via the engine), slug, artifacts, and ephemeral liveness registrations. Injectable clock + id factory for test determinism. - panopticon.taskservice.api: FastAPI app factory exposing repos, tasks, transitions (with per-responsibility settlement on the wire), slug, artifacts, liveness, and discovery, with domain exceptions mapped to HTTP status codes. - panopticon.core.artifacts + taskservice.artifacts_fs: the artifact-store interface, the filesystem adapter, and the shared id->path->MCP-URI resolver (ADR 0003). - panopticon.taskservice.mcp: the MCP surface contract (resource URI scheme + tool specs), defined now; the server is wired when real containers connect (Slice 2). Adds fastapi + uvicorn (runtime) and httpx (dev, for TestClient). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 61da990 commit 545ac23

11 files changed

Lines changed: 1247 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@ name = "panopticon"
33
version = "0.0.1"
44
description = "Orchestrate multiple coding agents across isolated tasks and configurable workflows."
55
requires-python = ">=3.11"
6-
dependencies = []
6+
dependencies = [
7+
"fastapi>=0.110",
8+
"uvicorn>=0.29",
9+
]
710

811
[dependency-groups]
912
dev = [
1013
"pytest>=8",
1114
"mypy>=1.11",
15+
"httpx>=0.27",
1216
]
1317

1418
[build-system]

src/panopticon/core/artifacts.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""The artifact-store interface + the shared id→path→URI resolver (ADR 0003).
2+
3+
Freeform per-task files (plan, notes) are file-backed, not in the DB. The same bytes are
4+
reachable via the filesystem, the dashboard, and MCP; this module owns the single resolver
5+
that maps ``(task_id, name)`` to a path and an MCP URI so every surface agrees.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from abc import ABC, abstractmethod
11+
12+
MCP_URI_SCHEME = "panopticon"
13+
14+
15+
class ArtifactError(Exception):
16+
"""Base class for artifact-store failures."""
17+
18+
19+
class InvalidArtifactName(ArtifactError):
20+
"""Raised for an artifact name (or task id) that could escape its directory."""
21+
22+
23+
def validate_segment(segment: str) -> None:
24+
"""Reject names/ids that contain path separators, dot-segments, or are empty."""
25+
if (
26+
not segment
27+
or "/" in segment
28+
or "\\" in segment
29+
or segment in (".", "..")
30+
or segment.startswith(".")
31+
):
32+
raise InvalidArtifactName(f"invalid artifact segment: {segment!r}")
33+
34+
35+
def mcp_uri(task_id: str, name: str) -> str:
36+
"""The canonical MCP resource URI for an artifact (the shared resolver)."""
37+
validate_segment(task_id)
38+
validate_segment(name)
39+
return f"{MCP_URI_SCHEME}://tasks/{task_id}/artifacts/{name}"
40+
41+
42+
class ArtifactStore(ABC):
43+
"""Read/write per-task artifact files."""
44+
45+
@abstractmethod
46+
def put(self, task_id: str, name: str, content: bytes) -> None:
47+
"""Create or overwrite an artifact."""
48+
49+
@abstractmethod
50+
def get(self, task_id: str, name: str) -> bytes | None:
51+
"""Return artifact bytes, or ``None`` if it does not exist."""
52+
53+
@abstractmethod
54+
def list(self, task_id: str) -> list[str]:
55+
"""Return the names of a task's artifacts (empty if none)."""
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""The task service: the deterministic control plane.
22
33
Owns the repository (the sole DB authority, ADR 0006), hosts the workflow registry, and
4-
drives task lifecycle. This package must remain LLM-free (enforced by a determinism test).
4+
drives task lifecycle. This package must remain LLM-free (the determinism invariant).
55
"""

src/panopticon/taskservice/api.py

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
"""The task service REST API (FastAPI).
2+
3+
The dashboard, the runner, and in-container skills are clients of this API. (Agents also
4+
reach artifacts/tools over MCP — see :mod:`panopticon.taskservice.mcp` — but the walking
5+
skeleton uses REST.) ``create_app`` builds an app around an injected
6+
:class:`~panopticon.taskservice.service.TaskService`, so tests can wire a deterministic one.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from fastapi import FastAPI, HTTPException, Request, Response
12+
from fastapi.responses import JSONResponse
13+
from pydantic import BaseModel
14+
15+
from panopticon.core.artifacts import ArtifactError
16+
from panopticon.core.models import Repo, Responsibility, Status, Task
17+
from panopticon.core.repository import AlreadyExists, NotFound, RepositoryError
18+
from panopticon.core.workflow import IllegalTransition, InvalidWorkflow, ResponsibilitiesNotMet
19+
from panopticon.taskservice.service import Registration, TaskService, UnknownWorkflow
20+
21+
# -- wire schemas -------------------------------------------------------------------
22+
23+
24+
class ResponsibilityOut(BaseModel):
25+
key: str
26+
description: str
27+
status: str
28+
comment: str | None = None
29+
30+
31+
class HistoryOut(BaseModel):
32+
at: str
33+
from_state: str | None
34+
to_state: str
35+
trigger: str | None = None
36+
note: str | None = None
37+
responsibilities: list[ResponsibilityOut] = []
38+
39+
40+
class TaskOut(BaseModel):
41+
id: str
42+
repo_id: str
43+
workflow: str
44+
state: str
45+
turn: str
46+
slug: str | None
47+
history: list[HistoryOut]
48+
49+
@classmethod
50+
def of(cls, task: Task) -> "TaskOut":
51+
return cls(
52+
id=task.id,
53+
repo_id=task.repo_id,
54+
workflow=task.workflow,
55+
state=task.state,
56+
turn=task.turn.value,
57+
slug=task.slug,
58+
history=[
59+
HistoryOut(
60+
at=h.at,
61+
from_state=h.from_state,
62+
to_state=h.to_state,
63+
trigger=h.trigger,
64+
note=h.note,
65+
responsibilities=[
66+
ResponsibilityOut(
67+
key=r.key,
68+
description=r.description,
69+
status=r.status.value,
70+
comment=r.comment,
71+
)
72+
for r in h.responsibilities
73+
],
74+
)
75+
for h in task.history
76+
],
77+
)
78+
79+
80+
class RepoIn(BaseModel):
81+
id: str
82+
name: str
83+
git_url: str
84+
default_base: str = "main"
85+
86+
87+
class RepoOut(BaseModel):
88+
id: str
89+
name: str
90+
git_url: str
91+
default_base: str
92+
93+
@classmethod
94+
def of(cls, repo: Repo) -> "RepoOut":
95+
return cls(
96+
id=repo.id, name=repo.name, git_url=repo.git_url, default_base=repo.default_base
97+
)
98+
99+
100+
class CreateTaskIn(BaseModel):
101+
repo_id: str
102+
workflow: str
103+
104+
105+
class ResponsibilityIn(BaseModel):
106+
key: str
107+
status: Status
108+
comment: str | None = None
109+
110+
111+
class TransitionIn(BaseModel):
112+
to_state: str
113+
trigger: str | None = None
114+
note: str | None = None
115+
responsibilities: list[ResponsibilityIn] | None = None
116+
117+
118+
class SlugIn(BaseModel):
119+
slug: str
120+
121+
122+
class RegisterIn(BaseModel):
123+
container_id: str
124+
runner_id: str | None = None
125+
126+
127+
class RegistrationOut(BaseModel):
128+
id: str
129+
task_id: str
130+
container_id: str
131+
runner_id: str | None
132+
registered_at: str
133+
last_seen: str
134+
135+
@classmethod
136+
def of(cls, reg: Registration) -> "RegistrationOut":
137+
return cls(
138+
id=reg.id,
139+
task_id=reg.task_id,
140+
container_id=reg.container_id,
141+
runner_id=reg.runner_id,
142+
registered_at=reg.registered_at,
143+
last_seen=reg.last_seen,
144+
)
145+
146+
147+
def create_app(service: TaskService) -> FastAPI:
148+
app = FastAPI(title="panopticon task service", version="0.0.1")
149+
150+
# -- error mapping: domain exceptions -> HTTP status --------------------------
151+
152+
@app.exception_handler(NotFound)
153+
async def _not_found(_: Request, exc: NotFound) -> JSONResponse:
154+
return JSONResponse(status_code=404, content={"detail": str(exc)})
155+
156+
@app.exception_handler(AlreadyExists)
157+
async def _conflict(_: Request, exc: AlreadyExists) -> JSONResponse:
158+
return JSONResponse(status_code=409, content={"detail": str(exc)})
159+
160+
@app.exception_handler(IllegalTransition)
161+
async def _illegal(_: Request, exc: IllegalTransition) -> JSONResponse:
162+
return JSONResponse(status_code=409, content={"detail": str(exc)})
163+
164+
@app.exception_handler(ResponsibilitiesNotMet)
165+
async def _responsibilities(_: Request, exc: ResponsibilitiesNotMet) -> JSONResponse:
166+
return JSONResponse(status_code=409, content={"detail": str(exc)})
167+
168+
@app.exception_handler(UnknownWorkflow)
169+
async def _unknown_wf(_: Request, exc: UnknownWorkflow) -> JSONResponse:
170+
return JSONResponse(status_code=400, content={"detail": str(exc)})
171+
172+
@app.exception_handler(InvalidWorkflow)
173+
async def _invalid_wf(_: Request, exc: InvalidWorkflow) -> JSONResponse:
174+
return JSONResponse(status_code=400, content={"detail": str(exc)})
175+
176+
@app.exception_handler(ArtifactError)
177+
async def _artifact(_: Request, exc: ArtifactError) -> JSONResponse:
178+
return JSONResponse(status_code=400, content={"detail": str(exc)})
179+
180+
@app.exception_handler(RepositoryError)
181+
async def _repo_error(_: Request, exc: RepositoryError) -> JSONResponse:
182+
return JSONResponse(status_code=409, content={"detail": str(exc)})
183+
184+
# -- health & discovery -------------------------------------------------------
185+
186+
@app.get("/healthz")
187+
async def healthz() -> dict[str, str]:
188+
return {"status": "ok"}
189+
190+
@app.get("/workflows")
191+
async def list_workflows() -> list[str]:
192+
return service.workflow_names()
193+
194+
# -- repos --------------------------------------------------------------------
195+
196+
@app.post("/repos", status_code=201)
197+
async def create_repo(body: RepoIn) -> RepoOut:
198+
repo = service.create_repo(
199+
Repo(
200+
id=body.id, name=body.name, git_url=body.git_url, default_base=body.default_base
201+
)
202+
)
203+
return RepoOut.of(repo)
204+
205+
@app.get("/repos")
206+
async def list_repos() -> list[RepoOut]:
207+
return [RepoOut.of(r) for r in service.list_repos()]
208+
209+
@app.get("/repos/{repo_id}")
210+
async def get_repo(repo_id: str) -> RepoOut:
211+
return RepoOut.of(service.get_repo(repo_id))
212+
213+
# -- tasks --------------------------------------------------------------------
214+
215+
@app.post("/tasks", status_code=201)
216+
async def create_task(body: CreateTaskIn) -> TaskOut:
217+
return TaskOut.of(service.create_task(body.repo_id, body.workflow))
218+
219+
@app.get("/tasks")
220+
async def list_tasks() -> list[TaskOut]:
221+
return [TaskOut.of(t) for t in service.list_tasks()]
222+
223+
@app.get("/tasks/{task_id}")
224+
async def get_task(task_id: str) -> TaskOut:
225+
return TaskOut.of(service.get_task(task_id))
226+
227+
@app.post("/tasks/{task_id}/transition")
228+
async def transition(task_id: str, body: TransitionIn) -> TaskOut:
229+
responsibilities = (
230+
[
231+
Responsibility(key=r.key, description="", status=r.status, comment=r.comment)
232+
for r in body.responsibilities
233+
]
234+
if body.responsibilities is not None
235+
else None
236+
)
237+
return TaskOut.of(
238+
service.request_transition(
239+
task_id,
240+
body.to_state,
241+
trigger=body.trigger,
242+
note=body.note,
243+
responsibilities=responsibilities,
244+
)
245+
)
246+
247+
@app.put("/tasks/{task_id}/slug")
248+
async def set_slug(task_id: str, body: SlugIn) -> TaskOut:
249+
return TaskOut.of(service.set_slug(task_id, body.slug))
250+
251+
# -- artifacts ----------------------------------------------------------------
252+
253+
@app.put("/tasks/{task_id}/artifacts/{name}", status_code=204)
254+
async def put_artifact(task_id: str, name: str, request: Request) -> Response:
255+
service.put_artifact(task_id, name, await request.body())
256+
return Response(status_code=204)
257+
258+
@app.get("/tasks/{task_id}/artifacts")
259+
async def list_artifacts(task_id: str) -> list[str]:
260+
return service.list_artifacts(task_id)
261+
262+
@app.get("/tasks/{task_id}/artifacts/{name}")
263+
async def get_artifact(task_id: str, name: str) -> Response:
264+
content = service.get_artifact(task_id, name)
265+
if content is None:
266+
raise HTTPException(status_code=404, detail=f"artifact {name!r} not found")
267+
return Response(content=content, media_type="application/octet-stream")
268+
269+
# -- liveness -----------------------------------------------------------------
270+
271+
@app.post("/tasks/{task_id}/registrations", status_code=201)
272+
async def register(task_id: str, body: RegisterIn) -> RegistrationOut:
273+
return RegistrationOut.of(
274+
service.register(task_id, body.container_id, body.runner_id)
275+
)
276+
277+
@app.get("/tasks/{task_id}/registrations")
278+
async def list_registrations(task_id: str) -> list[RegistrationOut]:
279+
service.get_task(task_id) # 404 if the task is unknown
280+
return [RegistrationOut.of(r) for r in service.registrations(task_id)]
281+
282+
@app.post("/registrations/{registration_id}/heartbeat")
283+
async def heartbeat(registration_id: str) -> RegistrationOut:
284+
return RegistrationOut.of(service.heartbeat(registration_id))
285+
286+
@app.delete("/registrations/{registration_id}", status_code=204)
287+
async def deregister(registration_id: str) -> Response:
288+
service.deregister(registration_id)
289+
return Response(status_code=204)
290+
291+
return app

0 commit comments

Comments
 (0)