@kubb/studio 5.11.2 → 5.11.4

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 CHANGED
@@ -85,9 +85,9 @@ running in a developer's project and a container running a fixed plugin set.
85
85
  the agent is a sandbox and carries its slug and Studio's version. From then on the agent pings every
86
86
  30 seconds, and Studio schedules one job at a time on the socket.
87
87
 
88
- With `allowRead`, the connect payload also includes a kept copy of the current `output.path` files.
89
- Studio can browse these files through `readFiles` before running a generation. Sandbox agents have
90
- no disk output, and output paths outside the project root are excluded.
88
+ With `allowRead`, the connect payload includes the current `output.path` file list. Studio reads
89
+ file contents from disk through `readFiles` when a user opens a file. Sandbox agents have no disk
90
+ output, and output paths outside the project root are excluded.
91
91
 
92
92
  Studio ends a socket on purpose with one of these close codes:
93
93
 
package/dist/index.cjs CHANGED
@@ -23,7 +23,6 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  //#endregion
24
24
  const require_protocol = require("./protocol.cjs");
25
25
  let node_crypto = require("node:crypto");
26
- let node_fs_promises = require("node:fs/promises");
27
26
  let node_os = require("node:os");
28
27
  let node_path = require("node:path");
29
28
  node_path = __toESM(node_path, 1);
@@ -32,6 +31,7 @@ node_process = __toESM(node_process, 1);
32
31
  let node_timers_promises = require("node:timers/promises");
33
32
  let node_url = require("node:url");
34
33
  let node_fs = require("node:fs");
34
+ let node_fs_promises = require("node:fs/promises");
35
35
  let _kubb_core = require("@kubb/core");
36
36
  let capnweb = require("capnweb");
37
37
  let magicast = require("magicast");
@@ -141,6 +141,27 @@ async function read(path) {
141
141
  return (0, node_fs_promises.readFile)(path, { encoding: "utf8" });
142
142
  }
143
143
  /**
144
+ * Writes UTF-8 content verbatim, including empty files and surrounding whitespace.
145
+ * Uses Bun.write under Bun and creates missing parent directories under either runtime.
146
+ *
147
+ * @example
148
+ * await writeRaw('./cache/output.ts', ' // keep these bytes\n\n')
149
+ */
150
+ async function writeRaw(path, content) {
151
+ const resolved = (0, node_path.resolve)(path);
152
+ if (runtime.isBun) {
153
+ await Bun.write(resolved, content);
154
+ return;
155
+ }
156
+ try {
157
+ await (0, node_fs_promises.writeFile)(resolved, content, { encoding: "utf-8" });
158
+ } catch (error) {
159
+ if (error.code !== "ENOENT") throw error;
160
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(resolved), { recursive: true });
161
+ await (0, node_fs_promises.writeFile)(resolved, content, { encoding: "utf-8" });
162
+ }
163
+ }
164
+ /**
144
165
  * Resolves to `true` when `path` is `parent` itself or nested inside it. Both sides are resolved
145
166
  * to absolute paths first, so relative and `..`-containing inputs compare correctly.
146
167
  *
@@ -199,7 +220,7 @@ function getElapsedMs(hrStart) {
199
220
  }
200
221
  //#endregion
201
222
  //#region package.json
202
- var version = "5.11.2";
223
+ var version = "5.11.4";
203
224
  //#endregion
204
225
  //#region src/resolveConfig.ts
205
226
  /**
@@ -1395,21 +1416,20 @@ const hashOf = (content) => (0, node_crypto.createHash)("sha1").update(content).
1395
1416
  /**
1396
1417
  * The bytes `entries` keep on disk: output, written files, and kept input.
1397
1418
  */
1398
- const totalBytes = (entries) => entries.reduce((sum, { output, disk, inputBytes }) => sum + output.bytes + (disk?.bytes ?? 0) + (inputBytes ?? 0), 0);
1419
+ const totalBytes = (entries) => entries.reduce((sum, { output, inputBytes }) => sum + output.bytes + (inputBytes ?? 0), 0);
1399
1420
  function positiveNumber(value) {
1400
1421
  const parsed = Number(value);
1401
1422
  return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1402
1423
  }
