@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.
Files changed (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. 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
- function stopReason(finish: unknown): string {
422
- switch (finish) {
423
- case "tool_calls":
424
- case "function_call":
425
- return "tool_use";
426
- case "length":
427
- return "max_tokens";
428
- default:
429
- return "end_turn";
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: stopReason(choice.finish_reason),
466
- stop_sequence: null,
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
- private finish: unknown = null;
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
- constructor(model: string) {
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 reason = this.tools.size && this.finish !== "length" ? "tool_use" : stopReason(this.finish);
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: null },
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 stop = content.some((c) => c.type === "tool_use") ? "tool_use" : incomplete === "max_output_tokens" ? "max_tokens" : "end_turn";
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.sawTool && this.incomplete !== "max_output_tokens" ? "tool_use" : this.incomplete === "max_output_tokens" ? "max_tokens" : "end_turn";
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.replace(/\/+$/, "");
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.status),
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 run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "previous_run", body), flow.name, flow.model, [
74
- `after:${finished.flow}`,
75
- ]);
76
- started.push(run.id);
77
- console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} started after ${finished.flow} ${finished.status}`);
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
- const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "storage_event", body), flow.name, flow.model, [
125
- "storage",
126
- ]);
127
- started.push(run.id);
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.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
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
+ }