@kubb/studio 5.4.0 → 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/dist/index.js CHANGED
@@ -2,17 +2,17 @@ import { t as __name } from "./rolldown-runtime-CRm0XQPb.js";
2
2
  import { AGENT_INSTANCE_HEADER, AgentCloseCode, GENERATION_GONE_MESSAGE, generationEventTypes } from "./protocol.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { createHash, hash, randomBytes, randomUUID } from "node:crypto";
5
- import { glob, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
6
- import { tmpdir } from "node:os";
7
- import path, { isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
8
6
  import process$1 from "node:process";
9
7
  import { spawn } from "node:child_process";
10
- import { Diagnostics, Hookable, cacheStorage, createKubb, fsStorage, memoryStorage, resolveCacheDir } from "@kubb/core";
11
- import { setTimeout as setTimeout$1 } from "node:timers/promises";
8
+ import { glob, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
9
+ import path, { isAbsolute, join, relative, resolve, sep } from "node:path";
12
10
  import { FetchError, ofetch } from "ofetch";
13
11
  import { promisify, styleText } from "node:util";
14
12
  import { createStorage } from "unstorage";
15
13
  import fsDriver from "unstorage/drivers/fs";
14
+ import { tmpdir } from "node:os";
15
+ import { Diagnostics, Hookable, cacheStorage, createKubb, fsStorage, memoryStorage, resolveCacheDir } from "@kubb/core";
16
16
  import { builders, detectCodeFormat, generateCode, parseModule } from "magicast";
17
17
  import { existsSync } from "node:fs";
18
18
  import { pathToFileURL } from "node:url";
@@ -344,9 +344,6 @@ function getElapsedMs(hrStart) {
344
344
  return Math.round(ms * 100) / 100;
345
345
  }
346
346
  //#endregion
347
- //#region package.json
348
- var version = "5.4.0";
349
- //#endregion
350
347
  //#region src/operations/machine.ts
351
348
  /**
352
349
  * Key-value storage the runtime uses for its machine secret and the last Studio config.
@@ -423,10 +420,6 @@ function responseMessage(data) {
423
420
  ]) if (typeof value === "string" && value) return value;
424
421
  }
425
422
  /**
426
- * Retries after the first registration attempt, each backing off twice as far as the last.
427
- */
428
- const REGISTER_RETRIES = 3;
429
- /**
430
423
  * Thrown when Studio rejects the agent token itself (401). Retrying cannot help: the token was
431
424
  * revoked, or the agent it belonged to was deleted in the Studio UI. Hosts catch this to forget
432
425
  * the stored credential and pair again.
@@ -463,11 +456,11 @@ function registrationError(cause) {
463
456
  * identity to the token, reports what the process can take on, and gets back the URL of the one
464
457
  * socket it keeps open.
465
458
  *
466
- * Retries a transient failure with backoff. A rejected token (401) throws
459
+ * Makes one bounded attempt; `runConnection` owns retries. A rejected token (401) throws
467
460
  * {@link InvalidAgentTokenError} and an unsupported agent version (426) throws
468
461
  * {@link IncompatibleAgentError}, since retrying either cannot help.
469
462
  */
470
- async function registerAgent({ token, studioUrl, instanceId, capacity }) {
463
+ async function registerAgent({ token, studioUrl, instanceId, capacity, signal }) {
471
464
  const body = {
472
465
  machineToken: await getMachineToken(),
473
466
  instanceId,
@@ -478,8 +471,9 @@ async function registerAgent({ token, studioUrl, instanceId, capacity }) {
478
471
  method: "POST",
479
472
  headers: { Authorization: `Bearer ${token}` },
480
473
  body,
481
- retry: REGISTER_RETRIES,
482
- retryDelay: ({ options }) => 2e3 * 2 ** (REGISTER_RETRIES - Number(options.retry))
474
+ retry: 0,
475
+ timeout: 1e4,
476
+ signal
483
477
  });
484
478
  } catch (error) {
485
479
  if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
@@ -649,6 +643,60 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
649
643
  }
650
644
  }
651
645
  //#endregion
646
+ //#region src/operations/constants.ts
647
+ /**
648
+ * Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
649
+ * not whatever default the client would pick on its own.
650
+ */
651
+ const defaultStudioUrl = "https://kubb.studio";
652
+ /**
653
+ * Defaults the Studio client uses when a host passes nothing.
654
+ * Config path is left out on purpose: each host discovers that itself.
655
+ */
656
+ const agentDefaults = {
657
+ studioUrl: defaultStudioUrl,
658
+ retryIntervalMs: 3e4,
659
+ heartbeatIntervalMs: 3e4,
660
+ /**
661
+ * Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
662
+ * stored ping is older than its liveness window, and it stores a ping at most once a minute, so
663
+ * a slower cadence would make a healthy agent look dead after a single missed ping.
664
+ */
665
+ maxHeartbeatIntervalMs: 6e4,
666
+ /** How long a heartbeat ping may take before the session is treated as dead. */
667
+ heartbeatTimeoutMs: 1e4,
668
+ maxConcurrent: 1,
669
+ maxGenerations: 8,
670
+ maxGenerationsMb: 100,
671
+ maxSnapshotMb: 50
672
+ };
673
+ function positiveNumber(value) {
674
+ const parsed = Number(value);
675
+ return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
676
+ }
677
+ /**
678
+ * How many generations an agent keeps and how large they may get, read from
679
+ * `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
680
+ * An unset or invalid value keeps the default.
681
+ */
682
+ function resolveGenerationLimits(env = process.env) {
683
+ return {
684
+ maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
685
+ maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
686
+ maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
687
+ };
688
+ }
689
+ /**
690
+ * An agent's capacity read from `KUBB_AGENT_MAX_CONCURRENT`. An unset or invalid value keeps the
691
+ * default: one job at a time.
692
+ */
693
+ function resolveAgentCapacity(env = process.env) {
694
+ return { maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)) };
695
+ }
696
+ //#endregion
697
+ //#region package.json
698
+ var version = "5.4.1";
699
+ //#endregion
652
700
  //#region src/operations/resolveConfig.ts
653
701
  /**
654
702
  * Imports a package, falling back to how the user's project would resolve it.
@@ -1384,57 +1432,6 @@ function withTrailingNewline(code, hadTrailingNewline) {
1384
1432
  return `${code}\n`;
1385
1433
  }
1386
1434
  //#endregion
1387
- //#region src/operations/constants.ts
1388
- /**
1389
- * Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
1390
- * not whatever default the client would pick on its own.
1391
- */
1392
- const defaultStudioUrl = "https://kubb.studio";
1393
- /**
1394
- * Defaults the Studio client uses when a host passes nothing.
1395
- * Config path is left out on purpose: each host discovers that itself.
1396
- */
1397
- const agentDefaults = {
1398
- studioUrl: defaultStudioUrl,
1399
- retryIntervalMs: 3e4,
1400
- heartbeatIntervalMs: 3e4,
1401
- /**
1402
- * Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
1403
- * stored ping is older than its liveness window, and it stores a ping at most once a minute, so
1404
- * a slower cadence would make a healthy agent look dead after a single missed ping.
1405
- */
1406
- maxHeartbeatIntervalMs: 6e4,
1407
- /** How long a heartbeat ping may take before the session is treated as dead. */
1408
- heartbeatTimeoutMs: 1e4,
1409
- maxConcurrent: 1,
1410
- maxGenerations: 8,
1411
- maxGenerationsMb: 100,
1412
- maxSnapshotMb: 50
1413
- };
1414
- function positiveNumber(value) {
1415
- const parsed = Number(value);
1416
- return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1417
- }
1418
- /**
1419
- * How many generations an agent keeps and how large they may get, read from
1420
- * `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
1421
- * An unset or invalid value keeps the default.
1422
- */
1423
- function resolveGenerationLimits(env = process.env) {
1424
- return {
1425
- maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
1426
- maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
1427
- maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
1428
- };
1429
- }
1430
- /**
1431
- * An agent's capacity read from `KUBB_AGENT_MAX_CONCURRENT`. An unset or invalid value keeps the
1432
- * default: one job at a time.
1433
- */
1434
- function resolveAgentCapacity(env = process.env) {
1435
- return { maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)) };
1436
- }
1437
- //#endregion
1438
1435
  //#region src/operations/snapshotPackage.ts
