@kubb/studio 5.3.11 → 5.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { t as __name } from "./rolldown-runtime-CRm0XQPb.js";
2
- import { generationEventTypes } from "./protocol.js";
2
+ import { GENERATION_GONE_MESSAGE, generationEventTypes } from "./protocol.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { promisify, styleText } from "node:util";
5
5
  import process$1 from "node:process";
6
6
  import { spawn } from "node:child_process";
7
7
  import { glob, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
8
8
  import path, { isAbsolute, join, relative, resolve, sep } from "node:path";
9
- import { Diagnostics, Hookable, createKubb, fsStorage, logLevel, memoryStorage } from "@kubb/core";
9
+ import { Diagnostics, Hookable, cacheStorage, createKubb, fsStorage, logLevel, memoryStorage } from "@kubb/core";
10
10
  import { FetchError, ofetch } from "ofetch";
11
11
  import { createHash, hash, randomBytes } from "node:crypto";
12
12
  import { createStorage } from "unstorage";
@@ -44,8 +44,27 @@ const agentDefaults = {
44
44
  maxHeartbeatIntervalMs: 6e4,
45
45
  /** How long a heartbeat ping may take before the session is treated as dead. */
46
46
  heartbeatTimeoutMs: 1e4,
47
- poolSize: 1
47
+ poolSize: 1,
48
+ maxGenerations: 8,
49
+ maxGenerationsMb: 100,
50
+ maxSnapshotMb: 50
48
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
+ }
49
68
  //#endregion
50
69
  //#region ../../internals/utils/src/casing.ts
51
70
  /**
@@ -676,7 +695,7 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
676
695
  }
677
696
  //#endregion
678
697
  //#region package.json
679
- var version = "5.3.11";
698
+ var version = "5.3.13";
680
699
  //#endregion
681
700
  //#region src/hooks.ts
682
701
  /**
@@ -1765,6 +1784,116 @@ async function createSnapshotPackage(files, packageInfo) {
1765
1784
  }
1766
1785
  }
1767
1786
  //#endregion
1787
+ //#region src/generations.ts
1788
+ const READ_CONCURRENCY = 50;
1789
+ const MB = 1048576;
1790
+ const INDEX_KEY = "studio/generations.json";
1791
+ const hashOf = (content) => createHash("sha1").update(content).digest("hex").slice(0, 16);
1792
+ /**
1793
+ * What the output directory holds on disk before a run. `undefined` when `outputPath` is not a real
1794
+ * subdirectory of `root` (listing the root would take in every source file) or holds more than `maxFiles`.
1795
+ */
1796
+ async function listDisk({ root, outputPath, maxFiles }) {
1797
+ const outputDir = resolve(root, outputPath);
1798
+ const fromRoot = relative(resolve(root), outputDir);
1799
+ if (!fromRoot || fromRoot.startsWith("..") || fromRoot.startsWith(sep)) return void 0;
1800
+ const storage = fsStorage();
1801
+ const keys = await storage.readKeys(outputDir);
1802
+ if (keys.length > maxFiles) return void 0;
1803
+ const paths = keys.filter((key) => !key.split("/").includes("node_modules")).map((key) => relative(resolve(root), resolve(outputDir, key)).replaceAll("\\", "/"));
1804
+ return {
1805
+ storage,
1806
+ root,
1807
+ paths: new Set(paths)
1808
+ };
1809
+ }
1810
+ /**
1811
+ * Keeps recent generations by job id in `storage`, with an index next to them so a store on disk
1812
+ * survives a restart. A generation is only ever looked up by its job id, so on a pooled sandbox one
1813
+ * tenant never reaches another's output. The oldest go past `maxCount` or `maxMb`, but the newest
1814
+ * always stays.
1815
+ */
1816
+ function createGenerationStore({ storage, maxCount, maxMb }) {
1817
+ let index;
1818
+ const dirOf = (jobId) => `studio/generations/${hashOf(jobId)}/`;
1819
+ async function load() {
1820
+ if (index) return index;
1821
+ const stored = await storage.readItem(INDEX_KEY).catch(() => null);
1822
+ try {
1823
+ index = stored ? JSON.parse(stored) : [];
1824
+ } catch {
1825
+ index = [];
1826
+ }
1827
+ return index;
1828
+ }
1829
+ /**
1830
+ * Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
1831
+ */
1832
+ async function keep({ jobId, source, files, maxSetMb }) {
1833
+ const maxSetBytes = maxSetMb * MB;
1834
+ const hashes = {};
1835
+ let bytes = 0;
1836
+ await inParallel({
1837
+ items: [...files.paths],
1838
+ limit: READ_CONCURRENCY,
1839
+ run: async (path) => {
1840
+ if (path.split("/").includes("..")) return;
1841
+ const content = await files.storage.readItem(resolve(files.root, path));
1842
+ if (content === null) return;
1843
+ hashes[path] = hashOf(content);
1844
+ bytes += Buffer.byteLength(content);
1845
+ if (bytes <= maxSetBytes) await storage.writeItem(`${dirOf(jobId)}${source}/${path}`, content);
1846
+ }
1847
+ });
1848
+ if (bytes > maxSetBytes) {
1849
+ await storage.empty(`${dirOf(jobId)}${source}/`);
1850
+ return {
1851
+ paths: [],
1852
+ hashes,
1853
+ bytes: 0
1854
+ };
1855
+ }
1856
+ return {
1857
+ paths: Object.keys(hashes),
1858
+ hashes,
1859
+ bytes
1860
+ };
1861
+ }
1862
+ async function drop(jobId) {
1863
+ await storage.empty(dirOf(jobId));
1864
+ }
1865
+ return {
1866
+ keep,
1867
+ drop,
1868
+ get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId),
1869
+ latest: async () => (await load()).at(-1),
1870
+ async add(generation) {
1871
+ const entries = (await load()).filter((entry) => entry.jobId !== generation.jobId);
1872
+ entries.push(generation);
1873
+ const weight = () => entries.reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0);
1874
+ while (entries.length > 1 && (entries.length > maxCount || weight() > maxMb * MB)) await drop(entries.shift().jobId);
1875
+ index = entries;
1876
+ await storage.writeItem(INDEX_KEY, JSON.stringify(entries));
1877
+ },
1878
+ /**
1879
+ * Reads the requested paths the set holds, skipping any it does not.
1880
+ */
1881
+ async read({ generation, source, paths }) {
1882
+ const kept = new Set(generation[source]?.paths);
1883
+ const files = {};
1884
+ await inParallel({
1885
+ items: paths.filter((path) => kept.has(path)),
1886
+ limit: READ_CONCURRENCY,
1887
+ run: async (path) => {
1888
+ const content = await storage.readItem(`${dirOf(generation.jobId)}${source}/${path}`);
1889
+ if (content !== null) files[path] = content;
1890
+ }
1891
+ });
1892
+ return files;
1893
+ }
1894
+ };
1895
+ }
1896
+ //#endregion
1768
1897
  //#region src/ws.ts
