withruntime 0.23.0 → 0.33.0

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.
Files changed (76) hide show
  1. package/COMPATIBILITY.md +64 -3
  2. package/E2B.md +1 -1
  3. package/README.md +36 -2
  4. package/README.src.md +35 -1
  5. package/compatibility-lock.json +3 -3
  6. package/dist/agents/browser.d.ts +12 -0
  7. package/dist/agents/browser.js +12 -0
  8. package/dist/agents/index.d.ts +7 -7
  9. package/dist/agents/index.js +7 -8
  10. package/dist/agents-cli.d.ts +1 -1
  11. package/dist/agents-cli.js +250 -3
  12. package/dist/agents-deploy.d.ts +186 -0
  13. package/dist/agents-deploy.js +752 -0
  14. package/dist/agents-file.d.ts +19 -0
  15. package/dist/agents-file.js +135 -0
  16. package/dist/apps/index.d.ts +10 -0
  17. package/dist/apps/index.js +11 -0
  18. package/dist/apps-cli.d.ts +3 -1
  19. package/dist/apps-cli.js +285 -38
  20. package/dist/blaxel/errors.js +2 -1
  21. package/dist/ci/index.d.ts +10 -0
  22. package/dist/ci/index.js +11 -0
  23. package/dist/ci-cli.d.ts +10 -0
  24. package/dist/ci-cli.js +343 -0
  25. package/dist/cli.d.ts +18 -7
  26. package/dist/cli.js +775 -85
  27. package/dist/daytona/errors.js +2 -1
  28. package/dist/e2b/errors.js +2 -1
  29. package/dist/e2b/filesystem.d.ts +1 -0
  30. package/dist/e2b/filesystem.js +1 -1
  31. package/dist/e2b/sandbox.d.ts +11 -2
  32. package/dist/e2b/sandbox.js +22 -6
  33. package/dist/errors.d.ts +11 -4
  34. package/dist/errors.js +27 -4
  35. package/dist/fly/index.d.ts +102 -0
  36. package/dist/fly/index.js +930 -0
  37. package/dist/index.d.ts +7 -3
  38. package/dist/machine.d.ts +10 -6
  39. package/dist/machine.js +14 -6
  40. package/dist/modal/index.d.ts +34 -0
  41. package/dist/modal/index.js +128 -22
  42. package/dist/products/agents.d.ts +101 -3
  43. package/dist/products/agents.js +49 -0
  44. package/dist/products/apps.d.ts +206 -10
  45. package/dist/products/apps.js +172 -1
  46. package/dist/products/ci.d.ts +332 -0
  47. package/dist/products/ci.js +101 -0
  48. package/dist/products/index.d.ts +8 -0
  49. package/dist/products/index.js +15 -0
  50. package/dist/products/jobs.d.ts +105 -5
  51. package/dist/products/jobs.js +47 -3
  52. package/dist/products/network.d.ts +13 -0
  53. package/dist/products/observability.d.ts +1 -1
  54. package/dist/products/plans.d.ts +131 -0
  55. package/dist/products/plans.js +24 -0
  56. package/dist/products/reservations.d.ts +108 -0
  57. package/dist/products/reservations.js +41 -0
  58. package/dist/products/resources.d.ts +2 -1
  59. package/dist/products/secrets.d.ts +90 -6
  60. package/dist/products/secrets.js +8 -2
  61. package/dist/proxy.d.ts +3 -3
  62. package/dist/proxy.js +20 -0
  63. package/dist/sandbox.d.ts +29 -1
  64. package/dist/sandbox.js +56 -3
  65. package/dist/snapshots.d.ts +1 -1
  66. package/dist/switched-off.d.ts +5 -0
  67. package/dist/switched-off.js +8 -0
  68. package/dist/tar.d.ts +1 -1
  69. package/dist/tar.js +19 -1
  70. package/dist/transport.d.ts +1 -1
  71. package/dist/transport.js +48 -9
  72. package/dist/types.d.ts +35 -4
  73. package/dist/vercel/errors.d.ts +1 -1
  74. package/dist/vercel/errors.js +2 -1
  75. package/dist/vercel/snapshot.js +4 -2
  76. package/package.json +23 -6
