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

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,301 @@ 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 CloudLane = class {
47
+ #options;
48
+ #store;
49
+ #siteKeys;
50
+ #now;
51
+ #fetch;
52
+ constructor(deps) {
53
+ this.#options = deps.options;
54
+ this.#store = deps.store;
55
+ this.#siteKeys = deps.siteKeys;
56
+ this.#now = deps.now;
57
+ this.#fetch = deps.options.fetch ?? globalThis.fetch;
58
+ }
59
+ /**
60
+ * Publish a job's stub for routing.
61
+ *
62
+ * The stub and nothing else — byollm_009 §6 makes that exhaustive by
63
+ * construction, so this cannot leak a payload even by mistake: there is no
64
+ * field on `JobStub` to put one in.
65
+ */
66
+ async publish(record) {
67
+ const stub = {
68
+ id: record.id,
69
+ kind: record.kind,
70
+ owner: record.owner,
71
+ // This site, by its identity key id — Amendment A §A.3. The relay
72
+ // already knows which site it is routing for, so this discloses nothing
73
+ // new to it; what it adds is that the *daemon* can check the stub
74
+ // against the envelope's `senderKeyId` without asking the relay.
75
+ site: keyId(publicIdentityOf(this.#siteKeys).identity),
76
+ audience: record.audience,
77
+ // `audienceAllow` is deliberately **not** published — cloud_008 §0.2.
78
+ //
79
+ // It is a list of the people who may run this job, and on the direct
80
+ // plane that is unremarkable: the site authored the list and the site is
81
+ // the upstream, so the party receiving it already has it. Through a
82
+ // relay it is a third party, and byollm_009 §6's enumerated metadata —
83
+ // "exhaustive and normative… what an upstream can see, stated as a
84
+ // commitment" — does not include it. It was reaching the relay on every
85
+ // named-audience job.
86
+ //
87
+ // Nothing is lost by withholding it, which is why this is a Tier 0 fix
88
+ // rather than a trade. `matchAudience` treats it as a *narrowing*:
89
+ // `job.audienceAllow !== undefined && !includes(daemon.owner)` refuses,
90
+ // and its absence simply falls through to the checks that actually
91
+ // enforce — the daemon's own allowlist (`NAMED_LOCAL_ALLOWLIST`) and the
92
+ // backend's offer scope. On this lane the relay narrows too, from the
93
+ // control plane's rosters. The enforcement was never here.
94
+ sizeClass: record.sizeClass,
95
+ streaming: false,
96
+ // The relay needs *a* deadline to bound routing. A job without one gets
97
+ // the envelope's, which is the outer bound on how long the ciphertext
98
+ // is worth carrying — never longer than the work could possibly matter.
99
+ // The same fallback the direct plane uses — cloud_008 Tier 4, finding
100
+ // 31. This said `createdAt + ENVELOPE_TTL_FALLBACK`, a local constant
101
+ // whose value happened to equal `ENVELOPE_MAX_AGE_MS`; the direct plane
102
+ // said `(claimableAt ?? now) + ttlMs`. One field, two meanings, and a
103
+ // job that was blocked on a dependency got a deadline measured from
104
+ // when it was *created* on one lane and from when it became *claimable*
105
+ // on the other.
106
+ deadlineAt: deadlineFor(record, this.#now())
107
+ };
108
+ await this.#post("enqueue", {
109
+ siteId: this.#options.siteId,
110
+ stub
111
+ });
112
+ }
113
+ /**
114
+ * Withdraw a job at the relay — cloud_008 §2.2.
115
+ *
116
+ * `app.cancel()` marks the site's own row terminal, which stops the *next*
117
+ * seal. It cannot stop a device that is already running the work, because
118
+ * on this lane the site is not the upstream: only the relay talks to the
119
+ * daemon, and it answered `cancel: []` unconditionally.
120
+ *
121
+ * So the cancellation has to travel. The relay marks the job, stops
122
+ * offering it, and names it to the holding device at its next heartbeat —
123
+ * the same path the direct plane has always had, arriving one hop later.
124
+ */
125
+ async cancel(jobId) {
126
+ await this.#post("cancel", { siteId: this.#options.siteId, jobId });
127
+ }
128
+ /**
129
+ * One cycle: seal for anything claimed, collect anything finished.
130
+ *
131
+ * Idempotent and safe to call as often as you like. Exposed as a single
132
+ * cycle rather than hidden behind a timer so a caller decides its own
133
+ * cadence — a serverless site runs it on a cron, a long-lived one on an
134
+ * interval, and a test runs it exactly when it means to.
135
+ */
136
+ async pump() {
137
+ const sealed = [];
138
+ const refused = [];
139
+ const completed = [];
140
+ const pending = await this.#get("pending");
141
+ for (const claim of pending.jobs) {
142
+ const record = await this.#store.get(claim.jobId);
143
+ if (!record) continue;
144
+ const resealed = await resealForDevice({
145
+ siteKeys: this.#siteKeys,
146
+ job: {
147
+ id: record.id,
148
+ envelope: record.envelope,
149
+ createdAt: record.createdAt
150
+ },
151
+ device: claim.device
152
+ });
153
+ if (!resealed.ok) {
154
+ refused.push(claim.jobId);
155
+ continue;
156
+ }
157
+ const adopted = await this.#store.adopt({
158
+ jobId: claim.jobId,
159
+ leaseId: claim.leaseId,
160
+ expiresAt: claim.leaseExpiresAt,
161
+ now: this.#now()
162
+ });
163
+ if (!adopted) {
164
+ refused.push(claim.jobId);
165
+ continue;
166
+ }
167
+ await this.#post("payload", {
168
+ siteId: this.#options.siteId,
169
+ jobId: claim.jobId,
170
+ envelope: resealed.envelope
171
+ });
172
+ sealed.push(claim.jobId);
173
+ }
174
+ const finished = await this.#get("results");
175
+ for (const done of finished.jobs) {
176
+ const record = await this.#store.get(done.jobId);
177
+ if (!record || record.state === "ok" || record.state === "error") {
178
+ continue;
179
+ }
180
+ const outcome = await this.#openResult(done);
181
+ if (!outcome) {
182
+ refused.push(done.jobId);
183
+ continue;
184
+ }
185
+ await this.#store.complete({
186
+ jobId: done.jobId,
187
+ // The relay named the device; the signature above proved it — §3.6.
188
+ runnerId: done.runnerId,
189
+ // The grant, not the machine: this site never paired with the device
190
+ // that ran it, and the signature it verified above is the stronger
191
+ // claim about who did.
192
+ holder: { by: "lease", leaseId: done.leaseId },
193
+ outcome: outcome.outcome,
194
+ provenance: provenanceFor({
195
+ audience: record.audience,
196
+ runnerId: done.runnerId,
197
+ // The owner, from the relay's own record of who claimed it — not a
198
+ // key id. cloud_008 §2.5: this said `keyId(device.identity)`, which
199
+ // put a key id where the direct plane puts a user id, so an app
200
+ // comparing provenance across lanes compared two namespaces and got
201
+ // `false` for the same person. The device's key is still what the
202
+ // signature was verified against, above; that is a different
203
+ // question from whose machine it is.
204
+ runnerOwner: done.runnerOwner,
205
+ // From the envelope, not invented — cloud_008 §2.5. These were
206
+ // hardcoded `"http"` and `"unknown"` because the daemon's declared
207
+ // values stopped at the relay, which is right: a blind relay acts
208
+ // on neither. Sealing them carries them past it untouched.
209
+ backendClass: outcome.ran.backendClass,
210
+ model: outcome.ran.model
211
+ }),
212
+ now: this.#now()
213
+ });
214
+ completed.push(done.jobId);
215
+ }
216
+ return { sealed, completed, refused };
217
+ }
218
+ /**
219
+ * Open a sealed result and verify it came from the device that claimed it.
220
+ *
221
+ * The relay says which device ran the job; this checks that claim against a
222
+ * signature the relay cannot produce. A relay that named the wrong device
223
+ * gets a refusal, not a stored result — which is what keeps `RELAY_BLIND`
224
+ * from quietly becoming `RELAY_TRUSTED`.
225
+ */
226
+ async #openResult(done) {
227
+ const opened = await open({
228
+ envelope: done.envelope,
229
+ recipientKeys: this.#siteKeys,
230
+ senderIdentityPublic: done.device.identity,
231
+ expected: {
232
+ jobId: done.jobId,
233
+ senderKeyId: keyId(done.device.identity),
234
+ recipientKeyId: keyId(publicIdentityOf(this.#siteKeys).identity),
235
+ direction: "result"
236
+ }
237
+ });
238
+ if (!opened.ok) return null;
239
+ let parsed;
240
+ try {
241
+ parsed = JSON.parse(opened.plaintext);
242
+ } catch {
243
+ return null;
244
+ }
245
+ const sealed = SealedOutcome.safeParse(parsed);
246
+ if (!sealed.success) return null;
247
+ if (sealed.data.outcome.outcome !== done.disposition) return null;
248
+ return sealed.data;
249
+ }
250
+ /**
251
+ * Sign a site-plane call with this site's identity key.
252
+ *
253
+ * The same scheme the daemon uses against an upstream, because the site is
254
+ * in the same position: an outbound caller whose key the relay already holds
255
+ * for other reasons. Nothing else authenticates this plane — a relay that
256
+ * took the `siteId` in a body at face value would let anyone enqueue work in
257
+ * a site's name and read who claimed it.
258
+ */
259
+ #headers(endpoint, rawBody) {
260
+ const signature = signSiteRequest(this.#siteKeys, {
261
+ endpoint,
262
+ siteId: this.#options.siteId,
263
+ issuedAt: this.#now(),
264
+ body: rawBody
265
+ });
266
+ return {
267
+ "x-byollm-site": this.#options.siteId,
268
+ "x-byollm-issued-at": String(signature.issuedAt),
269
+ "x-byollm-signature": signature.signature
270
+ };
271
+ }
272
+ async #post(endpoint, body) {
273
+ const rawBody = JSON.stringify({
274
+ protocolVersion: PROTOCOL_VERSION,
275
+ ...body
276
+ });
277
+ const response = await this.#fetch(
278
+ `${this.#options.relayOrigin}/relay/site/${endpoint}`,
279
+ {
280
+ method: "POST",
281
+ headers: {
282
+ "content-type": "application/json",
283
+ ...this.#headers(endpoint, rawBody)
284
+ },
285
+ body: rawBody
286
+ }
287
+ );
288
+ return response.json();
289
+ }
290
+ async #get(endpoint) {
291
+ const url = `${this.#options.relayOrigin}/relay/site/${endpoint}?siteId=${encodeURIComponent(this.#options.siteId)}&protocolVersion=${encodeURIComponent(PROTOCOL_VERSION)}`;
292
+ const response = await this.#fetch(url, {
293
+ headers: this.#headers(endpoint, "")
294
+ });
295
+ return response.json();
296
+ }
297
+ };
298
+
299
+ // src/app.ts
26
300
  var DEFAULT_LIVENESS_MS = 35e3;
