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
85 changes: 85 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# CLAUDE.md — operating manual

Guidance for agents working in this repo. The full design lives on the **`design-docs`**
branch (GOALS, PARITY, ARCHITECTURE, ROADMAP, ADRs 0001–0008). This file grows one slice
at a time (see ROADMAP "Definition of done — every slice").

## The one rule that matters most: the determinism invariant

The control plane makes **no LLM calls**. All LLM calls happen **inside task containers**.

- LLM-free packages: `core`, `taskservice`, `sessionservice`, `terminal`, `workflows`.
- The **only** LLM-bearing package is `container/` (the agent runs there).

If you add a package that orchestrates or renders, keep it LLM-free.

## Module map (current)

```
src/panopticon/
core/ # domain models, state classes, the Workflow interface (the state
# machine: resolution, queries, start_task/apply_transition),
# store & artifact interfaces — pure, no I/O
workflows/ # built-in Workflow subclasses (Spike seed for now)
taskservice/ # control plane: TaskService, FastAPI REST API, the SQLAlchemy store
# adapter (in-memory or on-disk SQLite), filesystem artifact store, MCP
sessionservice/ # the runner (stub for now; real Docker+tmux runner later)
container/ # in-container client + entrypoint protocol — the ONLY LLM-bearing pkg
```

## Conventions

- **The state machine is deterministic and clock-free.** Timestamps are passed in by the
caller (the task service stamps them); the workflow never reads the clock. Keep it that way.
- **Identity vs. slug.** A task's identity is its internal `id` (generated by the task
service). The `slug` is a human label, nullable, **set in the container** via a hook
(ARCHITECTURE.md §8.3) — not chosen host-side.
- **All task-state mutations go through the task service**, which enforces transitions via
the workflow before persisting (the store is the single writer; ADR 0006).
- **Interfaces vs. adapters.** Interfaces (ABCs) live in `core`; adapters live in the owning
package. New backends implement an interface; they don't change callers.

## Dev commands

```sh
uv sync # create the venv, install deps
uv run pytest # run the test suite
uv run mypy -p panopticon # type-check (strict)
```

CI (`.github/workflows/ci.yml`) runs `uv sync`, `mypy`, and `pytest` on every PR.

## Tests worth knowing

- `tests/test_workflow.py` — the **golden harness**: every legal/illegal transition, turn
derivation, responsibility gating, and workflow validation. Extend it when you touch the
state machine.
- `tests/test_store.py` — store **contract tests run against in-memory and on-disk SQLite**,
proving the interface is backend-agnostic (and that rows/domain models stay in sync).
- `tests/test_skeleton.py` — the end-to-end walking skeleton (create → register → slug →
transition → history) over the REST API, no Docker.

## Glossary

- **Task** — a unit of work; identity is `id`, label is `slug`.
- **Repo** — a repository tasks operate on (owns secret references, later slices).
- **Workflow** — a `Workflow` subclass whose **states are nested `State` classes**
(declarative). It declares `initial`; states are discovered and their transitions
(class refs or label strings) resolved + validated when the workflow is instantiated.
The lifecycle is code, not hardcoded control flow.
- **State** — a class (`State` non-terminal, inherits a `Dropped` transition; or
`TerminalState`). Carries a `label` (persisted in `Task.state`, shown on the dashboard),
`turn_on_enter`, `advanced_by`, `responsibilities`, and `transitions`. Built-ins:
`Complete`, `Dropped`.
- **Actor** — a party, `user` or `agent`. A state declares `turn_on_enter` (who holds the
turn on entry; seeds `Task.turn`) and `advanced_by` (who transitions out — the default is
`USER`). The two are orthogonal.
- **Responsibility / Status** — an agent obligation for a state. Entering a state seeds its
responsibilities onto that entry's history record, all `PENDING` (a promise); the agent
fulfils each one at a time (`MET`, or `FAILED` with a comment) — mutating that entry — and a
later advance is gated on all being resolved. Agent-only.
- **Registration / liveness** — a container's standing claim that it is working on a task.
- **Task service** — the deterministic control plane (sole DB authority).
- **Session service / runner** — spawns task containers (stubbed for now).
- **Terminal controller** — the user-facing CLI/dashboard (Slice 3).
- **Artifact** — a file-backed per-task document (plan, notes), reachable via REST/FS/MCP.
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,20 @@ See the `design-docs` branch for the full picture.

