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