@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/README.md +6 -3
- package/dist/index.cjs +187 -238
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +12 -94
- package/dist/index.js +186 -237
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
38
|
-
let
|
|
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.4.0";
|
|
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
|
-
*
|
|
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:
|
|
512
|
-
|
|
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,
|
|
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
|
-
* `
|
|
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
|
-
#
|
|
2469
|
-
|
|
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 (
|
|
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?.
|
|
2531
|
-
|
|
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
|
|
2548
|
-
this.
|
|
2549
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
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
|
-
|
|
2897
|
+
signal
|
|
2995
2898
|
});
|
|
2996
|
-
let rejection;
|
|
2997
2899
|
try {
|
|
2998
|
-
await
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
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 (
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
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
|
-
|
|
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
|