@foldrun/core 0.2.0 → 0.4.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 +31 -10
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/confine.js +6 -1
- package/dist/src/deploy.js +4 -2
- package/dist/src/egress.d.ts +19 -2
- package/dist/src/egress.js +2 -1
- package/dist/src/evals.d.ts +4 -0
- package/dist/src/evals.js +20 -11
- package/dist/src/flow-lint.d.ts +4 -0
- package/dist/src/flow-lint.js +108 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/library.js +13 -1
- package/dist/src/notify.d.ts +79 -1
- package/dist/src/notify.js +243 -9
- package/dist/src/paths.d.ts +5 -0
- package/dist/src/paths.js +32 -0
- package/dist/src/platform.d.ts +112 -2
- package/dist/src/platform.js +20 -2
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +232 -0
- package/dist/src/run-container.d.ts +80 -4
- package/dist/src/run-container.js +176 -37
- package/dist/src/runner.d.ts +41 -5
- package/dist/src/runner.js +513 -81
- package/dist/src/script-tools.d.ts +12 -0
- package/dist/src/script-tools.js +10 -2
- package/dist/src/secret-health.d.ts +59 -0
- package/dist/src/secret-health.js +155 -0
- package/dist/src/secrets.d.ts +19 -1
- package/dist/src/secrets.js +44 -12
- package/dist/src/starter.js +4 -0
- package/dist/src/step-exec.d.ts +62 -6
- package/dist/src/step-exec.js +199 -68
- package/dist/src/store.d.ts +300 -3
- package/dist/src/store.js +574 -26
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-test.js +48 -21
- package/dist/src/translator.js +2 -1
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +20 -0
- package/dist/src/triggers.js +91 -10
- package/dist/src/webhook.d.ts +15 -3
- package/dist/src/webhook.js +11 -11
- package/package.json +6 -5
- package/src/approvals.ts +253 -2
- package/src/budget.ts +178 -0
- package/src/catalog.ts +2 -1
- package/src/confine.ts +6 -1
- package/src/deploy.ts +4 -1
- package/src/egress.ts +20 -4
- package/src/evals.ts +25 -9
- package/src/flow-lint.ts +118 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/library.ts +13 -1
- package/src/notify.ts +272 -11
- package/src/paths.ts +29 -0
- package/src/platform.ts +119 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +215 -35
- package/src/runner.ts +525 -73
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +4 -0
- package/src/step-exec.ts +176 -21
- package/src/storage.ts +8 -2
- package/src/store.ts +785 -25
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-test.ts +49 -20
- package/src/translator.ts +2 -1
- package/src/trigger-log.ts +159 -0
- package/src/triggers.ts +92 -10
- package/src/webhook.ts +17 -11
package/dist/src/deploy.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import fs from "node:fs";
|
|
21
21
|
import os from "node:os";
|
|
22
22
|
import path from "node:path";
|
|
23
|
-
import { saveWorkspace, workspaceDir, listRuns, parseFlow, WORKSPACE_DIRS, assertSafeName, } from "./store.js";
|
|
23
|
+
import { saveWorkspace, workspaceDir, listRuns, parseFlow, WORKSPACE_DIRS, assertSafeName, notifyWorkspaceChanged, } from "./store.js";
|
|
24
24
|
import { conformanceIssues } from "./okf.js";
|
|
25
25
|
import { repoDir } from "./gitrepo.js";
|
|
26
26
|
/** Directories a deploy never reads out of a source tree. */
|
|
@@ -307,6 +307,8 @@ export function writePushNote(tenant, workspace, note) {
|
|
|
307
307
|
catch {
|
|
308
308
|
// The note is a record, not the deploy. Losing it must not fail a push.
|
|
309
309
|
}
|
|
310
|
+
// A push waiting on a run is the scheduler's to apply; tell it there is one.
|
|
311
|
+
notifyWorkspaceChanged(tenant, workspace, "push");
|
|
310
312
|
}
|
|
311
313
|
/**
|
|
312
314
|
* A push whose deploy was refused only because a run was in flight is not a
|
|
@@ -353,7 +355,7 @@ export function deployWorkspace(tenant, workspace, files, opts = {}) {
|
|
|
353
355
|
const commit = opts.commit ?? null;
|
|
354
356
|
if (commit)
|
|
355
357
|
writeDeployedCommit(tenant, workspace, commit);
|
|
356
|
-
return { ...plan, applied: true, commit, preserved };
|
|
358
|
+
return { ...plan, applied: true, commit, preserved }; // saveWorkspace told the platform
|
|
357
359
|
}
|
|
358
360
|
// Which commit a workspace is running.
|
|
359
361
|
//
|
package/dist/src/egress.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TestEffect } from "./test-mode.ts";
|
|
1
2
|
/** `${NAME}` — the shape a secret reference takes everywhere in foldrun. */
|
|
2
3
|
export declare const PLACEHOLDER: RegExp;
|
|
3
4
|
/** The env var the pod reads to know it should send through a proxy. */
|
|
@@ -30,6 +31,11 @@ export interface EgressGrant {
|
|
|
30
31
|
value: string;
|
|
31
32
|
hosts: string[];
|
|
32
33
|
}>;
|
|
34
|
+
/** Secret name → the vault entry it came from, as secret-health keys it:
|
|
35
|
+
* "account:NAME" or "workspace:<ws>:NAME". Two workspaces each holding an
|
|
36
|
+
* API_KEY are two credentials, and one's refusal must not paint the
|
|
37
|
+
* other red. Absent for a secret whose origin the runner did not know. */
|
|
38
|
+
healthKeys?: Record<string, string>;
|
|
33
39
|
}
|
|
34
40
|
export interface EgressLease {
|
|
35
41
|
/** The base the pod sends through: `http://…/e/<token>`. */
|
|
@@ -39,21 +45,32 @@ export interface EgressLease {
|
|
|
39
45
|
* lease URL first), so a store that is not this process's memory has
|
|
40
46
|
* to be told when the grant is complete. Call before every attempt. */
|
|
41
47
|
commit(): Promise<void>;
|
|
42
|
-
/** One line per request, for the run trace. Drained by the runner.
|
|
43
|
-
|
|
48
|
+
/** One line per request, for the run trace. Drained by the runner. A
|
|
49
|
+
* line may carry a test-mode effect beside its text — what the proxy
|
|
50
|
+
* refused or redirected on a test lease — for the run page to list. */
|
|
51
|
+
drainLog(): Promise<EgressLogLine[]>;
|
|
44
52
|
/** The step is over: the lease is void and its values are dropped. */
|
|
45
53
|
release(): Promise<void>;
|
|
46
54
|
}
|
|
55
|
+
export type EgressLogLine = string | {
|
|
56
|
+
text: string;
|
|
57
|
+
effect: TestEffect;
|
|
58
|
+
};
|
|
47
59
|
/**
|
|
48
60
|
* The seam the platform fills. `null` from `lease` (or no `egress` at all)
|
|
49
61
|
* means "no proxy here" and the runner falls back to materialising, exactly
|
|
50
62
|
* as it always did.
|
|
51
63
|
*/
|
|
52
64
|
export interface EgressHooks {
|
|
65
|
+
/** `test` — the run is a test run: the proxy applies the outward-write
|
|
66
|
+
* policy in test-mode.ts to every request on this lease. Carried on the
|
|
67
|
+
* lease, not the request, because the pod is the party not trusted to
|
|
68
|
+
* say so. */
|
|
53
69
|
lease(args: {
|
|
54
70
|
tenant: string;
|
|
55
71
|
runId: string;
|
|
56
72
|
grant: EgressGrant;
|
|
73
|
+
test?: boolean;
|
|
57
74
|
}): Promise<EgressLease | null>;
|
|
58
75
|
}
|
|
59
76
|
/** Strip a header set of the proxy's own and hop-by-hop headers. */
|
package/dist/src/egress.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
// This file is the vocabulary both sides share. The server is the
|
|
35
35
|
// platform's (it needs a long-lived process and a Service); the pod side is
|
|
36
36
|
// here in core, because it is what the runner image runs.
|
|
37
|
+
import { trimSlashes } from "./paths.js";
|
|
37
38
|
/** `${NAME}` — the shape a secret reference takes everywhere in foldrun. */
|
|
38
39
|
export const PLACEHOLDER = /\$\{([A-Z][A-Z0-9_]*)\}/g;
|
|
39
40
|
/** The env var the pod reads to know it should send through a proxy. */
|
|
@@ -48,7 +49,7 @@ export const MODEL_KEY_NAME = "FOLDRUN_MODEL_KEY";
|
|
|
48
49
|
export function viaEgress(egress, target) {
|
|
49
50
|
if (!egress)
|
|
50
51
|
return target;
|
|
51
|
-
return `${egress
|
|
52
|
+
return `${trimSlashes(egress)}/${target}`;
|
|
52
53
|
}
|
|
53
54
|
/** The names referenced by placeholders in a string. */
|
|
54
55
|
export function placeholderNames(text) {
|
package/dist/src/evals.d.ts
CHANGED
|
@@ -38,6 +38,10 @@ export interface EvalInfo {
|
|
|
38
38
|
* `manual`: only when a person presses Run or the CLI asks — for a flow
|
|
39
39
|
* eval that costs a whole run and touches real systems every time. */
|
|
40
40
|
trigger: "deploy" | "manual";
|
|
41
|
+
/** An eval's runs are test runs — nothing outward, state untouched —
|
|
42
|
+
* unless the file says `live: true`. A flow eval that must really send
|
|
43
|
+
* to prove itself opts in; nothing opts in by accident. */
|
|
44
|
+
live: boolean;
|
|
41
45
|
cases: EvalCase[];
|
|
42
46
|
}
|
|
43
47
|
export declare function parseEval(file: string, raw: string): EvalInfo;
|
package/dist/src/evals.js
CHANGED
|
@@ -39,7 +39,7 @@ import { spawn } from "node:child_process";
|
|
|
39
39
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
40
40
|
import { workspaceDir, readRun, writeWorkspaceFile, resolveModel, resolveEffort, assertSafeName, } from "./store.js";
|
|
41
41
|
import { startFlowRun, loadFlow } from "./runner.js";
|
|
42
|
-
import { platform } from "./platform.js";
|
|
42
|
+
import { platform, platformQueues } from "./platform.js";
|
|
43
43
|
const ASSERTION_TYPES = ["contains", "not-contains", "matches", "file", "run", "judge"];
|
|
44
44
|
/**
|
|
45
45
|
* A scalar that may continue onto following lines.
|
|
@@ -138,6 +138,7 @@ export function parseEval(file, raw) {
|
|
|
138
138
|
model: resolveModel(data.model ?? "fast"),
|
|
139
139
|
effort: resolveEffort(data.effort ?? "low"),
|
|
140
140
|
trigger: data.trigger === "manual" ? "manual" : "deploy",
|
|
141
|
+
live: data.live === true,
|
|
141
142
|
cases,
|
|
142
143
|
};
|
|
143
144
|
}
|
|
@@ -226,7 +227,9 @@ export function promoteRunToEval(tenant, workspace, runId, opts = {}) {
|
|
|
226
227
|
let expect;
|
|
227
228
|
if (opts.expect && opts.expect.length > 0) {
|
|
228
229
|
expect = opts.expect.map((line) => {
|
|
229
|
-
const m = oneLine(line).match(/^([a-z-]+)
|
|
230
|
+
const m = oneLine(line).match(/^([a-z-]+):(.*)$/);
|
|
231
|
+
if (m)
|
|
232
|
+
m[2] = m[2].trim();
|
|
230
233
|
if (!m || !ASSERTION_TYPES.includes(m[1]) || !m[2]) {
|
|
231
234
|
throw refuse(`"${line}" is not an assertion — expected one of ${ASSERTION_TYPES.map((t) => `${t}:`).join(", ")} followed by a value`, 400);
|
|
232
235
|
}
|
|
@@ -258,7 +261,7 @@ export function promoteRunToEval(tenant, workspace, runId, opts = {}) {
|
|
|
258
261
|
if (points.kind !== target.kind || points.name !== target.name) {
|
|
259
262
|
throw refuse(`${rel} tests ${points.kind} ${points.name ?? "(nothing)"}, not ${target.kind} ${target.name} — pick another evalName`, 409);
|
|
260
263
|
}
|
|
261
|
-
content = `${existing.
|
|
264
|
+
content = `${existing.trimEnd()}\n\n${caseText}`;
|
|
262
265
|
}
|
|
263
266
|
else {
|
|
264
267
|
// A flow eval costs a whole run and touches real systems, so it waits
|
|
@@ -285,19 +288,23 @@ export function promoteRunToEval(tenant, workspace, runId, opts = {}) {
|
|
|
285
288
|
});
|
|
286
289
|
return { file: rel, created: !existing, caseName };
|
|
287
290
|
}
|
|
288
|
-
/** Wait for a run to finish, with a ceiling so a hung agent can't hang an eval. */
|
|
289
291
|
/**
|
|
290
292
|
* A web replica never drives a run: it has no route to the cluster API and
|
|
291
293
|
* no right to create a pod, by design — so an eval started there (a push,
|
|
292
294
|
* the Run button) queues its run for the worker like any other. From 29 Aug
|
|
293
295
|
* every eval on the split platform died at "pod create failed: connection
|
|
294
296
|
* refused" for exactly this reason. A single process — the CLI, a laptop,
|
|
295
|
-
*
|
|
297
|
+
* no platform registered — still drives it right here, as it always did.
|
|
298
|
+
*
|
|
299
|
+
* The test is whether a platform owns the queue, not which role this
|
|
300
|
+
* process was started as: on a worker the role read "worker", the eval
|
|
301
|
+
* started its run in-process, and the run never passed through the
|
|
302
|
+
* account cap or the lanes the platform enforces on every other run.
|
|
296
303
|
*/
|
|
297
|
-
const runsViaQueue = () =>
|
|
298
|
-
async function beginEvalRun(tenant, workspace, steps, flowName, model, effort) {
|
|
304
|
+
const runsViaQueue = () => platformQueues();
|
|
305
|
+
async function beginEvalRun(tenant, workspace, steps, flowName, model, effort, test = true) {
|
|
299
306
|
if (!runsViaQueue())
|
|
300
|
-
return startFlowRun(tenant, workspace, steps, flowName, model, [], effort);
|
|
307
|
+
return startFlowRun(tenant, workspace, steps, flowName, model, [], effort, null, { test });
|
|
301
308
|
// The job carries a model override but no effort one, and driveRun cannot
|
|
302
309
|
// re-read a flow called `eval:<name>`. Put the flow's defaults onto the
|
|
303
310
|
// steps that have none — which is what nearest-wins resolves to anyway.
|
|
@@ -306,7 +313,7 @@ async function beginEvalRun(tenant, workspace, steps, flowName, model, effort) {
|
|
|
306
313
|
...(s.model || !model ? {} : { model }),
|
|
307
314
|
...(s.effort || !effort ? {} : { effort }),
|
|
308
315
|
}));
|
|
309
|
-
return platform.enqueueFlowRun(tenant, workspace, withDefaults, flowName, model ?? null, []);
|
|
316
|
+
return platform.enqueueFlowRun(tenant, workspace, withDefaults, flowName, model ?? null, [], null, { test });
|
|
310
317
|
}
|
|
311
318
|
// Waits for as long as the run runs. There was a cap here (5 minutes, then
|
|
312
319
|
// 30) and it marked a flow eval "run disappeared" at the cap while the flow
|
|
@@ -428,12 +435,14 @@ export async function runEval(tenant, workspace, info) {
|
|
|
428
435
|
const steps = flow.steps.map((s, i) => i === 0
|
|
429
436
|
? { ...s, instruction: `${s.instruction}\n\n<run_task>\n${testCase.task}\n</run_task>` }
|
|
430
437
|
: s);
|
|
431
|
-
|
|
438
|
+
// Test unless the eval OR the flow says live: an eval that runs a
|
|
439
|
+
// flow written `live: true` was written by someone who knew.
|
|
440
|
+
run = await beginEvalRun(tenant, workspace, steps, `eval:${info.name}`, flow.model, flow.effort, !(info.live || flow.live));
|
|
432
441
|
}
|
|
433
442
|
else {
|
|
434
443
|
if (!target)
|
|
435
444
|
throw new Error("this eval names neither an agent nor a flow");
|
|
436
|
-
run = await beginEvalRun(tenant, workspace, [{ agent: target, instruction: testCase.task, group: 1, optional: false }], `eval:${info.name}
|
|
445
|
+
run = await beginEvalRun(tenant, workspace, [{ agent: target, instruction: testCase.task, group: 1, optional: false }], `eval:${info.name}`, undefined, undefined, !info.live);
|
|
437
446
|
}
|
|
438
447
|
runId = run.id;
|
|
439
448
|
const finished = await waitForRun(tenant, workspace, run.id);
|
package/dist/src/flow-lint.d.ts
CHANGED
|
@@ -6,6 +6,10 @@ export interface FlowWarning {
|
|
|
6
6
|
line?: number;
|
|
7
7
|
message: string;
|
|
8
8
|
detail: string;
|
|
9
|
+
/** "error" for a value the parser could not read — `timeout: abc` — which
|
|
10
|
+
* `foldrun check` must fail on rather than note. Absent means advisory,
|
|
11
|
+
* which every structural warning in this file is. */
|
|
12
|
+
level?: "error" | "warn";
|
|
9
13
|
}
|
|
10
14
|
/** What exists in the workspace, when the caller knows. Optional: the lint
|
|
11
15
|
* is useful without it, and a caller that has the lists gets the reference
|
package/dist/src/flow-lint.js
CHANGED
|
@@ -14,6 +14,18 @@
|
|
|
14
14
|
// Deliberately conservative — a false warning is worse than a missed one,
|
|
15
15
|
// because noisy warnings get ignored and then the real one is invisible too.
|
|
16
16
|
const REFERS_BACK = /\b(both|above|earlier|previous(ly)?|prior|the (changes|draft|research|results|findings|report|article|fixes|work)|what (they|the others?) (found|shipped|wrote|said))\b/i;
|
|
17
|
+
/** One @, something either side, a dot in the domain, no whitespace. Written
|
|
18
|
+
* as string checks rather than a regex: the obvious pattern backtracks
|
|
19
|
+
* polynomially on a domain full of dots, and a lint that can be made slow
|
|
20
|
+
* by a flow file is a lint someone will turn off. */
|
|
21
|
+
function looksLikeAddress(a) {
|
|
22
|
+
const at = a.indexOf("@");
|
|
23
|
+
if (at < 1 || at !== a.lastIndexOf("@") || /\s/.test(a))
|
|
24
|
+
return false;
|
|
25
|
+
const domain = a.slice(at + 1);
|
|
26
|
+
const dot = domain.indexOf(".");
|
|
27
|
+
return dot > 0 && dot < domain.length - 1;
|
|
28
|
+
}
|
|
17
29
|
export function lintFlow(flow, known) {
|
|
18
30
|
const warnings = [];
|
|
19
31
|
const agentNames = known ? new Set(known.agents) : null;
|
|
@@ -25,6 +37,68 @@ export function lintFlow(flow, known) {
|
|
|
25
37
|
// eleven runs where one was meant. Nothing about the expression looks wrong,
|
|
26
38
|
// which is exactly why it needs saying here rather than being discovered on
|
|
27
39
|
// the bill.
|
|
40
|
+
// An approvers list only one word can satisfy. `approvers: [editors]` is
|
|
41
|
+
// a role that is not `admins` and not an address; mayApprove would then
|
|
42
|
+
// refuse every person, and the gate could only ever be answered by an
|
|
43
|
+
// emailed link. Said here rather than discovered at the gate.
|
|
44
|
+
const odd = (flow.approvers ?? []).filter((a) => a !== "admins" && !looksLikeAddress(a));
|
|
45
|
+
if (odd.length) {
|
|
46
|
+
warnings.push({
|
|
47
|
+
step: null,
|
|
48
|
+
message: `approvers: ${odd.join(", ")} — not an email address, and the only role word is admins`,
|
|
49
|
+
detail: "Each entry is an address that may answer this flow's gates, or the word `admins`. Anything else matches nobody, and a list nobody matches locks every gate.",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// A flow with an approvers list and no gate at all is a rule the author
|
|
53
|
+
// believes is in force and which does nothing — the worst kind of safety
|
|
54
|
+
// control.
|
|
55
|
+
if (flow.approvers?.length && !flow.steps.some((s) => s.approve || s.ask)) {
|
|
56
|
+
warnings.push({
|
|
57
|
+
step: null,
|
|
58
|
+
message: "approvers: is set but no step in this flow is a gate",
|
|
59
|
+
detail: "Nothing here waits for a person, so the list has no effect. Mark the step that needs " +
|
|
60
|
+
"a second pair of eyes with `!` (or `approve: true`), or drop the approvers: line.",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// A deadline on a gate that does not exist reads as a safety net and is not
|
|
64
|
+
// one. Same reasoning, said separately because the fix is different.
|
|
65
|
+
if (flow.approveWithin && !flow.steps.some((s) => s.approve || s.ask)) {
|
|
66
|
+
warnings.push({
|
|
67
|
+
step: null,
|
|
68
|
+
message: "approve_within: is set but no step in this flow is a gate",
|
|
69
|
+
detail: "Nothing waits for a person, so nothing can expire. Mark a step with `!`, or drop the line.",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
// A throttle no schedule can ever trip: the flow cannot run more often
|
|
73
|
+
// than its cron fires, so a floor below that interval never fires either.
|
|
74
|
+
// Cheap to write, silently pointless, and a reader will believe it.
|
|
75
|
+
if (flow.throttle && flow.trigger === "schedule" && flow.schedule) {
|
|
76
|
+
warnings.push({
|
|
77
|
+
step: null,
|
|
78
|
+
message: `throttle: on a scheduled flow — check it is shorter than the schedule, or it will drop runs you meant to happen`,
|
|
79
|
+
detail: "A schedule already decides how often this runs. A throttle longer than the gap between " +
|
|
80
|
+
"fires silently skips them; if that is what you want, say it in the cron instead.",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// `idempotency:` only has a delivery to read on a trigger that receives
|
|
84
|
+
// one. On a schedule or a chained flow there is no request and no field,
|
|
85
|
+
// so the key is inert.
|
|
86
|
+
if (flow.idempotency && flow.trigger !== "webhook" && flow.trigger !== "email") {
|
|
87
|
+
warnings.push({
|
|
88
|
+
step: null,
|
|
89
|
+
message: `idempotency: has nothing to read on trigger: ${flow.trigger}`,
|
|
90
|
+
detail: "A delivery id comes off an incoming request, so this only does something for " +
|
|
91
|
+
"trigger: webhook or trigger: email. On any other trigger it is ignored.",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
// `catchup:` is about a fire that was missed, which only a clock can miss.
|
|
95
|
+
if (flow.catchup && flow.trigger !== "schedule") {
|
|
96
|
+
warnings.push({
|
|
97
|
+
step: null,
|
|
98
|
+
message: `catchup: has no effect on trigger: ${flow.trigger}`,
|
|
99
|
+
detail: "Only a schedule can miss a fire. Every other trigger happens when it happens.",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
28
102
|
if (flow.schedule) {
|
|
29
103
|
const [, , dom, , dow] = flow.schedule.trim().split(/\s+/);
|
|
30
104
|
if (dom && dow && dom !== "*" && dow !== "*") {
|
|
@@ -39,6 +113,9 @@ export function lintFlow(flow, known) {
|
|
|
39
113
|
}
|
|
40
114
|
// Trigger shapes that can never fire, said at check time rather than
|
|
41
115
|
// discovered as a flow that "never runs".
|
|
116
|
+
if (flow.budgetProblem) {
|
|
117
|
+
warnings.push({ step: null, message: flow.budgetProblem, detail: "A flow's `budget:` is the most one run may spend — a number in USD, or `unlimited`. A cap over a day, week or month belongs in the workspace's or the account's AGENTS.md, where it means every run put together." });
|
|
118
|
+
}
|
|
42
119
|
if (flow.trigger === "once" && !flow.at) {
|
|
43
120
|
warnings.push({ step: null, message: "trigger: once needs an `at:` instant", detail: "Write `at: 2026-09-05T09:00:00+10:00` (ISO 8601). Without a readable instant this flow never fires." });
|
|
44
121
|
}
|
|
@@ -62,6 +139,20 @@ export function lintFlow(flow, known) {
|
|
|
62
139
|
const firstGroup = Math.min(...flow.steps.map((s) => s.group));
|
|
63
140
|
flow.steps.forEach((step, i) => {
|
|
64
141
|
const inFirstGroup = step.group === firstGroup;
|
|
142
|
+
// A value the parser refused. Not advisory: the run would go ahead on
|
|
143
|
+
// a number the author never wrote — `timeout: 15m` ran as one second
|
|
144
|
+
// for as long as this was a coercion — so the check fails instead.
|
|
145
|
+
for (const problem of step.problems ?? []) {
|
|
146
|
+
warnings.push({
|
|
147
|
+
step: i,
|
|
148
|
+
line: step.line,
|
|
149
|
+
level: "error",
|
|
150
|
+
message: problem,
|
|
151
|
+
detail: "The option is written but its value cannot be read, and a run would not do what the " +
|
|
152
|
+
"line says. Durations are seconds or 90s / 30m / 4h / 3d; counts are whole numbers " +
|
|
153
|
+
"within the key's cap (retry and loop at most 5, max at most 20).",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
65
156
|
// The first group starts with context = null. An instruction that points
|
|
66
157
|
// at earlier output therefore points at nothing.
|
|
67
158
|
if (inFirstGroup && step.instruction && REFERS_BACK.test(step.instruction)) {
|
|
@@ -102,6 +193,23 @@ export function lintFlow(flow, known) {
|
|
|
102
193
|
});
|
|
103
194
|
}
|
|
104
195
|
}
|
|
196
|
+
// Two options that read another option to mean anything.
|
|
197
|
+
if (step.parallel && !step.each) {
|
|
198
|
+
warnings.push({
|
|
199
|
+
step: i,
|
|
200
|
+
line: step.line,
|
|
201
|
+
message: `"${step.subflow ?? step.agent}" has parallel: but no each:`,
|
|
202
|
+
detail: "parallel: caps how many instances of a fan-out run at once. Without each: there is one instance, and the line does nothing.",
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if ((step.schema !== undefined || step.schemaPath) && step.output !== "json") {
|
|
206
|
+
warnings.push({
|
|
207
|
+
step: i,
|
|
208
|
+
line: step.line,
|
|
209
|
+
message: `"${step.subflow ?? step.agent}" has schema: but no output: json`,
|
|
210
|
+
detail: "schema: describes the JSON value an output: json step returns. Without output: json nothing is parsed, so nothing is checked. Add `output: json`.",
|
|
211
|
+
});
|
|
212
|
+
}
|
|
105
213
|
// A step's OTHER agent references — the ones no parser validates,
|
|
106
214
|
// because they are options rather than the step's target. Both fail
|
|
107
215
|
// silently and at the worst possible moment: a misspelled `delegate:`
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Does this name pass the allowlist? Exported so a test can pin the rule
|
|
2
|
+
* rather than the list. */
|
|
3
|
+
export declare function hostEnvAllowed(name: string): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* process.env, reduced to the allowlist. A fresh object every call, so a
|
|
6
|
+
* caller that layers secrets on top never writes into the host's own env.
|
|
7
|
+
*/
|
|
8
|
+
export declare function hostSafeEnv(from?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The environment a step's children start from, on the in-process path.
|
|
2
|
+
//
|
|
3
|
+
// Scripts, `verify:` shells and the SDK's own Bash tool used to inherit the
|
|
4
|
+
// whole of process.env — which on a platform holds FOLDRUN_SECRET_KEY (the
|
|
5
|
+
// key every tenant's vault and hook token derives from), the database URL,
|
|
6
|
+
// the object-store and mail credentials, the Stripe key. `console.log(
|
|
7
|
+
// process.env)` in a script tool was cross-tenant compromise. tool-test.ts
|
|
8
|
+
// built a stripped base for the Test button; this is that base, made the one
|
|
9
|
+
// definition, used everywhere a child is spawned host-side.
|
|
10
|
+
//
|
|
11
|
+
// A run container needs none of this: its process.env IS the boundary,
|
|
12
|
+
// assembled from an allowlist on the host before the container started.
|
|
13
|
+
//
|
|
14
|
+
// What passes is what an interpreter needs to be found and to run, the
|
|
15
|
+
// locale, the clock, the network's proxy settings, and the identifiers the
|
|
16
|
+
// runner stamps on a step (FOLDRUN_RUN_*, FOLDRUN_STEP_*) so a script can
|
|
17
|
+
// say which run made its records. The secrets an agent declares are layered
|
|
18
|
+
// on top by the caller — by name, from the vault, never from the host.
|
|
19
|
+
const EXACT = new Set([
|
|
20
|
+
"PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LANGUAGE",
|
|
21
|
+
"TZ", "TERM", "SHELL", "USER", "LOGNAME",
|
|
22
|
+
// The host's way to the network is the child's way to the network.
|
|
23
|
+
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
|
|
24
|
+
"SSL_CERT_FILE", "SSL_CERT_DIR",
|
|
25
|
+
// Windows equivalents, harmless elsewhere.
|
|
26
|
+
"SYSTEMROOT", "COMSPEC", "PATHEXT", "WINDIR",
|
|
27
|
+
]);
|
|
28
|
+
const PREFIXES = ["LC_", "NODE_", "FOLDRUN_RUN_", "FOLDRUN_STEP_"];
|
|
29
|
+
/** Does this name pass the allowlist? Exported so a test can pin the rule
|
|
30
|
+
* rather than the list. */
|
|
31
|
+
export function hostEnvAllowed(name) {
|
|
32
|
+
return EXACT.has(name) || PREFIXES.some((p) => name.startsWith(p));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* process.env, reduced to the allowlist. A fresh object every call, so a
|
|
36
|
+
* caller that layers secrets on top never writes into the host's own env.
|
|
37
|
+
*/
|
|
38
|
+
export function hostSafeEnv(from = process.env) {
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const [k, v] of Object.entries(from)) {
|
|
41
|
+
if (typeof v === "string" && hostEnvAllowed(k))
|
|
42
|
+
out[k] = v;
|
|
43
|
+
}
|
|
44
|
+
// Typed as ProcessEnv, not a string map: a host whose type augmentation
|
|
45
|
+
// makes a key required (Next declares NODE_ENV so) refuses a plain map at
|
|
46
|
+
// every spawn. The value is the same either way.
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface SchemaError {
|
|
2
|
+
/** JSON pointer to the offending value: "" is the root, "/leads/0/url". */
|
|
3
|
+
path: string;
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
/** Is `value` what `schema` describes? Empty means yes. */
|
|
7
|
+
export declare function validateSchema(value: unknown, schema: unknown): SchemaError[];
|
|
8
|
+
/** The errors as one sentence for a trace line. */
|
|
9
|
+
export declare function describeSchemaErrors(errors: SchemaError[]): string;
|
|
10
|
+
/** Is this a schema at all? An object (or a boolean, which JSON Schema
|
|
11
|
+
* allows) — not a string, not a list. */
|
|
12
|
+
export declare function looksLikeSchema(schema: unknown): boolean;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// A JSON Schema validator small enough to read, with no dependency.
|
|
2
|
+
//
|
|
3
|
+
// `schema:` beside `output: json` says what shape the reply's value must
|
|
4
|
+
// have, and a value that does not have it fails the step with a sentence
|
|
5
|
+
// naming the field. The subset here is the part of JSON Schema people write
|
|
6
|
+
// by hand for a handoff — types, required fields, enums, ranges, patterns,
|
|
7
|
+
// arrays, the combinators, and `$ref` to a definition in the same document.
|
|
8
|
+
// Formats (`date-time`, `email`) are accepted and not checked; a check that
|
|
9
|
+
// needs one belongs in a `verify:`. Anything the validator does not know is
|
|
10
|
+
// ignored, the way JSON Schema itself says unknown keywords are.
|
|
11
|
+
//
|
|
12
|
+
// Not ajv: it sits in node_modules as a dependency of a dependency, which is
|
|
13
|
+
// nothing to build on, and the whole of it is far more than a step needs.
|
|
14
|
+
const MAX_ERRORS = 8;
|
|
15
|
+
/** Is `value` what `schema` describes? Empty means yes. */
|
|
16
|
+
export function validateSchema(value, schema) {
|
|
17
|
+
const errors = [];
|
|
18
|
+
check(value, schema, "", schema, errors, 0);
|
|
19
|
+
return errors.slice(0, MAX_ERRORS);
|
|
20
|
+
}
|
|
21
|
+
/** The errors as one sentence for a trace line. */
|
|
22
|
+
export function describeSchemaErrors(errors) {
|
|
23
|
+
return errors.map((e) => `${e.path || "the value"}: ${e.message}`).join("; ");
|
|
24
|
+
}
|
|
25
|
+
/** Is this a schema at all? An object (or a boolean, which JSON Schema
|
|
26
|
+
* allows) — not a string, not a list. */
|
|
27
|
+
export function looksLikeSchema(schema) {
|
|
28
|
+
return typeof schema === "boolean" || (typeof schema === "object" && schema !== null && !Array.isArray(schema));
|
|
29
|
+
}
|
|
30
|
+
const typeOf = (v) => v === null ? "null" : Array.isArray(v) ? "array" : typeof v === "number" ? (Number.isInteger(v) ? "integer" : "number") : typeof v;
|
|
31
|
+
const matchesType = (v, t) => {
|
|
32
|
+
const actual = typeOf(v);
|
|
33
|
+
return t === actual || (t === "number" && actual === "integer");
|
|
34
|
+
};
|
|
35
|
+
function resolveRef(ref, root) {
|
|
36
|
+
if (!ref.startsWith("#"))
|
|
37
|
+
return null;
|
|
38
|
+
let node = root;
|
|
39
|
+
for (const raw of ref.slice(1).split("/").filter(Boolean)) {
|
|
40
|
+
const key = raw.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
41
|
+
if (typeof node !== "object" || node === null)
|
|
42
|
+
return null;
|
|
43
|
+
node = node[key];
|
|
44
|
+
}
|
|
45
|
+
return typeof node === "object" && node !== null ? node : typeof node === "boolean" ? node : null;
|
|
46
|
+
}
|
|
47
|
+
function check(value, schema, path, root, errors, depth) {
|
|
48
|
+
if (errors.length >= MAX_ERRORS || depth > 64)
|
|
49
|
+
return;
|
|
50
|
+
if (schema === true)
|
|
51
|
+
return;
|
|
52
|
+
if (schema === false)
|
|
53
|
+
return void errors.push({ path, message: "nothing is allowed here" });
|
|
54
|
+
if (typeof schema !== "object" || schema === null)
|
|
55
|
+
return;
|
|
56
|
+
const s = schema;
|
|
57
|
+
const fail = (message) => errors.push({ path, message });
|
|
58
|
+
if (typeof s.$ref === "string") {
|
|
59
|
+
const target = resolveRef(s.$ref, root);
|
|
60
|
+
if (target === null)
|
|
61
|
+
return fail(`schema: unresolvable $ref ${s.$ref}`);
|
|
62
|
+
check(value, target, path, root, errors, depth + 1);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// type — one, or a list of alternatives; "nullable: true" (OpenAPI's
|
|
66
|
+
// spelling) lets null through.
|
|
67
|
+
if (s.type !== undefined) {
|
|
68
|
+
const types = Array.isArray(s.type) ? s.type : [String(s.type)];
|
|
69
|
+
const ok = types.some((t) => matchesType(value, t)) || (s.nullable === true && value === null);
|
|
70
|
+
if (!ok)
|
|
71
|
+
return fail(`expected ${types.join(" or ")}, got ${typeOf(value)}`);
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(s.enum) && !s.enum.some((e) => deepEqual(e, value))) {
|
|
74
|
+
return fail(`must be one of ${s.enum.map((e) => JSON.stringify(e)).join(", ")}`);
|
|
75
|
+
}
|
|
76
|
+
if ("const" in s && !deepEqual(s.const, value))
|
|
77
|
+
return fail(`must be ${JSON.stringify(s.const)}`);
|
|
78
|
+
if (typeof value === "string") {
|
|
79
|
+
if (typeof s.minLength === "number" && value.length < s.minLength)
|
|
80
|
+
fail(`shorter than ${s.minLength} characters`);
|
|
81
|
+
if (typeof s.maxLength === "number" && value.length > s.maxLength)
|
|
82
|
+
fail(`longer than ${s.maxLength} characters`);
|
|
83
|
+
if (typeof s.pattern === "string") {
|
|
84
|
+
try {
|
|
85
|
+
if (!new RegExp(s.pattern, "u").test(value))
|
|
86
|
+
fail(`does not match ${s.pattern}`);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
fail(`schema: invalid pattern ${s.pattern}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (typeof value === "number") {
|
|
94
|
+
if (typeof s.minimum === "number" && value < s.minimum)
|
|
95
|
+
fail(`below the minimum ${s.minimum}`);
|
|
96
|
+
if (typeof s.maximum === "number" && value > s.maximum)
|
|
97
|
+
fail(`above the maximum ${s.maximum}`);
|
|
98
|
+
if (typeof s.exclusiveMinimum === "number" && value <= s.exclusiveMinimum)
|
|
99
|
+
fail(`must be above ${s.exclusiveMinimum}`);
|
|
100
|
+
if (typeof s.exclusiveMaximum === "number" && value >= s.exclusiveMaximum)
|
|
101
|
+
fail(`must be below ${s.exclusiveMaximum}`);
|
|
102
|
+
if (typeof s.multipleOf === "number" && s.multipleOf > 0 && Math.abs(value / s.multipleOf - Math.round(value / s.multipleOf)) > 1e-9) {
|
|
103
|
+
fail(`not a multiple of ${s.multipleOf}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (Array.isArray(value)) {
|
|
107
|
+
if (typeof s.minItems === "number" && value.length < s.minItems)
|
|
108
|
+
fail(`fewer than ${s.minItems} items`);
|
|
109
|
+
if (typeof s.maxItems === "number" && value.length > s.maxItems)
|
|
110
|
+
fail(`more than ${s.maxItems} items`);
|
|
111
|
+
if (s.uniqueItems === true) {
|
|
112
|
+
const seen = new Set(value.map((v) => JSON.stringify(v)));
|
|
113
|
+
if (seen.size !== value.length)
|
|
114
|
+
fail("items are not unique");
|
|
115
|
+
}
|
|
116
|
+
if (s.items !== undefined && !Array.isArray(s.items)) {
|
|
117
|
+
value.forEach((item, i) => check(item, s.items, `${path}/${i}`, root, errors, depth + 1));
|
|
118
|
+
}
|
|
119
|
+
else if (Array.isArray(s.items) || Array.isArray(s.prefixItems)) {
|
|
120
|
+
const tuple = (Array.isArray(s.prefixItems) ? s.prefixItems : s.items);
|
|
121
|
+
tuple.forEach((sub, i) => i < value.length && check(value[i], sub, `${path}/${i}`, root, errors, depth + 1));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
125
|
+
const obj = value;
|
|
126
|
+
const props = (typeof s.properties === "object" && s.properties !== null ? s.properties : {});
|
|
127
|
+
if (Array.isArray(s.required)) {
|
|
128
|
+
for (const key of s.required)
|
|
129
|
+
if (!(key in obj))
|
|
130
|
+
fail(`missing required field "${key}"`);
|
|
131
|
+
}
|
|
132
|
+
for (const [key, sub] of Object.entries(props)) {
|
|
133
|
+
if (key in obj)
|
|
134
|
+
check(obj[key], sub, `${path}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`, root, errors, depth + 1);
|
|
135
|
+
}
|
|
136
|
+
const patterns = (typeof s.patternProperties === "object" && s.patternProperties !== null ? s.patternProperties : {});
|
|
137
|
+
for (const key of Object.keys(obj)) {
|
|
138
|
+
if (key in props)
|
|
139
|
+
continue;
|
|
140
|
+
const pattern = Object.entries(patterns).find(([re]) => {
|
|
141
|
+
try {
|
|
142
|
+
return new RegExp(re, "u").test(key);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
if (pattern) {
|
|
149
|
+
check(obj[key], pattern[1], `${path}/${key}`, root, errors, depth + 1);
|
|
150
|
+
}
|
|
151
|
+
else if (s.additionalProperties === false) {
|
|
152
|
+
fail(`unexpected field "${key}"`);
|
|
153
|
+
}
|
|
154
|
+
else if (typeof s.additionalProperties === "object" && s.additionalProperties !== null) {
|
|
155
|
+
check(obj[key], s.additionalProperties, `${path}/${key}`, root, errors, depth + 1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (typeof s.minProperties === "number" && Object.keys(obj).length < s.minProperties)
|
|
159
|
+
fail(`fewer than ${s.minProperties} fields`);
|
|
160
|
+
if (typeof s.maxProperties === "number" && Object.keys(obj).length > s.maxProperties)
|
|
161
|
+
fail(`more than ${s.maxProperties} fields`);
|
|
162
|
+
}
|
|
163
|
+
if (Array.isArray(s.allOf)) {
|
|
164
|
+
for (const sub of s.allOf)
|
|
165
|
+
check(value, sub, path, root, errors, depth + 1);
|
|
166
|
+
}
|
|
167
|
+
if (Array.isArray(s.anyOf)) {
|
|
168
|
+
const ok = s.anyOf.some((sub) => validateSchema(value, withRoot(sub, root)).length === 0);
|
|
169
|
+
if (!ok)
|
|
170
|
+
fail("matches none of the allowed shapes (anyOf)");
|
|
171
|
+
}
|
|
172
|
+
if (Array.isArray(s.oneOf)) {
|
|
173
|
+
const n = s.oneOf.filter((sub) => validateSchema(value, withRoot(sub, root)).length === 0).length;
|
|
174
|
+
if (n !== 1)
|
|
175
|
+
fail(n === 0 ? "matches none of the allowed shapes (oneOf)" : `matches ${n} of the shapes, must match exactly one (oneOf)`);
|
|
176
|
+
}
|
|
177
|
+
if (s.not !== undefined && validateSchema(value, withRoot(s.not, root)).length === 0)
|
|
178
|
+
fail("matches a shape that is not allowed (not)");
|
|
179
|
+
}
|
|
180
|
+
/** A sub-schema checked on its own still resolves `$ref` against the
|
|
181
|
+
* document it came from: carry the root's definitions along. */
|
|
182
|
+
function withRoot(sub, root) {
|
|
183
|
+
if (typeof sub !== "object" || sub === null || typeof root !== "object" || root === null)
|
|
184
|
+
return sub;
|
|
185
|
+
const defs = {};
|
|
186
|
+
for (const key of ["$defs", "definitions"])
|
|
187
|
+
if (key in root)
|
|
188
|
+
defs[key] = root[key];
|
|
189
|
+
return { ...defs, ...sub };
|
|
190
|
+
}
|
|
191
|
+
function deepEqual(a, b) {
|
|
192
|
+
if (a === b)
|
|
193
|
+
return true;
|
|
194
|
+
if (typeof a !== typeof b || a === null || b === null || typeof a !== "object")
|
|
195
|
+
return false;
|
|
196
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
197
|
+
return false;
|
|
198
|
+
const ka = Object.keys(a), kb = Object.keys(b);
|
|
199
|
+
if (ka.length !== kb.length)
|
|
200
|
+
return false;
|
|
201
|
+
return ka.every((k) => deepEqual(a[k], b[k]));
|
|
202
|
+
}
|
package/dist/src/library.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// Resolution is nearest-wins, the same rule OKF uses for knowledge: an agent's
|
|
11
11
|
// own file beats its workspace's, which beats the account's. So a team can set
|
|
12
12
|
// a house style once and one agent can still override it.
|
|
13
|
+
import { platform } from "./platform.js";
|
|
13
14
|
import fs from "node:fs";
|
|
14
15
|
import path from "node:path";
|
|
15
16
|
import { dataRoot, singleWorkspace } from "./paths.js";
|
|
@@ -213,7 +214,18 @@ export function deleteLibraryPath(tenant, kind, rel, meta = {}) {
|
|
|
213
214
|
// ---------- consumed by the runner ----------
|
|
214
215
|
/** Library tool definitions, keyed by name — http or script, both grantable by name in `tools:`. */
|
|
215
216
|
export function libraryTools(tenant) {
|
|
216
|
-
|
|
217
|
+
// The platform's gallery first, the account's own copies over it: a tool
|
|
218
|
+
// the platform ships is granted by name in every account without anyone
|
|
219
|
+
// installing it, and an account that installed (copied) one keeps its
|
|
220
|
+
// copy — that is what "install to customise" means. Both read at account
|
|
221
|
+
// scope, so a folder tool's `run:` resolves to /library/tools/<name>/…
|
|
222
|
+
// in the sandbox either way; the staging step lays the gallery down
|
|
223
|
+
// first and the account library over it for the same reason.
|
|
224
|
+
const gallery = platform.galleryDir();
|
|
225
|
+
return {
|
|
226
|
+
...(gallery ? readToolDir(path.join(gallery, "tools"), "account") : {}),
|
|
227
|
+
...readToolDir(libraryDir(tenant, "tools"), "account"),
|
|
228
|
+
};
|
|
217
229
|
}
|
|
218
230
|
/** Shared memory index — derived from the files, like every other scope. */
|
|
219
231
|
export function libraryMemoryIndex(tenant) {
|