@byollm/relay 0.1.0-alpha.8 → 0.1.0-alpha.81
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 +147 -4
- package/dist/chunk-OB6LPEEE.js +405 -0
- package/dist/chunk-OB6LPEEE.js.map +1 -0
- package/dist/index.d.ts +478 -163
- package/dist/index.js +930 -264
- package/dist/index.js.map +1 -1
- package/dist/store-DPCLO12l.d.ts +655 -0
- package/dist/store-contract.d.ts +67 -0
- package/dist/store-contract.js +625 -0
- package/dist/store-contract.js.map +1 -0
- package/package.json +19 -4
package/README.md
CHANGED
|
@@ -1,14 +1,117 @@
|
|
|
1
1
|
> [!WARNING]
|
|
2
|
-
> **Alpha (`0.1.0-alpha.
|
|
2
|
+
> **Alpha (`0.1.0-alpha.81`) — under active development. Don't use this yet.**
|
|
3
3
|
>
|
|
4
4
|
> This is a walking skeleton. It routes real jobs between real daemons and real
|
|
5
5
|
> sites, and it is the fixture byollm_009 freezes against — but it keeps its
|
|
6
6
|
> state in memory, serves one site, and has never run anywhere but a test.
|
|
7
|
+
>
|
|
8
|
+
> **`alpha.15` is a breaking wire change, and it breaks daemons and relays —
|
|
9
|
+
> not app authors.** If you call `app.enqueue(...)` and read results, nothing
|
|
10
|
+
> in your code changes. If you run a daemon or an upstream, every package must
|
|
11
|
+
> move together: a mixed pair refuses on both sides, because both ends parse
|
|
12
|
+
> `.strict()`.
|
|
13
|
+
>
|
|
14
|
+
> What moved, all of it reconciling the frozen `byollm_009` with its code:
|
|
15
|
+
> `JobStub` gains `site` (the site's identity key id) and loses
|
|
16
|
+
> `audienceAllow`; `ResultRequest` gains `leaseId`; `HeartbeatResponse` loses
|
|
17
|
+
> `leases`, which nothing read; `WireErrorCode` gains `not-ready`,
|
|
18
|
+
> `clock-skew` and `forbidden`, and `403` is `forbidden` rather than
|
|
19
|
+
> `unauthorized`. `RESULT_PROVENANCE` is superseded by
|
|
20
|
+
> `PROVENANCE_NAMES_DEVICE`. See `byollm_009` Amendment A.>
|
|
21
|
+
> **`alpha.16` is a breaking wire change — daemons and relays again, not app
|
|
22
|
+
> authors.** `app.enqueue(...)` and reading results are unchanged. All five
|
|
23
|
+
> packages move together: both ends parse `.strict()`, so a mixed pair
|
|
24
|
+
> refuses.
|
|
25
|
+
>
|
|
26
|
+
> What moved, all of it Tier 2 of `cloud_008`: `model`, `backendClass` and
|
|
27
|
+
> `durationMs` come off `ResultRequest` and are sealed **inside** the result
|
|
28
|
+
> envelope as `SealedOutcome = { outcome, ran }` — so a daemon can no longer
|
|
29
|
+
> declare a model it did not sign, and a relay carries neither.
|
|
30
|
+
> `HeartbeatResponse` loses `leases` (nothing read it) and now reports real
|
|
31
|
+
> cancellations instead of an empty list. `WireErrorCode` gains `forbidden`
|
|
32
|
+
> for 403, leaving `unauthorized` at exactly 401. The relay gained a
|
|
33
|
+
> site-plane `cancel` endpoint, honours `stub.deadlineAt`, honours
|
|
34
|
+
> `stub.audience`, and remembers a refusal.>
|
|
35
|
+
> **`alpha.17` is additive** — no wire change. It exports `ReleaseReason`,
|
|
36
|
+
> which `RoutingStore.releaseLeases` names and the package did not export, so
|
|
37
|
+
> the interface was unimplementable outside this repo.>
|
|
38
|
+
> **`alpha.18` is a breaking wire change — daemons and relays, not app
|
|
39
|
+
> authors.** `app.enqueue(...)` and reading results are unchanged. All five
|
|
40
|
+
> packages move together.
|
|
41
|
+
>
|
|
42
|
+
> The **bearer token is gone**: off `PairPollResponse`, off the runner row,
|
|
43
|
+
> off the daemon's pairings file, out of the adapter's schema. It was minted,
|
|
44
|
+
> hashed and stored on two disks and never sent, looked up or compared —
|
|
45
|
+
> `REQUESTS_SIGNED_NOT_BEARER` was enforced by signatures the whole time. If
|
|
46
|
+
> you run the Supabase adapter, apply
|
|
47
|
+
> `20260819000000_drop_runner_token.sql`; `byollm_approve_pairing` now takes
|
|
48
|
+
> one argument. A pairings file written by an older daemon still loads.
|
|
49
|
+
>
|
|
50
|
+
> `model`, `backendClass` and `durationMs` moved **inside** the sealed result
|
|
51
|
+
> (`SealedOutcome = { outcome, ran }`), so a daemon cannot declare a model it
|
|
52
|
+
> did not sign and a relay carries none of them. Writing a `RoutingStore`?
|
|
53
|
+
> `releaseLeases` takes an optional `reason` and `complete` requires
|
|
54
|
+
> `leaseId`, and **an implementation that ignores either still typechecks** —
|
|
55
|
+
> run the store contract tests.>
|
|
56
|
+
> **`alpha.19` is additive on the wire and a behaviour change in every
|
|
57
|
+
> store.** `ResultResponse` gains an optional `duplicate`. Nothing is removed,
|
|
58
|
+
> so an older daemon keeps working — but the *order* two rules are checked in
|
|
59
|
+
> has changed, and a `RoutingStore` implementation must change with it.
|
|
60
|
+
>
|
|
61
|
+
> `complete` now checks **terminal state before holder**, scoped to the device
|
|
62
|
+
> that finished the job: a replay from that device is answered `duplicate:
|
|
63
|
+
> true` with a 2xx, and anyone else gets exactly the refusal they would get
|
|
64
|
+
> for a job that is not terminal. Previously `RESULT_IDEMPOTENT` held only
|
|
65
|
+
> because the lease is nulled on success, so the holder check tripped first —
|
|
66
|
+
> deleting the idempotency branch failed no test. Run the store contract
|
|
67
|
+
> tests; the compiler cannot see this.
|
|
68
|
+
|
|
69
|
+
<!-- release-note 0.1.0-alpha.21 -->
|
|
70
|
+
> [!NOTE]
|
|
71
|
+
> **`0.1.0-alpha.20` is not a complete release — do not pin it.** Four
|
|
72
|
+
> packages published and `@byollm/server` did not: a Sigstore
|
|
73
|
+
> transparency-log 409 on its provenance attestation. The workflow's
|
|
74
|
+
> "already published" guard correctly refuses to resume a partial publish,
|
|
75
|
+
> so `0.1.0-alpha.21` is that release, whole.
|
|
76
|
+
>
|
|
77
|
+
> If you run the Supabase adapter, `alpha.21` needs
|
|
78
|
+
> `20260819010000_completed_by_lease_id.sql`: alpha.19 shipped §3.6's
|
|
79
|
+
> ordering without the column it stores the grant in.
|
|
80
|
+
|
|
81
|
+
<!-- release-note 0.1.0-alpha.40 -->
|
|
82
|
+
**`byollm install` — stop keeping a terminal open.** The daemon can now run
|
|
83
|
+
under your computer's own supervisor and restart itself if it stops: a launchd
|
|
84
|
+
agent on macOS, a `systemd --user` unit on Linux, a logon task on Windows. All
|
|
85
|
+
user-level — no root, no system directories, and `byollm uninstall` takes it
|
|
86
|
+
away. `byollm status` gained a line saying whether it is actually supervised
|
|
87
|
+
right now, including the state that matters most: installed but not running,
|
|
88
|
+
which looks fine from an app's dashboard and serves nothing.
|
|
89
|
+
|
|
90
|
+
If you are running via `npx`, install properly first (`npm install -g
|
|
91
|
+
byollm@alpha`) — `install` refuses to supervise a copy in npx's cache, because
|
|
92
|
+
npm deletes that directory and the service would fail at some later boot.
|
|
93
|
+
|
|
94
|
+
<!-- release-note 0.1.0-alpha.41 -->
|
|
95
|
+
**`onNoRunner` takes a string.** Your fallback answer is your own value, not
|
|
96
|
+
wire data, and handing back a whole result record for it was ceremony — the
|
|
97
|
+
README's own example got the shape wrong, which is how this was found.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
const { outcome, fallback } = await job.result({
|
|
101
|
+
onNoRunner: () => runOnHostedModel(transcript),
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Whatever you return, `result()` labels it `fallback: true` — the stamp is
|
|
106
|
+
applied by the wait, not taken from you, so an answer that did not run on
|
|
107
|
+
somebody's device cannot be reported as though it did (`FALLBACK_LABELED`).
|
|
108
|
+
Both delivery channels do it, polling and Supabase Realtime. Records still
|
|
109
|
+
work; they just get labelled too.
|
|
7
110
|
|
|
8
111
|
# `@byollm/relay`
|
|
9
112
|
|
|
10
113
|
The **reference relay**: it routes byollm jobs between a site and someone's
|
|
11
|
-
|
|
114
|
+
device while holding no key that can open either end's traffic.
|
|
12
115
|
|
|
13
116
|
```
|
|
14
117
|
site ──stub──▶ relay ◀──claim── daemon
|
|
@@ -19,7 +122,7 @@ machine while holding no key that can open either end's traffic.
|
|
|
19
122
|
|
|
20
123
|
## Why a relay can be blind
|
|
21
124
|
|
|
22
|
-
A payload is encrypted to the
|
|
125
|
+
A payload is encrypted to the device that runs it. Nobody knows which device
|
|
23
126
|
that is until one claims the job — so the site publishes a **stub** first
|
|
24
127
|
(byollm_009 §6: user, kind, size class, audience, deadline, streaming flag, and
|
|
25
128
|
nothing else), a daemon claims it, and only then does the site seal the work to
|
|
@@ -81,13 +184,31 @@ Nothing here trusts a `siteId` in a body or a query string.
|
|
|
81
184
|
|
|
82
185
|
That is newer than the rest of this package. The site plane took the caller's
|
|
83
186
|
word for who it was until `0.1.0-alpha.8`, which on a relay reachable from the
|
|
84
|
-
internet is an open enqueue endpoint into consenting users'
|
|
187
|
+
internet is an open enqueue endpoint into consenting users' devices and an
|
|
85
188
|
open read of who is online. It was blind the whole time — nothing could open a
|
|
86
189
|
payload — and blind is not the same as safe.
|
|
87
190
|
|
|
88
191
|
If you are running this: the site plane is authenticated but this is still a
|
|
89
192
|
single-tenant relay with in-memory state. One site, one replica.
|
|
90
193
|
|
|
194
|
+
## Breaking in `0.1.0-alpha.12`: `RelayState` is async
|
|
195
|
+
|
|
196
|
+
Every method on `RelayState` now returns a `Promise`, and `Relay.sweep()` and
|
|
197
|
+
`debugPage()` with it. `RelayState.requeue` is private — it was only ever a
|
|
198
|
+
step inside another operation.
|
|
199
|
+
|
|
200
|
+
Nothing about the behaviour changed. The shape did, and it had to before
|
|
201
|
+
routing state can live anywhere but this process: a store on a network cannot
|
|
202
|
+
offer a synchronous read, and — more importantly — cannot offer a *read the
|
|
203
|
+
caller follows with a write*. So the operations are now decisions plus their
|
|
204
|
+
writes (`claim`, `takePayload`, `complete`, `releaseLeases`, `seal`) rather
|
|
205
|
+
than scans the caller mutates.
|
|
206
|
+
|
|
207
|
+
`claim` is the one that matters. It was atomic for exactly one reason — Node
|
|
208
|
+
is single-threaded and the Maps are local — and `CLAIM_ATOMIC` is a MUST. See
|
|
209
|
+
`packages/relay/test/two-replicas.test.ts`, where the resulting race is a
|
|
210
|
+
failing assertion waiting for the fix.
|
|
211
|
+
|
|
91
212
|
## Running it
|
|
92
213
|
|
|
93
214
|
```ts
|
|
@@ -111,4 +232,26 @@ const response = await relay.handle(request);
|
|
|
111
232
|
`awaiting-payload` timer has left. It shows no prompt or result text — not
|
|
112
233
|
because it filters them out, but because the relay does not have them.
|
|
113
234
|
|
|
235
|
+
**Do not serve `/debug` on the internet.** It shows no payloads and it does
|
|
236
|
+
show who is online, which device holds what, and every lease in flight — the
|
|
237
|
+
same metadata the site plane's signatures exist to protect, through a
|
|
238
|
+
different door. Whoever serves this package decides that, which is why the
|
|
239
|
+
route is still here: refuse it at your gateway and reach it through an
|
|
240
|
+
authenticated channel instead.
|
|
241
|
+
|
|
242
|
+
## Auditing a deployment
|
|
243
|
+
|
|
244
|
+
`@byollm/conformance` ships a posture audit that holds nothing but a URL,
|
|
245
|
+
which is what an attacker has:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
npx byollm-audit-deployment https://your-relay.example
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
It exists because eight of byollm_009's findings came from a suite in which
|
|
252
|
+
nothing was ever a stranger — the site had a reference to the relay object and
|
|
253
|
+
called it. A harness that invokes the system under test directly cannot see
|
|
254
|
+
anything about how the system is *reached*, and the ninth finding was in that
|
|
255
|
+
gap. Safe to run against production: nothing writes, nothing floods.
|
|
256
|
+
|
|
114
257
|
MIT
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// src/state.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
var AWAITING_PAYLOAD_MS = 1e4;
|
|
4
|
+
var RETRY_AFTER_MS = 3e4;
|
|
5
|
+
var routeKey = (siteId, owner) => `${siteId}\0${owner}`;
|
|
6
|
+
var keyOf = (siteId, jobId) => `${siteId}\0${jobId}`;
|
|
7
|
+
var RelayState = class {
|
|
8
|
+
/**
|
|
9
|
+
* Jobs by **(site, id)** — cloud_009 §3.
|
|
10
|
+
*
|
|
11
|
+
* A job id is a site's to choose, so two sites can choose the same one.
|
|
12
|
+
* Keyed by the bare id, the second site's enqueue returned the first
|
|
13
|
+
* site's job (cloud_008 finding 58), and the refusal that fixed it was a
|
|
14
|
+
* cross-tenant existence oracle. Keyed by the pair, the collision does not
|
|
15
|
+
* exist and there is nothing to refuse.
|
|
16
|
+
*
|
|
17
|
+
* `\u0000` as the separator, because a site id is a uuid and a job id is
|
|
18
|
+
* whatever a site chose — including, one day, a string with a colon in it.
|
|
19
|
+
* A separator that cannot appear in either half is the difference between
|
|
20
|
+
* a key and a parser.
|
|
21
|
+
*/
|
|
22
|
+
#jobs = /* @__PURE__ */ new Map();
|
|
23
|
+
/**
|
|
24
|
+
* Grants by lease id, so a holder-scoped call needs no site — §3.
|
|
25
|
+
*
|
|
26
|
+
* `takePayload`, `complete`, `releaseLeases` and `renewLeases` carry a
|
|
27
|
+
* `leaseId` the relay minted, which is unique across every site. That is
|
|
28
|
+
* what lets those four signatures stay as they are: the caller names the
|
|
29
|
+
* grant, and the grant names the job. A daemon never has to know a site id
|
|
30
|
+
* to answer for work it holds.
|
|
31
|
+
*/
|
|
32
|
+
#byLease = /* @__PURE__ */ new Map();
|
|
33
|
+
/**
|
|
34
|
+
* Jobs by bare id, across sites — the refusal path.
|
|
35
|
+
*
|
|
36
|
+
* The lease index alone answers the happy case and gets the refusals
|
|
37
|
+
* wrong: a **stale** lease finds nothing, so `LEASE_HONORED`'s "your grant
|
|
38
|
+
* ended" becomes "no such job", and a daemon that was slow is told
|
|
39
|
+
* something untrue about the work it was doing. Distinguishing
|
|
40
|
+
* `not-found`, `not-holder` and `stale-lease` needs the job even when the
|
|
41
|
+
* lease named is over, and that is what this is for.
|
|
42
|
+
*
|
|
43
|
+
* A list rather than a single value: two sites may choose one id, which is
|
|
44
|
+
* the whole reason `#jobs` is keyed by the pair.
|
|
45
|
+
*/
|
|
46
|
+
#byJobId = /* @__PURE__ */ new Map();
|
|
47
|
+
/**
|
|
48
|
+
* The job a holder-scoped call is about, without a site id.
|
|
49
|
+
*
|
|
50
|
+
* The exact grant first, and **checked against the job the caller named**:
|
|
51
|
+
* a lease id belonging to another job would otherwise hand over that job's
|
|
52
|
+
* payload to somebody holding a valid-looking grant. Then the same job held
|
|
53
|
+
* by this runner under an older grant, which is what `stale-lease` is.
|
|
54
|
+
*
|
|
55
|
+
* And then nothing — V1-8. There used to be a third step: any job with that
|
|
56
|
+
* id, which produced `not-holder` where an absent job produces `not-found`.
|
|
57
|
+
* Since job ids are chosen per site, a runner could name a bare id it had
|
|
58
|
+
* no relationship with and learn from the status code whether some *other*
|
|
59
|
+
* tenant had a job by that name. Finding 58's existence oracle, through the
|
|
60
|
+
* holder door.
|
|
61
|
+
*
|
|
62
|
+
* The distinction it bought was never acted on: a daemon abandons the work
|
|
63
|
+
* either way. So a caller now learns about jobs it holds or held, and about
|
|
64
|
+
* nothing else.
|
|
65
|
+
*/
|
|
66
|
+
#grantFor(jobId, runnerId, leaseId) {
|
|
67
|
+
const exact = this.#byLease.get(leaseId);
|
|
68
|
+
if (exact?.id === jobId) return exact;
|
|
69
|
+
const candidates = this.#byJobId.get(jobId) ?? [];
|
|
70
|
+
return candidates.find((job) => job.claimedBy?.runnerId === runnerId);
|
|
71
|
+
}
|
|
72
|
+
#index(job) {
|
|
73
|
+
const bare = this.#byJobId.get(job.id);
|
|
74
|
+
if (bare) {
|
|
75
|
+
if (!bare.includes(job)) bare.push(job);
|
|
76
|
+
} else {
|
|
77
|
+
this.#byJobId.set(job.id, [job]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
#forget(job) {
|
|
81
|
+
this.#jobs.delete(keyOf(job.siteId, job.id));
|
|
82
|
+
const bare = (this.#byJobId.get(job.id) ?? []).filter((it) => it !== job);
|
|
83
|
+
if (bare.length === 0) this.#byJobId.delete(job.id);
|
|
84
|
+
else this.#byJobId.set(job.id, bare);
|
|
85
|
+
if (job.claimedBy) this.#byLease.delete(job.claimedBy.leaseId);
|
|
86
|
+
}
|
|
87
|
+
#presence = /* @__PURE__ */ new Map();
|
|
88
|
+
#now;
|
|
89
|
+
constructor(options = {}) {
|
|
90
|
+
this.#now = options.now ?? Date.now;
|
|
91
|
+
}
|
|
92
|
+
/** The one clock every deadline in this store is stamped from. */
|
|
93
|
+
async now() {
|
|
94
|
+
return this.#now();
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Take a stub for routing. The payload is not here and will not be.
|
|
98
|
+
*
|
|
99
|
+
* **Idempotent by job id, and that is a security property rather than a
|
|
100
|
+
* convenience.** Site-plane calls are authenticated by signature, and
|
|
101
|
+
* byollm_009 §4.2's argument for signing the request instead of a
|
|
102
|
+
* server-issued nonce rests entirely on every write being idempotent per the
|
|
103
|
+
* instance it names. This one was not: re-enqueueing a known id built a
|
|
104
|
+
* fresh `queued` job over the top of the old one, discarding a live claim,
|
|
105
|
+
* its lease and any payload the site had already sealed to a device. A
|
|
106
|
+
* replayed enqueue inside the two-minute freshness window was therefore a
|
|
107
|
+
* way to yank a job back from the machine running it — the `release` bug of
|
|
108
|
+
* §4.2, rediscovered on the other plane.
|
|
109
|
+
*
|
|
110
|
+
* So a known id returns what is already routing, unchanged. A site that
|
|
111
|
+
* restarts and republishes its queue is the normal case, and it must not
|
|
112
|
+
* disturb work in flight.
|
|
113
|
+
*/
|
|
114
|
+
enqueue(input) {
|
|
115
|
+
const existing = this.#jobs.get(keyOf(input.siteId, input.id));
|
|
116
|
+
if (existing) return Promise.resolve(existing);
|
|
117
|
+
const job = {
|
|
118
|
+
id: input.id,
|
|
119
|
+
siteId: input.siteId,
|
|
120
|
+
stub: input.stub,
|
|
121
|
+
state: "queued",
|
|
122
|
+
refusedBy: []
|
|
123
|
+
};
|
|
124
|
+
this.#jobs.set(keyOf(job.siteId, job.id), job);
|
|
125
|
+
this.#index(job);
|
|
126
|
+
return Promise.resolve(job);
|
|
127
|
+
}
|
|
128
|
+
job(siteId, jobId) {
|
|
129
|
+
return Promise.resolve(this.#jobs.get(keyOf(siteId, jobId)));
|
|
130
|
+
}
|
|
131
|
+
jobs() {
|
|
132
|
+
return Promise.resolve([...this.#jobs.values()]);
|
|
133
|
+
}
|
|
134
|
+
/** Jobs a site must seal for, right now. */
|
|
135
|
+
async awaiting(siteId) {
|
|
136
|
+
return (await this.jobs()).filter(
|
|
137
|
+
(j) => j.siteId === siteId && j.state === "awaiting-payload"
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
/** Sealed results waiting to go home. */
|
|
141
|
+
async finished(siteId) {
|
|
142
|
+
return (await this.jobs()).filter(
|
|
143
|
+
(j) => j.siteId === siteId && j.state === "done" && j.result !== void 0
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Claim work — one operation, because it has to be.
|
|
148
|
+
*
|
|
149
|
+
* Moved here wholesale from `DaemonPlane`, where it was a scan followed by
|
|
150
|
+
* per-job mutation. Nothing about the *decision* changed; what changed is
|
|
151
|
+
* that a store can now implement it, because the filter and the write are
|
|
152
|
+
* one call rather than a loop the caller drives.
|
|
153
|
+
*
|
|
154
|
+
* The order of the guards is worth preserving as-is when this becomes a Lua
|
|
155
|
+
* script: cheapest first, and `owners` last because it is the only one that
|
|
156
|
+
* needed the projection.
|
|
157
|
+
*/
|
|
158
|
+
async claim(input) {
|
|
159
|
+
const now = await this.now();
|
|
160
|
+
await this.sweep();
|
|
161
|
+
const granted = [];
|
|
162
|
+
for (const job of this.#jobs.values()) {
|
|
163
|
+
if (granted.length >= input.max) break;
|
|
164
|
+
if (job.state !== "queued") continue;
|
|
165
|
+
if (!input.routes.has(routeKey(job.siteId, job.stub.owner))) continue;
|
|
166
|
+
if (!input.kinds.has(job.stub.kind)) continue;
|
|
167
|
+
if (job.refusedBy.includes(input.runnerId)) continue;
|
|
168
|
+
if ((job.retryAfter?.[input.runnerId] ?? 0) > now) continue;
|
|
169
|
+
if (job.cancelled) continue;
|
|
170
|
+
if (job.stub.audience === "private" && job.stub.owner !== input.owner) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const leaseId = randomUUID();
|
|
174
|
+
job.state = "awaiting-payload";
|
|
175
|
+
job.claimedBy = {
|
|
176
|
+
runnerId: input.runnerId,
|
|
177
|
+
owner: input.owner,
|
|
178
|
+
device: input.device,
|
|
179
|
+
leaseId,
|
|
180
|
+
leaseExpiresAt: now + input.leaseMs
|
|
181
|
+
};
|
|
182
|
+
job.awaitingUntil = now + AWAITING_PAYLOAD_MS;
|
|
183
|
+
this.#byLease.set(leaseId, job);
|
|
184
|
+
granted.push({
|
|
185
|
+
...job.stub,
|
|
186
|
+
lease: {
|
|
187
|
+
id: leaseId,
|
|
188
|
+
runnerId: input.runnerId,
|
|
189
|
+
expiresAt: job.claimedBy.leaseExpiresAt
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return granted;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Hand over the sealed payload to the device that holds the lease.
|
|
197
|
+
*
|
|
198
|
+
* The read and the state transition are one operation for the same reason
|
|
199
|
+
* `claim` is: `running` must be set by whoever was told the envelope, or two
|
|
200
|
+
* replicas can both hand out the same work and both believe they were first.
|
|
201
|
+
*/
|
|
202
|
+
takePayload(input) {
|
|
203
|
+
const job = this.#grantFor(input.jobId, input.runnerId, input.leaseId);
|
|
204
|
+
if (!job) return Promise.resolve({ refused: "not-found" });
|
|
205
|
+
if (job.claimedBy?.runnerId !== input.runnerId) {
|
|
206
|
+
return Promise.resolve({ refused: "not-holder" });
|
|
207
|
+
}
|
|
208
|
+
if (job.claimedBy.leaseId !== input.leaseId) {
|
|
209
|
+
return Promise.resolve({ refused: "stale-lease" });
|
|
210
|
+
}
|
|
211
|
+
if (!job.payload) return Promise.resolve({ refused: "not-ready" });
|
|
212
|
+
if (job.state !== "ready" && job.state !== "running") {
|
|
213
|
+
return Promise.resolve({ refused: "terminal" });
|
|
214
|
+
}
|
|
215
|
+
job.state = "running";
|
|
216
|
+
return Promise.resolve({ envelope: job.payload });
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Record a finished job.
|
|
220
|
+
*
|
|
221
|
+
* `RESULT_IDEMPOTENT` lives here rather than in the caller: a replayed
|
|
222
|
+
* result must be a no-op decided by the same operation that would have
|
|
223
|
+
* written it, or two replicas can both decide they were the first.
|
|
224
|
+
*/
|
|
225
|
+
complete(input) {
|
|
226
|
+
const job = this.#grantFor(input.jobId, input.runnerId, input.leaseId);
|
|
227
|
+
if (!job) return Promise.resolve({ refused: "not-found" });
|
|
228
|
+
if (job.claimedBy?.runnerId !== input.runnerId) {
|
|
229
|
+
return Promise.resolve({ refused: "not-holder" });
|
|
230
|
+
}
|
|
231
|
+
if (job.state === "done") {
|
|
232
|
+
const sameGrant = job.claimedBy.leaseId === input.leaseId;
|
|
233
|
+
return Promise.resolve(
|
|
234
|
+
sameGrant ? { accepted: false, duplicate: true, state: job.state } : { refused: "stale-lease" }
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
if (job.claimedBy.leaseId !== input.leaseId) {
|
|
238
|
+
return Promise.resolve({ refused: "stale-lease" });
|
|
239
|
+
}
|
|
240
|
+
job.result = input.envelope;
|
|
241
|
+
job.disposition = input.disposition;
|
|
242
|
+
job.state = "done";
|
|
243
|
+
return Promise.resolve({ accepted: true, state: job.state });
|
|
244
|
+
}
|
|
245
|
+
/** Give back leases this runner holds, naming each grant it means. */
|
|
246
|
+
releaseLeases(input) {
|
|
247
|
+
const released = [];
|
|
248
|
+
for (const { jobId, leaseId } of input.leases) {
|
|
249
|
+
const job = this.#grantFor(jobId, input.runnerId, leaseId);
|
|
250
|
+
if (!job || job.claimedBy?.runnerId !== input.runnerId) continue;
|
|
251
|
+
if (job.claimedBy.leaseId !== leaseId) continue;
|
|
252
|
+
if (job.state === "done") continue;
|
|
253
|
+
if (input.reason === "refused" && !job.refusedBy.includes(input.runnerId)) {
|
|
254
|
+
job.refusedBy.push(input.runnerId);
|
|
255
|
+
}
|
|
256
|
+
if (input.retryAfter !== void 0) {
|
|
257
|
+
job.retryAfter = {
|
|
258
|
+
...job.retryAfter,
|
|
259
|
+
[input.runnerId]: input.retryAfter
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
this.#requeue(job);
|
|
263
|
+
released.push(jobId);
|
|
264
|
+
}
|
|
265
|
+
return Promise.resolve(released);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Take a site's sealed payload for a claimed job.
|
|
269
|
+
*
|
|
270
|
+
* Refuses anything not `awaiting-payload`, which is what makes the timeout
|
|
271
|
+
* mean something: a late seal must not land on a claim that has moved.
|
|
272
|
+
*/
|
|
273
|
+
seal(input) {
|
|
274
|
+
const job = this.#jobs.get(keyOf(input.siteId, input.jobId));
|
|
275
|
+
if (job?.siteId !== input.siteId) {
|
|
276
|
+
return Promise.resolve({ refused: "not-found" });
|
|
277
|
+
}
|
|
278
|
+
if (job.state !== "awaiting-payload") {
|
|
279
|
+
return Promise.resolve({ refused: "too-late", was: job.state });
|
|
280
|
+
}
|
|
281
|
+
job.payload = input.envelope;
|
|
282
|
+
job.state = "ready";
|
|
283
|
+
delete job.awaitingUntil;
|
|
284
|
+
return Promise.resolve({ state: job.state });
|
|
285
|
+
}
|
|
286
|
+
/** {@link RoutingStore.cancel} — the site withdraws a job. */
|
|
287
|
+
cancel(input) {
|
|
288
|
+
const job = this.#jobs.get(keyOf(input.siteId, input.jobId));
|
|
289
|
+
if (job?.siteId !== input.siteId) return Promise.resolve(false);
|
|
290
|
+
job.cancelled = true;
|
|
291
|
+
return Promise.resolve(true);
|
|
292
|
+
}
|
|
293
|
+
/** {@link RoutingStore.cancelRequests} — cancelled jobs this runner holds. */
|
|
294
|
+
cancelRequests(runnerId) {
|
|
295
|
+
return Promise.resolve(
|
|
296
|
+
[...this.#jobs.values()].filter(
|
|
297
|
+
(job) => job.cancelled === true && job.claimedBy?.runnerId === runnerId
|
|
298
|
+
).map((job) => ({
|
|
299
|
+
jobId: job.id,
|
|
300
|
+
leaseId: job.claimedBy?.leaseId ?? ""
|
|
301
|
+
})).filter((grant) => grant.leaseId !== "")
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
/** {@link RoutingStore.renewLeases} — extend what is still held, name what is not. */
|
|
305
|
+
async renewLeases(input) {
|
|
306
|
+
const now = await this.now();
|
|
307
|
+
const renewed = [];
|
|
308
|
+
const lost = [];
|
|
309
|
+
for (const { jobId, leaseId } of input.leases) {
|
|
310
|
+
const job = this.#grantFor(jobId, input.runnerId, leaseId);
|
|
311
|
+
const held = job?.claimedBy;
|
|
312
|
+
if (!job || held?.leaseId !== leaseId || held.runnerId !== input.runnerId) {
|
|
313
|
+
lost.push({ jobId, leaseId });
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const expiresAt = now + input.leaseMs;
|
|
317
|
+
job.claimedBy = { ...held, leaseExpiresAt: expiresAt };
|
|
318
|
+
renewed.push({ jobId, expiresAt });
|
|
319
|
+
}
|
|
320
|
+
return { renewed, lost };
|
|
321
|
+
}
|
|
322
|
+
async seen(presence) {
|
|
323
|
+
const lastSeenAt = await this.now();
|
|
324
|
+
const existing = this.#presence.get(presence.runnerId);
|
|
325
|
+
if (existing) {
|
|
326
|
+
existing.lastSeenAt = lastSeenAt;
|
|
327
|
+
existing.capabilities = presence.capabilities;
|
|
328
|
+
existing.withheld = presence.withheld;
|
|
329
|
+
return existing;
|
|
330
|
+
}
|
|
331
|
+
const fresh = { ...presence, lastSeenAt };
|
|
332
|
+
this.#presence.set(presence.runnerId, fresh);
|
|
333
|
+
return fresh;
|
|
334
|
+
}
|
|
335
|
+
presence(runnerId) {
|
|
336
|
+
return Promise.resolve(this.#presence.get(runnerId));
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Lose a record, the way a real store does.
|
|
340
|
+
*
|
|
341
|
+
* A shared store drops presence for reasons this one never will — a TTL, a
|
|
342
|
+
* reschedule, a restart — and the interesting behaviour is what the relay
|
|
343
|
+
* does next. `ValkeyRoutingStore` has carried the same helper since
|
|
344
|
+
* finding 52; this is its memory twin, so the case can be written once
|
|
345
|
+
* against the implementation that is easy to reason about.
|
|
346
|
+
*/
|
|
347
|
+
dropPresenceForTests(runnerId) {
|
|
348
|
+
this.#presence.delete(runnerId);
|
|
349
|
+
return Promise.resolve();
|
|
350
|
+
}
|
|
351
|
+
everyone() {
|
|
352
|
+
return Promise.resolve([...this.#presence.values()]);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Return a job to the queue, forgetting the claim.
|
|
356
|
+
*
|
|
357
|
+
* The stub survives; nothing is lost. That is `LEASE_RECLAIMABLE` and it is
|
|
358
|
+
* why the awaiting-payload timeout is cheap to fire: the worst case is that
|
|
359
|
+
* a device did nothing for ten seconds and another one gets a turn.
|
|
360
|
+
*/
|
|
361
|
+
#requeue(job) {
|
|
362
|
+
job.state = "queued";
|
|
363
|
+
if (job.claimedBy) this.#byLease.delete(job.claimedBy.leaseId);
|
|
364
|
+
delete job.claimedBy;
|
|
365
|
+
delete job.awaitingUntil;
|
|
366
|
+
delete job.payload;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Fire whatever the clock says is due, and report it.
|
|
370
|
+
*
|
|
371
|
+
* Returns the jobs it requeued so a caller can log or surface them — a
|
|
372
|
+
* timeout that fires invisibly is indistinguishable from a job that was
|
|
373
|
+
* never claimed, and those want very different debugging.
|
|
374
|
+
*/
|
|
375
|
+
async sweep() {
|
|
376
|
+
const now = await this.now();
|
|
377
|
+
const requeued = [];
|
|
378
|
+
const expired = [];
|
|
379
|
+
for (const job of this.#jobs.values()) {
|
|
380
|
+
if (job.stub.deadlineAt <= now) {
|
|
381
|
+
this.#forget(job);
|
|
382
|
+
expired.push(job);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (job.state === "awaiting-payload" && (job.awaitingUntil ?? 0) <= now) {
|
|
386
|
+
this.#requeue(job);
|
|
387
|
+
requeued.push(job);
|
|
388
|
+
}
|
|
389
|
+
const lease = job.claimedBy;
|
|
390
|
+
if (lease && (job.state === "ready" || job.state === "running") && lease.leaseExpiresAt <= now) {
|
|
391
|
+
this.#requeue(job);
|
|
392
|
+
requeued.push(job);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return [...requeued, ...expired];
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
export {
|
|
400
|
+
AWAITING_PAYLOAD_MS,
|
|
401
|
+
RETRY_AFTER_MS,
|
|
402
|
+
routeKey,
|
|
403
|
+
RelayState
|
|
404
|
+
};
|
|
405
|
+
//# sourceMappingURL=chunk-OB6LPEEE.js.map
|