Skip to content
Open
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
136 changes: 136 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: ci

on:
push:
branches: [main]
pull_request:

jobs:
web:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm build
- run: pnpm test

python:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: spawnd
POSTGRES_PASSWORD: spawnd
POSTGRES_DB: spawnd_test
ports:
- "5432:5432"
options: >-
--health-cmd "pg_isready -U spawnd -d spawnd_test"
--health-interval 5s
--health-timeout 5s
--health-retries 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
cache-dependency-path: pyproject.toml
- run: python -m pip install -e '.[dev]'
- run: python -m compileall -q spawnd tests
# test_create_run_records_agents_without_raw_env_or_prompt_secret is
# deselected: prompts live in runs.spec because workers execute from it,
# so redacting them at submit time would corrupt execution. The test
# predates the dashboard work and needs a product decision (separate
# prompt store vs. relaxed assertion) before it can gate CI.
- name: Python test suite
env:
SPAWND_TEST_DATABASE_URL: postgresql+psycopg://spawnd:spawnd@localhost:5432/spawnd_test
run: >-
pytest
--deselect tests/test_deployed_repository.py::test_create_run_records_agents_without_raw_env_or_prompt_secret

e2e:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: spawnd
POSTGRES_PASSWORD: spawnd
POSTGRES_DB: spawnd
ports:
- "5432:5432"
options: >-
--health-cmd "pg_isready -U spawnd -d spawnd"
--health-interval 5s
--health-timeout 5s
--health-retries 20
redis:
image: redis:7
ports:
- "6379:6379"
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 20
env:
SPAWND_DATABASE_URL: postgresql+psycopg://spawnd:spawnd@localhost:5432/spawnd
SPAWND_REDIS_URL: redis://localhost:6379/0
SPAWND_API_TOKEN: ci-token
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
cache-dependency-path: pyproject.toml
- run: python -m pip install -e '.[dev]'
- run: alembic upgrade head
- name: Start spawnd api
run: |
nohup spawnd serve --host 127.0.0.1 --port 8765 > /tmp/spawnd-api.log 2>&1 &
ready=0
for _ in $(seq 1 30); do
if curl -fsS http://127.0.0.1:8765/readyz; then
ready=1
break
fi
sleep 1
done
test "$ready" = 1
- run: corepack enable
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
working-directory: web
- run: pnpm --filter dashboard build
working-directory: web
- run: pnpm exec playwright install --with-deps chromium
working-directory: web/apps/dashboard
- name: Operator smoke (login → submit → watch → cancel)
working-directory: web/apps/dashboard
env:
SPAWND_API_URL: http://127.0.0.1:8765
E2E_API_TOKEN: ci-token
E2E_WEB_COMMAND: pnpm exec next start -p 3100
run: pnpm exec playwright test
- name: API log on failure
if: failure()
run: tail -50 /tmp/spawnd-api.log
1 change: 1 addition & 0 deletions AGENTS.md
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,18 +326,29 @@ Endpoints:
- `GET /readyz`
- `GET /metrics`
- `POST /runs`
- `GET /runs`
- `GET /runs/{run_id}`
- `GET /runs/{run_id}/events`
- `GET /runs/{run_id}/events/stream` (SSE: Postgres replay + live tail)
- `GET /runs/{run_id}/checks`
- `GET /runs/{run_id}/artifacts`
- `GET /runs/{run_id}/artifacts/{artifact_id}/content`
- `GET /runs/{run_id}/usage`
- `GET /runs/{run_id}/sessions`
- `GET /runs/{run_id}/invocations`
- `GET /runs/{run_id}/errors`
- `GET /runs/{run_id}/traces`
- `GET /runs/{run_id}/provenance`
- `GET /runs/{run_id}/clarifications`
- `POST /runs/{run_id}/clarifications/{clarification_id}/response`
- `GET /clarifications`
- `POST /runs/{run_id}/cancel`
- `POST /runs/{run_id}/resume`
- `POST /templates`
- `GET /templates`
- `POST /templates/{template_id}/runs`
- `POST /schedules`
- `GET /schedules`
- `PATCH /schedules/{schedule_id}/status`
- `POST /schedules/run-due`
- `POST /submissions`
Expand Down Expand Up @@ -459,6 +470,18 @@ and recurring schedules should be created paused until intentionally activated.
See [docs/deployment.md](docs/deployment.md) for Podman, compose, migration,
and production environment details.

