@byollm/server 0.1.0-alpha.4 → 0.1.0-alpha.41
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 +124 -4
- package/dist/{chunk-MGJX6626.js → chunk-K5E6JS5A.js} +128 -72
- package/dist/chunk-K5E6JS5A.js.map +1 -0
- package/dist/{chunk-7RKXFPBZ.js → chunk-SAK63KNU.js} +17 -3
- package/dist/chunk-SAK63KNU.js.map +1 -0
- package/dist/{delivery-36nIe-b3.d.ts → delivery-C6VzgMgH.d.ts} +15 -3
- package/dist/{handlers-CF3yE-t2.d.ts → handlers-BJYm2kdq.d.ts} +1 -1
- package/dist/index.d.ts +149 -13
- package/dist/index.js +405 -33
- package/dist/index.js.map +1 -1
- package/dist/next.d.ts +2 -2
- package/dist/next.js +1 -1
- package/dist/{store-gFEEN1Dt.d.ts → store-Dno2fnHH.d.ts} +128 -13
- package/dist/supabase/index.d.ts +2 -2
- package/dist/supabase/index.js +65 -35
- 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/dist/chunk-7RKXFPBZ.js.map +0 -1
- package/dist/chunk-MGJX6626.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -2,35 +2,355 @@ 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
|
+
resealForDevice,
|
|
12
13
|
routeEndpoint,
|
|
13
14
|
secretsMatch,
|
|
14
15
|
signatureFrom
|
|
15
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-K5E6JS5A.js";
|
|
16
17
|
import {
|
|
17
18
|
NoRunnerAvailableError,
|
|
18
19
|
PollingDelivery,
|
|
19
20
|
ResultTimeoutError
|
|
20
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-SAK63KNU.js";
|
|
21
22
|
|
|
22
23
|
// src/app.ts
|
|
23
24
|
import {
|
|
24
25
|
ENVELOPE_MAX_AGE_MS,
|
|
25
26
|
KindedPayload,
|
|
26
|
-
keyId,
|
|
27
|
+
keyId as keyId2,
|
|
27
28
|
payloadTextLength,
|
|
28
|
-
publicIdentityOf,
|
|
29
|
+
publicIdentityOf as publicIdentityOf2,
|
|
29
30
|
seal,
|
|
30
31
|
sizeClassOf,
|
|
31
32
|
backendDescriptor,
|
|
32
33
|
matchAudience
|
|
33
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
|
|
34
354
|
var DEFAULT_LIVENESS_MS = 35e3;
|
|
35
355
|
var ByollmApp = class {
|
|
36
356
|
#store;
|
|
@@ -38,11 +358,19 @@ var ByollmApp = class {
|
|
|
38
358
|
#now;
|
|
39
359
|
#livenessMs;
|
|
40
360
|
#delivery;
|
|
361
|
+
/** Present only in the cloud lane; the site's side of the relay. */
|
|
362
|
+
cloud;
|
|
41
363
|
constructor(options) {
|
|
42
364
|
this.#store = options.store;
|
|
43
365
|
this.#siteKeys = options.siteKeys;
|
|
44
366
|
this.#now = options.now ?? Date.now;
|
|
45
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
|
+
});
|
|
46
374
|
const deps = {
|
|
47
375
|
...options.noRunnerGraceMs === void 0 ? {} : { graceMs: options.noRunnerGraceMs },
|
|
48
376
|
read: (jobId) => this.result(jobId),
|
|
@@ -87,7 +415,7 @@ var ByollmApp = class {
|
|
|
87
415
|
const createdAt = this.#now();
|
|
88
416
|
const envelopeDeadlineAt = createdAt + ENVELOPE_MAX_AGE_MS;
|
|
89
417
|
const jobId = input.id ?? generateJobId();
|
|
90
|
-
const senderKeyId =
|
|
418
|
+
const senderKeyId = keyId2(publicIdentityOf2(this.#siteKeys).identity);
|
|
91
419
|
const envelope = await seal({
|
|
92
420
|
plaintext: JSON.stringify(parsed.data.payload),
|
|
93
421
|
senderKeys: this.#siteKeys,
|
|
@@ -114,6 +442,7 @@ var ByollmApp = class {
|
|
|
114
442
|
},
|
|
115
443
|
createdAt
|
|
116
444
|
);
|
|
445
|
+
await this.cloud?.publish(record);
|
|
117
446
|
return {
|
|
118
447
|
id: record.id,
|
|
119
448
|
record,
|
|
@@ -134,7 +463,7 @@ var ByollmApp = class {
|
|
|
134
463
|
* Check `provenance.untrusted` before rendering. It is true for every
|
|
135
464
|
* `named`/`public` job, because that text came from someone else's machine
|
|
136
465
|
* and the app must not present it as its own AI's answer
|
|
137
|
-
* ({@link MUSTS.
|
|
466
|
+
* ({@link MUSTS.PROVENANCE_NAMES_DEVICE}).
|
|
138
467
|
*/
|
|
139
468
|
async result(jobId) {
|
|
140
469
|
const job = await this.job(jobId);
|
|
@@ -148,7 +477,11 @@ var ByollmApp = class {
|
|
|
148
477
|
}
|
|
149
478
|
/** Ask a runner to stop. Queued jobs cancel at once; held jobs at the next heartbeat. */
|
|
150
479
|
async cancel(jobId) {
|
|
151
|
-
|
|
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;
|
|
152
485
|
}
|
|
153
486
|
/**
|
|
154
487
|
* Is there a live runner that could take a job of this shape?
|
|
@@ -226,13 +559,10 @@ var ByollmApp = class {
|
|
|
226
559
|
* ({@link MUSTS.PAIR_ONE_USER}, {@link MUSTS.PAIR_INTERACTIVE}).
|
|
227
560
|
*/
|
|
228
561
|
async approvePairing(args) {
|
|
229
|
-
const token = generateRunnerToken();
|
|
230
562
|
return this.#store.approvePairing({
|
|
231
563
|
userCode: normalizeUserCode(args.userCode),
|
|
232
564
|
owner: args.owner,
|
|
233
565
|
runnerId: generateRunnerId(),
|
|
234
|
-
runnerToken: token,
|
|
235
|
-
tokenHash: hashSecret(token),
|
|
236
566
|
now: this.#now()
|
|
237
567
|
});
|
|
238
568
|
}
|
|
@@ -277,7 +607,7 @@ function normalizeUserCode(input) {
|
|
|
277
607
|
}
|
|
278
608
|
|
|
279
609
|
// src/keys.ts
|
|
280
|
-
import { StoredKeys, generateKeys, publicIdentityOf as
|
|
610
|
+
import { StoredKeys, generateKeys, publicIdentityOf as publicIdentityOf3 } from "@byollm/protocol";
|
|
281
611
|
import { fingerprint } from "@byollm/protocol";
|
|
282
612
|
var generateSiteKeys = (now = Date.now()) => generateKeys(now);
|
|
283
613
|
function siteKeysFromEnv(variable = "BYOLLM_SITE_KEYS", env = process.env) {
|
|
@@ -305,13 +635,27 @@ function siteKeysFromEnv(variable = "BYOLLM_SITE_KEYS", env = process.env) {
|
|
|
305
635
|
}
|
|
306
636
|
function formatSiteKeys(keys) {
|
|
307
637
|
const encoded = Buffer.from(JSON.stringify(keys)).toString("base64");
|
|
308
|
-
|
|
309
|
-
|
|
638
|
+
const pub = publicIdentityOf3(keys);
|
|
639
|
+
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
|
|
640
|
+
#
|
|
641
|
+
# This is the site's identity. Anything holding it can *be* this site,
|
|
642
|
+
# so it goes wherever your deployment keeps secrets \u2014 never in a repo,
|
|
643
|
+
# never in a browser, never pasted into a dashboard.
|
|
310
644
|
BYOLLM_SITE_KEYS=${encoded}
|
|
311
645
|
|
|
312
|
-
#
|
|
313
|
-
#
|
|
314
|
-
#
|
|
646
|
+
# \u2500\u2500 2. PUBLIC \u2014 paste this line into the byollm dashboard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
647
|
+
#
|
|
648
|
+
# The public half. It proves signatures and seals nothing, so it is
|
|
649
|
+
# safe to publish \u2014 which is the point: users pin it, and the relay
|
|
650
|
+
# cannot forge work without the secret above.
|
|
651
|
+
${JSON.stringify(pub)}
|
|
652
|
+
|
|
653
|
+
# \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
|
|
654
|
+
#
|
|
655
|
+
# A fingerprint is not secret. Show it on your site so somebody
|
|
656
|
+
# connecting can check it against what their daemon printed.
|
|
657
|
+
# The dashboard derives this itself, so there is nothing to paste.
|
|
658
|
+
# ${fingerprint(pub.identity)}
|
|
315
659
|
`;
|
|
316
660
|
}
|
|
317
661
|
|
|
@@ -351,6 +695,7 @@ var MemoryStore = class {
|
|
|
351
695
|
dependsOn,
|
|
352
696
|
state: "queued",
|
|
353
697
|
lease: null,
|
|
698
|
+
completedByLeaseId: null,
|
|
354
699
|
createdAt: now,
|
|
355
700
|
// The TTL clock starts here only if nothing blocks the job.
|
|
356
701
|
claimableAt: blocked ? null : now,
|
|
@@ -440,11 +785,11 @@ var MemoryStore = class {
|
|
|
440
785
|
for (const { jobId, leaseId } of args.leases) {
|
|
441
786
|
const job = this.#jobs.get(jobId);
|
|
442
787
|
if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
|
|
443
|
-
lost.push(jobId);
|
|
788
|
+
lost.push({ jobId, leaseId });
|
|
444
789
|
continue;
|
|
445
790
|
}
|
|
446
791
|
if (job.state !== "claimed" && job.state !== "running") {
|
|
447
|
-
lost.push(jobId);
|
|
792
|
+
lost.push({ jobId, leaseId });
|
|
448
793
|
continue;
|
|
449
794
|
}
|
|
450
795
|
const expiresAt = args.now + args.leaseMs;
|
|
@@ -459,16 +804,45 @@ var MemoryStore = class {
|
|
|
459
804
|
}
|
|
460
805
|
return Promise.resolve({ renewed, lost });
|
|
461
806
|
}
|
|
807
|
+
adopt(args) {
|
|
808
|
+
const job = this.#jobs.get(args.jobId);
|
|
809
|
+
if (!job) return Promise.resolve(null);
|
|
810
|
+
if (job.state !== "queued" && job.state !== "claimed") {
|
|
811
|
+
return Promise.resolve(null);
|
|
812
|
+
}
|
|
813
|
+
if (job.lease && job.lease.id !== args.leaseId) {
|
|
814
|
+
return Promise.resolve(null);
|
|
815
|
+
}
|
|
816
|
+
const updated = {
|
|
817
|
+
...job,
|
|
818
|
+
state: "claimed",
|
|
819
|
+
lease: {
|
|
820
|
+
id: args.leaseId,
|
|
821
|
+
// No runner: this site never paired with the machine holding it.
|
|
822
|
+
runnerId: "",
|
|
823
|
+
expiresAt: args.expiresAt
|
|
824
|
+
},
|
|
825
|
+
updatedAt: args.now
|
|
826
|
+
};
|
|
827
|
+
this.#write(updated.id, updated);
|
|
828
|
+
return Promise.resolve(updated);
|
|
829
|
+
}
|
|
462
830
|
complete(args) {
|
|
463
831
|
const job = this.#jobs.get(args.jobId);
|
|
464
832
|
if (!job) return Promise.resolve({ accepted: false, job: null });
|
|
465
833
|
if (job.state === "ok" || job.state === "error" || job.state === "canceled") {
|
|
834
|
+
const sameDevice = job.provenance?.runnerId !== void 0 && job.provenance.runnerId === args.runnerId;
|
|
835
|
+
const sameGrant = args.holder.by === "lease" && job.completedByLeaseId !== null && job.completedByLeaseId === args.holder.leaseId;
|
|
836
|
+
if (sameDevice && sameGrant) {
|
|
837
|
+
return Promise.resolve({ accepted: false, duplicate: true, job });
|
|
838
|
+
}
|
|
466
839
|
return Promise.resolve({ accepted: false, job });
|
|
467
840
|
}
|
|
468
841
|
if (job.state === "expired") {
|
|
469
842
|
return Promise.resolve({ accepted: false, job });
|
|
470
843
|
}
|
|
471
|
-
|
|
844
|
+
const holds = args.holder.by === "runner" ? job.lease?.runnerId === args.holder.runnerId : job.lease?.id === args.holder.leaseId;
|
|
845
|
+
if (!holds) {
|
|
472
846
|
return Promise.resolve({ accepted: false, job });
|
|
473
847
|
}
|
|
474
848
|
const state = args.outcome.outcome === "ok" ? "ok" : args.outcome.outcome === "canceled" ? "canceled" : "error";
|
|
@@ -476,6 +850,8 @@ var MemoryStore = class {
|
|
|
476
850
|
...job,
|
|
477
851
|
state,
|
|
478
852
|
lease: null,
|
|
853
|
+
// The grant that recorded it, kept after the lease is dropped — §3.6.
|
|
854
|
+
completedByLeaseId: args.holder.by === "lease" ? args.holder.leaseId : job.lease?.id ?? null,
|
|
479
855
|
outcome: args.outcome,
|
|
480
856
|
provenance: args.provenance,
|
|
481
857
|
updatedAt: args.now
|
|
@@ -564,6 +940,7 @@ var MemoryStore = class {
|
|
|
564
940
|
...job,
|
|
565
941
|
state: "queued",
|
|
566
942
|
lease: null,
|
|
943
|
+
completedByLeaseId: null,
|
|
567
944
|
// Newly available again, so the TTL clock restarts here too.
|
|
568
945
|
claimableAt: args.now,
|
|
569
946
|
// A refusal is remembered, or the pair spins between claim and
|
|
@@ -593,6 +970,7 @@ var MemoryStore = class {
|
|
|
593
970
|
...job,
|
|
594
971
|
state: "queued",
|
|
595
972
|
lease: null,
|
|
973
|
+
completedByLeaseId: null,
|
|
596
974
|
// The TTL clock restarts: it measures how long a job has waited
|
|
597
975
|
// *unclaimed*, and this job has just become available again. Without
|
|
598
976
|
// this, a job whose runner died would expire for time it spent being
|
|
@@ -615,6 +993,7 @@ var MemoryStore = class {
|
|
|
615
993
|
...job,
|
|
616
994
|
state: "expired",
|
|
617
995
|
lease: null,
|
|
996
|
+
completedByLeaseId: null,
|
|
618
997
|
updatedAt: now
|
|
619
998
|
};
|
|
620
999
|
this.#write(job.id, expired);
|
|
@@ -630,6 +1009,7 @@ var MemoryStore = class {
|
|
|
630
1009
|
...job,
|
|
631
1010
|
state: "canceled",
|
|
632
1011
|
lease: null,
|
|
1012
|
+
completedByLeaseId: null,
|
|
633
1013
|
updatedAt: now
|
|
634
1014
|
};
|
|
635
1015
|
this.#write(jobId, canceled);
|
|
@@ -650,9 +1030,7 @@ var MemoryStore = class {
|
|
|
650
1030
|
}
|
|
651
1031
|
listCancelRequests(runnerId) {
|
|
652
1032
|
return Promise.resolve(
|
|
653
|
-
[...this.#cancelRequests].filter(
|
|
654
|
-
(jobId) => this.#jobs.get(jobId)?.lease?.runnerId === runnerId
|
|
655
|
-
)
|
|
1033
|
+
[...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 ?? "" }))
|
|
656
1034
|
);
|
|
657
1035
|
}
|
|
658
1036
|
// -- pairing and runners -------------------------------------------------
|
|
@@ -683,7 +1061,6 @@ var MemoryStore = class {
|
|
|
683
1061
|
const runner = {
|
|
684
1062
|
id: args.runnerId,
|
|
685
1063
|
owner: args.owner,
|
|
686
|
-
tokenHash: args.tokenHash,
|
|
687
1064
|
// Carried from the pairing, not re-supplied at approval: the user
|
|
688
1065
|
// approved a specific machine, and the runner must be that machine.
|
|
689
1066
|
device: pairing.device,
|
|
@@ -702,7 +1079,7 @@ var MemoryStore = class {
|
|
|
702
1079
|
state: "approved",
|
|
703
1080
|
owner: args.owner,
|
|
704
1081
|
runnerId: runner.id,
|
|
705
|
-
|
|
1082
|
+
collected: false
|
|
706
1083
|
});
|
|
707
1084
|
return Promise.resolve(runner);
|
|
708
1085
|
}
|
|
@@ -723,17 +1100,11 @@ var MemoryStore = class {
|
|
|
723
1100
|
if (pairing) {
|
|
724
1101
|
this.#pairings.set(deviceCodeHash, {
|
|
725
1102
|
...pairing,
|
|
726
|
-
|
|
1103
|
+
collected: true
|
|
727
1104
|
});
|
|
728
1105
|
}
|
|
729
1106
|
return Promise.resolve();
|
|
730
1107
|
}
|
|
731
|
-
getRunnerByTokenHash(hash) {
|
|
732
|
-
for (const runner of this.#runners.values()) {
|
|
733
|
-
if (runner.tokenHash === hash) return Promise.resolve(runner);
|
|
734
|
-
}
|
|
735
|
-
return Promise.resolve(null);
|
|
736
|
-
}
|
|
737
1108
|
getRunner(runnerId) {
|
|
738
1109
|
return Promise.resolve(this.#runners.get(runnerId) ?? null);
|
|
739
1110
|
}
|
|
@@ -775,9 +1146,11 @@ function capabilityFor(capabilities, kind) {
|
|
|
775
1146
|
export {
|
|
776
1147
|
ByollmApp,
|
|
777
1148
|
ByollmHandlers,
|
|
1149
|
+
CloudLane,
|
|
778
1150
|
MemoryStore,
|
|
779
1151
|
NoRunnerAvailableError,
|
|
780
1152
|
PollingDelivery,
|
|
1153
|
+
RelayUnavailable,
|
|
781
1154
|
ResultTimeoutError,
|
|
782
1155
|
SERVED_PROTOCOL_VERSION,
|
|
783
1156
|
capabilityFor,
|
|
@@ -786,7 +1159,6 @@ export {
|
|
|
786
1159
|
generateDeviceCode,
|
|
787
1160
|
generateJobId,
|
|
788
1161
|
generateRunnerId,
|
|
789
|
-
generateRunnerToken,
|
|
790
1162
|
generateSiteKeys,
|
|
791
1163
|
generateUserCode,
|
|
792
1164
|
hashSecret,
|