@byollm/server 0.1.0-alpha.2 → 0.1.0-alpha.4

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.
package/dist/next.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { H as HandlerConfig } from './handlers-D7lWfwno.js';
1
+ import { H as HandlerConfig } from './handlers-CF3yE-t2.js';
2
2
  import '@byollm/protocol';
3
- import './store-D23N6iiP.js';
3
+ import './store-gFEEN1Dt.js';
4
4
 
5
5
  /**
6
6
  * `@byollm/server/next` — the one-file Next.js mount.
@@ -9,20 +9,52 @@ import './store-D23N6iiP.js';
9
9
  *
10
10
  * ```ts
11
11
  * import { createHandler } from "@byollm/server/next";
12
- * import { store } from "@/lib/byollm";
12
+ * import { siteKeysFromEnv } from "@byollm/server";
13
+ * import { getStore } from "@/lib/byollm";
13
14
  *
14
- * export const { POST } = createHandler({
15
- * store,
15
+ * export const { POST } = createHandler(() => ({
16
+ * store: getStore(),
17
+ * siteKeys: siteKeysFromEnv("BYOLLM_SITE_KEYS"),
16
18
  * verificationUrl: "https://your-app.com/settings/runners",
17
- * });
19
+ * // Next serves this route under /api, so say so. The handler matches the
20
+ * // full path, not a suffix, and will 404 without it.
21
+ * basePath: "/api/byollm",
22
+ * }));
18
23
  * ```
19
24
  *
25
+ * **Pass a function, not an object.** `next build` imports every route module
26
+ * to collect page data, in an environment that has no secrets — so a config
27
+ * *object* means the store and the site keys are constructed at build time,
28
+ * and the build fails on the credentials it cannot have. A function is not
29
+ * called until the first request, so importing this module does nothing.
30
+ *
31
+ * An object still works, for a store that needs no secrets to construct. It is
32
+ * the second form because it is the one that fails in production and not in
33
+ * development, which is the wrong way round for a default.
34
+ *
35
+ * **Then pair against that same path**: `byollm connect https://your-app.com/api`.
36
+ * The daemon appends `/byollm/<endpoint>` to whatever origin it is given, so
37
+ * connecting to the bare domain reaches `/byollm/claim` and finds nothing.
38
+ * This is the first thing an integrator gets wrong, and it used to fail as a
39
+ * silent 404 — the handler matched on the last path segment alone, so nothing
40
+ * ever checked where it was mounted.
41
+ *
42
+ * To serve at `/byollm` instead, move the route to
43
+ * `app/byollm/[...route]/route.ts`, drop `basePath`, and pair against the bare
44
+ * domain.
45
+ *
20
46
  * That is the whole protocol surface. The app-facing half — enqueue, approve
21
47
  * a pairing, read a result — is {@link ByollmApp} from `@byollm/server`.
22
48
  *
23
49
  * @packageDocumentation
24
50
  */
