Skip to content

Commit de83f4e

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 c0ed9dd commit de83f4e

11 files changed

Lines changed: 1273 additions & 4 deletions

File tree

docs/BACKLOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ in the ADRs; this file is for the smaller stuff that doesn't have a home there y
4141
_(Slice 1, P2)_
4242
- [ ] **Registrations are in-memory** — lost on task-service restart; no reconciliation with
4343
live containers on reconnect (relates to ADR 0008 failure-handling). _(Slice 1, P2)_
44+
- [ ] **Slug-addressable artifacts** — once a task has a `slug`, both
45+
`tasks/{task_id}/artifacts/{name}` and `tasks/{slug}/artifacts/{name}` should resolve to the
46+
same artifact (slug as an alias for the id on the artifact routes). _(Slice 1, P3)_
4447

4548
## Tracked elsewhere (pointers, do not duplicate)
4649

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ version = "0.0.1"
44
description = "Orchestrate multiple coding agents across isolated tasks and configurable workflows."
55
requires-python = ">=3.11"
66
dependencies = [
7+
"fastapi>=0.110",
78
"sqlalchemy>=2.0.50",
9+
"uvicorn>=0.29",
810
]
911

1012
[dependency-groups]
1113
dev = [
1214
"pytest>=8",
1315
"mypy>=1.11",
16+
"httpx>=0.27",
1417
]
1518

