@foldrun/core 0.2.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 (166) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -0
  3. package/dist/index.d.ts +32 -0
  4. package/dist/index.js +43 -0
  5. package/dist/src/agent-tools.d.ts +31 -0
  6. package/dist/src/agent-tools.js +99 -0
  7. package/dist/src/agents-md.d.ts +9 -0
  8. package/dist/src/agents-md.js +83 -0
  9. package/dist/src/api-tools.d.ts +38 -0
  10. package/dist/src/api-tools.js +313 -0
  11. package/dist/src/approvals.d.ts +39 -0
  12. package/dist/src/approvals.js +103 -0
  13. package/dist/src/arrange.d.ts +8 -0
  14. package/dist/src/arrange.js +40 -0
  15. package/dist/src/catalog.d.ts +108 -0
  16. package/dist/src/catalog.js +255 -0
  17. package/dist/src/completions.d.ts +61 -0
  18. package/dist/src/completions.js +578 -0
  19. package/dist/src/confine.d.ts +31 -0
  20. package/dist/src/confine.js +298 -0
  21. package/dist/src/container.d.ts +30 -0
  22. package/dist/src/container.js +172 -0
  23. package/dist/src/context-tools.d.ts +81 -0
  24. package/dist/src/context-tools.js +347 -0
  25. package/dist/src/deploy.d.ts +100 -0
  26. package/dist/src/deploy.js +377 -0
  27. package/dist/src/egress.d.ts +75 -0
  28. package/dist/src/egress.js +133 -0
  29. package/dist/src/evals.d.ts +150 -0
  30. package/dist/src/evals.js +651 -0
  31. package/dist/src/flow-lint.d.ts +16 -0
  32. package/dist/src/flow-lint.js +177 -0
  33. package/dist/src/git-hook.d.ts +1 -0
  34. package/dist/src/git-hook.js +70 -0
  35. package/dist/src/git.d.ts +37 -0
  36. package/dist/src/git.js +101 -0
  37. package/dist/src/gitrepo.d.ts +95 -0
  38. package/dist/src/gitrepo.js +355 -0
  39. package/dist/src/history.d.ts +82 -0
  40. package/dist/src/history.js +259 -0
  41. package/dist/src/kinds.d.ts +95 -0
  42. package/dist/src/kinds.js +421 -0
  43. package/dist/src/library.d.ts +94 -0
  44. package/dist/src/library.js +341 -0
  45. package/dist/src/linkable.d.ts +20 -0
  46. package/dist/src/linkable.js +113 -0
  47. package/dist/src/model-probe.d.ts +21 -0
  48. package/dist/src/model-probe.js +81 -0
  49. package/dist/src/notify.d.ts +61 -0
  50. package/dist/src/notify.js +252 -0
  51. package/dist/src/oauth-presets.d.ts +13 -0
  52. package/dist/src/oauth-presets.js +39 -0
  53. package/dist/src/okf.d.ts +220 -0
  54. package/dist/src/okf.js +656 -0
  55. package/dist/src/openapi.d.ts +66 -0
  56. package/dist/src/openapi.js +342 -0
  57. package/dist/src/paths.d.ts +33 -0
  58. package/dist/src/paths.js +78 -0
  59. package/dist/src/platform.d.ts +32 -0
  60. package/dist/src/platform.js +48 -0
  61. package/dist/src/providers.d.ts +47 -0
  62. package/dist/src/providers.js +114 -0
  63. package/dist/src/refs.d.ts +20 -0
  64. package/dist/src/refs.js +78 -0
  65. package/dist/src/run-container.d.ts +227 -0
  66. package/dist/src/run-container.js +760 -0
  67. package/dist/src/runner.d.ts +241 -0
  68. package/dist/src/runner.js +3215 -0
  69. package/dist/src/runtime.d.ts +41 -0
  70. package/dist/src/runtime.js +357 -0
  71. package/dist/src/script-tools.d.ts +62 -0
  72. package/dist/src/script-tools.js +296 -0
  73. package/dist/src/secret-files.d.ts +12 -0
  74. package/dist/src/secret-files.js +92 -0
  75. package/dist/src/secrets.d.ts +147 -0
  76. package/dist/src/secrets.js +626 -0
  77. package/dist/src/starter.d.ts +21 -0
  78. package/dist/src/starter.js +399 -0
  79. package/dist/src/step-exec.d.ts +136 -0
  80. package/dist/src/step-exec.js +391 -0
  81. package/dist/src/storage.d.ts +123 -0
  82. package/dist/src/storage.js +640 -0
  83. package/dist/src/store.d.ts +1052 -0
  84. package/dist/src/store.js +2378 -0
  85. package/dist/src/tar.d.ts +30 -0
  86. package/dist/src/tar.js +111 -0
  87. package/dist/src/tool-names.d.ts +39 -0
  88. package/dist/src/tool-names.js +79 -0
  89. package/dist/src/tool-programs.d.ts +18 -0
  90. package/dist/src/tool-programs.js +52 -0
  91. package/dist/src/tool-test.d.ts +19 -0
  92. package/dist/src/tool-test.js +335 -0
  93. package/dist/src/translator.d.ts +159 -0
  94. package/dist/src/translator.js +986 -0
  95. package/dist/src/triggers.d.ts +60 -0
  96. package/dist/src/triggers.js +270 -0
  97. package/dist/src/webhook.d.ts +47 -0
  98. package/dist/src/webhook.js +148 -0
  99. package/index.ts +44 -0
  100. package/package.json +64 -0
  101. package/src/agent-tools.ts +138 -0
  102. package/src/agents-md.ts +81 -0
  103. package/src/api-tools.ts +389 -0
  104. package/src/approvals.ts +129 -0
  105. package/src/arrange.ts +42 -0
  106. package/src/catalog.ts +321 -0
  107. package/src/completions.ts +652 -0
  108. package/src/confine.ts +350 -0
  109. package/src/container.ts +220 -0
  110. package/src/context-tools.ts +435 -0
  111. package/src/deploy.ts +458 -0
  112. package/src/egress.ts +176 -0
  113. package/src/evals.ts +812 -0
  114. package/src/flow-lint.ts +215 -0
  115. package/src/git-hook.ts +73 -0
  116. package/src/git.ts +117 -0
  117. package/src/gitrepo.ts +391 -0
  118. package/src/history.ts +329 -0
  119. package/src/kinds.ts +507 -0
  120. package/src/library.ts +419 -0
  121. package/src/linkable.ts +123 -0
  122. package/src/model-probe.ts +112 -0
  123. package/src/notify.ts +281 -0
  124. package/src/oauth-presets.ts +54 -0
  125. package/src/okf.ts +768 -0
  126. package/src/openapi.ts +383 -0
  127. package/src/paths.ts +79 -0
  128. package/src/platform.ts +87 -0
  129. package/src/providers.ts +147 -0
  130. package/src/refs.ts +83 -0
  131. package/src/run-container.ts +883 -0
  132. package/src/runner.ts +3446 -0
  133. package/src/runtime.ts +404 -0
  134. package/src/script-tools.ts +383 -0
  135. package/src/secret-files.ts +101 -0
  136. package/src/secrets.ts +799 -0
  137. package/src/starter.ts +415 -0
  138. package/src/step-exec.ts +498 -0
  139. package/src/storage.ts +771 -0
  140. package/src/store.ts +2985 -0
  141. package/src/tar.ts +124 -0
  142. package/src/tool-names.ts +91 -0
  143. package/src/tool-programs.ts +65 -0
  144. package/src/tool-test.ts +401 -0
  145. package/src/translator.ts +1029 -0
  146. package/src/triggers.ts +287 -0
  147. package/src/webhook.ts +183 -0
  148. package/templates/blog-desk/AGENTS.md +13 -0
  149. package/templates/blog-desk/agents/researcher/agent.md +11 -0
  150. package/templates/blog-desk/agents/writer/agent.md +8 -0
  151. package/templates/blog-desk/evals/writer-quality.md +10 -0
  152. package/templates/blog-desk/flows/publish.md +10 -0
  153. package/templates/blog-desk/knowledge/house-style.md +9 -0
  154. package/templates/blog-desk/knowledge/index.md +9 -0
  155. package/templates/hello/AGENTS.md +18 -0
  156. package/templates/hello/agents/curator/agent.md +32 -0
  157. package/templates/hello/agents/notetaker/agent.md +24 -0
  158. package/templates/hello/evals/house-style.md +16 -0
  159. package/templates/hello/flows/memory-review.md +7 -0
  160. package/templates/hello/flows/note.md +7 -0
  161. package/templates/hello/knowledge/house-style.md +15 -0
  162. package/templates/hello/knowledge/index.md +9 -0
  163. package/templates/hello/skills/plain-english/SKILL.md +11 -0
  164. package/templates/hello/state/publishing.md +7 -0
  165. package/templates/hello/tools/wordcount/run.py +13 -0
  166. package/templates/hello/tools/wordcount/tool.md +22 -0
