Files
tireless/crates/tireless-data/migrations/0001_init.sql
rob thijssen 6545d1980b
All checks were successful
deploy / deploy (push) Successful in 5m35s
feat(data): add the initial schema and migrations
Closes #2. Three tables mirroring tireless-entities, plus the sqlx
compile-time-checking decision the rest of stage 1 inherits.

RUNTIME QUERIES, NOT `query!`. No .sqlx metadata, no DATABASE_URL to build.
lairball, the other house project on this cluster, does the same. The deciding
argument is specific to tireless: compile-time checking makes a database a build
dependency, and this crate is meant to be modified by a 27B model working
unattended. A build that fails without a database it cannot provision is one
that model cannot fix, and its documented failure mode is to improvise. The cost
is named in store.rs — a malformed query is caught by a test, not by cargo.

Enums are text with a check constraint, not Postgres enum types: adding a
JobKind variant should be a migration, not an ALTER TYPE holding a lock. Unit
tests assert every serde variant appears in the schema, so adding a variant
without a migration fails the build rather than the first job of that kind.

That surfaced a spelling that would have been permanent: `rename_all =
"snake_case"` turns Forge::GitHub into `git_hub`, across the database, the JSON
API and the generated TypeScript. Renamed to `github` now, while nothing is
persisted and GitHub support is still disabled.

The live-issue index is PARTIAL, and both directions of getting it wrong are
silent. A plain unique constraint on (forge, owner, repo, number) would forbid
re-running a terminal job, and would forbid the discovery lane outright, since
discovery recurs against one tracking issue on a cooldown. Partial on the
non-terminal states gives at most one live job per issue and unlimited history.

The claim index orders by created_at alone and leaves kind as a filter, because
the claim takes LIMIT 1 and can stop at the first match. Leading with kind sorts
every pending row on every claim: measured at 720 buffers versus 4, and the gap
grows with the backlog rather than staying fixed. INCLUDE (kind) was measured
too and dropped — FOR UPDATE visits the heap regardless.

Verified against Postgres 18, the same major as the house cluster: migrations
apply to an empty database and are a no-op on the second run; all eight
constraints reject what they should and admit what they should; and two
concurrent claimers of one pending job produce exactly one winner, with the
loser skipping rather than blocking.

Those live tests are #[ignore]d, not skipped on a missing variable, so a green
`cargo test` never implies the schema was exercised. CLAUDE.md says how to run
them and that an applied migration must never be edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
2026-08-07 18:47:16 +03:00

188 lines
7.6 KiB
SQL

