@kubb/studio 5.3.16 → 5.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 tinyexec = require("tinyexec");
39
+ let node_timers_promises = require("node:timers/promises");
35
40
  let ofetch = require("ofetch");
36
- let node_crypto = require("node:crypto");
37
41
  let node_util = require("node:util");
38
42
  let unstorage = require("unstorage");
39
43
  let unstorage_drivers_fs = require("unstorage/drivers/fs");
40
44
  unstorage_drivers_fs = __toESM(unstorage_drivers_fs, 1);
41
- let _kubb_core = require("@kubb/core");
42
- let tinyexec = require("tinyexec");
43
45
  let magicast = require("magicast");
44
46
  let node_fs = require("node:fs");
45
47
  let node_module = require("node:module");
46
48
  let node_url = require("node:url");
47
49
  let remeda = require("remeda");
48
- let node_os = require("node:os");
49
50
  let node_zlib = require("node:zlib");
50
51
  let tsdown = require("tsdown");
51
52
  let capnweb = require("capnweb");
52
53
  let ws = require("ws");
53
54
  ws = __toESM(ws, 1);
54
- let node_timers_promises = require("node:timers/promises");
55
- //#region src/constants.ts
56
- /**
57
- * Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
58
- * not whatever default the client would pick on its own.
59
- */
60
- const defaultStudioUrl = "https://kubb.studio";
61
- /**
62
- * Defaults the Studio client uses when a host passes nothing.
63
- * Config path is left out on purpose: each host discovers that itself.
64
- */
65
- const agentDefaults = {
66
- studioUrl: defaultStudioUrl,
67
- retryIntervalMs: 3e4,
68
- heartbeatIntervalMs: 3e4,
69
- /**
70
- * Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
71
- * stored ping is older than its liveness window, and it stores a ping at most once a minute, so
72
- * a slower cadence would make a healthy agent look dead after a single missed ping.
73
- */
74
- maxHeartbeatIntervalMs: 6e4,
75
- /** How long a heartbeat ping may take before the session is treated as dead. */
76
- heartbeatTimeoutMs: 1e4,
77
- poolSize: 1,
78
- maxGenerations: 8,
79
- maxGenerationsMb: 100,
80
- maxSnapshotMb: 50
81
- };
82
- function positiveNumber(value) {
83
- const parsed = Number(value);
84
- return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
85
- }
86
- /**
87
- * How many generations an agent keeps and how large they may get, read from
88
- * `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
89
- * An unset or invalid value keeps the default.
90
- */
91
- function resolveGenerationLimits(env = process.env) {
92
- return {
93
- maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
94
- maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
95
- maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
96
- };
97
- }
98
- //#endregion
99
55
  //#region ../../internals/utils/src/casing.ts
100
56
  /**
101
57
  * Shared implementation for camelCase and PascalCase conversion.
@@ -418,6 +374,89 @@ function getElapsedMs(hrStart) {
418
374
  return Math.round(ms * 100) / 100;
419
375
  }
420
376
  //#endregion
377
+ //#region package.json
378
+ var version = "5.3.17";
379
+ //#endregion
380
+ //#region src/hooks.ts
381
+ /**
382
+ * Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
383
+ * streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
384
+ * Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
385
+ *
386
+ * Returns a remover, so a session that runs one generation after another on the same emitter does
387
+ * not stack a listener per run.
388
+ */
389
+ function setupHookListener(hooks, root, signal) {
390
+ return hooks.hook("kubb:hook:start", async (ctx) => {
391
+ const { id, command, args } = ctx;
392
+ if (!id) return;
393
+ const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
394
+ try {
395
+ const proc = (0, tinyexec.x)(command, [...args ?? []], {
396
+ signal,
397
+ nodeOptions: {
398
+ cwd: root,
399
+ detached: true
400
+ }
401
+ });
402
+ for await (const line of proc) await hooks.callHook("kubb:hook:line", {
403
+ id,
404
+ line
405
+ });
406
+ const { exitCode } = await proc;
407
+ if (exitCode !== 0) {
408
+ const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
409
+ await hooks.callHook("kubb:hook:end", {
410
+ id,
411
+ command,
412
+ args,
413
+ success: false,
414
+ error
415
+ });
416
+ await hooks.callHook("kubb:error", { error });
417
+ return;
418
+ }
419
+ await hooks.callHook("kubb:hook:end", {
420
+ id,
421
+ command,
422
+ args,
423
+ success: true,
424
+ error: null
425
+ });
426
+ } catch (caughtError) {
427
+ const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
428
+ error.cause = caughtError;
429
+ await hooks.callHook("kubb:hook:end", {
430
+ id,
431
+ command,
432
+ args,
433
+ success: false,
434
+ error
435
+ });
436
+ await hooks.callHook("kubb:error", { error });
437
+ }
438
+ });
439
+ }
440
+ /**
441
+ * Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
442
+ * `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
443
+ * that same listener, so a handler added afterward would already have missed it.
444
+ */
445
+ function waitForHookEnd(hooks, hookId) {
446
+ return new Promise((resolve, reject) => {
447
+ const handleHookEnd = (ctx) => {
448
+ if (ctx.id !== hookId) return;
449
+ hooks.removeHook("kubb:hook:end", handleHookEnd);
450
+ if (ctx.success) {
451
+ resolve();
452
+ return;
453
+ }
454
+ reject(ctx.error);
455
+ };
456
+ hooks.hook("kubb:hook:end", handleHookEnd);
457
+ });
458
+ }
459
+ //#endregion
421
460
  //#region src/machine.ts
