@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.cjs CHANGED
@@ -26,76 +26,32 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
26
  }) : target, mod));
27
27
  //#endregion
28
28
  const require_protocol = require("./protocol.cjs");
29
- let node_process = require("node:process");
30
- node_process = __toESM(node_process, 1);
31
- let node_child_process = require("node:child_process");
29
+ let node_crypto = require("node:crypto");
32
30
  let node_fs_promises = require("node:fs/promises");
31
+ let node_os = require("node:os");
33
32
  let node_path = require("node:path");
34
33
  node_path = __toESM(node_path, 1);
34
+ let node_process = require("node:process");
35
+ node_process = __toESM(node_process, 1);
36
+ let node_child_process = require("node:child_process");
37
+ let _kubb_core = require("@kubb/core");
38
+ let node_timers_promises = require("node:timers/promises");
35
39
  let ofetch = require("ofetch");
36
- let node_crypto = require("node:crypto");
37
40
  let node_util = require("node:util");
38
41
  let unstorage = require("unstorage");
39
42
  let unstorage_drivers_fs = require("unstorage/drivers/fs");
40
43
  unstorage_drivers_fs = __toESM(unstorage_drivers_fs, 1);
41
- let _kubb_core = require("@kubb/core");
42
- let tinyexec = require("tinyexec");
43
44
  let magicast = require("magicast");
44
45
  let node_fs = require("node:fs");
45
46
  let node_module = require("node:module");
46
47
  let node_url = require("node:url");
47
48
  let remeda = require("remeda");
48
- let node_os = require("node:os");
49
49
  let node_zlib = require("node:zlib");
50
50
  let tsdown = require("tsdown");
51
51
  let capnweb = require("capnweb");
52
52
  let ws = require("ws");
53
53
  ws = __toESM(ws, 1);
54
- let node_timers_promises = require("node:timers/promises");
55
- //#region src/constants.ts
56
- /**
57
- * Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
58
- * not whatever default the client would pick on its own.
59
- */
60
- const defaultStudioUrl = "https://kubb.studio";
61
- /**
62
- * Defaults the Studio client uses when a host passes nothing.
63
- * Config path is left out on purpose: each host discovers that itself.
64
- */
65
- const agentDefaults = {
66
- studioUrl: defaultStudioUrl,
67
- retryIntervalMs: 3e4,
68
- heartbeatIntervalMs: 3e4,
69
- /**
70
- * Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
71
- * stored ping is older than its liveness window, and it stores a ping at most once a minute, so
72
- * a slower cadence would make a healthy agent look dead after a single missed ping.
73
- */
74
- maxHeartbeatIntervalMs: 6e4,
75
- /** How long a heartbeat ping may take before the session is treated as dead. */
76
- heartbeatTimeoutMs: 1e4,
77
- poolSize: 1,
78
- maxGenerations: 8,
79
- maxGenerationsMb: 100,
80
- maxSnapshotMb: 50
81
- };
82
- function positiveNumber(value) {
83
- const parsed = Number(value);
84
- return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
85
- }
86
- /**
87
- * How many generations an agent keeps and how large they may get, read from
88
- * `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
89
- * An unset or invalid value keeps the default.
90
- */
91
- function resolveGenerationLimits(env = process.env) {
92
- return {
93
- maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
94
- maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
95
- maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
96
- };
97
- }
98
- //#endregion
54
+ let tinyexec = require("tinyexec");
99
55
  //#region ../../internals/utils/src/casing.ts
100
56
  /**
101
57
  * Shared implementation for camelCase and PascalCase conversion.
@@ -418,7 +374,10 @@ function getElapsedMs(hrStart) {
418
374
  return Math.round(ms * 100) / 100;
419
375
  }
420
376
  //#endregion
421
- //#region src/machine.ts
377
+ //#region package.json
378
+ var version = "5.3.18";
379
+ //#endregion
380
+ //#region src/operations/machine.ts
422
381
  /**
423
382
  * Key-value storage the runtime uses for its machine secret and the last Studio config.
424
383
  *
@@ -478,7 +437,7 @@ async function getMachineToken() {
478
437
  return machineTokenFrom(await fallbackSecretPromise);
479
438
  }
480
439
  //#endregion
481
- //#region src/api.ts
440
+ //#region src/operations/api.ts
482
441
  /**
483
442
  * Reads a human-readable message from a Studio JSON error body, when it has one. `FetchError`'s own
484
443
  * message stops at the status line, so the detail Studio sends with a failure (an agent limit, a
@@ -498,10 +457,6 @@ function responseMessage(data) {
498
457
  */
499
458
  const REGISTER_RETRIES = 3;
500
459
  /**
501
- * Shared in-flight registration so concurrent pool sessions trigger one purge, not N.
502
- */
503
- let registrationInFlight = null;
504
- /**
505
460
  * Thrown when Studio rejects the agent token itself (401). Retrying cannot help: the token was
506
461
  * revoked, or the agent it belonged to was deleted in the Studio UI. Hosts catch this to forget
507
462
  * the stored credential and pair again.
@@ -513,117 +468,87 @@ var InvalidAgentTokenError = class extends Error {
513
468
  }
514
469
  };
515
470
  /**
471
+ * Thrown when Studio refuses this agent's protocol version (426). Retrying cannot help until the
472
+ * agent is upgraded, so hosts stop instead of reconnecting.
473
+ */
474
+ var IncompatibleAgentError = class extends Error {
475
+ constructor(studioUrl, detail, options) {
476
+ super(`Kubb Studio at ${studioUrl} requires a newer agent${detail ? `: ${detail}` : ""}. Upgrade @kubb/studio or the Kubb agent image.`, options);
477
+ this.name = "IncompatibleAgentError";
478
+ }
479
+ };
480
+ /**
516
481
  * Whether a thrown value carries `statusCode`. Not narrowed to `FetchError`: a host wrapper can
517
482
  * throw its own error shape with the same field.
518
- *
519
- * A 401 means the agent token itself was rejected. A 403 from the session create endpoint means
520
- * the machine token stored in Studio no longer matches this agent (missing or mismatched).
521
483
  */
522
484
  function rejectedWith(error, statusCode) {
523
485
  return error?.statusCode === statusCode;
524
486
  }
