@kubb/studio 5.3.19 → 5.4.1

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/README.md CHANGED
@@ -85,7 +85,7 @@ every host.
85
85
 
86
86
  | Step | `kubb studio` | Docker agent | `kubb studio snapshot` |
87
87
  | ---------------- | --------------------------------------- | ---------------------------------------- | ---------------------------------------- |
88
- | Machine identity | `setStorage` under `~/.kubb` | `setStorage` on the Nitro `kubb` mount | `KUBB_AGENT_SECRET` from the CI identity |
88
+ | Machine identity | `setStorage` under project Kubb home | `setStorage` on the cache volume | `KUBB_AGENT_SECRET` from the CI identity |
89
89
  | Credentials | stored, or `pairAgent({ type: 'cli' })` | stored, or `pairAgent({ type: 'user' })` | `createAgent` with the CI API key |
90
90
  | Permissions | flags and a per-project prompt | `KUBB_AGENT_ALLOW_*` | flags only |
91
91
  | Connection | `runConnection` | `runConnection` | `runConnection` |
@@ -128,8 +128,11 @@ come back: `4001` registers and reconnects, `4002` (another instance took over)
128
128
  agent is too old or was deleted) stay down. A `426` at registration means the agent is too old for
129
129
  that Studio.
130
130
 
131
- The runtime reconnects on its own when the socket drops, and keeps retrying while Studio is
132
- unreachable. Generation progress is a native Cap'n Web `ReadableStream` on the generation
131
+ `runConnection` owns registration retries, capped reconnect backoff, credential replacement, and
132
+ shutdown. `StudioSession` serves one attempt and waits for canceled generation work to settle before
133
+ the loop reconnects. `createClient` remains a thin compatibility facade over that loop.
134
+
135
+ The runtime reconnects when the socket drops and keeps retrying while Studio is unreachable. Generation progress is a native Cap'n Web `ReadableStream` on the generation
133
136
  capability; durable job status is read through the HTTP job API.
134
137
 
135
138
  ## Studio job events
package/dist/index.cjs CHANGED
@@ -27,20 +27,20 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
27
27
  //#endregion
28
28
  const require_protocol = require("./protocol.cjs");
29
29
  let node_crypto = require("node:crypto");
30
- let node_fs_promises = require("node:fs/promises");
31
- let node_os = require("node:os");
32
- let node_path = require("node:path");
33
- node_path = __toESM(node_path, 1);
30
+ let node_timers_promises = require("node:timers/promises");
34
31
  let node_process = require("node:process");
35
32
  node_process = __toESM(node_process, 1);
36
33
  let node_child_process = require("node:child_process");
37
- let _kubb_core = require("@kubb/core");
38
- let node_timers_promises = require("node:timers/promises");
34
+ let node_fs_promises = require("node:fs/promises");
35
+ let node_path = require("node:path");
36
+ node_path = __toESM(node_path, 1);
39
37
  let ofetch = require("ofetch");
40
38
  let node_util = require("node:util");
41
39
  let unstorage = require("unstorage");
42
40
  let unstorage_drivers_fs = require("unstorage/drivers/fs");
43
41
  unstorage_drivers_fs = __toESM(unstorage_drivers_fs, 1);
42
+ let node_os = require("node:os");
43
+ let _kubb_core = require("@kubb/core");
44
44
  let magicast = require("magicast");
45
45
  let node_fs = require("node:fs");
46
46
  let node_module = require("node:module");
@@ -374,9 +374,6 @@ function getElapsedMs(hrStart) {
374
374
  return Math.round(ms * 100) / 100;
375
375
  }
376
376
  //#endregion
377
- //#region package.json
378
- var version = "5.3.19";
379
- //#endregion
380
377
  //#region src/operations/machine.ts
