@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.cjs
CHANGED
|
@@ -26,76 +26,32 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
26
26
|
}) : target, mod));
|
|
27
27
|
//#endregion
|
|
28
28
|
const require_protocol = require("./protocol.cjs");
|
|
29
|
-
let
|
|
30
|
-
node_process = __toESM(node_process, 1);
|
|
31
|
-
let node_child_process = require("node:child_process");
|
|
29
|
+
let node_crypto = require("node:crypto");
|
|
32
30
|
let node_fs_promises = require("node:fs/promises");
|
|
31
|
+
let node_os = require("node:os");
|
|
33
32
|
let node_path = require("node:path");
|
|
34
33
|
node_path = __toESM(node_path, 1);
|
|
34
|
+
let node_process = require("node:process");
|
|
35
|
+
node_process = __toESM(node_process, 1);
|
|
36
|
+
let node_child_process = require("node:child_process");
|
|
37
|
+
let _kubb_core = require("@kubb/core");
|
|
38
|
+
let node_timers_promises = require("node:timers/promises");
|
|
35
39
|
let ofetch = require("ofetch");
|
|
36
|
-
let node_crypto = require("node:crypto");
|
|
37
40
|
let node_util = require("node:util");
|
|
38
41
|
let unstorage = require("unstorage");
|
|
39
42
|
let unstorage_drivers_fs = require("unstorage/drivers/fs");
|
|
40
43
|
unstorage_drivers_fs = __toESM(unstorage_drivers_fs, 1);
|
|
41
|
-
let _kubb_core = require("@kubb/core");
|
|
42
|
-
let tinyexec = require("tinyexec");
|
|
43
44
|
let magicast = require("magicast");
|
|
44
45
|
let node_fs = require("node:fs");
|
|
45
46
|
let node_module = require("node:module");
|
|
46
47
|
let node_url = require("node:url");
|
|
47
48
|
let remeda = require("remeda");
|
|
48
|
-
let node_os = require("node:os");
|
|
49
49
|
let node_zlib = require("node:zlib");
|
|
50
50
|
let tsdown = require("tsdown");
|
|
51
51
|
let capnweb = require("capnweb");
|
|
52
52
|
let ws = require("ws");
|
|
53
53
|
ws = __toESM(ws, 1);
|
|
54
|
-
let
|
|
55
|
-
//#region src/constants.ts
|
|
56
|
-
/**
|
|
57
|
-
* Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
|
|
58
|
-
* not whatever default the client would pick on its own.
|
|
59
|
-
*/
|
|
60
|
-
const defaultStudioUrl = "https://kubb.studio";
|
|
61
|
-
/**
|
|
62
|
-
* Defaults the Studio client uses when a host passes nothing.
|
|
63
|
-
* Config path is left out on purpose: each host discovers that itself.
|
|
64
|
-
*/
|
|
65
|
-
const agentDefaults = {
|
|
66
|
-
studioUrl: defaultStudioUrl,
|
|
67
|
-
retryIntervalMs: 3e4,
|
|
68
|
-
heartbeatIntervalMs: 3e4,
|
|
69
|
-
/**
|
|
70
|
-
* Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
|
|
71
|
-
* stored ping is older than its liveness window, and it stores a ping at most once a minute, so
|
|
72
|
-
* a slower cadence would make a healthy agent look dead after a single missed ping.
|
|
73
|
-
*/
|
|
74
|
-
maxHeartbeatIntervalMs: 6e4,
|
|
75
|
-
/** How long a heartbeat ping may take before the session is treated as dead. */
|
|
76
|
-
heartbeatTimeoutMs: 1e4,
|
|
77
|
-
poolSize: 1,
|
|
78
|
-
maxGenerations: 8,
|
|
79
|
-
maxGenerationsMb: 100,
|
|
80
|
-
maxSnapshotMb: 50
|
|
81
|
-
};
|
|
82
|
-
function positiveNumber(value) {
|
|
83
|
-
const parsed = Number(value);
|
|
84
|
-
return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* How many generations an agent keeps and how large they may get, read from
|
|
88
|
-
* `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
|
|
89
|
-
* An unset or invalid value keeps the default.
|
|
90
|
-
*/
|
|
91
|
-
function resolveGenerationLimits(env = process.env) {
|
|
92
|
-
return {
|
|
93
|
-
maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
|
|
94
|
-
maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
|
|
95
|
-
maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
//#endregion
|
|
54
|
+
let tinyexec = require("tinyexec");
|
|
99
55
|
//#region ../../internals/utils/src/casing.ts
|
|
100
56
|
/**
|
|
101
57
|
* Shared implementation for camelCase and PascalCase conversion.
|
|
@@ -418,7 +374,10 @@ function getElapsedMs(hrStart) {
|
|
|
418
374
|
return Math.round(ms * 100) / 100;
|
|
419
375
|
}
|
|
420
376
|
//#endregion
|
|
421
|
-
//#region
|
|
377
|
+
//#region package.json
|
|
378
|
+
var version = "5.3.18";
|
|
379
|
+
//#endregion
|
|
380
|
+
//#region src/operations/machine.ts
|
|
422
381
|
/**
|
|
423
382
|
* Key-value storage the runtime uses for its machine secret and the last Studio config.
|
|
424
383
|
*
|
|
@@ -478,7 +437,7 @@ async function getMachineToken() {
|
|
|
478
437
|
return machineTokenFrom(await fallbackSecretPromise);
|
|
479
438
|
}
|
|
480
439
|
//#endregion
|
|
481
|
-
//#region src/api.ts
|
|
440
|
+
//#region src/operations/api.ts
|
|
482
441
|
/**
|
|
483
442
|
* Reads a human-readable message from a Studio JSON error body, when it has one. `FetchError`'s own
|
|
484
443
|
* message stops at the status line, so the detail Studio sends with a failure (an agent limit, a
|
|
@@ -498,10 +457,6 @@ function responseMessage(data) {
|
|
|
498
457
|
*/
|
|
499
458
|
const REGISTER_RETRIES = 3;
|
|
500
459
|
/**
|
|
501
|
-
* Shared in-flight registration so concurrent pool sessions trigger one purge, not N.
|
|
502
|
-
*/
|
|
503
|
-
let registrationInFlight = null;
|
|
504
|
-
/**
|
|
505
460
|
* Thrown when Studio rejects the agent token itself (401). Retrying cannot help: the token was
|
|
506
461
|
* revoked, or the agent it belonged to was deleted in the Studio UI. Hosts catch this to forget
|
|
507
462
|
* the stored credential and pair again.
|
|
@@ -513,117 +468,87 @@ var InvalidAgentTokenError = class extends Error {
|
|
|
513
468
|
}
|
|
514
469
|
};
|
|
515
470
|
/**
|
|
471
|
+
* Thrown when Studio refuses this agent's protocol version (426). Retrying cannot help until the
|
|
472
|
+
* agent is upgraded, so hosts stop instead of reconnecting.
|
|
473
|
+
*/
|
|
474
|
+
var IncompatibleAgentError = class extends Error {
|
|
475
|
+
constructor(studioUrl, detail, options) {
|
|
476
|
+
super(`Kubb Studio at ${studioUrl} requires a newer agent${detail ? `: ${detail}` : ""}. Upgrade @kubb/studio or the Kubb agent image.`, options);
|
|
477
|
+
this.name = "IncompatibleAgentError";
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
/**
|
|
516
481
|
* Whether a thrown value carries `statusCode`. Not narrowed to `FetchError`: a host wrapper can
|
|
517
482
|
* throw its own error shape with the same field.
|
|
518
|
-
*
|
|
519
|
-
* A 401 means the agent token itself was rejected. A 403 from the session create endpoint means
|
|
520
|
-
* the machine token stored in Studio no longer matches this agent (missing or mismatched).
|
|
521
483
|
*/
|
|
522
484
|
function rejectedWith(error, statusCode) {
|
|
523
485
|
return error?.statusCode === statusCode;
|
|
524
486
|
}
|
|
525
|
-
function
|
|
487
|
+
function registrationError(cause) {
|
|
526
488
|
const detail = (cause instanceof ofetch.FetchError ? responseMessage(cause.data) : void 0) ?? getErrorMessage(cause);
|
|
527
|
-
return new Error(detail ? `Failed to
|
|
528
|
-
}
|
|
529
|
-
/**
|
|
530
|
-
* Performs the raw session create request against Studio.
|
|
531
|
-
*/
|
|
532
|
-
async function requestAgentSession({ token, studioUrl }) {
|
|
533
|
-
const url = `${studioUrl}/api/agent/sessions`;
|
|
534
|
-
const data = await (0, ofetch.ofetch)(url, {
|
|
535
|
-
method: "POST",
|
|
536
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
537
|
-
body: { machineToken: await getMachineToken() }
|
|
538
|
-
});
|
|
539
|
-
if (!data) throw new Error("No data available for agent session");
|
|
540
|
-
return data;
|
|
541
|
-
}
|
|
542
|
-
/**
|
|
543
|
-
* Obtain an agent session token from Kubb Studio via HTTP.
|
|
544
|
-
*
|
|
545
|
-
* When Studio rejects the machine token (403), for example after the agent restarted
|
|
546
|
-
* with a new identity while the startup registration call failed, the agent re-registers
|
|
547
|
-
* and retries once, so a single failed registration can't permanently block session creation.
|
|
548
|
-
*/
|
|
549
|
-
async function createAgentSession({ token, studioUrl }) {
|
|
550
|
-
try {
|
|
551
|
-
return await requestAgentSession({
|
|
552
|
-
token,
|
|
553
|
-
studioUrl
|
|
554
|
-
});
|
|
555
|
-
} catch (error) {
|
|
556
|
-
if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
|
|
557
|
-
if (!rejectedWith(error, 403) || !await registerAgent({
|
|
558
|
-
token,
|
|
559
|
-
studioUrl
|
|
560
|
-
})) throw sessionError(error);
|
|
561
|
-
try {
|
|
562
|
-
return await requestAgentSession({
|
|
563
|
-
token,
|
|
564
|
-
studioUrl
|
|
565
|
-
});
|
|
566
|
-
} catch (retryError) {
|
|
567
|
-
if (rejectedWith(retryError, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: retryError });
|
|
568
|
-
throw sessionError(retryError);
|
|
569
|
-
}
|
|
570
|
-
}
|
|
489
|
+
return new Error(detail ? `Failed to register with Kubb Studio: ${detail}` : "Failed to register with Kubb Studio", { cause });
|
|
571
490
|
}
|
|
572
491
|
/**
|
|
573
|
-
*
|
|
574
|
-
*
|
|
575
|
-
*
|
|
492
|
+
* Registers this agent process with Kubb Studio (`POST /api/agent/connect`): binds the machine
|
|
493
|
+
* identity to the token, reports what the process can take on, and gets back the URL of the one
|
|
494
|
+
* socket it keeps open.
|
|
576
495
|
*
|
|
577
|
-
* Retries
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
return registrationInFlight;
|
|
588
|
-
}
|
|
589
|
-
async function runRegistration({ token, studioUrl, poolSize }) {
|
|
590
|
-
const machineToken = await getMachineToken();
|
|
496
|
+
* Retries a transient failure with backoff. A rejected token (401) throws
|
|
497
|
+
* {@link InvalidAgentTokenError} and an unsupported agent version (426) throws
|
|
498
|
+
* {@link IncompatibleAgentError}, since retrying either cannot help.
|
|
499
|
+
*/
|
|
500
|
+
async function registerAgent({ token, studioUrl, instanceId, capacity }) {
|
|
501
|
+
const body = {
|
|
502
|
+
machineToken: await getMachineToken(),
|
|
503
|
+
instanceId,
|
|
504
|
+
capacity
|
|
505
|
+
};
|
|
591
506
|
try {
|
|
592
|
-
await (0, ofetch.ofetch)(`${studioUrl}/api/agent/connect`, {
|
|
507
|
+
return await (0, ofetch.ofetch)(`${studioUrl}/api/agent/connect`, {
|
|
593
508
|
method: "POST",
|
|
594
509
|
headers: { Authorization: `Bearer ${token}` },
|
|
595
|
-
body
|
|
596
|
-
machineToken,
|
|
597
|
-
poolSize
|
|
598
|
-
},
|
|
510
|
+
body,
|
|
599
511
|
retry: REGISTER_RETRIES,
|
|
600
512
|
retryDelay: ({ options }) => 2e3 * 2 ** (REGISTER_RETRIES - Number(options.retry))
|
|
601
513
|
});
|
|
602
|
-
return true;
|
|
603
514
|
} catch (error) {
|
|
604
515
|
if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
|
|
605
|
-
|
|
516
|
+
if (rejectedWith(error, 426)) throw new IncompatibleAgentError(studioUrl, error instanceof ofetch.FetchError ? responseMessage(error.data) : void 0, { cause: error });
|
|
517
|
+
throw registrationError(error);
|
|
606
518
|
}
|
|
607
519
|
}
|
|
608
520
|
/**
|
|
609
|
-
*
|
|
610
|
-
* Called on process termination or server close. Never throws: the local socket is already gone,
|
|
611
|
-
* and failing teardown must not block shutdown or reconnect.
|
|
612
|
-
*
|
|
613
|
-
* @returns `false` when Studio could not be reached or rate limited the call. Any other 4xx
|
|
614
|
-
* counts as notified, since it means Studio already dropped the session.
|
|
521
|
+
* First wait before `createJob` retries a busy or queue-full response, absent a `Retry-After` hint.
|
|
615
522
|
*/
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
523
|
+
const CREATE_JOB_INITIAL_DELAY_MS = 1e3;
|
|
524
|
+
/**
|
|
525
|
+
* Slowest `createJob` backs off to between retries.
|
|
526
|
+
*/
|
|
527
|
+
const CREATE_JOB_MAX_INTERVAL_MS = 1e4;
|
|
528
|
+
/**
|
|
529
|
+
* Statuses worth retrying: the agent has no free connection yet (409, a stale conflict a moment
|
|
530
|
+
* later resolves), its queue is momentarily full (429), or it has no live connection at all yet
|
|
531
|
+
* (503, an agent process that is mid-reconnect). Anything else (404 agent not found, 401/403 auth)
|
|
532
|
+
* is thrown straight away, since retrying cannot change the outcome.
|
|
533
|
+
*/
|
|
534
|
+
const CREATE_JOB_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
|
|
535
|
+
409,
|
|
536
|
+
429,
|
|
537
|
+
503
|
|
538
|
+
]);
|
|
539
|
+
/**
|
|
540
|
+
* Reads Studio's `Retry-After` header (seconds) off a thrown `ofetch` error, when present.
|
|
541
|
+
*/
|
|
542
|
+
function retryAfterMs(error) {
|
|
543
|
+
const seconds = Number(error.response?.headers.get("retry-after"));
|
|
544
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Adds up to 30% jitter, so every CI run queued behind the same busy agent does not retry in
|
|
548
|
+
* lockstep.
|
|
549
|
+
*/
|
|
550
|
+
function withJitter(ms) {
|
|
551
|
+
return ms + Math.random() * ms * .3;
|
|
627
552
|
}
|
|
628
553
|
/**
|
|
629
554
|
* Queues a generation or snapshot job on Studio (`POST /api/jobs`).
|
|
@@ -631,6 +556,10 @@ async function disconnect({ sessionId, token, studioUrl }) {
|
|
|
631
556
|
* Returns as soon as Studio accepts the job (`202`). Poll with {@link waitForJob} until it finishes.
|
|
632
557
|
* Authenticates with the organization CI API key via `x-api-key`.
|
|
633
558
|
*
|
|
559
|
+
* A busy agent, a full queue, or a momentary lack of a live connection (409, 429, 503) retries with
|
|
560
|
+
* exponential backoff and jitter, honoring Studio's `Retry-After` header when it sends one, up to
|
|
561
|
+
* `timeoutMs`. Every other failure, including a missing agent (404), throws immediately.
|
|
562
|
+
*
|
|
634
563
|
* @example Snapshot job
|
|
635
564
|
* ```ts
|
|
636
565
|
* const job = await createJob({
|
|
@@ -644,20 +573,41 @@ async function disconnect({ sessionId, token, studioUrl }) {
|
|
|
644
573
|
* const finished = await waitForJob({ studioUrl, token, id: job.id })
|
|
645
574
|
* ```
|
|
646
575
|
*/
|
|
647
|
-
async function createJob({ studioUrl, token, type, agentId, name, version, commit, config }) {
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
576
|
+
async function createJob({ studioUrl, token, type, agentId, name, version, commit, baseId, config, instanceId, timeoutMs = 6e4, signal }) {
|
|
577
|
+
const deadline = Date.now() + timeoutMs;
|
|
578
|
+
let interval = CREATE_JOB_INITIAL_DELAY_MS;
|
|
579
|
+
for (;;) {
|
|
580
|
+
signal?.throwIfAborted();
|
|
581
|
+
try {
|
|
582
|
+
const { job } = await (0, ofetch.ofetch)(`${studioUrl}/api/jobs`, {
|
|
583
|
+
method: "POST",
|
|
584
|
+
headers: { "x-api-key": token },
|
|
585
|
+
body: {
|
|
586
|
+
type,
|
|
587
|
+
agentId,
|
|
588
|
+
name,
|
|
589
|
+
version,
|
|
590
|
+
commit,
|
|
591
|
+
baseId,
|
|
592
|
+
config,
|
|
593
|
+
instanceId
|
|
594
|
+
},
|
|
595
|
+
retry: false,
|
|
596
|
+
timeout: Math.max(deadline - Date.now(), 1),
|
|
597
|
+
signal
|
|
598
|
+
});
|
|
599
|
+
return job;
|
|
600
|
+
} catch (error) {
|
|
601
|
+
signal?.throwIfAborted();
|
|
602
|
+
const status = error.response?.status;
|
|
603
|
+
if (!status || !CREATE_JOB_RETRYABLE_STATUSES.has(status) || Date.now() >= deadline) throw error;
|
|
604
|
+
const wait = Math.min(retryAfterMs(error) ?? withJitter(interval), Math.max(deadline - Date.now(), 0));
|
|
605
|
+
if (signal) await (0, node_timers_promises.setTimeout)(wait, void 0, { signal });
|
|
606
|
+
else await new Promise((resolve) => setTimeout(resolve, wait));
|
|
607
|
+
if (Date.now() >= deadline) throw error;
|
|
608
|
+
interval = Math.min(interval * 2, CREATE_JOB_MAX_INTERVAL_MS);
|
|
658
609
|
}
|
|
659
|
-
}
|
|
660
|
-
return job;
|
|
610
|
+
}
|
|
661
611
|
}
|
|
662
612
|
/**
|
|
663
613
|
* A job runs a generation and packs a tarball, so it is never done the instant it is queued.
|
|
@@ -676,20 +626,26 @@ const MAX_POLL_INTERVAL_MS = 3e4;
|
|
|
676
626
|
* A `failed` job resolves normally. Check `job.status` and `job.error`. Throws only when the
|
|
677
627
|
* deadline passes before Studio finishes.
|
|
678
628
|
*/
|
|
679
|
-
async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4 }) {
|
|
629
|
+
async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4, signal }) {
|
|
680
630
|
const deadline = Date.now() + timeoutMs;
|
|
681
631
|
let interval = INITIAL_POLL_DELAY_MS;
|
|
682
632
|
for (;;) {
|
|
683
|
-
|
|
633
|
+
signal?.throwIfAborted();
|
|
634
|
+
const wait = Math.max(Math.min(interval, deadline - Date.now()), 0);
|
|
635
|
+
if (signal) await (0, node_timers_promises.setTimeout)(wait, void 0, { signal });
|
|
636
|
+
else await new Promise((resolve) => setTimeout(resolve, wait));
|
|
684
637
|
if (Date.now() >= deadline) throw new Error("Timed out waiting for the Studio job");
|
|
685
638
|
interval = Math.min(interval * 2, MAX_POLL_INTERVAL_MS);
|
|
686
639
|
try {
|
|
687
640
|
const { job } = await (0, ofetch.ofetch)(`${studioUrl}/api/jobs/${id}`, {
|
|
688
641
|
headers: { "x-api-key": token },
|
|
689
|
-
retry: false
|
|
642
|
+
retry: false,
|
|
643
|
+
timeout: Math.max(deadline - Date.now(), 1),
|
|
644
|
+
signal
|
|
690
645
|
});
|
|
691
646
|
if (job.status === "success" || job.status === "failed" || job.status === "canceled") return job;
|
|
692
647
|
} catch (error) {
|
|
648
|
+
signal?.throwIfAborted();
|
|
693
649
|
const response = error.response;
|
|
694
650
|
if (response?.status !== 429) throw error;
|
|
695
651
|
const retryAfter = response._data?.data?.tryAgainIn;
|
|
@@ -723,90 +679,7 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
|
|
|
723
679
|
}
|
|
724
680
|
}
|
|
725
681
|
//#endregion
|
|
726
|
-
//#region
|
|
727
|
-
var version = "5.3.16";
|
|
728
|
-
//#endregion
|
|
729
|
-
//#region src/hooks.ts
|
|
730
|
-
/**
|
|
731
|
-
* Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
|
|
732
|
-
* streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
|
|
733
|
-
* Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
|
|
734
|
-
*
|
|
735
|
-
* Returns a remover, so a session that runs one generation after another on the same emitter does
|
|
736
|
-
* not stack a listener per run.
|
|
737
|
-
*/
|
|
738
|
-
function setupHookListener(hooks, root, signal) {
|
|
739
|
-
return hooks.hook("kubb:hook:start", async (ctx) => {
|
|
740
|
-
const { id, command, args } = ctx;
|
|
741
|
-
if (!id) return;
|
|
742
|
-
const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
|
|
743
|
-
try {
|
|
744
|
-
const proc = (0, tinyexec.x)(command, [...args ?? []], {
|
|
745
|
-
signal,
|
|
746
|
-
nodeOptions: {
|
|
747
|
-
cwd: root,
|
|
748
|
-
detached: true
|
|
749
|
-
}
|
|
750
|
-
});
|
|
751
|
-
for await (const line of proc) await hooks.callHook("kubb:hook:line", {
|
|
752
|
-
id,
|
|
753
|
-
line
|
|
754
|
-
});
|
|
755
|
-
const { exitCode } = await proc;
|
|
756
|
-
if (exitCode !== 0) {
|
|
757
|
-
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
758
|
-
await hooks.callHook("kubb:hook:end", {
|
|
759
|
-
id,
|
|
760
|
-
command,
|
|
761
|
-
args,
|
|
762
|
-
success: false,
|
|
763
|
-
error
|
|
764
|
-
});
|
|
765
|
-
await hooks.callHook("kubb:error", { error });
|
|
766
|
-
return;
|
|
767
|
-
}
|
|
768
|
-
await hooks.callHook("kubb:hook:end", {
|
|
769
|
-
id,
|
|
770
|
-
command,
|
|
771
|
-
args,
|
|
772
|
-
success: true,
|
|
773
|
-
error: null
|
|
774
|
-
});
|
|
775
|
-
} catch (caughtError) {
|
|
776
|
-
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
777
|
-
error.cause = caughtError;
|
|
778
|
-
await hooks.callHook("kubb:hook:end", {
|
|
779
|
-
id,
|
|
780
|
-
command,
|
|
781
|
-
args,
|
|
782
|
-
success: false,
|
|
783
|
-
error
|
|
784
|
-
});
|
|
785
|
-
await hooks.callHook("kubb:error", { error });
|
|
786
|
-
}
|
|
787
|
-
});
|
|
788
|
-
}
|
|
789
|
-
/**
|
|
790
|
-
* Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
|
|
791
|
-
* `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
|
|
792
|
-
* that same listener, so a handler added afterward would already have missed it.
|
|
793
|
-
*/
|
|
794
|
-
function waitForHookEnd(hooks, hookId) {
|
|
795
|
-
return new Promise((resolve, reject) => {
|
|
796
|
-
const handleHookEnd = (ctx) => {
|
|
797
|
-
if (ctx.id !== hookId) return;
|
|
798
|
-
hooks.removeHook("kubb:hook:end", handleHookEnd);
|
|
799
|
-
if (ctx.success) {
|
|
800
|
-
resolve();
|
|
801
|
-
return;
|
|
802
|
-
}
|
|
803
|
-
reject(ctx.error);
|
|
804
|
-
};
|
|
805
|
-
hooks.hook("kubb:hook:end", handleHookEnd);
|
|
806
|
-
});
|
|
807
|
-
}
|
|
808
|
-
//#endregion
|
|
809
|
-
//#region src/resolveConfig.ts
|
|
682
|
+
//#region src/operations/resolveConfig.ts
|
|
810
683
|
/**
|
|
811
684
|
* Imports a package, falling back to how the user's project would resolve it.
|
|
812
685
|
*
|
|
@@ -964,7 +837,7 @@ async function mergeAdapter(diskAdapter, studioOptions) {
|
|
|
964
837
|
return factory((0, remeda.mergeDeep)(diskAdapter.options ?? {}, studioOptions));
|
|
965
838
|
}
|
|
966
839
|
//#endregion
|
|
967
|
-
//#region src/configFile.ts
|
|
840
|
+
//#region src/operations/configFile.ts
|
|
968
841
|
/**
|
|
969
842
|
* A valid JavaScript identifier, so an import name can only ever print as `import { name } from`,
|
|
970
843
|
* never as source that breaks out of the import statement.
|
|
@@ -1504,6 +1377,11 @@ function applyConfigEdits(source, edits) {
|
|
|
1504
1377
|
changed: current !== source
|
|
1505
1378
|
};
|
|
1506
1379
|
}
|
|
1380
|
+
async function writeConfigEdits({ filePath, edits }) {
|
|
1381
|
+
const result = applyConfigEdits(await read(filePath), edits);
|
|
1382
|
+
if (result.changed) await (0, node_fs_promises.writeFile)(filePath, result.source, "utf-8");
|
|
1383
|
+
return result;
|
|
1384
|
+
}
|
|
1507
1385
|
/**
|
|
1508
1386
|
* The 1-based line where the file's last import declaration ends, or `0` when it has none. Read
|
|
1509
1387
|
* off the parsed module, so a multi-line `import {\n x,\n} from '...'` reports its closing line
|
|
@@ -1536,145 +1414,60 @@ function withTrailingNewline(code, hadTrailingNewline) {
|
|
|
1536
1414
|
return `${code}\n`;
|
|
1537
1415
|
}
|
|
1538
1416
|
//#endregion
|
|
1539
|
-
//#region src/
|
|
1540
|
-
/**
|
|
1541
|
-
* `isToolAvailable` spawns a process, and a long-lived connection generates repeatedly, so each
|
|
1542
|
-
* executable is probed once per process. The CLI deliberately does not memoize: a `--watch` build
|
|
1543
|
-
* should keep noticing a tool installed mid-session.
|
|
1544
|
-
*/
|
|
1545
|
-
const detectTool = memoize(/* @__PURE__ */ new Map(), detectTool$1);
|
|
1417
|
+
//#region src/operations/constants.ts
|
|
1546
1418
|
/**
|
|
1547
|
-
*
|
|
1548
|
-
*
|
|
1549
|
-
*
|
|
1550
|
-
* `noun` and `verbing` are spelled out instead of built from `kind`. Concatenating `` `${kind}ter` ``
|
|
1551
|
-
* and `` `${kind}ting` `` works for `format`, but doubles the `t` in `lint`, giving "lintter" and
|
|
1552
|
-
* "lintting" instead of "linter" and "linting".
|
|
1419
|
+
* Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
|
|
1420
|
+
* not whatever default the client would pick on its own.
|
|
1553
1421
|
*/
|
|
1554
|
-
const
|
|
1555
|
-
kind: "format",
|
|
1556
|
-
noun: "formatter",
|
|
1557
|
-
verbing: "Formatting",
|
|
1558
|
-
tools: formatters,
|
|
1559
|
-
detect: FORMATTER_PREFERENCE
|
|
1560
|
-
}, {
|
|
1561
|
-
kind: "lint",
|
|
1562
|
-
noun: "linter",
|
|
1563
|
-
verbing: "Linting",
|
|
1564
|
-
tools: linters,
|
|
1565
|
-
detect: LINTER_PREFERENCE
|
|
1566
|
-
}];
|
|
1422
|
+
const defaultStudioUrl = "https://kubb.studio";
|
|
1567
1423
|
/**
|
|
1568
|
-
*
|
|
1424
|
+
* Defaults the Studio client uses when a host passes nothing.
|
|
1425
|
+
* Config path is left out on purpose: each host discovers that itself.
|
|
1569
1426
|
*/
|
|
1570
|
-
|
|
1571
|
-
|
|
1427
|
+
const agentDefaults = {
|
|
1428
|
+
studioUrl: defaultStudioUrl,
|
|
1429
|
+
retryIntervalMs: 3e4,
|
|
1430
|
+
heartbeatIntervalMs: 3e4,
|
|
1431
|
+
/**
|
|
1432
|
+
* Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
|
|
1433
|
+
* stored ping is older than its liveness window, and it stores a ping at most once a minute, so
|
|
1434
|
+
* a slower cadence would make a healthy agent look dead after a single missed ping.
|
|
1435
|
+
*/
|
|
1436
|
+
maxHeartbeatIntervalMs: 6e4,
|
|
1437
|
+
/** How long a heartbeat ping may take before the session is treated as dead. */
|
|
1438
|
+
heartbeatTimeoutMs: 1e4,
|
|
1439
|
+
maxConcurrent: 1,
|
|
1440
|
+
maxGenerations: 8,
|
|
1441
|
+
maxGenerationsMb: 100,
|
|
1442
|
+
maxSnapshotMb: 50
|
|
1443
|
+
};
|
|
1444
|
+
function positiveNumber(value) {
|
|
1445
|
+
const parsed = Number(value);
|
|
1446
|
+
return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
|
|
1572
1447
|
}
|
|
1573
1448
|
/**
|
|
1574
|
-
*
|
|
1575
|
-
*
|
|
1576
|
-
*
|
|
1577
|
-
* @throws whatever the command failed with, so callers can report it their own way.
|
|
1578
|
-
*/
|
|
1579
|
-
async function runHook({ hooks, id, command, args }) {
|
|
1580
|
-
const hookId = (0, node_crypto.hash)("sha256", id);
|
|
1581
|
-
const hookEnd = waitForHookEnd(hooks, hookId);
|
|
1582
|
-
await hooks.callHook("kubb:hook:start", {
|
|
1583
|
-
id: hookId,
|
|
1584
|
-
command,
|
|
1585
|
-
args: [...args]
|
|
1586
|
-
});
|
|
1587
|
-
await hookEnd;
|
|
1588
|
-
}
|
|
1589
|
-
function isProblemErrorDiagnostic(diagnostic) {
|
|
1590
|
-
return (diagnostic.kind ?? "problem") === "problem" && diagnostic.severity === "error";
|
|
1591
|
-
}
|
|
1592
|
-
/**
|
|
1593
|
-
* Folds error-severity diagnostics into one thrown error so logs name the failing plugin.
|
|
1449
|
+
* How many generations an agent keeps and how large they may get, read from
|
|
1450
|
+
* `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
|
|
1451
|
+
* An unset or invalid value keeps the default.
|
|
1594
1452
|
*/
|
|
1595
|
-
function
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1453
|
+
function resolveGenerationLimits(env = process.env) {
|
|
1454
|
+
return {
|
|
1455
|
+
maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
|
|
1456
|
+
maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
|
|
1457
|
+
maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
|
|
1458
|
+
};
|
|
1599
1459
|
}
|
|
1600
1460
|
/**
|
|
1601
|
-
*
|
|
1602
|
-
*
|
|
1603
|
-
* Emits lifecycle events on the provided `hooks` emitter so callers (e.g. the WebSocket stream)
|
|
1604
|
-
* can forward progress to connected clients. After a successful build, auto-formatting and
|
|
1605
|
-
* linting are applied when configured, followed by any user-defined `hooks.done` commands.
|
|
1461
|
+
* An agent's capacity read from `KUBB_AGENT_MAX_CONCURRENT`. An unset or invalid value keeps the
|
|
1462
|
+
* default: one job at a time.
|
|
1606
1463
|
*/
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
const hrStart = node_process.default.hrtime();
|
|
1610
|
-
await hooks.callHook("kubb:generation:start", { config });
|
|
1611
|
-
await hooks.callHook("kubb:info", { message: config.name ? `Setup generation ${config.name}` : "Setup generation" });
|
|
1612
|
-
const kubb = (0, _kubb_core.createKubb)(config, {
|
|
1613
|
-
hooks,
|
|
1614
|
-
signal
|
|
1615
|
-
});
|
|
1616
|
-
await kubb.setup();
|
|
1617
|
-
await hooks.callHook("kubb:info", { message: config.name ? `Build generation ${config.name}` : "Build generation" });
|
|
1618
|
-
const { files, diagnostics, storage } = await kubb.safeBuild();
|
|
1619
|
-
signal?.throwIfAborted();
|
|
1620
|
-
await hooks.callHook("kubb:info", { message: "Load summary" });
|
|
1621
|
-
for (const diagnostic of diagnostics.filter(isProblemErrorDiagnostic)) await hooks.callHook("kubb:error", { error: new Error(diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message) });
|
|
1622
|
-
const status = _kubb_core.Diagnostics.hasError(diagnostics) ? "failed" : "success";
|
|
1623
|
-
await hooks.callHook("kubb:generation:end", {
|
|
1624
|
-
config,
|
|
1625
|
-
storage: {
|
|
1626
|
-
...storage,
|
|
1627
|
-
readKeys: async () => [...new Set(files.map((file) => file.path))]
|
|
1628
|
-
},
|
|
1629
|
-
diagnostics,
|
|
1630
|
-
status,
|
|
1631
|
-
hrStart,
|
|
1632
|
-
filesCreated: files.length
|
|
1633
|
-
});
|
|
1634
|
-
if (status === "failed") throw formatGenerationFailure(diagnostics);
|
|
1635
|
-
await hooks.callHook("kubb:success", { message: "Generation successfully" });
|
|
1636
|
-
for (const step of TOOL_STEPS) {
|
|
1637
|
-
const setting = config.output[step.kind];
|
|
1638
|
-
if (!setting) continue;
|
|
1639
|
-
await hooks.callHook(`kubb:${step.kind}:start`);
|
|
1640
|
-
const tool = setting === "auto" ? await detectTool(step.detect) : setting;
|
|
1641
|
-
if (!tool) await hooks.callHook("kubb:warn", { message: `No ${step.noun} found (${step.detect.join(", ")}). Skipping ${step.verbing.toLowerCase()}.` });
|
|
1642
|
-
if (tool && setting === "auto") await hooks.callHook("kubb:info", { message: `Auto-detected ${step.noun}: ${(0, node_util.styleText)("dim", tool)}` });
|
|
1643
|
-
const command = tool ? step.tools[tool] : void 0;
|
|
1644
|
-
if (command) try {
|
|
1645
|
-
await runHook({
|
|
1646
|
-
hooks,
|
|
1647
|
-
id: [config.name, tool].filter(Boolean).join("-"),
|
|
1648
|
-
command: command.command,
|
|
1649
|
-
args: command.args(outputPath(config))
|
|
1650
|
-
});
|
|
1651
|
-
await hooks.callHook("kubb:success", { message: `${step.verbing} with ${tool} successfully` });
|
|
1652
|
-
} catch (caughtError) {
|
|
1653
|
-
await hooks.callHook("kubb:error", { error: new Error(command.errorMessage, { cause: caughtError }) });
|
|
1654
|
-
signal?.throwIfAborted();
|
|
1655
|
-
}
|
|
1656
|
-
await hooks.callHook(`kubb:${step.kind}:end`);
|
|
1657
|
-
}
|
|
1658
|
-
if (config.output.postGenerate?.length) {
|
|
1659
|
-
await hooks.callHook("kubb:hooks:start");
|
|
1660
|
-
for (const entry of config.output.postGenerate) {
|
|
1661
|
-
const line = typeof entry === "string" ? entry : entry.command;
|
|
1662
|
-
const [cmd, ...args] = tokenize(line);
|
|
1663
|
-
if (!cmd) continue;
|
|
1664
|
-
await runHook({
|
|
1665
|
-
hooks,
|
|
1666
|
-
id: line,
|
|
1667
|
-
command: cmd,
|
|
1668
|
-
args
|
|
1669
|
-
});
|
|
1670
|
-
await hooks.callHook("kubb:success", { message: `${line} successfully executed` });
|
|
1671
|
-
}
|
|
1672
|
-
await hooks.callHook("kubb:hooks:end");
|
|
1673
|
-
}
|
|
1464
|
+
function resolveAgentCapacity(env = process.env) {
|
|
1465
|
+
return { maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)) };
|
|
1674
1466
|
}
|
|
1675
1467
|
//#endregion
|
|
1676
|
-
//#region src/snapshotPackage.ts
|
|
1468
|
+
//#region src/operations/snapshotPackage.ts
|
|
1677
1469
|
const gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
|
|
1470
|
+
const UPLOAD_TIMEOUT_MS = 12e4;
|
|
1678
1471
|
/**
|
|
1679
1472
|
* Maps a generated file's path to its place inside the tarball, stripping everything before a
|
|
1680
1473
|
* `src`/`dist` segment and any `..`/empty path segment so a crafted file name cannot escape the
|
|
@@ -1812,10 +1605,33 @@ async function createSnapshotPackage(files, packageInfo) {
|
|
|
1812
1605
|
});
|
|
1813
1606
|
}
|
|
1814
1607
|
}
|
|
1608
|
+
async function uploadSnapshot({ bytes, uploadPath, studioUrl, token, shutdown }) {
|
|
1609
|
+
const uploadUrl = new URL(uploadPath, studioUrl);
|
|
1610
|
+
if (uploadUrl.origin !== new URL(studioUrl).origin) throw new Error("Snapshot upload path must stay on the Studio origin");
|
|
1611
|
+
const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS);
|
|
1612
|
+
const signal = shutdown ? AbortSignal.any([shutdown, timeout]) : timeout;
|
|
1613
|
+
const redirect = await fetch(uploadUrl, {
|
|
1614
|
+
method: "PUT",
|
|
1615
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1616
|
+
redirect: "manual",
|
|
1617
|
+
signal
|
|
1618
|
+
});
|
|
1619
|
+
const storageUrl = redirect.headers.get("location");
|
|
1620
|
+
if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
|
|
1621
|
+
const storage = new URL(storageUrl);
|
|
1622
|
+
if (storage.protocol !== "https:" && storage.hostname !== "localhost" && storage.hostname !== "127.0.0.1") throw new Error(`Refusing snapshot upload to ${storage.origin}`);
|
|
1623
|
+
const response = await fetch(storage, {
|
|
1624
|
+
method: "PUT",
|
|
1625
|
+
body: new Uint8Array(bytes),
|
|
1626
|
+
redirect: "error",
|
|
1627
|
+
signal
|
|
1628
|
+
});
|
|
1629
|
+
if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
|
|
1630
|
+
}
|
|
1815
1631
|
//#endregion
|
|
1816
|
-
//#region src/generations.ts
|
|
1632
|
+
//#region src/operations/generations.ts
|
|
1817
1633
|
const READ_CONCURRENCY = 50;
|
|
1818
|
-
const MB = 1048576;
|
|
1634
|
+
const MB$1 = 1048576;
|
|
1819
1635
|
const INDEX_KEY = "studio/generations.json";
|
|
1820
1636
|
const hashOf = (content) => (0, node_crypto.createHash)("sha1").update(content).digest("hex").slice(0, 16);
|
|
1821
1637
|
/**
|
|
@@ -1842,8 +1658,9 @@ async function listDisk({ root, outputPath, maxFiles }) {
|
|
|
1842
1658
|
* tenant never reaches another's output. The oldest go past `maxCount` or `maxMb`, but the newest
|
|
1843
1659
|
* always stays.
|
|
1844
1660
|
*/
|
|
1845
|
-
function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
1661
|
+
function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now }) {
|
|
1846
1662
|
let index;
|
|
1663
|
+
const isLive = (generation) => ttlMs === void 0 || generation.keptAt === void 0 || now() - generation.keptAt < ttlMs;
|
|
1847
1664
|
const dirOf = (jobId) => `studio/generations/${hashOf(jobId)}/`;
|
|
1848
1665
|
async function load() {
|
|
1849
1666
|
if (index) return index;
|
|
@@ -1859,7 +1676,7 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
|
1859
1676
|
* Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
|
|
1860
1677
|
*/
|
|
1861
1678
|
async function keep({ jobId, source, files, maxSetMb }) {
|
|
1862
|
-
const maxSetBytes = maxSetMb * MB;
|
|
1679
|
+
const maxSetBytes = maxSetMb * MB$1;
|
|
1863
1680
|
const hashes = {};
|
|
1864
1681
|
let bytes = 0;
|
|
1865
1682
|
await inParallel({
|
|
@@ -1894,13 +1711,20 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
|
1894
1711
|
return {
|
|
1895
1712
|
keep,
|
|
1896
1713
|
drop,
|
|
1897
|
-
get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId),
|
|
1898
|
-
latest: async () => (await load()).at(-1),
|
|
1714
|
+
get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId && isLive(generation)),
|
|
1715
|
+
latest: async () => (await load()).filter(isLive).at(-1),
|
|
1716
|
+
/** Total bytes of every set the store holds, expired ones too until the next add drops them. */
|
|
1717
|
+
bytes: async () => (await load()).reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0),
|
|
1899
1718
|
async add(generation) {
|
|
1900
|
-
const
|
|
1901
|
-
|
|
1719
|
+
const current = await load();
|
|
1720
|
+
for (const expired of current.filter((entry) => !isLive(entry))) await drop(expired.jobId);
|
|
1721
|
+
const entries = current.filter((entry) => entry.jobId !== generation.jobId && isLive(entry));
|
|
1722
|
+
entries.push({
|
|
1723
|
+
...generation,
|
|
1724
|
+
keptAt: now()
|
|
1725
|
+
});
|
|
1902
1726
|
const weight = () => entries.reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0);
|
|
1903
|
-
while (entries.length > 1 && (entries.length > maxCount || weight() > maxMb * MB)) await drop(entries.shift().jobId);
|
|
1727
|
+
while (entries.length > 1 && (entries.length > maxCount || weight() > maxMb * MB$1)) await drop(entries.shift().jobId);
|
|
1904
1728
|
index = entries;
|
|
1905
1729
|
await storage.writeItem(INDEX_KEY, JSON.stringify(entries));
|
|
1906
1730
|
},
|
|
@@ -1923,12 +1747,7 @@ function createGenerationStore({ storage, maxCount, maxMb }) {
|
|
|
1923
1747
|
};
|
|
1924
1748
|
}
|
|
1925
1749
|
//#endregion
|
|
1926
|
-
//#region src/
|
|
1927
|
-
/**
|
|
1928
|
-
* How long the initial handshake may take before the socket is closed and the reconnect loop
|
|
1929
|
-
* takes over.
|
|
1930
|
-
*/
|
|
1931
|
-
const CONNECT_TIMEOUT_MS = 5e3;
|
|
1750
|
+
//#region src/operations/generationEvents.ts
|
|
1932
1751
|
const require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
|
|
1933
1752
|
function relativeStoragePath(root, filePath) {
|
|
1934
1753
|
return ((0, node_path.isAbsolute)(filePath) ? (0, node_path.relative)((0, node_path.resolve)(root), filePath) : filePath).replaceAll("\\", "/");
|
|
@@ -1958,27 +1777,24 @@ async function resolvePeerDependencies(names) {
|
|
|
1958
1777
|
missingDependencies
|
|
1959
1778
|
};
|
|
1960
1779
|
}
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
function createWebsocket(url, options) {
|
|
1965
|
-
const ws$1 = new ws.default(url, options);
|
|
1966
|
-
const timer = setTimeout(() => {
|
|
1967
|
-
if (ws$1.readyState === ws.default.CONNECTING) ws$1.close(3008, "Connection timeout");
|
|
1968
|
-
}, CONNECT_TIMEOUT_MS);
|
|
1969
|
-
ws$1.once("open", () => clearTimeout(timer));
|
|
1970
|
-
ws$1.once("close", () => clearTimeout(timer));
|
|
1971
|
-
return ws$1;
|
|
1972
|
-
}
|
|
1780
|
+
const MAX_QUEUED_EVENTS = 1024;
|
|
1781
|
+
const RESERVED_EVENTS = 64;
|
|
1782
|
+
const isDiscardable = (event) => event.type === "kubb:files:processing:update" || event.type === "kubb:info" || event.type === "kubb:success";
|
|
1973
1783
|
/** Forwards selected Kubb lifecycle events to a native Cap'n Web stream. */
|
|
1974
1784
|
function createGenerationStream(hooks, jobId, options = {}) {
|
|
1975
1785
|
const unhooks = [];
|
|
1976
1786
|
let root = "";
|
|
1977
|
-
|
|
1978
|
-
const writer = transform.writable.getWriter();
|
|
1979
|
-
let writes = Promise.resolve();
|
|
1787
|
+
let controller;
|
|
1980
1788
|
let closed = false;
|
|
1981
|
-
|
|
1789
|
+
const stream = new ReadableStream({
|
|
1790
|
+
start: (value) => {
|
|
1791
|
+
controller = value;
|
|
1792
|
+
},
|
|
1793
|
+
cancel: () => {
|
|
1794
|
+
closed = true;
|
|
1795
|
+
detach();
|
|
1796
|
+
}
|
|
1797
|
+
}, { highWaterMark: MAX_QUEUED_EVENTS });
|
|
1982
1798
|
/**
|
|
1983
1799
|
* Registers a listener and keeps its remover, so one generation's listeners come off the session
|
|
1984
1800
|
* emitter again when that generation ends.
|
|
@@ -1987,6 +1803,7 @@ function createGenerationStream(hooks, jobId, options = {}) {
|
|
|
1987
1803
|
unhooks.push(hooks.hook(name, handler));
|
|
1988
1804
|
}
|
|
1989
1805
|
function emitEvent(type, data) {
|
|
1806
|
+
if (closed) return;
|
|
1990
1807
|
const event = {
|
|
1991
1808
|
jobId,
|
|
1992
1809
|
type,
|
|
@@ -1994,9 +1811,12 @@ function createGenerationStream(hooks, jobId, options = {}) {
|
|
|
1994
1811
|
version: 1,
|
|
1995
1812
|
timestamp: Date.now()
|
|
1996
1813
|
};
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
1814
|
+
if (controller.desiredSize <= RESERVED_EVENTS && isDiscardable(event)) return;
|
|
1815
|
+
if (controller.desiredSize <= 0) {
|
|
1816
|
+
fail(/* @__PURE__ */ new Error(`Generation event stream exceeded ${MAX_QUEUED_EVENTS} queued events`));
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
controller.enqueue(event);
|
|
2000
1820
|
}
|
|
2001
1821
|
on("kubb:plugin:start", (ctx) => {
|
|
2002
1822
|
emitEvent("kubb:plugin:start", [{ plugin: { name: ctx.plugin.name } }]);
|
|
@@ -2142,25 +1962,38 @@ function createGenerationStream(hooks, jobId, options = {}) {
|
|
|
2142
1962
|
if (closed) return;
|
|
2143
1963
|
closed = true;
|
|
2144
1964
|
detach();
|
|
2145
|
-
|
|
2146
|
-
if (streamError) return;
|
|
2147
|
-
await writer.close().catch(() => void 0);
|
|
1965
|
+
controller.close();
|
|
2148
1966
|
}
|
|
2149
1967
|
function fail(error) {
|
|
2150
1968
|
detach();
|
|
2151
1969
|
if (closed) return;
|
|
2152
1970
|
closed = true;
|
|
2153
|
-
|
|
1971
|
+
controller.error(error);
|
|
2154
1972
|
}
|
|
2155
1973
|
return {
|
|
2156
|
-
stream
|
|
1974
|
+
stream,
|
|
2157
1975
|
close,
|
|
2158
1976
|
dispose: () => fail(),
|
|
2159
1977
|
fail
|
|
2160
1978
|
};
|
|
2161
1979
|
}
|
|
2162
1980
|
//#endregion
|
|
2163
|
-
//#region src/
|
|
1981
|
+
//#region src/operations/websocket.ts
|
|
1982
|
+
const CONNECT_TIMEOUT_MS = 5e3;
|
|
1983
|
+
/**
|
|
1984
|
+
* Opens a Studio WebSocket connection and closes it when the initial handshake exceeds the configured timeout.
|
|
1985
|
+
*/
|
|
1986
|
+
function createWebsocket(url, options) {
|
|
1987
|
+
const ws$1 = new ws.default(url, options);
|
|
1988
|
+
const timer = setTimeout(() => {
|
|
1989
|
+
if (ws$1.readyState === ws.default.CONNECTING) ws$1.close(3008, "Connection timeout");
|
|
1990
|
+
}, CONNECT_TIMEOUT_MS);
|
|
1991
|
+
ws$1.once("open", () => clearTimeout(timer));
|
|
1992
|
+
ws$1.once("close", () => clearTimeout(timer));
|
|
1993
|
+
return ws$1;
|
|
1994
|
+
}
|
|
1995
|
+
//#endregion
|
|
1996
|
+
//#region src/operations/rpc.ts
|
|
2164
1997
|
/**
|
|
2165
1998
|
* The only methods Studio may call on an agent. A `StudioSession` carries far more than
|
|
2166
1999
|
* {@link AgentApi}, so it is wrapped rather than exposed: what Cap'n Web can reach is exactly what
|
|
@@ -2187,6 +2020,9 @@ var AgentRpcTarget = class extends capnweb.RpcTarget {
|
|
|
2187
2020
|
readFiles(input) {
|
|
2188
2021
|
return this.api.readFiles(input);
|
|
2189
2022
|
}
|
|
2023
|
+
cancel(jobId) {
|
|
2024
|
+
return this.api.cancel(jobId);
|
|
2025
|
+
}
|
|
2190
2026
|
};
|
|
2191
2027
|
/**
|
|
2192
2028
|
* Opens an authenticated Cap'n Web session to Studio over a WebSocket. Rejects an unencrypted URL
|
|
@@ -2198,11 +2034,17 @@ var AgentRpcTarget = class extends capnweb.RpcTarget {
|
|
|
2198
2034
|
* await rpc.studio.ping()
|
|
2199
2035
|
* ```
|
|
2200
2036
|
*/
|
|
2201
|
-
const connectWebSocketRpc = async ({ url, token, local }) => {
|
|
2037
|
+
const connectWebSocketRpc = async ({ url, token, instanceId, local }) => {
|
|
2202
2038
|
const { protocol, hostname, host } = new URL(url);
|
|
2203
2039
|
if (protocol !== "wss:" && !(protocol === "ws:" && (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"))) throw new Error(`Refusing unencrypted WebSocket to ${host}`);
|
|
2204
|
-
const socket = createWebsocket(url, { headers: {
|
|
2205
|
-
|
|
2040
|
+
const socket = createWebsocket(url, { headers: {
|
|
2041
|
+
Authorization: `Bearer ${token}`,
|
|
2042
|
+
[require_protocol.AGENT_INSTANCE_HEADER]: instanceId
|
|
2043
|
+
} });
|
|
2044
|
+
const closed = new Promise((resolve) => socket.once("close", (code, reason) => resolve({
|
|
2045
|
+
code,
|
|
2046
|
+
reason: reason.toString()
|
|
2047
|
+
})));
|
|
2206
2048
|
const studio = (0, capnweb.newWebSocketRpcSession)(socket, new AgentRpcTarget(local));
|
|
2207
2049
|
studio.onRpcBroken(() => socket.close());
|
|
2208
2050
|
return {
|
|
@@ -2212,11 +2054,274 @@ const connectWebSocketRpc = async ({ url, token, local }) => {
|
|
|
2212
2054
|
};
|
|
2213
2055
|
};
|
|
2214
2056
|
//#endregion
|
|
2215
|
-
//#region src/
|
|
2057
|
+
//#region src/operations/hooks.ts
|
|
2216
2058
|
/**
|
|
2217
|
-
*
|
|
2059
|
+
* Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
|
|
2060
|
+
* streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
|
|
2061
|
+
* Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
|
|
2062
|
+
*
|
|
2063
|
+
* Returns a remover, so a session that runs one generation after another on the same emitter does
|
|
2064
|
+
* not stack a listener per run.
|
|
2065
|
+
*/
|
|
2066
|
+
function setupHookListener(hooks, root, signal) {
|
|
2067
|
+
return hooks.hook("kubb:hook:start", async (ctx) => {
|
|
2068
|
+
const { id, command, args } = ctx;
|
|
2069
|
+
if (!id) return;
|
|
2070
|
+
const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
|
|
2071
|
+
try {
|
|
2072
|
+
const proc = (0, tinyexec.x)(command, [...args ?? []], {
|
|
2073
|
+
signal,
|
|
2074
|
+
nodeOptions: {
|
|
2075
|
+
cwd: root,
|
|
2076
|
+
detached: true
|
|
2077
|
+
}
|
|
2078
|
+
});
|
|
2079
|
+
for await (const line of proc) await hooks.callHook("kubb:hook:line", {
|
|
2080
|
+
id,
|
|
2081
|
+
line
|
|
2082
|
+
});
|
|
2083
|
+
const { exitCode } = await proc;
|
|
2084
|
+
if (exitCode !== 0) {
|
|
2085
|
+
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
2086
|
+
await hooks.callHook("kubb:hook:end", {
|
|
2087
|
+
id,
|
|
2088
|
+
command,
|
|
2089
|
+
args,
|
|
2090
|
+
success: false,
|
|
2091
|
+
error
|
|
2092
|
+
});
|
|
2093
|
+
await hooks.callHook("kubb:error", { error });
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
await hooks.callHook("kubb:hook:end", {
|
|
2097
|
+
id,
|
|
2098
|
+
command,
|
|
2099
|
+
args,
|
|
2100
|
+
success: true,
|
|
2101
|
+
error: null
|
|
2102
|
+
});
|
|
2103
|
+
} catch (caughtError) {
|
|
2104
|
+
const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
|
|
2105
|
+
error.cause = caughtError;
|
|
2106
|
+
await hooks.callHook("kubb:hook:end", {
|
|
2107
|
+
id,
|
|
2108
|
+
command,
|
|
2109
|
+
args,
|
|
2110
|
+
success: false,
|
|
2111
|
+
error
|
|
2112
|
+
});
|
|
2113
|
+
await hooks.callHook("kubb:error", { error });
|
|
2114
|
+
}
|
|
2115
|
+
});
|
|
2116
|
+
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
|
|
2119
|
+
* `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
|
|
2120
|
+
* that same listener, so a handler added afterward would already have missed it.
|
|
2218
2121
|
*/
|
|
2122
|
+
function waitForHookEnd(hooks, hookId) {
|
|
2123
|
+
return new Promise((resolve, reject) => {
|
|
2124
|
+
const handleHookEnd = (ctx) => {
|
|
2125
|
+
if (ctx.id !== hookId) return;
|
|
2126
|
+
hooks.removeHook("kubb:hook:end", handleHookEnd);
|
|
2127
|
+
if (ctx.success) {
|
|
2128
|
+
resolve();
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
reject(ctx.error);
|
|
2132
|
+
};
|
|
2133
|
+
hooks.hook("kubb:hook:end", handleHookEnd);
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
//#endregion
|
|
2137
|
+
//#region src/operations/generate.ts
|
|
2219
2138
|
const DISK_SNAPSHOT_MAX_FILES = 1e4;
|
|
2139
|
+
/**
|
|
2140
|
+
* `isToolAvailable` spawns a process, and a long-lived connection generates repeatedly, so each
|
|
2141
|
+
* executable is probed once per process. The CLI deliberately does not memoize: a `--watch` build
|
|
2142
|
+
* should keep noticing a tool installed mid-session.
|
|
2143
|
+
*/
|
|
2144
|
+
const detectTool = memoize(/* @__PURE__ */ new Map(), detectTool$1);
|
|
2145
|
+
/**
|
|
2146
|
+
* The two post-build tool steps. Formatting and linting differ only in which tools they look for,
|
|
2147
|
+
* so they run through one loop rather than two near-identical blocks.
|
|
2148
|
+
*
|
|
2149
|
+
* `noun` and `verbing` are spelled out instead of built from `kind`. Concatenating `` `${kind}ter` ``
|
|
2150
|
+
* and `` `${kind}ting` `` works for `format`, but doubles the `t` in `lint`, giving "lintter" and
|
|
2151
|
+
* "lintting" instead of "linter" and "linting".
|
|
2152
|
+
*/
|
|
2153
|
+
const TOOL_STEPS = [{
|
|
2154
|
+
kind: "format",
|
|
2155
|
+
noun: "formatter",
|
|
2156
|
+
verbing: "Formatting",
|
|
2157
|
+
tools: formatters,
|
|
2158
|
+
detect: FORMATTER_PREFERENCE
|
|
2159
|
+
}, {
|
|
2160
|
+
kind: "lint",
|
|
2161
|
+
noun: "linter",
|
|
2162
|
+
verbing: "Linting",
|
|
2163
|
+
tools: linters,
|
|
2164
|
+
detect: LINTER_PREFERENCE
|
|
2165
|
+
}];
|
|
2166
|
+
/**
|
|
2167
|
+
* Absolute path of the directory the formatter and linter are pointed at.
|
|
2168
|
+
*/
|
|
2169
|
+
function outputPath(config) {
|
|
2170
|
+
return node_path.default.isAbsolute(config.output.path) ? config.output.path : node_path.default.resolve(node_process.default.cwd(), config.root, config.output.path);
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Emits `kubb:hook:start` and waits for the matching `kubb:hook:end`. The host spawns the process:
|
|
2174
|
+
* this only describes what to run and when it finished.
|
|
2175
|
+
*
|
|
2176
|
+
* @throws whatever the command failed with, so callers can report it their own way.
|
|
2177
|
+
*/
|
|
2178
|
+
async function runHook({ hooks, id, command, args }) {
|
|
2179
|
+
const hookId = (0, node_crypto.hash)("sha256", id);
|
|
2180
|
+
const hookEnd = waitForHookEnd(hooks, hookId);
|
|
2181
|
+
await hooks.callHook("kubb:hook:start", {
|
|
2182
|
+
id: hookId,
|
|
2183
|
+
command,
|
|
2184
|
+
args: [...args]
|
|
2185
|
+
});
|
|
2186
|
+
await hookEnd;
|
|
2187
|
+
}
|
|
2188
|
+
function isProblemErrorDiagnostic(diagnostic) {
|
|
2189
|
+
return (diagnostic.kind ?? "problem") === "problem" && diagnostic.severity === "error";
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* Folds error-severity diagnostics into one thrown error so logs name the failing plugin.
|
|
2193
|
+
*/
|
|
2194
|
+
function formatGenerationFailure(diagnostics) {
|
|
2195
|
+
const reasons = diagnostics.filter(isProblemErrorDiagnostic).map((diagnostic) => diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message);
|
|
2196
|
+
if (!reasons.length) return /* @__PURE__ */ new Error("Generation failed");
|
|
2197
|
+
return /* @__PURE__ */ new Error(`Generation failed: ${reasons.length} error${reasons.length === 1 ? "" : "s"}: ${reasons.join("; ")}`);
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* Runs a full Kubb code-generation cycle for the given config.
|
|
2201
|
+
*
|
|
2202
|
+
* Emits lifecycle events on the provided `hooks` emitter so callers (e.g. the WebSocket stream)
|
|
2203
|
+
* can forward progress to connected clients. After a successful build, auto-formatting and
|
|
2204
|
+
* linting are applied when configured, followed by any user-defined `hooks.done` commands.
|
|
2205
|
+
*/
|
|
2206
|
+
async function generate({ config, hooks, signal }) {
|
|
2207
|
+
signal?.throwIfAborted();
|
|
2208
|
+
const hrStart = node_process.default.hrtime();
|
|
2209
|
+
await hooks.callHook("kubb:generation:start", { config });
|
|
2210
|
+
await hooks.callHook("kubb:info", { message: config.name ? `Setup generation ${config.name}` : "Setup generation" });
|
|
2211
|
+
const kubb = (0, _kubb_core.createKubb)(config, {
|
|
2212
|
+
hooks,
|
|
2213
|
+
signal
|
|
2214
|
+
});
|
|
2215
|
+
await kubb.setup();
|
|
2216
|
+
await hooks.callHook("kubb:info", { message: config.name ? `Build generation ${config.name}` : "Build generation" });
|
|
2217
|
+
const { files, diagnostics, storage } = await kubb.safeBuild();
|
|
2218
|
+
signal?.throwIfAborted();
|
|
2219
|
+
await hooks.callHook("kubb:info", { message: "Load summary" });
|
|
2220
|
+
for (const diagnostic of diagnostics.filter(isProblemErrorDiagnostic)) await hooks.callHook("kubb:error", { error: new Error(diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message) });
|
|
2221
|
+
const status = _kubb_core.Diagnostics.hasError(diagnostics) ? "failed" : "success";
|
|
2222
|
+
await hooks.callHook("kubb:generation:end", {
|
|
2223
|
+
config,
|
|
2224
|
+
storage: {
|
|
2225
|
+
...storage,
|
|
2226
|
+
readKeys: async () => [...new Set(files.map((file) => file.path))]
|
|
2227
|
+
},
|
|
2228
|
+
diagnostics,
|
|
2229
|
+
status,
|
|
2230
|
+
hrStart,
|
|
2231
|
+
filesCreated: files.length
|
|
2232
|
+
});
|
|
2233
|
+
if (status === "failed") throw formatGenerationFailure(diagnostics);
|
|
2234
|
+
await hooks.callHook("kubb:success", { message: "Generation successfully" });
|
|
2235
|
+
for (const step of TOOL_STEPS) {
|
|
2236
|
+
const setting = config.output[step.kind];
|
|
2237
|
+
if (!setting) continue;
|
|
2238
|
+
await hooks.callHook(`kubb:${step.kind}:start`);
|
|
2239
|
+
const tool = setting === "auto" ? await detectTool(step.detect) : setting;
|
|
2240
|
+
if (!tool) await hooks.callHook("kubb:warn", { message: `No ${step.noun} found (${step.detect.join(", ")}). Skipping ${step.verbing.toLowerCase()}.` });
|
|
2241
|
+
if (tool && setting === "auto") await hooks.callHook("kubb:info", { message: `Auto-detected ${step.noun}: ${(0, node_util.styleText)("dim", tool)}` });
|
|
2242
|
+
const command = tool ? step.tools[tool] : void 0;
|
|
2243
|
+
if (command) try {
|
|
2244
|
+
await runHook({
|
|
2245
|
+
hooks,
|
|
2246
|
+
id: [config.name, tool].filter(Boolean).join("-"),
|
|
2247
|
+
command: command.command,
|
|
2248
|
+
args: command.args(outputPath(config))
|
|
2249
|
+
});
|
|
2250
|
+
await hooks.callHook("kubb:success", { message: `${step.verbing} with ${tool} successfully` });
|
|
2251
|
+
} catch (caughtError) {
|
|
2252
|
+
await hooks.callHook("kubb:error", { error: new Error(command.errorMessage, { cause: caughtError }) });
|
|
2253
|
+
signal?.throwIfAborted();
|
|
2254
|
+
}
|
|
2255
|
+
await hooks.callHook(`kubb:${step.kind}:end`);
|
|
2256
|
+
}
|
|
2257
|
+
if (config.output.postGenerate?.length) {
|
|
2258
|
+
await hooks.callHook("kubb:hooks:start");
|
|
2259
|
+
for (const entry of config.output.postGenerate) {
|
|
2260
|
+
const line = typeof entry === "string" ? entry : entry.command;
|
|
2261
|
+
const [cmd, ...args] = tokenize(line);
|
|
2262
|
+
if (!cmd) continue;
|
|
2263
|
+
await runHook({
|
|
2264
|
+
hooks,
|
|
2265
|
+
id: line,
|
|
2266
|
+
command: cmd,
|
|
2267
|
+
args
|
|
2268
|
+
});
|
|
2269
|
+
await hooks.callHook("kubb:success", { message: `${line} successfully executed` });
|
|
2270
|
+
}
|
|
2271
|
+
await hooks.callHook("kubb:hooks:end");
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
async function runGenerationOperation({ config, hooks, signal, jobId, store, snapshotRoot, maxSnapshotMb }) {
|
|
2275
|
+
const diskFiles = snapshotRoot ? await listDisk({
|
|
2276
|
+
root: snapshotRoot,
|
|
2277
|
+
outputPath: config.output.path,
|
|
2278
|
+
maxFiles: DISK_SNAPSHOT_MAX_FILES
|
|
2279
|
+
}) : void 0;
|
|
2280
|
+
const disk = diskFiles ? await store.keep({
|
|
2281
|
+
jobId,
|
|
2282
|
+
source: "disk",
|
|
2283
|
+
files: diskFiles,
|
|
2284
|
+
maxSetMb: maxSnapshotMb
|
|
2285
|
+
}) : void 0;
|
|
2286
|
+
const removeHookListener = setupHookListener(hooks, config.root, signal);
|
|
2287
|
+
try {
|
|
2288
|
+
await generate({
|
|
2289
|
+
config,
|
|
2290
|
+
hooks,
|
|
2291
|
+
signal
|
|
2292
|
+
});
|
|
2293
|
+
} catch (error) {
|
|
2294
|
+
await store.drop(jobId);
|
|
2295
|
+
throw error;
|
|
2296
|
+
} finally {
|
|
2297
|
+
removeHookListener();
|
|
2298
|
+
}
|
|
2299
|
+
return disk;
|
|
2300
|
+
}
|
|
2301
|
+
//#endregion
|
|
2302
|
+
//#region src/runtime/StudioSession.ts
|
|
2303
|
+
/** A sandbox shares one in-memory generation store across tenants, so old generations expire. */
|
|
2304
|
+
const SANDBOX_GENERATION_TTL_MS = 9e5;
|
|
2305
|
+
/**
|
|
2306
|
+
* A fresh root for one sandbox job. Kubb keys its output manifest cache by root, so tenants that
|
|
2307
|
+
* shared the agent's own root would read each other's manifest.
|
|
2308
|
+
*/
|
|
2309
|
+
function createJobRoot() {
|
|
2310
|
+
return (0, node_fs_promises.mkdtemp)(node_path.default.join((0, node_os.tmpdir)(), "kubb-job-"));
|
|
2311
|
+
}
|
|
2312
|
+
/**
|
|
2313
|
+
* Removes a job root and the manifest cache Kubb derived from it. Best effort: a leftover temp
|
|
2314
|
+
* directory must not fail a job that already finished.
|
|
2315
|
+
*/
|
|
2316
|
+
async function removeJobRoot(jobRoot) {
|
|
2317
|
+
await Promise.all([(0, node_fs_promises.rm)(jobRoot, {
|
|
2318
|
+
recursive: true,
|
|
2319
|
+
force: true
|
|
2320
|
+
}), (0, node_fs_promises.rm)((0, _kubb_core.resolveCacheDir)(jobRoot), {
|
|
2321
|
+
recursive: true,
|
|
2322
|
+
force: true
|
|
2323
|
+
})]).catch(() => {});
|
|
2324
|
+
}
|
|
2220
2325
|
var GenerationRunTarget = class extends capnweb.RpcTarget {
|
|
2221
2326
|
generationStream;
|
|
2222
2327
|
generationResult;
|
|
@@ -2263,34 +2368,74 @@ function applyStudioDefaults(options) {
|
|
|
2263
2368
|
...options.permissions
|
|
2264
2369
|
},
|
|
2265
2370
|
retryInterval: options.retryInterval ?? agentDefaults.retryIntervalMs,
|
|
2266
|
-
heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs)
|
|
2371
|
+
heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs),
|
|
2372
|
+
capacity: {
|
|
2373
|
+
...resolveAgentCapacity(),
|
|
2374
|
+
...options.capacity
|
|
2375
|
+
},
|
|
2376
|
+
instanceId: options.instanceId ?? (0, node_crypto.randomUUID)()
|
|
2267
2377
|
};
|
|
2268
2378
|
}
|
|
2269
2379
|
/**
|
|
2270
|
-
*
|
|
2271
|
-
*
|
|
2272
|
-
* A free function rather than a method: a pending retry timer reaches whatever it closes over, so
|
|
2273
|
-
* closing only over `options` (not a `StudioSession`) keeps a queued retry from pinning a closed
|
|
2274
|
-
* socket, its hook emitter, or its session id alive for the length of the retry interval.
|
|
2380
|
+
* Jobs one agent process can run at once today. Two runs would share this session's hook emitter,
|
|
2381
|
+
* and with it each other's events, until each job runs in its own worker (ADR-0003 slice B2).
|
|
2275
2382
|
*/
|
|
2276
|
-
|
|
2277
|
-
|
|
2383
|
+
const RUNTIME_MAX_CONCURRENT = 1;
|
|
2384
|
+
const MB = 1048576;
|
|
2385
|
+
function rssMb() {
|
|
2386
|
+
return node_process.default.memoryUsage().rss / MB;
|
|
2387
|
+
}
|
|
2388
|
+
function backoffDelayMs(attempt, maxMs) {
|
|
2389
|
+
const cap = Math.min(1e3 * 2 ** (attempt - 1), maxMs);
|
|
2390
|
+
return Math.random() * cap;
|
|
2391
|
+
}
|
|
2392
|
+
function reconnect(options, delayMs, attempt) {
|
|
2393
|
+
const { signal, onTokenRejected } = options;
|
|
2278
2394
|
if (signal?.aborted) return;
|
|
2279
2395
|
const cancel = () => clearTimeout(timer);
|
|
2280
2396
|
const timer = setTimeout(() => {
|
|
2281
2397
|
signal?.removeEventListener("abort", cancel);
|
|
2282
2398
|
if (signal?.aborted) return;
|
|
2283
|
-
new StudioSession(
|
|
2399
|
+
new StudioSession({
|
|
2400
|
+
...options,
|
|
2401
|
+
reconnectAttempt: attempt
|
|
2402
|
+
}).start().catch((error) => {
|
|
2284
2403
|
if (error instanceof InvalidAgentTokenError) {
|
|
2285
2404
|
onTokenRejected?.(error);
|
|
2286
2405
|
return;
|
|
2287
2406
|
}
|
|
2288
|
-
|
|
2407
|
+
if (error instanceof IncompatibleAgentError) return;
|
|
2408
|
+
const nextAttempt = attempt + 1;
|
|
2409
|
+
reconnect(options, backoffDelayMs(nextAttempt, options.retryInterval), nextAttempt);
|
|
2289
2410
|
});
|
|
2290
|
-
},
|
|
2411
|
+
}, delayMs);
|
|
2291
2412
|
signal?.addEventListener("abort", cancel, { once: true });
|
|
2292
2413
|
}
|
|
2293
2414
|
/**
|
|
2415
|
+
* Reads what Studio meant by closing the connection. A code Studio did not send on purpose is an
|
|
2416
|
+
* ordinary drop, and the agent reconnects as it always has.
|
|
2417
|
+
*/
|
|
2418
|
+
function planEnd(close) {
|
|
2419
|
+
const code = close?.code;
|
|
2420
|
+
if (code === require_protocol.AgentCloseCode.REAUTHENTICATE) return {
|
|
2421
|
+
reason: "Kubb Studio asked the agent to register again",
|
|
2422
|
+
retry: true
|
|
2423
|
+
};
|
|
2424
|
+
if (code === require_protocol.AgentCloseCode.SUPERSEDED) return {
|
|
2425
|
+
reason: "another instance of this agent took over",
|
|
2426
|
+
retry: false
|
|
2427
|
+
};
|
|
2428
|
+
if (code === require_protocol.AgentCloseCode.INCOMPATIBLE) return {
|
|
2429
|
+
reason: "this agent is too old for Kubb Studio, or was deleted",
|
|
2430
|
+
retry: false,
|
|
2431
|
+
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.")
|
|
2432
|
+
};
|
|
2433
|
+
return {
|
|
2434
|
+
reason: "connection closed",
|
|
2435
|
+
retry: true
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
/**
|
|
2294
2439
|
* One agent-to-Studio RPC transport: opening it, keeping it alive, and serving remote methods.
|
|
2295
2440
|
* `createClient` opens one per pool slot and is the only caller.
|
|
2296
2441
|
*/
|
|
@@ -2303,14 +2448,15 @@ var StudioSession = class {
|
|
|
2303
2448
|
*/
|
|
2304
2449
|
#unhooks = [];
|
|
2305
2450
|
/**
|
|
2306
|
-
* What
|
|
2307
|
-
* Before it resolves there is
|
|
2451
|
+
* What registration handed back, and the marker for whether the agent registered at all.
|
|
2452
|
+
* Before it resolves there is no sandbox flag to read.
|
|
2308
2453
|
*/
|
|
2309
|
-
#
|
|
2454
|
+
#registration;
|
|
2310
2455
|
#rpc;
|
|
2311
2456
|
#studioVersion;
|
|
2312
2457
|
#disposed = false;
|
|
2313
2458
|
#isGenerating = false;
|
|
2459
|
+
#activeJob;
|
|
2314
2460
|
#heartbeatTimer;
|
|
2315
2461
|
#lastGeneration;
|
|
2316
2462
|
#store;
|
|
@@ -2320,17 +2466,17 @@ var StudioSession = class {
|
|
|
2320
2466
|
* host does not queue jobs before the agent session is registered.
|
|
2321
2467
|
*/
|
|
2322
2468
|
#connectAck = Promise.withResolvers();
|
|
2323
|
-
#
|
|
2324
|
-
constructor({
|
|
2469
|
+
#reconnectAttempt;
|
|
2470
|
+
constructor({ reconnectAttempt, ...options }) {
|
|
2325
2471
|
this.#options = applyStudioDefaults(options);
|
|
2326
|
-
this.#
|
|
2472
|
+
this.#reconnectAttempt = reconnectAttempt ?? 0;
|
|
2327
2473
|
this.#connectAck.promise.catch(() => {});
|
|
2328
2474
|
}
|
|
2329
2475
|
/**
|
|
2330
2476
|
* A sandbox agent runs on Studio's own infrastructure, so it has no user project to touch.
|
|
2331
2477
|
*/
|
|
2332
2478
|
get #isSandbox() {
|
|
2333
|
-
return this.#
|
|
2479
|
+
return this.#registration?.isSandbox === true;
|
|
2334
2480
|
}
|
|
2335
2481
|
/**
|
|
2336
2482
|
* Kept in the project's cache directory, so it survives a restart, except on a sandbox: its pool
|
|
@@ -2340,7 +2486,8 @@ var StudioSession = class {
|
|
|
2340
2486
|
this.#store ??= createGenerationStore({
|
|
2341
2487
|
storage: this.#isSandbox ? (0, _kubb_core.memoryStorage)() : (0, _kubb_core.cacheStorage)({ root: this.#options.root }),
|
|
2342
2488
|
maxCount: this.#limits.maxCount,
|
|
2343
|
-
maxMb: this.#limits.maxMb
|
|
2489
|
+
maxMb: this.#limits.maxMb,
|
|
2490
|
+
ttlMs: this.#isSandbox ? SANDBOX_GENERATION_TTL_MS : void 0
|
|
2344
2491
|
});
|
|
2345
2492
|
return this.#store;
|
|
2346
2493
|
}
|
|
@@ -2364,20 +2511,26 @@ var StudioSession = class {
|
|
|
2364
2511
|
return this.#isSandbox || this.#options.permissions.allowRead;
|
|
2365
2512
|
}
|
|
2366
2513
|
async start() {
|
|
2367
|
-
const { token, studioUrl, signal, heartbeatInterval, installLogger } = this.#options;
|
|
2514
|
+
const { token, studioUrl, signal, heartbeatInterval, installLogger, instanceId, capacity } = this.#options;
|
|
2368
2515
|
await installLogger?.(this.#hooks);
|
|
2369
|
-
if (this.#startupWarning) await this.#warn(this.#startupWarning);
|
|
2370
2516
|
try {
|
|
2371
2517
|
await this.#hooks.callHook("studio:connecting", { url: studioUrl });
|
|
2372
|
-
|
|
2518
|
+
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`);
|
|
2519
|
+
const registration = await registerAgent({
|
|
2373
2520
|
token,
|
|
2374
|
-
studioUrl
|
|
2521
|
+
studioUrl,
|
|
2522
|
+
instanceId,
|
|
2523
|
+
capacity: {
|
|
2524
|
+
...capacity,
|
|
2525
|
+
maxConcurrent: Math.min(capacity.maxConcurrent, RUNTIME_MAX_CONCURRENT)
|
|
2526
|
+
}
|
|
2375
2527
|
});
|
|
2376
|
-
this.#
|
|
2377
|
-
this.#studioVersion =
|
|
2528
|
+
this.#registration = registration;
|
|
2529
|
+
this.#studioVersion = registration.version;
|
|
2378
2530
|
const rpc = await (this.#options.connector ?? connectWebSocketRpc)({
|
|
2379
|
-
url:
|
|
2531
|
+
url: registration.socketUrl,
|
|
2380
2532
|
token,
|
|
2533
|
+
instanceId,
|
|
2381
2534
|
local: this
|
|
2382
2535
|
});
|
|
2383
2536
|
this.#rpc = rpc;
|
|
@@ -2392,16 +2545,17 @@ var StudioSession = class {
|
|
|
2392
2545
|
kubb: version,
|
|
2393
2546
|
agent: this.#options.version
|
|
2394
2547
|
},
|
|
2395
|
-
agentSlug:
|
|
2396
|
-
organizationSlug:
|
|
2548
|
+
agentSlug: registration.agentSlug,
|
|
2549
|
+
organizationSlug: registration.organizationSlug
|
|
2397
2550
|
});
|
|
2398
2551
|
await this.#connectAck.promise;
|
|
2552
|
+
this.#reconnectAttempt = 0;
|
|
2399
2553
|
await this.#hooks.callHook("studio:ready", {});
|
|
2400
2554
|
} catch (error) {
|
|
2401
2555
|
this.#disposed = true;
|
|
2402
2556
|
this.dispose();
|
|
2403
2557
|
await this.#hooks.callHook("studio:error", { error: toError(error) });
|
|
2404
|
-
if (error instanceof InvalidAgentTokenError) throw error;
|
|
2558
|
+
if (error instanceof InvalidAgentTokenError || error instanceof IncompatibleAgentError) throw error;
|
|
2405
2559
|
await this.#reconnect();
|
|
2406
2560
|
}
|
|
2407
2561
|
}
|
|
@@ -2411,8 +2565,10 @@ var StudioSession = class {
|
|
|
2411
2565
|
*/
|
|
2412
2566
|
async #reconnect() {
|
|
2413
2567
|
if (this.#options.signal?.aborted) return;
|
|
2414
|
-
|
|
2415
|
-
|
|
2568
|
+
const attempt = this.#reconnectAttempt + 1;
|
|
2569
|
+
const delayMs = backoffDelayMs(attempt, this.#options.retryInterval);
|
|
2570
|
+
await this.#hooks.callHook("studio:reconnecting", { delayMs });
|
|
2571
|
+
reconnect(this.#options, delayMs, attempt);
|
|
2416
2572
|
}
|
|
2417
2573
|
#warn(message, permission) {
|
|
2418
2574
|
return this.#hooks.callHook("studio:warn", {
|
|
@@ -2444,10 +2600,23 @@ var StudioSession = class {
|
|
|
2444
2600
|
/**
|
|
2445
2601
|
* Races `studio.ping()` against a deadline, so a half-open socket can't hang it forever.
|
|
2446
2602
|
* */
|
|
2447
|
-
#ping(rpc) {
|
|
2603
|
+
async #ping(rpc) {
|
|
2448
2604
|
const { promise: timedOut, reject: onTimeout } = Promise.withResolvers();
|
|
2449
2605
|
const timer = setTimeout(() => onTimeout(/* @__PURE__ */ new Error("Heartbeat ping timed out")), agentDefaults.heartbeatTimeoutMs);
|
|
2450
|
-
|
|
2606
|
+
try {
|
|
2607
|
+
const load = await Promise.race([this.#load().catch(() => void 0), timedOut]);
|
|
2608
|
+
await Promise.race([rpc.studio.ping(load), timedOut]);
|
|
2609
|
+
} finally {
|
|
2610
|
+
clearTimeout(timer);
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
async #load() {
|
|
2614
|
+
return {
|
|
2615
|
+
running: this.#isGenerating ? 1 : 0,
|
|
2616
|
+
rssMb: Math.round(rssMb()),
|
|
2617
|
+
storeBytes: await this.#generations.bytes(),
|
|
2618
|
+
accepting: true
|
|
2619
|
+
};
|
|
2451
2620
|
}
|
|
2452
2621
|
/**
|
|
2453
2622
|
* Reads `kubb.config.ts` and reports which plugin options Studio may edit.
|
|
@@ -2492,8 +2661,11 @@ var StudioSession = class {
|
|
|
2492
2661
|
this.#connectAck.resolve();
|
|
2493
2662
|
return payload;
|
|
2494
2663
|
}
|
|
2495
|
-
#onAbort = () => void this.#end({
|
|
2496
|
-
|
|
2664
|
+
#onAbort = () => void this.#end({
|
|
2665
|
+
reason: "shutdown",
|
|
2666
|
+
retry: false
|
|
2667
|
+
});
|
|
2668
|
+
#onClose = (close) => void this.#end(planEnd(close));
|
|
2497
2669
|
/**
|
|
2498
2670
|
* Drops the socket and detaches every listener and timer this session added. Idempotent, and
|
|
2499
2671
|
* safe before `connect` opened anything.
|
|
@@ -2513,17 +2685,12 @@ var StudioSession = class {
|
|
|
2513
2685
|
* Ends the session: tells Studio it is over, drops the socket, and optionally reconnects.
|
|
2514
2686
|
* `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
|
|
2515
2687
|
*/
|
|
2516
|
-
async #end({ retry }) {
|
|
2517
|
-
const { studioUrl, token } = this.#options;
|
|
2688
|
+
async #end({ reason, retry, error }) {
|
|
2518
2689
|
if (this.#disposed) return;
|
|
2519
2690
|
this.#disposed = true;
|
|
2520
2691
|
this.dispose();
|
|
2521
|
-
await this.#hooks.callHook("studio:disconnected", { reason
|
|
2522
|
-
if (this.#
|
|
2523
|
-
sessionId: this.#session.sessionId,
|
|
2524
|
-
studioUrl,
|
|
2525
|
-
token
|
|
2526
|
-
})) await this.#warn("Could not notify Kubb Studio of the disconnect");
|
|
2692
|
+
await this.#hooks.callHook("studio:disconnected", { reason });
|
|
2693
|
+
if (error) await this.#hooks.callHook("studio:error", { error });
|
|
2527
2694
|
if (retry) await this.#reconnect();
|
|
2528
2695
|
}
|
|
2529
2696
|
startGeneration(data) {
|
|
@@ -2531,27 +2698,42 @@ var StudioSession = class {
|
|
|
2531
2698
|
this.#lastGeneration = result;
|
|
2532
2699
|
} });
|
|
2533
2700
|
const controller = new AbortController();
|
|
2534
|
-
const
|
|
2701
|
+
const cancelRun = async () => {
|
|
2702
|
+
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2703
|
+
};
|
|
2704
|
+
const result = this.#runGeneration(data, controller, cancelRun).then(async (value) => {
|
|
2535
2705
|
await generationStream.close();
|
|
2536
2706
|
return value;
|
|
2537
2707
|
}).catch((error) => {
|
|
2538
2708
|
generationStream.fail(error);
|
|
2539
2709
|
throw error;
|
|
2710
|
+
}).finally(() => {
|
|
2711
|
+
if (this.#activeJob?.cancel === cancelRun) this.#activeJob = void 0;
|
|
2540
2712
|
});
|
|
2541
2713
|
result.catch(() => {});
|
|
2542
|
-
return new GenerationRunTarget(generationStream.stream, result,
|
|
2543
|
-
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2544
|
-
}, () => {
|
|
2714
|
+
return new GenerationRunTarget(generationStream.stream, result, cancelRun, () => {
|
|
2545
2715
|
controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
|
|
2546
2716
|
generationStream.dispose();
|
|
2547
2717
|
});
|
|
2548
2718
|
}
|
|
2549
|
-
|
|
2719
|
+
/**
|
|
2720
|
+
* Cancels the currently running job when its id matches `jobId`.
|
|
2721
|
+
*/
|
|
2722
|
+
async cancel(jobId) {
|
|
2723
|
+
if (this.#activeJob?.jobId === jobId) await this.#activeJob.cancel();
|
|
2724
|
+
}
|
|
2725
|
+
async #runGeneration(data, controller, cancelRun) {
|
|
2550
2726
|
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");
|
|
2551
2727
|
this.#isGenerating = true;
|
|
2728
|
+
this.#activeJob = {
|
|
2729
|
+
jobId: data.jobId,
|
|
2730
|
+
cancel: cancelRun
|
|
2731
|
+
};
|
|
2552
2732
|
const command = "generate";
|
|
2553
|
-
const {
|
|
2733
|
+
const { loadConfig, permissions } = this.#options;
|
|
2734
|
+
let root = this.#options.root;
|
|
2554
2735
|
try {
|
|
2736
|
+
if (this.#isSandbox) root = await createJobRoot();
|
|
2555
2737
|
await this.#hooks.callHook("studio:command:start", { command });
|
|
2556
2738
|
const config = await loadConfig();
|
|
2557
2739
|
const patch = data.config;
|
|
@@ -2562,43 +2744,28 @@ var StudioSession = class {
|
|
|
2562
2744
|
if (patch?.input && !this.#canUseInput) await this.#warn("Ignored the spec from Studio: generating from a Studio spec was not granted", "allowInput");
|
|
2563
2745
|
const resolvedPlugins = plugins ?? config.plugins;
|
|
2564
2746
|
this.#lastGeneration = void 0;
|
|
2565
|
-
const
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
const detach = [setupHookListener(this.#hooks, root, controller.signal)];
|
|
2577
|
-
try {
|
|
2578
|
-
await generate({
|
|
2579
|
-
config: {
|
|
2580
|
-
...config,
|
|
2581
|
-
root,
|
|
2582
|
-
input: inputOverride ?? config.input,
|
|
2583
|
-
storage: this.#canWrite ? (0, _kubb_core.fsStorage)() : (0, _kubb_core.memoryStorage)(),
|
|
2584
|
-
output: permissions.allowExec ? { ...config.output } : {
|
|
2585
|
-
...config.output,
|
|
2586
|
-
format: false,
|
|
2587
|
-
lint: false,
|
|
2588
|
-
postGenerate: []
|
|
2589
|
-
},
|
|
2590
|
-
plugins: resolvedPlugins,
|
|
2591
|
-
adapter
|
|
2747
|
+
const disk = await runGenerationOperation({
|
|
2748
|
+
config: {
|
|
2749
|
+
...config,
|
|
2750
|
+
root,
|
|
2751
|
+
input: inputOverride ?? config.input,
|
|
2752
|
+
storage: this.#canWrite ? (0, _kubb_core.fsStorage)() : (0, _kubb_core.memoryStorage)(),
|
|
2753
|
+
output: permissions.allowExec ? { ...config.output } : {
|
|
2754
|
+
...config.output,
|
|
2755
|
+
format: false,
|
|
2756
|
+
lint: false,
|
|
2757
|
+
postGenerate: []
|
|
2592
2758
|
},
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
}
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2759
|
+
plugins: resolvedPlugins,
|
|
2760
|
+
adapter
|
|
2761
|
+
},
|
|
2762
|
+
hooks: this.#hooks,
|
|
2763
|
+
signal: controller.signal,
|
|
2764
|
+
jobId: data.jobId,
|
|
2765
|
+
store: this.#generations,
|
|
2766
|
+
snapshotRoot: this.#hasProjectOnDisk ? root : void 0,
|
|
2767
|
+
maxSnapshotMb: this.#limits.maxSnapshotMb
|
|
2768
|
+
});
|
|
2602
2769
|
await this.#hooks.callHook("studio:command:end", {
|
|
2603
2770
|
command,
|
|
2604
2771
|
info: `${resolvedPlugins.length} plugin${resolvedPlugins.length === 1 ? "" : "s"}, ${this.#canWrite ? "written to disk" : "in memory"}${inputOverride !== void 0 ? ", from a Studio spec" : ""}`
|
|
@@ -2630,6 +2797,7 @@ var StudioSession = class {
|
|
|
2630
2797
|
disk: disk ? { hashes: disk.hashes } : void 0
|
|
2631
2798
|
};
|
|
2632
2799
|
} finally {
|
|
2800
|
+
if (root !== this.#options.root) await removeJobRoot(root);
|
|
2633
2801
|
this.#isGenerating = false;
|
|
2634
2802
|
}
|
|
2635
2803
|
}
|
|
@@ -2659,8 +2827,10 @@ var StudioSession = class {
|
|
|
2659
2827
|
}
|
|
2660
2828
|
if (this.#isGenerating) return refuse("a generation is in progress");
|
|
2661
2829
|
try {
|
|
2662
|
-
const { source: patched, outcomes, changed } =
|
|
2663
|
-
|
|
2830
|
+
const { source: patched, outcomes, changed } = await writeConfigEdits({
|
|
2831
|
+
filePath: configFile,
|
|
2832
|
+
edits
|
|
2833
|
+
});
|
|
2664
2834
|
const applied = outcomes.filter((outcome) => outcome.applied).length;
|
|
2665
2835
|
await this.#hooks.callHook("studio:command:end", {
|
|
2666
2836
|
command,
|
|
@@ -2698,24 +2868,14 @@ var StudioSession = class {
|
|
|
2698
2868
|
version,
|
|
2699
2869
|
peerDependencies: generation.peerDependencies
|
|
2700
2870
|
});
|
|
2701
|
-
const { token, studioUrl } = this.#options;
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
});
|
|
2709
|
-
const storageUrl = redirect.headers.get("location");
|
|
2710
|
-
if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
|
|
2711
|
-
const storage = new URL(storageUrl);
|
|
2712
|
-
if (storage.protocol !== "https:" && storage.hostname !== "localhost" && storage.hostname !== "127.0.0.1") throw new Error(`Refusing snapshot upload to ${storage.origin}`);
|
|
2713
|
-
const response = await fetch(storage, {
|
|
2714
|
-
method: "PUT",
|
|
2715
|
-
body: new Uint8Array(bytes),
|
|
2716
|
-
redirect: "error"
|
|
2871
|
+
const { token, studioUrl, signal } = this.#options;
|
|
2872
|
+
await uploadSnapshot({
|
|
2873
|
+
bytes,
|
|
2874
|
+
uploadPath,
|
|
2875
|
+
studioUrl,
|
|
2876
|
+
token,
|
|
2877
|
+
shutdown: signal
|
|
2717
2878
|
});
|
|
2718
|
-
if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
|
|
2719
2879
|
await this.#hooks.callHook("studio:command:end", {
|
|
2720
2880
|
command,
|
|
2721
2881
|
info: `packed ${Object.keys(files).length} file${Object.keys(files).length === 1 ? "" : "s"}`
|
|
@@ -2729,10 +2889,7 @@ var StudioSession = class {
|
|
|
2729
2889
|
throw error;
|
|
2730
2890
|
}
|
|
2731
2891
|
}
|
|
2732
|
-
/**
|
|
2733
|
-
* An agent with a project on disk can show a run against what its output directory held before.
|
|
2734
|
-
* A sandbox agent has no project.
|
|
2735
|
-
*/
|
|
2892
|
+
/** A sandbox has no project directory to snapshot. */
|
|
2736
2893
|
get #hasProjectOnDisk() {
|
|
2737
2894
|
return !this.#isSandbox && this.#canRead;
|
|
2738
2895
|
}
|
|
@@ -2761,7 +2918,7 @@ var StudioSession = class {
|
|
|
2761
2918
|
}
|
|
2762
2919
|
};
|
|
2763
2920
|
//#endregion
|
|
2764
|
-
//#region src/client.ts
|
|
2921
|
+
//#region src/runtime/client.ts
|
|
2765
2922
|
/**
|
|
2766
2923
|
* Creates the Kubb Studio client: the connection, the command loop, and the generation event
|
|
2767
2924
|
* stream shared by the `kubb studio` CLI command and the Docker agent.
|
|
@@ -2777,7 +2934,7 @@ var StudioSession = class {
|
|
|
2777
2934
|
*/
|
|
2778
2935
|
function createClient({ onAuthRequired, ...options }) {
|
|
2779
2936
|
const controller = new AbortController();
|
|
2780
|
-
const
|
|
2937
|
+
const instanceId = options.instanceId ?? (0, node_crypto.randomUUID)();
|
|
2781
2938
|
function notifyAuthRequired(error) {
|
|
2782
2939
|
if (controller.signal.aborted) return;
|
|
2783
2940
|
controller.abort();
|
|
@@ -2785,19 +2942,12 @@ function createClient({ onAuthRequired, ...options }) {
|
|
|
2785
2942
|
}
|
|
2786
2943
|
return {
|
|
2787
2944
|
async connect() {
|
|
2788
|
-
|
|
2789
|
-
token: options.token,
|
|
2790
|
-
studioUrl: options.studioUrl ?? agentDefaults.studioUrl,
|
|
2791
|
-
poolSize
|
|
2792
|
-
});
|
|
2793
|
-
if (controller.signal.aborted) return;
|
|
2794
|
-
const startupWarning = registered ? void 0 : "Could not register with Kubb Studio, continuing";
|
|
2795
|
-
await Promise.all(Array.from({ length: poolSize }, (_, slot) => new StudioSession({
|
|
2945
|
+
await new StudioSession({
|
|
2796
2946
|
...options,
|
|
2947
|
+
instanceId,
|
|
2797
2948
|
signal: controller.signal,
|
|
2798
|
-
onTokenRejected: notifyAuthRequired
|
|
2799
|
-
|
|
2800
|
-
}).start()));
|
|
2949
|
+
onTokenRejected: notifyAuthRequired
|
|
2950
|
+
}).start();
|
|
2801
2951
|
},
|
|
2802
2952
|
disconnect() {
|
|
2803
2953
|
controller.abort();
|
|
@@ -2805,7 +2955,7 @@ function createClient({ onAuthRequired, ...options }) {
|
|
|
2805
2955
|
};
|
|
2806
2956
|
}
|
|
2807
2957
|
//#endregion
|
|
2808
|
-
//#region src/runConnection.ts
|
|
2958
|
+
//#region src/runtime/runConnection.ts
|
|
2809
2959
|
/**
|
|
2810
2960
|
* Waits for whichever comes first: the shutdown signal, or Studio rejecting the token during a
|
|
2811
2961
|
* background reconnect. Resolves with the rejection, or nothing when the run is being shut down.
|
|
@@ -2878,7 +3028,7 @@ async function runConnection({ credentials, clientOptions, onTokenRejected, sign
|
|
|
2878
3028
|
}
|
|
2879
3029
|
}
|
|
2880
3030
|
//#endregion
|
|
2881
|
-
//#region src/pair.ts
|
|
3031
|
+
//#region src/operations/pair.ts
|
|
2882
3032
|
/** Labels, not secrets: a person approving the code in the browser is what authorizes a pairing. */
|
|
2883
3033
|
const CLIENT_IDS = {
|
|
2884
3034
|
cli: "kubb-cli",
|
|
@@ -3001,6 +3151,9 @@ async function pairAgent({ onCode, onRetry, maxAttempts = 1, ...options }) {
|
|
|
3001
3151
|
}
|
|
3002
3152
|
}
|
|
3003
3153
|
//#endregion
|
|
3154
|
+
exports.AGENT_INSTANCE_HEADER = require_protocol.AGENT_INSTANCE_HEADER;
|
|
3155
|
+
exports.AgentCloseCode = require_protocol.AgentCloseCode;
|
|
3156
|
+
exports.IncompatibleAgentError = IncompatibleAgentError;
|
|
3004
3157
|
exports.InvalidAgentTokenError = InvalidAgentTokenError;
|
|
3005
3158
|
exports.PairingCanceledError = PairingCanceledError;
|
|
3006
3159
|
exports.PairingDeniedError = PairingDeniedError;
|