25
- declare function createHandler(config: HandlerConfig): {
51
+ /** What this mount needs, plus where it is mounted. */
52
+ type NextHandlerConfig = HandlerConfig & {
53
+ /** Where this route is mounted. Next users almost always want
54
+ * `"/api/byollm"`; see the example above. */
55
+ readonly basePath?: string;
56
+ };
57
+ declare function createHandler(config: NextHandlerConfig | (() => NextHandlerConfig)): {
26
58
  POST: (request: Request) => Promise<Response>;
27
59
  /** Present so a stray GET gets a clear 405 rather than a framework 404. */
28
60
  GET: (request: Request) => Promise<Response>;
@@ -30,4 +62,4 @@ declare function createHandler(config: HandlerConfig): {
30
62
  dynamic: "force-dynamic";
31
63
  };
32
64
 
33
- export { HandlerConfig, createHandler };
65
+ export { HandlerConfig, type NextHandlerConfig, createHandler };
package/dist/next.js CHANGED
@@ -1,10 +1,16 @@
1
1
  import {
2
2
  createFetchHandler
3
- } from "./chunk-HL6EYHQ7.js";
3
+ } from "./chunk-MGJX6626.js";
4
4
 
5
5
  // src/next.ts
6
6
  function createHandler(config) {
7
- const handler = createFetchHandler(config);
7
+ let built;
8
+ const handler = (request) => {
9
+ built ??= createFetchHandler(
10
+ typeof config === "function" ? config() : config
11
+ );
12
+ return built(request);
13
+ };
8
14
  return {
9
15
  POST: handler,
10
16
  GET: handler,
package/dist/next.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/next.ts"],"sourcesContent":["import { createFetchHandler } from \"./http.js\";\nimport type { HandlerConfig } from \"./handlers.js\";\n\n/**\n * `@byollm/server/next` — the one-file Next.js mount.\n *\n * Drop this in `app/api/byollm/[...route]/route.ts`:\n *\n * ```ts\n * import { createHandler } from \"@byollm/server/next\";\n * import { store } from \"@/lib/byollm\";\n *\n * export const { POST } = createHandler({\n * store,\n * verificationUrl: \"https://your-app.com/settings/runners\",\n * });\n * ```\n *\n * That is the whole protocol surface. The app-facing half — enqueue, approve\n * a pairing, read a result — is {@link ByollmApp} from `@byollm/server`.\n *\n * @packageDocumentation\n */\nexport function createHandler(config: HandlerConfig): {\n POST: (request: Request) => Promise<Response>;\n /** Present so a stray GET gets a clear 405 rather than a framework 404. */\n GET: (request: Request) => Promise<Response>;\n /** Route handlers must not be cached — every call mutates lease state. */\n dynamic: \"force-dynamic\";\n} {\n const handler = createFetchHandler(config);\n return {\n POST: handler,\n GET: handler,\n dynamic: \"force-dynamic\",\n };\n}\n\nexport type { HandlerConfig };\n"],"mappings":";;;;;AAuBO,SAAS,cAAc,QAM5B;AACA,QAAM,UAAU,mBAAmB,MAAM;AACzC,SAAO;AAAA,IACL,MAAM;AAAA,IACN,KAAK;AAAA,IACL,SAAS;AAAA,EACX;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/next.ts"],"sourcesContent":["import { createFetchHandler } from \"./http.js\";\nimport type { HandlerConfig } from \"./handlers.js\";\n\n/**\n * `@byollm/server/next` — the one-file Next.js mount.\n *\n * Drop this in `app/api/byollm/[...route]/route.ts`:\n *\n * ```ts\n * import { createHandler } from \"@byollm/server/next\";\n * import { siteKeysFromEnv } from \"@byollm/server\";\n * import { getStore } from \"@/lib/byollm\";\n *\n * export const { POST } = createHandler(() => ({\n * store: getStore(),\n * siteKeys: siteKeysFromEnv(\"BYOLLM_SITE_KEYS\"),\n * verificationUrl: \"https://your-app.com/settings/runners\",\n * // Next serves this route under /api, so say so. The handler matches the\n * // full path, not a suffix, and will 404 without it.\n * basePath: \"/api/byollm\",\n * }));\n * ```\n *\n * **Pass a function, not an object.** `next build` imports every route module\n * to collect page data, in an environment that has no secrets — so a config\n * *object* means the store and the site keys are constructed at build time,\n * and the build fails on the credentials it cannot have. A function is not\n * called until the first request, so importing this module does nothing.\n *\n * An object still works, for a store that needs no secrets to construct. It is\n * the second form because it is the one that fails in production and not in\n * development, which is the wrong way round for a default.\n *\n * **Then pair against that same path**: `byollm connect https://your-app.com/api`.\n * The daemon appends `/byollm/<endpoint>` to whatever origin it is given, so\n * connecting to the bare domain reaches `/byollm/claim` and finds nothing.\n * This is the first thing an integrator gets wrong, and it used to fail as a\n * silent 404 — the handler matched on the last path segment alone, so nothing\n * ever checked where it was mounted.\n *\n * To serve at `/byollm` instead, move the route to\n * `app/byollm/[...route]/route.ts`, drop `basePath`, and pair against the bare\n * domain.\n *\n * That is the whole protocol surface. The app-facing half — enqueue, approve\n * a pairing, read a result — is {@link ByollmApp} from `@byollm/server`.\n *\n * @packageDocumentation\n */\n/** What this mount needs, plus where it is mounted. */\nexport type NextHandlerConfig = HandlerConfig & {\n /** Where this route is mounted. Next users almost always want\n * `\"/api/byollm\"`; see the example above. */\n readonly basePath?: string;\n};\n\nexport function createHandler(\n config: NextHandlerConfig | (() => NextHandlerConfig),\n): {\n POST: (request: Request) => Promise<Response>;\n /** Present so a stray GET gets a clear 405 rather than a framework 404. */\n GET: (request: Request) => Promise<Response>;\n /** Route handlers must not be cached — every call mutates lease state. */\n dynamic: \"force-dynamic\";\n} {\n // Built on the first request and kept, not rebuilt per call: the handlers\n // hold a store and a lease clock, and a fresh instance per request would be\n // a new connection pool per request.\n let built: ((request: Request) => Promise<Response>) | undefined;\n const handler = (request: Request): Promise<Response> => {\n built ??= createFetchHandler(\n typeof config === \"function\" ? config() : config,\n );\n return built(request);\n };\n\n return {\n POST: handler,\n GET: handler,\n dynamic: \"force-dynamic\",\n };\n}\n\nexport type { HandlerConfig };\n"],"mappings":";;;;;AAwDO,SAAS,cACd,QAOA;AAIA,MAAI;AACJ,QAAM,UAAU,CAAC,YAAwC;AACvD,cAAU;AAAA,MACR,OAAO,WAAW,aAAa,OAAO,IAAI;AAAA,IAC5C;AACA,WAAO,MAAM,OAAO;AAAA,EACtB;AAEA,SAAO;AAAA,IACL,MAAM;AAAA,IACN,KAAK;AAAA,IACL,SAAS;AAAA,EACX;AACF;","names":[]}
@@ -1,4 +1,4 @@
1
- import { JobKind, JobPayload, Audience, JobState, Lease, JobOutcome, ResultProvenance, Capability } from '@byollm/protocol';
1
+ import { JobKind, JobPayload, Audience, SealedEnvelope, SizeClass, JobState, Lease, JobOutcome, ResultProvenance, Capability, PublicIdentity } from '@byollm/protocol';
2
2
 
