@foldrun/core 1.4.2 → 2.1.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 (57) hide show
  1. package/dist/index.d.ts +0 -3
  2. package/dist/index.js +0 -3
  3. package/dist/src/catalog.d.ts +7 -0
  4. package/dist/src/catalog.js +16 -1
  5. package/dist/src/deploy.d.ts +6 -27
  6. package/dist/src/deploy.js +14 -56
  7. package/dist/src/history.d.ts +30 -10
  8. package/dist/src/history.js +232 -99
  9. package/dist/src/library.d.ts +0 -13
  10. package/dist/src/library.js +0 -50
  11. package/dist/src/model-credential.js +1 -1
  12. package/dist/src/model-env.js +1 -1
  13. package/dist/src/platform.d.ts +1 -3
  14. package/dist/src/platform.js +0 -1
  15. package/dist/src/run-container.d.ts +31 -5
  16. package/dist/src/run-container.js +71 -3
  17. package/dist/src/runner.d.ts +2 -0
  18. package/dist/src/runner.js +20 -12
  19. package/dist/src/secrets.js +1 -12
  20. package/dist/src/starter.js +2 -2
  21. package/dist/src/step-exec.d.ts +23 -5
  22. package/dist/src/step-exec.js +30 -23
  23. package/dist/src/store.d.ts +61 -22
  24. package/dist/src/store.js +105 -25
  25. package/dist/src/tool-loop.d.ts +4 -1
  26. package/dist/src/tool-loop.js +2 -0
  27. package/dist/src/translator.d.ts +12 -0
  28. package/dist/src/translator.js +36 -13
  29. package/index.ts +0 -3
  30. package/package.json +1 -3
  31. package/src/catalog.ts +18 -2
  32. package/src/deploy.ts +13 -74
  33. package/src/history.ts +230 -108
  34. package/src/library.ts +0 -44
  35. package/src/model-credential.ts +1 -1
  36. package/src/model-env.ts +1 -1
  37. package/src/platform.ts +1 -4
  38. package/src/run-container.ts +78 -5
  39. package/src/runner.ts +22 -12
  40. package/src/secrets.ts +1 -11
  41. package/src/starter.ts +2 -2
  42. package/src/step-exec.ts +44 -27
  43. package/src/store.ts +145 -37
  44. package/src/tool-loop.ts +6 -1
  45. package/src/translator.ts +39 -15
  46. package/dist/src/git-hook.d.ts +0 -1
  47. package/dist/src/git-hook.js +0 -70
  48. package/dist/src/git.d.ts +0 -37
  49. package/dist/src/git.js +0 -101
  50. package/dist/src/gitrepo.d.ts +0 -113
  51. package/dist/src/gitrepo.js +0 -404
  52. package/dist/src/tar.d.ts +0 -30
  53. package/dist/src/tar.js +0 -112
  54. package/src/git-hook.ts +0 -73
  55. package/src/git.ts +0 -117
  56. package/src/gitrepo.ts +0 -439
  57. package/src/tar.ts +0 -125
