@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
@@ -29,6 +29,8 @@ import http from "node:http";
29
29
  import crypto from "node:crypto";
30
30
  import { PROTECTED_PARAMS } from "./providers.js";
31
31
  import { viaEgress } from "./egress.js";
32
+ import { trimSlashes } from "./paths.js";
33
+ import { captureRefusalHeaders, isRefusalStatus, refusalLine } from "./refusal.js";
32
34
  function dropped() {
33
35
  const seen = new Set();
34
36
  return {
@@ -390,17 +392,42 @@ export function toResponses(req, opts, drop = dropped()) {
390
392
  }
391
393
  return out;
392
394
  }
393
- // ------------------------------------------------------- response mapping
394
- function stopReason(finish) {
395
- switch (finish) {
396
- case "tool_calls":
397
- case "function_call":
398
- return "tool_use";
399
- case "length":
400
- return "max_tokens";
401
- default:
402
- return "end_turn";
403
- }
395
+ /**
396
+ * Why a Chat Completion stopped, in Anthropic's words. `content_filter`
397
+ * (OpenAI, Azure, Gemini's safety block) is a refusal; Mistral's
398
+ * `model_length` is the context window filling, not max_tokens. A stop
399
+ * sequence is named only where the provider says which one matched — vLLM
400
+ * as `stop_reason`, SGLang as `matched_stop` — and only when it is one the
401
+ * request asked for; plain OpenAI says `stop` either way, which reads as
402
+ * end_turn. pause_turn has no counterpart: server-side tools are dropped.
403
+ */
404
+ function chatStop(choice, sawTool, stops) {
405
+ const finish = choice.finish_reason;
406
+ if (finish === "content_filter")
407
+ return { reason: "refusal", sequence: null };
408
+ if (finish === "length")
409
+ return { reason: "max_tokens", sequence: null };
410
+ if (finish === "model_length")
411
+ return { reason: "model_context_window_exceeded", sequence: null };
412
+ if (sawTool || finish === "tool_calls" || finish === "function_call")
413
+ return { reason: "tool_use", sequence: null };
414
+ const matched = typeof choice.stop_reason === "string" ? choice.stop_reason : choice.matched_stop;
415
+ if (typeof matched === "string" && stops.includes(matched))
416
+ return { reason: "stop_sequence", sequence: matched };
417
+ return { reason: "end_turn", sequence: null };
418
+ }
419
+ /** Why a Response stopped: a refusal part or a content filter is a refusal. */
420
+ function responsesStop(incomplete, sawTool, sawRefusal) {
421
+ if (sawRefusal || incomplete === "content_filter")
422
+ return "refusal";
423
+ if (incomplete === "max_output_tokens")
424
+ return "max_tokens";
425
+ if (sawTool)
426
+ return "tool_use";
427
+ return "end_turn";
428
+ }
429
+ function stopList(stops) {
430
+ return Array.isArray(stops) ? stops.filter((s) => typeof s === "string") : [];
404
431
  }
405
432
  function parseArgs(raw) {
406
433
  if (typeof raw !== "string" || !raw.trim())
@@ -413,7 +440,7 @@ function parseArgs(raw) {
413
440
  }
414
441
  }
415
442
  /** A finished Chat Completion as an Anthropic message. Pure. */
416
- export function fromChatCompletion(res, requestedModel) {
443
+ export function fromChatCompletion(res, requestedModel, stopSequences) {
417
444
  const choice = (res.choices ?? [])[0] ?? {};
418
445
  const msg = choice.message ?? {};
419
446
  const content = [];
@@ -430,14 +457,15 @@ export function fromChatCompletion(res, requestedModel) {
430
457
  });
431
458
  }
432
459
  const usage = res.usage ?? {};
460
+ const stop = chatStop(choice, content.some((c) => c.type === "tool_use"), stopList(stopSequences));
433
461
  return {
434
462
  id: String(res.id ?? `msg_${crypto.randomBytes(8).toString("hex")}`),
435
463
  type: "message",
436
464
  role: "assistant",
437
465
  model: String(res.model ?? requestedModel),
438
466
  content,
439
- stop_reason: stopReason(choice.finish_reason),
440
- stop_sequence: null,
467
+ stop_reason: stop.reason,
468
+ stop_sequence: stop.sequence,
441
469
  usage: {
442
470
  input_tokens: Number(usage.prompt_tokens ?? 0),
443
471
  output_tokens: Number(usage.completion_tokens ?? 0),
@@ -457,12 +485,15 @@ export class StreamTranslator {
457
485
  textIndex = null;
458
486
  /** Chat-Completions tool index → Anthropic block index. */
459
487
  tools = new Map();
460
- finish = null;
488
+ /** The last choice's stop fields: finish_reason and any matched stop. */
489
+ finish = {};
461
490
  usage = null;
462
491
  id = `msg_${crypto.randomBytes(8).toString("hex")}`;
463
492
  model;
464
- constructor(model) {
493
+ stops;
494
+ constructor(model, stopSequences) {
465
495
  this.model = model;
496
+ this.stops = stopList(stopSequences);
466
497
  }
467
498
  event(name, data) {
468
499
  return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
@@ -507,7 +538,7 @@ export class StreamTranslator {
507
538
  if (!choice)
508
539
  return out;
509
540
  if (choice.finish_reason)
510
- this.finish = choice.finish_reason;
541
+ this.finish = { finish_reason: choice.finish_reason, stop_reason: choice.stop_reason, matched_stop: choice.matched_stop };
511
542
  const delta = choice.delta ?? {};
512
543
  const text = typeof delta.content === "string" ? delta.content : "";
513
544
  if (text) {
@@ -574,10 +605,10 @@ export class StreamTranslator {
574
605
  }
575
606
  out.push(this.event("content_block_stop", { type: "content_block_stop", index: entry.block }));
576
607
  }
577
- const reason = this.tools.size && this.finish !== "length" ? "tool_use" : stopReason(this.finish);
608
+ const stop = chatStop(this.finish, this.tools.size > 0, this.stops);
578
609
  out.push(this.event("message_delta", {
579
610
  type: "message_delta",
580
- delta: { stop_reason: reason, stop_sequence: null },
611
+ delta: { stop_reason: stop.reason, stop_sequence: stop.sequence },
581
612
  usage: { input_tokens: this.usage?.input ?? 0, output_tokens: this.usage?.output ?? 0 },
582
613
  }));
583
614
  out.push(this.event("message_stop", { type: "message_stop" }));
@@ -607,7 +638,8 @@ export function fromResponses(res, requestedModel) {
607
638
  }
608
639
  const usage = res.usage ?? {};
609
640
  const incomplete = res.incomplete_details?.reason;
610
- const stop = content.some((c) => c.type === "tool_use") ? "tool_use" : incomplete === "max_output_tokens" ? "max_tokens" : "end_turn";
641
+ const refused = (Array.isArray(res.output) ? res.output : []).some((item) => item.type === "message" && Array.isArray(item.content) && item.content.some((c) => c.type === "refusal"));
642
+ const stop = responsesStop(incomplete, content.some((c) => c.type === "tool_use"), refused);
611
643
  return {
612
644
  id: String(res.id ?? `msg_${crypto.randomBytes(8).toString("hex")}`),
613
645
  type: "message",
@@ -637,6 +669,7 @@ export class ResponsesStreamTranslator {
637
669
  usage = null;
638
670
  incomplete = null;
639
671
  sawTool = false;
672
+ sawRefusal = false;
640
673
  id = `msg_${crypto.randomBytes(8).toString("hex")}`;
641
674
  model;
642
675
  constructor(model) {
@@ -699,6 +732,8 @@ export class ResponsesStreamTranslator {
699
732
  if (type === "response.output_text.delta" || type === "response.refusal.delta") {
700
733
  const entry = this.blocks.get(Number(ev.output_index));
701
734
  const text = String(ev.delta ?? "");
735
+ if (type === "response.refusal.delta")
736
+ this.sawRefusal = true;
702
737
  if (entry && entry.kind === "text" && text) {
703
738
  out.push(this.event("content_block_delta", { type: "content_block_delta", index: entry.block, delta: { type: "text_delta", text } }));
704
739
  }
@@ -751,7 +786,7 @@ export class ResponsesStreamTranslator {
751
786
  }
752
787
  out.push(this.event("content_block_stop", { type: "content_block_stop", index: entry.block }));
753
788
  }
754
- const reason = this.sawTool && this.incomplete !== "max_output_tokens" ? "tool_use" : this.incomplete === "max_output_tokens" ? "max_tokens" : "end_turn";
789
+ const reason = responsesStop(this.incomplete, this.sawTool, this.sawRefusal);
755
790
  out.push(this.event("message_delta", {
756
791
  type: "message_delta",
757
792
  delta: { stop_reason: reason, stop_sequence: null },
@@ -806,7 +841,7 @@ function upstreamMessage(text, status) {
806
841
  export async function startTranslator(spec) {
807
842
  const key = `fr-${crypto.randomBytes(16).toString("hex")}`;
808
843
  const log = [];
809
- const base = spec.upstreamBase.replace(/\/+$/, "");
844
+ const base = trimSlashes(spec.upstreamBase);
810
845
  const label = spec.label ?? base;
811
846
  const server = http.createServer(async (req, res) => {
812
847
  const started = Date.now();
@@ -872,6 +907,18 @@ export async function startTranslator(spec) {
872
907
  const text = await upstream.text();
873
908
  const message = upstreamMessage(text, upstream.status);
874
909
  log.push(`POST ${label} → ${upstream.status} (${Date.now() - started}ms): ${message.slice(0, 160)}`);
910
+ // The refusal in words, from the headers the response already
911
+ // carried. Only on a refusal, and only the allow-listed headers:
912
+ // a 400 about a malformed body is the work's problem, not the
913
+ // supply's, and explaining it as one would mislead.
914
+ if (isRefusalStatus(upstream.status)) {
915
+ log.push(refusalLine({
916
+ status: upstream.status,
917
+ headers: captureRefusalHeaders(upstream.headers),
918
+ timezone: spec.timezone,
919
+ provider: label,
920
+ }));
921
+ }
875
922
  // The status crosses unchanged, so a 401 or 429 from the provider
876
923
  // reads as one to the SDK — and to the fallback decision upstream.
877
924
  return send(upstream.status, errorBody(upstream.status, message));
@@ -887,10 +934,10 @@ export async function startTranslator(spec) {
887
934
  return send(502, errorBody(502, `translator: ${label} answered with something that is not JSON`));
888
935
  }
889
936
  log.push(`POST ${label} → 200 (${Date.now() - started}ms, ${model})`);
890
- return send(200, JSON.stringify(responses ? fromResponses(parsed, model) : fromChatCompletion(parsed, model)));
937
+ return send(200, JSON.stringify(responses ? fromResponses(parsed, model) : fromChatCompletion(parsed, model, body.stop_sequences)));
891
938
  }
892
939
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
893
- const machine = responses ? new ResponsesStreamTranslator(model) : new StreamTranslator(model);
940
+ const machine = responses ? new ResponsesStreamTranslator(model) : new StreamTranslator(model, body.stop_sequences);
894
941
  const reader = upstream.body?.getReader();
895
942
  if (!reader) {
896
943
  for (const e of machine.finishStream())
@@ -0,0 +1,43 @@
1
+ /** What became of one trigger firing. `started` is here too: without it the
2
+ * log answers "why did nothing run" and not "how often does anything",
3
+ * and the ratio is the whole point of the page. */
4
+ export type TriggerOutcome = "started" | "duplicate" | "throttled" | "debounced" | "quarantined" | "paused" | "overlap-skipped" | "missed" | "failed";
5
+ export interface TriggerEvent {
6
+ t: string;
7
+ flow: string;
8
+ /** The flow's `trigger:` — schedule, webhook, storage, watch, flow, email. */
9
+ trigger: string;
10
+ outcome: TriggerOutcome;
11
+ /** The sentence a person reads. Always says which key decided, so the fix
12
+ * is one search away in their own flow file. */
13
+ detail?: string;
14
+ runId?: string;
15
+ }
16
+ /** Never throws. A diagnostic that can break a trigger is worse than no
17
+ * diagnostic: the whole point is to be reading it on the bad morning. */
18
+ export declare function recordTriggerEvent(tenant: string, workspace: string, event: TriggerEvent): void;
19
+ export declare function readTriggerEvents(tenant: string, workspace: string, limit?: number): TriggerEvent[];
20
+ export interface FlowTriggerSummary {
21
+ flow: string;
22
+ trigger: string;
23
+ fired: number;
24
+ started: number;
25
+ /** Every outcome that was not a start, by reason, biggest first. */
26
+ dropped: {
27
+ outcome: TriggerOutcome;
28
+ count: number;
29
+ detail: string;
30
+ }[];
31
+ lastFiredAt: string | null;
32
+ lastStartedAt: string | null;
33
+ }
34
+ /**
35
+ * One row per flow: how often it fired, how often that became a run, and
36
+ * what happened to the difference.
37
+ *
38
+ * The gap between `fired` and `started` is the number someone is looking
39
+ * for. A flow with forty fires and two runs is either working exactly as
40
+ * configured or badly misconfigured, and the reasons column is what tells
41
+ * those apart without opening the flow file.
42
+ */
43
+ export declare function summariseTriggers(tenant: string, workspace: string, sinceMs?: number | null): FlowTriggerSummary[];
@@ -0,0 +1,122 @@
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
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+ import { workspaceDir } from "./store.js";
30
+ function logFile(tenant, workspace) {
31
+ return path.join(workspaceDir(tenant, workspace), "trigger-log.jsonl");
32
+ }
33
+ const KEEP = 500;
34
+ /** Never throws. A diagnostic that can break a trigger is worse than no
35
+ * diagnostic: the whole point is to be reading it on the bad morning. */
36
+ export function recordTriggerEvent(tenant, workspace, event) {
37
+ try {
38
+ const file = logFile(tenant, workspace);
39
+ if (!fs.existsSync(path.dirname(file)))
40
+ return;
41
+ fs.appendFileSync(file, JSON.stringify(event) + "\n");
42
+ const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean);
43
+ if (lines.length > KEEP * 2) {
44
+ const tmp = `${file}.tmp`;
45
+ fs.writeFileSync(tmp, lines.slice(-KEEP).join("\n") + "\n");
46
+ fs.renameSync(tmp, file);
47
+ }
48
+ }
49
+ catch {
50
+ // best effort, always
51
+ }
52
+ }
53
+ export function readTriggerEvents(tenant, workspace, limit = 200) {
54
+ let lines;
55
+ try {
56
+ lines = fs.readFileSync(logFile(tenant, workspace), "utf8").split("\n").filter(Boolean);
57
+ }
58
+ catch {
59
+ return [];
60
+ }
61
+ // One line at a time: a torn line — an append racing a compaction from
62
+ // another pod — must lose that line, not the whole log, on exactly the
63
+ // morning the log exists for.
64
+ const out = [];
65
+ for (const line of lines.slice(-limit).reverse()) {
66
+ try {
67
+ const e = JSON.parse(line);
68
+ if (e && typeof e.t === "string")
69
+ out.push(e);
70
+ }
71
+ catch {
72
+ // skip the torn line
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+ /**
78
+ * One row per flow: how often it fired, how often that became a run, and
79
+ * what happened to the difference.
80
+ *
81
+ * The gap between `fired` and `started` is the number someone is looking
82
+ * for. A flow with forty fires and two runs is either working exactly as
83
+ * configured or badly misconfigured, and the reasons column is what tells
84
+ * those apart without opening the flow file.
85
+ */
86
+ export function summariseTriggers(tenant, workspace, sinceMs = null) {
87
+ const rows = new Map();
88
+ const reasons = new Map();
89
+ for (const e of readTriggerEvents(tenant, workspace, 500)) {
90
+ if (sinceMs !== null && new Date(e.t).getTime() < sinceMs)
91
+ continue;
92
+ let row = rows.get(e.flow);
93
+ if (!row) {
94
+ row = { flow: e.flow, trigger: e.trigger, fired: 0, started: 0, dropped: [], lastFiredAt: null, lastStartedAt: null };
95
+ rows.set(e.flow, row);
96
+ reasons.set(e.flow, new Map());
97
+ }
98
+ row.fired++;
99
+ // Events come back newest first, so the first of each is the latest.
100
+ row.lastFiredAt ??= e.t;
101
+ if (e.outcome === "started") {
102
+ row.started++;
103
+ row.lastStartedAt ??= e.t;
104
+ }
105
+ else {
106
+ const byReason = reasons.get(e.flow);
107
+ const seen = byReason.get(e.outcome);
108
+ // Keep the newest detail: "the last run started 4 minutes ago" is more
109
+ // use than the same sentence from Tuesday.
110
+ if (seen)
111
+ seen.count++;
112
+ else
113
+ byReason.set(e.outcome, { count: 1, detail: e.detail ?? "" });
114
+ }
115
+ }
116
+ for (const [flow, byReason] of reasons) {
117
+ rows.get(flow).dropped = [...byReason.entries()]
118
+ .map(([outcome, v]) => ({ outcome, ...v }))
119
+ .sort((a, b) => b.count - a.count);
120
+ }
121
+ return [...rows.values()].sort((a, b) => b.fired - a.fired || a.flow.localeCompare(b.flow));
122
+ }
@@ -1,6 +1,9 @@
1
1
  import { type FlowInfo, type FlowStep, type RunRecord } from "./store.ts";
2
2
  /** Staple a payload to the first step, the way the run route staples a task. */
3
3
  export declare function withTask(steps: FlowStep[], tag: string, body: string): FlowStep[];
4
+ /** Which `on:` a settled run answers to: failed, blocked (completed, but
5
+ * its work refused itself), or completed. */
6
+ export declare function endingOf(run: RunRecord): "completed" | "failed" | "blocked";
4
7
  /** The flows a finished run should start, by this workspace's own files. */
5
8
  export declare function chainedFlows(tenant: string, workspace: string, finished: RunRecord): FlowInfo[];
6
9
  /**
@@ -22,6 +25,26 @@ export declare function underPrefix(rel: string, prefix: string | null): boolean
22
25
  * a loop — every other writer counts.
23
26
  */
24
27
  export declare function fireStorageTriggers(tenant: string, workspace: string, paths: string[], by: string): Promise<string[]>;
28
+ /**
29
+ * The value that identifies this delivery, as the flow's `idempotency:`
30
+ * names it.
31
+ *
32
+ * Two shapes, and only two:
33
+ *
34
+ * idempotency: x-github-delivery a request header
35
+ * idempotency: body:id a top-level field of a JSON body
36
+ *
37
+ * A NAME, never an expression — the grammar has no expressions, and this is
38
+ * not the place to grow one. Nothing is computed, combined or nested: if the
39
+ * sender does not put an identifier somewhere a name can reach, the flow
40
+ * cannot dedupe on it, and saying so is better than inventing a syntax.
41
+ *
42
+ * Returns null when the flow asks for no key, or when the delivery does not
43
+ * carry the one it asked for. A missing key admits the delivery: refusing
44
+ * one because a sender omitted a header would turn "dedupe when you can"
45
+ * into an outage.
46
+ */
47
+ export declare function deliveryKey(idempotency: string | null, header: (name: string) => string | null, body: string): string | null;
25
48
  export interface SignatureVerdict {
26
49
  ok: boolean;
27
50
  reason: string;
@@ -13,7 +13,7 @@
13
13
  // an agent reads it — the same rule as every other handoff in the format.
14
14
  import crypto from "node:crypto";
15
15
  import { platform } from "./platform.js";
16
- import { listFlows, readRun } from "./store.js";
16
+ import { listFlows, readRun, runVerdict } from "./store.js";
17
17
  import { getSecret } from "./secrets.js";
18
18
  /** The last step's result — the same reading runner.ts makes, kept local so
19
19
  * this module never imports the runner (the queue imports both). */
@@ -33,6 +33,13 @@ export function withTask(steps, tag, body) {
33
33
  return steps.map((s, i) => (i === 0 ? { ...s, instruction: `${s.instruction}\n\n<${tag}>\n${text}\n</${tag}>` } : s));
34
34
  }
35
35
  // ------------------------------------------------------------ trigger: flow
36
+ /** Which `on:` a settled run answers to: failed, blocked (completed, but
37
+ * its work refused itself), or completed. */
38
+ export function endingOf(run) {
39
+ if (run.status === "failed")
40
+ return "failed";
41
+ return runVerdict(run) === "BLOCKED" ? "blocked" : "completed";
42
+ }
36
43
  /** The flows a finished run should start, by this workspace's own files. */
37
44
  export function chainedFlows(tenant, workspace, finished) {
38
45
  if (finished.status !== "completed" && finished.status !== "failed")
@@ -45,7 +52,7 @@ export function chainedFlows(tenant, workspace, finished) {
45
52
  // A flow chained on itself is a loop with no bound — refused here and
46
53
  // by foldrun check, never started.
47
54
  f.name !== finished.flow &&
48
- (f.on === "any" || f.on === finished.status));
55
+ (f.on === "any" || f.on === endingOf(finished)));
49
56
  }
50
57
  /**
51
58
  * Start every flow chained on a run that just settled. The finished run's
@@ -61,15 +68,29 @@ export async function fireChainedFlows(tenant, workspace, finished) {
61
68
  const started = [];
62
69
  const result = (runResult(finished) ?? "").slice(0, 20_000);
63
70
  const body = `run: ${finished.id}\nflow: ${finished.flow}\nstatus: ${finished.status}\n` +
71
+ (runVerdict(finished) ? `verdict: ${runVerdict(finished)}\n` : "") +
64
72
  (finished.summary ? `summary: ${finished.summary}\n` : "") +
65
73
  (result ? `\n${result}` : "");
66
74
  for (const flow of flows) {
67
75
  try {
68
- const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "previous_run", body), flow.name, flow.model, [
69
- `after:${finished.flow}`,
70
- ]);
71
- started.push(run.id);
72
- console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} started after ${finished.flow} ${finished.status}`);
76
+ const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "previous_run" });
77
+ if (!gate.admit) {
78
+ console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
79
+ continue;
80
+ }
81
+ try {
82
+ // A test run's chain stays a test: the flow after it reads a result
83
+ // nothing real produced, and must not act on it for real either.
84
+ const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "previous_run", body), flow.name, flow.model, [
85
+ `after:${finished.flow}`,
86
+ ], null, finished.test ? { test: true } : {});
87
+ started.push(run.id);
88
+ console.log(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name} started after ${finished.flow} ${finished.status}`);
89
+ }
90
+ catch (err) {
91
+ await platform.rollbackTrigger(tenant, workspace, flow, { reason: err instanceof Error ? err.message : String(err) });
92
+ throw err;
93
+ }
73
94
  }
74
95
  catch (err) {
75
96
  console.error(`[foldrun] trigger: flow — ${tenant}/${workspace}/${flow.name}:`, err instanceof Error ? err.message : err);
@@ -115,10 +136,23 @@ export async function fireStorageTriggers(tenant, workspace, paths, by) {
115
136
  continue;
116
137
  try {
117
138
  const body = `by: ${by}\n${hits.map((h) => `- storage/${h}`).join("\n")}`;
118
- const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "storage_event", body), flow.name, flow.model, [
119
- "storage",
120
- ]);
121
- started.push(run.id);
139
+ // A deploy that writes forty files under one prefix is one event, not
140
+ // forty runs — which is exactly what `debounce:` is for.
141
+ const gate = await platform.admitTrigger(tenant, workspace, flow, { body, tag: "storage_event" });
142
+ if (!gate.admit) {
143
+ console.log(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name} not started: ${gate.detail}`);
144
+ continue;
145
+ }
146
+ try {
147
+ const run = await platform.enqueueFlowRun(tenant, workspace, withTask(flow.steps, "storage_event", body), flow.name, flow.model, [
148
+ "storage",
149
+ ]);
150
+ started.push(run.id);
151
+ }
152
+ catch (err) {
153
+ await platform.rollbackTrigger(tenant, workspace, flow, { reason: err instanceof Error ? err.message : String(err) });
154
+ throw err;
155
+ }
122
156
  }
123
157
  catch (err) {
124
158
  console.error(`[foldrun] trigger: storage — ${tenant}/${workspace}/${flow.name}:`, err instanceof Error ? err.message : err);
@@ -126,6 +160,61 @@ export async function fireStorageTriggers(tenant, workspace, paths, by) {
126
160
  }
127
161
  return started;
128
162
  }
163
+ /** HTML to the text a prompt reads. Style and script blocks are removed
164
+ * until none remain — one pass leaves `<sty<style>le>` behind, which is
165
+ * the shape a sanitiser checker (rightly) flags. Nothing here is rendered
166
+ * as HTML afterwards; it becomes a task for an agent. */
167
+ function stripTags(html) {
168
+ let out = html;
169
+ for (let prev = ""; prev !== out;) {
170
+ prev = out;
171
+ out = out.replace(/<(style|script)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, "");
172
+ }
173
+ return out.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
174
+ }
175
+ // ------------------------------------------------------- idempotency keys
176
+ /**
177
+ * The value that identifies this delivery, as the flow's `idempotency:`
178
+ * names it.
179
+ *
180
+ * Two shapes, and only two:
181
+ *
182
+ * idempotency: x-github-delivery a request header
183
+ * idempotency: body:id a top-level field of a JSON body
184
+ *
185
+ * A NAME, never an expression — the grammar has no expressions, and this is
186
+ * not the place to grow one. Nothing is computed, combined or nested: if the
187
+ * sender does not put an identifier somewhere a name can reach, the flow
188
+ * cannot dedupe on it, and saying so is better than inventing a syntax.
189
+ *
190
+ * Returns null when the flow asks for no key, or when the delivery does not
191
+ * carry the one it asked for. A missing key admits the delivery: refusing
192
+ * one because a sender omitted a header would turn "dedupe when you can"
193
+ * into an outage.
194
+ */
195
+ export function deliveryKey(idempotency, header, body) {
196
+ if (!idempotency)
197
+ return null;
198
+ if (idempotency.startsWith("body:")) {
199
+ const field = idempotency.slice(5).trim();
200
+ if (!field)
201
+ return null;
202
+ try {
203
+ const parsed = JSON.parse(body);
204
+ const value = parsed?.[field];
205
+ // A scalar only. An object stringifies to "[object Object]" for every
206
+ // delivery, which would make the second one and every one after it a
207
+ // "duplicate" of the first.
208
+ if (value === undefined || value === null || typeof value === "object")
209
+ return null;
210
+ return String(value).slice(0, 200);
211
+ }
212
+ catch {
213
+ return null; // not JSON, so no field to read
214
+ }
215
+ }
216
+ return header(idempotency)?.slice(0, 200) || null;
217
+ }
129
218
  function safeEqual(a, b) {
130
219
  const x = Buffer.from(a);
131
220
  const y = Buffer.from(b);
@@ -259,7 +348,7 @@ export function normaliseInboundEmail(contentType, raw) {
259
348
  const subject = pick("subject", "Subject");
260
349
  const html = pick("html", "HtmlBody", "body-html");
261
350
  const text = pick("text", "TextBody", "body-plain", "stripped-text", "plain") ||
262
- html.replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
351
+ stripTags(html);
263
352
  if (!from && !subject && !text)
264
353
  return null;
265
354
  return { from, to: pick("to", "To", "recipient", "ToFull"), subject, text };
@@ -0,0 +1,11 @@
1
+ /** How many redirects one request may follow. fetch's own limit is 20;
2
+ * nothing a tenant points the platform at needs more than a couple. */
3
+ export declare const MAX_REDIRECTS = 5;
4
+ export declare class RedirectRefused extends Error {
5
+ constructor(message: string);
6
+ }
7
+ export declare function fetchUntrusted(url: string, init?: RequestInit, opts?: {
8
+ /** The request carries a credential (in its body or its headers):
9
+ * follow a redirect only within the origin it was sent to. */
10
+ sameOrigin?: boolean;
11
+ }): Promise<Response>;
@@ -0,0 +1,77 @@
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
+ import { platform } from "./platform.js";
18
+ /** How many redirects one request may follow. fetch's own limit is 20;
19
+ * nothing a tenant points the platform at needs more than a couple. */
20
+ export const MAX_REDIRECTS = 5;
21
+ const REDIRECT = new Set([301, 302, 303, 307, 308]);
22
+ /** Headers that may cross to another origin: none of them can carry a
23
+ * credential. Everything else — Authorization, a cookie, an x-api-key, a
24
+ * webhook's signature — stays with the origin it was meant for. */
25
+ const CROSS_ORIGIN_SAFE = new Set(["accept", "accept-language", "content-type", "user-agent"]);
26
+ export class RedirectRefused extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = "RedirectRefused";
30
+ }
31
+ }
32
+ export async function fetchUntrusted(url, init = {}, opts = {}) {
33
+ let current = url;
34
+ let req = { ...init };
35
+ for (let hop = 0;; hop++) {
36
+ const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" });
37
+ const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
38
+ if (!location)
39
+ return res;
40
+ // The body of a redirect is never read; free the socket.
41
+ await res.body?.cancel().catch(() => { });
42
+ if (hop >= MAX_REDIRECTS)
43
+ throw new RedirectRefused(`${url}: more than ${MAX_REDIRECTS} redirects — not followed`);
44
+ let next;
45
+ try {
46
+ next = new URL(location, current);
47
+ }
48
+ catch {
49
+ throw new RedirectRefused(`${current} redirected to "${location}", which is not a URL`);
50
+ }
51
+ if (next.protocol !== "https:" && next.protocol !== "http:") {
52
+ throw new RedirectRefused(`${current} redirected to a ${next.protocol} URL — not followed`);
53
+ }
54
+ const from = new URL(current);
55
+ const crossOrigin = next.origin !== from.origin;
56
+ if (crossOrigin && opts.sameOrigin) {
57
+ throw new RedirectRefused(`${from.origin} redirected to ${next.origin} — not followed: this request carries a credential, and it goes only where it was sent`);
58
+ }
59
+ // fetch's own rewrite: a 303, and a 301/302 answering a POST, is
60
+ // followed as a GET with no body; a 307/308 repeats the request as it was.
61
+ const method = (req.method ?? "GET").toUpperCase();
62
+ const headers = new Headers(req.headers);
63
+ if ((res.status === 303 && method !== "GET" && method !== "HEAD") || ((res.status === 301 || res.status === 302) && method === "POST")) {
64
+ req = { ...req, method: "GET", body: undefined };
65
+ headers.delete("content-type");
66
+ }
67
+ if (crossOrigin) {
68
+ const names = [];
69
+ headers.forEach((_, name) => names.push(name));
70
+ for (const name of names)
71
+ if (!CROSS_ORIGIN_SAFE.has(name))
72
+ headers.delete(name);
73
+ }
74
+ req = { ...req, headers };
75
+ current = next.toString();
76
+ }
77
+ }