@foldrun/core 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +3 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +224 -4
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +26 -2
- package/dist/src/egress.js +8 -1
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +9 -0
- package/dist/src/evals.js +26 -13
- package/dist/src/flow-lint.d.ts +12 -0
- package/dist/src/flow-lint.js +331 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +43 -4
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +245 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +132 -8
- package/dist/src/notify.js +360 -58
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +87 -0
- package/dist/src/platform.d.ts +144 -4
- package/dist/src/platform.js +27 -3
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +233 -0
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +206 -17
- package/dist/src/run-container.js +547 -80
- package/dist/src/runner.d.ts +179 -10
- package/dist/src/runner.js +1423 -169
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +15 -2
- package/dist/src/script-tools.js +44 -4
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +83 -0
- package/dist/src/secret-health.js +177 -0
- package/dist/src/secrets.d.ts +38 -1
- package/dist/src/secrets.js +197 -96
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +95 -11
- package/dist/src/step-exec.d.ts +106 -1
- package/dist/src/step-exec.js +558 -85
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +492 -38
- package/dist/src/store.js +882 -69
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +152 -33
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +71 -24
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +23 -0
- package/dist/src/triggers.js +101 -12
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +50 -11
- package/index.ts +3 -0
- package/package.json +5 -5
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/approvals.ts +253 -2
- package/src/browser-pod.ts +313 -0
- package/src/budget.ts +178 -0
- package/src/catalog.ts +3 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +224 -4
- package/src/deploy.ts +137 -6
- package/src/egress.ts +32 -4
- package/src/eol.ts +7 -0
- package/src/evals.ts +36 -11
- package/src/flow-lint.ts +371 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +50 -4
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +316 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +391 -44
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +83 -0
- package/src/platform.ts +158 -5
- package/src/provider-health.ts +275 -0
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +611 -76
- package/src/runner.ts +1463 -165
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +55 -6
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +230 -0
- package/src/secrets.ts +191 -95
- package/src/starter.ts +96 -11
- package/src/step-exec.ts +548 -41
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +149 -24
- package/src/store.ts +1227 -74
- package/src/subagents.ts +189 -0
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +159 -32
- package/src/translator.ts +74 -23
- package/src/trigger-log.ts +160 -0
- package/src/triggers.ts +102 -12
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +58 -11
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
package/src/translator.ts
CHANGED
|
@@ -30,6 +30,8 @@ import http from "node:http";
|
|
|
30
30
|
import crypto from "node:crypto";
|
|
31
31
|
import { PROTECTED_PARAMS } from "./providers.ts";
|
|
32
32
|
import { viaEgress } from "./egress.ts";
|
|
33
|
+
import { trimSlashes } from "./paths.ts";
|
|
34
|
+
import { captureRefusalHeaders, isRefusalStatus, refusalLine } from "./refusal.ts";
|
|
33
35
|
|
|
34
36
|
// ------------------------------------------------------------------ shapes
|
|
35
37
|
|
|
@@ -59,6 +61,10 @@ export interface TranslatorSpec {
|
|
|
59
61
|
* upstream request goes through it, and `upstreamKey` is a placeholder
|
|
60
62
|
* the proxy fills. */
|
|
61
63
|
egress?: string;
|
|
64
|
+
/** The step's own zone, from the clock cascade — what a reset time in a
|
|
65
|
+
* refusal is told in. UTC when nothing said otherwise. */
|
|
66
|
+
timezone?: string;
|
|
67
|
+
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
export interface RunningTranslator {
|
|
@@ -418,16 +424,38 @@ export function toResponses(
|
|
|
418
424
|
|
|
419
425
|
// ------------------------------------------------------- response mapping
|
|
420
426
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
427
|
+
type Stop = { reason: string; sequence: string | null };
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Why a Chat Completion stopped, in Anthropic's words. `content_filter`
|
|
431
|
+
* (OpenAI, Azure, Gemini's safety block) is a refusal; Mistral's
|
|
432
|
+
* `model_length` is the context window filling, not max_tokens. A stop
|
|
433
|
+
* sequence is named only where the provider says which one matched — vLLM
|
|
434
|
+
* as `stop_reason`, SGLang as `matched_stop` — and only when it is one the
|
|
435
|
+
* request asked for; plain OpenAI says `stop` either way, which reads as
|
|
436
|
+
* end_turn. pause_turn has no counterpart: server-side tools are dropped.
|
|
437
|
+
*/
|
|
438
|
+
function chatStop(choice: Json, sawTool: boolean, stops: readonly string[]): Stop {
|
|
439
|
+
const finish = choice.finish_reason;
|
|
440
|
+
if (finish === "content_filter") return { reason: "refusal", sequence: null };
|
|
441
|
+
if (finish === "length") return { reason: "max_tokens", sequence: null };
|
|
442
|
+
if (finish === "model_length") return { reason: "model_context_window_exceeded", sequence: null };
|
|
443
|
+
if (sawTool || finish === "tool_calls" || finish === "function_call") return { reason: "tool_use", sequence: null };
|
|
444
|
+
const matched = typeof choice.stop_reason === "string" ? choice.stop_reason : choice.matched_stop;
|
|
445
|
+
if (typeof matched === "string" && stops.includes(matched)) return { reason: "stop_sequence", sequence: matched };
|
|
446
|
+
return { reason: "end_turn", sequence: null };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Why a Response stopped: a refusal part or a content filter is a refusal. */
|
|
450
|
+
function responsesStop(incomplete: unknown, sawTool: boolean, sawRefusal: boolean): string {
|
|
451
|
+
if (sawRefusal || incomplete === "content_filter") return "refusal";
|
|
452
|
+
if (incomplete === "max_output_tokens") return "max_tokens";
|
|
453
|
+
if (sawTool) return "tool_use";
|
|
454
|
+
return "end_turn";
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function stopList(stops: unknown): string[] {
|
|
458
|
+
return Array.isArray(stops) ? stops.filter((s): s is string => typeof s === "string") : [];
|
|
431
459
|
}
|
|
432
460
|
|
|
433
461
|
function parseArgs(raw: unknown): unknown {
|
|
@@ -440,7 +468,7 @@ function parseArgs(raw: unknown): unknown {
|
|
|
440
468
|
}
|
|
441
469
|
|
|
442
470
|
/** A finished Chat Completion as an Anthropic message. Pure. */
|
|
443
|
-
export function fromChatCompletion(res: Json, requestedModel: string): Json {
|
|
471
|
+
export function fromChatCompletion(res: Json, requestedModel: string, stopSequences?: unknown): Json {
|
|
444
472
|
const choice = ((res.choices as Json[] | undefined) ?? [])[0] ?? {};
|
|
445
473
|
const msg = (choice.message as Json | undefined) ?? {};
|
|
446
474
|
const content: Json[] = [];
|
|
@@ -456,14 +484,15 @@ export function fromChatCompletion(res: Json, requestedModel: string): Json {
|
|
|
456
484
|
});
|
|
457
485
|
}
|
|
458
486
|
const usage = (res.usage as Json | undefined) ?? {};
|
|
487
|
+
const stop = chatStop(choice, content.some((c) => c.type === "tool_use"), stopList(stopSequences));
|
|
459
488
|
return {
|
|
460
489
|
id: String(res.id ?? `msg_${crypto.randomBytes(8).toString("hex")}`),
|
|
461
490
|
type: "message",
|
|
462
491
|
role: "assistant",
|
|
463
492
|
model: String(res.model ?? requestedModel),
|
|
464
493
|
content,
|
|
465
|
-
stop_reason:
|
|
466
|
-
stop_sequence:
|
|
494
|
+
stop_reason: stop.reason,
|
|
495
|
+
stop_sequence: stop.sequence,
|
|
467
496
|
usage: {
|
|
468
497
|
input_tokens: Number(usage.prompt_tokens ?? 0),
|
|
469
498
|
output_tokens: Number(usage.completion_tokens ?? 0),
|
|
@@ -484,12 +513,15 @@ export class StreamTranslator {
|
|
|
484
513
|
private textIndex: number | null = null;
|
|
485
514
|
/** Chat-Completions tool index → Anthropic block index. */
|
|
486
515
|
private tools = new Map<number, { block: number; argsSeen: boolean }>();
|
|
487
|
-
|
|
516
|
+
/** The last choice's stop fields: finish_reason and any matched stop. */
|
|
517
|
+
private finish: Json = {};
|
|
488
518
|
private usage: { input: number; output: number } | null = null;
|
|
489
519
|
private id = `msg_${crypto.randomBytes(8).toString("hex")}`;
|
|
490
520
|
private readonly model: string;
|
|
491
|
-
|
|
521
|
+
private readonly stops: string[];
|
|
522
|
+
constructor(model: string, stopSequences?: unknown) {
|
|
492
523
|
this.model = model;
|
|
524
|
+
this.stops = stopList(stopSequences);
|
|
493
525
|
}
|
|
494
526
|
|
|
495
527
|
private event(name: string, data: Json): string {
|
|
@@ -533,7 +565,7 @@ export class StreamTranslator {
|
|
|
533
565
|
}
|
|
534
566
|
const choice = ((chunk.choices as Json[] | undefined) ?? [])[0];
|
|
535
567
|
if (!choice) return out;
|
|
536
|
-
if (choice.finish_reason) this.finish = choice.finish_reason;
|
|
568
|
+
if (choice.finish_reason) this.finish = { finish_reason: choice.finish_reason, stop_reason: choice.stop_reason, matched_stop: choice.matched_stop };
|
|
537
569
|
const delta = (choice.delta as Json | undefined) ?? {};
|
|
538
570
|
|
|
539
571
|
const text = typeof delta.content === "string" ? delta.content : "";
|
|
@@ -613,11 +645,11 @@ export class StreamTranslator {
|
|
|
613
645
|
}
|
|
614
646
|
out.push(this.event("content_block_stop", { type: "content_block_stop", index: entry.block }));
|
|
615
647
|
}
|
|
616
|
-
const
|
|
648
|
+
const stop = chatStop(this.finish, this.tools.size > 0, this.stops);
|
|
617
649
|
out.push(
|
|
618
650
|
this.event("message_delta", {
|
|
619
651
|
type: "message_delta",
|
|
620
|
-
delta: { stop_reason: reason, stop_sequence:
|
|
652
|
+
delta: { stop_reason: stop.reason, stop_sequence: stop.sequence },
|
|
621
653
|
usage: { input_tokens: this.usage?.input ?? 0, output_tokens: this.usage?.output ?? 0 },
|
|
622
654
|
}),
|
|
623
655
|
);
|
|
@@ -647,7 +679,10 @@ export function fromResponses(res: Json, requestedModel: string): Json {
|
|
|
647
679
|
}
|
|
648
680
|
const usage = (res.usage as Json | undefined) ?? {};
|
|
649
681
|
const incomplete = (res.incomplete_details as Json | undefined)?.reason;
|
|
650
|
-
const
|
|
682
|
+
const refused = (Array.isArray(res.output) ? (res.output as Json[]) : []).some(
|
|
683
|
+
(item) => item.type === "message" && Array.isArray(item.content) && (item.content as Json[]).some((c) => c.type === "refusal"),
|
|
684
|
+
);
|
|
685
|
+
const stop = responsesStop(incomplete, content.some((c) => c.type === "tool_use"), refused);
|
|
651
686
|
return {
|
|
652
687
|
id: String(res.id ?? `msg_${crypto.randomBytes(8).toString("hex")}`),
|
|
653
688
|
type: "message",
|
|
@@ -678,6 +713,7 @@ export class ResponsesStreamTranslator {
|
|
|
678
713
|
private usage: { input: number; output: number } | null = null;
|
|
679
714
|
private incomplete: string | null = null;
|
|
680
715
|
private sawTool = false;
|
|
716
|
+
private sawRefusal = false;
|
|
681
717
|
private id = `msg_${crypto.randomBytes(8).toString("hex")}`;
|
|
682
718
|
private readonly model: string;
|
|
683
719
|
constructor(model: string) {
|
|
@@ -742,6 +778,7 @@ export class ResponsesStreamTranslator {
|
|
|
742
778
|
if (type === "response.output_text.delta" || type === "response.refusal.delta") {
|
|
743
779
|
const entry = this.blocks.get(Number(ev.output_index));
|
|
744
780
|
const text = String(ev.delta ?? "");
|
|
781
|
+
if (type === "response.refusal.delta") this.sawRefusal = true;
|
|
745
782
|
if (entry && entry.kind === "text" && text) {
|
|
746
783
|
out.push(this.event("content_block_delta", { type: "content_block_delta", index: entry.block, delta: { type: "text_delta", text } }));
|
|
747
784
|
}
|
|
@@ -792,7 +829,7 @@ export class ResponsesStreamTranslator {
|
|
|
792
829
|
}
|
|
793
830
|
out.push(this.event("content_block_stop", { type: "content_block_stop", index: entry.block }));
|
|
794
831
|
}
|
|
795
|
-
const reason = this.
|
|
832
|
+
const reason = responsesStop(this.incomplete, this.sawTool, this.sawRefusal);
|
|
796
833
|
out.push(
|
|
797
834
|
this.event("message_delta", {
|
|
798
835
|
type: "message_delta",
|
|
@@ -845,7 +882,7 @@ function upstreamMessage(text: string, status: number): string {
|
|
|
845
882
|
export async function startTranslator(spec: TranslatorSpec): Promise<RunningTranslator> {
|
|
846
883
|
const key = `fr-${crypto.randomBytes(16).toString("hex")}`;
|
|
847
884
|
const log: string[] = [];
|
|
848
|
-
const base = spec.upstreamBase
|
|
885
|
+
const base = trimSlashes(spec.upstreamBase);
|
|
849
886
|
const label = spec.label ?? base;
|
|
850
887
|
|
|
851
888
|
const server = http.createServer(async (req, res) => {
|
|
@@ -912,6 +949,20 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
|
|
|
912
949
|
const text = await upstream.text();
|
|
913
950
|
const message = upstreamMessage(text, upstream.status);
|
|
914
951
|
log.push(`POST ${label} → ${upstream.status} (${Date.now() - started}ms): ${message.slice(0, 160)}`);
|
|
952
|
+
// The refusal in words, from the headers the response already
|
|
953
|
+
// carried. Only on a refusal, and only the allow-listed headers:
|
|
954
|
+
// a 400 about a malformed body is the work's problem, not the
|
|
955
|
+
// supply's, and explaining it as one would mislead.
|
|
956
|
+
if (isRefusalStatus(upstream.status)) {
|
|
957
|
+
log.push(
|
|
958
|
+
refusalLine({
|
|
959
|
+
status: upstream.status,
|
|
960
|
+
headers: captureRefusalHeaders(upstream.headers),
|
|
961
|
+
timezone: spec.timezone,
|
|
962
|
+
provider: label,
|
|
963
|
+
}),
|
|
964
|
+
);
|
|
965
|
+
}
|
|
915
966
|
// The status crosses unchanged, so a 401 or 429 from the provider
|
|
916
967
|
// reads as one to the SDK — and to the fallback decision upstream.
|
|
917
968
|
return send(upstream.status, errorBody(upstream.status, message));
|
|
@@ -927,11 +978,11 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
|
|
|
927
978
|
return send(502, errorBody(502, `translator: ${label} answered with something that is not JSON`));
|
|
928
979
|
}
|
|
929
980
|
log.push(`POST ${label} → 200 (${Date.now() - started}ms, ${model})`);
|
|
930
|
-
return send(200, JSON.stringify(responses ? fromResponses(parsed, model) : fromChatCompletion(parsed, model)));
|
|
981
|
+
return send(200, JSON.stringify(responses ? fromResponses(parsed, model) : fromChatCompletion(parsed, model, body.stop_sequences)));
|
|
931
982
|
}
|
|
932
983
|
|
|
933
984
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
934
|
-
const machine = responses ? new ResponsesStreamTranslator(model) : new StreamTranslator(model);
|
|
985
|
+
const machine = responses ? new ResponsesStreamTranslator(model) : new StreamTranslator(model, body.stop_sequences);
|
|
935
986
|
const reader = upstream.body?.getReader();
|
|
936
987
|
if (!reader) {
|
|
937
988
|
for (const e of machine.finishStream()) res.write(e);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Why nothing happened.
|
|
2
|
+
//
|
|
3
|
+
// A run that exists can be read. A run that does not exist leaves no record
|
|
4
|
+
// anywhere, and "the hook fired but nothing ran" is the hardest question the
|
|
5
|
+
// platform gets asked — because every honest answer to it is a thing that
|
|
6
|
+
// deliberately did NOT happen:
|
|
7
|
+
//
|
|
8
|
+
// the delivery was a duplicate idempotency:
|
|
9
|
+
// the burst is still being waited out debounce:
|
|
10
|
+
// it arrived inside the window throttle:
|
|
11
|
+
// the flow is switched off disable_after:
|
|
12
|
+
// yesterday's run is still going overlap: skip
|
|
13
|
+
// the platform was down at 05:00 catchup: none
|
|
14
|
+
//
|
|
15
|
+
// Six different reasons, all of them correct behaviour, none of them visible.
|
|
16
|
+
// Before this they were console lines on a box nobody reads.
|
|
17
|
+
//
|
|
18
|
+
// This is deliberately NOT the hook delivery log next door. That one answers
|
|
19
|
+
// "did the request reach us and was it authentic" — a bad token, a failed
|
|
20
|
+
// signature. This one answers "did an event become a run". A delivery can be
|
|
21
|
+
// perfectly authentic and still not start anything, which is exactly the
|
|
22
|
+
// case that needed writing down.
|
|
23
|
+
//
|
|
24
|
+
// One bounded JSONL per workspace, the same shape and the same compaction as
|
|
25
|
+
// the delivery log: this is diagnostics, not an audit trail, and it must
|
|
26
|
+
// never become a disk-full incident with a delay.
|
|
27
|
+
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { workspaceDir } from "./store.ts";
|
|
31
|
+
|
|
32
|
+
/** What became of one trigger firing. `started` is here too: without it the
|
|
33
|
+
* log answers "why did nothing run" and not "how often does anything",
|
|
34
|
+
* and the ratio is the whole point of the page. */
|
|
35
|
+
export type TriggerOutcome =
|
|
36
|
+
| "started"
|
|
37
|
+
| "duplicate"
|
|
38
|
+
| "throttled"
|
|
39
|
+
| "debounced"
|
|
40
|
+
| "quarantined"
|
|
41
|
+
| "paused"
|
|
42
|
+
| "overlap-skipped"
|
|
43
|
+
| "missed"
|
|
44
|
+
| "failed";
|
|
45
|
+
|
|
46
|
+
export interface TriggerEvent {
|
|
47
|
+
t: string;
|
|
48
|
+
flow: string;
|
|
49
|
+
/** The flow's `trigger:` — schedule, webhook, storage, watch, flow, email. */
|
|
50
|
+
trigger: string;
|
|
51
|
+
outcome: TriggerOutcome;
|
|
52
|
+
/** The sentence a person reads. Always says which key decided, so the fix
|
|
53
|
+
* is one search away in their own flow file. */
|
|
54
|
+
detail?: string;
|
|
55
|
+
runId?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function logFile(tenant: string, workspace: string) {
|
|
59
|
+
return path.join(workspaceDir(tenant, workspace), "trigger-log.jsonl");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const KEEP = 500;
|
|
63
|
+
|
|
64
|
+
/** Never throws. A diagnostic that can break a trigger is worse than no
|
|
65
|
+
* diagnostic: the whole point is to be reading it on the bad morning. */
|
|
66
|
+
export function recordTriggerEvent(tenant: string, workspace: string, event: TriggerEvent): void {
|
|
67
|
+
try {
|
|
68
|
+
const file = logFile(tenant, workspace);
|
|
69
|
+
if (!fs.existsSync(path.dirname(file))) return;
|
|
70
|
+
fs.appendFileSync(file, JSON.stringify(event) + "\n");
|
|
71
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
|
|
72
|
+
if (lines.length > KEEP * 2) {
|
|
73
|
+
const tmp = `${file}.tmp`;
|
|
74
|
+
fs.writeFileSync(tmp, lines.slice(-KEEP).join("\n") + "\n");
|
|
75
|
+
fs.renameSync(tmp, file);
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// best effort, always
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function readTriggerEvents(tenant: string, workspace: string, limit = 200): TriggerEvent[] {
|
|
83
|
+
let lines: string[];
|
|
84
|
+
try {
|
|
85
|
+
lines = fs.readFileSync(logFile(tenant, workspace), "utf8").split("\n").filter(Boolean);
|
|
86
|
+
} catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
// One line at a time: a torn line — an append racing a compaction from
|
|
90
|
+
// another pod — must lose that line, not the whole log, on exactly the
|
|
91
|
+
// morning the log exists for.
|
|
92
|
+
const out: TriggerEvent[] = [];
|
|
93
|
+
for (const line of lines.slice(-limit).reverse()) {
|
|
94
|
+
try {
|
|
95
|
+
const e = JSON.parse(line) as TriggerEvent;
|
|
96
|
+
if (e && typeof e.t === "string") out.push(e);
|
|
97
|
+
} catch {
|
|
98
|
+
// skip the torn line
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface FlowTriggerSummary {
|
|
105
|
+
flow: string;
|
|
106
|
+
trigger: string;
|
|
107
|
+
fired: number;
|
|
108
|
+
started: number;
|
|
109
|
+
/** Every outcome that was not a start, by reason, biggest first. */
|
|
110
|
+
dropped: { outcome: TriggerOutcome; count: number; detail: string }[];
|
|
111
|
+
lastFiredAt: string | null;
|
|
112
|
+
lastStartedAt: string | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One row per flow: how often it fired, how often that became a run, and
|
|
117
|
+
* what happened to the difference.
|
|
118
|
+
*
|
|
119
|
+
* The gap between `fired` and `started` is the number someone is looking
|
|
120
|
+
* for. A flow with forty fires and two runs is either working exactly as
|
|
121
|
+
* configured or badly misconfigured, and the reasons column is what tells
|
|
122
|
+
* those apart without opening the flow file.
|
|
123
|
+
*/
|
|
124
|
+
export function summariseTriggers(
|
|
125
|
+
tenant: string,
|
|
126
|
+
workspace: string,
|
|
127
|
+
sinceMs: number | null = null,
|
|
128
|
+
): FlowTriggerSummary[] {
|
|
129
|
+
const rows = new Map<string, FlowTriggerSummary>();
|
|
130
|
+
const reasons = new Map<string, Map<TriggerOutcome, { count: number; detail: string }>>();
|
|
131
|
+
for (const e of readTriggerEvents(tenant, workspace, 500)) {
|
|
132
|
+
if (sinceMs !== null && new Date(e.t).getTime() < sinceMs) continue;
|
|
133
|
+
let row = rows.get(e.flow);
|
|
134
|
+
if (!row) {
|
|
135
|
+
row = { flow: e.flow, trigger: e.trigger, fired: 0, started: 0, dropped: [], lastFiredAt: null, lastStartedAt: null };
|
|
136
|
+
rows.set(e.flow, row);
|
|
137
|
+
reasons.set(e.flow, new Map());
|
|
138
|
+
}
|
|
139
|
+
row.fired++;
|
|
140
|
+
// Events come back newest first, so the first of each is the latest.
|
|
141
|
+
row.lastFiredAt ??= e.t;
|
|
142
|
+
if (e.outcome === "started") {
|
|
143
|
+
row.started++;
|
|
144
|
+
row.lastStartedAt ??= e.t;
|
|
145
|
+
} else {
|
|
146
|
+
const byReason = reasons.get(e.flow)!;
|
|
147
|
+
const seen = byReason.get(e.outcome);
|
|
148
|
+
// Keep the newest detail: "the last run started 4 minutes ago" is more
|
|
149
|
+
// use than the same sentence from Tuesday.
|
|
150
|
+
if (seen) seen.count++;
|
|
151
|
+
else byReason.set(e.outcome, { count: 1, detail: e.detail ?? "" });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
for (const [flow, byReason] of reasons) {
|
|
155
|
+
rows.get(flow)!.dropped = [...byReason.entries()]
|
|
156
|
+
.map(([outcome, v]) => ({ outcome, ...v }))
|
|
157
|
+
.sort((a, b) => b.count - a.count);
|
|
158
|
+
}
|
|
159
|
+
return [...rows.values()].sort((a, b) => b.fired - a.fired || a.flow.localeCompare(b.flow));
|
|
160
|
+
}
|
package/src/triggers.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import crypto from "node:crypto";
|
|
16
16
|
import { platform } from "./platform.ts";
|
|
17
|
-
import { listFlows, readRun, type FlowInfo, type FlowStep, type RunRecord } from "./store.ts";
|
|
17
|
+
import { listFlows, readRun, runVerdict, type FlowInfo, type FlowStep, type RunRecord } from "./store.ts";
|
|
18
18
|
import { getSecret } from "./secrets.ts";
|
|
19
19
|
|
|
20
20
|
/** The last step's result — the same reading runner.ts makes, kept local so
|
|
@@ -36,6 +36,13 @@ export function withTask(steps: FlowStep[], tag: string, body: string): FlowStep
|
|
|
36
36
|
|
|
37
37
|
// ------------------------------------------------------------ trigger: flow
|
|
38
38
|
|
|
39
|
+
/** Which `on:` a settled run answers to: failed, blocked (completed, but
|
|
40
|
+
* its work refused itself), or completed. */
|
|
41
|
+
export function endingOf(run: RunRecord): "completed" | "failed" | "blocked" {
|
|
42
|
+
if (run.status === "failed") return "failed";
|
|
43
|
+
return runVerdict(run) === "BLOCKED" ? "blocked" : "completed";
|
|
44
|
+
}
|
|
45
|
+
|
|
39
46
|
/** The flows a finished run should start, by this workspace's own files. */
|
|
40
47
|
export function chainedFlows(tenant: string, workspace: string, finished: RunRecord): FlowInfo[] {
|
|
41
48
|
if (finished.status !== "completed" && finished.status !== "failed") return [];
|
|
@@ -48,7 +55,7 @@ export function chainedFlows(tenant: string, workspace: string, finished: RunRec
|
|
|
48
55
|
// A flow chained on itself is a loop with no bound — refused here and
|
|
49
56
|
// by foldrun check, never started.
|
|
50
57
|
f.name !== finished.flow &&
|
|
51
|
-
(f.on === "any" || f.on === finished
|
|
58
|
+
(f.on === "any" || f.on === endingOf(finished)),
|
|
52
59
|
);
|
|
53
60
|
}
|
|
54
61
|
|
|
@@ -66,15 +73,28 @@ export async function fireChainedFlows(tenant: string, workspace: string, finish
|
|
|
66
73
|
const result = (runResult(finished) ?? "").slice(0, 20_000);
|
|
67
74
|
const body =
|
|
68
75
|
`run: ${finished.id}\nflow: ${finished.flow}\nstatus: ${finished.status}\n` +
|
|
76
|
+
(runVerdict(finished) ? `verdict: ${runVerdict(finished)}\n` : "") +
|
|
69
77
|
(finished.summary ? `summary: ${finished.summary}\n` : "") +
|
|
70
78
|
(result ? `\n${result}` : "");
|
|
71
79
|
for (const flow of flows) {
|
|
72
80
|
try {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "previous_run" });
|
|
82
|
+
if (!gate.admit) {
|
|
83
|
+
console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
// A test run's chain stays a test: the flow after it reads a result
|
|
88
|
+
// nothing real produced, and must not act on it for real either.
|
|
89
|
+
const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "previous_run", body), flow.name, flow.model, [
|
|
90
|
+
`after:${finished.flow}`,
|
|
91
|
+
], null, finished.test ? { test: true } : {});
|
|
92
|
+
started.push(run.id);
|
|
93
|
+
console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} started after ${finished.flow} ${finished.status}`);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
await platform.rollbackTrigger(tenant, workspace, flow, { reason: err instanceof Error ? err.message : String(err) });
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
78
98
|
} catch (err) {
|
|
79
99
|
console.error(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name}:`, err instanceof Error ? err.message : err);
|
|
80
100
|
}
|
|
@@ -121,10 +141,22 @@ export async function fireStorageTriggers(
|
|
|
121
141
|
if (hits.length === 0) continue;
|
|
122
142
|
try {
|
|
123
143
|
const body = `by: ${by}\n${hits.map((h) => `- storage/${h}`).join("\n")}`;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
144
|
+
// A deploy that writes forty files under one prefix is one event, not
|
|
145
|
+
// forty runs — which is exactly what `debounce:` is for.
|
|
146
|
+
const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "storage_event" });
|
|
147
|
+
if (!gate.admit) {
|
|
148
|
+
console.log(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "storage_event", body), flow.name, flow.model, [
|
|
153
|
+
"storage",
|
|
154
|
+
]);
|
|
155
|
+
started.push(run.id);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
await platform.rollbackTrigger(tenant, workspace, flow, { reason: err instanceof Error ? err.message : String(err) });
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
128
160
|
} catch (err) {
|
|
129
161
|
console.error(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name}:`, err instanceof Error ? err.message : err);
|
|
130
162
|
}
|
|
@@ -132,6 +164,64 @@ export async function fireStorageTriggers(
|
|
|
132
164
|
return started;
|
|
133
165
|
}
|
|
134
166
|
|
|
167
|
+
/** HTML to the text a prompt reads. Style and script blocks are removed
|
|
168
|
+
* until none remain — one pass leaves `<sty<style>le>` behind, which is
|
|
169
|
+
* the shape a sanitiser checker (rightly) flags. Nothing here is rendered
|
|
170
|
+
* as HTML afterwards; it becomes a task for an agent. */
|
|
171
|
+
function stripTags(html: string): string {
|
|
172
|
+
let out = html;
|
|
173
|
+
for (let prev = ""; prev !== out; ) {
|
|
174
|
+
prev = out;
|
|
175
|
+
out = out.replace(/<(style|script)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, "");
|
|
176
|
+
}
|
|
177
|
+
return out.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ------------------------------------------------------- idempotency keys
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The value that identifies this delivery, as the flow's `idempotency:`
|
|
184
|
+
* names it.
|
|
185
|
+
*
|
|
186
|
+
* Two shapes, and only two:
|
|
187
|
+
*
|
|
188
|
+
* idempotency: x-github-delivery a request header
|
|
189
|
+
* idempotency: body:id a top-level field of a JSON body
|
|
190
|
+
*
|
|
191
|
+
* A NAME, never an expression — the grammar has no expressions, and this is
|
|
192
|
+
* not the place to grow one. Nothing is computed, combined or nested: if the
|
|
193
|
+
* sender does not put an identifier somewhere a name can reach, the flow
|
|
194
|
+
* cannot dedupe on it, and saying so is better than inventing a syntax.
|
|
195
|
+
*
|
|
196
|
+
* Returns null when the flow asks for no key, or when the delivery does not
|
|
197
|
+
* carry the one it asked for. A missing key admits the delivery: refusing
|
|
198
|
+
* one because a sender omitted a header would turn "dedupe when you can"
|
|
199
|
+
* into an outage.
|
|
200
|
+
*/
|
|
201
|
+
export function deliveryKey(
|
|
202
|
+
idempotency: string | null,
|
|
203
|
+
header: (name: string) => string | null,
|
|
204
|
+
body: string,
|
|
205
|
+
): string | null {
|
|
206
|
+
if (!idempotency) return null;
|
|
207
|
+
if (idempotency.startsWith("body:")) {
|
|
208
|
+
const field = idempotency.slice(5).trim();
|
|
209
|
+
if (!field) return null;
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
212
|
+
const value = parsed?.[field];
|
|
213
|
+
// A scalar only. An object stringifies to "[object Object]" for every
|
|
214
|
+
// delivery, which would make the second one and every one after it a
|
|
215
|
+
// "duplicate" of the first.
|
|
216
|
+
if (value === undefined || value === null || typeof value === "object") return null;
|
|
217
|
+
return String(value).slice(0, 200);
|
|
218
|
+
} catch {
|
|
219
|
+
return null; // not JSON, so no field to read
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return header(idempotency)?.slice(0, 200) || null;
|
|
223
|
+
}
|
|
224
|
+
|
|
135
225
|
// ------------------------------------------------------ signed webhooks
|
|
136
226
|
|
|
137
227
|
export interface SignatureVerdict {
|
|
@@ -276,7 +366,7 @@ export function normaliseInboundEmail(contentType: string | null, raw: string):
|
|
|
276
366
|
const html = pick("html", "HtmlBody", "body-html");
|
|
277
367
|
const text =
|
|
278
368
|
pick("text", "TextBody", "body-plain", "stripped-text", "plain") ||
|
|
279
|
-
html
|
|
369
|
+
stripTags(html);
|
|
280
370
|
if (!from && !subject && !text) return null;
|
|
281
371
|
return { from, to: pick("to", "To", "recipient", "ToFull"), subject, text };
|
|
282
372
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// fetch() for a URL a tenant chose, redirects included.
|
|
2
|
+
//
|
|
3
|
+
// platform.fetchUntrusted is the seam: on a platform it refuses private and
|
|
4
|
+
// cluster addresses (by name, by IP literal, and at connect time through a
|
|
5
|
+
// guarded DNS lookup). But it was handed fetch's default `redirect:
|
|
6
|
+
// "follow"`, and a redirect is followed inside fetch, past the seam: a
|
|
7
|
+
// public host answering `302 Location: http://169.254.169.254/…` sent the
|
|
8
|
+
// platform to the metadata endpoint, because an IP literal never reaches the
|
|
9
|
+
// DNS lookup the connect-time guard lives in. And the OAuth token POSTs
|
|
10
|
+
// carried a client secret to wherever a 307 pointed.
|
|
11
|
+
//
|
|
12
|
+
// So redirects are followed here, one hop at a time, each hop through the
|
|
13
|
+
// seam again — every address a request ends up at is checked the same way
|
|
14
|
+
// the first one was. A hop to another origin keeps only the headers that
|
|
15
|
+
// cannot carry a credential, and a caller whose request IS a credential
|
|
16
|
+
// (a token exchange, a provider key in a header) follows none at all.
|
|
17
|
+
|
|
18
|
+
import { platform } from "./platform.ts";
|
|
19
|
+
|
|
20
|
+
/** How many redirects one request may follow. fetch's own limit is 20;
|
|
21
|
+
* nothing a tenant points the platform at needs more than a couple. */
|
|
22
|
+
export const MAX_REDIRECTS = 5;
|
|
23
|
+
|
|
24
|
+
const REDIRECT = new Set([301, 302, 303, 307, 308]);
|
|
25
|
+
|
|
26
|
+
/** Headers that may cross to another origin: none of them can carry a
|
|
27
|
+
* credential. Everything else — Authorization, a cookie, an x-api-key, a
|
|
28
|
+
* webhook's signature — stays with the origin it was meant for. */
|
|
29
|
+
const CROSS_ORIGIN_SAFE = new Set(["accept", "accept-language", "content-type", "user-agent"]);
|
|
30
|
+
|
|
31
|
+
export class RedirectRefused extends Error {
|
|
32
|
+
constructor(message: string) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "RedirectRefused";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function fetchUntrusted(
|
|
39
|
+
url: string,
|
|
40
|
+
init: RequestInit = {},
|
|
41
|
+
opts: {
|
|
42
|
+
/** The request carries a credential (in its body or its headers):
|
|
43
|
+
* follow a redirect only within the origin it was sent to. */
|
|
44
|
+
sameOrigin?: boolean;
|
|
45
|
+
} = {},
|
|
46
|
+
): Promise<Response> {
|
|
47
|
+
let current = url;
|
|
48
|
+
let req: RequestInit = { ...init };
|
|
49
|
+
for (let hop = 0; ; hop++) {
|
|
50
|
+
const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" });
|
|
51
|
+
const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
|
|
52
|
+
if (!location) return res;
|
|
53
|
+
// The body of a redirect is never read; free the socket.
|
|
54
|
+
await res.body?.cancel().catch(() => {});
|
|
55
|
+
if (hop >= MAX_REDIRECTS) throw new RedirectRefused(`${url}: more than ${MAX_REDIRECTS} redirects — not followed`);
|
|
56
|
+
let next: URL;
|
|
57
|
+
try {
|
|
58
|
+
next = new URL(location, current);
|
|
59
|
+
} catch {
|
|
60
|
+
throw new RedirectRefused(`${current} redirected to "${location}", which is not a URL`);
|
|
61
|
+
}
|
|
62
|
+
if (next.protocol !== "https:" && next.protocol !== "http:") {
|
|
63
|
+
throw new RedirectRefused(`${current} redirected to a ${next.protocol} URL — not followed`);
|
|
64
|
+
}
|
|
65
|
+
const from = new URL(current);
|
|
66
|
+
const crossOrigin = next.origin !== from.origin;
|
|
67
|
+
if (crossOrigin && opts.sameOrigin) {
|
|
68
|
+
throw new RedirectRefused(
|
|
69
|
+
`${from.origin} redirected to ${next.origin} — not followed: this request carries a credential, and it goes only where it was sent`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
// fetch's own rewrite: a 303, and a 301/302 answering a POST, is
|
|
73
|
+
// followed as a GET with no body; a 307/308 repeats the request as it was.
|
|
74
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
75
|
+
const headers = new Headers(req.headers);
|
|
76
|
+
if ((res.status === 303 && method !== "GET" && method !== "HEAD") || ((res.status === 301 || res.status === 302) && method === "POST")) {
|
|
77
|
+
req = { ...req, method: "GET", body: undefined };
|
|
78
|
+
headers.delete("content-type");
|
|
79
|
+
}
|
|
80
|
+
if (crossOrigin) {
|
|
81
|
+
const names: string[] = [];
|
|
82
|
+
headers.forEach((_, name) => names.push(name));
|
|
83
|
+
for (const name of names) if (!CROSS_ORIGIN_SAFE.has(name)) headers.delete(name);
|
|
84
|
+
}
|
|
85
|
+
req = { ...req, headers };
|
|
86
|
+
current = next.toString();
|
|
87
|
+
}
|
|
88
|
+
}
|