@byollm/server 0.1.0-alpha.9 → 0.1.0-alpha.91
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 +180 -9
- package/dist/{chunk-4NIHWQAT.js → chunk-36Y77FUD.js} +106 -50
- package/dist/chunk-36Y77FUD.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 +317 -104
- 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,24 @@ 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
|
+
openSealedOutcome,
|
|
12
13
|
resealForDevice,
|
|
13
14
|
routeEndpoint,
|
|
14
15
|
secretsMatch,
|
|
15
16
|
signatureFrom
|
|
16
|
-
} from "./chunk-
|
|
17
|
+
} from "./chunk-36Y77FUD.js";
|
|
17
18
|
import {
|
|
18
19
|
NoRunnerAvailableError,
|
|
19
20
|
PollingDelivery,
|
|
20
21
|
ResultTimeoutError
|
|
21
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-I3ER27QG.js";
|
|
22
23
|
|
|
23
24
|
// src/app.ts
|
|
24
25
|
import {
|
|
@@ -35,13 +36,35 @@ import {
|
|
|
35
36
|
|
|
36
37
|
// src/cloud.ts
|
|
37
38
|
import {
|
|
38
|
-
|
|
39
|
+
PROTOCOL_VERSION,
|
|
39
40
|
keyId,
|
|
40
41
|
open,
|
|
41
42
|
publicIdentityOf,
|
|
42
43
|
provenanceFor,
|
|
43
44
|
signSiteRequest
|
|
44
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";
|
|
45
68
|
var CloudLane = class {
|
|
46
69
|
#options;
|
|
47
70
|
#store;
|
|
@@ -67,20 +90,64 @@ var CloudLane = class {
|
|
|
67
90
|
id: record.id,
|
|
68
91
|
kind: record.kind,
|
|
69
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),
|
|
70
98
|
audience: record.audience,
|
|
71
|
-
|
|
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 },
|
|
72
117
|
sizeClass: record.sizeClass,
|
|
73
118
|
streaming: false,
|
|
74
119
|
// The relay needs *a* deadline to bound routing. A job without one gets
|
|
75
120
|
// the envelope's, which is the outer bound on how long the ciphertext
|
|
76
121
|
// is worth carrying — never longer than the work could possibly matter.
|
|
77
|
-
|
|
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())
|
|
78
130
|
};
|
|
79
131
|
await this.#post("enqueue", {
|
|
80
132
|
siteId: this.#options.siteId,
|
|
81
133
|
stub
|
|
82
134
|
});
|
|
83
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
|
+
}
|
|
84
151
|
/**
|
|
85
152
|
* One cycle: seal for anything claimed, collect anything finished.
|
|
86
153
|
*
|
|
@@ -93,6 +160,16 @@ var CloudLane = class {
|
|
|
93
160
|
const sealed = [];
|
|
94
161
|
const refused = [];
|
|
95
162
|
const completed = [];
|
|
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) {
|
|
96
173
|
const pending = await this.#get("pending");
|
|
97
174
|
for (const claim of pending.jobs) {
|
|
98
175
|
const record = await this.#store.get(claim.jobId);
|
|
@@ -110,12 +187,16 @@ var CloudLane = class {
|
|
|
110
187
|
refused.push(claim.jobId);
|
|
111
188
|
continue;
|
|
112
189
|
}
|
|
113
|
-
await this.#store.adopt({
|
|
190
|
+
const adopted = await this.#store.adopt({
|
|
114
191
|
jobId: claim.jobId,
|
|
115
192
|
leaseId: claim.leaseId,
|
|
116
|
-
expiresAt: claim.
|
|
193
|
+
expiresAt: claim.leaseExpiresAt,
|
|
117
194
|
now: this.#now()
|
|
118
195
|
});
|
|
196
|
+
if (!adopted) {
|
|
197
|
+
refused.push(claim.jobId);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
119
200
|
await this.#post("payload", {
|
|
120
201
|
siteId: this.#options.siteId,
|
|
121
202
|
jobId: claim.jobId,
|
|
@@ -136,17 +217,30 @@ var CloudLane = class {
|
|
|
136
217
|
}
|
|
137
218
|
await this.#store.complete({
|
|
138
219
|
jobId: done.jobId,
|
|
220
|
+
// The relay named the device; the signature above proved it — §3.6.
|
|
221
|
+
runnerId: done.runnerId,
|
|
139
222
|
// The grant, not the machine: this site never paired with the device
|
|
140
223
|
// that ran it, and the signature it verified above is the stronger
|
|
141
224
|
// claim about who did.
|
|
142
225
|
holder: { by: "lease", leaseId: done.leaseId },
|
|
143
|
-
outcome,
|
|
226
|
+
outcome: outcome.outcome,
|
|
144
227
|
provenance: provenanceFor({
|
|
145
228
|
audience: record.audience,
|
|
146
229
|
runnerId: done.runnerId,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
150
244
|
}),
|
|
151
245
|
now: this.#now()
|
|
152
246
|
});
|
|
@@ -175,16 +269,11 @@ var CloudLane = class {
|
|
|
175
269
|
}
|
|
176
270
|
});
|
|
177
271
|
if (!opened.ok) return null;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
const outcome = JobOutcome.safeParse(parsed);
|
|
185
|
-
if (!outcome.success) return null;
|
|
186
|
-
if (outcome.data.outcome !== done.disposition) return null;
|
|
187
|
-
return outcome.data;
|
|
272
|
+
const outcome = openSealedOutcome({
|
|
273
|
+
plaintext: opened.plaintext,
|
|
274
|
+
disposition: done.disposition
|
|
275
|
+
});
|
|
276
|
+
return outcome.ok ? outcome.value : null;
|
|
188
277
|
}
|
|
189
278
|
/**
|
|
190
279
|
* Sign a site-plane call with this site's identity key.
|
|
@@ -208,8 +297,47 @@ var CloudLane = class {
|
|
|
208
297
|
"x-byollm-signature": signature.signature
|
|
209
298
|
};
|
|
210
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* A relay answer, checked before it is believed — alpha.31.
|
|
302
|
+
*
|
|
303
|
+
* The bug this closes is one line long and its shape is general: a response
|
|
304
|
+
* body used without looking at the status. The daemon's client has always
|
|
305
|
+
* done this properly (`client.ts` maps every status to a typed refusal); the
|
|
306
|
+
* site's lane parsed JSON and hoped.
|
|
307
|
+
*
|
|
308
|
+
* Two classes, because they need opposite handling. **Retryable** — 503 from
|
|
309
|
+
* a draining pod, 429, 5xx, and the protocol's own `not-ready` — means the
|
|
310
|
+
* work is still there and this cycle should end quietly. **Refused** — a bad
|
|
311
|
+
* signature, an unknown site, a version this relay does not speak — will
|
|
312
|
+
* still be true in five seconds, and swallowing it would leave a site
|
|
313
|
+
* silently disconnected from its own users.
|
|
314
|
+
*/
|
|
315
|
+
async #answer(response, endpoint) {
|
|
316
|
+
if (response.ok) return response.json();
|
|
317
|
+
let code = "";
|
|
318
|
+
let message;
|
|
319
|
+
try {
|
|
320
|
+
const body = await response.json();
|
|
321
|
+
code = body.error ?? "";
|
|
322
|
+
message = body.message ?? "";
|
|
323
|
+
} catch {
|
|
324
|
+
message = `HTTP ${String(response.status)}`;
|
|
325
|
+
}
|
|
326
|
+
const retryable = response.status >= 500 || response.status === 429 || code === "not-ready" || code === "server-error";
|
|
327
|
+
if (endpoint === ENQUEUE_ENDPOINT && response.status === 409 && !RETRYABLE_AT_ENQUEUE.has(code)) {
|
|
328
|
+
throw new EnqueueRefused(message, code);
|
|
329
|
+
}
|
|
330
|
+
throw new RelayUnavailable(
|
|
331
|
+
`${endpoint}: ${code || "refused"} \u2014 ${message}`,
|
|
332
|
+
retryable,
|
|
333
|
+
code
|
|
334
|
+
);
|
|
335
|
+
}
|
|
211
336
|
async #post(endpoint, body) {
|
|
212
|
-
const rawBody = JSON.stringify(
|
|
337
|
+
const rawBody = JSON.stringify({
|
|
338
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
339
|
+
...body
|
|
340
|
+
});
|
|
213
341
|
const response = await this.#fetch(
|
|
214
342
|
`${this.#options.relayOrigin}/relay/site/${endpoint}`,
|
|
215
343
|
{
|
|
@@ -221,20 +349,31 @@ var CloudLane = class {
|
|
|
221
349
|
body: rawBody
|
|
222
350
|
}
|
|
223
351
|
);
|
|
224
|
-
return response
|
|
352
|
+
return this.#answer(response, endpoint);
|
|
225
353
|
}
|
|
226
354
|
async #get(endpoint) {
|
|
227
|
-
const url = `${this.#options.relayOrigin}/relay/site/${endpoint}?siteId=${encodeURIComponent(this.#options.siteId)}`;
|
|
355
|
+
const url = `${this.#options.relayOrigin}/relay/site/${endpoint}?siteId=${encodeURIComponent(this.#options.siteId)}&protocolVersion=${encodeURIComponent(PROTOCOL_VERSION)}`;
|
|
228
356
|
const response = await this.#fetch(url, {
|
|
229
357
|
headers: this.#headers(endpoint, "")
|
|
230
358
|
});
|
|
231
|
-
return response
|
|
359
|
+
return this.#answer(response, endpoint);
|
|
232
360
|
}
|
|
233
361
|
};
|
|
234
|
-
var ENVELOPE_TTL_FALLBACK = 24 * 60 * 6e4;
|
|
235
362
|
|
|
236
363
|
// src/app.ts
|
|
237
364
|
var DEFAULT_LIVENESS_MS = 35e3;
|
|
365
|
+
var ENQUEUE_OPTIONS = Object.freeze({
|
|
366
|
+
kind: true,
|
|
367
|
+
payload: true,
|
|
368
|
+
owner: true,
|
|
369
|
+
audience: true,
|
|
370
|
+
purpose: true,
|
|
371
|
+
audienceAllow: true,
|
|
372
|
+
dependsOn: true,
|
|
373
|
+
ttlMs: true,
|
|
374
|
+
deadlineAt: true,
|
|
375
|
+
id: true
|
|
376
|
+
});
|
|
238
377
|
var ByollmApp = class {
|
|
239
378
|
#store;
|
|
240
379
|
#siteKeys;
|
|
@@ -257,28 +396,38 @@ var ByollmApp = class {
|
|
|
257
396
|
const deps = {
|
|
258
397
|
...options.noRunnerGraceMs === void 0 ? {} : { graceMs: options.noRunnerGraceMs },
|
|
259
398
|
read: (jobId) => this.result(jobId),
|
|
260
|
-
availability:
|
|
261
|
-
const job = await this.#store.get(jobId);
|
|
262
|
-
if (!job)
|
|
263
|
-
return { available: false, reason: "unknown-job", blocked: false };
|
|
264
|
-
if (job.claimableAt === null) {
|
|
265
|
-
return { available: true, blocked: true };
|
|
266
|
-
}
|
|
267
|
-
const availability = await this.runnerAvailability({
|
|
268
|
-
kind: job.kind,
|
|
269
|
-
owner: job.owner,
|
|
270
|
-
audience: job.audience,
|
|
271
|
-
...job.audienceAllow === void 0 ? {} : { audienceAllow: job.audienceAllow }
|
|
272
|
-
});
|
|
273
|
-
return {
|
|
274
|
-
available: availability.available,
|
|
275
|
-
...availability.reason === void 0 ? {} : { reason: availability.reason },
|
|
276
|
-
blocked: false
|
|
277
|
-
};
|
|
278
|
-
}
|
|
399
|
+
...this.cloud !== void 0 ? {} : { availability: this.#availabilityFor() }
|
|
279
400
|
};
|
|
280
401
|
this.#delivery = options.delivery?.(deps) ?? new PollingDelivery(deps);
|
|
281
402
|
}
|
|
403
|
+
/**
|
|
404
|
+
* The no-runner instrument, for a lane that can actually see runners.
|
|
405
|
+
*
|
|
406
|
+
* A method rather than an inline closure so the branch above reads as one
|
|
407
|
+
* decision — whether this deployment has the instrument at all — instead of
|
|
408
|
+
* a conditional wrapped around thirty lines of body.
|
|
409
|
+
*/
|
|
410
|
+
#availabilityFor() {
|
|
411
|
+
return async (jobId) => {
|
|
412
|
+
const job = await this.#store.get(jobId);
|
|
413
|
+
if (!job)
|
|
414
|
+
return { available: false, reason: "unknown-job", blocked: false };
|
|
415
|
+
if (job.claimableAt === null) {
|
|
416
|
+
return { available: true, blocked: true };
|
|
417
|
+
}
|
|
418
|
+
const availability = await this.runnerAvailability({
|
|
419
|
+
kind: job.kind,
|
|
420
|
+
owner: job.owner,
|
|
421
|
+
audience: job.audience,
|
|
422
|
+
...job.audienceAllow === void 0 ? {} : { audienceAllow: job.audienceAllow }
|
|
423
|
+
});
|
|
424
|
+
return {
|
|
425
|
+
available: availability.available,
|
|
426
|
+
...availability.reason === void 0 ? {} : { reason: availability.reason },
|
|
427
|
+
blocked: false
|
|
428
|
+
};
|
|
429
|
+
};
|
|
430
|
+
}
|
|
282
431
|
/**
|
|
283
432
|
* Enqueue a job.
|
|
284
433
|
*
|
|
@@ -287,6 +436,19 @@ var ByollmApp = class {
|
|
|
287
436
|
* the app is obliged to disclose that to whoever reads it.
|
|
288
437
|
*/
|
|
289
438
|
async enqueue(input) {
|
|
439
|
+
const unknown = Object.keys(input).filter(
|
|
440
|
+
(key) => !(key in ENQUEUE_OPTIONS)
|
|
441
|
+
);
|
|
442
|
+
if (unknown.length > 0) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`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.`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
if (this.cloud !== void 0 && input.audience !== void 0) {
|
|
448
|
+
throw new Error(
|
|
449
|
+
"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."
|
|
450
|
+
);
|
|
451
|
+
}
|
|
290
452
|
const parsed = KindedPayload.safeParse({
|
|
291
453
|
kind: input.kind,
|
|
292
454
|
payload: input.payload
|
|
@@ -314,6 +476,27 @@ var ByollmApp = class {
|
|
|
314
476
|
const record = await this.#store.create(
|
|
315
477
|
{
|
|
316
478
|
...input,
|
|
479
|
+
/**
|
|
480
|
+
* Derived here, because on the cloud lane it is derivable and nowhere
|
|
481
|
+
* else knows the lane.
|
|
482
|
+
*
|
|
483
|
+
* Refusing the site's declaration is only half of "derived, never
|
|
484
|
+
* declared" — the stub still carries an audience to the relay, and a
|
|
485
|
+
* store that defaults it to `private` would keep every cloud job
|
|
486
|
+
* private no matter who was forbidden from saying so. The half that
|
|
487
|
+
* fixes anything is this one.
|
|
488
|
+
*
|
|
489
|
+
* `team` is the value that defers: it says a device whose owner
|
|
490
|
+
* admits this person may serve, and the hub then decides whether one
|
|
491
|
+
* does, from the mapping the person authored, its service's owner,
|
|
492
|
+
* that owner's offer scope, and the roster. Nothing is widened by
|
|
493
|
+
* saying it — both axes still have to agree, and the owner's scope is
|
|
494
|
+
* the other axis.
|
|
495
|
+
*
|
|
496
|
+
* Direct mode keeps the store's `private` default: there is no
|
|
497
|
+
* control plane there to derive from, and owner-only is the ruling.
|
|
498
|
+
*/
|
|
499
|
+
...this.cloud === void 0 ? {} : { audience: "team" },
|
|
317
500
|
id: jobId,
|
|
318
501
|
envelope,
|
|
319
502
|
sizeClass: sizeClassOf(
|
|
@@ -346,7 +529,7 @@ var ByollmApp = class {
|
|
|
346
529
|
* Check `provenance.untrusted` before rendering. It is true for every
|
|
347
530
|
* `named`/`public` job, because that text came from someone else's machine
|
|
348
531
|
* and the app must not present it as its own AI's answer
|
|
349
|
-
* ({@link MUSTS.
|
|
532
|
+
* ({@link MUSTS.PROVENANCE_NAMES_DEVICE}).
|
|
350
533
|
*/
|
|
351
534
|
async result(jobId) {
|
|
352
535
|
const job = await this.job(jobId);
|
|
@@ -360,7 +543,11 @@ var ByollmApp = class {
|
|
|
360
543
|
}
|
|
361
544
|
/** Ask a runner to stop. Queued jobs cancel at once; held jobs at the next heartbeat. */
|
|
362
545
|
async cancel(jobId) {
|
|
363
|
-
|
|
546
|
+
const cancelled = await this.#store.cancel(jobId, this.#now());
|
|
547
|
+
if (cancelled && this.cloud) {
|
|
548
|
+
await this.cloud.cancel(jobId).catch(() => void 0);
|
|
549
|
+
}
|
|
550
|
+
return cancelled;
|
|
364
551
|
}
|
|
365
552
|
/**
|
|
366
553
|
* Is there a live runner that could take a job of this shape?
|
|
@@ -369,6 +556,11 @@ var ByollmApp = class {
|
|
|
369
556
|
* signal cannot promise a runner the claim would then refuse.
|
|
370
557
|
*/
|
|
371
558
|
async runnerAvailability(query) {
|
|
559
|
+
if (this.cloud !== void 0) {
|
|
560
|
+
throw new Error(
|
|
561
|
+
"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."
|
|
562
|
+
);
|
|
563
|
+
}
|
|
372
564
|
const now = this.#now();
|
|
373
565
|
const all = await this.#store.listRunners();
|
|
374
566
|
const live = all.filter(
|
|
@@ -382,37 +574,41 @@ var ByollmApp = class {
|
|
|
382
574
|
}
|
|
383
575
|
let capable = 0;
|
|
384
576
|
let admitted = 0;
|
|
577
|
+
let lastRefusal;
|
|
385
578
|
for (const runner of live) {
|
|
386
|
-
const capability
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
579
|
+
for (const capability of runner.capabilities.filter(
|
|
580
|
+
(c) => c.kind === query.kind
|
|
581
|
+
)) {
|
|
582
|
+
capable += 1;
|
|
583
|
+
const match = matchAudience(
|
|
584
|
+
{
|
|
585
|
+
owner: query.owner,
|
|
586
|
+
audience: query.audience ?? "private",
|
|
587
|
+
audienceAllow: query.audienceAllow
|
|
588
|
+
},
|
|
589
|
+
{
|
|
590
|
+
owner: runner.owner,
|
|
591
|
+
offerScope: capability.offerScope,
|
|
592
|
+
// A generic backend's cost depends on its base URL, which the
|
|
593
|
+
// server never sees; assume the expensive reading (byollm_007 §4).
|
|
594
|
+
cost: backendDescriptor(capability.backendId).cost ?? "metered",
|
|
595
|
+
// Consent is the daemon's to hold, and it has already applied it:
|
|
596
|
+
// the offer scope arriving here is the *effective* one, so a
|
|
597
|
+
// metered backend nobody agreed to share advertises `self` and is
|
|
598
|
+
// refused by the scope rule above. Re-deriving consent from
|
|
599
|
+
// `false` here would instead refuse every backend an owner
|
|
600
|
+
// deliberately shared, because the server has no way to learn they
|
|
601
|
+
// did — the signal would be wrong in the direction that breaks
|
|
602
|
+
// working setups.
|
|
603
|
+
spend: { acknowledged: true },
|
|
604
|
+
// Same conservative assumption the claim path makes: the server
|
|
605
|
+
// cannot see a remote daemon's local allowlist (protocol §4.2).
|
|
606
|
+
admits: () => true
|
|
607
|
+
}
|
|
608
|
+
);
|
|
609
|
+
if (match.ok) admitted += 1;
|
|
610
|
+
else lastRefusal = match.refusal;
|
|
611
|
+
}
|
|
416
612
|
}
|
|
417
613
|
if (capable === 0) {
|
|
418
614
|
return {
|
|
@@ -422,9 +618,10 @@ var ByollmApp = class {
|
|
|
422
618
|
};
|
|
423
619
|
}
|
|
424
620
|
if (admitted === 0) {
|
|
621
|
+
const ownersDoing = lastRefusal === "offer-scope-too-narrow" || lastRefusal === "subscription-self-lock" || lastRefusal === "metered-no-spend-consent" || lastRefusal === "metered-ceiling-reached";
|
|
425
622
|
return {
|
|
426
623
|
available: false,
|
|
427
|
-
reason: "audience-admits-nobody",
|
|
624
|
+
reason: ownersDoing ? "default-unusable" : "audience-admits-nobody",
|
|
428
625
|
candidates: 0
|
|
429
626
|
};
|
|
430
627
|
}
|
|
@@ -438,13 +635,10 @@ var ByollmApp = class {
|
|
|
438
635
|
* ({@link MUSTS.PAIR_ONE_USER}, {@link MUSTS.PAIR_INTERACTIVE}).
|
|
439
636
|
*/
|
|
440
637
|
async approvePairing(args) {
|
|
441
|
-
const token = generateRunnerToken();
|
|
442
638
|
return this.#store.approvePairing({
|
|
443
639
|
userCode: normalizeUserCode(args.userCode),
|
|
444
640
|
owner: args.owner,
|
|
445
641
|
runnerId: generateRunnerId(),
|
|
446
|
-
runnerToken: token,
|
|
447
|
-
tokenHash: hashSecret(token),
|
|
448
642
|
now: this.#now()
|
|
449
643
|
});
|
|
450
644
|
}
|
|
@@ -517,13 +711,27 @@ function siteKeysFromEnv(variable = "BYOLLM_SITE_KEYS", env = process.env) {
|
|
|
517
711
|
}
|
|
518
712
|
function formatSiteKeys(keys) {
|
|
519
713
|
const encoded = Buffer.from(JSON.stringify(keys)).toString("base64");
|
|
520
|
-
|
|
521
|
-
|
|
714
|
+
const pub = publicIdentityOf3(keys);
|
|
715
|
+
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
|
|
716
|
+
#
|
|
717
|
+
# This is the site's identity. Anything holding it can *be* this site,
|
|
718
|
+
# so it goes wherever your deployment keeps secrets \u2014 never in a repo,
|
|
719
|
+
# never in a browser, never pasted into a dashboard.
|
|
522
720
|
BYOLLM_SITE_KEYS=${encoded}
|
|
523
721
|
|
|
524
|
-
#
|
|
525
|
-
#
|
|
526
|
-
#
|
|
722
|
+
# \u2500\u2500 2. PUBLIC \u2014 paste this line into the byollm dashboard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
723
|
+
#
|
|
724
|
+
# The public half. It proves signatures and seals nothing, so it is
|
|
725
|
+
# safe to publish \u2014 which is the point: users pin it, and the relay
|
|
726
|
+
# cannot forge work without the secret above.
|
|
727
|
+
${JSON.stringify(pub)}
|
|
728
|
+
|
|
729
|
+
# \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
|
|
730
|
+
#
|
|
731
|
+
# A fingerprint is not secret. Show it on your site so somebody
|
|
732
|
+
# connecting can check it against what their daemon printed.
|
|
733
|
+
# The dashboard derives this itself, so there is nothing to paste.
|
|
734
|
+
# ${fingerprint(pub.identity)}
|
|
527
735
|
`;
|
|
528
736
|
}
|
|
529
737
|
|
|
@@ -557,12 +765,14 @@ var MemoryStore = class {
|
|
|
557
765
|
kind: input.kind,
|
|
558
766
|
envelope: input.envelope,
|
|
559
767
|
sizeClass: input.sizeClass,
|
|
560
|
-
audience: input.audience ?? "
|
|
768
|
+
audience: input.audience ?? "private",
|
|
769
|
+
purpose: input.purpose,
|
|
561
770
|
owner: input.owner,
|
|
562
771
|
audienceAllow: input.audienceAllow ? [...input.audienceAllow] : void 0,
|
|
563
772
|
dependsOn,
|
|
564
773
|
state: "queued",
|
|
565
774
|
lease: null,
|
|
775
|
+
completedByLeaseId: null,
|
|
566
776
|
createdAt: now,
|
|
567
777
|
// The TTL clock starts here only if nothing blocks the job.
|
|
568
778
|
claimableAt: blocked ? null : now,
|
|
@@ -640,7 +850,7 @@ var MemoryStore = class {
|
|
|
640
850
|
// not pretend to (protocol §4.2). It admits the job here; the daemon
|
|
641
851
|
// is the enforcing side and releases with `refused` if its own list
|
|
642
852
|
// says no.
|
|
643
|
-
|
|
853
|
+
admits: () => true
|
|
644
854
|
}
|
|
645
855
|
);
|
|
646
856
|
return match.ok;
|
|
@@ -652,11 +862,11 @@ var MemoryStore = class {
|
|
|
652
862
|
for (const { jobId, leaseId } of args.leases) {
|
|
653
863
|
const job = this.#jobs.get(jobId);
|
|
654
864
|
if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
|
|
655
|
-
lost.push(jobId);
|
|
865
|
+
lost.push({ jobId, leaseId });
|
|
656
866
|
continue;
|
|
657
867
|
}
|
|
658
868
|
if (job.state !== "claimed" && job.state !== "running") {
|
|
659
|
-
lost.push(jobId);
|
|
869
|
+
lost.push({ jobId, leaseId });
|
|
660
870
|
continue;
|
|
661
871
|
}
|
|
662
872
|
const expiresAt = args.now + args.leaseMs;
|
|
@@ -698,6 +908,11 @@ var MemoryStore = class {
|
|
|
698
908
|
const job = this.#jobs.get(args.jobId);
|
|
699
909
|
if (!job) return Promise.resolve({ accepted: false, job: null });
|
|
700
910
|
if (job.state === "ok" || job.state === "error" || job.state === "canceled") {
|
|
911
|
+
const sameDevice = job.provenance?.runnerId !== void 0 && job.provenance.runnerId === args.runnerId;
|
|
912
|
+
const sameGrant = args.holder.by === "lease" && job.completedByLeaseId !== null && job.completedByLeaseId === args.holder.leaseId;
|
|
913
|
+
if (sameDevice && sameGrant) {
|
|
914
|
+
return Promise.resolve({ accepted: false, duplicate: true, job });
|
|
915
|
+
}
|
|
701
916
|
return Promise.resolve({ accepted: false, job });
|
|
702
917
|
}
|
|
703
918
|
if (job.state === "expired") {
|
|
@@ -712,6 +927,8 @@ var MemoryStore = class {
|
|
|
712
927
|
...job,
|
|
713
928
|
state,
|
|
714
929
|
lease: null,
|
|
930
|
+
// The grant that recorded it, kept after the lease is dropped — §3.6.
|
|
931
|
+
completedByLeaseId: args.holder.by === "lease" ? args.holder.leaseId : job.lease?.id ?? null,
|
|
715
932
|
outcome: args.outcome,
|
|
716
933
|
provenance: args.provenance,
|
|
717
934
|
updatedAt: args.now
|
|
@@ -800,6 +1017,7 @@ var MemoryStore = class {
|
|
|
800
1017
|
...job,
|
|
801
1018
|
state: "queued",
|
|
802
1019
|
lease: null,
|
|
1020
|
+
completedByLeaseId: null,
|
|
803
1021
|
// Newly available again, so the TTL clock restarts here too.
|
|
804
1022
|
claimableAt: args.now,
|
|
805
1023
|
// A refusal is remembered, or the pair spins between claim and
|
|
@@ -829,6 +1047,7 @@ var MemoryStore = class {
|
|
|
829
1047
|
...job,
|
|
830
1048
|
state: "queued",
|
|
831
1049
|
lease: null,
|
|
1050
|
+
completedByLeaseId: null,
|
|
832
1051
|
// The TTL clock restarts: it measures how long a job has waited
|
|
833
1052
|
// *unclaimed*, and this job has just become available again. Without
|
|
834
1053
|
// this, a job whose runner died would expire for time it spent being
|
|
@@ -851,6 +1070,7 @@ var MemoryStore = class {
|
|
|
851
1070
|
...job,
|
|
852
1071
|
state: "expired",
|
|
853
1072
|
lease: null,
|
|
1073
|
+
completedByLeaseId: null,
|
|
854
1074
|
updatedAt: now
|
|
855
1075
|
};
|
|
856
1076
|
this.#write(job.id, expired);
|
|
@@ -866,6 +1086,7 @@ var MemoryStore = class {
|
|
|
866
1086
|
...job,
|
|
867
1087
|
state: "canceled",
|
|
868
1088
|
lease: null,
|
|
1089
|
+
completedByLeaseId: null,
|
|
869
1090
|
updatedAt: now
|
|
870
1091
|
};
|
|
871
1092
|
this.#write(jobId, canceled);
|
|
@@ -886,9 +1107,7 @@ var MemoryStore = class {
|
|
|
886
1107
|
}
|
|
887
1108
|
listCancelRequests(runnerId) {
|
|
888
1109
|
return Promise.resolve(
|
|
889
|
-
[...this.#cancelRequests].filter(
|
|
890
|
-
(jobId) => this.#jobs.get(jobId)?.lease?.runnerId === runnerId
|
|
891
|
-
)
|
|
1110
|
+
[...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 ?? "" }))
|
|
892
1111
|
);
|
|
893
1112
|
}
|
|
894
1113
|
// -- pairing and runners -------------------------------------------------
|
|
@@ -919,7 +1138,6 @@ var MemoryStore = class {
|
|
|
919
1138
|
const runner = {
|
|
920
1139
|
id: args.runnerId,
|
|
921
1140
|
owner: args.owner,
|
|
922
|
-
tokenHash: args.tokenHash,
|
|
923
1141
|
// Carried from the pairing, not re-supplied at approval: the user
|
|
924
1142
|
// approved a specific machine, and the runner must be that machine.
|
|
925
1143
|
device: pairing.device,
|
|
@@ -938,7 +1156,7 @@ var MemoryStore = class {
|
|
|
938
1156
|
state: "approved",
|
|
939
1157
|
owner: args.owner,
|
|
940
1158
|
runnerId: runner.id,
|
|
941
|
-
|
|
1159
|
+
collected: false
|
|
942
1160
|
});
|
|
943
1161
|
return Promise.resolve(runner);
|
|
944
1162
|
}
|
|
@@ -959,17 +1177,11 @@ var MemoryStore = class {
|
|
|
959
1177
|
if (pairing) {
|
|
960
1178
|
this.#pairings.set(deviceCodeHash, {
|
|
961
1179
|
...pairing,
|
|
962
|
-
|
|
1180
|
+
collected: true
|
|
963
1181
|
});
|
|
964
1182
|
}
|
|
965
1183
|
return Promise.resolve();
|
|
966
1184
|
}
|
|
967
|
-
getRunnerByTokenHash(hash) {
|
|
968
|
-
for (const runner of this.#runners.values()) {
|
|
969
|
-
if (runner.tokenHash === hash) return Promise.resolve(runner);
|
|
970
|
-
}
|
|
971
|
-
return Promise.resolve(null);
|
|
972
|
-
}
|
|
973
1185
|
getRunner(runnerId) {
|
|
974
1186
|
return Promise.resolve(this.#runners.get(runnerId) ?? null);
|
|
975
1187
|
}
|
|
@@ -1012,9 +1224,11 @@ export {
|
|
|
1012
1224
|
ByollmApp,
|
|
1013
1225
|
ByollmHandlers,
|
|
1014
1226
|
CloudLane,
|
|
1227
|
+
EnqueueRefused,
|
|
1015
1228
|
MemoryStore,
|
|
1016
1229
|
NoRunnerAvailableError,
|
|
1017
1230
|
PollingDelivery,
|
|
1231
|
+
RelayUnavailable,
|
|
1018
1232
|
ResultTimeoutError,
|
|
1019
1233
|
SERVED_PROTOCOL_VERSION,
|
|
1020
1234
|
capabilityFor,
|
|
@@ -1023,7 +1237,6 @@ export {
|
|
|
1023
1237
|
generateDeviceCode,
|
|
1024
1238
|
generateJobId,
|
|
1025
1239
|
generateRunnerId,
|
|
1026
|
-
generateRunnerToken,
|
|
1027
1240
|
generateSiteKeys,
|
|
1028
1241
|
generateUserCode,
|
|
1029
1242
|
hashSecret,
|