422
461
  /**
423
462
  * Key-value storage the runtime uses for its machine secret and the last Studio config.
@@ -498,10 +537,6 @@ function responseMessage(data) {
498
537
  */
499
538
  const REGISTER_RETRIES = 3;
500
539
  /**
501
- * Shared in-flight registration so concurrent pool sessions trigger one purge, not N.
502
- */
503
- let registrationInFlight = null;
504
- /**
505
540
  * Thrown when Studio rejects the agent token itself (401). Retrying cannot help: the token was
506
541
  * revoked, or the agent it belonged to was deleted in the Studio UI. Hosts catch this to forget
507
542
  * the stored credential and pair again.
@@ -513,117 +548,87 @@ var InvalidAgentTokenError = class extends Error {
513
548
  }
514
549
  };
515
550
  /**
551
+ * Thrown when Studio refuses this agent's protocol version (426). Retrying cannot help until the
552
+ * agent is upgraded, so hosts stop instead of reconnecting.
553
+ */
554
+ var IncompatibleAgentError = class extends Error {
555
+ constructor(studioUrl, detail, options) {
556
+ super(`Kubb Studio at ${studioUrl} requires a newer agent${detail ? `: ${detail}` : ""}. Upgrade @kubb/studio or the Kubb agent image.`, options);
557
+ this.name = "IncompatibleAgentError";
558
+ }
559
+ };
560
+ /**
516
561
  * Whether a thrown value carries `statusCode`. Not narrowed to `FetchError`: a host wrapper can
517
562
  * throw its own error shape with the same field.
518
- *
519
- * A 401 means the agent token itself was rejected. A 403 from the session create endpoint means
520
- * the machine token stored in Studio no longer matches this agent (missing or mismatched).
521
563
  */
522
564
  function rejectedWith(error, statusCode) {
523
565
  return error?.statusCode === statusCode;
524
566
  }
525
- function 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 });
528
- }
529
- /**
530
- * Performs the raw session create request against Studio.
531
- */
532
- async function requestAgentSession({ token, studioUrl }) {
533
- const url = `${studioUrl}/api/agent/sessions`;
534
- const data = await (0, ofetch.ofetch)(url, {
535
- method: "POST",
536
- headers: { Authorization: `Bearer ${token}` },
537
- body: { machineToken: await getMachineToken() }
538
- });
539
- if (!data) throw new Error("No data available for agent session");
540
- return data;
541
- }
542
- /**
543
- * Obtain an agent session token from Kubb Studio via HTTP.
544
- *
545
- * When Studio rejects the machine token (403), for example after the agent restarted
546
- * with a new identity while the startup registration call failed, the agent re-registers
547
- * and retries once, so a single failed registration can't permanently block session creation.
548
- */
549
- async function createAgentSession({ token, studioUrl }) {
550
- try {
551
- return await requestAgentSession({
552
- token,
553
- studioUrl
554
- });
555
- } catch (error) {
556
- if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
557
- if (!rejectedWith(error, 403) || !await registerAgent({
558
- token,
559
- studioUrl
560
- })) throw sessionError(error);
561
- try {
562
- return await requestAgentSession({
563
- token,
564
- studioUrl
565
- });
566
- } catch (retryError) {
567
- if (rejectedWith(retryError, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: retryError });
568
- throw sessionError(retryError);
569
- }
570
- }
569
+ return new Error(detail ? `Failed to register with Kubb Studio: ${detail}` : "Failed to register with Kubb Studio", { cause });
571
570
  }
572
571
  /**
573
- * 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.
572
+ * Registers this agent process with Kubb Studio (`POST /api/agent/connect`): binds the machine
573
+ * identity to the token, reports what the process can take on, and gets back the URL of the one
574
+ * socket it keeps open.
576
575
  *
577
- * Retries 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
- 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);
606
598
  }
607
599
  }
608
600
  /**
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.
601
+ * First wait before `createJob` retries a busy or queue-full response, absent a `Retry-After` hint.
615
602
  */
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
- }
603
+ const CREATE_JOB_INITIAL_DELAY_MS = 1e3;
604
+ /**
605
+ * Slowest `createJob` backs off to between retries.
606
+ */
607
+ const CREATE_JOB_MAX_INTERVAL_MS = 1e4;
608
+ /**
609
+ * Statuses worth retrying: the agent has no free connection yet (409, a stale conflict a moment
610
+ * later resolves), its queue is momentarily full (429), or it has no live connection at all yet
611
+ * (503, an agent process that is mid-reconnect). Anything else (404 agent not found, 401/403 auth)
612
+ * is thrown straight away, since retrying cannot change the outcome.
613
+ */
614
+ const CREATE_JOB_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
615
+ 409,
616
+ 429,
617
+ 503
618
+ ]);
619
+ /**
620
+ * Reads Studio's `Retry-After` header (seconds) off a thrown `ofetch` error, when present.
621
+ */
622
+ function retryAfterMs(error) {
623
+ const seconds = Number(error.response?.headers.get("retry-after"));
624
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
625
+ }
626
+ /**
627
+ * Adds up to 30% jitter, so every CI run queued behind the same busy agent does not retry in
628
+ * lockstep.
629
+ */
630
+ function withJitter(ms) {
631
+ return ms + Math.random() * ms * .3;
627
632
  }
