@kubb/studio 5.3.16 → 5.3.18

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/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 process$1 from "node:process";
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 { setTimeout as setTimeout$1 } from "node:timers/promises";
8
12
  import { FetchError, ofetch } from "ofetch";
9
- import { createHash, hash, randomBytes } from "node:crypto";
10
13
  import { promisify, styleText } from "node:util";
11
14
  import { createStorage } from "unstorage";
12
15
  import fsDriver from "unstorage/drivers/fs";
13
- import { Diagnostics, Hookable, cacheStorage, createKubb, fsStorage, memoryStorage } from "@kubb/core";
14
- import { x } from "tinyexec";
15
16
  import { builders, detectCodeFormat, generateCode, parseModule } from "magicast";
16
17
  import { existsSync } from "node:fs";
17
18
  import { pathToFileURL } from "node:url";
18
19
  import { mergeDeep } from "remeda";
19
- import { tmpdir } from "node:os";
20
20
  import { gzip } from "node:zlib";
21
21
  import { build } from "tsdown";
22
22
  import { RpcTarget, newWebSocketRpcSession } from "capnweb";
23
23
  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
24
+ import { x } from "tinyexec";
69
25
  //#region ../../internals/utils/src/casing.ts
70
26
  /**
71
27
  * Shared implementation for camelCase and PascalCase conversion.
@@ -388,7 +344,10 @@ function getElapsedMs(hrStart) {
388
344
  return Math.round(ms * 100) / 100;
389
345
  }
390
346
  //#endregion
391
- //#region src/machine.ts
347
+ //#region package.json
348
+ var version = "5.3.18";
349
+ //#endregion
350
+ //#region src/operations/machine.ts
392
351
  /**
393
352
  * Key-value storage the runtime uses for its machine secret and the last Studio config.
394
353
  *
@@ -448,7 +407,7 @@ async function getMachineToken() {
448
407
  return machineTokenFrom(await fallbackSecretPromise);
449
408
  }
450
409
  //#endregion
451
- //#region src/api.ts
410
+ //#region src/operations/api.ts
452
411
  /**
453
412
  * Reads a human-readable message from a Studio JSON error body, when it has one. `FetchError`'s own
454
413
  * message stops at the status line, so the detail Studio sends with a failure (an agent limit, a
@@ -468,10 +427,6 @@ function responseMessage(data) {
468
427
  */
469
428
  const REGISTER_RETRIES = 3;
470
429
  /**
471
- * Shared in-flight registration so concurrent pool sessions trigger one purge, not N.
472
- */
473
- let registrationInFlight = null;
474
- /**
475
430
  * Thrown when Studio rejects the agent token itself (401). Retrying cannot help: the token was
476
431
  * revoked, or the agent it belonged to was deleted in the Studio UI. Hosts catch this to forget
477
432
  * the stored credential and pair again.
@@ -483,117 +438,87 @@ var InvalidAgentTokenError = class extends Error {
483
438
  }
484
439
  };
485
440
  /**
441
+ * Thrown when Studio refuses this agent's protocol version (426). Retrying cannot help until the
442
+ * agent is upgraded, so hosts stop instead of reconnecting.
443
+ */
444
+ var IncompatibleAgentError = class extends Error {
445
+ constructor(studioUrl, detail, options) {
446
+ super(`Kubb Studio at ${studioUrl} requires a newer agent${detail ? `: ${detail}` : ""}. Upgrade @kubb/studio or the Kubb agent image.`, options);
447
+ this.name = "IncompatibleAgentError";
448
+ }
449
+ };
450
+ /**
486
451
  * Whether a thrown value carries `statusCode`. Not narrowed to `FetchError`: a host wrapper can
487
452
  * 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
453
  */
492
454
  function rejectedWith(error, statusCode) {
493
455
  return error?.statusCode === statusCode;
494
456
  }
