@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/README.md +20 -2
- package/dist/index.d.ts +315 -21
- package/dist/index.js +409 -224
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
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 });
|
|
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
|
|
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 });
|
|
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(
|
|
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 =
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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(
|
|
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
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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 ${
|
|
643
|
+
`job is ${sealed.was ?? "gone"}, not awaiting payload`
|
|
705
644
|
);
|
|
706
645
|
}
|
|
707
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
}
|