27
301
  var ByollmApp = class {
28
302
  #store;
303
+ #siteKeys;
29
304
  #now;
30
305
  #livenessMs;
31
306
  #delivery;
307
+ /** Present only in the cloud lane; the site's side of the relay. */
308
+ cloud;
32
309
  constructor(options) {
33
310
  this.#store = options.store;
311
+ this.#siteKeys = options.siteKeys;
34
312
  this.#now = options.now ?? Date.now;
35
313
  this.#livenessMs = options.livenessMs ?? DEFAULT_LIVENESS_MS;
314
+ this.cloud = options.lane === void 0 ? void 0 : new CloudLane({
315
+ options: options.lane,
316
+ store: options.store,
317
+ siteKeys: options.siteKeys,
318
+ now: this.#now
319
+ });
36
320
  const deps = {
37
321
  ...options.noRunnerGraceMs === void 0 ? {} : { graceMs: options.noRunnerGraceMs },
38
322
  read: (jobId) => this.result(jobId),
@@ -66,7 +350,45 @@ var ByollmApp = class {
66
350
  * the app is obliged to disclose that to whoever reads it.
67
351
  */
68
352
  async enqueue(input) {
69
- const record = await this.#store.create(input, this.#now());
353
+ const parsed = KindedPayload.safeParse({
354
+ kind: input.kind,
355
+ payload: input.payload
356
+ });
357
+ if (!parsed.success) {
358
+ const detail = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
359
+ throw new Error(`invalid ${input.kind} payload \u2014 ${detail}`);
360
+ }
361
+ const createdAt = this.#now();
362
+ const envelopeDeadlineAt = createdAt + ENVELOPE_MAX_AGE_MS;
363
+ const jobId = input.id ?? generateJobId();
364
+ const senderKeyId = keyId2(publicIdentityOf2(this.#siteKeys).identity);
365
+ const envelope = await seal({
366
+ plaintext: JSON.stringify(parsed.data.payload),
367
+ senderKeys: this.#siteKeys,
368
+ recipientEncryptionPublic: this.#siteKeys.encryptionPublic,
369
+ context: {
370
+ jobId,
371
+ senderKeyId,
372
+ recipientKeyId: senderKeyId,
373
+ deadlineAt: envelopeDeadlineAt,
374
+ direction: "payload"
375
+ }
376
+ });
377
+ const record = await this.#store.create(
378
+ {
379
+ ...input,
380
+ id: jobId,
381
+ envelope,
382
+ sizeClass: sizeClassOf(
383
+ payloadTextLength({
384
+ kind: input.kind,
385
+ payload: parsed.data.payload
386
+ })
387
+ )
388
+ },
389
+ createdAt
390
+ );
391
+ await this.cloud?.publish(record);
70
392
  return {
71
393
  id: record.id,
72
394
  record,
@@ -87,7 +409,7 @@ var ByollmApp = class {
87
409
  * Check `provenance.untrusted` before rendering. It is true for every
88
410
  * `named`/`public` job, because that text came from someone else's machine
89
411
  * and the app must not present it as its own AI's answer
90
- * ({@link MUSTS.RESULT_PROVENANCE}).
412
+ * ({@link MUSTS.PROVENANCE_NAMES_DEVICE}).
91
413
  */
92
414
  async result(jobId) {
93
415
  const job = await this.job(jobId);
@@ -101,7 +423,11 @@ var ByollmApp = class {
101
423
  }
102
424
  /** Ask a runner to stop. Queued jobs cancel at once; held jobs at the next heartbeat. */
103
425
  async cancel(jobId) {
104
- return this.#store.cancel(jobId, this.#now());
426
+ const cancelled = await this.#store.cancel(jobId, this.#now());
427
+ if (cancelled && this.cloud) {
428
+ await this.cloud.cancel(jobId).catch(() => void 0);
429
+ }
430
+ return cancelled;
105
431
  }
106
432
  /**
107
433
  * Is there a live runner that could take a job of this shape?
@@ -179,13 +505,10 @@ var ByollmApp = class {
179
505
  * ({@link MUSTS.PAIR_ONE_USER}, {@link MUSTS.PAIR_INTERACTIVE}).
180
506
  */
181
507
  async approvePairing(args) {
182
- const token = generateRunnerToken();
183
508
  return this.#store.approvePairing({
184
509
  userCode: normalizeUserCode(args.userCode),
185
510
  owner: args.owner,
186
511
  runnerId: generateRunnerId(),
187
- runnerToken: token,
188
- tokenHash: hashSecret(token),
189
512
  now: this.#now()
190
513
  });
191
514
  }
@@ -229,6 +552,45 @@ function normalizeUserCode(input) {
229
552
  return bare.length === 8 ? `${bare.slice(0, 4)}-${bare.slice(4)}` : bare;
230
553
  }
231
554
 
555
+ // src/keys.ts
556
+ import { StoredKeys, generateKeys, publicIdentityOf as publicIdentityOf3 } from "@byollm/protocol";
557
+ import { fingerprint } from "@byollm/protocol";
558
+ var generateSiteKeys = (now = Date.now()) => generateKeys(now);
559
+ function siteKeysFromEnv(variable = "BYOLLM_SITE_KEYS", env = process.env) {
560
+ const raw = env[variable];
561
+ if (raw === void 0 || raw === "") {
562
+ throw new Error(
563
+ `${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.`
564
+ );
565
+ }
566
+ let parsed;
567
+ try {
568
+ parsed = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
569
+ } catch {
570
+ throw new Error(
571
+ `${variable} is not base64-encoded JSON. It should be exactly what \`npx @byollm/server keygen\` printed.`
572
+ );
573
+ }
574
+ const result = StoredKeys.safeParse(parsed);
575
+ if (!result.success) {
576
+ throw new Error(
577
+ `${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.`
578
+ );
579
+ }
580
+ return result.data;
581
+ }
582
+ function formatSiteKeys(keys) {
583
+ const encoded = Buffer.from(JSON.stringify(keys)).toString("base64");
584
+ return `# Set this as BYOLLM_SITE_KEYS. It is a secret: it is this site's
585
+ # identity, and anything holding it can be this site.
586
+ BYOLLM_SITE_KEYS=${encoded}
587
+
588
+ # Fingerprint (not secret \u2014 show it to users so they can check what
589
+ # their daemon pinned):
590
+ # ${fingerprint(publicIdentityOf3(keys).identity)}
591
+ `;
592
+ }
593
+
232
594
  // src/memory.ts
233
595
  import {
234
596
  backendDescriptor as backendDescriptor2,
@@ -247,7 +609,7 @@ var MemoryStore = class {
247
609
  }
248
610
  // -- jobs ---------------------------------------------------------------
249
611
  create(input, now) {
250
- const id = input.id ?? generateJobId();
612
+ const id = input.id;
251
613
  const existing = this.#jobs.get(id);
252
614
  if (existing) return Promise.resolve(existing);
253
615
  const dependsOn = [...input.dependsOn ?? []];
@@ -257,13 +619,15 @@ var MemoryStore = class {
257
619
  const job = {
258
620
  id,
259
621
  kind: input.kind,
260
- payload: input.payload,
622
+ envelope: input.envelope,
623
+ sizeClass: input.sizeClass,
261
624
  audience: input.audience ?? "self",
262
625
  owner: input.owner,
263
626
  audienceAllow: input.audienceAllow ? [...input.audienceAllow] : void 0,
264
627
  dependsOn,
265
628
  state: "queued",
266
629
  lease: null,
630
+ completedByLeaseId: null,
267
631
  createdAt: now,
268
632
  // The TTL clock starts here only if nothing blocks the job.
269
633
  claimableAt: blocked ? null : now,
@@ -275,7 +639,7 @@ var MemoryStore = class {
275
639
  provenance: null,
276
640
  updatedAt: now
277
641
  };
278
- this.#jobs.set(id, job);
642
+ this.#write(id, job);
279
643
  return Promise.resolve(job);
280
644
  }
281
645
  get(jobId) {
@@ -294,13 +658,16 @@ var MemoryStore = class {
294
658
  ...job,
295
659
  state: "claimed",
296
660
  lease: {
661
+ // A fresh id per grant. Two claims of the same job by the same
662
+ // runner are two different leases, and must be distinguishable.
663
+ id: generateLeaseId(),
297
664
  runnerId: args.runnerId,
298
665
  expiresAt: args.now + args.leaseMs
299
666
  },
300
667
  attempts: job.attempts + 1,
301
668
  updatedAt: args.now
302
669
  };
303
- this.#jobs.set(job.id, updated);
670
+ this.#write(job.id, updated);
304
671
  claimed.push(updated);
305
672
  }
306
673
  return Promise.resolve(claimed);
@@ -347,37 +714,67 @@ var MemoryStore = class {
347
714
  this.#expireDueSync(args.now);
348
715
  const renewed = [];
349
716
  const lost = [];
350
- for (const jobId of args.jobIds) {
717
+ for (const { jobId, leaseId } of args.leases) {
351
718
  const job = this.#jobs.get(jobId);
352
- if (!job || job.lease?.runnerId !== args.runnerId) {
353
- lost.push(jobId);
719
+ if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
720
+ lost.push({ jobId, leaseId });
354
721
  continue;
355
722
  }
356
723
  if (job.state !== "claimed" && job.state !== "running") {
357
- lost.push(jobId);
724
+ lost.push({ jobId, leaseId });
358
725
  continue;
359
726
  }
360
727
  const expiresAt = args.now + args.leaseMs;
361
- this.#jobs.set(jobId, {
728
+ this.#write(jobId, {
362
729
  ...job,
363
730
  state: "running",
364
- lease: { runnerId: args.runnerId, expiresAt },
731
+ // Renewal extends the existing grant; it does not mint a new one.
732
+ lease: { ...job.lease, expiresAt },
365
733
  updatedAt: args.now
366
734
  });
367
735
  renewed.push({ jobId, expiresAt });
368
736
  }
369
737
  return Promise.resolve({ renewed, lost });
370
738
  }
739
+ adopt(args) {
740
+ const job = this.#jobs.get(args.jobId);
741
+ if (!job) return Promise.resolve(null);
742
+ if (job.state !== "queued" && job.state !== "claimed") {
743
+ return Promise.resolve(null);
744
+ }
745
+ if (job.lease && job.lease.id !== args.leaseId) {
746
+ return Promise.resolve(null);
747
+ }
748
+ const updated = {
749
+ ...job,
750
+ state: "claimed",
751
+ lease: {
752
+ id: args.leaseId,
753
+ // No runner: this site never paired with the machine holding it.
754
+ runnerId: "",
755
+ expiresAt: args.expiresAt
756
+ },
757
+ updatedAt: args.now
758
+ };
759
+ this.#write(updated.id, updated);
760
+ return Promise.resolve(updated);
761
+ }
371
762
  complete(args) {
372
763
  const job = this.#jobs.get(args.jobId);
373
764
  if (!job) return Promise.resolve({ accepted: false, job: null });
374
765
  if (job.state === "ok" || job.state === "error" || job.state === "canceled") {
766
+ const sameDevice = job.provenance?.runnerId !== void 0 && job.provenance.runnerId === args.runnerId;
767
+ const sameGrant = args.holder.by === "lease" && job.completedByLeaseId !== null && job.completedByLeaseId === args.holder.leaseId;
768
+ if (sameDevice && sameGrant) {
769
+ return Promise.resolve({ accepted: false, duplicate: true, job });
770
+ }
375
771
  return Promise.resolve({ accepted: false, job });
376
772
  }
377
773
  if (job.state === "expired") {
378
774
  return Promise.resolve({ accepted: false, job });
379
775
  }
380
- if (job.lease?.runnerId !== args.runnerId) {
776
+ const holds = args.holder.by === "runner" ? job.lease?.runnerId === args.holder.runnerId : job.lease?.id === args.holder.leaseId;
777
+ if (!holds) {
381
778
  return Promise.resolve({ accepted: false, job });
382
779
  }
383
780
  const state = args.outcome.outcome === "ok" ? "ok" : args.outcome.outcome === "canceled" ? "canceled" : "error";
@@ -385,11 +782,13 @@ var MemoryStore = class {
385
782
  ...job,
386
783
  state,
387
784
  lease: null,
785
+ // The grant that recorded it, kept after the lease is dropped — §3.6.
786
+ completedByLeaseId: args.holder.by === "lease" ? args.holder.leaseId : job.lease?.id ?? null,
388
787
  outcome: args.outcome,
389
788
  provenance: args.provenance,
390
789
  updatedAt: args.now
391
790
  };
392
- this.#jobs.set(job.id, updated);
791
+ this.#write(job.id, updated);
393
792
  this.#cancelRequests.delete(job.id);
394
793
  if (state === "ok") this.#unblockDependents(job.id, args.now);
395
794
  return Promise.resolve({ accepted: true, job: updated });
@@ -410,19 +809,70 @@ var MemoryStore = class {
410
809
  (depId) => this.#jobs.get(depId)?.state === "ok"
411
810
  );
412
811
  if (ready) {
413
- this.#jobs.set(job.id, { ...job, claimableAt: now, updatedAt: now });
812
+ this.#write(job.id, { ...job, claimableAt: now, updatedAt: now });
813
+ }
814
+ }
815
+ }
816
+ /**
817
+ * Watchers, by job id (byollm_009 §8.3).
818
+ *
819
+ * A `Set` per job so an unsubscribe removes exactly the handler it
820
+ * registered — two waiters on the same job are ordinary, and removing by
821
+ * job id alone would silently cancel someone else's wait.
822
+ */
823
+ #watchers = /* @__PURE__ */ new Map();
824
+ subscribe(jobId, onChange) {
825
+ const existing = this.#watchers.get(jobId) ?? /* @__PURE__ */ new Set();
826
+ existing.add(onChange);
827
+ this.#watchers.set(jobId, existing);
828
+ let live = true;
829
+ return () => {
830
+ if (!live) return;
831
+ live = false;
832
+ const set = this.#watchers.get(jobId);
833
+ set?.delete(onChange);
834
+ if (set?.size === 0) this.#watchers.delete(jobId);
835
+ };
836
+ }
837
+ /**
838
+ * The single write path for a job.
839
+ *
840
+ * Every mutation goes through here so notification cannot be forgotten by
841
+ * a future one. Nine call sites existed when the push seam was added, and
842
+ * "remember to notify" is not a property nine call sites keep.
843
+ */
844
+ #write(jobId, record) {
845
+ this.#jobs.set(jobId, record);
846
+ this.#notify(jobId);
847
+ }
848
+ /**
849
+ * Tell anyone watching that a job changed.
850
+ *
851
+ * A throwing watcher must not corrupt the store's own bookkeeping, so each
852
+ * is isolated: this runs inside write paths, and one bad listener taking
853
+ * out an unrelated write would be a far worse failure than a missed
854
+ * notification.
855
+ */
856
+ #notify(jobId) {
857
+ for (const watcher of this.#watchers.get(jobId) ?? []) {
858
+ try {
859
+ watcher();
860
+ } catch {
414
861
  }
415
862
  }
416
863
  }
417
864
  release(args) {
418
865
  const released = [];
419
- for (const jobId of args.jobIds) {
866
+ for (const { jobId, leaseId } of args.leases) {
420
867
  const job = this.#jobs.get(jobId);
421
- if (!job || job.lease?.runnerId !== args.runnerId) continue;
422
- this.#jobs.set(jobId, {
868
+ if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
869
+ continue;
870
+ }
871
+ this.#write(jobId, {
423
872
  ...job,
424
873
  state: "queued",
425
874
  lease: null,
875
+ completedByLeaseId: null,
426
876
  // Newly available again, so the TTL clock restarts here too.
427
877
  claimableAt: args.now,
428
878
  // A refusal is remembered, or the pair spins between claim and
@@ -452,6 +902,7 @@ var MemoryStore = class {
452
902
  ...job,
453
903
  state: "queued",
454
904
  lease: null,
905
+ completedByLeaseId: null,
455
906
  // The TTL clock restarts: it measures how long a job has waited
456
907
  // *unclaimed*, and this job has just become available again. Without
457
908
  // this, a job whose runner died would expire for time it spent being
@@ -461,7 +912,7 @@ var MemoryStore = class {
461
912
  claimableAt: now,
462
913
  updatedAt: now
463
914
  };
464
- this.#jobs.set(job.id, requeued);
915
+ this.#write(job.id, requeued);
465
916
  changed.push(requeued);
466
917
  }
467
918
  }
@@ -474,9 +925,10 @@ var MemoryStore = class {
474
925
  ...job,
475
926
  state: "expired",
476
927
  lease: null,
928
+ completedByLeaseId: null,
477
929
  updatedAt: now
478
930
  };
479
- this.#jobs.set(job.id, expired);
931
+ this.#write(job.id, expired);
480
932
  changed.push(expired);
481
933
  }
482
934
  return changed;
@@ -489,9 +941,10 @@ var MemoryStore = class {
489
941
  ...job,
490
942
  state: "canceled",
491
943
  lease: null,
944
+ completedByLeaseId: null,
492
945
  updatedAt: now
493
946
  };
494
- this.#jobs.set(jobId, canceled);
947
+ this.#write(jobId, canceled);
495
948
  return Promise.resolve(canceled);
496
949
  }
497
950
  if (job.state === "claimed" || job.state === "running") {
@@ -509,9 +962,7 @@ var MemoryStore = class {
509
962
  }
510
963
  listCancelRequests(runnerId) {
511
964
  return Promise.resolve(
512
- [...this.#cancelRequests].filter(
513
- (jobId) => this.#jobs.get(jobId)?.lease?.runnerId === runnerId
514
- )
965
+ [...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
966
  );
516
967
  }
517
968
  // -- pairing and runners -------------------------------------------------
@@ -542,7 +993,9 @@ var MemoryStore = class {
542
993
  const runner = {
543
994
  id: args.runnerId,
544
995
  owner: args.owner,
545
- tokenHash: args.tokenHash,
996
+ // Carried from the pairing, not re-supplied at approval: the user
997
+ // approved a specific machine, and the runner must be that machine.
998
+ device: pairing.device,
546
999
  label: pairing.label,
547
1000
  platform: pairing.platform,
548
1001
  daemonVersion: pairing.daemonVersion,
@@ -558,7 +1011,7 @@ var MemoryStore = class {
558
1011
  state: "approved",
559
1012
  owner: args.owner,
560
1013
  runnerId: runner.id,
561
- runnerTokenOnce: args.runnerToken
1014
+ collected: false
562
1015
  });
563
1016
  return Promise.resolve(runner);
564
1017
  }
@@ -579,17 +1032,11 @@ var MemoryStore = class {
579
1032
  if (pairing) {
580
1033
  this.#pairings.set(deviceCodeHash, {
581
1034
  ...pairing,
582
- runnerTokenOnce: null
1035
+ collected: true
583
1036
  });
584
1037
  }
585
1038
  return Promise.resolve();
586
1039
  }
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
1040
  getRunner(runnerId) {
594
1041
  return Promise.resolve(this.#runners.get(runnerId) ?? null);
595
1042
  }
@@ -631,22 +1078,25 @@ function capabilityFor(capabilities, kind) {
631
1078
  export {
632
1079
  ByollmApp,
633
1080
  ByollmHandlers,
1081
+ CloudLane,
634
1082
  MemoryStore,
635
1083
  NoRunnerAvailableError,
636
1084
  PollingDelivery,
637
1085
  ResultTimeoutError,
638
1086
  SERVED_PROTOCOL_VERSION,
639
- bearerFrom,
640
1087
  capabilityFor,
641
1088
  createFetchHandler,
1089
+ formatSiteKeys,
642
1090
  generateDeviceCode,
643
1091
  generateJobId,
644
1092
  generateRunnerId,
645
- generateRunnerToken,
1093
+ generateSiteKeys,
646
1094
  generateUserCode,
647
1095
  hashSecret,
648
1096
  normalizeUserCode,
649
1097
  routeEndpoint,
650
- secretsMatch
1098
+ secretsMatch,
1099
+ signatureFrom,
1100
+ siteKeysFromEnv
651
1101
  };
652
1102
  //# sourceMappingURL=index.js.map