@byollm/relay 0.1.0-alpha.10 → 0.1.0-alpha.12

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
@@ -12,117 +12,32 @@ import {
12
12
  verifyPublicIdentity,
13
13
  PublicIdentity
14
14
  } from "@byollm/protocol";
15
- import { randomUUID } from "crypto";
16
15
  import { z } from "zod";
17
-
18
- // src/state.ts
19
- var AWAITING_PAYLOAD_MS = 1e4;
20
- var RelayState = class {
21
- #jobs = /* @__PURE__ */ new Map();
22
- #presence = /* @__PURE__ */ new Map();
23
- /**
24
- * Take a stub for routing. The payload is not here and will not be.
25
- *
26
- * **Idempotent by job id, and that is a security property rather than a
27
- * convenience.** Site-plane calls are authenticated by signature, and
28
- * byollm_009 §4.2's argument for signing the request instead of a
29
- * server-issued nonce rests entirely on every write being idempotent per the
30
- * instance it names. This one was not: re-enqueueing a known id built a
31
- * fresh `queued` job over the top of the old one, discarding a live claim,
32
- * its lease and any payload the site had already sealed to a device. A
33
- * replayed enqueue inside the two-minute freshness window was therefore a
34
- * way to yank a job back from the machine running it — the `release` bug of
35
- * §4.2, rediscovered on the other plane.
36
- *
37
- * So a known id returns what is already routing, unchanged. A site that
38
- * restarts and republishes its queue is the normal case, and it must not
39
- * disturb work in flight.
40
- */
41
- enqueue(input) {
42
- const existing = this.#jobs.get(input.id);
43
- if (existing) return existing;
44
- const job = {
45
- id: input.id,
46
- siteId: input.siteId,
47
- stub: input.stub,
48
- state: "queued"
49
- };
50
- this.#jobs.set(job.id, job);
51
- return job;
52
- }
53
- job(jobId) {
54
- return this.#jobs.get(jobId);
55
- }
56
- jobs() {
57
- return [...this.#jobs.values()];
58
- }
59
- /** Jobs a site must seal for, right now. */
60
- awaiting(siteId) {
61
- return this.jobs().filter(
62
- (j) => j.siteId === siteId && j.state === "awaiting-payload"
63
- );
64
- }
65
- /** Sealed results waiting to go home. */
66
- finished(siteId) {
67
- return this.jobs().filter(
68
- (j) => j.siteId === siteId && j.state === "done" && j.result !== void 0
69
- );
70
- }
71
- seen(presence) {
72
- const existing = this.#presence.get(presence.runnerId);
73
- if (existing) {
74
- existing.lastSeenAt = presence.lastSeenAt;
75
- return existing;
16
+ var ok = (body) => ({ status: 200, body });
17
+ var REFUSALS = {
18
+ "not-found": {
19
+ status: 404,
20
+ body: { error: "not-found", message: "unknown job" }
21
+ },
22
+ "not-holder": {
23
+ status: 403,
24
+ body: {
25
+ error: "unauthorized",
26
+ message: "this runner does not hold the job"
76
27
  }
77
- const fresh = { ...presence, revoked: false };
78
- this.#presence.set(presence.runnerId, fresh);
79
- return fresh;
80
- }
81
- presence(runnerId) {
82
- return this.#presence.get(runnerId);
83
- }
84
- everyone() {
85
- return [...this.#presence.values()];
86
- }
87
- /**
88
- * Return a job to the queue, forgetting the claim.
89
- *
90
- * The stub survives; nothing is lost. That is `LEASE_RECLAIMABLE` and it is
91
- * why the awaiting-payload timeout is cheap to fire: the worst case is that
92
- * a device did nothing for ten seconds and another one gets a turn.
93
- */
94
- requeue(job) {
95
- job.state = "queued";
96
- delete job.claimedBy;
97
- delete job.awaitingUntil;
98
- delete job.payload;
99
- }
100
- /**
101
- * Fire whatever the clock says is due, and report it.
102
- *
103
- * Returns the jobs it requeued so a caller can log or surface them — a
104
- * timeout that fires invisibly is indistinguishable from a job that was
105
- * never claimed, and those want very different debugging.
106
- */
107
- sweep(now) {
108
- const requeued = [];
109
- for (const job of this.#jobs.values()) {
110
- if (job.state === "awaiting-payload" && (job.awaitingUntil ?? 0) <= now) {
111
- this.requeue(job);
112
- requeued.push(job);
113
- }
114
- const lease = job.claimedBy;
115
- if (lease && (job.state === "ready" || job.state === "running") && lease.leaseExpiresAt <= now) {
116
- this.requeue(job);
117
- requeued.push(job);
118
- }
28
+ },
29
+ "stale-lease": {
30
+ status: 403,
31
+ body: { error: "unauthorized", message: "that lease is no longer current" }
32
+ },
33
+ "not-ready": {
34
+ status: 409,
35
+ body: {
36
+ error: "not-ready",
37
+ message: "the site has not sealed this job yet"
119
38
  }
120
- return requeued;
121
39
  }
122
40
  };
123
-
124
- // src/daemon-plane.ts
125
- var ok = (body) => ({ status: 200, body });
126
41
  var fail = (status, error, message) => ({
127
42
  status,
128
43
  body: { error, message }
@@ -142,7 +57,7 @@ var DaemonPlane = class {
142
57
  * relay that substituted its own identity here could inject work — and would
143
58
  * need a private key to do it, which is why it has none.
144
59
  */
145
- pair(body) {
60
+ async pair(body) {
146
61
  const parsed = PairFixtureRequest.safeParse(body);
147
62
  if (!parsed.success) {
148
63
  return fail(400, "bad-request", "pair request failed schema validation");
@@ -175,7 +90,7 @@ var DaemonPlane = class {
175
90
  return fail(403, "unauthorized", "this device belongs to another owner");
176
91
  }
177
92
  const runnerId = approved.runnerId;
178
- this.#deps.state.seen({
93
+ await this.#deps.state.seen({
179
94
  runnerId,
180
95
  owner: parsed.data.owner,
181
96
  device: parsed.data.device,
@@ -189,12 +104,12 @@ var DaemonPlane = class {
189
104
  });
190
105
  }
191
106
  /** Every authenticated call: signature first, then consent, then work. */
192
- #authed(input, body, schema, run, options = {}) {
107
+ async #authed(input, body, schema, run, options = {}) {
193
108
  const signature = RequestSignature.safeParse(input.signature);
194
109
  if (!signature.success) {
195
110
  return fail(401, "unauthorized", "this request is not signed");
196
111
  }
197
- const known = this.#deps.state.presence(signature.data.runnerId);
112
+ const known = await this.#deps.state.presence(signature.data.runnerId);
198
113
  if (!known) {
199
114
  return fail(401, "unauthorized", "this runner is not recognised");
200
115
  }
@@ -218,41 +133,23 @@ var DaemonPlane = class {
218
133
  return run(parsed.data, known);
219
134
  }
220
135
  claim(auth, body) {
221
- return this.#authed(auth, body, ClaimRequest, (request, device) => {
136
+ return this.#authed(auth, body, ClaimRequest, async (request, device) => {
222
137
  if (request.runnerId !== device.runnerId) {
223
138
  return fail(401, "unauthorized", "runner id does not match the key");
224
139
  }
225
- const now = this.#deps.now();
226
- this.#deps.state.sweep(now);
227
- const kinds = new Set(request.capabilities.map((c) => c.kind));
228
- const granted = [];
229
- for (const job of this.#deps.state.jobs()) {
230
- if (granted.length >= request.max) break;
231
- if (job.state !== "queued") continue;
232
- if (job.siteId !== this.#deps.siteId) continue;
233
- if (!kinds.has(job.stub.kind)) continue;
234
- if (!this.#deps.projection.mayRunFor(device.owner, job.stub.owner)) {
235
- continue;
236
- }
237
- const leaseId = randomUUID();
238
- job.state = "awaiting-payload";
239
- job.claimedBy = {
240
- runnerId: device.runnerId,
241
- owner: device.owner,
242
- device: device.device,
243
- leaseId,
244
- leaseExpiresAt: now + this.#deps.leaseMs
245
- };
246
- job.awaitingUntil = now + AWAITING_PAYLOAD_MS;
247
- granted.push({
248
- ...job.stub,
249
- lease: {
250
- id: leaseId,
251
- runnerId: device.runnerId,
252
- expiresAt: job.claimedBy.leaseExpiresAt
253
- }
254
- });
255
- }
140
+ const granted = await this.#deps.state.claim({
141
+ runnerId: device.runnerId,
142
+ owner: device.owner,
143
+ device: device.device,
144
+ siteId: this.#deps.siteId,
145
+ kinds: new Set(request.capabilities.map((c) => c.kind)),
146
+ // The projection, collapsed to data the store can match on — a
147
+ // predicate does not travel.
148
+ owners: new Set(this.#deps.projection.ownersRunnableBy(device.owner)),
149
+ max: request.max,
150
+ leaseMs: this.#deps.leaseMs,
151
+ now: this.#deps.now()
152
+ });
256
153
  return ok({ jobs: granted, leaseMs: this.#deps.leaseMs });
257
154
  });
258
155
  }
@@ -267,20 +164,14 @@ var DaemonPlane = class {
267
164
  * awaiting-payload clock says otherwise.
268
165
  */
269
166
  fetch(auth, body) {
270
- return this.#authed(auth, body, FetchRequest, (request, device) => {
271
- const job = this.#deps.state.job(request.jobId);
272
- if (!job) return fail(404, "not-found", "unknown job");
273
- if (job.claimedBy?.runnerId !== device.runnerId) {
274
- return fail(403, "unauthorized", "this runner does not hold the job");
275
- }
276
- if (job.claimedBy.leaseId !== request.leaseId) {
277
- return fail(403, "unauthorized", "that lease is no longer current");
278
- }
279
- if (!job.payload) {
280
- return fail(409, "not-ready", "the site has not sealed this job yet");
281
- }
282
- job.state = "running";
283
- return ok({ envelope: job.payload });
167
+ return this.#authed(auth, body, FetchRequest, async (request, device) => {
168
+ const taken = await this.#deps.state.takePayload({
169
+ jobId: request.jobId,
170
+ runnerId: device.runnerId,
171
+ leaseId: request.leaseId
172
+ });
173
+ if ("refused" in taken) return REFUSALS[taken.refused];
174
+ return ok({ envelope: taken.envelope });
284
175
  });
285
176
  }
286
177
  /**
@@ -293,19 +184,15 @@ var DaemonPlane = class {
293
184
  * only verifiable there.
294
185
  */
295
186
  result(auth, body) {
296
- return this.#authed(auth, body, ResultRequest, (request, device) => {
297
- const job = this.#deps.state.job(request.jobId);
298
- if (!job) return fail(404, "not-found", "unknown job");
299
- if (job.claimedBy?.runnerId !== device.runnerId) {
300
- return fail(403, "unauthorized", "this runner does not hold the job");
301
- }
302
- if (job.state === "done") {
303
- return ok({ accepted: false, state: job.state });
304
- }
305
- job.result = request.envelope;
306
- job.disposition = request.disposition;
307
- job.state = "done";
308
- return ok({ accepted: true, state: job.state });
187
+ return this.#authed(auth, body, ResultRequest, async (request, device) => {
188
+ const recorded = await this.#deps.state.complete({
189
+ jobId: request.jobId,
190
+ runnerId: device.runnerId,
191
+ envelope: request.envelope,
192
+ disposition: request.disposition
193
+ });
194
+ if ("refused" in recorded) return REFUSALS[recorded.refused];
195
+ return ok(recorded);
309
196
  });
310
197
  }
311
198
  heartbeat(auth, body) {
@@ -313,20 +200,20 @@ var DaemonPlane = class {
313
200
  auth,
314
201
  body,
315
202
  HeartbeatRequest,
316
- (request, device) => {
203
+ async (request, device) => {
317
204
  const now = this.#deps.now();
318
- this.#deps.state.sweep(now);
319
- const known = this.#deps.state.presence(device.runnerId);
205
+ await this.#deps.state.sweep(now);
206
+ const known = await this.#deps.state.presence(device.runnerId);
320
207
  const consent = this.#deps.projection.consentFor(
321
208
  device.owner,
322
209
  this.#deps.siteId
323
210
  );
324
211
  const revoked = consent === null;
325
212
  if (known) known.revoked = revoked;
326
- const lost = request.activeLeases.filter(({ jobId, leaseId }) => {
327
- const job = this.#deps.state.job(jobId);
328
- return job?.claimedBy?.leaseId !== leaseId;
329
- }).map(({ jobId }) => jobId);
213
+ const lost = await this.#deps.state.lostLeases(
214
+ device.runnerId,
215
+ request.activeLeases
216
+ );
330
217
  return ok({
331
218
  revoked,
332
219
  cancel: [],
@@ -339,15 +226,11 @@ var DaemonPlane = class {
339
226
  );
340
227
  }
341
228
  release(auth, body) {
342
- return this.#authed(auth, body, ReleaseRequest, (request, device) => {
343
- const released = [];
344
- for (const { jobId, leaseId } of request.leases) {
345
- const job = this.#deps.state.job(jobId);
346
- if (!job || job.claimedBy?.runnerId !== device.runnerId) continue;
347
- if (job.claimedBy.leaseId !== leaseId) continue;
348
- this.#deps.state.requeue(job);
349
- released.push(jobId);
350
- }
229
+ return this.#authed(auth, body, ReleaseRequest, async (request, device) => {
230
+ const released = await this.#deps.state.releaseLeases({
231
+ runnerId: device.runnerId,
232
+ leases: request.leases
233
+ });
351
234
  return ok({ released });
352
235
  });
353
236
  }
@@ -387,9 +270,9 @@ function jobRow(job, now) {
387
270
  <td>${job.result ? escape(job.disposition ?? "?") : "<span class='dim'>\u2014</span>"}</td>
388
271
  </tr>`;
389
272
  }
390
- function debugPage(state, now) {
391
- const jobs = state.jobs();
392
- const devices = state.everyone();
273
+ async function debugPage(state, now) {
274
+ const jobs = await state.jobs();
275
+ const devices = await state.everyone();
393
276
  return `<!doctype html>
394
277
  <html><head><meta charset="utf-8"><title>byollm relay \u2014 debug</title>
395
278
  <meta http-equiv="refresh" content="1">
@@ -541,6 +424,37 @@ var Projection = class {
541
424
  (c) => c.owner === owner && c.siteId === siteId
542
425
  ) ?? null;
543
426
  }
427
+ /**
428
+ * Every owner whose work this device's owner may run, as a list.
429
+ *
430
+ * The same question {@link mayRunFor} answers, asked in the direction a
431
+ * *store* can use. That difference is the crux of making `claim` atomic
432
+ * (cloud_006 §3.2).
433
+ *
434
+ * Today `claim` scans every job and calls `mayRunFor` per candidate, which
435
+ * works because the projection is a local object. A shared routing store
436
+ * cannot do that: the filter has to travel to the store, and a predicate
437
+ * does not travel — you cannot send a closure to Valkey. So the projection
438
+ * is collapsed to **data** here and handed over as a set the store can
439
+ * match on.
440
+ *
441
+ * That the collapse is possible at all is a property of the design worth
442
+ * noticing: `mayRunFor` is a finite lookup over consent and rosters, not a
443
+ * computation over the jobs. If it ever became job-dependent — "may run
444
+ * work of this size", say — an atomic claim would stop being expressible,
445
+ * and that is the moment to argue rather than to add a parameter.
446
+ *
447
+ * The owner is always included: a device runs its owner's work, and the
448
+ * relay checks that before it checks a roster.
449
+ */
450
+ ownersRunnableBy(deviceOwner) {
451
+ const owners = /* @__PURE__ */ new Set([deviceOwner]);
452
+ for (const roster of this.#fixture.rosters) {
453
+ if (roster.owner !== deviceOwner) continue;
454
+ for (const member of roster.members) owners.add(member);
455
+ }
456
+ return [...owners];
457
+ }
544
458
  /**
545
459
  * May this device's owner run work belonging to `jobOwner`?
546
460
  *
@@ -608,7 +522,7 @@ var SitePlane = class {
608
522
  * stranger presence, claims and lease ids, and "who is online right now" is
609
523
  * exactly the fact a blind relay is otherwise so careful not to reveal.
610
524
  */
611
- #authed(auth, body, schema, siteIdOf, run) {
525
+ async #authed(auth, body, schema, siteIdOf, run) {
612
526
  const signature = RequestSignature2.safeParse(auth.signature);
613
527
  if (!signature.success) {
614
528
  return fail2(401, "unauthorized", "this request is not signed");
@@ -644,8 +558,8 @@ var SitePlane = class {
644
558
  body,
645
559
  EnqueueRequest,
646
560
  (request) => request.siteId,
647
- (request, siteId) => {
648
- const job = this.#deps.state.enqueue({
561
+ async (request, siteId) => {
562
+ const job = await this.#deps.state.enqueue({
649
563
  id: request.stub.id,
650
564
  siteId,
651
565
  stub: request.stub
@@ -668,9 +582,9 @@ var SitePlane = class {
668
582
  { siteId },
669
583
  QueryRequest,
670
584
  (request) => request.siteId,
671
- (_request, site) => {
672
- this.#deps.state.sweep(this.#deps.now());
673
- const jobs = this.#deps.state.awaiting(site).map((job) => ({
585
+ async (_request, site) => {
586
+ await this.#deps.state.sweep(this.#deps.now());
587
+ const jobs = (await this.#deps.state.awaiting(site)).map((job) => ({
674
588
  jobId: job.id,
675
589
  // Non-null by construction: `awaiting` only returns claimed jobs.
676
590
  // The optional chain is here so a future state-machine edit that
@@ -692,22 +606,20 @@ var SitePlane = class {
692
606
  body,
693
607
  PayloadRequest,
694
608
  (request) => request.siteId,
695
- (request, siteId) => {
696
- const job = this.#deps.state.job(request.jobId);
697
- if (job?.siteId !== siteId) {
698
- return fail2(404, "not-found", "unknown job");
699
- }
700
- if (job.state !== "awaiting-payload") {
701
- return fail2(
609
+ async (request, siteId) => {
610
+ const sealed = await this.#deps.state.seal({
611
+ jobId: request.jobId,
612
+ siteId,
613
+ envelope: request.envelope
614
+ });
615
+ if ("refused" in sealed) {
616
+ return sealed.refused === "not-found" ? fail2(404, "not-found", "unknown job") : fail2(
702
617
  409,
703
618
  "too-late",
704
- `job is ${job.state}, not awaiting payload`
619
+ `job is ${sealed.was ?? "gone"}, not awaiting payload`
705
620
  );
706
621
  }
707
- job.payload = request.envelope;
708
- job.state = "ready";
709
- delete job.awaitingUntil;
710
- return ok2({ jobId: job.id, state: job.state });
622
+ return ok2({ jobId: request.jobId, state: sealed.state });
711
623
  }
712
624
  );
713
625
  }
@@ -718,8 +630,8 @@ var SitePlane = class {
718
630
  { siteId },
719
631
  QueryRequest,
720
632
  (request) => request.siteId,
721
- (_request, site) => {
722
- const jobs = this.#deps.state.finished(site).map((job) => ({
633
+ async (_request, site) => {
634
+ const jobs = (await this.#deps.state.finished(site)).map((job) => ({
723
635
  jobId: job.id,
724
636
  envelope: job.result,
725
637
  disposition: job.disposition,
@@ -739,6 +651,237 @@ var SitePlane = class {
739
651
  }
740
652
  };
741
653
 
654
+ // src/state.ts
655
+ import { randomUUID } from "crypto";
656
+ var AWAITING_PAYLOAD_MS = 1e4;
657
+ var RelayState = class {
658
+ #jobs = /* @__PURE__ */ new Map();
659
+ #presence = /* @__PURE__ */ new Map();
660
+ /**
661
+ * Take a stub for routing. The payload is not here and will not be.
662
+ *
663
+ * **Idempotent by job id, and that is a security property rather than a
664
+ * convenience.** Site-plane calls are authenticated by signature, and
665
+ * byollm_009 §4.2's argument for signing the request instead of a
666
+ * server-issued nonce rests entirely on every write being idempotent per the
667
+ * instance it names. This one was not: re-enqueueing a known id built a
668
+ * fresh `queued` job over the top of the old one, discarding a live claim,
669
+ * its lease and any payload the site had already sealed to a device. A
670
+ * replayed enqueue inside the two-minute freshness window was therefore a
671
+ * way to yank a job back from the machine running it — the `release` bug of
672
+ * §4.2, rediscovered on the other plane.
673
+ *
674
+ * So a known id returns what is already routing, unchanged. A site that
675
+ * restarts and republishes its queue is the normal case, and it must not
676
+ * disturb work in flight.
677
+ */
678
+ enqueue(input) {
679
+ const existing = this.#jobs.get(input.id);
680
+ if (existing) return Promise.resolve(existing);
681
+ const job = {
682
+ id: input.id,
683
+ siteId: input.siteId,
684
+ stub: input.stub,
685
+ state: "queued"
686
+ };
687
+ this.#jobs.set(job.id, job);
688
+ return Promise.resolve(job);
689
+ }
690
+ job(jobId) {
691
+ return Promise.resolve(this.#jobs.get(jobId));
692
+ }
693
+ jobs() {
694
+ return Promise.resolve([...this.#jobs.values()]);
695
+ }
696
+ /** Jobs a site must seal for, right now. */
697
+ async awaiting(siteId) {
698
+ return (await this.jobs()).filter(
699
+ (j) => j.siteId === siteId && j.state === "awaiting-payload"
700
+ );
701
+ }
702
+ /** Sealed results waiting to go home. */
703
+ async finished(siteId) {
704
+ return (await this.jobs()).filter(
705
+ (j) => j.siteId === siteId && j.state === "done" && j.result !== void 0
706
+ );
707
+ }
708
+ /**
709
+ * Claim work — one operation, because it has to be.
710
+ *
711
+ * Moved here wholesale from `DaemonPlane`, where it was a scan followed by
712
+ * per-job mutation. Nothing about the *decision* changed; what changed is
713
+ * that a store can now implement it, because the filter and the write are
714
+ * one call rather than a loop the caller drives.
715
+ *
716
+ * The order of the guards is worth preserving as-is when this becomes a Lua
717
+ * script: cheapest first, and `owners` last because it is the only one that
718
+ * needed the projection.
719
+ */
720
+ async claim(input) {
721
+ await this.sweep(input.now);
722
+ const granted = [];
723
+ for (const job of this.#jobs.values()) {
724
+ if (granted.length >= input.max) break;
725
+ if (job.state !== "queued") continue;
726
+ if (job.siteId !== input.siteId) continue;
727
+ if (!input.kinds.has(job.stub.kind)) continue;
728
+ if (!input.owners.has(job.stub.owner)) continue;
729
+ const leaseId = randomUUID();
730
+ job.state = "awaiting-payload";
731
+ job.claimedBy = {
732
+ runnerId: input.runnerId,
733
+ owner: input.owner,
734
+ device: input.device,
735
+ leaseId,
736
+ leaseExpiresAt: input.now + input.leaseMs
737
+ };
738
+ job.awaitingUntil = input.now + AWAITING_PAYLOAD_MS;
739
+ granted.push({
740
+ ...job.stub,
741
+ lease: {
742
+ id: leaseId,
743
+ runnerId: input.runnerId,
744
+ expiresAt: job.claimedBy.leaseExpiresAt
745
+ }
746
+ });
747
+ }
748
+ return granted;
749
+ }
750
+ /**
751
+ * Hand over the sealed payload to the device that holds the lease.
752
+ *
753
+ * The read and the state transition are one operation for the same reason
754
+ * `claim` is: `running` must be set by whoever was told the envelope, or two
755
+ * replicas can both hand out the same work and both believe they were first.
756
+ */
757
+ takePayload(input) {
758
+ const job = this.#jobs.get(input.jobId);
759
+ if (!job) return Promise.resolve({ refused: "not-found" });
760
+ if (job.claimedBy?.runnerId !== input.runnerId) {
761
+ return Promise.resolve({ refused: "not-holder" });
762
+ }
763
+ if (job.claimedBy.leaseId !== input.leaseId) {
764
+ return Promise.resolve({ refused: "stale-lease" });
765
+ }
766
+ if (!job.payload) return Promise.resolve({ refused: "not-ready" });
767
+ job.state = "running";
768
+ return Promise.resolve({ envelope: job.payload });
769
+ }
770
+ /**
771
+ * Record a finished job.
772
+ *
773
+ * `RESULT_IDEMPOTENT` lives here rather than in the caller: a replayed
774
+ * result must be a no-op decided by the same operation that would have
775
+ * written it, or two replicas can both decide they were the first.
776
+ */
777
+ complete(input) {
778
+ const job = this.#jobs.get(input.jobId);
779
+ if (!job) return Promise.resolve({ refused: "not-found" });
780
+ if (job.claimedBy?.runnerId !== input.runnerId) {
781
+ return Promise.resolve({ refused: "not-holder" });
782
+ }
783
+ if (job.state === "done") {
784
+ return Promise.resolve({ accepted: false, state: job.state });
785
+ }
786
+ job.result = input.envelope;
787
+ job.disposition = input.disposition;
788
+ job.state = "done";
789
+ return Promise.resolve({ accepted: true, state: job.state });
790
+ }
791
+ /** Give back leases this runner holds, naming each grant it means. */
792
+ releaseLeases(input) {
793
+ const released = [];
794
+ for (const { jobId, leaseId } of input.leases) {
795
+ const job = this.#jobs.get(jobId);
796
+ if (!job || job.claimedBy?.runnerId !== input.runnerId) continue;
797
+ if (job.claimedBy.leaseId !== leaseId) continue;
798
+ this.#requeue(job);
799
+ released.push(jobId);
800
+ }
801
+ return Promise.resolve(released);
802
+ }
803
+ /**
804
+ * Take a site's sealed payload for a claimed job.
805
+ *
806
+ * Refuses anything not `awaiting-payload`, which is what makes the timeout
807
+ * mean something: a late seal must not land on a claim that has moved.
808
+ */
809
+ seal(input) {
810
+ const job = this.#jobs.get(input.jobId);
811
+ if (job?.siteId !== input.siteId) {
812
+ return Promise.resolve({ refused: "not-found" });
813
+ }
814
+ if (job.state !== "awaiting-payload") {
815
+ return Promise.resolve({ refused: "too-late", was: job.state });
816
+ }
817
+ job.payload = input.envelope;
818
+ job.state = "ready";
819
+ delete job.awaitingUntil;
820
+ return Promise.resolve({ state: job.state });
821
+ }
822
+ /** Which of these leases this runner no longer holds. */
823
+ lostLeases(runnerId, active) {
824
+ void runnerId;
825
+ return Promise.resolve(
826
+ active.filter(({ jobId, leaseId }) => {
827
+ const job = this.#jobs.get(jobId);
828
+ return job?.claimedBy?.leaseId !== leaseId;
829
+ }).map(({ jobId }) => jobId)
830
+ );
831
+ }
832
+ seen(presence) {
833
+ const existing = this.#presence.get(presence.runnerId);
834
+ if (existing) {
835
+ existing.lastSeenAt = presence.lastSeenAt;
836
+ return Promise.resolve(existing);
837
+ }
838
+ const fresh = { ...presence, revoked: false };
839
+ this.#presence.set(presence.runnerId, fresh);
840
+ return Promise.resolve(fresh);
841
+ }
842
+ presence(runnerId) {
843
+ return Promise.resolve(this.#presence.get(runnerId));
844
+ }
845
+ everyone() {
846
+ return Promise.resolve([...this.#presence.values()]);
847
+ }
848
+ /**
849
+ * Return a job to the queue, forgetting the claim.
850
+ *
851
+ * The stub survives; nothing is lost. That is `LEASE_RECLAIMABLE` and it is
852
+ * why the awaiting-payload timeout is cheap to fire: the worst case is that
853
+ * a device did nothing for ten seconds and another one gets a turn.
854
+ */
855
+ #requeue(job) {
856
+ job.state = "queued";
857
+ delete job.claimedBy;
858
+ delete job.awaitingUntil;
859
+ delete job.payload;
860
+ }
861
+ /**
862
+ * Fire whatever the clock says is due, and report it.
863
+ *
864
+ * Returns the jobs it requeued so a caller can log or surface them — a
865
+ * timeout that fires invisibly is indistinguishable from a job that was
866
+ * never claimed, and those want very different debugging.
867
+ */
868
+ sweep(now) {
869
+ const requeued = [];
870
+ for (const job of this.#jobs.values()) {
871
+ if (job.state === "awaiting-payload" && (job.awaitingUntil ?? 0) <= now) {
872
+ this.#requeue(job);
873
+ requeued.push(job);
874
+ }
875
+ const lease = job.claimedBy;
876
+ if (lease && (job.state === "ready" || job.state === "running") && lease.leaseExpiresAt <= now) {
877
+ this.#requeue(job);
878
+ requeued.push(job);
879
+ }
880
+ }
881
+ return Promise.resolve(requeued);
882
+ }
883
+ };
884
+
742
885
  // src/index.ts
743
886
  var Relay = class {
744
887
  state;
@@ -779,15 +922,16 @@ var Relay = class {
779
922
  * site vanished should return to the queue without waiting for someone to
780
923
  * ask about it.
781
924
  */
782
- sweep() {
783
- return { requeued: this.state.sweep(this.#now()).map((j) => j.id) };
925
+ async sweep() {
926
+ const requeued = await this.state.sweep(this.#now());
927
+ return { requeued: requeued.map((j) => j.id) };
784
928
  }
785
929
  /** The whole HTTP surface. */
786
930
  async handle(request) {
787
931
  const url = new URL(request.url);
788
932
  const path = url.pathname;
789
933
  if (path === "/debug" || path === `${this.#basePath}/debug`) {
790
- return new Response(debugPage(this.state, this.#now()), {
934
+ return new Response(await debugPage(this.state, this.#now()), {
791
935
  headers: { "content-type": "text/html; charset=utf-8" }
792
936
  });
793
937
  }
@@ -805,19 +949,25 @@ var Relay = class {
805
949
  signature: signatureFrom(request.headers, "x-byollm-site")
806
950
  };
807
951
  if (path === "/relay/site/enqueue") {
808
- return json(this.#site.enqueue(siteAuth, body));
952
+ return json(await this.#site.enqueue(siteAuth, body));
809
953
  }
810
954
  if (path === "/relay/site/payload") {
811
- return json(this.#site.payload(siteAuth, body));
955
+ return json(await this.#site.payload(siteAuth, body));
812
956
  }
813
957
  if (path === "/relay/site/pending") {
814
958
  return json(
815
- this.#site.pending(siteAuth, url.searchParams.get("siteId") ?? "")
959
+ await this.#site.pending(
960
+ siteAuth,
961
+ url.searchParams.get("siteId") ?? ""
962
+ )
816
963
  );
817
964
  }
818
965
  if (path === "/relay/site/results") {
819
966
  return json(
820
- this.#site.results(siteAuth, url.searchParams.get("siteId") ?? "")
967
+ await this.#site.results(
968
+ siteAuth,
969
+ url.searchParams.get("siteId") ?? ""
970
+ )
821
971
  );
822
972
  }
823
973
  if (!path.startsWith(`${this.#basePath}/`)) {
@@ -825,17 +975,17 @@ var Relay = class {
825
975
  }
826
976
  switch (auth.endpoint) {
827
977
  case "pair":
828
- return json(this.#daemon.pair(body));
978
+ return json(await this.#daemon.pair(body));
829
979
  case "claim":
830
- return json(this.#daemon.claim(auth, body));
980
+ return json(await this.#daemon.claim(auth, body));
831
981
  case "fetch":
832
- return json(this.#daemon.fetch(auth, body));
982
+ return json(await this.#daemon.fetch(auth, body));
833
983
  case "result":
834
- return json(this.#daemon.result(auth, body));
984
+ return json(await this.#daemon.result(auth, body));
835
985
  case "heartbeat":
836
- return json(this.#daemon.heartbeat(auth, body));
986
+ return json(await this.#daemon.heartbeat(auth, body));
837
987
  case "release":
838
- return json(this.#daemon.release(auth, body));
988
+ return json(await this.#daemon.release(auth, body));
839
989
  default:
840
990
  return json({ status: 404, body: { error: "not-found" } });
841
991
  }