@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/README.md +20 -2
- package/dist/index.d.ts +152 -18
- package/dist/index.js +371 -221
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
if (
|
|
277
|
-
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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 =
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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 ${
|
|
619
|
+
`job is ${sealed.was ?? "gone"}, not awaiting payload`
|
|
705
620
|
);
|
|
706
621
|
}
|
|
707
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
}
|