@byollm/server 0.1.0-alpha.7 → 0.1.0-alpha.70
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 +166 -9
- package/dist/{chunk-4NIHWQAT.js → chunk-DG6XZQU3.js} +76 -39
- package/dist/chunk-DG6XZQU3.js.map +1 -0
- package/dist/{chunk-7RKXFPBZ.js → chunk-I3ER27QG.js} +19 -5
- package/dist/chunk-I3ER27QG.js.map +1 -0
- package/dist/{delivery-36nIe-b3.d.ts → delivery-CaGbp0Tc.d.ts} +35 -5
- package/dist/{handlers-DgW0QNTf.d.ts → handlers-CTV3Jc6Q.d.ts} +2 -2
- package/dist/index.d.ts +92 -24
- package/dist/index.js +359 -109
- package/dist/index.js.map +1 -1
- package/dist/next.d.ts +2 -2
- package/dist/next.js +1 -1
- package/dist/{store-Cj5b6A9j.d.ts → store-Cx2_bck1.d.ts} +130 -18
- package/dist/supabase/index.d.ts +2 -2
- package/dist/supabase/index.js +62 -29
- package/dist/supabase/index.js.map +1 -1
- package/package.json +2 -2
- 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/supabase/migrations/20260824000000_one_vocabulary.sql +109 -0
- package/supabase/migrations/20260825000000_job_service.sql +20 -0
- package/supabase/migrations/20260827000000_job_purpose.sql +36 -0
- package/dist/chunk-4NIHWQAT.js.map +0 -1
- package/dist/chunk-7RKXFPBZ.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -2,23 +2,23 @@ import {
|
|
|
2
2
|
ByollmHandlers,
|
|
3
3
|
SERVED_PROTOCOL_VERSION,
|
|
4
4
|
createFetchHandler,
|
|
5
|
+
deadlineFor,
|
|
5
6
|
generateDeviceCode,
|
|
6
7
|
generateJobId,
|
|
7
8
|
generateLeaseId,
|
|
8
9
|
generateRunnerId,
|
|
9
|
-
generateRunnerToken,
|
|
10
10
|
generateUserCode,
|
|
11
11
|
hashSecret,
|
|
12
12
|
resealForDevice,
|
|
13
13
|
routeEndpoint,
|
|
14
14
|
secretsMatch,
|
|
15
15
|
signatureFrom
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-DG6XZQU3.js";
|
|
17
17
|
import {
|
|
18
18
|
NoRunnerAvailableError,
|
|
19
19
|
PollingDelivery,
|
|
20
20
|
ResultTimeoutError
|
|
21
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-I3ER27QG.js";
|
|
22
22
|
|
|
23
23
|
// src/app.ts
|
|
24
24
|
import {
|
|
@@ -35,12 +35,36 @@ import {
|
|
|
35
35
|
|
|
36
36
|
// src/cloud.ts
|
|
37
37
|
import {
|
|
38
|
-
|
|
38
|
+
PROTOCOL_VERSION,
|
|
39
|
+
SealedOutcome,
|
|
39
40
|
keyId,
|
|
40
41
|
open,
|
|
41
42
|
publicIdentityOf,
|
|
42
|
-
provenanceFor
|
|
43
|
+
provenanceFor,
|
|
44
|
+
signSiteRequest
|
|
43
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 EnqueueRefused = class extends Error {
|
|
58
|
+
/** `purpose-not-declared` or `slot-unsatisfiable`. */
|
|
59
|
+
code;
|
|
60
|
+
constructor(message, code) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = "EnqueueRefused";
|
|
63
|
+
this.code = code;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
var RETRYABLE_AT_ENQUEUE = /* @__PURE__ */ new Set(["not-ready"]);
|
|
67
|
+
var ENQUEUE_ENDPOINT = "enqueue";
|
|
44
68
|
var CloudLane = class {
|
|
45
69
|
#options;
|
|
46
70
|
#store;
|
|
@@ -66,20 +90,64 @@ var CloudLane = class {
|
|
|
66
90
|
id: record.id,
|
|
67
91
|
kind: record.kind,
|
|
68
92
|
owner: record.owner,
|
|
93
|
+
// This site, by its identity key id — Amendment A §A.3. The relay
|
|
94
|
+
// already knows which site it is routing for, so this discloses nothing
|
|
95
|
+
// new to it; what it adds is that the *daemon* can check the stub
|
|
96
|
+
// against the envelope's `senderKeyId` without asking the relay.
|
|
97
|
+
site: keyId(publicIdentityOf(this.#siteKeys).identity),
|
|
69
98
|
audience: record.audience,
|
|
70
|
-
|
|
99
|
+
// `audienceAllow` is deliberately **not** published — cloud_008 §0.2.
|
|
100
|
+
//
|
|
101
|
+
// It is a list of the people who may run this job, and on the direct
|
|
102
|
+
// plane that is unremarkable: the site authored the list and the site is
|
|
103
|
+
// the upstream, so the party receiving it already has it. Through a
|
|
104
|
+
// relay it is a third party, and byollm_009 §6's enumerated metadata —
|
|
105
|
+
// "exhaustive and normative… what an upstream can see, stated as a
|
|
106
|
+
// commitment" — does not include it. It was reaching the relay on every
|
|
107
|
+
// named-audience job.
|
|
108
|
+
//
|
|
109
|
+
// Nothing is lost by withholding it, which is why this is a Tier 0 fix
|
|
110
|
+
// rather than a trade. `matchAudience` treats it as a *narrowing*:
|
|
111
|
+
// `job.audienceAllow !== undefined && !includes(daemon.owner)` refuses,
|
|
112
|
+
// and its absence simply falls through to the checks that actually
|
|
113
|
+
// enforce — the daemon's own allowlist (`NAMED_LOCAL_ALLOWLIST`) and the
|
|
114
|
+
// backend's offer scope. On this lane the relay narrows too, from the
|
|
115
|
+
// control plane's rosters. The enforcement was never here.
|
|
116
|
+
...record.purpose === void 0 ? {} : { purpose: record.purpose },
|
|
71
117
|
sizeClass: record.sizeClass,
|
|
72
118
|
streaming: false,
|
|
73
119
|
// The relay needs *a* deadline to bound routing. A job without one gets
|
|
74
120
|
// the envelope's, which is the outer bound on how long the ciphertext
|
|
75
121
|
// is worth carrying — never longer than the work could possibly matter.
|
|
76
|
-
|
|
122
|
+
// The same fallback the direct plane uses — cloud_008 Tier 4, finding
|
|
123
|
+
// 31. This said `createdAt + ENVELOPE_TTL_FALLBACK`, a local constant
|
|
124
|
+
// whose value happened to equal `ENVELOPE_MAX_AGE_MS`; the direct plane
|
|
125
|
+
// said `(claimableAt ?? now) + ttlMs`. One field, two meanings, and a
|
|
126
|
+
// job that was blocked on a dependency got a deadline measured from
|
|
127
|
+
// when it was *created* on one lane and from when it became *claimable*
|
|
128
|
+
// on the other.
|
|
129
|
+
deadlineAt: deadlineFor(record, this.#now())
|
|
77
130
|
};
|
|
78
|
-
await this.#post("
|
|
131
|
+
await this.#post("enqueue", {
|
|
79
132
|
siteId: this.#options.siteId,
|
|
80
133
|
stub
|
|
81
134
|
});
|
|
82
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Withdraw a job at the relay — cloud_008 §2.2.
|
|
138
|
+
*
|
|
139
|
+
* `app.cancel()` marks the site's own row terminal, which stops the *next*
|
|
140
|
+
* seal. It cannot stop a device that is already running the work, because
|
|
141
|
+
* on this lane the site is not the upstream: only the relay talks to the
|
|
142
|
+
* daemon, and it answered `cancel: []` unconditionally.
|
|
143
|
+
*
|
|
144
|
+
* So the cancellation has to travel. The relay marks the job, stops
|
|
145
|
+
* offering it, and names it to the holding device at its next heartbeat —
|
|
146
|
+
* the same path the direct plane has always had, arriving one hop later.
|
|
147
|
+
*/
|
|
148
|
+
async cancel(jobId) {
|
|
149
|
+
await this.#post("cancel", { siteId: this.#options.siteId, jobId });
|
|
150
|
+
}
|
|
83
151
|
/**
|
|
84
152
|
* One cycle: seal for anything claimed, collect anything finished.
|
|
85
153
|
*
|
|
@@ -92,7 +160,17 @@ var CloudLane = class {
|
|
|
92
160
|
const sealed = [];
|
|
93
161
|
const refused = [];
|
|
94
162
|
const completed = [];
|
|
95
|
-
|
|
163
|
+
try {
|
|
164
|
+
return await this.#cycle(sealed, refused, completed);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (error instanceof RelayUnavailable && error.retryable) {
|
|
167
|
+
return { sealed, completed, refused, deferred: error.message };
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async #cycle(sealed, refused, completed) {
|
|
173
|
+
const pending = await this.#get("pending");
|
|
96
174
|
for (const claim of pending.jobs) {
|
|
97
175
|
const record = await this.#store.get(claim.jobId);
|
|
98
176
|
if (!record) continue;
|
|
@@ -109,20 +187,24 @@ var CloudLane = class {
|
|
|
109
187
|
refused.push(claim.jobId);
|
|
110
188
|
continue;
|
|
111
189
|
}
|
|
112
|
-
await this.#store.adopt({
|
|
190
|
+
const adopted = await this.#store.adopt({
|
|
113
191
|
jobId: claim.jobId,
|
|
114
192
|
leaseId: claim.leaseId,
|
|
115
|
-
expiresAt: claim.
|
|
193
|
+
expiresAt: claim.leaseExpiresAt,
|
|
116
194
|
now: this.#now()
|
|
117
195
|
});
|
|
118
|
-
|
|
196
|
+
if (!adopted) {
|
|
197
|
+
refused.push(claim.jobId);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
await this.#post("payload", {
|
|
119
201
|
siteId: this.#options.siteId,
|
|
120
202
|
jobId: claim.jobId,
|
|
121
203
|
envelope: resealed.envelope
|
|
122
204
|
});
|
|
123
205
|
sealed.push(claim.jobId);
|
|
124
206
|
}
|
|
125
|
-
const finished = await this.#get("
|
|
207
|
+
const finished = await this.#get("results");
|
|
126
208
|
for (const done of finished.jobs) {
|
|
127
209
|
const record = await this.#store.get(done.jobId);
|
|
128
210
|
if (!record || record.state === "ok" || record.state === "error") {
|
|
@@ -135,17 +217,30 @@ var CloudLane = class {
|
|
|
135
217
|
}
|
|
136
218
|
await this.#store.complete({
|
|
137
219
|
jobId: done.jobId,
|
|
220
|
+
// The relay named the device; the signature above proved it — §3.6.
|
|
221
|
+
runnerId: done.runnerId,
|
|
138
222
|
// The grant, not the machine: this site never paired with the device
|
|
139
223
|
// that ran it, and the signature it verified above is the stronger
|
|
140
224
|
// claim about who did.
|
|
141
225
|
holder: { by: "lease", leaseId: done.leaseId },
|
|
142
|
-
outcome,
|
|
226
|
+
outcome: outcome.outcome,
|
|
143
227
|
provenance: provenanceFor({
|
|
144
228
|
audience: record.audience,
|
|
145
229
|
runnerId: done.runnerId,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
230
|
+
// The owner, from the relay's own record of who claimed it — not a
|
|
231
|
+
// key id. cloud_008 §2.5: this said `keyId(device.identity)`, which
|
|
232
|
+
// put a key id where the direct plane puts a user id, so an app
|
|
233
|
+
// comparing provenance across lanes compared two namespaces and got
|
|
234
|
+
// `false` for the same person. The device's key is still what the
|
|
235
|
+
// signature was verified against, above; that is a different
|
|
236
|
+
// question from whose machine it is.
|
|
237
|
+
runnerOwner: done.runnerOwner,
|
|
238
|
+
// From the envelope, not invented — cloud_008 §2.5. These were
|
|
239
|
+
// hardcoded `"http"` and `"unknown"` because the daemon's declared
|
|
240
|
+
// values stopped at the relay, which is right: a blind relay acts
|
|
241
|
+
// on neither. Sealing them carries them past it untouched.
|
|
242
|
+
backendClass: outcome.ran.backendClass,
|
|
243
|
+
model: outcome.ran.model
|
|
149
244
|
}),
|
|
150
245
|
now: this.#now()
|
|
151
246
|
});
|
|
@@ -180,29 +275,110 @@ var CloudLane = class {
|
|
|
180
275
|
} catch {
|
|
181
276
|
return null;
|
|
182
277
|
}
|
|
183
|
-
const
|
|
184
|
-
if (!
|
|
185
|
-
if (
|
|
186
|
-
return
|
|
278
|
+
const sealed = SealedOutcome.safeParse(parsed);
|
|
279
|
+
if (!sealed.success) return null;
|
|
280
|
+
if (sealed.data.outcome.outcome !== done.disposition) return null;
|
|
281
|
+
return sealed.data;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Sign a site-plane call with this site's identity key.
|
|
285
|
+
*
|
|
286
|
+
* The same scheme the daemon uses against an upstream, because the site is
|
|
287
|
+
* in the same position: an outbound caller whose key the relay already holds
|
|
288
|
+
* for other reasons. Nothing else authenticates this plane — a relay that
|
|
289
|
+
* took the `siteId` in a body at face value would let anyone enqueue work in
|
|
290
|
+
* a site's name and read who claimed it.
|
|
291
|
+
*/
|
|
292
|
+
#headers(endpoint, rawBody) {
|
|
293
|
+
const signature = signSiteRequest(this.#siteKeys, {
|
|
294
|
+
endpoint,
|
|
295
|
+
siteId: this.#options.siteId,
|
|
296
|
+
issuedAt: this.#now(),
|
|
297
|
+
body: rawBody
|
|
298
|
+
});
|
|
299
|
+
return {
|
|
300
|
+
"x-byollm-site": this.#options.siteId,
|
|
301
|
+
"x-byollm-issued-at": String(signature.issuedAt),
|
|
302
|
+
"x-byollm-signature": signature.signature
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* A relay answer, checked before it is believed — alpha.31.
|
|
307
|
+
*
|
|
308
|
+
* The bug this closes is one line long and its shape is general: a response
|
|
309
|
+
* body used without looking at the status. The daemon's client has always
|
|
310
|
+
* done this properly (`client.ts` maps every status to a typed refusal); the
|
|
311
|
+
* site's lane parsed JSON and hoped.
|
|
312
|
+
*
|
|
313
|
+
* Two classes, because they need opposite handling. **Retryable** — 503 from
|
|
314
|
+
* a draining pod, 429, 5xx, and the protocol's own `not-ready` — means the
|
|
315
|
+
* work is still there and this cycle should end quietly. **Refused** — a bad
|
|
316
|
+
* signature, an unknown site, a version this relay does not speak — will
|
|
317
|
+
* still be true in five seconds, and swallowing it would leave a site
|
|
318
|
+
* silently disconnected from its own users.
|
|
319
|
+
*/
|
|
320
|
+
async #answer(response, endpoint) {
|
|
321
|
+
if (response.ok) return response.json();
|
|
322
|
+
let code = "";
|
|
323
|
+
let message;
|
|
324
|
+
try {
|
|
325
|
+
const body = await response.json();
|
|
326
|
+
code = body.error ?? "";
|
|
327
|
+
message = body.message ?? "";
|
|
328
|
+
} catch {
|
|
329
|
+
message = `HTTP ${String(response.status)}`;
|
|
330
|
+
}
|
|
331
|
+
const retryable = response.status >= 500 || response.status === 429 || code === "not-ready" || code === "server-error";
|
|
332
|
+
if (endpoint === ENQUEUE_ENDPOINT && response.status === 409 && !RETRYABLE_AT_ENQUEUE.has(code)) {
|
|
333
|
+
throw new EnqueueRefused(message, code);
|
|
334
|
+
}
|
|
335
|
+
throw new RelayUnavailable(
|
|
336
|
+
`${endpoint}: ${code || "refused"} \u2014 ${message}`,
|
|
337
|
+
retryable,
|
|
338
|
+
code
|
|
339
|
+
);
|
|
187
340
|
}
|
|
188
|
-
async #post(
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
body: JSON.stringify(body)
|
|
341
|
+
async #post(endpoint, body) {
|
|
342
|
+
const rawBody = JSON.stringify({
|
|
343
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
344
|
+
...body
|
|
193
345
|
});
|
|
194
|
-
|
|
346
|
+
const response = await this.#fetch(
|
|
347
|
+
`${this.#options.relayOrigin}/relay/site/${endpoint}`,
|
|
348
|
+
{
|
|
349
|
+
method: "POST",
|
|
350
|
+
headers: {
|
|
351
|
+
"content-type": "application/json",
|
|
352
|
+
...this.#headers(endpoint, rawBody)
|
|
353
|
+
},
|
|
354
|
+
body: rawBody
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
return this.#answer(response, endpoint);
|
|
195
358
|
}
|
|
196
|
-
async #get(
|
|
197
|
-
const url = `${this.#options.relayOrigin}
|
|
198
|
-
const response = await this.#fetch(url
|
|
199
|
-
|
|
359
|
+
async #get(endpoint) {
|
|
360
|
+
const url = `${this.#options.relayOrigin}/relay/site/${endpoint}?siteId=${encodeURIComponent(this.#options.siteId)}&protocolVersion=${encodeURIComponent(PROTOCOL_VERSION)}`;
|
|
361
|
+
const response = await this.#fetch(url, {
|
|
362
|
+
headers: this.#headers(endpoint, "")
|
|
363
|
+
});
|
|
364
|
+
return this.#answer(response, endpoint);
|
|
200
365
|
}
|
|
201
366
|
};
|
|
202
|
-
var ENVELOPE_TTL_FALLBACK = 24 * 60 * 6e4;
|
|
203
367
|
|
|
204
368
|
// src/app.ts
|
|
205
369
|
var DEFAULT_LIVENESS_MS = 35e3;
|
|
370
|
+
var ENQUEUE_OPTIONS = Object.freeze({
|
|
371
|
+
kind: true,
|
|
372
|
+
payload: true,
|
|
373
|
+
owner: true,
|
|
374
|
+
audience: true,
|
|
375
|
+
purpose: true,
|
|
376
|
+
audienceAllow: true,
|
|
377
|
+
dependsOn: true,
|
|
378
|
+
ttlMs: true,
|
|
379
|
+
deadlineAt: true,
|
|
380
|
+
id: true
|
|
381
|
+
});
|
|
206
382
|
var ByollmApp = class {
|
|
207
383
|
#store;
|
|
208
384
|
#siteKeys;
|
|
@@ -225,28 +401,38 @@ var ByollmApp = class {
|
|
|
225
401
|
const deps = {
|
|
226
402
|
...options.noRunnerGraceMs === void 0 ? {} : { graceMs: options.noRunnerGraceMs },
|
|
227
403
|
read: (jobId) => this.result(jobId),
|
|
228
|
-
availability:
|
|
229
|
-
const job = await this.#store.get(jobId);
|
|
230
|
-
if (!job)
|
|
231
|
-
return { available: false, reason: "unknown-job", blocked: false };
|
|
232
|
-
if (job.claimableAt === null) {
|
|
233
|
-
return { available: true, blocked: true };
|
|
234
|
-
}
|
|
235
|
-
const availability = await this.runnerAvailability({
|
|
236
|
-
kind: job.kind,
|
|
237
|
-
owner: job.owner,
|
|
238
|
-
audience: job.audience,
|
|
239
|
-
...job.audienceAllow === void 0 ? {} : { audienceAllow: job.audienceAllow }
|
|
240
|
-
});
|
|
241
|
-
return {
|
|
242
|
-
available: availability.available,
|
|
243
|
-
...availability.reason === void 0 ? {} : { reason: availability.reason },
|
|
244
|
-
blocked: false
|
|
245
|
-
};
|
|
246
|
-
}
|
|
404
|
+
...this.cloud !== void 0 ? {} : { availability: this.#availabilityFor() }
|
|
247
405
|
};
|
|
248
406
|
this.#delivery = options.delivery?.(deps) ?? new PollingDelivery(deps);
|
|
249
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* The no-runner instrument, for a lane that can actually see runners.
|
|
410
|
+
*
|
|
411
|
+
* A method rather than an inline closure so the branch above reads as one
|
|
412
|
+
* decision — whether this deployment has the instrument at all — instead of
|
|
413
|
+
* a conditional wrapped around thirty lines of body.
|
|
414
|
+
*/
|
|
415
|
+
#availabilityFor() {
|
|
416
|
+
return async (jobId) => {
|
|
417
|
+
const job = await this.#store.get(jobId);
|
|
418
|
+
if (!job)
|
|
419
|
+
return { available: false, reason: "unknown-job", blocked: false };
|
|
420
|
+
if (job.claimableAt === null) {
|
|
421
|
+
return { available: true, blocked: true };
|
|
422
|
+
}
|
|
423
|
+
const availability = await this.runnerAvailability({
|
|
424
|
+
kind: job.kind,
|
|
425
|
+
owner: job.owner,
|
|
426
|
+
audience: job.audience,
|
|
427
|
+
...job.audienceAllow === void 0 ? {} : { audienceAllow: job.audienceAllow }
|
|
428
|
+
});
|
|
429
|
+
return {
|
|
430
|
+
available: availability.available,
|
|
431
|
+
...availability.reason === void 0 ? {} : { reason: availability.reason },
|
|
432
|
+
blocked: false
|
|
433
|
+
};
|
|
434
|
+
};
|
|
435
|
+
}
|
|
250
436
|
/**
|
|
251
437
|
* Enqueue a job.
|
|
252
438
|
*
|
|
@@ -255,6 +441,19 @@ var ByollmApp = class {
|
|
|
255
441
|
* the app is obliged to disclose that to whoever reads it.
|
|
256
442
|
*/
|
|
257
443
|
async enqueue(input) {
|
|
444
|
+
const unknown = Object.keys(input).filter(
|
|
445
|
+
(key) => !(key in ENQUEUE_OPTIONS)
|
|
446
|
+
);
|
|
447
|
+
if (unknown.length > 0) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
`enqueue does not understand ${unknown.map((k) => `\`${k}\``).join(", ")}. An option this @byollm/server does not know is refused rather than ignored, because an ignored option is a job that runs differently than you asked with nothing to see \u2014 most often an SDK older than the code calling it. Upgrade @byollm/server, or remove the option.`
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
if (this.cloud !== void 0 && input.audience !== void 0) {
|
|
453
|
+
throw new Error(
|
|
454
|
+
"enqueue does not take `audience` on the cloud lane. Who may serve a job is derived from the person's own mapping \u2014 the service they chose, its owner, and that owner's sharing \u2014 which your site is not told and cannot compute. Remove `audience`; ask for the kind and the purpose, and their decision does the rest."
|
|
455
|
+
);
|
|
456
|
+
}
|
|
258
457
|
const parsed = KindedPayload.safeParse({
|
|
259
458
|
kind: input.kind,
|
|
260
459
|
payload: input.payload
|
|
@@ -282,6 +481,27 @@ var ByollmApp = class {
|
|
|
282
481
|
const record = await this.#store.create(
|
|
283
482
|
{
|
|
284
483
|
...input,
|
|
484
|
+
/**
|
|
485
|
+
* Derived here, because on the cloud lane it is derivable and nowhere
|
|
486
|
+
* else knows the lane.
|
|
487
|
+
*
|
|
488
|
+
* Refusing the site's declaration is only half of "derived, never
|
|
489
|
+
* declared" — the stub still carries an audience to the relay, and a
|
|
490
|
+
* store that defaults it to `private` would keep every cloud job
|
|
491
|
+
* private no matter who was forbidden from saying so. The half that
|
|
492
|
+
* fixes anything is this one.
|
|
493
|
+
*
|
|
494
|
+
* `team` is the value that defers: it says a device whose owner
|
|
495
|
+
* admits this person may serve, and the hub then decides whether one
|
|
496
|
+
* does, from the mapping the person authored, its service's owner,
|
|
497
|
+
* that owner's offer scope, and the roster. Nothing is widened by
|
|
498
|
+
* saying it — both axes still have to agree, and the owner's scope is
|
|
499
|
+
* the other axis.
|
|
500
|
+
*
|
|
501
|
+
* Direct mode keeps the store's `private` default: there is no
|
|
502
|
+
* control plane there to derive from, and owner-only is the ruling.
|
|
503
|
+
*/
|
|
504
|
+
...this.cloud === void 0 ? {} : { audience: "team" },
|
|
285
505
|
id: jobId,
|
|
286
506
|
envelope,
|
|
287
507
|
sizeClass: sizeClassOf(
|
|
@@ -314,7 +534,7 @@ var ByollmApp = class {
|
|
|
314
534
|
* Check `provenance.untrusted` before rendering. It is true for every
|
|
315
535
|
* `named`/`public` job, because that text came from someone else's machine
|
|
316
536
|
* and the app must not present it as its own AI's answer
|
|
317
|
-
* ({@link MUSTS.
|
|
537
|
+
* ({@link MUSTS.PROVENANCE_NAMES_DEVICE}).
|
|
318
538
|
*/
|
|
319
539
|
async result(jobId) {
|
|
320
540
|
const job = await this.job(jobId);
|
|
@@ -328,7 +548,11 @@ var ByollmApp = class {
|
|
|
328
548
|
}
|
|
329
549
|
/** Ask a runner to stop. Queued jobs cancel at once; held jobs at the next heartbeat. */
|
|
330
550
|
async cancel(jobId) {
|
|
331
|
-
|
|
551
|
+
const cancelled = await this.#store.cancel(jobId, this.#now());
|
|
552
|
+
if (cancelled && this.cloud) {
|
|
553
|
+
await this.cloud.cancel(jobId).catch(() => void 0);
|
|
554
|
+
}
|
|
555
|
+
return cancelled;
|
|
332
556
|
}
|
|
333
557
|
/**
|
|
334
558
|
* Is there a live runner that could take a job of this shape?
|
|
@@ -337,6 +561,11 @@ var ByollmApp = class {
|
|
|
337
561
|
* signal cannot promise a runner the claim would then refuse.
|
|
338
562
|
*/
|
|
339
563
|
async runnerAvailability(query) {
|
|
564
|
+
if (this.cloud !== void 0) {
|
|
565
|
+
throw new Error(
|
|
566
|
+
"runnerAvailability cannot answer on the cloud lane. It counts runners this site knows about, and on the cloud lane devices pair with the relay rather than with you \u2014 so the answer would be `none` whatever the truth is. Enqueue the job: the result says whether it ran, and the person's own dashboard says why not."
|
|
567
|
+
);
|
|
568
|
+
}
|
|
340
569
|
const now = this.#now();
|
|
341
570
|
const all = await this.#store.listRunners();
|
|
342
571
|
const live = all.filter(
|
|
@@ -350,37 +579,41 @@ var ByollmApp = class {
|
|
|
350
579
|
}
|
|
351
580
|
let capable = 0;
|
|
352
581
|
let admitted = 0;
|
|
582
|
+
let lastRefusal;
|
|
353
583
|
for (const runner of live) {
|
|
354
|
-
const capability
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
584
|
+
for (const capability of runner.capabilities.filter(
|
|
585
|
+
(c) => c.kind === query.kind
|
|
586
|
+
)) {
|
|
587
|
+
capable += 1;
|
|
588
|
+
const match = matchAudience(
|
|
589
|
+
{
|
|
590
|
+
owner: query.owner,
|
|
591
|
+
audience: query.audience ?? "private",
|
|
592
|
+
audienceAllow: query.audienceAllow
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
owner: runner.owner,
|
|
596
|
+
offerScope: capability.offerScope,
|
|
597
|
+
// A generic backend's cost depends on its base URL, which the
|
|
598
|
+
// server never sees; assume the expensive reading (byollm_007 §4).
|
|
599
|
+
cost: backendDescriptor(capability.backendId).cost ?? "metered",
|
|
600
|
+
// Consent is the daemon's to hold, and it has already applied it:
|
|
601
|
+
// the offer scope arriving here is the *effective* one, so a
|
|
602
|
+
// metered backend nobody agreed to share advertises `self` and is
|
|
603
|
+
// refused by the scope rule above. Re-deriving consent from
|
|
604
|
+
// `false` here would instead refuse every backend an owner
|
|
605
|
+
// deliberately shared, because the server has no way to learn they
|
|
606
|
+
// did — the signal would be wrong in the direction that breaks
|
|
607
|
+
// working setups.
|
|
608
|
+
spend: { acknowledged: true },
|
|
609
|
+
// Same conservative assumption the claim path makes: the server
|
|
610
|
+
// cannot see a remote daemon's local allowlist (protocol §4.2).
|
|
611
|
+
admits: () => true
|
|
612
|
+
}
|
|
613
|
+
);
|
|
614
|
+
if (match.ok) admitted += 1;
|
|
615
|
+
else lastRefusal = match.refusal;
|
|
616
|
+
}
|
|
384
617
|
}
|
|
385
618
|
if (capable === 0) {
|
|
386
619
|
return {
|
|
@@ -390,9 +623,10 @@ var ByollmApp = class {
|
|
|
390
623
|
};
|
|
391
624
|
}
|
|
392
625
|
if (admitted === 0) {
|
|
626
|
+
const ownersDoing = lastRefusal === "offer-scope-too-narrow" || lastRefusal === "subscription-self-lock" || lastRefusal === "metered-no-spend-consent" || lastRefusal === "metered-ceiling-reached";
|
|
393
627
|
return {
|
|
394
628
|
available: false,
|
|
395
|
-
reason: "audience-admits-nobody",
|
|
629
|
+
reason: ownersDoing ? "default-unusable" : "audience-admits-nobody",
|
|
396
630
|
candidates: 0
|
|
397
631
|
};
|
|
398
632
|
}
|
|
@@ -406,13 +640,10 @@ var ByollmApp = class {
|
|
|
406
640
|
* ({@link MUSTS.PAIR_ONE_USER}, {@link MUSTS.PAIR_INTERACTIVE}).
|
|
407
641
|
*/
|
|
408
642
|
async approvePairing(args) {
|
|
409
|
-
const token = generateRunnerToken();
|
|
410
643
|
return this.#store.approvePairing({
|
|
411
644
|
userCode: normalizeUserCode(args.userCode),
|
|
412
645
|
owner: args.owner,
|
|
413
646
|
runnerId: generateRunnerId(),
|
|
414
|
-
runnerToken: token,
|
|
415
|
-
tokenHash: hashSecret(token),
|
|
416
647
|
now: this.#now()
|
|
417
648
|
});
|
|
418
649
|
}
|
|
@@ -485,13 +716,27 @@ function siteKeysFromEnv(variable = "BYOLLM_SITE_KEYS", env = process.env) {
|
|
|
485
716
|
}
|
|
486
717
|
function formatSiteKeys(keys) {
|
|
487
718
|
const encoded = Buffer.from(JSON.stringify(keys)).toString("base64");
|
|
488
|
-
|
|
489
|
-
|
|
719
|
+
const pub = publicIdentityOf3(keys);
|
|
720
|
+
return `# \u2500\u2500 1. SECRET \u2014 set this on your server, and nowhere else \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
721
|
+
#
|
|
722
|
+
# This is the site's identity. Anything holding it can *be* this site,
|
|
723
|
+
# so it goes wherever your deployment keeps secrets \u2014 never in a repo,
|
|
724
|
+
# never in a browser, never pasted into a dashboard.
|
|
490
725
|
BYOLLM_SITE_KEYS=${encoded}
|
|
491
726
|
|
|
492
|
-
#
|
|
493
|
-
#
|
|
494
|
-
#
|
|
727
|
+
# \u2500\u2500 2. PUBLIC \u2014 paste this line into the byollm dashboard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
728
|
+
#
|
|
729
|
+
# The public half. It proves signatures and seals nothing, so it is
|
|
730
|
+
# safe to publish \u2014 which is the point: users pin it, and the relay
|
|
731
|
+
# cannot forge work without the secret above.
|
|
732
|
+
${JSON.stringify(pub)}
|
|
733
|
+
|
|
734
|
+
# \u2500\u2500 3. Fingerprint \u2014 what a person compares by eye \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
735
|
+
#
|
|
736
|
+
# A fingerprint is not secret. Show it on your site so somebody
|
|
737
|
+
# connecting can check it against what their daemon printed.
|
|
738
|
+
# The dashboard derives this itself, so there is nothing to paste.
|
|
739
|
+
# ${fingerprint(pub.identity)}
|
|
495
740
|
`;
|
|
496
741
|
}
|
|
497
742
|
|
|
@@ -525,12 +770,14 @@ var MemoryStore = class {
|
|
|
525
770
|
kind: input.kind,
|
|
526
771
|
envelope: input.envelope,
|
|
527
772
|
sizeClass: input.sizeClass,
|
|
528
|
-
audience: input.audience ?? "
|
|
773
|
+
audience: input.audience ?? "private",
|
|
774
|
+
purpose: input.purpose,
|
|
529
775
|
owner: input.owner,
|
|
530
776
|
audienceAllow: input.audienceAllow ? [...input.audienceAllow] : void 0,
|
|
531
777
|
dependsOn,
|
|
532
778
|
state: "queued",
|
|
533
779
|
lease: null,
|
|
780
|
+
completedByLeaseId: null,
|
|
534
781
|
createdAt: now,
|
|
535
782
|
// The TTL clock starts here only if nothing blocks the job.
|
|
536
783
|
claimableAt: blocked ? null : now,
|
|
@@ -608,7 +855,7 @@ var MemoryStore = class {
|
|
|
608
855
|
// not pretend to (protocol §4.2). It admits the job here; the daemon
|
|
609
856
|
// is the enforcing side and releases with `refused` if its own list
|
|
610
857
|
// says no.
|
|
611
|
-
|
|
858
|
+
admits: () => true
|
|
612
859
|
}
|
|
613
860
|
);
|
|
614
861
|
return match.ok;
|
|
@@ -620,11 +867,11 @@ var MemoryStore = class {
|
|
|
620
867
|
for (const { jobId, leaseId } of args.leases) {
|
|
621
868
|
const job = this.#jobs.get(jobId);
|
|
622
869
|
if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
|
|
623
|
-
lost.push(jobId);
|
|
870
|
+
lost.push({ jobId, leaseId });
|
|
624
871
|
continue;
|
|
625
872
|
}
|
|
626
873
|
if (job.state !== "claimed" && job.state !== "running") {
|
|
627
|
-
lost.push(jobId);
|
|
874
|
+
lost.push({ jobId, leaseId });
|
|
628
875
|
continue;
|
|
629
876
|
}
|
|
630
877
|
const expiresAt = args.now + args.leaseMs;
|
|
@@ -666,6 +913,11 @@ var MemoryStore = class {
|
|
|
666
913
|
const job = this.#jobs.get(args.jobId);
|
|
667
914
|
if (!job) return Promise.resolve({ accepted: false, job: null });
|
|
668
915
|
if (job.state === "ok" || job.state === "error" || job.state === "canceled") {
|
|
916
|
+
const sameDevice = job.provenance?.runnerId !== void 0 && job.provenance.runnerId === args.runnerId;
|
|
917
|
+
const sameGrant = args.holder.by === "lease" && job.completedByLeaseId !== null && job.completedByLeaseId === args.holder.leaseId;
|
|
918
|
+
if (sameDevice && sameGrant) {
|
|
919
|
+
return Promise.resolve({ accepted: false, duplicate: true, job });
|
|
920
|
+
}
|
|
669
921
|
return Promise.resolve({ accepted: false, job });
|
|
670
922
|
}
|
|
671
923
|
if (job.state === "expired") {
|
|
@@ -680,6 +932,8 @@ var MemoryStore = class {
|
|
|
680
932
|
...job,
|
|
681
933
|
state,
|
|
682
934
|
lease: null,
|
|
935
|
+
// The grant that recorded it, kept after the lease is dropped — §3.6.
|
|
936
|
+
completedByLeaseId: args.holder.by === "lease" ? args.holder.leaseId : job.lease?.id ?? null,
|
|
683
937
|
outcome: args.outcome,
|
|
684
938
|
provenance: args.provenance,
|
|
685
939
|
updatedAt: args.now
|
|
@@ -768,6 +1022,7 @@ var MemoryStore = class {
|
|
|
768
1022
|
...job,
|
|
769
1023
|
state: "queued",
|
|
770
1024
|
lease: null,
|
|
1025
|
+
completedByLeaseId: null,
|
|
771
1026
|
// Newly available again, so the TTL clock restarts here too.
|
|
772
1027
|
claimableAt: args.now,
|
|
773
1028
|
// A refusal is remembered, or the pair spins between claim and
|
|
@@ -797,6 +1052,7 @@ var MemoryStore = class {
|
|
|
797
1052
|
...job,
|
|
798
1053
|
state: "queued",
|
|
799
1054
|
lease: null,
|
|
1055
|
+
completedByLeaseId: null,
|
|
800
1056
|
// The TTL clock restarts: it measures how long a job has waited
|
|
801
1057
|
// *unclaimed*, and this job has just become available again. Without
|
|
802
1058
|
// this, a job whose runner died would expire for time it spent being
|
|
@@ -819,6 +1075,7 @@ var MemoryStore = class {
|
|
|
819
1075
|
...job,
|
|
820
1076
|
state: "expired",
|
|
821
1077
|
lease: null,
|
|
1078
|
+
completedByLeaseId: null,
|
|
822
1079
|
updatedAt: now
|
|
823
1080
|
};
|
|
824
1081
|
this.#write(job.id, expired);
|
|
@@ -834,6 +1091,7 @@ var MemoryStore = class {
|
|
|
834
1091
|
...job,
|
|
835
1092
|
state: "canceled",
|
|
836
1093
|
lease: null,
|
|
1094
|
+
completedByLeaseId: null,
|
|
837
1095
|
updatedAt: now
|
|
838
1096
|
};
|
|
839
1097
|
this.#write(jobId, canceled);
|
|
@@ -854,9 +1112,7 @@ var MemoryStore = class {
|
|
|
854
1112
|
}
|
|
855
1113
|
listCancelRequests(runnerId) {
|
|
856
1114
|
return Promise.resolve(
|
|
857
|
-
[...this.#cancelRequests].filter(
|
|
858
|
-
(jobId) => this.#jobs.get(jobId)?.lease?.runnerId === runnerId
|
|
859
|
-
)
|
|
1115
|
+
[...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 ?? "" }))
|
|
860
1116
|
);
|
|
861
1117
|
}
|
|
862
1118
|
// -- pairing and runners -------------------------------------------------
|
|
@@ -887,7 +1143,6 @@ var MemoryStore = class {
|
|
|
887
1143
|
const runner = {
|
|
888
1144
|
id: args.runnerId,
|
|
889
1145
|
owner: args.owner,
|
|
890
|
-
tokenHash: args.tokenHash,
|
|
891
1146
|
// Carried from the pairing, not re-supplied at approval: the user
|
|
892
1147
|
// approved a specific machine, and the runner must be that machine.
|
|
893
1148
|
device: pairing.device,
|
|
@@ -906,7 +1161,7 @@ var MemoryStore = class {
|
|
|
906
1161
|
state: "approved",
|
|
907
1162
|
owner: args.owner,
|
|
908
1163
|
runnerId: runner.id,
|
|
909
|
-
|
|
1164
|
+
collected: false
|
|
910
1165
|
});
|
|
911
1166
|
return Promise.resolve(runner);
|
|
912
1167
|
}
|
|
@@ -927,17 +1182,11 @@ var MemoryStore = class {
|
|
|
927
1182
|
if (pairing) {
|
|
928
1183
|
this.#pairings.set(deviceCodeHash, {
|
|
929
1184
|
...pairing,
|
|
930
|
-
|
|
1185
|
+
collected: true
|
|
931
1186
|
});
|
|
932
1187
|
}
|
|
933
1188
|
return Promise.resolve();
|
|
934
1189
|
}
|
|
935
|
-
getRunnerByTokenHash(hash) {
|
|
936
|
-
for (const runner of this.#runners.values()) {
|
|
937
|
-
if (runner.tokenHash === hash) return Promise.resolve(runner);
|
|
938
|
-
}
|
|
939
|
-
return Promise.resolve(null);
|
|
940
|
-
}
|
|
941
1190
|
getRunner(runnerId) {
|
|
942
1191
|
return Promise.resolve(this.#runners.get(runnerId) ?? null);
|
|
943
1192
|
}
|
|
@@ -980,9 +1229,11 @@ export {
|
|
|
980
1229
|
ByollmApp,
|
|
981
1230
|
ByollmHandlers,
|
|
982
1231
|
CloudLane,
|
|
1232
|
+
EnqueueRefused,
|
|
983
1233
|
MemoryStore,
|
|
984
1234
|
NoRunnerAvailableError,
|
|
985
1235
|
PollingDelivery,
|
|
1236
|
+
RelayUnavailable,
|
|
986
1237
|
ResultTimeoutError,
|
|
987
1238
|
SERVED_PROTOCOL_VERSION,
|
|
988
1239
|
capabilityFor,
|
|
@@ -991,7 +1242,6 @@ export {
|
|
|
991
1242
|
generateDeviceCode,
|
|
992
1243
|
generateJobId,
|
|
993
1244
|
generateRunnerId,
|
|
994
|
-
generateRunnerToken,
|
|
995
1245
|
generateSiteKeys,
|
|
996
1246
|
generateUserCode,
|
|
997
1247
|
hashSecret,
|