628
633
  /**
629
634
  * Queues a generation or snapshot job on Studio (`POST /api/jobs`).
@@ -631,6 +636,10 @@ async function disconnect({ sessionId, token, studioUrl }) {
631
636
  * Returns as soon as Studio accepts the job (`202`). Poll with {@link waitForJob} until it finishes.
632
637
  * Authenticates with the organization CI API key via `x-api-key`.
633
638
  *
639
+ * A busy agent, a full queue, or a momentary lack of a live connection (409, 429, 503) retries with
640
+ * exponential backoff and jitter, honoring Studio's `Retry-After` header when it sends one, up to
641
+ * `timeoutMs`. Every other failure, including a missing agent (404), throws immediately.
642
+ *
634
643
  * @example Snapshot job
635
644
  * ```ts
636
645
  * const job = await createJob({
@@ -644,20 +653,40 @@ async function disconnect({ sessionId, token, studioUrl }) {
644
653
  * const finished = await waitForJob({ studioUrl, token, id: job.id })
645
654
  * ```
646
655
  */
647
- async function createJob({ studioUrl, token, type, agentId, name, version, commit, config }) {
648
- const { 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
656
+ async function createJob({ studioUrl, token, type, agentId, name, version, commit, baseId, config, timeoutMs = 6e4, signal }) {
657
+ const deadline = Date.now() + timeoutMs;
658
+ let interval = CREATE_JOB_INITIAL_DELAY_MS;
659
+ for (;;) {
660
+ signal?.throwIfAborted();
661
+ try {
662
+ const { job } = await (0, ofetch.ofetch)(`${studioUrl}/api/jobs`, {
663
+ method: "POST",
664
+ headers: { "x-api-key": token },
665
+ body: {
666
+ type,
667
+ agentId,
668
+ name,
669
+ version,
670
+ commit,
671
+ baseId,
672
+ config
673
+ },
674
+ retry: false,
675
+ timeout: Math.max(deadline - Date.now(), 1),
676
+ signal
677
+ });
678
+ return job;
679
+ } catch (error) {
680
+ signal?.throwIfAborted();
681
+ const status = error.response?.status;
682
+ if (!status || !CREATE_JOB_RETRYABLE_STATUSES.has(status) || Date.now() >= deadline) throw error;
683
+ const wait = Math.min(retryAfterMs(error) ?? withJitter(interval), Math.max(deadline - Date.now(), 0));
684
+ if (signal) await (0, node_timers_promises.setTimeout)(wait, void 0, { signal });
685
+ else await new Promise((resolve) => setTimeout(resolve, wait));
686
+ if (Date.now() >= deadline) throw error;
687
+ interval = Math.min(interval * 2, CREATE_JOB_MAX_INTERVAL_MS);
658
688
  }
659
- });
660
- return job;
689
+ }
661
690
  }
662
691
  /**
663
692
  * A job runs a generation and packs a tarball, so it is never done the instant it is queued.
@@ -676,20 +705,26 @@ const MAX_POLL_INTERVAL_MS = 3e4;
676
705
  * A `failed` job resolves normally. Check `job.status` and `job.error`. Throws only when the
677
706
  * deadline passes before Studio finishes.
678
707
  */
679
- async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4 }) {
708
+ async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4, signal }) {
680
709
  const deadline = Date.now() + timeoutMs;
681
710
  let interval = INITIAL_POLL_DELAY_MS;
682
711
  for (;;) {
683
- 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));
684
716
  if (Date.now() >= deadline) throw new Error("Timed out waiting for the Studio job");
685
717
  interval = Math.min(interval * 2, MAX_POLL_INTERVAL_MS);
686
718
  try {
687
719
  const { job } = await (0, ofetch.ofetch)(`${studioUrl}/api/jobs/${id}`, {
688
720
  headers: { "x-api-key": token },
689
- retry: false
721
+ retry: false,
722
+ timeout: Math.max(deadline - Date.now(), 1),
723
+ signal
690
724
  });
691
725
  if (job.status === "success" || job.status === "failed" || job.status === "canceled") return job;
692
726
  } catch (error) {
727
+ signal?.throwIfAborted();
693
728
  const response = error.response;
694
729
  if (response?.status !== 429) throw error;
695
730
  const retryAfter = response._data?.data?.tryAgainIn;
@@ -723,89 +758,6 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
723
758
  }