381
378
  /**
382
379
  * Key-value storage the runtime uses for its machine secret and the last Studio config.
@@ -453,10 +450,6 @@ function responseMessage(data) {
453
450
  ]) if (typeof value === "string" && value) return value;
454
451
  }
455
452
  /**
456
- * Retries after the first registration attempt, each backing off twice as far as the last.
457
- */
458
- const REGISTER_RETRIES = 3;
459
- /**
460
453
  * Thrown when Studio rejects the agent token itself (401). Retrying cannot help: the token was
461
454
  * revoked, or the agent it belonged to was deleted in the Studio UI. Hosts catch this to forget
462
455
  * the stored credential and pair again.
@@ -493,11 +486,11 @@ function registrationError(cause) {
493
486
  * identity to the token, reports what the process can take on, and gets back the URL of the one
494
487
  * socket it keeps open.
495
488
  *
496
- * Retries a transient failure with backoff. A rejected token (401) throws
489
+ * Makes one bounded attempt; `runConnection` owns retries. A rejected token (401) throws
497
490
  * {@link InvalidAgentTokenError} and an unsupported agent version (426) throws
498
491
  * {@link IncompatibleAgentError}, since retrying either cannot help.
499
492
  */
500
- async function registerAgent({ token, studioUrl, instanceId, capacity }) {
493
+ async function registerAgent({ token, studioUrl, instanceId, capacity, signal }) {
501
494
  const body = {
502
495
  machineToken: await getMachineToken(),
503
496
  instanceId,
@@ -508,8 +501,9 @@ async function registerAgent({ token, studioUrl, instanceId, capacity }) {
508
501
  method: "POST",
509
502
  headers: { Authorization: `Bearer ${token}` },
510
503
  body,
511
- retry: REGISTER_RETRIES,
512
- retryDelay: ({ options }) => 2e3 * 2 ** (REGISTER_RETRIES - Number(options.retry))
504
+ retry: 0,
505
+ timeout: 1e4,
506
+ signal
513
507
  });
514
508
  } catch (error) {
515
509
  if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
@@ -679,6 +673,60 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
679
673
  }
680
674
  }
681
675
  //#endregion
676
+ //#region src/operations/constants.ts
677
+ /**
678
+ * Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
679
+ * not whatever default the client would pick on its own.
680
+ */
681
+ const defaultStudioUrl = "https://kubb.studio";
682
+ /**
683
+ * Defaults the Studio client uses when a host passes nothing.
684
+ * Config path is left out on purpose: each host discovers that itself.
685
+ */
686
+ const agentDefaults = {
687
+ studioUrl: defaultStudioUrl,
688
+ retryIntervalMs: 3e4,
689
+ heartbeatIntervalMs: 3e4,
690
+ /**
691
+ * Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
692
+ * stored ping is older than its liveness window, and it stores a ping at most once a minute, so
693
+ * a slower cadence would make a healthy agent look dead after a single missed ping.
694
+ */
695
+ maxHeartbeatIntervalMs: 6e4,
696
+ /** How long a heartbeat ping may take before the session is treated as dead. */
697
+ heartbeatTimeoutMs: 1e4,
698
+ maxConcurrent: 1,
699
+ maxGenerations: 8,
700
+ maxGenerationsMb: 100,
701
+ maxSnapshotMb: 50
702
+ };
703
+ function positiveNumber(value) {
704
+ const parsed = Number(value);
705
+ return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
706
+ }
707
+ /**
708
+ * How many generations an agent keeps and how large they may get, read from
709
+ * `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
710
+ * An unset or invalid value keeps the default.
711
+ */
712
+ function resolveGenerationLimits(env = process.env) {
713
+ return {
714
+ maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
715
+ maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
716
+ maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
717
+ };
718
+ }
719
+ /**
720
+ * An agent's capacity read from `KUBB_AGENT_MAX_CONCURRENT`. An unset or invalid value keeps the
721
+ * default: one job at a time.
722
+ */
723
+ function resolveAgentCapacity(env = process.env) {
724
+ return { maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)) };
725
+ }
726
+ //#endregion
727
+ //#region package.json
728
+ var version = "5.4.1";
729
+ //#endregion
682
730
  //#region src/operations/resolveConfig.ts