1403
1424
  /**
1404
1425
  * How many generations an agent keeps and how large they may get, read from
1405
- * `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
1426
+ * `KUBB_AGENT_MAX_GENERATIONS` and `KUBB_AGENT_MAX_GENERATIONS_MB`.
1406
1427
  * An unset or invalid value keeps the default.
1407
1428
  */
1408
1429
  function resolveGenerationLimits() {
1409
1430
  return {
1410
1431
  maxCount: Math.max(1, Math.floor(positiveNumber(node_process.default.env.KUBB_AGENT_MAX_GENERATIONS) ?? 8)),
1411
- maxMb: positiveNumber(node_process.default.env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? 100,
1412
- maxSnapshotMb: positiveNumber(node_process.default.env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? 50
1432
+ maxMb: positiveNumber(node_process.default.env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? 100
1413
1433
  };
1414
1434
  }
1415
1435
  /**
@@ -1456,7 +1476,7 @@ async function generate({ config, hooks, signal, allowExec }) {
1456
1476
  };
1457
1477
  }
1458
1478
  /**
1459
- * What the output directory holds on disk before a run. `undefined` when `outputPath` is not a real
1479
+ * What the output directory holds when the editor connects. `undefined` when `outputPath` is not a real
1460
1480
  * subdirectory of `root` (listing the root would take in every source file) or holds more than `maxFiles`.
1461
1481
  */
1462
1482
  async function listDisk({ root, outputPath, maxFiles }) {
@@ -1476,8 +1496,8 @@ async function listDisk({ root, outputPath, maxFiles }) {
1476
1496
  /**
1477
1497
  * Keeps recent generations by job id in `storage`, with an index next to them so a store on disk
1478
1498
  * survives a restart. A generation is only ever looked up by its job id, so on a pooled sandbox one
1479
- * tenant never reaches another's output. The oldest go past `maxCount` or `maxMb`, but the newest
1480
- * always stays.
1499
+ * tenant never reaches another's output. Initial disk output and generated jobs each get their
1500
+ * own `maxCount` and `maxMb` budget. Each budget always keeps its newest entry.
1481
1501
  */
1482
1502
  function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now }) {
1483
1503
  let index;
@@ -1496,7 +1516,7 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1496
1516
  /**
1497
1517
  * Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
1498
1518
  */
1499
- async function keep({ jobId, source, files, maxSetMb, normalize = source === require_protocol.FileSource.OUTPUT }) {
1519
+ async function keep({ jobId, files, maxSetMb }) {
1500
1520
  const maxSetBytes = maxSetMb * MB;
1501
1521
  const hashes = {};
1502
1522
  let bytes = 0;
@@ -1507,14 +1527,13 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1507
1527
  if (path.split("/").includes("..")) return;
1508
1528
  const content = await files.storage.readItem((0, node_path.resolve)(files.root, path));
1509
1529
  if (content === null) return;
1510
- if (normalize && !content.trim()) return;
1511
- hashes[path] = hashOf(normalize ? `${content.trim()}\n` : content);
1530
+ hashes[path] = hashOf(content);
1512
1531
  bytes += Buffer.byteLength(content);
1513
- if (bytes <= maxSetBytes) await storage.writeItem(`${dirOf(jobId)}${source}/${path}`, content);
1532
+ if (bytes <= maxSetBytes) await storage.writeItem(`${dirOf(jobId)}output/${path}`, content);
1514
1533
  }
1515
1534
  });
1516
1535
  if (bytes > maxSetBytes) {
1517
- await storage.empty(`${dirOf(jobId)}${source}/`);
1536
+ await storage.empty(`${dirOf(jobId)}output/`);
1518
1537
  return {
1519
1538
  paths: [],
1520
1539
  hashes,
@@ -1530,10 +1549,50 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1530
1549
  async function drop(jobId) {
1531
1550
  await storage.empty(dirOf(jobId));
1532
1551
  }
1552
+ async function add(generation) {
1553
+ const current = await load();
1554
+ for (const expired of current.filter((entry) => !isLive(entry))) await drop(expired.jobId);
1555
+ const live = current.filter((entry) => entry.jobId !== generation.jobId && isLive(entry));
1556
+ const sharesBudget = (entry) => Boolean(entry.initial) === Boolean(generation.initial);
1557
+ const entries = live.filter(sharesBudget);
1558
+ entries.push({
1559
+ ...generation,
1560
+ keptAt: now()
1561
+ });
1562
+ while (entries.length > 1 && (entries.length > maxCount || totalBytes(entries) > maxMb * MB)) await drop(entries.shift().jobId);
1563
+ index = [...live.filter((entry) => !sharesBudget(entry)), ...entries];
1564
+ await storage.writeItem(INDEX_KEY, JSON.stringify(index));
1565
+ }
1533
1566
  return {
1534
1567
  keep,
1535
1568
  drop,
1536
1569
  /**
1570
+ * Captures initial disk output, reusing a retained ID when its hashes and readable paths match.
1571
+ * A temporary candidate is discarded on reuse, so reconnects do not consume retention slots.
1572
+ */
1573
+ async keepInitial(files) {
1574
+ const jobId = (0, node_crypto.randomUUID)();
1575
+ const output = await keep({
1576
+ jobId,
1577
+ files,
1578
+ maxSetMb: maxMb
1579
+ });
1580
+ const hashes = Object.entries(output.hashes);
1581
+ const previous = (await load()).findLast((entry) => entry.initial && isLive(entry) && entry.output.paths.length === output.paths.length && Object.keys(entry.output.hashes).length === hashes.length && hashes.every(([path, hash]) => entry.output.hashes[path] === hash));
1582
+ if (previous) {
1583
+ await drop(jobId);
1584
+ await add(previous);
1585
+ return previous;
1586
+ }
1587
+ const generation = {
1588
+ jobId,
1589
+ output,
1590
+ initial: true
1591
+ };
1592
+ await add(generation);
1593
+ return generation;
1594
+ },
1595
+ /**
1537
1596
  * Keeps the `InputNode` of `jobId` and returns its size in bytes, or 0 when it keeps none. A ref schema loses
1538
1597
  * the copy of its target, which `input.schemas` holds: with it, a large spec outgrows what `JSON.stringify` can return.
1539
1598
  */
@@ -1561,30 +1620,19 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1561
1620
  return stored === null ? void 0 : JSON.parse(stored);
1562
1621
  },
1563
1622
  get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId && isLive(generation)),
1564
- async add(generation) {
1565
- const current = await load();
1566
- for (const expired of current.filter((entry) => !isLive(entry))) await drop(expired.jobId);
1567
- const entries = current.filter((entry) => entry.jobId !== generation.jobId && isLive(entry));
1568
- entries.push({
1569
- ...generation,
1570
- keptAt: now()
1571
- });
1572
- while (entries.length > 1 && (entries.length > maxCount || totalBytes(entries) > maxMb * MB)) await drop(entries.shift().jobId);
1573
- index = entries;
1574
- await storage.writeItem(INDEX_KEY, JSON.stringify(entries));
1575
- },
1623
+ add,
1576
1624
  /**
1577
1625
  * Reads the requested paths the set holds, skipping any it does not, one page of lines when `limit` is given.
1578
1626
  */
1579
- async read({ generation, source, paths, cursor = 0, limit }) {
1580
- const kept = new Set(generation[source]?.paths);
1627
+ async read({ generation, paths, cursor = 0, limit }) {
1628
+ const kept = new Set(generation.output.paths);
1581
1629
  const files = {};
1582
1630
  const pages = {};
1583
1631
  await inParallel({
1584
1632
  items: paths.filter((path) => kept.has(path)),
1585
1633
  limit: READ_CONCURRENCY,
1586
1634
  run: async (path) => {
1587
- const content = await storage.readItem(`${dirOf(generation.jobId)}${source}/${path}`);
1635
+ const content = await storage.readItem(`${dirOf(generation.jobId)}output/${path}`);
1588
1636
  if (content === null) return;
1589
1637
  if (limit === void 0) {
1590
1638
  files[path] = content;
@@ -1859,6 +1907,25 @@ function isValidPage({ cursor, limit }) {
1859
1907
  if (cursor !== void 0 && (!Number.isSafeInteger(cursor) || cursor < 0)) return false;
1860
1908
  return Number.isSafeInteger(limit) && limit >= 1 && limit <= 2e3;
1861
1909
  }
1910
+ /**
1911
+ * Maps generated paths into a temporary directory while keeping the project root for input resolution.
1912
+ */
1913
+ function stagingStorage({ root, directory }) {
1914
+ const storage = (0, _kubb_core.fsStorage)();
1915
+ const target = (key) => {
1916
+ const relative = node_path.default.relative(root, node_path.default.resolve(root, key));
1917
+ if (relative === ".." || relative.startsWith(`..${node_path.default.sep}`) || node_path.default.isAbsolute(relative)) throw new Error("Output path escapes the project root");
1918
+ return node_path.default.join(directory, relative);
1919
+ };
1920
+ return {
1921
+ name: "fs",
1922
+ readItem: (key) => storage.readItem(target(key)),
1923
+ writeItem: (key, value) => storage.writeItem(target(key), value),
1924
+ removeItem: (key) => storage.removeItem(target(key)),
1925
+ readKeys: (base) => storage.readKeys(base ? target(base) : directory),
1926
+ empty: (base) => base ? storage.empty(target(base)) : Promise.resolve()
1927
+ };
1928
+ }
1862
1929
  var GenerationRunTarget = class extends capnweb.RpcTarget {
1863
1930
  #run;
1864
1931
  constructor(run) {
@@ -1883,6 +1950,7 @@ var Agent = class extends capnweb.RpcTarget {
1883
1950
  #session;
1884
1951
  #limits = resolveGenerationLimits();
1885
1952
  #store;
1953
+ #sandboxCacheRoot = node_path.default.join((0, node_os.tmpdir)(), `kubb-studio-${(0, node_crypto.randomUUID)()}`);
1886
1954
  #diffs = /* @__PURE__ */ new Map();
1887
1955
  constructor({ options, session }) {
1888
1956
  super();
@@ -1904,13 +1972,19 @@ var Agent = class extends capnweb.RpcTarget {
1904
1972
  }
1905
1973
  get #generations() {
1906
1974
  this.#store ??= createGenerationStore({
1907
- storage: this.#sandbox ? (0, _kubb_core.memoryStorage)() : (0, _kubb_core.cacheStorage)({ root: this.#options.root }),
1975
+ storage: (0, _kubb_core.cacheStorage)({ root: this.#sandbox ? this.#sandboxCacheRoot : this.#options.root }),
1908
1976
  maxCount: this.#limits.maxCount,
1909
1977
  maxMb: this.#limits.maxMb,
1910
1978
  ttlMs: this.#sandbox ? SANDBOX_GENERATION_TTL_MS : void 0
1911
1979
  });
1912
1980
  return this.#store;
1913
1981
  }
1982
+ async [Symbol.asyncDispose]() {
1983
+ if (this.#sandbox) await (0, node_fs_promises.rm)((0, _kubb_core.resolveCacheDir)(this.#sandboxCacheRoot), {
1984
+ recursive: true,
1985
+ force: true
1986
+ });
1987
+ }
1914
1988
  #warn({ message, permission }) {
1915
1989
  return emit(this.#options.hooks, "studio:warn", {
1916
1990
  message,
@@ -2022,18 +2096,7 @@ var Agent = class extends capnweb.RpcTarget {
2022
2096
  maxFiles: DISK_SNAPSHOT_MAX_FILES
2023
2097
  });
2024
2098
  if (!files) return void 0;
2025
- const jobId = (0, node_crypto.randomUUID)();
2026
- const output = await this.#generations.keep({
2027
- jobId,
2028
- source: require_protocol.FileSource.OUTPUT,
2029
- files,
2030
- maxSetMb: this.#limits.maxSnapshotMb,
2031
- normalize: false
2032
- });
2033
- await this.#generations.add({
2034
- jobId,
2035
- output
2036
- });
2099
+ const { jobId, output } = await this.#generations.keepInitial(files);
2037
2100
  return {
2038
2101
  jobId,
2039
2102
  files: output.paths,
@@ -2057,7 +2120,9 @@ var Agent = class extends capnweb.RpcTarget {
2057
2120
  }
2058
2121
  });
2059
2122
  }
2060
- /** Runs one job and keeps its output, and its input with `allowRead`, for later reads. */
2123
+ /**
2124
+ * Keeps a job's output and permitted parsed input, then cleans up its temporary generation directories.
2125
+ */
2061
2126
  async #runGeneration(input, controller) {
2062
2127
  this.#assertConnected();
2063
2128
  if (this.#session.closed) throw new _kubb_core.Diagnostics.Error({
@@ -2065,7 +2130,7 @@ var Agent = class extends capnweb.RpcTarget {
2065
2130
  severity: "error",
2066
2131
  message: "Connection is closed"
2067
2132
  });
2068
- if (this.#session.active) return this.#refuse({ message: "A generation is already in progress, please wait for it to finish" });
2133
+ if (this.#session.active) return this.#refuse({ message: require_protocol.AgentMessage.GENERATION_IN_PROGRESS });
2069
2134
  const settled = Promise.withResolvers();
2070
2135
  this.#session.active = {
2071
2136
  jobId: input.jobId,
@@ -2073,11 +2138,14 @@ var Agent = class extends capnweb.RpcTarget {
2073
2138
  settled: settled.promise
2074
2139
  };
2075
2140
  let root = this.#options.root;
2141
+ let outputRoot;
2076
2142
  try {
2077
2143
  if (this.#sandbox) root = await (0, node_fs_promises.mkdtemp)(node_path.default.join((0, node_os.tmpdir)(), "kubb-job-"));
2144
+ if (!this.#sandbox && !this.#permissions.allowWrite) outputRoot = await (0, node_fs_promises.mkdtemp)(node_path.default.join((0, node_os.tmpdir)(), "kubb-output-"));
2078
2145
  return await this.#generate({
2079
2146
  input,
2080
2147
  root,
2148
+ outputRoot,
2081
2149
  signal: controller.signal
2082
2150
  });
2083
2151
  } finally {
@@ -2088,15 +2156,21 @@ var Agent = class extends capnweb.RpcTarget {
2088
2156
  recursive: true,
2089
2157
  force: true
2090
2158
  })]).catch(() => {});
2159
+ if (outputRoot) await (0, node_fs_promises.rm)(outputRoot, {
2160
+ recursive: true,
2161
+ force: true
2162
+ });
2091
2163
  this.#session.active = void 0;
2092
2164
  settled.resolve();
2093
2165
  }
2094
2166
  }
2095
- /** Merges Studio's config over the disk config, refuses output paths outside the output folder, then generates in `root`. */
2096
- async #generate({ input, root, signal }) {
2167
+ /**
2168
+ * Merges Studio's config over the project config and confines generation to the configured output folder.
2169
+ */
2170
+ async #generate({ input, root, outputRoot, signal }) {
2097
2171
  const { jobId, config: patch } = input;
2098
2172
  const studioInput = this.#sandbox ? patch?.input : void 0;
2099
- if (this.#sandbox && (typeof studioInput !== "string" || !studioInput.trim() || (0, _kubb_core.getInputKind)(studioInput) !== "inline")) return this.#refuse({ message: "A sandbox agent generates only from the OpenAPI document content Studio sends, not from a file path or URL" });
2173
+ if (this.#sandbox && (typeof studioInput !== "string" || !studioInput.trim() || (0, _kubb_core.getInputKind)(studioInput) !== "inline")) return this.#refuse({ message: require_protocol.AgentMessage.SANDBOX_INPUT_ONLY });
2100
2174
  const [config, extensions] = await Promise.all([this.#options.loadConfig(), this.#installedExtensions()]);
2101
2175
  const [plugins, adapter, parsers] = await Promise.all([
2102
2176
  mergePlugins({
@@ -2124,37 +2198,28 @@ var Agent = class extends capnweb.RpcTarget {
2124
2198
  });
2125
2199
  const activePlugins = plugins ?? config.plugins;
2126
2200
  if (patch?.input && !this.#sandbox) await emit(this.#options.hooks, "kubb:warn", { message: "Ignored the spec from Studio: generating from a Studio spec is only available to a sandbox agent" });
2127
- const diskFiles = !this.#sandbox && this.#permissions.allowRead ? await listDisk({
2128
- root,
2129
- outputPath: config.output.path,
2130
- maxFiles: DISK_SNAPSHOT_MAX_FILES
2131
- }) : void 0;
2132
- const disk = diskFiles ? await this.#generations.keep({
2133
- jobId,
2134
- source: require_protocol.FileSource.DISK,
2135
- files: diskFiles,
2136
- maxSetMb: this.#limits.maxSnapshotMb
2137
- }) : void 0;
2138
2201
  const files = await generate({
2139
2202
  config: {
2140
2203
  ...config,
2141
2204
  root,
2142
2205
  input: studioInput ?? config.input,
2143
- storage: this.#permissions.allowWrite ? (0, _kubb_core.fsStorage)() : (0, _kubb_core.memoryStorage)(),
2206
+ storage: outputRoot ? stagingStorage({
2207
+ root,
2208
+ directory: outputRoot
2209
+ }) : (0, _kubb_core.fsStorage)(),
2144
2210
  plugins: activePlugins,
2145
2211
  adapter,
2146
2212
  parsers
2147
2213
  },
2148
2214
  hooks: this.#options.hooks,
2149
2215
  signal,
2150
- allowExec: this.#permissions.allowExec
2216
+ allowExec: this.#permissions.allowWrite && this.#permissions.allowExec
2151
2217
  }).catch(async (error) => {
2152
2218
  await this.#generations.drop(jobId);
2153
2219
  throw error;
2154
2220
  });
2155
2221
  const output = await this.#generations.keep({
2156
2222
  jobId,
2157
- source: require_protocol.FileSource.OUTPUT,
2158
2223
  files,
2159
2224
  maxSetMb: this.#limits.maxMb
2160
2225
  });
@@ -2167,16 +2232,14 @@ var Agent = class extends capnweb.RpcTarget {
2167
2232
  await this.#generations.add({
2168
2233
  jobId,
2169
2234
  output,
2170
- disk,
2171
2235
  inputBytes
2172
2236
  });
2173
- await this.#logCommand("generate", `${activePlugins.length} plugin${activePlugins.length === 1 ? "" : "s"}, ${this.#permissions.allowWrite ? "written to disk" : "in memory"}${studioInput !== void 0 ? ", from a Studio spec" : ""}`);
2237
+ await this.#logCommand("generate", `${activePlugins.length} plugin${activePlugins.length === 1 ? "" : "s"}, ${this.#permissions.allowWrite ? "written to disk" : "staged on disk"}${studioInput !== void 0 ? ", from a Studio spec" : ""}`);
2174
2238
  return {
2175
2239
  status: "success",
2176
2240
  files: [...files.paths],
2177
2241
  fileCount: files.paths.size,
2178
- hashes: output.hashes,
2179
- disk: disk ? { hashes: disk.hashes } : void 0
2242
+ hashes: output.hashes
2180
2243
  };
2181
2244
  }
2182
2245
  async saveConfig(input) {
@@ -2229,7 +2292,7 @@ var Agent = class extends capnweb.RpcTarget {
2229
2292
  message: require_protocol.AgentMessage.READ_AST_NOT_ALLOWED,
2230
2293
  permission: require_protocol.AgentPermission.READ_AST
2231
2294
  });
2232
- if (typeof input.jobId !== "string" || !input.jobId) return this.#refuse({ message: "The request must name a job" });
2295
+ if (typeof input.jobId !== "string" || !input.jobId) return this.#refuse({ message: require_protocol.AgentMessage.JOB_REQUIRED });
2233
2296
  const generation = await this.#generations.get(input.jobId);
2234
2297
  if (!generation) return this.#refuse({ message: require_protocol.AgentMessage.GENERATION_GONE });
2235
2298
  const kept = generation.inputBytes ? await this.#generations.readInput(input.jobId) : void 0;
@@ -2246,17 +2309,14 @@ var Agent = class extends capnweb.RpcTarget {
2246
2309
  message: require_protocol.AgentMessage.READ_NOT_ALLOWED,
2247
2310
  permission: require_protocol.AgentPermission.READ
2248
2311
  });
2249
- if (!Array.isArray(input.paths) || typeof input.jobId !== "string" || !input.jobId) return this.#refuse({ message: "The request must name a job and carry paths" });
2312
+ if (!Array.isArray(input.paths) || typeof input.jobId !== "string" || !input.jobId) return this.#refuse({ message: require_protocol.AgentMessage.FILE_REQUEST_INVALID });
2250
2313
  const { paths } = input;
2251
- if (paths.length > 50) return this.#refuse({ message: `At most 50 paths may be requested at once` });
2314
+ if (paths.length > 50) return this.#refuse({ message: require_protocol.AgentMessage.TOO_MANY_FILES });
2315
+ if (!isValidPage(input)) return this.#refuse({ message: require_protocol.AgentMessage.INVALID_PAGE });
2252
2316
  const generation = await this.#generations.get(input.jobId);
2253
2317
  if (!generation) return this.#refuse({ message: require_protocol.AgentMessage.GENERATION_GONE });
2254
- if (!isValidPage(input)) return this.#refuse({ message: `The cursor must be a whole line from 0 and come with a limit of 1 to ${require_protocol.MAX_PAGE_LINES} lines` });
2255
- const source = input.source === require_protocol.FileSource.DISK ? require_protocol.FileSource.DISK : require_protocol.FileSource.OUTPUT;
2256
- if (!generation[source]) return this.#refuse({ message: "This agent kept no snapshot of the files on disk for that job" });
2257
2318
  const result = await this.#generations.read({
2258
2319
  generation,
2259
- source,
2260
2320
  paths,
2261
2321
  cursor: input.cursor,
2262
2322
  limit: input.limit
@@ -2274,26 +2334,22 @@ var Agent = class extends capnweb.RpcTarget {
2274
2334
  permission: require_protocol.AgentPermission.READ
2275
2335
  });
2276
2336
  const { jobId, path, base, cursor = 0, limit } = input;
2277
- if (typeof jobId !== "string" || !jobId || typeof path !== "string" || !path || typeof base?.jobId !== "string" || !base.jobId) return this.#refuse({ message: "The request must name a job, a base, and a path" });
2337
+ if (typeof jobId !== "string" || !jobId || typeof path !== "string" || !path || typeof base?.jobId !== "string" || !base.jobId) return this.#refuse({ message: require_protocol.AgentMessage.DIFF_REQUEST_INVALID });
2278
2338
  if (!isValidPage({
2279
2339
  cursor,
2280
2340
  limit
2281
- })) return this.#refuse({ message: `The cursor must be a whole line from 0 and come with a limit of 1 to ${require_protocol.MAX_PAGE_LINES} lines` });
2341
+ })) return this.#refuse({ message: require_protocol.AgentMessage.INVALID_PAGE });
2282
2342
  const [generation, baseGeneration] = await Promise.all([this.#generations.get(jobId), this.#generations.get(base.jobId)]);
2283
2343
  if (!generation || !baseGeneration) return this.#refuse({ message: require_protocol.AgentMessage.GENERATION_GONE });
2284
- const baseSource = base.source === require_protocol.FileSource.DISK ? require_protocol.FileSource.DISK : require_protocol.FileSource.OUTPUT;
2285
- if (!baseGeneration[baseSource]) return this.#refuse({ message: "This agent kept no snapshot of the files on disk for that job" });
2286
2344
  const [after, before] = await Promise.all([this.#generations.read({
2287
2345
  generation,
2288
- source: require_protocol.FileSource.OUTPUT,
2289
2346
  paths: [path]
2290
2347
  }), this.#generations.read({
2291
2348
  generation: baseGeneration,
2292
- source: baseSource,
2293
2349
  paths: [path]
2294
2350
  })]);
