@byollm/relay 0.1.0-alpha.24 → 0.1.0-alpha.26
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 +4 -4
- package/dist/chunk-ZH2GB6FY.js +364 -0
- package/dist/chunk-ZH2GB6FY.js.map +1 -0
- package/dist/index.d.ts +70 -19
- package/dist/index.js +119 -350
- package/dist/index.js.map +1 -1
- package/dist/{store-ClYl7FqD.d.ts → store-DgzTq29P.d.ts} +46 -34
- package/dist/store-contract.d.ts +1 -1
- package/dist/store-contract.js +45 -32
- package/dist/store-contract.js.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AWAITING_PAYLOAD_MS,
|
|
3
|
+
RelayState,
|
|
4
|
+
routeKey
|
|
5
|
+
} from "./chunk-ZH2GB6FY.js";
|
|
6
|
+
|
|
1
7
|
// src/daemon-plane.ts
|
|
2
8
|
import {
|
|
3
9
|
ClaimRequest,
|
|
@@ -84,17 +90,10 @@ var DaemonPlane = class {
|
|
|
84
90
|
if (!verifyPublicIdentity(parsed.data.device)) {
|
|
85
91
|
return fail(400, "bad-request", "the device identity is not consistent");
|
|
86
92
|
}
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
this.#deps.siteId
|
|
90
|
-
);
|
|
91
|
-
if (!consent) {
|
|
93
|
+
const sites = this.#deps.projection.sitesFor(parsed.data.owner);
|
|
94
|
+
if (sites.length === 0) {
|
|
92
95
|
return fail(403, "forbidden", "no consent record for this user");
|
|
93
96
|
}
|
|
94
|
-
const site = this.#deps.projection.siteFor(this.#deps.siteId);
|
|
95
|
-
if (!site) {
|
|
96
|
-
return fail(403, "forbidden", "this site is not registered");
|
|
97
|
-
}
|
|
98
97
|
const approved = this.#deps.projection.deviceByFingerprint(
|
|
99
98
|
parsed.data.device.identity
|
|
100
99
|
);
|
|
@@ -117,8 +116,17 @@ var DaemonPlane = class {
|
|
|
117
116
|
return ok({
|
|
118
117
|
protocolVersion: PROTOCOL_VERSION,
|
|
119
118
|
runnerId,
|
|
120
|
-
/**
|
|
121
|
-
|
|
119
|
+
/**
|
|
120
|
+
* The *sites'* keys. See the note above — this is load-bearing.
|
|
121
|
+
*
|
|
122
|
+
* The set this owner has consented to, keyed by each site's identity
|
|
123
|
+
* key id (cloud_009 §5). Paused sites are here: a paused consent keeps
|
|
124
|
+
* its pin so re-consenting never costs a re-pair, and what it does not
|
|
125
|
+
* do is route.
|
|
126
|
+
*/
|
|
127
|
+
sites: Object.fromEntries(
|
|
128
|
+
sites.map((record) => [keyId(record.site.identity), record.site])
|
|
129
|
+
)
|
|
122
130
|
});
|
|
123
131
|
}
|
|
124
132
|
/** Every authenticated call: signature first, then consent, then work. */
|
|
@@ -140,7 +148,7 @@ var DaemonPlane = class {
|
|
|
140
148
|
});
|
|
141
149
|
if (failure === "stale") return this.#clockSkew();
|
|
142
150
|
if (failure) return fail(401, "unauthorized", "signature check failed");
|
|
143
|
-
const revoked = this.#deps.projection.
|
|
151
|
+
const revoked = this.#deps.projection.sitesFor(known.owner).length === 0;
|
|
144
152
|
if (revoked && options.allowRevoked !== true) {
|
|
145
153
|
return fail(403, "revoked", "routing for this runner has been revoked");
|
|
146
154
|
}
|
|
@@ -176,19 +184,16 @@ var DaemonPlane = class {
|
|
|
176
184
|
runnerId: device.runnerId,
|
|
177
185
|
owner: device.owner,
|
|
178
186
|
device: device.device,
|
|
179
|
-
siteId: this.#deps.siteId,
|
|
180
187
|
kinds: new Set(request.capabilities.map((c) => c.kind)),
|
|
181
188
|
// The projection, collapsed to data the store can match on — a
|
|
182
|
-
// predicate does not travel
|
|
189
|
+
// predicate does not travel, and a set of (site, owner) pairs is
|
|
190
|
+
// what a route is (cloud_009 §3).
|
|
183
191
|
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
owners: new Set(
|
|
190
|
-
this.#deps.projection.routableOwners(device.owner, this.#deps.siteId)
|
|
191
|
-
),
|
|
192
|
+
// A paused consent (cloud_008 finding 48) drops its routes and
|
|
193
|
+
// leaves the rest, which is the whole difference between "we are
|
|
194
|
+
// waiting for you to read something about one site" and "a human cut
|
|
195
|
+
// you off from everything".
|
|
196
|
+
routes: this.#deps.projection.routesFor(device.owner),
|
|
192
197
|
max: request.max,
|
|
193
198
|
leaseMs: this.#deps.leaseMs
|
|
194
199
|
});
|
|
@@ -246,14 +251,17 @@ var DaemonPlane = class {
|
|
|
246
251
|
async (request, device) => {
|
|
247
252
|
const now = this.#deps.now();
|
|
248
253
|
await this.#deps.state.sweep();
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
254
|
+
const pinned = this.#deps.projection.sitesFor(device.owner);
|
|
255
|
+
const sites = Object.fromEntries(
|
|
256
|
+
pinned.map((record) => [keyId(record.site.identity), record.site])
|
|
252
257
|
);
|
|
253
|
-
const
|
|
254
|
-
|
|
258
|
+
const awaitingConsent = pinned.filter(
|
|
259
|
+
(record) => !this.#deps.projection.mayRouteFor(device.owner, record.siteId)
|
|
260
|
+
).map((record) => keyId(record.site.identity));
|
|
261
|
+
if (pinned.length === 0) {
|
|
255
262
|
return ok({
|
|
256
|
-
|
|
263
|
+
sites,
|
|
264
|
+
awaitingConsent,
|
|
257
265
|
cancel: [],
|
|
258
266
|
lost: request.activeLeases.map((lease) => lease.jobId),
|
|
259
267
|
serverTime: now
|
|
@@ -266,7 +274,8 @@ var DaemonPlane = class {
|
|
|
266
274
|
leaseMs: this.#deps.leaseMs
|
|
267
275
|
});
|
|
268
276
|
return ok({
|
|
269
|
-
|
|
277
|
+
sites,
|
|
278
|
+
awaitingConsent,
|
|
270
279
|
cancel,
|
|
271
280
|
lost,
|
|
272
281
|
serverTime: now
|
|
@@ -510,6 +519,35 @@ var Projection = class {
|
|
|
510
519
|
(c) => c.owner === owner && c.siteId === siteId
|
|
511
520
|
) ?? null;
|
|
512
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* Every site this owner may route with — cloud_009 §3.
|
|
524
|
+
*
|
|
525
|
+
* The set a pairing covers, and the set a claim will filter on. Consent
|
|
526
|
+
* decides it, which is the sentence the whole design rests on: a site
|
|
527
|
+
* appears here because a human clicked, never because a site asked to be
|
|
528
|
+
* here and never because a daemon named it.
|
|
529
|
+
*
|
|
530
|
+
* **Paused sites are here, and that is deliberate** — cloud_008 finding 48
|
|
531
|
+
* as ratified. A paused consent routes nothing and keeps its pin: the
|
|
532
|
+
* relationship stands, the key the daemon compared a fingerprint of stays
|
|
533
|
+
* pinned, and re-consenting never costs a re-pair. Written the other way
|
|
534
|
+
* round first, and three of the paused tests failed by refusing to pair at
|
|
535
|
+
* all — which is the trap the finding is about, arriving through the door
|
|
536
|
+
* marked "be stricter".
|
|
537
|
+
*
|
|
538
|
+
* So this is the *pairing* set and `mayRouteFor` is the *routing* set. Two
|
|
539
|
+
* questions with different answers, kept apart for the same reason
|
|
540
|
+
* `consentFor` and `mayRouteFor` are: one method answering both is how a
|
|
541
|
+
* paused user quietly starts routing again, or quietly loses their machine.
|
|
542
|
+
*
|
|
543
|
+
* Sorted by site id so two calls with the same projection produce the same
|
|
544
|
+
* answer: this ends up in a pairings file and in a fingerprint list a human
|
|
545
|
+
* compares by eye, and an order that drifts between polls is a diff nobody
|
|
546
|
+
* can read.
|
|
547
|
+
*/
|
|
548
|
+
sitesFor(owner) {
|
|
549
|
+
return this.#fixture.sites.filter((site) => this.consentFor(owner, site.siteId) !== null).sort((a, b) => a.siteId < b.siteId ? -1 : 1);
|
|
550
|
+
}
|
|
513
551
|
/**
|
|
514
552
|
* May this owner's work move for this site, right now?
|
|
515
553
|
*
|
|
@@ -526,29 +564,38 @@ var Projection = class {
|
|
|
526
564
|
return this.consentFor(owner, siteId)?.paused === true;
|
|
527
565
|
}
|
|
528
566
|
/**
|
|
529
|
-
*
|
|
567
|
+
* Every (site, owner) route this device may run — cloud_009 §3.
|
|
568
|
+
*
|
|
569
|
+
* The claim filter, collapsed to data a store can match on. `routableOwners`
|
|
570
|
+
* was this for one site; the hub needs it for the set, and the shape had to
|
|
571
|
+
* change rather than repeat, because **a set of sites and a set of owners
|
|
572
|
+
* multiply**. A device whose owner consented to site A, serving a roster
|
|
573
|
+
* member who consented to site B, appears in both sets and has no consented
|
|
574
|
+
* route between them. Pairs cannot express a route nobody agreed to.
|
|
530
575
|
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
* place respectively:
|
|
576
|
+
* Both halves of the rule are here, and neither was enforced before finding
|
|
577
|
+
* 48's work:
|
|
534
578
|
*
|
|
535
|
-
* - **
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
* machine, which is `CONSENT_BEFORE_ROUTE` read the other way round. The
|
|
545
|
-
* site plane does not check consent at enqueue either, so nothing did.
|
|
579
|
+
* - **This machine's owner** must have a live consent for the site, or
|
|
580
|
+
* nothing of that site's runs here at all — including a roster member's
|
|
581
|
+
* work. The roster says whose jobs may land on this machine; consent says
|
|
582
|
+
* whether this machine is available to that site.
|
|
583
|
+
* - **Each job's owner** must have one too. That check did not exist:
|
|
584
|
+
* consent was enforced by the daemon plane's blanket revoked guard, which
|
|
585
|
+
* asks only about the claiming device's owner, so a roster member who
|
|
586
|
+
* never consented to a site could have their work claimed by their admin's
|
|
587
|
+
* machine — `CONSENT_BEFORE_ROUTE` read the other way round.
|
|
546
588
|
*/
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
(
|
|
551
|
-
|
|
589
|
+
routesFor(deviceOwner) {
|
|
590
|
+
const routes = /* @__PURE__ */ new Set();
|
|
591
|
+
for (const site of this.#fixture.sites) {
|
|
592
|
+
if (!this.mayRouteFor(deviceOwner, site.siteId)) continue;
|
|
593
|
+
for (const owner of this.ownersRunnableBy(deviceOwner)) {
|
|
594
|
+
if (!this.mayRouteFor(owner, site.siteId)) continue;
|
|
595
|
+
routes.add(routeKey(site.siteId, owner));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return routes;
|
|
552
599
|
}
|
|
553
600
|
/**
|
|
554
601
|
* Every owner whose work this device's owner may run, as a list.
|
|
@@ -676,7 +723,7 @@ var SitePlane = class {
|
|
|
676
723
|
if (siteIdOf(parsed.data) !== siteId) {
|
|
677
724
|
return fail2(403, "forbidden", "that is not your site");
|
|
678
725
|
}
|
|
679
|
-
if (
|
|
726
|
+
if (this.#deps.projection.siteFor(siteId) === null) {
|
|
680
727
|
return fail2(403, "forbidden", "this relay does not route for you");
|
|
681
728
|
}
|
|
682
729
|
return run(parsed.data, siteId);
|
|
@@ -702,7 +749,7 @@ var SitePlane = class {
|
|
|
702
749
|
stub: request.stub
|
|
703
750
|
});
|
|
704
751
|
if ("refused" in job) {
|
|
705
|
-
return fail2(
|
|
752
|
+
return fail2(400, "bad-request", "that stub was not accepted");
|
|
706
753
|
}
|
|
707
754
|
return ok2({ jobId: job.id, state: job.state });
|
|
708
755
|
}
|
|
@@ -835,296 +882,6 @@ var SitePlane = class {
|
|
|
835
882
|
}
|
|
836
883
|
};
|
|
837
884
|
|
|
838
|
-
// src/state.ts
|
|
839
|
-
import { randomUUID } from "crypto";
|
|
840
|
-
var AWAITING_PAYLOAD_MS = 1e4;
|
|
841
|
-
var RelayState = class {
|
|
842
|
-
#jobs = /* @__PURE__ */ new Map();
|
|
843
|
-
#presence = /* @__PURE__ */ new Map();
|
|
844
|
-
#now;
|
|
845
|
-
constructor(options = {}) {
|
|
846
|
-
this.#now = options.now ?? Date.now;
|
|
847
|
-
}
|
|
848
|
-
/** The one clock every deadline in this store is stamped from. */
|
|
849
|
-
async now() {
|
|
850
|
-
return this.#now();
|
|
851
|
-
}
|
|
852
|
-
/**
|
|
853
|
-
* Take a stub for routing. The payload is not here and will not be.
|
|
854
|
-
*
|
|
855
|
-
* **Idempotent by job id, and that is a security property rather than a
|
|
856
|
-
* convenience.** Site-plane calls are authenticated by signature, and
|
|
857
|
-
* byollm_009 §4.2's argument for signing the request instead of a
|
|
858
|
-
* server-issued nonce rests entirely on every write being idempotent per the
|
|
859
|
-
* instance it names. This one was not: re-enqueueing a known id built a
|
|
860
|
-
* fresh `queued` job over the top of the old one, discarding a live claim,
|
|
861
|
-
* its lease and any payload the site had already sealed to a device. A
|
|
862
|
-
* replayed enqueue inside the two-minute freshness window was therefore a
|
|
863
|
-
* way to yank a job back from the machine running it — the `release` bug of
|
|
864
|
-
* §4.2, rediscovered on the other plane.
|
|
865
|
-
*
|
|
866
|
-
* So a known id returns what is already routing, unchanged. A site that
|
|
867
|
-
* restarts and republishes its queue is the normal case, and it must not
|
|
868
|
-
* disturb work in flight.
|
|
869
|
-
*/
|
|
870
|
-
enqueue(input) {
|
|
871
|
-
const existing = this.#jobs.get(input.id);
|
|
872
|
-
if (existing) {
|
|
873
|
-
return Promise.resolve(
|
|
874
|
-
existing.siteId === input.siteId ? existing : { refused: "id-taken" }
|
|
875
|
-
);
|
|
876
|
-
}
|
|
877
|
-
const job = {
|
|
878
|
-
id: input.id,
|
|
879
|
-
siteId: input.siteId,
|
|
880
|
-
stub: input.stub,
|
|
881
|
-
state: "queued",
|
|
882
|
-
refusedBy: []
|
|
883
|
-
};
|
|
884
|
-
this.#jobs.set(job.id, job);
|
|
885
|
-
return Promise.resolve(job);
|
|
886
|
-
}
|
|
887
|
-
job(jobId) {
|
|
888
|
-
return Promise.resolve(this.#jobs.get(jobId));
|
|
889
|
-
}
|
|
890
|
-
jobs() {
|
|
891
|
-
return Promise.resolve([...this.#jobs.values()]);
|
|
892
|
-
}
|
|
893
|
-
/** Jobs a site must seal for, right now. */
|
|
894
|
-
async awaiting(siteId) {
|
|
895
|
-
return (await this.jobs()).filter(
|
|
896
|
-
(j) => j.siteId === siteId && j.state === "awaiting-payload"
|
|
897
|
-
);
|
|
898
|
-
}
|
|
899
|
-
/** Sealed results waiting to go home. */
|
|
900
|
-
async finished(siteId) {
|
|
901
|
-
return (await this.jobs()).filter(
|
|
902
|
-
(j) => j.siteId === siteId && j.state === "done" && j.result !== void 0
|
|
903
|
-
);
|
|
904
|
-
}
|
|
905
|
-
/**
|
|
906
|
-
* Claim work — one operation, because it has to be.
|
|
907
|
-
*
|
|
908
|
-
* Moved here wholesale from `DaemonPlane`, where it was a scan followed by
|
|
909
|
-
* per-job mutation. Nothing about the *decision* changed; what changed is
|
|
910
|
-
* that a store can now implement it, because the filter and the write are
|
|
911
|
-
* one call rather than a loop the caller drives.
|
|
912
|
-
*
|
|
913
|
-
* The order of the guards is worth preserving as-is when this becomes a Lua
|
|
914
|
-
* script: cheapest first, and `owners` last because it is the only one that
|
|
915
|
-
* needed the projection.
|
|
916
|
-
*/
|
|
917
|
-
async claim(input) {
|
|
918
|
-
const now = await this.now();
|
|
919
|
-
await this.sweep();
|
|
920
|
-
const granted = [];
|
|
921
|
-
for (const job of this.#jobs.values()) {
|
|
922
|
-
if (granted.length >= input.max) break;
|
|
923
|
-
if (job.state !== "queued") continue;
|
|
924
|
-
if (job.siteId !== input.siteId) continue;
|
|
925
|
-
if (!input.kinds.has(job.stub.kind)) continue;
|
|
926
|
-
if (job.refusedBy.includes(input.runnerId)) continue;
|
|
927
|
-
if (job.cancelled) continue;
|
|
928
|
-
if (!input.owners.has(job.stub.owner)) continue;
|
|
929
|
-
if (job.stub.audience === "self" && job.stub.owner !== input.owner) {
|
|
930
|
-
continue;
|
|
931
|
-
}
|
|
932
|
-
const leaseId = randomUUID();
|
|
933
|
-
job.state = "awaiting-payload";
|
|
934
|
-
job.claimedBy = {
|
|
935
|
-
runnerId: input.runnerId,
|
|
936
|
-
owner: input.owner,
|
|
937
|
-
device: input.device,
|
|
938
|
-
leaseId,
|
|
939
|
-
leaseExpiresAt: now + input.leaseMs
|
|
940
|
-
};
|
|
941
|
-
job.awaitingUntil = now + AWAITING_PAYLOAD_MS;
|
|
942
|
-
granted.push({
|
|
943
|
-
...job.stub,
|
|
944
|
-
lease: {
|
|
945
|
-
id: leaseId,
|
|
946
|
-
runnerId: input.runnerId,
|
|
947
|
-
expiresAt: job.claimedBy.leaseExpiresAt
|
|
948
|
-
}
|
|
949
|
-
});
|
|
950
|
-
}
|
|
951
|
-
return granted;
|
|
952
|
-
}
|
|
953
|
-
/**
|
|
954
|
-
* Hand over the sealed payload to the device that holds the lease.
|
|
955
|
-
*
|
|
956
|
-
* The read and the state transition are one operation for the same reason
|
|
957
|
-
* `claim` is: `running` must be set by whoever was told the envelope, or two
|
|
958
|
-
* replicas can both hand out the same work and both believe they were first.
|
|
959
|
-
*/
|
|
960
|
-
takePayload(input) {
|
|
961
|
-
const job = this.#jobs.get(input.jobId);
|
|
962
|
-
if (!job) return Promise.resolve({ refused: "not-found" });
|
|
963
|
-
if (job.claimedBy?.runnerId !== input.runnerId) {
|
|
964
|
-
return Promise.resolve({ refused: "not-holder" });
|
|
965
|
-
}
|
|
966
|
-
if (job.claimedBy.leaseId !== input.leaseId) {
|
|
967
|
-
return Promise.resolve({ refused: "stale-lease" });
|
|
968
|
-
}
|
|
969
|
-
if (!job.payload) return Promise.resolve({ refused: "not-ready" });
|
|
970
|
-
job.state = "running";
|
|
971
|
-
return Promise.resolve({ envelope: job.payload });
|
|
972
|
-
}
|
|
973
|
-
/**
|
|
974
|
-
* Record a finished job.
|
|
975
|
-
*
|
|
976
|
-
* `RESULT_IDEMPOTENT` lives here rather than in the caller: a replayed
|
|
977
|
-
* result must be a no-op decided by the same operation that would have
|
|
978
|
-
* written it, or two replicas can both decide they were the first.
|
|
979
|
-
*/
|
|
980
|
-
complete(input) {
|
|
981
|
-
const job = this.#jobs.get(input.jobId);
|
|
982
|
-
if (!job) return Promise.resolve({ refused: "not-found" });
|
|
983
|
-
if (job.claimedBy?.runnerId !== input.runnerId) {
|
|
984
|
-
return Promise.resolve({ refused: "not-holder" });
|
|
985
|
-
}
|
|
986
|
-
if (job.state === "done") {
|
|
987
|
-
const sameGrant = job.claimedBy.leaseId === input.leaseId;
|
|
988
|
-
return Promise.resolve(
|
|
989
|
-
sameGrant ? { accepted: false, duplicate: true, state: job.state } : { refused: "stale-lease" }
|
|
990
|
-
);
|
|
991
|
-
}
|
|
992
|
-
if (job.claimedBy.leaseId !== input.leaseId) {
|
|
993
|
-
return Promise.resolve({ refused: "stale-lease" });
|
|
994
|
-
}
|
|
995
|
-
job.result = input.envelope;
|
|
996
|
-
job.disposition = input.disposition;
|
|
997
|
-
job.state = "done";
|
|
998
|
-
return Promise.resolve({ accepted: true, state: job.state });
|
|
999
|
-
}
|
|
1000
|
-
/** Give back leases this runner holds, naming each grant it means. */
|
|
1001
|
-
releaseLeases(input) {
|
|
1002
|
-
const released = [];
|
|
1003
|
-
for (const { jobId, leaseId } of input.leases) {
|
|
1004
|
-
const job = this.#jobs.get(jobId);
|
|
1005
|
-
if (!job || job.claimedBy?.runnerId !== input.runnerId) continue;
|
|
1006
|
-
if (job.claimedBy.leaseId !== leaseId) continue;
|
|
1007
|
-
if (input.reason === "refused" && !job.refusedBy.includes(input.runnerId)) {
|
|
1008
|
-
job.refusedBy.push(input.runnerId);
|
|
1009
|
-
}
|
|
1010
|
-
this.#requeue(job);
|
|
1011
|
-
released.push(jobId);
|
|
1012
|
-
}
|
|
1013
|
-
return Promise.resolve(released);
|
|
1014
|
-
}
|
|
1015
|
-
/**
|
|
1016
|
-
* Take a site's sealed payload for a claimed job.
|
|
1017
|
-
*
|
|
1018
|
-
* Refuses anything not `awaiting-payload`, which is what makes the timeout
|
|
1019
|
-
* mean something: a late seal must not land on a claim that has moved.
|
|
1020
|
-
*/
|
|
1021
|
-
seal(input) {
|
|
1022
|
-
const job = this.#jobs.get(input.jobId);
|
|
1023
|
-
if (job?.siteId !== input.siteId) {
|
|
1024
|
-
return Promise.resolve({ refused: "not-found" });
|
|
1025
|
-
}
|
|
1026
|
-
if (job.state !== "awaiting-payload") {
|
|
1027
|
-
return Promise.resolve({ refused: "too-late", was: job.state });
|
|
1028
|
-
}
|
|
1029
|
-
job.payload = input.envelope;
|
|
1030
|
-
job.state = "ready";
|
|
1031
|
-
delete job.awaitingUntil;
|
|
1032
|
-
return Promise.resolve({ state: job.state });
|
|
1033
|
-
}
|
|
1034
|
-
/** {@link RoutingStore.cancel} — the site withdraws a job. */
|
|
1035
|
-
cancel(input) {
|
|
1036
|
-
const job = this.#jobs.get(input.jobId);
|
|
1037
|
-
if (job?.siteId !== input.siteId) return Promise.resolve(false);
|
|
1038
|
-
job.cancelled = true;
|
|
1039
|
-
return Promise.resolve(true);
|
|
1040
|
-
}
|
|
1041
|
-
/** {@link RoutingStore.cancelRequests} — cancelled jobs this runner holds. */
|
|
1042
|
-
cancelRequests(runnerId) {
|
|
1043
|
-
return Promise.resolve(
|
|
1044
|
-
[...this.#jobs.values()].filter(
|
|
1045
|
-
(job) => job.cancelled === true && job.claimedBy?.runnerId === runnerId
|
|
1046
|
-
).map((job) => job.id)
|
|
1047
|
-
);
|
|
1048
|
-
}
|
|
1049
|
-
/** {@link RoutingStore.renewLeases} — extend what is still held, name what is not. */
|
|
1050
|
-
async renewLeases(input) {
|
|
1051
|
-
const now = await this.now();
|
|
1052
|
-
const renewed = [];
|
|
1053
|
-
const lost = [];
|
|
1054
|
-
for (const { jobId, leaseId } of input.leases) {
|
|
1055
|
-
const job = this.#jobs.get(jobId);
|
|
1056
|
-
const held = job?.claimedBy;
|
|
1057
|
-
if (!job || held?.leaseId !== leaseId || held.runnerId !== input.runnerId) {
|
|
1058
|
-
lost.push(jobId);
|
|
1059
|
-
continue;
|
|
1060
|
-
}
|
|
1061
|
-
const expiresAt = now + input.leaseMs;
|
|
1062
|
-
job.claimedBy = { ...held, leaseExpiresAt: expiresAt };
|
|
1063
|
-
renewed.push({ jobId, expiresAt });
|
|
1064
|
-
}
|
|
1065
|
-
return { renewed, lost };
|
|
1066
|
-
}
|
|
1067
|
-
async seen(presence) {
|
|
1068
|
-
const lastSeenAt = await this.now();
|
|
1069
|
-
const existing = this.#presence.get(presence.runnerId);
|
|
1070
|
-
if (existing) {
|
|
1071
|
-
existing.lastSeenAt = lastSeenAt;
|
|
1072
|
-
return existing;
|
|
1073
|
-
}
|
|
1074
|
-
const fresh = { ...presence, lastSeenAt };
|
|
1075
|
-
this.#presence.set(presence.runnerId, fresh);
|
|
1076
|
-
return fresh;
|
|
1077
|
-
}
|
|
1078
|
-
presence(runnerId) {
|
|
1079
|
-
return Promise.resolve(this.#presence.get(runnerId));
|
|
1080
|
-
}
|
|
1081
|
-
everyone() {
|
|
1082
|
-
return Promise.resolve([...this.#presence.values()]);
|
|
1083
|
-
}
|
|
1084
|
-
/**
|
|
1085
|
-
* Return a job to the queue, forgetting the claim.
|
|
1086
|
-
*
|
|
1087
|
-
* The stub survives; nothing is lost. That is `LEASE_RECLAIMABLE` and it is
|
|
1088
|
-
* why the awaiting-payload timeout is cheap to fire: the worst case is that
|
|
1089
|
-
* a device did nothing for ten seconds and another one gets a turn.
|
|
1090
|
-
*/
|
|
1091
|
-
#requeue(job) {
|
|
1092
|
-
job.state = "queued";
|
|
1093
|
-
delete job.claimedBy;
|
|
1094
|
-
delete job.awaitingUntil;
|
|
1095
|
-
delete job.payload;
|
|
1096
|
-
}
|
|
1097
|
-
/**
|
|
1098
|
-
* Fire whatever the clock says is due, and report it.
|
|
1099
|
-
*
|
|
1100
|
-
* Returns the jobs it requeued so a caller can log or surface them — a
|
|
1101
|
-
* timeout that fires invisibly is indistinguishable from a job that was
|
|
1102
|
-
* never claimed, and those want very different debugging.
|
|
1103
|
-
*/
|
|
1104
|
-
async sweep() {
|
|
1105
|
-
const now = await this.now();
|
|
1106
|
-
const requeued = [];
|
|
1107
|
-
const expired = [];
|
|
1108
|
-
for (const job of this.#jobs.values()) {
|
|
1109
|
-
if (job.stub.deadlineAt <= now) {
|
|
1110
|
-
this.#jobs.delete(job.id);
|
|
1111
|
-
expired.push(job);
|
|
1112
|
-
continue;
|
|
1113
|
-
}
|
|
1114
|
-
if (job.state === "awaiting-payload" && (job.awaitingUntil ?? 0) <= now) {
|
|
1115
|
-
this.#requeue(job);
|
|
1116
|
-
requeued.push(job);
|
|
1117
|
-
}
|
|
1118
|
-
const lease = job.claimedBy;
|
|
1119
|
-
if (lease && (job.state === "ready" || job.state === "running") && lease.leaseExpiresAt <= now) {
|
|
1120
|
-
this.#requeue(job);
|
|
1121
|
-
requeued.push(job);
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
return [...requeued, ...expired];
|
|
1125
|
-
}
|
|
1126
|
-
};
|
|
1127
|
-
|
|
1128
885
|
// src/index.ts
|
|
1129
886
|
var Relay = class {
|
|
1130
887
|
state;
|
|
@@ -1133,25 +890,23 @@ var Relay = class {
|
|
|
1133
890
|
#site;
|
|
1134
891
|
#now;
|
|
1135
892
|
#basePath;
|
|
1136
|
-
#
|
|
893
|
+
#debug;
|
|
1137
894
|
constructor(options) {
|
|
1138
895
|
this.state = options.store ?? new RelayState({ now: options.now ?? Date.now });
|
|
1139
896
|
this.projection = new Projection(options.fixture);
|
|
1140
897
|
this.#now = options.now ?? Date.now;
|
|
1141
898
|
this.#basePath = (options.basePath ?? "/byollm").replace(/\/+$/, "");
|
|
1142
|
-
this.#
|
|
899
|
+
this.#debug = options.debug ?? false;
|
|
1143
900
|
this.#daemon = new DaemonPlane({
|
|
1144
901
|
state: this.state,
|
|
1145
902
|
projection: this.projection,
|
|
1146
903
|
now: this.#now,
|
|
1147
|
-
leaseMs: options.leaseMs ?? 6e4
|
|
1148
|
-
siteId: options.siteId
|
|
904
|
+
leaseMs: options.leaseMs ?? 6e4
|
|
1149
905
|
});
|
|
1150
906
|
this.#site = new SitePlane({
|
|
1151
907
|
state: this.state,
|
|
1152
908
|
projection: this.projection,
|
|
1153
|
-
now: this.#now
|
|
1154
|
-
routesFor: options.siteId
|
|
909
|
+
now: this.#now
|
|
1155
910
|
});
|
|
1156
911
|
}
|
|
1157
912
|
/** Replace the projection — a control-plane push, or a fixture edit. */
|
|
@@ -1176,10 +931,23 @@ var Relay = class {
|
|
|
1176
931
|
const url = new URL(request.url);
|
|
1177
932
|
const path = url.pathname;
|
|
1178
933
|
if (path === "/debug" || path === `${this.#basePath}/debug`) {
|
|
934
|
+
const siteId = url.searchParams.get("site");
|
|
935
|
+
if (!this.#debug) {
|
|
936
|
+
return new Response(JSON.stringify({ error: "not-found" }), {
|
|
937
|
+
status: 404,
|
|
938
|
+
headers: { "content-type": "application/json" }
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
if (siteId === null || this.projection.siteFor(siteId) === null) {
|
|
942
|
+
return new Response(JSON.stringify({ error: "bad-request" }), {
|
|
943
|
+
status: 400,
|
|
944
|
+
headers: { "content-type": "application/json" }
|
|
945
|
+
});
|
|
946
|
+
}
|
|
1179
947
|
return new Response(
|
|
1180
948
|
await debugPage(this.state, this.#now(), {
|
|
1181
|
-
siteId
|
|
1182
|
-
consents: (owner) => this.projection.consentFor(owner,
|
|
949
|
+
siteId,
|
|
950
|
+
consents: (owner) => this.projection.consentFor(owner, siteId) !== null
|
|
1183
951
|
}),
|
|
1184
952
|
{ headers: { "content-type": "text/html; charset=utf-8" } }
|
|
1185
953
|
);
|
|
@@ -1275,6 +1043,7 @@ export {
|
|
|
1275
1043
|
RevocationRecord,
|
|
1276
1044
|
RosterRecord,
|
|
1277
1045
|
SiteRecord,
|
|
1278
|
-
debugPage
|
|
1046
|
+
debugPage,
|
|
1047
|
+
routeKey
|
|
1279
1048
|
};
|
|
1280
1049
|
//# sourceMappingURL=index.js.map
|