@foldrun/core 0.4.0 → 0.6.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.
- package/README.md +7 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/browser-pod.d.ts +148 -0
- package/dist/src/browser-pod.js +333 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +218 -3
- package/dist/src/container.d.ts +9 -0
- package/dist/src/container.js +115 -5
- package/dist/src/context-tools.d.ts +1 -1
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +7 -0
- package/dist/src/egress.js +6 -0
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +5 -0
- package/dist/src/evals.js +9 -2
- package/dist/src/flow-lint.d.ts +8 -0
- package/dist/src/flow-lint.js +223 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.js +5 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +30 -3
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +247 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +58 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +53 -7
- package/dist/src/notify.js +194 -126
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +28 -0
- package/dist/src/paths.js +55 -0
- package/dist/src/platform.d.ts +43 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/provider-health.js +3 -2
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +149 -13
- package/dist/src/run-container.js +424 -43
- package/dist/src/runner.d.ts +177 -9
- package/dist/src/runner.js +1146 -154
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +17 -2
- package/dist/src/script-tools.js +82 -10
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +30 -6
- package/dist/src/secret-health.js +42 -20
- package/dist/src/secrets.d.ts +19 -0
- package/dist/src/secrets.js +155 -86
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +92 -11
- package/dist/src/step-exec.d.ts +72 -2
- package/dist/src/step-exec.js +431 -37
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +222 -37
- package/dist/src/store.js +349 -62
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +104 -12
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +69 -23
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +3 -0
- package/dist/src/triggers.js +10 -2
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +4 -0
- package/dist/src/webhook.js +39 -0
- package/index.ts +4 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +350 -0
- package/src/catalog.ts +2 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +218 -3
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +14 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +5 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +37 -3
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +318 -0
- package/src/model-credential.ts +71 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +189 -103
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +54 -0
- package/src/platform.ts +52 -3
- package/src/provider-health.ts +3 -2
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +460 -44
- package/src/runner.ts +1168 -155
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +81 -10
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +64 -19
- package/src/secrets.ts +149 -87
- package/src/starter.ts +93 -11
- package/src/step-exec.ts +447 -38
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +506 -70
- package/src/subagents.ts +189 -0
- package/src/test-mode.ts +51 -11
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +110 -12
- package/src/translator.ts +72 -22
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +10 -2
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +41 -0
- package/templates/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +4 -4
- package/templates/hello/agents/notetaker/agent.md +3 -3
package/src/confine.ts
CHANGED
|
@@ -65,7 +65,7 @@ const PATH_KEYS = [
|
|
|
65
65
|
];
|
|
66
66
|
|
|
67
67
|
const DENIED_WITHIN = [
|
|
68
|
-
/(^|\/)secrets\.json
|
|
68
|
+
/(^|\/)secrets\.json(\.[^/]+)?$/, // and its lock and temp files
|
|
69
69
|
/(^|\/)runs(\/|$)/,
|
|
70
70
|
];
|
|
71
71
|
|
|
@@ -119,6 +119,9 @@ const VIRTUAL = [
|
|
|
119
119
|
|
|
120
120
|
const WRITE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
121
121
|
|
|
122
|
+
/** Workspace-level directories an agent's own folder must never grow. */
|
|
123
|
+
const SHARED_DIRS = new Set(["storage", "state", "workspace"]);
|
|
124
|
+
|
|
122
125
|
// Only the built-in filesystem tools get path checks. foldrun's own MCP tools
|
|
123
126
|
// are already confined at their own layer — script paths by resolveRunPath,
|
|
124
127
|
// APIs by their declared base URL — and their arguments are not filesystem
|
|
@@ -169,11 +172,40 @@ export function suggestPath(raw: string): string {
|
|
|
169
172
|
return `Use a path relative to your agent directory, or workspace/<dir>/… for the workspace root (workspace/storage/, workspace/state/).`;
|
|
170
173
|
}
|
|
171
174
|
|
|
175
|
+
/**
|
|
176
|
+
* A path an author wrote in a flow or an agent wrote in a tool call, as an
|
|
177
|
+
* absolute path: `workspace/…` from the workspace root, anything else from
|
|
178
|
+
* the agent's own folder (so the older `../../storage/x` lands in the same
|
|
179
|
+
* place as `workspace/storage/x`). Confinement is the caller's: this only
|
|
180
|
+
* says where the path points.
|
|
181
|
+
*/
|
|
182
|
+
export function resolveAgentPath(workspaceRoot: string, agentDir: string, raw: string): string {
|
|
183
|
+
const p = raw.trim();
|
|
184
|
+
if (p === "workspace" || p === "workspace/") return path.resolve(workspaceRoot);
|
|
185
|
+
if (p.startsWith("workspace/")) return path.resolve(workspaceRoot, p.slice("workspace/".length));
|
|
186
|
+
return path.resolve(agentDir, p);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** True when `abs` is `root` or under it. */
|
|
190
|
+
export function isWithin(root: string, abs: string): boolean {
|
|
191
|
+
const rel = path.relative(path.resolve(root), path.resolve(abs));
|
|
192
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
193
|
+
}
|
|
194
|
+
|
|
172
195
|
/** Expand a virtual prefix to a real absolute path, or null if it has none. */
|
|
173
196
|
export function expandVirtual(raw: string, roots: Roots): { abs: string; readOnly: boolean } | null {
|
|
197
|
+
// The SDK resolves a relative path against the cwd BEFORE canUseTool sees
|
|
198
|
+
// it, so `workspace/storage/x.json` arrives as
|
|
199
|
+
// `<agentDir>/workspace/storage/x.json` and never matched a prefix. The
|
|
200
|
+
// write then made a literal `workspace/` folder inside the agent's own
|
|
201
|
+
// directory and reported success: gbp-desk's reply-sheet, 2026-09-24, whose
|
|
202
|
+
// approved replies never reached the poster. Read the prefix off the path
|
|
203
|
+
// relative to the agent directory as well as off the raw one.
|
|
204
|
+
const rel = path.isAbsolute(raw) ? path.relative(roots.agentDir, raw).split(path.sep).join("/") : null;
|
|
205
|
+
const candidate = rel !== null && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : raw;
|
|
174
206
|
for (const v of VIRTUAL) {
|
|
175
|
-
if (
|
|
176
|
-
return { abs: path.resolve(v.root(roots),
|
|
207
|
+
if (candidate.startsWith(v.prefix)) {
|
|
208
|
+
return { abs: path.resolve(v.root(roots), candidate.slice(v.prefix.length)), readOnly: v.readOnly };
|
|
177
209
|
}
|
|
178
210
|
}
|
|
179
211
|
return null;
|
|
@@ -248,6 +280,27 @@ export function checkPaths(
|
|
|
248
280
|
};
|
|
249
281
|
}
|
|
250
282
|
|
|
283
|
+
// storage/, state/ and workspace/ belong to the workspace. The same name
|
|
284
|
+
// under an agent's own folder is always a missing prefix — `storage/x`
|
|
285
|
+
// written from agents/<name>/ — and the write used to succeed there,
|
|
286
|
+
// where no other agent looks: gbp-desk's approved replies, lawyer-desk's
|
|
287
|
+
// batch, five files in all by 2026-09-24. Refuse it with the path that
|
|
288
|
+
// was meant; the model takes the correction on its next turn.
|
|
289
|
+
if (isWrite && !virtual) {
|
|
290
|
+
const own = path.relative(real(path.resolve(roots.agentDir)), abs).split(path.sep).join("/");
|
|
291
|
+
const [top, ...rest] = own.split("/");
|
|
292
|
+
if (!own.startsWith("..") && rest.length && SHARED_DIRS.has(top)) {
|
|
293
|
+
const meant = top === "workspace" ? `workspace/${rest.join("/")}` : `workspace/${top}/${rest.join("/")}`;
|
|
294
|
+
return {
|
|
295
|
+
ok: false,
|
|
296
|
+
reason:
|
|
297
|
+
`${toolName} was denied: "${raw}" would create ${top}/ inside your own agent folder, where no ` +
|
|
298
|
+
`other agent or run looks. Did you mean ${meant}? Shared files go under workspace/ ` +
|
|
299
|
+
`(workspace/storage/, workspace/state/, workspace/memory/); your own scratch goes to outputs/.`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
251
304
|
if (isWrite && READ_ONLY_WITHIN.some((re) => re.test(within))) {
|
|
252
305
|
return {
|
|
253
306
|
ok: false,
|
|
@@ -263,6 +316,31 @@ export function checkPaths(
|
|
|
263
316
|
if (virtual) rewritten = { ...(rewritten ?? input), [key]: abs };
|
|
264
317
|
}
|
|
265
318
|
|
|
319
|
+
// Glob searches from `path` (the cwd when absent) and its PATTERN never
|
|
320
|
+
// climbs: `../../storage/draft/*.md` answered "No files found" with the
|
|
321
|
+
// files there (run-munef2am-439a), and an expanded `workspace/storage/*.md`
|
|
322
|
+
// is an absolute pattern the tool does not search by either. Split such a
|
|
323
|
+
// pattern at its first wildcard: the fixed directories become `path`, the
|
|
324
|
+
// rest stays the pattern — so both spellings find what is there.
|
|
325
|
+
if (toolName === "Glob") {
|
|
326
|
+
const cur = { ...input, ...(rewritten ?? {}) };
|
|
327
|
+
const pattern = typeof cur.pattern === "string" ? cur.pattern : "";
|
|
328
|
+
const climbs = pattern.startsWith("../");
|
|
329
|
+
if (pattern && (climbs || path.isAbsolute(pattern)) && (cur.path === undefined || cur.path === "")) {
|
|
330
|
+
const absPattern = climbs ? path.resolve(roots.agentDir, pattern) : pattern;
|
|
331
|
+
const segs = absPattern.split("/");
|
|
332
|
+
const wild = segs.findIndex((seg) => /[*?[{]/.test(seg));
|
|
333
|
+
if (wild > 0 && wild < segs.length) {
|
|
334
|
+
const base = segs.slice(0, wild).join("/") || "/";
|
|
335
|
+
const inWorkspace = !path.relative(workspace, real(base)).startsWith("..");
|
|
336
|
+
const inLibrary = !path.relative(library, real(base)).startsWith("..");
|
|
337
|
+
if (inWorkspace || inLibrary) {
|
|
338
|
+
rewritten = { ...(rewritten ?? input), path: base, pattern: segs.slice(wild).join("/") };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
266
344
|
return { ok: true, updatedInput: rewritten };
|
|
267
345
|
}
|
|
268
346
|
|
|
@@ -353,3 +431,140 @@ export function checkBash(command: string): ConfineVerdict {
|
|
|
353
431
|
}
|
|
354
432
|
return { ok: true };
|
|
355
433
|
}
|
|
434
|
+
|
|
435
|
+
// ------------------------------------------------------------ workspace link
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* `workspace/…` for a shell. The file tools expand the prefix themselves
|
|
439
|
+
* (expandVirtual above), but Bash, a script an agent runs, a scripts: tool
|
|
440
|
+
* and a shell `verify:` see only the filesystem — and there `workspace/` was
|
|
441
|
+
* a folder that did not exist, so the one spelling the docs teach worked in
|
|
442
|
+
* Read and failed in `cat`. For the length of a step the agent's folder holds
|
|
443
|
+
* a `workspace` link to the workspace root (`../..`), and nothing else.
|
|
444
|
+
*
|
|
445
|
+
* Why only for a step: a permanent link inside the tree it points at is a
|
|
446
|
+
* cycle, and anything that walks the tree following links walks it forever
|
|
447
|
+
* (a `shared` link was tried that way and reverted). Every walker this
|
|
448
|
+
* platform runs over a workspace skips links (storage harvest, the source
|
|
449
|
+
* listing, tar, the container copy-back), and the link is gone before the
|
|
450
|
+
* step's files are read back.
|
|
451
|
+
*
|
|
452
|
+
* Counted, because the parallel instances of one agent (`each:`, a fan-out)
|
|
453
|
+
* share its folder in one process: the last one out removes it. A real
|
|
454
|
+
* folder already called `workspace` (a stray from before the prefix was
|
|
455
|
+
* expanded) is left alone and reported — linking over it would hide files.
|
|
456
|
+
*/
|
|
457
|
+
const workspaceLinks = new Map<string, number>();
|
|
458
|
+
|
|
459
|
+
export function linkWorkspace(agentDir: string, workspaceRoot: string): { release: () => void; note: string | null } {
|
|
460
|
+
const link = path.join(agentDir, "workspace");
|
|
461
|
+
const target = path.relative(path.resolve(agentDir), path.resolve(workspaceRoot)) || ".";
|
|
462
|
+
const held = workspaceLinks.get(link) ?? 0;
|
|
463
|
+
let note: string | null = null;
|
|
464
|
+
let own = false;
|
|
465
|
+
if (held > 0) {
|
|
466
|
+
own = true;
|
|
467
|
+
} else {
|
|
468
|
+
try {
|
|
469
|
+
const st = fs.lstatSync(link);
|
|
470
|
+
if (st.isSymbolicLink()) {
|
|
471
|
+
// Left by a step that ended without its cleanup — adopt it when it
|
|
472
|
+
// points where ours would, replace it when it does not.
|
|
473
|
+
if (fs.readlinkSync(link) !== target) {
|
|
474
|
+
fs.unlinkSync(link);
|
|
475
|
+
fs.symlinkSync(target, link, "dir");
|
|
476
|
+
}
|
|
477
|
+
own = true;
|
|
478
|
+
} else {
|
|
479
|
+
note =
|
|
480
|
+
`agents/${path.basename(agentDir)}/workspace/ is a real folder, so a shell command's ` +
|
|
481
|
+
`workspace/… reaches it rather than the workspace root — move what it holds to the ` +
|
|
482
|
+
`workspace's own storage/ or state/ and delete it`;
|
|
483
|
+
}
|
|
484
|
+
} catch {
|
|
485
|
+
try {
|
|
486
|
+
fs.symlinkSync(target, link, "dir");
|
|
487
|
+
own = true;
|
|
488
|
+
} catch (err) {
|
|
489
|
+
note = `could not link workspace/ for the shell: ${err instanceof Error ? err.message : String(err)}`;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (own) workspaceLinks.set(link, held + 1);
|
|
494
|
+
let released = false;
|
|
495
|
+
return {
|
|
496
|
+
note,
|
|
497
|
+
release: () => {
|
|
498
|
+
if (!own || released) return;
|
|
499
|
+
released = true;
|
|
500
|
+
const left = (workspaceLinks.get(link) ?? 1) - 1;
|
|
501
|
+
if (left > 0) {
|
|
502
|
+
workspaceLinks.set(link, left);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
workspaceLinks.delete(link);
|
|
506
|
+
try {
|
|
507
|
+
if (fs.lstatSync(link).isSymbolicLink()) fs.unlinkSync(link);
|
|
508
|
+
} catch {
|
|
509
|
+
// already gone
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* A Node program started THROUGH the link is still its own main module.
|
|
517
|
+
*
|
|
518
|
+
* `node workspace/tools/x/run.mjs` loads the file by its real path —
|
|
519
|
+
* import.meta.url says `<ws>/tools/x/run.mjs` — while process.argv[1] keeps
|
|
520
|
+
* the path as typed, `<ws>/agents/a/workspace/tools/x/run.mjs`. The usual
|
|
521
|
+
* entry-point check, `import.meta.url === pathToFileURL(process.argv[1]).href`,
|
|
522
|
+
* was then false: the program did nothing, exited 0, and a `verify:` that
|
|
523
|
+
* ran it passed on every input.
|
|
524
|
+
*
|
|
525
|
+
* The fix rides NODE_OPTIONS into every child a step starts (the model's
|
|
526
|
+
* Bash, a shell `verify:`, a scripts: tool, the Test button): a preload that
|
|
527
|
+
* rewrites argv[1] to its real path, and only when it goes through this
|
|
528
|
+
* link. `--preserve-symlinks-main` was the obvious flag and the wrong one:
|
|
529
|
+
* it applies to EVERY linked entry point, and a `node_modules/.bin` shim
|
|
530
|
+
* then resolves `require("../lib/x.js")` beside the shim and fails.
|
|
531
|
+
*
|
|
532
|
+
* A data: URL rather than a file, so nothing has to be shipped to wherever
|
|
533
|
+
* the step runs (host, run container, pod, a runtime: venv). Node ≥ 20.6
|
|
534
|
+
* reads `--import` from NODE_OPTIONS; the runner image is Node 22. Python
|
|
535
|
+
* needs nothing: `python3 workspace/x.py` is `__main__` however it is
|
|
536
|
+
* reached.
|
|
537
|
+
*/
|
|
538
|
+
const MAIN_PRELOAD = [
|
|
539
|
+
`import{realpathSync}from"node:fs";`,
|
|
540
|
+
`import{resolve,sep,delimiter}from"node:path";`,
|
|
541
|
+
`const l=process.env.FOLDRUN_WORKSPACE_LINK,a=process.argv[1];`,
|
|
542
|
+
`if(l&&a){const f=resolve(a);`,
|
|
543
|
+
`if(l.split(delimiter).some(k=>k&&f.startsWith(k+sep))){`,
|
|
544
|
+
`try{process.argv[1]=realpathSync(f)}catch{}}}`,
|
|
545
|
+
].join("");
|
|
546
|
+
const MAIN_IMPORT = `--import=data:text/javascript,${encodeURIComponent(MAIN_PRELOAD)}`;
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* `env` plus what makes `node workspace/…` its own main module (above):
|
|
550
|
+
* the link's path(s) in FOLDRUN_WORKSPACE_LINK, the preload appended to
|
|
551
|
+
* NODE_OPTIONS — after what it held, or the host's when it held nothing.
|
|
552
|
+
* Idempotent, and harmless where no link exists: the preload matches only
|
|
553
|
+
* a path under it.
|
|
554
|
+
*/
|
|
555
|
+
export function workspaceLinkEnv<T extends Record<string, string | undefined>>(env: T, agentDir: string): T {
|
|
556
|
+
const link = path.join(path.resolve(agentDir), "workspace");
|
|
557
|
+
let real = link;
|
|
558
|
+
try {
|
|
559
|
+
real = path.join(fs.realpathSync(agentDir), "workspace");
|
|
560
|
+
} catch {
|
|
561
|
+
// not there yet — the lexical path is the one a shell would type
|
|
562
|
+
}
|
|
563
|
+
const held = env.NODE_OPTIONS ?? process.env.NODE_OPTIONS ?? "";
|
|
564
|
+
const options = held.includes(MAIN_IMPORT) ? held : [held.trim(), MAIN_IMPORT].filter(Boolean).join(" ");
|
|
565
|
+
return {
|
|
566
|
+
...env,
|
|
567
|
+
FOLDRUN_WORKSPACE_LINK: [...new Set([link, real])].join(path.delimiter),
|
|
568
|
+
NODE_OPTIONS: options,
|
|
569
|
+
};
|
|
570
|
+
}
|
package/src/container.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// development keeps working with zero configuration.
|
|
11
11
|
|
|
12
12
|
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
13
14
|
import path from "node:path";
|
|
14
15
|
import { dataRoot } from "./paths.ts";
|
|
15
16
|
import { spawnSync, spawn } from "node:child_process";
|
|
@@ -131,6 +132,10 @@ export interface ContainerRunOptions {
|
|
|
131
132
|
agentDir: string;
|
|
132
133
|
/** Extra read-only mounts: host path → container path. */
|
|
133
134
|
readOnly?: Record<string, string>;
|
|
135
|
+
/** Workspace paths a script was handed as `workspace/…`: copied in at
|
|
136
|
+
* `container` before it runs, and what it created or changed under them
|
|
137
|
+
* copied back to `host` after — the host's workspace link, as a copy. */
|
|
138
|
+
staged?: { host: string; container: string }[];
|
|
134
139
|
image: string;
|
|
135
140
|
argv: string[]; // command inside the container
|
|
136
141
|
env: Record<string, string>;
|
|
@@ -181,15 +186,36 @@ export async function runInContainer(
|
|
|
181
186
|
const cid = (created.stdout ?? "").trim();
|
|
182
187
|
|
|
183
188
|
try {
|
|
184
|
-
// The agent's own directory becomes /workspace…
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
+
// The agent's own directory becomes /workspace… — its entries staged
|
|
190
|
+
// like the workspace paths below (a scratch copy, opened up for the
|
|
191
|
+
// script's user, one tar), minus the step's `workspace` link. A step
|
|
192
|
+
// holds that link (→ ../..) so the shell can say workspace/…, and
|
|
193
|
+
// `docker cp` refuses a link that climbs out of what it copies
|
|
194
|
+
// ("invalid symlink"), so every Docker-run script failed before it
|
|
195
|
+
// started. And `docker cp` lands files as root while scripts run as
|
|
196
|
+
// `agent`, so a script could never write its own outputs/.
|
|
197
|
+
const own = fs
|
|
198
|
+
.readdirSync(opts.agentDir)
|
|
199
|
+
.filter((name) => {
|
|
200
|
+
if (name !== "workspace") return true;
|
|
201
|
+
try {
|
|
202
|
+
return !fs.lstatSync(path.join(opts.agentDir, name)).isSymbolicLink();
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
.map((name) => ({ host: path.join(opts.agentDir, name), container: `/workspace/${name}` }));
|
|
208
|
+
const copiedIn = stageIn(CLI, cid, own, ["/workspace"]);
|
|
209
|
+
if (copiedIn) return { code: null, out: `copy in failed: ${copiedIn}` };
|
|
189
210
|
// …and any shared directories land at their declared paths.
|
|
190
211
|
for (const [host, mount] of Object.entries(opts.readOnly ?? {})) {
|
|
191
212
|
if (fs.existsSync(host)) docker(["cp", `${host}/.`, `${cid}:${mount}`], 120_000);
|
|
192
213
|
}
|
|
214
|
+
const staged = opts.staged ?? [];
|
|
215
|
+
if (staged.length) {
|
|
216
|
+
const failed = stageIn(CLI, cid, staged);
|
|
217
|
+
if (failed) return { code: null, out: `copy in failed: ${failed}` };
|
|
218
|
+
}
|
|
193
219
|
|
|
194
220
|
const result = await new Promise<{ code: number | null; out: string }>((resolve) => {
|
|
195
221
|
const child = spawn(CLI, ["start", "-a", cid], { timeout: opts.timeoutMs });
|
|
@@ -213,8 +239,89 @@ export async function runInContainer(
|
|
|
213
239
|
|
|
214
240
|
// Bring deliverables back so outputs/ behaves the same either way.
|
|
215
241
|
docker(["cp", `${cid}:/workspace/outputs/.`, path.join(opts.agentDir, "outputs")], 120_000);
|
|
242
|
+
for (const s of staged) stageBack(CLI, cid, s);
|
|
216
243
|
return result;
|
|
217
244
|
} finally {
|
|
218
245
|
docker(["rm", "-f", cid], 30_000);
|
|
219
246
|
}
|
|
220
247
|
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Copy the staged workspace paths into a created container: one tar, built
|
|
251
|
+
* in a scratch directory that mirrors the container paths, extracted at /.
|
|
252
|
+
* A path that does not exist yet (an output the script is told to write)
|
|
253
|
+
* gets its parent directory, so the write has somewhere to land.
|
|
254
|
+
*/
|
|
255
|
+
function stageIn(cli: string, cid: string, staged: { host: string; container: string }[], dirs: string[] = []): string | null {
|
|
256
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-stage-"));
|
|
257
|
+
try {
|
|
258
|
+
for (const d of dirs) fs.mkdirSync(path.join(scratch, d), { recursive: true });
|
|
259
|
+
for (const { host, container } of staged) {
|
|
260
|
+
const dest = path.join(scratch, container);
|
|
261
|
+
if (fs.existsSync(host)) {
|
|
262
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
263
|
+
fs.cpSync(host, dest, { recursive: true, dereference: false });
|
|
264
|
+
} else {
|
|
265
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// `docker cp` lands files as root and scripts run as `agent`: these are
|
|
269
|
+
// scratch copies, so open them up for the script to write into.
|
|
270
|
+
openUp(scratch);
|
|
271
|
+
const top = fs.readdirSync(scratch);
|
|
272
|
+
const r = spawnSync(
|
|
273
|
+
"sh",
|
|
274
|
+
["-c", 'dir="$1"; cli="$2"; dest="$3"; shift 3; tar --no-xattrs -C "$dir" -cf - -- "$@" | "$cli" cp - "$dest"', "sh", scratch, cli, `${cid}:/`, ...top],
|
|
275
|
+
{ encoding: "utf8", timeout: 120_000, env: { ...process.env, COPYFILE_DISABLE: "1" } },
|
|
276
|
+
);
|
|
277
|
+
return r.status === 0 ? null : (r.stderr ?? "").slice(-300);
|
|
278
|
+
} finally {
|
|
279
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Copy back what the script created or changed under one staged path. Files
|
|
285
|
+
* whose bytes did not change are left alone, so an input keeps its mtime —
|
|
286
|
+
* freshness checks read it.
|
|
287
|
+
*/
|
|
288
|
+
function stageBack(cli: string, cid: string, s: { host: string; container: string }): void {
|
|
289
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-stage-"));
|
|
290
|
+
try {
|
|
291
|
+
const out = path.join(scratch, "x");
|
|
292
|
+
const got = spawnSync(cli, ["cp", `${cid}:${s.container}`, out], { encoding: "utf8", timeout: 120_000 });
|
|
293
|
+
if (got.status !== 0 || !fs.existsSync(out)) return;
|
|
294
|
+
syncChanged(out, s.host);
|
|
295
|
+
} finally {
|
|
296
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Write `from` over `to` where the bytes differ; recurse into folders. */
|
|
301
|
+
export function syncChanged(from: string, to: string): void {
|
|
302
|
+
const st = fs.lstatSync(from);
|
|
303
|
+
if (st.isSymbolicLink()) return;
|
|
304
|
+
if (st.isDirectory()) {
|
|
305
|
+
fs.mkdirSync(to, { recursive: true });
|
|
306
|
+
for (const name of fs.readdirSync(from)) syncChanged(path.join(from, name), path.join(to, name));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const next = fs.readFileSync(from);
|
|
310
|
+
if (fs.existsSync(to) && fs.statSync(to).isFile() && fs.readFileSync(to).equals(next)) return;
|
|
311
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
312
|
+
fs.writeFileSync(to, next);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Directories 0777, files writable by all (exec bits kept): a scratch tree only. */
|
|
316
|
+
function openUp(dir: string): void {
|
|
317
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
318
|
+
const p = path.join(dir, entry.name);
|
|
319
|
+
if (entry.isSymbolicLink()) continue;
|
|
320
|
+
if (entry.isDirectory()) {
|
|
321
|
+
fs.chmodSync(p, 0o777);
|
|
322
|
+
openUp(p);
|
|
323
|
+
} else {
|
|
324
|
+
fs.chmodSync(p, fs.statSync(p).mode | 0o666);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
package/src/context-tools.ts
CHANGED
|
@@ -32,7 +32,7 @@ import type { RunRecord } from "./store.ts";
|
|
|
32
32
|
// ------------------------------------------------------------------ search
|
|
33
33
|
|
|
34
34
|
export interface SearchRoot {
|
|
35
|
-
/** How the agent should name the place: "knowledge/", "
|
|
35
|
+
/** How the agent should name the place: "knowledge/", "workspace/memory/". */
|
|
36
36
|
label: string;
|
|
37
37
|
/** The directory, absolute. */
|
|
38
38
|
dir: string;
|
package/src/deploy.ts
CHANGED
|
@@ -23,16 +23,28 @@ import os from "node:os";
|
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import {
|
|
25
25
|
saveWorkspace,
|
|
26
|
+
keptOnDeploy,
|
|
26
27
|
workspaceDir,
|
|
27
28
|
listRuns,
|
|
28
29
|
parseFlow,
|
|
30
|
+
parseToolDef,
|
|
29
31
|
WORKSPACE_DIRS,
|
|
30
32
|
assertSafeName,
|
|
31
33
|
type DeployFile,
|
|
32
34
|
notifyWorkspaceChanged,
|
|
33
35
|
} from "./store.ts";
|
|
36
|
+
import { lintFlow } from "./flow-lint.ts";
|
|
37
|
+
import { refNames } from "./refs.ts";
|
|
38
|
+
import matter from "gray-matter";
|
|
34
39
|
import { conformanceIssues } from "./okf.ts";
|
|
40
|
+
import { timezoneProblem } from "./clock.ts";
|
|
41
|
+
import { webProblems, webWarnings, runnerEngines } from "./providers.ts";
|
|
42
|
+
import { readLimits } from "./limits.ts";
|
|
43
|
+
import { actionProblems } from "./web-actions.ts";
|
|
44
|
+
import { languageProblem } from "./language.ts";
|
|
45
|
+
import { localeProblems } from "./locale.ts";
|
|
35
46
|
import { repoDir } from "./gitrepo.ts";
|
|
47
|
+
import { entriesNoFollow } from "./paths.ts";
|
|
36
48
|
|
|
37
49
|
/** Directories a deploy never reads out of a source tree. */
|
|
38
50
|
const NOT_SOURCE = new Set([".git", "node_modules", "runs", "outputs", ".foldrun", ".results"]);
|
|
@@ -42,6 +54,23 @@ const NOT_SOURCE_FILE = new Set([".DS_Store", "secrets.json"]);
|
|
|
42
54
|
|
|
43
55
|
const IN_WORKSPACE_DIR = new RegExp(`^(${WORKSPACE_DIRS.join("|")})/`);
|
|
44
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Is this path, relative to a workspace root, one `readTree` would carry?
|
|
59
|
+
*
|
|
60
|
+
* The same question the walk above asks, asked of a path that is not on this
|
|
61
|
+
* machine — what the platform lists for a workspace, so `foldrun status` and
|
|
62
|
+
* `foldrun pull` compare like with like. Without it the comparison counted
|
|
63
|
+
* every run artefact and bookkeeping file the platform keeps as a local
|
|
64
|
+
* deletion, and every status was a wall of red.
|
|
65
|
+
*/
|
|
66
|
+
export function isSourcePath(rel: string): boolean {
|
|
67
|
+
const norm = rel.replaceAll("\\", "/");
|
|
68
|
+
const parts = norm.split("/");
|
|
69
|
+
if (parts.some((p) => NOT_SOURCE.has(p))) return false;
|
|
70
|
+
if (NOT_SOURCE_FILE.has(parts[parts.length - 1])) return false;
|
|
71
|
+
return norm === "AGENTS.md" || norm === "project.md" || IN_WORKSPACE_DIR.test(norm);
|
|
72
|
+
}
|
|
73
|
+
|
|
45
74
|
export interface DeployIssue {
|
|
46
75
|
where: string;
|
|
47
76
|
message: string;
|
|
@@ -55,12 +84,19 @@ export interface DeployPlan {
|
|
|
55
84
|
updated: string[];
|
|
56
85
|
/** Empty when the deploy is safe to apply. */
|
|
57
86
|
issues: DeployIssue[];
|
|
87
|
+
/** Advisory, non-blocking: a deploy proceeds with these. Today this is
|
|
88
|
+
* the outward-without-a-gate lint — the same one `foldrun check` fails
|
|
89
|
+
* on, surfaced here so a push over the API is not the last to hear it. */
|
|
90
|
+
warnings: DeployIssue[];
|
|
58
91
|
/** Set when a run is in flight, which blocks the swap. */
|
|
59
92
|
blockedBy: string[];
|
|
60
93
|
}
|
|
61
94
|
|
|
62
95
|
export interface DeployResult extends DeployPlan {
|
|
63
96
|
applied: boolean;
|
|
97
|
+
/** Set when `expectRemoved` was given and the deploy would have removed
|
|
98
|
+
* paths outside it: those paths. The deploy was refused, nothing changed. */
|
|
99
|
+
unexpectedRemovals?: string[];
|
|
64
100
|
/** The commit this workspace is now running, when the caller knows it. */
|
|
65
101
|
commit: string | null;
|
|
66
102
|
preserved: number;
|
|
@@ -166,6 +202,55 @@ export function readTree(dir: string): DeployFile[] {
|
|
|
166
202
|
* not on disk yet. A deploy that would leave the workspace failing its own
|
|
167
203
|
* checker should not be a deploy.
|
|
168
204
|
*/
|
|
205
|
+
/** Best-effort warnings for a deploy: an agent's `web:` warnings (see
|
|
206
|
+
* webWarnings), and the outward gate — a step whose agent grants
|
|
207
|
+
* a workspace tool marked `outward: true` and has no `verify:`/gate. Reads
|
|
208
|
+
* only the deployed files, so a library or gallery outward tool is not seen
|
|
209
|
+
* here — `foldrun check`, which resolves every scope, remains the real gate.
|
|
210
|
+
* Non-blocking by decision: the deploy proceeds and says so. */
|
|
211
|
+
export function deployWarnings(files: DeployFile[]): DeployIssue[] {
|
|
212
|
+
const front = (content: string): Record<string, unknown> => {
|
|
213
|
+
try { return matter(content).data as Record<string, unknown>; } catch { return {}; }
|
|
214
|
+
};
|
|
215
|
+
// Workspace tools that change something outside the workspace.
|
|
216
|
+
const outwardTools = new Set<string>();
|
|
217
|
+
for (const f of files) {
|
|
218
|
+
const m = f.path.match(/^tools\/([^/]+?)(?:\/tool)?\.md$/);
|
|
219
|
+
if (!m) continue;
|
|
220
|
+
const data = front(f.content);
|
|
221
|
+
const def = parseToolDef(data, m[1], f.content);
|
|
222
|
+
// `outward: true` is read off the frontmatter, the same test the runtime
|
|
223
|
+
// and the CLI use; parseToolDef does not fold it onto the def.
|
|
224
|
+
if (def && data.outward === true) outwardTools.add(def.name ?? m[1]);
|
|
225
|
+
}
|
|
226
|
+
// Agents that grant one of them.
|
|
227
|
+
const outwardAgents: string[] = [];
|
|
228
|
+
const agentNames: string[] = [];
|
|
229
|
+
const out: DeployIssue[] = [];
|
|
230
|
+
// The web: warnings check prints — the old session: key, and a pinned
|
|
231
|
+
// user agent behind the runner image's Chrome, which the platform knows
|
|
232
|
+
// from FOLDRUN_RUNNER_ENGINES (the deploy read it from the image).
|
|
233
|
+
const { chrome, chromium } = runnerEngines() ?? {};
|
|
234
|
+
for (const f of files) {
|
|
235
|
+
const m = f.path.match(/^agents\/([^/]+)\/agent\.md$/);
|
|
236
|
+
if (!m) continue;
|
|
237
|
+
agentNames.push(m[1]);
|
|
238
|
+
const data = front(f.content);
|
|
239
|
+
if (refNames(data.tools).some((t: string) => outwardTools.has(t))) outwardAgents.push(m[1]);
|
|
240
|
+
for (const w of webWarnings(data, { chrome, chromium })) out.push({ where: f.path, message: w });
|
|
241
|
+
}
|
|
242
|
+
if (!outwardAgents.length) return out;
|
|
243
|
+
for (const f of files) {
|
|
244
|
+
if (!/^flows\/[^/]+\.md$/.test(f.path)) continue;
|
|
245
|
+
let flow;
|
|
246
|
+
try { flow = parseFlow(path.basename(f.path), f.content); } catch { continue; }
|
|
247
|
+
for (const w of lintFlow(flow, { agents: agentNames, outwardAgents })) {
|
|
248
|
+
if (w.kind === "outward") out.push({ where: `${f.path}${w.line ? `:${w.line}` : ""}`, message: w.message });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
169
254
|
export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
170
255
|
const issues: DeployIssue[] = [];
|
|
171
256
|
const at = (where: string, message: string) => issues.push({ where, message });
|
|
@@ -193,6 +278,34 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
|
193
278
|
const m = f.path.match(/^agents\/([^/]+)\/agent\.md$/);
|
|
194
279
|
if (m) agents.add(m[1]);
|
|
195
280
|
}
|
|
281
|
+
|
|
282
|
+
// `timezone:` is read at four levels and a value nothing can parse falls
|
|
283
|
+
// silently through to the level above — so it is refused where it was
|
|
284
|
+
// written, not discovered as a date that is a day out.
|
|
285
|
+
for (const f of files) {
|
|
286
|
+
if (!/^(AGENTS\.md|agents\/[^/]+\/agent\.md)$/.test(f.path)) continue;
|
|
287
|
+
let front: Record<string, unknown> = {};
|
|
288
|
+
try {
|
|
289
|
+
front = matter(f.content).data as Record<string, unknown>;
|
|
290
|
+
} catch {
|
|
291
|
+
continue; // unparseable frontmatter is its own report
|
|
292
|
+
}
|
|
293
|
+
const problem = timezoneProblem(front.timezone);
|
|
294
|
+
if (problem) at(f.path, problem);
|
|
295
|
+
// `schedule:` on an agent or AGENTS.md runs nothing — only a flow has a
|
|
296
|
+
// clock. Refuse it where it was written rather than deploy it dead.
|
|
297
|
+
if (front.schedule !== undefined) {
|
|
298
|
+
at(f.path, "schedule: an agent has no schedule of its own — only a flow runs on a clock. Put schedule: on the flow that runs this agent, or remove it.");
|
|
299
|
+
}
|
|
300
|
+
// The same refusal check gives: a web key that cannot work never ships.
|
|
301
|
+
for (const w of [...webProblems(front), ...actionProblems(front)]) at(f.path, w);
|
|
302
|
+
// `limits:` that cannot be read — a count of 0, a word, web.<not an
|
|
303
|
+
// action> — would run uncapped where the author meant a cap.
|
|
304
|
+
for (const w of readLimits(front.limits).problems) at(f.path, w);
|
|
305
|
+
const lang = languageProblem(front.language);
|
|
306
|
+
if (lang) at(f.path, lang);
|
|
307
|
+
for (const w of localeProblems(front)) at(f.path, w);
|
|
308
|
+
}
|
|
196
309
|
if (agents.size === 0) {
|
|
197
310
|
at("agents/", "no agents — a workspace needs at least one agents/<name>/agent.md");
|
|
198
311
|
}
|
|
@@ -218,6 +331,7 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
|
|
|
218
331
|
continue;
|
|
219
332
|
}
|
|
220
333
|
if (parsed.steps.length === 0) at(f.path, "no steps");
|
|
334
|
+
for (const fp of parsed.frontProblems) at(f.path, fp);
|
|
221
335
|
for (const s of parsed.steps) {
|
|
222
336
|
const target = s.subflow ?? s.agent;
|
|
223
337
|
const known = s.subflow ? flowNames.has(target) : agents.has(target);
|
|
@@ -284,7 +398,7 @@ export function planDeploy(tenant: string, workspace: string, files: DeployFile[
|
|
|
284
398
|
|
|
285
399
|
const present = new Set<string>();
|
|
286
400
|
if (fs.existsSync(dir)) {
|
|
287
|
-
for (const entry of
|
|
401
|
+
for (const entry of entriesNoFollow(dir)) {
|
|
288
402
|
const rel = String(entry).split(path.sep).join("/");
|
|
289
403
|
if (NOT_SOURCE.has(rel.split("/")[0])) continue;
|
|
290
404
|
if (/(^|\/)(runs|outputs|\.results)\//.test(rel)) continue;
|
|
@@ -312,11 +426,11 @@ export function planDeploy(tenant: string, workspace: string, files: DeployFile[
|
|
|
312
426
|
}
|
|
313
427
|
}
|
|
314
428
|
|
|
315
|
-
// What saveWorkspace will keep regardless —
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const removed = [...present].filter((rel) => !shipped.has(rel) && !
|
|
429
|
+
// What saveWorkspace will keep regardless — storage/, state/, agent
|
|
430
|
+
// memory, the platform's own files. Reporting these as removals would be
|
|
431
|
+
// a lie, and the alarming kind; one predicate serves both.
|
|
432
|
+
const shippedPaths = new Set(shipped.keys());
|
|
433
|
+
const removed = [...present].filter((rel) => !shipped.has(rel) && !keptOnDeploy(rel, shippedPaths));
|
|
320
434
|
|
|
321
435
|
return {
|
|
322
436
|
files,
|
|
@@ -324,6 +438,7 @@ export function planDeploy(tenant: string, workspace: string, files: DeployFile[
|
|
|
324
438
|
updated: updated.sort(),
|
|
325
439
|
removed: removed.sort(),
|
|
326
440
|
issues: deployIssues(files),
|
|
441
|
+
warnings: deployWarnings(files),
|
|
327
442
|
blockedBy: runsInFlight(tenant, workspace),
|
|
328
443
|
};
|
|
329
444
|
}
|
|
@@ -336,6 +451,15 @@ export interface DeployOptions {
|
|
|
336
451
|
message?: string;
|
|
337
452
|
/** Apply even while runs are in flight. */
|
|
338
453
|
force?: boolean;
|
|
454
|
+
/**
|
|
455
|
+
* The removals a person confirmed from a dry run. When given, the deploy
|
|
456
|
+
* is refused (applied: false, `unexpectedRemovals` set) if it would remove
|
|
457
|
+
* anything not in this list — a run can write new storage/ outputs between
|
|
458
|
+
* the question and the answer, and a yes to one list is not a yes to
|
|
459
|
+
* another. Checked in the same synchronous call that writes, so there is
|
|
460
|
+
* no window between the check and the swap. Absent: no check, as before.
|
|
461
|
+
*/
|
|
462
|
+
expectRemoved?: string[];
|
|
339
463
|
}
|
|
340
464
|
|
|
341
465
|
/**
|
|
@@ -427,6 +551,13 @@ export function deployWorkspace(
|
|
|
427
551
|
if (plan.issues.length > 0 || blocked) {
|
|
428
552
|
return { ...plan, applied: false, commit: null, preserved: 0 };
|
|
429
553
|
}
|
|
554
|
+
if (opts.expectRemoved) {
|
|
555
|
+
const ok = new Set(opts.expectRemoved.map((p) => path.normalize(p).split(path.sep).join("/")));
|
|
556
|
+
const unexpectedRemovals = plan.removed.filter((p) => !ok.has(p));
|
|
557
|
+
if (unexpectedRemovals.length) {
|
|
558
|
+
return { ...plan, applied: false, commit: null, preserved: 0, unexpectedRemovals };
|
|
559
|
+
}
|
|
560
|
+
}
|
|
430
561
|
|
|
431
562
|
const { preserved } = saveWorkspace(tenant, workspace, files, { commit: opts.commit ?? null, by: opts.by ?? "deploy", message: opts.message });
|
|
432
563
|
const commit = opts.commit ?? null;
|