724
759
  }
725
760
  //#endregion
726
- //#region package.json
727
- var version = "5.3.16";
728
- //#endregion
729
- //#region src/hooks.ts
730
- /**
731
- * Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
732
- * streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
733
- * Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
734
- *
735
- * Returns a remover, so a session that runs one generation after another on the same emitter does
736
- * not stack a listener per run.
737
- */
738
- function setupHookListener(hooks, root, signal) {
739
- return hooks.hook("kubb:hook:start", async (ctx) => {
740
- const { id, command, args } = ctx;
741
- if (!id) return;
742
- const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
743
- try {
744
- const proc = (0, tinyexec.x)(command, [...args ?? []], {
745
- signal,
746
- nodeOptions: {
747
- cwd: root,
748
- detached: true
749
- }
750
- });
751
- for await (const line of proc) await hooks.callHook("kubb:hook:line", {
752
- id,
753
- line
754
- });
755
- const { exitCode } = await proc;
756
- if (exitCode !== 0) {
757
- const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
758
- await hooks.callHook("kubb:hook:end", {
759
- id,
760
- command,
761
- args,
762
- success: false,
763
- error
764
- });
765
- await hooks.callHook("kubb:error", { error });
766
- return;
767
- }
768
- await hooks.callHook("kubb:hook:end", {
769
- id,
770
- command,
771
- args,
772
- success: true,
773
- error: null
774
- });
775
- } catch (caughtError) {
776
- const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
777
- error.cause = caughtError;
778
- await hooks.callHook("kubb:hook:end", {
779
- id,
780
- command,
781
- args,
782
- success: false,
783
- error
784
- });
785
- await hooks.callHook("kubb:error", { error });
786
- }
787
- });
788
- }
789
- /**
790
- * Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
791
- * `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
792
- * that same listener, so a handler added afterward would already have missed it.
793
- */
794
- function waitForHookEnd(hooks, hookId) {
795
- return new Promise((resolve, reject) => {
796
- const handleHookEnd = (ctx) => {
797
- if (ctx.id !== hookId) return;
798
- hooks.removeHook("kubb:hook:end", handleHookEnd);
799
- if (ctx.success) {
800
- resolve();
801
- return;
802
- }
803
- reject(ctx.error);
804
- };
805
- hooks.hook("kubb:hook:end", handleHookEnd);
806
- });
807
- }
808
- //#endregion
809
761
  //#region src/resolveConfig.ts
810
762
  /**
811
763
  * Imports a package, falling back to how the user's project would resolve it.
@@ -1673,6 +1625,60 @@ async function generate({ config, hooks, signal }) {
1673
1625
  }
1674
1626
  }
1675
1627
  //#endregion
1628
+ //#region src/constants.ts
1629
+ /**
1630
+ * Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
1631
+ * not whatever default the client would pick on its own.
1632
+ */
1633
+ const defaultStudioUrl = "https://kubb.studio";
1634
+ /**
1635
+ * Defaults the Studio client uses when a host passes nothing.
1636
+ * Config path is left out on purpose: each host discovers that itself.
1637
+ */
1638
+ const agentDefaults = {
1639
+ studioUrl: defaultStudioUrl,
1640
+ retryIntervalMs: 3e4,
1641
+ heartbeatIntervalMs: 3e4,
1642
+ /**
1643
+ * Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
1644
+ * stored ping is older than its liveness window, and it stores a ping at most once a minute, so
1645
+ * a slower cadence would make a healthy agent look dead after a single missed ping.
1646
+ */
1647
+ maxHeartbeatIntervalMs: 6e4,
1648
+ /** How long a heartbeat ping may take before the session is treated as dead. */
1649
+ heartbeatTimeoutMs: 1e4,
1650
+ maxConcurrent: 1,
1651
+ maxGenerations: 8,
1652
+ maxGenerationsMb: 100,
1653
+ maxSnapshotMb: 50
1654
+ };
1655
+ function positiveNumber(value) {
1656
+ const parsed = Number(value);
1657
+ return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1658
+ }
1659
+ /**
1660
+ * How many generations an agent keeps and how large they may get, read from
1661
+ * `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
1662
+ * An unset or invalid value keeps the default.
1663
+ */
1664
+ function resolveGenerationLimits(env = process.env) {
1665
+ return {
1666
+ maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
1667
+ maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
1668
+ maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
1669
+ };
1670
+ }
1671
+ /**
1672
+ * An agent's capacity read from `KUBB_AGENT_MAX_CONCURRENT` and `KUBB_AGENT_MEMORY_BUDGET_MB`. An
1673
+ * unset or invalid value keeps the default: one job at a time, and no memory budget.
1674
+ */
1675
+ function resolveAgentCapacity(env = process.env) {
1676
+ return {
1677
+ maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)),
1678
+ memoryBudgetMb: positiveNumber(env.KUBB_AGENT_MEMORY_BUDGET_MB)
1679
+ };
1680
+ }
1681
+ //#endregion
1676
1682
  //#region src/snapshotPackage.ts
