@kubb/studio 5.3.15 → 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/README.md +92 -25
- package/dist/index.cjs +521 -347
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +116 -48
- package/dist/index.js +517 -349
- package/dist/index.js.map +1 -1
- package/dist/protocol.cjs +20 -0
- package/dist/protocol.cjs.map +1 -1
- package/dist/protocol.d.ts +80 -50
- package/dist/protocol.js +19 -1
- package/dist/protocol.js.map +1 -1
- package/package.json +4 -4
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 {
|
|
5
|
-
import process$1 from "node:process";
|
|
6
|
-
import { spawn } from "node:child_process";
|
|
4
|
+
import { createHash, hash, randomBytes, randomUUID } from "node:crypto";
|
|
7
5
|
import { glob, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
8
7
|
import path, { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
9
|
-
import
|
|
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";
|
|
10
13
|
import { FetchError, ofetch } from "ofetch";
|
|
11
|
-
import {
|
|
14
|
+
import { promisify, styleText } from "node:util";
|
|
12
15
|
import { createStorage } from "unstorage";
|
|
13
16
|
import fsDriver from "unstorage/drivers/fs";
|
|
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,119 +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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*/
|
|
519
|
-
async function
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
576
|
-
|
|
566
|
+
if (rejectedWith(error, 426)) throw new IncompatibleAgentError(studioUrl, error instanceof FetchError ? responseMessage(error.data) : void 0, { cause: error });
|
|
567
|
+
throw registrationError(error);
|
|
577
568
|
}
|
|
578
569
|
}
|
|
579
570
|
/**
|
|
580
|
-
*
|
|
581
|
-
* Called on process termination or server close. A failed notify is logged and swallowed: the
|
|
582
|
-
* local socket is already gone, and failing teardown must not block shutdown or reconnect.
|
|
571
|
+
* First wait before `createJob` retries a busy or queue-full response, absent a `Retry-After` hint.
|
|
583
572
|
*/
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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;
|
|
599
602
|
}
|
|
600
603
|
/**
|
|
601
604
|
* Queues a generation or snapshot job on Studio (`POST /api/jobs`).
|
|
@@ -603,6 +606,10 @@ async function disconnect({ sessionId, token, studioUrl, slug, logLevel: logLeve
|
|
|
603
606
|
* Returns as soon as Studio accepts the job (`202`). Poll with {@link waitForJob} until it finishes.
|
|
604
607
|
* Authenticates with the organization CI API key via `x-api-key`.
|
|
605
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
|
+
*
|
|
606
613
|
* @example Snapshot job
|
|
607
614
|
* ```ts
|
|
608
615
|
* const job = await createJob({
|
|
@@ -616,19 +623,40 @@ async function disconnect({ sessionId, token, studioUrl, slug, logLevel: logLeve
|
|
|
616
623
|
* const finished = await waitForJob({ studioUrl, token, id: job.id })
|
|
617
624
|
* ```
|
|
618
625
|
*/
|
|
619
|
-
async function createJob({ studioUrl, token, type, agentId, name, version, config }) {
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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);
|
|
629
658
|
}
|
|
630
|
-
}
|
|
631
|
-
return job;
|
|
659
|
+
}
|
|
632
660
|
}
|
|
633
661
|
/**
|
|
634
662
|
* A job runs a generation and packs a tarball, so it is never done the instant it is queued.
|
|
@@ -647,20 +675,26 @@ const MAX_POLL_INTERVAL_MS = 3e4;
|
|
|
647
675
|
* A `failed` job resolves normally. Check `job.status` and `job.error`. Throws only when the
|
|
648
676
|
* deadline passes before Studio finishes.
|
|
649
677
|
*/
|
|
650
|
-
async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4 }) {
|
|
678
|
+
async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4, signal }) {
|
|
651
679
|
const deadline = Date.now() + timeoutMs;
|
|
652
680
|
let interval = INITIAL_POLL_DELAY_MS;
|
|
653
681
|
for (;;) {
|
|
654
|
-
|
|
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));
|
|
655
686
|
if (Date.now() >= deadline) throw new Error("Timed out waiting for the Studio job");
|
|
656
687
|
interval = Math.min(interval * 2, MAX_POLL_INTERVAL_MS);
|
|
657
688
|
try {
|
|
658
689
|
const { job } = await ofetch(`${studioUrl}/api/jobs/${id}`, {
|
|
659
690
|
headers: { "x-api-key": token },
|
|
660
|
-
retry: false
|
|
691
|
+
retry: false,
|
|
692
|
+
timeout: Math.max(deadline - Date.now(), 1),
|
|
693
|
+
signal
|
|
661
694
|
});
|
|
662
695
|
if (job.status === "success" || job.status === "failed" || job.status === "canceled") return job;
|
|
663
696
|
} catch (error) {
|
|
697
|
+
signal?.throwIfAborted();
|
|
664
698
|
const response = error.response;
|
|
665
699
|
if (response?.status !== 429) throw error;
|
|
666
700
|
const retryAfter = response._data?.data?.tryAgainIn;
|
|
@@ -694,89 +728,6 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
|
|
|
694
728
|
}
|
|
695
729
|
}
|
|
696
730
|
//#endregion
|
|
697
|
-
//#region package.json
|
|
698
|
-
var version = "5.3.15";
|
|
699
|
-
//#endregion
|
|
700
|
-
//#region src/hooks.ts
|
|
701
|
-
/**
|
|
702
|
-
* Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
|
|
703
|
-
* streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
|
|
704
|
-
* Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
|
|
705
|
-
*
|
|
706
|
-
* Returns a remover, so a session that runs one generation after another on the same emitter does
|
|
707
|
-
* not stack a listener per run.
|
|
708
|
-
*/
|
|
709
|
-
function setupHookListener(hooks, root, signal) {
|
|
710
|
-
return hooks.hook("kubb:hook:start", async (ctx) => {
|
|
711
|
-
const { id, command, args } = ctx;
|
|
712
|
-
if (!id) return;
|
|
713
|
-
const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
|
|
714
|
-
try {
|
|
715
|
-
const proc = x(command, [...args ?? []], {
|
|
716
|
-
signal,
|
|
717
|
-
nodeOptions: {
|
|
718
|
-
cwd: root,
|
|
719
|
-
detached: true
|
|
720
|
-
}
|
|
721
|
-
});
|
|
722
|
-
for await (const line of proc) await hooks.callHook("kubb:hook:line", {
|
|
723
|
-
id,
|
|
724
|
-
line
|
|
725
|
-
});
|
|
726
|
-
const { exitCode } = await proc;
|
|
727
|
-
if (exitCode !== 0) {
|
|
728
|
-
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
729
|
-
await hooks.callHook("kubb:hook:end", {
|
|
730
|
-
id,
|
|
731
|
-
command,
|
|
732
|
-
args,
|
|
733
|
-
success: false,
|
|
734
|
-
error
|
|
735
|
-
});
|
|
736
|
-
await hooks.callHook("kubb:error", { error });
|
|
737
|
-
return;
|
|
738
|
-
}
|
|
739
|
-
await hooks.callHook("kubb:hook:end", {
|
|
740
|
-
id,
|
|
741
|
-
command,
|
|
742
|
-
args,
|
|
743
|
-
success: true,
|
|
744
|
-
error: null
|
|
745
|
-
});
|
|
746
|
-
} catch (caughtError) {
|
|
747
|
-
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
748
|
-
error.cause = caughtError;
|
|
749
|
-
await hooks.callHook("kubb:hook:end", {
|
|
750
|
-
id,
|
|
751
|
-
command,
|
|
752
|
-
args,
|
|
753
|
-
success: false,
|
|
754
|
-
error
|
|
755
|
-
});
|
|
756
|
-
await hooks.callHook("kubb:error", { error });
|
|
757
|
-
}
|
|
758
|
-
});
|
|
759
|
-
}
|
|
760
|
-
/**
|
|
761
|
-
* Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
|
|
762
|
-
* `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
|
|
763
|
-
* that same listener, so a handler added afterward would already have missed it.
|
|
764
|
-
*/
|
|
765
|
-
function waitForHookEnd(hooks, hookId) {
|
|
766
|
-
return new Promise((resolve, reject) => {
|
|
767
|
-
const handleHookEnd = (ctx) => {
|
|
768
|
-
if (ctx.id !== hookId) return;
|
|
769
|
-
hooks.removeHook("kubb:hook:end", handleHookEnd);
|
|
770
|
-
if (ctx.success) {
|
|
771
|
-
resolve();
|
|
772
|
-
return;
|
|
773
|
-
}
|
|
774
|
-
reject(ctx.error);
|
|
775
|
-
};
|
|
776
|
-
hooks.hook("kubb:hook:end", handleHookEnd);
|
|
777
|
-
});
|
|
778
|
-
}
|
|
779
|
-
//#endregion
|
|
780
731
|
//#region src/resolveConfig.ts
|
|
781
732
|
/**
|
|
782
733
|
* Imports a package, falling back to how the user's project would resolve it.
|
|
@@ -1644,6 +1595,60 @@ async function generate({ config, hooks, signal }) {
|
|
|
1644
1595
|
}
|
|
1645
1596
|
}
|
|
1646
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
|
|
1647
1652
|
//#region src/snapshotPackage.ts
|
|
1648
1653
|
const gzipAsync = promisify(gzip);
|
|
1649
1654
|
/**
|
|
@@ -1786,7 +1791,7 @@ async function createSnapshotPackage(files, packageInfo) {
|
|
|
1786
1791
|
//#endregion
|
|
1787
1792
|
//#region src/generations.ts
|
|
1788
1793
|
const READ_CONCURRENCY = 50;
|
|
1789
|
-
const MB = 1048576;
|
|
1794
|
+
const MB$1 = 1048576;
|
|
1790
1795
|
const INDEX_KEY = "studio/generations.json";
|
|
1791
1796
|
const hashOf = (content) => createHash("sha1").update(content).digest("hex").slice(0, 16);
|
|
1792
1797
|
/**
|
|
@@ -1813,8 +1818,9 @@ async function listDisk({ root, outputPath, maxFiles }) {
|
|
|
1813
1818
|
* tenant never reaches another's output. The oldest go past `maxCount` or `maxMb`, but the newest
|
|
1814
1819
|
* always stays.
|
|
1815
1820
|
*/
|
|
1816
|
-
function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
1821
|
+
function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now }) {
|
|
1817
1822
|
let index;
|
|
1823
|
+
const isLive = (generation) => ttlMs === void 0 || generation.keptAt === void 0 || now() - generation.keptAt < ttlMs;
|
|
1818
1824
|
const dirOf = (jobId) => `studio/generations/${hashOf(jobId)}/`;
|
|
1819
1825
|
async function load() {
|
|
1820
1826
|
if (index) return index;
|
|
@@ -1830,7 +1836,7 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
|
1830
1836
|
* Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
|
|
1831
1837
|
*/
|
|
1832
1838
|
async function keep({ jobId, source, files, maxSetMb }) {
|
|
1833
|
-
const maxSetBytes = maxSetMb * MB;
|
|
1839
|
+
const maxSetBytes = maxSetMb * MB$1;
|
|
1834
1840
|
const hashes = {};
|
|
1835
1841
|
let bytes = 0;
|
|
1836
1842
|
await inParallel({
|
|
@@ -1865,13 +1871,20 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
|
1865
1871
|
return {
|
|
1866
1872
|
keep,
|
|
1867
1873
|
drop,
|
|
1868
|
-
get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId),
|
|
1869
|
-
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),
|
|
1870
1878
|
async add(generation) {
|
|
1871
|
-
const
|
|
1872
|
-
|
|
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
|
+
});
|
|
1873
1886
|
const weight = () => entries.reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0);
|
|
1874
|
-
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);
|
|
1875
1888
|
index = entries;
|
|
1876
1889
|
await storage.writeItem(INDEX_KEY, JSON.stringify(entries));
|
|
1877
1890
|
},
|
|
@@ -2158,6 +2171,9 @@ var AgentRpcTarget = class extends RpcTarget {
|
|
|
2158
2171
|
readFiles(input) {
|
|
2159
2172
|
return this.api.readFiles(input);
|
|
2160
2173
|
}
|
|
2174
|
+
cancel(jobId) {
|
|
2175
|
+
return this.api.cancel(jobId);
|
|
2176
|
+
}
|
|
2161
2177
|
};
|
|
2162
2178
|
/**
|
|
2163
2179
|
* Opens an authenticated Cap'n Web session to Studio over a WebSocket. Rejects an unencrypted URL
|
|
@@ -2169,11 +2185,17 @@ var AgentRpcTarget = class extends RpcTarget {
|
|
|
2169
2185
|
* await rpc.studio.ping()
|
|
2170
2186
|
* ```
|
|
2171
2187
|
*/
|
|
2172
|
-
const connectWebSocketRpc = async ({ url, token, local }) => {
|
|
2188
|
+
const connectWebSocketRpc = async ({ url, token, instanceId, local }) => {
|
|
2173
2189
|
const { protocol, hostname, host } = new URL(url);
|
|
2174
2190
|
if (protocol !== "wss:" && !(protocol === "ws:" && (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"))) throw new Error(`Refusing unencrypted WebSocket to ${host}`);
|
|
2175
|
-
const socket = createWebsocket(url, { headers: {
|
|
2176
|
-
|
|
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
|
+
})));
|
|
2177
2199
|
const studio = newWebSocketRpcSession(socket, new AgentRpcTarget(local));
|
|
2178
2200
|
studio.onRpcBroken(() => socket.close());
|
|
2179
2201
|
return {
|
|
@@ -2188,6 +2210,32 @@ const connectWebSocketRpc = async ({ url, token, local }) => {
|
|
|
2188
2210
|
* Past this many files in the output directory, no snapshot of it is taken before a run.
|
|
2189
2211
|
*/
|
|
2190
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
|
+
}
|
|
2191
2239
|
var GenerationRunTarget = class extends RpcTarget {
|
|
2192
2240
|
generationStream;
|
|
2193
2241
|
generationResult;
|
|
@@ -2234,36 +2282,74 @@ function applyStudioDefaults(options) {
|
|
|
2234
2282
|
...options.permissions
|
|
2235
2283
|
},
|
|
2236
2284
|
retryInterval: options.retryInterval ?? agentDefaults.retryIntervalMs,
|
|
2237
|
-
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()
|
|
2238
2291
|
};
|
|
2239
2292
|
}
|
|
2240
2293
|
/**
|
|
2241
|
-
*
|
|
2242
|
-
*
|
|
2243
|
-
* A free function rather than a method: a pending retry timer reaches whatever it closes over, so
|
|
2244
|
-
* closing only over `options` (not a `StudioSession`) keeps a queued retry from pinning a closed
|
|
2245
|
-
* 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).
|
|
2246
2296
|
*/
|
|
2247
|
-
|
|
2248
|
-
|
|
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;
|
|
2249
2308
|
if (signal?.aborted) return;
|
|
2250
|
-
if (logLevel$1 !== void 0 && logLevel$1 > logLevel.silent) console.error(styleText("dim", `Retrying connection in ${retryInterval}ms to Kubb Studio ...`));
|
|
2251
2309
|
const cancel = () => clearTimeout(timer);
|
|
2252
2310
|
const timer = setTimeout(() => {
|
|
2253
2311
|
signal?.removeEventListener("abort", cancel);
|
|
2254
2312
|
if (signal?.aborted) return;
|
|
2255
|
-
new StudioSession(
|
|
2256
|
-
|
|
2313
|
+
new StudioSession({
|
|
2314
|
+
...options,
|
|
2315
|
+
reconnectAttempt: attempt
|
|
2316
|
+
}).start().catch((error) => {
|
|
2257
2317
|
if (error instanceof InvalidAgentTokenError) {
|
|
2258
2318
|
onTokenRejected?.(error);
|
|
2259
2319
|
return;
|
|
2260
2320
|
}
|
|
2261
|
-
|
|
2321
|
+
if (error instanceof IncompatibleAgentError) return;
|
|
2322
|
+
const nextAttempt = attempt + 1;
|
|
2323
|
+
reconnect(options, backoffDelayMs(nextAttempt, options.retryInterval), nextAttempt);
|
|
2262
2324
|
});
|
|
2263
|
-
},
|
|
2325
|
+
}, delayMs);
|
|
2264
2326
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
2265
2327
|
}
|
|
2266
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
|
+
/**
|
|
2267
2353
|
* One agent-to-Studio RPC transport: opening it, keeping it alive, and serving remote methods.
|
|
2268
2354
|
* `createClient` opens one per pool slot and is the only caller.
|
|
2269
2355
|
*/
|
|
@@ -2276,14 +2362,15 @@ var StudioSession = class {
|
|
|
2276
2362
|
*/
|
|
2277
2363
|
#unhooks = [];
|
|
2278
2364
|
/**
|
|
2279
|
-
* What
|
|
2280
|
-
* Before it resolves there is
|
|
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.
|
|
2281
2367
|
*/
|
|
2282
|
-
#
|
|
2368
|
+
#registration;
|
|
2283
2369
|
#rpc;
|
|
2284
2370
|
#studioVersion;
|
|
2285
2371
|
#disposed = false;
|
|
2286
2372
|
#isGenerating = false;
|
|
2373
|
+
#activeJob;
|
|
2287
2374
|
#heartbeatTimer;
|
|
2288
2375
|
#lastGeneration;
|
|
2289
2376
|
#store;
|
|
@@ -2293,15 +2380,17 @@ var StudioSession = class {
|
|
|
2293
2380
|
* host does not queue jobs before the agent session is registered.
|
|
2294
2381
|
*/
|
|
2295
2382
|
#connectAck = Promise.withResolvers();
|
|
2296
|
-
|
|
2383
|
+
#reconnectAttempt;
|
|
2384
|
+
constructor({ reconnectAttempt, ...options }) {
|
|
2297
2385
|
this.#options = applyStudioDefaults(options);
|
|
2386
|
+
this.#reconnectAttempt = reconnectAttempt ?? 0;
|
|
2298
2387
|
this.#connectAck.promise.catch(() => {});
|
|
2299
2388
|
}
|
|
2300
2389
|
/**
|
|
2301
2390
|
* A sandbox agent runs on Studio's own infrastructure, so it has no user project to touch.
|
|
2302
2391
|
*/
|
|
2303
2392
|
get #isSandbox() {
|
|
2304
|
-
return this.#
|
|
2393
|
+
return this.#registration?.isSandbox === true;
|
|
2305
2394
|
}
|
|
2306
2395
|
/**
|
|
2307
2396
|
* Kept in the project's cache directory, so it survives a restart, except on a sandbox: its pool
|
|
@@ -2311,7 +2400,8 @@ var StudioSession = class {
|
|
|
2311
2400
|
this.#store ??= createGenerationStore({
|
|
2312
2401
|
storage: this.#isSandbox ? memoryStorage() : cacheStorage({ root: this.#options.root }),
|
|
2313
2402
|
maxCount: this.#limits.maxCount,
|
|
2314
|
-
maxMb: this.#limits.maxMb
|
|
2403
|
+
maxMb: this.#limits.maxMb,
|
|
2404
|
+
ttlMs: this.#isSandbox ? SANDBOX_GENERATION_TTL_MS : void 0
|
|
2315
2405
|
});
|
|
2316
2406
|
return this.#store;
|
|
2317
2407
|
}
|
|
@@ -2335,19 +2425,26 @@ var StudioSession = class {
|
|
|
2335
2425
|
return this.#isSandbox || this.#options.permissions.allowRead;
|
|
2336
2426
|
}
|
|
2337
2427
|
async start() {
|
|
2338
|
-
const { token, studioUrl, signal, heartbeatInterval, installLogger } = this.#options;
|
|
2428
|
+
const { token, studioUrl, signal, heartbeatInterval, installLogger, instanceId, capacity } = this.#options;
|
|
2339
2429
|
await installLogger?.(this.#hooks);
|
|
2340
2430
|
try {
|
|
2341
2431
|
await this.#hooks.callHook("studio:connecting", { url: studioUrl });
|
|
2342
|
-
|
|
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.#
|
|
2347
|
-
this.#studioVersion =
|
|
2442
|
+
this.#registration = registration;
|
|
2443
|
+
this.#studioVersion = registration.version;
|
|
2348
2444
|
const rpc = await (this.#options.connector ?? connectWebSocketRpc)({
|
|
2349
|
-
url:
|
|
2445
|
+
url: registration.socketUrl,
|
|
2350
2446
|
token,
|
|
2447
|
+
instanceId,
|
|
2351
2448
|
local: this
|
|
2352
2449
|
});
|
|
2353
2450
|
this.#rpc = rpc;
|
|
@@ -2362,28 +2459,43 @@ var StudioSession = class {
|
|
|
2362
2459
|
kubb: version,
|
|
2363
2460
|
agent: this.#options.version
|
|
2364
2461
|
},
|
|
2365
|
-
agentSlug:
|
|
2366
|
-
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;
|
|
2375
|
-
reconnect(
|
|
2472
|
+
if (error instanceof InvalidAgentTokenError || error instanceof IncompatibleAgentError) throw error;
|
|
2473
|
+
await this.#reconnect();
|
|
2376
2474
|
}
|
|
2377
2475
|
}
|
|
2378
|
-
|
|
2379
|
-
|
|
2476
|
+
/**
|
|
2477
|
+
* Tells the host a retry is coming, then schedules it. The host prints the retry, since the
|
|
2478
|
+
* runtime has no output of its own.
|
|
2479
|
+
*/
|
|
2480
|
+
async #reconnect() {
|
|
2481
|
+
if (this.#options.signal?.aborted) return;
|
|
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);
|
|
2486
|
+
}
|
|
2487
|
+
#warn(message, permission) {
|
|
2488
|
+
return this.#hooks.callHook("studio:warn", {
|
|
2489
|
+
message,
|
|
2490
|
+
permission
|
|
2491
|
+
});
|
|
2380
2492
|
}
|
|
2381
2493
|
/**
|
|
2382
2494
|
* Declines a request: logs why locally, then tells Studio. The two wordings differ on purpose,
|
|
2383
2495
|
* since the log names the request that was ignored and the error names what the caller can do.
|
|
2384
2496
|
*/
|
|
2385
|
-
async #refuse(reason, message) {
|
|
2386
|
-
await this.#warn(reason);
|
|
2497
|
+
async #refuse(reason, message, permission) {
|
|
2498
|
+
await this.#warn(reason, permission);
|
|
2387
2499
|
throw new Error(message);
|
|
2388
2500
|
}
|
|
2389
2501
|
#scheduleHeartbeat(interval) {
|
|
@@ -2402,10 +2514,31 @@ var StudioSession = class {
|
|
|
2402
2514
|
/**
|
|
2403
2515
|
* Races `studio.ping()` against a deadline, so a half-open socket can't hang it forever.
|
|
2404
2516
|
* */
|
|
2405
|
-
#ping(rpc) {
|
|
2517
|
+
async #ping(rpc) {
|
|
2406
2518
|
const { promise: timedOut, reject: onTimeout } = Promise.withResolvers();
|
|
2407
2519
|
const timer = setTimeout(() => onTimeout(/* @__PURE__ */ new Error("Heartbeat ping timed out")), agentDefaults.heartbeatTimeoutMs);
|
|
2408
|
-
|
|
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
|
+
};
|
|
2409
2542
|
}
|
|
2410
2543
|
/**
|
|
2411
2544
|
* Reads `kubb.config.ts` and reports which plugin options Studio may edit.
|
|
@@ -2450,8 +2583,11 @@ var StudioSession = class {
|
|
|
2450
2583
|
this.#connectAck.resolve();
|
|
2451
2584
|
return payload;
|
|
2452
2585
|
}
|
|
2453
|
-
#onAbort = () => void this.#end({
|
|
2454
|
-
|
|
2586
|
+
#onAbort = () => void this.#end({
|
|
2587
|
+
reason: "shutdown",
|
|
2588
|
+
retry: false
|
|
2589
|
+
});
|
|
2590
|
+
#onClose = (close) => void this.#end(planEnd(close));
|
|
2455
2591
|
/**
|
|
2456
2592
|
* Drops the socket and detaches every listener and timer this session added. Idempotent, and
|
|
2457
2593
|
* safe before `connect` opened anything.
|
|
@@ -2471,47 +2607,56 @@ var StudioSession = class {
|
|
|
2471
2607
|
* Ends the session: tells Studio it is over, drops the socket, and optionally reconnects.
|
|
2472
2608
|
* `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
|
|
2473
2609
|
*/
|
|
2474
|
-
async #end({ retry }) {
|
|
2475
|
-
const { studioUrl, token, logLevel } = this.#options;
|
|
2610
|
+
async #end({ reason, retry, error }) {
|
|
2476
2611
|
if (this.#disposed) return;
|
|
2477
2612
|
this.#disposed = true;
|
|
2478
2613
|
this.dispose();
|
|
2479
|
-
await this.#hooks.callHook("studio:disconnected", { reason
|
|
2480
|
-
if (
|
|
2481
|
-
|
|
2482
|
-
studioUrl,
|
|
2483
|
-
token,
|
|
2484
|
-
slug: this.#session.slug,
|
|
2485
|
-
logLevel
|
|
2486
|
-
}).catch(() => {});
|
|
2487
|
-
if (retry) reconnect(this.#options);
|
|
2614
|
+
await this.#hooks.callHook("studio:disconnected", { reason });
|
|
2615
|
+
if (error) await this.#hooks.callHook("studio:error", { error });
|
|
2616
|
+
if (retry) await this.#reconnect();
|
|
2488
2617
|
}
|
|
2489
2618
|
startGeneration(data) {
|
|
2490
2619
|
const generationStream = createGenerationStream(this.#hooks, data.jobId, { onGenerationEnd: (result) => {
|
|
2491
2620
|
this.#lastGeneration = result;
|
|
2492
2621
|
} });
|
|
2493
2622
|
const controller = new AbortController();
|
|
2494
|
-
const
|
|
2623
|
+
const cancelRun = async () => {
|
|
2624
|
+
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2625
|
+
};
|
|
2626
|
+
const result = this.#runGeneration(data, controller, cancelRun).then(async (value) => {
|
|
2495
2627
|
await generationStream.close();
|
|
2496
2628
|
return value;
|
|
2497
2629
|
}).catch((error) => {
|
|
2498
2630
|
generationStream.fail(error);
|
|
2499
2631
|
throw error;
|
|
2632
|
+
}).finally(() => {
|
|
2633
|
+
if (this.#activeJob?.cancel === cancelRun) this.#activeJob = void 0;
|
|
2500
2634
|
});
|
|
2501
2635
|
result.catch(() => {});
|
|
2502
|
-
return new GenerationRunTarget(generationStream.stream, result,
|
|
2503
|
-
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2504
|
-
}, () => {
|
|
2636
|
+
return new GenerationRunTarget(generationStream.stream, result, cancelRun, () => {
|
|
2505
2637
|
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2506
2638
|
generationStream.dispose();
|
|
2507
2639
|
});
|
|
2508
2640
|
}
|
|
2509
|
-
|
|
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) {
|
|
2510
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");
|
|
2511
2650
|
this.#isGenerating = true;
|
|
2651
|
+
this.#activeJob = {
|
|
2652
|
+
jobId: data.jobId,
|
|
2653
|
+
cancel: cancelRun
|
|
2654
|
+
};
|
|
2512
2655
|
const command = "generate";
|
|
2513
|
-
const {
|
|
2656
|
+
const { loadConfig, permissions } = this.#options;
|
|
2657
|
+
let root = this.#options.root;
|
|
2514
2658
|
try {
|
|
2659
|
+
if (this.#isSandbox) root = await createJobRoot();
|
|
2515
2660
|
await this.#hooks.callHook("studio:command:start", { command });
|
|
2516
2661
|
const config = await loadConfig();
|
|
2517
2662
|
const patch = data.config;
|
|
@@ -2519,10 +2664,7 @@ var StudioSession = class {
|
|
|
2519
2664
|
const adapter = await mergeAdapter(config.adapter, patch?.adapter);
|
|
2520
2665
|
const inputOverride = this.#isSandbox ? patch?.input ?? "" : permissions.allowInput && patch?.input || void 0;
|
|
2521
2666
|
if (permissions.allowWrite && this.#isSandbox) await this.#warn("Running in a sandbox, so writing files is disabled");
|
|
2522
|
-
if (patch?.input && !this.#canUseInput)
|
|
2523
|
-
const remedy = client?.kind === "cli" ? "--allowInput, or answer yes when kubb studio asks," : "KUBB_AGENT_ALLOW_INPUT=true";
|
|
2524
|
-
await this.#warn(`Ignored the spec from Studio; set ${remedy} to generate from it`);
|
|
2525
|
-
}
|
|
2667
|
+
if (patch?.input && !this.#canUseInput) await this.#warn("Ignored the spec from Studio: generating from a Studio spec was not granted", "allowInput");
|
|
2526
2668
|
const resolvedPlugins = plugins ?? config.plugins;
|
|
2527
2669
|
this.#lastGeneration = void 0;
|
|
2528
2670
|
const diskFiles = this.#hasProjectOnDisk ? await listDisk({
|
|
@@ -2593,6 +2735,7 @@ var StudioSession = class {
|
|
|
2593
2735
|
disk: disk ? { hashes: disk.hashes } : void 0
|
|
2594
2736
|
};
|
|
2595
2737
|
} finally {
|
|
2738
|
+
if (root !== this.#options.root) await removeJobRoot(root);
|
|
2596
2739
|
this.#isGenerating = false;
|
|
2597
2740
|
}
|
|
2598
2741
|
}
|
|
@@ -2702,12 +2845,7 @@ var StudioSession = class {
|
|
|
2702
2845
|
async readFiles(data) {
|
|
2703
2846
|
const command = "readFiles";
|
|
2704
2847
|
await this.#hooks.callHook("studio:command:start", { command });
|
|
2705
|
-
|
|
2706
|
-
if (!this.#canRead) {
|
|
2707
|
-
await this.#warn("Ignored files: reading generated files was not granted");
|
|
2708
|
-
const remedy = client?.kind === "cli" ? "--allow-read, or answer yes when kubb studio asks," : "KUBB_AGENT_ALLOW_READ=true";
|
|
2709
|
-
throw new Error(`The agent was not granted permission to read generated files; set ${remedy} to allow it`);
|
|
2710
|
-
}
|
|
2848
|
+
if (!this.#canRead) return this.#refuse("Ignored files: reading generated files was not granted", "The agent was not granted permission to read generated files", "allowRead");
|
|
2711
2849
|
if (!Array.isArray(data.paths)) return this.#refuse("Ignored files: the message carried no paths", "The request carried no paths");
|
|
2712
2850
|
const { paths } = data;
|
|
2713
2851
|
if (paths.length > 50) return this.#refuse(`Ignored files: requested ${paths.length} paths, more than the 50 allowed per request`, `At most 50 paths may be requested at once`);
|
|
@@ -2734,7 +2872,8 @@ var StudioSession = class {
|
|
|
2734
2872
|
* Creates the Kubb Studio client: the connection, the command loop, and the generation event
|
|
2735
2873
|
* stream shared by the `kubb studio` CLI command and the Docker agent.
|
|
2736
2874
|
*
|
|
2737
|
-
* Every permission is off by default. A host that wants more grants it explicitly.
|
|
2875
|
+
* Every permission is off by default. A host that wants more grants it explicitly. The machine
|
|
2876
|
+
* identity comes from the storage the host installed with `setStorage`, before connecting.
|
|
2738
2877
|
*
|
|
2739
2878
|
* @example
|
|
2740
2879
|
* ```ts
|
|
@@ -2742,10 +2881,9 @@ var StudioSession = class {
|
|
|
2742
2881
|
* await studio.connect()
|
|
2743
2882
|
* ```
|
|
2744
2883
|
*/
|
|
2745
|
-
function createClient({
|
|
2746
|
-
if (storage) setStorage(storage);
|
|
2884
|
+
function createClient({ onAuthRequired, ...options }) {
|
|
2747
2885
|
const controller = new AbortController();
|
|
2748
|
-
const
|
|
2886
|
+
const instanceId = randomUUID();
|
|
2749
2887
|
function notifyAuthRequired(error) {
|
|
2750
2888
|
if (controller.signal.aborted) return;
|
|
2751
2889
|
controller.abort();
|
|
@@ -2753,16 +2891,12 @@ function createClient({ storage, onAuthRequired, ...options }) {
|
|
|
2753
2891
|
}
|
|
2754
2892
|
return {
|
|
2755
2893
|
async connect() {
|
|
2756
|
-
await
|
|
2757
|
-
token: options.token,
|
|
2758
|
-
studioUrl: options.studioUrl ?? agentDefaults.studioUrl,
|
|
2759
|
-
poolSize
|
|
2760
|
-
});
|
|
2761
|
-
await Promise.all(Array.from({ length: poolSize }, () => new StudioSession({
|
|
2894
|
+
await new StudioSession({
|
|
2762
2895
|
...options,
|
|
2896
|
+
instanceId,
|
|
2763
2897
|
signal: controller.signal,
|
|
2764
2898
|
onTokenRejected: notifyAuthRequired
|
|
2765
|
-
}).start()
|
|
2899
|
+
}).start();
|
|
2766
2900
|
},
|
|
2767
2901
|
disconnect() {
|
|
2768
2902
|
controller.abort();
|
|
@@ -2844,11 +2978,11 @@ async function runConnection({ credentials, clientOptions, onTokenRejected, sign
|
|
|
2844
2978
|
}
|
|
2845
2979
|
//#endregion
|
|
2846
2980
|
//#region src/pair.ts
|
|
2847
|
-
/**
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2981
|
+
/** Labels, not secrets: a person approving the code in the browser is what authorizes a pairing. */
|
|
2982
|
+
const CLIENT_IDS = {
|
|
2983
|
+
cli: "kubb-cli",
|
|
2984
|
+
agent: "kubb-agent"
|
|
2985
|
+
};
|
|
2852
2986
|
/**
|
|
2853
2987
|
* Thrown when a caller aborts `startPairing` or `pollForPairingToken` through their `signal`, such
|
|
2854
2988
|
* as a `kubb studio` shutdown mid-pairing. Distinct from a denial or an expired code, so a host can
|
|
@@ -2860,21 +2994,35 @@ var PairingCanceledError = class extends Error {
|
|
|
2860
2994
|
this.name = "PairingCanceledError";
|
|
2861
2995
|
}
|
|
2862
2996
|
};
|
|
2997
|
+
/** Thrown when the code expired unapproved. A fresh code can still succeed. */
|
|
2998
|
+
var PairingExpiredError = class extends Error {
|
|
2999
|
+
constructor(message = "The pairing code expired, pair again") {
|
|
3000
|
+
super(message);
|
|
3001
|
+
this.name = "PairingExpiredError";
|
|
3002
|
+
}
|
|
3003
|
+
};
|
|
3004
|
+
/** Thrown when the pairing was denied in the browser, including by the organization's agent limit. */
|
|
3005
|
+
var PairingDeniedError = class extends Error {
|
|
3006
|
+
constructor(message = "Pairing was denied in the browser") {
|
|
3007
|
+
super(message);
|
|
3008
|
+
this.name = "PairingDeniedError";
|
|
3009
|
+
}
|
|
3010
|
+
};
|
|
2863
3011
|
/**
|
|
2864
3012
|
* Asks Studio for a pairing code. The machine token travels with the request and is stored against
|
|
2865
3013
|
* the code, so approval knows which machine it is pairing: the same machine pairing twice rotates
|
|
2866
3014
|
* one agent's token instead of creating a second agent.
|
|
2867
3015
|
*/
|
|
2868
|
-
async function startPairing({ studioUrl = agentDefaults.studioUrl, name, hostname,
|
|
3016
|
+
async function startPairing({ studioUrl = agentDefaults.studioUrl, type, name, hostname, signal }) {
|
|
2869
3017
|
try {
|
|
2870
3018
|
return await ofetch(`${studioUrl}/api/auth/device/code`, {
|
|
2871
3019
|
method: "POST",
|
|
2872
3020
|
body: {
|
|
2873
|
-
client_id:
|
|
3021
|
+
client_id: type === "cli" ? CLIENT_IDS.cli : CLIENT_IDS.agent,
|
|
2874
3022
|
name,
|
|
2875
3023
|
hostname,
|
|
2876
3024
|
machine_token: await getMachineToken(),
|
|
2877
|
-
agent_kind:
|
|
3025
|
+
agent_kind: type === "cli" ? void 0 : type
|
|
2878
3026
|
},
|
|
2879
3027
|
signal
|
|
2880
3028
|
});
|
|
@@ -2887,15 +3035,16 @@ function isPairingResult(response) {
|
|
|
2887
3035
|
return !!response && typeof response === "object" && "token" in response && typeof response.token === "string";
|
|
2888
3036
|
}
|
|
2889
3037
|
/**
|
|
2890
|
-
* Polls until the user approves or denies, honoring the server's `slow_down` back-off.
|
|
2891
|
-
* cannot reach Studio is warned about and retried, since the code stays valid either way.
|
|
3038
|
+
* Polls until the user approves or denies, honoring the server's `slow_down` back-off.
|
|
2892
3039
|
*
|
|
2893
3040
|
* Studio's own endpoint is used rather than the auth layer's `/device/token`, because an approved
|
|
2894
3041
|
* Kubb pairing is worth an agent bearer token, not a user session.
|
|
2895
3042
|
*
|
|
2896
|
-
* @throws when the code expires
|
|
3043
|
+
* @throws {PairingExpiredError} when the code expires before anyone approves it.
|
|
3044
|
+
* @throws {PairingDeniedError} when the pairing is denied in the browser.
|
|
3045
|
+
* @throws {PairingCanceledError} when `signal` aborts.
|
|
2897
3046
|
*/
|
|
2898
|
-
async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, session, signal }) {
|
|
3047
|
+
async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, session, signal, onRetry }) {
|
|
2899
3048
|
const deadline = Date.now() + (session.expires_in > 0 ? session.expires_in : 600) * 1e3;
|
|
2900
3049
|
let intervalMs = (session.interval > 0 ? session.interval : 5) * 1e3;
|
|
2901
3050
|
while (Date.now() < deadline) {
|
|
@@ -2915,7 +3064,7 @@ async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, sessio
|
|
|
2915
3064
|
});
|
|
2916
3065
|
} catch (error) {
|
|
2917
3066
|
if (signal?.aborted) throw new PairingCanceledError();
|
|
2918
|
-
|
|
3067
|
+
onRetry?.(toError(error));
|
|
2919
3068
|
continue;
|
|
2920
3069
|
}
|
|
2921
3070
|
if (isPairingResult(response)) return response;
|
|
@@ -2925,13 +3074,32 @@ async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, sessio
|
|
|
2925
3074
|
intervalMs += 5e3;
|
|
2926
3075
|
continue;
|
|
2927
3076
|
}
|
|
2928
|
-
if (response.error === "access_denied") throw new
|
|
2929
|
-
if (response.error === "expired_token" || response.error === "invalid_grant") throw new
|
|
3077
|
+
if (response.error === "access_denied") throw new PairingDeniedError(response.error_description);
|
|
3078
|
+
if (response.error === "expired_token" || response.error === "invalid_grant") throw new PairingExpiredError(response.error_description);
|
|
2930
3079
|
throw new Error(response.error_description ?? `Pairing failed (${response.error})`);
|
|
2931
3080
|
}
|
|
2932
|
-
throw new
|
|
3081
|
+
throw new PairingExpiredError();
|
|
3082
|
+
}
|
|
3083
|
+
/**
|
|
3084
|
+
* Pairs this machine with Studio: asks for a code, hands it to the host to show, and waits for approval.
|
|
3085
|
+
*/
|
|
3086
|
+
async function pairAgent({ onCode, onRetry, maxAttempts = 1, ...options }) {
|
|
3087
|
+
for (let attempt = 1;; attempt++) {
|
|
3088
|
+
const session = await startPairing(options);
|
|
3089
|
+
await onCode(session, attempt);
|
|
3090
|
+
try {
|
|
3091
|
+
return await pollForPairingToken({
|
|
3092
|
+
studioUrl: options.studioUrl,
|
|
3093
|
+
session,
|
|
3094
|
+
signal: options.signal,
|
|
3095
|
+
onRetry
|
|
3096
|
+
});
|
|
3097
|
+
} catch (error) {
|
|
3098
|
+
if (!(error instanceof PairingExpiredError) || attempt >= maxAttempts) throw error;
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
2933
3101
|
}
|
|
2934
3102
|
//#endregion
|
|
2935
|
-
export { InvalidAgentTokenError, PairingCanceledError, connectWebSocketRpc, createAgent, createClient, createFileStorage, createJob, defaultStudioUrl, generationEventTypes, machineTokenFrom, 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 };
|
|
2936
3104
|
|
|
2937
3105
|
//# sourceMappingURL=index.js.map
|