3
3
  /**
4
4
  * A job as the server stores it.
@@ -10,7 +10,21 @@ import { JobKind, JobPayload, Audience, JobState, Lease, JobOutcome, ResultProve
10
10
  interface JobRecord {
11
11
  readonly id: string;
12
12
  readonly kind: JobKind;
13
- readonly payload: JobPayload;
13
+ /**
14
+ * The work, sealed to this site's own encryption key (byollm_009 §10).
15
+ *
16
+ * The store never holds plaintext. The app sees plaintext at enqueue and at
17
+ * result because the app *is* the endpoint; everything in between —
18
+ * database, backups, log aggregators, a support engineer with read access —
19
+ * sees ciphertext.
20
+ *
21
+ * This is not protection from the application the user deliberately sent
22
+ * their work to. It is protection from everything the application's storage
23
+ * touches, which is a longer list than most people picture.
24
+ */
25
+ readonly envelope: SealedEnvelope;
26
+ /** Fixed at enqueue, where the plaintext is. */
27
+ readonly sizeClass: SizeClass;
14
28
  readonly audience: Audience;
15
29
  /** The app's id for the user who enqueued it. */
16
30
  readonly owner: string;
@@ -63,6 +77,11 @@ interface RunnerRecord {
63
77
  readonly revokedAt: number | null;
64
78
  readonly lastHeartbeatAt: number;
65
79
  readonly createdAt: number;
80
+ /**
81
+ * The device's pinned public keys. What later signatures verify against —
82
+ * a runner id names a machine, this proves it.
83
+ */
84
+ readonly device: PublicIdentity;
66
85
  }
67
86
  /** An in-flight device-code pairing. */
