@byollm/server 0.1.0-alpha.3 → 0.1.0-alpha.31

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/index.js CHANGED
@@ -1,17 +1,19 @@
1
1
  import {
2
2
  ByollmHandlers,
3
3
  SERVED_PROTOCOL_VERSION,
4
- bearerFrom,
5
4
  createFetchHandler,
5
+ deadlineFor,
6
6
  generateDeviceCode,
7
7
  generateJobId,
8
+ generateLeaseId,
8
9
  generateRunnerId,
9
- generateRunnerToken,
10
10
  generateUserCode,
11
11
  hashSecret,
12
+ resealForDevice,
12
13
  routeEndpoint,
13
- secretsMatch
14
- } from "./chunk-HL6EYHQ7.js";
14
+ secretsMatch,
15
+ signatureFrom
16
+ } from "./chunk-K5E6JS5A.js";
15
17
  import {
16
18
  NoRunnerAvailableError,
17
19
  PollingDelivery,
@@ -20,19 +22,355 @@ import {
20
22
 
21
23
  // src/app.ts
22
24
  import {
25
+ ENVELOPE_MAX_AGE_MS,
26
+ KindedPayload,
27
+ keyId as keyId2,
28
+ payloadTextLength,
29
+ publicIdentityOf as publicIdentityOf2,
30
+ seal,
31
+ sizeClassOf,
23
32
  backendDescriptor,
24
33
  matchAudience
25
34
  } from "@byollm/protocol";
35
+
36
+ // src/cloud.ts
37
+ import {
38
+ PROTOCOL_VERSION,
39
+ SealedOutcome,
40
+ keyId,
41
+ open,
42
+ publicIdentityOf,
43
+ provenanceFor,
44
+ signSiteRequest
45
+ } from "@byollm/protocol";
46
+ var RelayUnavailable = class extends Error {
47
+ retryable;
48
+ /** The protocol's own code, when the relay sent one. */
49
+ code;
50
+ constructor(message, retryable, code) {
51
+ super(message);
52
+ this.name = "RelayUnavailable";
53
+ this.retryable = retryable;
54
+ this.code = code;
55
+ }
56
+ };
57
+ var CloudLane = class {
58
+ #options;
59
+ #store;
60
+ #siteKeys;
61
+ #now;
62
+ #fetch;
63
+ constructor(deps) {
64
+ this.#options = deps.options;
65
+ this.#store = deps.store;
66
+ this.#siteKeys = deps.siteKeys;
67
+ this.#now = deps.now;
68
+ this.#fetch = deps.options.fetch ?? globalThis.fetch;
69
+ }
70
+ /**
71
+ * Publish a job's stub for routing.
72
+ *
73
+ * The stub and nothing else — byollm_009 §6 makes that exhaustive by
74
+ * construction, so this cannot leak a payload even by mistake: there is no
75
+ * field on `JobStub` to put one in.
76
+ */
77
+ async publish(record) {
78
+ const stub = {
79
+ id: record.id,
80
+ kind: record.kind,
81
+ owner: record.owner,
82
+ // This site, by its identity key id — Amendment A §A.3. The relay
83
+ // already knows which site it is routing for, so this discloses nothing
84
+ // new to it; what it adds is that the *daemon* can check the stub
85
+ // against the envelope's `senderKeyId` without asking the relay.
86
+ site: keyId(publicIdentityOf(this.#siteKeys).identity),
87
+ audience: record.audience,
88
+ // `audienceAllow` is deliberately **not** published — cloud_008 §0.2.
89
+ //
90
+ // It is a list of the people who may run this job, and on the direct
91
+ // plane that is unremarkable: the site authored the list and the site is
92
+ // the upstream, so the party receiving it already has it. Through a
93
+ // relay it is a third party, and byollm_009 §6's enumerated metadata —
94
+ // "exhaustive and normative… what an upstream can see, stated as a
95
+ // commitment" — does not include it. It was reaching the relay on every
96
+ // named-audience job.
97
+ //
98
+ // Nothing is lost by withholding it, which is why this is a Tier 0 fix
99
+ // rather than a trade. `matchAudience` treats it as a *narrowing*:
100
+ // `job.audienceAllow !== undefined && !includes(daemon.owner)` refuses,
101
+ // and its absence simply falls through to the checks that actually
102
+ // enforce — the daemon's own allowlist (`NAMED_LOCAL_ALLOWLIST`) and the
103
+ // backend's offer scope. On this lane the relay narrows too, from the
104
+ // control plane's rosters. The enforcement was never here.
105
+ sizeClass: record.sizeClass,
106
+ streaming: false,
107
+ // The relay needs *a* deadline to bound routing. A job without one gets
108
+ // the envelope's, which is the outer bound on how long the ciphertext
109
+ // is worth carrying — never longer than the work could possibly matter.
110
+ // The same fallback the direct plane uses — cloud_008 Tier 4, finding
111
+ // 31. This said `createdAt + ENVELOPE_TTL_FALLBACK`, a local constant
112
+ // whose value happened to equal `ENVELOPE_MAX_AGE_MS`; the direct plane
113
+ // said `(claimableAt ?? now) + ttlMs`. One field, two meanings, and a
114
+ // job that was blocked on a dependency got a deadline measured from
115
+ // when it was *created* on one lane and from when it became *claimable*
116
+ // on the other.
117
+ deadlineAt: deadlineFor(record, this.#now())
118
+ };
119
+ await this.#post("enqueue", {
120
+ siteId: this.#options.siteId,
121
+ stub
122
+ });
123
+ }
124
+ /**
125
+ * Withdraw a job at the relay — cloud_008 §2.2.
126
+ *
127
+ * `app.cancel()` marks the site's own row terminal, which stops the *next*
128
+ * seal. It cannot stop a device that is already running the work, because
129
+ * on this lane the site is not the upstream: only the relay talks to the
130
+ * daemon, and it answered `cancel: []` unconditionally.
131
+ *
132
+ * So the cancellation has to travel. The relay marks the job, stops
133
+ * offering it, and names it to the holding device at its next heartbeat —
134
+ * the same path the direct plane has always had, arriving one hop later.
135
+ */
136
+ async cancel(jobId) {
137
+ await this.#post("cancel", { siteId: this.#options.siteId, jobId });
138
+ }
139
+ /**
140
+ * One cycle: seal for anything claimed, collect anything finished.
141
+ *
142
+ * Idempotent and safe to call as often as you like. Exposed as a single
143
+ * cycle rather than hidden behind a timer so a caller decides its own
144
+ * cadence — a serverless site runs it on a cron, a long-lived one on an
145
+ * interval, and a test runs it exactly when it means to.
146
+ */
147
+ async pump() {
148
+ const sealed = [];
149
+ const refused = [];
150
+ const completed = [];
151
+ try {
152
+ return await this.#cycle(sealed, refused, completed);
153
+ } catch (error) {
154
+ if (error instanceof RelayUnavailable && error.retryable) {
155
+ return { sealed, completed, refused, deferred: error.message };
156
+ }
157
+ throw error;
158
+ }
159
+ }
160
+ async #cycle(sealed, refused, completed) {
161
+ const pending = await this.#get("pending");
162
+ for (const claim of pending.jobs) {
163
+ const record = await this.#store.get(claim.jobId);
164
+ if (!record) continue;
165
+ const resealed = await resealForDevice({
166
+ siteKeys: this.#siteKeys,
167
+ job: {
168
+ id: record.id,
169
+ envelope: record.envelope,
170
+ createdAt: record.createdAt
171
+ },
172
+ device: claim.device
173
+ });
174
+ if (!resealed.ok) {
175
+ refused.push(claim.jobId);
176
+ continue;
177
+ }
178
+ const adopted = await this.#store.adopt({
179
+ jobId: claim.jobId,
180
+ leaseId: claim.leaseId,
181
+ expiresAt: claim.leaseExpiresAt,
182
+ now: this.#now()
183
+ });
184
+ if (!adopted) {
185
+ refused.push(claim.jobId);
186
+ continue;
187
+ }
188
+ await this.#post("payload", {
189
+ siteId: this.#options.siteId,
190
+ jobId: claim.jobId,
191
+ envelope: resealed.envelope
192
+ });
193
+ sealed.push(claim.jobId);
194
+ }
195
+ const finished = await this.#get("results");
196
+ for (const done of finished.jobs) {
197
+ const record = await this.#store.get(done.jobId);
198
+ if (!record || record.state === "ok" || record.state === "error") {
199
+ continue;
200
+ }
201
+ const outcome = await this.#openResult(done);
202
+ if (!outcome) {
203
+ refused.push(done.jobId);
204
+ continue;
205
+ }
206
+ await this.#store.complete({
207
+ jobId: done.jobId,
208
+ // The relay named the device; the signature above proved it — §3.6.
209
+ runnerId: done.runnerId,
210
+ // The grant, not the machine: this site never paired with the device
211
+ // that ran it, and the signature it verified above is the stronger
212
+ // claim about who did.
213
+ holder: { by: "lease", leaseId: done.leaseId },
214
+ outcome: outcome.outcome,
215
+ provenance: provenanceFor({
216
+ audience: record.audience,
217
+ runnerId: done.runnerId,
218
+ // The owner, from the relay's own record of who claimed it — not a
219
+ // key id. cloud_008 §2.5: this said `keyId(device.identity)`, which
220
+ // put a key id where the direct plane puts a user id, so an app
221
+ // comparing provenance across lanes compared two namespaces and got
222
+ // `false` for the same person. The device's key is still what the
223
+ // signature was verified against, above; that is a different
224
+ // question from whose machine it is.
225
+ runnerOwner: done.runnerOwner,
226
+ // From the envelope, not invented — cloud_008 §2.5. These were
227
+ // hardcoded `"http"` and `"unknown"` because the daemon's declared
228
+ // values stopped at the relay, which is right: a blind relay acts
229
+ // on neither. Sealing them carries them past it untouched.
230
+ backendClass: outcome.ran.backendClass,
231
+ model: outcome.ran.model
232
+ }),
233
+ now: this.#now()
234
+ });
235
+ completed.push(done.jobId);
236
+ }
237
+ return { sealed, completed, refused };
238
+ }
239
+ /**
240
+ * Open a sealed result and verify it came from the device that claimed it.
241
+ *
242
+ * The relay says which device ran the job; this checks that claim against a
243
+ * signature the relay cannot produce. A relay that named the wrong device
244
+ * gets a refusal, not a stored result — which is what keeps `RELAY_BLIND`
245
+ * from quietly becoming `RELAY_TRUSTED`.
246
+ */
247
+ async #openResult(done) {
248
+ const opened = await open({
249
+ envelope: done.envelope,
250
+ recipientKeys: this.#siteKeys,
251
+ senderIdentityPublic: done.device.identity,
252
+ expected: {
253
+ jobId: done.jobId,
254
+ senderKeyId: keyId(done.device.identity),
255
+ recipientKeyId: keyId(publicIdentityOf(this.#siteKeys).identity),
256
+ direction: "result"
257
+ }
258
+ });
259
+ if (!opened.ok) return null;
260
+ let parsed;
261
+ try {
262
+ parsed = JSON.parse(opened.plaintext);
263
+ } catch {
264
+ return null;
265
+ }
266
+ const sealed = SealedOutcome.safeParse(parsed);
267
+ if (!sealed.success) return null;
268
+ if (sealed.data.outcome.outcome !== done.disposition) return null;
269
+ return sealed.data;
270
+ }
271
+ /**
272
+ * Sign a site-plane call with this site's identity key.
273
+ *
274
+ * The same scheme the daemon uses against an upstream, because the site is
275
+ * in the same position: an outbound caller whose key the relay already holds
276
+ * for other reasons. Nothing else authenticates this plane — a relay that
277
+ * took the `siteId` in a body at face value would let anyone enqueue work in
278
+ * a site's name and read who claimed it.
279
+ */
280
+ #headers(endpoint, rawBody) {
281
+ const signature = signSiteRequest(this.#siteKeys, {
282
+ endpoint,
283
+ siteId: this.#options.siteId,
284
+ issuedAt: this.#now(),
285
+ body: rawBody
286
+ });
287
+ return {
288
+ "x-byollm-site": this.#options.siteId,
289
+ "x-byollm-issued-at": String(signature.issuedAt),
290
+ "x-byollm-signature": signature.signature
291
+ };
292
+ }
293
+ /**
294
+ * A relay answer, checked before it is believed — alpha.31.
295
+ *
296
+ * The bug this closes is one line long and its shape is general: a response
297
+ * body used without looking at the status. The daemon's client has always
298
+ * done this properly (`client.ts` maps every status to a typed refusal); the
299
+ * site's lane parsed JSON and hoped.
300
+ *
301
+ * Two classes, because they need opposite handling. **Retryable** — 503 from
302
+ * a draining pod, 429, 5xx, and the protocol's own `not-ready` — means the
303
+ * work is still there and this cycle should end quietly. **Refused** — a bad
304
+ * signature, an unknown site, a version this relay does not speak — will
305
+ * still be true in five seconds, and swallowing it would leave a site
306
+ * silently disconnected from its own users.
307
+ */
308
+ async #answer(response, endpoint) {
309
+ if (response.ok) return response.json();
310
+ let code = "";
311
+ let message;
312
+ try {
313
+ const body = await response.json();
314
+ code = body.error ?? "";
315
+ message = body.message ?? "";
316
+ } catch {
317
+ message = `HTTP ${String(response.status)}`;
318
+ }
319
+ const retryable = response.status >= 500 || response.status === 429 || code === "not-ready" || code === "server-error";
320
+ throw new RelayUnavailable(
321
+ `${endpoint}: ${code || "refused"} \u2014 ${message}`,
322
+ retryable,
323
+ code
324
+ );
325
+ }
326
+ async #post(endpoint, body) {
327
+ const rawBody = JSON.stringify({
328
+ protocolVersion: PROTOCOL_VERSION,
329
+ ...body
330
+ });
331
+ const response = await this.#fetch(
332
+ `${this.#options.relayOrigin}/relay/site/${endpoint}`,
333
+ {
334
+ method: "POST",
335
+ headers: {
336
+ "content-type": "application/json",
337
+ ...this.#headers(endpoint, rawBody)
338
+ },
339
+ body: rawBody
340
+ }
341
+ );
342
+ return this.#answer(response, endpoint);
343
+ }
344
+ async #get(endpoint) {
345
+ const url = `${this.#options.relayOrigin}/relay/site/${endpoint}?siteId=${encodeURIComponent(this.#options.siteId)}&protocolVersion=${encodeURIComponent(PROTOCOL_VERSION)}`;
346
+ const response = await this.#fetch(url, {
347
+ headers: this.#headers(endpoint, "")
348
+ });
349
+ return this.#answer(response, endpoint);
350
+ }
351
+ };
352
+
353
+ // src/app.ts
26
354
  var DEFAULT_LIVENESS_MS = 35e3;