1619
[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)."""

src/panopticon/taskservice/api.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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, ConfigDict
14+
15+
from panopticon.core.artifacts import ArtifactError
16+
from panopticon.core.models import Actor, Repo, Status
17+
from panopticon.core.store import AlreadyExists, NotFound, StoreError
18+
from panopticon.core.workflow import IllegalTransition, InvalidWorkflow, ResponsibilitiesNotMet
19+
from panopticon.taskservice.service import TaskService, UnknownWorkflow
20+
21+
# -- wire schemas -------------------------------------------------------------------
22+
23+
24+
# ``*Out`` models read straight off the domain objects (`model_validate`): their fields match
25+
# the domain attribute names, so `from_attributes=True` does the conversion — incl. nested
26+
# Task -> History -> Responsibility — with no hand-written copying.
27+
28+
29+
class ResponsibilityOut(BaseModel):
30+
model_config = ConfigDict(from_attributes=True)
31+
32+
key: str
33+
description: str
34+
status: Status
35+
comment: str | None = None
36+
37+
38+
class HistoryOut(BaseModel):
39+
model_config = ConfigDict(from_attributes=True)
40+
41+
at: str
42+
from_state: str | None
43+
to_state: str
44+
trigger: str | None = None
45+
note: str | None = None
46+
responsibilities: list[ResponsibilityOut] = []
47+
48+
49+
class TaskOut(BaseModel):
50+
model_config = ConfigDict(from_attributes=True)
51+
52+
id: str
53+
repo_id: str
54+
workflow: str
55+
state: str
56+
turn: Actor
57+
slug: str | None
58+
history: list[HistoryOut]
59+
60+
61+
class RepoIn(BaseModel):
62+
id: str
63+
name: str
64+
git_url: str
65+
default_base: str = "main"
66+
67+
68+
class RepoOut(BaseModel):
69+
model_config = ConfigDict(from_attributes=True)
70+
71+
id: str
72+
name: str
73+
git_url: str
74+
default_base: str
75+
76+
77+
class CreateTaskIn(BaseModel):
78+
repo_id: str
79+
workflow: str
80+
81+
82+
class ResponsibilityIn(BaseModel):
83+
key: str
84+
status: Status
85+
comment: str | None = None
86+
87+
88+
class TransitionIn(BaseModel):
89+
to_state: str
90+
trigger: str | None = None
91+
note: str | None = None
92+
93+
94+
class SlugIn(BaseModel):
95+
slug: str
96+
97+
98+
class RegisterIn(BaseModel):
99+
container_id: str
100+
runner_id: str | None = None
101+
102+
103+
class RegistrationOut(BaseModel):
104+
model_config = ConfigDict(from_attributes=True)
105+
106+
id: str
107+
task_id: str
108+
container_id: str
109+
runner_id: str | None
110+
registered_at: str
111+
last_seen: str
112+
113+
114+
def create_app(service: TaskService) -> FastAPI:
115+
app = FastAPI(title="panopticon task service", version="0.0.1")
116+
117+
# -- error mapping: domain exceptions -> HTTP status --------------------------
118+
119+
@app.exception_handler(NotFound)
120+
async def _not_found(_: Request, exc: NotFound) -> JSONResponse:
121+
return JSONResponse(status_code=404, content={"detail": str(exc)})
122+
123+
@app.exception_handler(AlreadyExists)
124+
async def _conflict(_: Request, exc: AlreadyExists) -> JSONResponse:
125+
return JSONResponse(status_code=409, content={"detail": str(exc)})
126+
127+
@app.exception_handler(IllegalTransition)
128+
async def _illegal(_: Request, exc: IllegalTransition) -> JSONResponse:
129+
return JSONResponse(status_code=409, content={"detail": str(exc)})
130+
131+
@app.exception_handler(ResponsibilitiesNotMet)
132+
async def _responsibilities(_: Request, exc: ResponsibilitiesNotMet) -> JSONResponse:
133+
return JSONResponse(status_code=409, content={"detail": str(exc)})
134+
135+
@app.exception_handler(UnknownWorkflow)
136+
async def _unknown_wf(_: Request, exc: UnknownWorkflow) -> JSONResponse:
137+
return JSONResponse(status_code=400, content={"detail": str(exc)})
138+
139+
@app.exception_handler(InvalidWorkflow)
140+
async def _invalid_wf(_: Request, exc: InvalidWorkflow) -> JSONResponse:
141+
return JSONResponse(status_code=400, content={"detail": str(exc)})
142+
143+
@app.exception_handler(ArtifactError)
144+
async def _artifact(_: Request, exc: ArtifactError) -> JSONResponse:
145+
return JSONResponse(status_code=400, content={"detail": str(exc)})
146+
147+
@app.exception_handler(StoreError)
148+
async def _store_error(_: Request, exc: StoreError) -> JSONResponse:
149+
return JSONResponse(status_code=409, content={"detail": str(exc)})
150+
151+
# -- health & discovery -------------------------------------------------------
152+
153+
@app.get("/healthz")
154+
async def healthz() -> dict[str, str]:
155+
return {"status": "ok"}
156+
157+
@app.get("/workflows")
158+
async def list_workflows() -> list[str]:
159+
return service.workflow_names()
160+
161+
# -- repos --------------------------------------------------------------------
162+
163+
@app.post("/repos", status_code=201)
164+
async def create_repo(body: RepoIn) -> RepoOut:
165+
repo = service.create_repo(Repo(**body.model_dump()))
166+
return RepoOut.model_validate(repo)
167+
168+
@app.get("/repos")
169+
async def list_repos() -> list[RepoOut]:
170+
return [RepoOut.model_validate(r) for r in service.list_repos()]
171+
172+
@app.get("/repos/{repo_id}")
173+
async def get_repo(repo_id: str) -> RepoOut:
174+
return RepoOut.model_validate(service.get_repo(repo_id))
175+
176+
# -- tasks --------------------------------------------------------------------
177+
178+
@app.post("/tasks", status_code=201)
179+
async def create_task(body: CreateTaskIn) -> TaskOut:
180+
return TaskOut.model_validate(service.create_task(body.repo_id, body.workflow))
181+
182+
@app.get("/tasks")
183+
async def list_tasks() -> list[TaskOut]:
184+
return [TaskOut.model_validate(t) for t in service.list_tasks()]
185+
186+
@app.get("/tasks/{task_id}")
187+
async def get_task(task_id: str) -> TaskOut:
188+
return TaskOut.model_validate(service.get_task(task_id))
189+
190+
@app.post("/tasks/{task_id}/transition")
191+
async def transition(task_id: str, body: TransitionIn) -> TaskOut:
192+
return TaskOut.model_validate(
193+
service.request_transition(
194+
task_id, body.to_state, trigger=body.trigger, note=body.note
195+
)
196+
)
197+
198+
@app.post("/tasks/{task_id}/responsibilities")
199+
async def resolve_responsibility(task_id: str, body: ResponsibilityIn) -> TaskOut:
200+
try:
201+
task = service.resolve_responsibility(
202+
task_id, body.key, status=body.status, comment=body.comment
203+
)
204+
except ValueError as exc: # unknown key / PENDING / FAILED without a comment
205+
raise HTTPException(status_code=400, detail=str(exc)) from exc
206+
return TaskOut.model_validate(task)
207+
208+
@app.put("/tasks/{task_id}/slug")
209+
async def set_slug(task_id: str, body: SlugIn) -> TaskOut:
210+
return TaskOut.model_validate(service.set_slug(task_id, body.slug))
211+
212+
# -- artifacts ----------------------------------------------------------------
213+
214+
@app.put("/tasks/{task_id}/artifacts/{name}", status_code=204)
215+
async def put_artifact(task_id: str, name: str, request: Request) -> Response:
216+
service.put_artifact(task_id, name, await request.body())
217+
return Response(status_code=204)
218+
219+
@app.get("/tasks/{task_id}/artifacts")
220+
async def list_artifacts(task_id: str) -> list[str]:
221+
return service.list_artifacts(task_id)
222+
223+
@app.get("/tasks/{task_id}/artifacts/{name}")
224+
async def get_artifact(task_id: str, name: str) -> Response:
225+
content = service.get_artifact(task_id, name)
226+
if content is None:
227+
raise HTTPException(status_code=404, detail=f"artifact {name!r} not found")
228+
return Response(content=content, media_type="application/octet-stream")
229+
230+
# -- liveness -----------------------------------------------------------------
231+
232+
@app.post("/tasks/{task_id}/registrations", status_code=201)
233+
async def register(task_id: str, body: RegisterIn) -> RegistrationOut:
234+
return RegistrationOut.model_validate(
235+
service.register(task_id, body.container_id, body.runner_id)
236+
)
237+
238+
@app.get("/tasks/{task_id}/registrations")
239+
async def list_registrations(task_id: str) -> list[RegistrationOut]:
240+
service.get_task(task_id) # 404 if the task is unknown
241+
return [RegistrationOut.model_validate(r) for r in service.registrations(task_id)]
242+
243+
@app.post("/registrations/{registration_id}/heartbeat")
244+
async def heartbeat(registration_id: str) -> RegistrationOut:
245+
return RegistrationOut.model_validate(service.heartbeat(registration_id))
246+
247+
@app.delete("/registrations/{registration_id}", status_code=204)
248+
async def deregister(registration_id: str) -> Response:
249+
service.deregister(registration_id)
250+
return Response(status_code=204)
251+
252+
return app
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Filesystem artifact-store adapter (ADR 0003: local filesystem first).
2+
3+
Layout: ``<root>/tasks/<task_id>/<name>``. The same files are openable in an editor and,
4+
later, served over MCP using the resolver in :mod:`panopticon.core.artifacts`.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from pathlib import Path
10+
11+
from panopticon.core.artifacts import ArtifactStore, validate_segment
12+
13+
14+
class FilesystemArtifactStore(ArtifactStore):
15+
"""Store artifacts as plain files under a root directory."""
16+
17+
def __init__(self, root: str | Path) -> None:
18+
self._root = Path(root)
19+
20+
def _task_dir(self, task_id: str) -> Path:
21+
validate_segment(task_id)
22+
return self._root / "tasks" / task_id
23+
24+
def put(self, task_id: str, name: str, content: bytes) -> None:
25+
validate_segment(name)
26+
task_dir = self._task_dir(task_id)
27+
task_dir.mkdir(parents=True, exist_ok=True)
28+
(task_dir / name).write_bytes(content)
29+
30+
def get(self, task_id: str, name: str) -> bytes | None:
31+
validate_segment(name)
32+
path = self._task_dir(task_id) / name
33+
return path.read_bytes() if path.is_file() else None
34+
35+
def list(self, task_id: str) -> list[str]:
36+
task_dir = self._task_dir(task_id)
37+
if not task_dir.is_dir():
38+
return []
39+
return sorted(p.name for p in task_dir.iterdir() if p.is_file())

0 commit comments

Comments
 (0)