1769
1898
  /**
1770
1899
  * How long the initial handshake may take before the socket is closed and the reconnect loop
@@ -1775,12 +1904,6 @@ const require = createRequire(import.meta.url);
1775
1904
  function relativeStoragePath(root, filePath) {
1776
1905
  return (isAbsolute(filePath) ? relative(resolve(root), filePath) : filePath).replaceAll("\\", "/");
1777
1906
  }
1778
- /**
1779
- * Inverse of {@link relativeStoragePath}: rebuilds the storage key a relative path came from.
1780
- */
1781
- function absoluteStoragePath(root, relativePath) {
1782
- return resolve(root, relativePath);
1783
- }
1784
1907
  async function resolvePeerDependencies(names) {
1785
1908
  const uniqueNames = [...new Set(names.map(toPackageName))];
1786
1909
  const peerDependencies = {};
@@ -1907,9 +2030,11 @@ function createGenerationStream(hooks, jobId, options = {}) {
1907
2030
  const keys = await storage.readKeys();
1908
2031
  const paths = new Set(keys.map((key) => relativeStoragePath(config.root, key)));
1909
2032
  options.onGenerationEnd?.({
1910
- storage,
1911
- root: config.root,
1912
- paths,
2033
+ output: {
2034
+ storage,
2035
+ root: config.root,
2036
+ paths
2037
+ },
1913
2038
  peerDependencies,
1914
2039
  missingDependencies
1915
2040
  });
@@ -2060,9 +2185,9 @@ const connectWebSocketRpc = async ({ url, token, local }) => {
2060
2185
  //#endregion
2061
2186
  //#region src/StudioSession.ts
2062
2187
  /**
2063
- * How many files are read from storage at once when serving `readFiles` or packing a snapshot.
2188
+ * Past this many files in the output directory, no snapshot of it is taken before a run.
2064
2189
  */
2065
- const FILE_READ_CONCURRENCY = 50;
2190
+ const DISK_SNAPSHOT_MAX_FILES = 1e4;
2066
2191
  var GenerationRunTarget = class extends RpcTarget {
2067
2192
  generationStream;
2068
2193
  generationResult;
@@ -2161,6 +2286,8 @@ var StudioSession = class {
2161
2286
  #isGenerating = false;
2162
2287
  #heartbeatTimer;
2163
2288
  #lastGeneration;
2289
+ #store;
2290
+ #limits = resolveGenerationLimits();
2164
2291
  /**
2165
2292
  * Resolves when Studio calls {@link StudioSession.connect}. `studio:ready` waits on this so the
2166
2293
  * host does not queue jobs before the agent session is registered.
@@ -2176,6 +2303,18 @@ var StudioSession = class {
2176
2303
  get #isSandbox() {
2177
2304
  return this.#session?.isSandbox === true;
2178
2305
  }
2306
+ /**
2307
+ * Kept in the project's cache directory, so it survives a restart, except on a sandbox: its pool
2308
+ * sessions run every tenant's jobs, so it keeps them in memory. Looked up by job id only.
2309
+ */
2310
+ get #generations() {
2311
+ this.#store ??= createGenerationStore({
2312
+ storage: this.#isSandbox ? memoryStorage() : cacheStorage({ root: this.#options.root }),
2313
+ maxCount: this.#limits.maxCount,
2314
+ maxMb: this.#limits.maxMb
2315
+ });
2316
+ return this.#store;
2317
+ }
2179
2318
  get #canWrite() {
2180
2319
  return !this.#isSandbox && this.#options.permissions.allowWrite;
2181
2320
  }
@@ -2386,6 +2525,17 @@ var StudioSession = class {
2386
2525
  }
2387
2526
  const resolvedPlugins = plugins ?? config.plugins;
2388
2527
  this.#lastGeneration = void 0;
2528
+ const diskFiles = this.#hasProjectOnDisk ? await listDisk({
2529
+ root,
2530
+ outputPath: config.output.path,
2531
+ maxFiles: DISK_SNAPSHOT_MAX_FILES
2532
+ }) : void 0;
2533
+ const disk = diskFiles ? await this.#generations.keep({
2534
+ jobId: data.jobId,
2535
+ source: "disk",
2536
+ files: diskFiles,
2537
+ maxSetMb: this.#limits.maxSnapshotMb
2538
+ }) : void 0;
2389
2539
  const detach = [setupHookListener(this.#hooks, root, controller.signal)];
2390
2540
  try {
2391
2541
  await generate({
@@ -2406,6 +2556,9 @@ var StudioSession = class {
2406
2556
  hooks: this.#hooks,
2407
2557
  signal: controller.signal
2408
2558
  });
2559
+ } catch (error) {
2560
+ await this.#generations.drop(data.jobId);
2561
+ throw error;
2409
2562
  } finally {
2410
2563
  for (const remove of detach) remove();
2411
2564
  }
@@ -2413,11 +2566,31 @@ var StudioSession = class {
2413
2566
  command,
2414
2567
  info: `${resolvedPlugins.length} plugin${resolvedPlugins.length === 1 ? "" : "s"}, ${this.#canWrite ? "written to disk" : "in memory"}${inputOverride !== void 0 ? ", from a Studio spec" : ""}`
2415
2568
  });
2416
- const files = [...this.#lastGeneration?.paths ?? []];
2569
+ const generation = this.#lastGeneration;
2570
+ const output = generation ? await this.#generations.keep({
2571
+ jobId: data.jobId,
2572
+ source: "output",
2573
+ files: generation.output,
2574
+ maxSetMb: this.#limits.maxMb
2575
+ }) : void 0;
2576
+ if (generation && output) {
2577
+ if (!output.paths.length && Object.keys(output.hashes).length) await this.#warn("Kept only the hashes of this generation: its output is too large to keep");
2578
+ const { peerDependencies, missingDependencies } = generation;
2579
+ await this.#generations.add({
2580
+ jobId: data.jobId,
2581
+ output,
2582
+ disk,
2583
+ peerDependencies,
2584
+ missingDependencies
2585
+ });
2586
+ }
2587
+ const files = [...generation?.output.paths ?? []];
2417
2588
  return {
2418
2589
  status: "success",
2419
2590
  files,
2420
- fileCount: files.length
2591
+ fileCount: files.length,
2592
+ hashes: output?.hashes ?? {},
2593
+ disk: disk ? { hashes: disk.hashes } : void 0
2421
2594
  };
