@foldrun/core 2.4.0 → 2.6.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 (149) hide show
  1. package/dist/src/agent-activity.d.ts +5 -0
  2. package/dist/src/agent-activity.js +31 -0
  3. package/dist/src/agent-import.js +2 -2
  4. package/dist/src/agent-tools.js +2 -2
  5. package/dist/src/agents-md.d.ts +5 -1
  6. package/dist/src/agents-md.js +42 -18
  7. package/dist/src/alerts.d.ts +60 -0
  8. package/dist/src/alerts.js +161 -0
  9. package/dist/src/browser-pod.js +4 -0
  10. package/dist/src/capture.d.ts +23 -0
  11. package/dist/src/capture.js +80 -0
  12. package/dist/src/compare.d.ts +57 -0
  13. package/dist/src/compare.js +116 -0
  14. package/dist/src/completions.js +26 -3
  15. package/dist/src/deploy.d.ts +36 -1
  16. package/dist/src/deploy.js +237 -13
  17. package/dist/src/egress.d.ts +16 -1
  18. package/dist/src/evals.d.ts +27 -2
  19. package/dist/src/evals.js +62 -19
  20. package/dist/src/flow-lint.d.ts +14 -0
  21. package/dist/src/flow-lint.js +35 -0
  22. package/dist/src/flow-patterns.js +29 -14
  23. package/dist/src/frontmatter-fields.d.ts +27 -0
  24. package/dist/src/frontmatter-fields.js +60 -3
  25. package/dist/src/frontmatter-guard.d.ts +19 -0
  26. package/dist/src/frontmatter-guard.js +67 -0
  27. package/dist/src/frontmatter-schema.d.ts +2 -2
  28. package/dist/src/frontmatter-schema.js +33 -2
  29. package/dist/src/frontmatter-text.d.ts +31 -0
  30. package/dist/src/frontmatter-text.js +102 -0
  31. package/dist/src/history.d.ts +4 -0
  32. package/dist/src/history.js +15 -0
  33. package/dist/src/hooks.js +1 -1
  34. package/dist/src/insights.d.ts +30 -0
  35. package/dist/src/insights.js +152 -0
  36. package/dist/src/issues.d.ts +140 -0
  37. package/dist/src/issues.js +302 -0
  38. package/dist/src/judge-verdict.d.ts +19 -0
  39. package/dist/src/judge-verdict.js +54 -0
  40. package/dist/src/library.js +1 -1
  41. package/dist/src/limits.d.ts +8 -0
  42. package/dist/src/limits.js +36 -5
  43. package/dist/src/linkable.js +1 -1
  44. package/dist/src/matter.d.ts +42 -0
  45. package/dist/src/matter.js +132 -0
  46. package/dist/src/model-credential.d.ts +14 -0
  47. package/dist/src/model-credential.js +40 -0
  48. package/dist/src/network.d.ts +12 -1
  49. package/dist/src/network.js +35 -8
  50. package/dist/src/notify.d.ts +16 -0
  51. package/dist/src/notify.js +17 -7
  52. package/dist/src/okf.js +1 -1
  53. package/dist/src/online-checks.d.ts +76 -0
  54. package/dist/src/online-checks.js +432 -0
  55. package/dist/src/operator.d.ts +277 -0
  56. package/dist/src/operator.js +176 -2
  57. package/dist/src/otel-config.d.ts +18 -0
  58. package/dist/src/otel-config.js +69 -0
  59. package/dist/src/otel.d.ts +38 -0
  60. package/dist/src/otel.js +85 -0
  61. package/dist/src/outcome.d.ts +40 -0
  62. package/dist/src/outcome.js +77 -0
  63. package/dist/src/package.js +9 -2
  64. package/dist/src/platform.d.ts +1 -1
  65. package/dist/src/prompt-layers.d.ts +30 -0
  66. package/dist/src/prompt-layers.js +77 -0
  67. package/dist/src/provider-health.js +2 -2
  68. package/dist/src/providers.d.ts +5 -0
  69. package/dist/src/providers.js +2 -2
  70. package/dist/src/run-container.d.ts +25 -2
  71. package/dist/src/run-container.js +61 -6
  72. package/dist/src/runner.d.ts +53 -2
  73. package/dist/src/runner.js +471 -102
  74. package/dist/src/runs-filter.d.ts +48 -0
  75. package/dist/src/runs-filter.js +73 -0
  76. package/dist/src/scan.d.ts +30 -1
  77. package/dist/src/scan.js +145 -14
  78. package/dist/src/starter.js +27 -2
  79. package/dist/src/step-exec.d.ts +39 -3
  80. package/dist/src/step-exec.js +118 -15
  81. package/dist/src/store.d.ts +144 -3
  82. package/dist/src/store.js +150 -7
  83. package/dist/src/tokens.d.ts +38 -0
  84. package/dist/src/tokens.js +57 -0
  85. package/dist/src/tool-names.js +1 -1
  86. package/dist/src/tool-programs.js +1 -1
  87. package/dist/src/trace-tree.d.ts +40 -0
  88. package/dist/src/trace-tree.js +156 -0
  89. package/dist/src/translator.d.ts +8 -0
  90. package/dist/src/translator.js +7 -2
  91. package/dist/src/trigger-log.d.ts +1 -1
  92. package/dist/src/workspace-check.d.ts +18 -0
  93. package/dist/src/workspace-check.js +34 -0
  94. package/package.json +2 -2
  95. package/src/agent-activity.ts +32 -0
  96. package/src/agent-import.ts +2 -2
  97. package/src/agent-tools.ts +2 -2
  98. package/src/agents-md.ts +39 -14
  99. package/src/alerts.ts +190 -0
  100. package/src/browser-pod.ts +3 -0
  101. package/src/capture.ts +88 -0
  102. package/src/compare.ts +146 -0
  103. package/src/completions.ts +27 -3
  104. package/src/deploy.ts +238 -12
  105. package/src/egress.ts +12 -2
  106. package/src/evals.ts +64 -19
  107. package/src/flow-lint.ts +36 -0
  108. package/src/flow-patterns.ts +26 -13
  109. package/src/frontmatter-fields.ts +83 -3
  110. package/src/frontmatter-guard.ts +70 -0
  111. package/src/frontmatter-schema.ts +33 -4
  112. package/src/frontmatter-text.ts +109 -0
  113. package/src/history.ts +13 -0
  114. package/src/hooks.ts +1 -1
  115. package/src/insights.ts +174 -0
  116. package/src/issues.ts +396 -0
  117. package/src/judge-verdict.ts +52 -0
  118. package/src/library.ts +1 -1
  119. package/src/limits.ts +38 -5
  120. package/src/linkable.ts +1 -1
  121. package/src/matter.ts +145 -0
  122. package/src/model-credential.ts +45 -0
  123. package/src/network.ts +35 -7
  124. package/src/notify.ts +22 -7
  125. package/src/okf.ts +1 -1
  126. package/src/online-checks.ts +450 -0
  127. package/src/operator.ts +213 -3
  128. package/src/otel-config.ts +67 -0
  129. package/src/otel.ts +100 -0
  130. package/src/outcome.ts +90 -0
  131. package/src/package.ts +8 -2
  132. package/src/platform.ts +1 -1
  133. package/src/prompt-layers.ts +90 -0
  134. package/src/provider-health.ts +2 -2
  135. package/src/providers.ts +7 -2
  136. package/src/run-container.ts +77 -8
  137. package/src/runner.ts +477 -79
  138. package/src/runs-filter.ts +88 -0
  139. package/src/scan.ts +153 -14
  140. package/src/starter.ts +27 -2
  141. package/src/step-exec.ts +168 -17
  142. package/src/store.ts +268 -9
  143. package/src/tokens.ts +68 -0
  144. package/src/tool-names.ts +1 -1
  145. package/src/tool-programs.ts +1 -1
  146. package/src/trace-tree.ts +187 -0
  147. package/src/translator.ts +13 -2
  148. package/src/trigger-log.ts +1 -0
  149. package/src/workspace-check.ts +41 -0
