@foldrun/core 0.6.0 → 0.7.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/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/agent-import.d.ts +21 -0
- package/dist/src/agent-import.js +116 -0
- package/dist/src/api-tools.js +14 -2
- package/dist/src/approvals.d.ts +8 -1
- package/dist/src/approvals.js +10 -5
- package/dist/src/completions.js +1 -0
- package/dist/src/confine.d.ts +1 -0
- package/dist/src/confine.js +33 -4
- package/dist/src/deploy.d.ts +9 -0
- package/dist/src/deploy.js +31 -3
- package/dist/src/evals.js +22 -5
- package/dist/src/host-env.d.ts +14 -0
- package/dist/src/host-env.js +26 -0
- package/dist/src/install-helper.d.ts +17 -0
- package/dist/src/install-helper.js +134 -0
- package/dist/src/install-tool.d.ts +128 -0
- package/dist/src/install-tool.js +133 -0
- package/dist/src/model-credential.d.ts +16 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/model-env.d.ts +17 -0
- package/dist/src/model-env.js +57 -0
- package/dist/src/package.d.ts +67 -0
- package/dist/src/package.js +594 -0
- package/dist/src/platform.d.ts +5 -0
- package/dist/src/platform.js +1 -0
- package/dist/src/provider-health.js +3 -0
- package/dist/src/run-container.d.ts +5 -0
- package/dist/src/run-container.js +66 -13
- package/dist/src/runner.d.ts +204 -0
- package/dist/src/runner.js +257 -36
- package/dist/src/runtime.d.ts +77 -0
- package/dist/src/runtime.js +230 -5
- package/dist/src/script-tools.d.ts +2 -0
- package/dist/src/script-tools.js +3 -1
- package/dist/src/secrets.d.ts +5 -0
- package/dist/src/secrets.js +37 -6
- package/dist/src/starter.d.ts +12 -0
- package/dist/src/starter.js +36 -4
- package/dist/src/step-exec.d.ts +11 -2
- package/dist/src/step-exec.js +52 -5
- package/dist/src/storage.d.ts +7 -1
- package/dist/src/storage.js +14 -1
- package/dist/src/store.d.ts +20 -0
- package/dist/src/store.js +89 -8
- package/dist/src/tool-names.d.ts +3 -1
- package/dist/src/tool-names.js +4 -2
- package/dist/src/tool-test.js +8 -1
- package/dist/src/zip.d.ts +17 -0
- package/dist/src/zip.js +184 -0
- package/index.ts +3 -0
- package/package.json +1 -1
- package/src/agent-import.ts +154 -0
- package/src/api-tools.ts +16 -1
- package/src/approvals.ts +10 -3
- package/src/completions.ts +1 -0
- package/src/confine.ts +36 -5
- package/src/deploy.ts +28 -3
- package/src/evals.ts +21 -3
- package/src/host-env.ts +27 -0
- package/src/install-helper.ts +143 -0
- package/src/install-tool.ts +154 -0
- package/src/model-credential.ts +41 -0
- package/src/model-env.ts +66 -0
- package/src/package.ts +664 -0
- package/src/platform.ts +6 -0
- package/src/provider-health.ts +3 -0
- package/src/run-container.ts +65 -12
- package/src/runner.ts +264 -36
- package/src/runtime.ts +235 -5
- package/src/script-tools.ts +5 -1
- package/src/secrets.ts +34 -6
- package/src/starter.ts +42 -4
- package/src/step-exec.ts +56 -8
- package/src/storage.ts +13 -0
- package/src/store.ts +85 -7
- package/src/tool-names.ts +4 -2
- package/src/tool-test.ts +8 -1
- package/src/zip.ts +189 -0
package/src/runtime.ts
CHANGED
|
@@ -33,6 +33,12 @@ export interface RuntimeSpec {
|
|
|
33
33
|
packages: string[]; // pip
|
|
34
34
|
node?: string | boolean;
|
|
35
35
|
npm: string[];
|
|
36
|
+
/** Debian packages the step needs from the operating system — a library's
|
|
37
|
+
* headers for a pip package that compiles (`libcairo2-dev`), a C compiler
|
|
38
|
+
* (`build-essential`), a program (`ffmpeg`). Installed with apt by the run
|
|
39
|
+
* pod's root start-up, before anything model-directed runs, from a .deb
|
|
40
|
+
* cache on the runtime volume (SYSTEM_INSTALL_SH); verified here. */
|
|
41
|
+
system?: string[];
|
|
36
42
|
/** Declared entries that are not valid requirements. Kept so the run can
|
|
37
43
|
* SAY they were dropped: silently discarding `pandas>=2` and then failing
|
|
38
44
|
* the script with "no module named pandas" is the least debuggable outcome
|
|
@@ -61,11 +67,17 @@ export interface RuntimeSpec {
|
|
|
61
67
|
*/
|
|
62
68
|
const PIP_NAME = String.raw`[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9._,-]+\])?`;
|
|
63
69
|
const PIP_SPECIFIER = String.raw`(==|!=|<=|>=|~=|<|>)[A-Za-z0-9._*+!-]+`;
|
|
64
|
-
const PIP_REQUIREMENT = new RegExp(`^${PIP_NAME}(${PIP_SPECIFIER}(,${PIP_SPECIFIER})*)?$`);
|
|
70
|
+
export const PIP_REQUIREMENT = new RegExp(`^${PIP_NAME}(${PIP_SPECIFIER}(,${PIP_SPECIFIER})*)?$`);
|
|
65
71
|
/** npm's own shape: an optional @scope, then a name, then an optional @range. */
|
|
66
|
-
const NPM_REQUIREMENT =
|
|
72
|
+
export const NPM_REQUIREMENT =
|
|
67
73
|
/^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(@[A-Za-z0-9._^~*>=< |-]+)?$/;
|
|
68
74
|
|
|
75
|
+
/** A Debian package name as policy defines it: lowercase letters, digits,
|
|
76
|
+
* `+`, `-` and `.`, starting with a letter or digit, two or more characters.
|
|
77
|
+
* Like the two above, a guard first: it can never begin with a dash, so it
|
|
78
|
+
* can never become an apt option, and it holds no shell characters. */
|
|
79
|
+
export const DEB_PACKAGE = /^[a-z0-9][a-z0-9+.-]{1,62}$/;
|
|
80
|
+
|
|
69
81
|
export function parseRuntime(raw: unknown): RuntimeSpec | null {
|
|
70
82
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
71
83
|
const e = raw as Record<string, unknown>;
|
|
@@ -85,9 +97,11 @@ export function parseRuntime(raw: unknown): RuntimeSpec | null {
|
|
|
85
97
|
node: typeof e.node === "string" || typeof e.node === "boolean" ? e.node : undefined,
|
|
86
98
|
npm: list(e.npm, NPM_REQUIREMENT),
|
|
87
99
|
};
|
|
100
|
+
const system = list(e.system ?? e.apt, DEB_PACKAGE);
|
|
101
|
+
if (system.length) spec.system = system;
|
|
88
102
|
if (rejected.length) spec.rejected = rejected;
|
|
89
103
|
if (Array.isArray(e.ignored) && e.ignored.length) spec.ignored = e.ignored.map(String);
|
|
90
|
-
return wantsPython(spec) || wantsNode(spec) ? spec : null;
|
|
104
|
+
return wantsPython(spec) || wantsNode(spec) || wantsSystem(spec) ? spec : null;
|
|
91
105
|
}
|
|
92
106
|
|
|
93
107
|
/**
|
|
@@ -117,6 +131,8 @@ export function mergeRuntimes(...specs: (RuntimeSpec | null | undefined)[]): Run
|
|
|
117
131
|
node: pick("node"),
|
|
118
132
|
npm: uniq(present.flatMap((s) => s.npm)),
|
|
119
133
|
};
|
|
134
|
+
const system = uniq(present.flatMap((s) => s.system ?? []));
|
|
135
|
+
if (system.length) merged.system = system;
|
|
120
136
|
const rejected = uniq(present.flatMap((s) => s.rejected ?? []));
|
|
121
137
|
if (rejected.length) merged.rejected = rejected;
|
|
122
138
|
const ignored = uniq(present.flatMap((s) => s.ignored ?? []));
|
|
@@ -130,6 +146,11 @@ export function fingerprint(spec: RuntimeSpec): string {
|
|
|
130
146
|
packages: [...spec.packages].sort(),
|
|
131
147
|
node: spec.node ?? null,
|
|
132
148
|
npm: [...spec.npm].sort(),
|
|
149
|
+
// Only when declared: adding the key unconditionally would give every
|
|
150
|
+
// environment already cached a new fingerprint and rebuild them all.
|
|
151
|
+
// Part of it when declared, because a venv whose packages compiled
|
|
152
|
+
// against these libraries is not the venv built without them.
|
|
153
|
+
...(spec.system?.length ? { system: [...spec.system].sort() } : {}),
|
|
133
154
|
});
|
|
134
155
|
return crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
135
156
|
}
|
|
@@ -244,6 +265,7 @@ function wire(root: string, spec: RuntimeSpec, note: string): PreparedRuntime {
|
|
|
244
265
|
|
|
245
266
|
// `python: false` says "not this one" — it used to read as a declaration
|
|
246
267
|
// because it was not undefined, and built a venv nobody asked for.
|
|
268
|
+
const wantsSystem = (s: RuntimeSpec) => (s.system?.length ?? 0) > 0;
|
|
247
269
|
const wantsPython = (s: RuntimeSpec) => (s.python !== undefined && s.python !== false) || s.packages.length > 0;
|
|
248
270
|
const wantsNode = (s: RuntimeSpec) => (s.node !== undefined && s.node !== false) || s.npm.length > 0;
|
|
249
271
|
|
|
@@ -556,8 +578,193 @@ function findUv(): string | null {
|
|
|
556
578
|
}
|
|
557
579
|
|
|
558
580
|
// Build (or reuse) the environment for one runtime declaration.
|
|
581
|
+
/**
|
|
582
|
+
* An installer's failure, said so that the first line is the cause.
|
|
583
|
+
*
|
|
584
|
+
* Until 11 Oct 2026 a failure was the installer's last 500 characters, and
|
|
585
|
+
* the run log shows a message's first line: a pycairo build that died for
|
|
586
|
+
* want of a C compiler read, in full, `uv pip install failed: files.` — the
|
|
587
|
+
* tail end of a setuptools deprecation notice. The cause was on the last
|
|
588
|
+
* line of 4 KB nobody saw. So: find the cause and the package it hit, say
|
|
589
|
+
* the fix when it is one we know, then the last lines of the log for the rest.
|
|
590
|
+
*/
|
|
591
|
+
export function summarizeInstallFailure(what: string, out: string): string {
|
|
592
|
+
const text = out ?? "";
|
|
593
|
+
const lines = text.split(/\r?\n/).map((l) => l.replace(/\s+$/, "")).filter(Boolean);
|
|
594
|
+
const pkg =
|
|
595
|
+
/Failed to build `([^`]+)`/.exec(text)?.[1] ??
|
|
596
|
+
/Failed building wheel for ([\w.-]+)/.exec(text)?.[1] ??
|
|
597
|
+
/Building wheel for ([\w.-]+) \(.*\) did not run successfully/.exec(text)?.[1] ??
|
|
598
|
+
/Failed to (?:download|prepare|fetch) `([^`]+)`/.exec(text)?.[1] ??
|
|
599
|
+
null;
|
|
600
|
+
const subject = pkg ? `\`${pkg}\`` : "a package";
|
|
601
|
+
let cause: string | null = null;
|
|
602
|
+
let hint = "";
|
|
603
|
+
const header = /fatal error: ([\w./+-]+\.h): No such file/.exec(text);
|
|
604
|
+
const pc = /Package '?([\w.+-]+)'?,? (?:required by [^,]+, )?(?:was )?not found|Dependency "([\w.+-]+)" not found/.exec(text);
|
|
605
|
+
if (/No such file or directory: '[^']*gcc'|unable to execute '[^']*gcc'|\bgcc: (?:command )?not found|command '[^']*gcc' failed|error: command '[^']*\b(?:cc|gcc|c\+\+|g\+\+)' failed|C compiler cannot create executables|no acceptable C compiler/i.test(text)) {
|
|
606
|
+
cause = `${subject} has to be compiled and this runner has no C compiler`;
|
|
607
|
+
hint = " — add `system: [build-essential]` (and the -dev package of any library it builds against) to the runtime, or pin a version that ships a prebuilt wheel";
|
|
608
|
+
} else if (/pkg-config(?:: command)? not found|Did not find pkg-config|pkg-config.*No such file/i.test(text)) {
|
|
609
|
+
cause = `${subject} looks for its libraries with pkg-config, which this runner lacks`;
|
|
610
|
+
hint = " — add `system: [pkg-config]` and the library's -dev package to the runtime";
|
|
611
|
+
} else if (header && /^Python\.h$/.test(header[1])) {
|
|
612
|
+
cause = `${subject} builds against Python's own C headers, which this runner lacks`;
|
|
613
|
+
hint = " — add `system: [python3-dev, build-essential]` to the runtime";
|
|
614
|
+
} else if (pc || header) {
|
|
615
|
+
const lib = pc?.[1] ?? pc?.[2] ?? header![1];
|
|
616
|
+
cause = `${subject} needs the system library ${lib} (its headers) to build`;
|
|
617
|
+
hint = ` — add its Debian -dev package to \`runtime: system:\`${pc ? ` (for ${lib}, usually \`lib${lib.replace(/^lib/, "")}-dev\`)` : ""}, plus build-essential and pkg-config`;
|
|
618
|
+
} else if (/E: Unable to locate package (\S+)/.test(text)) {
|
|
619
|
+
cause = `apt has no package \`${/E: Unable to locate package (\S+)/.exec(text)![1]}\``;
|
|
620
|
+
hint = " — check the name on packages.debian.org for trixie";
|
|
621
|
+
} else if (/No solution found when resolving dependencies/.test(text)) {
|
|
622
|
+
const i = lines.findIndex((l) => /No solution found/.test(l));
|
|
623
|
+
cause = `the requirements cannot all be satisfied: ${lines.slice(i + 1, i + 3).map((l) => l.replace(/^[\s╰─▶×]+/, "")).join(" ")}`.trim();
|
|
624
|
+
} else if (/No matching distribution found for (\S+)|Could not find a version that satisfies the requirement (\S+)|Because (\S+) was not found in the package registry/.test(text)) {
|
|
625
|
+
const m = /No matching distribution found for (\S+)|Could not find a version that satisfies the requirement (\S+)|Because (\S+) was not found in the package registry/.exec(text)!;
|
|
626
|
+
cause = `no published version of \`${m[1] ?? m[2] ?? m[3]}\` matches`;
|
|
627
|
+
} else if (/npm (?:ERR!|error) code (\S+)/.test(text)) {
|
|
628
|
+
const code = /npm (?:ERR!|error) code (\S+)/.exec(text)![1];
|
|
629
|
+
const detail = lines.find((l) => /npm (?:ERR!|error) (?:404|notarget|E\w+)\b/.test(l) && !/ code /.test(l));
|
|
630
|
+
cause = `npm ${code}${detail ? `: ${detail.replace(/^npm (?:ERR!|error)\s*/, "")}` : ""}`;
|
|
631
|
+
}
|
|
632
|
+
if (!cause) {
|
|
633
|
+
const last = [...lines].reverse().find((l) => /error/i.test(l)) ?? lines[lines.length - 1] ?? "no output";
|
|
634
|
+
cause = (pkg ? `${subject}: ` : "") + last.replace(/^\s*(?:error:|ERROR:|×)\s*/, "");
|
|
635
|
+
}
|
|
636
|
+
const excerpt = lines.slice(-25).join("\n");
|
|
637
|
+
return `${what} failed — ${cause}${hint}\n${excerpt.length > 2000 ? excerpt.slice(-2000) : excerpt}`;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Sets $FOLDRUN_SYSTEM_PACKAGES from the step's input.json (its merged
|
|
641
|
+
* runtime spec) — the run pod's and the docker entry's first line. */
|
|
642
|
+
export const SYSTEM_FROM_INPUT_SH = `FOLDRUN_SYSTEM_PACKAGES="$(node -e 'try{const r=JSON.parse(require("fs").readFileSync("/opt/runner/job/input.json","utf8")).runtime;process.stdout.write(((r&&r.system)||[]).join(" "))}catch(e){}')"`;
|
|
643
|
+
|
|
644
|
+
/** Where a run container's root start-up records the system install. */
|
|
645
|
+
export const SYSTEM_STATUS_FILE = "/opt/runner/job/system-install.json";
|
|
646
|
+
export const SYSTEM_LOG_FILE = "/opt/runner/job/system-install.log";
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* The root start-up's half of `runtime: system:` — one shell snippet, used by
|
|
650
|
+
* the k8s run pod, the docker entry and the deploy-time warm pod alike.
|
|
651
|
+
* Reads the package names from $FOLDRUN_SYSTEM_PACKAGES (space-separated,
|
|
652
|
+
* each re-checked against DEB_PACKAGE's shape here too, so nothing else can
|
|
653
|
+
* ride in), installs them with apt as root BEFORE the drop to the agent user,
|
|
654
|
+
* and writes SYSTEM_STATUS_FILE for prepareRuntime to read.
|
|
655
|
+
*
|
|
656
|
+
* The first step to need a package set installs it with apt (the .debs kept
|
|
657
|
+
* in `<cache>/.apt/archives`) and then saves the files those packages added,
|
|
658
|
+
* with their dpkg records, as a snapshot: `<cache>/.apt/snapshots/<key>.tar`.
|
|
659
|
+
* Every later step restores the snapshot instead — measured 11 Oct 2026 in a
|
|
660
|
+
* gVisor run pod on the NFS cache: build-essential, pkg-config, python3-dev,
|
|
661
|
+
* libcairo2-dev and libpango1.0-dev (154 packages, 432 MB) took 23-30 s
|
|
662
|
+
* through apt and 0.76 s from the snapshot, gcc and pkg-config working after.
|
|
663
|
+
* The key is the package set AND a checksum of the image's own dpkg status,
|
|
664
|
+
* so a new runner image never restores files built against the old one. A
|
|
665
|
+
* snapshot is written under a temporary name and renamed, so two pods
|
|
666
|
+
* building it at once cannot leave a torn one; a restore that fails falls
|
|
667
|
+
* back to apt, and prepareRuntime verifies the result either way. Unused for
|
|
668
|
+
* 30 days, one is pruned. Package lists stay per pod: two pods updating one
|
|
669
|
+
* lists directory on a shared volume would corrupt it.
|
|
670
|
+
*/
|
|
671
|
+
export function systemInstallSh(cacheDir: string, logDir = "/opt/runner/job"): string {
|
|
672
|
+
return `if [ -n "$FOLDRUN_SYSTEM_PACKAGES" ]; then
|
|
673
|
+
SYS=""; for p in $FOLDRUN_SYSTEM_PACKAGES; do case "$p" in -*|*[!a-z0-9+.-]*) ;; *) SYS="$SYS $p";; esac; done
|
|
674
|
+
D=\${FOLDRUN_DPKG_DIR:-/var/lib/dpkg}; R=\${FOLDRUN_SYS_ROOT:-/}
|
|
675
|
+
C=${cacheDir}/.apt; A=$C/archives; S=$C/snapshots
|
|
676
|
+
mkdir -p "$A/partial" "$S" 2>/dev/null || { A=/var/cache/apt/archives; S=""; }
|
|
677
|
+
KEY=v3-$( { printf '%s\\n' $SYS | sort -u; cksum < "$D/status"; } | cksum | tr ' ' '-')
|
|
678
|
+
T0=$(date +%s); MODE=apt; RC=1
|
|
679
|
+
if [ -n "$S" ] && [ -f "$S/$KEY.tar" ] && tar -xf "$S/$KEY.tar" -C "$R" > ${logDir}/system-install.log 2>&1; then
|
|
680
|
+
MODE=snapshot; RC=0; touch "$S/$KEY.tar" 2>/dev/null
|
|
681
|
+
# apt runs ldconfig through dpkg's trigger; a restore must too, or a
|
|
682
|
+
# library the snapshot brought is missing from /etc/ld.so.cache.
|
|
683
|
+
[ "$R" = / ] && ldconfig 2>/dev/null
|
|
684
|
+
else
|
|
685
|
+
ls "$D"/info/*.list 2>/dev/null | sort > /tmp/.sys-before
|
|
686
|
+
touch /tmp/.sys-mark; sleep 1
|
|
687
|
+
{ apt-get -o Acquire::Retries=3 -qq update && DEBIAN_FRONTEND=noninteractive apt-get -o Dir::Cache::Archives="$A" -o Acquire::Retries=3 -o Dpkg::Use-Pty=0 install -y -qq --no-install-recommends $SYS; } > ${logDir}/system-install.log 2>&1
|
|
688
|
+
RC=$?
|
|
689
|
+
if [ $RC -eq 0 ] && [ -n "$S" ]; then
|
|
690
|
+
ls "$D"/info/*.list 2>/dev/null | sort > /tmp/.sys-after
|
|
691
|
+
comm -13 /tmp/.sys-before /tmp/.sys-after > /tmp/.sys-new
|
|
692
|
+
if [ -s /tmp/.sys-new ]; then
|
|
693
|
+
{ cat $(cat /tmp/.sys-new) | sort -u | while read -r f; do if [ -f "$f" ] || [ -L "$f" ]; then echo "$f"; fi; done
|
|
694
|
+
for l in $(cat /tmp/.sys-new); do ls "\${l%.list}".* 2>/dev/null; done
|
|
695
|
+
echo "$D/status"
|
|
696
|
+
# What the packages' install scripts made, which dpkg's lists do not
|
|
697
|
+
# name: update-alternatives links (libblas.so.3 → /etc/alternatives),
|
|
698
|
+
# generated caches and configs. By ctime, not mtime: those links carry
|
|
699
|
+
# the package's own (2025) mtime, and only ctime cannot be backdated.
|
|
700
|
+
find \${FOLDRUN_SYS_SCAN:-/etc /usr /var/lib} -xdev \\( -path /var/lib/apt -o -path /var/lib/dpkg/info \\) -prune -o \\( -type f -o -type l \\) -cnewer /tmp/.sys-mark -print 2>/dev/null
|
|
701
|
+
} | sort -u > /tmp/.sys-files
|
|
702
|
+
tar -cf "$S/.$KEY.$$.tar" -T /tmp/.sys-files 2>/dev/null && mv -f "$S/.$KEY.$$.tar" "$S/$KEY.tar" 2>/dev/null
|
|
703
|
+
rm -f "$S/.$KEY.$$.tar" 2>/dev/null
|
|
704
|
+
fi
|
|
705
|
+
find "$S" -name '*.tar' -mtime +30 -delete 2>/dev/null
|
|
706
|
+
fi
|
|
707
|
+
fi
|
|
708
|
+
printf '{"ok": %s, "exit": %s, "seconds": %s, "mode": "%s", "packages": "%s"}\\n' "$([ $RC -eq 0 ] && echo true || echo false)" "$RC" "$(( $(date +%s) - T0 ))" "$MODE" "$SYS" > ${logDir}/system-install.json
|
|
709
|
+
rm -rf /var/lib/apt/lists/* 2>/dev/null || true
|
|
710
|
+
fi`;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Are the declared system packages on this machine? The install happened in
|
|
715
|
+
* the container's root start-up (systemInstallSh); this only reads the
|
|
716
|
+
* result. Without dpkg (a laptop, macOS) there is nothing to check against
|
|
717
|
+
* and nothing this process could install: say so and carry on.
|
|
718
|
+
*/
|
|
719
|
+
export function checkSystemPackages(pkgs: string[]): { log: string[]; error: string | null } {
|
|
720
|
+
if (!pkgs.length) return { log: [], error: null };
|
|
721
|
+
const statusFile = process.env.FOLDRUN_SYSTEM_STATUS ?? SYSTEM_STATUS_FILE;
|
|
722
|
+
const logFile = process.env.FOLDRUN_SYSTEM_LOG ?? SYSTEM_LOG_FILE;
|
|
723
|
+
let status: { ok?: boolean; seconds?: number; mode?: string } | null = null;
|
|
724
|
+
try {
|
|
725
|
+
status = JSON.parse(fs.readFileSync(statusFile, "utf8"));
|
|
726
|
+
} catch {
|
|
727
|
+
status = null;
|
|
728
|
+
}
|
|
729
|
+
const q = spawnSync("dpkg-query", ["-W", "-f=${Package} ${db:Status-Status}\n", ...pkgs], { encoding: "utf8" });
|
|
730
|
+
if (q.error) {
|
|
731
|
+
return { log: [`system packages ${pkgs.join(", ")} install only in a run container; on this machine they are assumed present`], error: null };
|
|
732
|
+
}
|
|
733
|
+
const installed = new Set(
|
|
734
|
+
(q.stdout ?? "")
|
|
735
|
+
.split("\n")
|
|
736
|
+
.map((l) => l.trim().split(/\s+/))
|
|
737
|
+
.filter((f) => f[1] === "installed")
|
|
738
|
+
.map((f) => f[0].replace(/:.*$/, "")),
|
|
739
|
+
);
|
|
740
|
+
const missing = pkgs.filter((p) => !installed.has(p));
|
|
741
|
+
if (!missing.length) {
|
|
742
|
+
return { log: [`system packages ready: ${pkgs.join(", ")}${status?.seconds !== undefined ? ` (${status.mode === "snapshot" ? "snapshot" : "apt"}, ${status.seconds}s)` : ""}`], error: null };
|
|
743
|
+
}
|
|
744
|
+
let aptOut = "";
|
|
745
|
+
try {
|
|
746
|
+
aptOut = fs.readFileSync(logFile, "utf8");
|
|
747
|
+
} catch {
|
|
748
|
+
aptOut = "";
|
|
749
|
+
}
|
|
750
|
+
const reason = !status
|
|
751
|
+
? "this runner did not install them — system packages are installed by a run container's start-up (k8s or docker isolation), not on a bare host"
|
|
752
|
+
: status.ok
|
|
753
|
+
? "apt reported success, yet they are not installed"
|
|
754
|
+
: summarizeInstallFailure("apt-get install", aptOut).replace(/^apt-get install failed — /, "apt: ");
|
|
755
|
+
return { log: [], error: `system packages not installed: ${missing.join(", ")} — ${reason}` };
|
|
756
|
+
}
|
|
757
|
+
|
|
559
758
|
export function prepareRuntime(tenant: string, spec: RuntimeSpec | null): PreparedRuntime {
|
|
560
759
|
if (!spec) return EMPTY;
|
|
760
|
+
const system = checkSystemPackages(spec.system ?? []);
|
|
761
|
+
if (system.error) return { ...EMPTY, log: system.log, error: system.error };
|
|
762
|
+
if (!wantsPython(spec) && !wantsNode(spec)) return { ...EMPTY, log: system.log };
|
|
763
|
+
const built = prepareLanguageRuntime(tenant, spec);
|
|
764
|
+
return system.log.length ? { ...built, log: [...system.log, ...built.log] } : built;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function prepareLanguageRuntime(tenant: string, spec: RuntimeSpec): PreparedRuntime {
|
|
561
768
|
|
|
562
769
|
const fp = fingerprint(spec);
|
|
563
770
|
const cacheRoot = path.join(dataRoot(), tenant, ".runtimes");
|
|
@@ -733,7 +940,7 @@ export function prepareRuntime(tenant: string, spec: RuntimeSpec | null): Prepar
|
|
|
733
940
|
? install(uv, ["pip", "install", "-q", "--python", venvPython, ...spec.packages], root, 300_000, uvEnv)
|
|
734
941
|
: install(path.join(root, "venv", "bin", "pip"), ["install", "--disable-pip-version-check", "-q", ...spec.packages], root);
|
|
735
942
|
if (!installed.ok) {
|
|
736
|
-
return fail(`${uv ? "uv pip" : "pip"} install
|
|
943
|
+
return fail(summarizeInstallFailure(`${uv ? "uv pip" : "pip"} install`, installed.out));
|
|
737
944
|
}
|
|
738
945
|
built.packages = spec.packages;
|
|
739
946
|
log.push(`runtime ${fp}: installed ${spec.packages.join(", ")}`);
|
|
@@ -766,7 +973,7 @@ export function prepareRuntime(tenant: string, spec: RuntimeSpec | null): Prepar
|
|
|
766
973
|
},
|
|
767
974
|
);
|
|
768
975
|
if (!installed.ok) {
|
|
769
|
-
return fail(
|
|
976
|
+
return fail(summarizeInstallFailure("npm install", installed.out));
|
|
770
977
|
}
|
|
771
978
|
built.npm = spec.npm;
|
|
772
979
|
log.push(`runtime ${fp}: installed ${spec.npm.join(", ")}`);
|
|
@@ -834,3 +1041,26 @@ function touch(file: string): void {
|
|
|
834
1041
|
// read-only or gone; neither is a reason to fail the step
|
|
835
1042
|
}
|
|
836
1043
|
}
|
|
1044
|
+
|
|
1045
|
+
// ---------- installing mid-step ----------
|
|
1046
|
+
|
|
1047
|
+
/** Where the root start-up's install helper listens, inside a run container. */
|
|
1048
|
+
export const INSTALL_SOCKET = "/opt/runner/job/install.sock";
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* What a step installs for itself mid-run (the `install` tool) lands under
|
|
1052
|
+
* one per-step directory: pip packages in `<dir>/py` (`uv pip install
|
|
1053
|
+
* --target`), npm packages in `<dir>/npm` (`npm install --prefix`). Both are
|
|
1054
|
+
* on the step's paths from its first turn, so a tool installed by one call is
|
|
1055
|
+
* found by the next — `ffmpeg`, `import manim`, `require("sharp")`. Inside a
|
|
1056
|
+
* run container the directory is the container's own filesystem and goes
|
|
1057
|
+
* when the container does.
|
|
1058
|
+
*/
|
|
1059
|
+
export function stepInstallEnv(dir: string, base: Record<string, string | undefined>): Record<string, string> {
|
|
1060
|
+
return {
|
|
1061
|
+
// After whatever is already there: a declared environment keeps winning.
|
|
1062
|
+
PATH: [base.PATH ?? "", path.join(dir, "py", "bin"), path.join(dir, "npm", "node_modules", ".bin")].filter(Boolean).join(path.delimiter),
|
|
1063
|
+
PYTHONPATH: [base.PYTHONPATH ?? "", path.join(dir, "py")].filter(Boolean).join(path.delimiter),
|
|
1064
|
+
NODE_PATH: [base.NODE_PATH ?? "", path.join(dir, "npm", "node_modules")].filter(Boolean).join(path.delimiter),
|
|
1065
|
+
};
|
|
1066
|
+
}
|
package/src/script-tools.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface ScriptSpec {
|
|
|
38
38
|
* script inline. Exactly one of the two is the program. */
|
|
39
39
|
run: string;
|
|
40
40
|
description: string;
|
|
41
|
+
/** The tool.md body, for the model: how to call it and what it means. */
|
|
42
|
+
guide?: string;
|
|
41
43
|
args: Record<string, string>; // arg name → description
|
|
42
44
|
interpreter?: string; // optional override, e.g. "python3", "bash"
|
|
43
45
|
/** Seconds this script may run — for the crawl that legitimately takes
|
|
@@ -91,6 +93,7 @@ export function parseScripts(raw: unknown): ScriptSpec[] {
|
|
|
91
93
|
run: run ?? "",
|
|
92
94
|
...(code ? { code, codeExt: typeof e.codeExt === "string" ? e.codeExt : ".mjs" } : {}),
|
|
93
95
|
description: typeof e.description === "string" ? e.description : "",
|
|
96
|
+
...(typeof e.guide === "string" && e.guide.trim() ? { guide: e.guide } : {}),
|
|
94
97
|
args,
|
|
95
98
|
interpreter: typeof e.interpreter === "string" ? e.interpreter : undefined,
|
|
96
99
|
secrets: e.secrets === "proxied" ? "proxied" : undefined,
|
|
@@ -427,7 +430,8 @@ export function buildScriptTools(
|
|
|
427
430
|
return tool(
|
|
428
431
|
spec.name,
|
|
429
432
|
`${spec.description || `Run ${spec.run}.`} Runs the workspace script ${spec.run}; returns its output. ` +
|
|
430
|
-
`Credentials it needs are already in its environment
|
|
433
|
+
`Credentials it needs are already in its environment.` +
|
|
434
|
+
(spec.guide ? `\n\nHow to use it (from its tool.md):\n${spec.guide}` : ""),
|
|
431
435
|
shape,
|
|
432
436
|
async (args) => {
|
|
433
437
|
const started = Date.now();
|
package/src/secrets.ts
CHANGED
|
@@ -505,6 +505,15 @@ export interface OAuth2Config {
|
|
|
505
505
|
|
|
506
506
|
const OAUTH2_PREFIX = "@oauth2 ";
|
|
507
507
|
|
|
508
|
+
/** Tries at a token endpoint before a refresh is called failed: the first,
|
|
509
|
+
* and two more after a short wait, for a network error, a 5xx or a 429. */
|
|
510
|
+
export const OAUTH_REFRESH_TRIES = 3;
|
|
511
|
+
/** Wait before try n (1-based retry): 1s, 3s — FOLDRUN_OAUTH_RETRY_MS scales it for tests. */
|
|
512
|
+
export function oauthRetryDelayMs(n: number): number {
|
|
513
|
+
const base = Number(process.env.FOLDRUN_OAUTH_RETRY_MS ?? 1000);
|
|
514
|
+
return base * (n === 1 ? 1 : 3);
|
|
515
|
+
}
|
|
516
|
+
|
|
508
517
|
export function setOAuth2Secret(
|
|
509
518
|
tenant: string,
|
|
510
519
|
name: string,
|
|
@@ -550,12 +559,31 @@ async function exchange(config: OAuth2Config, cacheKey: string): Promise<string>
|
|
|
550
559
|
client_secret: config.client_secret,
|
|
551
560
|
...(config.extra ?? {}),
|
|
552
561
|
});
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
562
|
+
// A network blip is retried; a provider's answer is not. "fetch failed"
|
|
563
|
+
// (a reset, a DNS hiccup, a timeout) failed whole steps on the first try
|
|
564
|
+
// — gbp-desk's Thursday posts, 7 Oct 2026. A 4xx (revoked, wrong client)
|
|
565
|
+
// will say the same thing again, so it goes straight to the error below
|
|
566
|
+
// and the reconnect mail; a 5xx or 429 is the provider busy, retried.
|
|
567
|
+
let res: Response | undefined;
|
|
568
|
+
let lastNetwork: unknown;
|
|
569
|
+
for (let attempt = 0; attempt < OAUTH_REFRESH_TRIES; attempt++) {
|
|
570
|
+
if (attempt) await new Promise((r) => setTimeout(r, oauthRetryDelayMs(attempt)));
|
|
571
|
+
try {
|
|
572
|
+
res = await fetchUntrusted(config.token_url, {
|
|
573
|
+
method: "POST",
|
|
574
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
575
|
+
body: body.toString(),
|
|
576
|
+
signal: AbortSignal.timeout(15_000),
|
|
577
|
+
}, { sameOrigin: true });
|
|
578
|
+
} catch (err) {
|
|
579
|
+
lastNetwork = err;
|
|
580
|
+
res = undefined;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
if (res.status === 429 || res.status >= 500) continue;
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
if (!res) throw lastNetwork instanceof Error ? lastNetwork : new Error(String(lastNetwork));
|
|
559
587
|
const payload = (await res.json().catch(() => ({}))) as {
|
|
560
588
|
access_token?: string;
|
|
561
589
|
expires_in?: number;
|
package/src/starter.ts
CHANGED
|
@@ -47,6 +47,10 @@ foldrun_version: "0.1"
|
|
|
47
47
|
# that declares the same key replaces it *whole* — base_url and token never
|
|
48
48
|
# merge across scopes, so a provider block belongs entirely at one level.
|
|
49
49
|
#
|
|
50
|
+
# Your own model key needs no block: Settings → Model provider, or
|
|
51
|
+
# \`foldrun account model set anthropic --key …\` (an API key, not a login).
|
|
52
|
+
# A provider: block here or lower wins over it.
|
|
53
|
+
#
|
|
50
54
|
# provider:
|
|
51
55
|
# base_url: https://openrouter.ai/api
|
|
52
56
|
# token: \${OPENROUTER_API_KEY}
|
|
@@ -342,13 +346,14 @@ All of it, \`/api/workspaces/<workspace>\` unless noted:
|
|
|
342
346
|
| \`/runs/<id>/message\` | POST | tell a running step something; \`step\` picks one of a parallel group |
|
|
343
347
|
| \`/flows\` · \`/flows/<f>\` | GET POST · POST DELETE PATCH | list, create, edit |
|
|
344
348
|
| \`/flows/<f>/run\` | POST | start a flow |
|
|
345
|
-
| \`/agents\` · \`/agents/<a>/run\` | GET POST · POST | list agents, run one |
|
|
349
|
+
| \`/agents\` · \`/agents/<a>/run\` · \`/agents/import\` | GET POST · POST · GET POST | list agents, run one, copy one in from another workspace |
|
|
346
350
|
| \`/evals\` · \`/evals/<e>/run\` | GET POST · POST | list evals, run one |
|
|
347
351
|
| \`/tools/<t>/test\` | POST | exercise one tool alone |
|
|
348
352
|
| \`/storage\` · \`/storage/download\` · \`/storage/upload-url\` · \`/storage/preview\` | GET POST PUT DELETE · GET · POST · GET | workspace files, any type; \`preview\` returns one parsed for reading |
|
|
349
353
|
| \`/assets\` | POST | upload an asset |
|
|
350
354
|
| \`/hooks/<f>/rotate\` | POST | new webhook token for a flow |
|
|
351
355
|
| \`/notify/deliveries\` · \`/notify/deliveries/<id>/redeliver\` | GET · POST | every notify: webhook sent and each attempt; send one again |
|
|
356
|
+
| \`/export\` · \`/import\` | GET · POST | the workspace, a flow (\`?kind=flow&name=\`) or an agent as a .zip; take one in (\`?dryRun=1\` first, \`?overwrite=1\` to replace) |
|
|
352
357
|
| \`/restore\` | POST | the workspace's source back to a point in its history — \`dryRun\` first, then \`confirm\` |
|
|
353
358
|
| \`/vocabulary\` | GET | what this workspace's documents may say |
|
|
354
359
|
|
|
@@ -458,10 +463,11 @@ subagents: [researcher] # colleagues it may delegate a job to (own context,
|
|
|
458
463
|
secrets: [CRM_TOKEN] # keys the agent uses itself; a tool's own come with the tool
|
|
459
464
|
limits: {web.search: 40, crm: 20, calls: 300} # per step; a call past a limit is refused, never run
|
|
460
465
|
timezone: Australia/Sydney # the calendar it works to — IANA name or UTC+10
|
|
461
|
-
provider: {} # BYOK:
|
|
466
|
+
provider: {} # BYOK for this agent; account-wide: \`foldrun account model set\`
|
|
462
467
|
permissionMode: plan # plan first, act once approved
|
|
463
468
|
runtime: # only if scripts need packages
|
|
464
469
|
packages: [requests]
|
|
470
|
+
system: [ffmpeg] # Debian packages, apt-installed before the step
|
|
465
471
|
---
|
|
466
472
|
|
|
467
473
|
You research a topic and report what you found...
|
|
@@ -475,8 +481,12 @@ flow's, else the workspace's AGENTS.md, else the account's, else UTC. It sets
|
|
|
475
481
|
\`TZ\` and the date the agent is told, so a Sydney desk stamps the Sydney date.
|
|
476
482
|
|
|
477
483
|
\`runtime:\` installs pip/npm packages in the sandbox each agent runs in. Pin
|
|
478
|
-
versions the normal way (\`pandas>=2\`, \`lodash@^4\`).
|
|
479
|
-
|
|
484
|
+
versions the normal way (\`pandas>=2\`, \`lodash@^4\`). \`system:\` adds Debian
|
|
485
|
+
packages \u{2014} a program (\`ffmpeg\`), a library's headers for a pip package that
|
|
486
|
+
compiles (\`libcairo2-dev\`, \`python3-dev\`), \`build-essential\` \u{2014} installed with
|
|
487
|
+
apt before the step starts and cached, so later runs only unpack them. An agent that
|
|
488
|
+
finds it needs something mid-run installs it itself with the \`install\` tool
|
|
489
|
+
(apt, pip or npm), which every agent has; declaring is only a speed-up.
|
|
480
490
|
|
|
481
491
|
Link documents to each other with \`[[wikilinks]]\` — the name, not the path.
|
|
482
492
|
`,
|
|
@@ -499,3 +509,31 @@ expect:
|
|
|
499
509
|
},
|
|
500
510
|
];
|
|
501
511
|
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* A new workspace with nothing in it to delete: its AGENTS.md, the
|
|
515
|
+
* .gitignore that keeps its secret key out of git, and the CLAUDE.md a coding
|
|
516
|
+
* tool reads. No example agents or flow.
|
|
517
|
+
*
|
|
518
|
+
* The starter's researcher and writer were a demo, and a person making a
|
|
519
|
+
* workspace for real work met them as clutter — "why does a new workspace
|
|
520
|
+
* have agents?" (owner, 2026-10-05). The agents worth having are usually ones
|
|
521
|
+
* already written in another workspace, which `agents/import` copies in; the
|
|
522
|
+
* example is still one flag away (`foldrun new <name> --starter`).
|
|
523
|
+
*/
|
|
524
|
+
export function blankWorkspaceFiles(workspace: string): StarterFile[] {
|
|
525
|
+
const keep = new Set(["AGENTS.md", ".gitignore", "CLAUDE.md"]);
|
|
526
|
+
return starterFiles(workspace)
|
|
527
|
+
.filter((f) => keep.has(f.path))
|
|
528
|
+
.map((f) =>
|
|
529
|
+
f.path === "AGENTS.md"
|
|
530
|
+
? {
|
|
531
|
+
...f,
|
|
532
|
+
content: f.content.replace(
|
|
533
|
+
"description: A starter workspace — edit the agents, then run the flow.",
|
|
534
|
+
"description: A new workspace — add an agent or import one from another workspace, then write a flow.",
|
|
535
|
+
),
|
|
536
|
+
}
|
|
537
|
+
: f,
|
|
538
|
+
);
|
|
539
|
+
}
|
package/src/step-exec.ts
CHANGED
|
@@ -329,6 +329,13 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
329
329
|
// declares no tools gets none, instead of seeing the full Claude
|
|
330
330
|
// Code toolset and burning turns on denied calls.
|
|
331
331
|
tools: opts.allowed,
|
|
332
|
+
// Stated, never left to the SDK. Unset, it chose its own mode for the
|
|
333
|
+
// login it ran under, and in auto mode its classifier refused a granted
|
|
334
|
+
// script before canUseTool saw it: "denied by the Claude Code auto mode
|
|
335
|
+
// classifier. Reason: [Irreversible Local Destruction]" for backlink-
|
|
336
|
+
// desk's outreach_reset, which only marks old bodies STALE (2026-10-04
|
|
337
|
+
// and 05). The decision is ours: canUseTool and the PreToolUse hook.
|
|
338
|
+
permissionMode: "default",
|
|
332
339
|
// Deliberately NOT allowedTools: listing them there auto-approves and
|
|
333
340
|
// skips canUseTool entirely, which is where confinement happens. The
|
|
334
341
|
// toolset is still restricted by `tools` above.
|
|
@@ -632,11 +639,23 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
632
639
|
...(block.is_error ? { err: true } : {}),
|
|
633
640
|
...(open.subagent ? { subagent: open.subagent } : {}),
|
|
634
641
|
});
|
|
642
|
+
// Why it failed, on the trail. The span above carried only `err`,
|
|
643
|
+
// so a refused call left the agent's own guess as the only account
|
|
644
|
+
// of it: backlink-desk's outreach_reset "denied by the permission
|
|
645
|
+
// classifier" (run-muufboma-2t6l, 2026-10-05) could not be checked.
|
|
646
|
+
if (block.is_error) {
|
|
647
|
+
const why = toolResultText(block.content).replace(/\s+/g, " ").trim().slice(0, 300);
|
|
648
|
+
if (why) emit("error", `${open.name}: ${why}`, { call: block.tool_use_id, ...(open.subagent ? { subagent: open.subagent } : {}) });
|
|
649
|
+
}
|
|
635
650
|
readPod(block.tool_use_id);
|
|
636
651
|
}
|
|
637
652
|
}
|
|
638
653
|
} else if (message.type === "result") {
|
|
639
654
|
status = message.subtype === "success" ? "completed" : "failed";
|
|
655
|
+
// Calls the SDK itself refused, which canUseTool never saw.
|
|
656
|
+
for (const d of ("permission_denials" in message && Array.isArray(message.permission_denials) ? message.permission_denials : [])) {
|
|
657
|
+
emit("error", `refused before it ran: ${String((d as { tool_name?: unknown }).tool_name ?? "a tool")}`);
|
|
658
|
+
}
|
|
640
659
|
if (message.subtype === "error_max_turns") {
|
|
641
660
|
emit("error", `stopped after ${opts.maxTurns ?? "its"} turns (max_turns: in the flow file) — the step did not finish`);
|
|
642
661
|
}
|
|
@@ -877,8 +896,8 @@ export async function checkVerify(
|
|
|
877
896
|
* and `matches:` test this, because it is also what the run's headline
|
|
878
897
|
* is read from: a reporter that narrated between tool calls ("Now let
|
|
879
898
|
* me write the report…") failed `matches: ^BAD\b` with a correct
|
|
880
|
-
* headline while the check read every turn joined. `judge:`
|
|
881
|
-
* the whole result. */
|
|
899
|
+
* headline while the check read every turn joined. `judge:` grades this
|
|
900
|
+
* too, as "the reply", with the whole result beside it as the work. */
|
|
882
901
|
conclusion?: string | null;
|
|
883
902
|
data?: unknown;
|
|
884
903
|
/** The step's own model environment, for `judge:` — it grades on the
|
|
@@ -941,7 +960,13 @@ export async function checkVerify(
|
|
|
941
960
|
return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
|
|
942
961
|
}
|
|
943
962
|
case "judge": {
|
|
944
|
-
|
|
963
|
+
// The reply is the final turn, as everywhere else; every turn goes
|
|
964
|
+
// beside it as the work, so a claim about what the step did can still
|
|
965
|
+
// be checked. Both as one <reply> failed `judge: the reply opens with
|
|
966
|
+
// GOOD` on a reply that did (fix-desk, run-muwevg72-7mza): the joined
|
|
967
|
+
// text opened with the narration before it.
|
|
968
|
+
const reply = ctx.conclusion?.trim() ? ctx.conclusion : output;
|
|
969
|
+
const verdict = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal);
|
|
945
970
|
const ok = /^\s*PASS\b/i.test(verdict);
|
|
946
971
|
return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
|
|
947
972
|
}
|
|
@@ -954,9 +979,24 @@ export async function checkVerify(
|
|
|
954
979
|
* Same shape as the eval judge, and answered with one word first so the
|
|
955
980
|
* verdict is a prefix test rather than a reading.
|
|
956
981
|
*/
|
|
982
|
+
export function judgePrompt(rubric: string, reply: string, work: string): string {
|
|
983
|
+
const shown = reply.slice(0, 20_000);
|
|
984
|
+
const context = work.trim() && work.trim() !== reply.trim()
|
|
985
|
+
? `Everything the step wrote while working, for context. "The reply" means only the reply block below, ` +
|
|
986
|
+
`never this:\n<work>\n${work.slice(-20_000)}\n</work>\n\n`
|
|
987
|
+
: "";
|
|
988
|
+
return (
|
|
989
|
+
`You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
|
|
990
|
+
`first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
|
|
991
|
+
context +
|
|
992
|
+
`<reply>\n${shown}\n</reply>`
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
|
|
957
996
|
async function judgeReply(
|
|
958
997
|
rubric: string,
|
|
959
|
-
|
|
998
|
+
reply: string,
|
|
999
|
+
work: string,
|
|
960
1000
|
env: Record<string, string | undefined>,
|
|
961
1001
|
signal?: AbortSignal,
|
|
962
1002
|
): Promise<string> {
|
|
@@ -966,10 +1006,7 @@ async function judgeReply(
|
|
|
966
1006
|
signal?.addEventListener("abort", () => abortController.abort(), { once: true });
|
|
967
1007
|
try {
|
|
968
1008
|
const q = query({
|
|
969
|
-
prompt:
|
|
970
|
-
`You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
|
|
971
|
-
`first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
|
|
972
|
-
`<reply>\n${output.slice(0, 40_000)}\n</reply>`,
|
|
1009
|
+
prompt: judgePrompt(rubric, reply, work),
|
|
973
1010
|
options: {
|
|
974
1011
|
model: "haiku",
|
|
975
1012
|
systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
|
|
@@ -1060,3 +1097,14 @@ function runVerify(
|
|
|
1060
1097
|
export function sdkEnv(base: Record<string, string | undefined> | undefined, extra: Record<string, string> = {}): Record<string, string | undefined> {
|
|
1061
1098
|
return { ...(base ?? process.env), ...extra, ENABLE_CLAUDEAI_MCP_SERVERS: "false" };
|
|
1062
1099
|
}
|
|
1100
|
+
|
|
1101
|
+
/** The text of a tool result, whichever shape it came in: a string, or a
|
|
1102
|
+
* list of content blocks of which only the text ones say anything. */
|
|
1103
|
+
export function toolResultText(content: unknown): string {
|
|
1104
|
+
if (typeof content === "string") return content;
|
|
1105
|
+
if (!Array.isArray(content)) return "";
|
|
1106
|
+
return content
|
|
1107
|
+
.filter((b): b is { type: "text"; text: string } => !!b && (b as { type?: unknown }).type === "text" && typeof (b as { text?: unknown }).text === "string")
|
|
1108
|
+
.map((b) => b.text)
|
|
1109
|
+
.join(" ");
|
|
1110
|
+
}
|
package/src/storage.ts
CHANGED
|
@@ -839,6 +839,14 @@ export async function materializeFiles(tenant: string, workspace: string): Promi
|
|
|
839
839
|
return brought;
|
|
840
840
|
}
|
|
841
841
|
|
|
842
|
+
/** path → sha of every stored file: what `storage/` holds once
|
|
843
|
+
* materializeFiles has run. A run keeps it to tell its own writes from the
|
|
844
|
+
* files it was handed. */
|
|
845
|
+
export function storageBaseline(tenant: string, workspace: string): Record<string, string> {
|
|
846
|
+
if (!fileStoreEnabled()) return {};
|
|
847
|
+
return Object.fromEntries(readIndex(tenant, workspace).files.map((f) => [f.path, f.sha]));
|
|
848
|
+
}
|
|
849
|
+
|
|
842
850
|
/**
|
|
843
851
|
* Take what the run left in `storage/` back into the store, stamped with the
|
|
844
852
|
* run that wrote it.
|
|
@@ -851,6 +859,7 @@ export async function harvestFiles(
|
|
|
851
859
|
tenant: string,
|
|
852
860
|
workspace: string,
|
|
853
861
|
by: string,
|
|
862
|
+
opts: { changedFrom?: Record<string, string> } = {},
|
|
854
863
|
): Promise<{ saved: string[]; errors: string[] }> {
|
|
855
864
|
if (!fileStoreEnabled()) return { saved: [], errors: [] };
|
|
856
865
|
adoptLegacyFilesDir(workspaceDir(tenant, workspace));
|
|
@@ -885,6 +894,10 @@ export async function harvestFiles(
|
|
|
885
894
|
// Same size is not proof, so confirm by hash before skipping a write.
|
|
886
895
|
if (sha256(fs.readFileSync(abs)) === previous.sha) continue;
|
|
887
896
|
}
|
|
897
|
+
// Only what the run itself changed: a file still holding the bytes it
|
|
898
|
+
// was handed is not this run's work, and the index may have moved on
|
|
899
|
+
// since (an upload while the run was going) — that must win.
|
|
900
|
+
if (opts.changedFrom && opts.changedFrom[norm] === sha256(fs.readFileSync(abs))) continue;
|
|
888
901
|
await putFileQuietly(tenant, workspace, norm, fs.readFileSync(abs), by);
|
|
889
902
|
saved.push(norm);
|
|
890
903
|
} catch (err) {
|