@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 CHANGED
@@ -1,5 +1,5 @@
1
1
  > [!WARNING]
2
- > **Alpha (`0.1.0-alpha.10`) — under active development. Don't use this yet.**
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.10`, which on a relay reachable from the
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
- seen(presence: Omit<Presence, "revoked">): Presence;
137
- presence(runnerId: string): Presence | undefined;
138
- everyone(): Presence[];
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
- * Return a job to the queue, forgetting the claim.
208
+ * Record a finished job.
141
209
  *
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.
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
- requeue(job: RoutedJob): void;
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
  }