1439
1436
  const gzipAsync = promisify(gzip);
1440
1437
  const UPLOAD_TIMEOUT_MS = 12e4;
@@ -2347,39 +2344,13 @@ function applyStudioDefaults(options) {
2347
2344
  }
2348
2345
  /**
2349
2346
  * Jobs one agent process can run at once today. Two runs would share this session's hook emitter,
2350
- * and with it each other's events, until each job runs in its own worker (ADR-0003 slice B2).
2347
+ * and with it each other's events, so the connection keeps this limit.
2351
2348
  */
2352
2349
  const RUNTIME_MAX_CONCURRENT = 1;
2353
2350
  const MB = 1048576;
2354
2351
  function rssMb() {
2355
2352
  return process$1.memoryUsage().rss / MB;
2356
2353
  }
2357
- function backoffDelayMs(attempt, maxMs) {
2358
- const cap = Math.min(1e3 * 2 ** (attempt - 1), maxMs);
2359
- return Math.random() * cap;
2360
- }
2361
- function reconnect(options, delayMs, attempt) {
2362
- const { signal, onTokenRejected } = options;
2363
- if (signal?.aborted) return;
2364
- const cancel = () => clearTimeout(timer);
2365
- const timer = setTimeout(() => {
2366
- signal?.removeEventListener("abort", cancel);
2367
- if (signal?.aborted) return;
2368
- new StudioSession({
2369
- ...options,
2370
- reconnectAttempt: attempt
2371
- }).start().catch((error) => {
2372
- if (error instanceof InvalidAgentTokenError) {
2373
- onTokenRejected?.(error);
2374
- return;
2375
- }
2376
- if (error instanceof IncompatibleAgentError) return;
2377
- const nextAttempt = attempt + 1;
2378
- reconnect(options, backoffDelayMs(nextAttempt, options.retryInterval), nextAttempt);
2379
- });
2380
- }, delayMs);
2381
- signal?.addEventListener("abort", cancel, { once: true });
2382
- }
2383
2354
  /**
2384
2355
  * Reads what Studio meant by closing the connection. A code Studio did not send on purpose is an
2385
2356
  * ordinary drop, and the agent reconnects as it always has.
@@ -2406,7 +2377,7 @@ function planEnd(close) {
2406
2377
  }
2407
2378
  /**
2408
2379
  * One agent-to-Studio RPC transport: opening it, keeping it alive, and serving remote methods.
2409
- * `createClient` opens one per pool slot and is the only caller.
2380
+ * `runConnection` owns the lifetime and retry of each attempt.
2410
2381
  */
