@byollm/relay 0.1.0-alpha.21 → 0.1.0-alpha.23
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 +15 -3
- package/dist/index.d.ts +45 -512
- package/dist/index.js +83 -3
- package/dist/index.js.map +1 -1
- package/dist/store-C_NtPAvT.d.ts +512 -0
- package/dist/store-contract.d.ts +67 -0
- package/dist/store-contract.js +305 -0
- package/dist/store-contract.js.map +1 -0
- package/package.json +18 -5
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
> [!WARNING]
|
|
2
|
-
> **Alpha (`0.1.0-alpha.
|
|
2
|
+
> **Alpha (`0.1.0-alpha.23`) — 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
|
|
@@ -66,6 +66,18 @@
|
|
|
66
66
|
> deleting the idempotency branch failed no test. Run the store contract
|
|
67
67
|
> tests; the compiler cannot see this.
|
|
68
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.23` 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
|
+
|
|
69
81
|
# `@byollm/relay`
|
|
70
82
|
|
|
71
83
|
The **reference relay**: it routes byollm jobs between a site and someone's
|
|
@@ -141,7 +153,7 @@ daemons pin at pairing, verified against the `sites` half of the projection.
|
|
|
141
153
|
Nothing here trusts a `siteId` in a body or a query string.
|
|
142
154
|
|
|
143
155
|
That is newer than the rest of this package. The site plane took the caller's
|
|
144
|
-
word for who it was until `0.1.0-alpha.
|
|
156
|
+
word for who it was until `0.1.0-alpha.23`, which on a relay reachable from the
|
|
145
157
|
internet is an open enqueue endpoint into consenting users' machines and an
|
|
146
158
|
open read of who is online. It was blind the whole time — nothing could open a
|
|
147
159
|
payload — and blind is not the same as safe.
|
|
@@ -149,7 +161,7 @@ payload — and blind is not the same as safe.
|
|
|
149
161
|
If you are running this: the site plane is authenticated but this is still a
|
|
150
162
|
single-tenant relay with in-memory state. One site, one replica.
|
|
151
163
|
|
|
152
|
-
## Breaking in `0.1.0-alpha.
|
|
164
|
+
## Breaking in `0.1.0-alpha.23`: `RelayState` is async
|
|
153
165
|
|
|
154
166
|
Every method on `RelayState` now returns a `Promise`, and `Relay.sweep()` and
|
|
155
167
|
`debugPage()` with it. `RelayState.requeue` is private — it was only ever a
|
package/dist/index.d.ts
CHANGED
|
@@ -1,514 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { R as RoutingStore } from './store-C_NtPAvT.js';
|
|
2
|
+
export { A as AWAITING_PAYLOAD_MS, C as ClaimInput, H as HolderRefusal, P as Presence, a as RelayState, b as ReleaseReason, c as RoutedJob, d as RoutedState } from './store-C_NtPAvT.js';
|
|
2
3
|
import { z } from 'zod';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The relay's routing state — byollm_009 §7, reachable at last.
|
|
6
|
-
*
|
|
7
|
-
* §7 described a state machine the direct plane could not produce. There, the
|
|
8
|
-
* site and the upstream are the same party: it seals when it likes, and a job
|
|
9
|
-
* is never claimed-but-unsealed. Here they are different parties, and the gap
|
|
10
|
-
* between them is a state:
|
|
11
|
-
*
|
|
12
|
-
* ```
|
|
13
|
-
* queued ──claim──▶ awaiting-payload ──sealed──▶ ready ──fetch──▶ running
|
|
14
|
-
* ▲ │ │
|
|
15
|
-
* └────────────────────┘ ▼
|
|
16
|
-
* site never seals, or seals too late ok | error | canceled
|
|
17
|
-
* ```
|
|
18
|
-
*
|
|
19
|
-
* The relay cannot seal, so it cannot shortcut this. A payload is encrypted
|
|
20
|
-
* to *the device that claimed it*, and nobody knows which device that is until
|
|
21
|
-
* the claim happens — which is precisely why claim-then-fetch makes a blind
|
|
22
|
-
* relay possible at all. The window is the price.
|
|
23
|
-
*
|
|
24
|
-
* ## What the relay holds, and what it cannot
|
|
25
|
-
*
|
|
26
|
-
* Stubs (metadata the site chose to publish), sealed envelopes it cannot open,
|
|
27
|
-
* and public keys. There is no field on any type in this file that could hold
|
|
28
|
-
* a private key or a plaintext, which is `RELAY_BLIND` expressed as a data
|
|
29
|
-
* model rather than as a policy.
|
|
30
|
-
*/
|
|
31
|
-
/** Where a routed job is. */
|
|
32
|
-
type RoutedState = "queued" | "awaiting-payload" | "ready" | "running" | "done";
|
|
33
|
-
/**
|
|
34
|
-
* How long a site has to seal after one of its jobs is claimed.
|
|
35
|
-
*
|
|
36
|
-
* **Distinct from the lease, and distinct from the job's TTL** — byollm_009
|
|
37
|
-
* §7.1. Three clocks, three different questions:
|
|
38
|
-
*
|
|
39
|
-
* - the **TTL** asks how long the work is worth doing at all;
|
|
40
|
-
* - the **lease** asks how long this device gets to run it;
|
|
41
|
-
* - this asks how long we wait for a site that has gone away.
|
|
42
|
-
*
|
|
43
|
-
* Collapsing any pair of them looks harmless until a site restarts during a
|
|
44
|
-
* deploy: with only a lease, the device sits politely holding a job whose
|
|
45
|
-
* payload will never arrive, and the lease's whole minute is spent waiting on
|
|
46
|
-
* a party that is not coming back. Short, because a site that is up answers in
|
|
47
|
-
* milliseconds and a site that is down will not answer sooner for waiting.
|
|
48
|
-
*/
|
|
49
|
-
declare const AWAITING_PAYLOAD_MS = 10000;
|
|
50
|
-
/** A job the relay is routing. Metadata and ciphertext, nothing else. */
|
|
51
|
-
/** Why a daemon gave a job back. Only `refused` means "not me, ever". */
|
|
52
|
-
type ReleaseReason = "shutdown" | "pause" | "revoked" | "backend-down" | "refused";
|
|
53
|
-
interface RoutedJob {
|
|
54
|
-
readonly id: string;
|
|
55
|
-
/** Which site enqueued it — the party that will be asked to seal. */
|
|
56
|
-
readonly siteId: string;
|
|
57
|
-
/**
|
|
58
|
-
* Everything the relay knows about the work, which is everything the site
|
|
59
|
-
* chose to publish and not one field more (byollm_009 §6).
|
|
60
|
-
*/
|
|
61
|
-
readonly stub: JobStub;
|
|
62
|
-
state: RoutedState;
|
|
63
|
-
/** Set from the claim; the site seals to these keys. */
|
|
64
|
-
claimedBy?: {
|
|
65
|
-
readonly runnerId: string;
|
|
66
|
-
readonly owner: string;
|
|
67
|
-
readonly device: PublicIdentity;
|
|
68
|
-
readonly leaseId: string;
|
|
69
|
-
readonly leaseExpiresAt: number;
|
|
70
|
-
};
|
|
71
|
-
/** When {@link AWAITING_PAYLOAD_MS} runs out for this claim. */
|
|
72
|
-
awaitingUntil?: number;
|
|
73
|
-
/**
|
|
74
|
-
* Runners that released this job with reason `refused` — cloud_008 §2.1.
|
|
75
|
-
*
|
|
76
|
-
* `REFUSAL_NOT_REOFFERED`, which the relay did not implement: it dropped
|
|
77
|
-
* `ReleaseRequest.reason` on the floor. The field's own docstring says why
|
|
78
|
-
* that is not cosmetic — an upstream cannot evaluate a daemon's *local*
|
|
79
|
-
* `named` allowlist, so it may legitimately offer work the daemon then
|
|
80
|
-
* declines, and without a record the two spin between claim and release
|
|
81
|
-
* forever. The direct plane has always kept this list.
|
|
82
|
-
*/
|
|
83
|
-
refusedBy: string[];
|
|
84
|
-
/**
|
|
85
|
-
* The site withdrew this job — cloud_008 §2.2.
|
|
86
|
-
*
|
|
87
|
-
* A flag rather than a state, because a cancelled job that a device is
|
|
88
|
-
* *running* is not finished: the daemon has to be told, abort its backend
|
|
89
|
-
* call and report `canceled`, and the ordinary `complete` path then closes
|
|
90
|
-
* it. Making it a state would strand the in-flight case between two
|
|
91
|
-
* machines' ideas of what happened.
|
|
92
|
-
*/
|
|
93
|
-
cancelled?: boolean;
|
|
94
|
-
/** Sealed to the claiming device by the site. Opaque here. */
|
|
95
|
-
payload?: SealedEnvelope;
|
|
96
|
-
/** Sealed to the site by the device. Opaque here. */
|
|
97
|
-
result?: SealedEnvelope;
|
|
98
|
-
/**
|
|
99
|
-
* The result's clear-text discriminator — byollm_009 §6.1.
|
|
100
|
-
*
|
|
101
|
-
* The one outcome fact the relay is given, and the reason it is given:
|
|
102
|
-
* without it the relay cannot stop dispatching a finished job. A routing
|
|
103
|
-
* hint and never a fact — the *site* verifies it against the sealed
|
|
104
|
-
* outcome, because only the site can open the envelope. The relay acts on
|
|
105
|
-
* it and is entitled to be wrong; a lying daemon costs it a dispatch
|
|
106
|
-
* decision, not a security property.
|
|
107
|
-
*/
|
|
108
|
-
disposition?: "ok" | "error" | "canceled";
|
|
109
|
-
}
|
|
110
|
-
/** A device the relay has seen recently. */
|
|
111
|
-
interface Presence {
|
|
112
|
-
readonly runnerId: string;
|
|
113
|
-
readonly owner: string;
|
|
114
|
-
readonly device: PublicIdentity;
|
|
115
|
-
lastSeenAt: number;
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* What a routing store must do, expressed as operations — cloud_006 §3.2.
|
|
119
|
-
*
|
|
120
|
-
* Every method below is a **decision plus its write**, never a read the caller
|
|
121
|
-
* follows with a mutation. That is the whole point, and it is the difference
|
|
122
|
-
* between an interface a shared store can implement and one it cannot.
|
|
123
|
-
*
|
|
124
|
-
* `claim` is the specimen. It used to live in `DaemonPlane` as
|
|
125
|
-
* `jobs()` → filter → mutate, which is atomic for exactly one reason: Node is
|
|
126
|
-
* single-threaded and these Maps are local, so nothing runs between the read
|
|
127
|
-
* and the write. Neither survives a store on a network, and
|
|
128
|
-
* `packages/relay/test/two-replicas.test.ts` holds the resulting race as a
|
|
129
|
-
* failing assertion.
|
|
130
|
-
*
|
|
131
|
-
* So the rule for anything added here: **if a caller has to read, decide, and
|
|
132
|
-
* write back, the operation is in the wrong place.** Move the decision in.
|
|
133
|
-
*
|
|
134
|
-
* ## Why the projection does not come with it
|
|
135
|
-
*
|
|
136
|
-
* `claim` takes `owners: string[]` rather than a projection or a predicate.
|
|
137
|
-
* A closure cannot travel to Valkey, and the projection replicates for free
|
|
138
|
-
* from the control plane — so the caller collapses it with
|
|
139
|
-
* `Projection.ownersRunnableBy` and hands over data the store can match on.
|
|
140
|
-
* That keeps the store ignorant of consent, which is also what keeps it
|
|
141
|
-
* replaceable.
|
|
142
|
-
*/
|
|
143
|
-
interface ClaimInput {
|
|
144
|
-
readonly runnerId: string;
|
|
145
|
-
readonly owner: string;
|
|
146
|
-
readonly device: PublicIdentity;
|
|
147
|
-
/** The site this relay routes for. Multi-tenancy widens this to a set. */
|
|
148
|
-
readonly siteId: string;
|
|
149
|
-
/** Job kinds this device can actually run. */
|
|
150
|
-
readonly kinds: ReadonlySet<string>;
|
|
151
|
-
/** Whose work it may run — the projection, already collapsed to data. */
|
|
152
|
-
readonly owners: ReadonlySet<string>;
|
|
153
|
-
readonly max: number;
|
|
154
|
-
readonly leaseMs: number;
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* Where the store's sense of time comes from — cloud_006 §3.4.
|
|
158
|
-
*
|
|
159
|
-
* **The store owns its clock; callers do not pass one.** Every deadline the
|
|
160
|
-
* relay decides — a lease's expiry, the `awaiting-payload` window, what a
|
|
161
|
-
* sweep considers due — is now stamped by one source, and it is the same
|
|
162
|
-
* source that will later stamp them for every replica.
|
|
163
|
-
*
|
|
164
|
-
* It used to be a parameter. `claim` took `now`, `sweep` took `now`, and each
|
|
165
|
-
* plane called its own `now()` before calling in — which is fine in one
|
|
166
|
-
* process and is the recurring bug the moment there are two. A lease granted
|
|
167
|
-
* by a pod whose clock runs fast is short; the same lease swept by a pod whose
|
|
168
|
-
* clock runs slow outlives it. Nobody is wrong and the lease has no length.
|
|
169
|
-
*
|
|
170
|
-
* A Valkey-backed store returns `TIME` here, so the deadline and the sweep
|
|
171
|
-
* that enforces it are read from the same server. The injected clock stays for
|
|
172
|
-
* tests, which is what lets them move time instead of sleeping.
|
|
173
|
-
*
|
|
174
|
-
* **What deliberately does not use this**: request-signature freshness. That
|
|
175
|
-
* is checked against the *local* clock on purpose — it is a question about the
|
|
176
|
-
* caller's clock versus this process's, `MAX_CLOCK_SKEW_MS` already tolerates
|
|
177
|
-
* two minutes of disagreement, and a network round trip to timestamp every
|
|
178
|
-
* inbound request would be a cost with no property behind it.
|
|
179
|
-
*/
|
|
180
|
-
interface RelayStateOptions {
|
|
181
|
-
readonly now?: () => number | Promise<number>;
|
|
182
|
-
}
|
|
183
|
-
/** Why a lease-scoped operation was refused, in the caller's vocabulary. */
|
|
184
|
-
type HolderRefusal = "not-found" | "not-holder" | "stale-lease" | "not-ready";
|
|
185
|
-
/**
|
|
186
|
-
* In-memory routing state.
|
|
187
|
-
*
|
|
188
|
-
* Deliberately not durable. The skeleton proves the protocol, and the
|
|
189
|
-
* production hub replaces this with the closed multi-tenant router behind the
|
|
190
|
-
* same shape (cloud_004 §9). Anything a restart loses here is a job that
|
|
191
|
-
* returns to its site's queue — which is the behaviour a lapsed lease already
|
|
192
|
-
* has to produce, so nothing new needs to be true for this to be safe.
|
|
193
|
-
*/
|
|
194
|
-
declare class RelayState implements RoutingStore {
|
|
195
|
-
#private;
|
|
196
|
-
constructor(options?: RelayStateOptions);
|
|
197
|
-
/** The one clock every deadline in this store is stamped from. */
|
|
198
|
-
now(): Promise<number>;
|
|
199
|
-
/**
|
|
200
|
-
* Take a stub for routing. The payload is not here and will not be.
|
|
201
|
-
*
|
|
202
|
-
* **Idempotent by job id, and that is a security property rather than a
|
|
203
|
-
* convenience.** Site-plane calls are authenticated by signature, and
|
|
204
|
-
* byollm_009 §4.2's argument for signing the request instead of a
|
|
205
|
-
* server-issued nonce rests entirely on every write being idempotent per the
|
|
206
|
-
* instance it names. This one was not: re-enqueueing a known id built a
|
|
207
|
-
* fresh `queued` job over the top of the old one, discarding a live claim,
|
|
208
|
-
* its lease and any payload the site had already sealed to a device. A
|
|
209
|
-
* replayed enqueue inside the two-minute freshness window was therefore a
|
|
210
|
-
* way to yank a job back from the machine running it — the `release` bug of
|
|
211
|
-
* §4.2, rediscovered on the other plane.
|
|
212
|
-
*
|
|
213
|
-
* So a known id returns what is already routing, unchanged. A site that
|
|
214
|
-
* restarts and republishes its queue is the normal case, and it must not
|
|
215
|
-
* disturb work in flight.
|
|
216
|
-
*/
|
|
217
|
-
enqueue(input: {
|
|
218
|
-
id: string;
|
|
219
|
-
siteId: string;
|
|
220
|
-
stub: JobStub;
|
|
221
|
-
}): Promise<RoutedJob>;
|
|
222
|
-
job(jobId: string): Promise<RoutedJob | undefined>;
|
|
223
|
-
jobs(): Promise<RoutedJob[]>;
|
|
224
|
-
/** Jobs a site must seal for, right now. */
|
|
225
|
-
awaiting(siteId: string): Promise<RoutedJob[]>;
|
|
226
|
-
/** Sealed results waiting to go home. */
|
|
227
|
-
finished(siteId: string): Promise<RoutedJob[]>;
|
|
228
|
-
/**
|
|
229
|
-
* Claim work — one operation, because it has to be.
|
|
230
|
-
*
|
|
231
|
-
* Moved here wholesale from `DaemonPlane`, where it was a scan followed by
|
|
232
|
-
* per-job mutation. Nothing about the *decision* changed; what changed is
|
|
233
|
-
* that a store can now implement it, because the filter and the write are
|
|
234
|
-
* one call rather than a loop the caller drives.
|
|
235
|
-
*
|
|
236
|
-
* The order of the guards is worth preserving as-is when this becomes a Lua
|
|
237
|
-
* script: cheapest first, and `owners` last because it is the only one that
|
|
238
|
-
* needed the projection.
|
|
239
|
-
*/
|
|
240
|
-
claim(input: ClaimInput): Promise<ClaimedStub[]>;
|
|
241
|
-
/**
|
|
242
|
-
* Hand over the sealed payload to the device that holds the lease.
|
|
243
|
-
*
|
|
244
|
-
* The read and the state transition are one operation for the same reason
|
|
245
|
-
* `claim` is: `running` must be set by whoever was told the envelope, or two
|
|
246
|
-
* replicas can both hand out the same work and both believe they were first.
|
|
247
|
-
*/
|
|
248
|
-
takePayload(input: {
|
|
249
|
-
jobId: string;
|
|
250
|
-
runnerId: string;
|
|
251
|
-
leaseId: string;
|
|
252
|
-
}): Promise<{
|
|
253
|
-
envelope: SealedEnvelope;
|
|
254
|
-
} | {
|
|
255
|
-
refused: HolderRefusal;
|
|
256
|
-
}>;
|
|
257
|
-
/**
|
|
258
|
-
* Record a finished job.
|
|
259
|
-
*
|
|
260
|
-
* `RESULT_IDEMPOTENT` lives here rather than in the caller: a replayed
|
|
261
|
-
* result must be a no-op decided by the same operation that would have
|
|
262
|
-
* written it, or two replicas can both decide they were the first.
|
|
263
|
-
*/
|
|
264
|
-
complete(input: {
|
|
265
|
-
jobId: string;
|
|
266
|
-
runnerId: string;
|
|
267
|
-
leaseId: string;
|
|
268
|
-
envelope: SealedEnvelope;
|
|
269
|
-
disposition: "ok" | "error" | "canceled";
|
|
270
|
-
}): Promise<{
|
|
271
|
-
accepted: boolean;
|
|
272
|
-
duplicate?: boolean;
|
|
273
|
-
state: RoutedState;
|
|
274
|
-
} | {
|
|
275
|
-
refused: HolderRefusal;
|
|
276
|
-
}>;
|
|
277
|
-
/** Give back leases this runner holds, naming each grant it means. */
|
|
278
|
-
releaseLeases(input: {
|
|
279
|
-
runnerId: string;
|
|
280
|
-
leases: readonly {
|
|
281
|
-
jobId: string;
|
|
282
|
-
leaseId: string;
|
|
283
|
-
}[];
|
|
284
|
-
reason?: ReleaseReason;
|
|
285
|
-
}): Promise<string[]>;
|
|
286
|
-
/**
|
|
287
|
-
* Take a site's sealed payload for a claimed job.
|
|
288
|
-
*
|
|
289
|
-
* Refuses anything not `awaiting-payload`, which is what makes the timeout
|
|
290
|
-
* mean something: a late seal must not land on a claim that has moved.
|
|
291
|
-
*/
|
|
292
|
-
seal(input: {
|
|
293
|
-
jobId: string;
|
|
294
|
-
siteId: string;
|
|
295
|
-
envelope: SealedEnvelope;
|
|
296
|
-
}): Promise<{
|
|
297
|
-
state: RoutedState;
|
|
298
|
-
} | {
|
|
299
|
-
refused: "not-found" | "too-late";
|
|
300
|
-
was?: RoutedState;
|
|
301
|
-
}>;
|
|
302
|
-
/** {@link RoutingStore.cancel} — the site withdraws a job. */
|
|
303
|
-
cancel(input: {
|
|
304
|
-
jobId: string;
|
|
305
|
-
siteId: string;
|
|
306
|
-
}): Promise<boolean>;
|
|
307
|
-
/** {@link RoutingStore.cancelRequests} — cancelled jobs this runner holds. */
|
|
308
|
-
cancelRequests(runnerId: string): Promise<string[]>;
|
|
309
|
-
/** {@link RoutingStore.renewLeases} — extend what is still held, name what is not. */
|
|
310
|
-
renewLeases(input: {
|
|
311
|
-
runnerId: string;
|
|
312
|
-
leases: readonly {
|
|
313
|
-
jobId: string;
|
|
314
|
-
leaseId: string;
|
|
315
|
-
}[];
|
|
316
|
-
leaseMs: number;
|
|
317
|
-
}): Promise<{
|
|
318
|
-
renewed: {
|
|
319
|
-
jobId: string;
|
|
320
|
-
expiresAt: number;
|
|
321
|
-
}[];
|
|
322
|
-
lost: string[];
|
|
323
|
-
}>;
|
|
324
|
-
seen(presence: Omit<Presence, "lastSeenAt">): Promise<Presence>;
|
|
325
|
-
presence(runnerId: string): Promise<Presence | undefined>;
|
|
326
|
-
everyone(): Promise<Presence[]>;
|
|
327
|
-
/**
|
|
328
|
-
* Fire whatever the clock says is due, and report it.
|
|
329
|
-
*
|
|
330
|
-
* Returns the jobs it requeued so a caller can log or surface them — a
|
|
331
|
-
* timeout that fires invisibly is indistinguishable from a job that was
|
|
332
|
-
* never claimed, and those want very different debugging.
|
|
333
|
-
*/
|
|
334
|
-
sweep(): Promise<RoutedJob[]>;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
/**
|
|
338
|
-
* What the relay needs from a place to keep routing state — cloud_006 §3.2.
|
|
339
|
-
*
|
|
340
|
-
* `RelayState` implements this in memory and is the reference; a Valkey-backed
|
|
341
|
-
* store implements the same thing across replicas. The interface exists so the
|
|
342
|
-
* relay depends on the *contract* rather than on either, and so the properties
|
|
343
|
-
* below are stated once rather than rediscovered per implementation.
|
|
344
|
-
*
|
|
345
|
-
* ## Every method is a decision plus its write
|
|
346
|
-
*
|
|
347
|
-
* Not one is a read the caller follows with a mutation. That is the whole
|
|
348
|
-
* design, and it is not a style preference: `claim` used to be
|
|
349
|
-
* `jobs()` → filter → mutate in the plane, which is atomic for exactly one
|
|
350
|
-
* reason — Node is single-threaded and the Maps are local. Neither survives a
|
|
351
|
-
* store on a network, and `packages/relay/test/two-replicas.test.ts` holds the
|
|
352
|
-
* resulting race as a failing assertion.
|
|
353
|
-
*
|
|
354
|
-
* **The rule for anything added here:** if a caller has to read, decide, and
|
|
355
|
-
* write back, the operation is in the wrong place. Move the decision in.
|
|
356
|
-
*
|
|
357
|
-
* ## What an implementation must guarantee
|
|
358
|
-
*
|
|
359
|
-
* 1. **`claim` is atomic.** Two callers claiming concurrently must not both
|
|
360
|
-
* receive the same job. `CLAIM_ATOMIC` is a protocol MUST.
|
|
361
|
-
* 2. **`enqueue` is idempotent by job id.** A known id returns what is already
|
|
362
|
-
* routing rather than rebuilding it — byollm_009 §4.2's replay argument
|
|
363
|
-
* rests on every write being idempotent per the instance it names.
|
|
364
|
-
* 3. **`complete` is idempotent.** A replayed result changes nothing, and the
|
|
365
|
-
* decision is made by the same operation that would have written it.
|
|
366
|
-
* 4. **Lease-scoped operations name the grant.** `takePayload`, `complete` and
|
|
367
|
-
* `releaseLeases` check the lease *id*, not just the runner — a runner
|
|
368
|
-
* survives a claim-release-reclaim cycle and a grant does not.
|
|
369
|
-
* 5. **`now()` is the only clock.** Every deadline the store stamps and every
|
|
370
|
-
* deadline it enforces come from here (§3.4). An implementation backed by a
|
|
371
|
-
* server returns that server's time, so two replicas cannot disagree about
|
|
372
|
-
* how long a lease is.
|
|
373
|
-
*
|
|
374
|
-
* ## What it must not do
|
|
375
|
-
*
|
|
376
|
-
* Hold a key, or learn about consent. `claim` takes `owners` as data because a
|
|
377
|
-
* predicate cannot travel to Valkey — and the effect is that the store cannot
|
|
378
|
-
* express an opinion about who may route, only about what it was told. That is
|
|
379
|
-
* what keeps `RELAY_BLIND` a property of the shape rather than of the code.
|
|
380
|
-
*/
|
|
381
|
-
interface RoutingStore {
|
|
382
|
-
/** The one clock every deadline in this store is stamped from. */
|
|
383
|
-
now(): Promise<number>;
|
|
384
|
-
/** Take a stub for routing. Idempotent by id. */
|
|
385
|
-
enqueue(input: {
|
|
386
|
-
id: string;
|
|
387
|
-
siteId: string;
|
|
388
|
-
stub: JobStub;
|
|
389
|
-
}): Promise<RoutedJob>;
|
|
390
|
-
job(jobId: string): Promise<RoutedJob | undefined>;
|
|
391
|
-
jobs(): Promise<RoutedJob[]>;
|
|
392
|
-
/** Jobs a site must seal for, right now. */
|
|
393
|
-
awaiting(siteId: string): Promise<RoutedJob[]>;
|
|
394
|
-
/** Sealed results waiting to go home. */
|
|
395
|
-
finished(siteId: string): Promise<RoutedJob[]>;
|
|
396
|
-
/** Grant work to a device — one operation, because it has to be. */
|
|
397
|
-
claim(input: ClaimInput): Promise<ClaimedStub[]>;
|
|
398
|
-
/** Hand the sealed payload to the device that holds the lease. */
|
|
399
|
-
takePayload(input: {
|
|
400
|
-
jobId: string;
|
|
401
|
-
runnerId: string;
|
|
402
|
-
leaseId: string;
|
|
403
|
-
}): Promise<{
|
|
404
|
-
envelope: SealedEnvelope;
|
|
405
|
-
} | {
|
|
406
|
-
refused: HolderRefusal;
|
|
407
|
-
}>;
|
|
408
|
-
/** Record a finished job. Idempotent. */
|
|
409
|
-
complete(input: {
|
|
410
|
-
jobId: string;
|
|
411
|
-
runnerId: string;
|
|
412
|
-
/**
|
|
413
|
-
* The grant the result was produced under — cloud_008 §1.4a.
|
|
414
|
-
*
|
|
415
|
-
* `takePayload` and `releaseLeases` have always checked this and said why;
|
|
416
|
-
* the operation that *writes* checked only the runner, which survives a
|
|
417
|
-
* claim-release-reclaim cycle.
|
|
418
|
-
*/
|
|
419
|
-
leaseId: string;
|
|
420
|
-
envelope: SealedEnvelope;
|
|
421
|
-
disposition: "ok" | "error" | "canceled";
|
|
422
|
-
}): Promise<{
|
|
423
|
-
accepted: boolean;
|
|
424
|
-
duplicate?: boolean;
|
|
425
|
-
state: RoutedState;
|
|
426
|
-
} | {
|
|
427
|
-
refused: HolderRefusal;
|
|
428
|
-
}>;
|
|
429
|
-
/** Give back the grants this runner names. */
|
|
430
|
-
releaseLeases(input: {
|
|
431
|
-
runnerId: string;
|
|
432
|
-
leases: readonly {
|
|
433
|
-
jobId: string;
|
|
434
|
-
leaseId: string;
|
|
435
|
-
}[];
|
|
436
|
-
/**
|
|
437
|
-
* Why — cloud_008 §2.1. `refused` MUST be remembered and that job MUST
|
|
438
|
-
* NOT be offered to that runner again (`REFUSAL_NOT_REOFFERED`); every
|
|
439
|
-
* other reason means "not now" and must leave the job claimable by the
|
|
440
|
-
* same device, or a restart would strand its own work.
|
|
441
|
-
*/
|
|
442
|
-
reason?: ReleaseReason;
|
|
443
|
-
}): Promise<string[]>;
|
|
444
|
-
/** Take a site's sealed payload for a claimed job. */
|
|
445
|
-
seal(input: {
|
|
446
|
-
jobId: string;
|
|
447
|
-
siteId: string;
|
|
448
|
-
envelope: SealedEnvelope;
|
|
449
|
-
}): Promise<{
|
|
450
|
-
state: RoutedState;
|
|
451
|
-
} | {
|
|
452
|
-
refused: "not-found" | "too-late";
|
|
453
|
-
was?: RoutedState;
|
|
454
|
-
}>;
|
|
455
|
-
/**
|
|
456
|
-
* Extend the leases this runner still holds, and name the ones it does not.
|
|
457
|
-
*
|
|
458
|
-
* One call because it is one question — cloud_008 §0.6. This was
|
|
459
|
-
* `lostLeases`, which answered only the second half, and the relay's
|
|
460
|
-
* heartbeat answered the first half with the literal `leases: []`. A daemon
|
|
461
|
-
* was therefore told, every few seconds, that none of its work had been
|
|
462
|
-
* renewed; the sweep requeued at `leaseMs` regardless of how alive the
|
|
463
|
-
* device was, and any job that took longer than a lease was handed to
|
|
464
|
-
* somebody else while the first device was still running it. The direct
|
|
465
|
-
* plane has always renewed here (`handlers.ts` §3), so this was also the two
|
|
466
|
-
* upstreams disagreeing about a rule the daemon cannot see.
|
|
467
|
-
*
|
|
468
|
-
* Renewal and loss come from one read of one state: asked separately they
|
|
469
|
-
* are two answers to "who holds this now", and under two replicas they can
|
|
470
|
-
* differ.
|
|
471
|
-
*/
|
|
472
|
-
renewLeases(input: {
|
|
473
|
-
runnerId: string;
|
|
474
|
-
leases: readonly {
|
|
475
|
-
jobId: string;
|
|
476
|
-
leaseId: string;
|
|
477
|
-
}[];
|
|
478
|
-
leaseMs: number;
|
|
479
|
-
}): Promise<{
|
|
480
|
-
renewed: readonly {
|
|
481
|
-
jobId: string;
|
|
482
|
-
expiresAt: number;
|
|
483
|
-
}[];
|
|
484
|
-
lost: readonly string[];
|
|
485
|
-
}>;
|
|
486
|
-
/**
|
|
487
|
-
* The site withdraws a job — cloud_008 §2.2.
|
|
488
|
-
*
|
|
489
|
-
* Returns false when the job is not this site's, which is the same scoping
|
|
490
|
-
* every other site-plane operation carries: a site must not cancel
|
|
491
|
-
* somebody else's work by guessing an id.
|
|
492
|
-
*/
|
|
493
|
-
cancel(input: {
|
|
494
|
-
jobId: string;
|
|
495
|
-
siteId: string;
|
|
496
|
-
}): Promise<boolean>;
|
|
497
|
-
/**
|
|
498
|
-
* Cancelled jobs this runner is holding, for the heartbeat to report.
|
|
499
|
-
*
|
|
500
|
-
* The relay answered `cancel: []` unconditionally, so a site could not stop
|
|
501
|
-
* a job it had already withdrawn — a device went on running work whose
|
|
502
|
-
* result nobody would accept, on somebody's own machine, at their expense.
|
|
503
|
-
*/
|
|
504
|
-
cancelRequests(runnerId: string): Promise<string[]>;
|
|
505
|
-
/** Record a device as present. The store stamps when. */
|
|
506
|
-
seen(presence: Omit<Presence, "lastSeenAt">): Promise<Presence>;
|
|
507
|
-
presence(runnerId: string): Promise<Presence | undefined>;
|
|
508
|
-
everyone(): Promise<Presence[]>;
|
|
509
|
-
/** Fire whatever the clock says is due, and report it. */
|
|
510
|
-
sweep(): Promise<RoutedJob[]>;
|
|
511
|
-
}
|
|
4
|
+
import '@byollm/protocol';
|
|
512
5
|
|
|
513
6
|
declare function debugPage(state: RoutingStore, now: number,
|
|
514
7
|
/**
|
|
@@ -593,6 +86,7 @@ type SiteRecord = z.infer<typeof SiteRecord>;
|
|
|
593
86
|
declare const ConsentRecord: z.ZodObject<{
|
|
594
87
|
owner: z.ZodString;
|
|
595
88
|
siteId: z.ZodString;
|
|
89
|
+
paused: z.ZodDefault<z.ZodBoolean>;
|
|
596
90
|
}, z.core.$strict>;
|
|
597
91
|
type ConsentRecord = z.infer<typeof ConsentRecord>;
|
|
598
92
|
/**
|
|
@@ -649,6 +143,7 @@ declare const RelayFixture: z.ZodObject<{
|
|
|
649
143
|
consents: z.ZodArray<z.ZodObject<{
|
|
650
144
|
owner: z.ZodString;
|
|
651
145
|
siteId: z.ZodString;
|
|
146
|
+
paused: z.ZodDefault<z.ZodBoolean>;
|
|
652
147
|
}, z.core.$strict>>;
|
|
653
148
|
devices: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
654
149
|
owner: z.ZodString;
|
|
@@ -701,8 +196,46 @@ declare class Projection {
|
|
|
701
196
|
deviceFor(runnerId: string): DeviceRecord | null;
|
|
702
197
|
/** The device approved for these exact keys, if any. */
|
|
703
198
|
deviceByFingerprint(identityPublic: string): DeviceRecord | null;
|
|
704
|
-
/**
|
|
199
|
+
/**
|
|
200
|
+
* The consent binding this owner to this site, if it exists and stands.
|
|
201
|
+
*
|
|
202
|
+
* **Liveness, not routing.** A paused consent is returned here: the
|
|
203
|
+
* relationship exists, the daemon is not revoked, the pairing stands. Ask
|
|
204
|
+
* {@link Projection.mayRouteFor} before moving anybody's work — the two
|
|
205
|
+
* questions have different answers and one method answering both is how a
|
|
206
|
+
* paused user would quietly start routing again.
|
|
207
|
+
*/
|
|
705
208
|
consentFor(owner: string, siteId: string): ConsentRecord | null;
|
|
209
|
+
/**
|
|
210
|
+
* May this owner's work move for this site, right now?
|
|
211
|
+
*
|
|
212
|
+
* Consent exists, was not revoked, and is not paused. The routing question,
|
|
213
|
+
* kept apart from {@link Projection.consentFor}'s liveness one so that a
|
|
214
|
+
* caller has to pick which it means.
|
|
215
|
+
*/
|
|
216
|
+
mayRouteFor(owner: string, siteId: string): boolean;
|
|
217
|
+
/** Whether this pair is consented and paused — what heartbeat reports. */
|
|
218
|
+
pausedFor(owner: string, siteId: string): boolean;
|
|
219
|
+
/**
|
|
220
|
+
* Whose work this device may run for this site — the set a claim carries.
|
|
221
|
+
*
|
|
222
|
+
* {@link Projection.ownersRunnableBy} collapsed and then filtered by
|
|
223
|
+
* consent, which is two things this relay was doing in one place and one
|
|
224
|
+
* place respectively:
|
|
225
|
+
*
|
|
226
|
+
* - **The device's own owner is checked first**, and an unroutable one
|
|
227
|
+
* empties the whole set. A machine whose owner has not agreed to this
|
|
228
|
+
* site's current terms runs nothing for it, including a roster member's
|
|
229
|
+
* work: the roster says whose jobs may land here, and consent says
|
|
230
|
+
* whether this machine is available to the site at all.
|
|
231
|
+
* - **Every job owner is checked too.** That check did not exist. Consent
|
|
232
|
+
* was enforced by the daemon plane's blanket revoked guard, which asks
|
|
233
|
+
* only about the *claiming* device's owner — so a roster member who never
|
|
234
|
+
* consented to a site could have their work claimed by their admin's
|
|
235
|
+
* machine, which is `CONSENT_BEFORE_ROUTE` read the other way round. The
|
|
236
|
+
* site plane does not check consent at enqueue either, so nothing did.
|
|
237
|
+
*/
|
|
238
|
+
routableOwners(deviceOwner: string, siteId: string): string[];
|
|
706
239
|
/**
|
|
707
240
|
* Every owner whose work this device's owner may run, as a list.
|
|
708
241
|
*
|
|
@@ -821,4 +354,4 @@ declare class Relay {
|
|
|
821
354
|
handle(request: Request): Promise<Response>;
|
|
822
355
|
}
|
|
823
356
|
|
|
824
|
-
export {
|
|
357
|
+
export { ConsentRecord, DeviceRecord, EMPTY_FIXTURE, Projection, Relay, RelayFixture, RelayFixture as RelayFixtureSchema, type RelayOptions, RevocationRecord, RosterRecord, RoutingStore, SiteRecord, debugPage };
|