@kyleroche/relay 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,153 @@
1
+ -- relay memory + event log. Postgres 15+. Works on Neon (prod) and PGlite (local/tests).
2
+ -- Vector search needs the pgvector extension; full-text uses built-in tsvector.
3
+ CREATE EXTENSION IF NOT EXISTS vector;
4
+
5
+ CREATE TABLE IF NOT EXISTS meta (
6
+ key text PRIMARY KEY,
7
+ value jsonb NOT NULL,
8
+ updated_at timestamptz NOT NULL DEFAULT now()
9
+ );
10
+
11
+ -- A registered repository. slug is the GitHub repo name; repo is "owner/name".
12
+ CREATE TABLE IF NOT EXISTS projects (
13
+ slug text PRIMARY KEY,
14
+ repo text,
15
+ path text,
16
+ stack text,
17
+ check_cmd text,
18
+ summary text,
19
+ summary_tokens int,
20
+ created_at timestamptz NOT NULL DEFAULT now(),
21
+ updated_at timestamptz NOT NULL DEFAULT now()
22
+ );
23
+
24
+ -- Per-file digest keyed by content hash; re-summarized only when sha256 changes.
25
+ CREATE TABLE IF NOT EXISTS file_digests (
26
+ project text NOT NULL REFERENCES projects(slug) ON DELETE CASCADE,
27
+ path text NOT NULL,
28
+ sha256 text NOT NULL,
29
+ size int NOT NULL DEFAULT 0,
30
+ summary text,
31
+ summary_sha text,
32
+ tokens int NOT NULL DEFAULT 0,
33
+ seen_at timestamptz NOT NULL DEFAULT now(),
34
+ summarized_at timestamptz,
35
+ PRIMARY KEY (project, path)
36
+ );
37
+
38
+ -- Durable, cross-tool memory: decisions and preferences.
39
+ CREATE TABLE IF NOT EXISTS decisions (
40
+ id bigserial PRIMARY KEY,
41
+ project text REFERENCES projects(slug) ON DELETE CASCADE,
42
+ text text NOT NULL,
43
+ source text, -- 'claude' | 'codex' | 'grok' | 'pr' | 'human' | 'nightly'
44
+ source_ref text, -- session id, PR url, etc.
45
+ valid_from timestamptz NOT NULL DEFAULT now(),
46
+ valid_to timestamptz, -- null = current; set when superseded
47
+ superseded_by bigint REFERENCES decisions(id),
48
+ embedding vector(1536),
49
+ tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', text)) STORED,
50
+ created_at timestamptz NOT NULL DEFAULT now()
51
+ );
52
+ CREATE INDEX IF NOT EXISTS decisions_project_idx ON decisions(project) WHERE valid_to IS NULL;
53
+ CREATE INDEX IF NOT EXISTS decisions_tsv_idx ON decisions USING gin(tsv);
54
+
55
+ CREATE TABLE IF NOT EXISTS preferences (
56
+ key text NOT NULL,
57
+ scope text NOT NULL, -- 'global' | 'project' | 'lane' | 'model'
58
+ scope_id text NOT NULL DEFAULT '',
59
+ value text NOT NULL,
60
+ reason text,
61
+ updated_at timestamptz NOT NULL DEFAULT now(),
62
+ PRIMARY KEY (key, scope, scope_id)
63
+ );
64
+
65
+ -- Official vendor guidance per model (the onboarding gate).
66
+ CREATE TABLE IF NOT EXISTS providers (
67
+ slug text PRIMARY KEY,
68
+ name text NOT NULL,
69
+ doc_domains text[] NOT NULL DEFAULT '{}',
70
+ skill_sources text[] NOT NULL DEFAULT '{}',
71
+ updated_at timestamptz NOT NULL DEFAULT now()
72
+ );
73
+ CREATE TABLE IF NOT EXISTS models (
74
+ id text PRIMARY KEY, -- e.g. bytedance/seedance-2.5
75
+ provider text NOT NULL REFERENCES providers(slug),
76
+ lanes text[] NOT NULL DEFAULT '{}',
77
+ onboarded_at timestamptz,
78
+ guidance_count int NOT NULL DEFAULT 0,
79
+ skill_count int NOT NULL DEFAULT 0,
80
+ stale_days int NOT NULL DEFAULT 30,
81
+ updated_at timestamptz NOT NULL DEFAULT now()
82
+ );
83
+ CREATE TABLE IF NOT EXISTS sources (
84
+ url text PRIMARY KEY,
85
+ provider text NOT NULL,
86
+ title text,
87
+ sha256 text,
88
+ etag text,
89
+ official boolean NOT NULL DEFAULT true,
90
+ fetched_at timestamptz,
91
+ checked_at timestamptz,
92
+ created_at timestamptz NOT NULL DEFAULT now()
93
+ );
94
+ CREATE TABLE IF NOT EXISTS guidance (
95
+ id text NOT NULL, -- sha256(source_url#index:head); the same source may guide several models
96
+ model text NOT NULL REFERENCES models(id) ON DELETE CASCADE,
97
+ provider text NOT NULL,
98
+ source_url text NOT NULL REFERENCES sources(url) ON DELETE CASCADE,
99
+ chunk_index int NOT NULL,
100
+ kind text NOT NULL, -- prompting | limits | api | moderation | style | skill
101
+ title text NOT NULL,
102
+ text text NOT NULL,
103
+ tokens int NOT NULL,
104
+ embedding vector(1536),
105
+ tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce(title,'') || ' ' || text)) STORED,
106
+ fetched_at timestamptz NOT NULL DEFAULT now(),
107
+ PRIMARY KEY (model, id)
108
+ );
109
+ CREATE INDEX IF NOT EXISTS guidance_model_idx ON guidance(model);
110
+ CREATE INDEX IF NOT EXISTS guidance_tsv_idx ON guidance USING gin(tsv);
111
+ CREATE TABLE IF NOT EXISTS skills (
112
+ name text NOT NULL,
113
+ model text NOT NULL REFERENCES models(id) ON DELETE CASCADE,
114
+ provider text NOT NULL,
115
+ install_path text NOT NULL,
116
+ version text NOT NULL,
117
+ sha256 text NOT NULL,
118
+ source_url text NOT NULL,
119
+ installed_at timestamptz NOT NULL DEFAULT now(),
120
+ PRIMARY KEY (name, model)
121
+ );
122
+
123
+ -- The event feed: one append-only log of everything agents and humans did. The ledger is a view over it.
124
+ CREATE TABLE IF NOT EXISTS events (
125
+ id bigserial PRIMARY KEY,
126
+ ts timestamptz NOT NULL DEFAULT now(),
127
+ kind text NOT NULL, -- issue.opened | run.started | run.finished | step.usage | pr.opened | pr.merged | preview.ready | decision.captured | memory.consolidated | onboard.refreshed | ...
128
+ actor text NOT NULL, -- 'human:kyle' | 'bot:grok' | 'runner:mini' | 'agent:claude' | ...
129
+ project text,
130
+ repo text,
131
+ issue int,
132
+ pr int,
133
+ lane text,
134
+ model text,
135
+ subscription text, -- max | codex | grok | gateway | cursor | local
136
+ tokens_in bigint NOT NULL DEFAULT 0,
137
+ tokens_out bigint NOT NULL DEFAULT 0,
138
+ cache_read bigint NOT NULL DEFAULT 0,
139
+ cache_write bigint NOT NULL DEFAULT 0,
140
+ cost_equiv numeric(12,6) NOT NULL DEFAULT 0,
141
+ duration_ms int,
142
+ summary text NOT NULL,
143
+ url text,
144
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb,
145
+ dedupe_key text UNIQUE -- e.g. github delivery id, so webhooks are idempotent
146
+ );
147
+ CREATE INDEX IF NOT EXISTS events_ts_idx ON events(ts DESC);
148
+ CREATE INDEX IF NOT EXISTS events_project_ts_idx ON events(project, ts DESC);
149
+ CREATE INDEX IF NOT EXISTS events_kind_ts_idx ON events(kind, ts DESC);
150
+
151
+ CREATE OR REPLACE VIEW ledger AS
152
+ SELECT ts, actor, project, lane, model, subscription, tokens_in, tokens_out, cache_read, cache_write, cost_equiv, duration_ms, url
153
+ FROM events WHERE kind = 'step.usage';
@@ -0,0 +1,33 @@
1
+ -- Asset library: media too large to share inline, versioned, with an approve-or-reject review loop (docs/ASSETS.md).
2
+ -- Access control is the app's job (API scopes); the table carries no per-row grants.
3
+ CREATE TABLE IF NOT EXISTS assets (
4
+ id text PRIMARY KEY, -- ulid
5
+ project text REFERENCES projects(slug) ON DELETE SET NULL,
6
+ issue int,
7
+ pr int,
8
+ title text NOT NULL,
9
+ description text,
10
+ kind text NOT NULL, -- image | video | audio | pdf | html | file
11
+ mime text NOT NULL,
12
+ size bigint NOT NULL,
13
+ sha256 text NOT NULL,
14
+ storage_key text NOT NULL, -- blob pathname or local relative path
15
+ thumb_key text,
16
+ width int,
17
+ height int,
18
+ duration_ms int,
19
+ version int NOT NULL DEFAULT 1,
20
+ parent_id text REFERENCES assets(id) ON DELETE SET NULL, -- previous version; survives the parent's expiry
21
+ status text NOT NULL DEFAULT 'pending', -- pending | approved | rejected | superseded | published
22
+ created_by text NOT NULL, -- actor, e.g. bot:grok, runner:mini, human:kyle
23
+ created_at timestamptz NOT NULL DEFAULT now(),
24
+ reviewed_by text,
25
+ reviewed_at timestamptz,
26
+ review_note text,
27
+ public_url text, -- set only after an explicit publish
28
+ expires_at timestamptz, -- set when rejected or superseded; null = keep
29
+ tags text[] NOT NULL DEFAULT '{}'
30
+ );
31
+ CREATE INDEX IF NOT EXISTS assets_project_status_idx ON assets(project, status, created_at DESC);
32
+ CREATE INDEX IF NOT EXISTS assets_expires_idx ON assets(expires_at) WHERE expires_at IS NOT NULL;
33
+ CREATE INDEX IF NOT EXISTS assets_parent_idx ON assets(parent_id);
@@ -0,0 +1,14 @@
1
+ -- API bearer tokens for the web app and the HTTP MCP server. Only the SHA-256 of a token is stored; the plaintext is
2
+ -- shown once by `relay token create`. Scopes are the names the OpenAPI description publishes (feed:read, memory:read,
3
+ -- memory:write, assets:read, assets:write, assets:review).
4
+ CREATE TABLE IF NOT EXISTS tokens (
5
+ id text PRIMARY KEY, -- ulid
6
+ name text NOT NULL,
7
+ token_hash text NOT NULL UNIQUE, -- sha256 hex of the plaintext token
8
+ scopes text[] NOT NULL DEFAULT '{}',
9
+ created_by text NOT NULL, -- actor, e.g. human:kyle
10
+ created_at timestamptz NOT NULL DEFAULT now(),
11
+ last_used_at timestamptz,
12
+ revoked_at timestamptz
13
+ );
14
+ CREATE INDEX IF NOT EXISTS tokens_hash_live_idx ON tokens(token_hash) WHERE revoked_at IS NULL;
@@ -0,0 +1,12 @@
1
+ -- Tasks: a thin record over a GitHub issue so bots can address work by one id ("<owner>/<repo>#<n>") and create it
2
+ -- idempotently. State is never stored here; it is derived from the feed (packages/core/src/db/tasks.ts taskState).
3
+ CREATE TABLE IF NOT EXISTS tasks (
4
+ id text PRIMARY KEY, -- "<owner>/<repo>#<n>"
5
+ project text NOT NULL REFERENCES projects(slug) ON DELETE CASCADE,
6
+ repo text NOT NULL, -- "owner/name"
7
+ issue int NOT NULL,
8
+ idempotency_key text UNIQUE, -- caller-supplied; a repeat create returns the same task
9
+ created_by text NOT NULL, -- actor, e.g. bot:grok or human:kyle
10
+ created_at timestamptz NOT NULL DEFAULT now()
11
+ );
12
+ CREATE INDEX IF NOT EXISTS tasks_project_created_idx ON tasks(project, created_at DESC);
@@ -0,0 +1,28 @@
1
+ -- Outbound webhooks (docs/FEED.md "Outbound webhooks"): after an event is written, matching hooks receive a signed
2
+ -- POST. The secret and bearer are stored as given because the dispatcher needs them to sign and authenticate; the
3
+ -- API never returns them after creation. One delivery row per (webhook, event) records the attempts.
4
+ CREATE TABLE IF NOT EXISTS webhooks (
5
+ id text PRIMARY KEY, -- ulid
6
+ project text REFERENCES projects(slug) ON DELETE CASCADE, -- null = every project
7
+ url text NOT NULL,
8
+ secret text NOT NULL, -- HMAC key for X-Relay-Signature
9
+ bearer text, -- sent as Authorization: Bearer when set
10
+ events text[] NOT NULL DEFAULT '{}', -- kinds, prefixes ending in '.', or '*'
11
+ active boolean NOT NULL DEFAULT true,
12
+ created_by text NOT NULL DEFAULT '',
13
+ created_at timestamptz NOT NULL DEFAULT now()
14
+ );
15
+ CREATE INDEX IF NOT EXISTS webhooks_active_idx ON webhooks(active) WHERE active;
16
+
17
+ CREATE TABLE IF NOT EXISTS webhook_deliveries (
18
+ id text PRIMARY KEY, -- ulid; also the X-Relay-Delivery header
19
+ webhook_id text NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
20
+ event_id bigint, -- events.id; null for test.ping
21
+ kind text NOT NULL DEFAULT '',
22
+ status text NOT NULL DEFAULT 'pending', -- pending | delivered | failed
23
+ attempts int NOT NULL DEFAULT 0,
24
+ last_error text,
25
+ delivered_at timestamptz,
26
+ created_at timestamptz NOT NULL DEFAULT now()
27
+ );
28
+ CREATE INDEX IF NOT EXISTS webhook_deliveries_hook_idx ON webhook_deliveries(webhook_id, created_at DESC);
@@ -0,0 +1,85 @@
1
+ -- OAuth 2.1 authorization server state (docs/SECURITY.md, "Access for agents"): registered clients, one-time
2
+ -- authorization codes, device codes, refresh tokens, invites, and operator settings. Access tokens are rows in `tokens`
3
+ -- (named `oauth:<client name>`), so every existing scope check keeps working; they gain an expiry and a client here.
4
+ -- Every secret-bearing column stores a SHA-256, never the plaintext, except `settings.value`, which is sealed
5
+ -- (AES-256-GCM under RELAY_SESSION_SECRET) when `sealed` is true.
6
+ ALTER TABLE tokens ADD COLUMN IF NOT EXISTS expires_at timestamptz;
7
+ ALTER TABLE tokens ADD COLUMN IF NOT EXISTS client_id text;
8
+
9
+ CREATE TABLE IF NOT EXISTS oauth_clients (
10
+ client_id text PRIMARY KEY,
11
+ client_secret_hash text, -- null for public clients (token_endpoint_auth_method = none)
12
+ name text NOT NULL,
13
+ redirect_uris text[] NOT NULL DEFAULT '{}',
14
+ grant_types text[] NOT NULL DEFAULT '{}',
15
+ token_endpoint_auth_method text NOT NULL DEFAULT 'none',
16
+ scopes text[] NOT NULL DEFAULT '{}', -- the most the client may ask for; empty = any
17
+ software_id text,
18
+ client_uri text,
19
+ created_by text NOT NULL DEFAULT '',
20
+ created_at timestamptz NOT NULL DEFAULT now()
21
+ );
22
+
23
+ CREATE TABLE IF NOT EXISTS oauth_codes (
24
+ code_hash text PRIMARY KEY,
25
+ client_id text NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
26
+ redirect_uri text NOT NULL,
27
+ scopes text[] NOT NULL,
28
+ code_challenge text NOT NULL,
29
+ code_challenge_method text NOT NULL DEFAULT 'S256',
30
+ resource text,
31
+ actor text NOT NULL, -- human:<login> who approved on the consent page
32
+ expires_at timestamptz NOT NULL,
33
+ used_at timestamptz,
34
+ created_at timestamptz NOT NULL DEFAULT now()
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS oauth_device_codes (
38
+ device_code_hash text PRIMARY KEY,
39
+ user_code text NOT NULL UNIQUE, -- RLAY-4F7Q, what the person types on /activate
40
+ client_id text NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
41
+ scopes text[] NOT NULL,
42
+ status text NOT NULL DEFAULT 'pending', -- pending | approved | denied | redeemed
43
+ actor text, -- who decided
44
+ decided_at timestamptz,
45
+ token_id text, -- tokens.id once redeemed
46
+ interval_seconds int NOT NULL DEFAULT 5,
47
+ last_polled_at timestamptz,
48
+ expires_at timestamptz NOT NULL,
49
+ created_at timestamptz NOT NULL DEFAULT now()
50
+ );
51
+ CREATE INDEX IF NOT EXISTS oauth_device_codes_pending_idx ON oauth_device_codes(expires_at) WHERE status = 'pending';
52
+
53
+ CREATE TABLE IF NOT EXISTS oauth_refresh_tokens (
54
+ token_hash text PRIMARY KEY,
55
+ client_id text NOT NULL,
56
+ access_token_id text REFERENCES tokens(id) ON DELETE SET NULL,
57
+ scopes text[] NOT NULL,
58
+ actor text NOT NULL, -- who the grant belongs to (the approver, or invite:<id>)
59
+ expires_at timestamptz NOT NULL,
60
+ rotated_from text, -- the token_hash this one replaced
61
+ used_at timestamptz, -- set when rotated; a second use revokes the lineage
62
+ revoked_at timestamptz,
63
+ created_at timestamptz NOT NULL DEFAULT now()
64
+ );
65
+
66
+ CREATE TABLE IF NOT EXISTS invites (
67
+ id text PRIMARY KEY, -- ulid
68
+ code_hash text NOT NULL UNIQUE,
69
+ name text NOT NULL, -- who it is for; the token is named oauth:<name>
70
+ scopes text[] NOT NULL,
71
+ uses_left int NOT NULL DEFAULT 1,
72
+ max_uses int NOT NULL DEFAULT 1,
73
+ expires_at timestamptz NOT NULL,
74
+ created_by text NOT NULL,
75
+ created_at timestamptz NOT NULL DEFAULT now(),
76
+ revoked_at timestamptz,
77
+ last_redeemed_at timestamptz
78
+ );
79
+
80
+ CREATE TABLE IF NOT EXISTS settings (
81
+ key text PRIMARY KEY,
82
+ value text NOT NULL,
83
+ sealed boolean NOT NULL DEFAULT false,
84
+ updated_at timestamptz NOT NULL DEFAULT now()
85
+ );
@@ -0,0 +1,31 @@
1
+ -- Workers and claims (docs/WORKERS.md): relay schedules its own tasks. A worker is a machine that ran
2
+ -- `relay work`; it registers by name, heartbeats, and claims the oldest queued task whose lane it serves. A claim
3
+ -- is a lease: it expires unless the heartbeat renews it, and an expired lease puts the task back in the queue.
4
+ -- Task state stays derived (packages/core/src/db/tasks.ts); claims only add the `claimed` step before run.started.
5
+ CREATE TABLE IF NOT EXISTS workers (
6
+ id text PRIMARY KEY, -- ulid
7
+ name text NOT NULL UNIQUE, -- `runner:<name>` is the actor on its run.* events
8
+ host text NOT NULL DEFAULT '',
9
+ lanes text[] NOT NULL DEFAULT '{}', -- claude | codex | grok, from the machine's logins
10
+ labels text[] NOT NULL DEFAULT '{}', -- extra capabilities (gpu, xcode, ...) a task may require
11
+ version text NOT NULL DEFAULT '', -- relay CLI version the worker runs
12
+ last_seen timestamptz NOT NULL DEFAULT now(),
13
+ status text NOT NULL DEFAULT 'online', -- what the worker last said: online | offline
14
+ created_at timestamptz NOT NULL DEFAULT now()
15
+ );
16
+
17
+ CREATE TABLE IF NOT EXISTS claims (
18
+ id text PRIMARY KEY, -- ulid
19
+ task_id text NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
20
+ worker_id text NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
21
+ lease_until timestamptz NOT NULL,
22
+ claimed_at timestamptz NOT NULL DEFAULT now(),
23
+ released_at timestamptz,
24
+ outcome text -- finished | failed | released | expired
25
+ );
26
+ -- one live claim per task; a second worker's insert fails instead of racing
27
+ CREATE UNIQUE INDEX IF NOT EXISTS claims_live_task_idx ON claims(task_id) WHERE released_at IS NULL;
28
+ CREATE INDEX IF NOT EXISTS claims_worker_idx ON claims(worker_id, claimed_at DESC);
29
+
30
+ -- The lane a task was opened with, so the scheduler can match it without folding the feed first.
31
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS lane text;
@@ -0,0 +1,13 @@
1
+ -- The doorbell (docs/WORKERS.md, "The loop"): one row whose version moves every time the queue may have changed
2
+ -- (a task opened, a lease released or expired). An idle worker reads it instead of running a claim, and claims only
3
+ -- when the version moved or something is queued, so an empty queue costs the site one cheap query per tick.
4
+ CREATE TABLE IF NOT EXISTS queue_state (
5
+ one boolean PRIMARY KEY DEFAULT true CHECK (one), -- exactly one row
6
+ version bigint NOT NULL DEFAULT 0,
7
+ updated_at timestamptz NOT NULL DEFAULT now()
8
+ );
9
+ INSERT INTO queue_state (one, version) VALUES (true, 0) ON CONFLICT DO NOTHING;
10
+
11
+ -- The poll interval a worker last reported with its heartbeat (ms), so the workers page and `relay work status`
12
+ -- can show how often an idle worker checks in. Null until a worker on this version heartbeats.
13
+ ALTER TABLE workers ADD COLUMN IF NOT EXISTS poll_ms integer;
@@ -0,0 +1,5 @@
1
+ -- Plan meters a worker reports with its heartbeat (packages/core/src/meters.ts): the 5-hour and 7-day used
2
+ -- percentages Claude Code hands its status line, saved by relay's status-line script under ~/.harness/meters and sent
3
+ -- along as [{subscription, window, used_percentage, resets_at, captured_at, source}]. Null until a worker on this
4
+ -- version heartbeats; the site shows the freshest reading per window across online and idle workers.
5
+ ALTER TABLE workers ADD COLUMN IF NOT EXISTS meters jsonb;
@@ -0,0 +1,20 @@
1
+ -- Materialized task state (docs/ARCHITECTURE.md, "A task's life"). The feed stays the source of truth: these columns
2
+ -- are a cache of the fold in packages/core/src/db/tasks.ts (deriveTaskStatus) and tasks/station.ts (deriveStation),
3
+ -- written by refreshTaskState (db/task-state.ts) after every write that can change a task (an event with the task's
4
+ -- repo and issue, a claim taken, released or expired, a task created). The flow map and the tasks list read them in
5
+ -- one query instead of folding every task's history on every poll. NULL state means "not computed yet": readers fold
6
+ -- the row and fill it in (backfillTaskState runs once when this migration lands).
7
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS title text; -- the issue title, from the creator or the feed
8
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS state text; -- queued | claimed | running | review | done | failed
9
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS station text; -- Queue | Claimed | Running | PR | Preview | Review | Merged | Failed
10
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS since timestamptz; -- when the task entered `station`
11
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pr integer;
12
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pr_url text;
13
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS preview_state text; -- ready | building | failed | none
14
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS preview_url text;
15
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS runner text; -- runner:<name> of the worker on it
16
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS last_error text; -- the latest run.failed (or failing run.finished) line
17
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS cost double precision; -- folded cost_equiv of the task's step.usage rows
18
+ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS state_updated_at timestamptz; -- when the cache was last written
19
+ CREATE INDEX IF NOT EXISTS tasks_state_idx ON tasks(state);
20
+ CREATE INDEX IF NOT EXISTS tasks_station_idx ON tasks(station);