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

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