@@ -0,0 +1,5 @@
1
+ import type { RunSummary } from "./store.ts";
2
+ /** Agent name → the ISO time of its latest step that ran. */
3
+ export declare function agentLastRuns(runs: RunSummary[]): Map<string, string>;
4
+ /** Whole days since `iso`, or null for never. */
5
+ export declare function daysSince(iso: string | null | undefined, now?: number): number | null;
@@ -0,0 +1,31 @@
1
+ // When each agent last did anything — the question an account with hundreds
2
+ // of agents has to be able to answer before it can retire the ones nobody
3
+ // uses. Read from the run index (listRunSummaries), so it covers what the
4
+ // install keeps: runs older than its retention are gone, and an agent whose
5
+ // last run was pruned reads as never run.
6
+ /** A step that started counts; one that was skipped or never reached does not. */
7
+ const RAN = new Set(["running", "completed", "failed", "awaiting-approval"]);
8
+ /** Agent name → the ISO time of its latest step that ran. */
9
+ export function agentLastRuns(runs) {
10
+ const last = new Map();
11
+ for (const run of runs) {
12
+ for (const step of run.steps ?? []) {
13
+ if (!step.agent || !RAN.has(step.status))
14
+ continue;
15
+ const at = step.startedAt ?? run.startedAt;
16
+ if (!at)
17
+ continue;
18
+ const prev = last.get(step.agent);
19
+ if (!prev || at > prev)
20
+ last.set(step.agent, at);
21
+ }
22
+ }
23
+ return last;
24
+ }
25
+ /** Whole days since `iso`, or null for never. */
26
+ export function daysSince(iso, now = Date.now()) {
27
+ if (!iso)
28
+ return null;
29
+ const t = Date.parse(iso);
30
+ return Number.isFinite(t) ? Math.floor((now - t) / 86_400_000) : null;
31
+ }
@@ -14,7 +14,7 @@
14
14
  // were about somebody else's desk.
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
- import matter from "gray-matter";
17
+ import { configMatter } from "./matter.js";
18
18
  import { assertSafeName, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, workspaceDir, workspaceTools, listWorkspaces, } from "./store.js";
