Metadata-Version: 2.4
Name: loopyard
Version: 0.1.1
Summary: PostgreSQL-backed task backend for coordinating multi-agent autonomous software development
Author: Andrew Grigorev
Author-email: Andrew Grigorev <andrew@ei-grad.ru>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Environment :: Web Environment
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Database
Classifier: Topic :: Software Development
Classifier: Topic :: Software Development :: Bug Tracking
Requires-Dist: click>=8.4.2
Requires-Dist: fastapi>=0.139.0
Requires-Dist: mcp[cli]>=1.28.1
Requires-Dist: psycopg-pool>=3.3.1
Requires-Dist: psycopg[binary]>=3.3.4
Requires-Dist: pyjwt[crypto]>=2.13.0
Requires-Dist: pyyaml>=6.0.3
Requires-Dist: uvicorn>=0.51.0
Requires-Python: >=3.12
Project-URL: Homepage, https://github.com/ei-grad/loopyard
Project-URL: Repository, https://github.com/ei-grad/loopyard
Description-Content-Type: text/markdown

# loopyard

loopyard is a PostgreSQL-backed task backend for coordinating multi-agent
autonomous software development. All business logic — runnable-set
computation, dependency and status-transition validation, conflict-domain
checks, lock leases with generation fencing — lives in PL/pgSQL functions,
constraints, and triggers, so state stays consistent no matter which client
touches it. The CLI, MCP server, and REST API are thin transports over the
same `api.*` SQL surface; the CLI also speaks
[vibe-loop](https://github.com/ei-grad/vibe-loop)'s task-source and lock
command contracts directly.

## Quickstart

```sh
cp .env.example .env               # adjust credentials if you like
docker compose up -d               # PostgreSQL 18 on 127.0.0.1:5439
export LOOPYARD_DSN=postgresql://loopyard:loopyard@127.0.0.1:5439/loopyard
uv run loopyard db migrate         # apply migrations + function definitions
```

`loopyard db reset --yes` drops and recreates the `public` schema and
re-migrates (development only — it destroys all data).

## Authentication

The REST API has two modes (`auth_mode` in `.loopyard.toml`, or
`LOOPYARD_AUTH_MODE`):

- `none` (default) — no authentication; intended for localhost
  deployments. Mutating endpoints accept an optional
  `X-Loopyard-Actor: <actor-id>` header for event attribution.
- `oidc` — every `/api/*` request must carry a JWT minted by an OIDC-style
  identity provider or authenticating proxy in front of loopyard. The
  token is validated offline against the provider's JWKS (RS256/ES256
  only; issuer, audience and expiry checked), the identity is mapped to an
  auto-provisioned human `actor`, all mutations are attributed to that
  actor, and `X-Loopyard-Actor` is ignored. `GET /api/whoami` reports the
  resolved identity; identities listed in `admin_identities` may call
  admin endpoints (currently lock force-release — 403 for everyone else).
  The interactive docs and `/openapi.json` are disabled in this mode, and
  the SSE stream validates the token at connect time only (an already-open
  stream is not torn down when the token expires; reconnects re-validate).

Identities are **namespaced with the claim they came from** —
`email:alice@example.com`, `sub:7d5a…` — so values from different claims
can never collide; `admin_identities` entries use the same form. The
`email` claim is trusted only when the token carries
`email_verified: true`; an explicit `email_verified: false` is never
accepted, and a missing `email_verified` counts as unverified unless
`oidc_allow_unverified_email` is enabled (needed for authenticating
proxies that assert the email themselves — see the Cloudflare preset). An
unusable email falls back to `sub`.

Config keys (each also settable via the matching `LOOPYARD_`-prefixed
upper-case env var; list-valued keys take a comma-separated string and
booleans a `true`/`false` string in the env form). In oidc mode
`oidc_issuer` and `oidc_jwks_url` must be `https` URLs — plain `http` is
allowed for localhost only (dev):

