@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.
- package/dist/src/agent-activity.d.ts +5 -0
- package/dist/src/agent-activity.js +31 -0
- package/dist/src/agent-import.js +2 -2
- package/dist/src/agent-tools.js +2 -2
- package/dist/src/agents-md.d.ts +5 -1
- package/dist/src/agents-md.js +42 -18
- package/dist/src/alerts.d.ts +60 -0
- package/dist/src/alerts.js +161 -0
- package/dist/src/browser-pod.js +4 -0
- package/dist/src/capture.d.ts +23 -0
- package/dist/src/capture.js +80 -0
- package/dist/src/compare.d.ts +57 -0
- package/dist/src/compare.js +116 -0
- package/dist/src/completions.js +26 -3
- package/dist/src/deploy.d.ts +36 -1
- package/dist/src/deploy.js +237 -13
- package/dist/src/egress.d.ts +16 -1
- package/dist/src/evals.d.ts +27 -2
- package/dist/src/evals.js +62 -19
- package/dist/src/flow-lint.d.ts +14 -0
- package/dist/src/flow-lint.js +35 -0
- package/dist/src/flow-patterns.js +29 -14
- package/dist/src/frontmatter-fields.d.ts +27 -0
- package/dist/src/frontmatter-fields.js +60 -3
- package/dist/src/frontmatter-guard.d.ts +19 -0
- package/dist/src/frontmatter-guard.js +67 -0
- package/dist/src/frontmatter-schema.d.ts +2 -2
- package/dist/src/frontmatter-schema.js +33 -2
- package/dist/src/frontmatter-text.d.ts +31 -0
- package/dist/src/frontmatter-text.js +102 -0
- package/dist/src/history.d.ts +4 -0
- package/dist/src/history.js +15 -0
- package/dist/src/hooks.js +1 -1
- package/dist/src/insights.d.ts +30 -0
- package/dist/src/insights.js +152 -0
- package/dist/src/issues.d.ts +140 -0
- package/dist/src/issues.js +302 -0
- package/dist/src/judge-verdict.d.ts +19 -0
- package/dist/src/judge-verdict.js +54 -0
- package/dist/src/library.js +1 -1
- package/dist/src/limits.d.ts +8 -0
- package/dist/src/limits.js +36 -5
- package/dist/src/linkable.js +1 -1
- package/dist/src/matter.d.ts +42 -0
- package/dist/src/matter.js +132 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/network.d.ts +12 -1
- package/dist/src/network.js +35 -8
- package/dist/src/notify.d.ts +16 -0
- package/dist/src/notify.js +17 -7
- package/dist/src/okf.js +1 -1
- package/dist/src/online-checks.d.ts +76 -0
- package/dist/src/online-checks.js +432 -0
- package/dist/src/operator.d.ts +277 -0
- package/dist/src/operator.js +176 -2
- package/dist/src/otel-config.d.ts +18 -0
- package/dist/src/otel-config.js +69 -0
- package/dist/src/otel.d.ts +38 -0
- package/dist/src/otel.js +85 -0
- package/dist/src/outcome.d.ts +40 -0
- package/dist/src/outcome.js +77 -0
- package/dist/src/package.js +9 -2
- package/dist/src/platform.d.ts +1 -1
- package/dist/src/prompt-layers.d.ts +30 -0
- package/dist/src/prompt-layers.js +77 -0
- package/dist/src/provider-health.js +2 -2
- package/dist/src/providers.d.ts +5 -0
- package/dist/src/providers.js +2 -2
- package/dist/src/run-container.d.ts +25 -2
- package/dist/src/run-container.js +61 -6
- package/dist/src/runner.d.ts +53 -2
- package/dist/src/runner.js +471 -102
- package/dist/src/runs-filter.d.ts +48 -0
- package/dist/src/runs-filter.js +73 -0
- package/dist/src/scan.d.ts +30 -1
- package/dist/src/scan.js +145 -14
- package/dist/src/starter.js +27 -2
- package/dist/src/step-exec.d.ts +39 -3
- package/dist/src/step-exec.js +118 -15
- package/dist/src/store.d.ts +144 -3
- package/dist/src/store.js +150 -7
- package/dist/src/tokens.d.ts +38 -0
- package/dist/src/tokens.js +57 -0
- package/dist/src/tool-names.js +1 -1
- package/dist/src/tool-programs.js +1 -1
- package/dist/src/trace-tree.d.ts +40 -0
- package/dist/src/trace-tree.js +156 -0
- package/dist/src/translator.d.ts +8 -0
- package/dist/src/translator.js +7 -2
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/workspace-check.d.ts +18 -0
- package/dist/src/workspace-check.js +34 -0
- package/package.json +2 -2
- package/src/agent-activity.ts +32 -0
- package/src/agent-import.ts +2 -2
- package/src/agent-tools.ts +2 -2
- package/src/agents-md.ts +39 -14
- package/src/alerts.ts +190 -0
- package/src/browser-pod.ts +3 -0
- package/src/capture.ts +88 -0
- package/src/compare.ts +146 -0
- package/src/completions.ts +27 -3
- package/src/deploy.ts +238 -12
- package/src/egress.ts +12 -2
- package/src/evals.ts +64 -19
- package/src/flow-lint.ts +36 -0
- package/src/flow-patterns.ts +26 -13
- package/src/frontmatter-fields.ts +83 -3
- package/src/frontmatter-guard.ts +70 -0
- package/src/frontmatter-schema.ts +33 -4
- package/src/frontmatter-text.ts +109 -0
- package/src/history.ts +13 -0
- package/src/hooks.ts +1 -1
- package/src/insights.ts +174 -0
- package/src/issues.ts +396 -0
- package/src/judge-verdict.ts +52 -0
- package/src/library.ts +1 -1
- package/src/limits.ts +38 -5
- package/src/linkable.ts +1 -1
- package/src/matter.ts +145 -0
- package/src/model-credential.ts +45 -0
- package/src/network.ts +35 -7
- package/src/notify.ts +22 -7
- package/src/okf.ts +1 -1
- package/src/online-checks.ts +450 -0
- package/src/operator.ts +213 -3
- package/src/otel-config.ts +67 -0
- package/src/otel.ts +100 -0
- package/src/outcome.ts +90 -0
- package/src/package.ts +8 -2
- package/src/platform.ts +1 -1
- package/src/prompt-layers.ts +90 -0
- package/src/provider-health.ts +2 -2
- package/src/providers.ts +7 -2
- package/src/run-container.ts +77 -8
- package/src/runner.ts +477 -79
- package/src/runs-filter.ts +88 -0
- package/src/scan.ts +153 -14
- package/src/starter.ts +27 -2
- package/src/step-exec.ts +168 -17
- package/src/store.ts +268 -9
- package/src/tokens.ts +68 -0
- package/src/tool-names.ts +1 -1
- package/src/tool-programs.ts +1 -1
- package/src/trace-tree.ts +187 -0
- package/src/translator.ts +13 -2
- package/src/trigger-log.ts +1 -0
- package/src/workspace-check.ts +41 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** What became of one trigger firing. `started` is here too: without it the
|
|
2
2
|
* log answers "why did nothing run" and not "how often does anything",
|
|
3
3
|
* and the ratio is the whole point of the page. */
|
|
4
|
-
export type TriggerOutcome = "started" | "duplicate" | "throttled" | "debounced" | "quarantined" | "paused" | "overlap-skipped" | "missed" | "failed";
|
|
4
|
+
export type TriggerOutcome = "started" | "duplicate" | "throttled" | "debounced" | "quarantined" | "paused" | "halted" | "overlap-skipped" | "missed" | "failed";
|
|
5
5
|
export interface TriggerEvent {
|
|
6
6
|
t: string;
|
|
7
7
|
flow: string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DeployFile } from "./store.ts";
|
|
2
|
+
import type { ScanFinding, ScanOptions } from "./scan.ts";
|
|
3
|
+
/** What the checks read of a workspace, at most. Data (state/ and memory/,
|
|
4
|
+
* what agents write) past these is left out and named; the rest past its
|
|
5
|
+
* budget is not read at all (TreeTooLarge). On 20 Oct 2026 the daily
|
|
6
|
+
* rescan read 1.2 GB of one desk's state/ into a 2 GB heap and crash-looped
|
|
7
|
+
* the worker. The rest is 64 MiB because a deploy is 10 MiB a push. */
|
|
8
|
+
export declare const CHECK_LIMITS: {
|
|
9
|
+
readonly maxDataFileBytes: number;
|
|
10
|
+
readonly maxDataBytes: number;
|
|
11
|
+
readonly maxDataFiles: 5000;
|
|
12
|
+
readonly maxSourceBytes: number;
|
|
13
|
+
};
|
|
14
|
+
/** A workspace as the checks read it: the deploy's tree within CHECK_LIMITS.
|
|
15
|
+
* Read once and hand it to everything that checks. */
|
|
16
|
+
export declare function checkTree(dir: string, onSkip?: (path: string, bytes: number) => void): DeployFile[];
|
|
17
|
+
export declare function checkOptions(tenant: string, workspace: string): ScanOptions;
|
|
18
|
+
export declare function checkWorkspace(tenant: string, workspace: string, tree?: DeployFile[]): ScanFinding[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// One workspace's findings, as the Checks page, `GET …/check` and the daily
|
|
2
|
+
// rescan see them (FOL-58): the deploy's checks and the whole scanner, with
|
|
3
|
+
// what the files alone do not carry — the account's network: and the
|
|
4
|
+
// outward tools of the library and the workspace, nearest wins.
|
|
5
|
+
import { accountDir, workspaceDir, workspaceTools } from "./store.js";
|
|
6
|
+
import { readAgentsMdKeys } from "./agents-md.js";
|
|
7
|
+
import { libraryTools } from "./library.js";
|
|
8
|
+
import { checkFiles, readTree } from "./deploy.js";
|
|
9
|
+
/** What the checks read of a workspace, at most. Data (state/ and memory/,
|
|
10
|
+
* what agents write) past these is left out and named; the rest past its
|
|
11
|
+
* budget is not read at all (TreeTooLarge). On 20 Oct 2026 the daily
|
|
12
|
+
* rescan read 1.2 GB of one desk's state/ into a 2 GB heap and crash-looped
|
|
13
|
+
* the worker. The rest is 64 MiB because a deploy is 10 MiB a push. */
|
|
14
|
+
export const CHECK_LIMITS = {
|
|
15
|
+
maxDataFileBytes: 5 * 1024 * 1024,
|
|
16
|
+
maxDataBytes: 32 * 1024 * 1024,
|
|
17
|
+
maxDataFiles: 5000,
|
|
18
|
+
maxSourceBytes: 64 * 1024 * 1024,
|
|
19
|
+
};
|
|
20
|
+
/** A workspace as the checks read it: the deploy's tree within CHECK_LIMITS.
|
|
21
|
+
* Read once and hand it to everything that checks. */
|
|
22
|
+
export function checkTree(dir, onSkip) {
|
|
23
|
+
return readTree(dir, { ...CHECK_LIMITS, onSkip });
|
|
24
|
+
}
|
|
25
|
+
export function checkOptions(tenant, workspace) {
|
|
26
|
+
const tools = { ...libraryTools(tenant), ...workspaceTools(tenant, workspace) };
|
|
27
|
+
const outwardTools = Object.entries(tools).filter(([, d]) => d.outward === true).map(([n]) => n);
|
|
28
|
+
// AGENTS.md, or the older project.md, as the runner reads it (Codex review).
|
|
29
|
+
const accountNetwork = readAgentsMdKeys(accountDir(tenant)).network;
|
|
30
|
+
return { outwardTools, accountNetwork };
|
|
31
|
+
}
|
|
32
|
+
export function checkWorkspace(tenant, workspace, tree = checkTree(workspaceDir(tenant, workspace))) {
|
|
33
|
+
return checkFiles(tree, checkOptions(tenant, workspace));
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foldrun/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Agents are just folders. The format, the runner, the checks.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"node": ">=22"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@anthropic-ai/claude-agent-sdk": "^0.3.
|
|
56
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.296",
|
|
57
57
|
"gray-matter": "^4.0.3",
|
|
58
58
|
"zod": "4.4.3"
|
|
59
59
|
},
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
|
|
7
|
+
import type { RunSummary } from "./store.ts";
|
|
8
|
+
|
|
9
|
+
/** A step that started counts; one that was skipped or never reached does not. */
|
|
10
|
+
const RAN = new Set(["running", "completed", "failed", "awaiting-approval"]);
|
|
11
|
+
|
|
12
|
+
/** Agent name → the ISO time of its latest step that ran. */
|
|
13
|
+
export function agentLastRuns(runs: RunSummary[]): Map<string, string> {
|
|
14
|
+
const last = new Map<string, string>();
|
|
15
|
+
for (const run of runs) {
|
|
16
|
+
for (const step of run.steps ?? []) {
|
|
17
|
+
if (!step.agent || !RAN.has(step.status)) continue;
|
|
18
|
+
const at = step.startedAt ?? run.startedAt;
|
|
19
|
+
if (!at) continue;
|
|
20
|
+
const prev = last.get(step.agent);
|
|
21
|
+
if (!prev || at > prev) last.set(step.agent, at);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return last;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whole days since `iso`, or null for never. */
|
|
28
|
+
export function daysSince(iso: string | null | undefined, now = Date.now()): number | null {
|
|
29
|
+
if (!iso) return null;
|
|
30
|
+
const t = Date.parse(iso);
|
|
31
|
+
return Number.isFinite(t) ? Math.floor((now - t) / 86_400_000) : null;
|
|
32
|
+
}
|
package/src/agent-import.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import fs from "node:fs";
|
|
17
17
|
import path from "node:path";
|
|
18
|
-
import
|
|
18
|
+
import { configMatter } from "./matter.ts";
|
|
19
19
|
import {
|
|
20
20
|
assertSafeName,
|
|
21
21
|
listWorkspaceFiles,
|
|
@@ -99,7 +99,7 @@ export function importAgent(
|
|
|
99
99
|
let content = readWorkspaceFile(tenant, from, src);
|
|
100
100
|
const rel = src.slice(`agents/${agent}/`.length);
|
|
101
101
|
if (rel === "agent.md") {
|
|
102
|
-
front =
|
|
102
|
+
front = configMatter(content).data as Record<string, unknown>;
|
|
103
103
|
// The folder is the name flows address; a `name:` that disagrees with
|
|
104
104
|
// it is the first thing check would complain about.
|
|
105
105
|
if (name !== agent) content = content.replace(/^(---\r?\n(?:[\s\S]*?\r?\n)?)name:[^\n]*$/m, `$1name: ${name}`);
|
package/src/agent-tools.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import fs from "node:fs";
|
|
18
18
|
import os from "node:os";
|
|
19
19
|
import path from "node:path";
|
|
20
|
-
import
|
|
20
|
+
import { configMatter } from "./matter.ts";
|
|
21
21
|
import { z } from "zod";
|
|
22
22
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
23
23
|
import { resolveModel, resolveEffort, type Effort } from "./store.ts";
|
|
@@ -51,7 +51,7 @@ export function gatherConsults(
|
|
|
51
51
|
missing.push(name);
|
|
52
52
|
continue;
|
|
53
53
|
}
|
|
54
|
-
const parsed =
|
|
54
|
+
const parsed = configMatter(fs.readFileSync(file, "utf8"));
|
|
55
55
|
consults.push({
|
|
56
56
|
name,
|
|
57
57
|
systemPrompt:
|
package/src/agents-md.ts
CHANGED
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
|
|
10
10
|
import fs from "node:fs";
|
|
11
11
|
import path from "node:path";
|
|
12
|
-
import matter from "
|
|
12
|
+
import matter, { configMatter } from "./matter.ts";
|
|
13
|
+
import { keySpan, dropEmptyBlock } from "./frontmatter-text.ts";
|
|
14
|
+
import { guardEdit } from "./frontmatter-guard.ts";
|
|
15
|
+
import { JOBS, FOLDABLE } from "./frontmatter-fields.ts";
|
|
16
|
+
|
|
17
|
+
export { keySpan };
|
|
13
18
|
|
|
14
19
|
const FRONT = /^(---\r?\n)([\s\S]*?)(\r?\n---)(\r?\n|$)/;
|
|
15
20
|
|
|
@@ -19,7 +24,7 @@ export function readAgentsMdKeys(dir: string): Record<string, unknown> {
|
|
|
19
24
|
const file = path.join(dir, name);
|
|
20
25
|
if (!fs.existsSync(file)) continue;
|
|
21
26
|
try {
|
|
22
|
-
return (
|
|
27
|
+
return (configMatter(fs.readFileSync(file, "utf8")).data ?? {}) as Record<string, unknown>;
|
|
23
28
|
} catch {
|
|
24
29
|
return {};
|
|
25
30
|
}
|
|
@@ -37,8 +42,31 @@ function dumpKey(key: string, value: unknown): string[] {
|
|
|
37
42
|
|
|
38
43
|
/** The text of `content` with each key in `patch` replaced (or removed for
|
|
39
44
|
* null), everything else byte for byte as it was. A file with no
|
|
40
|
-
* frontmatter gets one.
|
|
45
|
+
* frontmatter gets one. Read back before it is returned (frontmatter-guard
|
|
46
|
+
* .ts): an edit that would not parse, would change another key or would
|
|
47
|
+
* double one into its block throws UnsafeEditError (409). */
|
|
41
48
|
export function patchFrontmatter(content: string, patch: Record<string, unknown | null>): string {
|
|
49
|
+
// The guard reads the result folded (matter.ts configMatter), so a patch
|
|
50
|
+
// that writes a whole job block is checked as the keys it holds: each
|
|
51
|
+
// field the block may hold may change, and each one it sets reads back.
|
|
52
|
+
const changed = new Set<string>();
|
|
53
|
+
const expect: Record<string, unknown> = {};
|
|
54
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
55
|
+
changed.add(key);
|
|
56
|
+
const job = JOBS.find((j) => j.name === key)?.name;
|
|
57
|
+
if (!job) {
|
|
58
|
+
expect[key] = value;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
for (const k of FOLDABLE[job]) changed.add(k);
|
|
62
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
63
|
+
for (const [k, v] of Object.entries(value)) if (FOLDABLE[job].has(k)) expect[k] = v;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return guardEdit(content, patchFrontmatterText(content, patch), [...changed], expect);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function patchFrontmatterText(content: string, patch: Record<string, unknown | null>): string {
|
|
42
70
|
const m = content.match(FRONT);
|
|
43
71
|
let open = "---\n";
|
|
44
72
|
let body = "";
|
|
@@ -52,18 +80,15 @@ export function patchFrontmatter(content: string, patch: Record<string, unknown
|
|
|
52
80
|
}
|
|
53
81
|
let lines = body === "" ? [] : body.split("\n");
|
|
54
82
|
for (const [key, value] of Object.entries(patch)) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
83
|
+
// Where it is written now — at the top or inside its job block — is
|
|
84
|
+
// where it is edited; a key written nowhere is added at the top.
|
|
85
|
+
const where = keySpan(lines, key);
|
|
86
|
+
const replacement = value === null || value === undefined ? [] : dumpKey(key, value).map((l) => `${where?.indent ?? ""}${l}`);
|
|
87
|
+
if (!where) lines = [...lines, ...replacement];
|
|
88
|
+
else {
|
|
89
|
+
lines = [...lines.slice(0, where.at), ...replacement, ...lines.slice(where.end)];
|
|
90
|
+
if (!replacement.length && where.block !== null) lines = dropEmptyBlock(lines, where.block);
|
|
63
91
|
}
|
|
64
|
-
const replacement = value === null || value === undefined ? [] : dumpKey(key, value);
|
|
65
|
-
if (at === -1) lines = [...lines, ...replacement];
|
|
66
|
-
else lines = [...lines.slice(0, at), ...replacement, ...lines.slice(end)];
|
|
67
92
|
}
|
|
68
93
|
const front = lines.length ? `${open}${lines.join("\n")}${close}` : "";
|
|
69
94
|
if (!front) return rest.replace(/^(\r?\n)+/, "");
|
package/src/alerts.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
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
|
+
|
|
18
|
+
import type { Outcome } from "./outcome.ts";
|
|
19
|
+
|
|
20
|
+
export interface AlertConfig {
|
|
21
|
+
failureRate?: number;
|
|
22
|
+
minRuns: number;
|
|
23
|
+
creditsDay?: number;
|
|
24
|
+
runMinutes?: number;
|
|
25
|
+
staleDays?: number;
|
|
26
|
+
costSpike?: number;
|
|
27
|
+
runsPerHour?: number;
|
|
28
|
+
/** Told after a run (platform host-watch.ts), not on the watchdog's tick. */
|
|
29
|
+
newHost?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const KEYS = ["failure_rate", "min_runs", "credits_day", "run_minutes", "stale_days", "cost_spike", "runs_per_hour", "new_host"];
|
|
33
|
+
|
|
34
|
+
const num = (v: unknown): number | null => {
|
|
35
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
36
|
+
if (typeof v === "string" && v.trim() && Number.isFinite(Number(v.trim()))) return Number(v.trim());
|
|
37
|
+
return null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** The config, and what is wrong with it in words (for `foldrun check`). */
|
|
41
|
+
export function parseAlerts(raw: unknown): { config: AlertConfig | null; problems: string[] } {
|
|
42
|
+
if (raw === undefined || raw === null) return { config: null, problems: [] };
|
|
43
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
44
|
+
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"] };
|
|
45
|
+
}
|
|
46
|
+
const o = raw as Record<string, unknown>;
|
|
47
|
+
const problems: string[] = [];
|
|
48
|
+
for (const k of Object.keys(o)) if (!KEYS.includes(k)) problems.push(`alerts: ${k} — not a threshold; the keys are ${KEYS.join(", ")}`);
|
|
49
|
+
const cfg: AlertConfig = { minRuns: 5 };
|
|
50
|
+
if (o.failure_rate !== undefined) {
|
|
51
|
+
const s = String(o.failure_rate).trim();
|
|
52
|
+
const pct = /^(\d+(?:\.\d+)?)\s*%$/.exec(s);
|
|
53
|
+
const v = pct ? Number(pct[1]) / 100 : num(o.failure_rate);
|
|
54
|
+
if (v === null || v <= 0 || v > 1) problems.push(`alerts: failure_rate ${s} — a share of runs, like 30% or 0.3`);
|
|
55
|
+
else cfg.failureRate = v;
|
|
56
|
+
}
|
|
57
|
+
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]] as const) {
|
|
58
|
+
if (o[key] === undefined) continue;
|
|
59
|
+
const v = num(o[key]);
|
|
60
|
+
if (v === null || v < min) problems.push(`alerts: ${key} ${String(o[key])} — a number, at least ${min}`);
|
|
61
|
+
else (cfg as unknown as Record<string, number>)[field] = v;
|
|
62
|
+
}
|
|
63
|
+
if (o.new_host !== undefined) {
|
|
64
|
+
if (typeof o.new_host !== "boolean") problems.push(`alerts: new_host ${String(o.new_host)} — true or false`);
|
|
65
|
+
else if (o.new_host) cfg.newHost = true;
|
|
66
|
+
}
|
|
67
|
+
const any = cfg.newHost === true || cfg.failureRate !== undefined || cfg.creditsDay !== undefined || cfg.runMinutes !== undefined || cfg.staleDays !== undefined || cfg.costSpike !== undefined || cfg.runsPerHour !== undefined;
|
|
68
|
+
return { config: any ? cfg : null, problems };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface AlertFacts {
|
|
72
|
+
/** Outcomes of the runs started in the last 24 hours. */
|
|
73
|
+
outcomes: Outcome[];
|
|
74
|
+
/** Credits are cents: USD charged in the last 24 hours. */
|
|
75
|
+
chargedUsd: number;
|
|
76
|
+
/** Runs still going. */
|
|
77
|
+
live: { id: string; flow: string; startedAt: string }[];
|
|
78
|
+
/** For stale_days: each agent, when it last ran (null: not in the runs
|
|
79
|
+
* kept), and since when it has existed — an agent added yesterday is not
|
|
80
|
+
* stale for never having run. */
|
|
81
|
+
agents?: { name: string; lastRunAt: string | null; since: string }[];
|
|
82
|
+
/** For cost_spike and runs_per_hour: the runs of the last seven days,
|
|
83
|
+
* test runs left out. costUsd is what its steps cost. */
|
|
84
|
+
runs?: { id: string; flow: string; startedAt: string; finishedAt: string | null; costUsd: number }[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** How few earlier runs a flow needs before its average means anything. */
|
|
88
|
+
const SPIKE_MIN_RUNS = 3;
|
|
89
|
+
|
|
90
|
+
export interface Alert {
|
|
91
|
+
/** What the once-a-day memory is keyed by: the rule, or the rule and run. */
|
|
92
|
+
key: string;
|
|
93
|
+
headline: string;
|
|
94
|
+
detail: string;
|
|
95
|
+
runId?: string;
|
|
96
|
+
flow?: string;
|
|
97
|
+
/** Said once for this key, however long it stays true (a slow run, an
|
|
98
|
+
* idle spell); the watchdog's notice memory is pruned after a week, so
|
|
99
|
+
* a condition that lasts is said again weekly at most. */
|
|
100
|
+
once?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What crosses its threshold now. */
|
|
104
|
+
export function evaluateAlerts(cfg: AlertConfig, facts: AlertFacts, workspace: string, now: number): Alert[] {
|
|
105
|
+
const out: Alert[] = [];
|
|
106
|
+
if (cfg.failureRate !== undefined) {
|
|
107
|
+
const finished = facts.outcomes.filter((o) => o === "failed" || o === "good" || o === "done" || o === "quiet" || o === "blocked" || o === "bad");
|
|
108
|
+
const failed = finished.filter((o) => o === "failed").length;
|
|
109
|
+
if (finished.length >= cfg.minRuns && failed / finished.length >= cfg.failureRate) {
|
|
110
|
+
out.push({
|
|
111
|
+
key: "failure_rate",
|
|
112
|
+
headline: `⚠ ${workspace}: ${failed} of its last ${finished.length} runs failed`,
|
|
113
|
+
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.`,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (cfg.creditsDay !== undefined) {
|
|
118
|
+
const credits = Math.round(facts.chargedUsd * 100);
|
|
119
|
+
if (credits >= cfg.creditsDay) {
|
|
120
|
+
out.push({
|
|
121
|
+
key: "credits_day",
|
|
122
|
+
headline: `⚠ ${workspace} was charged ${credits.toLocaleString("en")} credits in a day`,
|
|
123
|
+
detail: `Over the ${cfg.creditsDay.toLocaleString("en")} in alerts: credits_day, in the last 24 hours. Observe shows which flows and agents.`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (cfg.runMinutes !== undefined) {
|
|
128
|
+
for (const r of facts.live) {
|
|
129
|
+
const mins = (now - Date.parse(r.startedAt)) / 60_000;
|
|
130
|
+
if (mins >= cfg.runMinutes) {
|
|
131
|
+
out.push({
|
|
132
|
+
key: `run_minutes/${r.id}`,
|
|
133
|
+
runId: r.id,
|
|
134
|
+
flow: r.flow,
|
|
135
|
+
headline: `⚠ ${workspace}: ${r.flow} has been running ${Math.round(mins)} minutes`,
|
|
136
|
+
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.`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (cfg.costSpike !== undefined && facts.runs) {
|
|
142
|
+
// A run that finished in the last day, against the runs of its flow that
|
|
143
|
+
// finished before it in the week: once per run.
|
|
144
|
+
const done = facts.runs.filter((r) => r.finishedAt).sort((a, b) => a.finishedAt!.localeCompare(b.finishedAt!));
|
|
145
|
+
for (const r of done) {
|
|
146
|
+
if (now - Date.parse(r.finishedAt!) > 86_400_000) continue;
|
|
147
|
+
const before = done.filter((o) => o.flow === r.flow && o.id !== r.id && o.finishedAt! < r.finishedAt!);
|
|
148
|
+
if (before.length < SPIKE_MIN_RUNS) continue;
|
|
149
|
+
const mean = before.reduce((n, o) => n + o.costUsd, 0) / before.length;
|
|
150
|
+
if (mean <= 0 || r.costUsd < cfg.costSpike * mean) continue;
|
|
151
|
+
out.push({
|
|
152
|
+
key: `cost_spike/${r.id}`,
|
|
153
|
+
runId: r.id,
|
|
154
|
+
flow: r.flow,
|
|
155
|
+
headline: `${workspace}: a ${r.flow} run cost ${(r.costUsd / mean).toFixed(1)}× its usual`,
|
|
156
|
+
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.`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (cfg.runsPerHour !== undefined && facts.runs) {
|
|
161
|
+
const hour = facts.runs.filter((r) => now - Date.parse(r.startedAt) <= 3_600_000);
|
|
162
|
+
const byFlow = new Map<string, number>();
|
|
163
|
+
for (const r of hour) byFlow.set(r.flow, (byFlow.get(r.flow) ?? 0) + 1);
|
|
164
|
+
for (const [flow, n] of byFlow) {
|
|
165
|
+
if (n < cfg.runsPerHour) continue;
|
|
166
|
+
out.push({
|
|
167
|
+
key: `runs_per_hour/${flow}`,
|
|
168
|
+
flow,
|
|
169
|
+
headline: `${workspace}: ${flow} started ${n} times in an hour`,
|
|
170
|
+
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.`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (cfg.staleDays !== undefined) {
|
|
175
|
+
for (const a of facts.agents ?? []) {
|
|
176
|
+
const from = Date.parse(a.lastRunAt ?? a.since);
|
|
177
|
+
if (!Number.isFinite(from)) continue;
|
|
178
|
+
const days = Math.floor((now - from) / 86_400_000);
|
|
179
|
+
if (days < cfg.staleDays) continue;
|
|
180
|
+
out.push({
|
|
181
|
+
// Keyed by the run it last made, so a run and a new idle spell are news again.
|
|
182
|
+
key: `stale_days/${a.name}/${a.lastRunAt ?? "never"}`,
|
|
183
|
+
once: true,
|
|
184
|
+
headline: `${workspace}: agent ${a.name} has not run in ${days} days`,
|
|
185
|
+
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.`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
package/src/browser-pod.ts
CHANGED
|
@@ -159,6 +159,9 @@ export function classifyCall(tool: string, input: Record<string, unknown> | unde
|
|
|
159
159
|
const [, server, name] = mcp;
|
|
160
160
|
if (READ_SERVERS.has(server)) return { write: false };
|
|
161
161
|
if (server === "foldrun_ask") return write("a question to a person");
|
|
162
|
+
// tools: [peers] — reading the roster changes nothing; a message to a
|
|
163
|
+
// colleague is said, and a re-run would say it twice.
|
|
164
|
+
if (server === "foldrun_peers") return name === "peers_list" ? { write: false } : write("a message to a colleague");
|
|
162
165
|
if (server === "foldrun_scripts") {
|
|
163
166
|
if (name !== "web") return write(`script ${name}`);
|
|
164
167
|
const action = String(args.action ?? "").trim().toLowerCase();
|
package/src/capture.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
|
|
8
|
+
import { redactText } from "./scan.ts";
|
|
9
|
+
import { writeCapturedCall, CAPTURE_CAP } from "./store.ts";
|
|
10
|
+
import { isFileValue, fileContent } from "./secrets.ts";
|
|
11
|
+
import type { CallIo } from "./step-exec.ts";
|
|
12
|
+
|
|
13
|
+
/** How much past the cap a step sends, so masking sees a secret whole
|
|
14
|
+
* before the cut (a value longer than this is a file secret, which is also
|
|
15
|
+
* masked line by line). */
|
|
16
|
+
export const CAPTURE_SLACK = 4096;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What the step's scrub replaces: every secret value it holds → its name,
|
|
20
|
+
* longest first. An `@file` secret (a key, a certificate) by its content, not
|
|
21
|
+
* its stored form; a multi-line value also line by line, because a file is
|
|
22
|
+
* read back a line at a time (the Read tool numbers each); and every value
|
|
23
|
+
* also as it appears inside JSON, where `"` `\` and newlines are escaped — a
|
|
24
|
+
* tool's input is JSON. Values under 8 characters are skipped: a two-letter
|
|
25
|
+
* secret would redact half the language, and anything that short is not one.
|
|
26
|
+
*/
|
|
27
|
+
export function redactionsFor(secrets: Record<string, string | undefined>): [string, string][] {
|
|
28
|
+
const out = new Map<string, string>();
|
|
29
|
+
const add = (v: string, name: string) => {
|
|
30
|
+
if (v.length >= 8 && !out.has(v)) out.set(v, name);
|
|
31
|
+
};
|
|
32
|
+
for (const [name, raw] of Object.entries(secrets)) {
|
|
33
|
+
if (typeof raw !== "string") continue;
|
|
34
|
+
for (const v of isFileValue(raw) ? [raw, fileContent(raw)] : [raw]) {
|
|
35
|
+
add(v, name);
|
|
36
|
+
add(v.trim(), name);
|
|
37
|
+
const escaped = JSON.stringify(v).slice(1, -1);
|
|
38
|
+
if (escaped !== v) add(escaped, name);
|
|
39
|
+
if (/\r?\n/.test(v.trim())) {
|
|
40
|
+
for (const line of v.split(/\r?\n/)) {
|
|
41
|
+
const l = line.trim();
|
|
42
|
+
if (l.length >= 16) add(l, name);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return [...out.entries()].sort((a, b) => b[0].length - a[0].length);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A private key's whole block — scan.ts names a key header and leaves the
|
|
51
|
+
// body, which is right for a finding and wrong for a stored output.
|
|
52
|
+
// Unterminated too: a key cut short, or printed without its END line, is
|
|
53
|
+
// masked to the end of the text.
|
|
54
|
+
const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$(?![\s\S]))/g;
|
|
55
|
+
|
|
56
|
+
/** Masked, then cut to the cap. */
|
|
57
|
+
export function maskCall(text: string, redactSecrets: (text: string) => string): { text: string; cut: boolean } {
|
|
58
|
+
const masked = redactText(redactSecrets(text)).replace(PRIVATE_KEY, "[redacted:private key]");
|
|
59
|
+
return masked.length > CAPTURE_CAP ? { text: masked.slice(0, CAPTURE_CAP), cut: true } : { text: masked, cut: false };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function recordCallIo(
|
|
63
|
+
workspaceRoot: string,
|
|
64
|
+
runId: string,
|
|
65
|
+
callId: string,
|
|
66
|
+
tool: string,
|
|
67
|
+
io: CallIo,
|
|
68
|
+
/** The step's own scrub: its resolved secret values → `[redacted:NAME]`. */
|
|
69
|
+
redactSecrets: (text: string) => string,
|
|
70
|
+
): boolean {
|
|
71
|
+
try {
|
|
72
|
+
const input = io.input === undefined ? null : maskCall(io.input, redactSecrets);
|
|
73
|
+
const output = io.output === undefined ? null : maskCall(io.output, redactSecrets);
|
|
74
|
+
writeCapturedCall(workspaceRoot, runId, {
|
|
75
|
+
id: callId,
|
|
76
|
+
tool,
|
|
77
|
+
// Which part was cut, each on its own: in the sandbox (the whole was
|
|
78
|
+
// longer than what was sent) or here, after masking.
|
|
79
|
+
...(input ? { input: input.text, ...(io.inputChars !== undefined ? { inputChars: io.inputChars } : {}), ...(input.cut || (io.inputChars ?? 0) > io.input!.length ? { inputCut: true } : {}) } : {}),
|
|
80
|
+
...(output ? { output: output.text, ...(io.outputChars !== undefined ? { outputChars: io.outputChars } : {}), ...(output.cut || (io.outputChars ?? 0) > io.output!.length ? { outputCut: true } : {}) } : {}),
|
|
81
|
+
...(io.truncated || input?.cut || output?.cut ? { truncated: true } : {}),
|
|
82
|
+
});
|
|
83
|
+
return true;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error(`[capture] ${tool} (${callId}) for ${runId}:`, err instanceof Error ? err.message : err);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|