2422
2595
  } finally {
2423
2596
  this.#isGenerating = false;
@@ -2472,20 +2645,16 @@ var StudioSession = class {
2472
2645
  if (this.#isSandbox) return this.#refuse("Ignored snapshot: a sandbox agent has no project to build a package from", "A sandbox agent has no project to build a package from");
2473
2646
  const { name, version, bundledDependencies, uploadPath } = data;
2474
2647
  if (!name || !version || !uploadPath) return this.#refuse("Ignored snapshot: the message was missing required fields", "The request was missing required fields");
2475
- const generation = this.#lastGeneration;
2648
+ const generation = await this.#generations.latest();
2476
2649
  if (!generation) return this.#refuse("Ignored snapshot: no prior generation to pack", "No prior generation exists to pack, run a generation first");
2477
2650
  const bundled = new Set(bundledDependencies ?? []);
2478
2651
  const missing = generation.missingDependencies.filter((dependency) => !bundled.has(dependency));
2479
2652
  if (missing.length) return this.#refuse(`Ignored snapshot: missing dependencies: ${missing.join(", ")}`, `Missing dependencies: ${missing.join(", ")}`);
2480
2653
  try {
2481
- const files = {};
2482
- await inParallel({
2483
- items: [...generation.paths],
2484
- limit: FILE_READ_CONCURRENCY,
2485
- run: async (relativePath) => {
2486
- const content = await generation.storage.readItem(absoluteStoragePath(generation.root, relativePath));
2487
- if (content !== null) files[relativePath] = content;
2488
- }
2654
+ const files = await this.#generations.read({
2655
+ generation,
2656
+ source: "output",
2657
+ paths: generation.output.paths
2489
2658
  });
2490
2659
  const { bytes, integrity } = await createSnapshotPackage(files, {
2491
2660
  name,
@@ -2523,6 +2692,13 @@ var StudioSession = class {
2523
2692
  throw error;
2524
2693
  }
2525
2694
  }
2695
+ /**
2696
+ * An agent with a project on disk can show a run against what its output directory held before.
2697
+ * A sandbox agent has no project.
2698
+ */
2699
+ get #hasProjectOnDisk() {
2700
+ return !this.#isSandbox && this.#canRead;
2701
+ }
2526
2702
  async readFiles(data) {
2527
2703
  const command = "readFiles";
2528
2704
  await this.#hooks.callHook("studio:command:start", { command });
@@ -2535,17 +2711,15 @@ var StudioSession = class {
2535
2711
  if (!Array.isArray(data.paths)) return this.#refuse("Ignored files: the message carried no paths", "The request carried no paths");
2536
2712
  const { paths } = data;
2537
2713
  if (paths.length > 50) return this.#refuse(`Ignored files: requested ${paths.length} paths, more than the 50 allowed per request`, `At most 50 paths may be requested at once`);
2538
- const generation = this.#lastGeneration;
2539
- if (!generation) return this.#refuse("Ignored files: no prior generation to read from", "No prior generation to read from, run a generation first");
2540
- const requested = paths.filter((path) => generation.paths.has(path));
2541
- const files = {};
2542
- await inParallel({
2543
- items: requested,
2544
- limit: FILE_READ_CONCURRENCY,
2545
- run: async (path) => {
2546
- const content = await generation.storage.readItem(absoluteStoragePath(generation.root, path));
2547
- if (content !== null) files[path] = content;
2548
- }
2714
+ if (typeof data.jobId !== "string" || !data.jobId) return this.#refuse("Ignored files: the message named no job", "The request named no generation job");
2715
+ const generation = await this.#generations.get(data.jobId);
2716
+ if (!generation) return this.#refuse(`Ignored files: job ${data.jobId} is not kept on this agent`, GENERATION_GONE_MESSAGE);
2717
+ const source = data.source === "disk" ? "disk" : "output";
2718
+ if (!generation[source]) return this.#refuse("Ignored files: that job has no snapshot of the files on disk", "This agent kept no snapshot of the files on disk for that job");
2719
+ const files = await this.#generations.read({
2720
+ generation,
2721
+ source,
2722
+ paths
2549
2723
  });
2550
2724
  await this.#hooks.callHook("studio:command:end", {
2551
2725
  command,