@kubb/studio 5.3.15 → 5.3.17

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