@byollm/relay 0.1.0-alpha.11 → 0.1.0-alpha.13

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
@@ -8,121 +8,37 @@ import {
8
8
  ResultRequest,
9
9
  RequestSignature,
10
10
  keyId,
11
+ MAX_CLOCK_SKEW_MS,
11
12
  verifyRequest,
12
13
  verifyPublicIdentity,
13
14
  PublicIdentity
14
15
  } from "@byollm/protocol";
15
- import { randomUUID } from "crypto";
16
16
  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;
17
+ var ok = (body) => ({ status: 200, body });
18
+ var REFUSALS = {
19
+ "not-found": {
20
+ status: 404,
21
+ body: { error: "not-found", message: "unknown job" }
22
+ },
23
+ "not-holder": {
24
+ status: 403,
25
+ body: {
26
+ error: "unauthorized",
27
+ message: "this runner does not hold the job"
76
28
  }
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
- }
29
+ },
30
+ "stale-lease": {
31
+ status: 403,
32
+ body: { error: "unauthorized", message: "that lease is no longer current" }
33
+ },
34
+ "not-ready": {
35
+ status: 409,
36
+ body: {
37
+ error: "not-ready",
38
+ message: "the site has not sealed this job yet"
119
39
  }
120
- return requeued;
121
40
  }
122
41
  };
123
-
124
- // src/daemon-plane.ts
125
- var ok = (body) => ({ status: 200, body });
126
42
  var fail = (status, error, message) => ({
127
43
  status,
128
44
  body: { error, message }
@@ -142,7 +58,7 @@ var DaemonPlane = class {
142
58
  * relay that substituted its own identity here could inject work — and would
143
59
  * need a private key to do it, which is why it has none.
144
60
  */
145
- pair(body) {
61
+ async pair(body) {
146
62
  const parsed = PairFixtureRequest.safeParse(body);
147
63
  if (!parsed.success) {
148
64
  return fail(400, "bad-request", "pair request failed schema validation");
@@ -175,11 +91,10 @@ var DaemonPlane = class {
175
91
  return fail(403, "unauthorized", "this device belongs to another owner");
176
92
  }
177
93
  const runnerId = approved.runnerId;
178
- this.#deps.state.seen({
94
+ await this.#deps.state.seen({
179
95
  runnerId,
180
96
  owner: parsed.data.owner,
181
- device: parsed.data.device,
182
- lastSeenAt: this.#deps.now()
97
+ device: parsed.data.device
183
98
  });
184
99
  return ok({
185
100
  protocolVersion: PROTOCOL_VERSION,
@@ -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
  }
@@ -205,6 +120,7 @@ var DaemonPlane = class {
205
120
  signature: signature.data,
206
121
  now: this.#deps.now()
207
122
  });
123
+ if (failure === "stale") return this.#clockSkew();
208
124
  if (failure) return fail(401, "unauthorized", "signature check failed");
209
125
  const revoked = this.#deps.projection.consentFor(known.owner, this.#deps.siteId) === null;
210
126
  if (revoked && options.allowRevoked !== true) {
@@ -217,42 +133,47 @@ var DaemonPlane = class {
217
133
  }
218
134
  return run(parsed.data, known);
219
135
  }
136
+ /**
137
+ * A clock too far from ours, said plainly and with the number to fix it by.
138
+ *
139
+ * Its own error code rather than a generic `unauthorized`, because it is the
140
+ * one refusal a retry can never fix and an `ntpdate` always can — the same
141
+ * reasoning `version-unsupported` already carries on the daemon side. A
142
+ * daemon that reports this as a generic rejection sends its owner looking at
143
+ * their network.
144
+ *
145
+ * `serverTime` is included so the far side can say *how far off* rather than
146
+ * *that something is wrong*. It is not a disclosure: the heartbeat response
147
+ * returns the same value, and so does every `Date` header.
148
+ */
149
+ #clockSkew() {
150
+ return {
151
+ status: 401,
152
+ body: {
153
+ error: "clock-skew",
154
+ message: "this request's timestamp is too far from the server's clock; check the machine's time and try again",
155
+ serverTime: this.#deps.now(),
156
+ maxSkewMs: MAX_CLOCK_SKEW_MS
157
+ }
158
+ };
159
+ }
220
160
  claim(auth, body) {
221
- return this.#authed(auth, body, ClaimRequest, (request, device) => {
161
+ return this.#authed(auth, body, ClaimRequest, async (request, device) => {
222
162
  if (request.runnerId !== device.runnerId) {
223
163
  return fail(401, "unauthorized", "runner id does not match the key");
224
164
  }
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
- }
165
+ const granted = await this.#deps.state.claim({
166
+ runnerId: device.runnerId,
167
+ owner: device.owner,
168
+ device: device.device,
169
+ siteId: this.#deps.siteId,
170
+ kinds: new Set(request.capabilities.map((c) => c.kind)),
171
+ // The projection, collapsed to data the store can match on — a
172
+ // predicate does not travel.
173
+ owners: new Set(this.#deps.projection.ownersRunnableBy(device.owner)),
174
+ max: request.max,
175
+ leaseMs: this.#deps.leaseMs
176
+ });
256
177
  return ok({ jobs: granted, leaseMs: this.#deps.leaseMs });
257
178
  });
258
179
  }
@@ -267,20 +188,14 @@ var DaemonPlane = class {
267
188
  * awaiting-payload clock says otherwise.
268
189
  */
269
190
  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 });