1677
1683
  const gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
1678
1684
  /**
@@ -1815,7 +1821,7 @@ async function createSnapshotPackage(files, packageInfo) {
1815
1821
  //#endregion
1816
1822
  //#region src/generations.ts
1817
1823
  const READ_CONCURRENCY = 50;
1818
- const MB = 1048576;
1824
+ const MB$1 = 1048576;
1819
1825
  const INDEX_KEY = "studio/generations.json";
1820
1826
  const hashOf = (content) => (0, node_crypto.createHash)("sha1").update(content).digest("hex").slice(0, 16);
1821
1827
  /**
@@ -1842,8 +1848,9 @@ async function listDisk({ root, outputPath, maxFiles }) {
1842
1848
  * tenant never reaches another's output. The oldest go past `maxCount` or `maxMb`, but the newest
1843
1849
  * always stays.
1844
1850
  */
1845
- function createGenerationStore({ storage, maxCount, maxMb }) {
1851
+ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now }) {
1846
1852
  let index;
1853
+ const isLive = (generation) => ttlMs === void 0 || generation.keptAt === void 0 || now() - generation.keptAt < ttlMs;
1847
1854
  const dirOf = (jobId) => `studio/generations/${hashOf(jobId)}/`;
1848
1855
  async function load() {
1849
1856
  if (index) return index;
@@ -1859,7 +1866,7 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
1859
1866
  * Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
1860
1867
  */
1861
1868
  async function keep({ jobId, source, files, maxSetMb }) {
1862
- const maxSetBytes = maxSetMb * MB;
1869
+ const maxSetBytes = maxSetMb * MB$1;
1863
1870
  const hashes = {};
1864
1871
  let bytes = 0;
1865
1872
  await inParallel({
@@ -1894,13 +1901,20 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
1894
1901
  return {
1895
1902
  keep,
1896
1903
  drop,
1897
- get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId),
1898
- latest: async () => (await load()).at(-1),
1904
+ get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId && isLive(generation)),
1905
+ latest: async () => (await load()).filter(isLive).at(-1),
1906
+ /** Total bytes of every set the store holds, expired ones too until the next add drops them. */
1907
+ bytes: async () => (await load()).reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0),
1899
1908
  async add(generation) {
1900
- const entries = (await load()).filter((entry) => entry.jobId !== generation.jobId);
1901
- 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
+ });
1902
1916
  const weight = () => entries.reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0);
1903
- while (entries.length > 1 && (entries.length > maxCount || weight() > maxMb * MB)) await drop(entries.shift().jobId);
1917
+ while (entries.length > 1 && (entries.length > maxCount || weight() > maxMb * MB$1)) await drop(entries.shift().jobId);
1904
1918
  index = entries;
1905
1919
  await storage.writeItem(INDEX_KEY, JSON.stringify(entries));
1906
1920
  },
@@ -2187,6 +2201,9 @@ var AgentRpcTarget = class extends capnweb.RpcTarget {
2187
2201
  readFiles(input) {
2188
2202
  return this.api.readFiles(input);
2189
2203
  }
2204
+ cancel(jobId) {
2205
+ return this.api.cancel(jobId);
2206
+ }
2190
2207
  };
2191
2208
  /**
2192
2209
  * Opens an authenticated Cap'n Web session to Studio over a WebSocket. Rejects an unencrypted URL
@@ -2198,11 +2215,17 @@ var AgentRpcTarget = class extends capnweb.RpcTarget {
2198
2215
  * await rpc.studio.ping()
2199
2216
  * ```
2200
2217
  */
