@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/README.md +65 -11
- package/bin/keygen.mjs +21 -0
- package/dist/{chunk-HL6EYHQ7.js → chunk-MGJX6626.js} +229 -52
- package/dist/chunk-MGJX6626.js.map +1 -0
- package/dist/{handlers-D7lWfwno.d.ts → handlers-CF3yE-t2.d.ts} +27 -4
- package/dist/index.d.ts +80 -12
- package/dist/index.js +191 -26
- package/dist/index.js.map +1 -1
- package/dist/next.d.ts +40 -8
- package/dist/next.js +8 -2
- package/dist/next.js.map +1 -1
- package/dist/{store-D23N6iiP.d.ts → store-gFEEN1Dt.d.ts} +72 -6
- package/dist/supabase/index.d.ts +1 -1
- package/dist/supabase/index.js +61 -17
- package/dist/supabase/index.js.map +1 -1
- package/package.json +8 -4
- package/supabase/migrations/20260809000000_byollm_runner.sql +22 -3
- package/dist/chunk-HL6EYHQ7.js.map +0 -1
package/dist/next.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { H as HandlerConfig } from './handlers-
|
|
1
|
+
import { H as HandlerConfig } from './handlers-CF3yE-t2.js';
|
|
2
2
|
import '@byollm/protocol';
|
|
3
|
-
import './store-
|
|
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 {
|
|
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
|
-
|
|
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-
|
|
3
|
+
} from "./chunk-MGJX6626.js";
|
|
4
4
|
|
|
5
5
|
// src/next.ts
|
|
6
6
|
function createHandler(config) {
|
|
7
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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 };
|
package/dist/supabase/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
-
import { B as ByollmStore } from '../store-
|
|
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
|
|
package/dist/supabase/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
180
|
+
id: input.id,
|
|
173
181
|
kind: input.kind,
|
|
174
|
-
|
|
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
|
|
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.
|
|
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).
|
|
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.
|
|
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.
|
|
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).
|
|
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({
|