## Status

Early development. Building Milestone 1 in vertical slices (see the roadmap). This slice
lands the core contracts:

- `panopticon.core` — domain models, the workflow port (`Workflow` ABC), and the
deterministic lifecycle engine (state machine, turn tracking, responsibility gating).
- `panopticon.workflows.FreeFormWorkflow` — the minimal seed workflow.
Early development. Building Milestone 1 in vertical slices (see the roadmap). **Slice 1**
lands the four contracts plus a walking skeleton:

- `panopticon.core` — domain models, state classes, the `Workflow` interface (the
deterministic state machine: resolution, turn tracking, responsibility gating), and the
store & artifact interfaces.
- `panopticon.taskservice` — the control plane: `TaskService`, a FastAPI REST API, the
SQLAlchemy store adapter (in-memory or on-disk SQLite), the filesystem artifact store, and
the MCP surface contract.
- `panopticon.sessionservice` / `panopticon.container` — a stub runner and the container
entrypoint protocol that drive the end-to-end walking skeleton (no Docker, no LLM yet).
- `panopticon.workflows.Spike` — the minimal seed workflow.

See [`CLAUDE.md`](CLAUDE.md) for the operating manual and the determinism invariant.

## Development

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ dependencies = [
"fastapi>=0.110",
"sqlalchemy>=2.0.50",
"uvicorn>=0.29",
"httpx>=0.27",
]

[dependency-groups]
dev = [
"pytest>=8",
"mypy>=1.11",
"httpx>=0.27",
]

[build-system]
Expand Down
6 changes: 6 additions & 0 deletions src/panopticon/container/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""In-container code: the task-service client and the entrypoint protocol.

This is the *only* package permitted to call an LLM (the agent runs here) — the
determinism invariant exempts it. In this slice there is no LLM yet — the entrypoint is a
faithful stub of the connect/register/slug protocol.
"""
95 changes: 95 additions & 0 deletions src/panopticon/container/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""A thin REST client for the task service, used from inside a task container.

Wraps an :class:`httpx.Client` (real, pointed at the runner-injected service URL; or a
FastAPI ``TestClient`` in tests). Skills and the entrypoint use this; agents also have the
MCP surface (later slice).
"""

from __future__ import annotations

from typing import Any, cast

import httpx

from panopticon.core.models import Status

JsonObj = dict[str, Any]


class TaskServiceClient:
def __init__(self, http: httpx.Client) -> None:
self._http = http

@staticmethod
def _json(resp: httpx.Response) -> JsonObj:
resp.raise_for_status()
return cast(JsonObj, resp.json())

# -- repos / tasks ------------------------------------------------------------

def create_repo(self, repo_id: str, name: str, default_base: str = "main") -> JsonObj:
return self._json(
self._http.post(
"/repos", json={"id": repo_id, "name": name, "default_base": default_base}
)
)

def create_task(self, repo_id: str, workflow: str) -> JsonObj:
return self._json(
self._http.post("/tasks", json={"repo_id": repo_id, "workflow": workflow})
)

def get_task(self, task_id: str) -> JsonObj:
return self._json(self._http.get(f"/tasks/{task_id}"))

def set_slug(self, task_id: str, slug: str) -> JsonObj:
return self._json(self._http.put(f"/tasks/{task_id}/slug", json={"slug": slug}))

