@byollm/relay 0.1.0-alpha.10 → 0.1.0-alpha.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -2
- package/dist/index.d.ts +152 -18
- package/dist/index.js +371 -221
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
> [!WARNING]
|
|
2
|
-
> **Alpha (`0.1.0-alpha.
|
|
2
|
+
> **Alpha (`0.1.0-alpha.12`) — 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
|
|
@@ -80,7 +80,7 @@ daemons pin at pairing, verified against the `sites` half of the projection.
|
|
|
80
80
|
Nothing here trusts a `siteId` in a body or a query string.
|
|
81
81
|
|
|
82
82
|
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.
|
|
83
|
+
word for who it was until `0.1.0-alpha.12`, which on a relay reachable from the
|
|
84
84
|
internet is an open enqueue endpoint into consenting users' machines and an
|
|
85
85
|
open read of who is online. It was blind the whole time — nothing could open a
|
|
86
86
|
payload — and blind is not the same as safe.
|
|
@@ -88,6 +88,24 @@ payload — and blind is not the same as safe.
|
|
|
88
88
|
If you are running this: the site plane is authenticated but this is still a
|
|
89
89
|
single-tenant relay with in-memory state. One site, one replica.
|
|
90
90
|
|
|
91
|
+
## Breaking in `0.1.0-alpha.12`: `RelayState` is async
|
|
92
|
+
|
|
93
|
+
Every method on `RelayState` now returns a `Promise`, and `Relay.sweep()` and
|
|
94
|
+
`debugPage()` with it. `RelayState.requeue` is private — it was only ever a
|
|
95
|
+
step inside another operation.
|
|
96
|
+
|
|
97
|
+
Nothing about the behaviour changed. The shape did, and it had to before
|
|
98
|
+
routing state can live anywhere but this process: a store on a network cannot
|
|
99
|
+
offer a synchronous read, and — more importantly — cannot offer a *read the
|
|
100
|
+
caller follows with a write*. So the operations are now decisions plus their
|
|
101
|
+
writes (`claim`, `takePayload`, `complete`, `releaseLeases`, `seal`) rather
|
|
102
|
+
than scans the caller mutates.
|
|
103
|
+
|
|
104
|
+
`claim` is the one that matters. It was atomic for exactly one reason — Node
|
|
105
|
+
is single-threaded and the Maps are local — and `CLAIM_ATOMIC` is a MUST. See
|
|
106
|
+
`packages/relay/test/two-replicas.test.ts`, where the resulting race is a
|
|
107
|
+
failing assertion waiting for the fix.
|
|
108
|
+
|
|
91
109
|
## Running it
|
|
92
110
|
|
|
93
111
|
```ts
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PublicIdentity, JobStub, SealedEnvelope } from '@byollm/protocol';
|
|
1
|
+
import { PublicIdentity, JobStub, SealedEnvelope, ClaimedStub } from '@byollm/protocol';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -93,6 +93,48 @@ interface Presence {
|
|
|
93
93
|
/** Set on revocation so the next request is refused rather than routed. */
|
|
94
94
|
revoked: boolean;
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* What a routing store must do, expressed as operations — cloud_006 §3.2.
|
|
98
|
+
*
|
|
99
|
+
* Every method below is a **decision plus its write**, never a read the caller
|
|
100
|
+
* follows with a mutation. That is the whole point, and it is the difference
|
|
101
|
+
* between an interface a shared store can implement and one it cannot.
|
|
102
|
+
*
|
|
103
|
+
* `claim` is the specimen. It used to live in `DaemonPlane` as
|
|
104
|
+
* `jobs()` → filter → mutate, which is atomic for exactly one reason: Node is
|
|
105
|
+
* single-threaded and these Maps are local, so nothing runs between the read
|
|
106
|
+
* and the write. Neither survives a store on a network, and
|
|
107
|
+
* `packages/relay/test/two-replicas.test.ts` holds the resulting race as a
|
|
108
|
+
* failing assertion.
|
|
109
|
+
*
|
|
110
|
+
* So the rule for anything added here: **if a caller has to read, decide, and
|
|
111
|
+
* write back, the operation is in the wrong place.** Move the decision in.
|
|
112
|
+
*
|
|
113
|
+
* ## Why the projection does not come with it
|
|
114
|
+
*
|
|
115
|
+
* `claim` takes `owners: string[]` rather than a projection or a predicate.
|
|
116
|
+
* A closure cannot travel to Valkey, and the projection replicates for free
|
|
117
|
+
* from the control plane — so the caller collapses it with
|
|
118
|
+
* `Projection.ownersRunnableBy` and hands over data the store can match on.
|
|
119
|
+
* That keeps the store ignorant of consent, which is also what keeps it
|
|
120
|
+
* replaceable.
|
|
121
|
+
*/
|
|
122
|
+
interface ClaimInput {
|
|
123
|
+
readonly runnerId: string;
|
|
124
|
+
readonly owner: string;
|
|
125
|
+
readonly device: PublicIdentity;
|
|
126
|
+
/** The site this relay routes for. Multi-tenancy widens this to a set. */
|
|
127
|
+
readonly siteId: string;
|
|
128
|
+
/** Job kinds this device can actually run. */
|
|
129
|
+
readonly kinds: ReadonlySet<string>;
|
|
130
|
+
/** Whose work it may run — the projection, already collapsed to data. */
|
|
131
|
+
readonly owners: ReadonlySet<string>;
|
|
132
|
+
readonly max: number;
|
|
133
|
+
readonly leaseMs: number;
|
|
134
|
+
readonly now: number;
|
|
135
|
+
}
|
|
136
|
+
/** Why a lease-scoped operation was refused, in the caller's vocabulary. */
|
|
137
|
+
type HolderRefusal = "not-found" | "not-holder" | "stale-lease" | "not-ready";
|
|
96
138
|
/**
|
|
97
139
|
* In-memory routing state.
|
|
98
140
|
*
|
|
@@ -126,24 +168,92 @@ declare class RelayState {
|
|
|
126
168
|
id: string;
|
|
127
169
|
siteId: string;
|
|
128
170
|
stub: JobStub;
|
|
129
|
-
}): RoutedJob
|
|
130
|
-
job(jobId: string): RoutedJob | undefined
|
|
131
|
-
jobs(): RoutedJob[]
|
|
171
|
+
}): Promise<RoutedJob>;
|
|
172
|
+
job(jobId: string): Promise<RoutedJob | undefined>;
|
|
173
|
+
jobs(): Promise<RoutedJob[]>;
|
|
132
174
|
/** Jobs a site must seal for, right now. */
|
|
133
|
-
awaiting(siteId: string): RoutedJob[]
|
|
175
|
+
awaiting(siteId: string): Promise<RoutedJob[]>;
|
|
134
176
|
/** Sealed results waiting to go home. */
|
|
135
|
-
finished(siteId: string): RoutedJob[]
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
177
|
+
finished(siteId: string): Promise<RoutedJob[]>;
|
|
178
|
+
/**
|
|
179
|
+
* Claim work — one operation, because it has to be.
|
|
180
|
+
*
|
|
181
|
+
* Moved here wholesale from `DaemonPlane`, where it was a scan followed by
|
|
182
|
+
* per-job mutation. Nothing about the *decision* changed; what changed is
|
|
183
|
+
* that a store can now implement it, because the filter and the write are
|
|
184
|
+
* one call rather than a loop the caller drives.
|
|
185
|
+
*
|
|
186
|
+
* The order of the guards is worth preserving as-is when this becomes a Lua
|
|
187
|
+
* script: cheapest first, and `owners` last because it is the only one that
|
|
188
|
+
* needed the projection.
|
|
189
|
+
*/
|
|
190
|
+
claim(input: ClaimInput): Promise<ClaimedStub[]>;
|
|
191
|
+
/**
|
|
192
|
+
* Hand over the sealed payload to the device that holds the lease.
|
|
193
|
+
*
|
|
194
|
+
* The read and the state transition are one operation for the same reason
|
|
195
|
+
* `claim` is: `running` must be set by whoever was told the envelope, or two
|
|
196
|
+
* replicas can both hand out the same work and both believe they were first.
|
|
197
|
+
*/
|
|
198
|
+
takePayload(input: {
|
|
199
|
+
jobId: string;
|
|
200
|
+
runnerId: string;
|
|
201
|
+
leaseId: string;
|
|
202
|
+
}): Promise<{
|
|
203
|
+
envelope: SealedEnvelope;
|
|
204
|
+
} | {
|
|
205
|
+
refused: HolderRefusal;
|
|
206
|
+
}>;
|
|
139
207
|
/**
|
|
140
|
-
*
|
|
208
|
+
* Record a finished job.
|
|
141
209
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
210
|
+
* `RESULT_IDEMPOTENT` lives here rather than in the caller: a replayed
|
|
211
|
+
* result must be a no-op decided by the same operation that would have
|
|
212
|
+
* written it, or two replicas can both decide they were the first.
|
|
145
213
|
*/
|
|
146
|
-
|
|
214
|
+
complete(input: {
|
|
215
|
+
jobId: string;
|
|
216
|
+
runnerId: string;
|
|
217
|
+
envelope: SealedEnvelope;
|
|
218
|
+
disposition: "ok" | "error" | "canceled";
|
|
219
|
+
}): Promise<{
|
|
220
|
+
accepted: boolean;
|
|
221
|
+
state: RoutedState;
|
|
222
|
+
} | {
|
|
223
|
+
refused: HolderRefusal;
|
|
224
|
+
}>;
|
|
225
|
+
/** Give back leases this runner holds, naming each grant it means. */
|
|
226
|
+
releaseLeases(input: {
|
|
227
|
+
runnerId: string;
|
|
228
|
+
leases: readonly {
|
|
229
|
+
jobId: string;
|
|
230
|
+
leaseId: string;
|
|
231
|
+
}[];
|
|
232
|
+
}): Promise<string[]>;
|
|
233
|
+
/**
|
|
234
|
+
* Take a site's sealed payload for a claimed job.
|
|
235
|
+
*
|
|
236
|
+
* Refuses anything not `awaiting-payload`, which is what makes the timeout
|
|
237
|
+
* mean something: a late seal must not land on a claim that has moved.
|
|
238
|
+
*/
|
|
239
|
+
seal(input: {
|
|
240
|
+
jobId: string;
|
|
241
|
+
siteId: string;
|
|
242
|
+
envelope: SealedEnvelope;
|
|
243
|
+
}): Promise<{
|
|
244
|
+
state: RoutedState;
|
|
245
|
+
} | {
|
|
246
|
+
refused: "not-found" | "too-late";
|
|
247
|
+
was?: RoutedState;
|
|
248
|
+
}>;
|
|
249
|
+
/** Which of these leases this runner no longer holds. */
|
|
250
|
+
lostLeases(runnerId: string, active: readonly {
|
|
251
|
+
jobId: string;
|
|
252
|
+
leaseId: string;
|
|
253
|
+
}[]): Promise<string[]>;
|
|
254
|
+
seen(presence: Omit<Presence, "revoked">): Promise<Presence>;
|
|
255
|
+
presence(runnerId: string): Promise<Presence | undefined>;
|
|
256
|
+
everyone(): Promise<Presence[]>;
|
|
147
257
|
/**
|
|
148
258
|
* Fire whatever the clock says is due, and report it.
|
|
149
259
|
*
|
|
@@ -151,10 +261,10 @@ declare class RelayState {
|
|
|
151
261
|
* timeout that fires invisibly is indistinguishable from a job that was
|
|
152
262
|
* never claimed, and those want very different debugging.
|
|
153
263
|
*/
|
|
154
|
-
sweep(now: number): RoutedJob[]
|
|
264
|
+
sweep(now: number): Promise<RoutedJob[]>;
|
|
155
265
|
}
|
|
156
266
|
|
|
157
|
-
declare function debugPage(state: RelayState, now: number): string
|
|
267
|
+
declare function debugPage(state: RelayState, now: number): Promise<string>;
|
|
158
268
|
|
|
159
269
|
/**
|
|
160
270
|
* What the relay is told about the world — cloud_004 §14.
|
|
@@ -335,6 +445,30 @@ declare class Projection {
|
|
|
335
445
|
deviceByFingerprint(identityPublic: string): DeviceRecord | null;
|
|
336
446
|
/** The consent binding this owner to this site, if it exists and stands. */
|
|
337
447
|
consentFor(owner: string, siteId: string): ConsentRecord | null;
|
|
448
|
+
/**
|
|
449
|
+
* Every owner whose work this device's owner may run, as a list.
|
|
450
|
+
*
|
|
451
|
+
* The same question {@link mayRunFor} answers, asked in the direction a
|
|
452
|
+
* *store* can use. That difference is the crux of making `claim` atomic
|
|
453
|
+
* (cloud_006 §3.2).
|
|
454
|
+
*
|
|
455
|
+
* Today `claim` scans every job and calls `mayRunFor` per candidate, which
|
|
456
|
+
* works because the projection is a local object. A shared routing store
|
|
457
|
+
* cannot do that: the filter has to travel to the store, and a predicate
|
|
458
|
+
* does not travel — you cannot send a closure to Valkey. So the projection
|
|
459
|
+
* is collapsed to **data** here and handed over as a set the store can
|
|
460
|
+
* match on.
|
|
461
|
+
*
|
|
462
|
+
* That the collapse is possible at all is a property of the design worth
|
|
463
|
+
* noticing: `mayRunFor` is a finite lookup over consent and rosters, not a
|
|
464
|
+
* computation over the jobs. If it ever became job-dependent — "may run
|
|
465
|
+
* work of this size", say — an atomic claim would stop being expressible,
|
|
466
|
+
* and that is the moment to argue rather than to add a parameter.
|
|
467
|
+
*
|
|
468
|
+
* The owner is always included: a device runs its owner's work, and the
|
|
469
|
+
* relay checks that before it checks a roster.
|
|
470
|
+
*/
|
|
471
|
+
ownersRunnableBy(deviceOwner: string): string[];
|
|
338
472
|
/**
|
|
339
473
|
* May this device's owner run work belonging to `jobOwner`?
|
|
340
474
|
*
|
|
@@ -406,9 +540,9 @@ declare class Relay {
|
|
|
406
540
|
* site vanished should return to the queue without waiting for someone to
|
|
407
541
|
* ask about it.
|
|
408
542
|
*/
|
|
409
|
-
sweep(): {
|
|
543
|
+
sweep(): Promise<{
|
|
410
544
|
requeued: string[];
|
|
411
|
-
}
|
|
545
|
+
}>;
|
|
412
546
|
/** The whole HTTP surface. */
|
|
413
547
|
handle(request: Request): Promise<Response>;
|
|
414
548
|
}
|