2201
- const connectWebSocketRpc = async ({ url, token, local }) => {
2218
+ const connectWebSocketRpc = async ({ url, token, instanceId, local }) => {
2202
2219
  const { protocol, hostname, host } = new URL(url);
2203
2220
  if (protocol !== "wss:" && !(protocol === "ws:" && (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"))) throw new Error(`Refusing unencrypted WebSocket to ${host}`);
2204
- const socket = createWebsocket(url, { headers: { Authorization: `Bearer ${token}` } });
2205
- 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
+ })));
2206
2229
  const studio = (0, capnweb.newWebSocketRpcSession)(socket, new AgentRpcTarget(local));
2207
2230
  studio.onRpcBroken(() => socket.close());
2208
2231
  return {
@@ -2217,6 +2240,32 @@ const connectWebSocketRpc = async ({ url, token, local }) => {
2217
2240
  * Past this many files in the output directory, no snapshot of it is taken before a run.
2218
2241
  */
2219
2242
  const DISK_SNAPSHOT_MAX_FILES = 1e4;
2243
+ /**
2244
+ * How long a sandbox keeps a generation readable. Its store is in memory and holds every tenant's
2245
+ * runs, so an old one has to go even when count and size leave room. A local agent keeps its runs
2246
+ * until count or size pushes them out, so a later run can still diff against the one before it.
2247
+ */
2248
+ const SANDBOX_GENERATION_TTL_MS = 9e5;
2249
+ /**
2250
+ * A fresh root for one sandbox job. Kubb keys its output manifest cache by root, so tenants that
2251
+ * shared the agent's own root would read each other's manifest.
2252
+ */
2253
+ function createJobRoot() {
2254
+ return (0, node_fs_promises.mkdtemp)(node_path.default.join((0, node_os.tmpdir)(), "kubb-job-"));
2255
+ }
2256
+ /**
2257
+ * Removes a job root and the manifest cache Kubb derived from it. Best effort: a leftover temp
2258
+ * directory must not fail a job that already finished.
2259
+ */
2260
+ async function removeJobRoot(jobRoot) {
2261
+ await Promise.all([(0, node_fs_promises.rm)(jobRoot, {
2262
+ recursive: true,
2263
+ force: true
2264
+ }), (0, node_fs_promises.rm)((0, _kubb_core.resolveCacheDir)(jobRoot), {
2265
+ recursive: true,
2266
+ force: true
2267
+ })]).catch(() => {});
2268
+ }
2220
2269
  var GenerationRunTarget = class extends capnweb.RpcTarget {
2221
2270
  generationStream;
2222
2271
  generationResult;
@@ -2263,34 +2312,74 @@ function applyStudioDefaults(options) {
2263
2312
  ...options.permissions
2264
2313
  },
2265
2314
  retryInterval: options.retryInterval ?? agentDefaults.retryIntervalMs,
2266
- heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs)
2315
+ heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs),
2316
+ capacity: {
2317
+ ...resolveAgentCapacity(),
2318
+ ...options.capacity
2319
+ },
2320
+ instanceId: options.instanceId ?? (0, node_crypto.randomUUID)()
2267
2321
  };
2268
2322
  }
2269
2323
  /**
2270
- * 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.
2324
+ * Jobs one agent process can run at once today. Two runs would share this session's hook emitter,
2325
+ * and with it each other's events, until each job runs in its own worker (ADR-0003 slice B2).
2275
2326
  */
2276
- function reconnect(options) {
2277
- const { signal, retryInterval, onTokenRejected } = 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;
2278
2338
  if (signal?.aborted) return;
2279
2339
  const cancel = () => clearTimeout(timer);
2280
2340
  const timer = setTimeout(() => {
2281
2341
  signal?.removeEventListener("abort", cancel);
2282
2342
  if (signal?.aborted) return;
2283
- new StudioSession(options).start().catch((error) => {
2343
+ new StudioSession({
2344
+ ...options,
2345
+ reconnectAttempt: attempt
2346
+ }).start().catch((error) => {
2284
2347
  if (error instanceof InvalidAgentTokenError) {
2285
2348
  onTokenRejected?.(error);
2286
2349
  return;
2287
2350
  }
2288
- reconnect(options);
2351
+ if (error instanceof IncompatibleAgentError) return;
2352
+ const nextAttempt = attempt + 1;
2353
+ reconnect(options, backoffDelayMs(nextAttempt, options.retryInterval), nextAttempt);
2289
2354
  });
2290
- }, retryInterval);
2355
+ }, delayMs);
2291
2356
  signal?.addEventListener("abort", cancel, { once: true });
2292
2357
  }
2293
2358
  /**
2359
+ * Reads what Studio meant by closing the connection. A code Studio did not send on purpose is an
2360
+ * ordinary drop, and the agent reconnects as it always has.
2361
+ */
2362
+ function planEnd(close) {
2363
+ const code = close?.code;
2364
+ if (code === require_protocol.AgentCloseCode.REAUTHENTICATE) return {
2365
+ reason: "Kubb Studio asked the agent to register again",
2366
+ retry: true
2367
+ };
2368
+ if (code === require_protocol.AgentCloseCode.SUPERSEDED) return {
2369
+ reason: "another instance of this agent took over",
2370
+ retry: false
2371
+ };
2372
+ if (code === require_protocol.AgentCloseCode.INCOMPATIBLE) return {
2373
+ reason: "this agent is too old for Kubb Studio, or was deleted",
2374
+ retry: false,
2375
+ error: /* @__PURE__ */ new Error("Kubb Studio closed the connection: this agent is too old for it, or was deleted. Upgrade the agent, or pair it again.")
2376
+ };
2377
+ return {
2378
+ reason: "connection closed",
2379
+ retry: true
2380
+ };
2381
+ }
2382
+ /**
2294
2383
  * One agent-to-Studio RPC transport: opening it, keeping it alive, and serving remote methods.
2295
2384
  * `createClient` opens one per pool slot and is the only caller.
2296
2385
  */
