@kubb/studio 5.3.15 → 5.3.17
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 +92 -25
- package/dist/index.cjs +521 -347
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +116 -48
- package/dist/index.js +517 -349
- package/dist/index.js.map +1 -1
- package/dist/protocol.cjs +20 -0
- package/dist/protocol.cjs.map +1 -1
- package/dist/protocol.d.ts +80 -50
- package/dist/protocol.js +19 -1
- package/dist/protocol.js.map +1 -1
- package/package.json +4 -4
package/dist/index.cjs
CHANGED
|
@@ -26,76 +26,32 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
26
26
|
}) : target, mod));
|
|
27
27
|
//#endregion
|
|
28
28
|
const require_protocol = require("./protocol.cjs");
|
|
29
|
-
let
|
|
30
|
-
let node_process = require("node:process");
|
|
31
|
-
node_process = __toESM(node_process, 1);
|
|
32
|
-
let node_child_process = require("node:child_process");
|
|
29
|
+
let node_crypto = require("node:crypto");
|
|
33
30
|
let node_fs_promises = require("node:fs/promises");
|
|
31
|
+
let node_os = require("node:os");
|
|
34
32
|
let node_path = require("node:path");
|
|
35
33
|
node_path = __toESM(node_path, 1);
|
|
34
|
+
let node_process = require("node:process");
|
|
35
|
+
node_process = __toESM(node_process, 1);
|
|
36
|
+
let node_child_process = require("node:child_process");
|
|
36
37
|
let _kubb_core = require("@kubb/core");
|
|
38
|
+
let tinyexec = require("tinyexec");
|
|
39
|
+
let node_timers_promises = require("node:timers/promises");
|
|
37
40
|
let ofetch = require("ofetch");
|
|
38
|
-
let
|
|
41
|
+
let node_util = require("node:util");
|
|
39
42
|
let unstorage = require("unstorage");
|
|
40
43
|
let unstorage_drivers_fs = require("unstorage/drivers/fs");
|
|
41
44
|
unstorage_drivers_fs = __toESM(unstorage_drivers_fs, 1);
|
|
42
|
-
let tinyexec = require("tinyexec");
|
|
43
45
|
let magicast = require("magicast");
|
|
44
46
|
let node_fs = require("node:fs");
|
|
45
47
|
let node_module = require("node:module");
|
|
46
48
|
let node_url = require("node:url");
|
|
47
49
|
let remeda = require("remeda");
|
|
48
|
-
let node_os = require("node:os");
|
|
49
50
|
let node_zlib = require("node:zlib");
|
|
50
51
|
let tsdown = require("tsdown");
|
|
51
52
|
let capnweb = require("capnweb");
|
|
52
53
|
let ws = require("ws");
|
|
53
54
|
ws = __toESM(ws, 1);
|
|
54
|
-
let node_timers_promises = require("node:timers/promises");
|
|
55
|
-
//#region src/constants.ts
|
|
56
|
-
/**
|
|
57
|
-
* Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
|
|
58
|
-
* not whatever default the client would pick on its own.
|
|
59
|
-
*/
|
|
60
|
-
const defaultStudioUrl = "https://kubb.studio";
|
|
61
|
-
/**
|
|
62
|
-
* Defaults the Studio client uses when a host passes nothing.
|
|
63
|
-
* Config path is left out on purpose: each host discovers that itself.
|
|
64
|
-
*/
|
|
65
|
-
const agentDefaults = {
|
|
66
|
-
studioUrl: defaultStudioUrl,
|
|
67
|
-
retryIntervalMs: 3e4,
|
|
68
|
-
heartbeatIntervalMs: 3e4,
|
|
69
|
-
/**
|
|
70
|
-
* Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
|
|
71
|
-
* stored ping is older than its liveness window, and it stores a ping at most once a minute, so
|
|
72
|
-
* a slower cadence would make a healthy agent look dead after a single missed ping.
|
|
73
|
-
*/
|
|
74
|
-
maxHeartbeatIntervalMs: 6e4,
|
|
75
|
-
/** How long a heartbeat ping may take before the session is treated as dead. */
|
|
76
|
-
heartbeatTimeoutMs: 1e4,
|
|
77
|
-
poolSize: 1,
|
|
78
|
-
maxGenerations: 8,
|
|
79
|
-
maxGenerationsMb: 100,
|
|
80
|
-
maxSnapshotMb: 50
|
|
81
|
-
};
|
|
82
|
-
function positiveNumber(value) {
|
|
83
|
-
const parsed = Number(value);
|
|
84
|
-
return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* How many generations an agent keeps and how large they may get, read from
|
|
88
|
-
* `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
|
|
89
|
-
* An unset or invalid value keeps the default.
|
|
90
|
-
*/
|
|
91
|
-
function resolveGenerationLimits(env = process.env) {
|
|
92
|
-
return {
|
|
93
|
-
maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
|
|
94
|
-
maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
|
|
95
|
-
maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
//#endregion
|
|
99
55
|
//#region ../../internals/utils/src/casing.ts
|
|
100
56
|
/**
|
|
101
57
|
* Shared implementation for camelCase and PascalCase conversion.
|
|
@@ -418,6 +374,89 @@ function getElapsedMs(hrStart) {
|
|
|
418
374
|
return Math.round(ms * 100) / 100;
|
|
419
375
|
}
|
|
420
376
|
//#endregion
|
|
377
|
+
//#region package.json
|
|
378
|
+
var version = "5.3.17";
|
|
379
|
+
//#endregion
|
|
380
|
+
//#region src/hooks.ts
|
|
381
|
+
/**
|
|
382
|
+
* Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
|
|
383
|
+
* streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
|
|
384
|
+
* Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
|
|
385
|
+
*
|
|
386
|
+
* Returns a remover, so a session that runs one generation after another on the same emitter does
|
|
387
|
+
* not stack a listener per run.
|
|
388
|
+
*/
|
|
389
|
+
function setupHookListener(hooks, root, signal) {
|
|
390
|
+
return hooks.hook("kubb:hook:start", async (ctx) => {
|
|
391
|
+
const { id, command, args } = ctx;
|
|
392
|
+
if (!id) return;
|
|
393
|
+
const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
|
|
394
|
+
try {
|
|
395
|
+
const proc = (0, tinyexec.x)(command, [...args ?? []], {
|
|
396
|
+
signal,
|
|
397
|
+
nodeOptions: {
|
|
398
|
+
cwd: root,
|
|
399
|
+
detached: true
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
for await (const line of proc) await hooks.callHook("kubb:hook:line", {
|
|
403
|
+
id,
|
|
404
|
+
line
|
|
405
|
+
});
|
|
406
|
+
const { exitCode } = await proc;
|
|
407
|
+
if (exitCode !== 0) {
|
|
408
|
+
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
409
|
+
await hooks.callHook("kubb:hook:end", {
|
|
410
|
+
id,
|
|
411
|
+
command,
|
|
412
|
+
args,
|
|
413
|
+
success: false,
|
|
414
|
+
error
|
|
415
|
+
});
|
|
416
|
+
await hooks.callHook("kubb:error", { error });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
await hooks.callHook("kubb:hook:end", {
|
|
420
|
+
id,
|
|
421
|
+
command,
|
|
422
|
+
args,
|
|
423
|
+
success: true,
|
|
424
|
+
error: null
|
|
425
|
+
});
|
|
426
|
+
} catch (caughtError) {
|
|
427
|
+
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
428
|
+
error.cause = caughtError;
|
|
429
|
+
await hooks.callHook("kubb:hook:end", {
|
|
430
|
+
id,
|
|
431
|
+
command,
|
|
432
|
+
args,
|
|
433
|
+
success: false,
|
|
434
|
+
error
|
|
435
|
+
});
|
|
436
|
+
await hooks.callHook("kubb:error", { error });
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
|
|
442
|
+
* `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
|
|
443
|
+
* that same listener, so a handler added afterward would already have missed it.
|
|
444
|
+
*/
|
|
445
|
+
function waitForHookEnd(hooks, hookId) {
|
|
446
|
+
return new Promise((resolve, reject) => {
|
|
447
|
+
const handleHookEnd = (ctx) => {
|
|
448
|
+
if (ctx.id !== hookId) return;
|
|
449
|
+
hooks.removeHook("kubb:hook:end", handleHookEnd);
|
|
450
|
+
if (ctx.success) {
|
|
451
|
+
resolve();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
reject(ctx.error);
|
|
455
|
+
};
|
|
456
|
+
hooks.hook("kubb:hook:end", handleHookEnd);
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
//#endregion
|
|
421
460
|
//#region src/machine.ts
|
|
422
461
|
/**
|
|
423
462
|
* Key-value storage the runtime uses for its machine secret and the last Studio config.
|
|
@@ -498,10 +537,6 @@ function responseMessage(data) {
|
|
|
498
537
|
*/
|
|
499
538
|
const REGISTER_RETRIES = 3;
|
|
500
539
|
/**
|
|
501
|
-
* Shared in-flight registration so concurrent pool sessions trigger one purge, not N.
|
|
502
|
-
*/
|
|
503
|
-
let registrationInFlight = null;
|
|
504
|
-
/**
|
|
505
540
|
* Thrown when Studio rejects the agent token itself (401). Retrying cannot help: the token was
|
|
506
541
|
* revoked, or the agent it belonged to was deleted in the Studio UI. Hosts catch this to forget
|
|
507
542
|
* the stored credential and pair again.
|
|
@@ -513,119 +548,87 @@ var InvalidAgentTokenError = class extends Error {
|
|
|
513
548
|
}
|
|
514
549
|
};
|
|
515
550
|
/**
|
|
551
|
+
* Thrown when Studio refuses this agent's protocol version (426). Retrying cannot help until the
|
|
552
|
+
* agent is upgraded, so hosts stop instead of reconnecting.
|
|
553
|
+
*/
|
|
554
|
+
var IncompatibleAgentError = class extends Error {
|
|
555
|
+
constructor(studioUrl, detail, options) {
|
|
556
|
+
super(`Kubb Studio at ${studioUrl} requires a newer agent${detail ? `: ${detail}` : ""}. Upgrade @kubb/studio or the Kubb agent image.`, options);
|
|
557
|
+
this.name = "IncompatibleAgentError";
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
/**
|
|
516
561
|
* Whether a thrown value carries `statusCode`. Not narrowed to `FetchError`: a host wrapper can
|
|
517
562
|
* throw its own error shape with the same field.
|
|
518
|
-
*
|
|
519
|
-
* A 401 means the agent token itself was rejected. A 403 from the session create endpoint means
|
|
520
|
-
* the machine token stored in Studio no longer matches this agent (missing or mismatched).
|
|
521
563
|
*/
|
|
522
564
|
function rejectedWith(error, statusCode) {
|
|
523
565
|
return error?.statusCode === statusCode;
|
|
524
566
|
}
|
|
525
|
-
function
|
|
567
|
+
function registrationError(cause) {
|
|
526
568
|
const detail = (cause instanceof ofetch.FetchError ? responseMessage(cause.data) : void 0) ?? getErrorMessage(cause);
|
|
527
|
-
return new Error(detail ? `Failed to
|
|
569
|
+
return new Error(detail ? `Failed to register with Kubb Studio: ${detail}` : "Failed to register with Kubb Studio", { cause });
|
|
528
570
|
}
|
|
529
571
|
/**
|
|
530
|
-
*
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const url = `${studioUrl}/api/agent/sessions`;
|
|
534
|
-
const data = await (0, ofetch.ofetch)(url, {
|
|
535
|
-
method: "POST",
|
|
536
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
537
|
-
body: { machineToken: await getMachineToken() }
|
|
538
|
-
});
|
|
539
|
-
if (!data) throw new Error("No data available for agent session");
|
|
540
|
-
return data;
|
|
541
|
-
}
|
|
542
|
-
/**
|
|
543
|
-
* Obtain an agent session token from Kubb Studio via HTTP.
|
|
572
|
+
* Registers this agent process with Kubb Studio (`POST /api/agent/connect`): binds the machine
|
|
573
|
+
* identity to the token, reports what the process can take on, and gets back the URL of the one
|
|
574
|
+
* socket it keeps open.
|
|
544
575
|
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*/
|
|
549
|
-
async function
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
} catch (error) {
|
|
556
|
-
if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
|
|
557
|
-
if (!rejectedWith(error, 403) || !await registerAgent({
|
|
558
|
-
token,
|
|
559
|
-
studioUrl
|
|
560
|
-
})) throw sessionError(error);
|
|
561
|
-
try {
|
|
562
|
-
return await requestAgentSession({
|
|
563
|
-
token,
|
|
564
|
-
studioUrl
|
|
565
|
-
});
|
|
566
|
-
} catch (retryError) {
|
|
567
|
-
if (rejectedWith(retryError, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: retryError });
|
|
568
|
-
throw sessionError(retryError);
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
/**
|
|
573
|
-
* Register this agent with Kubb Studio by sending the machine ID.
|
|
574
|
-
* Called on agent startup before creating a WebSocket session, and again when
|
|
575
|
-
* Studio rejects the machine token during session creation.
|
|
576
|
-
*
|
|
577
|
-
* Retries with backoff because a failed registration leaves Studio with a stale
|
|
578
|
-
* machine token that blocks every subsequent session create call. Registration
|
|
579
|
-
* purges all of the agent's sessions on the Studio side, so concurrent callers
|
|
580
|
-
* (multiple pool sessions hitting a 403 at once) share one in-flight run instead
|
|
581
|
-
* of purging each other's fresh sessions.
|
|
582
|
-
*/
|
|
583
|
-
function registerAgent(props) {
|
|
584
|
-
registrationInFlight ??= runRegistration(props).finally(() => {
|
|
585
|
-
registrationInFlight = null;
|
|
586
|
-
});
|
|
587
|
-
return registrationInFlight;
|
|
588
|
-
}
|
|
589
|
-
async function runRegistration({ token, studioUrl, poolSize }) {
|
|
590
|
-
const machineToken = await getMachineToken();
|
|
576
|
+
* Retries a transient failure with backoff. A rejected token (401) throws
|
|
577
|
+
* {@link InvalidAgentTokenError} and an unsupported agent version (426) throws
|
|
578
|
+
* {@link IncompatibleAgentError}, since retrying either cannot help.
|
|
579
|
+
*/
|
|
580
|
+
async function registerAgent({ token, studioUrl, instanceId, capacity }) {
|
|
581
|
+
const body = {
|
|
582
|
+
machineToken: await getMachineToken(),
|
|
583
|
+
instanceId,
|
|
584
|
+
capacity
|
|
585
|
+
};
|
|
591
586
|
try {
|
|
592
|
-
await (0, ofetch.ofetch)(`${studioUrl}/api/agent/connect`, {
|
|
587
|
+
return await (0, ofetch.ofetch)(`${studioUrl}/api/agent/connect`, {
|
|
593
588
|
method: "POST",
|
|
594
589
|
headers: { Authorization: `Bearer ${token}` },
|
|
595
|
-
body
|
|
596
|
-
machineToken,
|
|
597
|
-
poolSize
|
|
598
|
-
},
|
|
590
|
+
body,
|
|
599
591
|
retry: REGISTER_RETRIES,
|
|
600
592
|
retryDelay: ({ options }) => 2e3 * 2 ** (REGISTER_RETRIES - Number(options.retry))
|
|
601
593
|
});
|
|
602
|
-
return true;
|
|
603
594
|
} catch (error) {
|
|
604
595
|
if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
|
|
605
|
-
|
|
606
|
-
|
|
596
|
+
if (rejectedWith(error, 426)) throw new IncompatibleAgentError(studioUrl, error instanceof ofetch.FetchError ? responseMessage(error.data) : void 0, { cause: error });
|
|
597
|
+
throw registrationError(error);
|
|
607
598
|
}
|
|
608
599
|
}
|
|
609
600
|
/**
|
|
610
|
-
*
|
|
611
|
-
* Called on process termination or server close. A failed notify is logged and swallowed: the
|
|
612
|
-
* local socket is already gone, and failing teardown must not block shutdown or reconnect.
|
|
601
|
+
* First wait before `createJob` retries a busy or queue-full response, absent a `Retry-After` hint.
|
|
613
602
|
*/
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
603
|
+
const CREATE_JOB_INITIAL_DELAY_MS = 1e3;
|
|
604
|
+
/**
|
|
605
|
+
* Slowest `createJob` backs off to between retries.
|
|
606
|
+
*/
|
|
607
|
+
const CREATE_JOB_MAX_INTERVAL_MS = 1e4;
|
|
608
|
+
/**
|
|
609
|
+
* Statuses worth retrying: the agent has no free connection yet (409, a stale conflict a moment
|
|
610
|
+
* later resolves), its queue is momentarily full (429), or it has no live connection at all yet
|
|
611
|
+
* (503, an agent process that is mid-reconnect). Anything else (404 agent not found, 401/403 auth)
|
|
612
|
+
* is thrown straight away, since retrying cannot change the outcome.
|
|
613
|
+
*/
|
|
614
|
+
const CREATE_JOB_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
|
|
615
|
+
409,
|
|
616
|
+
429,
|
|
617
|
+
503
|
|
618
|
+
]);
|
|
619
|
+
/**
|
|
620
|
+
* Reads Studio's `Retry-After` header (seconds) off a thrown `ofetch` error, when present.
|
|
621
|
+
*/
|
|
622
|
+
function retryAfterMs(error) {
|
|
623
|
+
const seconds = Number(error.response?.headers.get("retry-after"));
|
|
624
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Adds up to 30% jitter, so every CI run queued behind the same busy agent does not retry in
|
|
628
|
+
* lockstep.
|
|
629
|
+
*/
|
|
630
|
+
function withJitter(ms) {
|
|
631
|
+
return ms + Math.random() * ms * .3;
|
|
629
632
|
}
|
|
630
633
|
/**
|
|
631
634
|
* Queues a generation or snapshot job on Studio (`POST /api/jobs`).
|
|
@@ -633,6 +636,10 @@ async function disconnect({ sessionId, token, studioUrl, slug, logLevel }) {
|
|
|
633
636
|
* Returns as soon as Studio accepts the job (`202`). Poll with {@link waitForJob} until it finishes.
|
|
634
637
|
* Authenticates with the organization CI API key via `x-api-key`.
|
|
635
638
|
*
|
|
639
|
+
* A busy agent, a full queue, or a momentary lack of a live connection (409, 429, 503) retries with
|
|
640
|
+
* exponential backoff and jitter, honoring Studio's `Retry-After` header when it sends one, up to
|
|
641
|
+
* `timeoutMs`. Every other failure, including a missing agent (404), throws immediately.
|
|
642
|
+
*
|
|
636
643
|
* @example Snapshot job
|
|
637
644
|
* ```ts
|
|
638
645
|
* const job = await createJob({
|
|
@@ -646,19 +653,40 @@ async function disconnect({ sessionId, token, studioUrl, slug, logLevel }) {
|
|
|
646
653
|
* const finished = await waitForJob({ studioUrl, token, id: job.id })
|
|
647
654
|
* ```
|
|
648
655
|
*/
|
|
649
|
-
async function createJob({ studioUrl, token, type, agentId, name, version, config }) {
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
656
|
+
async function createJob({ studioUrl, token, type, agentId, name, version, commit, baseId, config, timeoutMs = 6e4, signal }) {
|
|
657
|
+
const deadline = Date.now() + timeoutMs;
|
|
658
|
+
let interval = CREATE_JOB_INITIAL_DELAY_MS;
|
|
659
|
+
for (;;) {
|
|
660
|
+
signal?.throwIfAborted();
|
|
661
|
+
try {
|
|
662
|
+
const { job } = await (0, ofetch.ofetch)(`${studioUrl}/api/jobs`, {
|
|
663
|
+
method: "POST",
|
|
664
|
+
headers: { "x-api-key": token },
|
|
665
|
+
body: {
|
|
666
|
+
type,
|
|
667
|
+
agentId,
|
|
668
|
+
name,
|
|
669
|
+
version,
|
|
670
|
+
commit,
|
|
671
|
+
baseId,
|
|
672
|
+
config
|
|
673
|
+
},
|
|
674
|
+
retry: false,
|
|
675
|
+
timeout: Math.max(deadline - Date.now(), 1),
|
|
676
|
+
signal
|
|
677
|
+
});
|
|
678
|
+
return job;
|
|
679
|
+
} catch (error) {
|
|
680
|
+
signal?.throwIfAborted();
|
|
681
|
+
const status = error.response?.status;
|
|
682
|
+
if (!status || !CREATE_JOB_RETRYABLE_STATUSES.has(status) || Date.now() >= deadline) throw error;
|
|
683
|
+
const wait = Math.min(retryAfterMs(error) ?? withJitter(interval), Math.max(deadline - Date.now(), 0));
|
|
684
|
+
if (signal) await (0, node_timers_promises.setTimeout)(wait, void 0, { signal });
|
|
685
|
+
else await new Promise((resolve) => setTimeout(resolve, wait));
|
|
686
|
+
if (Date.now() >= deadline) throw error;
|
|
687
|
+
interval = Math.min(interval * 2, CREATE_JOB_MAX_INTERVAL_MS);
|
|
659
688
|
}
|
|
660
|
-
}
|
|
661
|
-
return job;
|
|
689
|
+
}
|
|
662
690
|
}
|
|
663
691
|
/**
|
|
664
692
|
* A job runs a generation and packs a tarball, so it is never done the instant it is queued.
|
|
@@ -677,20 +705,26 @@ const MAX_POLL_INTERVAL_MS = 3e4;
|
|
|
677
705
|
* A `failed` job resolves normally. Check `job.status` and `job.error`. Throws only when the
|
|
678
706
|
* deadline passes before Studio finishes.
|
|
679
707
|
*/
|
|
680
|
-
async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4 }) {
|
|
708
|
+
async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4, signal }) {
|
|
681
709
|
const deadline = Date.now() + timeoutMs;
|
|
682
710
|
let interval = INITIAL_POLL_DELAY_MS;
|
|
683
711
|
for (;;) {
|
|
684
|
-
|
|
712
|
+
signal?.throwIfAborted();
|
|
713
|
+
const wait = Math.max(Math.min(interval, deadline - Date.now()), 0);
|
|
714
|
+
if (signal) await (0, node_timers_promises.setTimeout)(wait, void 0, { signal });
|
|
715
|
+
else await new Promise((resolve) => setTimeout(resolve, wait));
|
|
685
716
|
if (Date.now() >= deadline) throw new Error("Timed out waiting for the Studio job");
|
|
686
717
|
interval = Math.min(interval * 2, MAX_POLL_INTERVAL_MS);
|
|
687
718
|
try {
|
|
688
719
|
const { job } = await (0, ofetch.ofetch)(`${studioUrl}/api/jobs/${id}`, {
|
|
689
720
|
headers: { "x-api-key": token },
|
|
690
|
-
retry: false
|
|
721
|
+
retry: false,
|
|
722
|
+
timeout: Math.max(deadline - Date.now(), 1),
|
|
723
|
+
signal
|
|
691
724
|
});
|
|
692
725
|
if (job.status === "success" || job.status === "failed" || job.status === "canceled") return job;
|
|
693
726
|
} catch (error) {
|
|
727
|
+
signal?.throwIfAborted();
|
|
694
728
|
const response = error.response;
|
|
695
729
|
if (response?.status !== 429) throw error;
|
|
696
730
|
const retryAfter = response._data?.data?.tryAgainIn;
|
|
@@ -724,89 +758,6 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
|
|
|
724
758
|
}
|
|
725
759
|
}
|
|
726
760
|
//#endregion
|
|
727
|
-
//#region package.json
|
|
728
|
-
var version = "5.3.15";
|
|
729
|
-
//#endregion
|
|
730
|
-
//#region src/hooks.ts
|
|
731
|
-
/**
|
|
732
|
-
* Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
|
|
733
|
-
* streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
|
|
734
|
-
* Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
|
|
735
|
-
*
|
|
736
|
-
* Returns a remover, so a session that runs one generation after another on the same emitter does
|
|
737
|
-
* not stack a listener per run.
|
|
738
|
-
*/
|
|
739
|
-
function setupHookListener(hooks, root, signal) {
|
|
740
|
-
return hooks.hook("kubb:hook:start", async (ctx) => {
|
|
741
|
-
const { id, command, args } = ctx;
|
|
742
|
-
if (!id) return;
|
|
743
|
-
const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
|
|
744
|
-
try {
|
|
745
|
-
const proc = (0, tinyexec.x)(command, [...args ?? []], {
|
|
746
|
-
signal,
|
|
747
|
-
nodeOptions: {
|
|
748
|
-
cwd: root,
|
|
749
|
-
detached: true
|
|
750
|
-
}
|
|
751
|
-
});
|
|
752
|
-
for await (const line of proc) await hooks.callHook("kubb:hook:line", {
|
|
753
|
-
id,
|
|
754
|
-
line
|
|
755
|
-
});
|
|
756
|
-
const { exitCode } = await proc;
|
|
757
|
-
if (exitCode !== 0) {
|
|
758
|
-
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
759
|
-
await hooks.callHook("kubb:hook:end", {
|
|
760
|
-
id,
|
|
761
|
-
command,
|
|
762
|
-
args,
|
|
763
|
-
success: false,
|
|
764
|
-
error
|
|
765
|
-
});
|
|
766
|
-
await hooks.callHook("kubb:error", { error });
|
|
767
|
-
return;
|
|
768
|
-
}
|
|
769
|
-
await hooks.callHook("kubb:hook:end", {
|
|
770
|
-
id,
|
|
771
|
-
command,
|
|
772
|
-
args,
|
|
773
|
-
success: true,
|
|
774
|
-
error: null
|
|
775
|
-
});
|
|
776
|
-
} catch (caughtError) {
|
|
777
|
-
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
778
|
-
error.cause = caughtError;
|
|
779
|
-
await hooks.callHook("kubb:hook:end", {
|
|
780
|
-
id,
|
|
781
|
-
command,
|
|
782
|
-
args,
|
|
783
|
-
success: false,
|
|
784
|
-
error
|
|
785
|
-
});
|
|
786
|
-
await hooks.callHook("kubb:error", { error });
|
|
787
|
-
}
|
|
788
|
-
});
|
|
789
|
-
}
|
|
790
|
-
/**
|
|
791
|
-
* Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
|
|
792
|
-
* `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
|
|
793
|
-
* that same listener, so a handler added afterward would already have missed it.
|
|
794
|
-
*/
|
|
795
|
-
function waitForHookEnd(hooks, hookId) {
|
|
796
|
-
return new Promise((resolve, reject) => {
|
|
797
|
-
const handleHookEnd = (ctx) => {
|
|
798
|
-
if (ctx.id !== hookId) return;
|
|
799
|
-
hooks.removeHook("kubb:hook:end", handleHookEnd);
|
|
800
|
-
if (ctx.success) {
|
|
801
|
-
resolve();
|
|
802
|
-
return;
|
|
803
|
-
}
|
|
804
|
-
reject(ctx.error);
|
|
805
|
-
};
|
|
806
|
-
hooks.hook("kubb:hook:end", handleHookEnd);
|
|
807
|
-
});
|
|
808
|
-
}
|
|
809
|
-
//#endregion
|
|
810
761
|
//#region src/resolveConfig.ts
|
|
811
762
|
/**
|
|
812
763
|
* Imports a package, falling back to how the user's project would resolve it.
|
|
@@ -1674,6 +1625,60 @@ async function generate({ config, hooks, signal }) {
|
|
|
1674
1625
|
}
|
|
1675
1626
|
}
|
|
1676
1627
|
//#endregion
|
|
1628
|
+
//#region src/constants.ts
|
|
1629
|
+
/**
|
|
1630
|
+
* Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
|
|
1631
|
+
* not whatever default the client would pick on its own.
|
|
1632
|
+
*/
|
|
1633
|
+
const defaultStudioUrl = "https://kubb.studio";
|
|
1634
|
+
/**
|
|
1635
|
+
* Defaults the Studio client uses when a host passes nothing.
|
|
1636
|
+
* Config path is left out on purpose: each host discovers that itself.
|
|
1637
|
+
*/
|
|
1638
|
+
const agentDefaults = {
|
|
1639
|
+
studioUrl: defaultStudioUrl,
|
|
1640
|
+
retryIntervalMs: 3e4,
|
|
1641
|
+
heartbeatIntervalMs: 3e4,
|
|
1642
|
+
/**
|
|
1643
|
+
* Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
|
|
1644
|
+
* stored ping is older than its liveness window, and it stores a ping at most once a minute, so
|
|
1645
|
+
* a slower cadence would make a healthy agent look dead after a single missed ping.
|
|
1646
|
+
*/
|
|
1647
|
+
maxHeartbeatIntervalMs: 6e4,
|
|
1648
|
+
/** How long a heartbeat ping may take before the session is treated as dead. */
|
|
1649
|
+
heartbeatTimeoutMs: 1e4,
|
|
1650
|
+
maxConcurrent: 1,
|
|
1651
|
+
maxGenerations: 8,
|
|
1652
|
+
maxGenerationsMb: 100,
|
|
1653
|
+
maxSnapshotMb: 50
|
|
1654
|
+
};
|
|
1655
|
+
function positiveNumber(value) {
|
|
1656
|
+
const parsed = Number(value);
|
|
1657
|
+
return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* How many generations an agent keeps and how large they may get, read from
|
|
1661
|
+
* `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
|
|
1662
|
+
* An unset or invalid value keeps the default.
|
|
1663
|
+
*/
|
|
1664
|
+
function resolveGenerationLimits(env = process.env) {
|
|
1665
|
+
return {
|
|
1666
|
+
maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
|
|
1667
|
+
maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
|
|
1668
|
+
maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* An agent's capacity read from `KUBB_AGENT_MAX_CONCURRENT` and `KUBB_AGENT_MEMORY_BUDGET_MB`. An
|
|
1673
|
+
* unset or invalid value keeps the default: one job at a time, and no memory budget.
|
|
1674
|
+
*/
|
|
1675
|
+
function resolveAgentCapacity(env = process.env) {
|
|
1676
|
+
return {
|
|
1677
|
+
maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)),
|
|
1678
|
+
memoryBudgetMb: positiveNumber(env.KUBB_AGENT_MEMORY_BUDGET_MB)
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
//#endregion
|
|
1677
1682
|
//#region src/snapshotPackage.ts
|
|
1678
1683
|
const gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
|
|
1679
1684
|
/**
|
|
@@ -1816,7 +1821,7 @@ async function createSnapshotPackage(files, packageInfo) {
|
|
|
1816
1821
|
//#endregion
|
|
1817
1822
|
//#region src/generations.ts
|
|
1818
1823
|
const READ_CONCURRENCY = 50;
|
|
1819
|
-
const MB = 1048576;
|
|
1824
|
+
const MB$1 = 1048576;
|
|
1820
1825
|
const INDEX_KEY = "studio/generations.json";
|
|
1821
1826
|
const hashOf = (content) => (0, node_crypto.createHash)("sha1").update(content).digest("hex").slice(0, 16);
|
|
1822
1827
|
/**
|
|
@@ -1843,8 +1848,9 @@ async function listDisk({ root, outputPath, maxFiles }) {
|
|
|
1843
1848
|
* tenant never reaches another's output. The oldest go past `maxCount` or `maxMb`, but the newest
|
|
1844
1849
|
* always stays.
|
|
1845
1850
|
*/
|
|
1846
|
-
function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
1851
|
+
function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now }) {
|
|
1847
1852
|
let index;
|
|
1853
|
+
const isLive = (generation) => ttlMs === void 0 || generation.keptAt === void 0 || now() - generation.keptAt < ttlMs;
|
|
1848
1854
|
const dirOf = (jobId) => `studio/generations/${hashOf(jobId)}/`;
|
|
1849
1855
|
async function load() {
|
|
1850
1856
|
if (index) return index;
|
|
@@ -1860,7 +1866,7 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
|
1860
1866
|
* Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
|
|
1861
1867
|
*/
|
|
1862
1868
|
async function keep({ jobId, source, files, maxSetMb }) {
|
|
1863
|
-
const maxSetBytes = maxSetMb * MB;
|
|
1869
|
+
const maxSetBytes = maxSetMb * MB$1;
|
|
1864
1870
|
const hashes = {};
|
|
1865
1871
|
let bytes = 0;
|
|
1866
1872
|
await inParallel({
|
|
@@ -1895,13 +1901,20 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
|
1895
1901
|
return {
|
|
1896
1902
|
keep,
|
|
1897
1903
|
drop,
|
|
1898
|
-
get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId),
|
|
1899
|
-
latest: async () => (await load()).at(-1),
|
|
1904
|
+
get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId && isLive(generation)),
|
|
1905
|
+
latest: async () => (await load()).filter(isLive).at(-1),
|
|
1906
|
+
/** Total bytes of every set the store holds, expired ones too until the next add drops them. */
|
|
1907
|
+
bytes: async () => (await load()).reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0),
|
|
1900
1908
|
async add(generation) {
|
|
1901
|
-
const
|
|
1902
|
-
|
|
1909
|
+
const current = await load();
|
|
1910
|
+
for (const expired of current.filter((entry) => !isLive(entry))) await drop(expired.jobId);
|
|
1911
|
+
const entries = current.filter((entry) => entry.jobId !== generation.jobId && isLive(entry));
|
|
1912
|
+
entries.push({
|
|
1913
|
+
...generation,
|
|
1914
|
+
keptAt: now()
|
|
1915
|
+
});
|
|
1903
1916
|
const weight = () => entries.reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0);
|
|
1904
|
-
while (entries.length > 1 && (entries.length > maxCount || weight() > maxMb * MB)) await drop(entries.shift().jobId);
|
|
1917
|
+
while (entries.length > 1 && (entries.length > maxCount || weight() > maxMb * MB$1)) await drop(entries.shift().jobId);
|
|
1905
1918
|
index = entries;
|
|
1906
1919
|
await storage.writeItem(INDEX_KEY, JSON.stringify(entries));
|
|
1907
1920
|
},
|
|
@@ -2188,6 +2201,9 @@ var AgentRpcTarget = class extends capnweb.RpcTarget {
|
|
|
2188
2201
|
readFiles(input) {
|
|
2189
2202
|
return this.api.readFiles(input);
|
|
2190
2203
|
}
|
|
2204
|
+
cancel(jobId) {
|
|
2205
|
+
return this.api.cancel(jobId);
|
|
2206
|
+
}
|
|
2191
2207
|
};
|
|
2192
2208
|
/**
|
|
2193
2209
|
* Opens an authenticated Cap'n Web session to Studio over a WebSocket. Rejects an unencrypted URL
|
|
@@ -2199,11 +2215,17 @@ var AgentRpcTarget = class extends capnweb.RpcTarget {
|
|
|
2199
2215
|
* await rpc.studio.ping()
|
|
2200
2216
|
* ```
|
|
2201
2217
|
*/
|
|
2202
|
-
const connectWebSocketRpc = async ({ url, token, local }) => {
|
|
2218
|
+
const connectWebSocketRpc = async ({ url, token, instanceId, local }) => {
|
|
2203
2219
|
const { protocol, hostname, host } = new URL(url);
|
|
2204
2220
|
if (protocol !== "wss:" && !(protocol === "ws:" && (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"))) throw new Error(`Refusing unencrypted WebSocket to ${host}`);
|
|
2205
|
-
const socket = createWebsocket(url, { headers: {
|
|
2206
|
-
|
|
2221
|
+
const socket = createWebsocket(url, { headers: {
|
|
2222
|
+
Authorization: `Bearer ${token}`,
|
|
2223
|
+
[require_protocol.AGENT_INSTANCE_HEADER]: instanceId
|
|
2224
|
+
} });
|
|
2225
|
+
const closed = new Promise((resolve) => socket.once("close", (code, reason) => resolve({
|
|
2226
|
+
code,
|
|
2227
|
+
reason: reason.toString()
|
|
2228
|
+
})));
|
|
2207
2229
|
const studio = (0, capnweb.newWebSocketRpcSession)(socket, new AgentRpcTarget(local));
|
|
2208
2230
|
studio.onRpcBroken(() => socket.close());
|
|
2209
2231
|
return {
|
|
@@ -2218,6 +2240,32 @@ const connectWebSocketRpc = async ({ url, token, local }) => {
|
|
|
2218
2240
|
* Past this many files in the output directory, no snapshot of it is taken before a run.
|
|
2219
2241
|
*/
|
|
2220
2242
|
const DISK_SNAPSHOT_MAX_FILES = 1e4;
|
|
2243
|
+
/**
|
|
2244
|
+
* How long a sandbox keeps a generation readable. Its store is in memory and holds every tenant's
|
|
2245
|
+
* runs, so an old one has to go even when count and size leave room. A local agent keeps its runs
|
|
2246
|
+
* until count or size pushes them out, so a later run can still diff against the one before it.
|
|
2247
|
+
*/
|
|
2248
|
+
const SANDBOX_GENERATION_TTL_MS = 9e5;
|
|
2249
|
+
/**
|
|
2250
|
+
* A fresh root for one sandbox job. Kubb keys its output manifest cache by root, so tenants that
|
|
2251
|
+
* shared the agent's own root would read each other's manifest.
|
|
2252
|
+
*/
|
|
2253
|
+
function createJobRoot() {
|
|
2254
|
+
return (0, node_fs_promises.mkdtemp)(node_path.default.join((0, node_os.tmpdir)(), "kubb-job-"));
|
|
2255
|
+
}
|
|
2256
|
+
/**
|
|
2257
|
+
* Removes a job root and the manifest cache Kubb derived from it. Best effort: a leftover temp
|
|
2258
|
+
* directory must not fail a job that already finished.
|
|
2259
|
+
*/
|
|
2260
|
+
async function removeJobRoot(jobRoot) {
|
|
2261
|
+
await Promise.all([(0, node_fs_promises.rm)(jobRoot, {
|
|
2262
|
+
recursive: true,
|
|
2263
|
+
force: true
|
|
2264
|
+
}), (0, node_fs_promises.rm)((0, _kubb_core.resolveCacheDir)(jobRoot), {
|
|
2265
|
+
recursive: true,
|
|
2266
|
+
force: true
|
|
2267
|
+
})]).catch(() => {});
|
|
2268
|
+
}
|
|
2221
2269
|
var GenerationRunTarget = class extends capnweb.RpcTarget {
|
|
2222
2270
|
generationStream;
|
|
2223
2271
|
generationResult;
|
|
@@ -2264,36 +2312,74 @@ function applyStudioDefaults(options) {
|
|
|
2264
2312
|
...options.permissions
|
|
2265
2313
|
},
|
|
2266
2314
|
retryInterval: options.retryInterval ?? agentDefaults.retryIntervalMs,
|
|
2267
|
-
heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs)
|
|
2315
|
+
heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs),
|
|
2316
|
+
capacity: {
|
|
2317
|
+
...resolveAgentCapacity(),
|
|
2318
|
+
...options.capacity
|
|
2319
|
+
},
|
|
2320
|
+
instanceId: options.instanceId ?? (0, node_crypto.randomUUID)()
|
|
2268
2321
|
};
|
|
2269
2322
|
}
|
|
2270
2323
|
/**
|
|
2271
|
-
*
|
|
2272
|
-
*
|
|
2273
|
-
* A free function rather than a method: a pending retry timer reaches whatever it closes over, so
|
|
2274
|
-
* closing only over `options` (not a `StudioSession`) keeps a queued retry from pinning a closed
|
|
2275
|
-
* socket, its hook emitter, or its session id alive for the length of the retry interval.
|
|
2324
|
+
* Jobs one agent process can run at once today. Two runs would share this session's hook emitter,
|
|
2325
|
+
* and with it each other's events, until each job runs in its own worker (ADR-0003 slice B2).
|
|
2276
2326
|
*/
|
|
2277
|
-
|
|
2278
|
-
|
|
2327
|
+
const RUNTIME_MAX_CONCURRENT = 1;
|
|
2328
|
+
const MB = 1048576;
|
|
2329
|
+
function rssMb() {
|
|
2330
|
+
return node_process.default.memoryUsage().rss / MB;
|
|
2331
|
+
}
|
|
2332
|
+
function backoffDelayMs(attempt, maxMs) {
|
|
2333
|
+
const cap = Math.min(1e3 * 2 ** (attempt - 1), maxMs);
|
|
2334
|
+
return Math.random() * cap;
|
|
2335
|
+
}
|
|
2336
|
+
function reconnect(options, delayMs, attempt) {
|
|
2337
|
+
const { signal, onTokenRejected } = options;
|
|
2279
2338
|
if (signal?.aborted) return;
|
|
2280
|
-
if (logLevel !== void 0 && logLevel > _kubb_core.logLevel.silent) console.error((0, node_util.styleText)("dim", `Retrying connection in ${retryInterval}ms to Kubb Studio ...`));
|
|
2281
2339
|
const cancel = () => clearTimeout(timer);
|
|
2282
2340
|
const timer = setTimeout(() => {
|
|
2283
2341
|
signal?.removeEventListener("abort", cancel);
|
|
2284
2342
|
if (signal?.aborted) return;
|
|
2285
|
-
new StudioSession(
|
|
2286
|
-
|
|
2343
|
+
new StudioSession({
|
|
2344
|
+
...options,
|
|
2345
|
+
reconnectAttempt: attempt
|
|
2346
|
+
}).start().catch((error) => {
|
|
2287
2347
|
if (error instanceof InvalidAgentTokenError) {
|
|
2288
2348
|
onTokenRejected?.(error);
|
|
2289
2349
|
return;
|
|
2290
2350
|
}
|
|
2291
|
-
|
|
2351
|
+
if (error instanceof IncompatibleAgentError) return;
|
|
2352
|
+
const nextAttempt = attempt + 1;
|
|
2353
|
+
reconnect(options, backoffDelayMs(nextAttempt, options.retryInterval), nextAttempt);
|
|
2292
2354
|
});
|
|
2293
|
-
},
|
|
2355
|
+
}, delayMs);
|
|
2294
2356
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
2295
2357
|
}
|
|
2296
2358
|
/**
|
|
2359
|
+
* Reads what Studio meant by closing the connection. A code Studio did not send on purpose is an
|
|
2360
|
+
* ordinary drop, and the agent reconnects as it always has.
|
|
2361
|
+
*/
|
|
2362
|
+
function planEnd(close) {
|
|
2363
|
+
const code = close?.code;
|
|
2364
|
+
if (code === require_protocol.AgentCloseCode.REAUTHENTICATE) return {
|
|
2365
|
+
reason: "Kubb Studio asked the agent to register again",
|
|
2366
|
+
retry: true
|
|
2367
|
+
};
|
|
2368
|
+
if (code === require_protocol.AgentCloseCode.SUPERSEDED) return {
|
|
2369
|
+
reason: "another instance of this agent took over",
|
|
2370
|
+
retry: false
|
|
2371
|
+
};
|
|
2372
|
+
if (code === require_protocol.AgentCloseCode.INCOMPATIBLE) return {
|
|
2373
|
+
reason: "this agent is too old for Kubb Studio, or was deleted",
|
|
2374
|
+
retry: false,
|
|
2375
|
+
error: /* @__PURE__ */ new Error("Kubb Studio closed the connection: this agent is too old for it, or was deleted. Upgrade the agent, or pair it again.")
|
|
2376
|
+
};
|
|
2377
|
+
return {
|
|
2378
|
+
reason: "connection closed",
|
|
2379
|
+
retry: true
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2297
2383
|
* One agent-to-Studio RPC transport: opening it, keeping it alive, and serving remote methods.
|
|
2298
2384
|
* `createClient` opens one per pool slot and is the only caller.
|
|
2299
2385
|
*/
|
|
@@ -2306,14 +2392,15 @@ var StudioSession = class {
|
|
|
2306
2392
|
*/
|
|
2307
2393
|
#unhooks = [];
|
|
2308
2394
|
/**
|
|
2309
|
-
* What
|
|
2310
|
-
* Before it resolves there is
|
|
2395
|
+
* What registration handed back, and the marker for whether the agent registered at all.
|
|
2396
|
+
* Before it resolves there is no sandbox flag to read.
|
|
2311
2397
|
*/
|
|
2312
|
-
#
|
|
2398
|
+
#registration;
|
|
2313
2399
|
#rpc;
|
|
2314
2400
|
#studioVersion;
|
|
2315
2401
|
#disposed = false;
|
|
2316
2402
|
#isGenerating = false;
|
|
2403
|
+
#activeJob;
|
|
2317
2404
|
#heartbeatTimer;
|
|
2318
2405
|
#lastGeneration;
|
|
2319
2406
|
#store;
|
|
@@ -2323,15 +2410,17 @@ var StudioSession = class {
|
|
|
2323
2410
|
* host does not queue jobs before the agent session is registered.
|
|
2324
2411
|
*/
|
|
2325
2412
|
#connectAck = Promise.withResolvers();
|
|
2326
|
-
|
|
2413
|
+
#reconnectAttempt;
|
|
2414
|
+
constructor({ reconnectAttempt, ...options }) {
|
|
2327
2415
|
this.#options = applyStudioDefaults(options);
|
|
2416
|
+
this.#reconnectAttempt = reconnectAttempt ?? 0;
|
|
2328
2417
|
this.#connectAck.promise.catch(() => {});
|
|
2329
2418
|
}
|
|
2330
2419
|
/**
|
|
2331
2420
|
* A sandbox agent runs on Studio's own infrastructure, so it has no user project to touch.
|
|
2332
2421
|
*/
|
|
2333
2422
|
get #isSandbox() {
|
|
2334
|
-
return this.#
|
|
2423
|
+
return this.#registration?.isSandbox === true;
|
|
2335
2424
|
}
|
|
2336
2425
|
/**
|
|
2337
2426
|
* Kept in the project's cache directory, so it survives a restart, except on a sandbox: its pool
|
|
@@ -2341,7 +2430,8 @@ var StudioSession = class {
|
|
|
2341
2430
|
this.#store ??= createGenerationStore({
|
|
2342
2431
|
storage: this.#isSandbox ? (0, _kubb_core.memoryStorage)() : (0, _kubb_core.cacheStorage)({ root: this.#options.root }),
|
|
2343
2432
|
maxCount: this.#limits.maxCount,
|
|
2344
|
-
maxMb: this.#limits.maxMb
|
|
2433
|
+
maxMb: this.#limits.maxMb,
|
|
2434
|
+
ttlMs: this.#isSandbox ? SANDBOX_GENERATION_TTL_MS : void 0
|
|
2345
2435
|
});
|
|
2346
2436
|
return this.#store;
|
|
2347
2437
|
}
|
|
@@ -2365,19 +2455,26 @@ var StudioSession = class {
|
|
|
2365
2455
|
return this.#isSandbox || this.#options.permissions.allowRead;
|
|
2366
2456
|
}
|
|
2367
2457
|
async start() {
|
|
2368
|
-
const { token, studioUrl, signal, heartbeatInterval, installLogger } = this.#options;
|
|
2458
|
+
const { token, studioUrl, signal, heartbeatInterval, installLogger, instanceId, capacity } = this.#options;
|
|
2369
2459
|
await installLogger?.(this.#hooks);
|
|
2370
2460
|
try {
|
|
2371
2461
|
await this.#hooks.callHook("studio:connecting", { url: studioUrl });
|
|
2372
|
-
|
|
2462
|
+
if (this.#reconnectAttempt === 0 && capacity.maxConcurrent > RUNTIME_MAX_CONCURRENT) await this.#warn(`Running ${RUNTIME_MAX_CONCURRENT} job at a time: KUBB_AGENT_MAX_CONCURRENT=${capacity.maxConcurrent} needs per-job workers, which this agent does not have yet`);
|
|
2463
|
+
const registration = await registerAgent({
|
|
2373
2464
|
token,
|
|
2374
|
-
studioUrl
|
|
2465
|
+
studioUrl,
|
|
2466
|
+
instanceId,
|
|
2467
|
+
capacity: {
|
|
2468
|
+
...capacity,
|
|
2469
|
+
maxConcurrent: Math.min(capacity.maxConcurrent, RUNTIME_MAX_CONCURRENT)
|
|
2470
|
+
}
|
|
2375
2471
|
});
|
|
2376
|
-
this.#
|
|
2377
|
-
this.#studioVersion =
|
|
2472
|
+
this.#registration = registration;
|
|
2473
|
+
this.#studioVersion = registration.version;
|
|
2378
2474
|
const rpc = await (this.#options.connector ?? connectWebSocketRpc)({
|
|
2379
|
-
url:
|
|
2475
|
+
url: registration.socketUrl,
|
|
2380
2476
|
token,
|
|
2477
|
+
instanceId,
|
|
2381
2478
|
local: this
|
|
2382
2479
|
});
|
|
2383
2480
|
this.#rpc = rpc;
|
|
@@ -2392,28 +2489,43 @@ var StudioSession = class {
|
|
|
2392
2489
|
kubb: version,
|
|
2393
2490
|
agent: this.#options.version
|
|
2394
2491
|
},
|
|
2395
|
-
agentSlug:
|
|
2396
|
-
organizationSlug:
|
|
2492
|
+
agentSlug: registration.agentSlug,
|
|
2493
|
+
organizationSlug: registration.organizationSlug
|
|
2397
2494
|
});
|
|
2398
2495
|
await this.#connectAck.promise;
|
|
2496
|
+
this.#reconnectAttempt = 0;
|
|
2399
2497
|
await this.#hooks.callHook("studio:ready", {});
|
|
2400
2498
|
} catch (error) {
|
|
2401
2499
|
this.#disposed = true;
|
|
2402
2500
|
this.dispose();
|
|
2403
2501
|
await this.#hooks.callHook("studio:error", { error: toError(error) });
|
|
2404
|
-
if (error instanceof InvalidAgentTokenError) throw error;
|
|
2405
|
-
reconnect(
|
|
2502
|
+
if (error instanceof InvalidAgentTokenError || error instanceof IncompatibleAgentError) throw error;
|
|
2503
|
+
await this.#reconnect();
|
|
2406
2504
|
}
|
|
2407
2505
|
}
|
|
2408
|
-
|
|
2409
|
-
|
|
2506
|
+
/**
|
|
2507
|
+
* Tells the host a retry is coming, then schedules it. The host prints the retry, since the
|
|
2508
|
+
* runtime has no output of its own.
|
|
2509
|
+
*/
|
|
2510
|
+
async #reconnect() {
|
|
2511
|
+
if (this.#options.signal?.aborted) return;
|
|
2512
|
+
const attempt = this.#reconnectAttempt + 1;
|
|
2513
|
+
const delayMs = backoffDelayMs(attempt, this.#options.retryInterval);
|
|
2514
|
+
await this.#hooks.callHook("studio:reconnecting", { delayMs });
|
|
2515
|
+
reconnect(this.#options, delayMs, attempt);
|
|
2516
|
+
}
|
|
2517
|
+
#warn(message, permission) {
|
|
2518
|
+
return this.#hooks.callHook("studio:warn", {
|
|
2519
|
+
message,
|
|
2520
|
+
permission
|
|
2521
|
+
});
|
|
2410
2522
|
}
|
|
2411
2523
|
/**
|
|
2412
2524
|
* Declines a request: logs why locally, then tells Studio. The two wordings differ on purpose,
|
|
2413
2525
|
* since the log names the request that was ignored and the error names what the caller can do.
|
|
2414
2526
|
*/
|
|
2415
|
-
async #refuse(reason, message) {
|
|
2416
|
-
await this.#warn(reason);
|
|
2527
|
+
async #refuse(reason, message, permission) {
|
|
2528
|
+
await this.#warn(reason, permission);
|
|
2417
2529
|
throw new Error(message);
|
|
2418
2530
|
}
|
|
2419
2531
|
#scheduleHeartbeat(interval) {
|
|
@@ -2432,10 +2544,31 @@ var StudioSession = class {
|
|
|
2432
2544
|
/**
|
|
2433
2545
|
* Races `studio.ping()` against a deadline, so a half-open socket can't hang it forever.
|
|
2434
2546
|
* */
|
|
2435
|
-
#ping(rpc) {
|
|
2547
|
+
async #ping(rpc) {
|
|
2436
2548
|
const { promise: timedOut, reject: onTimeout } = Promise.withResolvers();
|
|
2437
2549
|
const timer = setTimeout(() => onTimeout(/* @__PURE__ */ new Error("Heartbeat ping timed out")), agentDefaults.heartbeatTimeoutMs);
|
|
2438
|
-
|
|
2550
|
+
try {
|
|
2551
|
+
const load = await Promise.race([this.#load().catch(() => void 0), timedOut]);
|
|
2552
|
+
await Promise.race([rpc.studio.ping(load), timedOut]);
|
|
2553
|
+
} finally {
|
|
2554
|
+
clearTimeout(timer);
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
/**
|
|
2558
|
+
* Whether memory still leaves room for another job. Always, when the host set no budget.
|
|
2559
|
+
*/
|
|
2560
|
+
#isAccepting(memoryMb = rssMb()) {
|
|
2561
|
+
const budget = this.#options.capacity.memoryBudgetMb;
|
|
2562
|
+
return budget === void 0 || memoryMb <= budget * 1.5;
|
|
2563
|
+
}
|
|
2564
|
+
async #load() {
|
|
2565
|
+
const memoryMb = rssMb();
|
|
2566
|
+
return {
|
|
2567
|
+
running: this.#isGenerating ? 1 : 0,
|
|
2568
|
+
rssMb: Math.round(memoryMb),
|
|
2569
|
+
storeBytes: await this.#generations.bytes(),
|
|
2570
|
+
accepting: this.#isAccepting(memoryMb)
|
|
2571
|
+
};
|
|
2439
2572
|
}
|
|
2440
2573
|
/**
|
|
2441
2574
|
* Reads `kubb.config.ts` and reports which plugin options Studio may edit.
|
|
@@ -2480,8 +2613,11 @@ var StudioSession = class {
|
|
|
2480
2613
|
this.#connectAck.resolve();
|
|
2481
2614
|
return payload;
|
|
2482
2615
|
}
|
|
2483
|
-
#onAbort = () => void this.#end({
|
|
2484
|
-
|
|
2616
|
+
#onAbort = () => void this.#end({
|
|
2617
|
+
reason: "shutdown",
|
|
2618
|
+
retry: false
|
|
2619
|
+
});
|
|
2620
|
+
#onClose = (close) => void this.#end(planEnd(close));
|
|
2485
2621
|
/**
|
|
2486
2622
|
* Drops the socket and detaches every listener and timer this session added. Idempotent, and
|
|
2487
2623
|
* safe before `connect` opened anything.
|
|
@@ -2501,47 +2637,56 @@ var StudioSession = class {
|
|
|
2501
2637
|
* Ends the session: tells Studio it is over, drops the socket, and optionally reconnects.
|
|
2502
2638
|
* `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
|
|
2503
2639
|
*/
|
|
2504
|
-
async #end({ retry }) {
|
|
2505
|
-
const { studioUrl, token, logLevel } = this.#options;
|
|
2640
|
+
async #end({ reason, retry, error }) {
|
|
2506
2641
|
if (this.#disposed) return;
|
|
2507
2642
|
this.#disposed = true;
|
|
2508
2643
|
this.dispose();
|
|
2509
|
-
await this.#hooks.callHook("studio:disconnected", { reason
|
|
2510
|
-
if (
|
|
2511
|
-
|
|
2512
|
-
studioUrl,
|
|
2513
|
-
token,
|
|
2514
|
-
slug: this.#session.slug,
|
|
2515
|
-
logLevel
|
|
2516
|
-
}).catch(() => {});
|
|
2517
|
-
if (retry) reconnect(this.#options);
|
|
2644
|
+
await this.#hooks.callHook("studio:disconnected", { reason });
|
|
2645
|
+
if (error) await this.#hooks.callHook("studio:error", { error });
|
|
2646
|
+
if (retry) await this.#reconnect();
|
|
2518
2647
|
}
|
|
2519
2648
|
startGeneration(data) {
|
|
2520
2649
|
const generationStream = createGenerationStream(this.#hooks, data.jobId, { onGenerationEnd: (result) => {
|
|
2521
2650
|
this.#lastGeneration = result;
|
|
2522
2651
|
} });
|
|
2523
2652
|
const controller = new AbortController();
|
|
2524
|
-
const
|
|
2653
|
+
const cancelRun = async () => {
|
|
2654
|
+
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2655
|
+
};
|
|
2656
|
+
const result = this.#runGeneration(data, controller, cancelRun).then(async (value) => {
|
|
2525
2657
|
await generationStream.close();
|
|
2526
2658
|
return value;
|
|
2527
2659
|
}).catch((error) => {
|
|
2528
2660
|
generationStream.fail(error);
|
|
2529
2661
|
throw error;
|
|
2662
|
+
}).finally(() => {
|
|
2663
|
+
if (this.#activeJob?.cancel === cancelRun) this.#activeJob = void 0;
|
|
2530
2664
|
});
|
|
2531
2665
|
result.catch(() => {});
|
|
2532
|
-
return new GenerationRunTarget(generationStream.stream, result,
|
|
2533
|
-
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2534
|
-
}, () => {
|
|
2666
|
+
return new GenerationRunTarget(generationStream.stream, result, cancelRun, () => {
|
|
2535
2667
|
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2536
2668
|
generationStream.dispose();
|
|
2537
2669
|
});
|
|
2538
2670
|
}
|
|
2539
|
-
|
|
2671
|
+
/**
|
|
2672
|
+
* Cancels the currently running job when its id matches `jobId`.
|
|
2673
|
+
*/
|
|
2674
|
+
async cancel(jobId) {
|
|
2675
|
+
if (this.#activeJob?.jobId === jobId) await this.#activeJob.cancel();
|
|
2676
|
+
}
|
|
2677
|
+
async #runGeneration(data, controller, cancelRun) {
|
|
2540
2678
|
if (this.#isGenerating) return this.#refuse("Ignored generate: a generation is already in progress", "A generation is already in progress, please wait for it to finish");
|
|
2679
|
+
if (!this.#isAccepting()) return this.#refuse("Ignored generate: the agent is past its memory budget", "The agent is past its memory budget, try again once it frees memory");
|
|
2541
2680
|
this.#isGenerating = true;
|
|
2681
|
+
this.#activeJob = {
|
|
2682
|
+
jobId: data.jobId,
|
|
2683
|
+
cancel: cancelRun
|
|
2684
|
+
};
|
|
2542
2685
|
const command = "generate";
|
|
2543
|
-
const {
|
|
2686
|
+
const { loadConfig, permissions } = this.#options;
|
|
2687
|
+
let root = this.#options.root;
|
|
2544
2688
|
try {
|
|
2689
|
+
if (this.#isSandbox) root = await createJobRoot();
|
|
2545
2690
|
await this.#hooks.callHook("studio:command:start", { command });
|
|
2546
2691
|
const config = await loadConfig();
|
|
2547
2692
|
const patch = data.config;
|
|
@@ -2549,10 +2694,7 @@ var StudioSession = class {
|
|
|
2549
2694
|
const adapter = await mergeAdapter(config.adapter, patch?.adapter);
|
|
2550
2695
|
const inputOverride = this.#isSandbox ? patch?.input ?? "" : permissions.allowInput && patch?.input || void 0;
|
|
2551
2696
|
if (permissions.allowWrite && this.#isSandbox) await this.#warn("Running in a sandbox, so writing files is disabled");
|
|
2552
|
-
if (patch?.input && !this.#canUseInput)
|
|
2553
|
-
const remedy = client?.kind === "cli" ? "--allowInput, or answer yes when kubb studio asks," : "KUBB_AGENT_ALLOW_INPUT=true";
|
|
2554
|
-
await this.#warn(`Ignored the spec from Studio; set ${remedy} to generate from it`);
|
|
2555
|
-
}
|
|
2697
|
+
if (patch?.input && !this.#canUseInput) await this.#warn("Ignored the spec from Studio: generating from a Studio spec was not granted", "allowInput");
|
|
2556
2698
|
const resolvedPlugins = plugins ?? config.plugins;
|
|
2557
2699
|
this.#lastGeneration = void 0;
|
|
2558
2700
|
const diskFiles = this.#hasProjectOnDisk ? await listDisk({
|
|
@@ -2623,6 +2765,7 @@ var StudioSession = class {
|
|
|
2623
2765
|
disk: disk ? { hashes: disk.hashes } : void 0
|
|
2624
2766
|
};
|
|
2625
2767
|
} finally {
|
|
2768
|
+
if (root !== this.#options.root) await removeJobRoot(root);
|
|
2626
2769
|
this.#isGenerating = false;
|
|
2627
2770
|
}
|
|
2628
2771
|
}
|
|
@@ -2732,12 +2875,7 @@ var StudioSession = class {
|
|
|
2732
2875
|
async readFiles(data) {
|
|
2733
2876
|
const command = "readFiles";
|
|
2734
2877
|
await this.#hooks.callHook("studio:command:start", { command });
|
|
2735
|
-
|
|
2736
|
-
if (!this.#canRead) {
|
|
2737
|
-
await this.#warn("Ignored files: reading generated files was not granted");
|
|
2738
|
-
const remedy = client?.kind === "cli" ? "--allow-read, or answer yes when kubb studio asks," : "KUBB_AGENT_ALLOW_READ=true";
|
|
2739
|
-
throw new Error(`The agent was not granted permission to read generated files; set ${remedy} to allow it`);
|
|
2740
|
-
}
|
|
2878
|
+
if (!this.#canRead) return this.#refuse("Ignored files: reading generated files was not granted", "The agent was not granted permission to read generated files", "allowRead");
|
|
2741
2879
|
if (!Array.isArray(data.paths)) return this.#refuse("Ignored files: the message carried no paths", "The request carried no paths");
|
|
2742
2880
|
const { paths } = data;
|
|
2743
2881
|
if (paths.length > 50) return this.#refuse(`Ignored files: requested ${paths.length} paths, more than the 50 allowed per request`, `At most 50 paths may be requested at once`);
|
|
@@ -2764,7 +2902,8 @@ var StudioSession = class {
|
|
|
2764
2902
|
* Creates the Kubb Studio client: the connection, the command loop, and the generation event
|
|
2765
2903
|
* stream shared by the `kubb studio` CLI command and the Docker agent.
|
|
2766
2904
|
*
|
|
2767
|
-
* Every permission is off by default. A host that wants more grants it explicitly.
|
|
2905
|
+
* Every permission is off by default. A host that wants more grants it explicitly. The machine
|
|
2906
|
+
* identity comes from the storage the host installed with `setStorage`, before connecting.
|
|
2768
2907
|
*
|
|
2769
2908
|
* @example
|
|
2770
2909
|
* ```ts
|
|
@@ -2772,10 +2911,9 @@ var StudioSession = class {
|
|
|
2772
2911
|
* await studio.connect()
|
|
2773
2912
|
* ```
|
|
2774
2913
|
*/
|
|
2775
|
-
function createClient({
|
|
2776
|
-
if (storage) setStorage(storage);
|
|
2914
|
+
function createClient({ onAuthRequired, ...options }) {
|
|
2777
2915
|
const controller = new AbortController();
|
|
2778
|
-
const
|
|
2916
|
+
const instanceId = (0, node_crypto.randomUUID)();
|
|
2779
2917
|
function notifyAuthRequired(error) {
|
|
2780
2918
|
if (controller.signal.aborted) return;
|
|
2781
2919
|
controller.abort();
|
|
@@ -2783,16 +2921,12 @@ function createClient({ storage, onAuthRequired, ...options }) {
|
|
|
2783
2921
|
}
|
|
2784
2922
|
return {
|
|
2785
2923
|
async connect() {
|
|
2786
|
-
await
|
|
2787
|
-
token: options.token,
|
|
2788
|
-
studioUrl: options.studioUrl ?? agentDefaults.studioUrl,
|
|
2789
|
-
poolSize
|
|
2790
|
-
});
|
|
2791
|
-
await Promise.all(Array.from({ length: poolSize }, () => new StudioSession({
|
|
2924
|
+
await new StudioSession({
|
|
2792
2925
|
...options,
|
|
2926
|
+
instanceId,
|
|
2793
2927
|
signal: controller.signal,
|
|
2794
2928
|
onTokenRejected: notifyAuthRequired
|
|
2795
|
-
}).start()
|
|
2929
|
+
}).start();
|
|
2796
2930
|
},
|
|
2797
2931
|
disconnect() {
|
|
2798
2932
|
controller.abort();
|
|
@@ -2874,11 +3008,11 @@ async function runConnection({ credentials, clientOptions, onTokenRejected, sign
|
|
|
2874
3008
|
}
|
|
2875
3009
|
//#endregion
|
|
2876
3010
|
//#region src/pair.ts
|
|
2877
|
-
/**
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
3011
|
+
/** Labels, not secrets: a person approving the code in the browser is what authorizes a pairing. */
|
|
3012
|
+
const CLIENT_IDS = {
|
|
3013
|
+
cli: "kubb-cli",
|
|
3014
|
+
agent: "kubb-agent"
|
|
3015
|
+
};
|
|
2882
3016
|
/**
|
|
2883
3017
|
* Thrown when a caller aborts `startPairing` or `pollForPairingToken` through their `signal`, such
|
|
2884
3018
|
* as a `kubb studio` shutdown mid-pairing. Distinct from a denial or an expired code, so a host can
|
|
@@ -2890,21 +3024,35 @@ var PairingCanceledError = class extends Error {
|
|
|
2890
3024
|
this.name = "PairingCanceledError";
|
|
2891
3025
|
}
|
|
2892
3026
|
};
|
|
3027
|
+
/** Thrown when the code expired unapproved. A fresh code can still succeed. */
|
|
3028
|
+
var PairingExpiredError = class extends Error {
|
|
3029
|
+
constructor(message = "The pairing code expired, pair again") {
|
|
3030
|
+
super(message);
|
|
3031
|
+
this.name = "PairingExpiredError";
|
|
3032
|
+
}
|
|
3033
|
+
};
|
|
3034
|
+
/** Thrown when the pairing was denied in the browser, including by the organization's agent limit. */
|
|
3035
|
+
var PairingDeniedError = class extends Error {
|
|
3036
|
+
constructor(message = "Pairing was denied in the browser") {
|
|
3037
|
+
super(message);
|
|
3038
|
+
this.name = "PairingDeniedError";
|
|
3039
|
+
}
|
|
3040
|
+
};
|
|
2893
3041
|
/**
|
|
2894
3042
|
* Asks Studio for a pairing code. The machine token travels with the request and is stored against
|
|
2895
3043
|
* the code, so approval knows which machine it is pairing: the same machine pairing twice rotates
|
|
2896
3044
|
* one agent's token instead of creating a second agent.
|
|
2897
3045
|
*/
|
|
2898
|
-
async function startPairing({ studioUrl = agentDefaults.studioUrl, name, hostname,
|
|
3046
|
+
async function startPairing({ studioUrl = agentDefaults.studioUrl, type, name, hostname, signal }) {
|
|
2899
3047
|
try {
|
|
2900
3048
|
return await (0, ofetch.ofetch)(`${studioUrl}/api/auth/device/code`, {
|
|
2901
3049
|
method: "POST",
|
|
2902
3050
|
body: {
|
|
2903
|
-
client_id:
|
|
3051
|
+
client_id: type === "cli" ? CLIENT_IDS.cli : CLIENT_IDS.agent,
|
|
2904
3052
|
name,
|
|
2905
3053
|
hostname,
|
|
2906
3054
|
machine_token: await getMachineToken(),
|
|
2907
|
-
agent_kind:
|
|
3055
|
+
agent_kind: type === "cli" ? void 0 : type
|
|
2908
3056
|
},
|
|
2909
3057
|
signal
|
|
2910
3058
|
});
|
|
@@ -2917,15 +3065,16 @@ function isPairingResult(response) {
|
|
|
2917
3065
|
return !!response && typeof response === "object" && "token" in response && typeof response.token === "string";
|
|
2918
3066
|
}
|
|
2919
3067
|
/**
|
|
2920
|
-
* Polls until the user approves or denies, honoring the server's `slow_down` back-off.
|
|
2921
|
-
* cannot reach Studio is warned about and retried, since the code stays valid either way.
|
|
3068
|
+
* Polls until the user approves or denies, honoring the server's `slow_down` back-off.
|
|
2922
3069
|
*
|
|
2923
3070
|
* Studio's own endpoint is used rather than the auth layer's `/device/token`, because an approved
|
|
2924
3071
|
* Kubb pairing is worth an agent bearer token, not a user session.
|
|
2925
3072
|
*
|
|
2926
|
-
* @throws when the code expires
|
|
3073
|
+
* @throws {PairingExpiredError} when the code expires before anyone approves it.
|
|
3074
|
+
* @throws {PairingDeniedError} when the pairing is denied in the browser.
|
|
3075
|
+
* @throws {PairingCanceledError} when `signal` aborts.
|
|
2927
3076
|
*/
|
|
2928
|
-
async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, session, signal }) {
|
|
3077
|
+
async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, session, signal, onRetry }) {
|
|
2929
3078
|
const deadline = Date.now() + (session.expires_in > 0 ? session.expires_in : 600) * 1e3;
|
|
2930
3079
|
let intervalMs = (session.interval > 0 ? session.interval : 5) * 1e3;
|
|
2931
3080
|
while (Date.now() < deadline) {
|
|
@@ -2945,7 +3094,7 @@ async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, sessio
|
|
|
2945
3094
|
});
|
|
2946
3095
|
} catch (error) {
|
|
2947
3096
|
if (signal?.aborted) throw new PairingCanceledError();
|
|
2948
|
-
|
|
3097
|
+
onRetry?.(toError(error));
|
|
2949
3098
|
continue;
|
|
2950
3099
|
}
|
|
2951
3100
|
if (isPairingResult(response)) return response;
|
|
@@ -2955,15 +3104,39 @@ async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, sessio
|
|
|
2955
3104
|
intervalMs += 5e3;
|
|
2956
3105
|
continue;
|
|
2957
3106
|
}
|
|
2958
|
-
if (response.error === "access_denied") throw new
|
|
2959
|
-
if (response.error === "expired_token" || response.error === "invalid_grant") throw new
|
|
3107
|
+
if (response.error === "access_denied") throw new PairingDeniedError(response.error_description);
|
|
3108
|
+
if (response.error === "expired_token" || response.error === "invalid_grant") throw new PairingExpiredError(response.error_description);
|
|
2960
3109
|
throw new Error(response.error_description ?? `Pairing failed (${response.error})`);
|
|
2961
3110
|
}
|
|
2962
|
-
throw new
|
|
3111
|
+
throw new PairingExpiredError();
|
|
3112
|
+
}
|
|
3113
|
+
/**
|
|
3114
|
+
* Pairs this machine with Studio: asks for a code, hands it to the host to show, and waits for approval.
|
|
3115
|
+
*/
|
|
3116
|
+
async function pairAgent({ onCode, onRetry, maxAttempts = 1, ...options }) {
|
|
3117
|
+
for (let attempt = 1;; attempt++) {
|
|
3118
|
+
const session = await startPairing(options);
|
|
3119
|
+
await onCode(session, attempt);
|
|
3120
|
+
try {
|
|
3121
|
+
return await pollForPairingToken({
|
|
3122
|
+
studioUrl: options.studioUrl,
|
|
3123
|
+
session,
|
|
3124
|
+
signal: options.signal,
|
|
3125
|
+
onRetry
|
|
3126
|
+
});
|
|
3127
|
+
} catch (error) {
|
|
3128
|
+
if (!(error instanceof PairingExpiredError) || attempt >= maxAttempts) throw error;
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
2963
3131
|
}
|
|
2964
3132
|
//#endregion
|
|
3133
|
+
exports.AGENT_INSTANCE_HEADER = require_protocol.AGENT_INSTANCE_HEADER;
|
|
3134
|
+
exports.AgentCloseCode = require_protocol.AgentCloseCode;
|
|
3135
|
+
exports.IncompatibleAgentError = IncompatibleAgentError;
|
|
2965
3136
|
exports.InvalidAgentTokenError = InvalidAgentTokenError;
|
|
2966
3137
|
exports.PairingCanceledError = PairingCanceledError;
|
|
3138
|
+
exports.PairingDeniedError = PairingDeniedError;
|
|
3139
|
+
exports.PairingExpiredError = PairingExpiredError;
|
|
2967
3140
|
exports.connectWebSocketRpc = connectWebSocketRpc;
|
|
2968
3141
|
exports.createAgent = createAgent;
|
|
2969
3142
|
exports.createClient = createClient;
|
|
@@ -2972,6 +3145,7 @@ exports.createJob = createJob;
|
|
|
2972
3145
|
exports.defaultStudioUrl = defaultStudioUrl;
|
|
2973
3146
|
exports.generationEventTypes = require_protocol.generationEventTypes;
|
|
2974
3147
|
exports.machineTokenFrom = machineTokenFrom;
|
|
3148
|
+
exports.pairAgent = pairAgent;
|
|
2975
3149
|
exports.pollForPairingToken = pollForPairingToken;
|
|
2976
3150
|
exports.runConnection = runConnection;
|
|
2977
3151
|
exports.setStorage = setStorage;
|