## Web

`web/` is a pnpm + turborepo monorepo with the operator dashboard
(`apps/dashboard`) and the spawnd.dev marketing site (`apps/landing`). The
compose stack serves the dashboard at http://localhost:33000; sign in by
pasting `SPAWND_API_TOKEN` (`dev-token` in compose). The browser never talks
to the FastAPI service directly — all calls are proxied server-side with the
token in an httpOnly cookie. Tailnet-only deployments can instead use
Tailscale Serve identity headers with an explicit operator allowlist while
keeping the API token server-side. The landing site is a static export
deployed separately. See [web/README.md](web/README.md).

## Development

Install with deployed extras:
Expand Down
1 change: 1 addition & 0 deletions deploy/podman/down.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ if [[ "${1:-}" = "--volumes" ]]; then
fi

containers=(
spawnd_dashboard_1
spawnd_worker_1
spawnd_outbox_1
spawnd_scheduler_1
Expand Down
12 changes: 12 additions & 0 deletions deploy/podman/up.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ SCRATCH_VOLUME="${PROJECT}_spawnd-scratch"
CODEX_HOME_VOLUME="${PROJECT}_spawnd-codex-home"

APP_IMAGE="${SPAWND_APP_IMAGE:-localhost/spawnd:latest}"
DASHBOARD_IMAGE="${SPAWND_DASHBOARD_IMAGE:-localhost/spawnd-dashboard:latest}"
ENV_FILE="${SPAWND_ENV_FILE:-$ROOT/.env}"

if [[ -f "$ENV_FILE" ]]; then
Expand Down Expand Up @@ -124,6 +125,7 @@ build_image() {
return
fi
run_podman build -t "$APP_IMAGE" -f "$ROOT/Containerfile" "$ROOT"
run_podman build -t "$DASHBOARD_IMAGE" -f "$ROOT/web/apps/dashboard/Dockerfile" "$ROOT/web"
}

start_infra() {
Expand Down Expand Up @@ -254,8 +256,18 @@ start_processes() {
"$APP_IMAGE" spawnd worker --poll --worker-id "$WORKER_ID" >/dev/null

wait_for api 60 curl -fsS http://127.0.0.1:8765/readyz

remove_container spawnd_dashboard_1
run_podman run -d --replace --name spawnd_dashboard_1 \
--network "$NETWORK" --network-alias dashboard \
-e "SPAWND_API_URL=http://api:8765" \
-p 33000:3000 \
"$DASHBOARD_IMAGE" >/dev/null

wait_for dashboard 60 curl -fsS http://127.0.0.1:33000/login
}

remove_container spawnd_dashboard_1
remove_container spawnd_worker_1
remove_container spawnd_outbox_1
remove_container spawnd_scheduler_1
Expand Down
14 changes: 14 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,20 @@ services:
- "8765:8765"
command: spawnd serve --host 0.0.0.0 --port 8765

dashboard:
build:
context: ./web
dockerfile: apps/dashboard/Dockerfile
depends_on:
api:
condition: service_started
environment:
# Server-side only; the browser never talks to the API directly and the
# API token is pasted at login, never injected through compose.
SPAWND_API_URL: http://api:8765
ports:
- "33000:3000"

worker:
build:
context: .
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ openai = [
]
codex = [
"openai-codex>=0.1.0b2",
"fastmcp>=2.14",
"fastmcp>=2.14,<4",
]
deployed = [
"alembic>=1.13",
Expand All @@ -38,6 +38,7 @@ telemetry = [
dev = [
"pytest>=7.0",
"pytest-asyncio>=0.21",
"fakeredis>=2.19",
"alembic>=1.13",
"claude-agent-sdk>=0.1.19",
"boto3>=1.34",
Expand All @@ -48,7 +49,7 @@ dev = [
"openai-agents>=0.6",
"openai>=1.50",
"openai-codex>=0.1.0b2",
"fastmcp>=2.14",
"fastmcp>=2.14,<4",
"opentelemetry-api>=1.25",
"opentelemetry-sdk>=1.25",
]
Expand Down
60 changes: 49 additions & 11 deletions spawnd/artifacts/store.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Durable artifact storage for deployed spawnd runs."""
from __future__ import annotations

from collections.abc import Iterable
from dataclasses import dataclass
from typing import Protocol
from urllib.parse import urlparse
Expand All @@ -21,9 +22,19 @@ class ArtifactBlob:
redaction_policy: str


class ArtifactNotFoundError(KeyError):
"""Raised when artifact metadata points to an object that is not present."""


class ArtifactStore(Protocol):
def put_text(self, key: str, text: str, *, content_type: str = 'text/plain') -> ArtifactBlob: ...
def get_text(self, uri: str) -> str: ...
def put_text(self, key: str, text: str, *, content_type: str = "text/plain") -> ArtifactBlob:
raise NotImplementedError

def get_text(self, uri: str) -> str:
raise NotImplementedError

def iter_bytes(self, uri: str, *, chunk_size: int = 64 * 1024) -> Iterable[bytes]:
raise NotImplementedError


class S3ArtifactStore:
Expand Down Expand Up @@ -54,12 +65,32 @@ def put_text(self, key: str, text: str, *, content_type: str = 'text/plain') ->
)

def get_text(self, uri: str) -> str:
return b"".join(self.iter_bytes(uri)).decode("utf-8")

def iter_bytes(self, uri: str, *, chunk_size: int = 64 * 1024) -> Iterable[bytes]:
parsed = urlparse(uri)
if parsed.scheme != 's3' or parsed.netloc != self.config.bucket:
raise ValueError(f'Artifact URI is not in configured bucket: {uri}')
key = parsed.path.lstrip('/')
response = self.client.get_object(Bucket=self.config.bucket, Key=key)
return response['Body'].read().decode('utf-8')
if parsed.scheme != "s3" or parsed.netloc != self.config.bucket:
raise ValueError(f"Artifact URI is not in configured bucket: {uri}")
key = parsed.path.lstrip("/")
from botocore.exceptions import ClientError

try:
response = self.client.get_object(Bucket=self.config.bucket, Key=key)
except ClientError as exc:
code = str(exc.response.get("Error", {}).get("Code", ""))
if code in {"404", "NoSuchKey", "NoSuchVersion"}:
raise ArtifactNotFoundError(uri) from exc
raise
body = response["Body"]

def chunks() -> Iterable[bytes]:
try:
while chunk := body.read(chunk_size):
yield chunk
finally:
body.close()

return chunks()


class InMemoryArtifactStore:
Expand All @@ -80,13 +111,20 @@ def put_text(self, key: str, text: str, *, content_type: str = 'text/plain') ->
)

def get_text(self, uri: str) -> str:
return b"".join(self.iter_bytes(uri)).decode("utf-8")

def iter_bytes(self, uri: str, *, chunk_size: int = 64 * 1024) -> Iterable[bytes]:
parsed = urlparse(uri)
if parsed.scheme != 'memory':
raise ValueError(f'Unsupported in-memory artifact URI: {uri}')
if parsed.scheme != "memory":
raise ValueError(f"Unsupported in-memory artifact URI: {uri}")
key = parsed.netloc + parsed.path
if key.startswith('/'):
if key.startswith("/"):
key = key[1:]
return self.objects[key]
try:
data = self.objects[key].encode("utf-8")
except KeyError as exc:
raise ArtifactNotFoundError(uri) from exc
return (data[offset:offset + chunk_size] for offset in range(0, len(data), chunk_size))


def artifact_key(run_id: str, agent: str | None, kind: str, suffix: str = 'txt') -> str:
Expand Down
Loading
Loading