27
355
  var ByollmApp = class {
28
356
  #store;
357
+ #siteKeys;
29
358
  #now;
30
359
  #livenessMs;
31
360
  #delivery;
361
+ /** Present only in the cloud lane; the site's side of the relay. */
362
+ cloud;
32
363
  constructor(options) {
33
364
  this.#store = options.store;
365
+ this.#siteKeys = options.siteKeys;
34
366
  this.#now = options.now ?? Date.now;
35
367
  this.#livenessMs = options.livenessMs ?? DEFAULT_LIVENESS_MS;
368
+ this.cloud = options.lane === void 0 ? void 0 : new CloudLane({
369
+ options: options.lane,
370
+ store: options.store,
371
+ siteKeys: options.siteKeys,
372
+ now: this.#now
373
+ });
36
374
  const deps = {
37
375
  ...options.noRunnerGraceMs === void 0 ? {} : { graceMs: options.noRunnerGraceMs },
38
376
  read: (jobId) => this.result(jobId),
@@ -66,7 +404,45 @@ var ByollmApp = class {
66
404
  * the app is obliged to disclose that to whoever reads it.
67
405
  */
68
406
  async enqueue(input) {
69
- const record = await this.#store.create(input, this.#now());
407
+ const parsed = KindedPayload.safeParse({
408
+ kind: input.kind,
409
+ payload: input.payload
410
+ });
411
+ if (!parsed.success) {
412
+ const detail = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
413
+ throw new Error(`invalid ${input.kind} payload \u2014 ${detail}`);
414
+ }
415
+ const createdAt = this.#now();
416
+ const envelopeDeadlineAt = createdAt + ENVELOPE_MAX_AGE_MS;
417
+ const jobId = input.id ?? generateJobId();
418
+ const senderKeyId = keyId2(publicIdentityOf2(this.#siteKeys).identity);
419
+ const envelope = await seal({
420
+ plaintext: JSON.stringify(parsed.data.payload),
421
+ senderKeys: this.#siteKeys,
422
+ recipientEncryptionPublic: this.#siteKeys.encryptionPublic,
423
+ context: {
424
+ jobId,
425
+ senderKeyId,
426
+ recipientKeyId: senderKeyId,
427
+ deadlineAt: envelopeDeadlineAt,
428
+ direction: "payload"
429
+ }
430
+ });
431
+ const record = await this.#store.create(
432
+ {
433
+ ...input,
434
+ id: jobId,
435
+ envelope,
436
+ sizeClass: sizeClassOf(
437
+ payloadTextLength({
438
+ kind: input.kind,
439
+ payload: parsed.data.payload
440
+ })
441
+ )
442
+ },
443
+ createdAt
444
+ );
445
+ await this.cloud?.publish(record);
70
446
  return {
71
447
  id: record.id,
72
448
  record,
@@ -87,7 +463,7 @@ var ByollmApp = class {
87
463
  * Check `provenance.untrusted` before rendering. It is true for every
88
464
  * `named`/`public` job, because that text came from someone else's machine
89
465
  * and the app must not present it as its own AI's answer
90
- * ({@link MUSTS.RESULT_PROVENANCE}).
466
+ * ({@link MUSTS.PROVENANCE_NAMES_DEVICE}).
91
467
  */
92
468
  async result(jobId) {
93
469
  const job = await this.job(jobId);
@@ -101,7 +477,11 @@ var ByollmApp = class {
101
477
  }
102
478
  /** Ask a runner to stop. Queued jobs cancel at once; held jobs at the next heartbeat. */
103
479
  async cancel(jobId) {
104
- return this.#store.cancel(jobId, this.#now());
480
+ const cancelled = await this.#store.cancel(jobId, this.#now());
481
+ if (cancelled && this.cloud) {
482
+ await this.cloud.cancel(jobId).catch(() => void 0);
483
+ }
484
+ return cancelled;
105
485
  }
106
486
  /**
107
487
  * Is there a live runner that could take a job of this shape?
@@ -179,13 +559,10 @@ var ByollmApp = class {
179
559
  * ({@link MUSTS.PAIR_ONE_USER}, {@link MUSTS.PAIR_INTERACTIVE}).
180
560
  */
181
561
  async approvePairing(args) {
182
- const token = generateRunnerToken();
183
562
  return this.#store.approvePairing({
184
563
  userCode: normalizeUserCode(args.userCode),
185
564
  owner: args.owner,
186
565
  runnerId: generateRunnerId(),
187
- runnerToken: token,
188
- tokenHash: hashSecret(token),
189
566
  now: this.#now()
190
567
  });
191
568
  }
@@ -229,6 +606,45 @@ function normalizeUserCode(input) {
229
606
  return bare.length === 8 ? `${bare.slice(0, 4)}-${bare.slice(4)}` : bare;
230
607
  }
231
608
 
609
+ // src/keys.ts
610
+ import { StoredKeys, generateKeys, publicIdentityOf as publicIdentityOf3 } from "@byollm/protocol";
611
+ import { fingerprint } from "@byollm/protocol";
612
+ var generateSiteKeys = (now = Date.now()) => generateKeys(now);
613
+ function siteKeysFromEnv(variable = "BYOLLM_SITE_KEYS", env = process.env) {
614
+ const raw = env[variable];
615
+ if (raw === void 0 || raw === "") {
616
+ throw new Error(
617
+ `${variable} is not set. Generate a site identity once with \`npx @byollm/server keygen\` and set it as ${variable}. Do not generate keys at startup: every instance would get a different identity and daemons would pin one and be refused by another.`
618
+ );
619
+ }
620
+ let parsed;
621
+ try {
622
+ parsed = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
623
+ } catch {
624
+ throw new Error(
625
+ `${variable} is not base64-encoded JSON. It should be exactly what \`npx @byollm/server keygen\` printed.`
626
+ );
627
+ }
628
+ const result = StoredKeys.safeParse(parsed);
629
+ if (!result.success) {
630
+ throw new Error(
631
+ `${variable} does not contain a valid site identity. Regenerate it with \`npx @byollm/server keygen\` \u2014 and if this site has already paired daemons, they will need to pair again.`
632
+ );
633
+ }
634
+ return result.data;
635
+ }
636
+ function formatSiteKeys(keys) {
637
+ const encoded = Buffer.from(JSON.stringify(keys)).toString("base64");
638
+ return `# Set this as BYOLLM_SITE_KEYS. It is a secret: it is this site's
639
+ # identity, and anything holding it can be this site.
640
+ BYOLLM_SITE_KEYS=${encoded}
641
+
642
+ # Fingerprint (not secret \u2014 show it to users so they can check what
643
+ # their daemon pinned):
644
+ # ${fingerprint(publicIdentityOf3(keys).identity)}
645
+ `;
646
+ }
647
+
232
648
  // src/memory.ts
233
649
  import {
234
650
  backendDescriptor as backendDescriptor2,
@@ -247,7 +663,7 @@ var MemoryStore = class {
247
663
  }
248
664
  // -- jobs ---------------------------------------------------------------
249
665
  create(input, now) {
250
- const id = input.id ?? generateJobId();
666
+ const id = input.id;
251
667
  const existing = this.#jobs.get(id);
252
668
  if (existing) return Promise.resolve(existing);
253
669
  const dependsOn = [...input.dependsOn ?? []];
@@ -257,13 +673,15 @@ var MemoryStore = class {
257
673
  const job = {
258
674
  id,
259
675
  kind: input.kind,
260
- payload: input.payload,
676
+ envelope: input.envelope,
677
+ sizeClass: input.sizeClass,
261
678
  audience: input.audience ?? "self",
262
679
  owner: input.owner,
263
680
  audienceAllow: input.audienceAllow ? [...input.audienceAllow] : void 0,
264
681
  dependsOn,
265
682
  state: "queued",
266
683
  lease: null,
684
+ completedByLeaseId: null,
267
685
  createdAt: now,
268
686
  // The TTL clock starts here only if nothing blocks the job.
269
687
  claimableAt: blocked ? null : now,
@@ -275,7 +693,7 @@ var MemoryStore = class {
275
693
  provenance: null,
276
694
  updatedAt: now
277
695
  };
278
- this.#jobs.set(id, job);
696
+ this.#write(id, job);
279
697
  return Promise.resolve(job);
280
698
  }
281
699
  get(jobId) {
@@ -294,13 +712,16 @@ var MemoryStore = class {
294
712
  ...job,
295
713
  state: "claimed",
296
714
  lease: {
715
+ // A fresh id per grant. Two claims of the same job by the same
716
+ // runner are two different leases, and must be distinguishable.
717
+ id: generateLeaseId(),
297
718
  runnerId: args.runnerId,
298
719
  expiresAt: args.now + args.leaseMs
299
720
  },
300
721
  attempts: job.attempts + 1,
301
722
  updatedAt: args.now
302
723
  };
303
- this.#jobs.set(job.id, updated);
724
+ this.#write(job.id, updated);
304
725
  claimed.push(updated);
305
726
  }
306
727
  return Promise.resolve(claimed);
@@ -347,37 +768,67 @@ var MemoryStore = class {
347
768
  this.#expireDueSync(args.now);
348
769
  const renewed = [];
349
770
  const lost = [];
350
- for (const jobId of args.jobIds) {
771
+ for (const { jobId, leaseId } of args.leases) {
351
772
  const job = this.#jobs.get(jobId);
352
- if (!job || job.lease?.runnerId !== args.runnerId) {
353
- lost.push(jobId);
773
+ if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
774
+ lost.push({ jobId, leaseId });
354
775
  continue;
355
776
  }
356
777
  if (job.state !== "claimed" && job.state !== "running") {
357
- lost.push(jobId);
778
+ lost.push({ jobId, leaseId });
358
779
  continue;
359
780
  }
360
781
  const expiresAt = args.now + args.leaseMs;
361
- this.#jobs.set(jobId, {
782
+ this.#write(jobId, {
362
783
  ...job,
363
784
  state: "running",
364
- lease: { runnerId: args.runnerId, expiresAt },
785
+ // Renewal extends the existing grant; it does not mint a new one.
786
+ lease: { ...job.lease, expiresAt },
365
787
  updatedAt: args.now
366
788
  });
367
789
  renewed.push({ jobId, expiresAt });
368
790
  }
369
791
  return Promise.resolve({ renewed, lost });
370
792
  }
793
+ adopt(args) {
794
+ const job = this.#jobs.get(args.jobId);
795
+ if (!job) return Promise.resolve(null);
796
+ if (job.state !== "queued" && job.state !== "claimed") {
797
+ return Promise.resolve(null);
798
+ }
799
+ if (job.lease && job.lease.id !== args.leaseId) {
800
+ return Promise.resolve(null);
801
+ }
802
+ const updated = {
803
+ ...job,
804
+ state: "claimed",
805
+ lease: {
806
+ id: args.leaseId,
807
+ // No runner: this site never paired with the machine holding it.
808
+ runnerId: "",
809
+ expiresAt: args.expiresAt
810
+ },
811
+ updatedAt: args.now
812
+ };
813
+ this.#write(updated.id, updated);
814
+ return Promise.resolve(updated);
815
+ }
371
816
  complete(args) {
372
817
  const job = this.#jobs.get(args.jobId);
373
818
  if (!job) return Promise.resolve({ accepted: false, job: null });
374
819
  if (job.state === "ok" || job.state === "error" || job.state === "canceled") {
820
+ const sameDevice = job.provenance?.runnerId !== void 0 && job.provenance.runnerId === args.runnerId;
821
+ const sameGrant = args.holder.by === "lease" && job.completedByLeaseId !== null && job.completedByLeaseId === args.holder.leaseId;
822
+ if (sameDevice && sameGrant) {
823
+ return Promise.resolve({ accepted: false, duplicate: true, job });
824
+ }
375
825
  return Promise.resolve({ accepted: false, job });
376
826
  }
377
827
  if (job.state === "expired") {
378
828
  return Promise.resolve({ accepted: false, job });
379
829
  }
380
- if (job.lease?.runnerId !== args.runnerId) {
830
+ const holds = args.holder.by === "runner" ? job.lease?.runnerId === args.holder.runnerId : job.lease?.id === args.holder.leaseId;
831
+ if (!holds) {
381
832
  return Promise.resolve({ accepted: false, job });
382
833
  }
383
834
  const state = args.outcome.outcome === "ok" ? "ok" : args.outcome.outcome === "canceled" ? "canceled" : "error";
@@ -385,11 +836,13 @@ var MemoryStore = class {
385
836
  ...job,
386
837
  state,
387
838
  lease: null,
839
+ // The grant that recorded it, kept after the lease is dropped — §3.6.
840
+ completedByLeaseId: args.holder.by === "lease" ? args.holder.leaseId : job.lease?.id ?? null,
388
841
  outcome: args.outcome,
389
842
  provenance: args.provenance,
390
843
  updatedAt: args.now
391
844
  };
392
- this.#jobs.set(job.id, updated);
845
+ this.#write(job.id, updated);
393
846
  this.#cancelRequests.delete(job.id);
394
847
  if (state === "ok") this.#unblockDependents(job.id, args.now);
395
848
  return Promise.resolve({ accepted: true, job: updated });
@@ -410,19 +863,70 @@ var MemoryStore = class {
410
863
  (depId) => this.#jobs.get(depId)?.state === "ok"
411
864
  );
412
865
  if (ready) {
413
- this.#jobs.set(job.id, { ...job, claimableAt: now, updatedAt: now });
866
+ this.#write(job.id, { ...job, claimableAt: now, updatedAt: now });
867
+ }
868
+ }
869
+ }
870
+ /**
871
+ * Watchers, by job id (byollm_009 §8.3).
872
+ *
873
+ * A `Set` per job so an unsubscribe removes exactly the handler it
874
+ * registered — two waiters on the same job are ordinary, and removing by
875
+ * job id alone would silently cancel someone else's wait.
876
+ */
877
+ #watchers = /* @__PURE__ */ new Map();
878
+ subscribe(jobId, onChange) {
879
+ const existing = this.#watchers.get(jobId) ?? /* @__PURE__ */ new Set();
880
+ existing.add(onChange);
881
+ this.#watchers.set(jobId, existing);
882
+ let live = true;
883
+ return () => {
884
+ if (!live) return;
885
+ live = false;
886
+ const set = this.#watchers.get(jobId);
887
+ set?.delete(onChange);
888
+ if (set?.size === 0) this.#watchers.delete(jobId);
889
+ };
890
+ }
891
+ /**
892
+ * The single write path for a job.
893
+ *
894
+ * Every mutation goes through here so notification cannot be forgotten by
895
+ * a future one. Nine call sites existed when the push seam was added, and
896
+ * "remember to notify" is not a property nine call sites keep.
897
+ */
898
+ #write(jobId, record) {
899
+ this.#jobs.set(jobId, record);
900
+ this.#notify(jobId);
901
+ }
902
+ /**
903
+ * Tell anyone watching that a job changed.
904
+ *
905
+ * A throwing watcher must not corrupt the store's own bookkeeping, so each
906
+ * is isolated: this runs inside write paths, and one bad listener taking
907
+ * out an unrelated write would be a far worse failure than a missed
908
+ * notification.
909
+ */
910
+ #notify(jobId) {
911
+ for (const watcher of this.#watchers.get(jobId) ?? []) {
912
+ try {
913
+ watcher();
914
+ } catch {
414
915
  }
415
916
  }
416
917
  }
417
918
  release(args) {
418
919
  const released = [];
419
- for (const jobId of args.jobIds) {
920
+ for (const { jobId, leaseId } of args.leases) {
420
921
  const job = this.#jobs.get(jobId);
421
- if (!job || job.lease?.runnerId !== args.runnerId) continue;
422
- this.#jobs.set(jobId, {
922
+ if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
923
+ continue;
924
+ }
925
+ this.#write(jobId, {
423
926
  ...job,
424
927
  state: "queued",
425
928
  lease: null,
929
+ completedByLeaseId: null,
426
930
  // Newly available again, so the TTL clock restarts here too.
427
931
  claimableAt: args.now,
428
932
  // A refusal is remembered, or the pair spins between claim and
@@ -452,6 +956,7 @@ var MemoryStore = class {
452
956
  ...job,
453
957
  state: "queued",
454
958
  lease: null,
959
+ completedByLeaseId: null,
455
960
  // The TTL clock restarts: it measures how long a job has waited
456
961
  // *unclaimed*, and this job has just become available again. Without
457
962
  // this, a job whose runner died would expire for time it spent being
@@ -461,7 +966,7 @@ var MemoryStore = class {
461
966
  claimableAt: now,
462
967
  updatedAt: now
463
968
  };
464
- this.#jobs.set(job.id, requeued);
969
+ this.#write(job.id, requeued);
465
970
  changed.push(requeued);
466
971
  }
467
972
  }
@@ -474,9 +979,10 @@ var MemoryStore = class {
474
979
  ...job,
475
980
  state: "expired",
476
981
  lease: null,
982
+ completedByLeaseId: null,
477
983
  updatedAt: now
478
984
  };
479
- this.#jobs.set(job.id, expired);
985
+ this.#write(job.id, expired);
480
986
  changed.push(expired);
481
987
  }
482
988
  return changed;
@@ -489,9 +995,10 @@ var MemoryStore = class {
489
995
  ...job,
490
996
  state: "canceled",
491
997
  lease: null,
998
+ completedByLeaseId: null,
492
999
  updatedAt: now
493
1000
  };
494
- this.#jobs.set(jobId, canceled);
1001
+ this.#write(jobId, canceled);
495
1002
  return Promise.resolve(canceled);
496
1003
  }
497
1004
  if (job.state === "claimed" || job.state === "running") {
@@ -509,9 +1016,7 @@ var MemoryStore = class {
509
1016
  }
510
1017
  listCancelRequests(runnerId) {
511
1018
  return Promise.resolve(
512
- [...this.#cancelRequests].filter(
513
- (jobId) => this.#jobs.get(jobId)?.lease?.runnerId === runnerId
514
- )
1019
+ [...this.#cancelRequests].map((jobId) => ({ jobId, lease: this.#jobs.get(jobId)?.lease })).filter((row) => row.lease?.runnerId === runnerId).map((row) => ({ jobId: row.jobId, leaseId: row.lease?.id ?? "" }))
515
1020
  );
516
1021
  }
517
1022
  // -- pairing and runners -------------------------------------------------
@@ -542,7 +1047,9 @@ var MemoryStore = class {
542
1047
  const runner = {
543
1048
  id: args.runnerId,
544
1049
  owner: args.owner,
545
- tokenHash: args.tokenHash,
1050
+ // Carried from the pairing, not re-supplied at approval: the user
1051
+ // approved a specific machine, and the runner must be that machine.
1052
+ device: pairing.device,
546
1053
  label: pairing.label,
547
1054
  platform: pairing.platform,
548
1055
  daemonVersion: pairing.daemonVersion,
@@ -558,7 +1065,7 @@ var MemoryStore = class {
558
1065
  state: "approved",
559
1066
  owner: args.owner,
560
1067
  runnerId: runner.id,
561
- runnerTokenOnce: args.runnerToken
1068
+ collected: false
562
1069
  });
563
1070
  return Promise.resolve(runner);
564
1071
  }
@@ -579,17 +1086,11 @@ var MemoryStore = class {
579
1086
  if (pairing) {
580
1087
  this.#pairings.set(deviceCodeHash, {
581
1088
  ...pairing,
582
- runnerTokenOnce: null
1089
+ collected: true
583
1090
  });
584
1091
  }
585
1092
  return Promise.resolve();
586
1093
  }
587
- getRunnerByTokenHash(hash) {
588
- for (const runner of this.#runners.values()) {
589
- if (runner.tokenHash === hash) return Promise.resolve(runner);
590
- }
591
- return Promise.resolve(null);
592
- }
593
1094
  getRunner(runnerId) {
594
1095
  return Promise.resolve(this.#runners.get(runnerId) ?? null);
595
1096
  }
@@ -631,22 +1132,26 @@ function capabilityFor(capabilities, kind) {
631
1132
  export {
632
1133
  ByollmApp,
633
1134
  ByollmHandlers,
1135
+ CloudLane,
634
1136
  MemoryStore,
635
1137
  NoRunnerAvailableError,
636
1138
  PollingDelivery,
1139
+ RelayUnavailable,
637
1140
  ResultTimeoutError,
638
1141
  SERVED_PROTOCOL_VERSION,
639
- bearerFrom,
640
1142
  capabilityFor,
641
1143
  createFetchHandler,
1144
+ formatSiteKeys,
642
1145
  generateDeviceCode,
643
1146
  generateJobId,
644
1147
  generateRunnerId,
645
- generateRunnerToken,
1148
+ generateSiteKeys,
646
1149
  generateUserCode,
647
1150
  hashSecret,
648
1151
  normalizeUserCode,
649
1152
  routeEndpoint,
650
- secretsMatch
1153
+ secretsMatch,
1154
+ signatureFrom,
1155
+ siteKeysFromEnv
651
1156
  };
652
1157
  //# sourceMappingURL=index.js.map