@foldrun/core 2.10.1 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.js +3 -0
  3. package/dist/src/catalog.d.ts +3 -0
  4. package/dist/src/catalog.js +4 -1
  5. package/dist/src/deploy.js +16 -0
  6. package/dist/src/eval-compare.d.ts +74 -0
  7. package/dist/src/eval-compare.js +187 -0
  8. package/dist/src/evals.d.ts +62 -3
  9. package/dist/src/evals.js +161 -36
  10. package/dist/src/flow-lint.js +4 -0
  11. package/dist/src/frontmatter-fields.js +4 -1
  12. package/dist/src/git-hook.d.ts +1 -0
  13. package/dist/src/git-hook.js +70 -0
  14. package/dist/src/git.d.ts +37 -0
  15. package/dist/src/git.js +101 -0
  16. package/dist/src/gitrepo.d.ts +113 -0
  17. package/dist/src/gitrepo.js +404 -0
  18. package/dist/src/grades.d.ts +118 -0
  19. package/dist/src/grades.js +481 -0
  20. package/dist/src/judge-verdict.d.ts +1 -0
  21. package/dist/src/judge-verdict.js +3 -1
  22. package/dist/src/online-checks.d.ts +4 -0
  23. package/dist/src/online-checks.js +5 -1
  24. package/dist/src/openapi.d.ts +4 -0
  25. package/dist/src/openapi.js +5 -1
  26. package/dist/src/platform.d.ts +12 -3
  27. package/dist/src/platform.js +9 -1
  28. package/dist/src/starter.js +5 -0
  29. package/dist/src/store.d.ts +8 -0
  30. package/dist/src/store.js +5 -1
  31. package/dist/src/tar.d.ts +30 -0
  32. package/dist/src/tar.js +112 -0
  33. package/dist/src/tool-test.js +19 -7
  34. package/dist/src/translator.d.ts +4 -0
  35. package/dist/src/translator.js +22 -3
  36. package/dist/src/trigger-filter.d.ts +28 -0
  37. package/dist/src/trigger-filter.js +272 -0
  38. package/dist/src/trigger-log.d.ts +1 -1
  39. package/dist/src/triggers.d.ts +2 -0
  40. package/dist/src/triggers.js +13 -2
  41. package/dist/src/untrusted-fetch.d.ts +16 -0
  42. package/dist/src/untrusted-fetch.js +60 -2
  43. package/index.ts +3 -0
  44. package/package.json +2 -2
  45. package/src/catalog.ts +5 -1
  46. package/src/deploy.ts +10 -0
  47. package/src/eval-compare.ts +243 -0
  48. package/src/evals.ts +195 -38
  49. package/src/flow-lint.ts +4 -0
  50. package/src/frontmatter-fields.ts +4 -1
  51. package/src/grades.ts +532 -0
  52. package/src/judge-verdict.ts +4 -2
  53. package/src/online-checks.ts +5 -1
  54. package/src/openapi.ts +6 -1
  55. package/src/platform.ts +16 -4
  56. package/src/starter.ts +5 -0
  57. package/src/store.ts +11 -1
  58. package/src/tool-test.ts +21 -7
  59. package/src/translator.ts +24 -3
  60. package/src/trigger-filter.ts +264 -0
  61. package/src/trigger-log.ts +1 -0
  62. package/src/triggers.ts +14 -2
  63. package/src/untrusted-fetch.ts +65 -2