2295
2351
  const afterContent = after.files[path];
2296
- if (afterContent === void 0 && path in generation.output.hashes) return this.#refuse({ message: "This agent kept only the hashes of that job" });
2352
+ if (afterContent === void 0 && path in generation.output.hashes) return this.#refuse({ message: require_protocol.AgentMessage.HASHES_ONLY });
2297
2353
  const beforeContent = before.files[path];
2298
2354
  if (beforeContent === void 0) return {
2299
2355
  hunks: [],
@@ -2310,7 +2366,6 @@ var Agent = class extends capnweb.RpcTarget {
2310
2366
  const key = JSON.stringify([
2311
2367
  jobId,
2312
2368
  base.jobId,
2313
- baseSource,
2314
2369
  path
2315
2370
  ]);
2316
2371
  let diff = this.#diffs.get(key);
@@ -2367,14 +2422,15 @@ async function runSession({ options, openRpc, url, instanceId }) {
2367
2422
  connected: Promise.withResolvers(),
2368
2423
  closed: false
2369
2424
  };
2425
+ const agent = new Agent({
2426
+ options,
2427
+ session
2428
+ });
2370
2429
  const rpc = openRpc({
2371
2430
  url,
2372
2431
  token,
2373
2432
  instanceId,
2374
- agent: new Agent({
2375
- options,
2376
- session
2377
- })
2433
+ agent
2378
2434
  });
2379
2435
  signal?.addEventListener("abort", rpc.close, { once: true });
2380
2436
  let timer;
@@ -2409,6 +2465,7 @@ async function runSession({ options, openRpc, url, instanceId }) {
2409
2465
  await emit(hooks, "studio:warn", { message: "Canceled the running generation: the connection to Kubb Studio closed" });
2410
2466
  }
2411
2467
  await active?.settled;
2468
+ await agent[Symbol.asyncDispose]();
2412
2469
  return {
2413
2470
  ...close,
2414
2471
  connected: session.info !== void 0
@@ -2718,8 +2775,7 @@ async function createSnapshotPackage({ files, packageInfo }) {
2718
2775
  try {
2719
2776
  await (0, node_fs_promises.mkdir)(dist);
2720
2777
  await Promise.all([...sources].map(async ([path, { content }]) => {
2721
- await (0, node_fs_promises.mkdir)((0, node_path.dirname)((0, node_path.join)(root, path)), { recursive: true });
2722
- await (0, node_fs_promises.writeFile)((0, node_path.join)(root, path), content);
2778
+ await writeRaw((0, node_path.join)(root, path), content);
2723
2779
  }));
2724
2780
  const entry = [...sources.keys()].filter((path) => /\.(?:[cm]?[jt]sx?)$/.test(path)).map((path) => (0, node_path.join)(root, path));
2725
2781
  if (entry.length) {
@@ -2759,7 +2815,7 @@ async function createSnapshotPackage({ files, packageInfo }) {
2759
2815
  const chunks = [...new Map([
2760
2816
  ["package.json", JSON.stringify(manifest, null, 2)],
2761
2817
  ...[...sources].map(([path, { content }]) => [path, content]),
2762
- ...await Promise.all(built.map(async (key) => [`dist/${key}`, await (0, node_fs_promises.readFile)((0, node_path.join)(dist, key), "utf8")]))
2818
+ ...await Promise.all(built.map(async (key) => [`dist/${key}`, await read((0, node_path.join)(dist, key))]))
2763
2819
  ])].flatMap(([path, content]) => {
2764
2820
  const bytes = Buffer.from(content);
2765
2821
  return [
@@ -2790,7 +2846,10 @@ async function createSnapshot({ studioUrl = defaultStudioUrl, apiKey, config, ho
2790
2846
  path: "/api/snapshots"
2791
2847
  });
2792
2848
  const generated = await generate({
2793
- config,
2849
+ config: {
2850
+ ...config,
2851
+ storage: (0, _kubb_core.fsStorage)()
2852
+ },
2794
2853
  hooks,
2795
2854
  signal,
2796
2855
  allowExec: true