def request_transition(
self,
task_id: str,
to_state: str,
*,
trigger: str | None = None,
note: str | None = None,
) -> JsonObj:
body: JsonObj = {"to_state": to_state, "trigger": trigger, "note": note}
return self._json(self._http.post(f"/tasks/{task_id}/transition", json=body))

def resolve_responsibility(
self, task_id: str, key: str, status: Status, comment: str | None = None
) -> JsonObj:
"""Resolve one of the current state's promised responsibilities (MET or FAILED)."""
body: JsonObj = {"key": key, "status": status.value, "comment": comment}
return self._json(self._http.post(f"/tasks/{task_id}/responsibilities", json=body))

# -- artifacts ----------------------------------------------------------------

def put_artifact(self, task_id: str, name: str, content: bytes) -> None:
self._http.put(f"/tasks/{task_id}/artifacts/{name}", content=content).raise_for_status()

def get_artifact(self, task_id: str, name: str) -> bytes:
resp = self._http.get(f"/tasks/{task_id}/artifacts/{name}")
resp.raise_for_status()
return resp.content

# -- liveness -----------------------------------------------------------------

def register(self, task_id: str, container_id: str, runner_id: str | None = None) -> JsonObj:
return self._json(
self._http.post(
f"/tasks/{task_id}/registrations",
json={"container_id": container_id, "runner_id": runner_id},
)
)

def heartbeat(self, registration_id: str) -> JsonObj:
return self._json(self._http.post(f"/registrations/{registration_id}/heartbeat"))

def deregister(self, registration_id: str) -> None:
self._http.delete(f"/registrations/{registration_id}").raise_for_status()

def list_registrations(self, task_id: str) -> list[JsonObj]:
resp = self._http.get(f"/tasks/{task_id}/registrations")
resp.raise_for_status()
return cast("list[JsonObj]", resp.json())
41 changes: 41 additions & 0 deletions src/panopticon/container/entrypoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""The container entrypoint protocol (skeleton form).

A real task container will run an agent (the only place LLMs run). Here we implement the
deterministic *protocol* the entrypoint owns, so it can be exercised without Docker:

1. connect to the task service and **register** (liveness) — and stay registered until done;
2. if the task has no **slug**, set one (the slug hook — slugs are decided in the container,
unlike cloude-cade, per ARCHITECTURE.md §8.3);
3. run the task's work (here, an injected callback stands in for the agent);
4. deregister on exit.
"""

from __future__ import annotations

from collections.abc import Callable

from panopticon.container.client import TaskServiceClient

Work = Callable[[TaskServiceClient, str], None]


def run_task_container(
client: TaskServiceClient,
task_id: str,
*,
container_id: str,
runner_id: str | None = None,
proposed_slug: str | None = None,
work: Work | None = None,
) -> None:
"""Run the entrypoint protocol for ``task_id`` against the task service."""
registration = client.register(task_id, container_id=container_id, runner_id=runner_id)
try:
task = client.get_task(task_id)
if task["slug"] is None and proposed_slug is not None:
client.set_slug(task_id, proposed_slug) # the slug hook
client.heartbeat(registration["id"])
if work is not None:
work(client, task_id)
finally:
client.deregister(registration["id"])
6 changes: 6 additions & 0 deletions src/panopticon/sessionservice/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""The session service (runner): spawns task containers and owns their tmux sessions.

Realizes the execution-backend boundary (ADR 0006/0008). The real runner is a host process
that spawns containers on the host Docker daemon; this package currently ships only a stub
runner for the walking skeleton (no Docker). Must remain LLM-free (the determinism invariant).
"""
35 changes: 35 additions & 0 deletions src/panopticon/sessionservice/stub_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""A stub runner for the walking skeleton.