| key                           | default      | meaning                                       |
| ----------------------------- | ------------ | --------------------------------------------- |
| `auth_mode`                   | `"none"`     | `none` or `oidc`                              |
| `oidc_issuer`                 | —            | required in oidc mode; exact `iss` match      |
| `oidc_audience`               | —            | required in oidc mode; `aud` check            |
| `oidc_jwks_url`               | discovered   | JWKS endpoint; default from `<issuer>/.well-known/openid-configuration` |
| `oidc_token_sources`          | `["bearer"]` | ordered lookup: `bearer`, `header:<Name>`, `cookie:<name>` |
| `oidc_identity_claim`         | `"email"`    | identity claim; `sub` is always the fallback  |
| `oidc_allow_unverified_email` | `false`      | accept `email` when `email_verified` is ABSENT (explicit `false` never passes) |
| `oidc_http_timeout_ms`        | `5000`       | JWKS/discovery HTTP fetch timeout             |
| `admin_identities`            | `[]`         | namespaced identities allowed on admin endpoints, e.g. `"email:you@example.com"` |

MCP and CLI transports connect to PostgreSQL directly and are unaffected.

### Cloudflare Zero Trust Access preset

Access authenticates the user and forwards a JWT in a header and a cookie;
loopyard just validates it:

```toml
auth_mode = "oidc"
oidc_issuer = "https://<team>.cloudflareaccess.com"
oidc_audience = "<Access application AUD tag>"
oidc_jwks_url = "https://<team>.cloudflareaccess.com/cdn-cgi/access/certs"
oidc_token_sources = ["header:Cf-Access-Jwt-Assertion", "cookie:CF_Authorization"]
oidc_allow_unverified_email = true
admin_identities = ["email:you@example.com"]
```

`oidc_allow_unverified_email` is needed because Access JWTs carry the
`email` Cloudflare authenticated but no `email_verified` claim — Access
asserts the email itself. Without the opt-in every Access user would be
identified by the opaque `sub:<uuid>` instead of their email.

The cookie source is what lets the browser's `EventSource` authenticate
against the SSE live-events endpoint — `EventSource` cannot set request
headers, but Access sets the `CF_Authorization` cookie on the domain.

oauth2-proxy / Keycloak / Authentik and other standard OIDC setups work
with plain bearer tokens: keep the default `oidc_token_sources =
["bearer"]` and set `oidc_issuer` + `oidc_audience` (plus `oidc_jwks_url`
only when issuer discovery is unavailable).

## SQL conventions

SQL ships as package data: `src/loopyard/db/migrations/*.sql` is applied
exactly once per file, `src/loopyard/db/functions/*.sql` holds idempotent
`CREATE OR REPLACE` definitions that are all reapplied on every migrate.
Both are processed in lexicographic filename order — that order is the
contract, so zero-pad migration prefixes (`0001_...`) and encode
inter-function dependencies via filename prefixes. Every file runs in its
own transaction; statements that refuse to run in a transaction block
(e.g. `CREATE INDEX CONCURRENTLY`) are unsupported for now.

## Tests

```sh
uv run -m pytest
```

Tests start a throwaway `postgres:18` [testcontainer](https://testcontainers-python.readthedocs.io/)
and need a working Docker daemon. To use an existing PostgreSQL server
instead (e.g. in CI), set `LOOPYARD_TEST_DSN` to a server the suite may
create and drop databases on:

```sh
LOOPYARD_TEST_DSN=postgresql://loopyard:loopyard@127.0.0.1:5439/loopyard uv run -m pytest
```

The harness migrates a template database once per session and clones a
fresh database per test, so each test gets an isolated schema and may open
multiple concurrent connections.

## Running vibe-loop against this board

This repo self-hosts: its own development dispatch runs through `vibe-loop`
against loopyard's own PostgreSQL-backed board (see `.vibe-loop.toml`).
Project selection is via an environment variable, never a committed file or
baked CLI flag:

```sh
export LOOPYARD_PROJECT=loopyard
vibe-loop run-until-done
```

(`vibe-loop autopilot run` additionally needs an `[autopilot]` block in
`.vibe-loop.toml`, which this wiring does not yet configure.)

Task worktrees for this project live under
`/home/ei-grad/loopyard-worktrees/`. Workers move task status through the
loopyard CLI as they progress — `loopyard task transition <id>
active|review|done` — since vibe-loop only reads the task source and never
writes status itself.