@@ -567,6 +567,13 @@ export interface FlowInfo {
567
567
  /** `trigger: storage` — a path prefix under storage/; a file landing
568
568
  * there starts the flow with the path as its task. */
569
569
  path: string | null;
570
+ /** `filter:` — the fields a delivery must have before it becomes a run
571
+ * (trigger-filter.ts): matched against a webhook's JSON body, an email,
572
+ * a chained run or a storage event. Null: no `filter:` line, every
573
+ * delivery. Kept exactly as written — a list, `false`, a word — so one
574
+ * that cannot be read matches nothing (fails closed) rather than reading
575
+ * as no filter at all. Test it with `!= null`, never for truthiness. */
576
+ filter: unknown;
570
577
  /** `idempotency:` — the name of the field that identifies a delivery, so
571
578
  * the same one twice starts one run. A header on the request
572
579
  * ("x-github-delivery"), or "body:<key>" for a top-level field of a JSON
@@ -820,6 +827,7 @@ export declare function listWorkspaces(tenant: string): WorkspaceSummary[];
820
827
  * listing asks the writer, so a path that can be saved is a path that shows.
821
828
  */
822
829
  export declare function isEditablePath(rel: string): boolean;
830
+ export declare function assertEditablePath(rel: string): string;
823
831
  export declare function accountFileSealed(rel: string): string | null;
824
832
  /**
825
833
  * The account directory as it actually is on disk.
package/dist/src/store.js CHANGED
@@ -43,6 +43,7 @@ export function readFrontmatter(file) {
43
43
  }
44
44
  import { ownToolNames, legacyUseNames, retiredToolNames } from "./tool-names.js";
45
45
  import { refNames } from "./refs.js";
46
+ import { checkFilter } from "./trigger-filter.js";
46
47
  import { parseBudget, budgetProblem } from "./budget.js";
47
48
  import { timezoneProblem } from "./clock.js";
48
49
  import { parseStepLimits, readLimits } from "./limits.js";
@@ -1617,6 +1618,8 @@ export function parseFlow(file, raw) {
1617
1618
  badEnum("catchup", data.catchup, ["none", "last"]);
1618
1619
  badEnum("on", data.on, ["completed", "failed", "blocked", "any"]);
1619
1620
  badEnum("signature", data.signature, ["github", "stripe", "slack", "hmac"]);
1621
+ for (const p of checkFilter(data.filter))
1622
+ frontProblems.push(p);
1620
1623
  steps.sort((a, b) => a.group - b.group);
1621
1624
  return {
1622
1625
  name: data.name ?? file.replace(/\.md$/, ""),
@@ -1646,6 +1649,7 @@ export function parseFlow(file, raw) {
1646
1649
  ? data.signing_secret.trim().replace(/^\$\{|\}$/g, "")
1647
1650
  : null,
1648
1651
  path: typeof data.path === "string" ? data.path.trim().replace(/^\/+/, "") : null,
1652
+ filter: data.filter === undefined || data.filter === null ? null : data.filter,
1649
1653
  // A header name is case-insensitive on the wire; a JSON field is not.
1650
1654
  // `body:eventId` lower-cased to `body:eventid` matched nothing, and a
1651
1655
  // dedupe that matches nothing admits everything.
@@ -2125,7 +2129,7 @@ export function isEditablePath(rel) {
2125
2129
  return false;
2126
2130
  }
2127
2131
  }
2128
- function assertEditablePath(rel) {
2132
+ export function assertEditablePath(rel) {
2129
2133
  const norm = path.normalize(rel);
2130
2134
  if (norm.startsWith("..") || path.isAbsolute(norm))
2131
2135
  throw new Error(`illegal path: ${rel}`);
@@ -0,0 +1,30 @@
1
+ export interface TarEntry {
2
+ path: string;
3
+ content: Buffer;
4
+ }
5
+ /**
6
+ * Every regular file in a gzipped tarball.
7
+ *
8
+ * Directories, symlinks, and pax/global headers are skipped: a workspace is
9
+ * files, and following a link out of the archive is exactly the thing a deploy
10
+ * from an untrusted repository must not do.
11
+ */
12
+ export declare function readTarGz(gz: Buffer): TarEntry[];
13
+ /**
14
+ * Drop the single wrapper directory an archive is rooted at.
15
+ *
16
+ * GitHub tarballs are rooted at `owner-repo-<sha>/`, which is not part of
17
+ * anyone's workspace. Stripped only when *every* entry shares one first
18
+ * segment, so an archive that is already flat is left alone.
19
+ */
20
+ export declare function stripRoot(entries: TarEntry[]): TarEntry[];
21
+ /**
22
+ * Read a tarball as the file list a deploy ships.
23
+ *
24
+ * `subdir` picks a workspace living inside a larger repository — the common
25
+ * case, since a repo usually holds more than one thing.
26
+ */
27
+ export declare function filesFromTarball(gz: Buffer, subdir?: string): {
28
+ path: string;
29
+ content: string;
30
+ }[];
@@ -0,0 +1,112 @@
1
+ // Reading a .tar.gz, so a deploy can come from a GitHub tarball.
2
+ //
3
+ // `GET /repos/{owner}/{repo}/tarball/{sha}` is one HTTP call and needs no git
4
+ // binary, no clone, and no working copy on the server — which is the whole
5
+ // reason a markdown workspace can deploy from a webhook in a few hundred
6
+ // milliseconds. What it returns is a tar.gz, and Node has gzip but not tar.
7
+ //
8
+ // Deliberately dependency-free, for the same reason the cron parser is: tar is
9
+ // 512-byte blocks with an octal size field, and a reader is shorter than the
10
+ // argument about which library to add.
11
+ import zlib from "node:zlib";
12
+ import { trimSlashes } from "./paths.js";
13
+ const BLOCK = 512;
14
+ const str = (b, off, len) => {
15
+ const raw = b.subarray(off, off + len);
16
+ const end = raw.indexOf(0);
17
+ return raw.subarray(0, end === -1 ? raw.length : end).toString("utf8");
18
+ };
19
+ // Sizes are octal, space- or NUL-padded. GNU also writes base-256 for large
20
+ // files, flagged by the high bit — irrelevant for markdown, but a silent
21
+ // misparse would be worse than a clear refusal.
22
+ function size(b, off) {
23
+ if (b[off] & 0x80)
24
+ throw new Error("tar: base-256 size field is not supported");
25
+ const raw = str(b, off, 12).trim();
26
+ return raw ? parseInt(raw, 8) : 0;
27
+ }
28
+ /**
29
+ * Every regular file in a gzipped tarball.
30
+ *
31
+ * Directories, symlinks, and pax/global headers are skipped: a workspace is
32
+ * files, and following a link out of the archive is exactly the thing a deploy
33
+ * from an untrusted repository must not do.
34
+ */
35
+ export function readTarGz(gz) {
36
+ const buf = zlib.gunzipSync(gz);
37
+ const out = [];
38
+ let longName = null;
39
+ for (let off = 0; off + BLOCK <= buf.length;) {
40
+ const header = buf.subarray(off, off + BLOCK);
41
+ // Two consecutive zero blocks end the archive; one is enough to stop.
42
+ if (header.every((b) => b === 0))
43
+ break;
44
+ const name = str(header, 0, 100);
45
+ const bytes = size(header, 124);
46
+ const type = String.fromCharCode(header[156]) || "0";
47
+ const prefix = str(header, 345, 155);
48
+ off += BLOCK;
49
+ const body = buf.subarray(off, off + bytes);
50
+ off += Math.ceil(bytes / BLOCK) * BLOCK;
51
+ // GNU long name: the next header's name lives in this entry's body.
52
+ if (type === "L") {
53
+ longName = body.toString("utf8").replace(/\0+$/, "");
54
+ continue;
55
+ }
56
+ if (type !== "0" && type !== "\0") {
57
+ longName = null;
58
+ continue;
59
+ }
60
+ const full = longName ?? (prefix ? `${prefix}/${name}` : name);
61
+ longName = null;
62
+ if (full)
63
+ out.push({ path: full, content: Buffer.from(body) });
64
+ }
65
+ return out;
66
+ }
67
+ /**
68
+ * Drop the single wrapper directory an archive is rooted at.
69
+ *
70
+ * GitHub tarballs are rooted at `owner-repo-<sha>/`, which is not part of
71
+ * anyone's workspace. Stripped only when *every* entry shares one first
72
+ * segment, so an archive that is already flat is left alone.
73
+ */
74
+ export function stripRoot(entries) {
75
+ if (entries.length === 0)
76
+ return entries;
77
+ const first = entries[0].path.split("/")[0];
78
+ if (!entries.every((e) => e.path.split("/")[0] === first && e.path.includes("/"))) {
79
+ return entries;
80
+ }
81
+ return entries.map((e) => ({ ...e, path: e.path.slice(first.length + 1) }));
82
+ }
83
+ /**
84
+ * Read a tarball as the file list a deploy ships.
85
+ *
86
+ * `subdir` picks a workspace living inside a larger repository — the common
87
+ * case, since a repo usually holds more than one thing.
88
+ */
89
+ export function filesFromTarball(gz, subdir = "") {
90
+ const prefix = trimSlashes(subdir, "both");
91
+ const out = [];
92
+ for (const entry of stripRoot(readTarGz(gz))) {
93
+ let rel = entry.path;
94
+ if (prefix) {
95
+ if (!rel.startsWith(`${prefix}/`))
96
+ continue;
97
+ rel = rel.slice(prefix.length + 1);
98
+ }
99
+ // A tarball is untrusted input: an entry can name anything at all, and
100
+ // `../` in an archive is the oldest way to write outside the destination.
101
+ if (rel.split("/").includes("..") || rel.startsWith("/"))
102
+ continue;
103
+ // AppleDouble sidecars. macOS `tar` writes a `._name` beside every file to
104
+ // carry metadata no other system wants; they are not part of anyone's
105
+ // workspace, and letting one through means a deploy rejected for shipping
106
+ // a file the author never wrote.
107
+ if ((rel.split("/").pop() ?? "").startsWith("._"))
108
+ continue;
109
+ out.push({ path: rel, content: entry.content.toString("utf8") });
110
+ }
111
+ return out;
112
+ }
@@ -26,6 +26,7 @@ import { commandFor, resolveRunPath } from "./script-tools.js";
26
26
  import { parseRuntime, prepareRuntime } from "./runtime.js";
27
27
  import { hostSafeEnv } from "./host-env.js";
28
28
  import { linkWorkspace, workspaceLinkEnv } from "./confine.js";
29
+ import { fetchUntrusted, ResponseTooLarge, MAX_BYTES } from "./untrusted-fetch.js";
29
30
  const MAX_DETAIL = 4000;
30
31
  const TIMEOUT_MS = 15_000;
31
32
  // A tool test runs tenant-authored code directly on the host — real runs are
@@ -132,12 +133,21 @@ export async function testTool(tenant, workspace, def, input = {}) {
132
133
  const headers = {};
133
134
  for (const [k, v] of Object.entries(api.headers))
134
135
  headers[k] = substitute(v, env);
135
- const res = await fetch(url, {
136
- method,
137
- headers,
138
- signal: AbortSignal.timeout(TIMEOUT_MS),
139
- });
140
- const body = await res.text();
136
+ // A URL the tenant chose, fetched from the web tier: through the
137
+ // platform's guard (no private or cluster address, each redirect hop
138
+ // checked), and the secrets in the headers go only to the origin
139
+ // they were written for.
140
+ const res = await fetchUntrusted(url.href, { method, headers, signal: AbortSignal.timeout(TIMEOUT_MS) }, { sameOrigin: true });
141
+ // The status is the test; a body past the limit is said, not shown.
142
+ let body;
143
+ try {
144
+ body = await res.text();
145
+ }
146
+ catch (err) {
147
+ if (!(err instanceof ResponseTooLarge))
148
+ throw err;
149
+ body = `(the answer was larger than ${MAX_BYTES / (1024 * 1024)} MiB — not shown)`;
150
+ }
141
151
  // An unset secret means the tool cannot authenticate, so it is not
142
152
  // working — whatever a permissive endpoint chose to return. Reporting
143
153
  // ok on a request that went out with no credentials is how you ship a
@@ -308,7 +318,9 @@ export async function testTool(tenant, workspace, def, input = {}) {
308
318
  // A remote server: the honest check we can make without a full client is
309
319
  // that the endpoint answers.
310
320
  try {
311
- const res = await fetch(spec.url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
321
+ // Through the platform's guard, as the http probe is; only the status is read.
322
+ const res = await fetchUntrusted(spec.url, { signal: AbortSignal.timeout(TIMEOUT_MS) }, { sameOrigin: true });
323
+ await res.body?.cancel().catch(() => { });
312
324
  return done({
313
325
  ok: res.ok,
314
326
  transport: "mcp",
@@ -160,6 +160,10 @@ export declare class ResponsesStreamTranslator {
160
160
  /** The stream ended: close what is still open, say why, and stop. */
161
161
  finishStream(): string[];
162
162
  }
163
+ /** The most one upstream answer may be, read, on the host: a long streamed
164
+ * reply is many small events, well past fetchUntrusted's 1 MiB default,
165
+ * and still far under this. */
166
+ export declare const UPSTREAM_MAX_BYTES: number;
163
167
  /**
164
168
  * Start the translator for one step. Binds an ephemeral port on loopback,
165
169
  * mints the key the SDK will present, and returns the env that points the
@@ -31,6 +31,7 @@ import http from "node:http";
31
31
  import crypto from "node:crypto";
32
32
  import { PROTECTED_PARAMS } from "./providers.js";
33
33
  import { viaEgress } from "./egress.js";
34
+ import { fetchUntrusted } from "./untrusted-fetch.js";
34
35
  import { trimSlashes } from "./paths.js";
35
36
  import { captureRefusalHeaders, isRefusalStatus, refusalLine } from "./refusal.js";
36
37
  /**
@@ -860,6 +861,10 @@ function upstreamMessage(text, status) {
860
861
  return text.slice(0, 500) || `upstream returned HTTP ${status}`;
861
862
  }
862
863
  // ---------------------------------------------------------------- server
864
+ /** The most one upstream answer may be, read, on the host: a long streamed
865
+ * reply is many small events, well past fetchUntrusted's 1 MiB default,
866
+ * and still far under this. */
867
+ export const UPSTREAM_MAX_BYTES = 64 * 1024 * 1024;
863
868
  /**
864
869
  * Start the translator for one step. Binds an ephemeral port on loopback,
865
870
  * mints the key the SDK will present, and returns the env that points the
@@ -915,7 +920,8 @@ export async function startTranslator(spec) {
915
920
  log.push(line);
916
921
  let upstream;
917
922
  try {
918
- upstream = await fetch(viaEgress(spec.egress, `${base}${responses ? "/responses" : "/chat/completions"}`), {
923
+ const target = `${base}${responses ? "/responses" : "/chat/completions"}`;
924
+ const request = {
919
925
  method: "POST",
920
926
  headers: {
921
927
  "content-type": "application/json",
@@ -923,7 +929,16 @@ export async function startTranslator(spec) {
923
929
  ...(spec.headers ?? {}),
924
930
  },
925
931
  body: JSON.stringify(outbound),
926
- });
932
+ };
933
+ // In a step the lease's proxy is the way out: it guards the address
934
+ // and fills the key itself. Without one this runs on the host — an
935
+ // eval's judge, a dashboard draft — at a base URL the tenant wrote,
936
+ // so through the platform's guard: no private or cluster address,
937
+ // the key never follows a redirect off the provider's origin, and
938
+ // the answer read to UPSTREAM_MAX_BYTES.
939
+ upstream = spec.egress
940
+ ? await fetch(viaEgress(spec.egress, target), request)
941
+ : await fetchUntrusted(target, request, { sameOrigin: true, maxBytes: UPSTREAM_MAX_BYTES });
927
942
  }
928
943
  catch (err) {
929
944
  const message = err instanceof Error ? err.message : String(err);
@@ -1008,8 +1023,12 @@ export async function startTranslator(spec) {
1008
1023
  log.push(`translator error: ${message}`);
1009
1024
  if (!res.headersSent)
1010
1025
  send(500, errorBody(500, `translator: ${message}`));
1011
- else
1026
+ else {
1027
+ // Mid-stream: say so as an Anthropic error event, so the SDK reads a
1028
+ // failed reply, not a short one that merely stopped.
1029
+ res.write(`event: error\ndata: ${errorBody(500, `translator: ${message}`)}\n\n`);
1012
1030
  res.end();
1031
+ }
1013
1032
  }
1014
1033
  });
1015
1034
  await new Promise((resolve, reject) => {
@@ -0,0 +1,28 @@
1
+ export declare const FILTER_LIMITS: {
2
+ readonly depth: 10;
3
+ readonly leaves: 64;
4
+ readonly values: 32;
5
+ readonly textChars: 500;
6
+ };
7
+ /**
8
+ * What is wrong with a `filter:` as written, one sentence each — empty when
9
+ * it can be read. `foldrun check`, the deploy and the editor report these as
10
+ * `config/invalid-value`.
11
+ */
12
+ export declare function checkFilter(filter: unknown): string[];
13
+ export interface FilterVerdict {
14
+ match: boolean;
15
+ /** Why, in one sentence: the first condition that failed, or "matched". */
16
+ reason: string;
17
+ }
18
+ /**
19
+ * Does this payload pass this filter? No filter passes everything. A filter
20
+ * the check would refuse never matches — it fails closed, so a typo holds
21
+ * deliveries back instead of letting all of them through.
22
+ */
23
+ export declare function matchFilter(filter: unknown, payload: unknown): FilterVerdict;
24
+ /** A webhook body as the filter reads it: its JSON, or null when it is not
25
+ * JSON (which no filter matches). */
26
+ export declare function webhookFilterPayload(raw: string): unknown;
27
+ /** The triggers a filter means something for. */
28
+ export declare const FILTERABLE_TRIGGERS: readonly ["webhook", "email", "flow", "storage"];
@@ -0,0 +1,272 @@
1
+ // `filter:` — which deliveries of an event trigger become runs.
2
+ //
3
+ // A webhook flow used to start for every POST that carried the right token:
4
+ // a GitHub hook for "pull requests" started a run for drafts, bot pushes and
5
+ // label changes alike, and the first step's only job was to say "not this
6
+ // one" — a sandbox, a model call and a run on the record, per non-event.
7
+ // A filter says it in the flow's own file, and nothing starts:
8
+ //
9
+ // filter:
10
+ // action: [opened, reopened] # any of these values
11
+ // pull_request:
12
+ // draft: false # exactly this value
13
+ // base:
14
+ // ref: { starts_with: release/ } # text, never a regular expression
15
+ // sender:
16
+ // type: { not_in: [Bot] } # none of these
17
+ //
18
+ // What the pattern is matched against depends on the trigger: a webhook's
19
+ // JSON body; an email's { from, to, subject, text }; a chained flow's
20
+ // { run, flow, status, verdict, summary }; a storage event's { by, paths }.
21
+ //
22
+ // The rules, all of them:
23
+ //
24
+ // · every key must match (AND); a nested object matches the nested field;
25
+ // · a list is the values allowed (any of them); a scalar is the one value;
26
+ // · an operator object — only the keys in, not_in, exists, starts_with,
27
+ // ends_with, contains — says it explicitly; the last three compare text
28
+ // (case-sensitive) and never run a regular expression, so a body a
29
+ // sender crafts cannot make one backtrack for minutes;
30
+ // · strings compare exactly and case-sensitively, numbers numerically
31
+ // (1 equals 1.0), booleans and null as themselves;
32
+ // · a field that is missing fails `in`, a value and the text operators, and passes
33
+ // `not_in`; `exists: false` passes only when it is missing;
34
+ // · a list in the payload: a nested pattern matches when any element
35
+ // does; `in` matches when any element is allowed, `not_in` when none is;
36
+ // · it fails closed — a body that is not JSON, or a pattern that cannot be
37
+ // evaluated, never matches.
38
+ //
39
+ // Pure and browser-safe: the gate, the check, the editor and the dashboard's
40
+ // Test box all call these two functions.
41
+ export const FILTER_LIMITS = { depth: 10, leaves: 64, values: 32, textChars: 500 };
42
+ const TEXT_OPERATORS = ["starts_with", "ends_with", "contains"];
43
+ const TEXT_VERB = { starts_with: "start with", ends_with: "end with", contains: "contain" };
44
+ const OPERATORS = new Set(["in", "not_in", "exists", ...TEXT_OPERATORS]);
45
+ const isScalar = (v) => v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean";
46
+ const isPlainObject = (v) => !!v && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date);
47
+ /** An object whose every key is an operator — `{ not_in: [Bot] }`. A pattern
48
+ * object mixing operators with field names is refused by the check, since
49
+ * it cannot be read one way. */
50
+ function isOperatorObject(v) {
51
+ if (!isPlainObject(v))
52
+ return false;
53
+ const keys = Object.keys(v);
54
+ return keys.length > 0 && keys.every((k) => OPERATORS.has(k));
55
+ }
56
+ /** YAML reads `2026-10-01` as a Date; a filter means the text. */
57
+ const asScalar = (v) => v instanceof Date ? v.toISOString().slice(0, 10) : isScalar(v) ? v : undefined;
58
+ /** Strings exactly, numbers numerically, the rest by identity. */
59
+ function sameValue(a, b) {
60
+ if (typeof a === "number" && typeof b === "number")
61
+ return a === b;
62
+ return a === b;
63
+ }
64
+ const show = (v) => (typeof v === "string" ? JSON.stringify(v) : JSON.stringify(v ?? null));
65
+ /**
66
+ * What is wrong with a `filter:` as written, one sentence each — empty when
67
+ * it can be read. `foldrun check`, the deploy and the editor report these as
68
+ * `config/invalid-value`.
69
+ */
70
+ export function checkFilter(filter) {
71
+ const problems = [];
72
+ if (filter === undefined || filter === null)
73
+ return problems;
74
+ if (!isPlainObject(filter) || Object.keys(filter).length === 0) {
75
+ return ["filter: is a map of field names to the values they must have — e.g. `action: [opened]`"];
76
+ }
77
+ let leaves = 0;
78
+ const walk = (node, where, depth) => {
79
+ if (depth > FILTER_LIMITS.depth) {
80
+ problems.push(`filter: nests deeper than ${FILTER_LIMITS.depth} levels at ${where}`);
81
+ return;
82
+ }
83
+ for (const [key, value] of Object.entries(node)) {
84
+ const at = where ? `${where}.${key}` : key;
85
+ if (OPERATORS.has(key)) {
86
+ problems.push(`filter: ${at} — ${key} is an operator; write it under a field name, e.g. \`sender: { ${key}: … }\``);
87
+ continue;
88
+ }
89
+ if (isPlainObject(value) && Object.keys(value).some((k) => OPERATORS.has(k))) {
90
+ if (!isOperatorObject(value)) {
91
+ problems.push(`filter: ${at} mixes operators (${[...OPERATORS].join(", ")}) with field names — split them`);
92
+ continue;
93
+ }
94
+ leaves++;
95
+ problems.push(...checkOperators(value, at));
96
+ continue;
97
+ }
98
+ if (isPlainObject(value)) {
99
+ if (Object.keys(value).length === 0)
100
+ problems.push(`filter: ${at} is an empty map, which says nothing`);
101
+ else
102
+ walk(value, at, depth + 1);
103
+ continue;
104
+ }
105
+ leaves++;
106
+ if (Array.isArray(value))
107
+ problems.push(...checkValues(value, at, "the list"));
108
+ else if (asScalar(value) === undefined)
109
+ problems.push(`filter: ${at} — a value, a list of values, a map, or { in, not_in, exists, starts_with, ends_with, contains }`);
110
+ }
111
+ };
112
+ walk(filter, "", 1);
113
+ if (leaves > FILTER_LIMITS.leaves)
114
+ problems.push(`filter: has ${leaves} conditions — at most ${FILTER_LIMITS.leaves}`);
115
+ return problems;
116
+ }
117
+ function checkValues(list, at, what) {
118
+ const out = [];
119
+ if (list.length === 0)
120
+ out.push(`filter: ${at} — ${what} is empty, so nothing could ever match`);
121
+ if (list.length > FILTER_LIMITS.values)
122
+ out.push(`filter: ${at} — ${what} has ${list.length} values, at most ${FILTER_LIMITS.values}`);
123
+ if (list.some((v) => asScalar(v) === undefined))
124
+ out.push(`filter: ${at} — ${what} holds only plain values (text, numbers, true/false, null)`);
125
+ return out;
126
+ }
127
+ function checkOperators(op, at) {
128
+ const out = [];
129
+ const list = (k) => {
130
+ const v = op[k];
131
+ if (v === undefined)
132
+ return null;
133
+ const arr = Array.isArray(v) ? v : [v];
134
+ out.push(...checkValues(arr, at, k));
135
+ return arr.map(asScalar).filter((x) => x !== undefined);
136
+ };
137
+ const inList = list("in");
138
+ const notIn = list("not_in");
139
+ if (inList && notIn) {
140
+ const both = inList.filter((v) => notIn.some((w) => sameValue(v, w)));
141
+ if (both.length)
142
+ out.push(`filter: ${at} lists ${both.map(show).join(", ")} in both in and not_in`);
143
+ }
144
+ if (op.exists !== undefined && typeof op.exists !== "boolean")
145
+ out.push(`filter: ${at} — exists: is true or false`);
146
+ const texts = TEXT_OPERATORS.filter((k) => op[k] !== undefined);
147
+ if (op.exists === false && (inList?.length || texts.length)) {
148
+ out.push(`filter: ${at} — exists: false cannot also require a value`);
149
+ }
150
+ for (const k of texts) {
151
+ const v = op[k];
152
+ if (typeof v !== "string" || !v)
153
+ out.push(`filter: ${at} — ${k}: is text, e.g. \`${k}: "refs/heads/"\``);
154
+ else if (v.length > FILTER_LIMITS.textChars)
155
+ out.push(`filter: ${at} — ${k}: is longer than ${FILTER_LIMITS.textChars} characters`);
156
+ }
157
+ return out;
158
+ }
159
+ const MISSING = Symbol("missing");
160
+ /** One field's value against one leaf of the pattern. */
161
+ function leafMatches(value, cond, at) {
162
+ // An operator object.
163
+ if (isOperatorObject(cond)) {
164
+ if (cond.exists !== undefined) {
165
+ const present = value !== MISSING;
166
+ if (cond.exists === true && !present)
167
+ return { match: false, reason: `${at} is missing (exists: true)` };
168
+ if (cond.exists === false && present)
169
+ return { match: false, reason: `${at} is present (exists: false)` };
170
+ }
171
+ const values = Array.isArray(value) ? value : value === MISSING ? [] : [value];
172
+ if (cond.in !== undefined) {
173
+ const allowed = (Array.isArray(cond.in) ? cond.in : [cond.in]).map(asScalar).filter((x) => x !== undefined);
174
+ if (value === MISSING)
175
+ return { match: false, reason: `${at} is missing` };
176
+ if (!values.some((v) => allowed.some((a) => sameValue(a, v))))
177
+ return { match: false, reason: `${at} is ${show(value)}, not one of ${allowed.map(show).join(", ")}` };
178
+ }
179
+ if (cond.not_in !== undefined) {
180
+ const refused = (Array.isArray(cond.not_in) ? cond.not_in : [cond.not_in]).map(asScalar).filter((x) => x !== undefined);
181
+ const hit = values.find((v) => refused.some((a) => sameValue(a, v)));
182
+ if (hit !== undefined)
183
+ return { match: false, reason: `${at} is ${show(hit)}, which not_in refuses` };
184
+ }
185
+ for (const k of TEXT_OPERATORS) {
186
+ const want = cond[k];
187
+ if (want === undefined)
188
+ continue;
189
+ if (value === MISSING)
190
+ return { match: false, reason: `${at} is missing` };
191
+ const w = String(want);
192
+ const texts = values.filter((v) => typeof v === "string" || typeof v === "number").map(String);
193
+ const hit = texts.some((t) => (k === "starts_with" ? t.startsWith(w) : k === "ends_with" ? t.endsWith(w) : t.includes(w)));
194
+ if (!hit)
195
+ return { match: false, reason: `${at} is ${show(value)}, which does not ${TEXT_VERB[k]} ${show(w)}` };
196
+ }
197
+ return { match: true, reason: "matched" };
198
+ }
199
+ // A list of allowed values, or one value.
200
+ const allowed = (Array.isArray(cond) ? cond : [cond]).map(asScalar);
201
+ if (allowed.some((a) => a === undefined))
202
+ return { match: false, reason: `${at}: the filter's value cannot be compared` };
203
+ if (value === MISSING)
204
+ return { match: false, reason: `${at} is missing` };
205
+ const values = Array.isArray(value) ? value : [value];
206
+ if (values.some((v) => allowed.some((a) => sameValue(a, v))))
207
+ return { match: true, reason: "matched" };
208
+ return {
209
+ match: false,
210
+ reason: allowed.length === 1 ? `${at} is ${show(value)}, not ${show(allowed[0])}` : `${at} is ${show(value)}, not one of ${allowed.map(show).join(", ")}`,
211
+ };
212
+ }
213
+ function nodeMatches(payload, pattern, where, depth) {
214
+ if (depth > FILTER_LIMITS.depth)
215
+ return { match: false, reason: `the filter nests deeper than ${FILTER_LIMITS.depth} levels` };
216
+ for (const [key, cond] of Object.entries(pattern)) {
217
+ const at = where ? `${where}.${key}` : key;
218
+ const value = isPlainObject(payload) && Object.prototype.hasOwnProperty.call(payload, key) ? payload[key] : MISSING;
219
+ if (isPlainObject(cond) && !isOperatorObject(cond)) {
220
+ // A nested pattern: the field must be an object that matches it, or a
221
+ // list holding one that does.
222
+ if (value === MISSING)
223
+ return { match: false, reason: `${at} is missing` };
224
+ // A nested pattern needs an object (or a list of them) to look inside:
225
+ // a scalar or null there is not "every field missing", which would let
226
+ // not_in and exists: false pass on a malformed delivery.
227
+ if (!Array.isArray(value) && !isPlainObject(value))
228
+ return { match: false, reason: `${at} is ${show(value)}, not an object` };
229
+ if (Array.isArray(value)) {
230
+ const objects = value.filter(isPlainObject);
231
+ if (objects.some((o) => nodeMatches(o, cond, at, depth + 1).match))
232
+ continue;
233
+ return { match: false, reason: `no item of ${at} matches` };
234
+ }
235
+ const inner = nodeMatches(value, cond, at, depth + 1);
236
+ if (!inner.match)
237
+ return inner;
238
+ continue;
239
+ }
240
+ const leaf = leafMatches(value, cond, at);
241
+ if (!leaf.match)
242
+ return leaf;
243
+ }
244
+ return { match: true, reason: "matched" };
245
+ }
246
+ /**
247
+ * Does this payload pass this filter? No filter passes everything. A filter
248
+ * the check would refuse never matches — it fails closed, so a typo holds
249
+ * deliveries back instead of letting all of them through.
250
+ */
251
+ export function matchFilter(filter, payload) {
252
+ if (filter === undefined || filter === null)
253
+ return { match: true, reason: "no filter" };
254
+ const problems = checkFilter(filter);
255
+ if (problems.length)
256
+ return { match: false, reason: `the filter cannot be read: ${problems[0].replace(/^filter: /, "")}` };
257
+ if (!isPlainObject(payload))
258
+ return { match: false, reason: "the delivery is not a JSON object, so no field can match" };
259
+ return nodeMatches(payload, filter, "", 1);
260
+ }
261
+ /** A webhook body as the filter reads it: its JSON, or null when it is not
262
+ * JSON (which no filter matches). */
263
+ export function webhookFilterPayload(raw) {
264
+ try {
265
+ return JSON.parse(raw);
266
+ }
267
+ catch {
268
+ return null;
269
+ }
270
+ }
271
+ /** The triggers a filter means something for. */
272
+ export const FILTERABLE_TRIGGERS = ["webhook", "email", "flow", "storage"];
@@ -1,7 +1,7 @@
1
1
  /** What became of one trigger firing. `started` is here too: without it the
2
2
  * log answers "why did nothing run" and not "how often does anything",
3
3
  * and the ratio is the whole point of the page. */
4
- export type TriggerOutcome = "started" | "duplicate" | "throttled" | "debounced" | "quarantined" | "paused" | "halted" | "overlap-skipped" | "missed" | "failed";
4
+ export type TriggerOutcome = "started" | "duplicate" | "throttled" | "debounced" | "quarantined" | "paused" | "halted" | "overlap-skipped" | "missed" | "filtered" | "failed";
5
5
  export interface TriggerEvent {
6
6
  t: string;
7
7
  flow: string;
@@ -6,6 +6,8 @@ export declare function withTask(steps: FlowStep[], tag: string, body: string):
6
6
  export declare function endingOf(run: RunRecord): "completed" | "failed" | "blocked";
7
7
  /** The flows a finished run should start, by this workspace's own files. */
8
8
  export declare function chainedFlows(tenant: string, workspace: string, finished: RunRecord): FlowInfo[];
9
+ /** What a chained flow's `filter:` reads: the run that just settled. */
10
+ export declare function chainFields(finished: RunRecord): Record<string, unknown>;
9
11
  /**
10
12
  * Start every flow chained on a run that just settled. The finished run's
11
13
  * one-line summary and its final result are the task; the run id rides