@@ -30,6 +30,42 @@ import { safeTenantSegment, systemInstallSh, SYSTEM_FROM_INPUT_SH, INSTALL_SOCKE
30
30
  import { egressCaSh } from "./network.js";
31
31
  import { isOperatorEvent } from "./operator.js";
32
32
  import { parseBrowserPod } from "./browser-pod.js";
33
+ /**
34
+ * The start-up of one step, in the order it happens. Each is the moment the
35
+ * phase ENDED, in ms since the executor began the step, so the gap between
36
+ * two neighbours is what the later one cost (FOL-29: "pod ready" was the
37
+ * only number, and the six seconds after it had no names).
38
+ *
39
+ * The executor marks its own: `services` (the account's search/browser pods
40
+ * up), `room` (room on the cluster to start — platform pod-room.ts),
41
+ * `applied` (pod created), `ready`, `files` (the workspace copied in),
42
+ * `go` (the step released). The sandbox marks the rest as `{"e":"mark"}`
43
+ * lines: `shim` (the shim saw go), `system` (system packages restored),
44
+ * `driver` (node up), `imports` (core loaded), `tools` (runtime and tools
45
+ * built, the SDK about to start) and `model` (the model's first text or tool
46
+ * call). A worker that predates a mark ignores the line — parseDriverLine
47
+ * answers null for an `e` it does not know.
48
+ */
49
+ export const STARTUP_PHASES = [
50
+ "services", "room", "applied", "ready", "files", "go",
51
+ "shim", "system", "driver", "imports", "tools", "model",
52
+ ];
53
+ /** A sandbox-side mark: which phase ended, and when (epoch ms, the
54
+ * sandbox's clock — the same machine's as the worker's on one node). */
55
+ export function parseDriverMark(line) {
56
+ const trimmed = line.trim();
57
+ if (!trimmed.startsWith('{"e":"mark"'))
58
+ return null;
59
+ try {
60
+ const parsed = JSON.parse(trimmed);
61
+ if (parsed.e !== "mark" || typeof parsed.at !== "number" || !Number.isFinite(parsed.at))
62
+ return null;
63
+ return STARTUP_PHASES.includes(parsed.name) ? { name: parsed.name, at: parsed.at } : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
33
69
  /**
34
70
  * The one place the timing line is worded, so the docker and k8s traces
35
71
  * read the same and a cold-start regression is greppable across both.
@@ -37,7 +73,11 @@ import { parseBrowserPod } from "./browser-pod.js";
37
73
  export function timingLine(t) {
38
74
  const s = (ms) => `${(ms / 1000).toFixed(2)}s`;
39
75
  const first = t.firstOutputMs === null ? "no output" : `first output +${s(t.firstOutputMs)}`;
40
- return `timing: sandbox ready in ${s(t.sandboxMs)}, ${first}, ${s(t.totalMs)} total`;
76
+ const phases = t.phases
77
+ ? STARTUP_PHASES.filter((p) => typeof t.phases[p] === "number").map((p) => `${p} ${s(t.phases[p])}`)
78
+ : [];
79
+ return `timing: sandbox ready in ${s(t.sandboxMs)}, ${first}, ${s(t.totalMs)} total` +
80
+ (phases.length ? ` · start-up: ${phases.join(" → ")}` : "");
41
81
  }
42
82
  const cli = () => process.env.FOLDRUN_CONTAINER_CLI ?? "docker";
43
83
  // Paths that must never come back from a container, whatever happened in
@@ -374,7 +414,17 @@ const DRIVER = `// The runner container's entrypoint. Reads one step, executes i
374
414
  // same core the server uses, speaks JSONL on stdout, exits.
375
415
  import fs from "node:fs";
376
416
  const input = JSON.parse(fs.readFileSync("/opt/runner/job/input.json", "utf8"));
377
- const emit = (type, text, extra) => process.stdout.write(JSON.stringify({ e: "event", type, text, ...extra }) + "\\n");
417
+ // Start-up marks (run-container.ts#STARTUP_PHASES): which phase just ended,
418
+ // on the sandbox's clock. A worker that does not know a mark skips the line.
419
+ const mark = (name) => process.stdout.write(JSON.stringify({ e: "mark", name, at: Date.now() }) + "\\n");
420
+ mark("driver");
421
+ let modelSpoke = false;
422
+ const emit = (type, text, extra) => {
423
+ // The model's first text or tool call is where start-up ends and the
424
+ // step's own work begins — runtime and tool lines before it are set-up.
425
+ if (!modelSpoke && (type === "text" || type === "tool")) { modelSpoke = true; mark("model"); }
426
+ process.stdout.write(JSON.stringify({ e: "event", type, text, ...extra }) + "\\n");
427
+ };
378
428
  // What this sandbox actually touched, read at the last moment from the
379
429
  // kernel's own books: cgroup v2 for CPU busy-time and peak memory,
380
430
  // /proc/net/dev for bytes on the wire. Every read is best-effort — gVisor
@@ -430,6 +480,7 @@ try {
430
480
  const { prepareRuntime } = await import("@foldrun/core/runtime");
431
481
  const { materializeFileSecrets } = await import("@foldrun/core/secret-files");
432
482
  const { paidWebActions } = await import("@foldrun/core/browser-pod");
483
+ mark("imports");
433
484
 
434
485
  const agentDir = "/workspace/" + input.agentRel;
435
486
  // HOME is where a bare relative path lands when anything resolves one
@@ -512,6 +563,7 @@ try {
512
563
  const ask = input.ask ? buildAskTool({ channel, timeoutSec: input.ask.timeoutSec, emit }) : { server: null };
513
564
  const inbox = channel ? inboxReader(channel, emit) : undefined;
514
565
 
566
+ mark("tools");
515
567
  const outcome = await executeStep({
516
568
  agentDir,
517
569
  workspaceRoot: "/workspace",
@@ -1016,7 +1068,14 @@ export function parseDriverLine(line) {
1016
1068
  usage: parsed.usage &&
1017
1069
  typeof parsed.usage.inputTokens === "number" &&
1018
1070
  typeof parsed.usage.outputTokens === "number"
1019
- ? { inputTokens: parsed.usage.inputTokens, outputTokens: parsed.usage.outputTokens }
1071
+ ? {
1072
+ inputTokens: parsed.usage.inputTokens,
1073
+ outputTokens: parsed.usage.outputTokens,
1074
+ // A driver from before the split sends neither; zero, not
1075
+ // unknown, keeps the record's shape one thing.
1076
+ cacheReadTokens: typeof parsed.usage.cacheReadTokens === "number" ? parsed.usage.cacheReadTokens : 0,
1077
+ cacheWriteTokens: typeof parsed.usage.cacheWriteTokens === "number" ? parsed.usage.cacheWriteTokens : 0,
1078
+ }
1020
1079
  : null,
1021
1080
  res: parsed.res && typeof parsed.res === "object"
1022
1081
  ? {
@@ -1260,6 +1319,8 @@ export async function runStepInContainer(args) {
1260
1319
  throw new Error(`container create failed:\n${create.stderr}`);
1261
1320
  containerId = create.stdout.trim();
1262
1321
  const sandboxMs = Date.now() - t0;
1322
+ // Docker's phases are measured from create, its rent clock.
1323
+ const phases = { ready: sandboxMs };
1263
1324
  const cp = (from, to) => {
1264
1325
  const out = spawnSync(cli(), ["cp", from, to], { encoding: "utf8" });
1265
1326
  if (out.status !== 0)
@@ -1268,6 +1329,7 @@ export async function runStepInContainer(args) {
1268
1329
  cp(`${wsIn}/.`, `${containerId}:/workspace`);
1269
1330
  cp(`${libIn}/.`, `${containerId}:/library`);
1270
1331
  cp(`${jobIn}/input.json`, `${containerId}:/opt/runner/job/input.json`);
1332
+ phases.files = Date.now() - t0;
1271
1333
  // Start attached and read the driver's JSONL. The step timeout gets a
1272
1334
  // backstop out here — a wedged container is killed, not waited on.
1273
1335
  let firstOutputAt = null;
@@ -1292,6 +1354,11 @@ export async function runStepInContainer(args) {
1292
1354
  break;
1293
1355
  const line = buffer.slice(0, nl);
1294
1356
  buffer = buffer.slice(nl + 1);
1357
+ const marked = parseDriverMark(line);
1358
+ if (marked) {
1359
+ phases[marked.name] ??= Math.max(0, marked.at - t0);
1360
+ continue;
1361
+ }
1295
1362
  const parsed = parseDriverLine(line);
1296
1363
  if (!parsed)
1297
1364
  continue;
@@ -1339,6 +1406,7 @@ export async function runStepInContainer(args) {
1339
1406
  sandboxMs,
1340
1407
  firstOutputMs: firstOutputAt === null ? null : firstOutputAt - t0 - sandboxMs,
1341
1408
  totalMs: Date.now() - t0,
1409
+ phases,
1342
1410
  };
1343
1411
  args.emit("info", timingLine(timing));
1344
1412
  return { ...outcome, timing };
@@ -354,6 +354,8 @@ grantsOnly?: boolean): {
354
354
  export declare function repriced(catalog: Catalog | null, model: string, usage: {
355
355
  inputTokens: number;
356
356
  outputTokens: number;
357
+ cacheReadTokens?: number;
358
+ cacheWriteTokens?: number;
357
359
  } | null, sdkCost: number | null, push: (type: "info" | "error", text: string) => void,
358
360
  /** The step's turns each priced at its own model — set when a sub-agent
359
361
  * ran on a different model (ExecOutcome.turnsCostUsd). The lump `usage`
@@ -33,7 +33,7 @@ import { ASK_TOOL, applyOperatorEvent, askPromptLine, askTimeoutSec, buildAskToo
33
33
  import { sendPlainNotification } from "./notify.js";
34
34
  import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
35
35
  import { refNames } from "./refs.js";
36
- import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, verdictOf, sumAttempts, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
36
+ import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
37
37
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel } from "./catalog.js";
38
38
  import { resolveSecrets, getSecret, materializeSecrets, secretSites } from "./secrets.js";
39
39
  import { resolveNetwork, networkLine, proxyEnv, EGRESS_CA_ENV } from "./network.js";
@@ -1876,8 +1876,12 @@ stepIndex) {
1876
1876
  // that names a provider uses its own; otherwise the platform's (in a
1877
1877
  // run container) or this process's — never a login that just happens
1878
1878
  // to be on the machine (model-credential.ts).
1879
- const credential = providerLabel ? null : resolveModelCredential(isolatedRun() ? platformModelEnv() : process.env);
1880
- step.credential = credential ? credential.label : `provider: ${providerLabel}`;
1879
+ // An account refused the platform's credential (below) is never shown
1880
+ // running on it: the record said "Claude login token" on a step that
1881
+ // was then refused for having no key (FOL-14, 18 Oct 2026).
1882
+ const noKey = !providerLabel && !platform.platformKeyAllowed(tenant);
1883
+ const credential = providerLabel || noKey ? null : resolveModelCredential(isolatedRun() ? platformModelEnv() : process.env);
1884
+ step.credential = credential ? credential.label : noKey ? "none — this account has no model key" : `provider: ${providerLabel}`;
1881
1885
  if (credential) {
1882
1886
  const line = credentialLine(credential);
1883
1887
  push(line.type, line.text);
@@ -1890,7 +1894,7 @@ stepIndex) {
1890
1894
  const own = Object.values(providerSecrets).concat(Object.values(providerEnv));
1891
1895
  if (!providerLabel) {
1892
1896
  push("error", `no model key: this account runs on its own API key, and none is set. Add one in Settings → Model ` +
1893
- `(or \`foldrun model set <provider> --key …\`), or write a provider: block in AGENTS.md`);
1897
+ `(or \`foldrun account model set <provider> --key …\`), or write a provider: block in AGENTS.md`);
1894
1898
  step.status = "failed";
1895
1899
  save();
1896
1900
  return;
@@ -2621,7 +2625,7 @@ stepIndex) {
2621
2625
  step.podLostTry = {
2622
2626
  status: "lost",
2623
2627
  costUsd: repriced(catalog, wireModel, first.usage ?? null, first.costUsd, push, first.turnsCostUsd),
2624
- tokens: first.usage ? { input: first.usage.inputTokens, output: first.usage.outputTokens } : null,
2628
+ tokens: tokensOf(first.usage),
2625
2629
  computeSecs: first.timing ? Math.round(first.timing.totalMs) / 1000 : null,
2626
2630
  finishedAt: new Date().toISOString(),
2627
2631
  error: decision.note.slice(0, 500),
@@ -2671,9 +2675,7 @@ stepIndex) {
2671
2675
  if (outcome.data !== undefined)
2672
2676
  step.data = redactData(outcome.data);
2673
2677
  step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push, outcome.turnsCostUsd);
2674
- step.tokens = outcome.usage
2675
- ? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
2676
- : null;
2678
+ step.tokens = tokensOf(outcome.usage);
2677
2679
  step.actual = outcome.res ?? null;
2678
2680
  if (outcome.res?.peakMemBytes || outcome.res?.rxBytes) {
2679
2681
  const mb = (n) => (n === null ? "?" : (n / 1024 / 1024).toFixed(0));
@@ -2687,6 +2689,12 @@ stepIndex) {
2687
2689
  const secs = (ms) => Math.round(ms) / 1000;
2688
2690
  step.computeSecs = outcome.timing ? secs(outcome.timing.totalMs) : null;
2689
2691
  step.startupSecs = outcome.timing ? secs(outcome.timing.sandboxMs) : null;
2692
+ // Where the start-up went, phase by phase (run-container.ts#STARTUP_PHASES),
2693
+ // in seconds — what the cold-start numbers are computed from.
2694
+ const phases = outcome.timing?.phases;
2695
+ step.startup = phases && Object.keys(phases).length
2696
+ ? Object.fromEntries(Object.entries(phases).map(([k, v]) => [k, secs(v)]))
2697
+ : null;
2690
2698
  // Which reservation those seconds held — as numbers, because the bill
2691
2699
  // is per core-second and per GiB-second, and what was held must come
2692
2700
  // from the record, never from what the config says at billing time.
@@ -2864,9 +2872,7 @@ stepIndex) {
2864
2872
  const consultCost = consultTools.drainCost();
2865
2873
  const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push, outcome.turnsCostUsd);
2866
2874
  step.costUsd = stepCost === null && consultCost === 0 ? null : (stepCost ?? 0) + consultCost;
2867
- step.tokens = outcome.usage
2868
- ? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
2869
- : null;
2875
+ step.tokens = tokensOf(outcome.usage);
2870
2876
  for (const line of apiTools.drainLog())
2871
2877
  push("info", `api: ${line}`);
2872
2878
  for (const line of scriptTools.drainLog())
@@ -4768,7 +4774,9 @@ function lastActivity(run) {
4768
4774
  export function reconcileRuns(tenant, now = Date.now(), live) {
4769
4775
  const closed = [];
4770
4776
  for (const workspace of listWorkspaces(tenant)) {
4771
- for (const summary of listRuns(tenant, workspace.name)) {
4777
+ // Live runs only, read whole — every other status is passed over below,
4778
+ // and parsing the workspace's whole history every sweep was the cost.
4779
+ for (const summary of liveRuns(tenant, workspace.name)) {
4772
4780
  if (drivingRuns.has(summary.id))
4773
4781
  continue;
4774
4782
  // Driven by another process — a second worker pod, on a platform with
@@ -218,11 +218,9 @@ export function setSecretSites(tenant, name, sites, workspace) {
218
218
  export function secretSites(tenant, names, workspace) {
219
219
  const out = {};
220
220
  const own = workspace ? read(tenant, workspace) : {};
221
- const source = workspace ? platform.previewSourceOf(tenant, workspace) : null;
222
- const inherited = source ? read(tenant, source) : {};
223
221
  const account = read(tenant);
224
222
  for (const name of names) {
225
- const rec = own[name] ?? inherited[name] ?? account[name];
223
+ const rec = own[name] ?? account[name];
226
224
  if (rec?.sites?.length)
227
225
  out[name] = [...rec.sites];
228
226
  }
@@ -298,15 +296,6 @@ export function getSecret(tenant, name, workspace) {
298
296
  const own = decrypt(tenant, read(tenant, workspace)[name]);
299
297
  if (own !== null)
300
298
  return { value: own, scope: "workspace" };
301
- // A preview is a branch of its source workspace, and the branch's
302
- // agents need the same credentials the source's do. Its own store is
303
- // still consulted first, so a preview can override one on purpose.
304
- const source = platform.previewSourceOf(tenant, workspace);
305
- if (source) {
306
- const inherited = decrypt(tenant, read(tenant, source)[name]);
307
- if (inherited !== null)
308
- return { value: inherited, scope: "workspace" };
309
- }
310
299
  }
311
300
  const shared = decrypt(tenant, read(tenant)[name]);
312
301
  return shared === null ? null : { value: shared, scope: "account" };
@@ -373,8 +373,8 @@ Debugging a failed run is usually: \`runs\` to find it, \`runs/<id>\` to read wh
373
373
  step failed and what it cost, fix the markdown, \`deploy\`, then \`rerun\`.
374
374
 
375
375
  Every route, with request and response shapes, is in \`docs/api.md\` in the
376
- foldrun repository — including secrets, keys, billing, OAuth, webhooks and the
377
- git push endpoint.
376
+ foldrun repository — including secrets, keys, billing, OAuth, webhooks and
377
+ restore.
378
378
 
379
379
  ## Writing a flow
380
380
 
@@ -6,6 +6,17 @@ import type { OperatorEvent } from "./operator.ts";
6
6
  import { type SubagentSpec } from "./subagents.ts";
7
7
  import { type Limits } from "./limits.ts";
8
8
  import { type BrowserPodOutcome, type ClassifyContext } from "./browser-pod.ts";
9
+ export interface ExecUsage {
10
+ inputTokens: number;
11
+ outputTokens: number;
12
+ cacheReadTokens: number;
13
+ cacheWriteTokens: number;
14
+ }
15
+ /** The four counts from one SDK usage object (snake_case, as the API
16
+ * reports it). The API counts fresh input apart from the cache's; here
17
+ * `inputTokens` is all of it, as it always was on the record, and the
18
+ * cache fields say how much of that the cache served or stored. */
19
+ export declare function usageFromSdk(u: Record<string, number | undefined>): ExecUsage;
9
20
  export interface ExecOutcome {
10
21
  status: "completed" | "failed";
11
22
  result: string | null;
@@ -20,11 +31,12 @@ export interface ExecOutcome {
20
31
  costUsd: number | null;
21
32
  /** Token counts off the SDK's result message. costUsd is priced from
22
33
  * Anthropic's table, which is wrong for a routed model — these are the
23
- * raw numbers a caller with a gateway's own prices can reprice from. */
24
- usage: {
25
- inputTokens: number;
26
- outputTokens: number;
27
- } | null;
34
+ * raw numbers a caller with a gateway's own prices can reprice from.
35
+ * `inputTokens` is every input token; `cacheReadTokens` and
36
+ * `cacheWriteTokens` are the part of it the prompt cache served or
37
+ * stored, so a usage page can say how much of a step's context was
38
+ * re-read for a tenth of the price. */
39
+ usage: ExecUsage | null;
28
40
  /** Set only when some turns ran on a different model from the step's — a
29
41
  * sub-agent with its own `model:`. The sum of every turn, each priced at
30
42
  * its own model. `usage` above is one lump and cannot be repriced at the
@@ -178,6 +190,12 @@ export declare function knownPrice(model: string | undefined): {
178
190
  /**
179
191
  * What one assistant turn cost, from the usage the SDK reports. Pure.
180
192
  *
193
+ * Cache reads and writes are priced at Anthropic's multipliers
194
+ * (catalog.ts). Counting both at the full input rate — as this did —
195
+ * makes a long cached prompt read about ten times its price, which on a
196
+ * ceiling is not "the honest direction to be wrong in", it is a step
197
+ * killed for spending money it never spent.
198
+ *
181
199
  * A gateway whose cache is priced differently is over- or under-counted
182
200
  * here by that difference alone; the run record's own cost is repriced
183
201
  * from the catalogue afterwards and stays the authority on the bill. This
@@ -12,6 +12,7 @@ import fs from "node:fs";
12
12
  import os from "node:os";
13
13
  import path from "node:path";
14
14
  import { query } from "@anthropic-ai/claude-agent-sdk";
15
+ import { CACHE_READ_MULTIPLIER, CACHE_WRITE_MULTIPLIER } from "./catalog.js";
15
16
  import { spawn } from "node:child_process";
16
17
  import { checkPaths, checkBash, isFilesystemTool, linkWorkspace, workspaceLinkEnv, resolveAgentPath, isWithin } from "./confine.js";
17
18
  import { DELEGATE_TOOLS, subagentGuard, toAgentDefinitions } from "./subagents.js";
@@ -19,6 +20,18 @@ import { hostSafeEnv } from "./host-env.js";
19
20
  import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-schema.js";
20
21
  import { CallCounter, limitKeysFor } from "./limits.js";
21
22
  import { classifyCall, podAt, readPodEvents } from "./browser-pod.js";
23
+ /** The four counts from one SDK usage object (snake_case, as the API
24
+ * reports it). The API counts fresh input apart from the cache's; here
25
+ * `inputTokens` is all of it, as it always was on the record, and the
26
+ * cache fields say how much of that the cache served or stored. */
27
+ export function usageFromSdk(u) {
28
+ return {
29
+ inputTokens: (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0),
30
+ outputTokens: u.output_tokens ?? 0,
31
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
32
+ cacheWriteTokens: u.cache_creation_input_tokens ?? 0,
33
+ };
34
+ }
22
35
  /** How often the stop flag is read while a step runs. */
23
36
  export const STOP_POLL_MS = 2000;
24
37
  /** The least a `verify:` gets when the step used up most of its `timeout:` —
@@ -65,17 +78,15 @@ export function knownPrice(model) {
65
78
  return null;
66
79
  return KNOWN_PRICES.find(([re]) => re.test(model))?.[1] ?? null;
67
80
  }
68
- /** Anthropic's cache multipliers, and the shape every gateway that bills
69
- * for caching at all has copied: a write costs a quarter more than fresh
70
- * input, a read a tenth of it. Counting both at the full input rate — as
71
- * this did — makes a long cached prompt read about ten times its price,
72
- * which on a ceiling is not "the honest direction to be wrong in", it is
73
- * a step killed for spending money it never spent. */
74
- const CACHE_WRITE_MULTIPLIER = 1.25;
75
- const CACHE_READ_MULTIPLIER = 0.1;
76
81
  /**
77
82
  * What one assistant turn cost, from the usage the SDK reports. Pure.
78
83
  *
84
+ * Cache reads and writes are priced at Anthropic's multipliers
85
+ * (catalog.ts). Counting both at the full input rate — as this did —
86
+ * makes a long cached prompt read about ten times its price, which on a
87
+ * ceiling is not "the honest direction to be wrong in", it is a step
88
+ * killed for spending money it never spent.
89
+ *
79
90
  * A gateway whose cache is priced differently is over- or under-counted
80
91
  * here by that difference alone; the run record's own cost is repriced
81
92
  * from the catalogue afterwards and stays the authority on the bill. This
@@ -639,16 +650,10 @@ async function executeStepInner(opts, runQuery) {
639
650
  costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
640
651
  stopReason = "stop_reason" in message && typeof message.stop_reason === "string" ? message.stop_reason : null;
641
652
  if ("usage" in message && message.usage) {
642
- const u = message.usage;
643
- usage = {
644
- // Cache traffic is input the provider still bills (at its own
645
- // rates); folding it into the input count over-approximates for
646
- // gateways with cheaper cache reads, which errs on the honest side.
647
- inputTokens: (u.input_tokens ?? 0) +
648
- (u.cache_creation_input_tokens ?? 0) +
649
- (u.cache_read_input_tokens ?? 0),
650
- outputTokens: u.output_tokens ?? 0,
651
- };
653
+ // Cache reads and writes counted beside the input total: with only
654
+ // the total (until 18 Oct 2026) the hit rate could not be read and
655
+ // the catalogue repriced every cached token at the full rate.
656
+ usage = usageFromSdk(message.usage);
652
657
  }
653
658
  }
654
659
  }
@@ -671,16 +676,18 @@ async function executeStepInner(opts, runQuery) {
671
676
  // died — means no total from the SDK. The turns it did take were billed by
672
677
  // the provider all the same, so the step's cost is their sum.
673
678
  if (costUsd === null && turns.size) {
674
- let input = 0;
675
- let output = 0;
679
+ const summed = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
676
680
  let priced = 0;
677
681
  for (const { u: t, price } of turns.values()) {
678
- input += (t.input_tokens ?? 0) + (t.cache_creation_input_tokens ?? 0) + (t.cache_read_input_tokens ?? 0);
679
- output += t.output_tokens ?? 0;
682
+ const one = usageFromSdk(t);
683
+ summed.inputTokens += one.inputTokens;
684
+ summed.outputTokens += one.outputTokens;
685
+ summed.cacheReadTokens += one.cacheReadTokens;
686
+ summed.cacheWriteTokens += one.cacheWriteTokens;
680
687
  priced += priceTurn(t, price);
681
688
  }
682
689
  costUsd = priced;
683
- usage ??= { inputTokens: input, outputTokens: output };
690
+ usage ??= summed;
684
691
  emit("info", `cost from ${turns.size} model turn${turns.size === 1 ? "" : "s"}: $${priced.toFixed(4)} — the step ended before the model's closing total`);
685
692
  }
686
693
  if (ended === "timeout") {
@@ -125,6 +125,11 @@ export declare function saveWorkspace(tenant: string, workspace: string, files:
125
125
  * commit, which becomes the revision's id. */
126
126
  meta?: ChangeMeta & {
127
127
  commit?: string | null;
128
+ /** Keep every file that is not editable source — what an agent wrote
129
+ * into its own folder at run time (a browser profile, a PDF it
130
+ * downloaded). A restore sets it: history holds source, so a file it
131
+ * never tracked is not one a restore may take away. */
132
+ keepUntracked?: boolean;
128
133
  }): {
129
134
  preserved: number;
130
135
  };
@@ -754,15 +759,6 @@ export interface WorkspaceSummary {
754
759
  flows: number;
755
760
  deployedAt: string;
756
761
  runCount: number;
757
- /** Set when this workspace is a branch's preview of another (preview.ts):
758
- * which one, which branch, which commit. Read from the marker beside the
759
- * directory here rather than through preview.ts, which imports this file. */
760
- preview?: {
761
- source: string;
762
- branch: string;
763
- commit: string;
764
- at: string;
765
- };
766
762
  /** The workspace's picture, as a URL, when the page that lists it looked
767
763
  * one up (avatars.ts) — the store itself does not. */
768
764
  avatar?: string | null;
@@ -824,7 +820,7 @@ export interface ChangeMeta {
824
820
  }
825
821
  export declare function writeWorkspaceFile(tenant: string, workspace: string, rel: string, content: string, meta?: ChangeMeta): void;
826
822
  /** Tell the platform, and never let its listener fail a write. */
827
- declare function workspaceChanged(tenant: string, workspace: string, why: "write" | "deploy" | "push" | "delete"): void;
823
+ declare function workspaceChanged(tenant: string, workspace: string, why: "write" | "deploy" | "delete"): void;
828
824
  export { workspaceChanged as notifyWorkspaceChanged };
829
825
  export declare function syncBundleFor(file: string, change?: "Creation" | "Update"): void;
830
826
  /**
@@ -957,10 +953,7 @@ export interface StepAttempt {
957
953
  * of its own (image slim), before the full go's. */
958
954
  status: "completed" | "failed" | "lost";
959
955
  costUsd: number | null;
960
- tokens: {
961
- input: number;
962
- output: number;
963
- } | null;
956
+ tokens: TokenUsage | null;
964
957
  computeSecs: number | null;
965
958
  startedAt: string;
966
959
  finishedAt: string;
@@ -976,6 +969,44 @@ export interface StepAttempt {
976
969
  * attempt reported the figure — an in-process step rents no sandbox, and
977
970
  * billing reads null as zero, not as "unknown, charge something". */
978
971
  export declare function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs">;
972
+ /**
973
+ * The token counts behind a step's cost. `input` is every input token the
974
+ * model saw; `cacheRead` and `cacheWrite` are the part of it the prompt
975
+ * cache served or stored — a read costs a tenth of fresh input, and a usage
976
+ * page wants to say how much of a step's context was re-read rather than
977
+ * re-sent. Steps recorded before 18 Oct 2026 have no cache fields: their
978
+ * `input` is the same total, but how much of it was cached is unknown.
979
+ */
980
+ export interface TokenUsage {
981
+ input: number;
982
+ output: number;
983
+ cacheRead?: number;
984
+ cacheWrite?: number;
985
+ }
986
+ /** The record's shape from what a step reports (ExecOutcome.usage). */
987
+ export declare function tokensOf(usage: {
988
+ inputTokens: number;
989
+ outputTokens: number;
990
+ cacheReadTokens?: number;
991
+ cacheWriteTokens?: number;
992
+ } | null | undefined): TokenUsage | null;
993
+ /** Field-by-field sum; the cache fields are present in the result whenever
994
+ * any input had them, so a run of old and new steps still reads whole. */
995
+ export declare function sumTokens(list: readonly TokenUsage[]): TokenUsage;
996
+ /** The share of all input that the prompt cache served, 0–1; null when
997
+ * there was no input, or the record predates the split and cannot say. */
998
+ export declare function cacheHitRate(t: TokenUsage | null | undefined): number | null;
999
+ /** The cache share over several counts, taking only those that report the
1000
+ * split: a step recorded before it holds its cache traffic inside `input`,
1001
+ * and counting that as uncached would understate the rate. The rule the
1002
+ * Usage page and the CLI's totals follow too. */
1003
+ export declare function measuredHitRate(list: readonly (TokenUsage | null | undefined)[]): number | null;
1004
+ /** A run's token counts, summed over its steps; null when no step reported any. */
1005
+ export declare function runTokens(run: {
1006
+ steps: readonly {
1007
+ tokens?: TokenUsage | null;
1008
+ }[];
1009
+ }): TokenUsage | null;
979
1010
  export interface StepRecord {
980
1011
  agent: string;
981
1012
  instruction: string;
@@ -1144,6 +1175,12 @@ export interface StepRecord {
1144
1175
  */
1145
1176
  computeSecs?: number | null;
1146
1177
  startupSecs?: number | null;
1178
+ /** When each start-up phase ended, in seconds since the executor began
1179
+ * the step — `services`, `applied`, `ready`, `files`, `go`, `shim`,
1180
+ * `system`, `driver`, `imports`, `tools`, `model` (run-container.ts
1181
+ * #STARTUP_PHASES). Only the phases this step went through; null from
1182
+ * an executor that marks none, absent on steps from before 17 Oct 2026. */
1183
+ startup?: Record<string, number> | null;
1147
1184
  /** Which reservation class the sandbox held (small | large), and the
1148
1185
  * actual limits it held, as numbers. Facts of the step: a price is per
1149
1186
  * core-second, so what was reserved must come from the record, never
@@ -1157,10 +1194,7 @@ export interface StepRecord {
1157
1194
  /** Token counts behind costUsd, kept so usage reports can say "how many"
1158
1195
  * and not only "how much" — the price of a token changes, the count is
1159
1196
  * the fact. Absent on steps recorded before this existed. */
1160
- tokens?: {
1161
- input: number;
1162
- output: number;
1163
- } | null;
1197
+ tokens?: TokenUsage | null;
1164
1198
  /** What the sandbox actually touched, against what it reserved: CPU
1165
1199
  * busy-seconds and peak memory from the pod's own cgroup, bytes on the
1166
1200
  * wire from /proc/net/dev. Null per-field where the kernel interface
@@ -1288,6 +1322,14 @@ export declare function listRunOutputs(tenant: string, workspace: string, runId:
1288
1322
  export declare function runOutputPath(tenant: string, workspace: string, runId: string, agent: string, rel: string): string | null;
1289
1323
  export declare function readRun(tenant: string, workspace: string, runId: string): RunRecord | null;
1290
1324
  export declare function writeRun(tenant: string, workspace: string, run: RunRecord): void;
1325
+ /**
1326
+ * The workspace's live runs — queued, running, or parked at a gate — read
1327
+ * whole, newest first. Off the run index: only the live records are opened,
1328
+ * where listRuns parses every record the workspace ever kept. What the
1329
+ * periodic sweeps want (reconcile, the watchdog), and on a box that keeps
1330
+ * ninety days of runs that is a handful of files instead of thousands.
1331
+ */
1332
+ export declare function liveRuns(tenant: string, workspace: string): RunRecord[];
1291
1333
  export interface RunWithWorkspace extends RunRecord {
1292
1334
  workspace: string;
1293
1335
  }
@@ -1314,10 +1356,7 @@ export interface RunSummaryStep {
1314
1356
  group: number;
1315
1357
  status: StepRecord["status"];
1316
1358
  costUsd: number | null;
1317
- tokens?: {
1318
- input: number;
1319
- output: number;
1320
- } | null;
1359
+ tokens?: TokenUsage | null;
1321
1360
  computeSecs?: number | null;
1322
1361
  skipReason?: string;
1323
1362
  /** The last error event, for a failed step — what runFailure reads. */