@@ -0,0 +1,752 @@
1
+ import { RuntimeError } from "./errors.js";
2
+ import { AGENT_TEMPLATES, agentTemplate, AGENT_USES, } from "./products/agents.js";
3
+ /** The label a deploy puts on the agents it makes; prune deletes only these. */
4
+ export const MANAGED_LABEL = { "managed-by": "runtime.yml" };
5
+ /** What an agent gets when the file leaves its size out (agents-contract.md). */
6
+ const SIZE = { vcpu: 4, memoryMiB: 8192 };
7
+ /** What the API writes in when a definition leaves these out: a field the
8
+ * file leaves out matches the agent's when the agent holds this. */
9
+ const SERVER_DEFAULTS = {
10
+ idlePauseSeconds: 60,
11
+ maxSessionSeconds: 86400,
12
+ maxConcurrent: 10,
13
+ maxSessionDollars: 5,
14
+ maxDayDollars: 100,
15
+ memory: { scope: "key" },
16
+ };
17
+ const DEFINITION_FIELDS = [
18
+ "from",
19
+ "vcpu",
20
+ "memoryMiB",
21
+ "command",
22
+ "instructions",
23
+ "env",
24
+ "secrets",
25
+ "mcp",
26
+ "volumes",
27
+ "idlePauseSeconds",
28
+ "maxSessionSeconds",
29
+ "maxConcurrent",
30
+ "maxSessionDollars",
31
+ "maxDayDollars",
32
+ "memory",
33
+ ];
34
+ const FILE_FIELDS = ["template", "use", "instructionsFile", "triggers", ...DEFINITION_FIELDS];
35
+ const TRIGGER_KINDS = ["slack", "github", "schedule", "webhook"];
36
+ const RESERVED = new Set(["sessions", "triggers", "approvals", "templates"]);
37
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
38
+ const SHELL_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
39
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
40
+ const bytes = (text) => new TextEncoder().encode(text).length;
41
+ /** The file's agents, checked as the API checks them, with every mistake
42
+ * named by file, agent and field (and line, when the source knows it).
43
+ * Throws a RuntimeError `invalid_file` listing them all. */
44
+ export function checkAgentsFile(input, options = {}) {
45
+ const source = sourceOf(input, options);
46
+ const issues = [];
47
+ const say = (path, message) => issues.push({
48
+ agent: typeof path[1] === "string" ? path[1] : null,
49
+ field: path.length > 2 ? path.slice(2).join(".") : null,
50
+ path,
51
+ message,
52
+ });
53
+ const desired = [];
54
+ const root = source.config;
55
+ // The whole of it first, by a walk that stops at the cap: one value
56
+ // reached by many paths counts each time, so nothing larger is walked,
57
+ // copied or hashed.
58
+ if (!within(root, FILE_MAX_BYTES))
59
+ say([], "is over 256 KiB; keep long instructions in a file named by instructionsFile.");
60
+ else if (!isObject(root))
61
+ say([], "is not a map: write `agents:` and your agents under it.");
62
+ else {
63
+ for (const key of Object.keys(root))
64
+ if (key !== "agents")
65
+ say([key], `is not a section runtime.yml takes${hint(key, ["agents"])}`);
66
+ const agents = root.agents;
67
+ if (agents === undefined || agents === null)
68
+ say(["agents"], "is missing: name your agents under `agents:`.");
69
+ else if (!isObject(agents))
70
+ say(["agents"], "takes a map of names to agents.");
71
+ else
72
+ for (const [name, entry] of Object.entries(agents)) {
73
+ const made = checkAgent(name, entry, source, say);
74
+ if (made)
75
+ desired.push(made);
76
+ }
77
+ }
78
+ if (issues.length) {
79
+ const lines = issues.map((i) => {
80
+ const line = source.line?.(i.path);
81
+ const where = `${source.file}${line ? `:${line}` : ""}`;
82
+ const dotted = i.path.length ? `${i.path.join(".")}` : "";
83
+ return `${where}: ${dotted}${dotted ? " " : ""}${i.message}`;
84
+ });
85
+ throw new RuntimeError({
86
+ message: lines.join("\n"),
87
+ code: "invalid_file",
88
+ status: 0,
89
+ hint: "Fix the file and run the command again; nothing was changed.",
90
+ details: {
91
+ file: source.file,
92
+ issues: issues.map((i) => ({
93
+ agent: i.agent,
94
+ field: i.field,
95
+ line: source.line?.(i.path) ?? null,
96
+ message: i.message,
97
+ })),
98
+ },
99
+ });
100
+ }
101
+ return desired;
102
+ }
103
+ function checkAgent(name, entry, source, say) {
104
+ const at = (...rest) => ["agents", name, ...rest];
105
+ const before = { n: 0 };
106
+ const note = (path, message) => {
107
+ before.n++;
108
+ say(path, message);
109
+ };
110
+ if (!NAME.test(name))
111
+ note(at(), "is not an agent name: letters, digits, '.', '_' and '-', starting with a letter or digit, at most 128.");
112
+ else if (RESERVED.has(name.toLowerCase()))
113
+ note(at(), `may not be an agent's name: /v1/agents/${name} is another route.`);
114
+ if (!isObject(entry)) {
115
+ note(at(), "takes a map: template, command, instructions, triggers and the rest.");
116
+ return null;
117
+ }
118
+ for (const key of Object.keys(entry))
119
+ if (!FILE_FIELDS.includes(key))
120
+ note(at(key), `is not a field an agent takes${hint(key, FILE_FIELDS)}`);
121
+ const def = {};
122
+ const template = entry.template;
123
+ if (template !== undefined) {
124
+ if (typeof template !== "string" || !(template in AGENT_TEMPLATES))
125
+ note(at("template"), `takes ${Object.keys(AGENT_TEMPLATES).join(", ")}.`);
126
+ }
127
+ const use = entry.use;
128
+ if (use !== undefined && use !== "support" && use !== "team")
129
+ note(at("use"), "takes support or team.");
130
+ if (use === "support" || use === "team")
131
+ Object.assign(def, AGENT_USES[use].definition);
132
+ const found = typeof template === "string" ? agentTemplate(template) : undefined;
133
+ if (found) {
134
+ const t = found.definition;
135
+ def.command = t.command;
136
+ def.secrets = [...t.secrets];
137
+ }
138
+ const integer = (field, min, max) => {
139
+ const v = entry[field];
140
+ if (v === undefined)
141
+ return;
142
+ if (typeof v !== "number" || !Number.isInteger(v) || v < min || v > max)
143
+ note(at(field), `takes a whole number from ${min} to ${max}.`);
144
+ else
145
+ def[field] = v;
146
+ };
147
+ const dollars = (field, max) => {
148
+ const v = entry[field];
149
+ if (v === undefined)
150
+ return;
151
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0.01 || v > max)
152
+ note(at(field), `takes dollars from 0.01 to ${max}.`);
153
+ else
154
+ def[field] = v;
155
+ };
156
+ if (entry.command !== undefined) {
157
+ const v = entry.command;
158
+ if (typeof v !== "string" || v.length < 1 || v.length > 8192)
159
+ note(at("command"), "takes the command that runs the harness, 1 to 8192 characters.");
160
+ else
161
+ def.command = v;
162
+ }
163
+ if (entry.instructions !== undefined && entry.instructionsFile !== undefined)
164
+ note(at("instructionsFile"), "and instructions both give the instructions: keep one.");
165
+ if (entry.instructions !== undefined) {
166
+ const v = entry.instructions;
167
+ if (typeof v !== "string" || bytes(v) > 65536)
168
+ note(at("instructions"), "takes text, at most 64 KiB.");
169
+ else
170
+ def.instructions = v;
171
+ }
172
+ else if (entry.instructionsFile !== undefined) {
173
+ const v = entry.instructionsFile;
174
+ if (typeof v !== "string" ||
175
+ !v ||
176
+ /^([\\/]|[A-Za-z]:)/.test(v) ||
177
+ v.split(/[\\/]/).includes(".."))
178
+ note(at("instructionsFile"), "takes a path inside the repo, relative to runtime.yml, such as AGENTS.md.");
179
+ else if (!source.readFile)
180
+ note(at("instructionsFile"), "is read from the repo by `runtime agents deploy` (or loadAgentsFile); here, put the words in instructions.");
181
+ else {
182
+ let text;
183
+ try {
184
+ text = source.readFile(v);
185
+ }
186
+ catch {
187
+ note(at("instructionsFile"), `names ${v}, which cannot be read.`);
188
+ }
189
+ if (text !== undefined) {
190
+ if (bytes(text) > 65536)
191
+ note(at("instructionsFile"), `names ${v}, which is over 64 KiB.`);
192
+ else
193
+ def.instructions = text;
194
+ }
195
+ }
196
+ }
197
+ if (entry.from !== undefined) {
198
+ const v = entry.from;
199
+ const keys = isObject(v) ? Object.keys(v) : [];
200
+ if (!isObject(v) ||
201
+ keys.length !== 1 ||
202
+ !((typeof v.image === "string" && /^[A-Za-z0-9][A-Za-z0-9_./:@-]{0,255}$/.test(v.image)) ||
203
+ (typeof v.snapshot === "string" && UUID.test(v.snapshot))))
204
+ note(at("from"), "takes { image: name[:tag] } or { snapshot: <snapshot id> }.");
205
+ else
206
+ def.from = { ...v };
207
+ }
208
+ const snapshot = isObject(def.from) && "snapshot" in def.from;
209
+ if (entry.vcpu !== undefined) {
210
+ if (![1, 2, 4, 8, 16].includes(entry.vcpu))
211
+ note(at("vcpu"), "takes 1, 2, 4, 8 or 16.");
212
+ else
213
+ def.vcpu = entry.vcpu;
214
+ }
215
+ if (entry.memoryMiB !== undefined) {
216
+ const v = entry.memoryMiB;
217
+ if (typeof v !== "number" || !Number.isInteger(v) || v % 256 || v < 512 || v > 65536)
218
+ note(at("memoryMiB"), "takes 512 to 65536, a multiple of 256.");
219
+ else
220
+ def.memoryMiB = v;
221
+ }
222
+ if (snapshot) {
223
+ for (const field of ["vcpu", "memoryMiB"]) {
224
+ if (entry[field] !== undefined)
225
+ note(at(field), "cannot be set with from.snapshot: a snapshot keeps the size it was taken at.");
226
+ delete def[field];
227
+ }
228
+ }
229
+ else {
230
+ def.vcpu ??= SIZE.vcpu;
231
+ def.memoryMiB ??= SIZE.memoryMiB;
232
+ }
233
+ if (entry.env !== undefined) {
234
+ const v = entry.env;
235
+ if (!isObject(v) || Object.keys(v).length > 64)
236
+ note(at("env"), "takes a map of at most 64 names to values.");
237
+ else {
238
+ for (const [k, value] of Object.entries(v)) {
239
+ if (!SHELL_NAME.test(k))
240
+ note(at("env", k), "is not a variable name.");
241
+ else if (typeof value !== "string")
242
+ note(at("env", k), `takes text: quote it, as ${k}: "${String(value)}".`);
243
+ else if (bytes(value) > 8192)
244
+ note(at("env", k), "is over 8 KiB.");
245
+ }
246
+ def.env = { ...v };
247
+ }
248
+ }
249
+ if (entry.secrets !== undefined) {
250
+ const v = entry.secrets;
251
+ if (!Array.isArray(v) || v.length > 32)
252
+ note(at("secrets"), "takes a list of at most 32 egress secrets' names.");
253
+ else {
254
+ v.forEach((s, i) => {
255
+ if (typeof s !== "string" || !SHELL_NAME.test(s))
256
+ note(at("secrets", i), "is not a secret's name, such as ANTHROPIC_API_KEY.");
257
+ });
258
+ def.secrets = [...new Set(v)];
259
+ }
260
+ }
261
+ if (entry.mcp !== undefined) {
262
+ const v = entry.mcp;
263
+ if (!Array.isArray(v) || v.length > 16)
264
+ note(at("mcp"), "takes a list of at most 16 { name, url }.");
265
+ else {
266
+ v.forEach((m, i) => {
267
+ if (!isObject(m) ||
268
+ Object.keys(m).some((k) => k !== "name" && k !== "url") ||
269
+ typeof m.name !== "string" ||
270
+ !/^[a-z][a-z0-9_-]{0,63}$/.test(m.name) ||
271
+ typeof m.url !== "string" ||
272
+ !/^https:\/\/\S+$/.test(m.url) ||
273
+ m.url.length > 2048)
274
+ note(at("mcp", i), "takes { name: lowercase-name, url: https://... }.");
275
+ });
276
+ def.mcp = v;
277
+ }
278
+ }
279
+ if (entry.volumes !== undefined) {
280
+ const v = entry.volumes;
281
+ if (!Array.isArray(v) || v.length > 4)
282
+ note(at("volumes"), "takes a list of at most 4 { volume, path }.");
283
+ else {
284
+ v.forEach((m, i) => {
285
+ if (!isObject(m) ||
286
+ Object.keys(m).some((k) => k !== "volume" && k !== "path") ||
287
+ typeof m.volume !== "string" ||
288
+ !UUID.test(m.volume) ||
289
+ typeof m.path !== "string" ||
290
+ !/^\/[A-Za-z0-9._/-]{0,255}$/.test(m.path) ||
291
+ m.path.includes(".."))
292
+ note(at("volumes", i), "takes { volume: <volume id>, path: /absolute/path }.");
293
+ });
294
+ def.volumes = v;
295
+ }
296
+ }
297
+ if (entry.memory !== undefined) {
298
+ const v = entry.memory;
299
+ if (!isObject(v) ||
300
+ Object.keys(v).some((k) => k !== "scope") ||
301
+ (v.scope !== "key" && v.scope !== "agent"))
302
+ note(at("memory"), "takes { scope: key } (each session key its own, the default) or { scope: agent } (one all its sessions share).");
303
+ else if (v.scope === "agent" && def.template === "support")
304
+ note(at("memory", "scope"), "may not be agent for a support agent: one customer's session would read another's.");
305
+ else
306
+ def.memory = { scope: v.scope };
307
+ }
308
+ integer("idlePauseSeconds", 10, 86400);
309
+ integer("maxSessionSeconds", 60, 604800);
310
+ integer("maxConcurrent", 1, 1000);
311
+ dollars("maxSessionDollars", 10000);
312
+ dollars("maxDayDollars", 100000);
313
+ if (def.command === undefined)
314
+ note(at("command"), `is missing: give template (${Object.keys(AGENT_TEMPLATES).join(", ")}) or command.`);
315
+ const triggers = [];
316
+ if (entry.triggers !== undefined && entry.triggers !== null) {
317
+ if (!Array.isArray(entry.triggers))
318
+ note(at("triggers"), "takes a list of { kind, config }.");
319
+ else {
320
+ const seen = new Set();
321
+ entry.triggers.forEach((t, i) => {
322
+ const made = checkTrigger(t, (rest, message) => note(at("triggers", i, ...rest), message));
323
+ if (!made)
324
+ return;
325
+ const id = triggerIdentity(made);
326
+ if (seen.has(id))
327
+ note(at("triggers", i), "is the same trigger as one above: name it once.");
328
+ seen.add(id);
329
+ triggers.push(made);
330
+ });
331
+ }
332
+ }
333
+ if (!before.n && !within(def, DEFINITION_MAX_BYTES))
334
+ note(at(), "is over 128 KiB as the API takes it: shorten its instructions, env or lists.");
335
+ if (before.n)
336
+ return null;
337
+ return { name, definition: def, triggers };
338
+ }
339
+ function checkTrigger(t, note) {
340
+ if (!isObject(t)) {
341
+ note([], "takes { kind, config }.");
342
+ return null;
343
+ }
344
+ let ok = true;
345
+ const bad = (rest, message) => {
346
+ ok = false;
347
+ note(rest, message);
348
+ };
349
+ for (const key of Object.keys(t))
350
+ if (!["kind", "config", "paused"].includes(key))
351
+ bad([key], `is not a field a trigger takes${hint(key, ["kind", "config", "paused"])}`);
352
+ const kind = t.kind;
353
+ if (!TRIGGER_KINDS.includes(kind)) {
354
+ bad(["kind"], `takes ${TRIGGER_KINDS.join(", ")}.`);
355
+ return null;
356
+ }
357
+ const config = t.config ?? (kind === "webhook" ? {} : undefined);
358
+ if (!isObject(config)) {
359
+ bad(["config"], `is missing: ${CONFIGS[kind]}.`);
360
+ return null;
361
+ }
362
+ // Only the documented keys, each checked: what is hashed into a key and
363
+ // sent is never more than these.
364
+ const str = (v, re) => typeof v === "string" && re.test(v);
365
+ const allowed = TRIGGER_KEYS[kind];
366
+ const stray = Object.keys(config).filter((k) => !allowed.includes(k));
367
+ for (const k of stray)
368
+ bad(["config", k], `is not a key a ${kind} trigger takes${hint(k, allowed)}`);
369
+ if (!stray.length &&
370
+ ((kind === "slack" &&
371
+ !(str(config.team, /^T[A-Z0-9]{2,31}$/) && str(config.channel, /^[CG][A-Z0-9]{2,31}$/))) ||
372
+ (kind === "github" &&
373
+ !(str(config.repository, /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/) &&
374
+ typeof config.installation === "number" &&
375
+ Number.isSafeInteger(config.installation) &&
376
+ config.installation > 0 &&
377
+ (config.events === undefined ||
378
+ (Array.isArray(config.events) &&
379
+ config.events.length <= 20 &&
380
+ config.events.every((e) => str(e, /^[a-z_]{1,64}(\.[a-z_]{1,64})?$/)))))) ||
381
+ (kind === "schedule" &&
382
+ !(typeof config.cron === "string" &&
383
+ config.cron.length >= 9 &&
384
+ config.cron.length <= 128 &&
385
+ (config.timezone === undefined || str(config.timezone, /^[A-Za-z0-9_+\-/]{1,64}$/)) &&
386
+ typeof config.input === "string" &&
387
+ config.input.length >= 1 &&
388
+ config.input.length <= 32768))))
389
+ bad(["config"], `takes ${CONFIGS[kind]}.`);
390
+ const paused = t.paused;
391
+ if (paused !== undefined &&
392
+ (typeof paused !== "string" || paused.length < 1 || paused.length > 1024))
393
+ bad(["paused"], "takes the words a held trigger says, 1 to 1024 characters.");
394
+ if (ok && !within(config, TRIGGER_MAX_BYTES))
395
+ bad(["config"], "is over 40 KiB.");
396
+ if (!ok)
397
+ return null;
398
+ return { kind, config, ...(typeof paused === "string" ? { paused } : {}) };
399
+ }
400
+ const TRIGGER_KEYS = {
401
+ slack: ["team", "channel"],
402
+ github: ["installation", "repository", "events"],
403
+ schedule: ["cron", "timezone", "input"],
404
+ webhook: [],
405
+ };
406
+ const CONFIGS = {
407
+ slack: "{ team: T…, channel: C… } (Runtime's Slack app installed in your workspace)",
408
+ github: "{ installation: <id>, repository: owner/name, events?: up to 20 such as pull_request } (Runtime's GitHub App)",
409
+ schedule: '{ cron: "0 9 * * 1-5", timezone?: America/Denver, input: "what to do" }',
410
+ webhook: "{} or nothing",
411
+ };
412
+ /** Makes the account's agents match the file: the plan, then (unless
413
+ * dryRun) each change in order. A failed change stops the deploy; what was
414
+ * done stays done, and running it again finishes the rest. */
415
+ export async function deployAgents(port, input, options = {}) {
416
+ const source = sourceOf(input, options);
417
+ const desired = checkAgentsFile(source, options);
418
+ const plan = await planAgents(port, desired, options);
419
+ const result = { file: source.file, dryRun: options.dryRun === true, ...plan };
420
+ if (options.dryRun)
421
+ return result;
422
+ const ids = new Map();
423
+ for (const change of plan.changes) {
424
+ await apply(port, change, ids);
425
+ options.onChange?.(change);
426
+ }
427
+ return result;
428
+ }
429
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
430
+ /** What a deploy would change, reading the account through `port`. */
431
+ export async function planAgents(port, desired, options = {}) {
432
+ const changes = [];
433
+ const unchanged = [];
434
+ const leftAlone = [];
435
+ const current = await port.list();
436
+ const byName = new Map(current.map((a) => [a.name, a]));
437
+ for (const want of desired) {
438
+ const have = byName.get(want.name);
439
+ const before = changes.length;
440
+ if (!have) {
441
+ changes.push({
442
+ action: "create",
443
+ agent: want.name,
444
+ definition: want.definition,
445
+ key: await keyOf("create", want.name, want.definition),
446
+ text: `create agent ${want.name} (${describe(want.definition)})`,
447
+ });
448
+ for (const [i, trigger] of want.triggers.entries())
449
+ changes.push({
450
+ action: "add-trigger",
451
+ agent: want.name,
452
+ agentId: null,
453
+ trigger,
454
+ key: await keyOf("add-trigger", want.name, "new", i, trigger),
455
+ text: `add trigger ${triggerText(trigger)} to ${want.name}`,
456
+ });
457
+ continue;
458
+ }
459
+ const fields = changedFields(have.definition, want.definition);
460
+ if (fields.length)
461
+ changes.push({
462
+ action: "update",
463
+ agent: want.name,
464
+ agentId: have.id,
465
+ fromVersion: have.version,
466
+ fields,
467
+ definition: want.definition,
468
+ key: await keyOf("update", have.id, have.version, want.definition),
469
+ text: `update agent ${want.name}: ${fields.join(", ")} (v${have.version} -> v${have.version + 1})`,
470
+ });
471
+ const triggers = await port.triggers(have.id);
472
+ const state = triggers.map((t) => t.id).sort();
473
+ const open = [...triggers];
474
+ const adds = [];
475
+ for (const trigger of want.triggers) {
476
+ const at = open.findIndex((t) => triggerIdentity(t) === triggerIdentity(trigger));
477
+ if (at < 0) {
478
+ adds.push(trigger);
479
+ continue;
480
+ }
481
+ const [match] = open.splice(at, 1);
482
+ if (trigger.paused !== undefined && match.pausedWhy !== trigger.paused)
483
+ changes.push({
484
+ action: "pause-trigger",
485
+ agent: want.name,
486
+ triggerId: match.id,
487
+ why: trigger.paused,
488
+ key: await keyOf("pause-trigger", match.id, trigger.paused, match.updatedAt),
489
+ text: `pause trigger ${match.id} (${triggerText(trigger)}) of ${want.name}: ${trigger.paused}`,
490
+ });
491
+ else if (trigger.paused === undefined && match.pausedAt !== null)
492
+ changes.push({
493
+ action: "resume-trigger",
494
+ agent: want.name,
495
+ triggerId: match.id,
496
+ key: await keyOf("resume-trigger", match.id, match.updatedAt),
497
+ text: `resume trigger ${match.id} (${triggerText(trigger)}) of ${want.name}`,
498
+ });
499
+ }
500
+ for (const gone of open)
501
+ changes.push({
502
+ action: "remove-trigger",
503
+ agent: want.name,
504
+ triggerId: gone.id,
505
+ key: await keyOf("remove-trigger", gone.id),
506
+ text: `remove trigger ${gone.id} (${triggerText(gone)}) of ${want.name}`,
507
+ });
508
+ // The account's triggers as they stand are in the key: a trigger removed
509
+ // and named again later is a new trigger, never a replay of the old one.
510
+ for (const [i, trigger] of adds.entries())
511
+ changes.push({
512
+ action: "add-trigger",
513
+ agent: want.name,
514
+ agentId: have.id,
515
+ trigger,
516
+ key: await keyOf("add-trigger", have.id, state, i, trigger),
517
+ text: `add trigger ${triggerText(trigger)} to ${want.name}`,
518
+ });
519
+ if (changes.length === before)
520
+ unchanged.push(want.name);
521
+ }
522
+ const named = new Set(desired.map((d) => d.name));
523
+ for (const agent of current) {
524
+ if (named.has(agent.name))
525
+ continue;
526
+ const managed = agent.labels?.["managed-by"] === MANAGED_LABEL["managed-by"];
527
+ // Deleting an agent deletes its memory, for good: often its users' own
528
+ // notes. A renamed or dropped agent that still holds some is kept unless
529
+ // the caller says the files may go too.
530
+ const files = managed && options.prune ? await port.memoryFiles(agent.id) : 0;
531
+ if (managed && options.prune && files > 0 && !options.pruneMemory)
532
+ leftAlone.push({
533
+ agent: agent.name,
534
+ why: `not in the file, and it holds ${plural(files, "memory file")}; --prune-memory deletes it and them, for good`,
535
+ });
536
+ else if (managed && options.prune)
537
+ changes.push({
538
+ action: "delete",
539
+ agent: agent.name,
540
+ agentId: agent.id,
541
+ key: await keyOf("delete", agent.id),
542
+ text: `delete agent ${agent.name} (not in the file; its triggers${files ? `, its ${plural(files, "memory file")}` : ""} and its open sessions go)`,
543
+ });
544
+ else if (managed)
545
+ leftAlone.push({ agent: agent.name, why: "not in the file; --prune deletes it" });
546
+ else if (options.prune)
547
+ leftAlone.push({
548
+ agent: agent.name,
549
+ why: "made outside a runtime.yml, so --prune keeps it; `runtime agents rm` deletes it",
550
+ });
551
+ }
552
+ return { changes, unchanged, leftAlone };
553
+ }
554
+ async function apply(port, change, ids) {
555
+ switch (change.action) {
556
+ case "create": {
557
+ const input = {
558
+ name: change.agent,
559
+ definition: change.definition,
560
+ labels: { ...MANAGED_LABEL },
561
+ };
562
+ let made;
563
+ try {
564
+ made = await port.create(input, change.key);
565
+ }
566
+ catch (error) {
567
+ // The same key once made an agent of this name that was deleted
568
+ // since: the key answers that one. Make it again, with a new key.
569
+ if (!(error instanceof RuntimeError && error.code === "not_found"))
570
+ throw error;
571
+ made = await port.create(input, `${change.key}.${Math.random().toString(16).slice(2, 10)}`);
572
+ }
573
+ ids.set(change.agent, made.id);
574
+ return;
575
+ }
576
+ case "update":
577
+ await port.update(change.agentId, change.definition, change.key);
578
+ return;
579
+ case "delete":
580
+ await port.delete(change.agentId, change.key);
581
+ return;
582
+ case "add-trigger": {
583
+ const agentId = change.agentId ?? ids.get(change.agent);
584
+ if (!agentId)
585
+ throw new Error(`deploy: ${change.agent} was not made before its trigger`);
586
+ // A new agent's trigger is keyed by the agent it is added to as well:
587
+ // an agent of the same name deleted and made again is another agent, so
588
+ // its triggers never replay the deleted one's answers.
589
+ // The change answers the key it was sent with.
590
+ if (!change.agentId)
591
+ change.key = await keyOf(change.key, agentId);
592
+ const key = change.key;
593
+ const made = await port.addTrigger(agentId, { kind: change.trigger.kind, config: change.trigger.config }, key);
594
+ change.triggerId = made.id;
595
+ if (made.secret)
596
+ change.secret = made.secret;
597
+ if (change.trigger.paused !== undefined)
598
+ await port.pauseTrigger(made.id, change.trigger.paused, await keyOf("pause-trigger", made.id, change.trigger.paused, made.updatedAt));
599
+ return;
600
+ }
601
+ case "pause-trigger":
602
+ await port.pauseTrigger(change.triggerId, change.why, change.key);
603
+ return;
604
+ case "resume-trigger":
605
+ await port.resumeTrigger(change.triggerId, change.key);
606
+ return;
607
+ case "remove-trigger":
608
+ await port.removeTrigger(change.triggerId, change.key);
609
+ return;
610
+ }
611
+ }
612
+ /** The fields whose value differs. A field the file leaves out matches the
613
+ * API's default for it, and a snapshot's size is the snapshot's. */
614
+ export function changedFields(current, desired) {
615
+ const have = { ...current };
616
+ const want = { ...desired };
617
+ for (const [field, value] of Object.entries(SERVER_DEFAULTS))
618
+ if (!(field in want) && canonical(have[field]) === canonical(value))
619
+ delete have[field];
620
+ if (isObject(want.from) && "snapshot" in want.from)
621
+ for (const field of ["vcpu", "memoryMiB"])
622
+ if (!(field in want))
623
+ delete have[field];
624
+ const fields = new Set([...Object.keys(have), ...Object.keys(want)]);
625
+ return [...fields].filter((f) => canonical(have[f]) !== canonical(want[f])).sort();
626
+ }
627
+ /** JSON with every map's keys sorted, so equal values read equal. */
628
+ export function canonical(value) {
629
+ if (value === undefined)
630
+ return "undefined";
631
+ return JSON.stringify(value, (_, v) => isObject(v) && !Array.isArray(v)
632
+ ? Object.fromEntries(Object.keys(v)
633
+ .sort()
634
+ .map((k) => [k, v[k]]))
635
+ : v);
636
+ }
637
+ const triggerIdentity = (t) => `${t.kind} ${canonical(t.config)}`;
638
+ function triggerText(t) {
639
+ const c = t.config;
640
+ const what = t.kind === "schedule"
641
+ ? `"${String(c.cron)}"`
642
+ : t.kind === "github"
643
+ ? String(c.repository)
644
+ : t.kind === "slack"
645
+ ? `${String(c.team)}/${String(c.channel)}`
646
+ : "";
647
+ return what ? `${t.kind} ${what}` : t.kind;
648
+ }
649
+ function describe(d) {
650
+ const size = d.vcpu !== undefined ? `${d.vcpu} vCPU, ${d.memoryMiB} MiB` : "the snapshot's size";
651
+ const harness = Object.values(AGENT_TEMPLATES).find((t) => t.command === d.command);
652
+ return `${harness ? `${harness.key}, ` : ""}${size}`;
653
+ }
654
+ /** An idempotency key from what a change does and what it was planned
655
+ * against: the same rerun sends the same key. */
656
+ async function keyOf(...parts) {
657
+ const data = new TextEncoder().encode(parts.map(canonical).join("\n"));
658
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", data));
659
+ return `deploy:${[...digest.slice(0, 16)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
660
+ }
661
+ function sourceOf(input, options) {
662
+ if (isObject(input) && "config" in input && typeof input.file === "string")
663
+ return {
664
+ ...input,
665
+ ...(options.readFile ? { readFile: options.readFile } : {}),
666
+ };
667
+ return {
668
+ file: options.file ?? "runtime.yml",
669
+ config: input,
670
+ ...(options.readFile ? { readFile: options.readFile } : {}),
671
+ };
672
+ }
673
+ /** The largest file, definition and trigger config a deploy reads, in
674
+ * bytes of JSON (the API's own cap on a definition is 128 KiB). */
675
+ const FILE_MAX_BYTES = 256 * 1024;
676
+ const DEFINITION_MAX_BYTES = 128 * 1024;
677
+ const TRIGGER_MAX_BYTES = 40 * 1024;
678
+ /** Whether `value`, as JSON, is at most about `limit` bytes. Walks with a
679
+ * budget and stops when it is spent, so a value of shared parts (each part
680
+ * counted every time it is reached) or a cycle is refused at once, never
681
+ * expanded. */
682
+ export function within(value, limit) {
683
+ let left = limit;
684
+ const stack = [value];
685
+ while (stack.length) {
686
+ const v = stack.pop();
687
+ if (typeof v === "string")
688
+ left -= v.length + 2;
689
+ else if (Array.isArray(v)) {
690
+ left -= 2 + v.length;
691
+ for (const item of v)
692
+ stack.push(item);
693
+ }
694
+ else if (typeof v === "object" && v !== null) {
695
+ const entries = Object.entries(v);
696
+ left -= 2 + entries.length;
697
+ for (const [k, item] of entries) {
698
+ left -= k.length + 3;
699
+ stack.push(item);
700
+ }
701
+ }
702
+ else
703
+ left -= 8;
704
+ if (left < 0)
705
+ return false;
706
+ }
707
+ return true;
708
+ }
709
+ function isObject(v) {
710
+ return typeof v === "object" && v !== null && !Array.isArray(v);
711
+ }
712
+ /** " (did you mean X?)." when a known word is close, else ".". */
713
+ function hint(word, known) {
714
+ const lower = word.toLowerCase();
715
+ const near = known.find((k) => {
716
+ const l = k.toLowerCase();
717
+ if (l === lower || l.startsWith(lower) || lower.startsWith(l))
718
+ return true;
719
+ return distance(l, lower) <= (Math.max(l.length, lower.length) > 5 ? 2 : 1);
720
+ });
721
+ return near ? ` (did you mean ${near}?).` : ".";
722
+ }
723
+ function distance(a, b) {
724
+ const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
725
+ for (let i = 1; i <= a.length; i++)
726
+ for (let j = 1; j <= b.length; j++)
727
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
728
+ return d[a.length][b.length];
729
+ }
730
+ /** The deploy's reads and changes over the client's own calls, each change
731
+ * sent with its idempotency key. */
732
+ function clientPort(api) {
733
+ return {
734
+ list: async () => (await api.list({ limit: 100 })).toArray(Infinity),
735
+ create: (input, idempotencyKey) => api.create(input, { idempotencyKey }),
736
+ update: (agentId, definition, idempotencyKey) => api.update(agentId, definition, { idempotencyKey }),
737
+ delete: (agentId, idempotencyKey) => api.delete(agentId, { idempotencyKey }),
738
+ triggers: (agentId) => api.triggers.list(agentId),
739
+ addTrigger: (agentId, input, idempotencyKey) => api.triggers.create(agentId, input, { idempotencyKey }),
740
+ pauseTrigger: (id, why, idempotencyKey) => api.triggers.pause(id, why, { idempotencyKey }),
741
+ resumeTrigger: (id, idempotencyKey) => api.triggers.resume(id, { idempotencyKey }),
742
+ removeTrigger: (id, idempotencyKey) => api.triggers.delete(id, { idempotencyKey }),
743
+ memoryFiles: async (agentId) => (await api.memory.list(agentId, { limit: 1 })).usage.files,
744
+ };
745
+ }
746
+ /** `api` with `deploy`. */
747
+ export function withDeploy(api) {
748
+ return {
749
+ ...api,
750
+ deploy: (file, options = {}) => deployAgents(clientPort(api), file, options),
751
+ };
752
+ }