-- tireless initial schema.
--
-- Three tables mirroring `tireless-entities`: the repos we watch, the work
-- claimed against their issues, and each agent invocation. Enums are stored as
-- text with a check constraint rather than as Postgres enum types: adding a
-- `JobKind` variant should be an ordinary migration, not an ALTER TYPE that
-- takes a lock on every table using it. The accepted values must match what
-- serde emits for each enum, and a test in `tireless-data` asserts they do.
-- ---------------------------------------------------------------------------
-- Repos tireless polls.
-- ---------------------------------------------------------------------------
create table tracked_repo (
id uuid primary key default gen_random_uuid(),
forge text not null check (forge in ('gitea', 'github')),
owner text not null,
repo text not null,
clone_url text not null,
default_branch text not null,
-- PollSchedule, flattened. The floor lives in config, not here: it is an
-- operator policy that can change, and clamping is core's job
-- (`Poll::clamp`). The database asserts only that the value is sane.
interval_seconds integer not null check (interval_seconds > 0),
quiet_from text,
quiet_until text,
enabled boolean not null default true,
last_etag text,
last_polled_at timestamptz,
created_at timestamptz not null default now(),
unique (forge, owner, repo),
-- Both ends of the quiet window or neither. A half-specified window runs
-- around the clock while the operator believes otherwise, which is silently
-- wrong rather than loudly broken. `Config::validate` enforces the same rule
-- on the global window.
constraint quiet_window_complete
check ((quiet_from is null) = (quiet_until is null))
);
-- ---------------------------------------------------------------------------
-- One unit of work against one issue.
-- ---------------------------------------------------------------------------
create table job (
id uuid primary key default gen_random_uuid(),
-- IssueRef, flattened.
forge text not null check (forge in ('gitea', 'github')),
owner text not null,
repo text not null,
number bigint not null,
kind text not null
check (kind in ('discover', 'plan', 'implement')),
state text not null
check (state in ('pending', 'claimed', 'running',
'delivered', 'blocked', 'failed', 'abandoned')),
claimed_by text,
claim_expires_at timestamptz,
-- Set when this job's issue was produced by a Plan job. Presence of a parent
-- is what routes implementation to OpenCode, and what lets a child inherit
-- admission (design.md §2.5).
parent_job_id uuid references job (id),
attempts integer not null default 0 check (attempts >= 0),
last_error text,
-- When the poller last reconciled this job's forge labels. Labels mirror
-- this row; they are never read to make a decision (CLAUDE.md invariant 6).
-- The runner cannot write labels at all — a separate identity does it
-- (design.md §6.4) — so this column is how the two halves stay in step.
labels_synced_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
-- A claim is a pair. Half a claim is a job that either cannot be released or
-- cannot be found.
constraint claim_complete
check ((claimed_by is null) = (claim_expires_at is null)),
-- A pending job holds no claim. This is what makes lease expiry correct:
-- returning a job to the pool *must* clear its claim, and the database
-- refuses the half-done version.
--
-- Deliberately not the converse. Terminal jobs keep `claimed_by` as an audit
-- trail of which worker delivered them.
constraint pending_holds_no_claim
check (state <> 'pending' or claimed_by is null)
);
-- At most one *live* job per issue, and any number of historical ones.
--
-- A plain unique constraint would be wrong in both directions. It would forbid
-- re-running a job after it reached a terminal state, which `tireless job run`
-- exists to do; and it would forbid the discovery lane entirely, since discovery
-- recurs against the same tracking issue on a cooldown (design.md §2.6).
create unique index job_live_issue_idx
on job (forge, owner, repo, number)
where state in ('pending', 'claimed', 'running');
-- The claim query: oldest pending job of an allowed kind.
--
-- Ordered by `created_at` alone, with `kind` left as a filter, because the claim
-- takes `LIMIT 1` and can then stop at the first match. Leading with `kind`
-- instead forces a sort of every pending row on every claim — measured at 720
-- buffers versus 4, and the gap grows with the backlog rather than staying
-- fixed. Partial on `pending` because that is a small slice of a table that only
-- grows.
--
-- No INCLUDE (kind): `FOR UPDATE` has to visit the heap regardless, so the extra
-- index payload measured as no gain.
create index job_claimable_idx
on job (created_at)
where state = 'pending';
-- Lease expiry sweeps held claims looking for passed deadlines.
create index job_claim_expiry_idx
on job (claim_expires_at)
where state in ('claimed', 'running');
-- History for one issue: powers the discovery cooldown ("when did a discover job
-- for this issue last finish?") and the operator asking what has been tried.
create index job_issue_history_idx
on job (forge, owner, repo, number, kind, created_at desc);
-- ---------------------------------------------------------------------------
-- One invocation of one agent against one job.
--
-- A job may have several: a retry after a rate limit, or a follow-up turn
-- resuming the same agent session.
-- ---------------------------------------------------------------------------
create table agent_run (
id uuid primary key default gen_random_uuid(),
job_id uuid not null references job (id) on delete cascade,
agent text not null
check (agent in ('claude_code', 'opencode')),
model text,
-- Read from Claude Code's own `apiKeySource`, so the dashboard reports what
-- actually happened rather than what was intended (design.md §3.2).
billing text not null default 'unknown'
check (billing in ('subscription', 'api_key', 'unknown')),
session_id text,
outcome text
check (outcome in ('succeeded', 'failed', 'rate_limited',
'budget_exhausted', 'output_budget_exhausted',
'timed_out', 'cancelled')),
-- PullRequestRef, flattened. All six or none.
pr_forge text check (pr_forge in ('gitea', 'github')),
pr_owner text,
pr_repo text,
pr_number bigint,
pr_head_branch text,
pr_url text,
started_at timestamptz not null default now(),
finished_at timestamptz,
constraint pull_request_complete
check (num_nonnulls(pr_forge, pr_owner, pr_repo,
pr_number, pr_head_branch, pr_url) in (0, 6)),
-- A finished run has an outcome and an unfinished one does not. Without
-- this, a crashed writer leaves runs that look in-flight forever and the
-- governor counts them against concurrency.
constraint finished_has_outcome
check ((finished_at is null) = (outcome is null))
);
create index agent_run_job_idx
on agent_run (job_id, started_at desc);
-- The governor's window budget: runs started per lane inside a rolling window
-- (design.md §5). This is asked on every admission decision, so it is the one
-- index that has to be right from the start.
create index agent_run_lane_window_idx
on agent_run (agent, started_at desc);