@byollm/server 0.1.0-alpha.3 → 0.1.0-alpha.31
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 +151 -12
- package/bin/keygen.mjs +21 -0
- package/dist/chunk-K5E6JS5A.js +655 -0
- package/dist/chunk-K5E6JS5A.js.map +1 -0
- package/dist/{handlers-D7lWfwno.d.ts → handlers-BJYm2kdq.d.ts} +27 -4
- package/dist/index.d.ts +221 -17
- package/dist/index.js +549 -44
- package/dist/index.js.map +1 -1
- package/dist/next.d.ts +40 -8
- package/dist/next.js +8 -2
- package/dist/next.js.map +1 -1
- package/dist/store-Dno2fnHH.d.ts +436 -0
- package/dist/supabase/index.d.ts +1 -1
- package/dist/supabase/index.js +115 -42
- package/dist/supabase/index.js.map +1 -1
- package/package.json +7 -3
- package/supabase/migrations/20260809000000_byollm_runner.sql +22 -3
- package/supabase/migrations/20260819000000_drop_runner_token.sql +87 -0
- package/supabase/migrations/20260819010000_completed_by_lease_id.sql +25 -0
- package/supabase/migrations/20260821000000_rename_collected.sql +91 -0
- package/dist/chunk-HL6EYHQ7.js +0 -422
- package/dist/chunk-HL6EYHQ7.js.map +0 -1
- package/dist/store-D23N6iiP.d.ts +0 -255
package/dist/index.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ByollmHandlers,
|
|
3
3
|
SERVED_PROTOCOL_VERSION,
|
|
4
|
-
bearerFrom,
|
|
5
4
|
createFetchHandler,
|
|
5
|
+
deadlineFor,
|
|
6
6
|
generateDeviceCode,
|
|
7
7
|
generateJobId,
|
|
8
|
+
generateLeaseId,
|
|
8
9
|
generateRunnerId,
|
|
9
|
-
generateRunnerToken,
|
|
10
10
|
generateUserCode,
|
|
11
11
|
hashSecret,
|
|
12
|
+
resealForDevice,
|
|
12
13
|
routeEndpoint,
|
|
13
|
-
secretsMatch
|
|
14
|
-
|
|
14
|
+
secretsMatch,
|
|
15
|
+
signatureFrom
|
|
16
|
+
} from "./chunk-K5E6JS5A.js";
|
|
15
17
|
import {
|
|
16
18
|
NoRunnerAvailableError,
|
|
17
19
|
PollingDelivery,
|
|
@@ -20,19 +22,355 @@ import {
|
|
|
20
22
|
|
|
21
23
|
// src/app.ts
|
|
22
24
|
import {
|
|
25
|
+
ENVELOPE_MAX_AGE_MS,
|
|
26
|
+
KindedPayload,
|
|
27
|
+
keyId as keyId2,
|
|
28
|
+
payloadTextLength,
|
|
29
|
+
publicIdentityOf as publicIdentityOf2,
|
|
30
|
+
seal,
|
|
31
|
+
sizeClassOf,
|
|
23
32
|
backendDescriptor,
|
|
24
33
|
matchAudience
|
|
25
34
|
} from "@byollm/protocol";
|
|
35
|
+
|
|
36
|
+
// src/cloud.ts
|
|
37
|
+
import {
|
|
38
|
+
PROTOCOL_VERSION,
|
|
39
|
+
SealedOutcome,
|
|
40
|
+
keyId,
|
|
41
|
+
open,
|
|
42
|
+
publicIdentityOf,
|
|
43
|
+
provenanceFor,
|
|
44
|
+
signSiteRequest
|
|
45
|
+
} from "@byollm/protocol";
|
|
46
|
+
var RelayUnavailable = class extends Error {
|
|
47
|
+
retryable;
|
|
48
|
+
/** The protocol's own code, when the relay sent one. */
|
|
49
|
+
code;
|
|
50
|
+
constructor(message, retryable, code) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "RelayUnavailable";
|
|
53
|
+
this.retryable = retryable;
|
|
54
|
+
this.code = code;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
var CloudLane = class {
|
|
58
|
+
#options;
|
|
59
|
+
#store;
|
|
60
|
+
#siteKeys;
|
|
61
|
+
#now;
|
|
62
|
+
#fetch;
|
|
63
|
+
constructor(deps) {
|
|
64
|
+
this.#options = deps.options;
|
|
65
|
+
this.#store = deps.store;
|
|
66
|
+
this.#siteKeys = deps.siteKeys;
|
|
67
|
+
this.#now = deps.now;
|
|
68
|
+
this.#fetch = deps.options.fetch ?? globalThis.fetch;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Publish a job's stub for routing.
|
|
72
|
+
*
|
|
73
|
+
* The stub and nothing else — byollm_009 §6 makes that exhaustive by
|
|
74
|
+
* construction, so this cannot leak a payload even by mistake: there is no
|
|
75
|
+
* field on `JobStub` to put one in.
|
|
76
|
+
*/
|
|
77
|
+
async publish(record) {
|
|
78
|
+
const stub = {
|
|
79
|
+
id: record.id,
|
|
80
|
+
kind: record.kind,
|
|
81
|
+
owner: record.owner,
|
|
82
|
+
// This site, by its identity key id — Amendment A §A.3. The relay
|
|
83
|
+
// already knows which site it is routing for, so this discloses nothing
|
|
84
|
+
// new to it; what it adds is that the *daemon* can check the stub
|
|
85
|
+
// against the envelope's `senderKeyId` without asking the relay.
|
|
86
|
+
site: keyId(publicIdentityOf(this.#siteKeys).identity),
|
|
87
|
+
audience: record.audience,
|
|
88
|
+
// `audienceAllow` is deliberately **not** published — cloud_008 §0.2.
|
|
89
|
+
//
|
|
90
|
+
// It is a list of the people who may run this job, and on the direct
|
|
91
|
+
// plane that is unremarkable: the site authored the list and the site is
|
|
92
|
+
// the upstream, so the party receiving it already has it. Through a
|
|
93
|
+
// relay it is a third party, and byollm_009 §6's enumerated metadata —
|
|
94
|
+
// "exhaustive and normative… what an upstream can see, stated as a
|
|
95
|
+
// commitment" — does not include it. It was reaching the relay on every
|
|
96
|
+
// named-audience job.
|
|
97
|
+
//
|
|
98
|
+
// Nothing is lost by withholding it, which is why this is a Tier 0 fix
|
|
99
|
+
// rather than a trade. `matchAudience` treats it as a *narrowing*:
|
|
100
|
+
// `job.audienceAllow !== undefined && !includes(daemon.owner)` refuses,
|
|
101
|
+
// and its absence simply falls through to the checks that actually
|
|
102
|
+
// enforce — the daemon's own allowlist (`NAMED_LOCAL_ALLOWLIST`) and the
|
|
103
|
+
// backend's offer scope. On this lane the relay narrows too, from the
|
|
104
|
+
// control plane's rosters. The enforcement was never here.
|
|
105
|
+
sizeClass: record.sizeClass,
|
|
106
|
+
streaming: false,
|
|
107
|
+
// The relay needs *a* deadline to bound routing. A job without one gets
|
|
108
|
+
// the envelope's, which is the outer bound on how long the ciphertext
|
|
109
|
+
// is worth carrying — never longer than the work could possibly matter.
|
|
110
|
+
// The same fallback the direct plane uses — cloud_008 Tier 4, finding
|
|
111
|
+
// 31. This said `createdAt + ENVELOPE_TTL_FALLBACK`, a local constant
|
|
112
|
+
// whose value happened to equal `ENVELOPE_MAX_AGE_MS`; the direct plane
|
|
113
|
+
// said `(claimableAt ?? now) + ttlMs`. One field, two meanings, and a
|
|
114
|
+
// job that was blocked on a dependency got a deadline measured from
|
|
115
|
+
// when it was *created* on one lane and from when it became *claimable*
|
|
116
|
+
// on the other.
|
|
117
|
+
deadlineAt: deadlineFor(record, this.#now())
|
|
118
|
+
};
|
|
119
|
+
await this.#post("enqueue", {
|
|
120
|
+
siteId: this.#options.siteId,
|
|
121
|
+
stub
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Withdraw a job at the relay — cloud_008 §2.2.
|
|
126
|
+
*
|
|
127
|
+
* `app.cancel()` marks the site's own row terminal, which stops the *next*
|
|
128
|
+
* seal. It cannot stop a device that is already running the work, because
|
|
129
|
+
* on this lane the site is not the upstream: only the relay talks to the
|
|
130
|
+
* daemon, and it answered `cancel: []` unconditionally.
|
|
131
|
+
*
|
|
132
|
+
* So the cancellation has to travel. The relay marks the job, stops
|
|
133
|
+
* offering it, and names it to the holding device at its next heartbeat —
|
|
134
|
+
* the same path the direct plane has always had, arriving one hop later.
|
|
135
|
+
*/
|
|
136
|
+
async cancel(jobId) {
|
|
137
|
+
await this.#post("cancel", { siteId: this.#options.siteId, jobId });
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* One cycle: seal for anything claimed, collect anything finished.
|
|
141
|
+
*
|
|
142
|
+
* Idempotent and safe to call as often as you like. Exposed as a single
|
|
143
|
+
* cycle rather than hidden behind a timer so a caller decides its own
|
|
144
|
+
* cadence — a serverless site runs it on a cron, a long-lived one on an
|
|
145
|
+
* interval, and a test runs it exactly when it means to.
|
|
146
|
+
*/
|
|
147
|
+
async pump() {
|
|
148
|
+
const sealed = [];
|
|
149
|
+
const refused = [];
|
|
150
|
+
const completed = [];
|
|
151
|
+
try {
|
|
152
|
+
return await this.#cycle(sealed, refused, completed);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error instanceof RelayUnavailable && error.retryable) {
|
|
155
|
+
return { sealed, completed, refused, deferred: error.message };
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async #cycle(sealed, refused, completed) {
|
|
161
|
+
const pending = await this.#get("pending");
|
|
162
|
+
for (const claim of pending.jobs) {
|
|
163
|
+
const record = await this.#store.get(claim.jobId);
|
|
164
|
+
if (!record) continue;
|
|
165
|
+
const resealed = await resealForDevice({
|
|
166
|
+
siteKeys: this.#siteKeys,
|
|
167
|
+
job: {
|
|
168
|
+
id: record.id,
|
|
169
|
+
envelope: record.envelope,
|
|
170
|
+
createdAt: record.createdAt
|
|
171
|
+
},
|
|
172
|
+
device: claim.device
|
|
173
|
+
});
|
|
174
|
+
if (!resealed.ok) {
|
|
175
|
+
refused.push(claim.jobId);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const adopted = await this.#store.adopt({
|
|
179
|
+
jobId: claim.jobId,
|
|
180
|
+
leaseId: claim.leaseId,
|
|
181
|
+
expiresAt: claim.leaseExpiresAt,
|
|
182
|
+
now: this.#now()
|
|
183
|
+
});
|
|
184
|
+
if (!adopted) {
|
|
185
|
+
refused.push(claim.jobId);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
await this.#post("payload", {
|
|
189
|
+
siteId: this.#options.siteId,
|
|
190
|
+
jobId: claim.jobId,
|
|
191
|
+
envelope: resealed.envelope
|
|
192
|
+
});
|
|
193
|
+
sealed.push(claim.jobId);
|
|
194
|
+
}
|
|
195
|
+
const finished = await this.#get("results");
|
|
196
|
+
for (const done of finished.jobs) {
|
|
197
|
+
const record = await this.#store.get(done.jobId);
|
|
198
|
+
if (!record || record.state === "ok" || record.state === "error") {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const outcome = await this.#openResult(done);
|
|
202
|
+
if (!outcome) {
|
|
203
|
+
refused.push(done.jobId);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
await this.#store.complete({
|
|
207
|
+
jobId: done.jobId,
|
|
208
|
+
// The relay named the device; the signature above proved it — §3.6.
|
|
209
|
+
runnerId: done.runnerId,
|
|
210
|
+
// The grant, not the machine: this site never paired with the device
|
|
211
|
+
// that ran it, and the signature it verified above is the stronger
|
|
212
|
+
// claim about who did.
|
|
213
|
+
holder: { by: "lease", leaseId: done.leaseId },
|
|
214
|
+
outcome: outcome.outcome,
|
|
215
|
+
provenance: provenanceFor({
|
|
216
|
+
audience: record.audience,
|
|
217
|
+
runnerId: done.runnerId,
|
|
218
|
+
// The owner, from the relay's own record of who claimed it — not a
|
|
219
|
+
// key id. cloud_008 §2.5: this said `keyId(device.identity)`, which
|
|
220
|
+
// put a key id where the direct plane puts a user id, so an app
|
|
221
|
+
// comparing provenance across lanes compared two namespaces and got
|
|
222
|
+
// `false` for the same person. The device's key is still what the
|
|
223
|
+
// signature was verified against, above; that is a different
|
|
224
|
+
// question from whose machine it is.
|
|
225
|
+
runnerOwner: done.runnerOwner,
|
|
226
|
+
// From the envelope, not invented — cloud_008 §2.5. These were
|
|
227
|
+
// hardcoded `"http"` and `"unknown"` because the daemon's declared
|
|
228
|
+
// values stopped at the relay, which is right: a blind relay acts
|
|
229
|
+
// on neither. Sealing them carries them past it untouched.
|
|
230
|
+
backendClass: outcome.ran.backendClass,
|
|
231
|
+
model: outcome.ran.model
|
|
232
|
+
}),
|
|
233
|
+
now: this.#now()
|
|
234
|
+
});
|
|
235
|
+
completed.push(done.jobId);
|
|
236
|
+
}
|
|
237
|
+
return { sealed, completed, refused };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Open a sealed result and verify it came from the device that claimed it.
|
|
241
|
+
*
|
|
242
|
+
* The relay says which device ran the job; this checks that claim against a
|
|
243
|
+
* signature the relay cannot produce. A relay that named the wrong device
|
|
244
|
+
* gets a refusal, not a stored result — which is what keeps `RELAY_BLIND`
|
|
245
|
+
* from quietly becoming `RELAY_TRUSTED`.
|
|
246
|
+
*/
|
|
247
|
+
async #openResult(done) {
|
|
248
|
+
const opened = await open({
|
|
249
|
+
envelope: done.envelope,
|
|
250
|
+
recipientKeys: this.#siteKeys,
|
|
251
|
+
senderIdentityPublic: done.device.identity,
|
|
252
|
+
expected: {
|
|
253
|
+
jobId: done.jobId,
|
|
254
|
+
senderKeyId: keyId(done.device.identity),
|
|
255
|
+
recipientKeyId: keyId(publicIdentityOf(this.#siteKeys).identity),
|
|
256
|
+
direction: "result"
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
if (!opened.ok) return null;
|
|
260
|
+
let parsed;
|
|
261
|
+
try {
|
|
262
|
+
parsed = JSON.parse(opened.plaintext);
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const sealed = SealedOutcome.safeParse(parsed);
|
|
267
|
+
if (!sealed.success) return null;
|
|
268
|
+
if (sealed.data.outcome.outcome !== done.disposition) return null;
|
|
269
|
+
return sealed.data;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Sign a site-plane call with this site's identity key.
|
|
273
|
+
*
|
|
274
|
+
* The same scheme the daemon uses against an upstream, because the site is
|
|
275
|
+
* in the same position: an outbound caller whose key the relay already holds
|
|
276
|
+
* for other reasons. Nothing else authenticates this plane — a relay that
|
|
277
|
+
* took the `siteId` in a body at face value would let anyone enqueue work in
|
|
278
|
+
* a site's name and read who claimed it.
|
|
279
|
+
*/
|
|
280
|
+
#headers(endpoint, rawBody) {
|
|
281
|
+
const signature = signSiteRequest(this.#siteKeys, {
|
|
282
|
+
endpoint,
|
|
283
|
+
siteId: this.#options.siteId,
|
|
284
|
+
issuedAt: this.#now(),
|
|
285
|
+
body: rawBody
|
|
286
|
+
});
|
|
287
|
+
return {
|
|
288
|
+
"x-byollm-site": this.#options.siteId,
|
|
289
|
+
"x-byollm-issued-at": String(signature.issuedAt),
|
|
290
|
+
"x-byollm-signature": signature.signature
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* A relay answer, checked before it is believed — alpha.31.
|
|
295
|
+
*
|
|
296
|
+
* The bug this closes is one line long and its shape is general: a response
|
|
297
|
+
* body used without looking at the status. The daemon's client has always
|
|
298
|
+
* done this properly (`client.ts` maps every status to a typed refusal); the
|
|
299
|
+
* site's lane parsed JSON and hoped.
|
|
300
|
+
*
|
|
301
|
+
* Two classes, because they need opposite handling. **Retryable** — 503 from
|
|
302
|
+
* a draining pod, 429, 5xx, and the protocol's own `not-ready` — means the
|
|
303
|
+
* work is still there and this cycle should end quietly. **Refused** — a bad
|
|
304
|
+
* signature, an unknown site, a version this relay does not speak — will
|
|
305
|
+
* still be true in five seconds, and swallowing it would leave a site
|
|
306
|
+
* silently disconnected from its own users.
|
|
307
|
+
*/
|
|
308
|
+
async #answer(response, endpoint) {
|
|
309
|
+
if (response.ok) return response.json();
|
|
310
|
+
let code = "";
|
|
311
|
+
let message;
|
|
312
|
+
try {
|
|
313
|
+
const body = await response.json();
|
|
314
|
+
code = body.error ?? "";
|
|
315
|
+
message = body.message ?? "";
|
|
316
|
+
} catch {
|
|
317
|
+
message = `HTTP ${String(response.status)}`;
|
|
318
|
+
}
|
|
319
|
+
const retryable = response.status >= 500 || response.status === 429 || code === "not-ready" || code === "server-error";
|
|
320
|
+
throw new RelayUnavailable(
|
|
321
|
+
`${endpoint}: ${code || "refused"} \u2014 ${message}`,
|
|
322
|
+
retryable,
|
|
323
|
+
code
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
async #post(endpoint, body) {
|
|
327
|
+
const rawBody = JSON.stringify({
|
|
328
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
329
|
+
...body
|
|
330
|
+
});
|
|
331
|
+
const response = await this.#fetch(
|
|
332
|
+
`${this.#options.relayOrigin}/relay/site/${endpoint}`,
|
|
333
|
+
{
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers: {
|
|
336
|
+
"content-type": "application/json",
|
|
337
|
+
...this.#headers(endpoint, rawBody)
|
|
338
|
+
},
|
|
339
|
+
body: rawBody
|
|
340
|
+
}
|
|
341
|
+
);
|
|
342
|
+
return this.#answer(response, endpoint);
|
|
343
|
+
}
|
|
344
|
+
async #get(endpoint) {
|
|
345
|
+
const url = `${this.#options.relayOrigin}/relay/site/${endpoint}?siteId=${encodeURIComponent(this.#options.siteId)}&protocolVersion=${encodeURIComponent(PROTOCOL_VERSION)}`;
|
|
346
|
+
const response = await this.#fetch(url, {
|
|
347
|
+
headers: this.#headers(endpoint, "")
|
|
348
|
+
});
|
|
349
|
+
return this.#answer(response, endpoint);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// src/app.ts
|
|
26
354
|
var DEFAULT_LIVENESS_MS = 35e3;
|
|
27
355
|
var ByollmApp = class {
|
|
28
356
|
#store;
|
|
357
|
+
#siteKeys;
|
|
29
358
|
#now;
|
|
30
359
|
#livenessMs;
|
|
31
360
|
#delivery;
|
|
361
|
+
/** Present only in the cloud lane; the site's side of the relay. */
|
|
362
|
+
cloud;
|
|
32
363
|
constructor(options) {
|
|
33
364
|
this.#store = options.store;
|
|
365
|
+
this.#siteKeys = options.siteKeys;
|
|
34
366
|
this.#now = options.now ?? Date.now;
|
|
35
367
|
this.#livenessMs = options.livenessMs ?? DEFAULT_LIVENESS_MS;
|
|
368
|
+
this.cloud = options.lane === void 0 ? void 0 : new CloudLane({
|
|
369
|
+
options: options.lane,
|
|
370
|
+
store: options.store,
|
|
371
|
+
siteKeys: options.siteKeys,
|
|
372
|
+
now: this.#now
|
|
373
|
+
});
|
|
36
374
|
const deps = {
|
|
37
375
|
...options.noRunnerGraceMs === void 0 ? {} : { graceMs: options.noRunnerGraceMs },
|
|
38
376
|
read: (jobId) => this.result(jobId),
|
|
@@ -66,7 +404,45 @@ var ByollmApp = class {
|
|
|
66
404
|
* the app is obliged to disclose that to whoever reads it.
|
|
67
405
|
*/
|
|
68
406
|
async enqueue(input) {
|
|
69
|
-
const
|
|
407
|
+
const parsed = KindedPayload.safeParse({
|
|
408
|
+
kind: input.kind,
|
|
409
|
+
payload: input.payload
|
|
410
|
+
});
|
|
411
|
+
if (!parsed.success) {
|
|
412
|
+
const detail = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
413
|
+
throw new Error(`invalid ${input.kind} payload \u2014 ${detail}`);
|
|
414
|
+
}
|
|
415
|
+
const createdAt = this.#now();
|
|
416
|
+
const envelopeDeadlineAt = createdAt + ENVELOPE_MAX_AGE_MS;
|
|
417
|
+
const jobId = input.id ?? generateJobId();
|
|
418
|
+
const senderKeyId = keyId2(publicIdentityOf2(this.#siteKeys).identity);
|
|
419
|
+
const envelope = await seal({
|
|
420
|
+
plaintext: JSON.stringify(parsed.data.payload),
|
|
421
|
+
senderKeys: this.#siteKeys,
|
|
422
|
+
recipientEncryptionPublic: this.#siteKeys.encryptionPublic,
|
|
423
|
+
context: {
|
|
424
|
+
jobId,
|
|
425
|
+
senderKeyId,
|
|
426
|
+
recipientKeyId: senderKeyId,
|
|
427
|
+
deadlineAt: envelopeDeadlineAt,
|
|
428
|
+
direction: "payload"
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
const record = await this.#store.create(
|
|
432
|
+
{
|
|
433
|
+
...input,
|
|
434
|
+
id: jobId,
|
|
435
|
+
envelope,
|
|
436
|
+
sizeClass: sizeClassOf(
|
|
437
|
+
payloadTextLength({
|
|
438
|
+
kind: input.kind,
|
|
439
|
+
payload: parsed.data.payload
|
|
440
|
+
})
|
|
441
|
+
)
|
|
442
|
+
},
|
|
443
|
+
createdAt
|
|
444
|
+
);
|
|
445
|
+
await this.cloud?.publish(record);
|
|
70
446
|
return {
|
|
71
447
|
id: record.id,
|
|
72
448
|
record,
|
|
@@ -87,7 +463,7 @@ var ByollmApp = class {
|
|
|
87
463
|
* Check `provenance.untrusted` before rendering. It is true for every
|
|
88
464
|
* `named`/`public` job, because that text came from someone else's machine
|
|
89
465
|
* and the app must not present it as its own AI's answer
|
|
90
|
-
* ({@link MUSTS.
|
|
466
|
+
* ({@link MUSTS.PROVENANCE_NAMES_DEVICE}).
|
|
91
467
|
*/
|
|
92
468
|
async result(jobId) {
|
|
93
469
|
const job = await this.job(jobId);
|
|
@@ -101,7 +477,11 @@ var ByollmApp = class {
|
|
|
101
477
|
}
|
|
102
478
|
/** Ask a runner to stop. Queued jobs cancel at once; held jobs at the next heartbeat. */
|
|
103
479
|
async cancel(jobId) {
|
|
104
|
-
|
|
480
|
+
const cancelled = await this.#store.cancel(jobId, this.#now());
|
|
481
|
+
if (cancelled && this.cloud) {
|
|
482
|
+
await this.cloud.cancel(jobId).catch(() => void 0);
|
|
483
|
+
}
|
|
484
|
+
return cancelled;
|
|
105
485
|
}
|
|
106
486
|
/**
|
|
107
487
|
* Is there a live runner that could take a job of this shape?
|
|
@@ -179,13 +559,10 @@ var ByollmApp = class {
|
|
|
179
559
|
* ({@link MUSTS.PAIR_ONE_USER}, {@link MUSTS.PAIR_INTERACTIVE}).
|
|
180
560
|
*/
|
|
181
561
|
async approvePairing(args) {
|
|
182
|
-
const token = generateRunnerToken();
|
|
183
562
|
return this.#store.approvePairing({
|
|
184
563
|
userCode: normalizeUserCode(args.userCode),
|
|
185
564
|
owner: args.owner,
|
|
186
565
|
runnerId: generateRunnerId(),
|
|
187
|
-
runnerToken: token,
|
|
188
|
-
tokenHash: hashSecret(token),
|
|
189
566
|
now: this.#now()
|
|
190
567
|
});
|
|
191
568
|
}
|
|
@@ -229,6 +606,45 @@ function normalizeUserCode(input) {
|
|
|
229
606
|
return bare.length === 8 ? `${bare.slice(0, 4)}-${bare.slice(4)}` : bare;
|
|
230
607
|
}
|
|
231
608
|
|
|
609
|
+
// src/keys.ts
|
|
610
|
+
import { StoredKeys, generateKeys, publicIdentityOf as publicIdentityOf3 } from "@byollm/protocol";
|
|
611
|
+
import { fingerprint } from "@byollm/protocol";
|
|
612
|
+
var generateSiteKeys = (now = Date.now()) => generateKeys(now);
|
|
613
|
+
function siteKeysFromEnv(variable = "BYOLLM_SITE_KEYS", env = process.env) {
|
|
614
|
+
const raw = env[variable];
|
|
615
|
+
if (raw === void 0 || raw === "") {
|
|
616
|
+
throw new Error(
|
|
617
|
+
`${variable} is not set. Generate a site identity once with \`npx @byollm/server keygen\` and set it as ${variable}. Do not generate keys at startup: every instance would get a different identity and daemons would pin one and be refused by another.`
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
let parsed;
|
|
621
|
+
try {
|
|
622
|
+
parsed = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
|
|
623
|
+
} catch {
|
|
624
|
+
throw new Error(
|
|
625
|
+
`${variable} is not base64-encoded JSON. It should be exactly what \`npx @byollm/server keygen\` printed.`
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
const result = StoredKeys.safeParse(parsed);
|
|
629
|
+
if (!result.success) {
|
|
630
|
+
throw new Error(
|
|
631
|
+
`${variable} does not contain a valid site identity. Regenerate it with \`npx @byollm/server keygen\` \u2014 and if this site has already paired daemons, they will need to pair again.`
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
return result.data;
|
|
635
|
+
}
|
|
636
|
+
function formatSiteKeys(keys) {
|
|
637
|
+
const encoded = Buffer.from(JSON.stringify(keys)).toString("base64");
|
|
638
|
+
return `# Set this as BYOLLM_SITE_KEYS. It is a secret: it is this site's
|
|
639
|
+
# identity, and anything holding it can be this site.
|
|
640
|
+
BYOLLM_SITE_KEYS=${encoded}
|
|
641
|
+
|
|
642
|
+
# Fingerprint (not secret \u2014 show it to users so they can check what
|
|
643
|
+
# their daemon pinned):
|
|
644
|
+
# ${fingerprint(publicIdentityOf3(keys).identity)}
|
|
645
|
+
`;
|
|
646
|
+
}
|
|
647
|
+
|
|
232
648
|
// src/memory.ts
|
|
233
649
|
import {
|
|
234
650
|
backendDescriptor as backendDescriptor2,
|
|
@@ -247,7 +663,7 @@ var MemoryStore = class {
|
|
|
247
663
|
}
|
|
248
664
|
// -- jobs ---------------------------------------------------------------
|
|
249
665
|
create(input, now) {
|
|
250
|
-
const id = input.id
|
|
666
|
+
const id = input.id;
|
|
251
667
|
const existing = this.#jobs.get(id);
|
|
252
668
|
if (existing) return Promise.resolve(existing);
|
|
253
669
|
const dependsOn = [...input.dependsOn ?? []];
|
|
@@ -257,13 +673,15 @@ var MemoryStore = class {
|
|
|
257
673
|
const job = {
|
|
258
674
|
id,
|
|
259
675
|
kind: input.kind,
|
|
260
|
-
|
|
676
|
+
envelope: input.envelope,
|
|
677
|
+
sizeClass: input.sizeClass,
|
|
261
678
|
audience: input.audience ?? "self",
|
|
262
679
|
owner: input.owner,
|
|
263
680
|
audienceAllow: input.audienceAllow ? [...input.audienceAllow] : void 0,
|
|
264
681
|
dependsOn,
|
|
265
682
|
state: "queued",
|
|
266
683
|
lease: null,
|
|
684
|
+
completedByLeaseId: null,
|
|
267
685
|
createdAt: now,
|
|
268
686
|
// The TTL clock starts here only if nothing blocks the job.
|
|
269
687
|
claimableAt: blocked ? null : now,
|
|
@@ -275,7 +693,7 @@ var MemoryStore = class {
|
|
|
275
693
|
provenance: null,
|
|
276
694
|
updatedAt: now
|
|
277
695
|
};
|
|
278
|
-
this.#
|
|
696
|
+
this.#write(id, job);
|
|
279
697
|
return Promise.resolve(job);
|
|
280
698
|
}
|
|
281
699
|
get(jobId) {
|
|
@@ -294,13 +712,16 @@ var MemoryStore = class {
|
|
|
294
712
|
...job,
|
|
295
713
|
state: "claimed",
|
|
296
714
|
lease: {
|
|
715
|
+
// A fresh id per grant. Two claims of the same job by the same
|
|
716
|
+
// runner are two different leases, and must be distinguishable.
|
|
717
|
+
id: generateLeaseId(),
|
|
297
718
|
runnerId: args.runnerId,
|
|
298
719
|
expiresAt: args.now + args.leaseMs
|
|
299
720
|
},
|
|
300
721
|
attempts: job.attempts + 1,
|
|
301
722
|
updatedAt: args.now
|
|
302
723
|
};
|
|
303
|
-
this.#
|
|
724
|
+
this.#write(job.id, updated);
|
|
304
725
|
claimed.push(updated);
|
|
305
726
|
}
|
|
306
727
|
return Promise.resolve(claimed);
|
|
@@ -347,37 +768,67 @@ var MemoryStore = class {
|
|
|
347
768
|
this.#expireDueSync(args.now);
|
|
348
769
|
const renewed = [];
|
|
349
770
|
const lost = [];
|
|
350
|
-
for (const jobId of args.
|
|
771
|
+
for (const { jobId, leaseId } of args.leases) {
|
|
351
772
|
const job = this.#jobs.get(jobId);
|
|
352
|
-
if (!job || job.lease?.runnerId !== args.runnerId) {
|
|
353
|
-
lost.push(jobId);
|
|
773
|
+
if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
|
|
774
|
+
lost.push({ jobId, leaseId });
|
|
354
775
|
continue;
|
|
355
776
|
}
|
|
356
777
|
if (job.state !== "claimed" && job.state !== "running") {
|
|
357
|
-
lost.push(jobId);
|
|
778
|
+
lost.push({ jobId, leaseId });
|
|
358
779
|
continue;
|
|
359
780
|
}
|
|
360
781
|
const expiresAt = args.now + args.leaseMs;
|
|
361
|
-
this.#
|
|
782
|
+
this.#write(jobId, {
|
|
362
783
|
...job,
|
|
363
784
|
state: "running",
|
|
364
|
-
|
|
785
|
+
// Renewal extends the existing grant; it does not mint a new one.
|
|
786
|
+
lease: { ...job.lease, expiresAt },
|
|
365
787
|
updatedAt: args.now
|
|
366
788
|
});
|
|
367
789
|
renewed.push({ jobId, expiresAt });
|
|
368
790
|
}
|
|
369
791
|
return Promise.resolve({ renewed, lost });
|
|
370
792
|
}
|
|
793
|
+
adopt(args) {
|
|
794
|
+
const job = this.#jobs.get(args.jobId);
|
|
795
|
+
if (!job) return Promise.resolve(null);
|
|
796
|
+
if (job.state !== "queued" && job.state !== "claimed") {
|
|
797
|
+
return Promise.resolve(null);
|
|
798
|
+
}
|
|
799
|
+
if (job.lease && job.lease.id !== args.leaseId) {
|
|
800
|
+
return Promise.resolve(null);
|
|
801
|
+
}
|
|
802
|
+
const updated = {
|
|
803
|
+
...job,
|
|
804
|
+
state: "claimed",
|
|
805
|
+
lease: {
|
|
806
|
+
id: args.leaseId,
|
|
807
|
+
// No runner: this site never paired with the machine holding it.
|
|
808
|
+
runnerId: "",
|
|
809
|
+
expiresAt: args.expiresAt
|
|
810
|
+
},
|
|
811
|
+
updatedAt: args.now
|
|
812
|
+
};
|
|
813
|
+
this.#write(updated.id, updated);
|
|
814
|
+
return Promise.resolve(updated);
|
|
815
|
+
}
|
|
371
816
|
complete(args) {
|
|
372
817
|
const job = this.#jobs.get(args.jobId);
|
|
373
818
|
if (!job) return Promise.resolve({ accepted: false, job: null });
|
|
374
819
|
if (job.state === "ok" || job.state === "error" || job.state === "canceled") {
|
|
820
|
+
const sameDevice = job.provenance?.runnerId !== void 0 && job.provenance.runnerId === args.runnerId;
|
|
821
|
+
const sameGrant = args.holder.by === "lease" && job.completedByLeaseId !== null && job.completedByLeaseId === args.holder.leaseId;
|
|
822
|
+
if (sameDevice && sameGrant) {
|
|
823
|
+
return Promise.resolve({ accepted: false, duplicate: true, job });
|
|
824
|
+
}
|
|
375
825
|
return Promise.resolve({ accepted: false, job });
|
|
376
826
|
}
|
|
377
827
|
if (job.state === "expired") {
|
|
378
828
|
return Promise.resolve({ accepted: false, job });
|
|
379
829
|
}
|
|
380
|
-
|
|
830
|
+
const holds = args.holder.by === "runner" ? job.lease?.runnerId === args.holder.runnerId : job.lease?.id === args.holder.leaseId;
|
|
831
|
+
if (!holds) {
|
|
381
832
|
return Promise.resolve({ accepted: false, job });
|
|
382
833
|
}
|
|
383
834
|
const state = args.outcome.outcome === "ok" ? "ok" : args.outcome.outcome === "canceled" ? "canceled" : "error";
|
|
@@ -385,11 +836,13 @@ var MemoryStore = class {
|
|
|
385
836
|
...job,
|
|
386
837
|
state,
|
|
387
838
|
lease: null,
|
|
839
|
+
// The grant that recorded it, kept after the lease is dropped — §3.6.
|
|
840
|
+
completedByLeaseId: args.holder.by === "lease" ? args.holder.leaseId : job.lease?.id ?? null,
|
|
388
841
|
outcome: args.outcome,
|
|
389
842
|
provenance: args.provenance,
|
|
390
843
|
updatedAt: args.now
|
|
391
844
|
};
|
|
392
|
-
this.#
|
|
845
|
+
this.#write(job.id, updated);
|
|
393
846
|
this.#cancelRequests.delete(job.id);
|
|
394
847
|
if (state === "ok") this.#unblockDependents(job.id, args.now);
|
|
395
848
|
return Promise.resolve({ accepted: true, job: updated });
|
|
@@ -410,19 +863,70 @@ var MemoryStore = class {
|
|
|
410
863
|
(depId) => this.#jobs.get(depId)?.state === "ok"
|
|
411
864
|
);
|
|
412
865
|
if (ready) {
|
|
413
|
-
this.#
|
|
866
|
+
this.#write(job.id, { ...job, claimableAt: now, updatedAt: now });
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Watchers, by job id (byollm_009 §8.3).
|
|
872
|
+
*
|
|
873
|
+
* A `Set` per job so an unsubscribe removes exactly the handler it
|
|
874
|
+
* registered — two waiters on the same job are ordinary, and removing by
|
|
875
|
+
* job id alone would silently cancel someone else's wait.
|
|
876
|
+
*/
|
|
877
|
+
#watchers = /* @__PURE__ */ new Map();
|
|
878
|
+
subscribe(jobId, onChange) {
|
|
879
|
+
const existing = this.#watchers.get(jobId) ?? /* @__PURE__ */ new Set();
|
|
880
|
+
existing.add(onChange);
|
|
881
|
+
this.#watchers.set(jobId, existing);
|
|
882
|
+
let live = true;
|
|
883
|
+
return () => {
|
|
884
|
+
if (!live) return;
|
|
885
|
+
live = false;
|
|
886
|
+
const set = this.#watchers.get(jobId);
|
|
887
|
+
set?.delete(onChange);
|
|
888
|
+
if (set?.size === 0) this.#watchers.delete(jobId);
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* The single write path for a job.
|
|
893
|
+
*
|
|
894
|
+
* Every mutation goes through here so notification cannot be forgotten by
|
|
895
|
+
* a future one. Nine call sites existed when the push seam was added, and
|
|
896
|
+
* "remember to notify" is not a property nine call sites keep.
|
|
897
|
+
*/
|
|
898
|
+
#write(jobId, record) {
|
|
899
|
+
this.#jobs.set(jobId, record);
|
|
900
|
+
this.#notify(jobId);
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Tell anyone watching that a job changed.
|
|
904
|
+
*
|
|
905
|
+
* A throwing watcher must not corrupt the store's own bookkeeping, so each
|
|
906
|
+
* is isolated: this runs inside write paths, and one bad listener taking
|
|
907
|
+
* out an unrelated write would be a far worse failure than a missed
|
|
908
|
+
* notification.
|
|
909
|
+
*/
|
|
910
|
+
#notify(jobId) {
|
|
911
|
+
for (const watcher of this.#watchers.get(jobId) ?? []) {
|
|
912
|
+
try {
|
|
913
|
+
watcher();
|
|
914
|
+
} catch {
|
|
414
915
|
}
|
|
415
916
|
}
|
|
416
917
|
}
|
|
417
918
|
release(args) {
|
|
418
919
|
const released = [];
|
|
419
|
-
for (const jobId of args.
|
|
920
|
+
for (const { jobId, leaseId } of args.leases) {
|
|
420
921
|
const job = this.#jobs.get(jobId);
|
|
421
|
-
if (!job || job.lease?.runnerId !== args.runnerId)
|
|
422
|
-
|
|
922
|
+
if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
this.#write(jobId, {
|
|
423
926
|
...job,
|
|
424
927
|
state: "queued",
|
|
425
928
|
lease: null,
|
|
929
|
+
completedByLeaseId: null,
|
|
426
930
|
// Newly available again, so the TTL clock restarts here too.
|
|
427
931
|
claimableAt: args.now,
|
|
428
932
|
// A refusal is remembered, or the pair spins between claim and
|
|
@@ -452,6 +956,7 @@ var MemoryStore = class {
|
|
|
452
956
|
...job,
|
|
453
957
|
state: "queued",
|
|
454
958
|
lease: null,
|
|
959
|
+
completedByLeaseId: null,
|
|
455
960
|
// The TTL clock restarts: it measures how long a job has waited
|
|
456
961
|
// *unclaimed*, and this job has just become available again. Without
|
|
457
962
|
// this, a job whose runner died would expire for time it spent being
|
|
@@ -461,7 +966,7 @@ var MemoryStore = class {
|
|
|
461
966
|
claimableAt: now,
|
|
462
967
|
updatedAt: now
|
|
463
968
|
};
|
|
464
|
-
this.#
|
|
969
|
+
this.#write(job.id, requeued);
|
|
465
970
|
changed.push(requeued);
|
|
466
971
|
}
|
|
467
972
|
}
|
|
@@ -474,9 +979,10 @@ var MemoryStore = class {
|
|
|
474
979
|
...job,
|
|
475
980
|
state: "expired",
|
|
476
981
|
lease: null,
|
|
982
|
+
completedByLeaseId: null,
|
|
477
983
|
updatedAt: now
|
|
478
984
|
};
|
|
479
|
-
this.#
|
|
985
|
+
this.#write(job.id, expired);
|
|
480
986
|
changed.push(expired);
|
|
481
987
|
}
|
|
482
988
|
return changed;
|
|
@@ -489,9 +995,10 @@ var MemoryStore = class {
|
|
|
489
995
|
...job,
|
|
490
996
|
state: "canceled",
|
|
491
997
|
lease: null,
|
|
998
|
+
completedByLeaseId: null,
|
|
492
999
|
updatedAt: now
|
|
493
1000
|
};
|
|
494
|
-
this.#
|
|
1001
|
+
this.#write(jobId, canceled);
|
|
495
1002
|
return Promise.resolve(canceled);
|
|
496
1003
|
}
|
|
497
1004
|
if (job.state === "claimed" || job.state === "running") {
|
|
@@ -509,9 +1016,7 @@ var MemoryStore = class {
|
|
|
509
1016
|
}
|
|
510
1017
|
listCancelRequests(runnerId) {
|
|
511
1018
|
return Promise.resolve(
|
|
512
|
-
[...this.#cancelRequests].filter(
|
|
513
|
-
(jobId) => this.#jobs.get(jobId)?.lease?.runnerId === runnerId
|
|
514
|
-
)
|
|
1019
|
+
[...this.#cancelRequests].map((jobId) => ({ jobId, lease: this.#jobs.get(jobId)?.lease })).filter((row) => row.lease?.runnerId === runnerId).map((row) => ({ jobId: row.jobId, leaseId: row.lease?.id ?? "" }))
|
|
515
1020
|
);
|
|
516
1021
|
}
|
|
517
1022
|
// -- pairing and runners -------------------------------------------------
|
|
@@ -542,7 +1047,9 @@ var MemoryStore = class {
|
|
|
542
1047
|
const runner = {
|
|
543
1048
|
id: args.runnerId,
|
|
544
1049
|
owner: args.owner,
|
|
545
|
-
|
|
1050
|
+
// Carried from the pairing, not re-supplied at approval: the user
|
|
1051
|
+
// approved a specific machine, and the runner must be that machine.
|
|
1052
|
+
device: pairing.device,
|
|
546
1053
|
label: pairing.label,
|
|
547
1054
|
platform: pairing.platform,
|
|
548
1055
|
daemonVersion: pairing.daemonVersion,
|
|
@@ -558,7 +1065,7 @@ var MemoryStore = class {
|
|
|
558
1065
|
state: "approved",
|
|
559
1066
|
owner: args.owner,
|
|
560
1067
|
runnerId: runner.id,
|
|
561
|
-
|
|
1068
|
+
collected: false
|
|
562
1069
|
});
|
|
563
1070
|
return Promise.resolve(runner);
|
|
564
1071
|
}
|
|
@@ -579,17 +1086,11 @@ var MemoryStore = class {
|
|
|
579
1086
|
if (pairing) {
|
|
580
1087
|
this.#pairings.set(deviceCodeHash, {
|
|
581
1088
|
...pairing,
|
|
582
|
-
|
|
1089
|
+
collected: true
|
|
583
1090
|
});
|
|
584
1091
|
}
|
|
585
1092
|
return Promise.resolve();
|
|
586
1093
|
}
|
|
587
|
-
getRunnerByTokenHash(hash) {
|
|
588
|
-
for (const runner of this.#runners.values()) {
|
|
589
|
-
if (runner.tokenHash === hash) return Promise.resolve(runner);
|
|
590
|
-
}
|
|
591
|
-
return Promise.resolve(null);
|
|
592
|
-
}
|
|
593
1094
|
getRunner(runnerId) {
|
|
594
1095
|
return Promise.resolve(this.#runners.get(runnerId) ?? null);
|
|
595
1096
|
}
|
|
@@ -631,22 +1132,26 @@ function capabilityFor(capabilities, kind) {
|
|
|
631
1132
|
export {
|
|
632
1133
|
ByollmApp,
|
|
633
1134
|
ByollmHandlers,
|
|
1135
|
+
CloudLane,
|
|
634
1136
|
MemoryStore,
|
|
635
1137
|
NoRunnerAvailableError,
|
|
636
1138
|
PollingDelivery,
|
|
1139
|
+
RelayUnavailable,
|
|
637
1140
|
ResultTimeoutError,
|
|
638
1141
|
SERVED_PROTOCOL_VERSION,
|
|
639
|
-
bearerFrom,
|
|
640
1142
|
capabilityFor,
|
|
641
1143
|
createFetchHandler,
|
|
1144
|
+
formatSiteKeys,
|
|
642
1145
|
generateDeviceCode,
|
|
643
1146
|
generateJobId,
|
|
644
1147
|
generateRunnerId,
|
|
645
|
-
|
|
1148
|
+
generateSiteKeys,
|
|
646
1149
|
generateUserCode,
|
|
647
1150
|
hashSecret,
|
|
648
1151
|
normalizeUserCode,
|
|
649
1152
|
routeEndpoint,
|
|
650
|
-
secretsMatch
|
|
1153
|
+
secretsMatch,
|
|
1154
|
+
signatureFrom,
|
|
1155
|
+
siteKeysFromEnv
|
|
651
1156
|
};
|
|
652
1157
|
//# sourceMappingURL=index.js.map
|