@byollm/relay 0.1.0-alpha.11 → 0.1.0-alpha.13

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,5 +1,5 @@
1
1
  > [!WARNING]
2
- > **Alpha (`0.1.0-alpha.11`) — under active development. Don't use this yet.**
2
+ > **Alpha (`0.1.0-alpha.13`) — 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.11`, which on a relay reachable from the
83
+ word for who it was until `0.1.0-alpha.13`, 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.13`: `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,74 @@ 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
+ }
135
+ /**
136
+ * Where the store's sense of time comes from — cloud_006 §3.4.
137
+ *
138
+ * **The store owns its clock; callers do not pass one.** Every deadline the
139
+ * relay decides — a lease's expiry, the `awaiting-payload` window, what a
140
+ * sweep considers due — is now stamped by one source, and it is the same
141
+ * source that will later stamp them for every replica.
142
+ *
143
+ * It used to be a parameter. `claim` took `now`, `sweep` took `now`, and each
144
+ * plane called its own `now()` before calling in — which is fine in one
145
+ * process and is the recurring bug the moment there are two. A lease granted
146
+ * by a pod whose clock runs fast is short; the same lease swept by a pod whose
147
+ * clock runs slow outlives it. Nobody is wrong and the lease has no length.
148
+ *
149
+ * A Valkey-backed store returns `TIME` here, so the deadline and the sweep
150
+ * that enforces it are read from the same server. The injected clock stays for
151
+ * tests, which is what lets them move time instead of sleeping.
152
+ *
153
+ * **What deliberately does not use this**: request-signature freshness. That
154
+ * is checked against the *local* clock on purpose — it is a question about the
155
+ * caller's clock versus this process's, `MAX_CLOCK_SKEW_MS` already tolerates
156
+ * two minutes of disagreement, and a network round trip to timestamp every
157
+ * inbound request would be a cost with no property behind it.
158
+ */
159
+ interface RelayStateOptions {
160
+ readonly now?: () => number | Promise<number>;
161
+ }
162
+ /** Why a lease-scoped operation was refused, in the caller's vocabulary. */
163
+ type HolderRefusal = "not-found" | "not-holder" | "stale-lease" | "not-ready";
96
164
  /**
97
165
  * In-memory routing state.
98
166
  *
@@ -102,8 +170,11 @@ interface Presence {
102
170
  * returns to its site's queue — which is the behaviour a lapsed lease already
103
171
  * has to produce, so nothing new needs to be true for this to be safe.
104
172
  */
105
- declare class RelayState {
173
+ declare class RelayState implements RoutingStore {
106
174
  #private;
175
+ constructor(options?: RelayStateOptions);
176
+ /** The one clock every deadline in this store is stamped from. */
177
+ now(): Promise<number>;
107
178
  /**
108
179
  * Take a stub for routing. The payload is not here and will not be.
109
180
  *
@@ -126,24 +197,92 @@ declare class RelayState {
126
197
  id: string;
127
198
  siteId: string;
128
199
  stub: JobStub;
129
- }): RoutedJob;
130
- job(jobId: string): RoutedJob | undefined;
131
- jobs(): RoutedJob[];
200
+ }): Promise<RoutedJob>;
201
+ job(jobId: string): Promise<RoutedJob | undefined>;
202
+ jobs(): Promise<RoutedJob[]>;
132
203
  /** Jobs a site must seal for, right now. */
133
- awaiting(siteId: string): RoutedJob[];
204
+ awaiting(siteId: string): Promise<RoutedJob[]>;
134
205
  /** Sealed results waiting to go home. */
135
- finished(siteId: string): RoutedJob[];
136
- seen(presence: Omit<Presence, "revoked">): Presence;
137
- presence(runnerId: string): Presence | undefined;
138
- everyone(): Presence[];
206
+ finished(siteId: string): Promise<RoutedJob[]>;
207
+ /**
208
+ * Claim work one operation, because it has to be.
209
+ *
210
+ * Moved here wholesale from `DaemonPlane`, where it was a scan followed by
211
+ * per-job mutation. Nothing about the *decision* changed; what changed is
212
+ * that a store can now implement it, because the filter and the write are
213
+ * one call rather than a loop the caller drives.
214
+ *
215
+ * The order of the guards is worth preserving as-is when this becomes a Lua
216
+ * script: cheapest first, and `owners` last because it is the only one that
217
+ * needed the projection.
218
+ */
219
+ claim(input: ClaimInput): Promise<ClaimedStub[]>;
139
220
  /**
140
- * Return a job to the queue, forgetting the claim.
221
+ * Hand over the sealed payload to the device that holds the lease.
141
222
  *
142
- * The stub survives; nothing is lost. That is `LEASE_RECLAIMABLE` and it is
143
- * why the awaiting-payload timeout is cheap to fire: the worst case is that
144
- * a device did nothing for ten seconds and another one gets a turn.
223
+ * The read and the state transition are one operation for the same reason
224
+ * `claim` is: `running` must be set by whoever was told the envelope, or two
225
+ * replicas can both hand out the same work and both believe they were first.
145
226
  */
146
- requeue(job: RoutedJob): void;
227
+ takePayload(input: {
228
+ jobId: string;
229
+ runnerId: string;
230
+ leaseId: string;
231
+ }): Promise<{
232
+ envelope: SealedEnvelope;
233
+ } | {
234
+ refused: HolderRefusal;
235
+ }>;
236
+ /**
237
+ * Record a finished job.
238
+ *
239
+ * `RESULT_IDEMPOTENT` lives here rather than in the caller: a replayed
240
+ * result must be a no-op decided by the same operation that would have
241
+ * written it, or two replicas can both decide they were the first.
242
+ */
243
+ complete(input: {
244
+ jobId: string;
245
+ runnerId: string;
246
+ envelope: SealedEnvelope;
247
+ disposition: "ok" | "error" | "canceled";
248
+ }): Promise<{
249
+ accepted: boolean;
250
+ state: RoutedState;
251
+ } | {
252
+ refused: HolderRefusal;
253
+ }>;
254
+ /** Give back leases this runner holds, naming each grant it means. */
255
+ releaseLeases(input: {
256
+ runnerId: string;
257
+ leases: readonly {
258
+ jobId: string;
259
+ leaseId: string;
260
+ }[];
261
+ }): Promise<string[]>;
262
+ /**
263
+ * Take a site's sealed payload for a claimed job.
264
+ *
265
+ * Refuses anything not `awaiting-payload`, which is what makes the timeout
266
+ * mean something: a late seal must not land on a claim that has moved.
267
+ */
268
+ seal(input: {
269
+ jobId: string;
270
+ siteId: string;
271
+ envelope: SealedEnvelope;
272
+ }): Promise<{
273
+ state: RoutedState;
274
+ } | {
275
+ refused: "not-found" | "too-late";
276
+ was?: RoutedState;
277
+ }>;
278
+ /** Which of these leases this runner no longer holds. */
279
+ lostLeases(runnerId: string, active: readonly {
280
+ jobId: string;
281
+ leaseId: string;
282
+ }[]): Promise<string[]>;
283
+ seen(presence: Omit<Presence, "revoked" | "lastSeenAt">): Promise<Presence>;
284
+ presence(runnerId: string): Promise<Presence | undefined>;
285
+ everyone(): Promise<Presence[]>;
147
286
  /**
148
287
  * Fire whatever the clock says is due, and report it.
149
288
  *
@@ -151,10 +290,125 @@ declare class RelayState {
151
290
  * timeout that fires invisibly is indistinguishable from a job that was
152
291
  * never claimed, and those want very different debugging.
153
292
  */
154
- sweep(now: number): RoutedJob[];
293
+ sweep(): Promise<RoutedJob[]>;
155
294
  }
156
295
 
157
- declare function debugPage(state: RelayState, now: number): string;
296
+ /**
297
+ * What the relay needs from a place to keep routing state — cloud_006 §3.2.
298
+ *
299
+ * `RelayState` implements this in memory and is the reference; a Valkey-backed
300
+ * store implements the same thing across replicas. The interface exists so the
301
+ * relay depends on the *contract* rather than on either, and so the properties
302
+ * below are stated once rather than rediscovered per implementation.
303
+ *
304
+ * ## Every method is a decision plus its write
305
+ *
306
+ * Not one is a read the caller follows with a mutation. That is the whole
307
+ * design, and it is not a style preference: `claim` used to be
308
+ * `jobs()` → filter → mutate in the plane, which is atomic for exactly one
309
+ * reason — Node is single-threaded and the Maps are local. Neither survives a
310
+ * store on a network, and `packages/relay/test/two-replicas.test.ts` holds the
311
+ * resulting race as a failing assertion.
312
+ *
313
+ * **The rule for anything added here:** if a caller has to read, decide, and
314
+ * write back, the operation is in the wrong place. Move the decision in.
315
+ *
316
+ * ## What an implementation must guarantee
317
+ *
318
+ * 1. **`claim` is atomic.** Two callers claiming concurrently must not both
319
+ * receive the same job. `CLAIM_ATOMIC` is a protocol MUST.
320
+ * 2. **`enqueue` is idempotent by job id.** A known id returns what is already
321
+ * routing rather than rebuilding it — byollm_009 §4.2's replay argument
322
+ * rests on every write being idempotent per the instance it names.
323
+ * 3. **`complete` is idempotent.** A replayed result changes nothing, and the
324
+ * decision is made by the same operation that would have written it.
325
+ * 4. **Lease-scoped operations name the grant.** `takePayload`, `complete` and
326
+ * `releaseLeases` check the lease *id*, not just the runner — a runner
327
+ * survives a claim-release-reclaim cycle and a grant does not.
328
+ * 5. **`now()` is the only clock.** Every deadline the store stamps and every
329
+ * deadline it enforces come from here (§3.4). An implementation backed by a
330
+ * server returns that server's time, so two replicas cannot disagree about
331
+ * how long a lease is.
332
+ *
333
+ * ## What it must not do
334
+ *
335
+ * Hold a key, or learn about consent. `claim` takes `owners` as data because a
336
+ * predicate cannot travel to Valkey — and the effect is that the store cannot
337
+ * express an opinion about who may route, only about what it was told. That is
338
+ * what keeps `RELAY_BLIND` a property of the shape rather than of the code.
339
+ */
340
+ interface RoutingStore {
341
+ /** The one clock every deadline in this store is stamped from. */
342
+ now(): Promise<number>;
343
+ /** Take a stub for routing. Idempotent by id. */
344
+ enqueue(input: {
345
+ id: string;
346
+ siteId: string;
347
+ stub: JobStub;
348
+ }): Promise<RoutedJob>;
349
+ job(jobId: string): Promise<RoutedJob | undefined>;
350
+ jobs(): Promise<RoutedJob[]>;
351
+ /** Jobs a site must seal for, right now. */
352
+ awaiting(siteId: string): Promise<RoutedJob[]>;
353
+ /** Sealed results waiting to go home. */
354
+ finished(siteId: string): Promise<RoutedJob[]>;
355
+ /** Grant work to a device — one operation, because it has to be. */
356
+ claim(input: ClaimInput): Promise<ClaimedStub[]>;
357
+ /** Hand the sealed payload to the device that holds the lease. */
358
+ takePayload(input: {
359
+ jobId: string;
360
+ runnerId: string;
361
+ leaseId: string;
362
+ }): Promise<{
363
+ envelope: SealedEnvelope;
364
+ } | {
365
+ refused: HolderRefusal;
366
+ }>;
367
+ /** Record a finished job. Idempotent. */
368
+ complete(input: {
369
+ jobId: string;
370
+ runnerId: string;
371
+ envelope: SealedEnvelope;
372
+ disposition: "ok" | "error" | "canceled";
373
+ }): Promise<{
374
+ accepted: boolean;
375
+ state: RoutedState;
376
+ } | {
377
+ refused: HolderRefusal;
378
+ }>;
379
+ /** Give back the grants this runner names. */
380
+ releaseLeases(input: {
381
+ runnerId: string;
382
+ leases: readonly {
383
+ jobId: string;
384
+ leaseId: string;
385
+ }[];
386
+ }): Promise<string[]>;
387
+ /** Take a site's sealed payload for a claimed job. */
388
+ seal(input: {
389
+ jobId: string;
390
+ siteId: string;
391
+ envelope: SealedEnvelope;
392
+ }): Promise<{
393
+ state: RoutedState;
394
+ } | {
395
+ refused: "not-found" | "too-late";
396
+ was?: RoutedState;
397
+ }>;
398
+ /** Which of these leases this runner no longer holds. */
399
+ lostLeases(runnerId: string, active: readonly {
400
+ jobId: string;
401
+ leaseId: string;
402
+ }[]): Promise<string[]>;
403
+ /** Record a device as present. The store stamps when. */
404
+ seen(presence: Omit<Presence, "revoked" | "lastSeenAt">): Promise<Presence>;
405
+ presence(runnerId: string): Promise<Presence | undefined>;
406
+ everyone(): Promise<Presence[]>;
407
+ /** Fire whatever the clock says is due, and report it. */
408
+ sweep(): Promise<RoutedJob[]>;
409
+ }
410
+
411
+ declare function debugPage(state: RoutingStore, now: number): Promise<string>;
158
412
 
159
413
  /**
160
414
  * What the relay is told about the world — cloud_004 §14.
@@ -335,6 +589,30 @@ declare class Projection {
335
589
  deviceByFingerprint(identityPublic: string): DeviceRecord | null;
336
590
  /** The consent binding this owner to this site, if it exists and stands. */
337
591
  consentFor(owner: string, siteId: string): ConsentRecord | null;
592
+ /**
593
+ * Every owner whose work this device's owner may run, as a list.
594
+ *
595
+ * The same question {@link mayRunFor} answers, asked in the direction a
596
+ * *store* can use. That difference is the crux of making `claim` atomic
597
+ * (cloud_006 §3.2).
598
+ *
599
+ * Today `claim` scans every job and calls `mayRunFor` per candidate, which
600
+ * works because the projection is a local object. A shared routing store
601
+ * cannot do that: the filter has to travel to the store, and a predicate
602
+ * does not travel — you cannot send a closure to Valkey. So the projection
603
+ * is collapsed to **data** here and handed over as a set the store can
604
+ * match on.
605
+ *
606
+ * That the collapse is possible at all is a property of the design worth
607
+ * noticing: `mayRunFor` is a finite lookup over consent and rosters, not a
608
+ * computation over the jobs. If it ever became job-dependent — "may run
609
+ * work of this size", say — an atomic claim would stop being expressible,
610
+ * and that is the moment to argue rather than to add a parameter.
611
+ *
612
+ * The owner is always included: a device runs its owner's work, and the
613
+ * relay checks that before it checks a roster.
614
+ */
615
+ ownersRunnableBy(deviceOwner: string): string[];
338
616
  /**
339
617
  * May this device's owner run work belonging to `jobOwner`?
340
618
  *
@@ -388,11 +666,27 @@ interface RelayOptions {
388
666
  readonly now?: () => number;
389
667
  /** Where the daemon plane is mounted. */
390
668
  readonly basePath?: string;
669
+ /**
670
+ * Where routing state lives — cloud_006.
671
+ *
672
+ * Defaults to an in-process {@link RelayState}, which is correct for one
673
+ * replica and is what this package ships. A hub running more than one
674
+ * replica supplies a shared implementation of {@link RoutingStore} instead;
675
+ * `packages/relay/test/two-replicas.test.ts` is why that is not optional.
676
+ *
677
+ * **The implementation is deliberately not in this package.** A Valkey
678
+ * client is a dependency every consumer would carry to get a feature only a
679
+ * multi-replica deployment uses, and the production hub is the closed piece
680
+ * (cloud_001). What ships here is the interface, the reference
681
+ * implementation, and the tests that say what an implementation must
682
+ * guarantee.
683
+ */
684
+ readonly store?: RoutingStore;
391
685
  }
392
686
  /** A running relay: one fetch handler, two planes, one debug page. */
393
687
  declare class Relay {
394
688
  #private;
395
- readonly state: RelayState;
689
+ readonly state: RoutingStore;
396
690
  readonly projection: Projection;
397
691
  constructor(options: RelayOptions);
398
692
  /** Replace the projection — a control-plane push, or a fixture edit. */
@@ -406,11 +700,11 @@ declare class Relay {
406
700
  * site vanished should return to the queue without waiting for someone to
407
701
  * ask about it.
408
702
  */
409
- sweep(): {
703
+ sweep(): Promise<{
410
704
  requeued: string[];
411
- };
705
+ }>;
412
706
  /** The whole HTTP surface. */
413
707
  handle(request: Request): Promise<Response>;
414
708
  }
415
709
 
416
- export { AWAITING_PAYLOAD_MS, ConsentRecord, DeviceRecord, EMPTY_FIXTURE, type Presence, Projection, Relay, RelayFixture, RelayFixture as RelayFixtureSchema, type RelayOptions, RelayState, RevocationRecord, RosterRecord, type RoutedJob, type RoutedState, SiteRecord, debugPage };
710
+ export { AWAITING_PAYLOAD_MS, type ClaimInput, ConsentRecord, DeviceRecord, EMPTY_FIXTURE, type HolderRefusal, type Presence, Projection, Relay, RelayFixture, RelayFixture as RelayFixtureSchema, type RelayOptions, RelayState, RevocationRecord, RosterRecord, type RoutedJob, type RoutedState, type RoutingStore, SiteRecord, debugPage };