2411
2382
  var StudioSession = class {
2412
2383
  #options;
@@ -2435,10 +2406,11 @@ var StudioSession = class {
2435
2406
  * host does not queue jobs before the agent session is registered.
2436
2407
  */
2437
2408
  #connectAck = Promise.withResolvers();
2438
- #reconnectAttempt;
2439
- constructor({ reconnectAttempt, ...options }) {
2409
+ #ended = Promise.withResolvers();
2410
+ closed = this.#ended.promise;
2411
+ #generationResult;
2412
+ constructor(options) {
2440
2413
  this.#options = applyStudioDefaults(options);
2441
- this.#reconnectAttempt = reconnectAttempt ?? 0;
2442
2414
  this.#connectAck.promise.catch(() => {});
2443
2415
  }
2444
2416
  /**
@@ -2475,10 +2447,14 @@ var StudioSession = class {
2475
2447
  async start() {
2476
2448
  const { token, studioUrl, signal, heartbeatInterval, installLogger, instanceId, capacity } = this.#options;
2477
2449
  await installLogger?.(this.#hooks);
2450
+ signal?.addEventListener("abort", this.#onAbort, { once: true });
2451
+ this.#unhooks.push(() => signal?.removeEventListener("abort", this.#onAbort));
2478
2452
  try {
2453
+ signal?.throwIfAborted();
2479
2454
  await this.#hooks.callHook("studio:connecting", { url: studioUrl });
2480
- 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`);
2455
+ 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`);
2481
2456
  const registration = await registerAgent({
2457
+ signal,
2482
2458
  token,
2483
2459
  studioUrl,
2484
2460
  instanceId,
@@ -2487,6 +2463,7 @@ var StudioSession = class {
2487
2463
  maxConcurrent: Math.min(capacity.maxConcurrent, RUNTIME_MAX_CONCURRENT)
2488
2464
  }
2489
2465
  });
2466
+ signal?.throwIfAborted();
2490
2467
  this.#registration = registration;
2491
2468
  this.#studioVersion = registration.version;
2492
2469
  const rpc = await (this.#options.connector ?? connectWebSocketRpc)({
@@ -2497,8 +2474,10 @@ var StudioSession = class {
2497
2474
  });
2498
2475
  this.#rpc = rpc;
2499
2476
  rpc.closed.then(this.#onClose);
2500
- signal?.addEventListener("abort", this.#onAbort, { once: true });
2501
- this.#unhooks.push(() => signal?.removeEventListener("abort", this.#onAbort));
2477
+ if (signal?.aborted || this.#disposed) {
2478
+ rpc.close();
2479
+ throw new Error("Connection stopped during startup");
2480
+ }
2502
2481
  this.#scheduleHeartbeat(heartbeatInterval);
2503
2482
  await this.#hooks.callHook("studio:connected", {
2504
2483
  url: studioUrl,
@@ -2511,26 +2490,15 @@ var StudioSession = class {
2511
2490
  organizationSlug: registration.organizationSlug
2512
2491
  });
2513
2492
  await this.#connectAck.promise;
2514
- this.#reconnectAttempt = 0;
2515
2493
  await this.#hooks.callHook("studio:ready", {});
2516
2494
  } catch (error) {
2517
- this.#disposed = true;
2518
- this.dispose();
2519
- await this.#hooks.callHook("studio:error", { error: toError(error) });
2520
- if (error instanceof InvalidAgentTokenError || error instanceof IncompatibleAgentError) throw error;
2521
- await this.#reconnect();
2495
+ await this.dispose();
2496
+ if (!signal?.aborted) await this.#hooks.callHook("studio:error", { error: toError(error) });
2497
+ throw error;
2522
2498
  }
2523
2499
  }
2524
- /**
2525
- * Tells the host a retry is coming, then schedules it. The host prints the retry, since the
2526
- * runtime has no output of its own.
2527
- */
2528
- async #reconnect() {
2529
- if (this.#options.signal?.aborted) return;
2530
- const attempt = this.#reconnectAttempt + 1;
2531
- const delayMs = backoffDelayMs(attempt, this.#options.retryInterval);
2532
- await this.#hooks.callHook("studio:reconnecting", { delayMs });
2533
- reconnect(this.#options, delayMs, attempt);
2500
+ reportRetry(delayMs) {
2501
+ return this.#hooks.callHook("studio:reconnecting", { delayMs });
2534
2502
  }
2535
2503
  #warn(message, permission) {
2536
2504
  return this.#hooks.callHook("studio:warn", {
@@ -2625,34 +2593,35 @@ var StudioSession = class {
2625
2593
  #onAbort = () => void this.#end({
2626
2594
  reason: "shutdown",
2627
2595
  retry: false
2628
- });
2629
- #onClose = (close) => void this.#end(planEnd(close));
2630
- /**
2631
- * Drops the socket and detaches every listener and timer this session added. Idempotent, and
2632
- * safe before `connect` opened anything.
2633
- *
2634
- * @internal
2635
- */
2596
+ }).catch(() => {});
2597
+ #onClose = (close) => void this.#end(planEnd(close)).catch(() => {});
2598
+ /** Closes this attempt and waits for its generation to stop before another can start. */
2636
2599
  dispose() {
2600
+ return this.#end({
2601
+ reason: "shutdown",
2602
+ retry: false
2603
+ });
2604
+ }
2605
+ async #end(plan) {
2606
+ if (this.#disposed) {
2607
+ await this.closed;
2608
+ return;
2609
+ }
2610
+ this.#disposed = true;
2637
2611
  clearTimeout(this.#heartbeatTimer);
2638
- this.#heartbeatTimer = void 0;
2639
2612
  this.#rpc?.close();
2640
2613
  this.#rpc = void 0;
2641
2614
  this.#connectAck.reject(/* @__PURE__ */ new Error("Session ended before Studio called connect()"));
2642
2615
  for (const unhook of this.#unhooks) unhook();
2643
2616
  this.#unhooks.length = 0;
2644
- }
2645
- /**
2646
- * Ends the session: tells Studio it is over, drops the socket, and optionally reconnects.
2647
- * `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
2648
- */
2649
- async #end({ reason, retry, error }) {
2650
- if (this.#disposed) return;
2651
- this.#disposed = true;
2652
- this.dispose();
2653
- await this.#hooks.callHook("studio:disconnected", { reason });
2654
- if (error) await this.#hooks.callHook("studio:error", { error });
2655
- if (retry) await this.#reconnect();
2617
+ try {
2618
+ await this.#activeJob?.cancel();
2619
+ await this.#generationResult?.catch(() => {});
2620
+ await this.#hooks.callHook("studio:disconnected", { reason: plan.reason });
2621
+ if (plan.error) await this.#hooks.callHook("studio:error", { error: plan.error });
2622
+ } finally {
2623
+ this.#ended.resolve(plan);
2624
+ }
2656
2625
  }
2657
2626
  startGeneration(data) {
2658
2627
  const generationStream = createGenerationStream(this.#hooks, data.jobId, { onGenerationEnd: (result) => {
@@ -2671,6 +2640,7 @@ var StudioSession = class {
2671
2640
  }).finally(() => {
2672
2641
  if (this.#activeJob?.cancel === cancelRun) this.#activeJob = void 0;
2673
2642
  });
2643
+ if (this.#activeJob?.cancel === cancelRun) this.#generationResult = result;
2674
2644
  result.catch(() => {});
2675
2645
  return new GenerationRunTarget(generationStream.stream, result, cancelRun, () => {
2676
2646
  controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
@@ -2684,6 +2654,7 @@ var StudioSession = class {
2684
2654
  if (this.#activeJob?.jobId === jobId) await this.#activeJob.cancel();
2685
2655
  }
2686
2656
  async #runGeneration(data, controller, cancelRun) {
2657
+ if (this.#disposed) throw new Error("Connection is closed");
2687
2658
  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");
2688
2659
  this.#isGenerating = true;
2689
2660
  this.#activeJob = {
@@ -2879,114 +2850,92 @@ var StudioSession = class {
2879
2850
  }
2880
2851
  };
2881
2852
  //#endregion
2882
- //#region src/runtime/client.ts
2883
- /**
2884
- * Creates the Kubb Studio client: the connection, the command loop, and the generation event
2885
- * stream shared by the `kubb studio` CLI command and the Docker agent.
2886
- *
2887
- * Every permission is off by default. A host that wants more grants it explicitly. The machine
2888
- * identity comes from the storage the host installed with `setStorage`, before connecting.
2889
- *
2890
- * @example
2891
- * ```ts
2892
- * const studio = createClient({ token, configPath, version, loadConfig: () => loadMyConfig() })
2893
- * await studio.connect()
2894
- * ```
2895
- */
2896
- function createClient({ onAuthRequired, ...options }) {
2897
- const controller = new AbortController();
2898
- const instanceId = options.instanceId ?? randomUUID();
2899
- function notifyAuthRequired(error) {
2900
- if (controller.signal.aborted) return;
2901
- controller.abort();
2902
- onAuthRequired?.(error);
2903
- }
2904
- return {
2905
- async connect() {
2906
- await new StudioSession({
2907
- ...options,
2908
- instanceId,
2909
- signal: controller.signal,
2910
- onTokenRejected: notifyAuthRequired
2911
- }).start();
2912
- },
2913
- disconnect() {
2914
- controller.abort();
2915
- }
2916
- };
2917
- }
2918
- //#endregion
2919
2853
  //#region src/runtime/runConnection.ts
2920
- /**
2921
- * Waits for whichever comes first: the shutdown signal, or Studio rejecting the token during a
2922
- * background reconnect. Resolves with the rejection, or nothing when the run is being shut down.
2923
- */
2924
- function waitForRejection(authRequired, signal) {
2925
- if (!signal) return authRequired;
2926
- const settled = new AbortController();
2927
- const shutdown = new Promise((resolve) => {
2928
- if (signal.aborted) {
2929
- resolve(void 0);
2930
- return;
2931
- }
2932
- signal.addEventListener("abort", () => resolve(void 0), {
2933
- once: true,
2934
- signal: settled.signal
2935
- });
2936
- });
2937
- return Promise.race([shutdown, authRequired]).finally(() => settled.abort());
2938
- }
2939
- /**
2940
- * Keeps a host connected to Studio across token changes: it opens a client, waits until the run
2941
- * ends or Studio rejects the token, and reconnects with whatever credential the host hands back.
2942
- *
2943
- * The host owns everything around that. Where credentials live, whether a rejected token may be
2944
- * replaced, and how any of it is reported are all decisions `onTokenRejected` makes.
2945
- *
2946
- * @example
2947
- * ```ts
2948
- * const outcome = await runConnection({
2949
- * credentials,
2950
- * clientOptions: () => ({ studioUrl, configPath, version, loadConfig }),
2951
- * signal: shutdown.signal,
2952
- * onTokenRejected: ({ error, live }) => pairAgain(error, live),
2953
- * })
2954
- * ```
2955
- */
2956
- async function runConnection({ credentials, clientOptions, onTokenRejected, signal }) {
2854
+ /** Runs the process's one connection loop, including retries and credential replacement. */
2855
+ async function runConnection({ credentials, clientOptions, onTokenRejected, signal, onStarted }) {
2957
2856
  let current = credentials;
2958
- while (true) {
2959
- if (signal?.aborted) return "shutdown";
2960
- const { promise: authRequired, resolve: notifyAuthRequired } = Promise.withResolvers();
2961
- const client = createClient({
2962
- ...clientOptions(current),
2857
+ const initialOptions = clientOptions(current);
2858
+ const instanceId = initialOptions.instanceId ?? randomUUID();
2859
+ let options = initialOptions;
2860
+ let attempt = 0;
2861
+ let live = false;
2862
+ while (!signal?.aborted) {
2863
+ const session = new StudioSession({
2864
+ ...options,
2865
+ instanceId,
2963
2866
  token: current.token,
2964
- onAuthRequired: notifyAuthRequired
2867
+ signal
2965
2868
  });
2966
- let rejection;
2967
2869
  try {
2968
- await client.connect();
2969
- const error = await waitForRejection(authRequired, signal);
2970
- if (!error) return "shutdown";
2971
- rejection = {
2972
- error,
2973
- credentials: current,
2974
- live: true
2975
- };
2870
+ await session.start();
2871
+ live = true;
2872
+ attempt = 0;
2873
+ onStarted?.();
2874
+ if (!(await session.closed).retry) return signal?.aborted ? "shutdown" : "stopped";
2976
2875
  } catch (error) {
2977
- if (!(error instanceof InvalidAgentTokenError)) throw error;
2978
- rejection = {
2979
- error,
2980
- credentials: current,
2981
- live: false
2982
- };
2876
+ if (signal?.aborted) return "shutdown";
2877
+ if (error instanceof IncompatibleAgentError) throw error;
2878
+ await session.dispose();
2879
+ const end = await session.closed;
2880
+ if (!end.retry && end.reason !== "shutdown") return "stopped";
2881
+ if (error instanceof InvalidAgentTokenError) {
2882
+ const next = await onTokenRejected({
2883
+ error,
2884
+ credentials: current,
2885
+ live
2886
+ });
2887
+ if (!next) return "stopped";
2888
+ current = next;
2889
+ options = clientOptions(current);
2890
+ live = false;
2891
+ attempt = 0;
2892
+ continue;
2893
+ }
2983
2894
  } finally {
2984
- client.disconnect();
2895
+ await session.dispose();
2896
+ }
2897
+ onStarted?.();
2898
+ const cap = Math.min(1e3 * 2 ** Math.min(attempt++, 16), options.retryInterval ?? agentDefaults.retryIntervalMs);
2899
+ const delayMs = Math.random() * cap;
2900
+ await session.reportRetry(delayMs);
2901
+ try {
2902
+ await setTimeout$1(delayMs, void 0, { signal });
2903
+ } catch (error) {
2904
+ if (!signal?.aborted) throw error;
2985
2905
  }
2986
- const next = await onTokenRejected(rejection);
2987
- if (!next) return "stopped";
2988
- current = next;
2989
2906
  }
2907
+ return "shutdown";
2908
+ }
2909
+ //#endregion
2910
+ //#region src/runtime/client.ts
2911
+ /** Compatibility facade over the shared connection loop. */
2912
+ function createClient({ onAuthRequired, ...options }) {
2913
+ const shutdown = new AbortController();
2914
+ const started = Promise.withResolvers();
2915
+ let resolved = false;
2916
+ let running;
2917
+ return {
2918
+ connect() {
2919
+ running ??= runConnection({
2920
+ credentials: { token: options.token },
2921
+ clientOptions: () => options,
2922
+ signal: shutdown.signal,
2923
+ onStarted: () => {
2924
+ resolved = true;
2925
+ started.resolve();
2926
+ },
2927
+ onTokenRejected: async ({ error }) => {
2928
+ if (!resolved) throw error;
2929
+ onAuthRequired?.(error);
2930
+ return null;
2931
+ }
2932
+ }).then(() => started.resolve(), (error) => started.reject(error));
2933
+ return started.promise;
2934
+ },
2935
+ disconnect() {
2936
+ shutdown.abort();
2937
+ }
2938
+ };
2990
2939
  }
2991
2940
  //#endregion
2992
2941
  //#region src/operations/pair.ts