19
19
  import { libraryDir, libraryTools } from "./library.js";
20
20
  import { isRuntimeTool, toolEntryName } from "./tool-names.js";
@@ -64,7 +64,7 @@ export function importAgent(tenant, from, agent, to, opts = {}) {
64
64
  let content = readWorkspaceFile(tenant, from, src);
65
65
  const rel = src.slice(`agents/${agent}/`.length);
66
66
  if (rel === "agent.md") {
67
- front = matter(content).data;
67
+ front = configMatter(content).data;
68
68
  // The folder is the name flows address; a `name:` that disagrees with
69
69
  // it is the first thing check would complain about.
70
70
  if (name !== agent)
@@ -16,7 +16,7 @@
16
16
  import fs from "node:fs";
17
17
  import os from "node:os";
18
18
  import path from "node:path";
19
- import matter from "gray-matter";
19
+ import { configMatter } from "./matter.js";
20
20
  import { z } from "zod";
21
21
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
22
22
  import { resolveModel, resolveEffort } from "./store.js";
@@ -36,7 +36,7 @@ export function gatherConsults(workspaceRoot, names) {
36
36
  missing.push(name);
37
37
  continue;
38
38
  }
39
- const parsed = matter(fs.readFileSync(file, "utf8"));
39
+ const parsed = configMatter(fs.readFileSync(file, "utf8"));
40
40
  consults.push({
41
41
  name,
42
42
  systemPrompt: `${parsed.content.trim()}\n\n` +
@@ -1,8 +1,12 @@
1
+ import { keySpan } from "./frontmatter-text.ts";
2
+ export { keySpan };
1
3
  /** The frontmatter of a directory's AGENTS.md (or project.md), or {}. */
2
4
  export declare function readAgentsMdKeys(dir: string): Record<string, unknown>;
3
5
  /** The text of `content` with each key in `patch` replaced (or removed for
4
6
  * null), everything else byte for byte as it was. A file with no
5
- * frontmatter gets one. */
7
+ * frontmatter gets one. Read back before it is returned (frontmatter-guard
8
+ * .ts): an edit that would not parse, would change another key or would
9
+ * double one into its block throws UnsafeEditError (409). */
6
10
  export declare function patchFrontmatter(content: string, patch: Record<string, unknown | null>): string;
7
11
  /** Apply a frontmatter patch to a directory's AGENTS.md, creating the file
8
12
  * when the directory has none. Returns the new content. */
@@ -8,7 +8,11 @@
8
8
  // else in the file is untouched. Null removes the key.
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
- import matter from "gray-matter";
11
+ import matter, { configMatter } from "./matter.js";
12
+ import { keySpan, dropEmptyBlock } from "./frontmatter-text.js";
13
+ import { guardEdit } from "./frontmatter-guard.js";
14
+ import { JOBS, FOLDABLE } from "./frontmatter-fields.js";
15
+ export { keySpan };
12
16
  const FRONT = /^(---\r?\n)([\s\S]*?)(\r?\n---)(\r?\n|$)/;
13
17
  /** The frontmatter of a directory's AGENTS.md (or project.md), or {}. */
14
18
  export function readAgentsMdKeys(dir) {
@@ -17,7 +21,7 @@ export function readAgentsMdKeys(dir) {
17
21
  if (!fs.existsSync(file))
18
22
  continue;
19
23
  try {
20
- return (matter(fs.readFileSync(file, "utf8")).data ?? {});
24
+ return (configMatter(fs.readFileSync(file, "utf8")).data ?? {});
21
25
  }
22
26
  catch {
23
27
  return {};
@@ -34,8 +38,33 @@ function dumpKey(key, value) {
34
38
  }
35
39
  /** The text of `content` with each key in `patch` replaced (or removed for
36
40
  * null), everything else byte for byte as it was. A file with no
37
- * frontmatter gets one. */
41
+ * frontmatter gets one. Read back before it is returned (frontmatter-guard
42
+ * .ts): an edit that would not parse, would change another key or would
43
+ * double one into its block throws UnsafeEditError (409). */
38
44
  export function patchFrontmatter(content, patch) {
45
+ // The guard reads the result folded (matter.ts configMatter), so a patch
46
+ // that writes a whole job block is checked as the keys it holds: each
47
+ // field the block may hold may change, and each one it sets reads back.
48
+ const changed = new Set();
49
+ const expect = {};
50
+ for (const [key, value] of Object.entries(patch)) {
51
+ changed.add(key);
52
+ const job = JOBS.find((j) => j.name === key)?.name;
53
+ if (!job) {
54
+ expect[key] = value;
55
+ continue;
56
+ }
57
+ for (const k of FOLDABLE[job])
58
+ changed.add(k);
59
+ if (value && typeof value === "object" && !Array.isArray(value)) {
60
+ for (const [k, v] of Object.entries(value))
61
+ if (FOLDABLE[job].has(k))
62
+ expect[k] = v;
63
+ }
64
+ }
65
+ return guardEdit(content, patchFrontmatterText(content, patch), [...changed], expect);
66
+ }
67
+ function patchFrontmatterText(content, patch) {
39
68
  const m = content.match(FRONT);
40
69
  let open = "---\n";
41
70
  let body = "";
@@ -50,22 +79,17 @@ export function patchFrontmatter(content, patch) {
50
79
  }
51
80
  let lines = body === "" ? [] : body.split("\n");
52
81
  for (const [key, value] of Object.entries(patch)) {
53
- const at = lines.findIndex((l) => new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*:`).test(l));
54
- let end = at;
55
- if (at !== -1) {
56
- end = at + 1;
57
- // The block continues while lines are indented or blank-inside-block.
58
- while (end < lines.length && (/^[ \t]/.test(lines[end]) || lines[end].trim() === ""))
59
- end++;
60
- // A blank line at the block's foot belongs to what follows.
61
- while (end > at + 1 && lines[end - 1].trim() === "")
62
- end--;
63
- }
64
- const replacement = value === null || value === undefined ? [] : dumpKey(key, value);
65
- if (at === -1)
82
+ // Where it is written now — at the top or inside its job block — is
83
+ // where it is edited; a key written nowhere is added at the top.
84
+ const where = keySpan(lines, key);
85
+ const replacement = value === null || value === undefined ? [] : dumpKey(key, value).map((l) => `${where?.indent ?? ""}${l}`);
86
+ if (!where)
66
87
  lines = [...lines, ...replacement];
67
- else
68
- lines = [...lines.slice(0, at), ...replacement, ...lines.slice(end)];
88
+ else {
89
+ lines = [...lines.slice(0, where.at), ...replacement, ...lines.slice(where.end)];
90
+ if (!replacement.length && where.block !== null)
91
+ lines = dropEmptyBlock(lines, where.block);
92
+ }
69
93
  }
70
94
  const front = lines.length ? `${open}${lines.join("\n")}${close}` : "";
71
95
  if (!front)
@@ -0,0 +1,60 @@
1
+ import type { Outcome } from "./outcome.ts";
2
+ export interface AlertConfig {
3
+ failureRate?: number;
4
+ minRuns: number;
5
+ creditsDay?: number;
6
+ runMinutes?: number;
7
+ staleDays?: number;
8
+ costSpike?: number;
9
+ runsPerHour?: number;
10
+ /** Told after a run (platform host-watch.ts), not on the watchdog's tick. */
11
+ newHost?: boolean;
12
+ }
13
+ /** The config, and what is wrong with it in words (for `foldrun check`). */
14
+ export declare function parseAlerts(raw: unknown): {
15
+ config: AlertConfig | null;
16
+ problems: string[];
17
+ };
18
+ export interface AlertFacts {
19
+ /** Outcomes of the runs started in the last 24 hours. */
20
+ outcomes: Outcome[];
21
+ /** Credits are cents: USD charged in the last 24 hours. */
22
+ chargedUsd: number;
23
+ /** Runs still going. */
24
+ live: {
25
+ id: string;
26
+ flow: string;
27
+ startedAt: string;
28
+ }[];
29
+ /** For stale_days: each agent, when it last ran (null: not in the runs
30
+ * kept), and since when it has existed — an agent added yesterday is not
31
+ * stale for never having run. */
32
+ agents?: {
33
+ name: string;
34
+ lastRunAt: string | null;
35
+ since: string;
36
+ }[];
37
+ /** For cost_spike and runs_per_hour: the runs of the last seven days,
38
+ * test runs left out. costUsd is what its steps cost. */
39
+ runs?: {
40
+ id: string;
41
+ flow: string;
42
+ startedAt: string;
43
+ finishedAt: string | null;
44
+ costUsd: number;
45
+ }[];
46
+ }
47
+ export interface Alert {
48
+ /** What the once-a-day memory is keyed by: the rule, or the rule and run. */
49
+ key: string;
50
+ headline: string;
51
+ detail: string;
52
+ runId?: string;
53
+ flow?: string;
54
+ /** Said once for this key, however long it stays true (a slow run, an
55
+ * idle spell); the watchdog's notice memory is pruned after a week, so
56
+ * a condition that lasts is said again weekly at most. */
57
+ once?: boolean;
58
+ }
59
+ /** What crosses its threshold now. */
60
+ export declare function evaluateAlerts(cfg: AlertConfig, facts: AlertFacts, workspace: string, now: number): Alert[];
@@ -0,0 +1,161 @@
1
+ // `alerts:` — the thresholds a workspace wants to hear about (FOL-72 step 7):
2
+ //
3
+ // alerts:
4
+ // failure_rate: 30% # of its runs that finished in the last 24 hours
5
+ // min_runs: 5 # …once at least this many have (default 5)
6
+ // credits_day: 2000 # credits charged in the last 24 hours
7
+ // run_minutes: 45 # a run still going after this long
8
+ // stale_days: 30 # an agent that has not run in this many days
9
+ // cost_spike: 3 # a run that cost this many times its flow's 7-day average
10
+ // runs_per_hour: 20 # one flow started this many times in an hour
11
+ // new_host: true # an agent reached a site it never reached before
12
+ //
13
+ // In AGENTS.md (the workspace's, else the account's). The watchdog evaluates
14
+ // them on its tick and tells `notify:` once per rule a day (a slow run once
15
+ // per run), as event `alert`. Pure: `foldrun check` and the watchdog read the
16
+ // same parse.
17
+ const KEYS = ["failure_rate", "min_runs", "credits_day", "run_minutes", "stale_days", "cost_spike", "runs_per_hour", "new_host"];
18
+ const num = (v) => {
19
+ if (typeof v === "number" && Number.isFinite(v))
20
+ return v;
21
+ if (typeof v === "string" && v.trim() && Number.isFinite(Number(v.trim())))
22
+ return Number(v.trim());
23
+ return null;
24
+ };
25
+ /** The config, and what is wrong with it in words (for `foldrun check`). */
26
+ export function parseAlerts(raw) {
27
+ if (raw === undefined || raw === null)
28
+ return { config: null, problems: [] };
29
+ if (typeof raw !== "object" || Array.isArray(raw)) {
30
+ return { config: null, problems: ["alerts: is a block — failure_rate, min_runs, credits_day, run_minutes, stale_days, cost_spike, runs_per_hour, new_host"] };
31
+ }
32
+ const o = raw;
33
+ const problems = [];
34
+ for (const k of Object.keys(o))
35
+ if (!KEYS.includes(k))
36
+ problems.push(`alerts: ${k} — not a threshold; the keys are ${KEYS.join(", ")}`);
37
+ const cfg = { minRuns: 5 };
38
+ if (o.failure_rate !== undefined) {
39
+ const s = String(o.failure_rate).trim();
40
+ const pct = /^(\d+(?:\.\d+)?)\s*%$/.exec(s);
41
+ const v = pct ? Number(pct[1]) / 100 : num(o.failure_rate);
42
+ if (v === null || v <= 0 || v > 1)
43
+ problems.push(`alerts: failure_rate ${s} — a share of runs, like 30% or 0.3`);
44
+ else
45
+ cfg.failureRate = v;
46
+ }
47
+ for (const [key, field, min] of [["min_runs", "minRuns", 1], ["credits_day", "creditsDay", 1], ["run_minutes", "runMinutes", 1], ["stale_days", "staleDays", 1], ["cost_spike", "costSpike", 1.5], ["runs_per_hour", "runsPerHour", 2]]) {
48
+ if (o[key] === undefined)
49
+ continue;
50
+ const v = num(o[key]);
51
+ if (v === null || v < min)
52
+ problems.push(`alerts: ${key} ${String(o[key])} — a number, at least ${min}`);
53
+ else
54
+ cfg[field] = v;
55
+ }
56
+ if (o.new_host !== undefined) {
57
+ if (typeof o.new_host !== "boolean")
58
+ problems.push(`alerts: new_host ${String(o.new_host)} — true or false`);
59
+ else if (o.new_host)
60
+ cfg.newHost = true;
61
+ }
62
+ const any = cfg.newHost === true || cfg.failureRate !== undefined || cfg.creditsDay !== undefined || cfg.runMinutes !== undefined || cfg.staleDays !== undefined || cfg.costSpike !== undefined || cfg.runsPerHour !== undefined;
63
+ return { config: any ? cfg : null, problems };
64
+ }
65
+ /** How few earlier runs a flow needs before its average means anything. */
66
+ const SPIKE_MIN_RUNS = 3;
67
+ /** What crosses its threshold now. */
68
+ export function evaluateAlerts(cfg, facts, workspace, now) {
69
+ const out = [];
70
+ if (cfg.failureRate !== undefined) {
71
+ const finished = facts.outcomes.filter((o) => o === "failed" || o === "good" || o === "done" || o === "quiet" || o === "blocked" || o === "bad");
72
+ const failed = finished.filter((o) => o === "failed").length;
73
+ if (finished.length >= cfg.minRuns && failed / finished.length >= cfg.failureRate) {
74
+ out.push({
75
+ key: "failure_rate",
76
+ headline: `⚠ ${workspace}: ${failed} of its last ${finished.length} runs failed`,
77
+ detail: `${Math.round((100 * failed) / finished.length)}% in the last 24 hours, over the ${Math.round(cfg.failureRate * 100)}% in alerts: failure_rate. Its Issues show what broke.`,
78
+ });
79
+ }
80
+ }
81
+ if (cfg.creditsDay !== undefined) {
82
+ const credits = Math.round(facts.chargedUsd * 100);
83
+ if (credits >= cfg.creditsDay) {
84
+ out.push({
85
+ key: "credits_day",
86
+ headline: `⚠ ${workspace} was charged ${credits.toLocaleString("en")} credits in a day`,
87
+ detail: `Over the ${cfg.creditsDay.toLocaleString("en")} in alerts: credits_day, in the last 24 hours. Observe shows which flows and agents.`,
88
+ });
89
+ }
90
+ }
91
+ if (cfg.runMinutes !== undefined) {
92
+ for (const r of facts.live) {
93
+ const mins = (now - Date.parse(r.startedAt)) / 60_000;
94
+ if (mins >= cfg.runMinutes) {
95
+ out.push({
96
+ key: `run_minutes/${r.id}`,
97
+ runId: r.id,
98
+ flow: r.flow,
99
+ headline: `⚠ ${workspace}: ${r.flow} has been running ${Math.round(mins)} minutes`,
100
+ detail: `Run ${r.id} is past the ${cfg.runMinutes} minutes in alerts: run_minutes — still going, maybe stuck. Stop it from its page if it is.`,
101
+ });
102
+ }
103
+ }
104
+ }
105
+ if (cfg.costSpike !== undefined && facts.runs) {
106
+ // A run that finished in the last day, against the runs of its flow that
107
+ // finished before it in the week: once per run.
108
+ const done = facts.runs.filter((r) => r.finishedAt).sort((a, b) => a.finishedAt.localeCompare(b.finishedAt));
109
+ for (const r of done) {
110
+ if (now - Date.parse(r.finishedAt) > 86_400_000)
111
+ continue;
112
+ const before = done.filter((o) => o.flow === r.flow && o.id !== r.id && o.finishedAt < r.finishedAt);
113
+ if (before.length < SPIKE_MIN_RUNS)
114
+ continue;
115
+ const mean = before.reduce((n, o) => n + o.costUsd, 0) / before.length;
116
+ if (mean <= 0 || r.costUsd < cfg.costSpike * mean)
117
+ continue;
118
+ out.push({
119
+ key: `cost_spike/${r.id}`,
120
+ runId: r.id,
121
+ flow: r.flow,
122
+ headline: `${workspace}: a ${r.flow} run cost ${(r.costUsd / mean).toFixed(1)}× its usual`,
123
+ detail: `Run ${r.id} cost $${r.costUsd.toFixed(2)} against an average of $${mean.toFixed(2)} over its last ${before.length} runs this week — past the ${cfg.costSpike}× in alerts: cost_spike. Its page shows which step spent it.`,
124
+ });
125
+ }
126
+ }
127
+ if (cfg.runsPerHour !== undefined && facts.runs) {
128
+ const hour = facts.runs.filter((r) => now - Date.parse(r.startedAt) <= 3_600_000);
129
+ const byFlow = new Map();
130
+ for (const r of hour)
131
+ byFlow.set(r.flow, (byFlow.get(r.flow) ?? 0) + 1);
132
+ for (const [flow, n] of byFlow) {
133
+ if (n < cfg.runsPerHour)
134
+ continue;
135
+ out.push({
136
+ key: `runs_per_hour/${flow}`,
137
+ flow,
138
+ headline: `${workspace}: ${flow} started ${n} times in an hour`,
139
+ detail: `Past the ${cfg.runsPerHour} in alerts: runs_per_hour — a loop, a webhook sender retrying, or a schedule written wrong. Its Triggers page shows what started each; throttle: or debounce: caps it, and Settings → Pause stops it now.`,
140
+ });
141
+ }
142
+ }
143
+ if (cfg.staleDays !== undefined) {
144
+ for (const a of facts.agents ?? []) {
145
+ const from = Date.parse(a.lastRunAt ?? a.since);
146
+ if (!Number.isFinite(from))
147
+ continue;
148
+ const days = Math.floor((now - from) / 86_400_000);
149
+ if (days < cfg.staleDays)
150
+ continue;
151
+ out.push({
152
+ // Keyed by the run it last made, so a run and a new idle spell are news again.
153
+ key: `stale_days/${a.name}/${a.lastRunAt ?? "never"}`,
154
+ once: true,
155
+ headline: `${workspace}: agent ${a.name} has not run in ${days} days`,
156
+ detail: `${a.lastRunAt ? `Its last step started ${a.lastRunAt.slice(0, 10)}` : "No run kept on record has used it"} — past the ${cfg.staleDays} days in alerts: stale_days. If nothing needs it any more, retire it: delete agents/${a.name}/ and redeploy, so it holds no tools or secrets nobody watches.`,
157
+ });
158
+ }
159
+ }
160
+ return out;
161
+ }
@@ -169,6 +169,10 @@ export function classifyCall(tool, input, ctx = {}) {
169
169
  return { write: false };
170
170
  if (server === "foldrun_ask")
171
171
  return write("a question to a person");
172
+ // tools: [peers] — reading the roster changes nothing; a message to a
173
+ // colleague is said, and a re-run would say it twice.
174
+ if (server === "foldrun_peers")
175
+ return name === "peers_list" ? { write: false } : write("a message to a colleague");
172
176
  if (server === "foldrun_scripts") {
173
177
  if (name !== "web")
174
178
  return write(`script ${name}`);
@@ -0,0 +1,23 @@
1
+ import type { CallIo } from "./step-exec.ts";
2
+ /** How much past the cap a step sends, so masking sees a secret whole
3
+ * before the cut (a value longer than this is a file secret, which is also
4
+ * masked line by line). */
5
+ export declare const CAPTURE_SLACK = 4096;
6
+ /**
7
+ * What the step's scrub replaces: every secret value it holds → its name,
8
+ * longest first. An `@file` secret (a key, a certificate) by its content, not
9
+ * its stored form; a multi-line value also line by line, because a file is
10
+ * read back a line at a time (the Read tool numbers each); and every value
11
+ * also as it appears inside JSON, where `"` `\` and newlines are escaped — a
12
+ * tool's input is JSON. Values under 8 characters are skipped: a two-letter
13
+ * secret would redact half the language, and anything that short is not one.
14
+ */
15
+ export declare function redactionsFor(secrets: Record<string, string | undefined>): [string, string][];
16
+ /** Masked, then cut to the cap. */
17
+ export declare function maskCall(text: string, redactSecrets: (text: string) => string): {
18
+ text: string;
19
+ cut: boolean;
20
+ };
21
+ export declare function recordCallIo(workspaceRoot: string, runId: string, callId: string, tool: string, io: CallIo,
22
+ /** The step's own scrub: its resolved secret values → `[redacted:NAME]`. */
23
+ redactSecrets: (text: string) => string): boolean;
@@ -0,0 +1,80 @@
1
+ // A tool call's content, from a step's event to the file beside its run
2
+ // (FOL-54 step 4), masked twice: the step's own secrets by value (the
3
+ // runner's redact, from redactionsFor), then anything shaped like a
4
+ // credential or a private key — a key the agent read from a file or a page
5
+ // is not in the vault, and is still a key. Masked first, cut second, so a
6
+ // secret on the cap's edge is never kept half. Recording never fails the step.
7
+ import { redactText } from "./scan.js";
8
+ import { writeCapturedCall, CAPTURE_CAP } from "./store.js";
9
+ import { isFileValue, fileContent } from "./secrets.js";
10
+ /** How much past the cap a step sends, so masking sees a secret whole
11
+ * before the cut (a value longer than this is a file secret, which is also
12
+ * masked line by line). */
13
+ export const CAPTURE_SLACK = 4096;
14
+ /**
15
+ * What the step's scrub replaces: every secret value it holds → its name,
16
+ * longest first. An `@file` secret (a key, a certificate) by its content, not
17
+ * its stored form; a multi-line value also line by line, because a file is
18
+ * read back a line at a time (the Read tool numbers each); and every value
19
+ * also as it appears inside JSON, where `"` `\` and newlines are escaped — a
20
+ * tool's input is JSON. Values under 8 characters are skipped: a two-letter
21
+ * secret would redact half the language, and anything that short is not one.
22
+ */
23
+ export function redactionsFor(secrets) {
24
+ const out = new Map();
25
+ const add = (v, name) => {
26
+ if (v.length >= 8 && !out.has(v))
27
+ out.set(v, name);
28
+ };
29
+ for (const [name, raw] of Object.entries(secrets)) {
30
+ if (typeof raw !== "string")
31
+ continue;
32
+ for (const v of isFileValue(raw) ? [raw, fileContent(raw)] : [raw]) {
33
+ add(v, name);
34
+ add(v.trim(), name);
35
+ const escaped = JSON.stringify(v).slice(1, -1);
36
+ if (escaped !== v)
37
+ add(escaped, name);
38
+ if (/\r?\n/.test(v.trim())) {
39
+ for (const line of v.split(/\r?\n/)) {
40
+ const l = line.trim();
41
+ if (l.length >= 16)
42
+ add(l, name);
43
+ }
44
+ }
45
+ }
46
+ }
47
+ return [...out.entries()].sort((a, b) => b[0].length - a[0].length);
48
+ }
49
+ // A private key's whole block — scan.ts names a key header and leaves the
50
+ // body, which is right for a finding and wrong for a stored output.
51
+ // Unterminated too: a key cut short, or printed without its END line, is
52
+ // masked to the end of the text.
53
+ const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$(?![\s\S]))/g;
54
+ /** Masked, then cut to the cap. */
55
+ export function maskCall(text, redactSecrets) {
56
+ const masked = redactText(redactSecrets(text)).replace(PRIVATE_KEY, "[redacted:private key]");
57
+ return masked.length > CAPTURE_CAP ? { text: masked.slice(0, CAPTURE_CAP), cut: true } : { text: masked, cut: false };
58
+ }
59
+ export function recordCallIo(workspaceRoot, runId, callId, tool, io,
60
+ /** The step's own scrub: its resolved secret values → `[redacted:NAME]`. */
61
+ redactSecrets) {
62
+ try {
63
+ const input = io.input === undefined ? null : maskCall(io.input, redactSecrets);
64
+ const output = io.output === undefined ? null : maskCall(io.output, redactSecrets);
65
+ writeCapturedCall(workspaceRoot, runId, {
66
+ id: callId,
67
+ tool,
68
+ // Which part was cut, each on its own: in the sandbox (the whole was
69
+ // longer than what was sent) or here, after masking.
70
+ ...(input ? { input: input.text, ...(io.inputChars !== undefined ? { inputChars: io.inputChars } : {}), ...(input.cut || (io.inputChars ?? 0) > io.input.length ? { inputCut: true } : {}) } : {}),
71
+ ...(output ? { output: output.text, ...(io.outputChars !== undefined ? { outputChars: io.outputChars } : {}), ...(output.cut || (io.outputChars ?? 0) > io.output.length ? { outputCut: true } : {}) } : {}),
72
+ ...(io.truncated || input?.cut || output?.cut ? { truncated: true } : {}),
73
+ });
74
+ return true;
75
+ }
76
+ catch (err) {
77
+ console.error(`[capture] ${tool} (${callId}) for ${runId}:`, err instanceof Error ? err.message : err);
78
+ return false;
79
+ }
80
+ }
@@ -0,0 +1,57 @@
1
+ import type { RunRecord } from "./store.ts";
2
+ export interface StepSide {
3
+ status: string;
4
+ instruction: string;
5
+ model?: string;
6
+ secs: number | null;
7
+ tokensIn: number;
8
+ tokensOut: number;
9
+ modelCalls: number;
10
+ /** Tool name → how many calls. */
11
+ tools: Record<string, number>;
12
+ toolErrors: number;
13
+ /** The first errors it wrote, each one line. */
14
+ errors: string[];
15
+ /** The first line of what it concluded. */
16
+ said: string | null;
17
+ }
18
+ export interface StepDiff {
19
+ /** 1-based, as the trace numbers steps. */
20
+ step: number;
21
+ agent: string;
22
+ base: StepSide | null;
23
+ run: StepSide | null;
24
+ /** What differs, in words, most telling first; [] when nothing does. */
25
+ changes: string[];
26
+ }
27
+ export interface RunComparison {
28
+ base: {
29
+ id: string;
30
+ status: string;
31
+ startedAt: string;
32
+ revision: string | null;
33
+ };
34
+ run: {
35
+ id: string;
36
+ status: string;
37
+ startedAt: string;
38
+ revision: string | null;
39
+ };
40
+ revisionChanged: boolean;
41
+ /** Files changed between the two revisions; null when that cannot be said. */
42
+ changedFiles: string[] | null;
43
+ steps: StepDiff[];
44
+ }
45
+ export declare function compareRuns(base: RunRecord, run: RunRecord, changedFiles?: string[] | null): RunComparison;
46
+ /** The run to compare a run with when none is named: the newest earlier run
47
+ * of the same flow that finished well (completed, not BLOCKED or BAD), or
48
+ * null. `outcomeOf` is the caller's, so this stays free of the verdict reader. */
49
+ export declare function lastGoodBefore<R extends {
50
+ id: string;
51
+ flow: string;
52
+ startedAt: string;
53
+ }>(runs: R[], run: {
54
+ id: string;
55
+ flow: string;
56
+ startedAt: string;
57
+ }, good: (r: R) => boolean): R | null;