@@ -2303,14 +2392,15 @@ var StudioSession = class {
2303
2392
  */
2304
2393
  #unhooks = [];
2305
2394
  /**
2306
- * What `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.
2395
+ * What registration handed back, and the marker for whether the agent registered at all.
2396
+ * Before it resolves there is no sandbox flag to read.
2308
2397
  */
2309
- #session;
2398
+ #registration;
2310
2399
  #rpc;
2311
2400
  #studioVersion;
2312
2401
  #disposed = false;
2313
2402
  #isGenerating = false;
2403
+ #activeJob;
2314
2404
  #heartbeatTimer;
2315
2405
  #lastGeneration;
2316
2406
  #store;
@@ -2320,17 +2410,17 @@ var StudioSession = class {
2320
2410
  * host does not queue jobs before the agent session is registered.
2321
2411
  */
2322
2412
  #connectAck = Promise.withResolvers();
2323
- #startupWarning;
2324
- constructor({ startupWarning, ...options }) {
2413
+ #reconnectAttempt;
2414
+ constructor({ reconnectAttempt, ...options }) {
2325
2415
  this.#options = applyStudioDefaults(options);
2326
- this.#startupWarning = startupWarning;
2416
+ this.#reconnectAttempt = reconnectAttempt ?? 0;
2327
2417
  this.#connectAck.promise.catch(() => {});
2328
2418
  }
2329
2419
  /**
2330
2420
  * A sandbox agent runs on Studio's own infrastructure, so it has no user project to touch.
2331
2421
  */
2332
2422
  get #isSandbox() {
2333
- return this.#session?.isSandbox === true;
2423
+ return this.#registration?.isSandbox === true;
2334
2424
  }
2335
2425
  /**
2336
2426
  * Kept in the project's cache directory, so it survives a restart, except on a sandbox: its pool
@@ -2340,7 +2430,8 @@ var StudioSession = class {
2340
2430
  this.#store ??= createGenerationStore({
2341
2431
  storage: this.#isSandbox ? (0, _kubb_core.memoryStorage)() : (0, _kubb_core.cacheStorage)({ root: this.#options.root }),
2342
2432
  maxCount: this.#limits.maxCount,
2343
- maxMb: this.#limits.maxMb
2433
+ maxMb: this.#limits.maxMb,
2434
+ ttlMs: this.#isSandbox ? SANDBOX_GENERATION_TTL_MS : void 0
2344
2435
  });
2345
2436
  return this.#store;
2346
2437
  }
@@ -2364,20 +2455,26 @@ var StudioSession = class {
2364
2455
  return this.#isSandbox || this.#options.permissions.allowRead;
2365
2456
  }
2366
2457
  async start() {
2367
- const { token, studioUrl, signal, heartbeatInterval, installLogger } = this.#options;
2458
+ const { token, studioUrl, signal, heartbeatInterval, installLogger, instanceId, capacity } = this.#options;
2368
2459
  await installLogger?.(this.#hooks);
2369
- if (this.#startupWarning) await this.#warn(this.#startupWarning);
2370
2460
  try {
2371
2461
  await this.#hooks.callHook("studio:connecting", { url: studioUrl });
2372
- 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,16 +2489,17 @@ 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;
2502
+ if (error instanceof InvalidAgentTokenError || error instanceof IncompatibleAgentError) throw error;
2405
2503
  await this.#reconnect();
2406
2504
  }
2407
2505
  }
@@ -2411,8 +2509,10 @@ var StudioSession = class {
2411
2509
  */
2412
2510
  async #reconnect() {
2413
2511
  if (this.#options.signal?.aborted) return;
2414
- await this.#hooks.callHook("studio:reconnecting", { delayMs: this.#options.retryInterval });
2415
- reconnect(this.#options);
2512
+ const attempt = this.#reconnectAttempt + 1;
2513
+ const delayMs = backoffDelayMs(attempt, this.#options.retryInterval);
2514
+ await this.#hooks.callHook("studio:reconnecting", { delayMs });
2515
+ reconnect(this.#options, delayMs, attempt);
2416
2516
  }
2417
2517
  #warn(message, permission) {
2418
2518
  return this.#hooks.callHook("studio:warn", {
@@ -2444,10 +2544,31 @@ var StudioSession = class {
2444
2544
  /**
2445
2545
  * Races `studio.ping()` against a deadline, so a half-open socket can't hang it forever.
2446
2546
  * */
2447
- #ping(rpc) {
2547
+ async #ping(rpc) {
2448
2548
  const { promise: timedOut, reject: onTimeout } = Promise.withResolvers();
2449
2549
  const timer = setTimeout(() => onTimeout(/* @__PURE__ */ new Error("Heartbeat ping timed out")), agentDefaults.heartbeatTimeoutMs);
2450
- 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
+ };
2451
2572
  }
2452
2573
  /**
2453
2574
  * Reads `kubb.config.ts` and reports which plugin options Studio may edit.
@@ -2492,8 +2613,11 @@ var StudioSession = class {
2492
2613
  this.#connectAck.resolve();
2493
2614
  return payload;
2494
2615
  }
2495
- #onAbort = () => void this.#end({ retry: false });
2496
- #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));
2497
2621
  /**
2498
2622
  * Drops the socket and detaches every listener and timer this session added. Idempotent, and
2499
2623
  * safe before `connect` opened anything.
@@ -2513,17 +2637,12 @@ var StudioSession = class {
2513
2637
  * Ends the session: tells Studio it is over, drops the socket, and optionally reconnects.
2514
2638
  * `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
2515
2639
  */
2516
- async #end({ retry }) {
2517
- const { studioUrl, token } = this.#options;
2640
+ async #end({ reason, retry, error }) {
2518
2641
  if (this.#disposed) return;
2519
2642
  this.#disposed = true;
2520
2643
  this.dispose();
2521
- await this.#hooks.callHook("studio:disconnected", { reason: 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");
2644
+ await this.#hooks.callHook("studio:disconnected", { reason });
2645
+ if (error) await this.#hooks.callHook("studio:error", { error });
2527
2646
  if (retry) await this.#reconnect();
2528
2647
  }
2529
2648
  startGeneration(data) {
@@ -2531,27 +2650,43 @@ var StudioSession = class {
2531
2650
  this.#lastGeneration = result;
2532
2651
  } });
2533
2652
  const controller = new AbortController();
2534
- const 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) => {
2535
2657
  await generationStream.close();
2536
2658
  return value;
2537
2659
  }).catch((error) => {
2538
2660
  generationStream.fail(error);
2539
2661
  throw error;
2662
+ }).finally(() => {
2663
+ if (this.#activeJob?.cancel === cancelRun) this.#activeJob = void 0;
2540
2664
  });
2541
2665
  result.catch(() => {});
2542
- return new GenerationRunTarget(generationStream.stream, result, async () => {
2543
- controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
2544
- }, () => {
2666
+ return new GenerationRunTarget(generationStream.stream, result, cancelRun, () => {
2545
2667
  controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
2546
2668
  generationStream.dispose();
2547
2669
  });
2548
2670
  }
2549
- 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) {
2550
2678
  if (this.#isGenerating) return this.#refuse("Ignored generate: a generation is already in progress", "A generation is already in progress, please wait for it to finish");
2679
+ if (!this.#isAccepting()) return this.#refuse("Ignored generate: the agent is past its memory budget", "The agent is past its memory budget, try again once it frees memory");
2551
2680
  this.#isGenerating = true;
2681
+ this.#activeJob = {
2682
+ jobId: data.jobId,
2683
+ cancel: cancelRun
2684
+ };
2552
2685
  const command = "generate";
2553
- const { root, loadConfig, permissions } = this.#options;
2686
+ const { loadConfig, permissions } = this.#options;
2687
+ let root = this.#options.root;
2554
2688
  try {
2689
+ if (this.#isSandbox) root = await createJobRoot();
2555
2690
  await this.#hooks.callHook("studio:command:start", { command });
2556
2691
  const config = await loadConfig();
2557
2692
  const patch = data.config;
@@ -2630,6 +2765,7 @@ var StudioSession = class {
2630
2765
  disk: disk ? { hashes: disk.hashes } : void 0
2631
2766
  };
2632
2767
  } finally {
2768
+ if (root !== this.#options.root) await removeJobRoot(root);
2633
2769
  this.#isGenerating = false;
2634
2770
  }
2635
2771
  }
@@ -2777,7 +2913,7 @@ var StudioSession = class {
2777
2913
  */
2778
2914
  function createClient({ onAuthRequired, ...options }) {
2779
2915
  const controller = new AbortController();
2780
- const poolSize = options.poolSize ?? agentDefaults.poolSize;
2916
+ const instanceId = (0, node_crypto.randomUUID)();
2781
2917
  function notifyAuthRequired(error) {
2782
2918
  if (controller.signal.aborted) return;
2783
2919
  controller.abort();
@@ -2785,19 +2921,12 @@ function createClient({ onAuthRequired, ...options }) {
2785
2921
  }
2786
2922
  return {
2787
2923
  async connect() {
2788
- 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({
2924
+ await new StudioSession({
2796
2925
  ...options,
2926
+ instanceId,
2797
2927
  signal: controller.signal,
2798
- onTokenRejected: notifyAuthRequired,
2799
- startupWarning: slot === 0 ? startupWarning : void 0
2800
- }).start()));
2928
+ onTokenRejected: notifyAuthRequired
2929
+ }).start();
2801
2930
  },
2802
2931
  disconnect() {
2803
2932
  controller.abort();
@@ -3001,6 +3130,9 @@ async function pairAgent({ onCode, onRetry, maxAttempts = 1, ...options }) {
3001
3130
  }
3002
3131
  }
3003
3132
  //#endregion
3133
+ exports.AGENT_INSTANCE_HEADER = require_protocol.AGENT_INSTANCE_HEADER;
3134
+ exports.AgentCloseCode = require_protocol.AgentCloseCode;
3135
+ exports.IncompatibleAgentError = IncompatibleAgentError;
3004
3136
  exports.InvalidAgentTokenError = InvalidAgentTokenError;
3005
3137
  exports.PairingCanceledError = PairingCanceledError;
3006
3138
  exports.PairingDeniedError = PairingDeniedError;