683
731
  /**
684
732
  * Imports a package, falling back to how the user's project would resolve it.
@@ -1414,57 +1462,6 @@ function withTrailingNewline(code, hadTrailingNewline) {
1414
1462
  return `${code}\n`;
1415
1463
  }
1416
1464
  //#endregion
1417
- //#region src/operations/constants.ts
1418
- /**
1419
- * Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
1420
- * not whatever default the client would pick on its own.
1421
- */
1422
- const defaultStudioUrl = "https://kubb.studio";
1423
- /**
1424
- * Defaults the Studio client uses when a host passes nothing.
1425
- * Config path is left out on purpose: each host discovers that itself.
1426
- */
1427
- const agentDefaults = {
1428
- studioUrl: defaultStudioUrl,
1429
- retryIntervalMs: 3e4,
1430
- heartbeatIntervalMs: 3e4,
1431
- /**
1432
- * Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
1433
- * stored ping is older than its liveness window, and it stores a ping at most once a minute, so
1434
- * a slower cadence would make a healthy agent look dead after a single missed ping.
1435
- */
1436
- maxHeartbeatIntervalMs: 6e4,
1437
- /** How long a heartbeat ping may take before the session is treated as dead. */
1438
- heartbeatTimeoutMs: 1e4,
1439
- maxConcurrent: 1,
1440
- maxGenerations: 8,
1441
- maxGenerationsMb: 100,
1442
- maxSnapshotMb: 50
1443
- };
1444
- function positiveNumber(value) {
1445
- const parsed = Number(value);
1446
- return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1447
- }
1448
- /**
1449
- * How many generations an agent keeps and how large they may get, read from
1450
- * `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
1451
- * An unset or invalid value keeps the default.
1452
- */
1453
- function resolveGenerationLimits(env = process.env) {
1454
- return {
1455
- maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
1456
- maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
1457
- maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
1458
- };
1459
- }
1460
- /**
1461
- * An agent's capacity read from `KUBB_AGENT_MAX_CONCURRENT`. An unset or invalid value keeps the
1462
- * default: one job at a time.
1463
- */
1464
- function resolveAgentCapacity(env = process.env) {
1465
- return { maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)) };
1466
- }
1467
- //#endregion
1468
1465
  //#region src/operations/snapshotPackage.ts
1469
1466
  const gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
1470
1467
  const UPLOAD_TIMEOUT_MS = 12e4;
@@ -2377,39 +2374,13 @@ function applyStudioDefaults(options) {
2377
2374
  }
2378
2375
  /**
2379
2376
  * Jobs one agent process can run at once today. Two runs would share this session's hook emitter,
2380
- * and with it each other's events, until each job runs in its own worker (ADR-0003 slice B2).
2377
+ * and with it each other's events, so the connection keeps this limit.
2381
2378
  */
2382
2379
  const RUNTIME_MAX_CONCURRENT = 1;
2383
2380
  const MB = 1048576;
2384
2381
  function rssMb() {
2385
2382
  return node_process.default.memoryUsage().rss / MB;
2386
2383
  }
2387
- function backoffDelayMs(attempt, maxMs) {
2388
- const cap = Math.min(1e3 * 2 ** (attempt - 1), maxMs);
2389
- return Math.random() * cap;
2390
- }
2391
- function reconnect(options, delayMs, attempt) {
2392
- const { signal, onTokenRejected } = options;
2393
- if (signal?.aborted) return;
2394
- const cancel = () => clearTimeout(timer);
2395
- const timer = setTimeout(() => {
2396
- signal?.removeEventListener("abort", cancel);
2397
- if (signal?.aborted) return;
2398
- new StudioSession({
2399
- ...options,
2400
- reconnectAttempt: attempt
2401
- }).start().catch((error) => {
2402
- if (error instanceof InvalidAgentTokenError) {
2403
- onTokenRejected?.(error);
2404
- return;
2405
- }
2406
- if (error instanceof IncompatibleAgentError) return;
2407
- const nextAttempt = attempt + 1;
2408
- reconnect(options, backoffDelayMs(nextAttempt, options.retryInterval), nextAttempt);
2409
- });
2410
- }, delayMs);
2411
- signal?.addEventListener("abort", cancel, { once: true });
2412
- }
2413
2384
  /**
2414
2385
  * Reads what Studio meant by closing the connection. A code Studio did not send on purpose is an
2415
2386
  * ordinary drop, and the agent reconnects as it always has.
@@ -2436,7 +2407,7 @@ function planEnd(close) {
2436
2407
  }
2437
2408
  /**
2438
2409
  * One agent-to-Studio RPC transport: opening it, keeping it alive, and serving remote methods.
2439
- * `createClient` opens one per pool slot and is the only caller.
2410
+ * `runConnection` owns the lifetime and retry of each attempt.
2440
2411
  */
