@byollm/server 0.1.0-alpha.0
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/LICENSE +21 -0
- package/README.md +146 -0
- package/dist/chunk-7RKXFPBZ.js +72 -0
- package/dist/chunk-7RKXFPBZ.js.map +1 -0
- package/dist/chunk-HL6EYHQ7.js +422 -0
- package/dist/chunk-HL6EYHQ7.js.map +1 -0
- package/dist/delivery-36nIe-b3.d.ts +73 -0
- package/dist/handlers-D7lWfwno.d.ts +52 -0
- package/dist/index.d.ts +223 -0
- package/dist/index.js +634 -0
- package/dist/index.js.map +1 -0
- package/dist/next.d.ts +33 -0
- package/dist/next.js +17 -0
- package/dist/next.js.map +1 -0
- package/dist/store-D23N6iiP.d.ts +255 -0
- package/dist/supabase/index.d.ts +30 -0
- package/dist/supabase/index.js +414 -0
- package/dist/supabase/index.js.map +1 -0
- package/package.json +52 -0
- package/supabase/migrations/20260809000000_byollm_runner.sql +507 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ByollmHandlers,
|
|
3
|
+
SERVED_PROTOCOL_VERSION,
|
|
4
|
+
bearerFrom,
|
|
5
|
+
createFetchHandler,
|
|
6
|
+
generateDeviceCode,
|
|
7
|
+
generateJobId,
|
|
8
|
+
generateRunnerId,
|
|
9
|
+
generateRunnerToken,
|
|
10
|
+
generateUserCode,
|
|
11
|
+
hashSecret,
|
|
12
|
+
routeEndpoint,
|
|
13
|
+
secretsMatch
|
|
14
|
+
} from "./chunk-HL6EYHQ7.js";
|
|
15
|
+
import {
|
|
16
|
+
NoRunnerAvailableError,
|
|
17
|
+
PollingDelivery,
|
|
18
|
+
ResultTimeoutError
|
|
19
|
+
} from "./chunk-7RKXFPBZ.js";
|
|
20
|
+
|
|
21
|
+
// src/app.ts
|
|
22
|
+
import {
|
|
23
|
+
backendDescriptor,
|
|
24
|
+
matchAudience
|
|
25
|
+
} from "@byollm/protocol";
|
|
26
|
+
var DEFAULT_LIVENESS_MS = 35e3;
|
|
27
|
+
var ByollmApp = class {
|
|
28
|
+
#store;
|
|
29
|
+
#now;
|
|
30
|
+
#livenessMs;
|
|
31
|
+
#delivery;
|
|
32
|
+
constructor(options) {
|
|
33
|
+
this.#store = options.store;
|
|
34
|
+
this.#now = options.now ?? Date.now;
|
|
35
|
+
this.#livenessMs = options.livenessMs ?? DEFAULT_LIVENESS_MS;
|
|
36
|
+
const deps = {
|
|
37
|
+
...options.noRunnerGraceMs === void 0 ? {} : { graceMs: options.noRunnerGraceMs },
|
|
38
|
+
read: (jobId) => this.result(jobId),
|
|
39
|
+
availability: async (jobId) => {
|
|
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
|
+
}
|
|
58
|
+
};
|
|
59
|
+
this.#delivery = options.delivery?.(deps) ?? new PollingDelivery(deps);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Enqueue a job.
|
|
63
|
+
*
|
|
64
|
+
* `audience` defaults to `self` — the safe direction. Widening it means the
|
|
65
|
+
* result comes back marked untrusted (see {@link ByollmApp.result}), and
|
|
66
|
+
* the app is obliged to disclose that to whoever reads it.
|
|
67
|
+
*/
|
|
68
|
+
async enqueue(input) {
|
|
69
|
+
const record = await this.#store.create(input, this.#now());
|
|
70
|
+
return {
|
|
71
|
+
id: record.id,
|
|
72
|
+
record,
|
|
73
|
+
result: (options) => this.#delivery.waitFor(record.id, options),
|
|
74
|
+
cancel: async () => {
|
|
75
|
+
await this.cancel(record.id);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Read a job's current state. */
|
|
80
|
+
async job(jobId) {
|
|
81
|
+
await this.#store.expireDue(this.#now());
|
|
82
|
+
return this.#store.get(jobId);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A job's result with its provenance attached.
|
|
86
|
+
*
|
|
87
|
+
* Check `provenance.untrusted` before rendering. It is true for every
|
|
88
|
+
* `named`/`public` job, because that text came from someone else's machine
|
|
89
|
+
* and the app must not present it as its own AI's answer
|
|
90
|
+
* ({@link MUSTS.RESULT_PROVENANCE}).
|
|
91
|
+
*/
|
|
92
|
+
async result(jobId) {
|
|
93
|
+
const job = await this.job(jobId);
|
|
94
|
+
if (!job) return null;
|
|
95
|
+
return {
|
|
96
|
+
jobId: job.id,
|
|
97
|
+
state: job.state,
|
|
98
|
+
...job.outcome === null ? {} : { outcome: job.outcome },
|
|
99
|
+
...job.provenance === null ? {} : { provenance: job.provenance }
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Ask a runner to stop. Queued jobs cancel at once; held jobs at the next heartbeat. */
|
|
103
|
+
async cancel(jobId) {
|
|
104
|
+
return this.#store.cancel(jobId, this.#now());
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Is there a live runner that could take a job of this shape?
|
|
108
|
+
*
|
|
109
|
+
* Runs the identical {@link matchAudience} rule the claim path uses, so the
|
|
110
|
+
* signal cannot promise a runner the claim would then refuse.
|
|
111
|
+
*/
|
|
112
|
+
async runnerAvailability(query) {
|
|
113
|
+
const now = this.#now();
|
|
114
|
+
const all = await this.#store.listRunners();
|
|
115
|
+
const live = all.filter(
|
|
116
|
+
(runner) => runner.revokedAt === null && !runner.paused && now - runner.lastHeartbeatAt <= this.#livenessMs
|
|
117
|
+
);
|
|
118
|
+
if (all.length === 0) {
|
|
119
|
+
return { available: false, reason: "no-runner-paired", candidates: 0 };
|
|
120
|
+
}
|
|
121
|
+
if (live.length === 0) {
|
|
122
|
+
return { available: false, reason: "no-runner-online", candidates: 0 };
|
|
123
|
+
}
|
|
124
|
+
let capable = 0;
|
|
125
|
+
let admitted = 0;
|
|
126
|
+
for (const runner of live) {
|
|
127
|
+
const capability = runner.capabilities.find((c) => c.kind === query.kind);
|
|
128
|
+
if (!capability) continue;
|
|
129
|
+
capable += 1;
|
|
130
|
+
const match = matchAudience(
|
|
131
|
+
{
|
|
132
|
+
owner: query.owner,
|
|
133
|
+
audience: query.audience ?? "self",
|
|
134
|
+
audienceAllow: query.audienceAllow
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
owner: runner.owner,
|
|
138
|
+
offerScope: capability.offerScope,
|
|
139
|
+
account: backendDescriptor(capability.backendId).account,
|
|
140
|
+
// Same conservative assumption the claim path makes: the server
|
|
141
|
+
// cannot see a remote daemon's local allowlist (protocol §4.2).
|
|
142
|
+
locallyAllows: () => true
|
|
143
|
+
}
|
|
144
|
+
);
|
|
145
|
+
if (match.ok) admitted += 1;
|
|
146
|
+
}
|
|
147
|
+
if (capable === 0) {
|
|
148
|
+
return {
|
|
149
|
+
available: false,
|
|
150
|
+
reason: "no-matching-capability",
|
|
151
|
+
candidates: 0
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (admitted === 0) {
|
|
155
|
+
return {
|
|
156
|
+
available: false,
|
|
157
|
+
reason: "audience-admits-nobody",
|
|
158
|
+
candidates: 0
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return { available: true, candidates: admitted };
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Approve a pairing on behalf of an authenticated user.
|
|
165
|
+
*
|
|
166
|
+
* `owner` MUST come from the approving user's own session. A daemon can
|
|
167
|
+
* never assert who it is — that is the whole reason pairing is interactive
|
|
168
|
+
* ({@link MUSTS.PAIR_ONE_USER}, {@link MUSTS.PAIR_INTERACTIVE}).
|
|
169
|
+
*/
|
|
170
|
+
async approvePairing(args) {
|
|
171
|
+
const token = generateRunnerToken();
|
|
172
|
+
return this.#store.approvePairing({
|
|
173
|
+
userCode: normalizeUserCode(args.userCode),
|
|
174
|
+
owner: args.owner,
|
|
175
|
+
runnerId: generateRunnerId(),
|
|
176
|
+
runnerToken: token,
|
|
177
|
+
tokenHash: hashSecret(token),
|
|
178
|
+
now: this.#now()
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/** Deny a pairing the user did not initiate. */
|
|
182
|
+
async denyPairing(userCode) {
|
|
183
|
+
return this.#store.denyPairing(normalizeUserCode(userCode), this.#now());
|
|
184
|
+
}
|
|
185
|
+
/** What a pairing code refers to, for the approval page to show. */
|
|
186
|
+
async pendingPairing(userCode) {
|
|
187
|
+
const pairing = await this.#store.getPairingByUserCode(
|
|
188
|
+
normalizeUserCode(userCode)
|
|
189
|
+
);
|
|
190
|
+
if (pairing?.state !== "pending") return null;
|
|
191
|
+
if (pairing.expiresAt <= this.#now()) return null;
|
|
192
|
+
return {
|
|
193
|
+
label: pairing.label,
|
|
194
|
+
platform: pairing.platform,
|
|
195
|
+
daemonVersion: pairing.daemonVersion,
|
|
196
|
+
capabilities: pairing.capabilities.map((c) => ({
|
|
197
|
+
kind: c.kind,
|
|
198
|
+
model: c.model
|
|
199
|
+
})),
|
|
200
|
+
expiresAt: pairing.expiresAt
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/** The user's paired runners, for a settings page. */
|
|
204
|
+
async runners(owner) {
|
|
205
|
+
return this.#store.listRunners(owner);
|
|
206
|
+
}
|
|
207
|
+
/** Revoke a runner. It stops at its next heartbeat, mid-queue. */
|
|
208
|
+
async revokeRunner(runnerId) {
|
|
209
|
+
return this.#store.revokeRunner(runnerId, this.#now());
|
|
210
|
+
}
|
|
211
|
+
/** Run the expiry sweep. Idempotent; safe to call on a timer or a request. */
|
|
212
|
+
async sweep() {
|
|
213
|
+
return this.#store.expireDue(this.#now());
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
function normalizeUserCode(input) {
|
|
217
|
+
const bare = input.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
218
|
+
return bare.length === 8 ? `${bare.slice(0, 4)}-${bare.slice(4)}` : bare;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// src/memory.ts
|
|
222
|
+
import {
|
|
223
|
+
backendDescriptor as backendDescriptor2,
|
|
224
|
+
matchAudience as matchAudience2
|
|
225
|
+
} from "@byollm/protocol";
|
|
226
|
+
var DEFAULT_TTL_MS = 15 * 6e4;
|
|
227
|
+
var MemoryStore = class {
|
|
228
|
+
#jobs = /* @__PURE__ */ new Map();
|
|
229
|
+
#runners = /* @__PURE__ */ new Map();
|
|
230
|
+
#pairings = /* @__PURE__ */ new Map();
|
|
231
|
+
/** Job ids the app has asked to cancel, not yet acknowledged by a runner. */
|
|
232
|
+
#cancelRequests = /* @__PURE__ */ new Set();
|
|
233
|
+
#defaultTtlMs;
|
|
234
|
+
constructor(options = {}) {
|
|
235
|
+
this.#defaultTtlMs = options.defaultTtlMs ?? DEFAULT_TTL_MS;
|
|
236
|
+
}
|
|
237
|
+
// -- jobs ---------------------------------------------------------------
|
|
238
|
+
create(input, now) {
|
|
239
|
+
const id = input.id ?? generateJobId();
|
|
240
|
+
const existing = this.#jobs.get(id);
|
|
241
|
+
if (existing) return Promise.resolve(existing);
|
|
242
|
+
const dependsOn = [...input.dependsOn ?? []];
|
|
243
|
+
const blocked = dependsOn.some(
|
|
244
|
+
(depId) => this.#jobs.get(depId)?.state !== "ok"
|
|
245
|
+
);
|
|
246
|
+
const job = {
|
|
247
|
+
id,
|
|
248
|
+
kind: input.kind,
|
|
249
|
+
payload: input.payload,
|
|
250
|
+
audience: input.audience ?? "self",
|
|
251
|
+
owner: input.owner,
|
|
252
|
+
audienceAllow: input.audienceAllow ? [...input.audienceAllow] : void 0,
|
|
253
|
+
dependsOn,
|
|
254
|
+
state: "queued",
|
|
255
|
+
lease: null,
|
|
256
|
+
createdAt: now,
|
|
257
|
+
// The TTL clock starts here only if nothing blocks the job.
|
|
258
|
+
claimableAt: blocked ? null : now,
|
|
259
|
+
ttlMs: input.ttlMs ?? this.#defaultTtlMs,
|
|
260
|
+
deadlineAt: input.deadlineAt ?? null,
|
|
261
|
+
refusedBy: [],
|
|
262
|
+
attempts: 0,
|
|
263
|
+
outcome: null,
|
|
264
|
+
provenance: null,
|
|
265
|
+
updatedAt: now
|
|
266
|
+
};
|
|
267
|
+
this.#jobs.set(id, job);
|
|
268
|
+
return Promise.resolve(job);
|
|
269
|
+
}
|
|
270
|
+
get(jobId) {
|
|
271
|
+
return Promise.resolve(this.#jobs.get(jobId) ?? null);
|
|
272
|
+
}
|
|
273
|
+
claim(args) {
|
|
274
|
+
this.#expireDueSync(args.now);
|
|
275
|
+
const claimed = [];
|
|
276
|
+
const candidates = [...this.#jobs.values()].sort(
|
|
277
|
+
(a, b) => (a.claimableAt ?? Infinity) - (b.claimableAt ?? Infinity)
|
|
278
|
+
);
|
|
279
|
+
for (const job of candidates) {
|
|
280
|
+
if (claimed.length >= args.max) break;
|
|
281
|
+
if (!this.#isClaimable(job, args)) continue;
|
|
282
|
+
const updated = {
|
|
283
|
+
...job,
|
|
284
|
+
state: "claimed",
|
|
285
|
+
lease: {
|
|
286
|
+
runnerId: args.runnerId,
|
|
287
|
+
expiresAt: args.now + args.leaseMs
|
|
288
|
+
},
|
|
289
|
+
attempts: job.attempts + 1,
|
|
290
|
+
updatedAt: args.now
|
|
291
|
+
};
|
|
292
|
+
this.#jobs.set(job.id, updated);
|
|
293
|
+
claimed.push(updated);
|
|
294
|
+
}
|
|
295
|
+
return Promise.resolve(claimed);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The claim predicate, shared by `claim` and the no-runner signal so the
|
|
299
|
+
* two can never disagree about what "a runner that could take this" means.
|
|
300
|
+
*/
|
|
301
|
+
#isClaimable(job, args) {
|
|
302
|
+
if (job.state !== "queued") return false;
|
|
303
|
+
if (job.claimableAt === null || job.claimableAt > args.now) return false;
|
|
304
|
+
if (job.refusedBy.includes(args.runnerId)) return false;
|
|
305
|
+
const capability = capabilityFor(args.capabilities, job.kind);
|
|
306
|
+
if (!capability) return false;
|
|
307
|
+
const match = matchAudience2(
|
|
308
|
+
{
|
|
309
|
+
owner: job.owner,
|
|
310
|
+
audience: job.audience,
|
|
311
|
+
audienceAllow: job.audienceAllow
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
owner: args.runnerOwner,
|
|
315
|
+
offerScope: capability.offerScope,
|
|
316
|
+
// From the registry, not a local guess — the subscription self-lock
|
|
317
|
+
// must mean the same thing on both sides of the wire.
|
|
318
|
+
account: backendDescriptor2(capability.backendId).account,
|
|
319
|
+
// The server cannot see a remote daemon's local allowlist and must
|
|
320
|
+
// not pretend to (protocol §4.2). It admits the job here; the daemon
|
|
321
|
+
// is the enforcing side and releases with `refused` if its own list
|
|
322
|
+
// says no.
|
|
323
|
+
locallyAllows: () => true
|
|
324
|
+
}
|
|
325
|
+
);
|
|
326
|
+
return match.ok;
|
|
327
|
+
}
|
|
328
|
+
renewLeases(args) {
|
|
329
|
+
this.#expireDueSync(args.now);
|
|
330
|
+
const renewed = [];
|
|
331
|
+
const lost = [];
|
|
332
|
+
for (const jobId of args.jobIds) {
|
|
333
|
+
const job = this.#jobs.get(jobId);
|
|
334
|
+
if (!job || job.lease?.runnerId !== args.runnerId) {
|
|
335
|
+
lost.push(jobId);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (job.state !== "claimed" && job.state !== "running") {
|
|
339
|
+
lost.push(jobId);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const expiresAt = args.now + args.leaseMs;
|
|
343
|
+
this.#jobs.set(jobId, {
|
|
344
|
+
...job,
|
|
345
|
+
state: "running",
|
|
346
|
+
lease: { runnerId: args.runnerId, expiresAt },
|
|
347
|
+
updatedAt: args.now
|
|
348
|
+
});
|
|
349
|
+
renewed.push({ jobId, expiresAt });
|
|
350
|
+
}
|
|
351
|
+
return Promise.resolve({ renewed, lost });
|
|
352
|
+
}
|
|
353
|
+
complete(args) {
|
|
354
|
+
const job = this.#jobs.get(args.jobId);
|
|
355
|
+
if (!job) return Promise.resolve({ accepted: false, job: null });
|
|
356
|
+
if (job.state === "ok" || job.state === "error" || job.state === "canceled") {
|
|
357
|
+
return Promise.resolve({ accepted: false, job });
|
|
358
|
+
}
|
|
359
|
+
if (job.state === "expired") {
|
|
360
|
+
return Promise.resolve({ accepted: false, job });
|
|
361
|
+
}
|
|
362
|
+
if (job.lease?.runnerId !== args.runnerId) {
|
|
363
|
+
return Promise.resolve({ accepted: false, job });
|
|
364
|
+
}
|
|
365
|
+
const state = args.outcome.outcome === "ok" ? "ok" : args.outcome.outcome === "canceled" ? "canceled" : "error";
|
|
366
|
+
const updated = {
|
|
367
|
+
...job,
|
|
368
|
+
state,
|
|
369
|
+
lease: null,
|
|
370
|
+
outcome: args.outcome,
|
|
371
|
+
provenance: args.provenance,
|
|
372
|
+
updatedAt: args.now
|
|
373
|
+
};
|
|
374
|
+
this.#jobs.set(job.id, updated);
|
|
375
|
+
this.#cancelRequests.delete(job.id);
|
|
376
|
+
if (state === "ok") this.#unblockDependents(job.id, args.now);
|
|
377
|
+
return Promise.resolve({ accepted: true, job: updated });
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Start the TTL clock on anything this job was blocking.
|
|
381
|
+
*
|
|
382
|
+
* Deliberately only on `ok`: a dependency that errored leaves its dependents
|
|
383
|
+
* blocked forever rather than releasing them into a run whose input never
|
|
384
|
+
* arrived. They expire at their absolute deadline if one was set, and the
|
|
385
|
+
* app sees a chain that stopped where it broke.
|
|
386
|
+
*/
|
|
387
|
+
#unblockDependents(completedId, now) {
|
|
388
|
+
for (const job of this.#jobs.values()) {
|
|
389
|
+
if (job.claimableAt !== null) continue;
|
|
390
|
+
if (!job.dependsOn.includes(completedId)) continue;
|
|
391
|
+
const ready = job.dependsOn.every(
|
|
392
|
+
(depId) => this.#jobs.get(depId)?.state === "ok"
|
|
393
|
+
);
|
|
394
|
+
if (ready) {
|
|
395
|
+
this.#jobs.set(job.id, { ...job, claimableAt: now, updatedAt: now });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
release(args) {
|
|
400
|
+
const released = [];
|
|
401
|
+
for (const jobId of args.jobIds) {
|
|
402
|
+
const job = this.#jobs.get(jobId);
|
|
403
|
+
if (!job || job.lease?.runnerId !== args.runnerId) continue;
|
|
404
|
+
this.#jobs.set(jobId, {
|
|
405
|
+
...job,
|
|
406
|
+
state: "queued",
|
|
407
|
+
lease: null,
|
|
408
|
+
// Newly available again, so the TTL clock restarts here too.
|
|
409
|
+
claimableAt: args.now,
|
|
410
|
+
// A refusal is remembered, or the pair spins between claim and
|
|
411
|
+
// release forever ({@link MUSTS.REFUSAL_NOT_REOFFERED}).
|
|
412
|
+
refusedBy: args.reason === "refused" ? [.../* @__PURE__ */ new Set([...job.refusedBy, args.runnerId])] : job.refusedBy,
|
|
413
|
+
updatedAt: args.now
|
|
414
|
+
});
|
|
415
|
+
released.push(jobId);
|
|
416
|
+
}
|
|
417
|
+
return Promise.resolve(released);
|
|
418
|
+
}
|
|
419
|
+
expireDue(now) {
|
|
420
|
+
return Promise.resolve(this.#expireDueSync(now));
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Lease expiry and TTL expiry in one idempotent sweep.
|
|
424
|
+
*
|
|
425
|
+
* Order matters: a lease is reclaimed *before* the TTL is judged, so a job
|
|
426
|
+
* whose runner died is offered again rather than being expired for having
|
|
427
|
+
* sat in `claimed` too long.
|
|
428
|
+
*/
|
|
429
|
+
#expireDueSync(now) {
|
|
430
|
+
const changed = [];
|
|
431
|
+
for (const job of this.#jobs.values()) {
|
|
432
|
+
if ((job.state === "claimed" || job.state === "running") && job.lease !== null && job.lease.expiresAt <= now) {
|
|
433
|
+
const requeued = {
|
|
434
|
+
...job,
|
|
435
|
+
state: "queued",
|
|
436
|
+
lease: null,
|
|
437
|
+
// The TTL clock restarts: it measures how long a job has waited
|
|
438
|
+
// *unclaimed*, and this job has just become available again. Without
|
|
439
|
+
// this, a job whose runner died would expire for time it spent being
|
|
440
|
+
// actively worked on — losing exactly the work `kill -9` recovery
|
|
441
|
+
// exists to save. Total lifetime is bounded by `deadlineAt`, which
|
|
442
|
+
// is absolute and unaffected by reclaim.
|
|
443
|
+
claimableAt: now,
|
|
444
|
+
updatedAt: now
|
|
445
|
+
};
|
|
446
|
+
this.#jobs.set(job.id, requeued);
|
|
447
|
+
changed.push(requeued);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
for (const job of this.#jobs.values()) {
|
|
451
|
+
if (job.state !== "queued") continue;
|
|
452
|
+
const pastDeadline = job.deadlineAt !== null && job.deadlineAt <= now;
|
|
453
|
+
const pastTtl = job.claimableAt !== null && job.claimableAt + job.ttlMs <= now;
|
|
454
|
+
if (!pastDeadline && !pastTtl) continue;
|
|
455
|
+
const expired = {
|
|
456
|
+
...job,
|
|
457
|
+
state: "expired",
|
|
458
|
+
lease: null,
|
|
459
|
+
updatedAt: now
|
|
460
|
+
};
|
|
461
|
+
this.#jobs.set(job.id, expired);
|
|
462
|
+
changed.push(expired);
|
|
463
|
+
}
|
|
464
|
+
return changed;
|
|
465
|
+
}
|
|
466
|
+
cancel(jobId, now) {
|
|
467
|
+
const job = this.#jobs.get(jobId);
|
|
468
|
+
if (!job) return Promise.resolve(null);
|
|
469
|
+
if (job.state === "queued") {
|
|
470
|
+
const canceled = {
|
|
471
|
+
...job,
|
|
472
|
+
state: "canceled",
|
|
473
|
+
lease: null,
|
|
474
|
+
updatedAt: now
|
|
475
|
+
};
|
|
476
|
+
this.#jobs.set(jobId, canceled);
|
|
477
|
+
return Promise.resolve(canceled);
|
|
478
|
+
}
|
|
479
|
+
if (job.state === "claimed" || job.state === "running") {
|
|
480
|
+
this.#cancelRequests.add(jobId);
|
|
481
|
+
return Promise.resolve(job);
|
|
482
|
+
}
|
|
483
|
+
return Promise.resolve(job);
|
|
484
|
+
}
|
|
485
|
+
listClaimedBy(runnerId) {
|
|
486
|
+
return Promise.resolve(
|
|
487
|
+
[...this.#jobs.values()].filter(
|
|
488
|
+
(job) => job.lease?.runnerId === runnerId
|
|
489
|
+
)
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
listCancelRequests(runnerId) {
|
|
493
|
+
return Promise.resolve(
|
|
494
|
+
[...this.#cancelRequests].filter(
|
|
495
|
+
(jobId) => this.#jobs.get(jobId)?.lease?.runnerId === runnerId
|
|
496
|
+
)
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
// -- pairing and runners -------------------------------------------------
|
|
500
|
+
createPairing(record) {
|
|
501
|
+
this.#pairings.set(record.deviceCodeHash, record);
|
|
502
|
+
return Promise.resolve();
|
|
503
|
+
}
|
|
504
|
+
getPairingByDeviceCodeHash(hash) {
|
|
505
|
+
return Promise.resolve(this.#pairings.get(hash) ?? null);
|
|
506
|
+
}
|
|
507
|
+
getPairingByUserCode(userCode) {
|
|
508
|
+
for (const pairing of this.#pairings.values()) {
|
|
509
|
+
if (pairing.userCode === userCode) return Promise.resolve(pairing);
|
|
510
|
+
}
|
|
511
|
+
return Promise.resolve(null);
|
|
512
|
+
}
|
|
513
|
+
approvePairing(args) {
|
|
514
|
+
const pairing = [...this.#pairings.values()].find(
|
|
515
|
+
(p) => p.userCode === args.userCode
|
|
516
|
+
);
|
|
517
|
+
if (!pairing) throw new Error(`unknown pairing code: ${args.userCode}`);
|
|
518
|
+
if (pairing.expiresAt <= args.now) {
|
|
519
|
+
throw new Error("pairing code has expired");
|
|
520
|
+
}
|
|
521
|
+
if (pairing.state !== "pending") {
|
|
522
|
+
throw new Error(`pairing is already ${pairing.state}`);
|
|
523
|
+
}
|
|
524
|
+
const runner = {
|
|
525
|
+
id: args.runnerId,
|
|
526
|
+
owner: args.owner,
|
|
527
|
+
tokenHash: args.tokenHash,
|
|
528
|
+
label: pairing.label,
|
|
529
|
+
platform: pairing.platform,
|
|
530
|
+
daemonVersion: pairing.daemonVersion,
|
|
531
|
+
capabilities: pairing.capabilities,
|
|
532
|
+
paused: false,
|
|
533
|
+
revokedAt: null,
|
|
534
|
+
lastHeartbeatAt: args.now,
|
|
535
|
+
createdAt: args.now
|
|
536
|
+
};
|
|
537
|
+
this.#runners.set(runner.id, runner);
|
|
538
|
+
this.#pairings.set(pairing.deviceCodeHash, {
|
|
539
|
+
...pairing,
|
|
540
|
+
state: "approved",
|
|
541
|
+
owner: args.owner,
|
|
542
|
+
runnerId: runner.id,
|
|
543
|
+
runnerTokenOnce: args.runnerToken
|
|
544
|
+
});
|
|
545
|
+
return Promise.resolve(runner);
|
|
546
|
+
}
|
|
547
|
+
denyPairing(userCode, _now) {
|
|
548
|
+
const pairing = [...this.#pairings.values()].find(
|
|
549
|
+
(p) => p.userCode === userCode
|
|
550
|
+
);
|
|
551
|
+
if (pairing) {
|
|
552
|
+
this.#pairings.set(pairing.deviceCodeHash, {
|
|
553
|
+
...pairing,
|
|
554
|
+
state: "denied"
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return Promise.resolve();
|
|
558
|
+
}
|
|
559
|
+
consumePairingToken(deviceCodeHash) {
|
|
560
|
+
const pairing = this.#pairings.get(deviceCodeHash);
|
|
561
|
+
if (pairing) {
|
|
562
|
+
this.#pairings.set(deviceCodeHash, {
|
|
563
|
+
...pairing,
|
|
564
|
+
runnerTokenOnce: null
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
return Promise.resolve();
|
|
568
|
+
}
|
|
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
|
+
getRunner(runnerId) {
|
|
576
|
+
return Promise.resolve(this.#runners.get(runnerId) ?? null);
|
|
577
|
+
}
|
|
578
|
+
touchRunner(args) {
|
|
579
|
+
const runner = this.#runners.get(args.runnerId);
|
|
580
|
+
if (!runner) return Promise.resolve(null);
|
|
581
|
+
const updated = {
|
|
582
|
+
...runner,
|
|
583
|
+
capabilities: args.capabilities,
|
|
584
|
+
daemonVersion: args.daemonVersion,
|
|
585
|
+
paused: args.paused,
|
|
586
|
+
lastHeartbeatAt: args.now
|
|
587
|
+
};
|
|
588
|
+
this.#runners.set(runner.id, updated);
|
|
589
|
+
return Promise.resolve(updated);
|
|
590
|
+
}
|
|
591
|
+
revokeRunner(runnerId, now) {
|
|
592
|
+
const runner = this.#runners.get(runnerId);
|
|
593
|
+
if (runner?.revokedAt === null) {
|
|
594
|
+
this.#runners.set(runnerId, { ...runner, revokedAt: now });
|
|
595
|
+
}
|
|
596
|
+
return Promise.resolve();
|
|
597
|
+
}
|
|
598
|
+
listRunners(owner) {
|
|
599
|
+
const all = [...this.#runners.values()];
|
|
600
|
+
return Promise.resolve(
|
|
601
|
+
owner === void 0 ? all : all.filter((r) => r.owner === owner)
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
// -- test/demo helpers ---------------------------------------------------
|
|
605
|
+
/** All jobs, for demos and assertions. Not part of the store interface. */
|
|
606
|
+
allJobs() {
|
|
607
|
+
return [...this.#jobs.values()];
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
function capabilityFor(capabilities, kind) {
|
|
611
|
+
return capabilities.find((c) => c.kind === kind);
|
|
612
|
+
}
|
|
613
|
+
export {
|
|
614
|
+
ByollmApp,
|
|
615
|
+
ByollmHandlers,
|
|
616
|
+
MemoryStore,
|
|
617
|
+
NoRunnerAvailableError,
|
|
618
|
+
PollingDelivery,
|
|
619
|
+
ResultTimeoutError,
|
|
620
|
+
SERVED_PROTOCOL_VERSION,
|
|
621
|
+
bearerFrom,
|
|
622
|
+
capabilityFor,
|
|
623
|
+
createFetchHandler,
|
|
624
|
+
generateDeviceCode,
|
|
625
|
+
generateJobId,
|
|
626
|
+
generateRunnerId,
|
|
627
|
+
generateRunnerToken,
|
|
628
|
+
generateUserCode,
|
|
629
|
+
hashSecret,
|
|
630
|
+
normalizeUserCode,
|
|
631
|
+
routeEndpoint,
|
|
632
|
+
secretsMatch
|
|
633
|
+
};
|
|
634
|
+
//# sourceMappingURL=index.js.map
|