@foldrun/core 9.0.0 → 9.2.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/dist/src/a2a-client.d.ts +165 -0
- package/dist/src/a2a-client.js +440 -0
- package/dist/src/a2a-publish.d.ts +37 -0
- package/dist/src/a2a-publish.js +116 -0
- package/dist/src/a2a-remotes.d.ts +73 -0
- package/dist/src/a2a-remotes.js +192 -0
- package/dist/src/a2a-tools.d.ts +148 -0
- package/dist/src/a2a-tools.js +833 -0
- package/dist/src/api-tools.js +15 -10
- package/dist/src/approvals.d.ts +18 -10
- package/dist/src/approvals.js +51 -1
- package/dist/src/browser-pod.js +4 -0
- package/dist/src/catalog.d.ts +14 -0
- package/dist/src/catalog.js +28 -0
- package/dist/src/completions.js +8 -8
- package/dist/src/cron.d.ts +9 -0
- package/dist/src/cron.js +20 -0
- package/dist/src/frontmatter-fields.js +3 -1
- package/dist/src/frontmatter-schema.d.ts +5 -1
- package/dist/src/frontmatter-schema.js +44 -1
- package/dist/src/limits.js +22 -4
- package/dist/src/notify.d.ts +6 -3
- package/dist/src/notify.js +25 -12
- package/dist/src/package.d.ts +5 -1
- package/dist/src/package.js +5 -3
- package/dist/src/run-container.d.ts +10 -1
- package/dist/src/run-container.js +17 -0
- package/dist/src/runner.d.ts +10 -0
- package/dist/src/runner.js +163 -16
- package/dist/src/scan.js +123 -5
- package/dist/src/secrets.d.ts +122 -10
- package/dist/src/secrets.js +291 -27
- package/dist/src/starter.js +5 -1
- package/dist/src/store.d.ts +34 -0
- package/dist/src/store.js +28 -7
- package/dist/src/subagents.d.ts +16 -0
- package/dist/src/subagents.js +23 -2
- package/dist/src/triggers.js +2 -2
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +14 -5
- package/package.json +4 -1
- package/src/a2a-client.ts +517 -0
- package/src/a2a-publish.ts +120 -0
- package/src/a2a-remotes.ts +225 -0
- package/src/a2a-tools.ts +919 -0
- package/src/api-tools.ts +20 -8
- package/src/approvals.ts +55 -1
- package/src/browser-pod.ts +3 -0
- package/src/catalog.ts +27 -0
- package/src/completions.ts +8 -8
- package/src/cron.ts +19 -0
- package/src/frontmatter-fields.ts +3 -1
- package/src/frontmatter-schema.ts +42 -2
- package/src/limits.ts +22 -4
- package/src/notify.ts +23 -12
- package/src/package.ts +5 -3
- package/src/run-container.ts +22 -0
- package/src/runner.ts +157 -13
- package/src/scan.ts +146 -6
- package/src/secrets.ts +319 -33
- package/src/starter.ts +5 -1
- package/src/store.ts +47 -2
- package/src/subagents.ts +29 -2
- package/src/triggers.ts +2 -2
- package/src/webhook.ts +22 -5
package/dist/src/api-tools.js
CHANGED
|
@@ -17,7 +17,7 @@ import { EGRESS_ENV, viaEgress } from "./egress.js";
|
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
19
19
|
import { previewOrKeep, RESULT_KEEP_CHARS } from "./tool-output.js";
|
|
20
|
-
import { resolveSecrets,
|
|
20
|
+
import { resolveSecrets, materializeCurrent } from "./secrets.js";
|
|
21
21
|
// Collected whole up to this; the model sees a preview and where the rest is
|
|
22
22
|
// kept (tool-output.ts), not a cut with the rest gone.
|
|
23
23
|
const MAX_BODY_CHARS = RESULT_KEEP_CHARS;
|
|
@@ -97,7 +97,7 @@ export function typedToolName(apiName, opId) {
|
|
|
97
97
|
*/
|
|
98
98
|
async function performRequest(api, env, bucket, log, req,
|
|
99
99
|
/** Where a long body is kept, when the agent can read it back (tool-output.ts). */
|
|
100
|
-
keepIn) {
|
|
100
|
+
keepIn, scope = null) {
|
|
101
101
|
const started = Date.now();
|
|
102
102
|
try {
|
|
103
103
|
const waited = bucket ? await bucket.take() : 0;
|
|
@@ -105,8 +105,11 @@ keepIn) {
|
|
|
105
105
|
// Per call, not per build: an oauth2 secret's recipe becomes a
|
|
106
106
|
// live access token here, so a step that runs for an hour keeps
|
|
107
107
|
// getting fresh tokens (the exchange is cached until near expiry)
|
|
108
|
-
// — and a recipe can never end up inside a header.
|
|
109
|
-
|
|
108
|
+
// — and a recipe can never end up inside a header. On a host, the
|
|
109
|
+
// vault's recipe as it is now, made live in the account's scope: a
|
|
110
|
+
// rotating refresh token the runner (or an earlier call) spent is never
|
|
111
|
+
// sent again (secrets.ts materializeCurrent).
|
|
112
|
+
const live = await materializeCurrent(env, scope);
|
|
110
113
|
const path = req.path.startsWith("/") ? req.path : `/${req.path}`;
|
|
111
114
|
const url = new URL(api.base + path);
|
|
112
115
|
// Base-URL confinement: a path can't escape the declared host.
|
|
@@ -183,7 +186,7 @@ function argKeys(op) {
|
|
|
183
186
|
}
|
|
184
187
|
return out;
|
|
185
188
|
}
|
|
186
|
-
function typedTool(api, op, env, bucket, log, keepIn) {
|
|
189
|
+
function typedTool(api, op, env, bucket, log, keepIn, scope = null) {
|
|
187
190
|
const keys = argKeys(op);
|
|
188
191
|
const shape = {};
|
|
189
192
|
for (const [key, p] of keys)
|
|
@@ -235,10 +238,10 @@ function typedTool(api, op, env, bucket, log, keepIn) {
|
|
|
235
238
|
query,
|
|
236
239
|
headers,
|
|
237
240
|
body: typeof values.body === "string" ? values.body : undefined,
|
|
238
|
-
}, keepIn);
|
|
241
|
+
}, keepIn, scope);
|
|
239
242
|
});
|
|
240
243
|
}
|
|
241
|
-
function genericTool(api, env, bucket, log, keepIn) {
|
|
244
|
+
function genericTool(api, env, bucket, log, keepIn, scope = null) {
|
|
242
245
|
const typed = api.resolvedOperations?.length
|
|
243
246
|
? ` Prefer the typed ${api.name}_* tools; this one is the escape hatch for operations they do not cover.`
|
|
244
247
|
: "";
|
|
@@ -258,7 +261,7 @@ function genericTool(api, env, bucket, log, keepIn) {
|
|
|
258
261
|
query: args.query ?? {},
|
|
259
262
|
headers: {},
|
|
260
263
|
body: args.body,
|
|
261
|
-
}, keepIn));
|
|
264
|
+
}, keepIn, scope));
|
|
262
265
|
}
|
|
263
266
|
export function buildApiTools(tenant, apis, workspace,
|
|
264
267
|
// The runner container passes this: secrets were resolved before the specs
|
|
@@ -277,6 +280,8 @@ keepIn) {
|
|
|
277
280
|
missing: resolved.missing,
|
|
278
281
|
}
|
|
279
282
|
: resolveSecrets(tenant, neededSecrets, workspace);
|
|
283
|
+
// Read from the vault here: made live per call in its account's scope.
|
|
284
|
+
const scope = !resolved && tenant ? { tenant, ...(workspace ? { workspace } : {}) } : null;
|
|
280
285
|
const log = [];
|
|
281
286
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- the SDK types its own list this way
|
|
282
287
|
const tools = [];
|
|
@@ -311,12 +316,12 @@ keepIn) {
|
|
|
311
316
|
}
|
|
312
317
|
const typedNames = [];
|
|
313
318
|
for (const op of ops) {
|
|
314
|
-
tools.push(typedTool(api, op, env, bucket, log, keepIn));
|
|
319
|
+
tools.push(typedTool(api, op, env, bucket, log, keepIn, scope));
|
|
315
320
|
typedNames.push(typedToolName(api.name, op.id));
|
|
316
321
|
methods[`mcp__foldrun_apis__${typedToolName(api.name, op.id)}`] = String(op.method ?? "").toUpperCase();
|
|
317
322
|
}
|
|
318
323
|
if (withGeneric)
|
|
319
|
-
tools.push(genericTool(api, env, bucket, log, keepIn));
|
|
324
|
+
tools.push(genericTool(api, env, bucket, log, keepIn, scope));
|
|
320
325
|
const names = [...typedNames, ...(withGeneric ? [`call_${api.name}`] : [])];
|
|
321
326
|
for (const n of names) {
|
|
322
327
|
toolNames.push(`mcp__foldrun_apis__${n}`);
|
package/dist/src/approvals.d.ts
CHANGED
|
@@ -127,19 +127,27 @@ export declare function decideApproval(tenant: string, workspace: string, runId:
|
|
|
127
127
|
*/
|
|
128
128
|
export declare function expireStaleGate(tenant: string, workspace: string, run: RunRecord, now?: number): Promise<boolean>;
|
|
129
129
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* a park was opened through.
|
|
130
|
+
* What a presented event token opens on a run (30 Oct 2026): `known` when
|
|
131
|
+
* it is a real token of this run — one of its parks, or the old per-run
|
|
132
|
+
* form — and `steps`, the waiting steps it releases now (none: a 409, the
|
|
133
|
+
* gate it was for is no longer waiting).
|
|
135
134
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
135
|
+
* With `step` (the `&step=` every URL printed since carries), the token is
|
|
136
|
+
* that step's, bound to the moment it parked: the `t` of its "waiting for an
|
|
137
|
+
* external event" line. It releases that step only while that park is the
|
|
138
|
+
* step's current one — a released step is no longer waiting, and a step
|
|
139
|
+
* parked again (a loop) has a new `t` — so a URL handed out for one gate
|
|
140
|
+
* opens neither a later gate nor a later park.
|
|
138
141
|
*
|
|
139
|
-
* `
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
+
* Without `step`, the per-run token URLs carried before then. It releases
|
|
143
|
+
* only a step still waiting under such a URL — its park line has no
|
|
144
|
+
* `step=` — so a run parked when this shipped can still be released, and
|
|
145
|
+
* nothing parked since can be released by the old form.
|
|
142
146
|
*/
|
|
147
|
+
export declare function eventGatesFor(tenant: string, workspace: string, run: RunRecord, presented: string, step: number | null): {
|
|
148
|
+
known: boolean;
|
|
149
|
+
steps: number[];
|
|
150
|
+
};
|
|
143
151
|
export declare function deliverEvent(tenant: string, workspace: string, runId: string, payload: string, opts?: {
|
|
144
152
|
by?: string;
|
|
145
153
|
step?: number;
|
package/dist/src/approvals.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import crypto from "node:crypto";
|
|
13
13
|
import { readRun, writeRun, readFlow, mayApprove } from "./store.js";
|
|
14
14
|
import { platform } from "./platform.js";
|
|
15
|
-
import { approveToken, installKey } from "./webhook.js";
|
|
15
|
+
import { approveToken, eventToken, installKey } from "./webhook.js";
|
|
16
16
|
// ----------------------------------------------------------------- links
|
|
17
17
|
/** How long an emailed link decides for when the flow does not say. */
|
|
18
18
|
export const APPROVE_LINK_DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -276,6 +276,56 @@ export async function expireStaleGate(tenant, workspace, run, now = Date.now())
|
|
|
276
276
|
* key through the dashboard's approve route — and `step` releases one
|
|
277
277
|
* waiting step rather than all of them.
|
|
278
278
|
*/
|
|
279
|
+
const EVENT_WAIT_LINE = "waiting for an external event — release it with POST ";
|
|
280
|
+
/**
|
|
281
|
+
* What a presented event token opens on a run (30 Oct 2026): `known` when
|
|
282
|
+
* it is a real token of this run — one of its parks, or the old per-run
|
|
283
|
+
* form — and `steps`, the waiting steps it releases now (none: a 409, the
|
|
284
|
+
* gate it was for is no longer waiting).
|
|
285
|
+
*
|
|
286
|
+
* With `step` (the `&step=` every URL printed since carries), the token is
|
|
287
|
+
* that step's, bound to the moment it parked: the `t` of its "waiting for an
|
|
288
|
+
* external event" line. It releases that step only while that park is the
|
|
289
|
+
* step's current one — a released step is no longer waiting, and a step
|
|
290
|
+
* parked again (a loop) has a new `t` — so a URL handed out for one gate
|
|
291
|
+
* opens neither a later gate nor a later park.
|
|
292
|
+
*
|
|
293
|
+
* Without `step`, the per-run token URLs carried before then. It releases
|
|
294
|
+
* only a step still waiting under such a URL — its park line has no
|
|
295
|
+
* `step=` — so a run parked when this shipped can still be released, and
|
|
296
|
+
* nothing parked since can be released by the old form.
|
|
297
|
+
*/
|
|
298
|
+
export function eventGatesFor(tenant, workspace, run, presented, step) {
|
|
299
|
+
const none = { known: false, steps: [] };
|
|
300
|
+
if (!presented)
|
|
301
|
+
return none;
|
|
302
|
+
const same = (want) => {
|
|
303
|
+
const a = Buffer.from(presented), b = Buffer.from(want);
|
|
304
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
305
|
+
};
|
|
306
|
+
const parks = (i) => (run.steps[i]?.events ?? []).filter((e) => e?.text?.startsWith(EVENT_WAIT_LINE));
|
|
307
|
+
const waiting = (i) => {
|
|
308
|
+
const s = run.steps[i];
|
|
309
|
+
return !!s && s.status === "awaiting-approval" && s.waitFor === "event";
|
|
310
|
+
};
|
|
311
|
+
if (step !== null) {
|
|
312
|
+
const lines = parks(step);
|
|
313
|
+
const hit = lines.findIndex((e) => same(eventToken(tenant, workspace, run.id, { step, at: e.t })));
|
|
314
|
+
if (hit < 0)
|
|
315
|
+
return none;
|
|
316
|
+
// Only the step's latest park is open, and only while it waits.
|
|
317
|
+
return { known: true, steps: hit === lines.length - 1 && waiting(step) ? [step] : [] };
|
|
318
|
+
}
|
|
319
|
+
if (!same(eventToken(tenant, workspace, run.id)))
|
|
320
|
+
return none;
|
|
321
|
+
const legacy = [];
|
|
322
|
+
for (let i = 0; i < run.steps.length; i++) {
|
|
323
|
+
const last = parks(i).at(-1);
|
|
324
|
+
if (last && waiting(i) && !/[?&]step=\d/.test(last.text))
|
|
325
|
+
legacy.push(i);
|
|
326
|
+
}
|
|
327
|
+
return { known: true, steps: legacy };
|
|
328
|
+
}
|
|
279
329
|
export async function deliverEvent(tenant, workspace, runId, payload, opts = {}) {
|
|
280
330
|
const run = readRun(tenant, workspace, runId);
|
|
281
331
|
if (!run)
|
package/dist/src/browser-pod.js
CHANGED
|
@@ -173,6 +173,10 @@ export function classifyCall(tool, input, ctx = {}) {
|
|
|
173
173
|
// colleague is said, and a re-run would say it twice.
|
|
174
174
|
if (server === "foldrun_peers")
|
|
175
175
|
return name === "peers_list" ? { write: false } : write("a message to a colleague");
|
|
176
|
+
// remotes: — reading where a request got to changes nothing; a send, a
|
|
177
|
+
// reply or a cancel acts on an outside agent's system.
|
|
178
|
+
if (server === "foldrun_a2a")
|
|
179
|
+
return name === "a2a_status" ? { write: false } : write(`${name.replace(/^a2a_/, "")} to an outside agent`);
|
|
176
180
|
if (server === "foldrun_scripts") {
|
|
177
181
|
if (name !== "web")
|
|
178
182
|
return write(`script ${name}`);
|
package/dist/src/catalog.d.ts
CHANGED
|
@@ -45,6 +45,20 @@ export declare const CATALOG_MAX_BYTES: number;
|
|
|
45
45
|
* there is nothing at all to fall back on.
|
|
46
46
|
*/
|
|
47
47
|
export declare function loadCatalog(baseUrl: string): Promise<Catalog | null>;
|
|
48
|
+
/** How long after an attempt a background refresh of the same gateway is
|
|
49
|
+
* not tried again, whether it worked or not. */
|
|
50
|
+
export declare const CATALOG_RETRY_MS: number;
|
|
51
|
+
/**
|
|
52
|
+
* Refresh a gateway's catalogue behind the caller, at most once per
|
|
53
|
+
* CATALOG_RETRY_MS per gateway (30 Oct 2026). For the editor's completions and
|
|
54
|
+
* the flows page, which ask on every request: a stale or missing catalogue
|
|
55
|
+
* used to start a fresh outbound fetch each time — a 10 s one per keystroke
|
|
56
|
+
* against an unreachable gateway, nothing remembering that it had just
|
|
57
|
+
* failed. The attempt is noted before it starts, so callers arriving while
|
|
58
|
+
* one is in flight do not start another. True when one was started. A run's
|
|
59
|
+
* own read (loadCatalog) is untouched.
|
|
60
|
+
*/
|
|
61
|
+
export declare function refreshCatalogSoon(baseUrl: string, load?: (baseUrl: string) => Promise<unknown>, now?: number): boolean;
|
|
48
62
|
/** A preset reference — settings live in the gateway's dashboard, opaque to
|
|
49
63
|
* us on purpose. The catalogue offers no opinion on one. */
|
|
50
64
|
export declare const isPresetRef: (model: string) => boolean;
|
package/dist/src/catalog.js
CHANGED
|
@@ -118,6 +118,34 @@ export async function loadCatalog(baseUrl) {
|
|
|
118
118
|
return cached; // stale beats nothing; null when there is no nothing
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
|
+
/** How long after an attempt a background refresh of the same gateway is
|
|
122
|
+
* not tried again, whether it worked or not. */
|
|
123
|
+
export const CATALOG_RETRY_MS = 5 * 60_000;
|
|
124
|
+
const lastTried = new Map();
|
|
125
|
+
/**
|
|
126
|
+
* Refresh a gateway's catalogue behind the caller, at most once per
|
|
127
|
+
* CATALOG_RETRY_MS per gateway (30 Oct 2026). For the editor's completions and
|
|
128
|
+
* the flows page, which ask on every request: a stale or missing catalogue
|
|
129
|
+
* used to start a fresh outbound fetch each time — a 10 s one per keystroke
|
|
130
|
+
* against an unreachable gateway, nothing remembering that it had just
|
|
131
|
+
* failed. The attempt is noted before it starts, so callers arriving while
|
|
132
|
+
* one is in flight do not start another. True when one was started. A run's
|
|
133
|
+
* own read (loadCatalog) is untouched.
|
|
134
|
+
*/
|
|
135
|
+
export function refreshCatalogSoon(baseUrl, load = loadCatalog, now = Date.now()) {
|
|
136
|
+
const cached = readCatalog(baseUrl);
|
|
137
|
+
if (cached && now - Date.parse(cached.fetchedAt) < TTL_MS)
|
|
138
|
+
return false; // fresh: nothing to fetch
|
|
139
|
+
const last = lastTried.get(baseUrl);
|
|
140
|
+
if (last !== undefined && now - last < CATALOG_RETRY_MS)
|
|
141
|
+
return false;
|
|
142
|
+
lastTried.delete(baseUrl);
|
|
143
|
+
lastTried.set(baseUrl, now);
|
|
144
|
+
if (lastTried.size > 1000)
|
|
145
|
+
lastTried.delete(lastTried.keys().next().value);
|
|
146
|
+
void load(baseUrl).catch(() => null);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
121
149
|
/** A preset reference — settings live in the gateway's dashboard, opaque to
|
|
122
150
|
* us on purpose. The catalogue offers no opinion on one. */
|
|
123
151
|
export const isPresetRef = (model) => model.startsWith("@preset/");
|
package/dist/src/completions.js
CHANGED
|
@@ -331,9 +331,9 @@ export function completionsAt(path, text, cursor, vocab) {
|
|
|
331
331
|
// that field's files and nothing else. `[[search]]` in tools: is how a
|
|
332
332
|
// tool named after a runtime group gets granted; offering the group
|
|
333
333
|
// here would defeat the point of the brackets.
|
|
334
|
-
const inlineOwner = line.match(/^\s*([a-zA-Z_]
|
|
334
|
+
const inlineOwner = line.match(/^\s*([a-zA-Z_][a-zA-Z0-9_]*):\s*(?:\[.*)?\[\[[^\]]*$/)?.[1];
|
|
335
335
|
const blockOwner = /^\s+-\s*\[\[/.test(line)
|
|
336
|
-
? before.slice(0, lineStart).split("\n").reverse().find((l) => /^\s*[a-zA-Z_]
|
|
336
|
+
? before.slice(0, lineStart).split("\n").reverse().find((l) => /^\s*[a-zA-Z_][a-zA-Z0-9_]*:\s*$/.test(l))?.trim().replace(":", "")
|
|
337
337
|
: undefined;
|
|
338
338
|
const owner = inlineOwner ?? blockOwner;
|
|
339
339
|
const byField = {
|
|
@@ -391,7 +391,7 @@ export function completionsAt(path, text, cursor, vocab) {
|
|
|
391
391
|
// been chosen yet, one at a time. Fixed combinations were the wrong
|
|
392
392
|
// model: nobody wants a menu of every permutation, they want to add the
|
|
393
393
|
// next verb.
|
|
394
|
-
const inlineList = line.match(/^(\s*)([a-zA-Z_]
|
|
394
|
+
const inlineList = line.match(/^(\s*)([a-zA-Z_][a-zA-Z0-9_]*):\s*\[([^\]]*)$/);
|
|
395
395
|
if (inlineList) {
|
|
396
396
|
const [, , key, inside] = inlineList;
|
|
397
397
|
// The inline and block forms of a list are the same field — `tools: [a, b]`
|
|
@@ -435,7 +435,7 @@ export function completionsAt(path, text, cursor, vocab) {
|
|
|
435
435
|
}
|
|
436
436
|
}
|
|
437
437
|
// 4. `key: value` — the value side of a known field.
|
|
438
|
-
const kv = line.match(/^(\s*)([a-zA-Z_]
|
|
438
|
+
const kv = line.match(/^(\s*)([a-zA-Z_][a-zA-Z0-9_]*):\s*(\S*)$/);
|
|
439
439
|
if (kv) {
|
|
440
440
|
const [, , key, partial] = kv;
|
|
441
441
|
const from = cursor - partial.length;
|
|
@@ -517,7 +517,7 @@ export function completionsAt(path, text, cursor, vocab) {
|
|
|
517
517
|
const partial = listItem[2];
|
|
518
518
|
const from = cursor - partial.length;
|
|
519
519
|
const priorLines = before.slice(0, lineStart).split("\n").reverse();
|
|
520
|
-
const owner = priorLines.find((l) => /^\s*[a-zA-Z_]
|
|
520
|
+
const owner = priorLines.find((l) => /^\s*[a-zA-Z_][a-zA-Z0-9_]*:\s*$/.test(l))?.trim().replace(":", "");
|
|
521
521
|
const byOwner = {
|
|
522
522
|
// One list now: built-ins and your own tools are granted the same way.
|
|
523
523
|
// Groups first (they cover most needs), then your own tools — the ones
|
|
@@ -558,7 +558,7 @@ export function completionsAt(path, text, cursor, vocab) {
|
|
|
558
558
|
const partial = mapping[2] ?? "";
|
|
559
559
|
const priorLines = before.slice(0, lineStart).split("\n").reverse();
|
|
560
560
|
const owner = priorLines
|
|
561
|
-
.find((l) => /^\s*[a-zA-Z_]
|
|
561
|
+
.find((l) => /^\s*[a-zA-Z_][a-zA-Z0-9_]*:\s*$/.test(l))
|
|
562
562
|
?.trim()
|
|
563
563
|
.replace(":", "");
|
|
564
564
|
const byOwner = {
|
|
@@ -593,7 +593,7 @@ export function completionsAt(path, text, cursor, vocab) {
|
|
|
593
593
|
// kind of file — the block takes nothing else.
|
|
594
594
|
const fmClose = text.indexOf("\n---", text.indexOf("---") + 3);
|
|
595
595
|
const insideFrontmatter = text.startsWith("---") && (fmClose === -1 || cursor <= fmClose);
|
|
596
|
-
const nested = line.match(/^(\s+)([a-zA-Z_]
|
|
596
|
+
const nested = line.match(/^(\s+)([a-zA-Z_][a-zA-Z0-9_]*|)$/);
|
|
597
597
|
if (insideFrontmatter && nested) {
|
|
598
598
|
const header = before.slice(0, lineStart).split("\n").reverse().find((l) => /^\S/.test(l));
|
|
599
599
|
const job = JOBS.find((j) => new RegExp(`^${j.name}\\s*:\\s*$`).test(header ?? ""));
|
|
@@ -624,7 +624,7 @@ export function completionsAt(path, text, cursor, vocab) {
|
|
|
624
624
|
// this kind of file. This is where you learn the format by writing it.
|
|
625
625
|
const fmEnd = text.indexOf("\n---", text.indexOf("---") + 3);
|
|
626
626
|
const inFrontmatter = text.startsWith("---") && (fmEnd === -1 || cursor <= fmEnd);
|
|
627
|
-
if (inFrontmatter && /^[a-zA-Z_][a-zA-
|
|
627
|
+
if (inFrontmatter && /^[a-zA-Z_][a-zA-Z0-9_-]*$|^$/.test(line)) {
|
|
628
628
|
const from = cursor - line.length;
|
|
629
629
|
return { from, query: line, items: filter(fieldsFor(path, kind), line), title: `${kind} fields` };
|
|
630
630
|
}
|
package/dist/src/cron.d.ts
CHANGED
|
@@ -15,3 +15,12 @@ export interface Cron {
|
|
|
15
15
|
export declare function parseCron(expr: string): Cron | null;
|
|
16
16
|
/** Why `schedule:` is not a cron the scheduler can run, or null when it is. */
|
|
17
17
|
export declare function cronProblem(expr: unknown): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Whether the calendar has any day this cron names (30 Oct 2026). `0 0 30 2 *`
|
|
20
|
+
* parses — every field is in range — yet no February has a 30th, so a search
|
|
21
|
+
* for its next fire walked the whole window every time it was asked (the
|
|
22
|
+
* schedule page did, 57,600 minutes per flow per request). Answered from the
|
|
23
|
+
* fields alone: only a day of the month with no weekday beside it can miss,
|
|
24
|
+
* since with a weekday either may match and every month has every weekday.
|
|
25
|
+
*/
|
|
26
|
+
export declare function cronCanFire(cron: Cron): boolean;
|
package/dist/src/cron.js
CHANGED
|
@@ -105,3 +105,23 @@ export function cronProblem(expr) {
|
|
|
105
105
|
? `schedule: "${expr}" has ${fields} field${fields === 1 ? "" : "s"}; cron has five — minute hour day-of-month month day-of-week ("0 9 * * MON"), or @hourly, @daily, @weekly, @monthly. As written the flow never fires`
|
|
106
106
|
: `schedule: "${expr}" is not a cron the scheduler can read — a field is out of range or not a number (minute 0-59, hour 0-23, day 1-31, month 1-12, weekday 0-7 or sun-sat). As written the flow never fires`;
|
|
107
107
|
}
|
|
108
|
+
/** Days in each month, February at its leap-year 29: a cron naming the 29th
|
|
109
|
+
* of February does fire, every fourth year. */
|
|
110
|
+
const MONTH_DAYS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
111
|
+
/**
|
|
112
|
+
* Whether the calendar has any day this cron names (30 Oct 2026). `0 0 30 2 *`
|
|
113
|
+
* parses — every field is in range — yet no February has a 30th, so a search
|
|
114
|
+
* for its next fire walked the whole window every time it was asked (the
|
|
115
|
+
* schedule page did, 57,600 minutes per flow per request). Answered from the
|
|
116
|
+
* fields alone: only a day of the month with no weekday beside it can miss,
|
|
117
|
+
* since with a weekday either may match and every month has every weekday.
|
|
118
|
+
*/
|
|
119
|
+
export function cronCanFire(cron) {
|
|
120
|
+
if (!cron.domRestricted || cron.dowRestricted)
|
|
121
|
+
return true;
|
|
122
|
+
const firstDay = Math.min(...cron.dom);
|
|
123
|
+
for (const m of cron.month)
|
|
124
|
+
if (firstDay <= MONTH_DAYS[m - 1])
|
|
125
|
+
return true;
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
@@ -43,6 +43,7 @@ const AGENT = [
|
|
|
43
43
|
{ key: "scripts", type: "list", help: "programs in scripts/, each a tool" },
|
|
44
44
|
{ key: "apis", type: "list", help: "an HTTP API as a tool" },
|
|
45
45
|
{ key: "mcpServers", type: "object", help: "MCP servers, name → {command | url, args, env, headers}" },
|
|
46
|
+
{ key: "remotes", type: "list", help: "outside A2A agents it may call — [{name, card: the https URL of its agent card, secret?: a vault NAME sent as a bearer token, description?}]" },
|
|
46
47
|
{ key: "agents", type: ["list", "string"], help: "colleagues it may consult" },
|
|
47
48
|
{ key: "subagents", type: ["list", "string"], help: "colleagues it may delegate to" },
|
|
48
49
|
{ key: "sandbox", type: "string", values: ["own", "parent"], help: "when delegated to as a sub-agent: own = its own sandbox, tools, secrets and network; parent (default) = inside the delegating step's" },
|
|
@@ -109,7 +110,7 @@ const FLOW = [
|
|
|
109
110
|
{ key: "catchup", type: "string", validated: true, help: "last | none" },
|
|
110
111
|
{ key: "on", type: "string", validated: true, help: "trigger: flow — completed | failed | blocked | any" },
|
|
111
112
|
{ key: "signature", type: "string", validated: true, help: "github | stripe | slack | hmac" },
|
|
112
|
-
{ key: "signing_secret", type: "string", help: "the secret a webhook's signature is checked with" },
|
|
113
|
+
{ key: "signing_secret", type: "string", help: "the NAME of the vault secret a webhook's signature is checked with — never the value" },
|
|
113
114
|
{ key: "after", type: ["string", "list"], help: "trigger: flow — the flow this one follows" },
|
|
114
115
|
{ key: "at", type: "string", help: "trigger: once — when" },
|
|
115
116
|
{ key: "url", type: "string", help: "trigger: watch — the page watched" },
|
|
@@ -127,6 +128,7 @@ const FLOW = [
|
|
|
127
128
|
{ key: "budget", type: ["number", "string"], help: "USD per run" },
|
|
128
129
|
{ key: "compute", type: "object", help: "{vcpu, memory, disk} one run's steps may hold together at once — only narrows the workspace's and account's" },
|
|
129
130
|
{ key: "live", type: "boolean", help: "true: steps may act for real in a test run" },
|
|
131
|
+
{ key: "a2a", type: "object", help: "serve this flow to outside agents over A2A 1.0 — {publish: true, name?, description?, examples?, public_card?, input?}; changing it needs workspace:manage" },
|
|
130
132
|
];
|
|
131
133
|
const TOOL = [
|
|
132
134
|
{ key: "name", type: "string", help: "defaults to the file or folder name" },
|
|
@@ -2,7 +2,7 @@ import { type SchemaKind } from "./frontmatter-fields.ts";
|
|
|
2
2
|
export { FRONTMATTER, schemaKindOf, type FieldSpec, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
|
|
3
3
|
export interface FieldProblem {
|
|
4
4
|
key: string;
|
|
5
|
-
rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field" | "config/conflict" | "config/misplaced";
|
|
5
|
+
rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field" | "config/conflict" | "config/misplaced" | "config/cron-never-fires" | "secret/signing-secret-value";
|
|
6
6
|
level: "error" | "warn" | "info";
|
|
7
7
|
message: string;
|
|
8
8
|
}
|
|
@@ -13,3 +13,7 @@ export interface FieldProblem {
|
|
|
13
13
|
* `near` is the typo matcher; scan.ts passes its own.
|
|
14
14
|
*/
|
|
15
15
|
export declare function frontmatterProblems(kind: SchemaKind, data: Record<string, unknown>, near: (key: string, known: readonly string[]) => string | null): FieldProblem[];
|
|
16
|
+
/** Why a `signing_secret:` value is not a vault secret's name, or null. The
|
|
17
|
+
* runtime reads `NAME` and `${NAME}` alike (notify.ts, store.ts). Never
|
|
18
|
+
* says what the value is: it may be the secret itself. */
|
|
19
|
+
export declare function signingSecretProblem(v: unknown): string | null;
|
|
@@ -5,9 +5,12 @@ import { FRONTMATTER, JOBS } from "./frontmatter-fields.js";
|
|
|
5
5
|
import { jobFold } from "./matter.js";
|
|
6
6
|
import { resolveEffort, parseDuration, parseInstant, parseRate, STEP_OPTION_KEYS } from "./store.js";
|
|
7
7
|
import { budgetProblem } from "./budget.js";
|
|
8
|
-
import { cronProblem } from "./cron.js";
|
|
8
|
+
import { cronProblem, parseCron, cronCanFire } from "./cron.js";
|
|
9
|
+
import { SECRET_NAME } from "./secrets.js";
|
|
9
10
|
import { modelSecretCollision } from "./model-credential.js";
|
|
10
11
|
import { resourcesProblem, computeProblem } from "./resources.js";
|
|
12
|
+
import { remotesProblem } from "./a2a-remotes.js";
|
|
13
|
+
import { a2aPublishProblem } from "./a2a-publish.js";
|
|
11
14
|
export { FRONTMATTER, schemaKindOf } from "./frontmatter-fields.js";
|
|
12
15
|
/** A value for a message: a scalar as written, anything else by its shape.
|
|
13
16
|
* Never String() of a map — its `toString` is whatever the YAML says. */
|
|
@@ -30,6 +33,7 @@ const CHECKS = {
|
|
|
30
33
|
"agent": {
|
|
31
34
|
effort: (v) => (resolveEffort(v) ? null : `"${String(v)}" is not an effort — low, medium, high, xhigh or max; the step runs at the model's default`),
|
|
32
35
|
apis: apisProblem,
|
|
36
|
+
remotes: remotesProblem,
|
|
33
37
|
budget: (v) => budgetProblem(v, ["run"]),
|
|
34
38
|
ask: (v) => askProblem(v),
|
|
35
39
|
resources: resourcesProblem,
|
|
@@ -54,6 +58,7 @@ const CHECKS = {
|
|
|
54
58
|
pause_when: (v) => (String(v).length <= 60 ? null : "is longer than 60 characters"),
|
|
55
59
|
budget: (v) => budgetProblem(v, ["run"]),
|
|
56
60
|
compute: computeProblem,
|
|
61
|
+
a2a: a2aPublishProblem,
|
|
57
62
|
},
|
|
58
63
|
"tool": {
|
|
59
64
|
transport: (v) => (["http", "script", "mcp"].includes(String(v).toLowerCase()) ? null : `"${String(v)}" is not a transport — http, script or mcp`),
|
|
@@ -163,11 +168,49 @@ export function frontmatterProblems(kind, data, near) {
|
|
|
163
168
|
if (why)
|
|
164
169
|
out.push({ key, rule: "config/invalid-value", level: "warn", message: `\`${key}:\` ${why.replace(/^\w+: /, "")}` });
|
|
165
170
|
}
|
|
171
|
+
// A cron every field of which is in range, on a day no month has —
|
|
172
|
+
// `0 0 30 2 *`. The scheduler's own cronCanFire decides (30 Oct 2026), so
|
|
173
|
+
// check and the scheduler say the same about it: it never fires.
|
|
174
|
+
if (kind === "flow" && typeof data.schedule === "string") {
|
|
175
|
+
const cron = parseCron(data.schedule);
|
|
176
|
+
if (cron && !cronCanFire(cron)) {
|
|
177
|
+
out.push({ key: "schedule", rule: "config/cron-never-fires", level: "warn", message: `\`schedule: "${data.schedule}"\` names a day no month it allows has — the flow never fires. Pick a day every named month has, or add a weekday` });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// A signing secret is the NAME of a vault entry (30 Oct 2026): a flow's
|
|
181
|
+
// `signing_secret:` and notify's `signing_secret:` are looked up with
|
|
182
|
+
// getSecret, so a literal value there is a secret written into a
|
|
183
|
+
// versioned, deployed file — and never matches anything in the vault.
|
|
184
|
+
// The message never repeats the value.
|
|
185
|
+
if (kind === "flow" && data.signing_secret !== undefined) {
|
|
186
|
+
const why = signingSecretProblem(data.signing_secret);
|
|
187
|
+
if (why)
|
|
188
|
+
out.push({ key: "signing_secret", rule: "secret/signing-secret-value", level: "warn", message: `\`signing_secret:\` ${why}` });
|
|
189
|
+
}
|
|
190
|
+
if (kind === "agents-md" && data.notify && typeof data.notify === "object" && !Array.isArray(data.notify)) {
|
|
191
|
+
const raw = data.notify.signing_secret;
|
|
192
|
+
const why = raw === undefined ? null : signingSecretProblem(raw);
|
|
193
|
+
if (why)
|
|
194
|
+
out.push({ key: "notify", rule: "secret/signing-secret-value", level: "warn", message: `\`notify: signing_secret:\` ${why}` });
|
|
195
|
+
}
|
|
166
196
|
if (kind === "skill" && !(typeof data.description === "string" && data.description.trim())) {
|
|
167
197
|
out.push({ key: "description", rule: "config/missing-field", level: "warn", message: "no `description:` — a skill without one is never loaded; the model chooses a skill by it" });
|
|
168
198
|
}
|
|
169
199
|
return out;
|
|
170
200
|
}
|
|
201
|
+
/** Why a `signing_secret:` value is not a vault secret's name, or null. The
|
|
202
|
+
* runtime reads `NAME` and `${NAME}` alike (notify.ts, store.ts). Never
|
|
203
|
+
* says what the value is: it may be the secret itself. */
|
|
204
|
+
export function signingSecretProblem(v) {
|
|
205
|
+
if (typeof v !== "string")
|
|
206
|
+
return null; // config/wrong-type says so
|
|
207
|
+
const name = v.trim().replace(/^\$\{|\}$/g, "");
|
|
208
|
+
if (!name)
|
|
209
|
+
return null;
|
|
210
|
+
return SECRET_NAME.test(name)
|
|
211
|
+
? null
|
|
212
|
+
: "holds something that is not a secret's name (UPPER_SNAKE_CASE, like WEBHOOK_SIGNING_KEY) — it is looked up in the vault by name, so a value written here is never found, and is deployed and versioned in plain text. Put the value in the vault (`foldrun secrets set NAME`), write the name here, and rotate it";
|
|
213
|
+
}
|
|
171
214
|
function askProblem(v) {
|
|
172
215
|
const t = v?.timeout;
|
|
173
216
|
return t === undefined ? null : DURATION(30, 86400, "30s to 24h")(t);
|
package/dist/src/limits.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// web.search: 40 # one action of the web tool
|
|
6
6
|
// web: 150 # every web action together
|
|
7
7
|
// crm: 20 # an API, script or MCP tool, by the name it is granted as
|
|
8
|
+
// a2a: 10 # messages to outside agents (a2a_send and a2a_reply)
|
|
9
|
+
// a2a.status: 30 # polls of them, apart from the sends
|
|
8
10
|
// calls: 300 # every tool call in the step, whatever the tool
|
|
9
11
|
// repeat: 5 # the same call — one tool, the same input — at most this often
|
|
10
12
|
//
|
|
@@ -21,6 +23,7 @@
|
|
|
21
23
|
import crypto from "node:crypto";
|
|
22
24
|
import { WEB_ACTIONS } from "./providers.js";
|
|
23
25
|
import { DELEGATE_TOOLS } from "./subagents.js";
|
|
26
|
+
import { A2A_SERVER, A2A_LIMIT_KEY, A2A_LIMIT_ACTIONS } from "./a2a-remotes.js";
|
|
24
27
|
/** The key that caps every call in the step. */
|
|
25
28
|
export const TOTAL_KEY = "calls";
|
|
26
29
|
/** The key that caps one identical call: a model asking the same tool the
|
|
@@ -96,8 +99,11 @@ export function readLimits(raw, where = "limits") {
|
|
|
96
99
|
}
|
|
97
100
|
if (k.includes(".")) {
|
|
98
101
|
const [head, action] = k.split(".");
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
const web = head === "web" && WEB_ACTIONS.includes(action);
|
|
103
|
+
const a2a = head === A2A_LIMIT_KEY && A2A_LIMIT_ACTIONS.includes(action);
|
|
104
|
+
if (!web && !a2a) {
|
|
105
|
+
problems.push(`${where}: "${k}" is not a key — the dotted keys are web.<action>: ${WEB_ACTIONS.map((a) => `web.${a}`).join(", ")}, ` +
|
|
106
|
+
`and ${A2A_LIMIT_ACTIONS.map((a) => `${A2A_LIMIT_KEY}.${a}`).join(", ")}.`);
|
|
101
107
|
continue;
|
|
102
108
|
}
|
|
103
109
|
}
|
|
@@ -166,6 +172,16 @@ export function limitKeysFor(toolName, input, owners = {}) {
|
|
|
166
172
|
}
|
|
167
173
|
if (DELEGATE_TOOLS.includes(toolName))
|
|
168
174
|
return keys;
|
|
175
|
+
// remotes: — a message to an outside agent (a2a_send, a2a_reply) counts
|
|
176
|
+
// under `a2a`; every tool also under its own `a2a.<action>`, so polling
|
|
177
|
+
// (`a2a.status`) is capped apart from sending.
|
|
178
|
+
const a2a = new RegExp(`^mcp__${A2A_SERVER}__a2a_([a-z]+)$`).exec(toolName);
|
|
179
|
+
if (a2a) {
|
|
180
|
+
if (a2a[1] === "send" || a2a[1] === "reply")
|
|
181
|
+
keys.push(A2A_LIMIT_KEY);
|
|
182
|
+
keys.push(`${A2A_LIMIT_KEY}.${a2a[1]}`);
|
|
183
|
+
return keys;
|
|
184
|
+
}
|
|
169
185
|
const owner = owners[toolName];
|
|
170
186
|
const mcp = /^mcp__(.+?)__(.+)$/.exec(toolName);
|
|
171
187
|
const name = owner
|
|
@@ -263,7 +279,7 @@ export class CallCounter {
|
|
|
263
279
|
*/
|
|
264
280
|
export function limitKeyProblems(limits, ctx) {
|
|
265
281
|
const out = [];
|
|
266
|
-
const builtinKeys = ["read", "write", "code", ...Object.keys(BUILTIN_GROUP), "TodoWrite", "search", "history", "desks", "ask", "web"];
|
|
282
|
+
const builtinKeys = ["read", "write", "code", ...Object.keys(BUILTIN_GROUP), "TodoWrite", "search", "history", "desks", "ask", "web", A2A_LIMIT_KEY];
|
|
267
283
|
const known = new Set([...builtinKeys, ...ctx.known].map(normKey));
|
|
268
284
|
const granted = new Set([...ctx.granted].flatMap((g) => {
|
|
269
285
|
const n = normKey(g);
|
|
@@ -281,10 +297,12 @@ export function limitKeyProblems(limits, ctx) {
|
|
|
281
297
|
for (const key of Object.keys(limits)) {
|
|
282
298
|
if (key === TOTAL_KEY || key === REPEAT_KEY)
|
|
283
299
|
continue;
|
|
284
|
-
const base = key.startsWith("web.") ? "web" : key;
|
|
300
|
+
const base = key.startsWith("web.") ? "web" : key.startsWith(`${A2A_LIMIT_KEY}.`) ? A2A_LIMIT_KEY : key;
|
|
285
301
|
const n = normKey(base);
|
|
286
302
|
if (!known.has(n)) {
|
|
287
303
|
const valid = [TOTAL_KEY, "web", ...WEB_ACTIONS.map((a) => `web.${a}`), ...[...new Set([...ctx.granted].map(String))].filter((g) => g !== "web")];
|
|
304
|
+
if ([...ctx.granted].map(String).includes(A2A_LIMIT_KEY))
|
|
305
|
+
valid.push(...A2A_LIMIT_ACTIONS.map((a) => `${A2A_LIMIT_KEY}.${a}`));
|
|
288
306
|
out.push({ level: "error", message: `limits: ${key} — not a tool this agent could call. The keys: ${[...new Set(valid)].join(", ")}.` });
|
|
289
307
|
}
|
|
290
308
|
else if (!granted.has(n)) {
|
package/dist/src/notify.d.ts
CHANGED
|
@@ -15,8 +15,11 @@ export declare function platformMail(tenant: string): {
|
|
|
15
15
|
key: string;
|
|
16
16
|
from: string;
|
|
17
17
|
} | null;
|
|
18
|
-
/** The account's own Resend key and sender, or null when it has none.
|
|
19
|
-
|
|
18
|
+
/** The account's own Resend key and sender, or null when it has none.
|
|
19
|
+
* With a workspace — mail a workspace's run causes — an account key or
|
|
20
|
+
* sender limited to other workspaces is not used (30 Oct 2026); the
|
|
21
|
+
* account's own mail (resets, invites) names none and is not limited. */
|
|
22
|
+
export declare function accountMail(tenant: string, workspace?: string): {
|
|
20
23
|
key: string;
|
|
21
24
|
from: string;
|
|
22
25
|
} | null;
|
|
@@ -30,7 +33,7 @@ export declare function accountMail(tenant: string): {
|
|
|
30
33
|
* owner's bin. The platform's key is the fallback for an account that set
|
|
31
34
|
* none, so `notify:` works before anyone has configured mail at all.
|
|
32
35
|
*/
|
|
33
|
-
export declare function notifyMail(tenant: string): {
|
|
36
|
+
export declare function notifyMail(tenant: string, workspace?: string): {
|
|
34
37
|
key: string;
|
|
35
38
|
from: string;
|
|
36
39
|
} | null;
|