525
- function sessionError(cause) {
487
+ function registrationError(cause) {
526
488
  const detail = (cause instanceof ofetch.FetchError ? responseMessage(cause.data) : void 0) ?? getErrorMessage(cause);
527
- return new Error(detail ? `Failed to get agent session from Kubb Studio: ${detail}` : "Failed to get agent session from Kubb Studio", { cause });
528
- }
529
- /**
530
- * Performs the raw session create request against Studio.
531
- */
532
- async function requestAgentSession({ token, studioUrl }) {
533
- const url = `${studioUrl}/api/agent/sessions`;
534
- const data = await (0, ofetch.ofetch)(url, {
535
- method: "POST",
536
- headers: { Authorization: `Bearer ${token}` },
537
- body: { machineToken: await getMachineToken() }
538
- });
539
- if (!data) throw new Error("No data available for agent session");
540
- return data;
541
- }
542
- /**
543
- * Obtain an agent session token from Kubb Studio via HTTP.
544
- *
545
- * When Studio rejects the machine token (403), for example after the agent restarted
546
- * with a new identity while the startup registration call failed, the agent re-registers
547
- * and retries once, so a single failed registration can't permanently block session creation.
548
- */
549
- async function createAgentSession({ token, studioUrl }) {
550
- try {
551
- return await requestAgentSession({
552
- token,
553
- studioUrl
554
- });
555
- } catch (error) {
556
- if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
557
- if (!rejectedWith(error, 403) || !await registerAgent({
558
- token,
559
- studioUrl
560
- })) throw sessionError(error);
561
- try {
562
- return await requestAgentSession({
563
- token,
564
- studioUrl
565
- });
566
- } catch (retryError) {
567
- if (rejectedWith(retryError, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: retryError });
568
- throw sessionError(retryError);
569
- }
570
- }
489
+ return new Error(detail ? `Failed to register with Kubb Studio: ${detail}` : "Failed to register with Kubb Studio", { cause });
571
490
  }
572
491
  /**
573
- * Register this agent with Kubb Studio by sending the machine ID.
574
- * Called on agent startup before creating a WebSocket session, and again when
575
- * Studio rejects the machine token during session creation.
492
+ * Registers this agent process with Kubb Studio (`POST /api/agent/connect`): binds the machine
493
+ * identity to the token, reports what the process can take on, and gets back the URL of the one
494
+ * socket it keeps open.
576
495
  *
577
- * Retries with backoff because a failed registration leaves Studio with a stale
578
- * machine token that blocks every subsequent session create call. Registration
579
- * purges all of the agent's sessions on the Studio side, so concurrent callers
580
- * (multiple pool sessions hitting a 403 at once) share one in-flight run instead
581
- * of purging each other's fresh sessions.
582
- */
583
- function registerAgent(props) {
584
- registrationInFlight ??= runRegistration(props).finally(() => {
585
- registrationInFlight = null;
586
- });
587
- return registrationInFlight;
588
- }
589
- async function runRegistration({ token, studioUrl, poolSize }) {
590
- const machineToken = await getMachineToken();
496
+ * Retries a transient failure with backoff. A rejected token (401) throws
497
+ * {@link InvalidAgentTokenError} and an unsupported agent version (426) throws
498
+ * {@link IncompatibleAgentError}, since retrying either cannot help.
499
+ */
500
+ async function registerAgent({ token, studioUrl, instanceId, capacity }) {
501
+ const body = {
502
+ machineToken: await getMachineToken(),
503
+ instanceId,
504
+ capacity
505
+ };
591
506
  try {
592
- await (0, ofetch.ofetch)(`${studioUrl}/api/agent/connect`, {
507
+ return await (0, ofetch.ofetch)(`${studioUrl}/api/agent/connect`, {
593
508
  method: "POST",
594
509
  headers: { Authorization: `Bearer ${token}` },
595
- body: {
596
- machineToken,
597
- poolSize
598
- },
510
+ body,
599
511
  retry: REGISTER_RETRIES,
600
512
  retryDelay: ({ options }) => 2e3 * 2 ** (REGISTER_RETRIES - Number(options.retry))
601
513
  });
602
- return true;
603
514
  } catch (error) {
604
515
  if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
605
- return false;
516
+ if (rejectedWith(error, 426)) throw new IncompatibleAgentError(studioUrl, error instanceof ofetch.FetchError ? responseMessage(error.data) : void 0, { cause: error });
517
+ throw registrationError(error);
606
518
  }
607
519
  }
608
520
  /**
609
- * Notify Kubb Studio that this agent is disconnecting.
610
- * Called on process termination or server close. Never throws: the local socket is already gone,
611
- * and failing teardown must not block shutdown or reconnect.
612
- *
613
- * @returns `false` when Studio could not be reached or rate limited the call. Any other 4xx
614
- * counts as notified, since it means Studio already dropped the session.
521
+ * First wait before `createJob` retries a busy or queue-full response, absent a `Retry-After` hint.
615
522
  */
616
- async function disconnect({ sessionId, token, studioUrl }) {
617
- try {
618
- await (0, ofetch.ofetch)(`${studioUrl}/api/agent/sessions/${sessionId}/disconnect`, {
619
- method: "POST",
620
- headers: { Authorization: `Bearer ${token}` }
621
- });
622
- return true;
623
- } catch (error) {
624
- const statusCode = error?.statusCode;
625
- return statusCode !== void 0 && statusCode !== 429 && statusCode >= 400 && statusCode < 500;
626
- }
523
+ const CREATE_JOB_INITIAL_DELAY_MS = 1e3;
524
+ /**
525
+ * Slowest `createJob` backs off to between retries.
526
+ */
527
+ const CREATE_JOB_MAX_INTERVAL_MS = 1e4;
528
+ /**
529
+ * Statuses worth retrying: the agent has no free connection yet (409, a stale conflict a moment
530
+ * later resolves), its queue is momentarily full (429), or it has no live connection at all yet
531
+ * (503, an agent process that is mid-reconnect). Anything else (404 agent not found, 401/403 auth)
532
+ * is thrown straight away, since retrying cannot change the outcome.
533
+ */
534
+ const CREATE_JOB_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
535
+ 409,
536
+ 429,
537
+ 503
538
+ ]);
539
+ /**
540
+ * Reads Studio's `Retry-After` header (seconds) off a thrown `ofetch` error, when present.
541
+ */
542
+ function retryAfterMs(error) {
543
+ const seconds = Number(error.response?.headers.get("retry-after"));
544
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
545
+ }
546
+ /**
547
+ * Adds up to 30% jitter, so every CI run queued behind the same busy agent does not retry in
548
+ * lockstep.
549
+ */
550
+ function withJitter(ms) {
551
+ return ms + Math.random() * ms * .3;
627
552
  }
628
553
  /**
629
554
  * Queues a generation or snapshot job on Studio (`POST /api/jobs`).
@@ -631,6 +556,10 @@ async function disconnect({ sessionId, token, studioUrl }) {
631
556
  * Returns as soon as Studio accepts the job (`202`). Poll with {@link waitForJob} until it finishes.
632
557
  * Authenticates with the organization CI API key via `x-api-key`.
633
558
  *
559
+ * A busy agent, a full queue, or a momentary lack of a live connection (409, 429, 503) retries with
560
+ * exponential backoff and jitter, honoring Studio's `Retry-After` header when it sends one, up to
561
+ * `timeoutMs`. Every other failure, including a missing agent (404), throws immediately.
562
+ *
634
563
  * @example Snapshot job
635
564
  * ```ts
636
565
  * const job = await createJob({
@@ -644,20 +573,41 @@ async function disconnect({ sessionId, token, studioUrl }) {
644
573
  * const finished = await waitForJob({ studioUrl, token, id: job.id })
645
574
  * ```
646
575
  */
647
- async function createJob({ studioUrl, token, type, agentId, name, version, commit, config }) {
648
- const { job } = await (0, ofetch.ofetch)(`${studioUrl}/api/jobs`, {
649
- method: "POST",
650
- headers: { "x-api-key": token },
651
- body: {
652
- type,
653
- agentId,
654
- name,
655
- version,
656
- commit,
657
- config
576
+ async function createJob({ studioUrl, token, type, agentId, name, version, commit, baseId, config, instanceId, timeoutMs = 6e4, signal }) {
577
+ const deadline = Date.now() + timeoutMs;
578
+ let interval = CREATE_JOB_INITIAL_DELAY_MS;
579
+ for (;;) {
580
+ signal?.throwIfAborted();
581
+ try {
582
+ const { job } = await (0, ofetch.ofetch)(`${studioUrl}/api/jobs`, {
583
+ method: "POST",
584
+ headers: { "x-api-key": token },
585
+ body: {
586
+ type,
587
+ agentId,
588
+ name,
589
+ version,
590
+ commit,
591
+ baseId,
592
+ config,
593
+ instanceId
594
+ },
595
+ retry: false,
596
+ timeout: Math.max(deadline - Date.now(), 1),
597
+ signal
598
+ });
599
+ return job;
600
+ } catch (error) {
601
+ signal?.throwIfAborted();
602
+ const status = error.response?.status;
603
+ if (!status || !CREATE_JOB_RETRYABLE_STATUSES.has(status) || Date.now() >= deadline) throw error;
604
+ const wait = Math.min(retryAfterMs(error) ?? withJitter(interval), Math.max(deadline - Date.now(), 0));
605
+ if (signal) await (0, node_timers_promises.setTimeout)(wait, void 0, { signal });
606
+ else await new Promise((resolve) => setTimeout(resolve, wait));
607
+ if (Date.now() >= deadline) throw error;
608
+ interval = Math.min(interval * 2, CREATE_JOB_MAX_INTERVAL_MS);
658
609
  }
659
- });
660
- return job;
610
+ }
661
611
  }
662
612
  /**
663
613
  * A job runs a generation and packs a tarball, so it is never done the instant it is queued.
@@ -676,20 +626,26 @@ const MAX_POLL_INTERVAL_MS = 3e4;
676
626
  * A `failed` job resolves normally. Check `job.status` and `job.error`. Throws only when the
677
627
  * deadline passes before Studio finishes.
678
628
  */
679
- async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4 }) {
629
+ async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4, signal }) {
680
630
  const deadline = Date.now() + timeoutMs;
681
631
  let interval = INITIAL_POLL_DELAY_MS;
682
632
  for (;;) {
683
- await new Promise((resolve) => setTimeout(resolve, Math.max(Math.min(interval, deadline - Date.now()), 0)));
633
+ signal?.throwIfAborted();
634
+ const wait = Math.max(Math.min(interval, deadline - Date.now()), 0);
635
+ if (signal) await (0, node_timers_promises.setTimeout)(wait, void 0, { signal });
636
+ else await new Promise((resolve) => setTimeout(resolve, wait));
684
637
  if (Date.now() >= deadline) throw new Error("Timed out waiting for the Studio job");
685
638
  interval = Math.min(interval * 2, MAX_POLL_INTERVAL_MS);
686
639
  try {
687
640
  const { job } = await (0, ofetch.ofetch)(`${studioUrl}/api/jobs/${id}`, {
688
641
  headers: { "x-api-key": token },
689
- retry: false
642
+ retry: false,
643
+ timeout: Math.max(deadline - Date.now(), 1),
644
+ signal
690
645
  });
691
646
  if (job.status === "success" || job.status === "failed" || job.status === "canceled") return job;
692
647
  } catch (error) {
648
+ signal?.throwIfAborted();
693
649
  const response = error.response;
694
650
  if (response?.status !== 429) throw error;
695
651
  const retryAfter = response._data?.data?.tryAgainIn;
@@ -723,90 +679,7 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
723
679
  }
724
680
  }
725
681
  //#endregion
726
- //#region package.json
727
- var version = "5.3.16";
728
- //#endregion
729
- //#region src/hooks.ts
730
- /**
731
- * Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
732
- * streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
733
- * Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
734
- *
735
- * Returns a remover, so a session that runs one generation after another on the same emitter does
736
- * not stack a listener per run.
737
- */
738
- function setupHookListener(hooks, root, signal) {
739
- return hooks.hook("kubb:hook:start", async (ctx) => {
740
- const { id, command, args } = ctx;
741
- if (!id) return;
742
- const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
743
- try {
744
- const proc = (0, tinyexec.x)(command, [...args ?? []], {
745
- signal,
746
- nodeOptions: {
747
- cwd: root,
748
- detached: true
749
- }
750
- });
751
- for await (const line of proc) await hooks.callHook("kubb:hook:line", {
752
- id,
753
- line
754
- });
755
- const { exitCode } = await proc;
756
- if (exitCode !== 0) {
757
- const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
758
- await hooks.callHook("kubb:hook:end", {
759
- id,
760
- command,
761
- args,
762
- success: false,
763
- error
764
- });
765
- await hooks.callHook("kubb:error", { error });
766
- return;
767
- }
768
- await hooks.callHook("kubb:hook:end", {
769
- id,
770
- command,
771
- args,
772
- success: true,
773
- error: null
774
- });
775
- } catch (caughtError) {
776
- const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
777
- error.cause = caughtError;
778
- await hooks.callHook("kubb:hook:end", {
779
- id,
780
- command,
781
- args,
782
- success: false,
783
- error
784
- });
785
- await hooks.callHook("kubb:error", { error });
786
- }
787
- });
788
- }
789
- /**
790
- * Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
791
- * `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
792
- * that same listener, so a handler added afterward would already have missed it.
793
- */
794
- function waitForHookEnd(hooks, hookId) {
795
- return new Promise((resolve, reject) => {
796
- const handleHookEnd = (ctx) => {
797
- if (ctx.id !== hookId) return;
798
- hooks.removeHook("kubb:hook:end", handleHookEnd);
799
- if (ctx.success) {
800
- resolve();
801
- return;
802
- }
803
- reject(ctx.error);
804
- };
805
- hooks.hook("kubb:hook:end", handleHookEnd);
806
- });
807
- }
808
- //#endregion
809
- //#region src/resolveConfig.ts
682
+ //#region src/operations/resolveConfig.ts
810
683
  /**
811
684
  * Imports a package, falling back to how the user's project would resolve it.
812
685
  *
@@ -964,7 +837,7 @@ async function mergeAdapter(diskAdapter, studioOptions) {
964
837
  return factory((0, remeda.mergeDeep)(diskAdapter.options ?? {}, studioOptions));
965
838
  }
966
839
  //#endregion
967
- //#region src/configFile.ts
840
+ //#region src/operations/configFile.ts
968
841
  /**
969
842
  * A valid JavaScript identifier, so an import name can only ever print as `import { name } from`,
970
843
  * never as source that breaks out of the import statement.
@@ -1504,6 +1377,11 @@ function applyConfigEdits(source, edits) {
1504
1377
  changed: current !== source
1505
1378
  };
1506
1379
  }
1380
+ async function writeConfigEdits({ filePath, edits }) {
1381
+ const result = applyConfigEdits(await read(filePath), edits);
1382
+ if (result.changed) await (0, node_fs_promises.writeFile)(filePath, result.source, "utf-8");
1383
+ return result;
1384
+ }
1507
1385
  /**
1508
1386
  * The 1-based line where the file's last import declaration ends, or `0` when it has none. Read
1509
1387
  * off the parsed module, so a multi-line `import {\n x,\n} from '...'` reports its closing line
@@ -1536,145 +1414,60 @@ function withTrailingNewline(code, hadTrailingNewline) {
1536
1414
  return `${code}\n`;
1537
1415
  }
1538
1416
  //#endregion
1539
- //#region src/generate.ts
1540
- /**
1541
- * `isToolAvailable` spawns a process, and a long-lived connection generates repeatedly, so each
1542
- * executable is probed once per process. The CLI deliberately does not memoize: a `--watch` build
1543
- * should keep noticing a tool installed mid-session.
1544
- */
1545
- const detectTool = memoize(/* @__PURE__ */ new Map(), detectTool$1);
1417
+ //#region src/operations/constants.ts
1546
1418
  /**
1547
- * The two post-build tool steps. Formatting and linting differ only in which tools they look for,
1548
- * so they run through one loop rather than two near-identical blocks.
1549
- *
1550
- * `noun` and `verbing` are spelled out instead of built from `kind`. Concatenating `` `${kind}ter` ``
1551
- * and `` `${kind}ting` `` works for `format`, but doubles the `t` in `lint`, giving "lintter" and
1552
- * "lintting" instead of "linter" and "linting".
1419
+ * Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
1420
+ * not whatever default the client would pick on its own.
1553
1421
  */
1554
- const TOOL_STEPS = [{
1555
- kind: "format",
1556
- noun: "formatter",
1557
- verbing: "Formatting",
1558
- tools: formatters,
1559
- detect: FORMATTER_PREFERENCE
1560
- }, {
1561
- kind: "lint",
1562
- noun: "linter",
1563
- verbing: "Linting",
1564
- tools: linters,
1565
- detect: LINTER_PREFERENCE
1566
- }];
1422
+ const defaultStudioUrl = "https://kubb.studio";
1567
1423
  /**
1568
- * Absolute path of the directory the formatter and linter are pointed at.
1424
+ * Defaults the Studio client uses when a host passes nothing.
1425
+ * Config path is left out on purpose: each host discovers that itself.
1569
1426
  */
1570
- function outputPath(config) {
1571
- return node_path.default.isAbsolute(config.output.path) ? config.output.path : node_path.default.resolve(node_process.default.cwd(), config.root, config.output.path);
1427
+ const agentDefaults = {
1428
+ studioUrl: defaultStudioUrl,
1429
+ retryIntervalMs: 3e4,
1430
+ heartbeatIntervalMs: 3e4,
1431
+ /**
1432
+ * Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
1433
+ * stored ping is older than its liveness window, and it stores a ping at most once a minute, so
1434
+ * a slower cadence would make a healthy agent look dead after a single missed ping.
1435
+ */
1436
+ maxHeartbeatIntervalMs: 6e4,
1437
+ /** How long a heartbeat ping may take before the session is treated as dead. */
1438
+ heartbeatTimeoutMs: 1e4,
1439
+ maxConcurrent: 1,
1440
+ maxGenerations: 8,
1441
+ maxGenerationsMb: 100,
1442
+ maxSnapshotMb: 50
1443
+ };
1444
+ function positiveNumber(value) {
1445
+ const parsed = Number(value);
1446
+ return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1572
1447
  }
1573
1448
  /**
1574
- * Emits `kubb:hook:start` and waits for the matching `kubb:hook:end`. The host spawns the process:
1575
- * this only describes what to run and when it finished.
1576
- *
1577
- * @throws whatever the command failed with, so callers can report it their own way.
1578
- */
1579
- async function runHook({ hooks, id, command, args }) {
1580
- const hookId = (0, node_crypto.hash)("sha256", id);
1581
- const hookEnd = waitForHookEnd(hooks, hookId);
1582
- await hooks.callHook("kubb:hook:start", {
1583
- id: hookId,
1584
- command,
1585
- args: [...args]
1586
- });
1587
- await hookEnd;
1588
- }
1589
- function isProblemErrorDiagnostic(diagnostic) {
1590
- return (diagnostic.kind ?? "problem") === "problem" && diagnostic.severity === "error";
1591
- }
1592
- /**
1593
- * Folds error-severity diagnostics into one thrown error so logs name the failing plugin.
1449
+ * How many generations an agent keeps and how large they may get, read from
1450
+ * `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
1451
+ * An unset or invalid value keeps the default.
1594
1452
  */
1595
- function formatGenerationFailure(diagnostics) {
1596
- const reasons = diagnostics.filter(isProblemErrorDiagnostic).map((diagnostic) => diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message);
1597
- if (!reasons.length) return /* @__PURE__ */ new Error("Generation failed");
1598
- return /* @__PURE__ */ new Error(`Generation failed: ${reasons.length} error${reasons.length === 1 ? "" : "s"}: ${reasons.join("; ")}`);
1453
+ function resolveGenerationLimits(env = process.env) {
1454
+ return {
1455
+ maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
1456
+ maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
1457
+ maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
1458
+ };
1599
1459
  }
1600
1460
  /**
1601
- * Runs a full Kubb code-generation cycle for the given config.
1602
- *
1603
- * Emits lifecycle events on the provided `hooks` emitter so callers (e.g. the WebSocket stream)
1604
- * can forward progress to connected clients. After a successful build, auto-formatting and
1605
- * linting are applied when configured, followed by any user-defined `hooks.done` commands.
1461
+ * An agent's capacity read from `KUBB_AGENT_MAX_CONCURRENT`. An unset or invalid value keeps the
1462
+ * default: one job at a time.
1606
1463
  */
1607
- async function generate({ config, hooks, signal }) {
1608
- signal?.throwIfAborted();
1609
- const hrStart = node_process.default.hrtime();
1610
- await hooks.callHook("kubb:generation:start", { config });
1611
- await hooks.callHook("kubb:info", { message: config.name ? `Setup generation ${config.name}` : "Setup generation" });
1612
- const kubb = (0, _kubb_core.createKubb)(config, {
1613
- hooks,
1614
- signal
1615
- });
1616
- await kubb.setup();
1617
- await hooks.callHook("kubb:info", { message: config.name ? `Build generation ${config.name}` : "Build generation" });
1618
- const { files, diagnostics, storage } = await kubb.safeBuild();
1619
- signal?.throwIfAborted();
1620
- await hooks.callHook("kubb:info", { message: "Load summary" });
1621
- for (const diagnostic of diagnostics.filter(isProblemErrorDiagnostic)) await hooks.callHook("kubb:error", { error: new Error(diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message) });
1622
- const status = _kubb_core.Diagnostics.hasError(diagnostics) ? "failed" : "success";
1623
- await hooks.callHook("kubb:generation:end", {
1624
- config,
1625
- storage: {
1626
- ...storage,
1627
- readKeys: async () => [...new Set(files.map((file) => file.path))]
1628
- },
1629
- diagnostics,
1630
- status,
1631
- hrStart,
1632
- filesCreated: files.length
1633
- });
1634
- if (status === "failed") throw formatGenerationFailure(diagnostics);
1635
- await hooks.callHook("kubb:success", { message: "Generation successfully" });
1636
- for (const step of TOOL_STEPS) {
1637
- const setting = config.output[step.kind];
1638
- if (!setting) continue;
1639
- await hooks.callHook(`kubb:${step.kind}:start`);
1640
- const tool = setting === "auto" ? await detectTool(step.detect) : setting;
1641
- if (!tool) await hooks.callHook("kubb:warn", { message: `No ${step.noun} found (${step.detect.join(", ")}). Skipping ${step.verbing.toLowerCase()}.` });
1642
- if (tool && setting === "auto") await hooks.callHook("kubb:info", { message: `Auto-detected ${step.noun}: ${(0, node_util.styleText)("dim", tool)}` });
1643
- const command = tool ? step.tools[tool] : void 0;
1644
- if (command) try {
1645
- await runHook({
1646
- hooks,
1647
- id: [config.name, tool].filter(Boolean).join("-"),
1648
- command: command.command,
1649
- args: command.args(outputPath(config))
1650
- });
1651
- await hooks.callHook("kubb:success", { message: `${step.verbing} with ${tool} successfully` });
1652
- } catch (caughtError) {
1653
- await hooks.callHook("kubb:error", { error: new Error(command.errorMessage, { cause: caughtError }) });
1654
- signal?.throwIfAborted();
1655
- }
1656
- await hooks.callHook(`kubb:${step.kind}:end`);
1657
- }
1658
- if (config.output.postGenerate?.length) {
1659
- await hooks.callHook("kubb:hooks:start");
1660
- for (const entry of config.output.postGenerate) {
1661
- const line = typeof entry === "string" ? entry : entry.command;
1662
- const [cmd, ...args] = tokenize(line);
1663
- if (!cmd) continue;
1664
- await runHook({
1665
- hooks,
1666
- id: line,
1667
- command: cmd,
1668
- args
1669
- });
1670
- await hooks.callHook("kubb:success", { message: `${line} successfully executed` });
1671
- }
1672
- await hooks.callHook("kubb:hooks:end");
1673
- }
1464
+ function resolveAgentCapacity(env = process.env) {
1465
+ return { maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)) };
1674
1466
  }
1675
1467
  //#endregion
1676
- //#region src/snapshotPackage.ts
1468
+ //#region src/operations/snapshotPackage.ts
1677
1469
  const gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
1470
+ const UPLOAD_TIMEOUT_MS = 12e4;
1678
1471
  /**
1679
1472
  * Maps a generated file's path to its place inside the tarball, stripping everything before a
1680
1473
  * `src`/`dist` segment and any `..`/empty path segment so a crafted file name cannot escape the
@@ -1812,10 +1605,33 @@ async function createSnapshotPackage(files, packageInfo) {
1812
1605
  });
1813
1606
  }
1814
1607
  }
1608
+ async function uploadSnapshot({ bytes, uploadPath, studioUrl, token, shutdown }) {
1609
+ const uploadUrl = new URL(uploadPath, studioUrl);
1610
+ if (uploadUrl.origin !== new URL(studioUrl).origin) throw new Error("Snapshot upload path must stay on the Studio origin");
1611
+ const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS);
1612
+ const signal = shutdown ? AbortSignal.any([shutdown, timeout]) : timeout;
1613
+ const redirect = await fetch(uploadUrl, {
1614
+ method: "PUT",
1615
+ headers: { Authorization: `Bearer ${token}` },
1616
+ redirect: "manual",
1617
+ signal
1618
+ });
1619
+ const storageUrl = redirect.headers.get("location");
1620
+ if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
1621
+ const storage = new URL(storageUrl);
1622
+ if (storage.protocol !== "https:" && storage.hostname !== "localhost" && storage.hostname !== "127.0.0.1") throw new Error(`Refusing snapshot upload to ${storage.origin}`);
1623
+ const response = await fetch(storage, {
1624
+ method: "PUT",
1625
+ body: new Uint8Array(bytes),
1626
+ redirect: "error",
1627
+ signal
1628
+ });
1629
+ if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
1630
+ }
1815
1631
  //#endregion
1816
- //#region src/generations.ts
1632
+ //#region src/operations/generations.ts
1817
1633
  const READ_CONCURRENCY = 50;
1818
- const MB = 1048576;
1634
+ const MB$1 = 1048576;
1819
1635
  const INDEX_KEY = "studio/generations.json";
1820
1636
  const hashOf = (content) => (0, node_crypto.createHash)("sha1").update(content).digest("hex").slice(0, 16);
1821
1637
  /**
@@ -1842,8 +1658,9 @@ async function listDisk({ root, outputPath, maxFiles }) {
1842
1658
  * tenant never reaches another's output. The oldest go past `maxCount` or `maxMb`, but the newest
1843
1659
  * always stays.
1844
1660
  */
1845
- function createGenerationStore({ storage, maxCount, maxMb }) {
1661
+ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now }) {
1846
1662
  let index;
1663
+ const isLive = (generation) => ttlMs === void 0 || generation.keptAt === void 0 || now() - generation.keptAt < ttlMs;
1847
1664
  const dirOf = (jobId) => `studio/generations/${hashOf(jobId)}/`;
1848
1665
  async function load() {
1849
1666
  if (index) return index;
@@ -1859,7 +1676,7 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
1859
1676
  * Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
1860
1677
  */
1861
1678
  async function keep({ jobId, source, files, maxSetMb }) {
1862
- const maxSetBytes = maxSetMb * MB;
1679
+ const maxSetBytes = maxSetMb * MB$1;
1863
1680
  const hashes = {};
1864
1681
  let bytes = 0;
1865
1682
  await inParallel({
@@ -1894,13 +1711,20 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
1894
1711
  return {
1895
1712
  keep,
1896
1713
  drop,
1897
- get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId),
1898
- latest: async () => (await load()).at(-1),
1714
+ get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId && isLive(generation)),
1715
+ latest: async () => (await load()).filter(isLive).at(-1),
1716
+ /** Total bytes of every set the store holds, expired ones too until the next add drops them. */
1717
+ bytes: async () => (await load()).reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0),
1899
1718
  async add(generation) {
1900
- const entries = (await load()).filter((entry) => entry.jobId !== generation.jobId);
1901
- entries.push(generation);
1719
+ const current = await load();
1720
+ for (const expired of current.filter((entry) => !isLive(entry))) await drop(expired.jobId);
1721
+ const entries = current.filter((entry) => entry.jobId !== generation.jobId && isLive(entry));
1722
+ entries.push({
1723
+ ...generation,
1724
+ keptAt: now()
1725
+ });
1902
1726
  const weight = () => entries.reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0);
1903
- while (entries.length > 1 && (entries.length > maxCount || weight() > maxMb * MB)) await drop(entries.shift().jobId);
1727
+ while (entries.length > 1 && (entries.length > maxCount || weight() > maxMb * MB$1)) await drop(entries.shift().jobId);
1904
1728
  index = entries;
1905
1729
  await storage.writeItem(INDEX_KEY, JSON.stringify(entries));
1906
1730
  },
@@ -1923,12 +1747,7 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
1923
1747
  };
1924
1748
  }
1925
1749
  //#endregion
1926
- //#region src/ws.ts
1927
- /**
1928
- * How long the initial handshake may take before the socket is closed and the reconnect loop
1929
- * takes over.
1930
- */
1931
- const CONNECT_TIMEOUT_MS = 5e3;
1750
+ //#region src/operations/generationEvents.ts
1932
1751
  const require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
1933
1752
  function relativeStoragePath(root, filePath) {
1934
1753
  return ((0, node_path.isAbsolute)(filePath) ? (0, node_path.relative)((0, node_path.resolve)(root), filePath) : filePath).replaceAll("\\", "/");
@@ -1958,27 +1777,24 @@ async function resolvePeerDependencies(names) {
1958
1777
  missingDependencies
1959
1778
  };
1960
1779
  }
1961
- /**
1962
- * Opens a Studio WebSocket connection and closes it when the initial handshake exceeds the configured timeout.
1963
- */
1964
- function createWebsocket(url, options) {
1965
- const ws$1 = new ws.default(url, options);
1966
- const timer = setTimeout(() => {
1967
- if (ws$1.readyState === ws.default.CONNECTING) ws$1.close(3008, "Connection timeout");
1968
- }, CONNECT_TIMEOUT_MS);
1969
- ws$1.once("open", () => clearTimeout(timer));
1970
- ws$1.once("close", () => clearTimeout(timer));
1971
- return ws$1;
1972
- }
1780
+ const MAX_QUEUED_EVENTS = 1024;
1781
+ const RESERVED_EVENTS = 64;
1782
+ const isDiscardable = (event) => event.type === "kubb:files:processing:update" || event.type === "kubb:info" || event.type === "kubb:success";
1973
1783
  /** Forwards selected Kubb lifecycle events to a native Cap'n Web stream. */
1974
1784
  function createGenerationStream(hooks, jobId, options = {}) {
1975
1785
  const unhooks = [];
1976
1786
  let root = "";
1977
- const transform = new TransformStream(void 0, void 0, { highWaterMark: Infinity });
1978
- const writer = transform.writable.getWriter();
1979
- let writes = Promise.resolve();
1787
+ let controller;
1980
1788
  let closed = false;
1981
- let streamError;
1789
+ const stream = new ReadableStream({
1790
+ start: (value) => {
1791
+ controller = value;
1792
+ },
1793
+ cancel: () => {
1794
+ closed = true;
1795
+ detach();
1796
+ }
1797
+ }, { highWaterMark: MAX_QUEUED_EVENTS });
1982
1798
  /**
1983
1799
  * Registers a listener and keeps its remover, so one generation's listeners come off the session
1984
1800
  * emitter again when that generation ends.
@@ -1987,6 +1803,7 @@ function createGenerationStream(hooks, jobId, options = {}) {
1987
1803
  unhooks.push(hooks.hook(name, handler));
1988
1804
  }
1989
1805
  function emitEvent(type, data) {
1806
+ if (closed) return;
1990
1807
  const event = {
1991
1808
  jobId,
1992
1809
  type,
@@ -1994,9 +1811,12 @@ function createGenerationStream(hooks, jobId, options = {}) {
1994
1811
  version: 1,
1995
1812
  timestamp: Date.now()
1996
1813
  };
1997
- writes = writes.then(() => writer.write(event)).catch((error) => {
1998
- streamError = error;
1999
- });
1814
+ if (controller.desiredSize <= RESERVED_EVENTS && isDiscardable(event)) return;
1815
+ if (controller.desiredSize <= 0) {
1816
+ fail(/* @__PURE__ */ new Error(`Generation event stream exceeded ${MAX_QUEUED_EVENTS} queued events`));
1817
+ return;
1818
+ }
1819
+ controller.enqueue(event);
2000
1820
  }
2001
1821
  on("kubb:plugin:start", (ctx) => {
2002
1822
  emitEvent("kubb:plugin:start", [{ plugin: { name: ctx.plugin.name } }]);
@@ -2142,25 +1962,38 @@ function createGenerationStream(hooks, jobId, options = {}) {
2142
1962
  if (closed) return;
2143
1963
  closed = true;
2144
1964
  detach();
2145
- await writes;
2146
- if (streamError) return;
2147
- await writer.close().catch(() => void 0);
1965
+ controller.close();
2148
1966
  }
2149
1967
  function fail(error) {
2150
1968
  detach();
2151
1969
  if (closed) return;
2152
1970
  closed = true;
2153
- writer.abort(error).catch(() => void 0);
1971
+ controller.error(error);
2154
1972
  }
2155
1973
  return {
2156
- stream: transform.readable,
1974
+ stream,
2157
1975
  close,
2158
1976
  dispose: () => fail(),
2159
1977
  fail
2160
1978
  };
2161
1979
  }
2162
1980
  //#endregion
2163
- //#region src/rpc.ts
1981
+ //#region src/operations/websocket.ts
1982
+ const CONNECT_TIMEOUT_MS = 5e3;
1983
+ /**
1984
+ * Opens a Studio WebSocket connection and closes it when the initial handshake exceeds the configured timeout.
1985
+ */
1986
+ function createWebsocket(url, options) {
1987
+ const ws$1 = new ws.default(url, options);
1988
+ const timer = setTimeout(() => {
1989
+ if (ws$1.readyState === ws.default.CONNECTING) ws$1.close(3008, "Connection timeout");
1990
+ }, CONNECT_TIMEOUT_MS);
1991
+ ws$1.once("open", () => clearTimeout(timer));
1992
+ ws$1.once("close", () => clearTimeout(timer));
1993
+ return ws$1;
1994
+ }
1995
+ //#endregion
1996
+ //#region src/operations/rpc.ts
2164
1997
  /**
2165
1998
  * The only methods Studio may call on an agent. A `StudioSession` carries far more than
2166
1999
  * {@link AgentApi}, so it is wrapped rather than exposed: what Cap'n Web can reach is exactly what
@@ -2187,6 +2020,9 @@ var AgentRpcTarget = class extends capnweb.RpcTarget {
2187
2020
  readFiles(input) {
2188
2021
  return this.api.readFiles(input);
2189
2022
  }
2023
+ cancel(jobId) {
2024
+ return this.api.cancel(jobId);
2025
+ }
2190
2026
  };
2191
2027
  /**
2192
2028
  * Opens an authenticated Cap'n Web session to Studio over a WebSocket. Rejects an unencrypted URL
@@ -2198,11 +2034,17 @@ var AgentRpcTarget = class extends capnweb.RpcTarget {
2198
2034
  * await rpc.studio.ping()
2199
2035
  * ```
2200
2036
  */
2201
- const connectWebSocketRpc = async ({ url, token, local }) => {
2037
+ const connectWebSocketRpc = async ({ url, token, instanceId, local }) => {
2202
2038
  const { protocol, hostname, host } = new URL(url);
2203
2039
  if (protocol !== "wss:" && !(protocol === "ws:" && (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"))) throw new Error(`Refusing unencrypted WebSocket to ${host}`);
2204
- const socket = createWebsocket(url, { headers: { Authorization: `Bearer ${token}` } });
2205
- const closed = new Promise((resolve) => socket.once("close", resolve));
2040
+ const socket = createWebsocket(url, { headers: {
2041
+ Authorization: `Bearer ${token}`,
2042
+ [require_protocol.AGENT_INSTANCE_HEADER]: instanceId
2043
+ } });
2044
+ const closed = new Promise((resolve) => socket.once("close", (code, reason) => resolve({
2045
+ code,
2046
+ reason: reason.toString()
2047
+ })));
2206
2048
  const studio = (0, capnweb.newWebSocketRpcSession)(socket, new AgentRpcTarget(local));
2207
2049
  studio.onRpcBroken(() => socket.close());
2208
2050
  return {
@@ -2212,11 +2054,274 @@ const connectWebSocketRpc = async ({ url, token, local }) => {
2212
2054
  };
2213
2055
  };
2214
2056
  //#endregion
2215
- //#region src/StudioSession.ts
2057
+ //#region src/operations/hooks.ts
2216
2058
  /**
2217
- * Past this many files in the output directory, no snapshot of it is taken before a run.
2059
+ * Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
2060
+ * streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
2061
+ * Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
2062
+ *
2063
+ * Returns a remover, so a session that runs one generation after another on the same emitter does
2064
+ * not stack a listener per run.
2065
+ */
2066
+ function setupHookListener(hooks, root, signal) {
2067
+ return hooks.hook("kubb:hook:start", async (ctx) => {
2068
+ const { id, command, args } = ctx;
2069
+ if (!id) return;
2070
+ const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
2071
+ try {
2072
+ const proc = (0, tinyexec.x)(command, [...args ?? []], {
2073
+ signal,
2074
+ nodeOptions: {
2075
+ cwd: root,
2076
+ detached: true
2077
+ }
2078
+ });
2079
+ for await (const line of proc) await hooks.callHook("kubb:hook:line", {
2080
+ id,
2081
+ line
2082
+ });
2083
+ const { exitCode } = await proc;
2084
+ if (exitCode !== 0) {
2085
+ const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
2086
+ await hooks.callHook("kubb:hook:end", {
2087
+ id,
2088
+ command,
2089
+ args,
2090
+ success: false,
2091
+ error
2092
+ });
2093
+ await hooks.callHook("kubb:error", { error });
2094
+ return;
2095
+ }
2096
+ await hooks.callHook("kubb:hook:end", {
2097
+ id,
2098
+ command,
2099
+ args,
2100
+ success: true,
2101
+ error: null
2102
+ });
2103
+ } catch (caughtError) {
2104
+ const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
2105
+ error.cause = caughtError;
2106
+ await hooks.callHook("kubb:hook:end", {
2107
+ id,
2108
+ command,
2109
+ args,
2110
+ success: false,
2111
+ error
2112
+ });
2113
+ await hooks.callHook("kubb:error", { error });
2114
+ }
2115
+ });
2116
+ }
2117
+ /**
2118
+ * Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
2119
+ * `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
2120
+ * that same listener, so a handler added afterward would already have missed it.
2218
2121
  */
2122
+ function waitForHookEnd(hooks, hookId) {
2123
+ return new Promise((resolve, reject) => {
2124
+ const handleHookEnd = (ctx) => {
2125
+ if (ctx.id !== hookId) return;
2126
+ hooks.removeHook("kubb:hook:end", handleHookEnd);
2127
+ if (ctx.success) {
2128
+ resolve();
2129
+ return;
2130
+ }
2131
+ reject(ctx.error);
2132
+ };
2133
+ hooks.hook("kubb:hook:end", handleHookEnd);
2134
+ });
2135
+ }
2136
+ //#endregion
2137
+ //#region src/operations/generate.ts
2219
2138
  const DISK_SNAPSHOT_MAX_FILES = 1e4;
2139
+ /**
2140
+ * `isToolAvailable` spawns a process, and a long-lived connection generates repeatedly, so each
2141
+ * executable is probed once per process. The CLI deliberately does not memoize: a `--watch` build
2142
+ * should keep noticing a tool installed mid-session.
2143
+ */
2144
+ const detectTool = memoize(/* @__PURE__ */ new Map(), detectTool$1);
2145
+ /**
2146
+ * The two post-build tool steps. Formatting and linting differ only in which tools they look for,
2147
+ * so they run through one loop rather than two near-identical blocks.
2148
+ *
2149
+ * `noun` and `verbing` are spelled out instead of built from `kind`. Concatenating `` `${kind}ter` ``
2150
+ * and `` `${kind}ting` `` works for `format`, but doubles the `t` in `lint`, giving "lintter" and
2151
+ * "lintting" instead of "linter" and "linting".
2152
+ */
2153
+ const TOOL_STEPS = [{
2154
+ kind: "format",
2155
+ noun: "formatter",
2156
+ verbing: "Formatting",
2157
+ tools: formatters,
2158
+ detect: FORMATTER_PREFERENCE
2159
+ }, {
2160
+ kind: "lint",
2161
+ noun: "linter",
2162
+ verbing: "Linting",
2163
+ tools: linters,
2164
+ detect: LINTER_PREFERENCE
2165
+ }];
2166
+ /**
2167
+ * Absolute path of the directory the formatter and linter are pointed at.
2168
+ */
2169
+ function outputPath(config) {
2170
+ return node_path.default.isAbsolute(config.output.path) ? config.output.path : node_path.default.resolve(node_process.default.cwd(), config.root, config.output.path);
2171
+ }
2172
+ /**
2173
+ * Emits `kubb:hook:start` and waits for the matching `kubb:hook:end`. The host spawns the process:
2174
+ * this only describes what to run and when it finished.
2175
+ *
2176
+ * @throws whatever the command failed with, so callers can report it their own way.
2177
+ */
2178
+ async function runHook({ hooks, id, command, args }) {
2179
+ const hookId = (0, node_crypto.hash)("sha256", id);
2180
+ const hookEnd = waitForHookEnd(hooks, hookId);
2181
+ await hooks.callHook("kubb:hook:start", {
2182
+ id: hookId,
2183
+ command,
2184
+ args: [...args]
2185
+ });
2186
+ await hookEnd;
2187
+ }
2188
+ function isProblemErrorDiagnostic(diagnostic) {
2189
+ return (diagnostic.kind ?? "problem") === "problem" && diagnostic.severity === "error";
2190
+ }
2191
+ /**
2192
+ * Folds error-severity diagnostics into one thrown error so logs name the failing plugin.
2193
+ */
2194
+ function formatGenerationFailure(diagnostics) {
2195
+ const reasons = diagnostics.filter(isProblemErrorDiagnostic).map((diagnostic) => diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message);
2196
+ if (!reasons.length) return /* @__PURE__ */ new Error("Generation failed");
2197
+ return /* @__PURE__ */ new Error(`Generation failed: ${reasons.length} error${reasons.length === 1 ? "" : "s"}: ${reasons.join("; ")}`);
2198
+ }
2199
+ /**
2200
+ * Runs a full Kubb code-generation cycle for the given config.
2201
+ *
2202
+ * Emits lifecycle events on the provided `hooks` emitter so callers (e.g. the WebSocket stream)
2203
+ * can forward progress to connected clients. After a successful build, auto-formatting and
2204
+ * linting are applied when configured, followed by any user-defined `hooks.done` commands.
2205
+ */
2206
+ async function generate({ config, hooks, signal }) {
2207
+ signal?.throwIfAborted();
2208
+ const hrStart = node_process.default.hrtime();
2209
+ await hooks.callHook("kubb:generation:start", { config });
2210
+ await hooks.callHook("kubb:info", { message: config.name ? `Setup generation ${config.name}` : "Setup generation" });
2211
+ const kubb = (0, _kubb_core.createKubb)(config, {
2212
+ hooks,
2213
+ signal
2214
+ });
2215
+ await kubb.setup();
2216
+ await hooks.callHook("kubb:info", { message: config.name ? `Build generation ${config.name}` : "Build generation" });
2217
+ const { files, diagnostics, storage } = await kubb.safeBuild();
2218
+ signal?.throwIfAborted();
2219
+ await hooks.callHook("kubb:info", { message: "Load summary" });
2220
+ for (const diagnostic of diagnostics.filter(isProblemErrorDiagnostic)) await hooks.callHook("kubb:error", { error: new Error(diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message) });
2221
+ const status = _kubb_core.Diagnostics.hasError(diagnostics) ? "failed" : "success";
2222
+ await hooks.callHook("kubb:generation:end", {
2223
+ config,
2224
+ storage: {
2225
+ ...storage,
2226
+ readKeys: async () => [...new Set(files.map((file) => file.path))]
2227
+ },
2228
+ diagnostics,
2229
+ status,
2230
+ hrStart,
2231
+ filesCreated: files.length
2232
+ });
2233
+ if (status === "failed") throw formatGenerationFailure(diagnostics);
2234
+ await hooks.callHook("kubb:success", { message: "Generation successfully" });
2235
+ for (const step of TOOL_STEPS) {
2236
+ const setting = config.output[step.kind];
2237
+ if (!setting) continue;
2238
+ await hooks.callHook(`kubb:${step.kind}:start`);
2239
+ const tool = setting === "auto" ? await detectTool(step.detect) : setting;
2240
+ if (!tool) await hooks.callHook("kubb:warn", { message: `No ${step.noun} found (${step.detect.join(", ")}). Skipping ${step.verbing.toLowerCase()}.` });
2241
+ if (tool && setting === "auto") await hooks.callHook("kubb:info", { message: `Auto-detected ${step.noun}: ${(0, node_util.styleText)("dim", tool)}` });
2242
+ const command = tool ? step.tools[tool] : void 0;
2243
+ if (command) try {
2244
+ await runHook({
2245
+ hooks,
2246
+ id: [config.name, tool].filter(Boolean).join("-"),
2247
+ command: command.command,
2248
+ args: command.args(outputPath(config))
2249
+ });
2250
+ await hooks.callHook("kubb:success", { message: `${step.verbing} with ${tool} successfully` });
2251
+ } catch (caughtError) {
2252
+ await hooks.callHook("kubb:error", { error: new Error(command.errorMessage, { cause: caughtError }) });
2253
+ signal?.throwIfAborted();
2254
+ }
2255
+ await hooks.callHook(`kubb:${step.kind}:end`);
2256
+ }
2257
+ if (config.output.postGenerate?.length) {
2258
+ await hooks.callHook("kubb:hooks:start");
2259
+ for (const entry of config.output.postGenerate) {
2260
+ const line = typeof entry === "string" ? entry : entry.command;
2261
+ const [cmd, ...args] = tokenize(line);
2262
+ if (!cmd) continue;
2263
+ await runHook({
2264
+ hooks,
2265
+ id: line,
2266
+ command: cmd,
2267
+ args
2268
+ });
2269
+ await hooks.callHook("kubb:success", { message: `${line} successfully executed` });
2270
+ }
2271
+ await hooks.callHook("kubb:hooks:end");
2272
+ }
2273
+ }
2274
+ async function runGenerationOperation({ config, hooks, signal, jobId, store, snapshotRoot, maxSnapshotMb }) {
2275
+ const diskFiles = snapshotRoot ? await listDisk({
2276
+ root: snapshotRoot,
2277
+ outputPath: config.output.path,
2278
+ maxFiles: DISK_SNAPSHOT_MAX_FILES
2279
+ }) : void 0;
2280
+ const disk = diskFiles ? await store.keep({
2281
+ jobId,
2282
+ source: "disk",
2283
+ files: diskFiles,
2284
+ maxSetMb: maxSnapshotMb
2285
+ }) : void 0;
2286
+ const removeHookListener = setupHookListener(hooks, config.root, signal);
2287
+ try {
2288
+ await generate({
2289
+ config,
2290
+ hooks,
2291
+ signal
2292
+ });
2293
+ } catch (error) {
2294
+ await store.drop(jobId);
2295
+ throw error;
2296
+ } finally {
2297
+ removeHookListener();
2298
+ }
2299
+ return disk;
2300
+ }
2301
+ //#endregion
2302
+ //#region src/runtime/StudioSession.ts
2303
+ /** A sandbox shares one in-memory generation store across tenants, so old generations expire. */
2304
+ const SANDBOX_GENERATION_TTL_MS = 9e5;
2305
+ /**
2306
+ * A fresh root for one sandbox job. Kubb keys its output manifest cache by root, so tenants that
2307
+ * shared the agent's own root would read each other's manifest.
2308
+ */
2309
+ function createJobRoot() {
2310
+ return (0, node_fs_promises.mkdtemp)(node_path.default.join((0, node_os.tmpdir)(), "kubb-job-"));
2311
+ }
2312
+ /**
2313
+ * Removes a job root and the manifest cache Kubb derived from it. Best effort: a leftover temp
2314
+ * directory must not fail a job that already finished.
2315
+ */
2316
+ async function removeJobRoot(jobRoot) {
2317
+ await Promise.all([(0, node_fs_promises.rm)(jobRoot, {
2318
+ recursive: true,
2319
+ force: true
2320
+ }), (0, node_fs_promises.rm)((0, _kubb_core.resolveCacheDir)(jobRoot), {
2321
+ recursive: true,
2322
+ force: true
2323
+ })]).catch(() => {});
2324
+ }
2220
2325
  var GenerationRunTarget = class extends capnweb.RpcTarget {
2221
2326
  generationStream;
2222
2327
  generationResult;
@@ -2263,34 +2368,74 @@ function applyStudioDefaults(options) {
2263
2368
  ...options.permissions
2264
2369
  },
2265
2370
  retryInterval: options.retryInterval ?? agentDefaults.retryIntervalMs,
2266
- heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs)
2371
+ heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs),
2372
+ capacity: {
2373
+ ...resolveAgentCapacity(),
2374
+ ...options.capacity
2375
+ },
2376
+ instanceId: options.instanceId ?? (0, node_crypto.randomUUID)()
2267
2377
  };
2268
2378
  }
2269
2379
  /**
2270
- * Schedules another connection attempt.
2271
- *
2272
- * A free function rather than a method: a pending retry timer reaches whatever it closes over, so
2273
- * closing only over `options` (not a `StudioSession`) keeps a queued retry from pinning a closed
2274
- * socket, its hook emitter, or its session id alive for the length of the retry interval.
2380
+ * Jobs one agent process can run at once today. Two runs would share this session's hook emitter,
2381
+ * and with it each other's events, until each job runs in its own worker (ADR-0003 slice B2).
2275
2382
  */
2276
- function reconnect(options) {
2277
- const { signal, retryInterval, onTokenRejected } = options;
2383
+ const RUNTIME_MAX_CONCURRENT = 1;
2384
+ const MB = 1048576;
2385
+ function rssMb() {
2386
+ return node_process.default.memoryUsage().rss / MB;
2387
+ }
2388
+ function backoffDelayMs(attempt, maxMs) {
2389
+ const cap = Math.min(1e3 * 2 ** (attempt - 1), maxMs);
2390
+ return Math.random() * cap;
2391
+ }
2392
+ function reconnect(options, delayMs, attempt) {
2393
+ const { signal, onTokenRejected } = options;
2278
2394
  if (signal?.aborted) return;
2279
2395
  const cancel = () => clearTimeout(timer);
2280
2396
  const timer = setTimeout(() => {
2281
2397
  signal?.removeEventListener("abort", cancel);
2282
2398
  if (signal?.aborted) return;
2283
- new StudioSession(options).start().catch((error) => {
2399
+ new StudioSession({
2400
+ ...options,
2401
+ reconnectAttempt: attempt
2402
+ }).start().catch((error) => {
2284
2403
  if (error instanceof InvalidAgentTokenError) {
2285
2404
  onTokenRejected?.(error);
2286
2405
  return;
2287
2406
  }
2288
- reconnect(options);
2407
+ if (error instanceof IncompatibleAgentError) return;
2408
+ const nextAttempt = attempt + 1;
2409
+ reconnect(options, backoffDelayMs(nextAttempt, options.retryInterval), nextAttempt);
2289
2410
  });
2290
- }, retryInterval);
2411
+ }, delayMs);
2291
2412
  signal?.addEventListener("abort", cancel, { once: true });
2292
2413
  }
2293
2414
  /**
2415
+ * Reads what Studio meant by closing the connection. A code Studio did not send on purpose is an
2416
+ * ordinary drop, and the agent reconnects as it always has.
2417
+ */
2418
+ function planEnd(close) {
2419
+ const code = close?.code;
2420
+ if (code === require_protocol.AgentCloseCode.REAUTHENTICATE) return {
2421
+ reason: "Kubb Studio asked the agent to register again",
2422
+ retry: true
2423
+ };
2424
+ if (code === require_protocol.AgentCloseCode.SUPERSEDED) return {
2425
+ reason: "another instance of this agent took over",
2426
+ retry: false
2427
+ };
2428
+ if (code === require_protocol.AgentCloseCode.INCOMPATIBLE) return {
2429
+ reason: "this agent is too old for Kubb Studio, or was deleted",
2430
+ retry: false,
2431
+ 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.")
2432
+ };
2433
+ return {
2434
+ reason: "connection closed",
2435
+ retry: true
2436
+ };
2437
+ }
2438
+ /**
2294
2439
  * One agent-to-Studio RPC transport: opening it, keeping it alive, and serving remote methods.
2295
2440
  * `createClient` opens one per pool slot and is the only caller.
2296
2441
  */
@@ -2303,14 +2448,15 @@ var StudioSession = class {
2303
2448
  */
2304
2449
  #unhooks = [];
2305
2450
  /**
2306
- * What `createAgentSession` handed back, and the marker for whether a session exists at all.
2307
- * Before it resolves there is nothing to disconnect and no sandbox flag to read.
2451
+ * What registration handed back, and the marker for whether the agent registered at all.
2452
+ * Before it resolves there is no sandbox flag to read.
2308
2453
  */
2309
- #session;
2454
+ #registration;
2310
2455
  #rpc;
2311
2456
  #studioVersion;
2312
2457
  #disposed = false;
2313
2458
  #isGenerating = false;
2459
+ #activeJob;
2314
2460
  #heartbeatTimer;
2315
2461
  #lastGeneration;
2316
2462
  #store;
@@ -2320,17 +2466,17 @@ var StudioSession = class {
2320
2466
  * host does not queue jobs before the agent session is registered.
2321
2467
  */
2322
2468
  #connectAck = Promise.withResolvers();
2323
- #startupWarning;
2324
- constructor({ startupWarning, ...options }) {
2469
+ #reconnectAttempt;
2470
+ constructor({ reconnectAttempt, ...options }) {
2325
2471
  this.#options = applyStudioDefaults(options);
2326
- this.#startupWarning = startupWarning;
2472
+ this.#reconnectAttempt = reconnectAttempt ?? 0;
2327
2473
  this.#connectAck.promise.catch(() => {});
2328
2474
  }
2329
2475
  /**
2330
2476
  * A sandbox agent runs on Studio's own infrastructure, so it has no user project to touch.
2331
2477
  */
2332
2478
  get #isSandbox() {
2333
- return this.#session?.isSandbox === true;
2479
+ return this.#registration?.isSandbox === true;
2334
2480
  }
2335
2481
  /**
2336
2482
  * Kept in the project's cache directory, so it survives a restart, except on a sandbox: its pool
@@ -2340,7 +2486,8 @@ var StudioSession = class {
2340
2486
  this.#store ??= createGenerationStore({
2341
2487
  storage: this.#isSandbox ? (0, _kubb_core.memoryStorage)() : (0, _kubb_core.cacheStorage)({ root: this.#options.root }),
2342
2488
  maxCount: this.#limits.maxCount,
2343
- maxMb: this.#limits.maxMb
2489
+ maxMb: this.#limits.maxMb,
2490
+ ttlMs: this.#isSandbox ? SANDBOX_GENERATION_TTL_MS : void 0
2344
2491
  });
2345
2492
  return this.#store;
2346
2493
  }
@@ -2364,20 +2511,26 @@ var StudioSession = class {
2364
2511
  return this.#isSandbox || this.#options.permissions.allowRead;
2365
2512
  }
2366
2513
  async start() {
2367
- const { token, studioUrl, signal, heartbeatInterval, installLogger } = this.#options;
2514
+ const { token, studioUrl, signal, heartbeatInterval, installLogger, instanceId, capacity } = this.#options;
2368
2515
  await installLogger?.(this.#hooks);
2369
- if (this.#startupWarning) await this.#warn(this.#startupWarning);
2370
2516
  try {
2371
2517
  await this.#hooks.callHook("studio:connecting", { url: studioUrl });
2372
- const session = await createAgentSession({
2518
+ 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`);
2519
+ const registration = await registerAgent({
2373
2520
  token,
2374
- studioUrl
2521
+ studioUrl,
2522
+ instanceId,
2523
+ capacity: {
2524
+ ...capacity,
2525
+ maxConcurrent: Math.min(capacity.maxConcurrent, RUNTIME_MAX_CONCURRENT)
2526
+ }
2375
2527
  });
2376
- this.#session = session;
2377
- this.#studioVersion = session.version;
2528
+ this.#registration = registration;
2529
+ this.#studioVersion = registration.version;
2378
2530
  const rpc = await (this.#options.connector ?? connectWebSocketRpc)({
2379
- url: session.url,
2531
+ url: registration.socketUrl,
2380
2532
  token,
2533
+ instanceId,
2381
2534
  local: this
2382
2535
  });
2383
2536
  this.#rpc = rpc;
@@ -2392,16 +2545,17 @@ var StudioSession = class {
2392
2545
  kubb: version,
2393
2546
  agent: this.#options.version
2394
2547
  },
2395
- agentSlug: session.agentSlug,
2396
- organizationSlug: session.organizationSlug
2548
+ agentSlug: registration.agentSlug,
2549
+ organizationSlug: registration.organizationSlug
2397
2550
  });
2398
2551
  await this.#connectAck.promise;
2552
+ this.#reconnectAttempt = 0;
2399
2553
  await this.#hooks.callHook("studio:ready", {});
2400
2554
  } catch (error) {
2401
2555
  this.#disposed = true;
2402
2556
  this.dispose();
2403
2557
  await this.#hooks.callHook("studio:error", { error: toError(error) });
2404
- if (error instanceof InvalidAgentTokenError) throw error;
2558
+ if (error instanceof InvalidAgentTokenError || error instanceof IncompatibleAgentError) throw error;
2405
2559
  await this.#reconnect();
2406
2560
  }
2407
2561
  }
@@ -2411,8 +2565,10 @@ var StudioSession = class {
2411
2565
  */
2412
2566
  async #reconnect() {
2413
2567
  if (this.#options.signal?.aborted) return;
2414
- await this.#hooks.callHook("studio:reconnecting", { delayMs: this.#options.retryInterval });
2415
- reconnect(this.#options);
2568
+ const attempt = this.#reconnectAttempt + 1;
2569
+ const delayMs = backoffDelayMs(attempt, this.#options.retryInterval);
2570
+ await this.#hooks.callHook("studio:reconnecting", { delayMs });
2571
+ reconnect(this.#options, delayMs, attempt);
2416
2572
  }
2417
2573
  #warn(message, permission) {
2418
2574
  return this.#hooks.callHook("studio:warn", {
@@ -2444,10 +2600,23 @@ var StudioSession = class {
2444
2600
  /**
2445
2601
  * Races `studio.ping()` against a deadline, so a half-open socket can't hang it forever.
2446
2602
  * */
2447
- #ping(rpc) {
2603
+ async #ping(rpc) {
2448
2604
  const { promise: timedOut, reject: onTimeout } = Promise.withResolvers();
2449
2605
  const timer = setTimeout(() => onTimeout(/* @__PURE__ */ new Error("Heartbeat ping timed out")), agentDefaults.heartbeatTimeoutMs);
2450
- return Promise.race([rpc.studio.ping(), timedOut]).finally(() => clearTimeout(timer));
2606
+ try {
2607
+ const load = await Promise.race([this.#load().catch(() => void 0), timedOut]);
2608
+ await Promise.race([rpc.studio.ping(load), timedOut]);
2609
+ } finally {
2610
+ clearTimeout(timer);
2611
+ }
2612
+ }
2613
+ async #load() {
2614
+ return {
2615
+ running: this.#isGenerating ? 1 : 0,
2616
+ rssMb: Math.round(rssMb()),
2617
+ storeBytes: await this.#generations.bytes(),
2618
+ accepting: true
2619
+ };
2451
2620
  }
2452
2621
  /**
2453
2622
  * Reads `kubb.config.ts` and reports which plugin options Studio may edit.
@@ -2492,8 +2661,11 @@ var StudioSession = class {
2492
2661
  this.#connectAck.resolve();
2493
2662
  return payload;
2494
2663
  }
2495
- #onAbort = () => void this.#end({ retry: false });
2496
- #onClose = () => void this.#end({ retry: true });
2664
+ #onAbort = () => void this.#end({
2665
+ reason: "shutdown",
2666
+ retry: false
2667
+ });
2668
+ #onClose = (close) => void this.#end(planEnd(close));
2497
2669
  /**
2498
2670
  * Drops the socket and detaches every listener and timer this session added. Idempotent, and
2499
2671
  * safe before `connect` opened anything.
@@ -2513,17 +2685,12 @@ var StudioSession = class {
2513
2685
  * Ends the session: tells Studio it is over, drops the socket, and optionally reconnects.
2514
2686
  * `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
2515
2687
  */
2516
- async #end({ retry }) {
2517
- const { studioUrl, token } = this.#options;
2688
+ async #end({ reason, retry, error }) {
2518
2689
  if (this.#disposed) return;
2519
2690
  this.#disposed = true;
2520
2691
  this.dispose();
2521
- await this.#hooks.callHook("studio:disconnected", { reason: retry ? "connection closed" : "shutdown" });
2522
- if (this.#session && !await disconnect({
2523
- sessionId: this.#session.sessionId,
2524
- studioUrl,
2525
- token
2526
- })) await this.#warn("Could not notify Kubb Studio of the disconnect");
2692
+ await this.#hooks.callHook("studio:disconnected", { reason });
2693
+ if (error) await this.#hooks.callHook("studio:error", { error });
2527
2694
  if (retry) await this.#reconnect();
2528
2695
  }
2529
2696
  startGeneration(data) {
@@ -2531,27 +2698,42 @@ var StudioSession = class {
2531
2698
  this.#lastGeneration = result;
2532
2699
  } });
2533
2700
  const controller = new AbortController();
2534
- const result = this.#runGeneration(data, controller).then(async (value) => {
2701
+ const cancelRun = async () => {
2702
+ controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
2703
+ };
2704
+ const result = this.#runGeneration(data, controller, cancelRun).then(async (value) => {
2535
2705
  await generationStream.close();
2536
2706
  return value;
2537
2707
  }).catch((error) => {
2538
2708
  generationStream.fail(error);
2539
2709
  throw error;
2710
+ }).finally(() => {
2711
+ if (this.#activeJob?.cancel === cancelRun) this.#activeJob = void 0;
2540
2712
  });
2541
2713
  result.catch(() => {});
2542
- return new GenerationRunTarget(generationStream.stream, result, async () => {
2543
- controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
2544
- }, () => {
2714
+ return new GenerationRunTarget(generationStream.stream, result, cancelRun, () => {
2545
2715
  controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
2546
2716
  generationStream.dispose();
2547
2717
  });
2548
2718
  }
2549
- async #runGeneration(data, controller) {
2719
+ /**
2720
+ * Cancels the currently running job when its id matches `jobId`.
2721
+ */
2722
+ async cancel(jobId) {
2723
+ if (this.#activeJob?.jobId === jobId) await this.#activeJob.cancel();
2724
+ }
2725
+ async #runGeneration(data, controller, cancelRun) {
2550
2726
  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");
2551
2727
  this.#isGenerating = true;
2728
+ this.#activeJob = {
2729
+ jobId: data.jobId,
2730
+ cancel: cancelRun
2731
+ };
2552
2732
  const command = "generate";
2553
- const { root, loadConfig, permissions } = this.#options;
2733
+ const { loadConfig, permissions } = this.#options;
2734
+ let root = this.#options.root;
2554
2735
  try {
2736
+ if (this.#isSandbox) root = await createJobRoot();
2555
2737
  await this.#hooks.callHook("studio:command:start", { command });
2556
2738
  const config = await loadConfig();
2557
2739
  const patch = data.config;
@@ -2562,43 +2744,28 @@ var StudioSession = class {
2562
2744
  if (patch?.input && !this.#canUseInput) await this.#warn("Ignored the spec from Studio: generating from a Studio spec was not granted", "allowInput");
2563
2745
  const resolvedPlugins = plugins ?? config.plugins;
2564
2746
  this.#lastGeneration = void 0;
2565
- const diskFiles = this.#hasProjectOnDisk ? await listDisk({
2566
- root,
2567
- outputPath: config.output.path,
2568
- maxFiles: DISK_SNAPSHOT_MAX_FILES
2569
- }) : void 0;
2570
- const disk = diskFiles ? await this.#generations.keep({
2571
- jobId: data.jobId,
2572
- source: "disk",
2573
- files: diskFiles,
2574
- maxSetMb: this.#limits.maxSnapshotMb
2575
- }) : void 0;
2576
- const detach = [setupHookListener(this.#hooks, root, controller.signal)];
2577
- try {
2578
- await generate({
2579
- config: {
2580
- ...config,
2581
- root,
2582
- input: inputOverride ?? config.input,
2583
- storage: this.#canWrite ? (0, _kubb_core.fsStorage)() : (0, _kubb_core.memoryStorage)(),
2584
- output: permissions.allowExec ? { ...config.output } : {
2585
- ...config.output,
2586
- format: false,
2587
- lint: false,
2588
- postGenerate: []
2589
- },
2590
- plugins: resolvedPlugins,
2591
- adapter
2747
+ const disk = await runGenerationOperation({
2748
+ config: {
2749
+ ...config,
2750
+ root,
2751
+ input: inputOverride ?? config.input,
2752
+ storage: this.#canWrite ? (0, _kubb_core.fsStorage)() : (0, _kubb_core.memoryStorage)(),
2753
+ output: permissions.allowExec ? { ...config.output } : {
2754
+ ...config.output,
2755
+ format: false,
2756
+ lint: false,
2757
+ postGenerate: []
2592
2758
  },
2593
- hooks: this.#hooks,
2594
- signal: controller.signal
2595
- });
2596
- } catch (error) {
2597
- await this.#generations.drop(data.jobId);
2598
- throw error;
2599
- } finally {
2600
- for (const remove of detach) remove();
2601
- }
2759
+ plugins: resolvedPlugins,
2760
+ adapter
2761
+ },
2762
+ hooks: this.#hooks,
2763
+ signal: controller.signal,
2764
+ jobId: data.jobId,
2765
+ store: this.#generations,
2766
+ snapshotRoot: this.#hasProjectOnDisk ? root : void 0,
2767
+ maxSnapshotMb: this.#limits.maxSnapshotMb
2768
+ });
2602
2769
  await this.#hooks.callHook("studio:command:end", {
2603
2770
  command,
2604
2771
  info: `${resolvedPlugins.length} plugin${resolvedPlugins.length === 1 ? "" : "s"}, ${this.#canWrite ? "written to disk" : "in memory"}${inputOverride !== void 0 ? ", from a Studio spec" : ""}`
@@ -2630,6 +2797,7 @@ var StudioSession = class {
2630
2797
  disk: disk ? { hashes: disk.hashes } : void 0
2631
2798
  };
2632
2799
  } finally {
2800
+ if (root !== this.#options.root) await removeJobRoot(root);
2633
2801
  this.#isGenerating = false;
2634
2802
  }
2635
2803
  }
@@ -2659,8 +2827,10 @@ var StudioSession = class {
2659
2827
  }
2660
2828
  if (this.#isGenerating) return refuse("a generation is in progress");
2661
2829
  try {
2662
- const { source: patched, outcomes, changed } = applyConfigEdits(await read(configFile), edits);
2663
- if (changed) await (0, node_fs_promises.writeFile)(configFile, patched, "utf-8");
2830
+ const { source: patched, outcomes, changed } = await writeConfigEdits({
2831
+ filePath: configFile,
2832
+ edits
2833
+ });
2664
2834
  const applied = outcomes.filter((outcome) => outcome.applied).length;
2665
2835
  await this.#hooks.callHook("studio:command:end", {
2666
2836
  command,
@@ -2698,24 +2868,14 @@ var StudioSession = class {
2698
2868
  version,
2699
2869
  peerDependencies: generation.peerDependencies
2700
2870
  });
2701
- const { token, studioUrl } = this.#options;
2702
- const uploadUrl = new URL(uploadPath, studioUrl);
2703
- if (uploadUrl.origin !== new URL(studioUrl).origin) throw new Error("Snapshot upload path must stay on the Studio origin");
2704
- const redirect = await fetch(uploadUrl, {
2705
- method: "PUT",
2706
- headers: { Authorization: `Bearer ${token}` },
2707
- redirect: "manual"
2708
- });
2709
- const storageUrl = redirect.headers.get("location");
2710
- if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
2711
- const storage = new URL(storageUrl);
2712
- if (storage.protocol !== "https:" && storage.hostname !== "localhost" && storage.hostname !== "127.0.0.1") throw new Error(`Refusing snapshot upload to ${storage.origin}`);
2713
- const response = await fetch(storage, {
2714
- method: "PUT",
2715
- body: new Uint8Array(bytes),
2716
- redirect: "error"
2871
+ const { token, studioUrl, signal } = this.#options;
2872
+ await uploadSnapshot({
2873
+ bytes,
2874
+ uploadPath,
2875
+ studioUrl,
2876
+ token,
2877
+ shutdown: signal
2717
2878
  });
2718
- if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
2719
2879
  await this.#hooks.callHook("studio:command:end", {
2720
2880
  command,
2721
2881
  info: `packed ${Object.keys(files).length} file${Object.keys(files).length === 1 ? "" : "s"}`
@@ -2729,10 +2889,7 @@ var StudioSession = class {
2729
2889
  throw error;
2730
2890
  }
2731
2891
  }
2732
- /**
2733
- * An agent with a project on disk can show a run against what its output directory held before.
2734
- * A sandbox agent has no project.
2735
- */
2892
+ /** A sandbox has no project directory to snapshot. */
2736
2893
  get #hasProjectOnDisk() {
2737
2894
  return !this.#isSandbox && this.#canRead;
2738
2895
  }
@@ -2761,7 +2918,7 @@ var StudioSession = class {
2761
2918
  }
2762
2919
  };
2763
2920
  //#endregion
2764
- //#region src/client.ts
2921
+ //#region src/runtime/client.ts
2765
2922
  /**
2766
2923
  * Creates the Kubb Studio client: the connection, the command loop, and the generation event
2767
2924
  * stream shared by the `kubb studio` CLI command and the Docker agent.
@@ -2777,7 +2934,7 @@ var StudioSession = class {
2777
2934
  */
2778
2935
  function createClient({ onAuthRequired, ...options }) {
2779
2936
  const controller = new AbortController();
2780
- const poolSize = options.poolSize ?? agentDefaults.poolSize;
2937
+ const instanceId = options.instanceId ?? (0, node_crypto.randomUUID)();
2781
2938
  function notifyAuthRequired(error) {
2782
2939
  if (controller.signal.aborted) return;
2783
2940
  controller.abort();
@@ -2785,19 +2942,12 @@ function createClient({ onAuthRequired, ...options }) {
2785
2942
  }
2786
2943
  return {
2787
2944
  async connect() {
2788
- const registered = await registerAgent({
2789
- token: options.token,
2790
- studioUrl: options.studioUrl ?? agentDefaults.studioUrl,
2791
- poolSize
2792
- });
2793
- if (controller.signal.aborted) return;
2794
- const startupWarning = registered ? void 0 : "Could not register with Kubb Studio, continuing";
2795
- await Promise.all(Array.from({ length: poolSize }, (_, slot) => new StudioSession({
2945
+ await new StudioSession({
2796
2946
  ...options,
2947
+ instanceId,
2797
2948
  signal: controller.signal,
2798
- onTokenRejected: notifyAuthRequired,
2799
- startupWarning: slot === 0 ? startupWarning : void 0
2800
- }).start()));
2949
+ onTokenRejected: notifyAuthRequired
2950
+ }).start();
2801
2951
  },
2802
2952
  disconnect() {
2803
2953
  controller.abort();
@@ -2805,7 +2955,7 @@ function createClient({ onAuthRequired, ...options }) {
2805
2955
  };
2806
2956
  }
2807
2957
  //#endregion
2808
- //#region src/runConnection.ts
2958
+ //#region src/runtime/runConnection.ts
2809
2959
  /**
2810
2960
  * Waits for whichever comes first: the shutdown signal, or Studio rejecting the token during a
2811
2961
  * background reconnect. Resolves with the rejection, or nothing when the run is being shut down.
@@ -2878,7 +3028,7 @@ async function runConnection({ credentials, clientOptions, onTokenRejected, sign
2878
3028
  }
2879
3029
  }
2880
3030
  //#endregion
2881
- //#region src/pair.ts
3031
+ //#region src/operations/pair.ts
2882
3032
  /** Labels, not secrets: a person approving the code in the browser is what authorizes a pairing. */
2883
3033
  const CLIENT_IDS = {
2884
3034
  cli: "kubb-cli",
@@ -3001,6 +3151,9 @@ async function pairAgent({ onCode, onRetry, maxAttempts = 1, ...options }) {
3001
3151
  }
3002
3152
  }
3003
3153
  //#endregion
3154
+ exports.AGENT_INSTANCE_HEADER = require_protocol.AGENT_INSTANCE_HEADER;
3155
+ exports.AgentCloseCode = require_protocol.AgentCloseCode;
3156
+ exports.IncompatibleAgentError = IncompatibleAgentError;
3004
3157
  exports.InvalidAgentTokenError = InvalidAgentTokenError;
3005
3158
  exports.PairingCanceledError = PairingCanceledError;
3006
3159
  exports.PairingDeniedError = PairingDeniedError;