495
- function sessionError(cause) {
457
+ function registrationError(cause) {
496
458
  const detail = (cause instanceof FetchError ? responseMessage(cause.data) : void 0) ?? getErrorMessage(cause);
497
- return new Error(detail ? `Failed to get agent session from Kubb Studio: ${detail}` : "Failed to get agent session from Kubb Studio", { cause });
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;
511
- }
512
- /**
513
- * Obtain an agent session token from Kubb Studio via HTTP.
514
- *
515
- * When Studio rejects the machine token (403), for example after the agent restarted
516
- * with a new identity while the startup registration call failed, the agent re-registers
517
- * and retries once, so a single failed registration can't permanently block session creation.
518
- */
519
- async function createAgentSession({ token, studioUrl }) {
520
- try {
521
- return await requestAgentSession({
522
- token,
523
- studioUrl
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
- }
459
+ return new Error(detail ? `Failed to register with Kubb Studio: ${detail}` : "Failed to register with Kubb Studio", { cause });
541
460
  }
542
461
  /**
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.
462
+ * Registers this agent process with Kubb Studio (`POST /api/agent/connect`): binds the machine
463
+ * identity to the token, reports what the process can take on, and gets back the URL of the one
464
+ * socket it keeps open.
546
465
  *
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();
466
+ * Retries a transient failure with backoff. A rejected token (401) throws
467
+ * {@link InvalidAgentTokenError} and an unsupported agent version (426) throws
468
+ * {@link IncompatibleAgentError}, since retrying either cannot help.
469
+ */
470
+ async function registerAgent({ token, studioUrl, instanceId, capacity }) {
471
+ const body = {
472
+ machineToken: await getMachineToken(),
473
+ instanceId,
474
+ capacity
475
+ };
561
476
  try {
562
- await ofetch(`${studioUrl}/api/agent/connect`, {
477
+ return await ofetch(`${studioUrl}/api/agent/connect`, {
563
478
  method: "POST",
564
479
  headers: { Authorization: `Bearer ${token}` },
565
- body: {
566
- machineToken,
567
- poolSize
568
- },
480
+ body,
569
481
  retry: REGISTER_RETRIES,
570
482
  retryDelay: ({ options }) => 2e3 * 2 ** (REGISTER_RETRIES - Number(options.retry))
571
483
  });
572
- return true;
573
484
  } catch (error) {
574
485
  if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
575
- return false;
486
+ if (rejectedWith(error, 426)) throw new IncompatibleAgentError(studioUrl, error instanceof FetchError ? responseMessage(error.data) : void 0, { cause: error });
487
+ throw registrationError(error);
576
488
  }
577
489
  }
578
490
  /**
579
- * Notify Kubb Studio that this agent is disconnecting.
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.
491
+ * First wait before `createJob` retries a busy or queue-full response, absent a `Retry-After` hint.
585
492
  */
586
- async function disconnect({ sessionId, token, studioUrl }) {
587
- try {
588
- await ofetch(`${studioUrl}/api/agent/sessions/${sessionId}/disconnect`, {
589
- method: "POST",
590
- headers: { Authorization: `Bearer ${token}` }
591
- });
592
- return true;
593
- } catch (error) {
594
- const statusCode = error?.statusCode;
595
- return statusCode !== void 0 && statusCode !== 429 && statusCode >= 400 && statusCode < 500;
596
- }
493
+ const CREATE_JOB_INITIAL_DELAY_MS = 1e3;
494
+ /**
495
+ * Slowest `createJob` backs off to between retries.
496
+ */
497
+ const CREATE_JOB_MAX_INTERVAL_MS = 1e4;
498
+ /**
499
+ * Statuses worth retrying: the agent has no free connection yet (409, a stale conflict a moment
500
+ * later resolves), its queue is momentarily full (429), or it has no live connection at all yet
501
+ * (503, an agent process that is mid-reconnect). Anything else (404 agent not found, 401/403 auth)
502
+ * is thrown straight away, since retrying cannot change the outcome.
503
+ */
504
+ const CREATE_JOB_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
505
+ 409,
506
+ 429,
507
+ 503
508
+ ]);
509
+ /**
510
+ * Reads Studio's `Retry-After` header (seconds) off a thrown `ofetch` error, when present.
511
+ */
512
+ function retryAfterMs(error) {
513
+ const seconds = Number(error.response?.headers.get("retry-after"));
514
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
515
+ }
516
+ /**
517
+ * Adds up to 30% jitter, so every CI run queued behind the same busy agent does not retry in
518
+ * lockstep.
519
+ */
520
+ function withJitter(ms) {
521
+ return ms + Math.random() * ms * .3;
597
522
  }
598
523
  /**
599
524
  * Queues a generation or snapshot job on Studio (`POST /api/jobs`).
@@ -601,6 +526,10 @@ async function disconnect({ sessionId, token, studioUrl }) {
601
526
  * Returns as soon as Studio accepts the job (`202`). Poll with {@link waitForJob} until it finishes.
602
527
  * Authenticates with the organization CI API key via `x-api-key`.
603
528
  *
529
+ * A busy agent, a full queue, or a momentary lack of a live connection (409, 429, 503) retries with
530
+ * exponential backoff and jitter, honoring Studio's `Retry-After` header when it sends one, up to
531
+ * `timeoutMs`. Every other failure, including a missing agent (404), throws immediately.
532
+ *
604
533
  * @example Snapshot job
605
534
  * ```ts
606
535
  * const job = await createJob({
@@ -614,20 +543,41 @@ async function disconnect({ sessionId, token, studioUrl }) {
614
543
  * const finished = await waitForJob({ studioUrl, token, id: job.id })
615
544
  * ```
616
545
  */
617
- async function createJob({ studioUrl, token, type, agentId, name, version, commit, config }) {
618
- const { job } = await ofetch(`${studioUrl}/api/jobs`, {
619
- method: "POST",
620
- headers: { "x-api-key": token },
621
- body: {
622
- type,
623
- agentId,
624
- name,
625
- version,
626
- commit,
627
- config
546
+ async function createJob({ studioUrl, token, type, agentId, name, version, commit, baseId, config, instanceId, timeoutMs = 6e4, signal }) {
547
+ const deadline = Date.now() + timeoutMs;
548
+ let interval = CREATE_JOB_INITIAL_DELAY_MS;
549
+ for (;;) {
550
+ signal?.throwIfAborted();
551
+ try {
552
+ const { job } = await ofetch(`${studioUrl}/api/jobs`, {
553
+ method: "POST",
554
+ headers: { "x-api-key": token },
555
+ body: {
556
+ type,
557
+ agentId,
558
+ name,
559
+ version,
560
+ commit,
561
+ baseId,
562
+ config,
563
+ instanceId
564
+ },
565
+ retry: false,
566
+ timeout: Math.max(deadline - Date.now(), 1),
567
+ signal
568
+ });
569
+ return job;
570
+ } catch (error) {
571
+ signal?.throwIfAborted();
572
+ const status = error.response?.status;
573
+ if (!status || !CREATE_JOB_RETRYABLE_STATUSES.has(status) || Date.now() >= deadline) throw error;
574
+ const wait = Math.min(retryAfterMs(error) ?? withJitter(interval), Math.max(deadline - Date.now(), 0));
575
+ if (signal) await setTimeout$1(wait, void 0, { signal });
576
+ else await new Promise((resolve) => setTimeout(resolve, wait));
577
+ if (Date.now() >= deadline) throw error;
578
+ interval = Math.min(interval * 2, CREATE_JOB_MAX_INTERVAL_MS);
628
579
  }
629
- });
630
- return job;
580
+ }
631
581
  }
632
582
  /**
633
583
  * A job runs a generation and packs a tarball, so it is never done the instant it is queued.
@@ -646,20 +596,26 @@ const MAX_POLL_INTERVAL_MS = 3e4;
646
596
  * A `failed` job resolves normally. Check `job.status` and `job.error`. Throws only when the
647
597
  * deadline passes before Studio finishes.
648
598
  */
649
- async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4 }) {
599
+ async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4, signal }) {
650
600
  const deadline = Date.now() + timeoutMs;
651
601
  let interval = INITIAL_POLL_DELAY_MS;
652
602
  for (;;) {
653
- await new Promise((resolve) => setTimeout(resolve, Math.max(Math.min(interval, deadline - Date.now()), 0)));
603
+ signal?.throwIfAborted();
604
+ const wait = Math.max(Math.min(interval, deadline - Date.now()), 0);
605
+ if (signal) await setTimeout$1(wait, void 0, { signal });
606
+ else await new Promise((resolve) => setTimeout(resolve, wait));
654
607
  if (Date.now() >= deadline) throw new Error("Timed out waiting for the Studio job");
655
608
  interval = Math.min(interval * 2, MAX_POLL_INTERVAL_MS);
656
609
  try {
657
610
  const { job } = await ofetch(`${studioUrl}/api/jobs/${id}`, {
658
611
  headers: { "x-api-key": token },
659
- retry: false
612
+ retry: false,
613
+ timeout: Math.max(deadline - Date.now(), 1),
614
+ signal
660
615
  });
661
616
  if (job.status === "success" || job.status === "failed" || job.status === "canceled") return job;
662
617
  } catch (error) {
618
+ signal?.throwIfAborted();
663
619
  const response = error.response;
664
620
  if (response?.status !== 429) throw error;
665
621
  const retryAfter = response._data?.data?.tryAgainIn;
@@ -693,90 +649,7 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
693
649
  }
694
650
  }
695
651
  //#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
- //#region src/resolveConfig.ts
652
+ //#region src/operations/resolveConfig.ts
780
653
  /**
781
654
  * Imports a package, falling back to how the user's project would resolve it.
782
655
  *
@@ -934,7 +807,7 @@ async function mergeAdapter(diskAdapter, studioOptions) {
934
807
  return factory(mergeDeep(diskAdapter.options ?? {}, studioOptions));
935
808
  }
936
809
  //#endregion
937
- //#region src/configFile.ts
810
+ //#region src/operations/configFile.ts
938
811
  /**
939
812
  * A valid JavaScript identifier, so an import name can only ever print as `import { name } from`,
940
813
  * never as source that breaks out of the import statement.
@@ -1474,6 +1347,11 @@ function applyConfigEdits(source, edits) {
1474
1347
  changed: current !== source
1475
1348
  };
1476
1349
  }
1350
+ async function writeConfigEdits({ filePath, edits }) {
1351
+ const result = applyConfigEdits(await read(filePath), edits);
1352
+ if (result.changed) await writeFile(filePath, result.source, "utf-8");
1353
+ return result;
1354
+ }
1477
1355
  /**
1478
1356
  * The 1-based line where the file's last import declaration ends, or `0` when it has none. Read
1479
1357
  * off the parsed module, so a multi-line `import {\n x,\n} from '...'` reports its closing line
@@ -1506,145 +1384,60 @@ function withTrailingNewline(code, hadTrailingNewline) {
1506
1384
  return `${code}\n`;
1507
1385
  }
1508
1386
  //#endregion
1509
- //#region src/generate.ts
1510
- /**
1511
- * `isToolAvailable` spawns a process, and a long-lived connection generates repeatedly, so each
1512
- * executable is probed once per process. The CLI deliberately does not memoize: a `--watch` build
1513
- * should keep noticing a tool installed mid-session.
1514
- */
1515
- const detectTool = memoize(/* @__PURE__ */ new Map(), detectTool$1);
1387
+ //#region src/operations/constants.ts
1516
1388
  /**
1517
- * The two post-build tool steps. Formatting and linting differ only in which tools they look for,
1518
- * so they run through one loop rather than two near-identical blocks.
1519
- *
1520
- * `noun` and `verbing` are spelled out instead of built from `kind`. Concatenating `` `${kind}ter` ``
1521
- * and `` `${kind}ting` `` works for `format`, but doubles the `t` in `lint`, giving "lintter" and
1522
- * "lintting" instead of "linter" and "linting".
1389
+ * Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
1390
+ * not whatever default the client would pick on its own.
1523
1391
  */
1524
- const TOOL_STEPS = [{
1525
- kind: "format",
1526
- noun: "formatter",
1527
- verbing: "Formatting",
1528
- tools: formatters,
1529
- detect: FORMATTER_PREFERENCE
1530
- }, {
1531
- kind: "lint",
1532
- noun: "linter",
1533
- verbing: "Linting",
1534
- tools: linters,
1535
- detect: LINTER_PREFERENCE
1536
- }];
1392
+ const defaultStudioUrl = "https://kubb.studio";
1537
1393
  /**
1538
- * Absolute path of the directory the formatter and linter are pointed at.
1394
+ * Defaults the Studio client uses when a host passes nothing.
1395
+ * Config path is left out on purpose: each host discovers that itself.
1539
1396
  */
1540
- function outputPath(config) {
1541
- return path.isAbsolute(config.output.path) ? config.output.path : path.resolve(process$1.cwd(), config.root, config.output.path);
1397
+ const agentDefaults = {
1398
+ studioUrl: defaultStudioUrl,
1399
+ retryIntervalMs: 3e4,
1400
+ heartbeatIntervalMs: 3e4,
1401
+ /**
1402
+ * Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
1403
+ * stored ping is older than its liveness window, and it stores a ping at most once a minute, so
1404
+ * a slower cadence would make a healthy agent look dead after a single missed ping.
1405
+ */
1406
+ maxHeartbeatIntervalMs: 6e4,
1407
+ /** How long a heartbeat ping may take before the session is treated as dead. */
1408
+ heartbeatTimeoutMs: 1e4,
1409
+ maxConcurrent: 1,
1410
+ maxGenerations: 8,
1411
+ maxGenerationsMb: 100,
1412
+ maxSnapshotMb: 50
1413
+ };
1414
+ function positiveNumber(value) {
1415
+ const parsed = Number(value);
1416
+ return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1542
1417
  }
1543
1418
  /**
1544
- * Emits `kubb:hook:start` and waits for the matching `kubb:hook:end`. The host spawns the process:
1545
- * this only describes what to run and when it finished.
1546
- *
1547
- * @throws whatever the command failed with, so callers can report it their own way.
1548
- */
1549
- async function runHook({ hooks, id, command, args }) {
1550
- const hookId = hash("sha256", id);
1551
- const hookEnd = waitForHookEnd(hooks, hookId);
1552
- await hooks.callHook("kubb:hook:start", {
1553
- id: hookId,
1554
- command,
1555
- args: [...args]
1556
- });
1557
- await hookEnd;
1558
- }
1559
- function isProblemErrorDiagnostic(diagnostic) {
1560
- return (diagnostic.kind ?? "problem") === "problem" && diagnostic.severity === "error";
1561
- }
1562
- /**
1563
- * Folds error-severity diagnostics into one thrown error so logs name the failing plugin.
1419
+ * How many generations an agent keeps and how large they may get, read from
1420
+ * `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
1421
+ * An unset or invalid value keeps the default.
1564
1422
  */
1565
- function formatGenerationFailure(diagnostics) {
1566
- const reasons = diagnostics.filter(isProblemErrorDiagnostic).map((diagnostic) => diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message);
1567
- if (!reasons.length) return /* @__PURE__ */ new Error("Generation failed");
1568
- return /* @__PURE__ */ new Error(`Generation failed: ${reasons.length} error${reasons.length === 1 ? "" : "s"}: ${reasons.join("; ")}`);
1423
+ function resolveGenerationLimits(env = process.env) {
1424
+ return {
1425
+ maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
1426
+ maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
1427
+ maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
1428
+ };
1569
1429
  }
1570
1430
  /**
1571
- * Runs a full Kubb code-generation cycle for the given config.
1572
- *
1573
- * Emits lifecycle events on the provided `hooks` emitter so callers (e.g. the WebSocket stream)
1574
- * can forward progress to connected clients. After a successful build, auto-formatting and
1575
- * linting are applied when configured, followed by any user-defined `hooks.done` commands.
1431
+ * An agent's capacity read from `KUBB_AGENT_MAX_CONCURRENT`. An unset or invalid value keeps the
1432
+ * default: one job at a time.
1576
1433
  */
1577
- async function generate({ config, hooks, signal }) {
1578
- signal?.throwIfAborted();
1579
- const hrStart = process$1.hrtime();
1580
- await hooks.callHook("kubb:generation:start", { config });
1581
- await hooks.callHook("kubb:info", { message: config.name ? `Setup generation ${config.name}` : "Setup generation" });
1582
- const kubb = createKubb(config, {
1583
- hooks,
1584
- signal
1585
- });
1586
- await kubb.setup();
1587
- await hooks.callHook("kubb:info", { message: config.name ? `Build generation ${config.name}` : "Build generation" });
1588
- const { files, diagnostics, storage } = await kubb.safeBuild();
1589
- signal?.throwIfAborted();
1590
- await hooks.callHook("kubb:info", { message: "Load summary" });
1591
- for (const diagnostic of diagnostics.filter(isProblemErrorDiagnostic)) await hooks.callHook("kubb:error", { error: new Error(diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message) });
1592
- const status = Diagnostics.hasError(diagnostics) ? "failed" : "success";
1593
- await hooks.callHook("kubb:generation:end", {
1594
- config,
1595
- storage: {
1596
- ...storage,
1597
- readKeys: async () => [...new Set(files.map((file) => file.path))]
1598
- },
1599
- diagnostics,
1600
- status,
1601
- hrStart,
1602
- filesCreated: files.length
1603
- });
1604
- if (status === "failed") throw formatGenerationFailure(diagnostics);
1605
- await hooks.callHook("kubb:success", { message: "Generation successfully" });
1606
- for (const step of TOOL_STEPS) {
1607
- const setting = config.output[step.kind];
1608
- if (!setting) continue;
1609
- await hooks.callHook(`kubb:${step.kind}:start`);
1610
- const tool = setting === "auto" ? await detectTool(step.detect) : setting;
1611
- if (!tool) await hooks.callHook("kubb:warn", { message: `No ${step.noun} found (${step.detect.join(", ")}). Skipping ${step.verbing.toLowerCase()}.` });
1612
- if (tool && setting === "auto") await hooks.callHook("kubb:info", { message: `Auto-detected ${step.noun}: ${styleText("dim", tool)}` });
1613
- const command = tool ? step.tools[tool] : void 0;
1614
- if (command) try {
1615
- await runHook({
1616
- hooks,
1617
- id: [config.name, tool].filter(Boolean).join("-"),
1618
- command: command.command,
1619
- args: command.args(outputPath(config))
1620
- });
1621
- await hooks.callHook("kubb:success", { message: `${step.verbing} with ${tool} successfully` });
1622
- } catch (caughtError) {
1623
- await hooks.callHook("kubb:error", { error: new Error(command.errorMessage, { cause: caughtError }) });
1624
- signal?.throwIfAborted();
1625
- }
1626
- await hooks.callHook(`kubb:${step.kind}:end`);
1627
- }
1628
- if (config.output.postGenerate?.length) {
1629
- await hooks.callHook("kubb:hooks:start");
1630
- for (const entry of config.output.postGenerate) {
1631
- const line = typeof entry === "string" ? entry : entry.command;
1632
- const [cmd, ...args] = tokenize(line);
1633
- if (!cmd) continue;
1634
- await runHook({
1635
- hooks,
1636
- id: line,
1637
- command: cmd,
1638
- args
1639
- });
1640
- await hooks.callHook("kubb:success", { message: `${line} successfully executed` });
1641
- }
1642
- await hooks.callHook("kubb:hooks:end");
1643
- }
1434
+ function resolveAgentCapacity(env = process.env) {
1435
+ return { maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)) };
1644
1436
  }
1645
1437
  //#endregion
1646
- //#region src/snapshotPackage.ts
1438
+ //#region src/operations/snapshotPackage.ts
1647
1439
  const gzipAsync = promisify(gzip);
1440
+ const UPLOAD_TIMEOUT_MS = 12e4;
1648
1441
  /**
1649
1442
  * Maps a generated file's path to its place inside the tarball, stripping everything before a
1650
1443
  * `src`/`dist` segment and any `..`/empty path segment so a crafted file name cannot escape the
@@ -1782,10 +1575,33 @@ async function createSnapshotPackage(files, packageInfo) {
1782
1575
  });
1783
1576
  }
1784
1577
  }
1578
+ async function uploadSnapshot({ bytes, uploadPath, studioUrl, token, shutdown }) {
1579
+ const uploadUrl = new URL(uploadPath, studioUrl);
1580
+ if (uploadUrl.origin !== new URL(studioUrl).origin) throw new Error("Snapshot upload path must stay on the Studio origin");
1581
+ const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS);
1582
+ const signal = shutdown ? AbortSignal.any([shutdown, timeout]) : timeout;
1583
+ const redirect = await fetch(uploadUrl, {
1584
+ method: "PUT",
1585
+ headers: { Authorization: `Bearer ${token}` },
1586
+ redirect: "manual",
1587
+ signal
1588
+ });
1589
+ const storageUrl = redirect.headers.get("location");
1590
+ if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
1591
+ const storage = new URL(storageUrl);
1592
+ if (storage.protocol !== "https:" && storage.hostname !== "localhost" && storage.hostname !== "127.0.0.1") throw new Error(`Refusing snapshot upload to ${storage.origin}`);
1593
+ const response = await fetch(storage, {
1594
+ method: "PUT",
1595
+ body: new Uint8Array(bytes),
1596
+ redirect: "error",
1597
+ signal
1598
+ });
1599
+ if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
1600
+ }
1785
1601
  //#endregion
1786
- //#region src/generations.ts
1602
+ //#region src/operations/generations.ts
1787
1603
  const READ_CONCURRENCY = 50;
1788
- const MB = 1048576;
1604
+ const MB$1 = 1048576;
1789
1605
  const INDEX_KEY = "studio/generations.json";
1790
1606
  const hashOf = (content) => createHash("sha1").update(content).digest("hex").slice(0, 16);
1791
1607
  /**
@@ -1812,8 +1628,9 @@ async function listDisk({ root, outputPath, maxFiles }) {
1812
1628
  * tenant never reaches another's output. The oldest go past `maxCount` or `maxMb`, but the newest
1813
1629
  * always stays.
1814
1630
  */
1815
- function createGenerationStore({ storage, maxCount, maxMb }) {
1631
+ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now }) {
1816
1632
  let index;
1633
+ const isLive = (generation) => ttlMs === void 0 || generation.keptAt === void 0 || now() - generation.keptAt < ttlMs;
1817
1634
  const dirOf = (jobId) => `studio/generations/${hashOf(jobId)}/`;
1818
1635
  async function load() {
1819
1636
  if (index) return index;
@@ -1829,7 +1646,7 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
1829
1646
  * Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
1830
1647
  */
1831
1648
  async function keep({ jobId, source, files, maxSetMb }) {
1832
- const maxSetBytes = maxSetMb * MB;
1649
+ const maxSetBytes = maxSetMb * MB$1;
1833
1650
  const hashes = {};
1834
1651
  let bytes = 0;
1835
1652
  await inParallel({
@@ -1864,13 +1681,20 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
1864
1681
  return {
1865
1682
  keep,
1866
1683
  drop,
1867
- get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId),
1868
- latest: async () => (await load()).at(-1),
1684
+ get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId && isLive(generation)),
1685
+ latest: async () => (await load()).filter(isLive).at(-1),
1686
+ /** Total bytes of every set the store holds, expired ones too until the next add drops them. */
1687
+ bytes: async () => (await load()).reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0),
1869
1688
  async add(generation) {
1870
- const entries = (await load()).filter((entry) => entry.jobId !== generation.jobId);
1871
- entries.push(generation);
1689
+ const current = await load();
1690
+ for (const expired of current.filter((entry) => !isLive(entry))) await drop(expired.jobId);
1691
+ const entries = current.filter((entry) => entry.jobId !== generation.jobId && isLive(entry));
1692
+ entries.push({
1693
+ ...generation,
1694
+ keptAt: now()
1695
+ });
1872
1696
  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);
1697
+ while (entries.length > 1 && (entries.length > maxCount || weight() > maxMb * MB$1)) await drop(entries.shift().jobId);
1874
1698
  index = entries;
1875
1699
  await storage.writeItem(INDEX_KEY, JSON.stringify(entries));
1876
1700
  },
@@ -1893,12 +1717,7 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
1893
1717
  };
1894
1718
  }
1895
1719
  //#endregion
1896
- //#region src/ws.ts
1897
- /**
1898
- * How long the initial handshake may take before the socket is closed and the reconnect loop
1899
- * takes over.
1900
- */
1901
- const CONNECT_TIMEOUT_MS = 5e3;
1720
+ //#region src/operations/generationEvents.ts
1902
1721
  const require = createRequire(import.meta.url);
1903
1722
  function relativeStoragePath(root, filePath) {
1904
1723
  return (isAbsolute(filePath) ? relative(resolve(root), filePath) : filePath).replaceAll("\\", "/");
@@ -1928,27 +1747,24 @@ async function resolvePeerDependencies(names) {
1928
1747
  missingDependencies
1929
1748
  };
1930
1749
  }
1931
- /**
1932
- * Opens a Studio WebSocket connection and closes it when the initial handshake exceeds the configured timeout.
1933
- */
1934
- function createWebsocket(url, options) {
1935
- const ws = new WebSocket(url, options);
1936
- const timer = setTimeout(() => {
1937
- if (ws.readyState === WebSocket.CONNECTING) ws.close(3008, "Connection timeout");
1938
- }, CONNECT_TIMEOUT_MS);
1939
- ws.once("open", () => clearTimeout(timer));
1940
- ws.once("close", () => clearTimeout(timer));
1941
- return ws;
1942
- }
1750
+ const MAX_QUEUED_EVENTS = 1024;
1751
+ const RESERVED_EVENTS = 64;
1752
+ const isDiscardable = (event) => event.type === "kubb:files:processing:update" || event.type === "kubb:info" || event.type === "kubb:success";
1943
1753
  /** Forwards selected Kubb lifecycle events to a native Cap'n Web stream. */
1944
1754
  function createGenerationStream(hooks, jobId, options = {}) {
1945
1755
  const unhooks = [];
1946
1756
  let root = "";
1947
- const transform = new TransformStream(void 0, void 0, { highWaterMark: Infinity });
1948
- const writer = transform.writable.getWriter();
1949
- let writes = Promise.resolve();
1757
+ let controller;
1950
1758
  let closed = false;
1951
- let streamError;
1759
+ const stream = new ReadableStream({
1760
+ start: (value) => {
1761
+ controller = value;
1762
+ },
1763
+ cancel: () => {
1764
+ closed = true;
1765
+ detach();
1766
+ }
1767
+ }, { highWaterMark: MAX_QUEUED_EVENTS });
1952
1768
  /**
1953
1769
  * Registers a listener and keeps its remover, so one generation's listeners come off the session
1954
1770
  * emitter again when that generation ends.
@@ -1957,6 +1773,7 @@ function createGenerationStream(hooks, jobId, options = {}) {
1957
1773
  unhooks.push(hooks.hook(name, handler));
1958
1774
  }
1959
1775
  function emitEvent(type, data) {
1776
+ if (closed) return;
1960
1777
  const event = {
1961
1778
  jobId,
1962
1779
  type,
@@ -1964,9 +1781,12 @@ function createGenerationStream(hooks, jobId, options = {}) {
1964
1781
  version: 1,
1965
1782
  timestamp: Date.now()
1966
1783
  };
1967
- writes = writes.then(() => writer.write(event)).catch((error) => {
1968
- streamError = error;
1969
- });
1784
+ if (controller.desiredSize <= RESERVED_EVENTS && isDiscardable(event)) return;
1785
+ if (controller.desiredSize <= 0) {
1786
+ fail(/* @__PURE__ */ new Error(`Generation event stream exceeded ${MAX_QUEUED_EVENTS} queued events`));
1787
+ return;
1788
+ }
1789
+ controller.enqueue(event);
1970
1790
  }
1971
1791
  on("kubb:plugin:start", (ctx) => {
1972
1792
  emitEvent("kubb:plugin:start", [{ plugin: { name: ctx.plugin.name } }]);
@@ -2112,25 +1932,38 @@ function createGenerationStream(hooks, jobId, options = {}) {
2112
1932
  if (closed) return;
2113
1933
  closed = true;
2114
1934
  detach();
2115
- await writes;
2116
- if (streamError) return;
2117
- await writer.close().catch(() => void 0);
1935
+ controller.close();
2118
1936
  }
2119
1937
  function fail(error) {
2120
1938
  detach();
2121
1939
  if (closed) return;
2122
1940
  closed = true;
2123
- writer.abort(error).catch(() => void 0);
1941
+ controller.error(error);
2124
1942
  }
2125
1943
  return {
2126
- stream: transform.readable,
1944
+ stream,
2127
1945
  close,
2128
1946
  dispose: () => fail(),
2129
1947
  fail
2130
1948
  };
2131
1949
  }
2132
1950
  //#endregion
2133
- //#region src/rpc.ts
1951
+ //#region src/operations/websocket.ts
1952
+ const CONNECT_TIMEOUT_MS = 5e3;
1953
+ /**
1954
+ * Opens a Studio WebSocket connection and closes it when the initial handshake exceeds the configured timeout.
1955
+ */
1956
+ function createWebsocket(url, options) {
1957
+ const ws = new WebSocket(url, options);
1958
+ const timer = setTimeout(() => {
1959
+ if (ws.readyState === WebSocket.CONNECTING) ws.close(3008, "Connection timeout");
1960
+ }, CONNECT_TIMEOUT_MS);
1961
+ ws.once("open", () => clearTimeout(timer));
1962
+ ws.once("close", () => clearTimeout(timer));
1963
+ return ws;
1964
+ }
1965
+ //#endregion
1966
+ //#region src/operations/rpc.ts
2134
1967
  /**
2135
1968
  * The only methods Studio may call on an agent. A `StudioSession` carries far more than
2136
1969
  * {@link AgentApi}, so it is wrapped rather than exposed: what Cap'n Web can reach is exactly what
@@ -2157,6 +1990,9 @@ var AgentRpcTarget = class extends RpcTarget {
2157
1990
  readFiles(input) {
2158
1991
  return this.api.readFiles(input);
2159
1992
  }
1993
+ cancel(jobId) {
1994
+ return this.api.cancel(jobId);
1995
+ }
2160
1996
  };
2161
1997
  /**
2162
1998
  * Opens an authenticated Cap'n Web session to Studio over a WebSocket. Rejects an unencrypted URL
@@ -2168,11 +2004,17 @@ var AgentRpcTarget = class extends RpcTarget {
2168
2004
  * await rpc.studio.ping()
2169
2005
  * ```
2170
2006
  */
2171
- const connectWebSocketRpc = async ({ url, token, local }) => {
2007
+ const connectWebSocketRpc = async ({ url, token, instanceId, local }) => {
2172
2008
  const { protocol, hostname, host } = new URL(url);
2173
2009
  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: { Authorization: `Bearer ${token}` } });
2175
- const closed = new Promise((resolve) => socket.once("close", resolve));
2010
+ const socket = createWebsocket(url, { headers: {
2011
+ Authorization: `Bearer ${token}`,
2012
+ [AGENT_INSTANCE_HEADER]: instanceId
2013
+ } });
2014
+ const closed = new Promise((resolve) => socket.once("close", (code, reason) => resolve({
2015
+ code,
2016
+ reason: reason.toString()
2017
+ })));
2176
2018
  const studio = newWebSocketRpcSession(socket, new AgentRpcTarget(local));
2177
2019
  studio.onRpcBroken(() => socket.close());
2178
2020
  return {
@@ -2182,11 +2024,274 @@ const connectWebSocketRpc = async ({ url, token, local }) => {
2182
2024
  };
2183
2025
  };
2184
2026
  //#endregion
2185
- //#region src/StudioSession.ts
2027
+ //#region src/operations/hooks.ts
2186
2028
  /**
2187
- * Past this many files in the output directory, no snapshot of it is taken before a run.
2029
+ * Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
2030
+ * streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
2031
+ * Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
2032
+ *
2033
+ * Returns a remover, so a session that runs one generation after another on the same emitter does
2034
+ * not stack a listener per run.
2188
2035
  */
2036
+ function setupHookListener(hooks, root, signal) {
2037
+ return hooks.hook("kubb:hook:start", async (ctx) => {
2038
+ const { id, command, args } = ctx;
2039
+ if (!id) return;
2040
+ const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
2041
+ try {
2042
+ const proc = x(command, [...args ?? []], {
2043
+ signal,
2044
+ nodeOptions: {
2045
+ cwd: root,
2046
+ detached: true
2047
+ }
2048
+ });
2049
+ for await (const line of proc) await hooks.callHook("kubb:hook:line", {
2050
+ id,
2051
+ line
2052
+ });
2053
+ const { exitCode } = await proc;
2054
+ if (exitCode !== 0) {
2055
+ const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
2056
+ await hooks.callHook("kubb:hook:end", {
2057
+ id,
2058
+ command,
2059
+ args,
2060
+ success: false,
2061
+ error
2062
+ });
2063
+ await hooks.callHook("kubb:error", { error });
2064
+ return;
2065
+ }
2066
+ await hooks.callHook("kubb:hook:end", {
2067
+ id,
2068
+ command,
2069
+ args,
2070
+ success: true,
2071
+ error: null
2072
+ });
2073
+ } catch (caughtError) {
2074
+ const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
2075
+ error.cause = caughtError;
2076
+ await hooks.callHook("kubb:hook:end", {
2077
+ id,
2078
+ command,
2079
+ args,
2080
+ success: false,
2081
+ error
2082
+ });
2083
+ await hooks.callHook("kubb:error", { error });
2084
+ }
2085
+ });
2086
+ }
2087
+ /**
2088
+ * Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
2089
+ * `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
2090
+ * that same listener, so a handler added afterward would already have missed it.
2091
+ */
2092
+ function waitForHookEnd(hooks, hookId) {
2093
+ return new Promise((resolve, reject) => {
2094
+ const handleHookEnd = (ctx) => {
2095
+ if (ctx.id !== hookId) return;
2096
+ hooks.removeHook("kubb:hook:end", handleHookEnd);
2097
+ if (ctx.success) {
2098
+ resolve();
2099
+ return;
2100
+ }
2101
+ reject(ctx.error);
2102
+ };
2103
+ hooks.hook("kubb:hook:end", handleHookEnd);
2104
+ });
2105
+ }
2106
+ //#endregion
2107
+ //#region src/operations/generate.ts
2189
2108
  const DISK_SNAPSHOT_MAX_FILES = 1e4;
2109
+ /**
2110
+ * `isToolAvailable` spawns a process, and a long-lived connection generates repeatedly, so each
2111
+ * executable is probed once per process. The CLI deliberately does not memoize: a `--watch` build
2112
+ * should keep noticing a tool installed mid-session.
2113
+ */
2114
+ const detectTool = memoize(/* @__PURE__ */ new Map(), detectTool$1);
2115
+ /**
2116
+ * The two post-build tool steps. Formatting and linting differ only in which tools they look for,
2117
+ * so they run through one loop rather than two near-identical blocks.
2118
+ *
2119
+ * `noun` and `verbing` are spelled out instead of built from `kind`. Concatenating `` `${kind}ter` ``
2120
+ * and `` `${kind}ting` `` works for `format`, but doubles the `t` in `lint`, giving "lintter" and
2121
+ * "lintting" instead of "linter" and "linting".
2122
+ */
2123
+ const TOOL_STEPS = [{
2124
+ kind: "format",
2125
+ noun: "formatter",
2126
+ verbing: "Formatting",
2127
+ tools: formatters,
2128
+ detect: FORMATTER_PREFERENCE
2129
+ }, {
2130
+ kind: "lint",
2131
+ noun: "linter",
2132
+ verbing: "Linting",
2133
+ tools: linters,
2134
+ detect: LINTER_PREFERENCE
2135
+ }];
2136
+ /**
2137
+ * Absolute path of the directory the formatter and linter are pointed at.
2138
+ */
2139
+ function outputPath(config) {
2140
+ return path.isAbsolute(config.output.path) ? config.output.path : path.resolve(process$1.cwd(), config.root, config.output.path);
2141
+ }
2142
+ /**
2143
+ * Emits `kubb:hook:start` and waits for the matching `kubb:hook:end`. The host spawns the process:
2144
+ * this only describes what to run and when it finished.
2145
+ *
2146
+ * @throws whatever the command failed with, so callers can report it their own way.
2147
+ */
2148
+ async function runHook({ hooks, id, command, args }) {
2149
+ const hookId = hash("sha256", id);
2150
+ const hookEnd = waitForHookEnd(hooks, hookId);
2151
+ await hooks.callHook("kubb:hook:start", {
2152
+ id: hookId,
2153
+ command,
2154
+ args: [...args]
2155
+ });
2156
+ await hookEnd;
2157
+ }
2158
+ function isProblemErrorDiagnostic(diagnostic) {
2159
+ return (diagnostic.kind ?? "problem") === "problem" && diagnostic.severity === "error";
2160
+ }
2161
+ /**
2162
+ * Folds error-severity diagnostics into one thrown error so logs name the failing plugin.
2163
+ */
2164
+ function formatGenerationFailure(diagnostics) {
2165
+ const reasons = diagnostics.filter(isProblemErrorDiagnostic).map((diagnostic) => diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message);
2166
+ if (!reasons.length) return /* @__PURE__ */ new Error("Generation failed");
2167
+ return /* @__PURE__ */ new Error(`Generation failed: ${reasons.length} error${reasons.length === 1 ? "" : "s"}: ${reasons.join("; ")}`);
2168
+ }
2169
+ /**
2170
+ * Runs a full Kubb code-generation cycle for the given config.
2171
+ *
2172
+ * Emits lifecycle events on the provided `hooks` emitter so callers (e.g. the WebSocket stream)
2173
+ * can forward progress to connected clients. After a successful build, auto-formatting and
2174
+ * linting are applied when configured, followed by any user-defined `hooks.done` commands.
2175
+ */
2176
+ async function generate({ config, hooks, signal }) {
2177
+ signal?.throwIfAborted();
2178
+ const hrStart = process$1.hrtime();
2179
+ await hooks.callHook("kubb:generation:start", { config });
2180
+ await hooks.callHook("kubb:info", { message: config.name ? `Setup generation ${config.name}` : "Setup generation" });
2181
+ const kubb = createKubb(config, {
2182
+ hooks,
2183
+ signal
2184
+ });
2185
+ await kubb.setup();
2186
+ await hooks.callHook("kubb:info", { message: config.name ? `Build generation ${config.name}` : "Build generation" });
2187
+ const { files, diagnostics, storage } = await kubb.safeBuild();
2188
+ signal?.throwIfAborted();
2189
+ await hooks.callHook("kubb:info", { message: "Load summary" });
2190
+ for (const diagnostic of diagnostics.filter(isProblemErrorDiagnostic)) await hooks.callHook("kubb:error", { error: new Error(diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message) });
2191
+ const status = Diagnostics.hasError(diagnostics) ? "failed" : "success";
2192
+ await hooks.callHook("kubb:generation:end", {
2193
+ config,
2194
+ storage: {
2195
+ ...storage,
2196
+ readKeys: async () => [...new Set(files.map((file) => file.path))]
2197
+ },
2198
+ diagnostics,
2199
+ status,
2200
+ hrStart,
2201
+ filesCreated: files.length
2202
+ });
2203
+ if (status === "failed") throw formatGenerationFailure(diagnostics);
2204
+ await hooks.callHook("kubb:success", { message: "Generation successfully" });
2205
+ for (const step of TOOL_STEPS) {
2206
+ const setting = config.output[step.kind];
2207
+ if (!setting) continue;
2208
+ await hooks.callHook(`kubb:${step.kind}:start`);
2209
+ const tool = setting === "auto" ? await detectTool(step.detect) : setting;
2210
+ if (!tool) await hooks.callHook("kubb:warn", { message: `No ${step.noun} found (${step.detect.join(", ")}). Skipping ${step.verbing.toLowerCase()}.` });
2211
+ if (tool && setting === "auto") await hooks.callHook("kubb:info", { message: `Auto-detected ${step.noun}: ${styleText("dim", tool)}` });
2212
+ const command = tool ? step.tools[tool] : void 0;
2213
+ if (command) try {
2214
+ await runHook({
2215
+ hooks,
2216
+ id: [config.name, tool].filter(Boolean).join("-"),
2217
+ command: command.command,
2218
+ args: command.args(outputPath(config))
2219
+ });
2220
+ await hooks.callHook("kubb:success", { message: `${step.verbing} with ${tool} successfully` });
2221
+ } catch (caughtError) {
2222
+ await hooks.callHook("kubb:error", { error: new Error(command.errorMessage, { cause: caughtError }) });
2223
+ signal?.throwIfAborted();
2224
+ }
2225
+ await hooks.callHook(`kubb:${step.kind}:end`);
2226
+ }
2227
+ if (config.output.postGenerate?.length) {
2228
+ await hooks.callHook("kubb:hooks:start");
2229
+ for (const entry of config.output.postGenerate) {
2230
+ const line = typeof entry === "string" ? entry : entry.command;
2231
+ const [cmd, ...args] = tokenize(line);
2232
+ if (!cmd) continue;
2233
+ await runHook({
2234
+ hooks,
2235
+ id: line,
2236
+ command: cmd,
2237
+ args
2238
+ });
2239
+ await hooks.callHook("kubb:success", { message: `${line} successfully executed` });
2240
+ }
2241
+ await hooks.callHook("kubb:hooks:end");
2242
+ }
2243
+ }
2244
+ async function runGenerationOperation({ config, hooks, signal, jobId, store, snapshotRoot, maxSnapshotMb }) {
2245
+ const diskFiles = snapshotRoot ? await listDisk({
2246
+ root: snapshotRoot,
2247
+ outputPath: config.output.path,
2248
+ maxFiles: DISK_SNAPSHOT_MAX_FILES
2249
+ }) : void 0;
2250
+ const disk = diskFiles ? await store.keep({
2251
+ jobId,
2252
+ source: "disk",
2253
+ files: diskFiles,
2254
+ maxSetMb: maxSnapshotMb
2255
+ }) : void 0;
2256
+ const removeHookListener = setupHookListener(hooks, config.root, signal);
2257
+ try {
2258
+ await generate({
2259
+ config,
2260
+ hooks,
2261
+ signal
2262
+ });
2263
+ } catch (error) {
2264
+ await store.drop(jobId);
2265
+ throw error;
2266
+ } finally {
2267
+ removeHookListener();
2268
+ }
2269
+ return disk;
2270
+ }
2271
+ //#endregion
2272
+ //#region src/runtime/StudioSession.ts
2273
+ /** A sandbox shares one in-memory generation store across tenants, so old generations expire. */
2274
+ const SANDBOX_GENERATION_TTL_MS = 9e5;
2275
+ /**
2276
+ * A fresh root for one sandbox job. Kubb keys its output manifest cache by root, so tenants that
2277
+ * shared the agent's own root would read each other's manifest.
2278
+ */
2279
+ function createJobRoot() {
2280
+ return mkdtemp(path.join(tmpdir(), "kubb-job-"));
2281
+ }
2282
+ /**
2283
+ * Removes a job root and the manifest cache Kubb derived from it. Best effort: a leftover temp
2284
+ * directory must not fail a job that already finished.
2285
+ */
2286
+ async function removeJobRoot(jobRoot) {
2287
+ await Promise.all([rm(jobRoot, {
2288
+ recursive: true,
2289
+ force: true
2290
+ }), rm(resolveCacheDir(jobRoot), {
2291
+ recursive: true,
2292
+ force: true
2293
+ })]).catch(() => {});
2294
+ }
2190
2295
  var GenerationRunTarget = class extends RpcTarget {
2191
2296
  generationStream;
2192
2297
  generationResult;
@@ -2233,34 +2338,74 @@ function applyStudioDefaults(options) {
2233
2338
  ...options.permissions
2234
2339
  },
2235
2340
  retryInterval: options.retryInterval ?? agentDefaults.retryIntervalMs,
2236
- heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs)
2341
+ heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs),
2342
+ capacity: {
2343
+ ...resolveAgentCapacity(),
2344
+ ...options.capacity
2345
+ },
2346
+ instanceId: options.instanceId ?? randomUUID()
2237
2347
  };
2238
2348
  }
