@byollm/relay 0.1.0-alpha.9 → 0.1.0-alpha.91

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 CHANGED
@@ -1,14 +1,117 @@
1
1
  > [!WARNING]
2
- > **Alpha (`0.1.0-alpha.9`) — under active development. Don't use this yet.**
2
+ > **Alpha (`0.1.0-alpha.91`) — 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 start` — 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 stop` 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@latest`) — `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
- machine while holding no key that can open either end's traffic.
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 machine that runs it. Nobody knows which machine
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
@@ -80,14 +183,32 @@ daemons pin at pairing, verified against the `sites` half of the projection.
80
183
  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
- word for who it was until `0.1.0-alpha.9`, which on a relay reachable from the
84
- internet is an open enqueue endpoint into consenting users' machines and an
186
+ word for who it was until `0.1.0-alpha.8`, which on a relay reachable from the
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,40 @@ 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
258
+
259
+ <!-- family:start -->
260
+
261
+ ## The rest of byollm
262
+
263
+ Six packages, and they are only interesting together:
264
+
265
+ - [`byollm`](https://www.npmjs.com/package/byollm) — the daemon — runs models on your own machine and answers for it
266
+ - [`@byollm/protocol`](https://www.npmjs.com/package/@byollm/protocol) — the wire: envelopes, signatures and the closed vocabularies both ends validate against
267
+ - [`@byollm/server`](https://www.npmjs.com/package/@byollm/server) — the SDK a site uses to ask a device for work
268
+ - [`@byollm/control-plane`](https://www.npmjs.com/package/@byollm/control-plane) — who may ask whom, and the policy store behind it
269
+ - [`@byollm/conformance`](https://www.npmjs.com/package/@byollm/conformance) — the kit that proves an implementation is one — including a posture audit that holds nothing but a URL
270
+
271
+ <!-- family:end -->
@@ -0,0 +1,484 @@
1
+ // src/state.ts
2
+ import { randomUUID } from "crypto";
3
+ var AWAITING_PAYLOAD_MS = 1e4;
4
+ var UNSEALED_PER_PAIR = 2;
5
+ var SEAL_ATTEMPTS_BEFORE_EVICTION = 3;
6
+ var RETRY_AFTER_MS = 3e4;
7
+ var routeKey = (siteId, owner) => `${siteId}\0${owner}`;
8
+ var CHURN_EXPIRIES = 6;
9
+ var CHURN_WINDOW_MS = 5 * 6e4;
10
+ var CHURN_QUIET_MS = 60 * 6e4;
11
+ var keyOf = (siteId, jobId) => `${siteId}\0${jobId}`;
12
+ var RelayState = class {
13
+ /**
14
+ * Jobs by **(site, id)** — cloud_009 §3.
15
+ *
16
+ * A job id is a site's to choose, so two sites can choose the same one.
17
+ * Keyed by the bare id, the second site's enqueue returned the first
18
+ * site's job (cloud_008 finding 58), and the refusal that fixed it was a
19
+ * cross-tenant existence oracle. Keyed by the pair, the collision does not
20
+ * exist and there is nothing to refuse.
21
+ *
22
+ * `\u0000` as the separator, because a site id is a uuid and a job id is
23
+ * whatever a site chose — including, one day, a string with a colon in it.
24
+ * A separator that cannot appear in either half is the difference between
25
+ * a key and a parser.
26
+ */
27
+ #jobs = /* @__PURE__ */ new Map();
28
+ /**
29
+ * Grants by lease id, so a holder-scoped call needs no site — §3.
30
+ *
31
+ * `takePayload`, `complete`, `releaseLeases` and `renewLeases` carry a
32
+ * `leaseId` the relay minted, which is unique across every site. That is
33
+ * what lets those four signatures stay as they are: the caller names the
34
+ * grant, and the grant names the job. A daemon never has to know a site id
35
+ * to answer for work it holds.
36
+ */
37
+ #byLease = /* @__PURE__ */ new Map();
38
+ /**
39
+ * Jobs by bare id, across sites — the refusal path.
40
+ *
41
+ * The lease index alone answers the happy case and gets the refusals
42
+ * wrong: a **stale** lease finds nothing, so `LEASE_HONORED`'s "your grant
43
+ * ended" becomes "no such job", and a daemon that was slow is told
44
+ * something untrue about the work it was doing. Distinguishing
45
+ * `not-found`, `not-holder` and `stale-lease` needs the job even when the
46
+ * lease named is over, and that is what this is for.
47
+ *
48
+ * A list rather than a single value: two sites may choose one id, which is
49
+ * the whole reason `#jobs` is keyed by the pair.
50
+ */
51
+ #byJobId = /* @__PURE__ */ new Map();
52
+ /**
53
+ * The job a holder-scoped call is about, without a site id.
54
+ *
55
+ * The exact grant first, and **checked against the job the caller named**:
56
+ * a lease id belonging to another job would otherwise hand over that job's
57
+ * payload to somebody holding a valid-looking grant. Then the same job held
58
+ * by this runner under an older grant, which is what `stale-lease` is.
59
+ *
60
+ * And then nothing — V1-8. There used to be a third step: any job with that
61
+ * id, which produced `not-holder` where an absent job produces `not-found`.
62
+ * Since job ids are chosen per site, a runner could name a bare id it had
63
+ * no relationship with and learn from the status code whether some *other*
64
+ * tenant had a job by that name. Finding 58's existence oracle, through the
65
+ * holder door.
66
+ *
67
+ * The distinction it bought was never acted on: a daemon abandons the work
68
+ * either way. So a caller now learns about jobs it holds or held, and about
69
+ * nothing else.
70
+ */
71
+ #grantFor(jobId, runnerId, leaseId) {
72
+ const exact = this.#byLease.get(leaseId);
73
+ if (exact?.id === jobId) return exact;
74
+ const candidates = this.#byJobId.get(jobId) ?? [];
75
+ return candidates.find((job) => job.claimedBy?.runnerId === runnerId);
76
+ }
77
+ #index(job) {
78
+ const bare = this.#byJobId.get(job.id);
79
+ if (bare) {
80
+ if (!bare.includes(job)) bare.push(job);
81
+ } else {
82
+ this.#byJobId.set(job.id, [job]);
83
+ }
84
+ }
85
+ /**
86
+ * Record an unsealed grant expiring, and report a pair that keeps doing it.
87
+ *
88
+ * Reported at the crossing rather than on every expiry after it, and then
89
+ * quiet for {@link CHURN_QUIET_MS} — a walk produces one of these every ten
90
+ * seconds, and an alert per expiry is an alert somebody filters.
91
+ *
92
+ * The callback is never awaited and its throw is swallowed: a report is
93
+ * evidence ABOUT the sweep, not part of it, and a mail server being down
94
+ * must not stop jobs being requeued.
95
+ */
96
+ #noteChurn(siteId, owner, now) {
97
+ const report = this.#churnReport;
98
+ if (report === void 0) return;
99
+ const pair = routeKey(siteId, owner);
100
+ const seen = [...this.#churn.get(pair) ?? [], now].filter(
101
+ (at) => now - at <= CHURN_WINDOW_MS
102
+ );
103
+ this.#churn.set(pair, seen);
104
+ if (seen.length < CHURN_EXPIRIES) return;
105
+ const told = this.#churnReported.get(pair);
106
+ if (told !== void 0 && now - told < CHURN_QUIET_MS) return;
107
+ this.#churnReported.set(pair, now);
108
+ try {
109
+ report({
110
+ siteId,
111
+ owner,
112
+ expiries: seen.length,
113
+ windowMs: CHURN_WINDOW_MS
114
+ });
115
+ } catch {
116
+ }
117
+ }
118
+ #forget(job) {
119
+ this.#jobs.delete(keyOf(job.siteId, job.id));
120
+ const bare = (this.#byJobId.get(job.id) ?? []).filter((it) => it !== job);
121
+ if (bare.length === 0) this.#byJobId.delete(job.id);
122
+ else this.#byJobId.set(job.id, bare);
123
+ if (job.claimedBy) this.#byLease.delete(job.claimedBy.leaseId);
124
+ }
125
+ /**
126
+ * When each pair's unsealed grants expired, and when it was last reported.
127
+ *
128
+ * Timestamps rather than a counter, because the threshold is "inside a
129
+ * window" — a counter would have to be reset by something, and whatever
130
+ * reset it would be the thing to get wrong. Trimmed on write, so it holds
131
+ * at most a window's worth per pair.
132
+ */
133
+ #churn = /* @__PURE__ */ new Map();
134
+ #churnReported = /* @__PURE__ */ new Map();
135
+ #presence = /* @__PURE__ */ new Map();
136
+ #now;
137
+ /** Kept whole rather than destructured: B187's report seam lives on it. */
138
+ #churnReport;
139
+ constructor(options = {}) {
140
+ this.#now = options.now ?? Date.now;
141
+ this.#churnReport = options.onUnsealedChurn;
142
+ }
143
+ /** The one clock every deadline in this store is stamped from. */
144
+ async now() {
145
+ return this.#now();
146
+ }
147
+ /**
148
+ * Take a stub for routing. The payload is not here and will not be.
149
+ *
150
+ * **Idempotent by job id, and that is a security property rather than a
151
+ * convenience.** Site-plane calls are authenticated by signature, and
152
+ * byollm_009 §4.2's argument for signing the request instead of a
153
+ * server-issued nonce rests entirely on every write being idempotent per the
154
+ * instance it names. This one was not: re-enqueueing a known id built a
155
+ * fresh `queued` job over the top of the old one, discarding a live claim,
156
+ * its lease and any payload the site had already sealed to a device. A
157
+ * replayed enqueue inside the two-minute freshness window was therefore a
158
+ * way to yank a job back from the machine running it — the `release` bug of
159
+ * §4.2, rediscovered on the other plane.
160
+ *
161
+ * So a known id returns what is already routing, unchanged. A site that
162
+ * restarts and republishes its queue is the normal case, and it must not
163
+ * disturb work in flight.
164
+ */
165
+ enqueue(input) {
166
+ const existing = this.#jobs.get(keyOf(input.siteId, input.id));
167
+ if (existing) return Promise.resolve(existing);
168
+ const job = {
169
+ id: input.id,
170
+ siteId: input.siteId,
171
+ stub: input.stub,
172
+ state: "queued",
173
+ refusedBy: []
174
+ };
175
+ this.#jobs.set(keyOf(job.siteId, job.id), job);
176
+ this.#index(job);
177
+ return Promise.resolve(job);
178
+ }
179
+ job(siteId, jobId) {
180
+ return Promise.resolve(this.#jobs.get(keyOf(siteId, jobId)));
181
+ }
182
+ jobs() {
183
+ return Promise.resolve([...this.#jobs.values()]);
184
+ }
185
+ /** Jobs a site must seal for, right now. */
186
+ async awaiting(siteId) {
187
+ return (await this.jobs()).filter(
188
+ (j) => j.siteId === siteId && j.state === "awaiting-payload"
189
+ );
190
+ }
191
+ /** Sealed results waiting to go home. */
192
+ async finished(siteId) {
193
+ return (await this.jobs()).filter(
194
+ (j) => j.siteId === siteId && j.state === "done" && j.result !== void 0
195
+ );
196
+ }
197
+ /**
198
+ * Claim work — one operation, because it has to be.
199
+ *
200
+ * Moved here wholesale from `DaemonPlane`, where it was a scan followed by
201
+ * per-job mutation. Nothing about the *decision* changed; what changed is
202
+ * that a store can now implement it, because the filter and the write are
203
+ * one call rather than a loop the caller drives.
204
+ *
205
+ * The order of the guards is worth preserving as-is when this becomes a Lua
206
+ * script: cheapest first, and `owners` last because it is the only one that
207
+ * needed the projection.
208
+ */
209
+ async claim(input) {
210
+ const now = await this.now();
211
+ await this.sweep();
212
+ const granted = [];
213
+ const unsealed = /* @__PURE__ */ new Map();
214
+ for (const job of this.#jobs.values()) {
215
+ if (job.state !== "awaiting-payload") continue;
216
+ const pair = routeKey(job.siteId, job.stub.owner);
217
+ unsealed.set(pair, (unsealed.get(pair) ?? 0) + 1);
218
+ }
219
+ for (const job of this.#jobs.values()) {
220
+ if (granted.length >= input.max) break;
221
+ if (job.state !== "queued") continue;
222
+ if (!input.routes.has(routeKey(job.siteId, job.stub.owner))) continue;
223
+ if (job.reofferTo !== void 0 && job.reofferTo !== input.runnerId) {
224
+ continue;
225
+ }
226
+ const pair = routeKey(job.siteId, job.stub.owner);
227
+ if ((unsealed.get(pair) ?? 0) >= UNSEALED_PER_PAIR) continue;
228
+ if (!input.kinds.has(job.stub.kind)) continue;
229
+ if (job.refusedBy.includes(input.runnerId)) continue;
230
+ if ((job.retryAfter?.[input.runnerId] ?? 0) > now) continue;
231
+ if (job.cancelled) continue;
232
+ if (job.stub.audience === "private" && job.stub.owner !== input.owner) {
233
+ continue;
234
+ }
235
+ const leaseId = randomUUID();
236
+ job.state = "awaiting-payload";
237
+ job.claimedBy = {
238
+ runnerId: input.runnerId,
239
+ owner: input.owner,
240
+ device: input.device,
241
+ leaseId,
242
+ leaseExpiresAt: now + input.leaseMs
243
+ };
244
+ job.awaitingUntil = now + AWAITING_PAYLOAD_MS;
245
+ unsealed.set(pair, (unsealed.get(pair) ?? 0) + 1);
246
+ this.#byLease.set(leaseId, job);
247
+ granted.push({
248
+ ...job.stub,
249
+ lease: {
250
+ id: leaseId,
251
+ runnerId: input.runnerId,
252
+ expiresAt: job.claimedBy.leaseExpiresAt
253
+ }
254
+ });
255
+ }
256
+ return granted;
257
+ }
258
+ /**
259
+ * Hand over the sealed payload to the device that holds the lease.
260
+ *
261
+ * The read and the state transition are one operation for the same reason
262
+ * `claim` is: `running` must be set by whoever was told the envelope, or two
263
+ * replicas can both hand out the same work and both believe they were first.
264
+ */
265
+ takePayload(input) {
266
+ const job = this.#grantFor(input.jobId, input.runnerId, input.leaseId);
267
+ if (!job) return Promise.resolve({ refused: "not-found" });
268
+ if (job.claimedBy?.runnerId !== input.runnerId) {
269
+ return Promise.resolve({ refused: "not-holder" });
270
+ }
271
+ if (job.claimedBy.leaseId !== input.leaseId) {
272
+ return Promise.resolve({ refused: "stale-lease" });
273
+ }
274
+ if (!job.payload) return Promise.resolve({ refused: "not-ready" });
275
+ if (job.state !== "ready" && job.state !== "running") {
276
+ return Promise.resolve({ refused: "terminal" });
277
+ }
278
+ job.state = "running";
279
+ return Promise.resolve({ envelope: job.payload });
280
+ }
281
+ /**
282
+ * Record a finished job.
283
+ *
284
+ * `RESULT_IDEMPOTENT` lives here rather than in the caller: a replayed
285
+ * result must be a no-op decided by the same operation that would have
286
+ * written it, or two replicas can both decide they were the first.
287
+ */
288
+ complete(input) {
289
+ const job = this.#grantFor(input.jobId, input.runnerId, input.leaseId);
290
+ if (!job) return Promise.resolve({ refused: "not-found" });
291
+ if (job.claimedBy?.runnerId !== input.runnerId) {
292
+ return Promise.resolve({ refused: "not-holder" });
293
+ }
294
+ if (job.state === "done") {
295
+ const sameGrant = job.claimedBy.leaseId === input.leaseId;
296
+ return Promise.resolve(
297
+ sameGrant ? { accepted: false, duplicate: true, state: job.state } : { refused: "stale-lease" }
298
+ );
299
+ }
300
+ if (job.claimedBy.leaseId !== input.leaseId) {
301
+ return Promise.resolve({ refused: "stale-lease" });
302
+ }
303
+ job.result = input.envelope;
304
+ job.disposition = input.disposition;
305
+ job.state = "done";
306
+ return Promise.resolve({ accepted: true, state: job.state });
307
+ }
308
+ /** Give back leases this runner holds, naming each grant it means. */
309
+ releaseLeases(input) {
310
+ const released = [];
311
+ for (const { jobId, leaseId } of input.leases) {
312
+ const job = this.#grantFor(jobId, input.runnerId, leaseId);
313
+ if (!job || job.claimedBy?.runnerId !== input.runnerId) continue;
314
+ if (job.claimedBy.leaseId !== leaseId) continue;
315
+ if (job.state === "done") continue;
316
+ if (input.reason === "refused" && !job.refusedBy.includes(input.runnerId)) {
317
+ job.refusedBy.push(input.runnerId);
318
+ }
319
+ if (input.retryAfter !== void 0) {
320
+ job.retryAfter = {
321
+ ...job.retryAfter,
322
+ [input.runnerId]: input.retryAfter
323
+ };
324
+ }
325
+ this.#requeue(job);
326
+ released.push(jobId);
327
+ }
328
+ return Promise.resolve(released);
329
+ }
330
+ /**
331
+ * Take a site's sealed payload for a claimed job.
332
+ *
333
+ * Refuses anything not `awaiting-payload`, which is what makes the timeout
334
+ * mean something: a late seal must not land on a claim that has moved.
335
+ */
336
+ seal(input) {
337
+ const job = this.#jobs.get(keyOf(input.siteId, input.jobId));
338
+ if (job?.siteId !== input.siteId) {
339
+ return Promise.resolve({ refused: "not-found" });
340
+ }
341
+ if (job.state !== "awaiting-payload") {
342
+ return Promise.resolve({ refused: "too-late", was: job.state });
343
+ }
344
+ job.payload = input.envelope;
345
+ job.state = "ready";
346
+ delete job.awaitingUntil;
347
+ delete job.sealAttempts;
348
+ return Promise.resolve({ state: job.state });
349
+ }
350
+ /** {@link RoutingStore.cancel} — the site withdraws a job. */
351
+ cancel(input) {
352
+ const job = this.#jobs.get(keyOf(input.siteId, input.jobId));
353
+ if (job?.siteId !== input.siteId) return Promise.resolve(false);
354
+ job.cancelled = true;
355
+ return Promise.resolve(true);
356
+ }
357
+ /** {@link RoutingStore.cancelRequests} — cancelled jobs this runner holds. */
358
+ cancelRequests(runnerId) {
359
+ return Promise.resolve(
360
+ [...this.#jobs.values()].filter(
361
+ (job) => job.cancelled === true && job.claimedBy?.runnerId === runnerId
362
+ ).map((job) => ({
363
+ jobId: job.id,
364
+ leaseId: job.claimedBy?.leaseId ?? ""
365
+ })).filter((grant) => grant.leaseId !== "")
366
+ );
367
+ }
368
+ /** {@link RoutingStore.renewLeases} — extend what is still held, name what is not. */
369
+ async renewLeases(input) {
370
+ const now = await this.now();
371
+ const renewed = [];
372
+ const lost = [];
373
+ for (const { jobId, leaseId } of input.leases) {
374
+ const job = this.#grantFor(jobId, input.runnerId, leaseId);
375
+ const held = job?.claimedBy;
376
+ if (!job || held?.leaseId !== leaseId || held.runnerId !== input.runnerId) {
377
+ lost.push({ jobId, leaseId });
378
+ continue;
379
+ }
380
+ const expiresAt = now + input.leaseMs;
381
+ job.claimedBy = { ...held, leaseExpiresAt: expiresAt };
382
+ renewed.push({ jobId, expiresAt });
383
+ }
384
+ return { renewed, lost };
385
+ }
386
+ async seen(presence) {
387
+ const lastSeenAt = await this.now();
388
+ const existing = this.#presence.get(presence.runnerId);
389
+ if (existing) {
390
+ existing.lastSeenAt = lastSeenAt;
391
+ existing.capabilities = presence.capabilities;
392
+ existing.withheld = presence.withheld;
393
+ return existing;
394
+ }
395
+ const fresh = { ...presence, lastSeenAt };
396
+ this.#presence.set(presence.runnerId, fresh);
397
+ return fresh;
398
+ }
399
+ presence(runnerId) {
400
+ return Promise.resolve(this.#presence.get(runnerId));
401
+ }
402
+ /**
403
+ * Lose a record, the way a real store does.
404
+ *
405
+ * A shared store drops presence for reasons this one never will — a TTL, a
406
+ * reschedule, a restart — and the interesting behaviour is what the relay
407
+ * does next. `ValkeyRoutingStore` has carried the same helper since
408
+ * finding 52; this is its memory twin, so the case can be written once
409
+ * against the implementation that is easy to reason about.
410
+ */
411
+ dropPresenceForTests(runnerId) {
412
+ this.#presence.delete(runnerId);
413
+ return Promise.resolve();
414
+ }
415
+ everyone() {
416
+ return Promise.resolve([...this.#presence.values()]);
417
+ }
418
+ /**
419
+ * Return a job to the queue, forgetting the claim.
420
+ *
421
+ * The stub survives; nothing is lost. That is `LEASE_RECLAIMABLE` and it is
422
+ * why the awaiting-payload timeout is cheap to fire: the worst case is that
423
+ * a device did nothing for ten seconds and another one gets a turn.
424
+ */
425
+ #requeue(job) {
426
+ job.state = "queued";
427
+ if (job.claimedBy) this.#byLease.delete(job.claimedBy.leaseId);
428
+ delete job.claimedBy;
429
+ delete job.awaitingUntil;
430
+ delete job.payload;
431
+ }
432
+ /**
433
+ * Fire whatever the clock says is due, and report it.
434
+ *
435
+ * Returns the jobs it requeued so a caller can log or surface them — a
436
+ * timeout that fires invisibly is indistinguishable from a job that was
437
+ * never claimed, and those want very different debugging.
438
+ */
439
+ async sweep() {
440
+ const now = await this.now();
441
+ const requeued = [];
442
+ const expired = [];
443
+ for (const job of this.#jobs.values()) {
444
+ if (job.stub.deadlineAt <= now) {
445
+ this.#forget(job);
446
+ expired.push(job);
447
+ continue;
448
+ }
449
+ if (job.state === "awaiting-payload" && (job.awaitingUntil ?? 0) <= now) {
450
+ this.#noteChurn(job.siteId, job.stub.owner, now);
451
+ const abandonedBy = job.claimedBy?.runnerId;
452
+ if (abandonedBy !== void 0) job.reofferTo ??= abandonedBy;
453
+ const attempts = (job.sealAttempts ?? 0) + 1;
454
+ if (attempts >= SEAL_ATTEMPTS_BEFORE_EVICTION) {
455
+ this.#forget(job);
456
+ expired.push(job);
457
+ continue;
458
+ }
459
+ job.sealAttempts = attempts;
460
+ this.#requeue(job);
461
+ requeued.push(job);
462
+ }
463
+ const lease = job.claimedBy;
464
+ if (lease && (job.state === "ready" || job.state === "running") && lease.leaseExpiresAt <= now) {
465
+ this.#requeue(job);
466
+ requeued.push(job);
467
+ }
468
+ }
469
+ return [...requeued, ...expired];
470
+ }
471
+ };
472
+
473
+ export {
474
+ AWAITING_PAYLOAD_MS,
475
+ UNSEALED_PER_PAIR,
476
+ SEAL_ATTEMPTS_BEFORE_EVICTION,
477
+ RETRY_AFTER_MS,
478
+ routeKey,
479
+ CHURN_EXPIRIES,
480
+ CHURN_WINDOW_MS,
481
+ CHURN_QUIET_MS,
482
+ RelayState
483
+ };
484
+ //# sourceMappingURL=chunk-4ISUXCIO.js.map