@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.js
CHANGED
|
@@ -1,71 +1,27 @@
|
|
|
1
1
|
import { t as __name } from "./rolldown-runtime-CRm0XQPb.js";
|
|
2
|
-
import { GENERATION_GONE_MESSAGE, generationEventTypes } from "./protocol.js";
|
|
2
|
+
import { AGENT_INSTANCE_HEADER, AgentCloseCode, GENERATION_GONE_MESSAGE, generationEventTypes } from "./protocol.js";
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
|
-
import
|
|
5
|
-
import { spawn } from "node:child_process";
|
|
4
|
+
import { createHash, hash, randomBytes, randomUUID } from "node:crypto";
|
|
6
5
|
import { glob, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
7
|
import path, { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
|
+
import process$1 from "node:process";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { Diagnostics, Hookable, cacheStorage, createKubb, fsStorage, memoryStorage, resolveCacheDir } from "@kubb/core";
|
|
11
|
+
import { x } from "tinyexec";
|
|
12
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
8
13
|
import { FetchError, ofetch } from "ofetch";
|
|
9
|
-
import { createHash, hash, randomBytes } from "node:crypto";
|
|
10
14
|
import { promisify, styleText } from "node:util";
|
|
11
15
|
import { createStorage } from "unstorage";
|
|
12
16
|
import fsDriver from "unstorage/drivers/fs";
|
|
13
|
-
import { Diagnostics, Hookable, cacheStorage, createKubb, fsStorage, memoryStorage } from "@kubb/core";
|
|
14
|
-
import { x } from "tinyexec";
|
|
15
17
|
import { builders, detectCodeFormat, generateCode, parseModule } from "magicast";
|
|
16
18
|
import { existsSync } from "node:fs";
|
|
17
19
|
import { pathToFileURL } from "node:url";
|
|
18
20
|
import { mergeDeep } from "remeda";
|
|
19
|
-
import { tmpdir } from "node:os";
|
|
20
21
|
import { gzip } from "node:zlib";
|
|
21
22
|
import { build } from "tsdown";
|
|
22
23
|
import { RpcTarget, newWebSocketRpcSession } from "capnweb";
|
|
23
24
|
import WebSocket from "ws";
|
|
24
|
-
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
25
|
-
//#region src/constants.ts
|
|
26
|
-
/**
|
|
27
|
-
* Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
|
|
28
|
-
* not whatever default the client would pick on its own.
|
|
29
|
-
*/
|
|
30
|
-
const defaultStudioUrl = "https://kubb.studio";
|
|
31
|
-
/**
|
|
32
|
-
* Defaults the Studio client uses when a host passes nothing.
|
|
33
|
-
* Config path is left out on purpose: each host discovers that itself.
|
|
34
|
-
*/
|
|
35
|
-
const agentDefaults = {
|
|
36
|
-
studioUrl: defaultStudioUrl,
|
|
37
|
-
retryIntervalMs: 3e4,
|
|
38
|
-
heartbeatIntervalMs: 3e4,
|
|
39
|
-
/**
|
|
40
|
-
* Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
|
|
41
|
-
* stored ping is older than its liveness window, and it stores a ping at most once a minute, so
|
|
42
|
-
* a slower cadence would make a healthy agent look dead after a single missed ping.
|
|
43
|
-
*/
|
|
44
|
-
maxHeartbeatIntervalMs: 6e4,
|
|
45
|
-
/** How long a heartbeat ping may take before the session is treated as dead. */
|
|
46
|
-
heartbeatTimeoutMs: 1e4,
|
|
47
|
-
poolSize: 1,
|
|
48
|
-
maxGenerations: 8,
|
|
49
|
-
maxGenerationsMb: 100,
|
|
50
|
-
maxSnapshotMb: 50
|
|
51
|
-
};
|
|
52
|
-
function positiveNumber(value) {
|
|
53
|
-
const parsed = Number(value);
|
|
54
|
-
return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* How many generations an agent keeps and how large they may get, read from
|
|
58
|
-
* `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
|
|
59
|
-
* An unset or invalid value keeps the default.
|
|
60
|
-
*/
|
|
61
|
-
function resolveGenerationLimits(env = process.env) {
|
|
62
|
-
return {
|
|
63
|
-
maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
|
|
64
|
-
maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
|
|
65
|
-
maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
//#endregion
|
|
69
25
|
//#region ../../internals/utils/src/casing.ts
|
|
70
26
|
/**
|
|
71
27
|
* Shared implementation for camelCase and PascalCase conversion.
|
|
@@ -388,6 +344,89 @@ function getElapsedMs(hrStart) {
|
|
|
388
344
|
return Math.round(ms * 100) / 100;
|
|
389
345
|
}
|
|
390
346
|
//#endregion
|
|
347
|
+
//#region package.json
|
|
348
|
+
var version = "5.3.17";
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region src/hooks.ts
|
|
351
|
+
/**
|
|
352
|
+
* Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
|
|
353
|
+
* streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
|
|
354
|
+
* Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
|
|
355
|
+
*
|
|
356
|
+
* Returns a remover, so a session that runs one generation after another on the same emitter does
|
|
357
|
+
* not stack a listener per run.
|
|
358
|
+
*/
|
|
359
|
+
function setupHookListener(hooks, root, signal) {
|
|
360
|
+
return hooks.hook("kubb:hook:start", async (ctx) => {
|
|
361
|
+
const { id, command, args } = ctx;
|
|
362
|
+
if (!id) return;
|
|
363
|
+
const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
|
|
364
|
+
try {
|
|
365
|
+
const proc = x(command, [...args ?? []], {
|
|
366
|
+
signal,
|
|
367
|
+
nodeOptions: {
|
|
368
|
+
cwd: root,
|
|
369
|
+
detached: true
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
for await (const line of proc) await hooks.callHook("kubb:hook:line", {
|
|
373
|
+
id,
|
|
374
|
+
line
|
|
375
|
+
});
|
|
376
|
+
const { exitCode } = await proc;
|
|
377
|
+
if (exitCode !== 0) {
|
|
378
|
+
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
379
|
+
await hooks.callHook("kubb:hook:end", {
|
|
380
|
+
id,
|
|
381
|
+
command,
|
|
382
|
+
args,
|
|
383
|
+
success: false,
|
|
384
|
+
error
|
|
385
|
+
});
|
|
386
|
+
await hooks.callHook("kubb:error", { error });
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
await hooks.callHook("kubb:hook:end", {
|
|
390
|
+
id,
|
|
391
|
+
command,
|
|
392
|
+
args,
|
|
393
|
+
success: true,
|
|
394
|
+
error: null
|
|
395
|
+
});
|
|
396
|
+
} catch (caughtError) {
|
|
397
|
+
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
398
|
+
error.cause = caughtError;
|
|
399
|
+
await hooks.callHook("kubb:hook:end", {
|
|
400
|
+
id,
|
|
401
|
+
command,
|
|
402
|
+
args,
|
|
403
|
+
success: false,
|
|
404
|
+
error
|
|
405
|
+
});
|
|
406
|
+
await hooks.callHook("kubb:error", { error });
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
|
|
412
|
+
* `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
|
|
413
|
+
* that same listener, so a handler added afterward would already have missed it.
|
|
414
|
+
*/
|
|
415
|
+
function waitForHookEnd(hooks, hookId) {
|
|
416
|
+
return new Promise((resolve, reject) => {
|
|
417
|
+
const handleHookEnd = (ctx) => {
|
|
418
|
+
if (ctx.id !== hookId) return;
|
|
419
|
+
hooks.removeHook("kubb:hook:end", handleHookEnd);
|
|
420
|
+
if (ctx.success) {
|
|
421
|
+
resolve();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
reject(ctx.error);
|
|
425
|
+
};
|
|
426
|
+
hooks.hook("kubb:hook:end", handleHookEnd);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
//#endregion
|
|
391
430
|
//#region src/machine.ts
|
|
392
431
|
/**
|
|
393
432
|
* Key-value storage the runtime uses for its machine secret and the last Studio config.
|
|
@@ -468,10 +507,6 @@ function responseMessage(data) {
|
|
|
468
507
|
*/
|
|
469
508
|
const REGISTER_RETRIES = 3;
|
|
470
509
|
/**
|
|
471
|
-
* Shared in-flight registration so concurrent pool sessions trigger one purge, not N.
|
|
472
|
-
*/
|
|
473
|
-
let registrationInFlight = null;
|
|
474
|
-
/**
|
|
475
510
|
* Thrown when Studio rejects the agent token itself (401). Retrying cannot help: the token was
|
|
476
511
|
* revoked, or the agent it belonged to was deleted in the Studio UI. Hosts catch this to forget
|
|
477
512
|
* the stored credential and pair again.
|
|
@@ -483,117 +518,87 @@ var InvalidAgentTokenError = class extends Error {
|
|
|
483
518
|
}
|
|
484
519
|
};
|
|
485
520
|
/**
|
|
521
|
+
* Thrown when Studio refuses this agent's protocol version (426). Retrying cannot help until the
|
|
522
|
+
* agent is upgraded, so hosts stop instead of reconnecting.
|
|
523
|
+
*/
|
|
524
|
+
var IncompatibleAgentError = class extends Error {
|
|
525
|
+
constructor(studioUrl, detail, options) {
|
|
526
|
+
super(`Kubb Studio at ${studioUrl} requires a newer agent${detail ? `: ${detail}` : ""}. Upgrade @kubb/studio or the Kubb agent image.`, options);
|
|
527
|
+
this.name = "IncompatibleAgentError";
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
/**
|
|
486
531
|
* Whether a thrown value carries `statusCode`. Not narrowed to `FetchError`: a host wrapper can
|
|
487
532
|
* throw its own error shape with the same field.
|
|
488
|
-
*
|
|
489
|
-
* A 401 means the agent token itself was rejected. A 403 from the session create endpoint means
|
|
490
|
-
* the machine token stored in Studio no longer matches this agent (missing or mismatched).
|
|
491
533
|
*/
|
|
492
534
|
function rejectedWith(error, statusCode) {
|
|
493
535
|
return error?.statusCode === statusCode;
|
|
494
536
|
}
|
|
495
|
-
function
|
|
537
|
+
function registrationError(cause) {
|
|
496
538
|
const detail = (cause instanceof FetchError ? responseMessage(cause.data) : void 0) ?? getErrorMessage(cause);
|
|
497
|
-
return new Error(detail ? `Failed to
|
|
498
|
-
}
|
|
499
|
-
/**
|
|
500
|
-
* Performs the raw session create request against Studio.
|
|
501
|
-
*/
|
|
502
|
-
async function requestAgentSession({ token, studioUrl }) {
|
|
503
|
-
const url = `${studioUrl}/api/agent/sessions`;
|
|
504
|
-
const data = await ofetch(url, {
|
|
505
|
-
method: "POST",
|
|
506
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
507
|
-
body: { machineToken: await getMachineToken() }
|
|
508
|
-
});
|
|
509
|
-
if (!data) throw new Error("No data available for agent session");
|
|
510
|
-
return data;
|
|
539
|
+
return new Error(detail ? `Failed to register with Kubb Studio: ${detail}` : "Failed to register with Kubb Studio", { cause });
|
|
511
540
|
}
|
|
512
541
|
/**
|
|
513
|
-
*
|
|
542
|
+
* Registers this agent process with Kubb Studio (`POST /api/agent/connect`): binds the machine
|
|
543
|
+
* identity to the token, reports what the process can take on, and gets back the URL of the one
|
|
544
|
+
* socket it keeps open.
|
|
514
545
|
*
|
|
515
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*/
|
|
519
|
-
async function
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
} catch (error) {
|
|
526
|
-
if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
|
|
527
|
-
if (!rejectedWith(error, 403) || !await registerAgent({
|
|
528
|
-
token,
|
|
529
|
-
studioUrl
|
|
530
|
-
})) throw sessionError(error);
|
|
531
|
-
try {
|
|
532
|
-
return await requestAgentSession({
|
|
533
|
-
token,
|
|
534
|
-
studioUrl
|
|
535
|
-
});
|
|
536
|
-
} catch (retryError) {
|
|
537
|
-
if (rejectedWith(retryError, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: retryError });
|
|
538
|
-
throw sessionError(retryError);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
/**
|
|
543
|
-
* Register this agent with Kubb Studio by sending the machine ID.
|
|
544
|
-
* Called on agent startup before creating a WebSocket session, and again when
|
|
545
|
-
* Studio rejects the machine token during session creation.
|
|
546
|
-
*
|
|
547
|
-
* Retries with backoff because a failed registration leaves Studio with a stale
|
|
548
|
-
* machine token that blocks every subsequent session create call. Registration
|
|
549
|
-
* purges all of the agent's sessions on the Studio side, so concurrent callers
|
|
550
|
-
* (multiple pool sessions hitting a 403 at once) share one in-flight run instead
|
|
551
|
-
* of purging each other's fresh sessions.
|
|
552
|
-
*/
|
|
553
|
-
function registerAgent(props) {
|
|
554
|
-
registrationInFlight ??= runRegistration(props).finally(() => {
|
|
555
|
-
registrationInFlight = null;
|
|
556
|
-
});
|
|
557
|
-
return registrationInFlight;
|
|
558
|
-
}
|
|
559
|
-
async function runRegistration({ token, studioUrl, poolSize }) {
|
|
560
|
-
const machineToken = await getMachineToken();
|
|
546
|
+
* Retries a transient failure with backoff. A rejected token (401) throws
|
|
547
|
+
* {@link InvalidAgentTokenError} and an unsupported agent version (426) throws
|
|
548
|
+
* {@link IncompatibleAgentError}, since retrying either cannot help.
|
|
549
|
+
*/
|
|
550
|
+
async function registerAgent({ token, studioUrl, instanceId, capacity }) {
|
|
551
|
+
const body = {
|
|
552
|
+
machineToken: await getMachineToken(),
|
|
553
|
+
instanceId,
|
|
554
|
+
capacity
|
|
555
|
+
};
|
|
561
556
|
try {
|
|
562
|
-
await ofetch(`${studioUrl}/api/agent/connect`, {
|
|
557
|
+
return await ofetch(`${studioUrl}/api/agent/connect`, {
|
|
563
558
|
method: "POST",
|
|
564
559
|
headers: { Authorization: `Bearer ${token}` },
|
|
565
|
-
body
|
|
566
|
-
machineToken,
|
|
567
|
-
poolSize
|
|
568
|
-
},
|
|
560
|
+
body,
|
|
569
561
|
retry: REGISTER_RETRIES,
|
|
570
562
|
retryDelay: ({ options }) => 2e3 * 2 ** (REGISTER_RETRIES - Number(options.retry))
|
|
571
563
|
});
|
|
572
|
-
return true;
|
|
573
564
|
} catch (error) {
|
|
574
565
|
if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
|
|
575
|
-
|
|
566
|
+
if (rejectedWith(error, 426)) throw new IncompatibleAgentError(studioUrl, error instanceof FetchError ? responseMessage(error.data) : void 0, { cause: error });
|
|
567
|
+
throw registrationError(error);
|
|
576
568
|
}
|
|
577
569
|
}
|
|
578
570
|
/**
|
|
579
|
-
*
|
|
580
|
-
* Called on process termination or server close. Never throws: the local socket is already gone,
|
|
581
|
-
* and failing teardown must not block shutdown or reconnect.
|
|
582
|
-
*
|
|
583
|
-
* @returns `false` when Studio could not be reached or rate limited the call. Any other 4xx
|
|
584
|
-
* counts as notified, since it means Studio already dropped the session.
|
|
571
|
+
* First wait before `createJob` retries a busy or queue-full response, absent a `Retry-After` hint.
|
|
585
572
|
*/
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
573
|
+
const CREATE_JOB_INITIAL_DELAY_MS = 1e3;
|
|
574
|
+
/**
|
|
575
|
+
* Slowest `createJob` backs off to between retries.
|
|
576
|
+
*/
|
|
577
|
+
const CREATE_JOB_MAX_INTERVAL_MS = 1e4;
|
|
578
|
+
/**
|
|
579
|
+
* Statuses worth retrying: the agent has no free connection yet (409, a stale conflict a moment
|
|
580
|
+
* later resolves), its queue is momentarily full (429), or it has no live connection at all yet
|
|
581
|
+
* (503, an agent process that is mid-reconnect). Anything else (404 agent not found, 401/403 auth)
|
|
582
|
+
* is thrown straight away, since retrying cannot change the outcome.
|
|
583
|
+
*/
|
|
584
|
+
const CREATE_JOB_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
|
|
585
|
+
409,
|
|
586
|
+
429,
|
|
587
|
+
503
|
|
588
|
+
]);
|
|
589
|
+
/**
|
|
590
|
+
* Reads Studio's `Retry-After` header (seconds) off a thrown `ofetch` error, when present.
|
|
591
|
+
*/
|
|
592
|
+
function retryAfterMs(error) {
|
|
593
|
+
const seconds = Number(error.response?.headers.get("retry-after"));
|
|
594
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Adds up to 30% jitter, so every CI run queued behind the same busy agent does not retry in
|
|
598
|
+
* lockstep.
|
|
599
|
+
*/
|
|
600
|
+
function withJitter(ms) {
|
|
601
|
+
return ms + Math.random() * ms * .3;
|
|
597
602
|
}
|
|
598
603
|
/**
|
|
599
604
|
* Queues a generation or snapshot job on Studio (`POST /api/jobs`).
|
|
@@ -601,6 +606,10 @@ async function disconnect({ sessionId, token, studioUrl }) {
|
|
|
601
606
|
* Returns as soon as Studio accepts the job (`202`). Poll with {@link waitForJob} until it finishes.
|
|
602
607
|
* Authenticates with the organization CI API key via `x-api-key`.
|
|
603
608
|
*
|
|
609
|
+
* A busy agent, a full queue, or a momentary lack of a live connection (409, 429, 503) retries with
|
|
610
|
+
* exponential backoff and jitter, honoring Studio's `Retry-After` header when it sends one, up to
|
|
611
|
+
* `timeoutMs`. Every other failure, including a missing agent (404), throws immediately.
|
|
612
|
+
*
|
|
604
613
|
* @example Snapshot job
|
|
605
614
|
* ```ts
|
|
606
615
|
* const job = await createJob({
|
|
@@ -614,20 +623,40 @@ async function disconnect({ sessionId, token, studioUrl }) {
|
|
|
614
623
|
* const finished = await waitForJob({ studioUrl, token, id: job.id })
|
|
615
624
|
* ```
|
|
616
625
|
*/
|
|
617
|
-
async function createJob({ studioUrl, token, type, agentId, name, version, commit, config }) {
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
626
|
+
async function createJob({ studioUrl, token, type, agentId, name, version, commit, baseId, config, timeoutMs = 6e4, signal }) {
|
|
627
|
+
const deadline = Date.now() + timeoutMs;
|
|
628
|
+
let interval = CREATE_JOB_INITIAL_DELAY_MS;
|
|
629
|
+
for (;;) {
|
|
630
|
+
signal?.throwIfAborted();
|
|
631
|
+
try {
|
|
632
|
+
const { job } = await ofetch(`${studioUrl}/api/jobs`, {
|
|
633
|
+
method: "POST",
|
|
634
|
+
headers: { "x-api-key": token },
|
|
635
|
+
body: {
|
|
636
|
+
type,
|
|
637
|
+
agentId,
|
|
638
|
+
name,
|
|
639
|
+
version,
|
|
640
|
+
commit,
|
|
641
|
+
baseId,
|
|
642
|
+
config
|
|
643
|
+
},
|
|
644
|
+
retry: false,
|
|
645
|
+
timeout: Math.max(deadline - Date.now(), 1),
|
|
646
|
+
signal
|
|
647
|
+
});
|
|
648
|
+
return job;
|
|
649
|
+
} catch (error) {
|
|
650
|
+
signal?.throwIfAborted();
|
|
651
|
+
const status = error.response?.status;
|
|
652
|
+
if (!status || !CREATE_JOB_RETRYABLE_STATUSES.has(status) || Date.now() >= deadline) throw error;
|
|
653
|
+
const wait = Math.min(retryAfterMs(error) ?? withJitter(interval), Math.max(deadline - Date.now(), 0));
|
|
654
|
+
if (signal) await setTimeout$1(wait, void 0, { signal });
|
|
655
|
+
else await new Promise((resolve) => setTimeout(resolve, wait));
|
|
656
|
+
if (Date.now() >= deadline) throw error;
|
|
657
|
+
interval = Math.min(interval * 2, CREATE_JOB_MAX_INTERVAL_MS);
|
|
628
658
|
}
|
|
629
|
-
}
|
|
630
|
-
return job;
|
|
659
|
+
}
|
|
631
660
|
}
|
|
632
661
|
/**
|
|
633
662
|
* A job runs a generation and packs a tarball, so it is never done the instant it is queued.
|
|
@@ -646,20 +675,26 @@ const MAX_POLL_INTERVAL_MS = 3e4;
|
|
|
646
675
|
* A `failed` job resolves normally. Check `job.status` and `job.error`. Throws only when the
|
|
647
676
|
* deadline passes before Studio finishes.
|
|
648
677
|
*/
|
|
649
|
-
async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4 }) {
|
|
678
|
+
async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4, signal }) {
|
|
650
679
|
const deadline = Date.now() + timeoutMs;
|
|
651
680
|
let interval = INITIAL_POLL_DELAY_MS;
|
|
652
681
|
for (;;) {
|
|
653
|
-
|
|
682
|
+
signal?.throwIfAborted();
|
|
683
|
+
const wait = Math.max(Math.min(interval, deadline - Date.now()), 0);
|
|
684
|
+
if (signal) await setTimeout$1(wait, void 0, { signal });
|
|
685
|
+
else await new Promise((resolve) => setTimeout(resolve, wait));
|
|
654
686
|
if (Date.now() >= deadline) throw new Error("Timed out waiting for the Studio job");
|
|
655
687
|
interval = Math.min(interval * 2, MAX_POLL_INTERVAL_MS);
|
|
656
688
|
try {
|
|
657
689
|
const { job } = await ofetch(`${studioUrl}/api/jobs/${id}`, {
|
|
658
690
|
headers: { "x-api-key": token },
|
|
659
|
-
retry: false
|
|
691
|
+
retry: false,
|
|
692
|
+
timeout: Math.max(deadline - Date.now(), 1),
|
|
693
|
+
signal
|
|
660
694
|
});
|
|
661
695
|
if (job.status === "success" || job.status === "failed" || job.status === "canceled") return job;
|
|
662
696
|
} catch (error) {
|
|
697
|
+
signal?.throwIfAborted();
|
|
663
698
|
const response = error.response;
|
|
664
699
|
if (response?.status !== 429) throw error;
|
|
665
700
|
const retryAfter = response._data?.data?.tryAgainIn;
|
|
@@ -693,89 +728,6 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
|
|
|
693
728
|
}
|
|
694
729
|
}
|
|
695
730
|
//#endregion
|
|
696
|
-
//#region package.json
|
|
697
|
-
var version = "5.3.16";
|
|
698
|
-
//#endregion
|
|
699
|
-
//#region src/hooks.ts
|
|
700
|
-
/**
|
|
701
|
-
* Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
|
|
702
|
-
* streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
|
|
703
|
-
* Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
|
|
704
|
-
*
|
|
705
|
-
* Returns a remover, so a session that runs one generation after another on the same emitter does
|
|
706
|
-
* not stack a listener per run.
|
|
707
|
-
*/
|
|
708
|
-
function setupHookListener(hooks, root, signal) {
|
|
709
|
-
return hooks.hook("kubb:hook:start", async (ctx) => {
|
|
710
|
-
const { id, command, args } = ctx;
|
|
711
|
-
if (!id) return;
|
|
712
|
-
const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
|
|
713
|
-
try {
|
|
714
|
-
const proc = x(command, [...args ?? []], {
|
|
715
|
-
signal,
|
|
716
|
-
nodeOptions: {
|
|
717
|
-
cwd: root,
|
|
718
|
-
detached: true
|
|
719
|
-
}
|
|
720
|
-
});
|
|
721
|
-
for await (const line of proc) await hooks.callHook("kubb:hook:line", {
|
|
722
|
-
id,
|
|
723
|
-
line
|
|
724
|
-
});
|
|
725
|
-
const { exitCode } = await proc;
|
|
726
|
-
if (exitCode !== 0) {
|
|
727
|
-
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
728
|
-
await hooks.callHook("kubb:hook:end", {
|
|
729
|
-
id,
|
|
730
|
-
command,
|
|
731
|
-
args,
|
|
732
|
-
success: false,
|
|
733
|
-
error
|
|
734
|
-
});
|
|
735
|
-
await hooks.callHook("kubb:error", { error });
|
|
736
|
-
return;
|
|
737
|
-
}
|
|
738
|
-
await hooks.callHook("kubb:hook:end", {
|
|
739
|
-
id,
|
|
740
|
-
command,
|
|
741
|
-
args,
|
|
742
|
-
success: true,
|
|
743
|
-
error: null
|
|
744
|
-
});
|
|
745
|
-
} catch (caughtError) {
|
|
746
|
-
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
747
|
-
error.cause = caughtError;
|
|
748
|
-
await hooks.callHook("kubb:hook:end", {
|
|
749
|
-
id,
|
|
750
|
-
command,
|
|
751
|
-
args,
|
|
752
|
-
success: false,
|
|
753
|
-
error
|
|
754
|
-
});
|
|
755
|
-
await hooks.callHook("kubb:error", { error });
|
|
756
|
-
}
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
|
-
/**
|
|
760
|
-
* Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
|
|
761
|
-
* `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
|
|
762
|
-
* that same listener, so a handler added afterward would already have missed it.
|
|
763
|
-
*/
|
|
764
|
-
function waitForHookEnd(hooks, hookId) {
|
|
765
|
-
return new Promise((resolve, reject) => {
|
|
766
|
-
const handleHookEnd = (ctx) => {
|
|
767
|
-
if (ctx.id !== hookId) return;
|
|
768
|
-
hooks.removeHook("kubb:hook:end", handleHookEnd);
|
|
769
|
-
if (ctx.success) {
|
|
770
|
-
resolve();
|
|
771
|
-
return;
|
|
772
|
-
}
|
|
773
|
-
reject(ctx.error);
|
|
774
|
-
};
|
|
775
|
-
hooks.hook("kubb:hook:end", handleHookEnd);
|
|
776
|
-
});
|
|
777
|
-
}
|
|
778
|
-
//#endregion
|
|
779
731
|
//#region src/resolveConfig.ts
|
|
780
732
|
/**
|
|
781
733
|
* Imports a package, falling back to how the user's project would resolve it.
|
|
@@ -1643,6 +1595,60 @@ async function generate({ config, hooks, signal }) {
|
|
|
1643
1595
|
}
|
|
1644
1596
|
}
|
|
1645
1597
|
//#endregion
|
|
1598
|
+
//#region src/constants.ts
|
|
1599
|
+
/**
|
|
1600
|
+
* Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
|
|
1601
|
+
* not whatever default the client would pick on its own.
|
|
1602
|
+
*/
|
|
1603
|
+
const defaultStudioUrl = "https://kubb.studio";
|
|
1604
|
+
/**
|
|
1605
|
+
* Defaults the Studio client uses when a host passes nothing.
|
|
1606
|
+
* Config path is left out on purpose: each host discovers that itself.
|
|
1607
|
+
*/
|
|
1608
|
+
const agentDefaults = {
|
|
1609
|
+
studioUrl: defaultStudioUrl,
|
|
1610
|
+
retryIntervalMs: 3e4,
|
|
1611
|
+
heartbeatIntervalMs: 3e4,
|
|
1612
|
+
/**
|
|
1613
|
+
* Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
|
|
1614
|
+
* stored ping is older than its liveness window, and it stores a ping at most once a minute, so
|
|
1615
|
+
* a slower cadence would make a healthy agent look dead after a single missed ping.
|
|
1616
|
+
*/
|
|
1617
|
+
maxHeartbeatIntervalMs: 6e4,
|
|
1618
|
+
/** How long a heartbeat ping may take before the session is treated as dead. */
|
|
1619
|
+
heartbeatTimeoutMs: 1e4,
|
|
1620
|
+
maxConcurrent: 1,
|
|
1621
|
+
maxGenerations: 8,
|
|
1622
|
+
maxGenerationsMb: 100,
|
|
1623
|
+
maxSnapshotMb: 50
|
|
1624
|
+
};
|
|
1625
|
+
function positiveNumber(value) {
|
|
1626
|
+
const parsed = Number(value);
|
|
1627
|
+
return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
1628
|
+
}
|
|
1629
|
+
/**
|
|
1630
|
+
* How many generations an agent keeps and how large they may get, read from
|
|
1631
|
+
* `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
|
|
1632
|
+
* An unset or invalid value keeps the default.
|
|
1633
|
+
*/
|
|
1634
|
+
function resolveGenerationLimits(env = process.env) {
|
|
1635
|
+
return {
|
|
1636
|
+
maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
|
|
1637
|
+
maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
|
|
1638
|
+
maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* An agent's capacity read from `KUBB_AGENT_MAX_CONCURRENT` and `KUBB_AGENT_MEMORY_BUDGET_MB`. An
|
|
1643
|
+
* unset or invalid value keeps the default: one job at a time, and no memory budget.
|
|
1644
|
+
*/
|
|
1645
|
+
function resolveAgentCapacity(env = process.env) {
|
|
1646
|
+
return {
|
|
1647
|
+
maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)),
|
|
1648
|
+
memoryBudgetMb: positiveNumber(env.KUBB_AGENT_MEMORY_BUDGET_MB)
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
//#endregion
|
|
1646
1652
|
//#region src/snapshotPackage.ts
|
|
1647
1653
|
const gzipAsync = promisify(gzip);
|
|
1648
1654
|
/**
|
|
@@ -1785,7 +1791,7 @@ async function createSnapshotPackage(files, packageInfo) {
|
|
|
1785
1791
|
//#endregion
|
|
1786
1792
|
//#region src/generations.ts
|
|
1787
1793
|
const READ_CONCURRENCY = 50;
|
|
1788
|
-
const MB = 1048576;
|
|
1794
|
+
const MB$1 = 1048576;
|
|
1789
1795
|
const INDEX_KEY = "studio/generations.json";
|
|
1790
1796
|
const hashOf = (content) => createHash("sha1").update(content).digest("hex").slice(0, 16);
|
|
1791
1797
|
/**
|
|
@@ -1812,8 +1818,9 @@ async function listDisk({ root, outputPath, maxFiles }) {
|
|
|
1812
1818
|
* tenant never reaches another's output. The oldest go past `maxCount` or `maxMb`, but the newest
|
|
1813
1819
|
* always stays.
|
|
1814
1820
|
*/
|
|
1815
|
-
function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
1821
|
+
function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now }) {
|
|
1816
1822
|
let index;
|
|
1823
|
+
const isLive = (generation) => ttlMs === void 0 || generation.keptAt === void 0 || now() - generation.keptAt < ttlMs;
|
|
1817
1824
|
const dirOf = (jobId) => `studio/generations/${hashOf(jobId)}/`;
|
|
1818
1825
|
async function load() {
|
|
1819
1826
|
if (index) return index;
|
|
@@ -1829,7 +1836,7 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
|
1829
1836
|
* Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
|
|
1830
1837
|
*/
|
|
1831
1838
|
async function keep({ jobId, source, files, maxSetMb }) {
|
|
1832
|
-
const maxSetBytes = maxSetMb * MB;
|
|
1839
|
+
const maxSetBytes = maxSetMb * MB$1;
|
|
1833
1840
|
const hashes = {};
|
|
1834
1841
|
let bytes = 0;
|
|
1835
1842
|
await inParallel({
|
|
@@ -1864,13 +1871,20 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
|
1864
1871
|
return {
|
|
1865
1872
|
keep,
|
|
1866
1873
|
drop,
|
|
1867
|
-
get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId),
|
|
1868
|
-
latest: async () => (await load()).at(-1),
|
|
1874
|
+
get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId && isLive(generation)),
|
|
1875
|
+
latest: async () => (await load()).filter(isLive).at(-1),
|
|
1876
|
+
/** Total bytes of every set the store holds, expired ones too until the next add drops them. */
|
|
1877
|
+
bytes: async () => (await load()).reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0),
|
|
1869
1878
|
async add(generation) {
|
|
1870
|
-
const
|
|
1871
|
-
|
|
1879
|
+
const current = await load();
|
|
1880
|
+
for (const expired of current.filter((entry) => !isLive(entry))) await drop(expired.jobId);
|
|
1881
|
+
const entries = current.filter((entry) => entry.jobId !== generation.jobId && isLive(entry));
|
|
1882
|
+
entries.push({
|
|
1883
|
+
...generation,
|
|
1884
|
+
keptAt: now()
|
|
1885
|
+
});
|
|
1872
1886
|
const weight = () => entries.reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0);
|
|
1873
|
-
while (entries.length > 1 && (entries.length > maxCount || weight() > maxMb * MB)) await drop(entries.shift().jobId);
|
|
1887
|
+
while (entries.length > 1 && (entries.length > maxCount || weight() > maxMb * MB$1)) await drop(entries.shift().jobId);
|
|
1874
1888
|
index = entries;
|
|
1875
1889
|
await storage.writeItem(INDEX_KEY, JSON.stringify(entries));
|
|
1876
1890
|
},
|
|
@@ -2157,6 +2171,9 @@ var AgentRpcTarget = class extends RpcTarget {
|
|
|
2157
2171
|
readFiles(input) {
|
|
2158
2172
|
return this.api.readFiles(input);
|
|
2159
2173
|
}
|
|
2174
|
+
cancel(jobId) {
|
|
2175
|
+
return this.api.cancel(jobId);
|
|
2176
|
+
}
|
|
2160
2177
|
};
|
|
2161
2178
|
/**
|
|
2162
2179
|
* Opens an authenticated Cap'n Web session to Studio over a WebSocket. Rejects an unencrypted URL
|
|
@@ -2168,11 +2185,17 @@ var AgentRpcTarget = class extends RpcTarget {
|
|
|
2168
2185
|
* await rpc.studio.ping()
|
|
2169
2186
|
* ```
|
|
2170
2187
|
*/
|
|
2171
|
-
const connectWebSocketRpc = async ({ url, token, local }) => {
|
|
2188
|
+
const connectWebSocketRpc = async ({ url, token, instanceId, local }) => {
|
|
2172
2189
|
const { protocol, hostname, host } = new URL(url);
|
|
2173
2190
|
if (protocol !== "wss:" && !(protocol === "ws:" && (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"))) throw new Error(`Refusing unencrypted WebSocket to ${host}`);
|
|
2174
|
-
const socket = createWebsocket(url, { headers: {
|
|
2175
|
-
|
|
2191
|
+
const socket = createWebsocket(url, { headers: {
|
|
2192
|
+
Authorization: `Bearer ${token}`,
|
|
2193
|
+
[AGENT_INSTANCE_HEADER]: instanceId
|
|
2194
|
+
} });
|
|
2195
|
+
const closed = new Promise((resolve) => socket.once("close", (code, reason) => resolve({
|
|
2196
|
+
code,
|
|
2197
|
+
reason: reason.toString()
|
|
2198
|
+
})));
|
|
2176
2199
|
const studio = newWebSocketRpcSession(socket, new AgentRpcTarget(local));
|
|
2177
2200
|
studio.onRpcBroken(() => socket.close());
|
|
2178
2201
|
return {
|
|
@@ -2187,6 +2210,32 @@ const connectWebSocketRpc = async ({ url, token, local }) => {
|
|
|
2187
2210
|
* Past this many files in the output directory, no snapshot of it is taken before a run.
|
|
2188
2211
|
*/
|
|
2189
2212
|
const DISK_SNAPSHOT_MAX_FILES = 1e4;
|
|
2213
|
+
/**
|
|
2214
|
+
* How long a sandbox keeps a generation readable. Its store is in memory and holds every tenant's
|
|
2215
|
+
* runs, so an old one has to go even when count and size leave room. A local agent keeps its runs
|
|
2216
|
+
* until count or size pushes them out, so a later run can still diff against the one before it.
|
|
2217
|
+
*/
|
|
2218
|
+
const SANDBOX_GENERATION_TTL_MS = 9e5;
|
|
2219
|
+
/**
|
|
2220
|
+
* A fresh root for one sandbox job. Kubb keys its output manifest cache by root, so tenants that
|
|
2221
|
+
* shared the agent's own root would read each other's manifest.
|
|
2222
|
+
*/
|
|
2223
|
+
function createJobRoot() {
|
|
2224
|
+
return mkdtemp(path.join(tmpdir(), "kubb-job-"));
|
|
2225
|
+
}
|
|
2226
|
+
/**
|
|
2227
|
+
* Removes a job root and the manifest cache Kubb derived from it. Best effort: a leftover temp
|
|
2228
|
+
* directory must not fail a job that already finished.
|
|
2229
|
+
*/
|
|
2230
|
+
async function removeJobRoot(jobRoot) {
|
|
2231
|
+
await Promise.all([rm(jobRoot, {
|
|
2232
|
+
recursive: true,
|
|
2233
|
+
force: true
|
|
2234
|
+
}), rm(resolveCacheDir(jobRoot), {
|
|
2235
|
+
recursive: true,
|
|
2236
|
+
force: true
|
|
2237
|
+
})]).catch(() => {});
|
|
2238
|
+
}
|
|
2190
2239
|
var GenerationRunTarget = class extends RpcTarget {
|
|
2191
2240
|
generationStream;
|
|
2192
2241
|
generationResult;
|
|
@@ -2233,34 +2282,74 @@ function applyStudioDefaults(options) {
|
|
|
2233
2282
|
...options.permissions
|
|
2234
2283
|
},
|
|
2235
2284
|
retryInterval: options.retryInterval ?? agentDefaults.retryIntervalMs,
|
|
2236
|
-
heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs)
|
|
2285
|
+
heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs),
|
|
2286
|
+
capacity: {
|
|
2287
|
+
...resolveAgentCapacity(),
|
|
2288
|
+
...options.capacity
|
|
2289
|
+
},
|
|
2290
|
+
instanceId: options.instanceId ?? randomUUID()
|
|
2237
2291
|
};
|
|
2238
2292
|
}
|
|
2239
2293
|
/**
|
|
2240
|
-
*
|
|
2241
|
-
*
|
|
2242
|
-
* A free function rather than a method: a pending retry timer reaches whatever it closes over, so
|
|
2243
|
-
* closing only over `options` (not a `StudioSession`) keeps a queued retry from pinning a closed
|
|
2244
|
-
* socket, its hook emitter, or its session id alive for the length of the retry interval.
|
|
2294
|
+
* Jobs one agent process can run at once today. Two runs would share this session's hook emitter,
|
|
2295
|
+
* and with it each other's events, until each job runs in its own worker (ADR-0003 slice B2).
|
|
2245
2296
|
*/
|
|
2246
|
-
|
|
2247
|
-
|
|
2297
|
+
const RUNTIME_MAX_CONCURRENT = 1;
|
|
2298
|
+
const MB = 1048576;
|
|
2299
|
+
function rssMb() {
|
|
2300
|
+
return process$1.memoryUsage().rss / MB;
|
|
2301
|
+
}
|
|
2302
|
+
function backoffDelayMs(attempt, maxMs) {
|
|
2303
|
+
const cap = Math.min(1e3 * 2 ** (attempt - 1), maxMs);
|
|
2304
|
+
return Math.random() * cap;
|
|
2305
|
+
}
|
|
2306
|
+
function reconnect(options, delayMs, attempt) {
|
|
2307
|
+
const { signal, onTokenRejected } = options;
|
|
2248
2308
|
if (signal?.aborted) return;
|
|
2249
2309
|
const cancel = () => clearTimeout(timer);
|
|
2250
2310
|
const timer = setTimeout(() => {
|
|
2251
2311
|
signal?.removeEventListener("abort", cancel);
|
|
2252
2312
|
if (signal?.aborted) return;
|
|
2253
|
-
new StudioSession(
|
|
2313
|
+
new StudioSession({
|
|
2314
|
+
...options,
|
|
2315
|
+
reconnectAttempt: attempt
|
|
2316
|
+
}).start().catch((error) => {
|
|
2254
2317
|
if (error instanceof InvalidAgentTokenError) {
|
|
2255
2318
|
onTokenRejected?.(error);
|
|
2256
2319
|
return;
|
|
2257
2320
|
}
|
|
2258
|
-
|
|
2321
|
+
if (error instanceof IncompatibleAgentError) return;
|
|
2322
|
+
const nextAttempt = attempt + 1;
|
|
2323
|
+
reconnect(options, backoffDelayMs(nextAttempt, options.retryInterval), nextAttempt);
|
|
2259
2324
|
});
|
|
2260
|
-
},
|
|
2325
|
+
}, delayMs);
|
|
2261
2326
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
2262
2327
|
}
|
|
2263
2328
|
/**
|
|
2329
|
+
* Reads what Studio meant by closing the connection. A code Studio did not send on purpose is an
|
|
2330
|
+
* ordinary drop, and the agent reconnects as it always has.
|
|
2331
|
+
*/
|
|
2332
|
+
function planEnd(close) {
|
|
2333
|
+
const code = close?.code;
|
|
2334
|
+
if (code === AgentCloseCode.REAUTHENTICATE) return {
|
|
2335
|
+
reason: "Kubb Studio asked the agent to register again",
|
|
2336
|
+
retry: true
|
|
2337
|
+
};
|
|
2338
|
+
if (code === AgentCloseCode.SUPERSEDED) return {
|
|
2339
|
+
reason: "another instance of this agent took over",
|
|
2340
|
+
retry: false
|
|
2341
|
+
};
|
|
2342
|
+
if (code === AgentCloseCode.INCOMPATIBLE) return {
|
|
2343
|
+
reason: "this agent is too old for Kubb Studio, or was deleted",
|
|
2344
|
+
retry: false,
|
|
2345
|
+
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.")
|
|
2346
|
+
};
|
|
2347
|
+
return {
|
|
2348
|
+
reason: "connection closed",
|
|
2349
|
+
retry: true
|
|
2350
|
+
};
|
|
2351
|
+
}
|
|
2352
|
+
/**
|
|
2264
2353
|
* One agent-to-Studio RPC transport: opening it, keeping it alive, and serving remote methods.
|
|
2265
2354
|
* `createClient` opens one per pool slot and is the only caller.
|
|
2266
2355
|
*/
|
|
@@ -2273,14 +2362,15 @@ var StudioSession = class {
|
|
|
2273
2362
|
*/
|
|
2274
2363
|
#unhooks = [];
|
|
2275
2364
|
/**
|
|
2276
|
-
* What
|
|
2277
|
-
* Before it resolves there is
|
|
2365
|
+
* What registration handed back, and the marker for whether the agent registered at all.
|
|
2366
|
+
* Before it resolves there is no sandbox flag to read.
|
|
2278
2367
|
*/
|
|
2279
|
-
#
|
|
2368
|
+
#registration;
|
|
2280
2369
|
#rpc;
|
|
2281
2370
|
#studioVersion;
|
|
2282
2371
|
#disposed = false;
|
|
2283
2372
|
#isGenerating = false;
|
|
2373
|
+
#activeJob;
|
|
2284
2374
|
#heartbeatTimer;
|
|
2285
2375
|
#lastGeneration;
|
|
2286
2376
|
#store;
|
|
@@ -2290,17 +2380,17 @@ var StudioSession = class {
|
|
|
2290
2380
|
* host does not queue jobs before the agent session is registered.
|
|
2291
2381
|
*/
|
|
2292
2382
|
#connectAck = Promise.withResolvers();
|
|
2293
|
-
#
|
|
2294
|
-
constructor({
|
|
2383
|
+
#reconnectAttempt;
|
|
2384
|
+
constructor({ reconnectAttempt, ...options }) {
|
|
2295
2385
|
this.#options = applyStudioDefaults(options);
|
|
2296
|
-
this.#
|
|
2386
|
+
this.#reconnectAttempt = reconnectAttempt ?? 0;
|
|
2297
2387
|
this.#connectAck.promise.catch(() => {});
|
|
2298
2388
|
}
|
|
2299
2389
|
/**
|
|
2300
2390
|
* A sandbox agent runs on Studio's own infrastructure, so it has no user project to touch.
|
|
2301
2391
|
*/
|
|
2302
2392
|
get #isSandbox() {
|
|
2303
|
-
return this.#
|
|
2393
|
+
return this.#registration?.isSandbox === true;
|
|
2304
2394
|
}
|
|
2305
2395
|
/**
|
|
2306
2396
|
* Kept in the project's cache directory, so it survives a restart, except on a sandbox: its pool
|
|
@@ -2310,7 +2400,8 @@ var StudioSession = class {
|
|
|
2310
2400
|
this.#store ??= createGenerationStore({
|
|
2311
2401
|
storage: this.#isSandbox ? memoryStorage() : cacheStorage({ root: this.#options.root }),
|
|
2312
2402
|
maxCount: this.#limits.maxCount,
|
|
2313
|
-
maxMb: this.#limits.maxMb
|
|
2403
|
+
maxMb: this.#limits.maxMb,
|
|
2404
|
+
ttlMs: this.#isSandbox ? SANDBOX_GENERATION_TTL_MS : void 0
|
|
2314
2405
|
});
|
|
2315
2406
|
return this.#store;
|
|
2316
2407
|
}
|
|
@@ -2334,20 +2425,26 @@ var StudioSession = class {
|
|
|
2334
2425
|
return this.#isSandbox || this.#options.permissions.allowRead;
|
|
2335
2426
|
}
|
|
2336
2427
|
async start() {
|
|
2337
|
-
const { token, studioUrl, signal, heartbeatInterval, installLogger } = this.#options;
|
|
2428
|
+
const { token, studioUrl, signal, heartbeatInterval, installLogger, instanceId, capacity } = this.#options;
|
|
2338
2429
|
await installLogger?.(this.#hooks);
|
|
2339
|
-
if (this.#startupWarning) await this.#warn(this.#startupWarning);
|
|
2340
2430
|
try {
|
|
2341
2431
|
await this.#hooks.callHook("studio:connecting", { url: studioUrl });
|
|
2342
|
-
|
|
2432
|
+
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`);
|
|
2433
|
+
const registration = await registerAgent({
|
|
2343
2434
|
token,
|
|
2344
|
-
studioUrl
|
|
2435
|
+
studioUrl,
|
|
2436
|
+
instanceId,
|
|
2437
|
+
capacity: {
|
|
2438
|
+
...capacity,
|
|
2439
|
+
maxConcurrent: Math.min(capacity.maxConcurrent, RUNTIME_MAX_CONCURRENT)
|
|
2440
|
+
}
|
|
2345
2441
|
});
|
|
2346
|
-
this.#
|
|
2347
|
-
this.#studioVersion =
|
|
2442
|
+
this.#registration = registration;
|
|
2443
|
+
this.#studioVersion = registration.version;
|
|
2348
2444
|
const rpc = await (this.#options.connector ?? connectWebSocketRpc)({
|
|
2349
|
-
url:
|
|
2445
|
+
url: registration.socketUrl,
|
|
2350
2446
|
token,
|
|
2447
|
+
instanceId,
|
|
2351
2448
|
local: this
|
|
2352
2449
|
});
|
|
2353
2450
|
this.#rpc = rpc;
|
|
@@ -2362,16 +2459,17 @@ var StudioSession = class {
|
|
|
2362
2459
|
kubb: version,
|
|
2363
2460
|
agent: this.#options.version
|
|
2364
2461
|
},
|
|
2365
|
-
agentSlug:
|
|
2366
|
-
organizationSlug:
|
|
2462
|
+
agentSlug: registration.agentSlug,
|
|
2463
|
+
organizationSlug: registration.organizationSlug
|
|
2367
2464
|
});
|
|
2368
2465
|
await this.#connectAck.promise;
|
|
2466
|
+
this.#reconnectAttempt = 0;
|
|
2369
2467
|
await this.#hooks.callHook("studio:ready", {});
|
|
2370
2468
|
} catch (error) {
|
|
2371
2469
|
this.#disposed = true;
|
|
2372
2470
|
this.dispose();
|
|
2373
2471
|
await this.#hooks.callHook("studio:error", { error: toError(error) });
|
|
2374
|
-
if (error instanceof InvalidAgentTokenError) throw error;
|
|
2472
|
+
if (error instanceof InvalidAgentTokenError || error instanceof IncompatibleAgentError) throw error;
|
|
2375
2473
|
await this.#reconnect();
|
|
2376
2474
|
}
|
|
2377
2475
|
}
|
|
@@ -2381,8 +2479,10 @@ var StudioSession = class {
|
|
|
2381
2479
|
*/
|
|
2382
2480
|
async #reconnect() {
|
|
2383
2481
|
if (this.#options.signal?.aborted) return;
|
|
2384
|
-
|
|
2385
|
-
|
|
2482
|
+
const attempt = this.#reconnectAttempt + 1;
|
|
2483
|
+
const delayMs = backoffDelayMs(attempt, this.#options.retryInterval);
|
|
2484
|
+
await this.#hooks.callHook("studio:reconnecting", { delayMs });
|
|
2485
|
+
reconnect(this.#options, delayMs, attempt);
|
|
2386
2486
|
}
|
|
2387
2487
|
#warn(message, permission) {
|
|
2388
2488
|
return this.#hooks.callHook("studio:warn", {
|
|
@@ -2414,10 +2514,31 @@ var StudioSession = class {
|
|
|
2414
2514
|
/**
|
|
2415
2515
|
* Races `studio.ping()` against a deadline, so a half-open socket can't hang it forever.
|
|
2416
2516
|
* */
|
|
2417
|
-
#ping(rpc) {
|
|
2517
|
+
async #ping(rpc) {
|
|
2418
2518
|
const { promise: timedOut, reject: onTimeout } = Promise.withResolvers();
|
|
2419
2519
|
const timer = setTimeout(() => onTimeout(/* @__PURE__ */ new Error("Heartbeat ping timed out")), agentDefaults.heartbeatTimeoutMs);
|
|
2420
|
-
|
|
2520
|
+
try {
|
|
2521
|
+
const load = await Promise.race([this.#load().catch(() => void 0), timedOut]);
|
|
2522
|
+
await Promise.race([rpc.studio.ping(load), timedOut]);
|
|
2523
|
+
} finally {
|
|
2524
|
+
clearTimeout(timer);
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
/**
|
|
2528
|
+
* Whether memory still leaves room for another job. Always, when the host set no budget.
|
|
2529
|
+
*/
|
|
2530
|
+
#isAccepting(memoryMb = rssMb()) {
|
|
2531
|
+
const budget = this.#options.capacity.memoryBudgetMb;
|
|
2532
|
+
return budget === void 0 || memoryMb <= budget * 1.5;
|
|
2533
|
+
}
|
|
2534
|
+
async #load() {
|
|
2535
|
+
const memoryMb = rssMb();
|
|
2536
|
+
return {
|
|
2537
|
+
running: this.#isGenerating ? 1 : 0,
|
|
2538
|
+
rssMb: Math.round(memoryMb),
|
|
2539
|
+
storeBytes: await this.#generations.bytes(),
|
|
2540
|
+
accepting: this.#isAccepting(memoryMb)
|
|
2541
|
+
};
|
|
2421
2542
|
}
|
|
2422
2543
|
/**
|
|
2423
2544
|
* Reads `kubb.config.ts` and reports which plugin options Studio may edit.
|
|
@@ -2462,8 +2583,11 @@ var StudioSession = class {
|
|
|
2462
2583
|
this.#connectAck.resolve();
|
|
2463
2584
|
return payload;
|
|
2464
2585
|
}
|
|
2465
|
-
#onAbort = () => void this.#end({
|
|
2466
|
-
|
|
2586
|
+
#onAbort = () => void this.#end({
|
|
2587
|
+
reason: "shutdown",
|
|
2588
|
+
retry: false
|
|
2589
|
+
});
|
|
2590
|
+
#onClose = (close) => void this.#end(planEnd(close));
|
|
2467
2591
|
/**
|
|
2468
2592
|
* Drops the socket and detaches every listener and timer this session added. Idempotent, and
|
|
2469
2593
|
* safe before `connect` opened anything.
|
|
@@ -2483,17 +2607,12 @@ var StudioSession = class {
|
|
|
2483
2607
|
* Ends the session: tells Studio it is over, drops the socket, and optionally reconnects.
|
|
2484
2608
|
* `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
|
|
2485
2609
|
*/
|
|
2486
|
-
async #end({ retry }) {
|
|
2487
|
-
const { studioUrl, token } = this.#options;
|
|
2610
|
+
async #end({ reason, retry, error }) {
|
|
2488
2611
|
if (this.#disposed) return;
|
|
2489
2612
|
this.#disposed = true;
|
|
2490
2613
|
this.dispose();
|
|
2491
|
-
await this.#hooks.callHook("studio:disconnected", { reason
|
|
2492
|
-
if (this.#
|
|
2493
|
-
sessionId: this.#session.sessionId,
|
|
2494
|
-
studioUrl,
|
|
2495
|
-
token
|
|
2496
|
-
})) await this.#warn("Could not notify Kubb Studio of the disconnect");
|
|
2614
|
+
await this.#hooks.callHook("studio:disconnected", { reason });
|
|
2615
|
+
if (error) await this.#hooks.callHook("studio:error", { error });
|
|
2497
2616
|
if (retry) await this.#reconnect();
|
|
2498
2617
|
}
|
|
2499
2618
|
startGeneration(data) {
|
|
@@ -2501,27 +2620,43 @@ var StudioSession = class {
|
|
|
2501
2620
|
this.#lastGeneration = result;
|
|
2502
2621
|
} });
|
|
2503
2622
|
const controller = new AbortController();
|
|
2504
|
-
const
|
|
2623
|
+
const cancelRun = async () => {
|
|
2624
|
+
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2625
|
+
};
|
|
2626
|
+
const result = this.#runGeneration(data, controller, cancelRun).then(async (value) => {
|
|
2505
2627
|
await generationStream.close();
|
|
2506
2628
|
return value;
|
|
2507
2629
|
}).catch((error) => {
|
|
2508
2630
|
generationStream.fail(error);
|
|
2509
2631
|
throw error;
|
|
2632
|
+
}).finally(() => {
|
|
2633
|
+
if (this.#activeJob?.cancel === cancelRun) this.#activeJob = void 0;
|
|
2510
2634
|
});
|
|
2511
2635
|
result.catch(() => {});
|
|
2512
|
-
return new GenerationRunTarget(generationStream.stream, result,
|
|
2513
|
-
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2514
|
-
}, () => {
|
|
2636
|
+
return new GenerationRunTarget(generationStream.stream, result, cancelRun, () => {
|
|
2515
2637
|
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2516
2638
|
generationStream.dispose();
|
|
2517
2639
|
});
|
|
2518
2640
|
}
|
|
2519
|
-
|
|
2641
|
+
/**
|
|
2642
|
+
* Cancels the currently running job when its id matches `jobId`.
|
|
2643
|
+
*/
|
|
2644
|
+
async cancel(jobId) {
|
|
2645
|
+
if (this.#activeJob?.jobId === jobId) await this.#activeJob.cancel();
|
|
2646
|
+
}
|
|
2647
|
+
async #runGeneration(data, controller, cancelRun) {
|
|
2520
2648
|
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");
|
|
2649
|
+
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");
|
|
2521
2650
|
this.#isGenerating = true;
|
|
2651
|
+
this.#activeJob = {
|
|
2652
|
+
jobId: data.jobId,
|
|
2653
|
+
cancel: cancelRun
|
|
2654
|
+
};
|
|
2522
2655
|
const command = "generate";
|
|
2523
|
-
const {
|
|
2656
|
+
const { loadConfig, permissions } = this.#options;
|
|
2657
|
+
let root = this.#options.root;
|
|
2524
2658
|
try {
|
|
2659
|
+
if (this.#isSandbox) root = await createJobRoot();
|
|
2525
2660
|
await this.#hooks.callHook("studio:command:start", { command });
|
|
2526
2661
|
const config = await loadConfig();
|
|
2527
2662
|
const patch = data.config;
|
|
@@ -2600,6 +2735,7 @@ var StudioSession = class {
|
|
|
2600
2735
|
disk: disk ? { hashes: disk.hashes } : void 0
|
|
2601
2736
|
};
|
|
2602
2737
|
} finally {
|
|
2738
|
+
if (root !== this.#options.root) await removeJobRoot(root);
|
|
2603
2739
|
this.#isGenerating = false;
|
|
2604
2740
|
}
|
|
2605
2741
|
}
|
|
@@ -2747,7 +2883,7 @@ var StudioSession = class {
|
|
|
2747
2883
|
*/
|
|
2748
2884
|
function createClient({ onAuthRequired, ...options }) {
|
|
2749
2885
|
const controller = new AbortController();
|
|
2750
|
-
const
|
|
2886
|
+
const instanceId = randomUUID();
|
|
2751
2887
|
function notifyAuthRequired(error) {
|
|
2752
2888
|
if (controller.signal.aborted) return;
|
|
2753
2889
|
controller.abort();
|
|
@@ -2755,19 +2891,12 @@ function createClient({ onAuthRequired, ...options }) {
|
|
|
2755
2891
|
}
|
|
2756
2892
|
return {
|
|
2757
2893
|
async connect() {
|
|
2758
|
-
|
|
2759
|
-
token: options.token,
|
|
2760
|
-
studioUrl: options.studioUrl ?? agentDefaults.studioUrl,
|
|
2761
|
-
poolSize
|
|
2762
|
-
});
|
|
2763
|
-
if (controller.signal.aborted) return;
|
|
2764
|
-
const startupWarning = registered ? void 0 : "Could not register with Kubb Studio, continuing";
|
|
2765
|
-
await Promise.all(Array.from({ length: poolSize }, (_, slot) => new StudioSession({
|
|
2894
|
+
await new StudioSession({
|
|
2766
2895
|
...options,
|
|
2896
|
+
instanceId,
|
|
2767
2897
|
signal: controller.signal,
|
|
2768
|
-
onTokenRejected: notifyAuthRequired
|
|
2769
|
-
|
|
2770
|
-
}).start()));
|
|
2898
|
+
onTokenRejected: notifyAuthRequired
|
|
2899
|
+
}).start();
|
|
2771
2900
|
},
|
|
2772
2901
|
disconnect() {
|
|
2773
2902
|
controller.abort();
|
|
@@ -2971,6 +3100,6 @@ async function pairAgent({ onCode, onRetry, maxAttempts = 1, ...options }) {
|
|
|
2971
3100
|
}
|
|
2972
3101
|
}
|
|
2973
3102
|
//#endregion
|
|
2974
|
-
export { InvalidAgentTokenError, PairingCanceledError, PairingDeniedError, PairingExpiredError, connectWebSocketRpc, createAgent, createClient, createFileStorage, createJob, defaultStudioUrl, generationEventTypes, machineTokenFrom, pairAgent, pollForPairingToken, runConnection, setStorage, startPairing, waitForJob };
|
|
3103
|
+
export { AGENT_INSTANCE_HEADER, AgentCloseCode, IncompatibleAgentError, InvalidAgentTokenError, PairingCanceledError, PairingDeniedError, PairingExpiredError, connectWebSocketRpc, createAgent, createClient, createFileStorage, createJob, defaultStudioUrl, generationEventTypes, machineTokenFrom, pairAgent, pollForPairingToken, runConnection, setStorage, startPairing, waitForJob };
|
|
2975
3104
|
|
|
2976
3105
|
//# sourceMappingURL=index.js.map
|