@foldrun/core 2.10.1 → 2.11.1
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/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/catalog.d.ts +3 -0
- package/dist/src/catalog.js +4 -1
- package/dist/src/deploy.js +16 -0
- package/dist/src/eval-compare.d.ts +74 -0
- package/dist/src/eval-compare.js +187 -0
- package/dist/src/evals.d.ts +62 -3
- package/dist/src/evals.js +161 -36
- package/dist/src/flow-lint.js +4 -0
- package/dist/src/frontmatter-fields.js +4 -1
- package/dist/src/grades.d.ts +118 -0
- package/dist/src/grades.js +481 -0
- package/dist/src/judge-verdict.d.ts +1 -0
- package/dist/src/judge-verdict.js +3 -1
- package/dist/src/online-checks.d.ts +4 -0
- package/dist/src/online-checks.js +5 -1
- package/dist/src/openapi.d.ts +4 -0
- package/dist/src/openapi.js +5 -1
- package/dist/src/platform.d.ts +12 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/starter.js +5 -0
- package/dist/src/store.d.ts +8 -0
- package/dist/src/store.js +5 -1
- package/dist/src/tool-test.js +19 -7
- package/dist/src/translator.d.ts +4 -0
- package/dist/src/translator.js +22 -3
- package/dist/src/trigger-filter.d.ts +28 -0
- package/dist/src/trigger-filter.js +272 -0
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +2 -0
- package/dist/src/triggers.js +13 -2
- package/dist/src/untrusted-fetch.d.ts +16 -0
- package/dist/src/untrusted-fetch.js +60 -2
- package/index.ts +3 -0
- package/package.json +3 -3
- package/src/catalog.ts +5 -1
- package/src/deploy.ts +10 -0
- package/src/eval-compare.ts +243 -0
- package/src/evals.ts +195 -38
- package/src/flow-lint.ts +4 -0
- package/src/frontmatter-fields.ts +4 -1
- package/src/grades.ts +532 -0
- package/src/judge-verdict.ts +4 -2
- package/src/online-checks.ts +5 -1
- package/src/openapi.ts +6 -1
- package/src/platform.ts +16 -4
- package/src/starter.ts +5 -0
- package/src/store.ts +11 -1
- package/src/tool-test.ts +21 -7
- package/src/translator.ts +24 -3
- package/src/trigger-filter.ts +264 -0
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +14 -2
- package/src/untrusted-fetch.ts +65 -2
package/dist/src/store.d.ts
CHANGED
|
@@ -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}`);
|
package/dist/src/tool-test.js
CHANGED
|
@@ -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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
});
|
|
140
|
-
|
|
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
|
-
|
|
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",
|
package/dist/src/translator.d.ts
CHANGED
|
@@ -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
|
package/dist/src/translator.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/src/triggers.d.ts
CHANGED
|
@@ -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
|
package/dist/src/triggers.js
CHANGED
|
@@ -54,6 +54,17 @@ export function chainedFlows(tenant, workspace, finished) {
|
|
|
54
54
|
f.name !== finished.flow &&
|
|
55
55
|
(f.on === "any" || f.on === endingOf(finished)));
|
|
56
56
|
}
|
|
57
|
+
/** What a chained flow's `filter:` reads: the run that just settled. */
|
|
58
|
+
export function chainFields(finished) {
|
|
59
|
+
return {
|
|
60
|
+
run: finished.id,
|
|
61
|
+
flow: finished.flow,
|
|
62
|
+
status: finished.status,
|
|
63
|
+
ending: endingOf(finished),
|
|
64
|
+
verdict: runVerdict(finished) ?? null,
|
|
65
|
+
summary: finished.summary ?? null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
57
68
|
/**
|
|
58
69
|
* Start every flow chained on a run that just settled. The finished run's
|
|
59
70
|
* one-line summary and its final result are the task; the run id rides
|
|
@@ -73,7 +84,7 @@ export async function fireChainedFlows(tenant, workspace, finished) {
|
|
|
73
84
|
(result ? `\n${result}` : "");
|
|
74
85
|
for (const flow of flows) {
|
|
75
86
|
try {
|
|
76
|
-
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "previous_run" });
|
|
87
|
+
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "previous_run", fields: chainFields(finished) });
|
|
77
88
|
if (!gate.admit) {
|
|
78
89
|
console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
|
|
79
90
|
continue;
|
|
@@ -138,7 +149,7 @@ export async function fireStorageTriggers(tenant, workspace, paths, by) {
|
|
|
138
149
|
const body = `by: ${by}\n${hits.map((h) => `- storage/${h}`).join("\n")}`;
|
|
139
150
|
// A deploy that writes forty files under one prefix is one event, not
|
|
140
151
|
// forty runs — which is exactly what `debounce:` is for.
|
|
141
|
-
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "storage_event" });
|
|
152
|
+
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "storage_event", fields: { by, paths: hits } });
|
|
142
153
|
if (!gate.admit) {
|
|
143
154
|
console.log(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
|
|
144
155
|
continue;
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
/** How many redirects one request may follow. fetch's own limit is 20;
|
|
2
2
|
* nothing a tenant points the platform at needs more than a couple. */
|
|
3
3
|
export declare const MAX_REDIRECTS = 5;
|
|
4
|
+
/** What one answer may be, read, unless the caller says otherwise. */
|
|
5
|
+
export declare const MAX_BYTES: number;
|
|
6
|
+
export declare class ResponseTooLarge extends Error {
|
|
7
|
+
constructor(url: string, maxBytes: number);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* `res`, its body read to at most `maxBytes`. The status and headers stand;
|
|
11
|
+
* the body errors with ResponseTooLarge at the limit — `text()`, `json()`
|
|
12
|
+
* and a reader all reject, never resolve short. A declared length past the
|
|
13
|
+
* limit errors at the first read, before a byte is fetched; an undeclared
|
|
14
|
+
* one is counted as it streams (after decompression, so a small gzip that
|
|
15
|
+
* unpacks huge is caught too). Infinity: no limit.
|
|
16
|
+
*/
|
|
17
|
+
export declare function capBody(res: Response, maxBytes: number, url: string): Response;
|
|
4
18
|
export declare class RedirectRefused extends Error {
|
|
5
19
|
constructor(message: string);
|
|
6
20
|
}
|
|
@@ -8,4 +22,6 @@ export declare function fetchUntrusted(url: string, init?: RequestInit, opts?: {
|
|
|
8
22
|
/** The request carries a credential (in its body or its headers):
|
|
9
23
|
* follow a redirect only within the origin it was sent to. */
|
|
10
24
|
sameOrigin?: boolean;
|
|
25
|
+
/** The most the answer may be, read; MAX_BYTES unless said. */
|
|
26
|
+
maxBytes?: number;
|
|
11
27
|
}): Promise<Response>;
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
// the first one was. A hop to another origin keeps only the headers that
|
|
15
15
|
// cannot carry a credential, and a caller whose request IS a credential
|
|
16
16
|
// (a token exchange, a provider key in a header) follows none at all.
|
|
17
|
+
//
|
|
18
|
+
// And every answer is read to a limit (maxBytes, 1 MiB unless the caller
|
|
19
|
+
// says otherwise): a tenant's URL can answer forever, and the worker or web
|
|
20
|
+
// tier holding it all in memory is the platform's problem, not the tenant's.
|
|
21
|
+
// Past the limit the body errors — a caller never gets the first megabyte
|
|
22
|
+
// passed off as the whole answer.
|
|
17
23
|
import { platform } from "./platform.js";
|
|
18
24
|
/** How many redirects one request may follow. fetch's own limit is 20;
|
|
19
25
|
* nothing a tenant points the platform at needs more than a couple. */
|
|
@@ -23,6 +29,57 @@ const REDIRECT = new Set([301, 302, 303, 307, 308]);
|
|
|
23
29
|
* credential. Everything else — Authorization, a cookie, an x-api-key, a
|
|
24
30
|
* webhook's signature — stays with the origin it was meant for. */
|
|
25
31
|
const CROSS_ORIGIN_SAFE = new Set(["accept", "accept-language", "content-type", "user-agent"]);
|
|
32
|
+
/** What one answer may be, read, unless the caller says otherwise. */
|
|
33
|
+
export const MAX_BYTES = 1024 * 1024;
|
|
34
|
+
export class ResponseTooLarge extends Error {
|
|
35
|
+
constructor(url, maxBytes) {
|
|
36
|
+
super(`${url} answered more than ${sizeLabel(maxBytes)} — not read`);
|
|
37
|
+
this.name = "ResponseTooLarge";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const sizeLabel = (n) => (n % (1024 * 1024) === 0 ? `${n / (1024 * 1024)} MiB` : `${n} bytes`);
|
|
41
|
+
/** Statuses whose Response may carry no body (the constructor throws on one). */
|
|
42
|
+
const NULL_BODY = new Set([101, 204, 205, 304]);
|
|
43
|
+
/**
|
|
44
|
+
* `res`, its body read to at most `maxBytes`. The status and headers stand;
|
|
45
|
+
* the body errors with ResponseTooLarge at the limit — `text()`, `json()`
|
|
46
|
+
* and a reader all reject, never resolve short. A declared length past the
|
|
47
|
+
* limit errors at the first read, before a byte is fetched; an undeclared
|
|
48
|
+
* one is counted as it streams (after decompression, so a small gzip that
|
|
49
|
+
* unpacks huge is caught too). Infinity: no limit.
|
|
50
|
+
*/
|
|
51
|
+
export function capBody(res, maxBytes, url) {
|
|
52
|
+
if (!Number.isFinite(maxBytes) || !res.body || NULL_BODY.has(res.status))
|
|
53
|
+
return res;
|
|
54
|
+
const declared = Number(res.headers.get("content-length") ?? NaN);
|
|
55
|
+
const tooLong = Number.isFinite(declared) && declared > maxBytes;
|
|
56
|
+
const reader = res.body.getReader();
|
|
57
|
+
let seen = 0;
|
|
58
|
+
const body = new ReadableStream({
|
|
59
|
+
async pull(controller) {
|
|
60
|
+
if (tooLong) {
|
|
61
|
+
reader.cancel().catch(() => { });
|
|
62
|
+
return controller.error(new ResponseTooLarge(url, maxBytes));
|
|
63
|
+
}
|
|
64
|
+
const { done, value } = await reader.read();
|
|
65
|
+
if (done)
|
|
66
|
+
return controller.close();
|
|
67
|
+
seen += value.byteLength;
|
|
68
|
+
if (seen > maxBytes) {
|
|
69
|
+
reader.cancel().catch(() => { });
|
|
70
|
+
return controller.error(new ResponseTooLarge(url, maxBytes));
|
|
71
|
+
}
|
|
72
|
+
controller.enqueue(value);
|
|
73
|
+
},
|
|
74
|
+
cancel(reason) {
|
|
75
|
+
return reader.cancel(reason);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const out = new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
79
|
+
// Where the answer came from, as the original said.
|
|
80
|
+
Object.defineProperty(out, "url", { value: res.url });
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
26
83
|
export class RedirectRefused extends Error {
|
|
27
84
|
constructor(message) {
|
|
28
85
|
super(message);
|
|
@@ -30,13 +87,14 @@ export class RedirectRefused extends Error {
|
|
|
30
87
|
}
|
|
31
88
|
}
|
|
32
89
|
export async function fetchUntrusted(url, init = {}, opts = {}) {
|
|
90
|
+
const maxBytes = opts.maxBytes ?? MAX_BYTES;
|
|
33
91
|
let current = url;
|
|
34
92
|
let req = { ...init };
|
|
35
93
|
for (let hop = 0;; hop++) {
|
|
36
|
-
const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" });
|
|
94
|
+
const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, { maxBytes });
|
|
37
95
|
const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
|
|
38
96
|
if (!location)
|
|
39
|
-
return res;
|
|
97
|
+
return capBody(res, maxBytes, current);
|
|
40
98
|
// The body of a redirect is never read; free the socket.
|
|
41
99
|
await res.body?.cancel().catch(() => { });
|
|
42
100
|
if (hop >= MAX_REDIRECTS)
|