@@ -0,0 +1,391 @@
1
+ // The model loop, extracted to run in two places: the server process (the
2
+ // classic path) and the runner container's driver (the isolated path). One
3
+ // implementation, because the moment these fork, the sandboxed path becomes
4
+ // the less-tested one — backwards from its whole purpose.
5
+ //
6
+ // Everything here takes values, not stores: the caller resolves secrets,
7
+ // assembles the system prompt and builds MCP servers, then hands this the
8
+ // results. That is what lets the same function run somewhere the vault, the
9
+ // library and the account do not exist.
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { query } from "@anthropic-ai/claude-agent-sdk";
13
+ import { spawn } from "node:child_process";
14
+ import { checkPaths, checkBash, isFilesystemTool } from "./confine.js";
15
+ /** Conservative per-token rates for a model the catalogue cannot price —
16
+ * Opus-class — so a ceiling still means something on an unknown gateway. */
17
+ export const FALLBACK_PRICE = { input: 15e-6, output: 75e-6 };
18
+ /**
19
+ * What one assistant turn cost. Cache traffic is counted as input at the
20
+ * input rate, which over-approximates on gateways with cheaper cache reads
21
+ * — on the money side, the honest direction to be wrong in. Pure.
22
+ */
23
+ export function priceTurn(u, price) {
24
+ const inTok = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
25
+ const outTok = u.output_tokens ?? 0;
26
+ const p = price ?? FALLBACK_PRICE;
27
+ return inTok * p.input + outTok * p.output;
28
+ }
29
+ /**
30
+ * Each step launched together gets an equal share of what is left of the
31
+ * run's budget: the shares sum to the remainder, so a group cannot end
32
+ * over the cap however many steps it fans out to. No budget, no ceiling.
33
+ */
34
+ export function stepCeiling(budgetUsd, spentUsd, launching) {
35
+ if (!budgetUsd || budgetUsd <= 0)
36
+ return null;
37
+ return Math.max(0, budgetUsd - spentUsd) / Math.max(1, launching);
38
+ }
39
+ export async function executeStep(opts) {
40
+ const { agentDir, workspaceRoot, libraryRoot, emit } = opts;
41
+ let status = "running";
42
+ let costUsd = null;
43
+ let usage = null;
44
+ const texts = [];
45
+ fs.mkdirSync(path.join(agentDir, "outputs"), { recursive: true });
46
+ fs.mkdirSync(path.join(agentDir, "memory"), { recursive: true });
47
+ const q = query({
48
+ prompt: opts.prompt,
49
+ options: {
50
+ cwd: agentDir,
51
+ model: opts.model,
52
+ // Omitted, not passed as undefined-with-a-default: an unset effort
53
+ // should mean "whatever this model does normally", which is not a
54
+ // level we can name — it moves as models ship.
55
+ ...(opts.effort ? { effort: opts.effort } : {}),
56
+ systemPrompt: opts.systemPrompt,
57
+ // Restrict the toolset itself, not just approval: an agent that
58
+ // declares no tools gets none, instead of seeing the full Claude
59
+ // Code toolset and burning turns on denied calls.
60
+ tools: opts.allowed,
61
+ // Deliberately NOT allowedTools: listing them there auto-approves and
62
+ // skips canUseTool entirely, which is where confinement happens. The
63
+ // toolset is still restricted by `tools` above.
64
+ settingSources: [],
65
+ // Sandbox the shell — but only when the shell would otherwise run in a
66
+ // process worth protecting. In an isolated run the container IS the
67
+ // boundary (non-root, cap-dropped, host-less, icc-disabled network),
68
+ // so the SDK's own bash sandbox is redundant there and actively harms:
69
+ // it blocks outbound network, which a declared SSH or curl step
70
+ // legitimately needs. So the container relaxes it (opts.sandboxBash =
71
+ // false) and the container's walls do the containing; the in-process
72
+ // path (the CLI on someone's laptop, and the host executor) keeps it,
73
+ // because there a shell escape reaches the real machine.
74
+ sandbox: {
75
+ enabled: opts.sandboxBash !== false,
76
+ // Must stay false. Auto-allowing bash because it's sandboxed skips
77
+ // canUseTool entirely — and the OS sandbox blocks writes outside the
78
+ // tree but still permits reads, so `cat ../../secrets.json` walked
79
+ // straight out. Verified by probe: true leaks, false denies.
80
+ autoAllowBashIfSandboxed: false,
81
+ failIfUnavailable: false,
82
+ },
83
+ env: opts.env,
84
+ mcpServers: opts.mcpServers,
85
+ canUseTool: async (toolName, input) => {
86
+ // The toolset was already narrowed to what the agent declared, so
87
+ // anything outside it is a denial with a reason the model can act on.
88
+ const fromGrantedServer = opts.mcpNames.some((n) => toolName.startsWith(`mcp__${n}__`));
89
+ if (!opts.allowed.includes(toolName) && !fromGrantedServer) {
90
+ return {
91
+ behavior: "deny",
92
+ message: `Tool ${toolName} is not enabled for this agent.`,
93
+ };
94
+ }
95
+ const verdict = toolName === "Bash"
96
+ ? checkBash(String(input.command ?? ""))
97
+ : isFilesystemTool(toolName)
98
+ ? checkPaths(toolName, input, { agentDir, workspaceRoot, libraryRoot })
99
+ : { ok: true };
100
+ if (!verdict.ok) {
101
+ emit("error", verdict.reason);
102
+ return { behavior: "deny", message: verdict.reason };
103
+ }
104
+ return { behavior: "allow", updatedInput: verdict.updatedInput ?? input };
105
+ },
106
+ },
107
+ });
108
+ const deadline = opts.timeoutSec ? Date.now() + opts.timeoutSec * 1000 : null;
109
+ // The hard cap. Spend is counted as each assistant turn arrives — its
110
+ // usage is in the message — so the step can stop at the ceiling rather
111
+ // than learn afterwards that it crossed it. Cache traffic is counted as
112
+ // input at the input rate, which over-approximates: on the money side
113
+ // that is the honest direction to be wrong in.
114
+ const ceiling = typeof opts.budgetUsd === "number" && opts.budgetUsd > 0 ? opts.budgetUsd : null;
115
+ let spentUsd = 0;
116
+ // Open tool calls, by the provider's id, so the result can be paired with
117
+ // its call and the trace can say how long each tool ran.
118
+ const openCalls = new Map();
119
+ for await (const message of q) {
120
+ if (deadline && Date.now() > deadline) {
121
+ emit("error", `step exceeded its ${opts.timeoutSec}s timeout`);
122
+ status = "failed";
123
+ break;
124
+ }
125
+ if (message.type === "assistant") {
126
+ const u = message.message.usage;
127
+ if (ceiling && u) {
128
+ spentUsd += priceTurn(u, opts.price ?? null);
129
+ if (spentUsd >= ceiling) {
130
+ emit("error", `over budget — this step reached $${spentUsd.toFixed(4)} of its $${ceiling.toFixed(4)} ceiling mid-turn and was stopped (budget: in the flow file)`);
131
+ status = "failed";
132
+ break;
133
+ }
134
+ }
135
+ for (const block of message.message.content) {
136
+ if (block.type === "text" && block.text.trim()) {
137
+ texts.push(block.text);
138
+ emit("text", block.text);
139
+ }
140
+ else if (block.type === "tool_use") {
141
+ openCalls.set(block.id, { name: block.name, at: Date.now() });
142
+ emit("tool", block.name, { call: block.id });
143
+ }
144
+ }
145
+ }
146
+ else if (message.type === "user") {
147
+ // Tool results ride back as user turns. The completion event closes
148
+ // the span the call opened; the runner's journal keeps both.
149
+ const content = message.message.content;
150
+ if (Array.isArray(content)) {
151
+ for (const block of content) {
152
+ if (block.type !== "tool_result")
153
+ continue;
154
+ const open = openCalls.get(block.tool_use_id);
155
+ if (!open)
156
+ continue;
157
+ openCalls.delete(block.tool_use_id);
158
+ emit("tool", open.name, {
159
+ call: block.tool_use_id,
160
+ ms: Date.now() - open.at,
161
+ ...(block.is_error ? { err: true } : {}),
162
+ });
163
+ }
164
+ }
165
+ }
166
+ else if (message.type === "result") {
167
+ status = message.subtype === "success" ? "completed" : "failed";
168
+ costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
169
+ if ("usage" in message && message.usage) {
170
+ const u = message.usage;
171
+ usage = {
172
+ // Cache traffic is input the provider still bills (at its own
173
+ // rates); folding it into the input count over-approximates for
174
+ // gateways with cheaper cache reads, which errs on the honest side.
175
+ inputTokens: (u.input_tokens ?? 0) +
176
+ (u.cache_creation_input_tokens ?? 0) +
177
+ (u.cache_read_input_tokens ?? 0),
178
+ outputTokens: u.output_tokens ?? 0,
179
+ };
180
+ }
181
+ }
182
+ }
183
+ // The SDK ends a healthy run with a `result` message. A stream that just
184
+ // stops — subprocess OOM-killed, crashed, or torn down — used to fall
185
+ // through as "completed", which reported a run that produced nothing as a
186
+ // success. Silence is not success.
187
+ if (status === "running") {
188
+ status = "failed";
189
+ emit("error", "the model stream ended without a result — the model process likely died (out of memory?)");
190
+ }
191
+ const result = texts.join("\n").trim() || null;
192
+ // A step's reply is not one message: the model narrates ("Now let me read
193
+ // the outputs…"), calls tools, and answers last. Joining those and reading
194
+ // the first line — which is what the run summary did — reports the plan
195
+ // and never the outcome. The final block is the answer; keep it so the
196
+ // summary has something true to read.
197
+ const conclusion = texts.at(-1)?.trim() || null;
198
+ // output: json — the declared shape is a contract, checked here where both
199
+ // executors run. A reply with no JSON in it is not "done with a caveat";
200
+ // it is the failure the next step would otherwise inherit as garbage.
201
+ let data = undefined;
202
+ if (status === "completed" && opts.output === "json") {
203
+ const extracted = extractJson(result);
204
+ if (extracted.ok) {
205
+ data = extracted.value;
206
+ emit("info", `output: json — ${describeJson(data)}`);
207
+ }
208
+ else {
209
+ emit("error", `output: json — ${extracted.reason}`);
210
+ status = "failed";
211
+ }
212
+ }
213
+ // "Done" should mean a check passed, not that the model stopped talking.
214
+ if (status === "completed" && opts.verify) {
215
+ const verdict = await checkVerify(agentDir, opts.verify, {
216
+ env: opts.verifyEnv ?? {},
217
+ result,
218
+ conclusion,
219
+ data,
220
+ modelEnv: opts.env,
221
+ });
222
+ emit(verdict.ok ? "info" : "error", `verify \`${opts.verify}\` → ${verdict.headline}`);
223
+ if (verdict.detail.trim())
224
+ emit(verdict.ok ? "info" : "error", verdict.detail.slice(0, 1000));
225
+ if (!verdict.ok)
226
+ status = "failed";
227
+ }
228
+ return { status, result, conclusion, ...(opts.output ? { data } : {}), costUsd, usage };
229
+ }
230
+ // ------------------------------------------------------------ output: json
231
+ /**
232
+ * The one JSON value a reply carries. A ```json fence wins — it is what the
233
+ * step was asked to write — and the LAST one at that, because a model that
234
+ * shows its working often quotes an earlier draft first. Without a fence,
235
+ * the reply's trailing `{…}` or `[…]` is tried, so a model that answered
236
+ * with bare JSON is not failed for good behaviour.
237
+ */
238
+ export function extractJson(result) {
239
+ const text = (result ?? "").trim();
240
+ if (!text)
241
+ return { ok: false, reason: "the reply was empty" };
242
+ const fences = [...text.matchAll(/```(?:json|JSON)?\s*\n([\s\S]*?)\n\s*```/g)].map((m) => m[1].trim());
243
+ const candidates = fences.length ? fences.reverse() : [];
244
+ if (!candidates.length) {
245
+ // The largest trailing bracketed span: walk back from the end to the
246
+ // last closing bracket, then forward to the matching opener.
247
+ const close = Math.max(text.lastIndexOf("}"), text.lastIndexOf("]"));
248
+ if (close !== -1) {
249
+ const opener = text[close] === "}" ? "{" : "[";
250
+ for (let i = text.indexOf(opener); i !== -1 && i < close; i = text.indexOf(opener, i + 1)) {
251
+ candidates.push(text.slice(i, close + 1));
252
+ }
253
+ }
254
+ }
255
+ let lastError = "no JSON value found in the reply";
256
+ for (const c of candidates) {
257
+ try {
258
+ return { ok: true, value: JSON.parse(c) };
259
+ }
260
+ catch (err) {
261
+ lastError = `could not parse: ${err instanceof Error ? err.message : String(err)}`;
262
+ }
263
+ }
264
+ return { ok: false, reason: lastError };
265
+ }
266
+ function describeJson(value) {
267
+ if (Array.isArray(value))
268
+ return `an array of ${value.length}`;
269
+ if (value && typeof value === "object")
270
+ return `an object with keys ${Object.keys(value).slice(0, 8).join(", ")}`;
271
+ return `a ${typeof value}`;
272
+ }
273
+ // ----------------------------------------------------------------- verify:
274
+ /** The eval vocabulary a `verify:` may borrow. Anything else is a shell command. */
275
+ const VERIFY_ASSERTION = /^(contains|not-contains|matches|file|judge):\s*([\s\S]+)$/;
276
+ /**
277
+ * Decide a `verify:`. Two dialects, one key: an eval assertion, when the
278
+ * value starts with one of the eval file's assertion words, or a shell
279
+ * command. The assertion form exists so that a flow and an eval share a
280
+ * vocabulary — "the output must mention the price" is the same sentence in
281
+ * both places — and so that the commonest checks need no shell at all.
282
+ */
283
+ export async function checkVerify(agentDir, verify, ctx) {
284
+ const m = verify.trim().match(VERIFY_ASSERTION);
285
+ if (!m) {
286
+ const { code, out } = await runVerify(agentDir, verify, ctx.env, ctx.data);
287
+ return { ok: code === 0, headline: `exit ${code ?? "error"}`, detail: out };
288
+ }
289
+ const [, kind, rawValue] = m;
290
+ const value = rawValue.trim().replace(/^["']|["']$/g, "");
291
+ const output = (kind === "judge" ? ctx.result : (ctx.conclusion ?? ctx.result)) ?? "";
292
+ const hay = output.toLowerCase();
293
+ switch (kind) {
294
+ case "contains": {
295
+ const ok = hay.includes(value.toLowerCase());
296
+ return { ok, headline: ok ? "found" : `"${value}" not in the reply`, detail: "" };
297
+ }
298
+ case "not-contains": {
299
+ const ok = !hay.includes(value.toLowerCase());
300
+ return { ok, headline: ok ? "absent" : `"${value}" appeared in the reply`, detail: "" };
301
+ }
302
+ case "matches": {
303
+ try {
304
+ const ok = new RegExp(value, "i").test(output);
305
+ return { ok, headline: ok ? "matched" : "no match", detail: "" };
306
+ }
307
+ catch {
308
+ return { ok: false, headline: "invalid regular expression", detail: "" };
309
+ }
310
+ }
311
+ case "file": {
312
+ const target = path.resolve(agentDir, value);
313
+ if (!target.startsWith(path.resolve(agentDir) + path.sep)) {
314
+ return { ok: false, headline: "path escapes the agent directory", detail: "" };
315
+ }
316
+ const ok = fs.existsSync(target) && fs.statSync(target).size > 0;
317
+ return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
318
+ }
319
+ case "judge": {
320
+ const verdict = await judgeReply(value, output, ctx.modelEnv ?? {});
321
+ const ok = /^\s*PASS\b/i.test(verdict);
322
+ return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
323
+ }
324
+ }
325
+ return { ok: false, headline: `unknown check "${kind}"`, detail: "" };
326
+ }
327
+ /**
328
+ * A toolless, fast-tier grading call: does the reply satisfy the sentence?
329
+ * Same shape as the eval judge, and answered with one word first so the
330
+ * verdict is a prefix test rather than a reading.
331
+ */
332
+ async function judgeReply(rubric, output, env) {
333
+ const texts = [];
334
+ try {
335
+ const q = query({
336
+ prompt: `You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
337
+ `first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
338
+ `<reply>\n${output.slice(0, 40_000)}\n</reply>`,
339
+ options: {
340
+ model: "haiku",
341
+ systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
342
+ tools: [],
343
+ settingSources: [],
344
+ env,
345
+ maxTurns: 1,
346
+ },
347
+ });
348
+ for await (const message of q) {
349
+ if (message.type === "assistant") {
350
+ for (const block of message.message.content) {
351
+ if (block.type === "text")
352
+ texts.push(block.text);
353
+ }
354
+ }
355
+ }
356
+ }
357
+ catch (err) {
358
+ return `FAIL — the judge could not run: ${err instanceof Error ? err.message : String(err)}`;
359
+ }
360
+ return texts.join("\n").trim();
361
+ }
362
+ // Verification runs in the agent's directory with its secrets available, so
363
+ // a check can be as simple as `npm run build` or `test -s outputs/report.md`.
364
+ function runVerify(agentDir, command, env, data) {
365
+ return new Promise((resolve) => {
366
+ // No clock of the platform's: a verify like `npm run build` takes what
367
+ // it takes, and the step's own `timeout:` is the bound if the flow set one.
368
+ const child = spawn("bash", ["-lc", command], {
369
+ cwd: agentDir,
370
+ env: { ...process.env, ...env },
371
+ stdio: ["pipe", "pipe", "pipe"],
372
+ });
373
+ // An `output: json` step's data arrives on stdin, so a check can be
374
+ // `jq -e '.total > 0'` — arithmetic in a real tool, reading the value
375
+ // the step actually returned rather than re-parsing its prose.
376
+ child.stdin.on("error", () => { });
377
+ if (data !== undefined)
378
+ child.stdin.end(JSON.stringify(data));
379
+ else
380
+ child.stdin.end();
381
+ let out = "";
382
+ const append = (c) => {
383
+ if (out.length < 4000)
384
+ out += c.toString();
385
+ };
386
+ child.stdout.on("data", append);
387
+ child.stderr.on("data", append);
388
+ child.on("error", (e) => resolve({ code: null, out: e.message }));
389
+ child.on("close", (code) => resolve({ code, out }));
390
+ });
391
+ }
@@ -0,0 +1,123 @@
1
+ import { STORAGE_DIR } from "./store.ts";
2
+ /** The directory a run sees, and the one this module mirrors into it.
3
+ * Declared in store.ts, where saveWorkspace also needs it; re-exported so
4
+ * callers of this module do not need to know that. */
5
+ export { STORAGE_DIR };
6
+ export interface FileRecord {
7
+ /** Path under storage/, forward-slashed. The primary key: it is also exactly
8
+ * what the agent typed, which is why the record is keyed by it and not by
9
+ * a generated id nobody could reach from inside a run. */
10
+ path: string;
11
+ /** Content hash. Blobs are stored under it, so re-uploading the same bytes
12
+ * under a second name costs nothing and a rename moves no data. */
13
+ sha: string;
14
+ size: number;
15
+ mime: string;
16
+ updatedAt: string;
17
+ /** Provenance: `user:<email>` or `run:<runId>:<agent>`. The reason the
18
+ * dashboard can answer "which run produced this?" without a join table. */
19
+ by: string;
20
+ }
21
+ interface Index {
22
+ version: 1;
23
+ files: FileRecord[];
24
+ }
25
+ /**
26
+ * Hosted only. On a laptop `storage/` is just a folder in the user's own
27
+ * project — they can see it, open it, and put a PDF in it with Finder — and
28
+ * an index plus a second hardlinked copy under data/ would be bookkeeping
29
+ * for a problem nobody has. The store exists to give many tenants a shared
30
+ * bucket and a provenance trail; one person with one directory has neither
31
+ * question to answer.
32
+ *
33
+ * Everything public here checks this first and does nothing, so the CLI runs
34
+ * the same driveRun the platform does and simply skips the mirror.
35
+ */
36
+ export declare function fileStoreEnabled(): boolean;
37
+ /** The object key. Tenant-first so a bucket policy can be written per tenant,
38
+ * and content-addressed so objects are immutable — no cache invalidation,
39
+ * no partially-overwritten object, and a repeated upload is a no-op. */
40
+ export declare function blobKey(tenant: string, workspace: string, sha: string): string;
41
+ /**
42
+ * Reject anything that isn't a plain relative path under storage/.
43
+ *
44
+ * This runs on names that came from an upload form *and* on paths harvested
45
+ * out of a container, so it is the boundary for both directions. `..` is the
46
+ * obvious one; a leading slash and a Windows drive letter are the two that
47
+ * path.normalize alone will happily keep.
48
+ */
49
+ export declare function assertFilePath(rel: string): string;
50
+ export declare function mimeFor(rel: string): string;
51
+ export declare function readIndex(tenant: string, workspace: string): Index;
52
+ export declare function listStorage(tenant: string, workspace: string): FileRecord[];
53
+ export declare function getFile(tenant: string, workspace: string, rel: string): FileRecord | null;
54
+ export declare function usedBytes(tenant: string, workspace: string): number;
55
+ export interface Driver {
56
+ kind: "fs" | "s3";
57
+ put(key: string, body: Buffer, mime: string): Promise<void>;
58
+ get(key: string): Promise<Buffer>;
59
+ has(key: string): Promise<boolean>;
60
+ remove(key: string): Promise<void>;
61
+ /** A URL a browser can GET directly, or null when the driver has no such
62
+ * thing (fs) and the route must stream the bytes itself. */
63
+ presignGet(key: string, filename: string, mime: string, ttlSec?: number): Promise<string | null>;
64
+ /** A URL a browser can PUT directly to, so upload bytes never transit the
65
+ * dashboard process. Null on fs, where the route takes the body. */
66
+ presignPut(key: string, mime: string, ttlSec?: number): Promise<string | null>;
67
+ }
68
+ /** Where the fs driver keeps a blob. Exported because the run mirror
69
+ * hardlinks straight to it rather than reading and rewriting the bytes. */
70
+ export declare function blobPath(tenant: string, workspace: string, sha: string): string;
71
+ interface S3Config {
72
+ endpoint: string;
73
+ bucket: string;
74
+ region: string;
75
+ accessKeyId: string;
76
+ secretAccessKey: string;
77
+ pathStyle: boolean;
78
+ }
79
+ export declare function s3Config(): S3Config | null;
80
+ /**
81
+ * The driver in force. `fs` unless S3 is both selected and configured —
82
+ * a half-set environment falls back rather than failing at the first upload,
83
+ * because a dashboard that boots and stores locally beats one that 500s.
84
+ */
85
+ export declare function driverFor(tenant: string, workspace: string): Driver;
86
+ export declare function putFile(tenant: string, workspace: string, rel: string, body: Buffer, by: string): Promise<FileRecord>;
87
+ /** Register bytes a browser PUT straight to the bucket. Same bookkeeping as
88
+ * putFile, minus the bytes — the platform never saw them. */
89
+ export declare function registerUpload(tenant: string, workspace: string, rel: string, sha: string, size: number, by: string): Promise<FileRecord>;
90
+ export declare function readFileBytes(tenant: string, workspace: string, rel: string): Promise<Buffer | null>;
91
+ export declare function deleteFile(tenant: string, workspace: string, rel: string): Promise<boolean>;
92
+ /** A link the browser can follow, or null when the route must stream it. */
93
+ export declare function downloadUrl(tenant: string, workspace: string, record: FileRecord): Promise<string | null>;
94
+ /** A link the browser can PUT to, plus the key it will land at. Null on fs. */
95
+ export declare function uploadUrl(tenant: string, workspace: string, rel: string, sha: string): Promise<string | null>;
96
+ /**
97
+ * Put the workspace's files on disk under `<workspace>/storage/`, where a run
98
+ * copies them into its container like any other directory.
99
+ *
100
+ * On the fs driver this is a hardlink: the blob and the mirror are the same
101
+ * inode, so a 2GB corpus costs 2GB once, not twice. On s3 it is a download,
102
+ * and the mirror is a cache — a second run over the same files fetches
103
+ * nothing.
104
+ *
105
+ * Not pruned. A workspace that churns hundreds of large files will hold a
106
+ * copy of each on the PVC until someone deletes the record, and the quota is
107
+ * what stops that becoming a full disk. Pruning wants an LRU that knows no
108
+ * run is currently reading the file it is about to remove, and that bookkeeping
109
+ * is worth writing when a workspace actually outgrows its quota, not before.
110
+ */
111
+ export declare function materializeFiles(tenant: string, workspace: string): Promise<string[]>;
112
+ /**
113
+ * Take what the run left in `storage/` back into the store, stamped with the
114
+ * run that wrote it.
115
+ *
116
+ * Compares against the index rather than a pre-run snapshot: the mirror is
117
+ * derived from the index, so anything on disk that the index does not already
118
+ * describe with the same size and hash is, by construction, new work.
119
+ */
120
+ export declare function harvestFiles(tenant: string, workspace: string, by: string): Promise<{
121
+ saved: string[];
122
+ errors: string[];
123
+ }>;