@byollm/server 0.1.0-alpha.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,507 @@
1
+ -- BYOLLM runner tables, RLS, and the atomic claim RPC.
2
+ --
3
+ -- Platform conventions from the of-tomorrow-framework's `the-system.md` apply:
4
+ -- RLS is the only permission system, every function ships explicit
5
+ -- REVOKE/GRANT (PostgREST exposes functions at /rest/v1/rpc/ by default), and
6
+ -- helpers are wrapped `(select ...)` for the initplan.
7
+ --
8
+ -- Tables are prefixed `byollm_*` so the app folder stays a portable unit.
9
+
10
+ -- ---------------------------------------------------------------------------
11
+ -- Enums
12
+ -- ---------------------------------------------------------------------------
13
+
14
+ create type byollm_job_state as enum (
15
+ 'queued', 'claimed', 'running', 'ok', 'error', 'canceled', 'expired'
16
+ );
17
+
18
+ create type byollm_audience as enum ('self', 'named', 'public');
19
+
20
+ create type byollm_offer_scope as enum ('self', 'named', 'public');
21
+
22
+ -- ---------------------------------------------------------------------------
23
+ -- Runners
24
+ -- ---------------------------------------------------------------------------
25
+
26
+ create table byollm_runners (
27
+ id uuid primary key default gen_random_uuid(),
28
+ -- Exactly one user. This column is the whole of PAIR_ONE_USER.
29
+ owner uuid not null references auth.users (id) on delete cascade,
30
+ -- SHA-256 hex of the bearer token. The token itself is never stored.
31
+ token_hash text not null unique,
32
+ label text not null,
33
+ platform text not null check (platform in ('darwin', 'linux', 'win32')),
34
+ daemon_version text not null,
35
+ capabilities jsonb not null default '[]'::jsonb,
36
+ paused boolean not null default false,
37
+ -- Set once. A revoked runner never un-revokes.
38
+ revoked_at timestamptz,
39
+ last_heartbeat_at timestamptz not null default now(),
40
+ created_at timestamptz not null default now()
41
+ );
42
+
43
+ create index byollm_runners_owner_idx on byollm_runners (owner);
44
+ create index byollm_runners_live_idx
45
+ on byollm_runners (last_heartbeat_at)
46
+ where revoked_at is null and paused = false;
47
+
48
+ -- ---------------------------------------------------------------------------
49
+ -- Pairings (device-code flow)
50
+ -- ---------------------------------------------------------------------------
51
+
52
+ create table byollm_pairings (
53
+ device_code_hash text primary key,
54
+ -- Short code the user reads. Unique among live pairings.
55
+ user_code text not null unique,
56
+ state text not null default 'pending'
57
+ check (state in ('pending', 'approved', 'denied')),
58
+ owner uuid references auth.users (id) on delete cascade,
59
+ runner_id uuid references byollm_runners (id) on delete set null,
60
+ -- Held until the daemon's next poll collects it, then nulled. Delivered once.
61
+ runner_token_once text,
62
+ label text not null,
63
+ platform text not null,
64
+ daemon_version text not null,
65
+ capabilities jsonb not null default '[]'::jsonb,
66
+ expires_at timestamptz not null,
67
+ created_at timestamptz not null default now()
68
+ );
69
+
70
+ create index byollm_pairings_user_code_idx on byollm_pairings (user_code);
71
+ create index byollm_pairings_expiry_idx on byollm_pairings (expires_at);
72
+
73
+ -- ---------------------------------------------------------------------------
74
+ -- Jobs
75
+ -- ---------------------------------------------------------------------------
76
+
77
+ create table byollm_jobs (
78
+ id uuid primary key default gen_random_uuid(),
79
+ kind text not null,
80
+ payload jsonb not null,
81
+ audience byollm_audience not null default 'self',
82
+ owner uuid not null references auth.users (id) on delete cascade,
83
+ -- Server-side restriction on which runner owners may take a `named` job.
84
+ -- Defence in depth: the daemon's own local allowlist is the enforcing side.
85
+ audience_allow uuid[] ,
86
+ depends_on uuid[] not null default '{}',
87
+ state byollm_job_state not null default 'queued',
88
+ lease_runner uuid references byollm_runners (id) on delete set null,
89
+ lease_expires_at timestamptz,
90
+ -- When the job became claimable. THE TTL CLOCK STARTS HERE, not at
91
+ -- created_at: a dependent job must not expire for waiting on a slow
92
+ -- dependency, and a reclaimed job must not expire for time spent being
93
+ -- actively worked on. Null means still blocked on a dependency.
94
+ claimable_at timestamptz,
95
+ ttl_ms integer not null default 900000 check (ttl_ms > 0),
96
+ -- Absolute lifetime bound, unaffected by reclaim.
97
+ deadline_at timestamptz,
98
+ -- Runners that released this job with reason 'refused'.
99
+ refused_by uuid[] not null default '{}',
100
+ attempts integer not null default 0,
101
+ outcome jsonb,
102
+ provenance jsonb,
103
+ created_at timestamptz not null default now(),
104
+ updated_at timestamptz not null default now()
105
+ );
106
+
107
+ create index byollm_jobs_claimable_idx
108
+ on byollm_jobs (claimable_at)
109
+ where state = 'queued';
110
+ create index byollm_jobs_owner_idx on byollm_jobs (owner);
111
+ create index byollm_jobs_lease_idx
112
+ on byollm_jobs (lease_expires_at)
113
+ where state in ('claimed', 'running');
114
+ create index byollm_jobs_depends_idx on byollm_jobs using gin (depends_on);
115
+
116
+ -- ---------------------------------------------------------------------------
117
+ -- Cancel requests
118
+ -- ---------------------------------------------------------------------------
119
+
120
+ -- A separate table rather than a column on the job: a cancel is a *request*
121
+ -- that the holding runner has not yet acknowledged, and the job's own state
122
+ -- must keep saying `running` until the runner reports back. Folding the two
123
+ -- together would make "cancel asked for" and "cancel happened" look alike.
124
+ create table byollm_job_cancels (
125
+ job_id uuid primary key references byollm_jobs (id) on delete cascade,
126
+ requested_at timestamptz not null default now()
127
+ );
128
+
129
+ -- ---------------------------------------------------------------------------
130
+ -- Dependency gating: unblock dependents when a job reaches `ok`
131
+ -- ---------------------------------------------------------------------------
132
+
133
+ create or replace function byollm_unblock_dependents()
134
+ returns trigger
135
+ language plpgsql
136
+ security definer
137
+ set search_path = public
138
+ as $$
139
+ begin
140
+ -- Only `ok` unblocks. A dependency that errored leaves its dependents
141
+ -- blocked rather than releasing them into a run whose input never arrived —
142
+ -- the chain stops where it broke.
143
+ if new.state = 'ok' and (old.state is distinct from 'ok') then
144
+ update byollm_jobs dependent
145
+ set claimable_at = now(),
146
+ updated_at = now()
147
+ where dependent.claimable_at is null
148
+ and new.id = any (dependent.depends_on)
149
+ and not exists (
150
+ select 1
151
+ from byollm_jobs dep
152
+ where dep.id = any (dependent.depends_on)
153
+ and dep.state is distinct from 'ok'
154
+ );
155
+ end if;
156
+ return new;
157
+ end;
158
+ $$;
159
+
160
+ create trigger byollm_jobs_unblock_dependents
161
+ after update of state on byollm_jobs
162
+ for each row
163
+ execute function byollm_unblock_dependents();
164
+
165
+ -- ---------------------------------------------------------------------------
166
+ -- Expiry sweep: leases first, then TTL. Idempotent — firing twice is safe.
167
+ -- ---------------------------------------------------------------------------
168
+
169
+ create or replace function byollm_expire_due()
170
+ returns integer
171
+ language plpgsql
172
+ security definer
173
+ set search_path = public
174
+ as $$
175
+ declare
176
+ changed integer := 0;
177
+ n integer;
178
+ begin
179
+ -- 1. Reclaim lapsed leases. A job whose runner died returns to the queue
180
+ -- with its TTL clock RESTARTED: it has not been waiting, it has been
181
+ -- worked on, and expiring it here would throw away the recovery that
182
+ -- lease reclaim exists to provide.
183
+ update byollm_jobs
184
+ set state = 'queued',
185
+ lease_runner = null,
186
+ lease_expires_at = null,
187
+ claimable_at = now(),
188
+ updated_at = now()
189
+ where state in ('claimed', 'running')
190
+ and lease_expires_at is not null
191
+ and lease_expires_at <= now();
192
+ get diagnostics n = row_count;
193
+ changed := changed + n;
194
+
195
+ -- 2. Expire what has genuinely sat unclaimed past its TTL, plus anything
196
+ -- past its absolute deadline.
197
+ update byollm_jobs
198
+ set state = 'expired',
199
+ lease_runner = null,
200
+ lease_expires_at = null,
201
+ updated_at = now()
202
+ where state = 'queued'
203
+ and (
204
+ (claimable_at is not null
205
+ and claimable_at + (ttl_ms || ' milliseconds')::interval <= now())
206
+ or (deadline_at is not null and deadline_at <= now())
207
+ );
208
+ get diagnostics n = row_count;
209
+ changed := changed + n;
210
+
211
+ return changed;
212
+ end;
213
+ $$;
214
+
215
+ -- ---------------------------------------------------------------------------
216
+ -- The atomic claim (CLAIM_ATOMIC)
217
+ -- ---------------------------------------------------------------------------
218
+
219
+ create or replace function byollm_claim_jobs(
220
+ p_runner_id uuid,
221
+ p_capabilities jsonb,
222
+ p_max integer,
223
+ p_lease_ms integer
224
+ )
225
+ returns setof byollm_jobs
226
+ language plpgsql
227
+ security definer
228
+ set search_path = public
229
+ as $$
230
+ declare
231
+ v_owner uuid;
232
+ v_revoked timestamptz;
233
+ v_kinds text[];
234
+ begin
235
+ select owner, revoked_at into v_owner, v_revoked
236
+ from byollm_runners where id = p_runner_id;
237
+
238
+ if v_owner is null then
239
+ raise exception 'unknown runner';
240
+ end if;
241
+ if v_revoked is not null then
242
+ raise exception 'runner is revoked';
243
+ end if;
244
+
245
+ perform byollm_expire_due();
246
+
247
+ -- Kinds this runner is advertising *in this request*. A daemon that just
248
+ -- lost a backend must not be handed work for it.
249
+ select array_agg(value ->> 'kind') into v_kinds
250
+ from jsonb_array_elements(p_capabilities);
251
+
252
+ return query
253
+ with candidate as (
254
+ select j.id
255
+ from byollm_jobs j
256
+ where j.state = 'queued'
257
+ and j.claimable_at is not null
258
+ and j.claimable_at <= now()
259
+ and j.kind = any (v_kinds)
260
+ and not (p_runner_id = any (j.refused_by))
261
+ -- Dependency gating, belt and braces alongside claimable_at.
262
+ and not exists (
263
+ select 1 from byollm_jobs dep
264
+ where dep.id = any (j.depends_on) and dep.state is distinct from 'ok'
265
+ )
266
+ -- The audience rules, server side. The daemon enforces them too; this
267
+ -- is defence in depth, and the `named` case is deliberately permissive
268
+ -- here because the server cannot see a remote daemon's local allowlist.
269
+ and byollm_audience_admits(j, p_runner_id, v_owner, p_capabilities)
270
+ order by j.claimable_at
271
+ limit p_max
272
+ for update skip locked
273
+ )
274
+ update byollm_jobs j
275
+ set state = 'claimed',
276
+ lease_runner = p_runner_id,
277
+ lease_expires_at = now() + (p_lease_ms || ' milliseconds')::interval,
278
+ attempts = j.attempts + 1,
279
+ updated_at = now()
280
+ from candidate c
281
+ where j.id = c.id
282
+ returning j.*;
283
+ end;
284
+ $$;
285
+
286
+ -- The audience decision, mirroring `matchAudience` in @byollm/protocol.
287
+ create or replace function byollm_audience_admits(
288
+ p_job byollm_jobs,
289
+ p_runner_id uuid,
290
+ p_runner_owner uuid,
291
+ p_capabilities jsonb
292
+ )
293
+ returns boolean
294
+ language plpgsql
295
+ stable
296
+ security definer
297
+ set search_path = public
298
+ as $$
299
+ declare
300
+ v_cap jsonb;
301
+ v_scope text;
302
+ v_backend text;
303
+ v_same_owner boolean := (p_job.owner = p_runner_owner);
304
+ begin
305
+ select value into v_cap
306
+ from jsonb_array_elements(p_capabilities)
307
+ where value ->> 'kind' = p_job.kind
308
+ limit 1;
309
+
310
+ if v_cap is null then
311
+ return false;
312
+ end if;
313
+
314
+ v_scope := v_cap ->> 'offerScope';
315
+ v_backend := v_cap ->> 'backendId';
316
+
317
+ -- Side 1: does the job's audience admit this runner's owner?
318
+ if p_job.audience = 'self' and not v_same_owner then
319
+ return false;
320
+ end if;
321
+ if p_job.audience = 'named'
322
+ and not v_same_owner
323
+ and p_job.audience_allow is not null
324
+ and not (p_runner_owner = any (p_job.audience_allow)) then
325
+ return false;
326
+ end if;
327
+
328
+ -- A daemon always runs its own owner's work.
329
+ if v_same_owner then
330
+ return true;
331
+ end if;
332
+
333
+ -- The subscription self-lock. Applied here regardless of the scope the
334
+ -- daemon advertised: a widened scope on a subscription backend is refused,
335
+ -- not obeyed (SUBSCRIPTION_SELF_LOCK).
336
+ if v_backend = 'claude-cli' then
337
+ return false;
338
+ end if;
339
+
340
+ -- Side 2: does the backend's offer scope admit the job's owner?
341
+ if v_scope = 'self' then
342
+ return false;
343
+ end if;
344
+ if v_scope = 'named' then
345
+ -- The server cannot verify a remote daemon's local allowlist and must not
346
+ -- pretend to. It offers the job; the daemon refuses if its own list says
347
+ -- no, and the refusal is remembered in refused_by.
348
+ return true;
349
+ end if;
350
+ return v_scope = 'public';
351
+ end;
352
+ $$;
353
+
354
+ -- ---------------------------------------------------------------------------
355
+ -- RLS
356
+ -- ---------------------------------------------------------------------------
357
+
358
+ alter table byollm_runners enable row level security;
359
+ alter table byollm_jobs enable row level security;
360
+ alter table byollm_pairings enable row level security;
361
+ alter table byollm_job_cancels enable row level security;
362
+
363
+ -- A user sees and manages their own runners, and nobody else's.
364
+ create policy byollm_runners_owner_select on byollm_runners
365
+ for select using ((select auth.uid()) = owner);
366
+ create policy byollm_runners_owner_update on byollm_runners
367
+ for update using ((select auth.uid()) = owner);
368
+ create policy byollm_runners_owner_delete on byollm_runners
369
+ for delete using ((select auth.uid()) = owner);
370
+
371
+ -- A user sees their own jobs. Community jobs they volunteered to run are
372
+ -- visible through the claim RPC, which is security definer — deliberately
373
+ -- not through a broad select policy, so browsing other people's prompts is
374
+ -- not possible even for a willing volunteer.
375
+ create policy byollm_jobs_owner_select on byollm_jobs
376
+ for select using ((select auth.uid()) = owner);
377
+ create policy byollm_jobs_owner_insert on byollm_jobs
378
+ for insert with check ((select auth.uid()) = owner);
379
+
380
+ -- Pairings are approved through the RPC below, never written directly.
381
+ create policy byollm_pairings_owner_select on byollm_pairings
382
+ for select using ((select auth.uid()) = owner);
383
+
384
+ -- A user may ask to cancel their own job, and see that they asked.
385
+ create policy byollm_job_cancels_owner_select on byollm_job_cancels
386
+ for select using (
387
+ exists (
388
+ select 1 from byollm_jobs j
389
+ where j.id = byollm_job_cancels.job_id
390
+ and j.owner = (select auth.uid())
391
+ )
392
+ );
393
+ create policy byollm_job_cancels_owner_insert on byollm_job_cancels
394
+ for insert with check (
395
+ exists (
396
+ select 1 from byollm_jobs j
397
+ where j.id = byollm_job_cancels.job_id
398
+ and j.owner = (select auth.uid())
399
+ )
400
+ );
401
+
402
+ -- ---------------------------------------------------------------------------
403
+ -- Pairing approval — the one door for creating a runner
404
+ -- ---------------------------------------------------------------------------
405
+
406
+ create or replace function byollm_approve_pairing(
407
+ p_user_code text,
408
+ p_token_hash text
409
+ )
410
+ returns byollm_runners
411
+ language plpgsql
412
+ security definer
413
+ set search_path = public
414
+ as $$
415
+ declare
416
+ v_pairing byollm_pairings;
417
+ v_runner byollm_runners;
418
+ v_owner uuid := (select auth.uid());
419
+ begin
420
+ -- The owner comes from the caller's own session. A daemon can never assert
421
+ -- who it is; that is the whole reason pairing is interactive.
422
+ if v_owner is null then
423
+ raise exception 'approving a pairing requires an authenticated user';
424
+ end if;
425
+
426
+ select * into v_pairing from byollm_pairings
427
+ where user_code = p_user_code for update;
428
+
429
+ if v_pairing is null then
430
+ raise exception 'unknown pairing code';
431
+ end if;
432
+ if v_pairing.expires_at <= now() then
433
+ raise exception 'pairing code has expired';
434
+ end if;
435
+ if v_pairing.state <> 'pending' then
436
+ raise exception 'pairing is already %', v_pairing.state;
437
+ end if;
438
+
439
+ insert into byollm_runners (owner, token_hash, label, platform,
440
+ daemon_version, capabilities)
441
+ values (v_owner, p_token_hash, v_pairing.label, v_pairing.platform,
442
+ v_pairing.daemon_version, v_pairing.capabilities)
443
+ returning * into v_runner;
444
+
445
+ update byollm_pairings
446
+ set state = 'approved', owner = v_owner, runner_id = v_runner.id
447
+ where device_code_hash = v_pairing.device_code_hash;
448
+
449
+ return v_runner;
450
+ end;
451
+ $$;
452
+
453
+ -- ---------------------------------------------------------------------------
454
+ -- Grants — REVOKE from PUBLIC, GRANT deliberately (the house rule)
455
+ -- ---------------------------------------------------------------------------
456
+
457
+ -- Table privileges, granted deliberately rather than inherited.
458
+ --
459
+ -- Supabase images have historically granted blanket privileges on new public
460
+ -- tables to anon/authenticated/service_role, and relying on that is how this
461
+ -- migration passed on one CLI version and failed with "permission denied" on a
462
+ -- newer one. RLS decides *which rows*; these decide *which operations* — both
463
+ -- are required, and neither is a substitute for the other.
464
+ --
465
+ -- Revoke first, then grant. Supabase images ship `ALTER DEFAULT PRIVILEGES`
466
+ -- that hand `anon` and `authenticated` ALL privileges on every new public
467
+ -- table — verified on a fresh database, where `anon` arrived holding
468
+ -- INSERT/UPDATE/DELETE on all four of these. RLS still refuses every row, so
469
+ -- nothing was exposed, but "we grant anon nothing" is only true if we take
470
+ -- away what the image already gave. Revoking makes this file authoritative
471
+ -- instead of dependent on whichever image happens to be running.
472
+ revoke all on byollm_jobs, byollm_runners, byollm_pairings, byollm_job_cancels
473
+ from anon, authenticated;
474
+
475
+ -- `anon` is deliberately granted nothing back: no part of this schema is
476
+ -- reachable without a session.
477
+ grant select, insert on byollm_jobs to authenticated;
478
+ grant select, update, delete on byollm_runners to authenticated;
479
+ grant select on byollm_pairings to authenticated;
480
+ grant select, insert on byollm_job_cancels to authenticated;
481
+
482
+ -- The protocol handler authenticates runners with their own bearer tokens,
483
+ -- which are not Supabase sessions, so it runs as the service role.
484
+ grant select, insert, update, delete
485
+ on byollm_jobs, byollm_runners, byollm_pairings, byollm_job_cancels
486
+ to service_role;
487
+
488
+ revoke all on function byollm_claim_jobs(uuid, jsonb, integer, integer) from public;
489
+ revoke all on function byollm_audience_admits(byollm_jobs, uuid, uuid, jsonb) from public;
490
+ revoke all on function byollm_expire_due() from public;
491
+ revoke all on function byollm_approve_pairing(text, text) from public;
492
+ revoke all on function byollm_unblock_dependents() from public;
493
+
494
+ -- The claim RPC is called by the protocol handler with the service role, not
495
+ -- by a browser: a runner authenticates with its bearer token, which is not a
496
+ -- Supabase session.
497
+ grant execute on function byollm_claim_jobs(uuid, jsonb, integer, integer) to service_role;
498
+ grant execute on function byollm_expire_due() to service_role;
499
+
500
+ -- Approval happens in the browser, in the user's own session.
501
+ grant execute on function byollm_approve_pairing(text, text) to authenticated;
502
+
503
+ -- ---------------------------------------------------------------------------
504
+ -- Realtime — the app's delivery channel
505
+ -- ---------------------------------------------------------------------------
506
+
507
+ alter publication supabase_realtime add table byollm_jobs;