68
87
  interface PairingRecord {
@@ -83,12 +102,21 @@ interface PairingRecord {
83
102
  readonly platform: "darwin" | "linux" | "win32";
84
103
  readonly daemonVersion: string;
85
104
  readonly capabilities: readonly Capability[];
105
+ /**
106
+ * The device's public keys, presented at pair start (byollm_009 §5).
107
+ *
108
+ * Kept on the pairing so the approving user is approving a *specific
109
+ * machine*, not a code that any machine could later redeem. It is copied
110
+ * onto the runner at approval.
111
+ */
112
+ readonly device: PublicIdentity;
86
113
  readonly expiresAt: number;
87
114
  readonly createdAt: number;
88
115
  }
89
116
  /** What the app supplies to enqueue a job. */
90
117
  interface EnqueueInput {
91
118
  readonly kind: JobKind;
119
+ /** The work, in plaintext. The server seals it before it is stored. */
92
120
  readonly payload: JobPayload;
93
121
  readonly owner: string;
94
122
  /** Defaults to `self` — the safe direction. */
@@ -101,6 +129,20 @@ interface EnqueueInput {
101
129
  /** Caller-supplied id, for idempotent enqueue. */
102
130
  readonly id?: string;
103
131
  }
132
+ /**
133
+ * What the *store* is given — the sealed form.
134
+ *
135
+ * Distinct from {@link EnqueueInput} because the two are genuinely different
136
+ * things: an app hands over work in plaintext, and what gets written down is
137
+ * sealed. Collapsing them into one type would mean a field that is sometimes
138
+ * readable and sometimes not, which is the kind of ambiguity that ends with
139
+ * plaintext in a database.
140
+ */
141
+ interface StoredJobInput extends Omit<EnqueueInput, "payload" | "id"> {
142
+ readonly id: string;
143
+ readonly envelope: SealedEnvelope;
144
+ readonly sizeClass: SizeClass;
145
+ }
104
146
 
105
147
  /**
106
148
  * The adapter seam.
@@ -112,7 +154,7 @@ interface EnqueueInput {
112
154
  */
113
155
  interface JobStore {
114
156
  /** Create a job. Idempotent when `input.id` is supplied and already exists. */
115
- create(input: EnqueueInput, now: number): Promise<JobRecord>;
157
+ create(input: StoredJobInput, now: number): Promise<JobRecord>;
116
158
  get(jobId: string): Promise<JobRecord | null>;
117
159
  /**
118
160
  * Atomically claim up to `max` jobs for a runner
@@ -176,9 +218,14 @@ interface ClaimArgs {
176
218
  readonly leaseMs: number;
177
219
  readonly now: number;
178
220
  }
221
+ /** A lease named by its grant, not only by the job it covers. */
222
+ interface LeaseRef {
223
+ readonly jobId: string;
224
+ readonly leaseId: string;
225
+ }
179
226
  interface RenewArgs {
180
227
  readonly runnerId: string;
181
- readonly jobIds: readonly string[];
228
+ readonly leases: readonly LeaseRef[];
182
229
  readonly leaseMs: number;
183
230
  readonly now: number;
184
231
  }
@@ -204,7 +251,7 @@ interface CompleteResult {
204
251
  }
205
252
  interface ReleaseArgs {
206
253
  readonly runnerId: string;
207
- readonly jobIds: readonly string[];
254
+ readonly leases: readonly LeaseRef[];
208
255
  readonly reason: "shutdown" | "pause" | "revoked" | "backend-down" | "refused";
209
256
  readonly now: number;
210
257
  }
@@ -231,6 +278,25 @@ interface RunnerStore {
231
278
  revokeRunner(runnerId: string, now: number): Promise<void>;
232
279
  /** Live runners for an owner — used by the no-runner signal. */
233
280
  listRunners(owner?: string): Promise<RunnerRecord[]>;
281
+ /**
282
+ * Watch one job for state changes — the push seam (byollm_009 §8.3).
283
+ *
284
+ * **Required of every adapter, from day one, even though v1 uses it only
285
+ * for result readiness.** byollm_006 located streaming's real difficulty at
286
+ * the server→app leg: polling cannot carry deltas by construction. If the
287
+ * store contract were request/response only, adding streaming later would
288
+ * force a *second* adapter-breaking reshape — so the channel exists now and
289
+ * gets one more use later, rather than the interface changing twice.
290
+ *
291
+ * The handler is called after a change lands; it is a signal, not a
292
+ * payload, so a missed or duplicated call is survivable and the caller
293
+ * re-reads. That looseness is deliberate: it is the weakest contract every
294
+ * plausible backend can honour, and a stronger one would exclude adapters
295
+ * for no gain.
296
+ *
297
+ * Returns an unsubscribe function. Calling it twice MUST be safe.
298
+ */
299
+ subscribe(jobId: string, onChange: () => void): () => void;
234
300
  }
235
301
  interface ApproveArgs {
236
302
  readonly userCode: string;
@@ -252,4 +318,4 @@ interface TouchArgs {
252
318
  interface ByollmStore extends JobStore, RunnerStore {
253
319
  }
254
320
 
255
- export type { ApproveArgs as A, ByollmStore as B, ClaimArgs as C, EnqueueInput as E, JobRecord as J, PairingRecord as P, RunnerRecord as R, TouchArgs as T, RenewArgs as a, RenewResult as b, CompleteArgs as c, CompleteResult as d, ReleaseArgs as e, JobStore as f, RunnerStore as g };
321
+ export type { ApproveArgs as A, ByollmStore as B, ClaimArgs as C, EnqueueInput as E, JobRecord as J, PairingRecord as P, RunnerRecord as R, StoredJobInput as S, TouchArgs as T, RenewArgs as a, RenewResult as b, CompleteArgs as c, CompleteResult as d, ReleaseArgs as e, JobStore as f, RunnerStore as g };
@@ -1,5 +1,5 @@
1
1
  import { SupabaseClient } from '@supabase/supabase-js';
2
- import { B as ByollmStore } from '../store-D23N6iiP.js';
2
+ import { B as ByollmStore } from '../store-gFEEN1Dt.js';
3
3
  import { P as PollingDeliveryDeps, R as ResultDelivery } from '../delivery-36nIe-b3.js';
4
4
  import '@byollm/protocol';
5
5
 
@@ -20,6 +20,8 @@ var SupabaseRealtimeDelivery = class {
20
20
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
21
21
  const current = await this.#deps.read(jobId);
22
22
  if (current && isTerminal(current.state)) return current;
23
+ const settled = Promise.withResolvers();
24
+ this.#resolve = settled.resolve;
23
25
  const channel = this.#client.channel(`byollm_job_${jobId}`).on(
24
26
  "postgres_changes",
25
27
  {
@@ -29,13 +31,11 @@ var SupabaseRealtimeDelivery = class {
29
31
  filter: `id=eq.${jobId}`
30
32
  },
31
33
  () => {
32
- void this.#check(jobId);
34
+ this.#check(jobId).catch(settled.reject);
33
35
  }
34
36
  );
35
- const settled = Promise.withResolvers();
36
- this.#resolve = settled.resolve;
37
37
  channel.subscribe();
38
- void this.#check(jobId);
38
+ this.#check(jobId).catch(settled.reject);
39
39
  const timer = setTimeout(() => {
40
40
  settled.reject(new ResultTimeoutError(jobId, timeoutMs));
41
41
  }, timeoutMs);
@@ -62,7 +62,7 @@ var SupabaseRealtimeDelivery = class {
62
62
  #watchAvailability(jobId, options, settled) {
63
63
  let noRunnerSince = null;
64
64
  return setInterval(() => {
65
- void (async () => {
65
+ (async () => {
66
66
  const availability = await this.#deps.availability(jobId);
67
67
  if (availability.available || availability.blocked) {
68
68
  noRunnerSince = null;
@@ -77,7 +77,7 @@ var SupabaseRealtimeDelivery = class {
77
77
  } else {
78
78
  settled.reject(new NoRunnerAvailableError(jobId, reason));
79
79
  }
80
- })();
80
+ })().catch(settled.reject);
81
81
  }, 2e3);
82
82
  }
83
83
  };
@@ -93,13 +93,18 @@ function toJob(row) {
93
93
  return {
94
94
  id: row.id,
95
95
  kind: row.kind,
96
- payload: row.payload,
96
+ envelope: row.envelope,
97
+ sizeClass: row.size_class,
97
98
  audience: row.audience,
98
99
  owner: row.owner,
99
100
  audienceAllow: row.audience_allow ?? void 0,
100
101
  dependsOn: row.depends_on,
101
102
  state: row.state,
102
- lease: row.lease_runner !== null && leaseExpires !== null ? { runnerId: row.lease_runner, expiresAt: leaseExpires } : null,
103
+ lease: row.lease_runner !== null && leaseExpires !== null && row.lease_id !== null ? {
104
+ id: row.lease_id,
105
+ runnerId: row.lease_runner,
106
+ expiresAt: leaseExpires
107
+ } : null,
103
108
  createdAt: Date.parse(row.created_at),
104
109
  claimableAt: ms(row.claimable_at),
105
110
  ttlMs: row.ttl_ms,
@@ -111,6 +116,7 @@ function toJob(row) {
111
116
  updatedAt: Date.parse(row.updated_at)
112
117
  };
113
118
  }
119
+ var leasePairs = (leases) => leases.map((l) => `and(id.eq.${l.jobId},lease_id.eq.${l.leaseId})`).join(",");
114
120
  function toRunner(row) {
115
121
  return {
116
122
  id: row.id,
@@ -120,6 +126,7 @@ function toRunner(row) {
120
126
  platform: row.platform,
121
127
  daemonVersion: row.daemon_version,
122
128
  capabilities: row.capabilities,
129
+ device: row.device,
123
130
  paused: row.paused,
124
131
  revokedAt: ms(row.revoked_at),
125
132
  lastHeartbeatAt: Date.parse(row.last_heartbeat_at),
@@ -138,6 +145,7 @@ function toPairing(row) {
138
145
  platform: row.platform,
139
146
  daemonVersion: row.daemon_version,
140
147
  capabilities: row.capabilities,
148
+ device: row.device,
141
149
  expiresAt: Date.parse(row.expires_at),
142
150
  createdAt: Date.parse(row.created_at)
143
151
  };
@@ -169,9 +177,10 @@ function supabaseStore(options) {
169
177
  claimableAt = allDone ? iso(now) : null;
170
178
  }
171
179
  const row = {
172
- ...input.id === void 0 ? {} : { id: input.id },
180
+ id: input.id,
173
181
  kind: input.kind,
174
- payload: input.payload,
182
+ envelope: input.envelope,
183
+ size_class: input.sizeClass,
175
184
  audience: input.audience ?? "self",
176
185
  owner: input.owner,
177
186
  audience_allow: input.audienceAllow ? [...input.audienceAllow] : null,
@@ -185,7 +194,7 @@ function supabaseStore(options) {
185
194
  );
186
195
  if (inserted) return toJob(inserted);
187
196
  const existing = unwrap(
188
- await db.from("byollm_jobs").select().eq("id", input.id ?? "").single()
197
+ await db.from("byollm_jobs").select().eq("id", input.id).single()
189
198
  );
190
199
  return toJob(existing);
191
200
  },
@@ -208,14 +217,14 @@ function supabaseStore(options) {
208
217
  },
209
218
  async renewLeases(args) {
210
219
  await db.rpc("byollm_expire_due");
211
- if (args.jobIds.length === 0) return { renewed: [], lost: [] };
220
+ if (args.leases.length === 0) return { renewed: [], lost: [] };
212
221
  const expiresAt = iso(args.now + args.leaseMs);
213
222
  const renewedRows = unwrap(
214
223
  await db.from("byollm_jobs").update({
215
224
  state: "running",
216
225
  lease_expires_at: expiresAt,
217
226
  updated_at: iso(args.now)
218
- }).eq("lease_runner", args.runnerId).in("id", [...args.jobIds]).in("state", ["claimed", "running"]).select("id")
227
+ }).eq("lease_runner", args.runnerId).or(leasePairs(args.leases)).in("state", ["claimed", "running"]).select("id")
219
228
  );
220
229
  const renewedIds = new Set(renewedRows.map((row) => row.id));
221
230
  return {
@@ -224,7 +233,7 @@ function supabaseStore(options) {
224
233
  expiresAt: args.now + args.leaseMs
225
234
  })),
226
235
  // Anything the runner thinks it holds but did not renew is gone.
227
- lost: args.jobIds.filter((id) => !renewedIds.has(id))
236
+ lost: args.leases.map((l) => l.jobId).filter((id) => !renewedIds.has(id))
228
237
  };
229
238
  },
230
239
  async complete(args) {
@@ -252,15 +261,16 @@ function supabaseStore(options) {
252
261
  return { accepted: true, job: toJob(written) };
253
262
  },
254
263
  async release(args) {
255
- if (args.jobIds.length === 0) return [];
264
+ if (args.leases.length === 0) return [];
256
265
  const held = unwrap(
257
- await db.from("byollm_jobs").select("id,refused_by").eq("lease_runner", args.runnerId).in("id", [...args.jobIds])
266
+ await db.from("byollm_jobs").select("id,refused_by").eq("lease_runner", args.runnerId).or(leasePairs(args.leases))
258
267
  );
259
268
  const released = [];
260
269
  for (const row of held) {
261
270
  const refusedBy = args.reason === "refused" ? [.../* @__PURE__ */ new Set([...row.refused_by, args.runnerId])] : row.refused_by;
262
271
  const { error } = await db.from("byollm_jobs").update({
263
272
  state: "queued",
273
+ lease_id: null,
264
274
  lease_runner: null,
265
275
  lease_expires_at: null,
266
276
  // Newly available again, so the TTL clock restarts.
@@ -309,9 +319,37 @@ function supabaseStore(options) {
309
319
  return rows.map((row) => row.job_id);
310
320
  },
311
321
  // -- pairing and runners -------------------------------------------------
322
+ /**
323
+ * The push seam (byollm_009 §8.3), over Postgres Realtime.
324
+ *
325
+ * Native here, which is the point of requiring it of every adapter: the
326
+ * backend that can push does, the one that cannot polls, and the
327
+ * interface does not change again when streaming arrives.
328
+ */
329
+ subscribe(jobId, onChange) {
330
+ const channel = db.channel(`byollm_job_${jobId}`).on(
331
+ "postgres_changes",
332
+ {
333
+ event: "UPDATE",
334
+ schema: "public",
335
+ table: "byollm_jobs",
336
+ filter: `id=eq.${jobId}`
337
+ },
338
+ () => {
339
+ onChange();
340
+ }
341
+ ).subscribe();
342
+ let live = true;
343
+ return () => {
344
+ if (!live) return;
345
+ live = false;
346
+ void db.removeChannel(channel).catch(() => void 0);
347
+ };
348
+ },
312
349
  async createPairing(record) {
313
350
  const { error } = await db.from("byollm_pairings").insert({
314
351
  device_code_hash: record.deviceCodeHash,
352
+ device: record.device,
315
353
  user_code: record.userCode,
316
354
  state: record.state,
317
355
  label: record.label,
@@ -351,7 +389,13 @@ function supabaseStore(options) {
351
389
  label: pairing.label,
352
390
  platform: pairing.platform,
353
391
  daemon_version: pairing.daemon_version,
354
- capabilities: pairing.capabilities
392
+ capabilities: pairing.capabilities,
393
+ // Carried from the pairing, exactly as the SQL RPC does. There
394
+ // are two approval paths — this service-role one and
395
+ // `byollm_approve_pairing` for browser callers — and a field
396
+ // added to one and not the other produces a runner that is
397
+ // correct through one door and broken through the other.
398
+ device: pairing.device
355
399
  }).select().single()
356
400
  );
357
401
  const { error } = await db.from("byollm_pairings").update({