@kubb/studio 5.3.16 → 5.3.18
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 +29 -7
- package/dist/index.cjs +695 -542
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +68 -33
- package/dist/index.js +693 -543
- 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 +77 -40
- 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 { spawn } from "node:child_process";
|
|
4
|
+
import { createHash, hash, randomBytes, randomUUID } from "node:crypto";
|
|
6
5
|
import { glob, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
7
|
import path, { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
|
+
import process$1 from "node:process";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { Diagnostics, Hookable, cacheStorage, createKubb, fsStorage, memoryStorage, resolveCacheDir } from "@kubb/core";
|
|
11
|
+
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
8
12
|
import { FetchError, ofetch } from "ofetch";
|
|
9
|
-
import { createHash, hash, randomBytes } from "node:crypto";
|
|
10
13
|
import { promisify, styleText } from "node:util";
|
|
11
14
|
import { createStorage } from "unstorage";
|
|
12
15
|
import fsDriver from "unstorage/drivers/fs";
|
|
13
|
-
import { Diagnostics, Hookable, cacheStorage, createKubb, fsStorage, memoryStorage } from "@kubb/core";
|
|
14
|
-
import { x } from "tinyexec";
|
|
15
16
|
import { builders, detectCodeFormat, generateCode, parseModule } from "magicast";
|
|
16
17
|
import { existsSync } from "node:fs";
|
|
17
18
|
import { pathToFileURL } from "node:url";
|
|
18
19
|
import { mergeDeep } from "remeda";
|
|
19
|
-
import { tmpdir } from "node:os";
|
|
20
20
|
import { gzip } from "node:zlib";
|
|
21
21
|
import { build } from "tsdown";
|
|
22
22
|
import { RpcTarget, newWebSocketRpcSession } from "capnweb";
|
|
23
23
|
import WebSocket from "ws";
|
|
24
|
-
import {
|
|
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
|
|
24
|
+
import { x } from "tinyexec";
|
|
69
25
|
//#region ../../internals/utils/src/casing.ts
|
|
70
26
|
/**
|
|
71
27
|
* Shared implementation for camelCase and PascalCase conversion.
|
|
@@ -388,7 +344,10 @@ function getElapsedMs(hrStart) {
|
|
|
388
344
|
return Math.round(ms * 100) / 100;
|
|
389
345
|
}
|
|
390
346
|
//#endregion
|
|
391
|
-
//#region
|
|
347
|
+
//#region package.json
|
|
348
|
+
var version = "5.3.18";
|
|
349
|
+
//#endregion
|
|
350
|
+
//#region src/operations/machine.ts
|
|
392
351
|
/**
|
|
393
352
|
* Key-value storage the runtime uses for its machine secret and the last Studio config.
|
|
394
353
|
*
|
|
@@ -448,7 +407,7 @@ async function getMachineToken() {
|
|
|
448
407
|
return machineTokenFrom(await fallbackSecretPromise);
|
|
449
408
|
}
|
|
450
409
|
//#endregion
|
|
451
|
-
//#region src/api.ts
|
|
410
|
+
//#region src/operations/api.ts
|
|
452
411
|
/**
|
|
453
412
|
* Reads a human-readable message from a Studio JSON error body, when it has one. `FetchError`'s own
|
|
454
413
|
* message stops at the status line, so the detail Studio sends with a failure (an agent limit, a
|
|
@@ -468,10 +427,6 @@ function responseMessage(data) {
|
|
|
468
427
|
*/
|
|
469
428
|
const REGISTER_RETRIES = 3;
|
|
470
429
|
/**
|
|
471
|
-
* Shared in-flight registration so concurrent pool sessions trigger one purge, not N.
|
|
472
|
-
*/
|
|
473
|
-
let registrationInFlight = null;
|
|
474
|
-
/**
|
|
475
430
|
* Thrown when Studio rejects the agent token itself (401). Retrying cannot help: the token was
|
|
476
431
|
* revoked, or the agent it belonged to was deleted in the Studio UI. Hosts catch this to forget
|
|
477
432
|
* the stored credential and pair again.
|
|
@@ -483,117 +438,87 @@ var InvalidAgentTokenError = class extends Error {
|
|
|
483
438
|
}
|
|
484
439
|
};
|
|
485
440
|
/**
|
|
441
|
+
* Thrown when Studio refuses this agent's protocol version (426). Retrying cannot help until the
|
|
442
|
+
* agent is upgraded, so hosts stop instead of reconnecting.
|
|
443
|
+
*/
|
|
444
|
+
var IncompatibleAgentError = class extends Error {
|
|
445
|
+
constructor(studioUrl, detail, options) {
|
|
446
|
+
super(`Kubb Studio at ${studioUrl} requires a newer agent${detail ? `: ${detail}` : ""}. Upgrade @kubb/studio or the Kubb agent image.`, options);
|
|
447
|
+
this.name = "IncompatibleAgentError";
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
/**
|
|
486
451
|
* Whether a thrown value carries `statusCode`. Not narrowed to `FetchError`: a host wrapper can
|
|
487
452
|
* 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
453
|
*/
|
|
492
454
|
function rejectedWith(error, statusCode) {
|
|
493
455
|
return error?.statusCode === statusCode;
|
|
494
456
|
}
|
|
495
|
-
function
|
|
457
|
+
function registrationError(cause) {
|
|
496
458
|
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;
|
|
511
|
-
}
|
|
512
|
-
/**
|
|
513
|
-
* Obtain an agent session token from Kubb Studio via HTTP.
|
|
514
|
-
*
|
|
515
|
-
* When Studio rejects the machine token (403), for example after the agent restarted
|
|
516
|
-
* with a new identity while the startup registration call failed, the agent re-registers
|
|
517
|
-
* and retries once, so a single failed registration can't permanently block session creation.
|
|
518
|
-
*/
|
|
519
|
-
async function createAgentSession({ token, studioUrl }) {
|
|
520
|
-
try {
|
|
521
|
-
return await requestAgentSession({
|
|
522
|
-
token,
|
|
523
|
-
studioUrl
|
|
524
|
-
});
|
|
525
|
-
} catch (error) {
|
|
526
|
-
if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
|
|
527
|
-
if (!rejectedWith(error, 403) || !await registerAgent({
|
|
528
|
-
token,
|
|
529
|
-
studioUrl
|
|
530
|
-
})) throw sessionError(error);
|
|
531
|
-
try {
|
|
532
|
-
return await requestAgentSession({
|
|
533
|
-
token,
|
|
534
|
-
studioUrl
|
|
535
|
-
});
|
|
536
|
-
} catch (retryError) {
|
|
537
|
-
if (rejectedWith(retryError, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: retryError });
|
|
538
|
-
throw sessionError(retryError);
|
|
539
|
-
}
|
|
540
|
-
}
|
|
459
|
+
return new Error(detail ? `Failed to register with Kubb Studio: ${detail}` : "Failed to register with Kubb Studio", { cause });
|
|
541
460
|
}
|
|
542
461
|
/**
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
462
|
+
* Registers this agent process with Kubb Studio (`POST /api/agent/connect`): binds the machine
|
|
463
|
+
* identity to the token, reports what the process can take on, and gets back the URL of the one
|
|
464
|
+
* socket it keeps open.
|
|
546
465
|
*
|
|
547
|
-
* Retries
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
}
|
|
557
|
-
return registrationInFlight;
|
|
558
|
-
}
|
|
559
|
-
async function runRegistration({ token, studioUrl, poolSize }) {
|
|
560
|
-
const machineToken = await getMachineToken();
|
|
466
|
+
* Retries a transient failure with backoff. A rejected token (401) throws
|
|
467
|
+
* {@link InvalidAgentTokenError} and an unsupported agent version (426) throws
|
|
468
|
+
* {@link IncompatibleAgentError}, since retrying either cannot help.
|
|
469
|
+
*/
|
|
470
|
+
async function registerAgent({ token, studioUrl, instanceId, capacity }) {
|
|
471
|
+
const body = {
|
|
472
|
+
machineToken: await getMachineToken(),
|
|
473
|
+
instanceId,
|
|
474
|
+
capacity
|
|
475
|
+
};
|
|
561
476
|
try {
|
|
562
|
-
await ofetch(`${studioUrl}/api/agent/connect`, {
|
|
477
|
+
return await ofetch(`${studioUrl}/api/agent/connect`, {
|
|
563
478
|
method: "POST",
|
|
564
479
|
headers: { Authorization: `Bearer ${token}` },
|
|
565
|
-
body
|
|
566
|
-
machineToken,
|
|
567
|
-
poolSize
|
|
568
|
-
},
|
|
480
|
+
body,
|
|
569
481
|
retry: REGISTER_RETRIES,
|
|
570
482
|
retryDelay: ({ options }) => 2e3 * 2 ** (REGISTER_RETRIES - Number(options.retry))
|
|
571
483
|
});
|
|
572
|
-
return true;
|
|
573
484
|
} catch (error) {
|
|
574
485
|
if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
|
|
575
|
-
|
|
486
|
+
if (rejectedWith(error, 426)) throw new IncompatibleAgentError(studioUrl, error instanceof FetchError ? responseMessage(error.data) : void 0, { cause: error });
|
|
487
|
+
throw registrationError(error);
|
|
576
488
|
}
|
|
577
489
|
}
|
|
578
490
|
/**
|
|
579
|
-
*
|
|
580
|
-
* Called on process termination or server close. Never throws: the local socket is already gone,
|
|
581
|
-
* and failing teardown must not block shutdown or reconnect.
|
|
582
|
-
*
|
|
583
|
-
* @returns `false` when Studio could not be reached or rate limited the call. Any other 4xx
|
|
584
|
-
* counts as notified, since it means Studio already dropped the session.
|
|
491
|
+
* First wait before `createJob` retries a busy or queue-full response, absent a `Retry-After` hint.
|
|
585
492
|
*/
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
493
|
+
const CREATE_JOB_INITIAL_DELAY_MS = 1e3;
|
|
494
|
+
/**
|
|
495
|
+
* Slowest `createJob` backs off to between retries.
|
|
496
|
+
*/
|
|
497
|
+
const CREATE_JOB_MAX_INTERVAL_MS = 1e4;
|
|
498
|
+
/**
|
|
499
|
+
* Statuses worth retrying: the agent has no free connection yet (409, a stale conflict a moment
|
|
500
|
+
* later resolves), its queue is momentarily full (429), or it has no live connection at all yet
|
|
501
|
+
* (503, an agent process that is mid-reconnect). Anything else (404 agent not found, 401/403 auth)
|
|
502
|
+
* is thrown straight away, since retrying cannot change the outcome.
|
|
503
|
+
*/
|
|
504
|
+
const CREATE_JOB_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
|
|
505
|
+
409,
|
|
506
|
+
429,
|
|
507
|
+
503
|
|
508
|
+
]);
|
|
509
|
+
/**
|
|
510
|
+
* Reads Studio's `Retry-After` header (seconds) off a thrown `ofetch` error, when present.
|
|
511
|
+
*/
|
|
512
|
+
function retryAfterMs(error) {
|
|
513
|
+
const seconds = Number(error.response?.headers.get("retry-after"));
|
|
514
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Adds up to 30% jitter, so every CI run queued behind the same busy agent does not retry in
|
|
518
|
+
* lockstep.
|
|
519
|
+
*/
|
|
520
|
+
function withJitter(ms) {
|
|
521
|
+
return ms + Math.random() * ms * .3;
|
|
597
522
|
}
|
|
598
523
|
/**
|
|
599
524
|
* Queues a generation or snapshot job on Studio (`POST /api/jobs`).
|
|
@@ -601,6 +526,10 @@ async function disconnect({ sessionId, token, studioUrl }) {
|
|
|
601
526
|
* Returns as soon as Studio accepts the job (`202`). Poll with {@link waitForJob} until it finishes.
|
|
602
527
|
* Authenticates with the organization CI API key via `x-api-key`.
|
|
603
528
|
*
|
|
529
|
+
* A busy agent, a full queue, or a momentary lack of a live connection (409, 429, 503) retries with
|
|
530
|
+
* exponential backoff and jitter, honoring Studio's `Retry-After` header when it sends one, up to
|
|
531
|
+
* `timeoutMs`. Every other failure, including a missing agent (404), throws immediately.
|
|
532
|
+
*
|
|
604
533
|
* @example Snapshot job
|
|
605
534
|
* ```ts
|
|
606
535
|
* const job = await createJob({
|
|
@@ -614,20 +543,41 @@ async function disconnect({ sessionId, token, studioUrl }) {
|
|
|
614
543
|
* const finished = await waitForJob({ studioUrl, token, id: job.id })
|
|
615
544
|
* ```
|
|
616
545
|
*/
|
|
617
|
-
async function createJob({ studioUrl, token, type, agentId, name, version, commit, config }) {
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
546
|
+
async function createJob({ studioUrl, token, type, agentId, name, version, commit, baseId, config, instanceId, timeoutMs = 6e4, signal }) {
|
|
547
|
+
const deadline = Date.now() + timeoutMs;
|
|
548
|
+
let interval = CREATE_JOB_INITIAL_DELAY_MS;
|
|
549
|
+
for (;;) {
|
|
550
|
+
signal?.throwIfAborted();
|
|
551
|
+
try {
|
|
552
|
+
const { job } = await ofetch(`${studioUrl}/api/jobs`, {
|
|
553
|
+
method: "POST",
|
|
554
|
+
headers: { "x-api-key": token },
|
|
555
|
+
body: {
|
|
556
|
+
type,
|
|
557
|
+
agentId,
|
|
558
|
+
name,
|
|
559
|
+
version,
|
|
560
|
+
commit,
|
|
561
|
+
baseId,
|
|
562
|
+
config,
|
|
563
|
+
instanceId
|
|
564
|
+
},
|
|
565
|
+
retry: false,
|
|
566
|
+
timeout: Math.max(deadline - Date.now(), 1),
|
|
567
|
+
signal
|
|
568
|
+
});
|
|
569
|
+
return job;
|
|
570
|
+
} catch (error) {
|
|
571
|
+
signal?.throwIfAborted();
|
|
572
|
+
const status = error.response?.status;
|
|
573
|
+
if (!status || !CREATE_JOB_RETRYABLE_STATUSES.has(status) || Date.now() >= deadline) throw error;
|
|
574
|
+
const wait = Math.min(retryAfterMs(error) ?? withJitter(interval), Math.max(deadline - Date.now(), 0));
|
|
575
|
+
if (signal) await setTimeout$1(wait, void 0, { signal });
|
|
576
|
+
else await new Promise((resolve) => setTimeout(resolve, wait));
|
|
577
|
+
if (Date.now() >= deadline) throw error;
|
|
578
|
+
interval = Math.min(interval * 2, CREATE_JOB_MAX_INTERVAL_MS);
|
|
628
579
|
}
|
|
629
|
-
}
|
|
630
|
-
return job;
|
|
580
|
+
}
|
|
631
581
|
}
|
|
632
582
|
/**
|
|
633
583
|
* A job runs a generation and packs a tarball, so it is never done the instant it is queued.
|
|
@@ -646,20 +596,26 @@ const MAX_POLL_INTERVAL_MS = 3e4;
|
|
|
646
596
|
* A `failed` job resolves normally. Check `job.status` and `job.error`. Throws only when the
|
|
647
597
|
* deadline passes before Studio finishes.
|
|
648
598
|
*/
|
|
649
|
-
async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4 }) {
|
|
599
|
+
async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4, signal }) {
|
|
650
600
|
const deadline = Date.now() + timeoutMs;
|
|
651
601
|
let interval = INITIAL_POLL_DELAY_MS;
|
|
652
602
|
for (;;) {
|
|
653
|
-
|
|
603
|
+
signal?.throwIfAborted();
|
|
604
|
+
const wait = Math.max(Math.min(interval, deadline - Date.now()), 0);
|
|
605
|
+
if (signal) await setTimeout$1(wait, void 0, { signal });
|
|
606
|
+
else await new Promise((resolve) => setTimeout(resolve, wait));
|
|
654
607
|
if (Date.now() >= deadline) throw new Error("Timed out waiting for the Studio job");
|
|
655
608
|
interval = Math.min(interval * 2, MAX_POLL_INTERVAL_MS);
|
|
656
609
|
try {
|
|
657
610
|
const { job } = await ofetch(`${studioUrl}/api/jobs/${id}`, {
|
|
658
611
|
headers: { "x-api-key": token },
|
|
659
|
-
retry: false
|
|
612
|
+
retry: false,
|
|
613
|
+
timeout: Math.max(deadline - Date.now(), 1),
|
|
614
|
+
signal
|
|
660
615
|
});
|
|
661
616
|
if (job.status === "success" || job.status === "failed" || job.status === "canceled") return job;
|
|
662
617
|
} catch (error) {
|
|
618
|
+
signal?.throwIfAborted();
|
|
663
619
|
const response = error.response;
|
|
664
620
|
if (response?.status !== 429) throw error;
|
|
665
621
|
const retryAfter = response._data?.data?.tryAgainIn;
|
|
@@ -693,90 +649,7 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
|
|
|
693
649
|
}
|
|
694
650
|
}
|
|
695
651
|
//#endregion
|
|
696
|
-
//#region
|
|
697
|
-
var version = "5.3.16";
|
|
698
|
-
//#endregion
|
|
699
|
-
//#region src/hooks.ts
|
|
700
|
-
/**
|
|
701
|
-
* Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
|
|
702
|
-
* streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
|
|
703
|
-
* Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
|
|
704
|
-
*
|
|
705
|
-
* Returns a remover, so a session that runs one generation after another on the same emitter does
|
|
706
|
-
* not stack a listener per run.
|
|
707
|
-
*/
|
|
708
|
-
function setupHookListener(hooks, root, signal) {
|
|
709
|
-
return hooks.hook("kubb:hook:start", async (ctx) => {
|
|
710
|
-
const { id, command, args } = ctx;
|
|
711
|
-
if (!id) return;
|
|
712
|
-
const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
|
|
713
|
-
try {
|
|
714
|
-
const proc = x(command, [...args ?? []], {
|
|
715
|
-
signal,
|
|
716
|
-
nodeOptions: {
|
|
717
|
-
cwd: root,
|
|
718
|
-
detached: true
|
|
719
|
-
}
|
|
720
|
-
});
|
|
721
|
-
for await (const line of proc) await hooks.callHook("kubb:hook:line", {
|
|
722
|
-
id,
|
|
723
|
-
line
|
|
724
|
-
});
|
|
725
|
-
const { exitCode } = await proc;
|
|
726
|
-
if (exitCode !== 0) {
|
|
727
|
-
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
728
|
-
await hooks.callHook("kubb:hook:end", {
|
|
729
|
-
id,
|
|
730
|
-
command,
|
|
731
|
-
args,
|
|
732
|
-
success: false,
|
|
733
|
-
error
|
|
734
|
-
});
|
|
735
|
-
await hooks.callHook("kubb:error", { error });
|
|
736
|
-
return;
|
|
737
|
-
}
|
|
738
|
-
await hooks.callHook("kubb:hook:end", {
|
|
739
|
-
id,
|
|
740
|
-
command,
|
|
741
|
-
args,
|
|
742
|
-
success: true,
|
|
743
|
-
error: null
|
|
744
|
-
});
|
|
745
|
-
} catch (caughtError) {
|
|
746
|
-
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
747
|
-
error.cause = caughtError;
|
|
748
|
-
await hooks.callHook("kubb:hook:end", {
|
|
749
|
-
id,
|
|
750
|
-
command,
|
|
751
|
-
args,
|
|
752
|
-
success: false,
|
|
753
|
-
error
|
|
754
|
-
});
|
|
755
|
-
await hooks.callHook("kubb:error", { error });
|
|
756
|
-
}
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
|
-
/**
|
|
760
|
-
* Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
|
|
761
|
-
* `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
|
|
762
|
-
* that same listener, so a handler added afterward would already have missed it.
|
|
763
|
-
*/
|
|
764
|
-
function waitForHookEnd(hooks, hookId) {
|
|
765
|
-
return new Promise((resolve, reject) => {
|
|
766
|
-
const handleHookEnd = (ctx) => {
|
|
767
|
-
if (ctx.id !== hookId) return;
|
|
768
|
-
hooks.removeHook("kubb:hook:end", handleHookEnd);
|
|
769
|
-
if (ctx.success) {
|
|
770
|
-
resolve();
|
|
771
|
-
return;
|
|
772
|
-
}
|
|
773
|
-
reject(ctx.error);
|
|
774
|
-
};
|
|
775
|
-
hooks.hook("kubb:hook:end", handleHookEnd);
|
|
776
|
-
});
|
|
777
|
-
}
|
|
778
|
-
//#endregion
|
|
779
|
-
//#region src/resolveConfig.ts
|
|
652
|
+
//#region src/operations/resolveConfig.ts
|
|
780
653
|
/**
|
|
781
654
|
* Imports a package, falling back to how the user's project would resolve it.
|
|
782
655
|
*
|
|
@@ -934,7 +807,7 @@ async function mergeAdapter(diskAdapter, studioOptions) {
|
|
|
934
807
|
return factory(mergeDeep(diskAdapter.options ?? {}, studioOptions));
|
|
935
808
|
}
|
|
936
809
|
//#endregion
|
|
937
|
-
//#region src/configFile.ts
|
|
810
|
+
//#region src/operations/configFile.ts
|
|
938
811
|
/**
|
|
939
812
|
* A valid JavaScript identifier, so an import name can only ever print as `import { name } from`,
|
|
940
813
|
* never as source that breaks out of the import statement.
|
|
@@ -1474,6 +1347,11 @@ function applyConfigEdits(source, edits) {
|
|
|
1474
1347
|
changed: current !== source
|
|
1475
1348
|
};
|
|
1476
1349
|
}
|
|
1350
|
+
async function writeConfigEdits({ filePath, edits }) {
|
|
1351
|
+
const result = applyConfigEdits(await read(filePath), edits);
|
|
1352
|
+
if (result.changed) await writeFile(filePath, result.source, "utf-8");
|
|
1353
|
+
return result;
|
|
1354
|
+
}
|
|
1477
1355
|
/**
|
|
1478
1356
|
* The 1-based line where the file's last import declaration ends, or `0` when it has none. Read
|
|
1479
1357
|
* off the parsed module, so a multi-line `import {\n x,\n} from '...'` reports its closing line
|
|
@@ -1506,145 +1384,60 @@ function withTrailingNewline(code, hadTrailingNewline) {
|
|
|
1506
1384
|
return `${code}\n`;
|
|
1507
1385
|
}
|
|
1508
1386
|
//#endregion
|
|
1509
|
-
//#region src/
|
|
1510
|
-
/**
|
|
1511
|
-
* `isToolAvailable` spawns a process, and a long-lived connection generates repeatedly, so each
|
|
1512
|
-
* executable is probed once per process. The CLI deliberately does not memoize: a `--watch` build
|
|
1513
|
-
* should keep noticing a tool installed mid-session.
|
|
1514
|
-
*/
|
|
1515
|
-
const detectTool = memoize(/* @__PURE__ */ new Map(), detectTool$1);
|
|
1387
|
+
//#region src/operations/constants.ts
|
|
1516
1388
|
/**
|
|
1517
|
-
*
|
|
1518
|
-
*
|
|
1519
|
-
*
|
|
1520
|
-
* `noun` and `verbing` are spelled out instead of built from `kind`. Concatenating `` `${kind}ter` ``
|
|
1521
|
-
* and `` `${kind}ting` `` works for `format`, but doubles the `t` in `lint`, giving "lintter" and
|
|
1522
|
-
* "lintting" instead of "linter" and "linting".
|
|
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.
|
|
1523
1391
|
*/
|
|
1524
|
-
const
|
|
1525
|
-
kind: "format",
|
|
1526
|
-
noun: "formatter",
|
|
1527
|
-
verbing: "Formatting",
|
|
1528
|
-
tools: formatters,
|
|
1529
|
-
detect: FORMATTER_PREFERENCE
|
|
1530
|
-
}, {
|
|
1531
|
-
kind: "lint",
|
|
1532
|
-
noun: "linter",
|
|
1533
|
-
verbing: "Linting",
|
|
1534
|
-
tools: linters,
|
|
1535
|
-
detect: LINTER_PREFERENCE
|
|
1536
|
-
}];
|
|
1392
|
+
const defaultStudioUrl = "https://kubb.studio";
|
|
1537
1393
|
/**
|
|
1538
|
-
*
|
|
1394
|
+
* Defaults the Studio client uses when a host passes nothing.
|
|
1395
|
+
* Config path is left out on purpose: each host discovers that itself.
|
|
1539
1396
|
*/
|
|
1540
|
-
|
|
1541
|
-
|
|
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;
|
|
1542
1417
|
}
|
|
1543
1418
|
/**
|
|
1544
|
-
*
|
|
1545
|
-
*
|
|
1546
|
-
*
|
|
1547
|
-
* @throws whatever the command failed with, so callers can report it their own way.
|
|
1548
|
-
*/
|
|
1549
|
-
async function runHook({ hooks, id, command, args }) {
|
|
1550
|
-
const hookId = hash("sha256", id);
|
|
1551
|
-
const hookEnd = waitForHookEnd(hooks, hookId);
|
|
1552
|
-
await hooks.callHook("kubb:hook:start", {
|
|
1553
|
-
id: hookId,
|
|
1554
|
-
command,
|
|
1555
|
-
args: [...args]
|
|
1556
|
-
});
|
|
1557
|
-
await hookEnd;
|
|
1558
|
-
}
|
|
1559
|
-
function isProblemErrorDiagnostic(diagnostic) {
|
|
1560
|
-
return (diagnostic.kind ?? "problem") === "problem" && diagnostic.severity === "error";
|
|
1561
|
-
}
|
|
1562
|
-
/**
|
|
1563
|
-
* Folds error-severity diagnostics into one thrown error so logs name the failing plugin.
|
|
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.
|
|
1564
1422
|
*/
|
|
1565
|
-
function
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
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
|
+
};
|
|
1569
1429
|
}
|
|
1570
1430
|
/**
|
|
1571
|
-
*
|
|
1572
|
-
*
|
|
1573
|
-
* Emits lifecycle events on the provided `hooks` emitter so callers (e.g. the WebSocket stream)
|
|
1574
|
-
* can forward progress to connected clients. After a successful build, auto-formatting and
|
|
1575
|
-
* linting are applied when configured, followed by any user-defined `hooks.done` commands.
|
|
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.
|
|
1576
1433
|
*/
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
const hrStart = process$1.hrtime();
|
|
1580
|
-
await hooks.callHook("kubb:generation:start", { config });
|
|
1581
|
-
await hooks.callHook("kubb:info", { message: config.name ? `Setup generation ${config.name}` : "Setup generation" });
|
|
1582
|
-
const kubb = createKubb(config, {
|
|
1583
|
-
hooks,
|
|
1584
|
-
signal
|
|
1585
|
-
});
|
|
1586
|
-
await kubb.setup();
|
|
1587
|
-
await hooks.callHook("kubb:info", { message: config.name ? `Build generation ${config.name}` : "Build generation" });
|
|
1588
|
-
const { files, diagnostics, storage } = await kubb.safeBuild();
|
|
1589
|
-
signal?.throwIfAborted();
|
|
1590
|
-
await hooks.callHook("kubb:info", { message: "Load summary" });
|
|
1591
|
-
for (const diagnostic of diagnostics.filter(isProblemErrorDiagnostic)) await hooks.callHook("kubb:error", { error: new Error(diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message) });
|
|
1592
|
-
const status = Diagnostics.hasError(diagnostics) ? "failed" : "success";
|
|
1593
|
-
await hooks.callHook("kubb:generation:end", {
|
|
1594
|
-
config,
|
|
1595
|
-
storage: {
|
|
1596
|
-
...storage,
|
|
1597
|
-
readKeys: async () => [...new Set(files.map((file) => file.path))]
|
|
1598
|
-
},
|
|
1599
|
-
diagnostics,
|
|
1600
|
-
status,
|
|
1601
|
-
hrStart,
|
|
1602
|
-
filesCreated: files.length
|
|
1603
|
-
});
|
|
1604
|
-
if (status === "failed") throw formatGenerationFailure(diagnostics);
|
|
1605
|
-
await hooks.callHook("kubb:success", { message: "Generation successfully" });
|
|
1606
|
-
for (const step of TOOL_STEPS) {
|
|
1607
|
-
const setting = config.output[step.kind];
|
|
1608
|
-
if (!setting) continue;
|
|
1609
|
-
await hooks.callHook(`kubb:${step.kind}:start`);
|
|
1610
|
-
const tool = setting === "auto" ? await detectTool(step.detect) : setting;
|
|
1611
|
-
if (!tool) await hooks.callHook("kubb:warn", { message: `No ${step.noun} found (${step.detect.join(", ")}). Skipping ${step.verbing.toLowerCase()}.` });
|
|
1612
|
-
if (tool && setting === "auto") await hooks.callHook("kubb:info", { message: `Auto-detected ${step.noun}: ${styleText("dim", tool)}` });
|
|
1613
|
-
const command = tool ? step.tools[tool] : void 0;
|
|
1614
|
-
if (command) try {
|
|
1615
|
-
await runHook({
|
|
1616
|
-
hooks,
|
|
1617
|
-
id: [config.name, tool].filter(Boolean).join("-"),
|
|
1618
|
-
command: command.command,
|
|
1619
|
-
args: command.args(outputPath(config))
|
|
1620
|
-
});
|
|
1621
|
-
await hooks.callHook("kubb:success", { message: `${step.verbing} with ${tool} successfully` });
|
|
1622
|
-
} catch (caughtError) {
|
|
1623
|
-
await hooks.callHook("kubb:error", { error: new Error(command.errorMessage, { cause: caughtError }) });
|
|
1624
|
-
signal?.throwIfAborted();
|
|
1625
|
-
}
|
|
1626
|
-
await hooks.callHook(`kubb:${step.kind}:end`);
|
|
1627
|
-
}
|
|
1628
|
-
if (config.output.postGenerate?.length) {
|
|
1629
|
-
await hooks.callHook("kubb:hooks:start");
|
|
1630
|
-
for (const entry of config.output.postGenerate) {
|
|
1631
|
-
const line = typeof entry === "string" ? entry : entry.command;
|
|
1632
|
-
const [cmd, ...args] = tokenize(line);
|
|
1633
|
-
if (!cmd) continue;
|
|
1634
|
-
await runHook({
|
|
1635
|
-
hooks,
|
|
1636
|
-
id: line,
|
|
1637
|
-
command: cmd,
|
|
1638
|
-
args
|
|
1639
|
-
});
|
|
1640
|
-
await hooks.callHook("kubb:success", { message: `${line} successfully executed` });
|
|
1641
|
-
}
|
|
1642
|
-
await hooks.callHook("kubb:hooks:end");
|
|
1643
|
-
}
|
|
1434
|
+
function resolveAgentCapacity(env = process.env) {
|
|
1435
|
+
return { maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)) };
|
|
1644
1436
|
}
|
|
1645
1437
|
//#endregion
|
|
1646
|
-
//#region src/snapshotPackage.ts
|
|
1438
|
+
//#region src/operations/snapshotPackage.ts
|
|
1647
1439
|
const gzipAsync = promisify(gzip);
|
|
1440
|
+
const UPLOAD_TIMEOUT_MS = 12e4;
|
|
1648
1441
|
/**
|
|
1649
1442
|
* Maps a generated file's path to its place inside the tarball, stripping everything before a
|
|
1650
1443
|
* `src`/`dist` segment and any `..`/empty path segment so a crafted file name cannot escape the
|
|
@@ -1782,10 +1575,33 @@ async function createSnapshotPackage(files, packageInfo) {
|
|
|
1782
1575
|
});
|
|
1783
1576
|
}
|
|
1784
1577
|
}
|
|
1578
|
+
async function uploadSnapshot({ bytes, uploadPath, studioUrl, token, shutdown }) {
|
|
1579
|
+
const uploadUrl = new URL(uploadPath, studioUrl);
|
|
1580
|
+
if (uploadUrl.origin !== new URL(studioUrl).origin) throw new Error("Snapshot upload path must stay on the Studio origin");
|
|
1581
|
+
const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS);
|
|
1582
|
+
const signal = shutdown ? AbortSignal.any([shutdown, timeout]) : timeout;
|
|
1583
|
+
const redirect = await fetch(uploadUrl, {
|
|
1584
|
+
method: "PUT",
|
|
1585
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1586
|
+
redirect: "manual",
|
|
1587
|
+
signal
|
|
1588
|
+
});
|
|
1589
|
+
const storageUrl = redirect.headers.get("location");
|
|
1590
|
+
if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
|
|
1591
|
+
const storage = new URL(storageUrl);
|
|
1592
|
+
if (storage.protocol !== "https:" && storage.hostname !== "localhost" && storage.hostname !== "127.0.0.1") throw new Error(`Refusing snapshot upload to ${storage.origin}`);
|
|
1593
|
+
const response = await fetch(storage, {
|
|
1594
|
+
method: "PUT",
|
|
1595
|
+
body: new Uint8Array(bytes),
|
|
1596
|
+
redirect: "error",
|
|
1597
|
+
signal
|
|
1598
|
+
});
|
|
1599
|
+
if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
|
|
1600
|
+
}
|
|
1785
1601
|
//#endregion
|
|
1786
|
-
//#region src/generations.ts
|
|
1602
|
+
//#region src/operations/generations.ts
|
|
1787
1603
|
const READ_CONCURRENCY = 50;
|
|
1788
|
-
const MB = 1048576;
|
|
1604
|
+
const MB$1 = 1048576;
|
|
1789
1605
|
const INDEX_KEY = "studio/generations.json";
|
|
1790
1606
|
const hashOf = (content) => createHash("sha1").update(content).digest("hex").slice(0, 16);
|
|
1791
1607
|
/**
|
|
@@ -1812,8 +1628,9 @@ async function listDisk({ root, outputPath, maxFiles }) {
|
|
|
1812
1628
|
* tenant never reaches another's output. The oldest go past `maxCount` or `maxMb`, but the newest
|
|
1813
1629
|
* always stays.
|
|
1814
1630
|
*/
|
|
1815
|
-
function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
1631
|
+
function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now }) {
|
|
1816
1632
|
let index;
|
|
1633
|
+
const isLive = (generation) => ttlMs === void 0 || generation.keptAt === void 0 || now() - generation.keptAt < ttlMs;
|
|
1817
1634
|
const dirOf = (jobId) => `studio/generations/${hashOf(jobId)}/`;
|
|
1818
1635
|
async function load() {
|
|
1819
1636
|
if (index) return index;
|
|
@@ -1829,7 +1646,7 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
|
1829
1646
|
* Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
|
|
1830
1647
|
*/
|
|
1831
1648
|
async function keep({ jobId, source, files, maxSetMb }) {
|
|
1832
|
-
const maxSetBytes = maxSetMb * MB;
|
|
1649
|
+
const maxSetBytes = maxSetMb * MB$1;
|
|
1833
1650
|
const hashes = {};
|
|
1834
1651
|
let bytes = 0;
|
|
1835
1652
|
await inParallel({
|
|
@@ -1864,13 +1681,20 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
|
1864
1681
|
return {
|
|
1865
1682
|
keep,
|
|
1866
1683
|
drop,
|
|
1867
|
-
get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId),
|
|
1868
|
-
latest: async () => (await load()).at(-1),
|
|
1684
|
+
get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId && isLive(generation)),
|
|
1685
|
+
latest: async () => (await load()).filter(isLive).at(-1),
|
|
1686
|
+
/** Total bytes of every set the store holds, expired ones too until the next add drops them. */
|
|
1687
|
+
bytes: async () => (await load()).reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0),
|
|
1869
1688
|
async add(generation) {
|
|
1870
|
-
const
|
|
1871
|
-
|
|
1689
|
+
const current = await load();
|
|
1690
|
+
for (const expired of current.filter((entry) => !isLive(entry))) await drop(expired.jobId);
|
|
1691
|
+
const entries = current.filter((entry) => entry.jobId !== generation.jobId && isLive(entry));
|
|
1692
|
+
entries.push({
|
|
1693
|
+
...generation,
|
|
1694
|
+
keptAt: now()
|
|
1695
|
+
});
|
|
1872
1696
|
const weight = () => entries.reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0);
|
|
1873
|
-
while (entries.length > 1 && (entries.length > maxCount || weight() > maxMb * MB)) await drop(entries.shift().jobId);
|
|
1697
|
+
while (entries.length > 1 && (entries.length > maxCount || weight() > maxMb * MB$1)) await drop(entries.shift().jobId);
|
|
1874
1698
|
index = entries;
|
|
1875
1699
|
await storage.writeItem(INDEX_KEY, JSON.stringify(entries));
|
|
1876
1700
|
},
|
|
@@ -1893,12 +1717,7 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
|
1893
1717
|
};
|
|
1894
1718
|
}
|
|
1895
1719
|
//#endregion
|
|
1896
|
-
//#region src/
|
|
1897
|
-
/**
|
|
1898
|
-
* How long the initial handshake may take before the socket is closed and the reconnect loop
|
|
1899
|
-
* takes over.
|
|
1900
|
-
*/
|
|
1901
|
-
const CONNECT_TIMEOUT_MS = 5e3;
|
|
1720
|
+
//#region src/operations/generationEvents.ts
|
|
1902
1721
|
const require = createRequire(import.meta.url);
|
|
1903
1722
|
function relativeStoragePath(root, filePath) {
|
|
1904
1723
|
return (isAbsolute(filePath) ? relative(resolve(root), filePath) : filePath).replaceAll("\\", "/");
|
|
@@ -1928,27 +1747,24 @@ async function resolvePeerDependencies(names) {
|
|
|
1928
1747
|
missingDependencies
|
|
1929
1748
|
};
|
|
1930
1749
|
}
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
function createWebsocket(url, options) {
|
|
1935
|
-
const ws = new WebSocket(url, options);
|
|
1936
|
-
const timer = setTimeout(() => {
|
|
1937
|
-
if (ws.readyState === WebSocket.CONNECTING) ws.close(3008, "Connection timeout");
|
|
1938
|
-
}, CONNECT_TIMEOUT_MS);
|
|
1939
|
-
ws.once("open", () => clearTimeout(timer));
|
|
1940
|
-
ws.once("close", () => clearTimeout(timer));
|
|
1941
|
-
return ws;
|
|
1942
|
-
}
|
|
1750
|
+
const MAX_QUEUED_EVENTS = 1024;
|
|
1751
|
+
const RESERVED_EVENTS = 64;
|
|
1752
|
+
const isDiscardable = (event) => event.type === "kubb:files:processing:update" || event.type === "kubb:info" || event.type === "kubb:success";
|
|
1943
1753
|
/** Forwards selected Kubb lifecycle events to a native Cap'n Web stream. */
|
|
1944
1754
|
function createGenerationStream(hooks, jobId, options = {}) {
|
|
1945
1755
|
const unhooks = [];
|
|
1946
1756
|
let root = "";
|
|
1947
|
-
|
|
1948
|
-
const writer = transform.writable.getWriter();
|
|
1949
|
-
let writes = Promise.resolve();
|
|
1757
|
+
let controller;
|
|
1950
1758
|
let closed = false;
|
|
1951
|
-
|
|
1759
|
+
const stream = new ReadableStream({
|
|
1760
|
+
start: (value) => {
|
|
1761
|
+
controller = value;
|
|
1762
|
+
},
|
|
1763
|
+
cancel: () => {
|
|
1764
|
+
closed = true;
|
|
1765
|
+
detach();
|
|
1766
|
+
}
|
|
1767
|
+
}, { highWaterMark: MAX_QUEUED_EVENTS });
|
|
1952
1768
|
/**
|
|
1953
1769
|
* Registers a listener and keeps its remover, so one generation's listeners come off the session
|
|
1954
1770
|
* emitter again when that generation ends.
|
|
@@ -1957,6 +1773,7 @@ function createGenerationStream(hooks, jobId, options = {}) {
|
|
|
1957
1773
|
unhooks.push(hooks.hook(name, handler));
|
|
1958
1774
|
}
|
|
1959
1775
|
function emitEvent(type, data) {
|
|
1776
|
+
if (closed) return;
|
|
1960
1777
|
const event = {
|
|
1961
1778
|
jobId,
|
|
1962
1779
|
type,
|
|
@@ -1964,9 +1781,12 @@ function createGenerationStream(hooks, jobId, options = {}) {
|
|
|
1964
1781
|
version: 1,
|
|
1965
1782
|
timestamp: Date.now()
|
|
1966
1783
|
};
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1784
|
+
if (controller.desiredSize <= RESERVED_EVENTS && isDiscardable(event)) return;
|
|
1785
|
+
if (controller.desiredSize <= 0) {
|
|
1786
|
+
fail(/* @__PURE__ */ new Error(`Generation event stream exceeded ${MAX_QUEUED_EVENTS} queued events`));
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
controller.enqueue(event);
|
|
1970
1790
|
}
|
|
1971
1791
|
on("kubb:plugin:start", (ctx) => {
|
|
1972
1792
|
emitEvent("kubb:plugin:start", [{ plugin: { name: ctx.plugin.name } }]);
|
|
@@ -2112,25 +1932,38 @@ function createGenerationStream(hooks, jobId, options = {}) {
|
|
|
2112
1932
|
if (closed) return;
|
|
2113
1933
|
closed = true;
|
|
2114
1934
|
detach();
|
|
2115
|
-
|
|
2116
|
-
if (streamError) return;
|
|
2117
|
-
await writer.close().catch(() => void 0);
|
|
1935
|
+
controller.close();
|
|
2118
1936
|
}
|
|
2119
1937
|
function fail(error) {
|
|
2120
1938
|
detach();
|
|
2121
1939
|
if (closed) return;
|
|
2122
1940
|
closed = true;
|
|
2123
|
-
|
|
1941
|
+
controller.error(error);
|
|
2124
1942
|
}
|
|
2125
1943
|
return {
|
|
2126
|
-
stream
|
|
1944
|
+
stream,
|
|
2127
1945
|
close,
|
|
2128
1946
|
dispose: () => fail(),
|
|
2129
1947
|
fail
|
|
2130
1948
|
};
|
|
2131
1949
|
}
|
|
2132
1950
|
//#endregion
|
|
2133
|
-
//#region src/
|
|
1951
|
+
//#region src/operations/websocket.ts
|
|
1952
|
+
const CONNECT_TIMEOUT_MS = 5e3;
|
|
1953
|
+
/**
|
|
1954
|
+
* Opens a Studio WebSocket connection and closes it when the initial handshake exceeds the configured timeout.
|
|
1955
|
+
*/
|
|
1956
|
+
function createWebsocket(url, options) {
|
|
1957
|
+
const ws = new WebSocket(url, options);
|
|
1958
|
+
const timer = setTimeout(() => {
|
|
1959
|
+
if (ws.readyState === WebSocket.CONNECTING) ws.close(3008, "Connection timeout");
|
|
1960
|
+
}, CONNECT_TIMEOUT_MS);
|
|
1961
|
+
ws.once("open", () => clearTimeout(timer));
|
|
1962
|
+
ws.once("close", () => clearTimeout(timer));
|
|
1963
|
+
return ws;
|
|
1964
|
+
}
|
|
1965
|
+
//#endregion
|
|
1966
|
+
//#region src/operations/rpc.ts
|
|
2134
1967
|
/**
|
|
2135
1968
|
* The only methods Studio may call on an agent. A `StudioSession` carries far more than
|
|
2136
1969
|
* {@link AgentApi}, so it is wrapped rather than exposed: what Cap'n Web can reach is exactly what
|
|
@@ -2157,6 +1990,9 @@ var AgentRpcTarget = class extends RpcTarget {
|
|
|
2157
1990
|
readFiles(input) {
|
|
2158
1991
|
return this.api.readFiles(input);
|
|
2159
1992
|
}
|
|
1993
|
+
cancel(jobId) {
|
|
1994
|
+
return this.api.cancel(jobId);
|
|
1995
|
+
}
|
|
2160
1996
|
};
|
|
2161
1997
|
/**
|
|
2162
1998
|
* Opens an authenticated Cap'n Web session to Studio over a WebSocket. Rejects an unencrypted URL
|
|
@@ -2168,11 +2004,17 @@ var AgentRpcTarget = class extends RpcTarget {
|
|
|
2168
2004
|
* await rpc.studio.ping()
|
|
2169
2005
|
* ```
|
|
2170
2006
|
*/
|
|
2171
|
-
const connectWebSocketRpc = async ({ url, token, local }) => {
|
|
2007
|
+
const connectWebSocketRpc = async ({ url, token, instanceId, local }) => {
|
|
2172
2008
|
const { protocol, hostname, host } = new URL(url);
|
|
2173
2009
|
if (protocol !== "wss:" && !(protocol === "ws:" && (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"))) throw new Error(`Refusing unencrypted WebSocket to ${host}`);
|
|
2174
|
-
const socket = createWebsocket(url, { headers: {
|
|
2175
|
-
|
|
2010
|
+
const socket = createWebsocket(url, { headers: {
|
|
2011
|
+
Authorization: `Bearer ${token}`,
|
|
2012
|
+
[AGENT_INSTANCE_HEADER]: instanceId
|
|
2013
|
+
} });
|
|
2014
|
+
const closed = new Promise((resolve) => socket.once("close", (code, reason) => resolve({
|
|
2015
|
+
code,
|
|
2016
|
+
reason: reason.toString()
|
|
2017
|
+
})));
|
|
2176
2018
|
const studio = newWebSocketRpcSession(socket, new AgentRpcTarget(local));
|
|
2177
2019
|
studio.onRpcBroken(() => socket.close());
|
|
2178
2020
|
return {
|
|
@@ -2182,11 +2024,274 @@ const connectWebSocketRpc = async ({ url, token, local }) => {
|
|
|
2182
2024
|
};
|
|
2183
2025
|
};
|
|
2184
2026
|
//#endregion
|
|
2185
|
-
//#region src/
|
|
2027
|
+
//#region src/operations/hooks.ts
|
|
2186
2028
|
/**
|
|
2187
|
-
*
|
|
2029
|
+
* Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
|
|
2030
|
+
* streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
|
|
2031
|
+
* Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
|
|
2032
|
+
*
|
|
2033
|
+
* Returns a remover, so a session that runs one generation after another on the same emitter does
|
|
2034
|
+
* not stack a listener per run.
|
|
2188
2035
|
*/
|
|
2036
|
+
function setupHookListener(hooks, root, signal) {
|
|
2037
|
+
return hooks.hook("kubb:hook:start", async (ctx) => {
|
|
2038
|
+
const { id, command, args } = ctx;
|
|
2039
|
+
if (!id) return;
|
|
2040
|
+
const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
|
|
2041
|
+
try {
|
|
2042
|
+
const proc = x(command, [...args ?? []], {
|
|
2043
|
+
signal,
|
|
2044
|
+
nodeOptions: {
|
|
2045
|
+
cwd: root,
|
|
2046
|
+
detached: true
|
|
2047
|
+
}
|
|
2048
|
+
});
|
|
2049
|
+
for await (const line of proc) await hooks.callHook("kubb:hook:line", {
|
|
2050
|
+
id,
|
|
2051
|
+
line
|
|
2052
|
+
});
|
|
2053
|
+
const { exitCode } = await proc;
|
|
2054
|
+
if (exitCode !== 0) {
|
|
2055
|
+
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
2056
|
+
await hooks.callHook("kubb:hook:end", {
|
|
2057
|
+
id,
|
|
2058
|
+
command,
|
|
2059
|
+
args,
|
|
2060
|
+
success: false,
|
|
2061
|
+
error
|
|
2062
|
+
});
|
|
2063
|
+
await hooks.callHook("kubb:error", { error });
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
await hooks.callHook("kubb:hook:end", {
|
|
2067
|
+
id,
|
|
2068
|
+
command,
|
|
2069
|
+
args,
|
|
2070
|
+
success: true,
|
|
2071
|
+
error: null
|
|
2072
|
+
});
|
|
2073
|
+
} catch (caughtError) {
|
|
2074
|
+
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
2075
|
+
error.cause = caughtError;
|
|
2076
|
+
await hooks.callHook("kubb:hook:end", {
|
|
2077
|
+
id,
|
|
2078
|
+
command,
|
|
2079
|
+
args,
|
|
2080
|
+
success: false,
|
|
2081
|
+
error
|
|
2082
|
+
});
|
|
2083
|
+
await hooks.callHook("kubb:error", { error });
|
|
2084
|
+
}
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
|
|
2089
|
+
* `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
|
|
2090
|
+
* that same listener, so a handler added afterward would already have missed it.
|
|
2091
|
+
*/
|
|
2092
|
+
function waitForHookEnd(hooks, hookId) {
|
|
2093
|
+
return new Promise((resolve, reject) => {
|
|
2094
|
+
const handleHookEnd = (ctx) => {
|
|
2095
|
+
if (ctx.id !== hookId) return;
|
|
2096
|
+
hooks.removeHook("kubb:hook:end", handleHookEnd);
|
|
2097
|
+
if (ctx.success) {
|
|
2098
|
+
resolve();
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
reject(ctx.error);
|
|
2102
|
+
};
|
|
2103
|
+
hooks.hook("kubb:hook:end", handleHookEnd);
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
//#endregion
|
|
2107
|
+
//#region src/operations/generate.ts
|
|
2189
2108
|
const DISK_SNAPSHOT_MAX_FILES = 1e4;
|
|
2109
|
+
/**
|
|
2110
|
+
* `isToolAvailable` spawns a process, and a long-lived connection generates repeatedly, so each
|
|
2111
|
+
* executable is probed once per process. The CLI deliberately does not memoize: a `--watch` build
|
|
2112
|
+
* should keep noticing a tool installed mid-session.
|
|
2113
|
+
*/
|
|
2114
|
+
const detectTool = memoize(/* @__PURE__ */ new Map(), detectTool$1);
|
|
2115
|
+
/**
|
|
2116
|
+
* The two post-build tool steps. Formatting and linting differ only in which tools they look for,
|
|
2117
|
+
* so they run through one loop rather than two near-identical blocks.
|
|
2118
|
+
*
|
|
2119
|
+
* `noun` and `verbing` are spelled out instead of built from `kind`. Concatenating `` `${kind}ter` ``
|
|
2120
|
+
* and `` `${kind}ting` `` works for `format`, but doubles the `t` in `lint`, giving "lintter" and
|
|
2121
|
+
* "lintting" instead of "linter" and "linting".
|
|
2122
|
+
*/
|
|
2123
|
+
const TOOL_STEPS = [{
|
|
2124
|
+
kind: "format",
|
|
2125
|
+
noun: "formatter",
|
|
2126
|
+
verbing: "Formatting",
|
|
2127
|
+
tools: formatters,
|
|
2128
|
+
detect: FORMATTER_PREFERENCE
|
|
2129
|
+
}, {
|
|
2130
|
+
kind: "lint",
|
|
2131
|
+
noun: "linter",
|
|
2132
|
+
verbing: "Linting",
|
|
2133
|
+
tools: linters,
|
|
2134
|
+
detect: LINTER_PREFERENCE
|
|
2135
|
+
}];
|
|
2136
|
+
/**
|
|
2137
|
+
* Absolute path of the directory the formatter and linter are pointed at.
|
|
2138
|
+
*/
|
|
2139
|
+
function outputPath(config) {
|
|
2140
|
+
return path.isAbsolute(config.output.path) ? config.output.path : path.resolve(process$1.cwd(), config.root, config.output.path);
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* Emits `kubb:hook:start` and waits for the matching `kubb:hook:end`. The host spawns the process:
|
|
2144
|
+
* this only describes what to run and when it finished.
|
|
2145
|
+
*
|
|
2146
|
+
* @throws whatever the command failed with, so callers can report it their own way.
|
|
2147
|
+
*/
|
|
2148
|
+
async function runHook({ hooks, id, command, args }) {
|
|
2149
|
+
const hookId = hash("sha256", id);
|
|
2150
|
+
const hookEnd = waitForHookEnd(hooks, hookId);
|
|
2151
|
+
await hooks.callHook("kubb:hook:start", {
|
|
2152
|
+
id: hookId,
|
|
2153
|
+
command,
|
|
2154
|
+
args: [...args]
|
|
2155
|
+
});
|
|
2156
|
+
await hookEnd;
|
|
2157
|
+
}
|
|
2158
|
+
function isProblemErrorDiagnostic(diagnostic) {
|
|
2159
|
+
return (diagnostic.kind ?? "problem") === "problem" && diagnostic.severity === "error";
|
|
2160
|
+
}
|
|
2161
|
+
/**
|
|
2162
|
+
* Folds error-severity diagnostics into one thrown error so logs name the failing plugin.
|
|
2163
|
+
*/
|
|
2164
|
+
function formatGenerationFailure(diagnostics) {
|
|
2165
|
+
const reasons = diagnostics.filter(isProblemErrorDiagnostic).map((diagnostic) => diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message);
|
|
2166
|
+
if (!reasons.length) return /* @__PURE__ */ new Error("Generation failed");
|
|
2167
|
+
return /* @__PURE__ */ new Error(`Generation failed: ${reasons.length} error${reasons.length === 1 ? "" : "s"}: ${reasons.join("; ")}`);
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Runs a full Kubb code-generation cycle for the given config.
|
|
2171
|
+
*
|
|
2172
|
+
* Emits lifecycle events on the provided `hooks` emitter so callers (e.g. the WebSocket stream)
|
|
2173
|
+
* can forward progress to connected clients. After a successful build, auto-formatting and
|
|
2174
|
+
* linting are applied when configured, followed by any user-defined `hooks.done` commands.
|
|
2175
|
+
*/
|
|
2176
|
+
async function generate({ config, hooks, signal }) {
|
|
2177
|
+
signal?.throwIfAborted();
|
|
2178
|
+
const hrStart = process$1.hrtime();
|
|
2179
|
+
await hooks.callHook("kubb:generation:start", { config });
|
|
2180
|
+
await hooks.callHook("kubb:info", { message: config.name ? `Setup generation ${config.name}` : "Setup generation" });
|
|
2181
|
+
const kubb = createKubb(config, {
|
|
2182
|
+
hooks,
|
|
2183
|
+
signal
|
|
2184
|
+
});
|
|
2185
|
+
await kubb.setup();
|
|
2186
|
+
await hooks.callHook("kubb:info", { message: config.name ? `Build generation ${config.name}` : "Build generation" });
|
|
2187
|
+
const { files, diagnostics, storage } = await kubb.safeBuild();
|
|
2188
|
+
signal?.throwIfAborted();
|
|
2189
|
+
await hooks.callHook("kubb:info", { message: "Load summary" });
|
|
2190
|
+
for (const diagnostic of diagnostics.filter(isProblemErrorDiagnostic)) await hooks.callHook("kubb:error", { error: new Error(diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message) });
|
|
2191
|
+
const status = Diagnostics.hasError(diagnostics) ? "failed" : "success";
|
|
2192
|
+
await hooks.callHook("kubb:generation:end", {
|
|
2193
|
+
config,
|
|
2194
|
+
storage: {
|
|
2195
|
+
...storage,
|
|
2196
|
+
readKeys: async () => [...new Set(files.map((file) => file.path))]
|
|
2197
|
+
},
|
|
2198
|
+
diagnostics,
|
|
2199
|
+
status,
|
|
2200
|
+
hrStart,
|
|
2201
|
+
filesCreated: files.length
|
|
2202
|
+
});
|
|
2203
|
+
if (status === "failed") throw formatGenerationFailure(diagnostics);
|
|
2204
|
+
await hooks.callHook("kubb:success", { message: "Generation successfully" });
|
|
2205
|
+
for (const step of TOOL_STEPS) {
|
|
2206
|
+
const setting = config.output[step.kind];
|
|
2207
|
+
if (!setting) continue;
|
|
2208
|
+
await hooks.callHook(`kubb:${step.kind}:start`);
|
|
2209
|
+
const tool = setting === "auto" ? await detectTool(step.detect) : setting;
|
|
2210
|
+
if (!tool) await hooks.callHook("kubb:warn", { message: `No ${step.noun} found (${step.detect.join(", ")}). Skipping ${step.verbing.toLowerCase()}.` });
|
|
2211
|
+
if (tool && setting === "auto") await hooks.callHook("kubb:info", { message: `Auto-detected ${step.noun}: ${styleText("dim", tool)}` });
|
|
2212
|
+
const command = tool ? step.tools[tool] : void 0;
|
|
2213
|
+
if (command) try {
|
|
2214
|
+
await runHook({
|
|
2215
|
+
hooks,
|
|
2216
|
+
id: [config.name, tool].filter(Boolean).join("-"),
|
|
2217
|
+
command: command.command,
|
|
2218
|
+
args: command.args(outputPath(config))
|
|
2219
|
+
});
|
|
2220
|
+
await hooks.callHook("kubb:success", { message: `${step.verbing} with ${tool} successfully` });
|
|
2221
|
+
} catch (caughtError) {
|
|
2222
|
+
await hooks.callHook("kubb:error", { error: new Error(command.errorMessage, { cause: caughtError }) });
|
|
2223
|
+
signal?.throwIfAborted();
|
|
2224
|
+
}
|
|
2225
|
+
await hooks.callHook(`kubb:${step.kind}:end`);
|
|
2226
|
+
}
|
|
2227
|
+
if (config.output.postGenerate?.length) {
|
|
2228
|
+
await hooks.callHook("kubb:hooks:start");
|
|
2229
|
+
for (const entry of config.output.postGenerate) {
|
|
2230
|
+
const line = typeof entry === "string" ? entry : entry.command;
|
|
2231
|
+
const [cmd, ...args] = tokenize(line);
|
|
2232
|
+
if (!cmd) continue;
|
|
2233
|
+
await runHook({
|
|
2234
|
+
hooks,
|
|
2235
|
+
id: line,
|
|
2236
|
+
command: cmd,
|
|
2237
|
+
args
|
|
2238
|
+
});
|
|
2239
|
+
await hooks.callHook("kubb:success", { message: `${line} successfully executed` });
|
|
2240
|
+
}
|
|
2241
|
+
await hooks.callHook("kubb:hooks:end");
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
async function runGenerationOperation({ config, hooks, signal, jobId, store, snapshotRoot, maxSnapshotMb }) {
|
|
2245
|
+
const diskFiles = snapshotRoot ? await listDisk({
|
|
2246
|
+
root: snapshotRoot,
|
|
2247
|
+
outputPath: config.output.path,
|
|
2248
|
+
maxFiles: DISK_SNAPSHOT_MAX_FILES
|
|
2249
|
+
}) : void 0;
|
|
2250
|
+
const disk = diskFiles ? await store.keep({
|
|
2251
|
+
jobId,
|
|
2252
|
+
source: "disk",
|
|
2253
|
+
files: diskFiles,
|
|
2254
|
+
maxSetMb: maxSnapshotMb
|
|
2255
|
+
}) : void 0;
|
|
2256
|
+
const removeHookListener = setupHookListener(hooks, config.root, signal);
|
|
2257
|
+
try {
|
|
2258
|
+
await generate({
|
|
2259
|
+
config,
|
|
2260
|
+
hooks,
|
|
2261
|
+
signal
|
|
2262
|
+
});
|
|
2263
|
+
} catch (error) {
|
|
2264
|
+
await store.drop(jobId);
|
|
2265
|
+
throw error;
|
|
2266
|
+
} finally {
|
|
2267
|
+
removeHookListener();
|
|
2268
|
+
}
|
|
2269
|
+
return disk;
|
|
2270
|
+
}
|
|
2271
|
+
//#endregion
|
|
2272
|
+
//#region src/runtime/StudioSession.ts
|
|
2273
|
+
/** A sandbox shares one in-memory generation store across tenants, so old generations expire. */
|
|
2274
|
+
const SANDBOX_GENERATION_TTL_MS = 9e5;
|
|
2275
|
+
/**
|
|
2276
|
+
* A fresh root for one sandbox job. Kubb keys its output manifest cache by root, so tenants that
|
|
2277
|
+
* shared the agent's own root would read each other's manifest.
|
|
2278
|
+
*/
|
|
2279
|
+
function createJobRoot() {
|
|
2280
|
+
return mkdtemp(path.join(tmpdir(), "kubb-job-"));
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Removes a job root and the manifest cache Kubb derived from it. Best effort: a leftover temp
|
|
2284
|
+
* directory must not fail a job that already finished.
|
|
2285
|
+
*/
|
|
2286
|
+
async function removeJobRoot(jobRoot) {
|
|
2287
|
+
await Promise.all([rm(jobRoot, {
|
|
2288
|
+
recursive: true,
|
|
2289
|
+
force: true
|
|
2290
|
+
}), rm(resolveCacheDir(jobRoot), {
|
|
2291
|
+
recursive: true,
|
|
2292
|
+
force: true
|
|
2293
|
+
})]).catch(() => {});
|
|
2294
|
+
}
|
|
2190
2295
|
var GenerationRunTarget = class extends RpcTarget {
|
|
2191
2296
|
generationStream;
|
|
2192
2297
|
generationResult;
|
|
@@ -2233,34 +2338,74 @@ function applyStudioDefaults(options) {
|
|
|
2233
2338
|
...options.permissions
|
|
2234
2339
|
},
|
|
2235
2340
|
retryInterval: options.retryInterval ?? agentDefaults.retryIntervalMs,
|
|
2236
|
-
heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs)
|
|
2341
|
+
heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs),
|
|
2342
|
+
capacity: {
|
|
2343
|
+
...resolveAgentCapacity(),
|
|
2344
|
+
...options.capacity
|
|
2345
|
+
},
|
|
2346
|
+
instanceId: options.instanceId ?? randomUUID()
|
|
2237
2347
|
};
|
|
2238
2348
|
}
|
|
2239
2349
|
/**
|
|
2240
|
-
*
|
|
2241
|
-
*
|
|
2242
|
-
* A free function rather than a method: a pending retry timer reaches whatever it closes over, so
|
|
2243
|
-
* closing only over `options` (not a `StudioSession`) keeps a queued retry from pinning a closed
|
|
2244
|
-
* socket, its hook emitter, or its session id alive for the length of the retry interval.
|
|
2350
|
+
* Jobs one agent process can run at once today. Two runs would share this session's hook emitter,
|
|
2351
|
+
* and with it each other's events, until each job runs in its own worker (ADR-0003 slice B2).
|
|
2245
2352
|
*/
|
|
2246
|
-
|
|
2247
|
-
|
|
2353
|
+
const RUNTIME_MAX_CONCURRENT = 1;
|
|
2354
|
+
const MB = 1048576;
|
|
2355
|
+
function rssMb() {
|
|
2356
|
+
return process$1.memoryUsage().rss / MB;
|
|
2357
|
+
}
|
|
2358
|
+
function backoffDelayMs(attempt, maxMs) {
|
|
2359
|
+
const cap = Math.min(1e3 * 2 ** (attempt - 1), maxMs);
|
|
2360
|
+
return Math.random() * cap;
|
|
2361
|
+
}
|
|
2362
|
+
function reconnect(options, delayMs, attempt) {
|
|
2363
|
+
const { signal, onTokenRejected } = options;
|
|
2248
2364
|
if (signal?.aborted) return;
|
|
2249
2365
|
const cancel = () => clearTimeout(timer);
|
|
2250
2366
|
const timer = setTimeout(() => {
|
|
2251
2367
|
signal?.removeEventListener("abort", cancel);
|
|
2252
2368
|
if (signal?.aborted) return;
|
|
2253
|
-
new StudioSession(
|
|
2369
|
+
new StudioSession({
|
|
2370
|
+
...options,
|
|
2371
|
+
reconnectAttempt: attempt
|
|
2372
|
+
}).start().catch((error) => {
|
|
2254
2373
|
if (error instanceof InvalidAgentTokenError) {
|
|
2255
2374
|
onTokenRejected?.(error);
|
|
2256
2375
|
return;
|
|
2257
2376
|
}
|
|
2258
|
-
|
|
2377
|
+
if (error instanceof IncompatibleAgentError) return;
|
|
2378
|
+
const nextAttempt = attempt + 1;
|
|
2379
|
+
reconnect(options, backoffDelayMs(nextAttempt, options.retryInterval), nextAttempt);
|
|
2259
2380
|
});
|
|
2260
|
-
},
|
|
2381
|
+
}, delayMs);
|
|
2261
2382
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
2262
2383
|
}
|
|
2263
2384
|
/**
|
|
2385
|
+
* Reads what Studio meant by closing the connection. A code Studio did not send on purpose is an
|
|
2386
|
+
* ordinary drop, and the agent reconnects as it always has.
|
|
2387
|
+
*/
|
|
2388
|
+
function planEnd(close) {
|
|
2389
|
+
const code = close?.code;
|
|
2390
|
+
if (code === AgentCloseCode.REAUTHENTICATE) return {
|
|
2391
|
+
reason: "Kubb Studio asked the agent to register again",
|
|
2392
|
+
retry: true
|
|
2393
|
+
};
|
|
2394
|
+
if (code === AgentCloseCode.SUPERSEDED) return {
|
|
2395
|
+
reason: "another instance of this agent took over",
|
|
2396
|
+
retry: false
|
|
2397
|
+
};
|
|
2398
|
+
if (code === AgentCloseCode.INCOMPATIBLE) return {
|
|
2399
|
+
reason: "this agent is too old for Kubb Studio, or was deleted",
|
|
2400
|
+
retry: false,
|
|
2401
|
+
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.")
|
|
2402
|
+
};
|
|
2403
|
+
return {
|
|
2404
|
+
reason: "connection closed",
|
|
2405
|
+
retry: true
|
|
2406
|
+
};
|
|
2407
|
+
}
|
|
2408
|
+
/**
|
|
2264
2409
|
* One agent-to-Studio RPC transport: opening it, keeping it alive, and serving remote methods.
|
|
2265
2410
|
* `createClient` opens one per pool slot and is the only caller.
|
|
2266
2411
|
*/
|
|
@@ -2273,14 +2418,15 @@ var StudioSession = class {
|
|
|
2273
2418
|
*/
|
|
2274
2419
|
#unhooks = [];
|
|
2275
2420
|
/**
|
|
2276
|
-
* What
|
|
2277
|
-
* Before it resolves there is
|
|
2421
|
+
* What registration handed back, and the marker for whether the agent registered at all.
|
|
2422
|
+
* Before it resolves there is no sandbox flag to read.
|
|
2278
2423
|
*/
|
|
2279
|
-
#
|
|
2424
|
+
#registration;
|
|
2280
2425
|
#rpc;
|
|
2281
2426
|
#studioVersion;
|
|
2282
2427
|
#disposed = false;
|
|
2283
2428
|
#isGenerating = false;
|
|
2429
|
+
#activeJob;
|
|
2284
2430
|
#heartbeatTimer;
|
|
2285
2431
|
#lastGeneration;
|
|
2286
2432
|
#store;
|
|
@@ -2290,17 +2436,17 @@ var StudioSession = class {
|
|
|
2290
2436
|
* host does not queue jobs before the agent session is registered.
|
|
2291
2437
|
*/
|
|
2292
2438
|
#connectAck = Promise.withResolvers();
|
|
2293
|
-
#
|
|
2294
|
-
constructor({
|
|
2439
|
+
#reconnectAttempt;
|
|
2440
|
+
constructor({ reconnectAttempt, ...options }) {
|
|
2295
2441
|
this.#options = applyStudioDefaults(options);
|
|
2296
|
-
this.#
|
|
2442
|
+
this.#reconnectAttempt = reconnectAttempt ?? 0;
|
|
2297
2443
|
this.#connectAck.promise.catch(() => {});
|
|
2298
2444
|
}
|
|
2299
2445
|
/**
|
|
2300
2446
|
* A sandbox agent runs on Studio's own infrastructure, so it has no user project to touch.
|
|
2301
2447
|
*/
|
|
2302
2448
|
get #isSandbox() {
|
|
2303
|
-
return this.#
|
|
2449
|
+
return this.#registration?.isSandbox === true;
|
|
2304
2450
|
}
|
|
2305
2451
|
/**
|
|
2306
2452
|
* Kept in the project's cache directory, so it survives a restart, except on a sandbox: its pool
|
|
@@ -2310,7 +2456,8 @@ var StudioSession = class {
|
|
|
2310
2456
|
this.#store ??= createGenerationStore({
|
|
2311
2457
|
storage: this.#isSandbox ? memoryStorage() : cacheStorage({ root: this.#options.root }),
|
|
2312
2458
|
maxCount: this.#limits.maxCount,
|
|
2313
|
-
maxMb: this.#limits.maxMb
|
|
2459
|
+
maxMb: this.#limits.maxMb,
|
|
2460
|
+
ttlMs: this.#isSandbox ? SANDBOX_GENERATION_TTL_MS : void 0
|
|
2314
2461
|
});
|
|
2315
2462
|
return this.#store;
|
|
2316
2463
|
}
|
|
@@ -2334,20 +2481,26 @@ var StudioSession = class {
|
|
|
2334
2481
|
return this.#isSandbox || this.#options.permissions.allowRead;
|
|
2335
2482
|
}
|
|
2336
2483
|
async start() {
|
|
2337
|
-
const { token, studioUrl, signal, heartbeatInterval, installLogger } = this.#options;
|
|
2484
|
+
const { token, studioUrl, signal, heartbeatInterval, installLogger, instanceId, capacity } = this.#options;
|
|
2338
2485
|
await installLogger?.(this.#hooks);
|
|
2339
|
-
if (this.#startupWarning) await this.#warn(this.#startupWarning);
|
|
2340
2486
|
try {
|
|
2341
2487
|
await this.#hooks.callHook("studio:connecting", { url: studioUrl });
|
|
2342
|
-
|
|
2488
|
+
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`);
|
|
2489
|
+
const registration = await registerAgent({
|
|
2343
2490
|
token,
|
|
2344
|
-
studioUrl
|
|
2491
|
+
studioUrl,
|
|
2492
|
+
instanceId,
|
|
2493
|
+
capacity: {
|
|
2494
|
+
...capacity,
|
|
2495
|
+
maxConcurrent: Math.min(capacity.maxConcurrent, RUNTIME_MAX_CONCURRENT)
|
|
2496
|
+
}
|
|
2345
2497
|
});
|
|
2346
|
-
this.#
|
|
2347
|
-
this.#studioVersion =
|
|
2498
|
+
this.#registration = registration;
|
|
2499
|
+
this.#studioVersion = registration.version;
|
|
2348
2500
|
const rpc = await (this.#options.connector ?? connectWebSocketRpc)({
|
|
2349
|
-
url:
|
|
2501
|
+
url: registration.socketUrl,
|
|
2350
2502
|
token,
|
|
2503
|
+
instanceId,
|
|
2351
2504
|
local: this
|
|
2352
2505
|
});
|
|
2353
2506
|
this.#rpc = rpc;
|
|
@@ -2362,16 +2515,17 @@ var StudioSession = class {
|
|
|
2362
2515
|
kubb: version,
|
|
2363
2516
|
agent: this.#options.version
|
|
2364
2517
|
},
|
|
2365
|
-
agentSlug:
|
|
2366
|
-
organizationSlug:
|
|
2518
|
+
agentSlug: registration.agentSlug,
|
|
2519
|
+
organizationSlug: registration.organizationSlug
|
|
2367
2520
|
});
|
|
2368
2521
|
await this.#connectAck.promise;
|
|
2522
|
+
this.#reconnectAttempt = 0;
|
|
2369
2523
|
await this.#hooks.callHook("studio:ready", {});
|
|
2370
2524
|
} catch (error) {
|
|
2371
2525
|
this.#disposed = true;
|
|
2372
2526
|
this.dispose();
|
|
2373
2527
|
await this.#hooks.callHook("studio:error", { error: toError(error) });
|
|
2374
|
-
if (error instanceof InvalidAgentTokenError) throw error;
|
|
2528
|
+
if (error instanceof InvalidAgentTokenError || error instanceof IncompatibleAgentError) throw error;
|
|
2375
2529
|
await this.#reconnect();
|
|
2376
2530
|
}
|
|
2377
2531
|
}
|
|
@@ -2381,8 +2535,10 @@ var StudioSession = class {
|
|
|
2381
2535
|
*/
|
|
2382
2536
|
async #reconnect() {
|
|
2383
2537
|
if (this.#options.signal?.aborted) return;
|
|
2384
|
-
|
|
2385
|
-
|
|
2538
|
+
const attempt = this.#reconnectAttempt + 1;
|
|
2539
|
+
const delayMs = backoffDelayMs(attempt, this.#options.retryInterval);
|
|
2540
|
+
await this.#hooks.callHook("studio:reconnecting", { delayMs });
|
|
2541
|
+
reconnect(this.#options, delayMs, attempt);
|
|
2386
2542
|
}
|
|
2387
2543
|
#warn(message, permission) {
|
|
2388
2544
|
return this.#hooks.callHook("studio:warn", {
|
|
@@ -2414,10 +2570,23 @@ var StudioSession = class {
|
|
|
2414
2570
|
/**
|
|
2415
2571
|
* Races `studio.ping()` against a deadline, so a half-open socket can't hang it forever.
|
|
2416
2572
|
* */
|
|
2417
|
-
#ping(rpc) {
|
|
2573
|
+
async #ping(rpc) {
|
|
2418
2574
|
const { promise: timedOut, reject: onTimeout } = Promise.withResolvers();
|
|
2419
2575
|
const timer = setTimeout(() => onTimeout(/* @__PURE__ */ new Error("Heartbeat ping timed out")), agentDefaults.heartbeatTimeoutMs);
|
|
2420
|
-
|
|
2576
|
+
try {
|
|
2577
|
+
const load = await Promise.race([this.#load().catch(() => void 0), timedOut]);
|
|
2578
|
+
await Promise.race([rpc.studio.ping(load), timedOut]);
|
|
2579
|
+
} finally {
|
|
2580
|
+
clearTimeout(timer);
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
async #load() {
|
|
2584
|
+
return {
|
|
2585
|
+
running: this.#isGenerating ? 1 : 0,
|
|
2586
|
+
rssMb: Math.round(rssMb()),
|
|
2587
|
+
storeBytes: await this.#generations.bytes(),
|
|
2588
|
+
accepting: true
|
|
2589
|
+
};
|
|
2421
2590
|
}
|
|
2422
2591
|
/**
|
|
2423
2592
|
* Reads `kubb.config.ts` and reports which plugin options Studio may edit.
|
|
@@ -2462,8 +2631,11 @@ var StudioSession = class {
|
|
|
2462
2631
|
this.#connectAck.resolve();
|
|
2463
2632
|
return payload;
|
|
2464
2633
|
}
|
|
2465
|
-
#onAbort = () => void this.#end({
|
|
2466
|
-
|
|
2634
|
+
#onAbort = () => void this.#end({
|
|
2635
|
+
reason: "shutdown",
|
|
2636
|
+
retry: false
|
|
2637
|
+
});
|
|
2638
|
+
#onClose = (close) => void this.#end(planEnd(close));
|
|
2467
2639
|
/**
|
|
2468
2640
|
* Drops the socket and detaches every listener and timer this session added. Idempotent, and
|
|
2469
2641
|
* safe before `connect` opened anything.
|
|
@@ -2483,17 +2655,12 @@ var StudioSession = class {
|
|
|
2483
2655
|
* Ends the session: tells Studio it is over, drops the socket, and optionally reconnects.
|
|
2484
2656
|
* `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
|
|
2485
2657
|
*/
|
|
2486
|
-
async #end({ retry }) {
|
|
2487
|
-
const { studioUrl, token } = this.#options;
|
|
2658
|
+
async #end({ reason, retry, error }) {
|
|
2488
2659
|
if (this.#disposed) return;
|
|
2489
2660
|
this.#disposed = true;
|
|
2490
2661
|
this.dispose();
|
|
2491
|
-
await this.#hooks.callHook("studio:disconnected", { reason
|
|
2492
|
-
if (this.#
|
|
2493
|
-
sessionId: this.#session.sessionId,
|
|
2494
|
-
studioUrl,
|
|
2495
|
-
token
|
|
2496
|
-
})) await this.#warn("Could not notify Kubb Studio of the disconnect");
|
|
2662
|
+
await this.#hooks.callHook("studio:disconnected", { reason });
|
|
2663
|
+
if (error) await this.#hooks.callHook("studio:error", { error });
|
|
2497
2664
|
if (retry) await this.#reconnect();
|
|
2498
2665
|
}
|
|
2499
2666
|
startGeneration(data) {
|
|
@@ -2501,27 +2668,42 @@ var StudioSession = class {
|
|
|
2501
2668
|
this.#lastGeneration = result;
|
|
2502
2669
|
} });
|
|
2503
2670
|
const controller = new AbortController();
|
|
2504
|
-
const
|
|
2671
|
+
const cancelRun = async () => {
|
|
2672
|
+
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2673
|
+
};
|
|
2674
|
+
const result = this.#runGeneration(data, controller, cancelRun).then(async (value) => {
|
|
2505
2675
|
await generationStream.close();
|
|
2506
2676
|
return value;
|
|
2507
2677
|
}).catch((error) => {
|
|
2508
2678
|
generationStream.fail(error);
|
|
2509
2679
|
throw error;
|
|
2680
|
+
}).finally(() => {
|
|
2681
|
+
if (this.#activeJob?.cancel === cancelRun) this.#activeJob = void 0;
|
|
2510
2682
|
});
|
|
2511
2683
|
result.catch(() => {});
|
|
2512
|
-
return new GenerationRunTarget(generationStream.stream, result,
|
|
2513
|
-
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2514
|
-
}, () => {
|
|
2684
|
+
return new GenerationRunTarget(generationStream.stream, result, cancelRun, () => {
|
|
2515
2685
|
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2516
2686
|
generationStream.dispose();
|
|
2517
2687
|
});
|
|
2518
2688
|
}
|
|
2519
|
-
|
|
2689
|
+
/**
|
|
2690
|
+
* Cancels the currently running job when its id matches `jobId`.
|
|
2691
|
+
*/
|
|
2692
|
+
async cancel(jobId) {
|
|
2693
|
+
if (this.#activeJob?.jobId === jobId) await this.#activeJob.cancel();
|
|
2694
|
+
}
|
|
2695
|
+
async #runGeneration(data, controller, cancelRun) {
|
|
2520
2696
|
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");
|
|
2521
2697
|
this.#isGenerating = true;
|
|
2698
|
+
this.#activeJob = {
|
|
2699
|
+
jobId: data.jobId,
|
|
2700
|
+
cancel: cancelRun
|
|
2701
|
+
};
|
|
2522
2702
|
const command = "generate";
|
|
2523
|
-
const {
|
|
2703
|
+
const { loadConfig, permissions } = this.#options;
|
|
2704
|
+
let root = this.#options.root;
|
|
2524
2705
|
try {
|
|
2706
|
+
if (this.#isSandbox) root = await createJobRoot();
|
|
2525
2707
|
await this.#hooks.callHook("studio:command:start", { command });
|
|
2526
2708
|
const config = await loadConfig();
|
|
2527
2709
|
const patch = data.config;
|
|
@@ -2532,43 +2714,28 @@ var StudioSession = class {
|
|
|
2532
2714
|
if (patch?.input && !this.#canUseInput) await this.#warn("Ignored the spec from Studio: generating from a Studio spec was not granted", "allowInput");
|
|
2533
2715
|
const resolvedPlugins = plugins ?? config.plugins;
|
|
2534
2716
|
this.#lastGeneration = void 0;
|
|
2535
|
-
const
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
const detach = [setupHookListener(this.#hooks, root, controller.signal)];
|
|
2547
|
-
try {
|
|
2548
|
-
await generate({
|
|
2549
|
-
config: {
|
|
2550
|
-
...config,
|
|
2551
|
-
root,
|
|
2552
|
-
input: inputOverride ?? config.input,
|
|
2553
|
-
storage: this.#canWrite ? fsStorage() : memoryStorage(),
|
|
2554
|
-
output: permissions.allowExec ? { ...config.output } : {
|
|
2555
|
-
...config.output,
|
|
2556
|
-
format: false,
|
|
2557
|
-
lint: false,
|
|
2558
|
-
postGenerate: []
|
|
2559
|
-
},
|
|
2560
|
-
plugins: resolvedPlugins,
|
|
2561
|
-
adapter
|
|
2717
|
+
const disk = await runGenerationOperation({
|
|
2718
|
+
config: {
|
|
2719
|
+
...config,
|
|
2720
|
+
root,
|
|
2721
|
+
input: inputOverride ?? config.input,
|
|
2722
|
+
storage: this.#canWrite ? fsStorage() : memoryStorage(),
|
|
2723
|
+
output: permissions.allowExec ? { ...config.output } : {
|
|
2724
|
+
...config.output,
|
|
2725
|
+
format: false,
|
|
2726
|
+
lint: false,
|
|
2727
|
+
postGenerate: []
|
|
2562
2728
|
},
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
}
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2729
|
+
plugins: resolvedPlugins,
|
|
2730
|
+
adapter
|
|
2731
|
+
},
|
|
2732
|
+
hooks: this.#hooks,
|
|
2733
|
+
signal: controller.signal,
|
|
2734
|
+
jobId: data.jobId,
|
|
2735
|
+
store: this.#generations,
|
|
2736
|
+
snapshotRoot: this.#hasProjectOnDisk ? root : void 0,
|
|
2737
|
+
maxSnapshotMb: this.#limits.maxSnapshotMb
|
|
2738
|
+
});
|
|
2572
2739
|
await this.#hooks.callHook("studio:command:end", {
|
|
2573
2740
|
command,
|
|
2574
2741
|
info: `${resolvedPlugins.length} plugin${resolvedPlugins.length === 1 ? "" : "s"}, ${this.#canWrite ? "written to disk" : "in memory"}${inputOverride !== void 0 ? ", from a Studio spec" : ""}`
|
|
@@ -2600,6 +2767,7 @@ var StudioSession = class {
|
|
|
2600
2767
|
disk: disk ? { hashes: disk.hashes } : void 0
|
|
2601
2768
|
};
|
|
2602
2769
|
} finally {
|
|
2770
|
+
if (root !== this.#options.root) await removeJobRoot(root);
|
|
2603
2771
|
this.#isGenerating = false;
|
|
2604
2772
|
}
|
|
2605
2773
|
}
|
|
@@ -2629,8 +2797,10 @@ var StudioSession = class {
|
|
|
2629
2797
|
}
|
|
2630
2798
|
if (this.#isGenerating) return refuse("a generation is in progress");
|
|
2631
2799
|
try {
|
|
2632
|
-
const { source: patched, outcomes, changed } =
|
|
2633
|
-
|
|
2800
|
+
const { source: patched, outcomes, changed } = await writeConfigEdits({
|
|
2801
|
+
filePath: configFile,
|
|
2802
|
+
edits
|
|
2803
|
+
});
|
|
2634
2804
|
const applied = outcomes.filter((outcome) => outcome.applied).length;
|
|
2635
2805
|
await this.#hooks.callHook("studio:command:end", {
|
|
2636
2806
|
command,
|
|
@@ -2668,24 +2838,14 @@ var StudioSession = class {
|
|
|
2668
2838
|
version,
|
|
2669
2839
|
peerDependencies: generation.peerDependencies
|
|
2670
2840
|
});
|
|
2671
|
-
const { token, studioUrl } = this.#options;
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
});
|
|
2679
|
-
const storageUrl = redirect.headers.get("location");
|
|
2680
|
-
if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
|
|
2681
|
-
const storage = new URL(storageUrl);
|
|
2682
|
-
if (storage.protocol !== "https:" && storage.hostname !== "localhost" && storage.hostname !== "127.0.0.1") throw new Error(`Refusing snapshot upload to ${storage.origin}`);
|
|
2683
|
-
const response = await fetch(storage, {
|
|
2684
|
-
method: "PUT",
|
|
2685
|
-
body: new Uint8Array(bytes),
|
|
2686
|
-
redirect: "error"
|
|
2841
|
+
const { token, studioUrl, signal } = this.#options;
|
|
2842
|
+
await uploadSnapshot({
|
|
2843
|
+
bytes,
|
|
2844
|
+
uploadPath,
|
|
2845
|
+
studioUrl,
|
|
2846
|
+
token,
|
|
2847
|
+
shutdown: signal
|
|
2687
2848
|
});
|
|
2688
|
-
if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
|
|
2689
2849
|
await this.#hooks.callHook("studio:command:end", {
|
|
2690
2850
|
command,
|
|
2691
2851
|
info: `packed ${Object.keys(files).length} file${Object.keys(files).length === 1 ? "" : "s"}`
|
|
@@ -2699,10 +2859,7 @@ var StudioSession = class {
|
|
|
2699
2859
|
throw error;
|
|
2700
2860
|
}
|
|
2701
2861
|
}
|
|
2702
|
-
/**
|
|
2703
|
-
* An agent with a project on disk can show a run against what its output directory held before.
|
|
2704
|
-
* A sandbox agent has no project.
|
|
2705
|
-
*/
|
|
2862
|
+
/** A sandbox has no project directory to snapshot. */
|
|
2706
2863
|
get #hasProjectOnDisk() {
|
|
2707
2864
|
return !this.#isSandbox && this.#canRead;
|
|
2708
2865
|
}
|
|
@@ -2731,7 +2888,7 @@ var StudioSession = class {
|
|
|
2731
2888
|
}
|
|
2732
2889
|
};
|
|
2733
2890
|
//#endregion
|
|
2734
|
-
//#region src/client.ts
|
|
2891
|
+
//#region src/runtime/client.ts
|
|
2735
2892
|
/**
|
|
2736
2893
|
* Creates the Kubb Studio client: the connection, the command loop, and the generation event
|
|
2737
2894
|
* stream shared by the `kubb studio` CLI command and the Docker agent.
|
|
@@ -2747,7 +2904,7 @@ var StudioSession = class {
|
|
|
2747
2904
|
*/
|
|
2748
2905
|
function createClient({ onAuthRequired, ...options }) {
|
|
2749
2906
|
const controller = new AbortController();
|
|
2750
|
-
const
|
|
2907
|
+
const instanceId = options.instanceId ?? randomUUID();
|
|
2751
2908
|
function notifyAuthRequired(error) {
|
|
2752
2909
|
if (controller.signal.aborted) return;
|
|
2753
2910
|
controller.abort();
|
|
@@ -2755,19 +2912,12 @@ function createClient({ onAuthRequired, ...options }) {
|
|
|
2755
2912
|
}
|
|
2756
2913
|
return {
|
|
2757
2914
|
async connect() {
|
|
2758
|
-
|
|
2759
|
-
token: options.token,
|
|
2760
|
-
studioUrl: options.studioUrl ?? agentDefaults.studioUrl,
|
|
2761
|
-
poolSize
|
|
2762
|
-
});
|
|
2763
|
-
if (controller.signal.aborted) return;
|
|
2764
|
-
const startupWarning = registered ? void 0 : "Could not register with Kubb Studio, continuing";
|
|
2765
|
-
await Promise.all(Array.from({ length: poolSize }, (_, slot) => new StudioSession({
|
|
2915
|
+
await new StudioSession({
|
|
2766
2916
|
...options,
|
|
2917
|
+
instanceId,
|
|
2767
2918
|
signal: controller.signal,
|
|
2768
|
-
onTokenRejected: notifyAuthRequired
|
|
2769
|
-
|
|
2770
|
-
}).start()));
|
|
2919
|
+
onTokenRejected: notifyAuthRequired
|
|
2920
|
+
}).start();
|
|
2771
2921
|
},
|
|
2772
2922
|
disconnect() {
|
|
2773
2923
|
controller.abort();
|
|
@@ -2775,7 +2925,7 @@ function createClient({ onAuthRequired, ...options }) {
|
|
|
2775
2925
|
};
|
|
2776
2926
|
}
|
|
2777
2927
|
//#endregion
|
|
2778
|
-
//#region src/runConnection.ts
|
|
2928
|
+
//#region src/runtime/runConnection.ts
|
|
2779
2929
|
/**
|
|
2780
2930
|
* Waits for whichever comes first: the shutdown signal, or Studio rejecting the token during a
|
|
2781
2931
|
* background reconnect. Resolves with the rejection, or nothing when the run is being shut down.
|
|
@@ -2848,7 +2998,7 @@ async function runConnection({ credentials, clientOptions, onTokenRejected, sign
|
|
|
2848
2998
|
}
|
|
2849
2999
|
}
|
|
2850
3000
|
//#endregion
|
|
2851
|
-
//#region src/pair.ts
|
|
3001
|
+
//#region src/operations/pair.ts
|
|
2852
3002
|
/** Labels, not secrets: a person approving the code in the browser is what authorizes a pairing. */
|
|
2853
3003
|
const CLIENT_IDS = {
|
|
2854
3004
|
cli: "kubb-cli",
|
|
@@ -2971,6 +3121,6 @@ async function pairAgent({ onCode, onRetry, maxAttempts = 1, ...options }) {
|
|
|
2971
3121
|
}
|
|
2972
3122
|
}
|
|
2973
3123
|
//#endregion
|
|
2974
|
-
export { InvalidAgentTokenError, PairingCanceledError, PairingDeniedError, PairingExpiredError, connectWebSocketRpc, createAgent, createClient, createFileStorage, createJob, defaultStudioUrl, generationEventTypes, machineTokenFrom, pairAgent, pollForPairingToken, runConnection, setStorage, startPairing, waitForJob };
|
|
3124
|
+
export { AGENT_INSTANCE_HEADER, AgentCloseCode, IncompatibleAgentError, InvalidAgentTokenError, PairingCanceledError, PairingDeniedError, PairingExpiredError, connectWebSocketRpc, createAgent, createClient, createFileStorage, createJob, defaultStudioUrl, generationEventTypes, machineTokenFrom, pairAgent, pollForPairingToken, runConnection, setStorage, startPairing, waitForJob };
|
|
2975
3125
|
|
|
2976
3126
|
//# sourceMappingURL=index.js.map
|