@kubb/studio 5.3.16 → 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 +22 -8
- package/dist/index.cjs +448 -316
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +54 -24
- package/dist/index.js +446 -317
- 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 -38
- 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
|
-
node_process = __toESM(node_process, 1);
|
|
31
|
-
let node_child_process = require("node:child_process");
|
|
29
|
+
let node_crypto = require("node:crypto");
|
|
32
30
|
let node_fs_promises = require("node:fs/promises");
|
|
31
|
+
let node_os = require("node:os");
|
|
33
32
|
let node_path = require("node:path");
|
|
34
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");
|
|
37
|
+
let _kubb_core = require("@kubb/core");
|
|
38
|
+
let tinyexec = require("tinyexec");
|
|
39
|
+
let node_timers_promises = require("node:timers/promises");
|
|
35
40
|
let ofetch = require("ofetch");
|
|
36
|
-
let node_crypto = require("node:crypto");
|
|
37
41
|
let node_util = require("node:util");
|
|
38
42
|
let unstorage = require("unstorage");
|
|
39
43
|
let unstorage_drivers_fs = require("unstorage/drivers/fs");
|
|
40
44
|
unstorage_drivers_fs = __toESM(unstorage_drivers_fs, 1);
|
|
41
|
-
let _kubb_core = require("@kubb/core");
|
|
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,117 +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
|
|
528
|
-
}
|
|
529
|
-
/**
|
|
530
|
-
* Performs the raw session create request against Studio.
|
|
531
|
-
*/
|
|
532
|
-
async function requestAgentSession({ token, studioUrl }) {
|
|
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.
|
|
544
|
-
*
|
|
545
|
-
* When Studio rejects the machine token (403), for example after the agent restarted
|
|
546
|
-
* with a new identity while the startup registration call failed, the agent re-registers
|
|
547
|
-
* and retries once, so a single failed registration can't permanently block session creation.
|
|
548
|
-
*/
|
|
549
|
-
async function createAgentSession({ token, studioUrl }) {
|
|
550
|
-
try {
|
|
551
|
-
return await requestAgentSession({
|
|
552
|
-
token,
|
|
553
|
-
studioUrl
|
|
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
|
-
}
|
|
569
|
+
return new Error(detail ? `Failed to register with Kubb Studio: ${detail}` : "Failed to register with Kubb Studio", { cause });
|
|
571
570
|
}
|
|
572
571
|
/**
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
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.
|
|
576
575
|
*
|
|
577
|
-
* Retries
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
596
|
+
if (rejectedWith(error, 426)) throw new IncompatibleAgentError(studioUrl, error instanceof ofetch.FetchError ? responseMessage(error.data) : void 0, { cause: error });
|
|
597
|
+
throw registrationError(error);
|
|
606
598
|
}
|
|
607
599
|
}
|
|
608
600
|
/**
|
|
609
|
-
*
|
|
610
|
-
* Called on process termination or server close. Never throws: the local socket is already gone,
|
|
611
|
-
* and failing teardown must not block shutdown or reconnect.
|
|
612
|
-
*
|
|
613
|
-
* @returns `false` when Studio could not be reached or rate limited the call. Any other 4xx
|
|
614
|
-
* counts as notified, since it means Studio already dropped the session.
|
|
601
|
+
* First wait before `createJob` retries a busy or queue-full response, absent a `Retry-After` hint.
|
|
615
602
|
*/
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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;
|
|
627
632
|
}
|
|
628
633
|
/**
|
|
629
634
|
* Queues a generation or snapshot job on Studio (`POST /api/jobs`).
|
|
@@ -631,6 +636,10 @@ async function disconnect({ sessionId, token, studioUrl }) {
|
|
|
631
636
|
* Returns as soon as Studio accepts the job (`202`). Poll with {@link waitForJob} until it finishes.
|
|
632
637
|
* Authenticates with the organization CI API key via `x-api-key`.
|
|
633
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
|
+
*
|
|
634
643
|
* @example Snapshot job
|
|
635
644
|
* ```ts
|
|
636
645
|
* const job = await createJob({
|
|
@@ -644,20 +653,40 @@ async function disconnect({ sessionId, token, studioUrl }) {
|
|
|
644
653
|
* const finished = await waitForJob({ studioUrl, token, id: job.id })
|
|
645
654
|
* ```
|
|
646
655
|
*/
|
|
647
|
-
async function createJob({ studioUrl, token, type, agentId, name, version, commit, config }) {
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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);
|
|
658
688
|
}
|
|
659
|
-
}
|
|
660
|
-
return job;
|
|
689
|
+
}
|
|
661
690
|
}
|
|
662
691
|
/**
|
|
663
692
|
* A job runs a generation and packs a tarball, so it is never done the instant it is queued.
|
|
@@ -676,20 +705,26 @@ const MAX_POLL_INTERVAL_MS = 3e4;
|
|
|
676
705
|
* A `failed` job resolves normally. Check `job.status` and `job.error`. Throws only when the
|
|
677
706
|
* deadline passes before Studio finishes.
|
|
678
707
|
*/
|
|
679
|
-
async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4 }) {
|
|
708
|
+
async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4, signal }) {
|
|
680
709
|
const deadline = Date.now() + timeoutMs;
|
|
681
710
|
let interval = INITIAL_POLL_DELAY_MS;
|
|
682
711
|
for (;;) {
|
|
683
|
-
|
|
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));
|
|
684
716
|
if (Date.now() >= deadline) throw new Error("Timed out waiting for the Studio job");
|
|
685
717
|
interval = Math.min(interval * 2, MAX_POLL_INTERVAL_MS);
|
|
686
718
|
try {
|
|
687
719
|
const { job } = await (0, ofetch.ofetch)(`${studioUrl}/api/jobs/${id}`, {
|
|
688
720
|
headers: { "x-api-key": token },
|
|
689
|
-
retry: false
|
|
721
|
+
retry: false,
|
|
722
|
+
timeout: Math.max(deadline - Date.now(), 1),
|
|
723
|
+
signal
|
|
690
724
|
});
|
|
691
725
|
if (job.status === "success" || job.status === "failed" || job.status === "canceled") return job;
|
|
692
726
|
} catch (error) {
|
|
727
|
+
signal?.throwIfAborted();
|
|
693
728
|
const response = error.response;
|
|
694
729
|
if (response?.status !== 429) throw error;
|
|
695
730
|
const retryAfter = response._data?.data?.tryAgainIn;
|
|
@@ -723,89 +758,6 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
|
|
|
723
758
|
}
|
|
724
759
|
}
|
|
725
760
|
//#endregion
|
|
726
|
-
//#region package.json
|
|
727
|
-
var version = "5.3.16";
|
|
728
|
-
//#endregion
|
|
729
|
-
//#region src/hooks.ts
|
|
730
|
-
/**
|
|
731
|
-
* Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
|
|
732
|
-
* streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
|
|
733
|
-
* Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
|
|
734
|
-
*
|
|
735
|
-
* Returns a remover, so a session that runs one generation after another on the same emitter does
|
|
736
|
-
* not stack a listener per run.
|
|
737
|
-
*/
|
|
738
|
-
function setupHookListener(hooks, root, signal) {
|
|
739
|
-
return hooks.hook("kubb:hook:start", async (ctx) => {
|
|
740
|
-
const { id, command, args } = ctx;
|
|
741
|
-
if (!id) return;
|
|
742
|
-
const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
|
|
743
|
-
try {
|
|
744
|
-
const proc = (0, tinyexec.x)(command, [...args ?? []], {
|
|
745
|
-
signal,
|
|
746
|
-
nodeOptions: {
|
|
747
|
-
cwd: root,
|
|
748
|
-
detached: true
|
|
749
|
-
}
|
|
750
|
-
});
|
|
751
|
-
for await (const line of proc) await hooks.callHook("kubb:hook:line", {
|
|
752
|
-
id,
|
|
753
|
-
line
|
|
754
|
-
});
|
|
755
|
-
const { exitCode } = await proc;
|
|
756
|
-
if (exitCode !== 0) {
|
|
757
|
-
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
758
|
-
await hooks.callHook("kubb:hook:end", {
|
|
759
|
-
id,
|
|
760
|
-
command,
|
|
761
|
-
args,
|
|
762
|
-
success: false,
|
|
763
|
-
error
|
|
764
|
-
});
|
|
765
|
-
await hooks.callHook("kubb:error", { error });
|
|
766
|
-
return;
|
|
767
|
-
}
|
|
768
|
-
await hooks.callHook("kubb:hook:end", {
|
|
769
|
-
id,
|
|
770
|
-
command,
|
|
771
|
-
args,
|
|
772
|
-
success: true,
|
|
773
|
-
error: null
|
|
774
|
-
});
|
|
775
|
-
} catch (caughtError) {
|
|
776
|
-
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
777
|
-
error.cause = caughtError;
|
|
778
|
-
await hooks.callHook("kubb:hook:end", {
|
|
779
|
-
id,
|
|
780
|
-
command,
|
|
781
|
-
args,
|
|
782
|
-
success: false,
|
|
783
|
-
error
|
|
784
|
-
});
|
|
785
|
-
await hooks.callHook("kubb:error", { error });
|
|
786
|
-
}
|
|
787
|
-
});
|
|
788
|
-
}
|
|
789
|
-
/**
|
|
790
|
-
* Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
|
|
791
|
-
* `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
|
|
792
|
-
* that same listener, so a handler added afterward would already have missed it.
|
|
793
|
-
*/
|
|
794
|
-
function waitForHookEnd(hooks, hookId) {
|
|
795
|
-
return new Promise((resolve, reject) => {
|
|
796
|
-
const handleHookEnd = (ctx) => {
|
|
797
|
-
if (ctx.id !== hookId) return;
|
|
798
|
-
hooks.removeHook("kubb:hook:end", handleHookEnd);
|
|
799
|
-
if (ctx.success) {
|
|
800
|
-
resolve();
|
|
801
|
-
return;
|
|
802
|
-
}
|
|
803
|
-
reject(ctx.error);
|
|
804
|
-
};
|
|
805
|
-
hooks.hook("kubb:hook:end", handleHookEnd);
|
|
806
|
-
});
|
|
807
|
-
}
|
|
808
|
-
//#endregion
|
|
809
761
|
//#region src/resolveConfig.ts
|
|
810
762
|
/**
|
|
811
763
|
* Imports a package, falling back to how the user's project would resolve it.
|
|
@@ -1673,6 +1625,60 @@ async function generate({ config, hooks, signal }) {
|
|
|
1673
1625
|
}
|
|
1674
1626
|
}
|
|
1675
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
|
|
1676
1682
|
//#region src/snapshotPackage.ts
|
|
1677
1683
|
const gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
|
|
1678
1684
|
/**
|
|
@@ -1815,7 +1821,7 @@ async function createSnapshotPackage(files, packageInfo) {
|
|
|
1815
1821
|
//#endregion
|
|
1816
1822
|
//#region src/generations.ts
|
|
1817
1823
|
const READ_CONCURRENCY = 50;
|
|
1818
|
-
const MB = 1048576;
|
|
1824
|
+
const MB$1 = 1048576;
|
|
1819
1825
|
const INDEX_KEY = "studio/generations.json";
|
|
1820
1826
|
const hashOf = (content) => (0, node_crypto.createHash)("sha1").update(content).digest("hex").slice(0, 16);
|
|
1821
1827
|
/**
|
|
@@ -1842,8 +1848,9 @@ async function listDisk({ root, outputPath, maxFiles }) {
|
|
|
1842
1848
|
* tenant never reaches another's output. The oldest go past `maxCount` or `maxMb`, but the newest
|
|
1843
1849
|
* always stays.
|
|
1844
1850
|
*/
|
|
1845
|
-
function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
1851
|
+
function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now }) {
|
|
1846
1852
|
let index;
|
|
1853
|
+
const isLive = (generation) => ttlMs === void 0 || generation.keptAt === void 0 || now() - generation.keptAt < ttlMs;
|
|
1847
1854
|
const dirOf = (jobId) => `studio/generations/${hashOf(jobId)}/`;
|
|
1848
1855
|
async function load() {
|
|
1849
1856
|
if (index) return index;
|
|
@@ -1859,7 +1866,7 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
|
1859
1866
|
* Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
|
|
1860
1867
|
*/
|
|
1861
1868
|
async function keep({ jobId, source, files, maxSetMb }) {
|
|
1862
|
-
const maxSetBytes = maxSetMb * MB;
|
|
1869
|
+
const maxSetBytes = maxSetMb * MB$1;
|
|
1863
1870
|
const hashes = {};
|
|
1864
1871
|
let bytes = 0;
|
|
1865
1872
|
await inParallel({
|
|
@@ -1894,13 +1901,20 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
|
1894
1901
|
return {
|
|
1895
1902
|
keep,
|
|
1896
1903
|
drop,
|
|
1897
|
-
get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId),
|
|
1898
|
-
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),
|
|
1899
1908
|
async add(generation) {
|
|
1900
|
-
const
|
|
1901
|
-
|
|
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
|
+
});
|
|
1902
1916
|
const weight = () => entries.reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0);
|
|
1903
|
-
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);
|
|
1904
1918
|
index = entries;
|
|
1905
1919
|
await storage.writeItem(INDEX_KEY, JSON.stringify(entries));
|
|
1906
1920
|
},
|
|
@@ -2187,6 +2201,9 @@ var AgentRpcTarget = class extends capnweb.RpcTarget {
|
|
|
2187
2201
|
readFiles(input) {
|
|
2188
2202
|
return this.api.readFiles(input);
|
|
2189
2203
|
}
|
|
2204
|
+
cancel(jobId) {
|
|
2205
|
+
return this.api.cancel(jobId);
|
|
2206
|
+
}
|
|
2190
2207
|
};
|
|
2191
2208
|
/**
|
|
2192
2209
|
* Opens an authenticated Cap'n Web session to Studio over a WebSocket. Rejects an unencrypted URL
|
|
@@ -2198,11 +2215,17 @@ var AgentRpcTarget = class extends capnweb.RpcTarget {
|
|
|
2198
2215
|
* await rpc.studio.ping()
|
|
2199
2216
|
* ```
|
|
2200
2217
|
*/
|
|
2201
|
-
const connectWebSocketRpc = async ({ url, token, local }) => {
|
|
2218
|
+
const connectWebSocketRpc = async ({ url, token, instanceId, local }) => {
|
|
2202
2219
|
const { protocol, hostname, host } = new URL(url);
|
|
2203
2220
|
if (protocol !== "wss:" && !(protocol === "ws:" && (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"))) throw new Error(`Refusing unencrypted WebSocket to ${host}`);
|
|
2204
|
-
const socket = createWebsocket(url, { headers: {
|
|
2205
|
-
|
|
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
|
+
})));
|
|
2206
2229
|
const studio = (0, capnweb.newWebSocketRpcSession)(socket, new AgentRpcTarget(local));
|
|
2207
2230
|
studio.onRpcBroken(() => socket.close());
|
|
2208
2231
|
return {
|
|
@@ -2217,6 +2240,32 @@ const connectWebSocketRpc = async ({ url, token, local }) => {
|
|
|
2217
2240
|
* Past this many files in the output directory, no snapshot of it is taken before a run.
|
|
2218
2241
|
*/
|
|
2219
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
|
+
}
|
|
2220
2269
|
var GenerationRunTarget = class extends capnweb.RpcTarget {
|
|
2221
2270
|
generationStream;
|
|
2222
2271
|
generationResult;
|
|
@@ -2263,34 +2312,74 @@ function applyStudioDefaults(options) {
|
|
|
2263
2312
|
...options.permissions
|
|
2264
2313
|
},
|
|
2265
2314
|
retryInterval: options.retryInterval ?? agentDefaults.retryIntervalMs,
|
|
2266
|
-
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)()
|
|
2267
2321
|
};
|
|
2268
2322
|
}
|
|
2269
2323
|
/**
|
|
2270
|
-
*
|
|
2271
|
-
*
|
|
2272
|
-
* A free function rather than a method: a pending retry timer reaches whatever it closes over, so
|
|
2273
|
-
* closing only over `options` (not a `StudioSession`) keeps a queued retry from pinning a closed
|
|
2274
|
-
* 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).
|
|
2275
2326
|
*/
|
|
2276
|
-
|
|
2277
|
-
|
|
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;
|
|
2278
2338
|
if (signal?.aborted) return;
|
|
2279
2339
|
const cancel = () => clearTimeout(timer);
|
|
2280
2340
|
const timer = setTimeout(() => {
|
|
2281
2341
|
signal?.removeEventListener("abort", cancel);
|
|
2282
2342
|
if (signal?.aborted) return;
|
|
2283
|
-
new StudioSession(
|
|
2343
|
+
new StudioSession({
|
|
2344
|
+
...options,
|
|
2345
|
+
reconnectAttempt: attempt
|
|
2346
|
+
}).start().catch((error) => {
|
|
2284
2347
|
if (error instanceof InvalidAgentTokenError) {
|
|
2285
2348
|
onTokenRejected?.(error);
|
|
2286
2349
|
return;
|
|
2287
2350
|
}
|
|
2288
|
-
|
|
2351
|
+
if (error instanceof IncompatibleAgentError) return;
|
|
2352
|
+
const nextAttempt = attempt + 1;
|
|
2353
|
+
reconnect(options, backoffDelayMs(nextAttempt, options.retryInterval), nextAttempt);
|
|
2289
2354
|
});
|
|
2290
|
-
},
|
|
2355
|
+
}, delayMs);
|
|
2291
2356
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
2292
2357
|
}
|
|
2293
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
|
+
/**
|
|
2294
2383
|
* One agent-to-Studio RPC transport: opening it, keeping it alive, and serving remote methods.
|
|
2295
2384
|
* `createClient` opens one per pool slot and is the only caller.
|
|
2296
2385
|
*/
|
|
@@ -2303,14 +2392,15 @@ var StudioSession = class {
|
|
|
2303
2392
|
*/
|
|
2304
2393
|
#unhooks = [];
|
|
2305
2394
|
/**
|
|
2306
|
-
* What
|
|
2307
|
-
* 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.
|
|
2308
2397
|
*/
|
|
2309
|
-
#
|
|
2398
|
+
#registration;
|
|
2310
2399
|
#rpc;
|
|
2311
2400
|
#studioVersion;
|
|
2312
2401
|
#disposed = false;
|
|
2313
2402
|
#isGenerating = false;
|
|
2403
|
+
#activeJob;
|
|
2314
2404
|
#heartbeatTimer;
|
|
2315
2405
|
#lastGeneration;
|
|
2316
2406
|
#store;
|
|
@@ -2320,17 +2410,17 @@ var StudioSession = class {
|
|
|
2320
2410
|
* host does not queue jobs before the agent session is registered.
|
|
2321
2411
|
*/
|
|
2322
2412
|
#connectAck = Promise.withResolvers();
|
|
2323
|
-
#
|
|
2324
|
-
constructor({
|
|
2413
|
+
#reconnectAttempt;
|
|
2414
|
+
constructor({ reconnectAttempt, ...options }) {
|
|
2325
2415
|
this.#options = applyStudioDefaults(options);
|
|
2326
|
-
this.#
|
|
2416
|
+
this.#reconnectAttempt = reconnectAttempt ?? 0;
|
|
2327
2417
|
this.#connectAck.promise.catch(() => {});
|
|
2328
2418
|
}
|
|
2329
2419
|
/**
|
|
2330
2420
|
* A sandbox agent runs on Studio's own infrastructure, so it has no user project to touch.
|
|
2331
2421
|
*/
|
|
2332
2422
|
get #isSandbox() {
|
|
2333
|
-
return this.#
|
|
2423
|
+
return this.#registration?.isSandbox === true;
|
|
2334
2424
|
}
|
|
2335
2425
|
/**
|
|
2336
2426
|
* Kept in the project's cache directory, so it survives a restart, except on a sandbox: its pool
|
|
@@ -2340,7 +2430,8 @@ var StudioSession = class {
|
|
|
2340
2430
|
this.#store ??= createGenerationStore({
|
|
2341
2431
|
storage: this.#isSandbox ? (0, _kubb_core.memoryStorage)() : (0, _kubb_core.cacheStorage)({ root: this.#options.root }),
|
|
2342
2432
|
maxCount: this.#limits.maxCount,
|
|
2343
|
-
maxMb: this.#limits.maxMb
|
|
2433
|
+
maxMb: this.#limits.maxMb,
|
|
2434
|
+
ttlMs: this.#isSandbox ? SANDBOX_GENERATION_TTL_MS : void 0
|
|
2344
2435
|
});
|
|
2345
2436
|
return this.#store;
|
|
2346
2437
|
}
|
|
@@ -2364,20 +2455,26 @@ var StudioSession = class {
|
|
|
2364
2455
|
return this.#isSandbox || this.#options.permissions.allowRead;
|
|
2365
2456
|
}
|
|
2366
2457
|
async start() {
|
|
2367
|
-
const { token, studioUrl, signal, heartbeatInterval, installLogger } = this.#options;
|
|
2458
|
+
const { token, studioUrl, signal, heartbeatInterval, installLogger, instanceId, capacity } = this.#options;
|
|
2368
2459
|
await installLogger?.(this.#hooks);
|
|
2369
|
-
if (this.#startupWarning) await this.#warn(this.#startupWarning);
|
|
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,16 +2489,17 @@ 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;
|
|
2502
|
+
if (error instanceof InvalidAgentTokenError || error instanceof IncompatibleAgentError) throw error;
|
|
2405
2503
|
await this.#reconnect();
|
|
2406
2504
|
}
|
|
2407
2505
|
}
|
|
@@ -2411,8 +2509,10 @@ var StudioSession = class {
|
|
|
2411
2509
|
*/
|
|
2412
2510
|
async #reconnect() {
|
|
2413
2511
|
if (this.#options.signal?.aborted) return;
|
|
2414
|
-
|
|
2415
|
-
|
|
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);
|
|
2416
2516
|
}
|
|
2417
2517
|
#warn(message, permission) {
|
|
2418
2518
|
return this.#hooks.callHook("studio:warn", {
|
|
@@ -2444,10 +2544,31 @@ var StudioSession = class {
|
|
|
2444
2544
|
/**
|
|
2445
2545
|
* Races `studio.ping()` against a deadline, so a half-open socket can't hang it forever.
|
|
2446
2546
|
* */
|
|
2447
|
-
#ping(rpc) {
|
|
2547
|
+
async #ping(rpc) {
|
|
2448
2548
|
const { promise: timedOut, reject: onTimeout } = Promise.withResolvers();
|
|
2449
2549
|
const timer = setTimeout(() => onTimeout(/* @__PURE__ */ new Error("Heartbeat ping timed out")), agentDefaults.heartbeatTimeoutMs);
|
|
2450
|
-
|
|
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
|
+
};
|
|
2451
2572
|
}
|
|
2452
2573
|
/**
|
|
2453
2574
|
* Reads `kubb.config.ts` and reports which plugin options Studio may edit.
|
|
@@ -2492,8 +2613,11 @@ var StudioSession = class {
|
|
|
2492
2613
|
this.#connectAck.resolve();
|
|
2493
2614
|
return payload;
|
|
2494
2615
|
}
|
|
2495
|
-
#onAbort = () => void this.#end({
|
|
2496
|
-
|
|
2616
|
+
#onAbort = () => void this.#end({
|
|
2617
|
+
reason: "shutdown",
|
|
2618
|
+
retry: false
|
|
2619
|
+
});
|
|
2620
|
+
#onClose = (close) => void this.#end(planEnd(close));
|
|
2497
2621
|
/**
|
|
2498
2622
|
* Drops the socket and detaches every listener and timer this session added. Idempotent, and
|
|
2499
2623
|
* safe before `connect` opened anything.
|
|
@@ -2513,17 +2637,12 @@ var StudioSession = class {
|
|
|
2513
2637
|
* Ends the session: tells Studio it is over, drops the socket, and optionally reconnects.
|
|
2514
2638
|
* `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
|
|
2515
2639
|
*/
|
|
2516
|
-
async #end({ retry }) {
|
|
2517
|
-
const { studioUrl, token } = this.#options;
|
|
2640
|
+
async #end({ reason, retry, error }) {
|
|
2518
2641
|
if (this.#disposed) return;
|
|
2519
2642
|
this.#disposed = true;
|
|
2520
2643
|
this.dispose();
|
|
2521
|
-
await this.#hooks.callHook("studio:disconnected", { reason
|
|
2522
|
-
if (this.#
|
|
2523
|
-
sessionId: this.#session.sessionId,
|
|
2524
|
-
studioUrl,
|
|
2525
|
-
token
|
|
2526
|
-
})) await this.#warn("Could not notify Kubb Studio of the disconnect");
|
|
2644
|
+
await this.#hooks.callHook("studio:disconnected", { reason });
|
|
2645
|
+
if (error) await this.#hooks.callHook("studio:error", { error });
|
|
2527
2646
|
if (retry) await this.#reconnect();
|
|
2528
2647
|
}
|
|
2529
2648
|
startGeneration(data) {
|
|
@@ -2531,27 +2650,43 @@ var StudioSession = class {
|
|
|
2531
2650
|
this.#lastGeneration = result;
|
|
2532
2651
|
} });
|
|
2533
2652
|
const controller = new AbortController();
|
|
2534
|
-
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) => {
|
|
2535
2657
|
await generationStream.close();
|
|
2536
2658
|
return value;
|
|
2537
2659
|
}).catch((error) => {
|
|
2538
2660
|
generationStream.fail(error);
|
|
2539
2661
|
throw error;
|
|
2662
|
+
}).finally(() => {
|
|
2663
|
+
if (this.#activeJob?.cancel === cancelRun) this.#activeJob = void 0;
|
|
2540
2664
|
});
|
|
2541
2665
|
result.catch(() => {});
|
|
2542
|
-
return new GenerationRunTarget(generationStream.stream, result,
|
|
2543
|
-
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2544
|
-
}, () => {
|
|
2666
|
+
return new GenerationRunTarget(generationStream.stream, result, cancelRun, () => {
|
|
2545
2667
|
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2546
2668
|
generationStream.dispose();
|
|
2547
2669
|
});
|
|
2548
2670
|
}
|
|
2549
|
-
|
|
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) {
|
|
2550
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");
|
|
2551
2680
|
this.#isGenerating = true;
|
|
2681
|
+
this.#activeJob = {
|
|
2682
|
+
jobId: data.jobId,
|
|
2683
|
+
cancel: cancelRun
|
|
2684
|
+
};
|
|
2552
2685
|
const command = "generate";
|
|
2553
|
-
const {
|
|
2686
|
+
const { loadConfig, permissions } = this.#options;
|
|
2687
|
+
let root = this.#options.root;
|
|
2554
2688
|
try {
|
|
2689
|
+
if (this.#isSandbox) root = await createJobRoot();
|
|
2555
2690
|
await this.#hooks.callHook("studio:command:start", { command });
|
|
2556
2691
|
const config = await loadConfig();
|
|
2557
2692
|
const patch = data.config;
|
|
@@ -2630,6 +2765,7 @@ var StudioSession = class {
|
|
|
2630
2765
|
disk: disk ? { hashes: disk.hashes } : void 0
|
|
2631
2766
|
};
|
|
2632
2767
|
} finally {
|
|
2768
|
+
if (root !== this.#options.root) await removeJobRoot(root);
|
|
2633
2769
|
this.#isGenerating = false;
|
|
2634
2770
|
}
|
|
2635
2771
|
}
|
|
@@ -2777,7 +2913,7 @@ var StudioSession = class {
|
|
|
2777
2913
|
*/
|
|
2778
2914
|
function createClient({ onAuthRequired, ...options }) {
|
|
2779
2915
|
const controller = new AbortController();
|
|
2780
|
-
const
|
|
2916
|
+
const instanceId = (0, node_crypto.randomUUID)();
|
|
2781
2917
|
function notifyAuthRequired(error) {
|
|
2782
2918
|
if (controller.signal.aborted) return;
|
|
2783
2919
|
controller.abort();
|
|
@@ -2785,19 +2921,12 @@ function createClient({ onAuthRequired, ...options }) {
|
|
|
2785
2921
|
}
|
|
2786
2922
|
return {
|
|
2787
2923
|
async connect() {
|
|
2788
|
-
|
|
2789
|
-
token: options.token,
|
|
2790
|
-
studioUrl: options.studioUrl ?? agentDefaults.studioUrl,
|
|
2791
|
-
poolSize
|
|
2792
|
-
});
|
|
2793
|
-
if (controller.signal.aborted) return;
|
|
2794
|
-
const startupWarning = registered ? void 0 : "Could not register with Kubb Studio, continuing";
|
|
2795
|
-
await Promise.all(Array.from({ length: poolSize }, (_, slot) => new StudioSession({
|
|
2924
|
+
await new StudioSession({
|
|
2796
2925
|
...options,
|
|
2926
|
+
instanceId,
|
|
2797
2927
|
signal: controller.signal,
|
|
2798
|
-
onTokenRejected: notifyAuthRequired
|
|
2799
|
-
|
|
2800
|
-
}).start()));
|
|
2928
|
+
onTokenRejected: notifyAuthRequired
|
|
2929
|
+
}).start();
|
|
2801
2930
|
},
|
|
2802
2931
|
disconnect() {
|
|
2803
2932
|
controller.abort();
|
|
@@ -3001,6 +3130,9 @@ async function pairAgent({ onCode, onRetry, maxAttempts = 1, ...options }) {
|
|
|
3001
3130
|
}
|
|
3002
3131
|
}
|
|
3003
3132
|
//#endregion
|
|
3133
|
+
exports.AGENT_INSTANCE_HEADER = require_protocol.AGENT_INSTANCE_HEADER;
|
|
3134
|
+
exports.AgentCloseCode = require_protocol.AgentCloseCode;
|
|
3135
|
+
exports.IncompatibleAgentError = IncompatibleAgentError;
|
|
3004
3136
|
exports.InvalidAgentTokenError = InvalidAgentTokenError;
|
|
3005
3137
|
exports.PairingCanceledError = PairingCanceledError;
|
|
3006
3138
|
exports.PairingDeniedError = PairingDeniedError;
|