2441
2412
  var StudioSession = class {
2442
2413
  #options;
@@ -2465,10 +2436,11 @@ var StudioSession = class {
2465
2436
  * host does not queue jobs before the agent session is registered.
2466
2437
  */
2467
2438
  #connectAck = Promise.withResolvers();
2468
- #reconnectAttempt;
2469
- constructor({ reconnectAttempt, ...options }) {
2439
+ #ended = Promise.withResolvers();
2440
+ closed = this.#ended.promise;
2441
+ #generationResult;
2442
+ constructor(options) {
2470
2443
  this.#options = applyStudioDefaults(options);
2471
- this.#reconnectAttempt = reconnectAttempt ?? 0;
2472
2444
  this.#connectAck.promise.catch(() => {});
2473
2445
  }
2474
2446
  /**
@@ -2505,10 +2477,14 @@ var StudioSession = class {
2505
2477
  async start() {
2506
2478
  const { token, studioUrl, signal, heartbeatInterval, installLogger, instanceId, capacity } = this.#options;
2507
2479
  await installLogger?.(this.#hooks);
2480
+ signal?.addEventListener("abort", this.#onAbort, { once: true });
2481
+ this.#unhooks.push(() => signal?.removeEventListener("abort", this.#onAbort));
2508
2482
  try {
2483
+ signal?.throwIfAborted();
2509
2484
  await this.#hooks.callHook("studio:connecting", { url: studioUrl });
2510
- 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`);
2485
+ if (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`);
2511
2486
  const registration = await registerAgent({
2487
+ signal,
2512
2488
  token,
2513
2489
  studioUrl,
2514
2490
  instanceId,
@@ -2517,6 +2493,7 @@ var StudioSession = class {
2517
2493
  maxConcurrent: Math.min(capacity.maxConcurrent, RUNTIME_MAX_CONCURRENT)
2518
2494
  }
2519
2495
  });
2496
+ signal?.throwIfAborted();
2520
2497
  this.#registration = registration;
2521
2498
  this.#studioVersion = registration.version;
2522
2499
  const rpc = await (this.#options.connector ?? connectWebSocketRpc)({
@@ -2527,8 +2504,10 @@ var StudioSession = class {
2527
2504
  });
2528
2505
  this.#rpc = rpc;
2529
2506
  rpc.closed.then(this.#onClose);
2530
- signal?.addEventListener("abort", this.#onAbort, { once: true });
2531
- this.#unhooks.push(() => signal?.removeEventListener("abort", this.#onAbort));
2507
+ if (signal?.aborted || this.#disposed) {
2508
+ rpc.close();
2509
+ throw new Error("Connection stopped during startup");
2510
+ }
2532
2511
  this.#scheduleHeartbeat(heartbeatInterval);
2533
2512
  await this.#hooks.callHook("studio:connected", {
2534
2513
  url: studioUrl,
@@ -2541,26 +2520,15 @@ var StudioSession = class {
2541
2520
  organizationSlug: registration.organizationSlug
2542
2521
  });
2543
2522
  await this.#connectAck.promise;
2544
- this.#reconnectAttempt = 0;
2545
2523
  await this.#hooks.callHook("studio:ready", {});
2546
2524
  } catch (error) {
2547
- this.#disposed = true;
2548
- this.dispose();
2549
- await this.#hooks.callHook("studio:error", { error: toError(error) });
2550
- if (error instanceof InvalidAgentTokenError || error instanceof IncompatibleAgentError) throw error;
2551
- await this.#reconnect();
2525
+ await this.dispose();
2526
+ if (!signal?.aborted) await this.#hooks.callHook("studio:error", { error: toError(error) });
2527
+ throw error;
2552
2528
  }
2553
2529
  }
2554
- /**
2555
- * Tells the host a retry is coming, then schedules it. The host prints the retry, since the
2556
- * runtime has no output of its own.
2557
- */
2558
- async #reconnect() {
2559
- if (this.#options.signal?.aborted) return;
2560
- const attempt = this.#reconnectAttempt + 1;
2561
- const delayMs = backoffDelayMs(attempt, this.#options.retryInterval);
2562
- await this.#hooks.callHook("studio:reconnecting", { delayMs });
2563
- reconnect(this.#options, delayMs, attempt);
2530
+ reportRetry(delayMs) {
2531
+ return this.#hooks.callHook("studio:reconnecting", { delayMs });
2564
2532
  }
2565
2533
  #warn(message, permission) {
2566
2534
  return this.#hooks.callHook("studio:warn", {
@@ -2655,34 +2623,35 @@ var StudioSession = class {
2655
2623
  #onAbort = () => void this.#end({
2656
2624
  reason: "shutdown",
2657
2625
  retry: false
2658
- });
2659
- #onClose = (close) => void this.#end(planEnd(close));
2660
- /**
2661
- * Drops the socket and detaches every listener and timer this session added. Idempotent, and
2662
- * safe before `connect` opened anything.
2663
- *
2664
- * @internal
2665
- */
2626
+ }).catch(() => {});
2627
+ #onClose = (close) => void this.#end(planEnd(close)).catch(() => {});
2628
+ /** Closes this attempt and waits for its generation to stop before another can start. */
2666
2629
  dispose() {
2630
+ return this.#end({
2631
+ reason: "shutdown",
2632
+ retry: false
2633
+ });
2634
+ }
2635
+ async #end(plan) {
2636
+ if (this.#disposed) {
2637
+ await this.closed;
2638
+ return;
2639
+ }
2640
+ this.#disposed = true;
2667
2641
  clearTimeout(this.#heartbeatTimer);
2668
- this.#heartbeatTimer = void 0;
2669
2642
  this.#rpc?.close();
2670
2643
  this.#rpc = void 0;
2671
2644
  this.#connectAck.reject(/* @__PURE__ */ new Error("Session ended before Studio called connect()"));
2672
2645
  for (const unhook of this.#unhooks) unhook();
2673
2646
  this.#unhooks.length = 0;
2674
- }
2675
- /**
2676
- * Ends the session: tells Studio it is over, drops the socket, and optionally reconnects.
2677
- * `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
2678
- */
2679
- async #end({ reason, retry, error }) {
2680
- if (this.#disposed) return;
2681
- this.#disposed = true;
2682
- this.dispose();
2683
- await this.#hooks.callHook("studio:disconnected", { reason });
2684
- if (error) await this.#hooks.callHook("studio:error", { error });
2685
- if (retry) await this.#reconnect();
2647
+ try {
2648
+ await this.#activeJob?.cancel();
2649
+ await this.#generationResult?.catch(() => {});
2650
+ await this.#hooks.callHook("studio:disconnected", { reason: plan.reason });
2651
+ if (plan.error) await this.#hooks.callHook("studio:error", { error: plan.error });
2652
+ } finally {
2653
+ this.#ended.resolve(plan);
2654
+ }
2686
2655
  }
2687
2656
  startGeneration(data) {
2688
2657
  const generationStream = createGenerationStream(this.#hooks, data.jobId, { onGenerationEnd: (result) => {
@@ -2701,6 +2670,7 @@ var StudioSession = class {
2701
2670
  }).finally(() => {
2702
2671
  if (this.#activeJob?.cancel === cancelRun) this.#activeJob = void 0;
2703
2672
  });
2673
+ if (this.#activeJob?.cancel === cancelRun) this.#generationResult = result;
2704
2674
  result.catch(() => {});
2705
2675
  return new GenerationRunTarget(generationStream.stream, result, cancelRun, () => {
2706
2676
  controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
@@ -2714,6 +2684,7 @@ var StudioSession = class {
2714
2684
  if (this.#activeJob?.jobId === jobId) await this.#activeJob.cancel();
2715
2685
  }
2716
2686
  async #runGeneration(data, controller, cancelRun) {
2687
+ if (this.#disposed) throw new Error("Connection is closed");
2717
2688
  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");
2718
2689
  this.#isGenerating = true;
2719
2690
  this.#activeJob = {
@@ -2909,114 +2880,92 @@ var StudioSession = class {
2909
2880
  }
2910
2881
  };
2911
2882
  //#endregion
2912
- //#region src/runtime/client.ts
2913
- /**
2914
- * Creates the Kubb Studio client: the connection, the command loop, and the generation event
2915
- * stream shared by the `kubb studio` CLI command and the Docker agent.
2916
- *
2917
- * Every permission is off by default. A host that wants more grants it explicitly. The machine
2918
- * identity comes from the storage the host installed with `setStorage`, before connecting.
2919
- *
2920
- * @example
2921
- * ```ts
2922
- * const studio = createClient({ token, configPath, version, loadConfig: () => loadMyConfig() })
2923
- * await studio.connect()
2924
- * ```
2925
- */
2926
- function createClient({ onAuthRequired, ...options }) {
2927
- const controller = new AbortController();
2928
- const instanceId = options.instanceId ?? (0, node_crypto.randomUUID)();
2929
- function notifyAuthRequired(error) {
2930
- if (controller.signal.aborted) return;
2931
- controller.abort();
2932
- onAuthRequired?.(error);
2933
- }
2934
- return {
2935
- async connect() {
2936
- await new StudioSession({
2937
- ...options,
2938
- instanceId,
2939
- signal: controller.signal,
2940
- onTokenRejected: notifyAuthRequired
2941
- }).start();
2942
- },
2943
- disconnect() {
2944
- controller.abort();
2945
- }
2946
- };
2947
- }
2948
- //#endregion
2949
2883
  //#region src/runtime/runConnection.ts
2950
- /**
2951
- * Waits for whichever comes first: the shutdown signal, or Studio rejecting the token during a
2952
- * background reconnect. Resolves with the rejection, or nothing when the run is being shut down.
2953
- */
2954
- function waitForRejection(authRequired, signal) {
2955
- if (!signal) return authRequired;
2956
- const settled = new AbortController();
2957
- const shutdown = new Promise((resolve) => {
2958
- if (signal.aborted) {
2959
- resolve(void 0);
2960
- return;
2961
- }
2962
- signal.addEventListener("abort", () => resolve(void 0), {
2963
- once: true,
2964
- signal: settled.signal
2965
- });
2966
- });
2967
- return Promise.race([shutdown, authRequired]).finally(() => settled.abort());
2968
- }
2969
- /**
2970
- * Keeps a host connected to Studio across token changes: it opens a client, waits until the run
2971
- * ends or Studio rejects the token, and reconnects with whatever credential the host hands back.
2972
- *
2973
- * The host owns everything around that. Where credentials live, whether a rejected token may be
2974
- * replaced, and how any of it is reported are all decisions `onTokenRejected` makes.
2975
- *
2976
- * @example
2977
- * ```ts
2978
- * const outcome = await runConnection({
2979
- * credentials,
2980
- * clientOptions: () => ({ studioUrl, configPath, version, loadConfig }),
2981
- * signal: shutdown.signal,
2982
- * onTokenRejected: ({ error, live }) => pairAgain(error, live),
2983
- * })
2984
- * ```
2985
- */
2986
- async function runConnection({ credentials, clientOptions, onTokenRejected, signal }) {
2884
+ /** Runs the process's one connection loop, including retries and credential replacement. */
2885
+ async function runConnection({ credentials, clientOptions, onTokenRejected, signal, onStarted }) {
2987
2886
  let current = credentials;
2988
- while (true) {
2989
- if (signal?.aborted) return "shutdown";
2990
- const { promise: authRequired, resolve: notifyAuthRequired } = Promise.withResolvers();
2991
- const client = createClient({
2992
- ...clientOptions(current),
2887
+ const initialOptions = clientOptions(current);
2888
+ const instanceId = initialOptions.instanceId ?? (0, node_crypto.randomUUID)();
2889
+ let options = initialOptions;
2890
+ let attempt = 0;
2891
+ let live = false;
2892
+ while (!signal?.aborted) {
2893
+ const session = new StudioSession({
2894
+ ...options,
2895
+ instanceId,
2993
2896
  token: current.token,
2994
- onAuthRequired: notifyAuthRequired
2897
+ signal
2995
2898
  });
2996
- let rejection;
2997
2899
  try {
2998
- await client.connect();
2999
- const error = await waitForRejection(authRequired, signal);
3000
- if (!error) return "shutdown";
3001
- rejection = {
3002
- error,
3003
- credentials: current,
3004
- live: true
3005
- };
2900
+ await session.start();
2901
+ live = true;
2902
+ attempt = 0;
2903
+ onStarted?.();
2904
+ if (!(await session.closed).retry) return signal?.aborted ? "shutdown" : "stopped";
3006
2905
  } catch (error) {
3007
- if (!(error instanceof InvalidAgentTokenError)) throw error;
3008
- rejection = {
3009
- error,
3010
- credentials: current,
3011
- live: false
3012
- };
2906
+ if (signal?.aborted) return "shutdown";
2907
+ if (error instanceof IncompatibleAgentError) throw error;
2908
+ await session.dispose();
2909
+ const end = await session.closed;
2910
+ if (!end.retry && end.reason !== "shutdown") return "stopped";
2911
+ if (error instanceof InvalidAgentTokenError) {
2912
+ const next = await onTokenRejected({
2913
+ error,
2914
+ credentials: current,
2915
+ live
2916
+ });
2917
+ if (!next) return "stopped";
2918
+ current = next;
2919
+ options = clientOptions(current);
2920
+ live = false;
2921
+ attempt = 0;
2922
+ continue;
2923
+ }
3013
2924
  } finally {
3014
- client.disconnect();
2925
+ await session.dispose();
2926
+ }
2927
+ onStarted?.();
2928
+ const cap = Math.min(1e3 * 2 ** Math.min(attempt++, 16), options.retryInterval ?? agentDefaults.retryIntervalMs);
2929
+ const delayMs = Math.random() * cap;
2930
+ await session.reportRetry(delayMs);
2931
+ try {
2932
+ await (0, node_timers_promises.setTimeout)(delayMs, void 0, { signal });
2933
+ } catch (error) {
2934
+ if (!signal?.aborted) throw error;
3015
2935
  }
3016
- const next = await onTokenRejected(rejection);
3017
- if (!next) return "stopped";
3018
- current = next;
3019
2936
  }
2937
+ return "shutdown";
2938
+ }
2939
+ //#endregion
2940
+ //#region src/runtime/client.ts
2941
+ /** Compatibility facade over the shared connection loop. */
2942
+ function createClient({ onAuthRequired, ...options }) {
2943
+ const shutdown = new AbortController();
2944
+ const started = Promise.withResolvers();
2945
+ let resolved = false;
2946
+ let running;
2947
+ return {
2948
+ connect() {
2949
+ running ??= runConnection({
2950
+ credentials: { token: options.token },
2951
+ clientOptions: () => options,
2952
+ signal: shutdown.signal,
2953
+ onStarted: () => {
2954
+ resolved = true;
2955
+ started.resolve();
2956
+ },
2957
+ onTokenRejected: async ({ error }) => {
2958
+ if (!resolved) throw error;
2959
+ onAuthRequired?.(error);
2960
+ return null;
2961
+ }
2962
+ }).then(() => started.resolve(), (error) => started.reject(error));
2963
+ return started.promise;
2964
+ },
2965
+ disconnect() {
2966
+ shutdown.abort();
2967
+ }
2968
+ };
3020
2969
  }
3021
2970
  //#endregion
3022
2971
  //#region src/operations/pair.ts