@byollm/server 0.1.0-alpha.1 → 0.1.0-alpha.100
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 +256 -18
- package/bin/keygen.mjs +21 -0
- package/dist/chunk-36Y77FUD.js +678 -0
- package/dist/chunk-36Y77FUD.js.map +1 -0
- package/dist/{chunk-7RKXFPBZ.js → chunk-YBNLLQZT.js} +19 -5
- package/dist/chunk-YBNLLQZT.js.map +1 -0
- package/dist/delivery-C5mz_Ykm.d.ts +114 -0
- package/dist/{handlers-D7lWfwno.d.ts → handlers-CTV3Jc6Q.d.ts} +28 -5
- package/dist/index.d.ts +262 -30
- package/dist/index.js +704 -89
- package/dist/index.js.map +1 -1
- package/dist/next.d.ts +40 -8
- package/dist/next.js +8 -2
- package/dist/next.js.map +1 -1
- package/dist/store-Cx2_bck1.d.ts +500 -0
- package/dist/supabase/index.d.ts +2 -2
- package/dist/supabase/index.js +151 -51
- package/dist/supabase/index.js.map +1 -1
- package/package.json +16 -4
- package/supabase/migrations/20260809000000_byollm_runner.sql +22 -3
- package/supabase/migrations/20260819000000_drop_runner_token.sql +87 -0
- package/supabase/migrations/20260819010000_completed_by_lease_id.sql +25 -0
- package/supabase/migrations/20260821000000_rename_collected.sql +91 -0
- package/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-7RKXFPBZ.js.map +0 -1
- package/dist/chunk-HL6EYHQ7.js +0 -422
- package/dist/chunk-HL6EYHQ7.js.map +0 -1
- package/dist/delivery-36nIe-b3.d.ts +0 -73
- package/dist/store-D23N6iiP.d.ts +0 -255
package/dist/index.js
CHANGED
|
@@ -1,63 +1,433 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ByollmHandlers,
|
|
3
3
|
SERVED_PROTOCOL_VERSION,
|
|
4
|
-
bearerFrom,
|
|
5
4
|
createFetchHandler,
|
|
5
|
+
deadlineFor,
|
|
6
6
|
generateDeviceCode,
|
|
7
7
|
generateJobId,
|
|
8
|
+
generateLeaseId,
|
|
8
9
|
generateRunnerId,
|
|
9
|
-
generateRunnerToken,
|
|
10
10
|
generateUserCode,
|
|
11
11
|
hashSecret,
|
|
12
|
+
openSealedOutcome,
|
|
13
|
+
resealForDevice,
|
|
12
14
|
routeEndpoint,
|
|
13
|
-
secretsMatch
|
|
14
|
-
|
|
15
|
+
secretsMatch,
|
|
16
|
+
signatureFrom
|
|
17
|
+
} from "./chunk-36Y77FUD.js";
|
|
15
18
|
import {
|
|
16
19
|
NoRunnerAvailableError,
|
|
17
20
|
PollingDelivery,
|
|
18
21
|
ResultTimeoutError
|
|
19
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-YBNLLQZT.js";
|
|
20
23
|
|
|
21
24
|
// src/app.ts
|
|
22
25
|
import {
|
|
26
|
+
ENVELOPE_MAX_AGE_MS,
|
|
27
|
+
KindedPayload,
|
|
28
|
+
keyId as keyId2,
|
|
29
|
+
payloadTextLength,
|
|
30
|
+
publicIdentityOf as publicIdentityOf2,
|
|
31
|
+
seal,
|
|
32
|
+
sizeClassOf,
|
|
23
33
|
backendDescriptor,
|
|
24
34
|
matchAudience
|
|
25
35
|
} from "@byollm/protocol";
|
|
36
|
+
|
|
37
|
+
// src/cloud.ts
|
|
38
|
+
import {
|
|
39
|
+
PROTOCOL_VERSION,
|
|
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 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";
|
|
68
|
+
var CloudLane = class {
|
|
69
|
+
#options;
|
|
70
|
+
#store;
|
|
71
|
+
#siteKeys;
|
|
72
|
+
#now;
|
|
73
|
+
#fetch;
|
|
74
|
+
constructor(deps) {
|
|
75
|
+
this.#options = deps.options;
|
|
76
|
+
this.#store = deps.store;
|
|
77
|
+
this.#siteKeys = deps.siteKeys;
|
|
78
|
+
this.#now = deps.now;
|
|
79
|
+
this.#fetch = deps.options.fetch ?? globalThis.fetch;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Publish a job's stub for routing.
|
|
83
|
+
*
|
|
84
|
+
* The stub and nothing else — byollm_009 §6 makes that exhaustive by
|
|
85
|
+
* construction, so this cannot leak a payload even by mistake: there is no
|
|
86
|
+
* field on `JobStub` to put one in.
|
|
87
|
+
*/
|
|
88
|
+
async publish(record) {
|
|
89
|
+
const stub = {
|
|
90
|
+
id: record.id,
|
|
91
|
+
kind: record.kind,
|
|
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),
|
|
98
|
+
audience: record.audience,
|
|
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 },
|
|
117
|
+
sizeClass: record.sizeClass,
|
|
118
|
+
streaming: false,
|
|
119
|
+
// The relay needs *a* deadline to bound routing. A job without one gets
|
|
120
|
+
// the envelope's, which is the outer bound on how long the ciphertext
|
|
121
|
+
// is worth carrying — never longer than the work could possibly matter.
|
|
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())
|
|
130
|
+
};
|
|
131
|
+
await this.#post("enqueue", {
|
|
132
|
+
siteId: this.#options.siteId,
|
|
133
|
+
stub
|
|
134
|
+
});
|
|
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
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* One cycle: seal for anything claimed, collect anything finished.
|
|
153
|
+
*
|
|
154
|
+
* Idempotent and safe to call as often as you like. Exposed as a single
|
|
155
|
+
* cycle rather than hidden behind a timer so a caller decides its own
|
|
156
|
+
* cadence — a serverless site runs it on a cron, a long-lived one on an
|
|
157
|
+
* interval, and a test runs it exactly when it means to.
|
|
158
|
+
*/
|
|
159
|
+
async pump() {
|
|
160
|
+
const sealed = [];
|
|
161
|
+
const refused = [];
|
|
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) {
|
|
173
|
+
const pending = await this.#get("pending");
|
|
174
|
+
for (const claim of pending.jobs) {
|
|
175
|
+
const record = await this.#store.get(claim.jobId);
|
|
176
|
+
if (!record) continue;
|
|
177
|
+
const resealed = await resealForDevice({
|
|
178
|
+
siteKeys: this.#siteKeys,
|
|
179
|
+
job: {
|
|
180
|
+
id: record.id,
|
|
181
|
+
envelope: record.envelope,
|
|
182
|
+
createdAt: record.createdAt
|
|
183
|
+
},
|
|
184
|
+
device: claim.device
|
|
185
|
+
});
|
|
186
|
+
if (!resealed.ok) {
|
|
187
|
+
refused.push(claim.jobId);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const adopted = await this.#store.adopt({
|
|
191
|
+
jobId: claim.jobId,
|
|
192
|
+
leaseId: claim.leaseId,
|
|
193
|
+
expiresAt: claim.leaseExpiresAt,
|
|
194
|
+
now: this.#now()
|
|
195
|
+
});
|
|
196
|
+
if (!adopted) {
|
|
197
|
+
refused.push(claim.jobId);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
await this.#post("payload", {
|
|
201
|
+
siteId: this.#options.siteId,
|
|
202
|
+
jobId: claim.jobId,
|
|
203
|
+
envelope: resealed.envelope
|
|
204
|
+
});
|
|
205
|
+
sealed.push(claim.jobId);
|
|
206
|
+
}
|
|
207
|
+
const finished = await this.#get("results");
|
|
208
|
+
for (const done of finished.jobs) {
|
|
209
|
+
const record = await this.#store.get(done.jobId);
|
|
210
|
+
if (!record || record.state === "ok" || record.state === "error") {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const outcome = await this.#openResult(done);
|
|
214
|
+
if (!outcome) {
|
|
215
|
+
refused.push(done.jobId);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
await this.#store.complete({
|
|
219
|
+
jobId: done.jobId,
|
|
220
|
+
// The relay named the device; the signature above proved it — §3.6.
|
|
221
|
+
runnerId: done.runnerId,
|
|
222
|
+
// The grant, not the machine: this site never paired with the device
|
|
223
|
+
// that ran it, and the signature it verified above is the stronger
|
|
224
|
+
// claim about who did.
|
|
225
|
+
holder: { by: "lease", leaseId: done.leaseId },
|
|
226
|
+
outcome: outcome.outcome,
|
|
227
|
+
provenance: provenanceFor({
|
|
228
|
+
audience: record.audience,
|
|
229
|
+
runnerId: done.runnerId,
|
|
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
|
|
244
|
+
}),
|
|
245
|
+
now: this.#now()
|
|
246
|
+
});
|
|
247
|
+
completed.push(done.jobId);
|
|
248
|
+
}
|
|
249
|
+
return { sealed, completed, refused };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Open a sealed result and verify it came from the device that claimed it.
|
|
253
|
+
*
|
|
254
|
+
* The relay says which device ran the job; this checks that claim against a
|
|
255
|
+
* signature the relay cannot produce. A relay that named the wrong device
|
|
256
|
+
* gets a refusal, not a stored result — which is what keeps `RELAY_BLIND`
|
|
257
|
+
* from quietly becoming `RELAY_TRUSTED`.
|
|
258
|
+
*/
|
|
259
|
+
async #openResult(done) {
|
|
260
|
+
const opened = await open({
|
|
261
|
+
envelope: done.envelope,
|
|
262
|
+
recipientKeys: this.#siteKeys,
|
|
263
|
+
senderIdentityPublic: done.device.identity,
|
|
264
|
+
expected: {
|
|
265
|
+
jobId: done.jobId,
|
|
266
|
+
senderKeyId: keyId(done.device.identity),
|
|
267
|
+
recipientKeyId: keyId(publicIdentityOf(this.#siteKeys).identity),
|
|
268
|
+
direction: "result"
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
if (!opened.ok) return null;
|
|
272
|
+
const outcome = openSealedOutcome({
|
|
273
|
+
plaintext: opened.plaintext,
|
|
274
|
+
disposition: done.disposition
|
|
275
|
+
});
|
|
276
|
+
return outcome.ok ? outcome.value : null;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Sign a site-plane call with this site's identity key.
|
|
280
|
+
*
|
|
281
|
+
* The same scheme the daemon uses against an upstream, because the site is
|
|
282
|
+
* in the same position: an outbound caller whose key the relay already holds
|
|
283
|
+
* for other reasons. Nothing else authenticates this plane — a relay that
|
|
284
|
+
* took the `siteId` in a body at face value would let anyone enqueue work in
|
|
285
|
+
* a site's name and read who claimed it.
|
|
286
|
+
*/
|
|
287
|
+
#headers(endpoint, rawBody) {
|
|
288
|
+
const signature = signSiteRequest(this.#siteKeys, {
|
|
289
|
+
endpoint,
|
|
290
|
+
siteId: this.#options.siteId,
|
|
291
|
+
issuedAt: this.#now(),
|
|
292
|
+
body: rawBody
|
|
293
|
+
});
|
|
294
|
+
return {
|
|
295
|
+
"x-byollm-site": this.#options.siteId,
|
|
296
|
+
"x-byollm-issued-at": String(signature.issuedAt),
|
|
297
|
+
"x-byollm-signature": signature.signature
|
|
298
|
+
};
|
|
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
|
+
}
|
|
336
|
+
async #post(endpoint, body) {
|
|
337
|
+
const rawBody = JSON.stringify({
|
|
338
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
339
|
+
...body
|
|
340
|
+
});
|
|
341
|
+
const response = await this.#fetch(
|
|
342
|
+
`${this.#options.relayOrigin}/relay/site/${endpoint}`,
|
|
343
|
+
{
|
|
344
|
+
method: "POST",
|
|
345
|
+
headers: {
|
|
346
|
+
"content-type": "application/json",
|
|
347
|
+
...this.#headers(endpoint, rawBody)
|
|
348
|
+
},
|
|
349
|
+
body: rawBody
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
return this.#answer(response, endpoint);
|
|
353
|
+
}
|
|
354
|
+
async #get(endpoint) {
|
|
355
|
+
const url = `${this.#options.relayOrigin}/relay/site/${endpoint}?siteId=${encodeURIComponent(this.#options.siteId)}&protocolVersion=${encodeURIComponent(PROTOCOL_VERSION)}`;
|
|
356
|
+
const response = await this.#fetch(url, {
|
|
357
|
+
headers: this.#headers(endpoint, "")
|
|
358
|
+
});
|
|
359
|
+
return this.#answer(response, endpoint);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// src/app.ts
|
|
26
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
|
+
});
|
|
27
377
|
var ByollmApp = class {
|
|
28
378
|
#store;
|
|
379
|
+
#siteKeys;
|
|
29
380
|
#now;
|
|
30
381
|
#livenessMs;
|
|
31
382
|
#delivery;
|
|
383
|
+
/** Present only in the cloud lane; the site's side of the relay. */
|
|
384
|
+
cloud;
|
|
32
385
|
constructor(options) {
|
|
33
386
|
this.#store = options.store;
|
|
387
|
+
this.#siteKeys = options.siteKeys;
|
|
34
388
|
this.#now = options.now ?? Date.now;
|
|
35
389
|
this.#livenessMs = options.livenessMs ?? DEFAULT_LIVENESS_MS;
|
|
390
|
+
this.cloud = options.lane === void 0 ? void 0 : new CloudLane({
|
|
391
|
+
options: options.lane,
|
|
392
|
+
store: options.store,
|
|
393
|
+
siteKeys: options.siteKeys,
|
|
394
|
+
now: this.#now
|
|
395
|
+
});
|
|
36
396
|
const deps = {
|
|
37
397
|
...options.noRunnerGraceMs === void 0 ? {} : { graceMs: options.noRunnerGraceMs },
|
|
38
398
|
read: (jobId) => this.result(jobId),
|
|
39
|
-
availability:
|
|
40
|
-
const job = await this.#store.get(jobId);
|
|
41
|
-
if (!job)
|
|
42
|
-
return { available: false, reason: "unknown-job", blocked: false };
|
|
43
|
-
if (job.claimableAt === null) {
|
|
44
|
-
return { available: true, blocked: true };
|
|
45
|
-
}
|
|
46
|
-
const availability = await this.runnerAvailability({
|
|
47
|
-
kind: job.kind,
|
|
48
|
-
owner: job.owner,
|
|
49
|
-
audience: job.audience,
|
|
50
|
-
...job.audienceAllow === void 0 ? {} : { audienceAllow: job.audienceAllow }
|
|
51
|
-
});
|
|
52
|
-
return {
|
|
53
|
-
available: availability.available,
|
|
54
|
-
...availability.reason === void 0 ? {} : { reason: availability.reason },
|
|
55
|
-
blocked: false
|
|
56
|
-
};
|
|
57
|
-
}
|
|
399
|
+
...this.cloud !== void 0 ? {} : { availability: this.#availabilityFor() }
|
|
58
400
|
};
|
|
59
401
|
this.#delivery = options.delivery?.(deps) ?? new PollingDelivery(deps);
|
|
60
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
|
+
}
|
|
61
431
|
/**
|
|
62
432
|
* Enqueue a job.
|
|
63
433
|
*
|
|
@@ -66,7 +436,79 @@ var ByollmApp = class {
|
|
|
66
436
|
* the app is obliged to disclose that to whoever reads it.
|
|
67
437
|
*/
|
|
68
438
|
async enqueue(input) {
|
|
69
|
-
const
|
|
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
|
+
}
|
|
452
|
+
const parsed = KindedPayload.safeParse({
|
|
453
|
+
kind: input.kind,
|
|
454
|
+
payload: input.payload
|
|
455
|
+
});
|
|
456
|
+
if (!parsed.success) {
|
|
457
|
+
const detail = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
458
|
+
throw new Error(`invalid ${input.kind} payload \u2014 ${detail}`);
|
|
459
|
+
}
|
|
460
|
+
const createdAt = this.#now();
|
|
461
|
+
const envelopeDeadlineAt = createdAt + ENVELOPE_MAX_AGE_MS;
|
|
462
|
+
const jobId = input.id ?? generateJobId();
|
|
463
|
+
const senderKeyId = keyId2(publicIdentityOf2(this.#siteKeys).identity);
|
|
464
|
+
const envelope = await seal({
|
|
465
|
+
plaintext: JSON.stringify(parsed.data.payload),
|
|
466
|
+
senderKeys: this.#siteKeys,
|
|
467
|
+
recipientEncryptionPublic: this.#siteKeys.encryptionPublic,
|
|
468
|
+
context: {
|
|
469
|
+
jobId,
|
|
470
|
+
senderKeyId,
|
|
471
|
+
recipientKeyId: senderKeyId,
|
|
472
|
+
deadlineAt: envelopeDeadlineAt,
|
|
473
|
+
direction: "payload"
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
const record = await this.#store.create(
|
|
477
|
+
{
|
|
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" },
|
|
500
|
+
id: jobId,
|
|
501
|
+
envelope,
|
|
502
|
+
sizeClass: sizeClassOf(
|
|
503
|
+
payloadTextLength({
|
|
504
|
+
kind: input.kind,
|
|
505
|
+
payload: parsed.data.payload
|
|
506
|
+
})
|
|
507
|
+
)
|
|
508
|
+
},
|
|
509
|
+
createdAt
|
|
510
|
+
);
|
|
511
|
+
await this.cloud?.publish(record);
|
|
70
512
|
return {
|
|
71
513
|
id: record.id,
|
|
72
514
|
record,
|
|
@@ -87,7 +529,7 @@ var ByollmApp = class {
|
|
|
87
529
|
* Check `provenance.untrusted` before rendering. It is true for every
|
|
88
530
|
* `named`/`public` job, because that text came from someone else's machine
|
|
89
531
|
* and the app must not present it as its own AI's answer
|
|
90
|
-
* ({@link MUSTS.
|
|
532
|
+
* ({@link MUSTS.PROVENANCE_NAMES_DEVICE}).
|
|
91
533
|
*/
|
|
92
534
|
async result(jobId) {
|
|
93
535
|
const job = await this.job(jobId);
|
|
@@ -101,7 +543,11 @@ var ByollmApp = class {
|
|
|
101
543
|
}
|
|
102
544
|
/** Ask a runner to stop. Queued jobs cancel at once; held jobs at the next heartbeat. */
|
|
103
545
|
async cancel(jobId) {
|
|
104
|
-
|
|
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;
|
|
105
551
|
}
|
|
106
552
|
/**
|
|
107
553
|
* Is there a live runner that could take a job of this shape?
|
|
@@ -110,6 +556,11 @@ var ByollmApp = class {
|
|
|
110
556
|
* signal cannot promise a runner the claim would then refuse.
|
|
111
557
|
*/
|
|
112
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
|
+
}
|
|
113
564
|
const now = this.#now();
|
|
114
565
|
const all = await this.#store.listRunners();
|
|
115
566
|
const live = all.filter(
|
|
@@ -123,26 +574,41 @@ var ByollmApp = class {
|
|
|
123
574
|
}
|
|
124
575
|
let capable = 0;
|
|
125
576
|
let admitted = 0;
|
|
577
|
+
let lastRefusal;
|
|
126
578
|
for (const runner of live) {
|
|
127
|
-
const capability
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
+
}
|
|
146
612
|
}
|
|
147
613
|
if (capable === 0) {
|
|
148
614
|
return {
|
|
@@ -152,9 +618,10 @@ var ByollmApp = class {
|
|
|
152
618
|
};
|
|
153
619
|
}
|
|
154
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";
|
|
155
622
|
return {
|
|
156
623
|
available: false,
|
|
157
|
-
reason: "audience-admits-nobody",
|
|
624
|
+
reason: ownersDoing ? "default-unusable" : "audience-admits-nobody",
|
|
158
625
|
candidates: 0
|
|
159
626
|
};
|
|
160
627
|
}
|
|
@@ -168,13 +635,10 @@ var ByollmApp = class {
|
|
|
168
635
|
* ({@link MUSTS.PAIR_ONE_USER}, {@link MUSTS.PAIR_INTERACTIVE}).
|
|
169
636
|
*/
|
|
170
637
|
async approvePairing(args) {
|
|
171
|
-
const token = generateRunnerToken();
|
|
172
638
|
return this.#store.approvePairing({
|
|
173
639
|
userCode: normalizeUserCode(args.userCode),
|
|
174
640
|
owner: args.owner,
|
|
175
641
|
runnerId: generateRunnerId(),
|
|
176
|
-
runnerToken: token,
|
|
177
|
-
tokenHash: hashSecret(token),
|
|
178
642
|
now: this.#now()
|
|
179
643
|
});
|
|
180
644
|
}
|
|
@@ -218,6 +682,59 @@ function normalizeUserCode(input) {
|
|
|
218
682
|
return bare.length === 8 ? `${bare.slice(0, 4)}-${bare.slice(4)}` : bare;
|
|
219
683
|
}
|
|
220
684
|
|
|
685
|
+
// src/keys.ts
|
|
686
|
+
import { StoredKeys, generateKeys, publicIdentityOf as publicIdentityOf3 } from "@byollm/protocol";
|
|
687
|
+
import { fingerprint } from "@byollm/protocol";
|
|
688
|
+
var generateSiteKeys = (now = Date.now()) => generateKeys(now);
|
|
689
|
+
function siteKeysFromEnv(variable = "BYOLLM_SITE_KEYS", env = process.env) {
|
|
690
|
+
const raw = env[variable];
|
|
691
|
+
if (raw === void 0 || raw === "") {
|
|
692
|
+
throw new Error(
|
|
693
|
+
`${variable} is not set. Generate a site identity once with \`npx @byollm/server keygen\` and set it as ${variable}. Do not generate keys at startup: every instance would get a different identity and daemons would pin one and be refused by another.`
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
let parsed;
|
|
697
|
+
try {
|
|
698
|
+
parsed = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
|
|
699
|
+
} catch {
|
|
700
|
+
throw new Error(
|
|
701
|
+
`${variable} is not base64-encoded JSON. It should be exactly what \`npx @byollm/server keygen\` printed.`
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
const result = StoredKeys.safeParse(parsed);
|
|
705
|
+
if (!result.success) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
`${variable} does not contain a valid site identity. Regenerate it with \`npx @byollm/server keygen\` \u2014 and if this site has already paired daemons, they will need to pair again.`
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
return result.data;
|
|
711
|
+
}
|
|
712
|
+
function formatSiteKeys(keys) {
|
|
713
|
+
const encoded = Buffer.from(JSON.stringify(keys)).toString("base64");
|
|
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.
|
|
720
|
+
BYOLLM_SITE_KEYS=${encoded}
|
|
721
|
+
|
|
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)}
|
|
735
|
+
`;
|
|
736
|
+
}
|
|
737
|
+
|
|
221
738
|
// src/memory.ts
|
|
222
739
|
import {
|
|
223
740
|
backendDescriptor as backendDescriptor2,
|
|
@@ -236,7 +753,7 @@ var MemoryStore = class {
|
|
|
236
753
|
}
|
|
237
754
|
// -- jobs ---------------------------------------------------------------
|
|
238
755
|
create(input, now) {
|
|
239
|
-
const id = input.id
|
|
756
|
+
const id = input.id;
|
|
240
757
|
const existing = this.#jobs.get(id);
|
|
241
758
|
if (existing) return Promise.resolve(existing);
|
|
242
759
|
const dependsOn = [...input.dependsOn ?? []];
|
|
@@ -246,13 +763,16 @@ var MemoryStore = class {
|
|
|
246
763
|
const job = {
|
|
247
764
|
id,
|
|
248
765
|
kind: input.kind,
|
|
249
|
-
|
|
250
|
-
|
|
766
|
+
envelope: input.envelope,
|
|
767
|
+
sizeClass: input.sizeClass,
|
|
768
|
+
audience: input.audience ?? "private",
|
|
769
|
+
purpose: input.purpose,
|
|
251
770
|
owner: input.owner,
|
|
252
771
|
audienceAllow: input.audienceAllow ? [...input.audienceAllow] : void 0,
|
|
253
772
|
dependsOn,
|
|
254
773
|
state: "queued",
|
|
255
774
|
lease: null,
|
|
775
|
+
completedByLeaseId: null,
|
|
256
776
|
createdAt: now,
|
|
257
777
|
// The TTL clock starts here only if nothing blocks the job.
|
|
258
778
|
claimableAt: blocked ? null : now,
|
|
@@ -264,7 +784,7 @@ var MemoryStore = class {
|
|
|
264
784
|
provenance: null,
|
|
265
785
|
updatedAt: now
|
|
266
786
|
};
|
|
267
|
-
this.#
|
|
787
|
+
this.#write(id, job);
|
|
268
788
|
return Promise.resolve(job);
|
|
269
789
|
}
|
|
270
790
|
get(jobId) {
|
|
@@ -283,13 +803,16 @@ var MemoryStore = class {
|
|
|
283
803
|
...job,
|
|
284
804
|
state: "claimed",
|
|
285
805
|
lease: {
|
|
806
|
+
// A fresh id per grant. Two claims of the same job by the same
|
|
807
|
+
// runner are two different leases, and must be distinguishable.
|
|
808
|
+
id: generateLeaseId(),
|
|
286
809
|
runnerId: args.runnerId,
|
|
287
810
|
expiresAt: args.now + args.leaseMs
|
|
288
811
|
},
|
|
289
812
|
attempts: job.attempts + 1,
|
|
290
813
|
updatedAt: args.now
|
|
291
814
|
};
|
|
292
|
-
this.#
|
|
815
|
+
this.#write(job.id, updated);
|
|
293
816
|
claimed.push(updated);
|
|
294
817
|
}
|
|
295
818
|
return Promise.resolve(claimed);
|
|
@@ -313,14 +836,21 @@ var MemoryStore = class {
|
|
|
313
836
|
{
|
|
314
837
|
owner: args.runnerOwner,
|
|
315
838
|
offerScope: capability.offerScope,
|
|
316
|
-
// From the registry, not a local guess — the
|
|
317
|
-
//
|
|
318
|
-
|
|
839
|
+
// From the registry, not a local guess — the cost rules must mean the
|
|
840
|
+
// same thing on both sides of the wire. The server cannot see a
|
|
841
|
+
// remote daemon's base URL, so a generic backend with no declared
|
|
842
|
+
// cost is treated as metered: the expensive side, and the daemon
|
|
843
|
+
// refuses anyway if it disagrees (byollm_007 §2).
|
|
844
|
+
cost: backendDescriptor2(capability.backendId).cost ?? "metered",
|
|
845
|
+
// Nor can it see the owner's spend consent. It offers; the daemon is
|
|
846
|
+
// the enforcing side and releases with `refused` if its own rules say
|
|
847
|
+
// no — the same shape as the `named` allowlist.
|
|
848
|
+
spend: { acknowledged: true },
|
|
319
849
|
// The server cannot see a remote daemon's local allowlist and must
|
|
320
850
|
// not pretend to (protocol §4.2). It admits the job here; the daemon
|
|
321
851
|
// is the enforcing side and releases with `refused` if its own list
|
|
322
852
|
// says no.
|
|
323
|
-
|
|
853
|
+
admits: () => true
|
|
324
854
|
}
|
|
325
855
|
);
|
|
326
856
|
return match.ok;
|
|
@@ -329,37 +859,67 @@ var MemoryStore = class {
|
|
|
329
859
|
this.#expireDueSync(args.now);
|
|
330
860
|
const renewed = [];
|
|
331
861
|
const lost = [];
|
|
332
|
-
for (const jobId of args.
|
|
862
|
+
for (const { jobId, leaseId } of args.leases) {
|
|
333
863
|
const job = this.#jobs.get(jobId);
|
|
334
|
-
if (!job || job.lease?.runnerId !== args.runnerId) {
|
|
335
|
-
lost.push(jobId);
|
|
864
|
+
if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
|
|
865
|
+
lost.push({ jobId, leaseId });
|
|
336
866
|
continue;
|
|
337
867
|
}
|
|
338
868
|
if (job.state !== "claimed" && job.state !== "running") {
|
|
339
|
-
lost.push(jobId);
|
|
869
|
+
lost.push({ jobId, leaseId });
|
|
340
870
|
continue;
|
|
341
871
|
}
|
|
342
872
|
const expiresAt = args.now + args.leaseMs;
|
|
343
|
-
this.#
|
|
873
|
+
this.#write(jobId, {
|
|
344
874
|
...job,
|
|
345
875
|
state: "running",
|
|
346
|
-
|
|
876
|
+
// Renewal extends the existing grant; it does not mint a new one.
|
|
877
|
+
lease: { ...job.lease, expiresAt },
|
|
347
878
|
updatedAt: args.now
|
|
348
879
|
});
|
|
349
880
|
renewed.push({ jobId, expiresAt });
|
|
350
881
|
}
|
|
351
882
|
return Promise.resolve({ renewed, lost });
|
|
352
883
|
}
|
|
884
|
+
adopt(args) {
|
|
885
|
+
const job = this.#jobs.get(args.jobId);
|
|
886
|
+
if (!job) return Promise.resolve(null);
|
|
887
|
+
if (job.state !== "queued" && job.state !== "claimed") {
|
|
888
|
+
return Promise.resolve(null);
|
|
889
|
+
}
|
|
890
|
+
if (job.lease && job.lease.id !== args.leaseId) {
|
|
891
|
+
return Promise.resolve(null);
|
|
892
|
+
}
|
|
893
|
+
const updated = {
|
|
894
|
+
...job,
|
|
895
|
+
state: "claimed",
|
|
896
|
+
lease: {
|
|
897
|
+
id: args.leaseId,
|
|
898
|
+
// No runner: this site never paired with the machine holding it.
|
|
899
|
+
runnerId: "",
|
|
900
|
+
expiresAt: args.expiresAt
|
|
901
|
+
},
|
|
902
|
+
updatedAt: args.now
|
|
903
|
+
};
|
|
904
|
+
this.#write(updated.id, updated);
|
|
905
|
+
return Promise.resolve(updated);
|
|
906
|
+
}
|
|
353
907
|
complete(args) {
|
|
354
908
|
const job = this.#jobs.get(args.jobId);
|
|
355
909
|
if (!job) return Promise.resolve({ accepted: false, job: null });
|
|
356
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
|
+
}
|
|
357
916
|
return Promise.resolve({ accepted: false, job });
|
|
358
917
|
}
|
|
359
918
|
if (job.state === "expired") {
|
|
360
919
|
return Promise.resolve({ accepted: false, job });
|
|
361
920
|
}
|
|
362
|
-
|
|
921
|
+
const holds = args.holder.by === "runner" ? job.lease?.runnerId === args.holder.runnerId : job.lease?.id === args.holder.leaseId;
|
|
922
|
+
if (!holds) {
|
|
363
923
|
return Promise.resolve({ accepted: false, job });
|
|
364
924
|
}
|
|
365
925
|
const state = args.outcome.outcome === "ok" ? "ok" : args.outcome.outcome === "canceled" ? "canceled" : "error";
|
|
@@ -367,11 +927,13 @@ var MemoryStore = class {
|
|
|
367
927
|
...job,
|
|
368
928
|
state,
|
|
369
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,
|
|
370
932
|
outcome: args.outcome,
|
|
371
933
|
provenance: args.provenance,
|
|
372
934
|
updatedAt: args.now
|
|
373
935
|
};
|
|
374
|
-
this.#
|
|
936
|
+
this.#write(job.id, updated);
|
|
375
937
|
this.#cancelRequests.delete(job.id);
|
|
376
938
|
if (state === "ok") this.#unblockDependents(job.id, args.now);
|
|
377
939
|
return Promise.resolve({ accepted: true, job: updated });
|
|
@@ -392,19 +954,70 @@ var MemoryStore = class {
|
|
|
392
954
|
(depId) => this.#jobs.get(depId)?.state === "ok"
|
|
393
955
|
);
|
|
394
956
|
if (ready) {
|
|
395
|
-
this.#
|
|
957
|
+
this.#write(job.id, { ...job, claimableAt: now, updatedAt: now });
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Watchers, by job id (byollm_009 §8.3).
|
|
963
|
+
*
|
|
964
|
+
* A `Set` per job so an unsubscribe removes exactly the handler it
|
|
965
|
+
* registered — two waiters on the same job are ordinary, and removing by
|
|
966
|
+
* job id alone would silently cancel someone else's wait.
|
|
967
|
+
*/
|
|
968
|
+
#watchers = /* @__PURE__ */ new Map();
|
|
969
|
+
subscribe(jobId, onChange) {
|
|
970
|
+
const existing = this.#watchers.get(jobId) ?? /* @__PURE__ */ new Set();
|
|
971
|
+
existing.add(onChange);
|
|
972
|
+
this.#watchers.set(jobId, existing);
|
|
973
|
+
let live = true;
|
|
974
|
+
return () => {
|
|
975
|
+
if (!live) return;
|
|
976
|
+
live = false;
|
|
977
|
+
const set = this.#watchers.get(jobId);
|
|
978
|
+
set?.delete(onChange);
|
|
979
|
+
if (set?.size === 0) this.#watchers.delete(jobId);
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* The single write path for a job.
|
|
984
|
+
*
|
|
985
|
+
* Every mutation goes through here so notification cannot be forgotten by
|
|
986
|
+
* a future one. Nine call sites existed when the push seam was added, and
|
|
987
|
+
* "remember to notify" is not a property nine call sites keep.
|
|
988
|
+
*/
|
|
989
|
+
#write(jobId, record) {
|
|
990
|
+
this.#jobs.set(jobId, record);
|
|
991
|
+
this.#notify(jobId);
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Tell anyone watching that a job changed.
|
|
995
|
+
*
|
|
996
|
+
* A throwing watcher must not corrupt the store's own bookkeeping, so each
|
|
997
|
+
* is isolated: this runs inside write paths, and one bad listener taking
|
|
998
|
+
* out an unrelated write would be a far worse failure than a missed
|
|
999
|
+
* notification.
|
|
1000
|
+
*/
|
|
1001
|
+
#notify(jobId) {
|
|
1002
|
+
for (const watcher of this.#watchers.get(jobId) ?? []) {
|
|
1003
|
+
try {
|
|
1004
|
+
watcher();
|
|
1005
|
+
} catch {
|
|
396
1006
|
}
|
|
397
1007
|
}
|
|
398
1008
|
}
|
|
399
1009
|
release(args) {
|
|
400
1010
|
const released = [];
|
|
401
|
-
for (const jobId of args.
|
|
1011
|
+
for (const { jobId, leaseId } of args.leases) {
|
|
402
1012
|
const job = this.#jobs.get(jobId);
|
|
403
|
-
if (!job || job.lease?.runnerId !== args.runnerId)
|
|
404
|
-
|
|
1013
|
+
if (!job || job.lease?.runnerId !== args.runnerId || job.lease.id !== leaseId) {
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
this.#write(jobId, {
|
|
405
1017
|
...job,
|
|
406
1018
|
state: "queued",
|
|
407
1019
|
lease: null,
|
|
1020
|
+
completedByLeaseId: null,
|
|
408
1021
|
// Newly available again, so the TTL clock restarts here too.
|
|
409
1022
|
claimableAt: args.now,
|
|
410
1023
|
// A refusal is remembered, or the pair spins between claim and
|
|
@@ -434,6 +1047,7 @@ var MemoryStore = class {
|
|
|
434
1047
|
...job,
|
|
435
1048
|
state: "queued",
|
|
436
1049
|
lease: null,
|
|
1050
|
+
completedByLeaseId: null,
|
|
437
1051
|
// The TTL clock restarts: it measures how long a job has waited
|
|
438
1052
|
// *unclaimed*, and this job has just become available again. Without
|
|
439
1053
|
// this, a job whose runner died would expire for time it spent being
|
|
@@ -443,7 +1057,7 @@ var MemoryStore = class {
|
|
|
443
1057
|
claimableAt: now,
|
|
444
1058
|
updatedAt: now
|
|
445
1059
|
};
|
|
446
|
-
this.#
|
|
1060
|
+
this.#write(job.id, requeued);
|
|
447
1061
|
changed.push(requeued);
|
|
448
1062
|
}
|
|
449
1063
|
}
|
|
@@ -456,9 +1070,10 @@ var MemoryStore = class {
|
|
|
456
1070
|
...job,
|
|
457
1071
|
state: "expired",
|
|
458
1072
|
lease: null,
|
|
1073
|
+
completedByLeaseId: null,
|
|
459
1074
|
updatedAt: now
|
|
460
1075
|
};
|
|
461
|
-
this.#
|
|
1076
|
+
this.#write(job.id, expired);
|
|
462
1077
|
changed.push(expired);
|
|
463
1078
|
}
|
|
464
1079
|
return changed;
|
|
@@ -471,9 +1086,10 @@ var MemoryStore = class {
|
|
|
471
1086
|
...job,
|
|
472
1087
|
state: "canceled",
|
|
473
1088
|
lease: null,
|
|
1089
|
+
completedByLeaseId: null,
|
|
474
1090
|
updatedAt: now
|
|
475
1091
|
};
|
|
476
|
-
this.#
|
|
1092
|
+
this.#write(jobId, canceled);
|
|
477
1093
|
return Promise.resolve(canceled);
|
|
478
1094
|
}
|
|
479
1095
|
if (job.state === "claimed" || job.state === "running") {
|
|
@@ -491,9 +1107,7 @@ var MemoryStore = class {
|
|
|
491
1107
|
}
|
|
492
1108
|
listCancelRequests(runnerId) {
|
|
493
1109
|
return Promise.resolve(
|
|
494
|
-
[...this.#cancelRequests].filter(
|
|
495
|
-
(jobId) => this.#jobs.get(jobId)?.lease?.runnerId === runnerId
|
|
496
|
-
)
|
|
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 ?? "" }))
|
|
497
1111
|
);
|
|
498
1112
|
}
|
|
499
1113
|
// -- pairing and runners -------------------------------------------------
|
|
@@ -524,7 +1138,9 @@ var MemoryStore = class {
|
|
|
524
1138
|
const runner = {
|
|
525
1139
|
id: args.runnerId,
|
|
526
1140
|
owner: args.owner,
|
|
527
|
-
|
|
1141
|
+
// Carried from the pairing, not re-supplied at approval: the user
|
|
1142
|
+
// approved a specific machine, and the runner must be that machine.
|
|
1143
|
+
device: pairing.device,
|
|
528
1144
|
label: pairing.label,
|
|
529
1145
|
platform: pairing.platform,
|
|
530
1146
|
daemonVersion: pairing.daemonVersion,
|
|
@@ -540,7 +1156,7 @@ var MemoryStore = class {
|
|
|
540
1156
|
state: "approved",
|
|
541
1157
|
owner: args.owner,
|
|
542
1158
|
runnerId: runner.id,
|
|
543
|
-
|
|
1159
|
+
collected: false
|
|
544
1160
|
});
|
|
545
1161
|
return Promise.resolve(runner);
|
|
546
1162
|
}
|
|
@@ -561,17 +1177,11 @@ var MemoryStore = class {
|
|
|
561
1177
|
if (pairing) {
|
|
562
1178
|
this.#pairings.set(deviceCodeHash, {
|
|
563
1179
|
...pairing,
|
|
564
|
-
|
|
1180
|
+
collected: true
|
|
565
1181
|
});
|
|
566
1182
|
}
|
|
567
1183
|
return Promise.resolve();
|
|
568
1184
|
}
|
|
569
|
-
getRunnerByTokenHash(hash) {
|
|
570
|
-
for (const runner of this.#runners.values()) {
|
|
571
|
-
if (runner.tokenHash === hash) return Promise.resolve(runner);
|
|
572
|
-
}
|
|
573
|
-
return Promise.resolve(null);
|
|
574
|
-
}
|
|
575
1185
|
getRunner(runnerId) {
|
|
576
1186
|
return Promise.resolve(this.#runners.get(runnerId) ?? null);
|
|
577
1187
|
}
|
|
@@ -613,22 +1223,27 @@ function capabilityFor(capabilities, kind) {
|
|
|
613
1223
|
export {
|
|
614
1224
|
ByollmApp,
|
|
615
1225
|
ByollmHandlers,
|
|
1226
|
+
CloudLane,
|
|
1227
|
+
EnqueueRefused,
|
|
616
1228
|
MemoryStore,
|
|
617
1229
|
NoRunnerAvailableError,
|
|
618
1230
|
PollingDelivery,
|
|
1231
|
+
RelayUnavailable,
|
|
619
1232
|
ResultTimeoutError,
|
|
620
1233
|
SERVED_PROTOCOL_VERSION,
|
|
621
|
-
bearerFrom,
|
|
622
1234
|
capabilityFor,
|
|
623
1235
|
createFetchHandler,
|
|
1236
|
+
formatSiteKeys,
|
|
624
1237
|
generateDeviceCode,
|
|
625
1238
|
generateJobId,
|
|
626
1239
|
generateRunnerId,
|
|
627
|
-
|
|
1240
|
+
generateSiteKeys,
|
|
628
1241
|
generateUserCode,
|
|
629
1242
|
hashSecret,
|
|
630
1243
|
normalizeUserCode,
|
|
631
1244
|
routeEndpoint,
|
|
632
|
-
secretsMatch
|
|
1245
|
+
secretsMatch,
|
|
1246
|
+
signatureFrom,
|
|
1247
|
+
siteKeysFromEnv
|
|
633
1248
|
};
|
|
634
1249
|
//# sourceMappingURL=index.js.map
|