Stands in for the session service: instead of spawning a container on the host Docker
daemon and a tmux session (ADR 0008), it runs the container entrypoint **in-process**, so
the end-to-end path works without Docker. Real adapters replace this behind the same idea.
"""

from __future__ import annotations

import itertools

from panopticon.container.client import TaskServiceClient
from panopticon.container.entrypoint import Work, run_task_container


class StubRunner:
def __init__(self, client: TaskServiceClient, *, runner_id: str = "stub-runner") -> None:
self._client = client
self._runner_id = runner_id
self._counter = itertools.count(1)

def spawn(
self, task_id: str, *, proposed_slug: str | None = None, work: Work | None = None
) -> str:
""""Spawn" a fake container for ``task_id`` and return its container id."""
container_id = f"{self._runner_id}-c{next(self._counter)}"
run_task_container(
self._client,
task_id,
container_id=container_id,
runner_id=self._runner_id,
proposed_slug=proposed_slug,
work=work,
)
return container_id
81 changes: 81 additions & 0 deletions tests/test_skeleton.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Slice 1 acceptance: the walking skeleton, end to end over REST.

Proves the contract path: create a task -> the task service persists it -> a (fake)
container registers (liveness) -> sets a slug -> requests a transition the workflow accepts
-> history reflects it -> liveness is cleaned up. No Docker, no LLM.
"""

from __future__ import annotations

from collections.abc import Iterator
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

from panopticon.container.client import TaskServiceClient
from panopticon.core.models import Repo
from panopticon.sessionservice.stub_runner import StubRunner
from panopticon.taskservice.api import create_app
from panopticon.taskservice.artifacts_fs import FilesystemArtifactStore
from panopticon.taskservice.store_sqlalchemy import SqlAlchemyStore
from panopticon.taskservice.service import TaskService
from panopticon.workflows import Spike


@pytest.fixture
def client(tmp_path: Path) -> Iterator[TaskServiceClient]:
service = TaskService(
SqlAlchemyStore(),
{"spike": Spike()},
FilesystemArtifactStore(tmp_path),
)
service.create_repo(Repo(id="r1", name="acme/widgets", git_url="https://x/r1.git"))
with TestClient(create_app(service)) as http:
yield TaskServiceClient(http)


def test_walking_skeleton(client: TaskServiceClient) -> None:
# 1. A task is created and persisted in the workflow's initial state.
task = client.create_task("r1", "spike")
task_id = task["id"]
assert task["state"] == "ITERATING"
assert task["slug"] is None

# 2-4. The runner "spawns" a container that registers, sets the slug, and works.
runner = StubRunner(client)

def work(c: TaskServiceClient, tid: str) -> None:
c.put_artifact(tid, "plan.md", b"# Plan\nfix the widget\n")
c.request_transition(tid, "COMPLETE", trigger="finish")

runner.spawn(task_id, proposed_slug="fix-widget", work=work)

# 5. The persisted record reflects everything the container did.
final = client.get_task(task_id)
assert final["slug"] == "fix-widget" # slug was set in the container
assert final["state"] == "COMPLETE"
assert [h["to_state"] for h in final["history"]] == ["ITERATING", "COMPLETE"]
assert client.get_artifact(task_id, "plan.md") == b"# Plan\nfix the widget\n"

# Liveness registration was cleaned up on container exit.
assert client.list_registrations(task_id) == []


def test_slug_hook_does_not_overwrite_existing_slug(client: TaskServiceClient) -> None:
task_id = client.create_task("r1", "spike")["id"]
client.set_slug(task_id, "chosen-by-user")
StubRunner(client).spawn(task_id, proposed_slug="would-be-overwrite")
assert client.get_task(task_id)["slug"] == "chosen-by-user"


def test_registration_active_during_work(client: TaskServiceClient) -> None:
task_id = client.create_task("r1", "spike")["id"]
seen: list[int] = []

def work(c: TaskServiceClient, tid: str) -> None:
seen.append(len(c.list_registrations(tid))) # registered while working

StubRunner(client).spawn(task_id, work=work)
assert seen == [1]
assert client.list_registrations(task_id) == [] # deregistered after
Loading