191
+ return this.#authed(auth, body, FetchRequest, async (request, device) => {
192
+ const taken = await this.#deps.state.takePayload({
193
+ jobId: request.jobId,
194
+ runnerId: device.runnerId,
195
+ leaseId: request.leaseId
196
+ });
197
+ if ("refused" in taken) return REFUSALS[taken.refused];
198
+ return ok({ envelope: taken.envelope });
284
199
  });
285
200
  }
286
201
  /**
@@ -293,19 +208,15 @@ var DaemonPlane = class {
293
208
  * only verifiable there.
294
209
  */
295
210
  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 });
211
+ return this.#authed(auth, body, ResultRequest, async (request, device) => {
212
+ const recorded = await this.#deps.state.complete({
213
+ jobId: request.jobId,
214
+ runnerId: device.runnerId,
215
+ envelope: request.envelope,
216
+ disposition: request.disposition
217
+ });
218
+ if ("refused" in recorded) return REFUSALS[recorded.refused];
219
+ return ok(recorded);
309
220
  });
310
221
  }
311
222
  heartbeat(auth, body) {
@@ -313,20 +224,20 @@ var DaemonPlane = class {
313
224
  auth,
314
225
  body,
315
226
  HeartbeatRequest,
316
- (request, device) => {
227
+ async (request, device) => {
317
228
  const now = this.#deps.now();
318
- this.#deps.state.sweep(now);
319
- const known = this.#deps.state.presence(device.runnerId);
229
+ await this.#deps.state.sweep();
230
+ const known = await this.#deps.state.presence(device.runnerId);
320
231
  const consent = this.#deps.projection.consentFor(
321
232
  device.owner,
322
233
  this.#deps.siteId
323
234
  );
324
235
  const revoked = consent === null;
325
236
  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);
237
+ const lost = await this.#deps.state.lostLeases(
238
+ device.runnerId,
239
+ request.activeLeases
240
+ );
330
241
  return ok({
331
242
  revoked,
332
243
  cancel: [],
@@ -339,15 +250,11 @@ var DaemonPlane = class {
339
250
  );
340
251
  }
341
252
  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
- }
253
+ return this.#authed(auth, body, ReleaseRequest, async (request, device) => {
254
+ const released = await this.#deps.state.releaseLeases({
255
+ runnerId: device.runnerId,
256
+ leases: request.leases
257
+ });
351
258
  return ok({ released });
352
259
  });
353
260
  }