2239
2349
  /**
2240
- * Schedules another connection attempt.
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.
2350
+ * Jobs one agent process can run at once today. Two runs would share this session's hook emitter,
2351
+ * and with it each other's events, until each job runs in its own worker (ADR-0003 slice B2).
2245
2352
  */
2246
- function reconnect(options) {
2247
- const { signal, retryInterval, onTokenRejected } = options;
2353
+ const RUNTIME_MAX_CONCURRENT = 1;
2354
+ const MB = 1048576;
2355
+ function rssMb() {
2356
+ return process$1.memoryUsage().rss / MB;
2357
+ }
2358
+ function backoffDelayMs(attempt, maxMs) {
2359
+ const cap = Math.min(1e3 * 2 ** (attempt - 1), maxMs);
2360
+ return Math.random() * cap;
2361
+ }
2362
+ function reconnect(options, delayMs, attempt) {
2363
+ const { signal, onTokenRejected } = options;
2248
2364
  if (signal?.aborted) return;
2249
2365
  const cancel = () => clearTimeout(timer);
2250
2366
  const timer = setTimeout(() => {
2251
2367
  signal?.removeEventListener("abort", cancel);
2252
2368
  if (signal?.aborted) return;
2253
- new StudioSession(options).start().catch((error) => {
2369
+ new StudioSession({
2370
+ ...options,
2371
+ reconnectAttempt: attempt
2372
+ }).start().catch((error) => {
2254
2373
  if (error instanceof InvalidAgentTokenError) {
2255
2374
  onTokenRejected?.(error);
2256
2375
  return;
2257
2376
  }
2258
- reconnect(options);
2377
+ if (error instanceof IncompatibleAgentError) return;
2378
+ const nextAttempt = attempt + 1;
2379
+ reconnect(options, backoffDelayMs(nextAttempt, options.retryInterval), nextAttempt);
2259
2380
  });
2260
- }, retryInterval);
2381
+ }, delayMs);
2261
2382
  signal?.addEventListener("abort", cancel, { once: true });
2262
2383
  }
2263
2384
  /**
2385
+ * Reads what Studio meant by closing the connection. A code Studio did not send on purpose is an
2386
+ * ordinary drop, and the agent reconnects as it always has.
2387
+ */
2388
+ function planEnd(close) {
2389
+ const code = close?.code;
2390
+ if (code === AgentCloseCode.REAUTHENTICATE) return {
2391
+ reason: "Kubb Studio asked the agent to register again",
2392
+ retry: true
2393
+ };
2394
+ if (code === AgentCloseCode.SUPERSEDED) return {
2395
+ reason: "another instance of this agent took over",
2396
+ retry: false
2397
+ };
2398
+ if (code === AgentCloseCode.INCOMPATIBLE) return {
2399
+ reason: "this agent is too old for Kubb Studio, or was deleted",
2400
+ retry: false,
2401
+ 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.")
2402
+ };
2403
+ return {
2404
+ reason: "connection closed",
2405
+ retry: true
2406
+ };
2407
+ }
2408
+ /**
2264
2409
  * One agent-to-Studio RPC transport: opening it, keeping it alive, and serving remote methods.
2265
2410
  * `createClient` opens one per pool slot and is the only caller.
2266
2411
  */
@@ -2273,14 +2418,15 @@ var StudioSession = class {
2273
2418
  */
2274
2419
  #unhooks = [];
2275
2420
  /**
2276
- * What `createAgentSession` handed back, and the marker for whether a session exists at all.
2277
- * Before it resolves there is nothing to disconnect and no sandbox flag to read.
2421
+ * What registration handed back, and the marker for whether the agent registered at all.
2422
+ * Before it resolves there is no sandbox flag to read.
2278
2423
  */
2279
- #session;
2424
+ #registration;
2280
2425
  #rpc;
2281
2426
  #studioVersion;
2282
2427
  #disposed = false;
2283
2428
  #isGenerating = false;
2429
+ #activeJob;
2284
2430
  #heartbeatTimer;
2285
2431
  #lastGeneration;
2286
2432
  #store;
@@ -2290,17 +2436,17 @@ var StudioSession = class {
2290
2436
  * host does not queue jobs before the agent session is registered.
2291
2437
  */
2292
2438
  #connectAck = Promise.withResolvers();
2293
- #startupWarning;
2294
- constructor({ startupWarning, ...options }) {
2439
+ #reconnectAttempt;
2440
+ constructor({ reconnectAttempt, ...options }) {
2295
2441
  this.#options = applyStudioDefaults(options);
2296
- this.#startupWarning = startupWarning;
2442
+ this.#reconnectAttempt = reconnectAttempt ?? 0;
2297
2443
  this.#connectAck.promise.catch(() => {});
2298
2444
  }
2299
2445
  /**
2300
2446
  * A sandbox agent runs on Studio's own infrastructure, so it has no user project to touch.
2301
2447
  */
2302
2448
  get #isSandbox() {
2303
- return this.#session?.isSandbox === true;
2449
+ return this.#registration?.isSandbox === true;
2304
2450
  }
2305
2451
  /**
2306
2452
  * Kept in the project's cache directory, so it survives a restart, except on a sandbox: its pool
@@ -2310,7 +2456,8 @@ var StudioSession = class {
2310
2456
  this.#store ??= createGenerationStore({
2311
2457
  storage: this.#isSandbox ? memoryStorage() : cacheStorage({ root: this.#options.root }),
2312
2458
  maxCount: this.#limits.maxCount,
2313
- maxMb: this.#limits.maxMb
2459
+ maxMb: this.#limits.maxMb,
2460
+ ttlMs: this.#isSandbox ? SANDBOX_GENERATION_TTL_MS : void 0
2314
2461
  });
2315
2462
  return this.#store;
2316
2463
  }
@@ -2334,20 +2481,26 @@ var StudioSession = class {
2334
2481
  return this.#isSandbox || this.#options.permissions.allowRead;
2335
2482
  }
2336
2483
  async start() {
2337
- const { token, studioUrl, signal, heartbeatInterval, installLogger } = this.#options;
2484
+ const { token, studioUrl, signal, heartbeatInterval, installLogger, instanceId, capacity } = this.#options;
2338
2485
  await installLogger?.(this.#hooks);
2339
- if (this.#startupWarning) await this.#warn(this.#startupWarning);
2340
2486
  try {
2341
2487
  await this.#hooks.callHook("studio:connecting", { url: studioUrl });
2342
- const session = await createAgentSession({
2488
+ 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`);
2489
+ const registration = await registerAgent({
2343
2490
  token,
2344
- studioUrl
2491
+ studioUrl,
2492
+ instanceId,
2493
+ capacity: {
2494
+ ...capacity,
2495
+ maxConcurrent: Math.min(capacity.maxConcurrent, RUNTIME_MAX_CONCURRENT)
2496
+ }
2345
2497
  });
2346
- this.#session = session;
2347
- this.#studioVersion = session.version;
2498
+ this.#registration = registration;
2499
+ this.#studioVersion = registration.version;
2348
2500
  const rpc = await (this.#options.connector ?? connectWebSocketRpc)({
2349
- url: session.url,
2501
+ url: registration.socketUrl,
2350
2502
  token,
2503
+ instanceId,
2351
2504
  local: this
2352
2505
  });
2353
2506
  this.#rpc = rpc;
@@ -2362,16 +2515,17 @@ var StudioSession = class {
2362
2515
  kubb: version,
2363
2516
  agent: this.#options.version
2364
2517
  },
2365
- agentSlug: session.agentSlug,
2366
- organizationSlug: session.organizationSlug
2518
+ agentSlug: registration.agentSlug,
2519
+ organizationSlug: registration.organizationSlug
2367
2520
  });
2368
2521
  await this.#connectAck.promise;
2522
+ this.#reconnectAttempt = 0;
2369
2523
  await this.#hooks.callHook("studio:ready", {});
2370
2524
  } catch (error) {
2371
2525
  this.#disposed = true;
2372
2526
  this.dispose();
2373
2527
  await this.#hooks.callHook("studio:error", { error: toError(error) });
2374
- if (error instanceof InvalidAgentTokenError) throw error;
2528
+ if (error instanceof InvalidAgentTokenError || error instanceof IncompatibleAgentError) throw error;
2375
2529
  await this.#reconnect();
2376
2530
  }
2377
2531
  }
@@ -2381,8 +2535,10 @@ var StudioSession = class {
2381
2535
  */
2382
2536
  async #reconnect() {
2383
2537
  if (this.#options.signal?.aborted) return;
2384
- await this.#hooks.callHook("studio:reconnecting", { delayMs: this.#options.retryInterval });
2385
- reconnect(this.#options);
2538
+ const attempt = this.#reconnectAttempt + 1;
2539
+ const delayMs = backoffDelayMs(attempt, this.#options.retryInterval);
2540
+ await this.#hooks.callHook("studio:reconnecting", { delayMs });
2541
+ reconnect(this.#options, delayMs, attempt);
2386
2542
  }
2387
2543
  #warn(message, permission) {
2388
2544
  return this.#hooks.callHook("studio:warn", {
@@ -2414,10 +2570,23 @@ var StudioSession = class {
2414
2570
  /**
2415
2571
  * Races `studio.ping()` against a deadline, so a half-open socket can't hang it forever.
2416
2572
  * */
2417
- #ping(rpc) {
2573
+ async #ping(rpc) {
2418
2574
  const { promise: timedOut, reject: onTimeout } = Promise.withResolvers();
2419
2575
  const timer = setTimeout(() => onTimeout(/* @__PURE__ */ new Error("Heartbeat ping timed out")), agentDefaults.heartbeatTimeoutMs);
2420
- return Promise.race([rpc.studio.ping(), timedOut]).finally(() => clearTimeout(timer));
2576
+ try {
2577
+ const load = await Promise.race([this.#load().catch(() => void 0), timedOut]);
2578
+ await Promise.race([rpc.studio.ping(load), timedOut]);
2579
+ } finally {
2580
+ clearTimeout(timer);
2581
+ }
2582
+ }
2583
+ async #load() {
2584
+ return {
2585
+ running: this.#isGenerating ? 1 : 0,
2586
+ rssMb: Math.round(rssMb()),
2587
+ storeBytes: await this.#generations.bytes(),
2588
+ accepting: true
2589
+ };
2421
2590
  }
2422
2591
  /**
2423
2592
  * Reads `kubb.config.ts` and reports which plugin options Studio may edit.
@@ -2462,8 +2631,11 @@ var StudioSession = class {
2462
2631
  this.#connectAck.resolve();
2463
2632
  return payload;
2464
2633
  }
2465
- #onAbort = () => void this.#end({ retry: false });
2466
- #onClose = () => void this.#end({ retry: true });
2634
+ #onAbort = () => void this.#end({
2635
+ reason: "shutdown",
2636
+ retry: false
2637
+ });
2638
+ #onClose = (close) => void this.#end(planEnd(close));
2467
2639
  /**
2468
2640
  * Drops the socket and detaches every listener and timer this session added. Idempotent, and
2469
2641
  * safe before `connect` opened anything.
@@ -2483,17 +2655,12 @@ var StudioSession = class {
2483
2655
  * Ends the session: tells Studio it is over, drops the socket, and optionally reconnects.
2484
2656
  * `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
2485
2657
  */
2486
- async #end({ retry }) {
2487
- const { studioUrl, token } = this.#options;
2658
+ async #end({ reason, retry, error }) {
2488
2659
  if (this.#disposed) return;
2489
2660
  this.#disposed = true;
2490
2661
  this.dispose();
2491
- await this.#hooks.callHook("studio:disconnected", { reason: retry ? "connection closed" : "shutdown" });
2492
- if (this.#session && !await disconnect({
2493
- sessionId: this.#session.sessionId,
2494
- studioUrl,
2495
- token
2496
- })) await this.#warn("Could not notify Kubb Studio of the disconnect");
2662
+ await this.#hooks.callHook("studio:disconnected", { reason });
2663
+ if (error) await this.#hooks.callHook("studio:error", { error });
2497
2664
  if (retry) await this.#reconnect();
2498
2665
  }
2499
2666
  startGeneration(data) {
@@ -2501,27 +2668,42 @@ var StudioSession = class {
2501
2668
  this.#lastGeneration = result;
2502
2669
  } });
2503
2670
  const controller = new AbortController();
2504
- const result = this.#runGeneration(data, controller).then(async (value) => {
2671
+ const cancelRun = async () => {
2672
+ controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
2673
+ };
2674
+ const result = this.#runGeneration(data, controller, cancelRun).then(async (value) => {
2505
2675
  await generationStream.close();
2506
2676
  return value;
2507
2677
  }).catch((error) => {
2508
2678
  generationStream.fail(error);
2509
2679
  throw error;
2680
+ }).finally(() => {
2681
+ if (this.#activeJob?.cancel === cancelRun) this.#activeJob = void 0;
2510
2682
  });
2511
2683
  result.catch(() => {});
2512
- return new GenerationRunTarget(generationStream.stream, result, async () => {
2513
- controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
2514
- }, () => {
2684
+ return new GenerationRunTarget(generationStream.stream, result, cancelRun, () => {
2515
2685
  controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
2516
2686
  generationStream.dispose();
2517
2687
  });
2518
2688
  }
2519
- async #runGeneration(data, controller) {
2689
+ /**
2690
+ * Cancels the currently running job when its id matches `jobId`.
2691
+ */
2692
+ async cancel(jobId) {
2693
+ if (this.#activeJob?.jobId === jobId) await this.#activeJob.cancel();
2694
+ }
2695
+ async #runGeneration(data, controller, cancelRun) {
2520
2696
  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");
2521
2697
  this.#isGenerating = true;
2698
+ this.#activeJob = {
2699
+ jobId: data.jobId,
2700
+ cancel: cancelRun
2701
+ };
2522
2702
  const command = "generate";
2523
- const { root, loadConfig, permissions } = this.#options;
2703
+ const { loadConfig, permissions } = this.#options;
2704
+ let root = this.#options.root;
2524
2705
  try {
2706
+ if (this.#isSandbox) root = await createJobRoot();
2525
2707
  await this.#hooks.callHook("studio:command:start", { command });
2526
2708
  const config = await loadConfig();
2527
2709
  const patch = data.config;
@@ -2532,43 +2714,28 @@ var StudioSession = class {
2532
2714
  if (patch?.input && !this.#canUseInput) await this.#warn("Ignored the spec from Studio: generating from a Studio spec was not granted", "allowInput");
2533
2715
  const resolvedPlugins = plugins ?? config.plugins;
2534
2716
  this.#lastGeneration = void 0;
2535
- const diskFiles = this.#hasProjectOnDisk ? await listDisk({
2536
- root,
2537
- outputPath: config.output.path,
2538
- maxFiles: DISK_SNAPSHOT_MAX_FILES
2539
- }) : void 0;
2540
- const disk = diskFiles ? await this.#generations.keep({
2541
- jobId: data.jobId,
2542
- source: "disk",
2543
- files: diskFiles,
2544
- maxSetMb: this.#limits.maxSnapshotMb
2545
- }) : void 0;
2546
- const detach = [setupHookListener(this.#hooks, root, controller.signal)];
2547
- try {
2548
- await generate({
2549
- config: {
2550
- ...config,
2551
- root,
2552
- input: inputOverride ?? config.input,
2553
- storage: this.#canWrite ? fsStorage() : memoryStorage(),
2554
- output: permissions.allowExec ? { ...config.output } : {
2555
- ...config.output,
2556
- format: false,
2557
- lint: false,
2558
- postGenerate: []
2559
- },
2560
- plugins: resolvedPlugins,
2561
- adapter
2717
+ const disk = await runGenerationOperation({
2718
+ config: {
2719
+ ...config,
2720
+ root,
2721
+ input: inputOverride ?? config.input,
2722
+ storage: this.#canWrite ? fsStorage() : memoryStorage(),
2723
+ output: permissions.allowExec ? { ...config.output } : {
2724
+ ...config.output,
2725
+ format: false,
2726
+ lint: false,
2727
+ postGenerate: []
2562
2728
  },
2563
- hooks: this.#hooks,
2564
- signal: controller.signal
2565
- });
2566
- } catch (error) {
2567
- await this.#generations.drop(data.jobId);
2568
- throw error;
2569
- } finally {
2570
- for (const remove of detach) remove();
2571
- }
2729
+ plugins: resolvedPlugins,
2730
+ adapter
2731
+ },
2732
+ hooks: this.#hooks,
2733
+ signal: controller.signal,
2734
+ jobId: data.jobId,
2735
+ store: this.#generations,
2736
+ snapshotRoot: this.#hasProjectOnDisk ? root : void 0,
2737
+ maxSnapshotMb: this.#limits.maxSnapshotMb
2738
+ });
2572
2739
  await this.#hooks.callHook("studio:command:end", {
2573
2740
  command,
2574
2741
  info: `${resolvedPlugins.length} plugin${resolvedPlugins.length === 1 ? "" : "s"}, ${this.#canWrite ? "written to disk" : "in memory"}${inputOverride !== void 0 ? ", from a Studio spec" : ""}`
@@ -2600,6 +2767,7 @@ var StudioSession = class {
2600
2767
  disk: disk ? { hashes: disk.hashes } : void 0
2601
2768
  };
2602
2769
  } finally {
2770
+ if (root !== this.#options.root) await removeJobRoot(root);
2603
2771
  this.#isGenerating = false;
2604
2772
  }
2605
2773
  }
@@ -2629,8 +2797,10 @@ var StudioSession = class {
2629
2797
  }
2630
2798
  if (this.#isGenerating) return refuse("a generation is in progress");
2631
2799
  try {
2632
- const { source: patched, outcomes, changed } = applyConfigEdits(await read(configFile), edits);
2633
- if (changed) await writeFile(configFile, patched, "utf-8");
2800
+ const { source: patched, outcomes, changed } = await writeConfigEdits({
2801
+ filePath: configFile,
2802
+ edits
2803
+ });
2634
2804
  const applied = outcomes.filter((outcome) => outcome.applied).length;
2635
2805
  await this.#hooks.callHook("studio:command:end", {
2636
2806
  command,
@@ -2668,24 +2838,14 @@ var StudioSession = class {
2668
2838
  version,
2669
2839
  peerDependencies: generation.peerDependencies
2670
2840
  });
2671
- const { token, studioUrl } = this.#options;
2672
- const uploadUrl = new URL(uploadPath, studioUrl);
2673
- if (uploadUrl.origin !== new URL(studioUrl).origin) throw new Error("Snapshot upload path must stay on the Studio origin");
2674
- const redirect = await fetch(uploadUrl, {
2675
- method: "PUT",
2676
- headers: { Authorization: `Bearer ${token}` },
2677
- redirect: "manual"
2678
- });
2679
- const storageUrl = redirect.headers.get("location");
2680
- if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
2681
- const storage = new URL(storageUrl);
2682
- if (storage.protocol !== "https:" && storage.hostname !== "localhost" && storage.hostname !== "127.0.0.1") throw new Error(`Refusing snapshot upload to ${storage.origin}`);
2683
- const response = await fetch(storage, {
2684
- method: "PUT",
2685
- body: new Uint8Array(bytes),
2686
- redirect: "error"
2841
+ const { token, studioUrl, signal } = this.#options;
2842
+ await uploadSnapshot({
2843
+ bytes,
2844
+ uploadPath,
2845
+ studioUrl,
2846
+ token,
2847
+ shutdown: signal
2687
2848
  });
2688
- if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
2689
2849
  await this.#hooks.callHook("studio:command:end", {
2690
2850
  command,
2691
2851
  info: `packed ${Object.keys(files).length} file${Object.keys(files).length === 1 ? "" : "s"}`
@@ -2699,10 +2859,7 @@ var StudioSession = class {
2699
2859
  throw error;
2700
2860
  }
2701
2861
  }
2702
- /**
2703
- * An agent with a project on disk can show a run against what its output directory held before.
2704
- * A sandbox agent has no project.
2705
- */
2862
+ /** A sandbox has no project directory to snapshot. */
2706
2863
  get #hasProjectOnDisk() {
2707
2864
  return !this.#isSandbox && this.#canRead;
2708
2865
  }
@@ -2731,7 +2888,7 @@ var StudioSession = class {
2731
2888
  }
2732
2889
  };
2733
2890
  //#endregion
2734
- //#region src/client.ts
2891
+ //#region src/runtime/client.ts
2735
2892
  /**
2736
2893
  * Creates the Kubb Studio client: the connection, the command loop, and the generation event
2737
2894
  * stream shared by the `kubb studio` CLI command and the Docker agent.
@@ -2747,7 +2904,7 @@ var StudioSession = class {
2747
2904
  */
2748
2905
  function createClient({ onAuthRequired, ...options }) {
2749
2906
  const controller = new AbortController();
2750
- const poolSize = options.poolSize ?? agentDefaults.poolSize;
2907
+ const instanceId = options.instanceId ?? randomUUID();
2751
2908
  function notifyAuthRequired(error) {
2752
2909
  if (controller.signal.aborted) return;
2753
2910
  controller.abort();
@@ -2755,19 +2912,12 @@ function createClient({ onAuthRequired, ...options }) {
2755
2912
  }
2756
2913
  return {
2757
2914
  async connect() {
2758
- const registered = await registerAgent({
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({
2915
+ await new StudioSession({
2766
2916
  ...options,
2917
+ instanceId,
2767
2918
  signal: controller.signal,
2768
- onTokenRejected: notifyAuthRequired,
2769
- startupWarning: slot === 0 ? startupWarning : void 0
2770
- }).start()));
2919
+ onTokenRejected: notifyAuthRequired
2920
+ }).start();
2771
2921
  },
2772
2922
  disconnect() {
2773
2923
  controller.abort();
@@ -2775,7 +2925,7 @@ function createClient({ onAuthRequired, ...options }) {
2775
2925
  };
2776
2926
  }
2777
2927
  //#endregion
2778
- //#region src/runConnection.ts
2928
+ //#region src/runtime/runConnection.ts
2779
2929
  /**
2780
2930
  * Waits for whichever comes first: the shutdown signal, or Studio rejecting the token during a
2781
2931
  * background reconnect. Resolves with the rejection, or nothing when the run is being shut down.
@@ -2848,7 +2998,7 @@ async function runConnection({ credentials, clientOptions, onTokenRejected, sign
2848
2998
  }
2849
2999
  }
2850
3000
  //#endregion
2851
- //#region src/pair.ts
3001
+ //#region src/operations/pair.ts
2852
3002
  /** Labels, not secrets: a person approving the code in the browser is what authorizes a pairing. */
2853
3003
  const CLIENT_IDS = {
2854
3004
  cli: "kubb-cli",
@@ -2971,6 +3121,6 @@ async function pairAgent({ onCode, onRetry, maxAttempts = 1, ...options }) {
2971
3121
  }
2972
3122
  }
2973
3123
  //#endregion
2974
- export { InvalidAgentTokenError, PairingCanceledError, PairingDeniedError, PairingExpiredError, connectWebSocketRpc, createAgent, createClient, createFileStorage, createJob, defaultStudioUrl, generationEventTypes, machineTokenFrom, pairAgent, pollForPairingToken, runConnection, setStorage, startPairing, waitForJob };
3124
+ 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
3125
 
2976
3126
  //# sourceMappingURL=index.js.map