@byollm/server 0.1.0-alpha.8 → 0.1.0-alpha.81
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 +166 -9
- package/dist/{chunk-4NIHWQAT.js → chunk-DG6XZQU3.js} +76 -39
- package/dist/chunk-DG6XZQU3.js.map +1 -0
- package/dist/{chunk-7RKXFPBZ.js → chunk-I3ER27QG.js} +19 -5
- package/dist/chunk-I3ER27QG.js.map +1 -0
- package/dist/{delivery-36nIe-b3.d.ts → delivery-CaGbp0Tc.d.ts} +35 -5
- package/dist/{handlers-DgW0QNTf.d.ts → handlers-CTV3Jc6Q.d.ts} +2 -2
- package/dist/index.d.ts +92 -24
- package/dist/index.js +316 -98
- package/dist/index.js.map +1 -1
- package/dist/next.d.ts +2 -2
- package/dist/next.js +1 -1
- package/dist/{store-Cj5b6A9j.d.ts → store-Cx2_bck1.d.ts} +130 -18
- package/dist/supabase/index.d.ts +2 -2
- package/dist/supabase/index.js +62 -29
- package/dist/supabase/index.js.map +1 -1
- package/package.json +2 -2
- package/supabase/migrations/20260819000000_drop_runner_token.sql +87 -0
- package/supabase/migrations/20260819010000_completed_by_lease_id.sql +25 -0
- package/supabase/migrations/20260821000000_rename_collected.sql +91 -0
- package/supabase/migrations/20260824000000_one_vocabulary.sql +109 -0
- package/supabase/migrations/20260825000000_job_service.sql +20 -0
- package/supabase/migrations/20260827000000_job_purpose.sql +36 -0
- package/dist/chunk-4NIHWQAT.js.map +0 -1
- package/dist/chunk-7RKXFPBZ.js.map +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { JobKind,
|
|
1
|
+
import { JobKind, PayloadFor, 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.
|
|
@@ -26,6 +26,14 @@ interface JobRecord {
|
|
|
26
26
|
/** Fixed at enqueue, where the plaintext is. */
|
|
27
27
|
readonly sizeClass: SizeClass;
|
|
28
28
|
readonly audience: Audience;
|
|
29
|
+
/**
|
|
30
|
+
* The service the site named, if it named one — byollm_016 Phase B.
|
|
31
|
+
*
|
|
32
|
+
* Stored rather than derived, because the stub carries it to the router and
|
|
33
|
+
* the router matches on it. `undefined` means the owner's default answers,
|
|
34
|
+
* which is every job written before this field existed.
|
|
35
|
+
*/
|
|
36
|
+
readonly purpose: string | undefined;
|
|
29
37
|
/** The app's id for the user who enqueued it. */
|
|
30
38
|
readonly owner: string;
|
|
31
39
|
/** Server-side restriction on which runner owners may take a `named` job. */
|
|
@@ -34,6 +42,17 @@ interface JobRecord {
|
|
|
34
42
|
readonly dependsOn: readonly string[];
|
|
35
43
|
readonly state: JobState;
|
|
36
44
|
readonly lease: Lease | null;
|
|
45
|
+
/**
|
|
46
|
+
* The grant that recorded this job's result — cloud_008 §3.6.
|
|
47
|
+
*
|
|
48
|
+
* Kept after `lease` is nulled, because "who finished this" outlives "who
|
|
49
|
+
* holds this" and the two are asked for different reasons. It is what lets
|
|
50
|
+
* a replay from the device that finished the job be answered *as a
|
|
51
|
+
* duplicate* rather than as a stale lease — and lets a replay from any
|
|
52
|
+
* other device be refused exactly as it would be for a job that is not
|
|
53
|
+
* terminal, so a job id is not a terminality probe.
|
|
54
|
+
*/
|
|
55
|
+
readonly completedByLeaseId: string | null;
|
|
37
56
|
readonly createdAt: number;
|
|
38
57
|
/**
|
|
39
58
|
* When the job became claimable — enqueue time for a job with no
|
|
@@ -66,8 +85,6 @@ interface RunnerRecord {
|
|
|
66
85
|
readonly id: string;
|
|
67
86
|
/** The app's id for the user this runner is bound to — exactly one. */
|
|
68
87
|
readonly owner: string;
|
|
69
|
-
/** SHA-256 of the bearer token. The token itself is never stored. */
|
|
70
|
-
readonly tokenHash: string;
|
|
71
88
|
readonly label: string;
|
|
72
89
|
readonly platform: "darwin" | "linux" | "win32";
|
|
73
90
|
readonly daemonVersion: string;
|
|
@@ -94,10 +111,19 @@ interface PairingRecord {
|
|
|
94
111
|
readonly owner: string | null;
|
|
95
112
|
readonly runnerId: string | null;
|
|
96
113
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
114
|
+
* Whether this approval has already been collected — cloud_008 §2.4.
|
|
115
|
+
*
|
|
116
|
+
* This was `runnerTokenOnce`, a bearer token held until the daemon's next
|
|
117
|
+
* poll and then nulled. The token is gone (finding 37: minted, hashed,
|
|
118
|
+
* written to two disks, never sent or compared), but the *deliver-once*
|
|
119
|
+
* property it carried is real and separate: a replayed device code must get
|
|
120
|
+
* nothing, or a code seen in a shell history is a second pairing.
|
|
121
|
+
*
|
|
122
|
+
* So the flag stays and the secret does not. Nulling a token to mean
|
|
123
|
+
* "collected" was one field doing two jobs, and only one of them was load
|
|
124
|
+
* bearing.
|
|
99
125
|
*/
|
|
100
|
-
readonly
|
|
126
|
+
readonly collected: boolean;
|
|
101
127
|
readonly label: string;
|
|
102
128
|
readonly platform: "darwin" | "linux" | "win32";
|
|
103
129
|
readonly daemonVersion: string;
|
|
@@ -113,14 +139,70 @@ interface PairingRecord {
|
|
|
113
139
|
readonly expiresAt: number;
|
|
114
140
|
readonly createdAt: number;
|
|
115
141
|
}
|
|
116
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
142
|
+
/**
|
|
143
|
+
* What the app supplies to enqueue a job.
|
|
144
|
+
*
|
|
145
|
+
* Generic over the kind, so the payload has to be the payload *for* that kind.
|
|
146
|
+
* These were independent — `kind: JobKind` beside `payload: JobPayload`, the
|
|
147
|
+
* union of both shapes — and the pairing was left to the author's memory. A
|
|
148
|
+
* chat job carrying a generate payload typechecked, built, shipped, and was
|
|
149
|
+
* refused at the relay's ingress with a precise sentence nobody sees until
|
|
150
|
+
* somebody clicks.
|
|
151
|
+
*
|
|
152
|
+
* `PayloadFor<K>` was already exported by the protocol when that happened, and
|
|
153
|
+
* `enqueue` did not use it. A wrong pairing is now a compile error at the call
|
|
154
|
+
* site, which is the only place that knows what it meant.
|
|
155
|
+
*
|
|
156
|
+
* A caller whose `kind` is a variable rather than a literal still gets the old
|
|
157
|
+
* permissive union — the conditional distributes — so nothing that was legal
|
|
158
|
+
* and correct stops compiling.
|
|
159
|
+
*/
|
|
160
|
+
interface EnqueueInput<K extends JobKind = JobKind> {
|
|
161
|
+
readonly kind: K;
|
|
119
162
|
/** The work, in plaintext. The server seals it before it is stored. */
|
|
120
|
-
readonly payload:
|
|
163
|
+
readonly payload: PayloadFor<K>;
|
|
121
164
|
readonly owner: string;
|
|
122
|
-
/**
|
|
165
|
+
/**
|
|
166
|
+
* Direct lane only. Refused on the cloud lane, where it is derived.
|
|
167
|
+
*
|
|
168
|
+
* On the cloud lane, who may serve a job comes from the person's own
|
|
169
|
+
* mapping — the service they chose, its owner, and that owner's offer scope
|
|
170
|
+
* — none of which a site is told, and all of which the hub holds at claim.
|
|
171
|
+
* A site declaring an audience there was a third vote cast by the one party
|
|
172
|
+
* the disclosure fence forbids from knowing the answer, and its `private`
|
|
173
|
+
* default silently disabled team sharing for every user who had a team.
|
|
174
|
+
*
|
|
175
|
+
* On the direct lane it still selects something real, which is why it stays
|
|
176
|
+
* rather than going in the same release: it is the switch that turns
|
|
177
|
+
* {@link EnqueueInput.audienceAllow} on. `private` is own-devices-only;
|
|
178
|
+
* `team` hands the decision to the allowlist. Without it there is no way to
|
|
179
|
+
* say "these runner owners, and no others", and supplier trust needs one.
|
|
180
|
+
*
|
|
181
|
+
* Defaults to `private` — the safe direction, and on this lane a direction
|
|
182
|
+
* a caller can meaningfully choose.
|
|
183
|
+
*/
|
|
123
184
|
readonly audience?: Audience;
|
|
185
|
+
/**
|
|
186
|
+
* Which of *your site's* declared purposes this job serves — Amendment L.
|
|
187
|
+
*
|
|
188
|
+
* **A need, never a name.** You declare purposes at registration —
|
|
189
|
+
* `"revenue"`, `"writing-assistant"` — and each of your users maps them to
|
|
190
|
+
* one of their own services on the consent screen. This field names the
|
|
191
|
+
* purpose; the mapping does the rest.
|
|
192
|
+
*
|
|
193
|
+
* There is no model field, no base URL, no flags, and — since Amendment L —
|
|
194
|
+
* no way to name a service either. Your vocabulary is your purposes; theirs
|
|
195
|
+
* is their services; the two never meet. You learn whether a slot was
|
|
196
|
+
* satisfiable and nothing else.
|
|
197
|
+
*
|
|
198
|
+
* Use the purpose **key**, not its label. Labels are prose for the consent
|
|
199
|
+
* screen and may change; a key travels on every job and is what mappings
|
|
200
|
+
* are stored against.
|
|
201
|
+
*
|
|
202
|
+
* Leave it out only in direct mode, which has no control plane to hold a
|
|
203
|
+
* mapping and answers by kind alone.
|
|
204
|
+
*/
|
|
205
|
+
readonly purpose?: string;
|
|
124
206
|
readonly audienceAllow?: readonly string[];
|
|
125
207
|
readonly dependsOn?: readonly string[];
|
|
126
208
|
/** Defaults to the server config's `defaultTtlMs`. */
|
|
@@ -228,7 +310,7 @@ interface JobStore {
|
|
|
228
310
|
/** Jobs a runner currently holds — used to build the heartbeat cancel list. */
|
|
229
311
|
listClaimedBy(runnerId: string): Promise<JobRecord[]>;
|
|
230
312
|
/** Jobs awaiting cancellation that a given runner holds. */
|
|
231
|
-
listCancelRequests(runnerId: string): Promise<
|
|
313
|
+
listCancelRequests(runnerId: string): Promise<LeaseRef[]>;
|
|
232
314
|
}
|
|
233
315
|
interface ClaimArgs {
|
|
234
316
|
readonly runnerId: string;
|
|
@@ -276,8 +358,13 @@ interface RenewResult {
|
|
|
276
358
|
jobId: string;
|
|
277
359
|
expiresAt: number;
|
|
278
360
|
}[];
|
|
279
|
-
/**
|
|
280
|
-
|
|
361
|
+
/**
|
|
362
|
+
* Grants the runner claimed to hold but no longer does — V1-3.
|
|
363
|
+
*
|
|
364
|
+
* Both halves, because a job id is chosen per site: a daemon serving two
|
|
365
|
+
* sites that picked the same id cannot act on a bare one without guessing.
|
|
366
|
+
*/
|
|
367
|
+
readonly lost: readonly LeaseRef[];
|
|
281
368
|
}
|
|
282
369
|
/**
|
|
283
370
|
* Which grant is being completed — the `LEASE_HONORED` guard, as a shape.
|
|
@@ -305,6 +392,20 @@ type CompleteHolder = {
|
|
|
305
392
|
};
|
|
306
393
|
interface CompleteArgs {
|
|
307
394
|
readonly jobId: string;
|
|
395
|
+
/**
|
|
396
|
+
* The authenticated caller — cloud_008 §3.6, and for the duplicate answer
|
|
397
|
+
* only.
|
|
398
|
+
*
|
|
399
|
+
* Never for authorisation: {@link CompleteHolder} decides whether this
|
|
400
|
+
* write may happen, and that has not changed. This decides which of two
|
|
401
|
+
* refusals a caller is owed once the write is refused.
|
|
402
|
+
*
|
|
403
|
+
* It exists because a *grant* is not a device. Scoping the duplicate answer
|
|
404
|
+
* to the lease id alone let a second daemon replay under a lease id it had
|
|
405
|
+
* learned and be told "already recorded" — which is both untrue and a
|
|
406
|
+
* terminality probe. Found by writing C010's third case.
|
|
407
|
+
*/
|
|
408
|
+
readonly runnerId: string;
|
|
308
409
|
/** Who claims to hold this job. Both variants are checked, never trusted. */
|
|
309
410
|
readonly holder: CompleteHolder;
|
|
310
411
|
readonly outcome: JobOutcome;
|
|
@@ -312,8 +413,22 @@ interface CompleteArgs {
|
|
|
312
413
|
readonly now: number;
|
|
313
414
|
}
|
|
314
415
|
interface CompleteResult {
|
|
315
|
-
/** False when this submission
|
|
416
|
+
/** False when this submission wrote nothing. */
|
|
316
417
|
readonly accepted: boolean;
|
|
418
|
+
/**
|
|
419
|
+
* True when the caller is the device that already recorded this result.
|
|
420
|
+
*
|
|
421
|
+
* cloud_008 §3.6. `RESULT_IDEMPOTENT` used to hold as a *side effect* of
|
|
422
|
+
* `LEASE_HONORED`: `complete` nulls the lease on success, so a replay was
|
|
423
|
+
* refused for not holding the job and the idempotency branch was never
|
|
424
|
+
* reached. Deleting that branch failed no check.
|
|
425
|
+
*
|
|
426
|
+
* byollm_009 §4's case for signing requests rather than issuing nonces
|
|
427
|
+
* rests on every write being idempotent per the instance it names. A MUST
|
|
428
|
+
* that another MUST's security argument leans on cannot be one that holds
|
|
429
|
+
* only because a null happened to trip first.
|
|
430
|
+
*/
|
|
431
|
+
readonly duplicate?: boolean;
|
|
317
432
|
readonly job: JobRecord | null;
|
|
318
433
|
}
|
|
319
434
|
interface ReleaseArgs {
|
|
@@ -337,7 +452,6 @@ interface RunnerStore {
|
|
|
337
452
|
denyPairing(userCode: string, now: number): Promise<void>;
|
|
338
453
|
/** Clear the one-shot token after the daemon collects it. */
|
|
339
454
|
consumePairingToken(deviceCodeHash: string): Promise<void>;
|
|
340
|
-
getRunnerByTokenHash(hash: string): Promise<RunnerRecord | null>;
|
|
341
455
|
getRunner(runnerId: string): Promise<RunnerRecord | null>;
|
|
342
456
|
/** Record a heartbeat: capabilities, version, pause state, liveness. */
|
|
343
457
|
touchRunner(args: TouchArgs): Promise<RunnerRecord | null>;
|
|
@@ -370,8 +484,6 @@ interface ApproveArgs {
|
|
|
370
484
|
/** From the approving user's session, never from the daemon. */
|
|
371
485
|
readonly owner: string;
|
|
372
486
|
readonly runnerId: string;
|
|
373
|
-
readonly runnerToken: string;
|
|
374
|
-
readonly tokenHash: string;
|
|
375
487
|
readonly now: number;
|
|
376
488
|
}
|
|
377
489
|
interface TouchArgs {
|
package/dist/supabase/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
-
import { B as ByollmStore } from '../store-
|
|
3
|
-
import { P as PollingDeliveryDeps, R as ResultDelivery } from '../delivery-
|
|
2
|
+
import { B as ByollmStore } from '../store-Cx2_bck1.js';
|
|
3
|
+
import { P as PollingDeliveryDeps, R as ResultDelivery } from '../delivery-CaGbp0Tc.js';
|
|
4
4
|
import '@byollm/protocol';
|
|
5
5
|
|
|
6
6
|
/**
|
package/dist/supabase/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
NoRunnerAvailableError,
|
|
3
|
-
ResultTimeoutError
|
|
4
|
-
|
|
3
|
+
ResultTimeoutError,
|
|
4
|
+
labelFallback
|
|
5
|
+
} from "../chunk-I3ER27QG.js";
|
|
5
6
|
|
|
6
7
|
// src/supabase/realtime.ts
|
|
7
8
|
var DEFAULT_TIMEOUT_MS = 5 * 6e4;
|
|
@@ -21,7 +22,7 @@ var SupabaseRealtimeDelivery = class {
|
|
|
21
22
|
const current = await this.#deps.read(jobId);
|
|
22
23
|
if (current && isTerminal(current.state)) return current;
|
|
23
24
|
const settled = Promise.withResolvers();
|
|
24
|
-
this.#
|
|
25
|
+
this.#resolvers.set(jobId, settled.resolve);
|
|
25
26
|
const channel = this.#client.channel(`byollm_job_${jobId}`).on(
|
|
26
27
|
"postgres_changes",
|
|
27
28
|
{
|
|
@@ -49,22 +50,46 @@ var SupabaseRealtimeDelivery = class {
|
|
|
49
50
|
} finally {
|
|
50
51
|
clearTimeout(timer);
|
|
51
52
|
clearInterval(watcher);
|
|
53
|
+
this.#resolvers.delete(jobId);
|
|
52
54
|
options.signal?.removeEventListener("abort", abort);
|
|
53
55
|
await this.#client.removeChannel(channel);
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
|
-
|
|
58
|
+
/**
|
|
59
|
+
* One resolver per job in flight — P0, 2026-09-02.
|
|
60
|
+
*
|
|
61
|
+
* This was a single field, `#resolve`, assigned by every `waitFor`. One
|
|
62
|
+
* delivery object serves a whole app, so two concurrent waits meant the
|
|
63
|
+
* second assignment clobbered the first, and then:
|
|
64
|
+
*
|
|
65
|
+
* 1. `waitFor(A)` sets the resolver.
|
|
66
|
+
* 2. `waitFor(B)` overwrites it.
|
|
67
|
+
* 3. A's row event arrives, `#check(A)` reads A's result — and resolves
|
|
68
|
+
* **B's** promise with it.
|
|
69
|
+
* 4. A never resolves and waits out its timeout.
|
|
70
|
+
*
|
|
71
|
+
* So an app awaiting two jobs at once got one answer under the wrong job
|
|
72
|
+
* id, with the wrong text, silently — and a spurious timeout beside it. No
|
|
73
|
+
* error anywhere; the failure is that the caller believes it.
|
|
74
|
+
*
|
|
75
|
+
* A map, and the entry is removed in the same `finally` that tears down the
|
|
76
|
+
* channel. A resolver that outlived its wait would be a leak that also
|
|
77
|
+
* resolves a promise nobody is holding.
|
|
78
|
+
*/
|
|
79
|
+
#resolvers = /* @__PURE__ */ new Map();
|
|
57
80
|
async #check(jobId) {
|
|
58
81
|
const current = await this.#deps.read(jobId);
|
|
59
|
-
if (current && isTerminal(current.state))
|
|
82
|
+
if (current && isTerminal(current.state)) {
|
|
83
|
+
this.#resolvers.get(jobId)?.(current);
|
|
84
|
+
}
|
|
60
85
|
}
|
|
61
86
|
/** Poll runner liveness; there is no row event for "nothing is happening". */
|
|
62
87
|
#watchAvailability(jobId, options, settled) {
|
|
63
88
|
let noRunnerSince = null;
|
|
64
89
|
return setInterval(() => {
|
|
65
90
|
(async () => {
|
|
66
|
-
const availability = await this.#deps.availability(jobId);
|
|
67
|
-
if (availability.available || availability.blocked) {
|
|
91
|
+
const availability = await this.#deps.availability?.(jobId);
|
|
92
|
+
if (availability === void 0 || availability.available || availability.blocked) {
|
|
68
93
|
noRunnerSince = null;
|
|
69
94
|
return;
|
|
70
95
|
}
|
|
@@ -72,8 +97,8 @@ var SupabaseRealtimeDelivery = class {
|
|
|
72
97
|
if (Date.now() - noRunnerSince < NO_RUNNER_GRACE_MS) return;
|
|
73
98
|
const reason = availability.reason ?? "no-runner-online";
|
|
74
99
|
const substitute = await options.onNoRunner?.(reason);
|
|
75
|
-
if (substitute) {
|
|
76
|
-
settled.resolve(substitute);
|
|
100
|
+
if (substitute !== void 0) {
|
|
101
|
+
settled.resolve(labelFallback(jobId, substitute));
|
|
77
102
|
} else {
|
|
78
103
|
settled.reject(new NoRunnerAvailableError(jobId, reason));
|
|
79
104
|
}
|
|
@@ -96,10 +121,12 @@ function toJob(row) {
|
|
|
96
121
|
envelope: row.envelope,
|
|
97
122
|
sizeClass: row.size_class,
|
|
98
123
|
audience: row.audience,
|
|
124
|
+
purpose: row.purpose ?? void 0,
|
|
99
125
|
owner: row.owner,
|
|
100
126
|
audienceAllow: row.audience_allow ?? void 0,
|
|
101
127
|
dependsOn: row.depends_on,
|
|
102
128
|
state: row.state,
|
|
129
|
+
completedByLeaseId: row.completed_by_lease_id ?? null,
|
|
103
130
|
lease: (
|
|
104
131
|
// Keyed on the lease id, not the runner. A relayed grant has no runner
|
|
105
132
|
// row to point at (see AdoptArgs), and reading the lease as absent
|
|
@@ -127,7 +154,6 @@ function toRunner(row) {
|
|
|
127
154
|
return {
|
|
128
155
|
id: row.id,
|
|
129
156
|
owner: row.owner,
|
|
130
|
-
tokenHash: row.token_hash,
|
|
131
157
|
label: row.label,
|
|
132
158
|
platform: row.platform,
|
|
133
159
|
daemonVersion: row.daemon_version,
|
|
@@ -146,7 +172,10 @@ function toPairing(row) {
|
|
|
146
172
|
state: row.state,
|
|
147
173
|
owner: row.owner,
|
|
148
174
|
runnerId: row.runner_id,
|
|
149
|
-
|
|
175
|
+
// Collected when it has a timestamp. This was `runner_token_once ===
|
|
176
|
+
// null` — a nulled token standing in for a fact about delivery, which is
|
|
177
|
+
// one field doing two jobs (cloud_008 §2.4a).
|
|
178
|
+
collected: row.collected_at !== null,
|
|
150
179
|
label: row.label,
|
|
151
180
|
platform: row.platform,
|
|
152
181
|
daemonVersion: row.daemon_version,
|
|
@@ -187,7 +216,8 @@ function supabaseStore(options) {
|
|
|
187
216
|
kind: input.kind,
|
|
188
217
|
envelope: input.envelope,
|
|
189
218
|
size_class: input.sizeClass,
|
|
190
|
-
audience: input.audience ?? "
|
|
219
|
+
audience: input.audience ?? "private",
|
|
220
|
+
purpose: input.purpose ?? null,
|
|
191
221
|
owner: input.owner,
|
|
192
222
|
audience_allow: input.audienceAllow ? [...input.audienceAllow] : null,
|
|
193
223
|
depends_on: dependsOn,
|
|
@@ -238,8 +268,9 @@ function supabaseStore(options) {
|
|
|
238
268
|
jobId: row.id,
|
|
239
269
|
expiresAt: args.now + args.leaseMs
|
|
240
270
|
})),
|
|
241
|
-
// Anything the runner thinks it holds but did not renew is gone
|
|
242
|
-
|
|
271
|
+
// Anything the runner thinks it holds but did not renew is gone,
|
|
272
|
+
// named by the grant it asked about rather than by a bare id — V1-3.
|
|
273
|
+
lost: args.leases.filter((lease) => !renewedIds.has(lease.jobId))
|
|
243
274
|
};
|
|
244
275
|
},
|
|
245
276
|
async adopt(args) {
|
|
@@ -265,6 +296,8 @@ function supabaseStore(options) {
|
|
|
265
296
|
state,
|
|
266
297
|
lease_runner: null,
|
|
267
298
|
lease_expires_at: null,
|
|
299
|
+
// Which grant recorded it, kept after the lease is dropped — §3.6.
|
|
300
|
+
completed_by_lease_id: args.holder.by === "lease" ? args.holder.leaseId : null,
|
|
268
301
|
outcome: args.outcome,
|
|
269
302
|
provenance: args.provenance,
|
|
270
303
|
updated_at: iso(args.now)
|
|
@@ -276,10 +309,9 @@ function supabaseStore(options) {
|
|
|
276
309
|
const current = unwrapMaybe(
|
|
277
310
|
await db.from("byollm_jobs").select().eq("id", args.jobId).maybeSingle()
|
|
278
311
|
);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
};
|
|
312
|
+
const job = current === null ? null : toJob(current);
|
|
313
|
+
const duplicate = job !== null && job.provenance?.runnerId === args.runnerId && (job.state === "ok" || job.state === "error" || job.state === "canceled") && args.holder.by === "lease" && job.completedByLeaseId !== null && job.completedByLeaseId === args.holder.leaseId;
|
|
314
|
+
return duplicate ? { accepted: false, duplicate: true, job } : { accepted: false, job };
|
|
283
315
|
}
|
|
284
316
|
return { accepted: true, job: toJob(written) };
|
|
285
317
|
},
|
|
@@ -337,9 +369,12 @@ function supabaseStore(options) {
|
|
|
337
369
|
},
|
|
338
370
|
async listCancelRequests(runnerId) {
|
|
339
371
|
const rows = unwrap(
|
|
340
|
-
await db.from("byollm_job_cancels").select("job_id, byollm_jobs!inner(lease_runner)").eq("byollm_jobs.lease_runner", runnerId)
|
|
372
|
+
await db.from("byollm_job_cancels").select("job_id, byollm_jobs!inner(lease_runner, lease_id)").eq("byollm_jobs.lease_runner", runnerId)
|
|
341
373
|
);
|
|
342
|
-
return rows.
|
|
374
|
+
return rows.filter((row) => row.byollm_jobs.lease_id !== null).map((row) => ({
|
|
375
|
+
jobId: row.job_id,
|
|
376
|
+
leaseId: row.byollm_jobs.lease_id ?? ""
|
|
377
|
+
}));
|
|
343
378
|
},
|
|
344
379
|
// -- pairing and runners -------------------------------------------------
|
|
345
380
|
/**
|
|
@@ -408,7 +443,6 @@ function supabaseStore(options) {
|
|
|
408
443
|
const runner = unwrap(
|
|
409
444
|
await db.from("byollm_runners").insert({
|
|
410
445
|
owner: args.owner,
|
|
411
|
-
token_hash: args.tokenHash,
|
|
412
446
|
label: pairing.label,
|
|
413
447
|
platform: pairing.platform,
|
|
414
448
|
daemon_version: pairing.daemon_version,
|
|
@@ -425,7 +459,12 @@ function supabaseStore(options) {
|
|
|
425
459
|
state: "approved",
|
|
426
460
|
owner: args.owner,
|
|
427
461
|
runner_id: runner.id,
|
|
428
|
-
|
|
462
|
+
// Marks the approval collectable — cloud_008 §2.4. The column held
|
|
463
|
+
// a bearer token; it now holds a marker, and the next migration
|
|
464
|
+
// renames it. Written as a constant rather than left null because
|
|
465
|
+
// `collected` reads `=== null`, and a schema change and a code
|
|
466
|
+
// change landing in one step is how a rollback strands rows.
|
|
467
|
+
collected_at: null
|
|
429
468
|
}).eq("device_code_hash", pairing.device_code_hash);
|
|
430
469
|
if (error) throw new Error(`supabase: ${error.message}`);
|
|
431
470
|
return toRunner(runner);
|
|
@@ -435,15 +474,9 @@ function supabaseStore(options) {
|
|
|
435
474
|
if (error) throw new Error(`supabase: ${error.message}`);
|
|
436
475
|
},
|
|
437
476
|
async consumePairingToken(deviceCodeHash) {
|
|
438
|
-
const { error } = await db.from("byollm_pairings").update({
|
|
477
|
+
const { error } = await db.from("byollm_pairings").update({ collected_at: "now()" }).eq("device_code_hash", deviceCodeHash);
|
|
439
478
|
if (error) throw new Error(`supabase: ${error.message}`);
|
|
440
479
|
},
|
|
441
|
-
async getRunnerByTokenHash(hash) {
|
|
442
|
-
const row = unwrapMaybe(
|
|
443
|
-
await db.from("byollm_runners").select().eq("token_hash", hash).maybeSingle()
|
|
444
|
-
);
|
|
445
|
-
return row === null ? null : toRunner(row);
|
|
446
|
-
},
|
|
447
480
|
async getRunner(runnerId) {
|
|
448
481
|
const row = unwrapMaybe(
|
|
449
482
|
await db.from("byollm_runners").select().eq("id", runnerId).maybeSingle()
|