@@ -387,9 +294,9 @@ function jobRow(job, now) {
387
294
  <td>${job.result ? escape(job.disposition ?? "?") : "<span class='dim'>\u2014</span>"}</td>
388
295
  </tr>`;
389
296
  }
390
- function debugPage(state, now) {
391
- const jobs = state.jobs();
392
- const devices = state.everyone();
297
+ async function debugPage(state, now) {
298
+ const jobs = await state.jobs();
299
+ const devices = await state.everyone();
393
300
  return `<!doctype html>
394
301
  <html><head><meta charset="utf-8"><title>byollm relay \u2014 debug</title>
395
302
  <meta http-equiv="refresh" content="1">
@@ -541,6 +448,37 @@ var Projection = class {
541
448
  (c) => c.owner === owner && c.siteId === siteId
542
449
  ) ?? null;
543
450
  }
451
+ /**
452
+ * Every owner whose work this device's owner may run, as a list.
453
+ *
454
+ * The same question {@link mayRunFor} answers, asked in the direction a
455
+ * *store* can use. That difference is the crux of making `claim` atomic
456
+ * (cloud_006 §3.2).
457
+ *
458
+ * Today `claim` scans every job and calls `mayRunFor` per candidate, which
459
+ * works because the projection is a local object. A shared routing store
460
+ * cannot do that: the filter has to travel to the store, and a predicate
461
+ * does not travel — you cannot send a closure to Valkey. So the projection
462
+ * is collapsed to **data** here and handed over as a set the store can
463
+ * match on.
464
+ *
465
+ * That the collapse is possible at all is a property of the design worth
466
+ * noticing: `mayRunFor` is a finite lookup over consent and rosters, not a
467
+ * computation over the jobs. If it ever became job-dependent — "may run
468
+ * work of this size", say — an atomic claim would stop being expressible,
469
+ * and that is the moment to argue rather than to add a parameter.
470
+ *
471
+ * The owner is always included: a device runs its owner's work, and the
472
+ * relay checks that before it checks a roster.
473
+ */
474
+ ownersRunnableBy(deviceOwner) {
475
+ const owners = /* @__PURE__ */ new Set([deviceOwner]);
476
+ for (const roster of this.#fixture.rosters) {
477
+ if (roster.owner !== deviceOwner) continue;
478
+ for (const member of roster.members) owners.add(member);
479
+ }
480
+ return [...owners];
481
+ }
544
482
  /**
545
483
  * May this device's owner run work belonging to `jobOwner`?
546
484
  *
@@ -608,7 +546,7 @@ var SitePlane = class {
608
546
  * stranger presence, claims and lease ids, and "who is online right now" is
609
547
  * exactly the fact a blind relay is otherwise so careful not to reveal.
610
548
  */
611
- #authed(auth, body, schema, siteIdOf, run) {
549
+ async #authed(auth, body, schema, siteIdOf, run) {
612
550
  const signature = RequestSignature2.safeParse(auth.signature);
