@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.
- package/dist/index.d.ts +0 -3
- package/dist/index.js +0 -3
- package/dist/src/catalog.d.ts +7 -0
- package/dist/src/catalog.js +16 -1
- package/dist/src/deploy.d.ts +6 -27
- package/dist/src/deploy.js +14 -56
- package/dist/src/history.d.ts +30 -10
- package/dist/src/history.js +232 -99
- package/dist/src/library.d.ts +0 -13
- package/dist/src/library.js +0 -50
- package/dist/src/model-credential.js +1 -1
- package/dist/src/model-env.js +1 -1
- package/dist/src/platform.d.ts +1 -3
- package/dist/src/platform.js +0 -1
- package/dist/src/run-container.d.ts +31 -5
- package/dist/src/run-container.js +71 -3
- package/dist/src/runner.d.ts +2 -0
- package/dist/src/runner.js +20 -12
- package/dist/src/secrets.js +1 -12
- package/dist/src/starter.js +2 -2
- package/dist/src/step-exec.d.ts +23 -5
- package/dist/src/step-exec.js +30 -23
- package/dist/src/store.d.ts +61 -22
- package/dist/src/store.js +105 -25
- package/dist/src/tool-loop.d.ts +4 -1
- package/dist/src/tool-loop.js +2 -0
- package/dist/src/translator.d.ts +12 -0
- package/dist/src/translator.js +36 -13
- package/index.ts +0 -3
- package/package.json +1 -3
- package/src/catalog.ts +18 -2
- package/src/deploy.ts +13 -74
- package/src/history.ts +230 -108
- package/src/library.ts +0 -44
- package/src/model-credential.ts +1 -1
- package/src/model-env.ts +1 -1
- package/src/platform.ts +1 -4
- package/src/run-container.ts +78 -5
- package/src/runner.ts +22 -12
- package/src/secrets.ts +1 -11
- package/src/starter.ts +2 -2
- package/src/step-exec.ts +44 -27
- package/src/store.ts +145 -37
- package/src/tool-loop.ts +6 -1
- package/src/translator.ts +39 -15
- package/dist/src/git-hook.d.ts +0 -1
- package/dist/src/git-hook.js +0 -70
- package/dist/src/git.d.ts +0 -37
- package/dist/src/git.js +0 -101
- package/dist/src/gitrepo.d.ts +0 -113
- package/dist/src/gitrepo.js +0 -404
- package/dist/src/tar.d.ts +0 -30
- package/dist/src/tar.js +0 -112
- package/src/git-hook.ts +0 -73
- package/src/git.ts +0 -117
- package/src/gitrepo.ts +0 -439
- package/src/tar.ts +0 -125
package/src/runner.ts
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
listWorkspaces,
|
|
50
50
|
listTenants,
|
|
51
51
|
listRuns,
|
|
52
|
+
liveRuns,
|
|
52
53
|
syncBundleFor,
|
|
53
54
|
parseToolDef,
|
|
54
55
|
type ToolDef,
|
|
@@ -74,6 +75,7 @@ import {
|
|
|
74
75
|
type StepRecord,
|
|
75
76
|
type StepAttempt,
|
|
76
77
|
sumAttempts,
|
|
78
|
+
tokensOf,
|
|
77
79
|
parseSchemaText,
|
|
78
80
|
STORAGE_DIR,
|
|
79
81
|
adoptLegacyVersionKey,
|
|
@@ -1711,7 +1713,7 @@ async function publishPublicDir(
|
|
|
1711
1713
|
export function repriced(
|
|
1712
1714
|
catalog: Catalog | null,
|
|
1713
1715
|
model: string,
|
|
1714
|
-
usage: { inputTokens: number; outputTokens: number } | null,
|
|
1716
|
+
usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } | null,
|
|
1715
1717
|
sdkCost: number | null,
|
|
1716
1718
|
push: (type: "info" | "error", text: string) => void,
|
|
1717
1719
|
/** The step's turns each priced at its own model — set when a sub-agent
|
|
@@ -2063,8 +2065,12 @@ async function runStep(
|
|
|
2063
2065
|
// that names a provider uses its own; otherwise the platform's (in a
|
|
2064
2066
|
// run container) or this process's — never a login that just happens
|
|
2065
2067
|
// to be on the machine (model-credential.ts).
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
+
// An account refused the platform's credential (below) is never shown
|
|
2069
|
+
// running on it: the record said "Claude login token" on a step that
|
|
2070
|
+
// was then refused for having no key (FOL-14, 18 Oct 2026).
|
|
2071
|
+
const noKey = !providerLabel && !platform.platformKeyAllowed(tenant);
|
|
2072
|
+
const credential = providerLabel || noKey ? null : resolveModelCredential(isolatedRun() ? platformModelEnv() : process.env);
|
|
2073
|
+
step.credential = credential ? credential.label : noKey ? "none — this account has no model key" : `provider: ${providerLabel}`;
|
|
2068
2074
|
if (credential) {
|
|
2069
2075
|
const line = credentialLine(credential);
|
|
2070
2076
|
push(line.type, line.text);
|
|
@@ -2079,7 +2085,7 @@ async function runStep(
|
|
|
2079
2085
|
push(
|
|
2080
2086
|
"error",
|
|
2081
2087
|
`no model key: this account runs on its own API key, and none is set. Add one in Settings → Model ` +
|
|
2082
|
-
`(or \`foldrun model set <provider> --key …\`), or write a provider: block in AGENTS.md`,
|
|
2088
|
+
`(or \`foldrun account model set <provider> --key …\`), or write a provider: block in AGENTS.md`,
|
|
2083
2089
|
);
|
|
2084
2090
|
step.status = "failed";
|
|
2085
2091
|
save();
|
|
@@ -2827,7 +2833,7 @@ async function runStep(
|
|
|
2827
2833
|
step.podLostTry = {
|
|
2828
2834
|
status: "lost",
|
|
2829
2835
|
costUsd: repriced(catalog, wireModel, first.usage ?? null, first.costUsd, push, first.turnsCostUsd),
|
|
2830
|
-
tokens: first.usage
|
|
2836
|
+
tokens: tokensOf(first.usage),
|
|
2831
2837
|
computeSecs: first.timing ? Math.round(first.timing.totalMs) / 1000 : null,
|
|
2832
2838
|
finishedAt: new Date().toISOString(),
|
|
2833
2839
|
error: decision.note.slice(0, 500),
|
|
@@ -2871,9 +2877,7 @@ async function runStep(
|
|
|
2871
2877
|
if (outcome.status === "failed" && outcome.reason) push("error", `sandbox ended: ${outcome.reason}`);
|
|
2872
2878
|
if (outcome.data !== undefined) step.data = redactData(outcome.data);
|
|
2873
2879
|
step.costUsd = repriced(catalog, wireModel, outcome.usage ?? null, outcome.costUsd, push, outcome.turnsCostUsd);
|
|
2874
|
-
step.tokens = outcome.usage
|
|
2875
|
-
? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
|
|
2876
|
-
: null;
|
|
2880
|
+
step.tokens = tokensOf(outcome.usage);
|
|
2877
2881
|
step.actual = outcome.res ?? null;
|
|
2878
2882
|
if (outcome.res?.peakMemBytes || outcome.res?.rxBytes) {
|
|
2879
2883
|
const mb = (n: number | null) => (n === null ? "?" : (n / 1024 / 1024).toFixed(0));
|
|
@@ -2890,6 +2894,12 @@ async function runStep(
|
|
|
2890
2894
|
const secs = (ms: number) => Math.round(ms) / 1000;
|
|
2891
2895
|
step.computeSecs = outcome.timing ? secs(outcome.timing.totalMs) : null;
|
|
2892
2896
|
step.startupSecs = outcome.timing ? secs(outcome.timing.sandboxMs) : null;
|
|
2897
|
+
// Where the start-up went, phase by phase (run-container.ts#STARTUP_PHASES),
|
|
2898
|
+
// in seconds — what the cold-start numbers are computed from.
|
|
2899
|
+
const phases = outcome.timing?.phases;
|
|
2900
|
+
step.startup = phases && Object.keys(phases).length
|
|
2901
|
+
? Object.fromEntries(Object.entries(phases).map(([k, v]) => [k, secs(v)]))
|
|
2902
|
+
: null;
|
|
2893
2903
|
// Which reservation those seconds held — as numbers, because the bill
|
|
2894
2904
|
// is per core-second and per GiB-second, and what was held must come
|
|
2895
2905
|
// from the record, never from what the config says at billing time.
|
|
@@ -3069,9 +3079,7 @@ async function runStep(
|
|
|
3069
3079
|
const consultCost = consultTools.drainCost();
|
|
3070
3080
|
const stepCost = repriced(catalog, wireModel, outcome.usage, outcome.costUsd, push, outcome.turnsCostUsd);
|
|
3071
3081
|
step.costUsd = stepCost === null && consultCost === 0 ? null : (stepCost ?? 0) + consultCost;
|
|
3072
|
-
step.tokens = outcome.usage
|
|
3073
|
-
? { input: outcome.usage.inputTokens, output: outcome.usage.outputTokens }
|
|
3074
|
-
: null;
|
|
3082
|
+
step.tokens = tokensOf(outcome.usage);
|
|
3075
3083
|
for (const line of apiTools.drainLog()) push("info", `api: ${line}`);
|
|
3076
3084
|
for (const line of scriptTools.drainLog()) push("info", `script: ${line}`);
|
|
3077
3085
|
}
|
|
@@ -5054,7 +5062,9 @@ export function reconcileRuns(tenant: string, now = Date.now(), live?: ReadonlyS
|
|
|
5054
5062
|
const closed: Reconciliation[] = [];
|
|
5055
5063
|
|
|
5056
5064
|
for (const workspace of listWorkspaces(tenant)) {
|
|
5057
|
-
|
|
5065
|
+
// Live runs only, read whole — every other status is passed over below,
|
|
5066
|
+
// and parsing the workspace's whole history every sweep was the cost.
|
|
5067
|
+
for (const summary of liveRuns(tenant, workspace.name)) {
|
|
5058
5068
|
if (drivingRuns.has(summary.id)) continue;
|
|
5059
5069
|
// Driven by another process — a second worker pod, on a platform with
|
|
5060
5070
|
// more than one. The idle window protects a quiet step from THIS
|
package/src/secrets.ts
CHANGED
|
@@ -252,11 +252,9 @@ export function setSecretSites(tenant: string, name: string, sites: string[], wo
|
|
|
252
252
|
export function secretSites(tenant: string, names: string[], workspace?: string): Record<string, string[]> {
|
|
253
253
|
const out: Record<string, string[]> = {};
|
|
254
254
|
const own = workspace ? read(tenant, workspace) : {};
|
|
255
|
-
const source = workspace ? platform.previewSourceOf(tenant, workspace) : null;
|
|
256
|
-
const inherited = source ? read(tenant, source) : {};
|
|
257
255
|
const account = read(tenant);
|
|
258
256
|
for (const name of names) {
|
|
259
|
-
const rec = own[name] ??
|
|
257
|
+
const rec = own[name] ?? account[name];
|
|
260
258
|
if (rec?.sites?.length) out[name] = [...rec.sites];
|
|
261
259
|
}
|
|
262
260
|
return out;
|
|
@@ -349,14 +347,6 @@ export function getSecret(
|
|
|
349
347
|
if (workspace) {
|
|
350
348
|
const own = decrypt(tenant, read(tenant, workspace)[name]);
|
|
351
349
|
if (own !== null) return { value: own, scope: "workspace" };
|
|
352
|
-
// A preview is a branch of its source workspace, and the branch's
|
|
353
|
-
// agents need the same credentials the source's do. Its own store is
|
|
354
|
-
// still consulted first, so a preview can override one on purpose.
|
|
355
|
-
const source = platform.previewSourceOf(tenant, workspace);
|
|
356
|
-
if (source) {
|
|
357
|
-
const inherited = decrypt(tenant, read(tenant, source)[name]);
|
|
358
|
-
if (inherited !== null) return { value: inherited, scope: "workspace" };
|
|
359
|
-
}
|
|
360
350
|
}
|
|
361
351
|
const shared = decrypt(tenant, read(tenant)[name]);
|
|
362
352
|
return shared === null ? null : { value: shared, scope: "account" };
|
package/src/starter.ts
CHANGED
|
@@ -389,8 +389,8 @@ Debugging a failed run is usually: \`runs\` to find it, \`runs/<id>\` to read wh
|
|
|
389
389
|
step failed and what it cost, fix the markdown, \`deploy\`, then \`rerun\`.
|
|
390
390
|
|
|
391
391
|
Every route, with request and response shapes, is in \`docs/api.md\` in the
|
|
392
|
-
foldrun repository — including secrets, keys, billing, OAuth, webhooks and
|
|
393
|
-
|
|
392
|
+
foldrun repository — including secrets, keys, billing, OAuth, webhooks and
|
|
393
|
+
restore.
|
|
394
394
|
|
|
395
395
|
## Writing a flow
|
|
396
396
|
|
package/src/step-exec.ts
CHANGED
|
@@ -15,6 +15,7 @@ import path from "node:path";
|
|
|
15
15
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
16
16
|
import type { HookInput, McpServerConfig, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
17
17
|
import type { Effort } from "./store.ts";
|
|
18
|
+
import { CACHE_READ_MULTIPLIER, CACHE_WRITE_MULTIPLIER } from "./catalog.ts";
|
|
18
19
|
import type { TestEffect } from "./test-mode.ts";
|
|
19
20
|
import type { OperatorEvent } from "./operator.ts";
|
|
20
21
|
import { spawn } from "node:child_process";
|
|
@@ -25,6 +26,26 @@ import { validateSchema, describeSchemaErrors, looksLikeSchema } from "./json-sc
|
|
|
25
26
|
import { CallCounter, limitKeysFor, type Limits } from "./limits.ts";
|
|
26
27
|
import { classifyCall, podAt, readPodEvents, type BrowserPodOutcome, type CallKind, type ClassifyContext } from "./browser-pod.ts";
|
|
27
28
|
|
|
29
|
+
export interface ExecUsage {
|
|
30
|
+
inputTokens: number;
|
|
31
|
+
outputTokens: number;
|
|
32
|
+
cacheReadTokens: number;
|
|
33
|
+
cacheWriteTokens: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The four counts from one SDK usage object (snake_case, as the API
|
|
37
|
+
* reports it). The API counts fresh input apart from the cache's; here
|
|
38
|
+
* `inputTokens` is all of it, as it always was on the record, and the
|
|
39
|
+
* cache fields say how much of that the cache served or stored. */
|
|
40
|
+
export function usageFromSdk(u: Record<string, number | undefined>): ExecUsage {
|
|
41
|
+
return {
|
|
42
|
+
inputTokens: (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0),
|
|
43
|
+
outputTokens: u.output_tokens ?? 0,
|
|
44
|
+
cacheReadTokens: u.cache_read_input_tokens ?? 0,
|
|
45
|
+
cacheWriteTokens: u.cache_creation_input_tokens ?? 0,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
28
49
|
export interface ExecOutcome {
|
|
29
50
|
status: "completed" | "failed";
|
|
30
51
|
result: string | null;
|
|
@@ -39,8 +60,12 @@ export interface ExecOutcome {
|
|
|
39
60
|
costUsd: number | null;
|
|
40
61
|
/** Token counts off the SDK's result message. costUsd is priced from
|
|
41
62
|
* Anthropic's table, which is wrong for a routed model — these are the
|
|
42
|
-
* raw numbers a caller with a gateway's own prices can reprice from.
|
|
43
|
-
|
|
63
|
+
* raw numbers a caller with a gateway's own prices can reprice from.
|
|
64
|
+
* `inputTokens` is every input token; `cacheReadTokens` and
|
|
65
|
+
* `cacheWriteTokens` are the part of it the prompt cache served or
|
|
66
|
+
* stored, so a usage page can say how much of a step's context was
|
|
67
|
+
* re-read for a tenth of the price. */
|
|
68
|
+
usage: ExecUsage | null;
|
|
44
69
|
/** Set only when some turns ran on a different model from the step's — a
|
|
45
70
|
* sub-agent with its own `model:`. The sum of every turn, each priced at
|
|
46
71
|
* its own model. `usage` above is one lump and cannot be repriced at the
|
|
@@ -201,18 +226,15 @@ export function knownPrice(model: string | undefined): { input: number; output:
|
|
|
201
226
|
return KNOWN_PRICES.find(([re]) => re.test(model))?.[1] ?? null;
|
|
202
227
|
}
|
|
203
228
|
|
|
204
|
-
/** Anthropic's cache multipliers, and the shape every gateway that bills
|
|
205
|
-
* for caching at all has copied: a write costs a quarter more than fresh
|
|
206
|
-
* input, a read a tenth of it. Counting both at the full input rate — as
|
|
207
|
-
* this did — makes a long cached prompt read about ten times its price,
|
|
208
|
-
* which on a ceiling is not "the honest direction to be wrong in", it is
|
|
209
|
-
* a step killed for spending money it never spent. */
|
|
210
|
-
const CACHE_WRITE_MULTIPLIER = 1.25;
|
|
211
|
-
const CACHE_READ_MULTIPLIER = 0.1;
|
|
212
|
-
|
|
213
229
|
/**
|
|
214
230
|
* What one assistant turn cost, from the usage the SDK reports. Pure.
|
|
215
231
|
*
|
|
232
|
+
* Cache reads and writes are priced at Anthropic's multipliers
|
|
233
|
+
* (catalog.ts). Counting both at the full input rate — as this did —
|
|
234
|
+
* makes a long cached prompt read about ten times its price, which on a
|
|
235
|
+
* ceiling is not "the honest direction to be wrong in", it is a step
|
|
236
|
+
* killed for spending money it never spent.
|
|
237
|
+
*
|
|
216
238
|
* A gateway whose cache is priced differently is over- or under-counted
|
|
217
239
|
* here by that difference alone; the run record's own cost is repriced
|
|
218
240
|
* from the catalogue afterwards and stays the authority on the bill. This
|
|
@@ -762,17 +784,10 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
762
784
|
costUsd = "total_cost_usd" in message ? (message.total_cost_usd ?? null) : null;
|
|
763
785
|
stopReason = "stop_reason" in message && typeof message.stop_reason === "string" ? message.stop_reason : null;
|
|
764
786
|
if ("usage" in message && message.usage) {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
// gateways with cheaper cache reads, which errs on the honest side.
|
|
770
|
-
inputTokens:
|
|
771
|
-
(u.input_tokens ?? 0) +
|
|
772
|
-
(u.cache_creation_input_tokens ?? 0) +
|
|
773
|
-
(u.cache_read_input_tokens ?? 0),
|
|
774
|
-
outputTokens: u.output_tokens ?? 0,
|
|
775
|
-
};
|
|
787
|
+
// Cache reads and writes counted beside the input total: with only
|
|
788
|
+
// the total (until 18 Oct 2026) the hit rate could not be read and
|
|
789
|
+
// the catalogue repriced every cached token at the full rate.
|
|
790
|
+
usage = usageFromSdk(message.usage as unknown as Record<string, number | undefined>);
|
|
776
791
|
}
|
|
777
792
|
}
|
|
778
793
|
}
|
|
@@ -790,16 +805,18 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
|
|
|
790
805
|
// died — means no total from the SDK. The turns it did take were billed by
|
|
791
806
|
// the provider all the same, so the step's cost is their sum.
|
|
792
807
|
if (costUsd === null && turns.size) {
|
|
793
|
-
|
|
794
|
-
let output = 0;
|
|
808
|
+
const summed: ExecUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
795
809
|
let priced = 0;
|
|
796
810
|
for (const { u: t, price } of turns.values()) {
|
|
797
|
-
|
|
798
|
-
|
|
811
|
+
const one = usageFromSdk(t);
|
|
812
|
+
summed.inputTokens += one.inputTokens;
|
|
813
|
+
summed.outputTokens += one.outputTokens;
|
|
814
|
+
summed.cacheReadTokens += one.cacheReadTokens;
|
|
815
|
+
summed.cacheWriteTokens += one.cacheWriteTokens;
|
|
799
816
|
priced += priceTurn(t, price);
|
|
800
817
|
}
|
|
801
818
|
costUsd = priced;
|
|
802
|
-
usage ??=
|
|
819
|
+
usage ??= summed;
|
|
803
820
|
emit("info", `cost from ${turns.size} model turn${turns.size === 1 ? "" : "s"}: $${priced.toFixed(4)} — the step ended before the model's closing total`);
|
|
804
821
|
}
|
|
805
822
|
if (ended === "timeout") {
|
package/src/store.ts
CHANGED
|
@@ -369,7 +369,14 @@ export function saveWorkspace(
|
|
|
369
369
|
files: DeployFile[],
|
|
370
370
|
/** Attribution for the revision this save records. A deploy passes the
|
|
371
371
|
* commit, which becomes the revision's id. */
|
|
372
|
-
meta: ChangeMeta & {
|
|
372
|
+
meta: ChangeMeta & {
|
|
373
|
+
commit?: string | null;
|
|
374
|
+
/** Keep every file that is not editable source — what an agent wrote
|
|
375
|
+
* into its own folder at run time (a browser profile, a PDF it
|
|
376
|
+
* downloaded). A restore sets it: history holds source, so a file it
|
|
377
|
+
* never tracked is not one a restore may take away. */
|
|
378
|
+
keepUntracked?: boolean;
|
|
379
|
+
} = {},
|
|
373
380
|
) {
|
|
374
381
|
if (files.length > 500) throw new Error("too many files");
|
|
375
382
|
for (const f of files) {
|
|
@@ -424,8 +431,6 @@ export function saveWorkspace(
|
|
|
424
431
|
// about it. Before this, every deploy silently erased everything the agents
|
|
425
432
|
// had learned.
|
|
426
433
|
const shipped = new Set(files.map((f) => path.normalize(f.path)));
|
|
427
|
-
const isAgentOwned = (rel: string) => keptOnDeploy(rel, shipped);
|
|
428
|
-
|
|
429
434
|
// What is there now, for the revision: every editable file's content
|
|
430
435
|
// before this save replaces the tree.
|
|
431
436
|
const before = new Map<string, string>();
|
|
@@ -439,6 +444,8 @@ export function saveWorkspace(
|
|
|
439
444
|
}
|
|
440
445
|
}
|
|
441
446
|
|
|
447
|
+
const isAgentOwned = (rel: string) => keptOnDeploy(rel, shipped) || (meta.keepUntracked === true && !before.has(rel) && !shipped.has(rel));
|
|
448
|
+
|
|
442
449
|
const snapshot = fs.existsSync(dir) ? fs.mkdtempSync(path.join(dataRoot(), ".keep-")) : null;
|
|
443
450
|
const preserved: string[] = [];
|
|
444
451
|
if (snapshot) {
|
|
@@ -446,9 +453,13 @@ export function saveWorkspace(
|
|
|
446
453
|
const rel = String(entry).split(path.sep).join("/");
|
|
447
454
|
const from = path.join(dir, rel);
|
|
448
455
|
try {
|
|
449
|
-
|
|
456
|
+
// lstat, never stat: a link is never followed. An agent folder's link
|
|
457
|
+
// to a file outside the workspace was copied in as that file's
|
|
458
|
+
// contents — on a restore with keepUntracked, any such link
|
|
459
|
+
// (Codex, 18 Oct 2026).
|
|
460
|
+
if (!fs.lstatSync(from).isFile() || !isAgentOwned(rel)) continue;
|
|
450
461
|
} catch {
|
|
451
|
-
continue; // a
|
|
462
|
+
continue; // a file that vanished mid-deploy
|
|
452
463
|
}
|
|
453
464
|
const to = path.join(snapshot, rel);
|
|
454
465
|
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
@@ -2095,7 +2106,7 @@ export function mayApprove(
|
|
|
2095
2106
|
|
|
2096
2107
|
/** A live run of this flow — the fact overlap: decisions are made on. */
|
|
2097
2108
|
export function flowHasLiveRun(tenant: string, workspace: string, flowName: string): boolean {
|
|
2098
|
-
return
|
|
2109
|
+
return listRunSummaries(tenant, workspace).some(
|
|
2099
2110
|
(r) => r.flow === flowName && (r.status === "running" || r.status === "queued" || r.status === "awaiting-approval"),
|
|
2100
2111
|
);
|
|
2101
2112
|
}
|
|
@@ -2396,10 +2407,6 @@ export interface WorkspaceSummary {
|
|
|
2396
2407
|
flows: number;
|
|
2397
2408
|
deployedAt: string;
|
|
2398
2409
|
runCount: number;
|
|
2399
|
-
/** Set when this workspace is a branch's preview of another (preview.ts):
|
|
2400
|
-
* which one, which branch, which commit. Read from the marker beside the
|
|
2401
|
-
* directory here rather than through preview.ts, which imports this file. */
|
|
2402
|
-
preview?: { source: string; branch: string; commit: string; at: string };
|
|
2403
2410
|
/** The workspace's picture, as a URL, when the page that lists it looked
|
|
2404
2411
|
* one up (avatars.ts) — the store itself does not. */
|
|
2405
2412
|
avatar?: string | null;
|
|
@@ -2460,12 +2467,6 @@ export function listWorkspaces(tenant: string): WorkspaceSummary[] {
|
|
|
2460
2467
|
}
|
|
2461
2468
|
}
|
|
2462
2469
|
const runsDir = path.join(pDir, "runs");
|
|
2463
|
-
let preview: WorkspaceSummary["preview"];
|
|
2464
|
-
try {
|
|
2465
|
-
preview = JSON.parse(fs.readFileSync(path.join(dir, `.${name}.preview.json`), "utf8"));
|
|
2466
|
-
} catch {
|
|
2467
|
-
// not a preview
|
|
2468
|
-
}
|
|
2469
2470
|
return {
|
|
2470
2471
|
name,
|
|
2471
2472
|
description,
|
|
@@ -2473,7 +2474,6 @@ export function listWorkspaces(tenant: string): WorkspaceSummary[] {
|
|
|
2473
2474
|
flows: listFlows(tenant, name).length,
|
|
2474
2475
|
deployedAt: fs.statSync(pDir).mtime.toISOString(),
|
|
2475
2476
|
runCount: fs.existsSync(runsDir) ? fs.readdirSync(runsDir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).length : 0,
|
|
2476
|
-
...(preview ? { preview } : {}),
|
|
2477
2477
|
};
|
|
2478
2478
|
})
|
|
2479
2479
|
.sort((a, b) => b.deployedAt.localeCompare(a.deployedAt));
|
|
@@ -2709,7 +2709,7 @@ export function writeWorkspaceFile(
|
|
|
2709
2709
|
}
|
|
2710
2710
|
|
|
2711
2711
|
/** Tell the platform, and never let its listener fail a write. */
|
|
2712
|
-
function workspaceChanged(tenant: string, workspace: string, why: "write" | "deploy" | "
|
|
2712
|
+
function workspaceChanged(tenant: string, workspace: string, why: "write" | "deploy" | "delete") {
|
|
2713
2713
|
try {
|
|
2714
2714
|
platform.workspaceChanged(tenant, workspace, why);
|
|
2715
2715
|
} catch {
|
|
@@ -3102,7 +3102,7 @@ export interface StepAttempt {
|
|
|
3102
3102
|
* of its own (image slim), before the full go's. */
|
|
3103
3103
|
status: "completed" | "failed" | "lost";
|
|
3104
3104
|
costUsd: number | null;
|
|
3105
|
-
tokens:
|
|
3105
|
+
tokens: TokenUsage | null;
|
|
3106
3106
|
computeSecs: number | null;
|
|
3107
3107
|
startedAt: string;
|
|
3108
3108
|
finishedAt: string;
|
|
@@ -3120,15 +3120,84 @@ export interface StepAttempt {
|
|
|
3120
3120
|
* billing reads null as zero, not as "unknown, charge something". */
|
|
3121
3121
|
export function sumAttempts(tries: StepAttempt[]): Pick<StepAttempt, "costUsd" | "tokens" | "computeSecs"> {
|
|
3122
3122
|
const costs = tries.map((t) => t.costUsd).filter((c): c is number => typeof c === "number");
|
|
3123
|
-
const toks = tries.map((t) => t.tokens).filter((t): t is
|
|
3123
|
+
const toks = tries.map((t) => t.tokens).filter((t): t is TokenUsage => !!t);
|
|
3124
3124
|
const secs = tries.map((t) => t.computeSecs).filter((c): c is number => typeof c === "number");
|
|
3125
3125
|
return {
|
|
3126
3126
|
costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
|
|
3127
|
-
tokens: toks.length ? toks
|
|
3127
|
+
tokens: toks.length ? sumTokens(toks) : null,
|
|
3128
3128
|
computeSecs: secs.length ? secs.reduce((a, b) => a + b, 0) : null,
|
|
3129
3129
|
};
|
|
3130
3130
|
}
|
|
3131
3131
|
|
|
3132
|
+
/**
|
|
3133
|
+
* The token counts behind a step's cost. `input` is every input token the
|
|
3134
|
+
* model saw; `cacheRead` and `cacheWrite` are the part of it the prompt
|
|
3135
|
+
* cache served or stored — a read costs a tenth of fresh input, and a usage
|
|
3136
|
+
* page wants to say how much of a step's context was re-read rather than
|
|
3137
|
+
* re-sent. Steps recorded before 18 Oct 2026 have no cache fields: their
|
|
3138
|
+
* `input` is the same total, but how much of it was cached is unknown.
|
|
3139
|
+
*/
|
|
3140
|
+
export interface TokenUsage {
|
|
3141
|
+
input: number;
|
|
3142
|
+
output: number;
|
|
3143
|
+
cacheRead?: number;
|
|
3144
|
+
cacheWrite?: number;
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
/** The record's shape from what a step reports (ExecOutcome.usage). */
|
|
3148
|
+
export function tokensOf(usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } | null | undefined): TokenUsage | null {
|
|
3149
|
+
if (!usage) return null;
|
|
3150
|
+
return {
|
|
3151
|
+
input: usage.inputTokens,
|
|
3152
|
+
output: usage.outputTokens,
|
|
3153
|
+
cacheRead: usage.cacheReadTokens ?? 0,
|
|
3154
|
+
cacheWrite: usage.cacheWriteTokens ?? 0,
|
|
3155
|
+
};
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
/** Field-by-field sum; the cache fields are present in the result whenever
|
|
3159
|
+
* any input had them, so a run of old and new steps still reads whole. */
|
|
3160
|
+
export function sumTokens(list: readonly TokenUsage[]): TokenUsage {
|
|
3161
|
+
const out: TokenUsage = { input: 0, output: 0 };
|
|
3162
|
+
for (const t of list) {
|
|
3163
|
+
out.input += t.input;
|
|
3164
|
+
out.output += t.output;
|
|
3165
|
+
if (t.cacheRead !== undefined || t.cacheWrite !== undefined) {
|
|
3166
|
+
out.cacheRead = (out.cacheRead ?? 0) + (t.cacheRead ?? 0);
|
|
3167
|
+
out.cacheWrite = (out.cacheWrite ?? 0) + (t.cacheWrite ?? 0);
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
return out;
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
/** The share of all input that the prompt cache served, 0–1; null when
|
|
3174
|
+
* there was no input, or the record predates the split and cannot say. */
|
|
3175
|
+
export function cacheHitRate(t: TokenUsage | null | undefined): number | null {
|
|
3176
|
+
if (!t || t.cacheRead === undefined) return null;
|
|
3177
|
+
return t.input > 0 ? Math.min(1, t.cacheRead / t.input) : null;
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
/** The cache share over several counts, taking only those that report the
|
|
3181
|
+
* split: a step recorded before it holds its cache traffic inside `input`,
|
|
3182
|
+
* and counting that as uncached would understate the rate. The rule the
|
|
3183
|
+
* Usage page and the CLI's totals follow too. */
|
|
3184
|
+
export function measuredHitRate(list: readonly (TokenUsage | null | undefined)[]): number | null {
|
|
3185
|
+
let read = 0;
|
|
3186
|
+
let all = 0;
|
|
3187
|
+
for (const t of list) {
|
|
3188
|
+
if (!t || t.cacheRead === undefined) continue;
|
|
3189
|
+
read += t.cacheRead;
|
|
3190
|
+
all += t.input;
|
|
3191
|
+
}
|
|
3192
|
+
return all > 0 ? read / all : null;
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
/** A run's token counts, summed over its steps; null when no step reported any. */
|
|
3196
|
+
export function runTokens(run: { steps: readonly { tokens?: TokenUsage | null }[] }): TokenUsage | null {
|
|
3197
|
+
const toks = run.steps.map((s) => s.tokens).filter((t): t is TokenUsage => !!t);
|
|
3198
|
+
return toks.length ? sumTokens(toks) : null;
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3132
3201
|
export interface StepRecord {
|
|
3133
3202
|
agent: string;
|
|
3134
3203
|
instruction: string;
|
|
@@ -3291,6 +3360,12 @@ export interface StepRecord {
|
|
|
3291
3360
|
*/
|
|
3292
3361
|
computeSecs?: number | null;
|
|
3293
3362
|
startupSecs?: number | null;
|
|
3363
|
+
/** When each start-up phase ended, in seconds since the executor began
|
|
3364
|
+
* the step — `services`, `applied`, `ready`, `files`, `go`, `shim`,
|
|
3365
|
+
* `system`, `driver`, `imports`, `tools`, `model` (run-container.ts
|
|
3366
|
+
* #STARTUP_PHASES). Only the phases this step went through; null from
|
|
3367
|
+
* an executor that marks none, absent on steps from before 17 Oct 2026. */
|
|
3368
|
+
startup?: Record<string, number> | null;
|
|
3294
3369
|
/** Which reservation class the sandbox held (small | large), and the
|
|
3295
3370
|
* actual limits it held, as numbers. Facts of the step: a price is per
|
|
3296
3371
|
* core-second, so what was reserved must come from the record, never
|
|
@@ -3301,7 +3376,7 @@ export interface StepRecord {
|
|
|
3301
3376
|
/** Token counts behind costUsd, kept so usage reports can say "how many"
|
|
3302
3377
|
* and not only "how much" — the price of a token changes, the count is
|
|
3303
3378
|
* the fact. Absent on steps recorded before this existed. */
|
|
3304
|
-
tokens?:
|
|
3379
|
+
tokens?: TokenUsage | null;
|
|
3305
3380
|
/** What the sandbox actually touched, against what it reserved: CPU
|
|
3306
3381
|
* busy-seconds and peak memory from the pod's own cgroup, bytes on the
|
|
3307
3382
|
* wire from /proc/net/dev. Null per-field where the kernel interface
|
|
@@ -3512,6 +3587,23 @@ export function writeRun(tenant: string, workspace: string, run: RunRecord) {
|
|
|
3512
3587
|
noteRunWritten(path.dirname(p), run, p);
|
|
3513
3588
|
}
|
|
3514
3589
|
|
|
3590
|
+
/**
|
|
3591
|
+
* The workspace's live runs — queued, running, or parked at a gate — read
|
|
3592
|
+
* whole, newest first. Off the run index: only the live records are opened,
|
|
3593
|
+
* where listRuns parses every record the workspace ever kept. What the
|
|
3594
|
+
* periodic sweeps want (reconcile, the watchdog), and on a box that keeps
|
|
3595
|
+
* ninety days of runs that is a handful of files instead of thousands.
|
|
3596
|
+
*/
|
|
3597
|
+
export function liveRuns(tenant: string, workspace: string): RunRecord[] {
|
|
3598
|
+
const out: RunRecord[] = [];
|
|
3599
|
+
for (const s of listRunSummaries(tenant, workspace)) {
|
|
3600
|
+
if (!LIVE_STATUSES.has(s.status)) continue;
|
|
3601
|
+
const run = readRun(tenant, workspace, s.id);
|
|
3602
|
+
if (run) out.push(run);
|
|
3603
|
+
}
|
|
3604
|
+
return out;
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3515
3607
|
// Every run in an account, each tagged with the workspace it belongs to —
|
|
3516
3608
|
// the observability surface.
|
|
3517
3609
|
export interface RunWithWorkspace extends RunRecord {
|
|
@@ -3565,7 +3657,7 @@ export interface RunSummaryStep {
|
|
|
3565
3657
|
group: number;
|
|
3566
3658
|
status: StepRecord["status"];
|
|
3567
3659
|
costUsd: number | null;
|
|
3568
|
-
tokens?:
|
|
3660
|
+
tokens?: TokenUsage | null;
|
|
3569
3661
|
computeSecs?: number | null;
|
|
3570
3662
|
skipReason?: string;
|
|
3571
3663
|
/** The last error event, for a failed step — what runFailure reads. */
|
|
@@ -3603,7 +3695,18 @@ export interface RunSummary {
|
|
|
3603
3695
|
interface RunIndexRow extends RunSummary {
|
|
3604
3696
|
/** mtime of the record this row was made from; a moved mtime means re-read. */
|
|
3605
3697
|
mtimeMs: number;
|
|
3698
|
+
/** mtime, inode and size together (fileStamp) — what freshness is decided
|
|
3699
|
+
* on now. A row from before it has none and is re-read once. */
|
|
3700
|
+
stamp?: string;
|
|
3606
3701
|
}
|
|
3702
|
+
|
|
3703
|
+
/**
|
|
3704
|
+
* Which version of a file this is. mtime alone missed a rewrite inside one
|
|
3705
|
+
* mtime tick (coarse on some NFS servers), leaving a stale row that the
|
|
3706
|
+
* sweeps act on; every write here is a rename to a new file, so the inode
|
|
3707
|
+
* moves on each write whatever the clock says, and the size usually does.
|
|
3708
|
+
*/
|
|
3709
|
+
const fileStamp = (st: fs.Stats) => `${st.mtimeMs}:${st.ino}:${st.size}`;
|
|
3607
3710
|
interface RunIndex {
|
|
3608
3711
|
v: 1;
|
|
3609
3712
|
runs: Record<string, RunIndexRow>;
|
|
@@ -3668,23 +3771,23 @@ export function runFromSummary(s: RunSummary): RunRecord {
|
|
|
3668
3771
|
};
|
|
3669
3772
|
}
|
|
3670
3773
|
|
|
3671
|
-
const runIndexCache = new Map<string, { index: RunIndex;
|
|
3774
|
+
const runIndexCache = new Map<string, { index: RunIndex; stamp: string }>();
|
|
3672
3775
|
|
|
3673
3776
|
function readRunIndex(dir: string): RunIndex | null {
|
|
3674
3777
|
const file = path.join(dir, RUN_INDEX);
|
|
3675
|
-
let
|
|
3778
|
+
let stamp: string;
|
|
3676
3779
|
try {
|
|
3677
|
-
|
|
3780
|
+
stamp = fileStamp(fs.statSync(file));
|
|
3678
3781
|
} catch {
|
|
3679
3782
|
runIndexCache.delete(dir);
|
|
3680
3783
|
return null;
|
|
3681
3784
|
}
|
|
3682
3785
|
const cached = runIndexCache.get(dir);
|
|
3683
|
-
if (cached && cached.
|
|
3786
|
+
if (cached && cached.stamp === stamp) return cached.index;
|
|
3684
3787
|
try {
|
|
3685
3788
|
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as RunIndex;
|
|
3686
3789
|
if (parsed?.v !== 1 || typeof parsed.runs !== "object" || !parsed.runs) return null;
|
|
3687
|
-
runIndexCache.set(dir, { index: parsed,
|
|
3790
|
+
runIndexCache.set(dir, { index: parsed, stamp });
|
|
3688
3791
|
return parsed;
|
|
3689
3792
|
} catch {
|
|
3690
3793
|
return null;
|
|
@@ -3697,14 +3800,17 @@ function writeRunIndex(dir: string, index: RunIndex) {
|
|
|
3697
3800
|
fs.writeFileSync(tmp, JSON.stringify(index));
|
|
3698
3801
|
fs.renameSync(tmp, file);
|
|
3699
3802
|
try {
|
|
3700
|
-
runIndexCache.set(dir, { index,
|
|
3803
|
+
runIndexCache.set(dir, { index, stamp: fileStamp(fs.statSync(file)) });
|
|
3701
3804
|
} catch {
|
|
3702
3805
|
runIndexCache.delete(dir);
|
|
3703
3806
|
}
|
|
3704
3807
|
}
|
|
3705
3808
|
|
|
3706
|
-
const sameRow = (a: RunIndexRow | undefined, b: RunSummary) =>
|
|
3707
|
-
|
|
3809
|
+
const sameRow = (a: RunIndexRow | undefined, b: RunSummary) => {
|
|
3810
|
+
if (!a) return false;
|
|
3811
|
+
const { mtimeMs: _m, stamp: _s, ...row } = a;
|
|
3812
|
+
return JSON.stringify(row) === JSON.stringify(b);
|
|
3813
|
+
};
|
|
3708
3814
|
|
|
3709
3815
|
/** writeRun just saved `run` at `file`: bring its row up to date. The file
|
|
3710
3816
|
* is rewritten only when the row's content moved — a live step appends an
|
|
@@ -3717,7 +3823,8 @@ function noteRunWritten(dir: string, run: RunRecord, file: string) {
|
|
|
3717
3823
|
if (!index) return; // no index yet — the first reader builds it whole
|
|
3718
3824
|
const row = summarizeRun(run);
|
|
3719
3825
|
if (sameRow(index.runs[run.id], row)) return;
|
|
3720
|
-
|
|
3826
|
+
const st = fs.statSync(file);
|
|
3827
|
+
index.runs[run.id] = { ...row, mtimeMs: st.mtimeMs, stamp: fileStamp(st) };
|
|
3721
3828
|
writeRunIndex(dir, index);
|
|
3722
3829
|
} catch {
|
|
3723
3830
|
// the index is a cache; the record is the truth and it is already on disk
|
|
@@ -3752,17 +3859,18 @@ export function listRunSummaries(tenant: string, workspace: string): RunSummary[
|
|
|
3752
3859
|
if (!f.endsWith(".json") || f.startsWith(".")) continue;
|
|
3753
3860
|
const id = f.slice(0, -".json".length);
|
|
3754
3861
|
present.add(id);
|
|
3755
|
-
let
|
|
3862
|
+
let st: fs.Stats;
|
|
3756
3863
|
try {
|
|
3757
|
-
|
|
3864
|
+
st = fs.statSync(path.join(dir, f));
|
|
3758
3865
|
} catch {
|
|
3759
3866
|
continue; // pruned between the listing and the stat
|
|
3760
3867
|
}
|
|
3868
|
+
const stamp = fileStamp(st);
|
|
3761
3869
|
const row = index.runs[id];
|
|
3762
|
-
if (row && row.
|
|
3870
|
+
if (row && row.stamp === stamp) continue;
|
|
3763
3871
|
try {
|
|
3764
3872
|
const run = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")) as RunRecord;
|
|
3765
|
-
index.runs[id] = { ...summarizeRun(run), mtimeMs };
|
|
3873
|
+
index.runs[id] = { ...summarizeRun(run), mtimeMs: st.mtimeMs, stamp };
|
|
3766
3874
|
changed = true;
|
|
3767
3875
|
} catch {
|
|
3768
3876
|
// a torn or foreign file is not a run; leave whatever row it had
|
|
@@ -3781,7 +3889,7 @@ export function listRunSummaries(tenant: string, workspace: string): RunSummary[
|
|
|
3781
3889
|
}
|
|
3782
3890
|
}
|
|
3783
3891
|
return Object.values(index.runs)
|
|
3784
|
-
.map(({ mtimeMs: _m, ...row }) => row)
|
|
3892
|
+
.map(({ mtimeMs: _m, stamp: _s, ...row }) => row)
|
|
3785
3893
|
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
3786
3894
|
}
|
|
3787
3895
|
|
package/src/tool-loop.ts
CHANGED
|
@@ -34,9 +34,12 @@ export interface LoopTool {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export interface LoopUsage {
|
|
37
|
-
/** Everything sent, cache reads and writes included
|
|
37
|
+
/** Everything sent, cache reads and writes included; the two cache
|
|
38
|
+
* fields say how much of it the prompt cache served or stored. */
|
|
38
39
|
inputTokens: number;
|
|
39
40
|
outputTokens: number;
|
|
41
|
+
cacheReadTokens: number;
|
|
42
|
+
cacheWriteTokens: number;
|
|
40
43
|
costUsd: number;
|
|
41
44
|
}
|
|
42
45
|
|
|
@@ -221,6 +224,8 @@ export async function runToolLoop(o: LoopOptions): Promise<LoopResult> {
|
|
|
221
224
|
usage = {
|
|
222
225
|
inputTokens: models.reduce((n, m) => n + (m.inputTokens ?? 0) + (m.cacheReadInputTokens ?? 0) + (m.cacheCreationInputTokens ?? 0), 0),
|
|
223
226
|
outputTokens: models.reduce((n, m) => n + (m.outputTokens ?? 0), 0),
|
|
227
|
+
cacheReadTokens: models.reduce((n, m) => n + (m.cacheReadInputTokens ?? 0), 0),
|
|
228
|
+
cacheWriteTokens: models.reduce((n, m) => n + (m.cacheCreationInputTokens ?? 0), 0),
|
|
224
229
|
costUsd,
|
|
225
230
|
};
|
|
226
231
|
if (message.subtype === "success") {
|