613
551
  if (!signature.success) {
614
552
  return fail2(401, "unauthorized", "this request is not signed");
@@ -644,8 +582,8 @@ var SitePlane = class {
644
582
  body,
645
583
  EnqueueRequest,
646
584
  (request) => request.siteId,
647
- (request, siteId) => {
648
- const job = this.#deps.state.enqueue({
585
+ async (request, siteId) => {
586
+ const job = await this.#deps.state.enqueue({
649
587
  id: request.stub.id,
650
588
  siteId,
651
589
  stub: request.stub
@@ -668,9 +606,9 @@ var SitePlane = class {
668
606
  { siteId },
669
607
  QueryRequest,
670
608
  (request) => request.siteId,
671
- (_request, site) => {
672
- this.#deps.state.sweep(this.#deps.now());
673
- const jobs = this.#deps.state.awaiting(site).map((job) => ({
609
+ async (_request, site) => {
610
+ await this.#deps.state.sweep();
611
+ const jobs = (await this.#deps.state.awaiting(site)).map((job) => ({
674
612
  jobId: job.id,
675
613
  // Non-null by construction: `awaiting` only returns claimed jobs.
676
614
  // The optional chain is here so a future state-machine edit that
@@ -692,22 +630,20 @@ var SitePlane = class {
692
630
  body,
693
631
  PayloadRequest,
694
632
  (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(
633
+ async (request, siteId) => {
634
+ const sealed = await this.#deps.state.seal({
635
+ jobId: request.jobId,
636
+ siteId,
637
+ envelope: request.envelope
638
+ });
639
+ if ("refused" in sealed) {
640
+ return sealed.refused === "not-found" ? fail2(404, "not-found", "unknown job") : fail2(
702
641
  409,
703
642
  "too-late",
704
- `job is ${job.state}, not awaiting payload`
643
+ `job is ${sealed.was ?? "gone"}, not awaiting payload`
705
644
  );
706
645
  }
707
- job.payload = request.envelope;
708
- job.state = "ready";
709
- delete job.awaitingUntil;
710
- return ok2({ jobId: job.id, state: job.state });
646
+ return ok2({ jobId: request.jobId, state: sealed.state });
711
647
  }
712
648
  );
713
649
  }
@@ -718,8 +654,8 @@ var SitePlane = class {
718
654
  { siteId },
719
655
  QueryRequest,
720
656
  (request) => request.siteId,
721
- (_request, site) => {
722
- const jobs = this.#deps.state.finished(site).map((job) => ({
657
+ async (_request, site) => {
658
+ const jobs = (await this.#deps.state.finished(site)).map((job) => ({
723
659
  jobId: job.id,
724
660
  envelope: job.result,
725
661
  disposition: job.disposition,
@@ -739,6 +675,248 @@ var SitePlane = class {
739
675
  }
740
676
  };
741
677
 
678
+ // src/state.ts
679
+ import { randomUUID } from "crypto";
680
+ var AWAITING_PAYLOAD_MS = 1e4;
681
+ var RelayState = class {
682
+ #jobs = /* @__PURE__ */ new Map();
683
+ #presence = /* @__PURE__ */ new Map();
684
+ #now;
685
+ constructor(options = {}) {
686
+ this.#now = options.now ?? Date.now;
687
+ }
688
+ /** The one clock every deadline in this store is stamped from. */
689
+ async now() {
690
+ return this.#now();
691
+ }
692
+ /**
693
+ * Take a stub for routing. The payload is not here and will not be.
694
+ *
695
+ * **Idempotent by job id, and that is a security property rather than a
696
+ * convenience.** Site-plane calls are authenticated by signature, and
697
+ * byollm_009 §4.2's argument for signing the request instead of a
698
+ * server-issued nonce rests entirely on every write being idempotent per the
699
+ * instance it names. This one was not: re-enqueueing a known id built a
700
+ * fresh `queued` job over the top of the old one, discarding a live claim,
701
+ * its lease and any payload the site had already sealed to a device. A
702
+ * replayed enqueue inside the two-minute freshness window was therefore a
703
+ * way to yank a job back from the machine running it — the `release` bug of
704
+ * §4.2, rediscovered on the other plane.
705
+ *
706
+ * So a known id returns what is already routing, unchanged. A site that
707
+ * restarts and republishes its queue is the normal case, and it must not
708
+ * disturb work in flight.
709
+ */
710
+ enqueue(input) {
711
+ const existing = this.#jobs.get(input.id);
712
+ if (existing) return Promise.resolve(existing);
713
+ const job = {
714
+ id: input.id,
715
+ siteId: input.siteId,
716
+ stub: input.stub,
717
+ state: "queued"
718
+ };
719
+ this.#jobs.set(job.id, job);
720
+ return Promise.resolve(job);
721
+ }
722
+ job(jobId) {
723
+ return Promise.resolve(this.#jobs.get(jobId));
724
+ }
725
+ jobs() {
726
+ return Promise.resolve([...this.#jobs.values()]);
727
+ }
728
+ /** Jobs a site must seal for, right now. */
729
+ async awaiting(siteId) {
730
+ return (await this.jobs()).filter(
731
+ (j) => j.siteId === siteId && j.state === "awaiting-payload"
732
+ );
733
+ }
734
+ /** Sealed results waiting to go home. */
735
+ async finished(siteId) {
736
+ return (await this.jobs()).filter(
737
+ (j) => j.siteId === siteId && j.state === "done" && j.result !== void 0
738
+ );
739
+ }
740
+ /**
741
+ * Claim work — one operation, because it has to be.
742
+ *
743
+ * Moved here wholesale from `DaemonPlane`, where it was a scan followed by
744
+ * per-job mutation. Nothing about the *decision* changed; what changed is
745
+ * that a store can now implement it, because the filter and the write are
746
+ * one call rather than a loop the caller drives.
747
+ *
748
+ * The order of the guards is worth preserving as-is when this becomes a Lua
749
+ * script: cheapest first, and `owners` last because it is the only one that
750
+ * needed the projection.
751
+ */
752
+ async claim(input) {
753
+ const now = await this.now();
754
+ await this.sweep();
755
+ const granted = [];
756
+ for (const job of this.#jobs.values()) {
757
+ if (granted.length >= input.max) break;
758
+ if (job.state !== "queued") continue;
759
+ if (job.siteId !== input.siteId) continue;
760
+ if (!input.kinds.has(job.stub.kind)) continue;
761
+ if (!input.owners.has(job.stub.owner)) continue;
762
+ const leaseId = randomUUID();
763
+ job.state = "awaiting-payload";
764
+ job.claimedBy = {
765
+ runnerId: input.runnerId,
766
+ owner: input.owner,
767
+ device: input.device,
768
+ leaseId,
769
+ leaseExpiresAt: now + input.leaseMs
770
+ };
771
+ job.awaitingUntil = now + AWAITING_PAYLOAD_MS;
772
+ granted.push({
773
+ ...job.stub,
774
+ lease: {
775
+ id: leaseId,
776
+ runnerId: input.runnerId,
777
+ expiresAt: job.claimedBy.leaseExpiresAt
778
+ }
779
+ });
780
+ }
781
+ return granted;
782
+ }
783
+ /**
784
+ * Hand over the sealed payload to the device that holds the lease.
785
+ *
786
+ * The read and the state transition are one operation for the same reason
787
+ * `claim` is: `running` must be set by whoever was told the envelope, or two
788
+ * replicas can both hand out the same work and both believe they were first.
789
+ */
790
+ takePayload(input) {
791
+ const job = this.#jobs.get(input.jobId);
792
+ if (!job) return Promise.resolve({ refused: "not-found" });
793
+ if (job.claimedBy?.runnerId !== input.runnerId) {
794
+ return Promise.resolve({ refused: "not-holder" });
795
+ }
796
+ if (job.claimedBy.leaseId !== input.leaseId) {
797
+ return Promise.resolve({ refused: "stale-lease" });
798
+ }
799
+ if (!job.payload) return Promise.resolve({ refused: "not-ready" });
800
+ job.state = "running";
801
+ return Promise.resolve({ envelope: job.payload });
802
+ }
803
+ /**
804
+ * Record a finished job.
805
+ *
806
+ * `RESULT_IDEMPOTENT` lives here rather than in the caller: a replayed
807
+ * result must be a no-op decided by the same operation that would have
808
+ * written it, or two replicas can both decide they were the first.
809
+ */
810
+ complete(input) {
811
+ const job = this.#jobs.get(input.jobId);
812
+ if (!job) return Promise.resolve({ refused: "not-found" });
813
+ if (job.claimedBy?.runnerId !== input.runnerId) {
814
+ return Promise.resolve({ refused: "not-holder" });
815
+ }
816
+ if (job.state === "done") {
817
+ return Promise.resolve({ accepted: false, state: job.state });
818
+ }
819
+ job.result = input.envelope;
820
+ job.disposition = input.disposition;
821
+ job.state = "done";
822
+ return Promise.resolve({ accepted: true, state: job.state });
823
+ }
824
+ /** Give back leases this runner holds, naming each grant it means. */
825
+ releaseLeases(input) {
826
+ const released = [];
827
+ for (const { jobId, leaseId } of input.leases) {
828
+ const job = this.#jobs.get(jobId);
829
+ if (!job || job.claimedBy?.runnerId !== input.runnerId) continue;
830
+ if (job.claimedBy.leaseId !== leaseId) continue;
831
+ this.#requeue(job);
832
+ released.push(jobId);
833
+ }
834
+ return Promise.resolve(released);
835
+ }
836
+ /**
837
+ * Take a site's sealed payload for a claimed job.
838
+ *
839
+ * Refuses anything not `awaiting-payload`, which is what makes the timeout
840
+ * mean something: a late seal must not land on a claim that has moved.
841
+ */
842
+ seal(input) {
843
+ const job = this.#jobs.get(input.jobId);
844
+ if (job?.siteId !== input.siteId) {
845
+ return Promise.resolve({ refused: "not-found" });
846
+ }
847
+ if (job.state !== "awaiting-payload") {
848
+ return Promise.resolve({ refused: "too-late", was: job.state });
849
+ }
850
+ job.payload = input.envelope;
851
+ job.state = "ready";
852
+ delete job.awaitingUntil;
853
+ return Promise.resolve({ state: job.state });
854
+ }
855
+ /** Which of these leases this runner no longer holds. */
856
+ lostLeases(runnerId, active) {
857
+ void runnerId;
858
+ return Promise.resolve(
859
+ active.filter(({ jobId, leaseId }) => {
860
+ const job = this.#jobs.get(jobId);
861
+ return job?.claimedBy?.leaseId !== leaseId;
862
+ }).map(({ jobId }) => jobId)
863
+ );
864
+ }
865
+ async seen(presence) {
866
+ const lastSeenAt = await this.now();
867
+ const existing = this.#presence.get(presence.runnerId);
868
+ if (existing) {
869
+ existing.lastSeenAt = lastSeenAt;
870
+ return existing;
871
+ }
872
+ const fresh = { ...presence, lastSeenAt, revoked: false };
873
+ this.#presence.set(presence.runnerId, fresh);
874
+ return fresh;
875
+ }
876
+ presence(runnerId) {
877
+ return Promise.resolve(this.#presence.get(runnerId));
878
+ }
879
+ everyone() {
880
+ return Promise.resolve([...this.#presence.values()]);
881
+ }
882
+ /**
883
+ * Return a job to the queue, forgetting the claim.
884
+ *
885
+ * The stub survives; nothing is lost. That is `LEASE_RECLAIMABLE` and it is
886
+ * why the awaiting-payload timeout is cheap to fire: the worst case is that
887
+ * a device did nothing for ten seconds and another one gets a turn.
888
+ */
889
+ #requeue(job) {
890
+ job.state = "queued";
891
+ delete job.claimedBy;
892
+ delete job.awaitingUntil;
893
+ delete job.payload;
894
+ }
895
+ /**
896
+ * Fire whatever the clock says is due, and report it.
897
+ *
898
+ * Returns the jobs it requeued so a caller can log or surface them — a
899
+ * timeout that fires invisibly is indistinguishable from a job that was
900
+ * never claimed, and those want very different debugging.
901
+ */
902
+ async sweep() {
903
+ const now = await this.now();
904
+ const requeued = [];
905
+ for (const job of this.#jobs.values()) {
906
+ if (job.state === "awaiting-payload" && (job.awaitingUntil ?? 0) <= now) {
907
+ this.#requeue(job);
908
+ requeued.push(job);
909
+ }
910
+ const lease = job.claimedBy;
911
+ if (lease && (job.state === "ready" || job.state === "running") && lease.leaseExpiresAt <= now) {
912
+ this.#requeue(job);
913
+ requeued.push(job);
914
+ }
915
+ }
916
+ return requeued;
917
+ }
918
+ };
919
+
742
920
  // src/index.ts
743
921
  var Relay = class {
744
922
  state;
@@ -748,7 +926,7 @@ var Relay = class {
748
926
  #now;
749
927
  #basePath;
750
928
  constructor(options) {
751
- this.state = new RelayState();
929
+ this.state = options.store ?? new RelayState({ now: options.now ?? Date.now });
752
930
  this.projection = new Projection(options.fixture);
753
931
  this.#now = options.now ?? Date.now;
754
932
  this.#basePath = (options.basePath ?? "/byollm").replace(/\/+$/, "");
@@ -779,15 +957,16 @@ var Relay = class {
779
957
  * site vanished should return to the queue without waiting for someone to
780
958
  * ask about it.
781
959
  */
782
- sweep() {
783
- return { requeued: this.state.sweep(this.#now()).map((j) => j.id) };
960
+ async sweep() {
961
+ const requeued = await this.state.sweep();
962
+ return { requeued: requeued.map((j) => j.id) };
784
963
  }
785
964
  /** The whole HTTP surface. */
786
965
  async handle(request) {
787
966
  const url = new URL(request.url);
788
967
  const path = url.pathname;
789
968
  if (path === "/debug" || path === `${this.#basePath}/debug`) {
790
- return new Response(debugPage(this.state, this.#now()), {
969
+ return new Response(await debugPage(this.state, this.#now()), {
791
970
  headers: { "content-type": "text/html; charset=utf-8" }
792
971
  });
793
972
  }
@@ -805,19 +984,25 @@ var Relay = class {
805
984
  signature: signatureFrom(request.headers, "x-byollm-site")
806
985
  };
807
986
  if (path === "/relay/site/enqueue") {
808
- return json(this.#site.enqueue(siteAuth, body));
987
+ return json(await this.#site.enqueue(siteAuth, body));
809
988
  }
810
989
  if (path === "/relay/site/payload") {
811
- return json(this.#site.payload(siteAuth, body));
990
+ return json(await this.#site.payload(siteAuth, body));
812
991
  }
813
992
  if (path === "/relay/site/pending") {
814
993
  return json(
815
- this.#site.pending(siteAuth, url.searchParams.get("siteId") ?? "")
994
+ await this.#site.pending(
995
+ siteAuth,
996
+ url.searchParams.get("siteId") ?? ""
997
+ )
816
998
  );
817
999
  }
818
1000
  if (path === "/relay/site/results") {
819
1001
  return json(
820
- this.#site.results(siteAuth, url.searchParams.get("siteId") ?? "")
1002
+ await this.#site.results(
1003
+ siteAuth,
1004
+ url.searchParams.get("siteId") ?? ""
1005
+ )
821
1006
  );
822
1007
  }
823
1008
  if (!path.startsWith(`${this.#basePath}/`)) {
@@ -825,17 +1010,17 @@ var Relay = class {
825
1010
  }
826
1011
  switch (auth.endpoint) {
827
1012
  case "pair":
828
- return json(this.#daemon.pair(body));
1013
+ return json(await this.#daemon.pair(body));
829
1014
  case "claim":
830
- return json(this.#daemon.claim(auth, body));
1015
+ return json(await this.#daemon.claim(auth, body));
831
1016
  case "fetch":
832
- return json(this.#daemon.fetch(auth, body));
1017
+ return json(await this.#daemon.fetch(auth, body));
833
1018
  case "result":
834
- return json(this.#daemon.result(auth, body));
1019
+ return json(await this.#daemon.result(auth, body));
835
1020
  case "heartbeat":
836
- return json(this.#daemon.heartbeat(auth, body));
1021
+ return json(await this.#daemon.heartbeat(auth, body));
837
1022
  case "release":
838
- return json(this.#daemon.release(auth, body));
1023
+ return json(await this.#daemon.release(auth, body));
839
1024
  default:
840
1025
  return json({ status: 404, body: { error: "not-found" } });
841
1026
  }