@foldrun/core 0.3.0 → 0.5.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/README.md +27 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +3 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +224 -4
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +26 -2
- package/dist/src/egress.js +8 -1
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +9 -0
- package/dist/src/evals.js +26 -13
- package/dist/src/flow-lint.d.ts +12 -0
- package/dist/src/flow-lint.js +331 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +43 -4
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +245 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +132 -8
- package/dist/src/notify.js +360 -58
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +87 -0
- package/dist/src/platform.d.ts +144 -4
- package/dist/src/platform.js +27 -3
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +233 -0
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +206 -17
- package/dist/src/run-container.js +547 -80
- package/dist/src/runner.d.ts +179 -10
- package/dist/src/runner.js +1423 -169
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +15 -2
- package/dist/src/script-tools.js +44 -4
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +83 -0
- package/dist/src/secret-health.js +177 -0
- package/dist/src/secrets.d.ts +38 -1
- package/dist/src/secrets.js +197 -96
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +95 -11
- package/dist/src/step-exec.d.ts +106 -1
- package/dist/src/step-exec.js +558 -85
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +492 -38
- package/dist/src/store.js +882 -69
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +152 -33
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +71 -24
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +23 -0
- package/dist/src/triggers.js +101 -12
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +50 -11
- package/index.ts +3 -0
- package/package.json +5 -5
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/approvals.ts +253 -2
- package/src/browser-pod.ts +313 -0
- package/src/budget.ts +178 -0
- package/src/catalog.ts +3 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +224 -4
- package/src/deploy.ts +137 -6
- package/src/egress.ts +32 -4
- package/src/eol.ts +7 -0
- package/src/evals.ts +36 -11
- package/src/flow-lint.ts +371 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +50 -4
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +316 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +391 -44
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +83 -0
- package/src/platform.ts +158 -5
- package/src/provider-health.ts +275 -0
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +611 -76
- package/src/runner.ts +1463 -165
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +55 -6
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +230 -0
- package/src/secrets.ts +191 -95
- package/src/starter.ts +96 -11
- package/src/step-exec.ts +548 -41
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +149 -24
- package/src/store.ts +1227 -74
- package/src/subagents.ts +189 -0
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +159 -32
- package/src/translator.ts +74 -23
- package/src/trigger-log.ts +160 -0
- package/src/triggers.ts +102 -12
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +58 -11
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
package/dist/src/approvals.js
CHANGED
|
@@ -9,8 +9,125 @@
|
|
|
9
9
|
// or a run approved from a phone would resume differently from one approved
|
|
10
10
|
// at a desk. So the decision lives in core and the routes only say who made
|
|
11
11
|
// it.
|
|
12
|
-
import
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import { readRun, writeRun, readFlow, mayApprove } from "./store.js";
|
|
13
14
|
import { platform } from "./platform.js";
|
|
15
|
+
import { approveToken, installKey } from "./webhook.js";
|
|
16
|
+
// ----------------------------------------------------------------- links
|
|
17
|
+
/** How long an emailed link decides for when the flow does not say. */
|
|
18
|
+
export const APPROVE_LINK_DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
19
|
+
/** The link's lifetime: the flow's `approve_within:` when it has one —
|
|
20
|
+
* after that the gate has expired on its own — else a week. */
|
|
21
|
+
export function approveLinkTtlMs(flow) {
|
|
22
|
+
return flow?.approveWithin ? flow.approveWithin * 1000 : APPROVE_LINK_DEFAULT_TTL_MS;
|
|
23
|
+
}
|
|
24
|
+
const b64 = (s) => Buffer.from(s, "utf8").toString("base64url");
|
|
25
|
+
const unb64 = (s) => Buffer.from(s, "base64url").toString("utf8");
|
|
26
|
+
function signLink(tenant, workspace, runId, step, expires, approver) {
|
|
27
|
+
return crypto
|
|
28
|
+
.createHmac("sha256", installKey())
|
|
29
|
+
.update(`approve-link:${tenant}/${workspace}/${runId}/${step}/${expires}/${approver}`)
|
|
30
|
+
.digest("hex")
|
|
31
|
+
.slice(0, 32);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The token an emailed link carries. Bound to one step of one run and to
|
|
35
|
+
* a moment, and — when the notification went to a named approver — to
|
|
36
|
+
* that person, so the link decides as them. A link that was only bound
|
|
37
|
+
* to the run approved every later gate of that run for as long as it ran,
|
|
38
|
+
* and a forwarded one let anyone past `approvers:`.
|
|
39
|
+
*
|
|
40
|
+
* `step.expires.approver.signature`; the signature covers all of it plus
|
|
41
|
+
* the run's identity, under the install key, so rotating the key kills
|
|
42
|
+
* every link at once, the way it kills every hook.
|
|
43
|
+
*/
|
|
44
|
+
export function approveLinkToken(tenant, workspace, runId, link) {
|
|
45
|
+
const approver = (link.approver ?? "").trim().toLowerCase();
|
|
46
|
+
const expires = Math.floor(link.expiresAt);
|
|
47
|
+
return `${link.step}.${expires}.${approver ? b64(approver) : "-"}.${signLink(tenant, workspace, runId, link.step, expires, approver)}`;
|
|
48
|
+
}
|
|
49
|
+
/** The path an emailed link opens — absolute when the caller prefixes the
|
|
50
|
+
* install's public origin. `decision=reject` pre-selects; a GET never
|
|
51
|
+
* decides. */
|
|
52
|
+
export function approveLinkPath(tenant, workspace, runId, link) {
|
|
53
|
+
return `/api/approve/${tenant}/${workspace}/${runId}?token=${approveLinkToken(tenant, workspace, runId, link)}`;
|
|
54
|
+
}
|
|
55
|
+
const same = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
56
|
+
/**
|
|
57
|
+
* What a presented token is, or why it is nothing. 401 for a token that
|
|
58
|
+
* was never minted for this run, 410 for one that was and has expired —
|
|
59
|
+
* the second deserves a sentence, because the person holding it did
|
|
60
|
+
* nothing wrong.
|
|
61
|
+
*/
|
|
62
|
+
export function readApproveLink(tenant, workspace, runId, presented, now = Date.now()) {
|
|
63
|
+
const invalid = { error: "invalid token", status: 401 };
|
|
64
|
+
if (!presented)
|
|
65
|
+
return invalid;
|
|
66
|
+
const parts = presented.split(".");
|
|
67
|
+
if (parts.length === 4) {
|
|
68
|
+
const [stepText, expText, approverText, sig] = parts;
|
|
69
|
+
if (!/^\d+$/.test(stepText) || !/^\d+$/.test(expText))
|
|
70
|
+
return invalid;
|
|
71
|
+
let approver = "";
|
|
72
|
+
try {
|
|
73
|
+
approver = approverText === "-" ? "" : unb64(approverText);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return invalid;
|
|
77
|
+
}
|
|
78
|
+
const step = Number(stepText);
|
|
79
|
+
const expires = Number(expText);
|
|
80
|
+
if (!same(sig, signLink(tenant, workspace, runId, step, expires, approver)))
|
|
81
|
+
return invalid;
|
|
82
|
+
if (now >= expires)
|
|
83
|
+
return { error: "this link has expired — open the run in the dashboard to decide it", status: 410 };
|
|
84
|
+
return { link: { kind: "step", step, expiresAt: new Date(expires).toISOString(), approver: approver || null } };
|
|
85
|
+
}
|
|
86
|
+
if (same(presented, approveToken(tenant, workspace, runId)))
|
|
87
|
+
return { link: { kind: "run" } };
|
|
88
|
+
return invalid;
|
|
89
|
+
}
|
|
90
|
+
/** A used link is written on the run as a digest, never the token. */
|
|
91
|
+
const linkDigest = (token) => crypto.createHash("sha256").update(token).digest("hex").slice(0, 24);
|
|
92
|
+
/**
|
|
93
|
+
* Everything a link has to pass before it decides anything, answered the
|
|
94
|
+
* same way for the page that shows the gate and the POST that decides it.
|
|
95
|
+
* Returns which waiting steps the link may decide and who it decides as.
|
|
96
|
+
*/
|
|
97
|
+
export function checkApproveLink(tenant, workspace, run, presented, now = Date.now()) {
|
|
98
|
+
const read = readApproveLink(tenant, workspace, run.id, presented, now);
|
|
99
|
+
if ("error" in read)
|
|
100
|
+
return { ok: false, status: read.status, message: read.error };
|
|
101
|
+
const { link } = read;
|
|
102
|
+
if (run.approvalLinksUsed?.includes(linkDigest(presented))) {
|
|
103
|
+
return { ok: false, status: 409, message: "this link was already used — a link decides once" };
|
|
104
|
+
}
|
|
105
|
+
// The older, run-bound link: kept for runs already waiting, but not for
|
|
106
|
+
// longer than a new link would have been.
|
|
107
|
+
if (link.kind === "run") {
|
|
108
|
+
const since = Date.parse(run.parkedAt ?? run.startedAt);
|
|
109
|
+
if (Number.isFinite(since) && now - since > APPROVE_LINK_DEFAULT_TTL_MS) {
|
|
110
|
+
return { ok: false, status: 410, message: "this link has expired — open the run in the dashboard to decide it" };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const waiting = run.steps
|
|
114
|
+
.map((s, i) => ({ s, i }))
|
|
115
|
+
.filter(({ s, i }) => s.status === "awaiting-approval" && s.waitFor !== "event" && (link.kind === "run" || i === link.step))
|
|
116
|
+
.map(({ i }) => i);
|
|
117
|
+
const approver = link.kind === "step" ? link.approver : null;
|
|
118
|
+
// A flow that names its approvers is asking for a person; a link is a
|
|
119
|
+
// possession. Only a link minted for one of the named approvers carries
|
|
120
|
+
// enough of an identity to be checked against the list.
|
|
121
|
+
const flow = readFlow(tenant, workspace, run.flow);
|
|
122
|
+
if (flow?.approvers?.length && !approver) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
status: 403,
|
|
126
|
+
message: `this flow names who may approve it (${flow.approvers.join(", ")}), and an emailed link carries no identity — sign in to the dashboard to decide it`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { ok: true, link, steps: waiting, approver };
|
|
130
|
+
}
|
|
14
131
|
/** An error the HTTP layer can map straight to a status — see errorResponse. */
|
|
15
132
|
class ApprovalError extends Error {
|
|
16
133
|
status;
|
|
@@ -32,9 +149,19 @@ export async function decideApproval(tenant, workspace, runId, d) {
|
|
|
32
149
|
const run = readRun(tenant, workspace, runId);
|
|
33
150
|
if (!run)
|
|
34
151
|
throw new ApprovalError("run not found", 404);
|
|
152
|
+
// A link decides what it was minted for, as whom it was minted for.
|
|
153
|
+
let allowed = null;
|
|
154
|
+
if (d.link !== undefined) {
|
|
155
|
+
const check = checkApproveLink(tenant, workspace, run, d.link);
|
|
156
|
+
if (!check.ok)
|
|
157
|
+
throw new ApprovalError(check.message, check.status);
|
|
158
|
+
allowed = check.steps;
|
|
159
|
+
d = { ...d, actor: check.approver ? { email: check.approver, role: null } : null };
|
|
160
|
+
}
|
|
161
|
+
assertMayDecide(tenant, workspace, run, d);
|
|
35
162
|
const waiting = run.steps
|
|
36
163
|
.map((s, i) => ({ s, i }))
|
|
37
|
-
.filter(({ s, i }) => s.status === "awaiting-approval" && (d.step === undefined || d.step === i));
|
|
164
|
+
.filter(({ s, i }) => s.status === "awaiting-approval" && (d.step === undefined || d.step === i) && (allowed === null || allowed.includes(i)));
|
|
38
165
|
if (waiting.length === 0)
|
|
39
166
|
throw new ApprovalError("nothing is awaiting approval", 409);
|
|
40
167
|
const note = typeof d.note === "string" ? d.note.trim() : "";
|
|
@@ -62,6 +189,17 @@ export async function decideApproval(tenant, workspace, runId, d) {
|
|
|
62
189
|
: `rejected ${d.by}${reason ? `: ${reason.slice(0, 200)}` : ""}`,
|
|
63
190
|
});
|
|
64
191
|
}
|
|
192
|
+
// The deadline belonged to the gate that just closed. Leaving it on the
|
|
193
|
+
// record would hand a later gate in the same run a deadline that has
|
|
194
|
+
// already passed.
|
|
195
|
+
if (run.steps.every((s) => s.status !== "awaiting-approval"))
|
|
196
|
+
run.approveBy = null;
|
|
197
|
+
// A link decides once. Written as a digest on the record, so a step that
|
|
198
|
+
// parks again — a loop, a rerun from the gate — is not decided by the
|
|
199
|
+
// same forwarded mail.
|
|
200
|
+
if (d.link !== undefined) {
|
|
201
|
+
run.approvalLinksUsed = [...(run.approvalLinksUsed ?? []), linkDigest(d.link)].slice(-50);
|
|
202
|
+
}
|
|
65
203
|
writeRun(tenant, workspace, run);
|
|
66
204
|
// A parked run has no process polling for this decision — the worker
|
|
67
205
|
// that was driving it gave its slot back at the gate. Line it up again.
|
|
@@ -72,6 +210,58 @@ export async function decideApproval(tenant, workspace, runId, d) {
|
|
|
72
210
|
}
|
|
73
211
|
return { run, steps: waiting.map(({ i }) => i) };
|
|
74
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* The two rules a person's decision has to pass. A token-bearing door — the
|
|
215
|
+
* emailed link, an external event — carries no actor and is not checked
|
|
216
|
+
* here: possession of a token derived from the run is its own authority,
|
|
217
|
+
* and the link was mailed to whoever the account told us to ask.
|
|
218
|
+
*
|
|
219
|
+
* Throws 403, which the routes surface verbatim: a refusal that does not say
|
|
220
|
+
* which rule refused sends someone to the wrong settings page.
|
|
221
|
+
*/
|
|
222
|
+
function assertMayDecide(tenant, workspace, run, d) {
|
|
223
|
+
if (!d.actor)
|
|
224
|
+
return;
|
|
225
|
+
const email = (d.actor.email ?? "").trim().toLowerCase();
|
|
226
|
+
// Rejecting your own run is always allowed: stopping something you started
|
|
227
|
+
// needs no second opinion, and the gate's whole purpose is that it does
|
|
228
|
+
// not proceed. Only approval needs someone else.
|
|
229
|
+
if (d.decision === "approve" && email && run.startedBy && run.startedBy === email) {
|
|
230
|
+
throw new ApprovalError("you started this run — an approval gate asks a second person, so someone else has to answer it", 403);
|
|
231
|
+
}
|
|
232
|
+
const flow = readFlow(tenant, workspace, run.flow);
|
|
233
|
+
if (!mayApprove(flow, d.actor)) {
|
|
234
|
+
throw new ApprovalError(`this flow's approvers: list does not include you (${flow?.approvers?.join(", ")})`, 403);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* A gate that ran out of time. `approve_within:` in the flow file stamps a
|
|
239
|
+
* deadline when the run parks; past it, the gate is rejected and the run
|
|
240
|
+
* fails, exactly as if a person had said no.
|
|
241
|
+
*
|
|
242
|
+
* Rejection, not approval, is the only safe way for a clock to answer a
|
|
243
|
+
* question a person was asked. The whole reason the step is parked is that
|
|
244
|
+
* someone wanted to look at it first.
|
|
245
|
+
*
|
|
246
|
+
* Returns whether it acted. Called from the same sweep that closes abandoned
|
|
247
|
+
* runs, so an expiry needs nothing running of its own.
|
|
248
|
+
*/
|
|
249
|
+
export async function expireStaleGate(tenant, workspace, run, now = Date.now()) {
|
|
250
|
+
if (run.status !== "awaiting-approval" || !run.approveBy)
|
|
251
|
+
return false;
|
|
252
|
+
if (now < new Date(run.approveBy).getTime())
|
|
253
|
+
return false;
|
|
254
|
+
// A step waiting on `wait: event` is not waiting on a person and is not
|
|
255
|
+
// this deadline's business.
|
|
256
|
+
if (!run.steps.some((s) => s.status === "awaiting-approval" && s.waitFor !== "event"))
|
|
257
|
+
return false;
|
|
258
|
+
await decideApproval(tenant, workspace, run.id, {
|
|
259
|
+
decision: "reject",
|
|
260
|
+
by: "automatically — nobody answered in time",
|
|
261
|
+
reason: `no decision within the flow's approve_within: (due ${run.approveBy})`,
|
|
262
|
+
});
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
75
265
|
/**
|
|
76
266
|
* Release a run parked on `wait: event` with what arrived. The payload is
|
|
77
267
|
* stored on the step — not as an approval note, which is a person's
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Set by the platform on a step it put on the slim image to browse
|
|
2
|
+
* through the pod. The web tool then never launches a browser of its own. */
|
|
3
|
+
export declare const POD_ONLY_ENV = "FOLDRUN_BROWSER_POD_ONLY";
|
|
4
|
+
/** Where the web tool appends one JSON line per reconnect, and the line
|
|
5
|
+
* that says the pod is lost or the call needs the full image. */
|
|
6
|
+
export declare const POD_EVENTS_ENV = "FOLDRUN_BROWSER_POD_EVENTS";
|
|
7
|
+
/** The file, inside the step's sandbox. */
|
|
8
|
+
export declare const POD_EVENTS_FILE = "/tmp/foldrun-browser-pod.jsonl";
|
|
9
|
+
/** The note the run carries when the fallback was taken. Worded once. */
|
|
10
|
+
export declare const RERAN_ON_FULL = "browser pod lost; re-ran on full";
|
|
11
|
+
/**
|
|
12
|
+
* Browse actions that can change something beyond the page in front of the
|
|
13
|
+
* model: anything that types, picks, submits, signs in, uploads, runs script
|
|
14
|
+
* or pays. A click is here because a click can submit and nothing before
|
|
15
|
+
* the click can tell. The web tool's run.mjs carries a copy (it decides
|
|
16
|
+
* whether a dropped call may be repeated); the gallery test holds the two
|
|
17
|
+
* equal.
|
|
18
|
+
*/
|
|
19
|
+
export declare const BROWSE_WRITE_ACTIONS: readonly ["click", "dblclick", "rightclick", "tap", "swipe", "mouse", "drag", "fill", "type", "insert", "paste", "press", "keyboard", "keydown", "keyup", "clear", "select", "check", "uncheck", "upload", "dialog", "login", "eval", "webmcp", "solve"];
|
|
20
|
+
export type CallKind = {
|
|
21
|
+
write: false;
|
|
22
|
+
} | {
|
|
23
|
+
write: true;
|
|
24
|
+
what: string;
|
|
25
|
+
};
|
|
26
|
+
export interface ClassifyContext {
|
|
27
|
+
/** SDK tool name → HTTP method, for an API's typed operations (api-tools
|
|
28
|
+
* builds the map; a generic call_<api> carries its method in the input). */
|
|
29
|
+
methods?: Record<string, string>;
|
|
30
|
+
/** Web actions a paid provider answers this step — from the env's
|
|
31
|
+
* FOLDRUN_WEB_<ACTION>_VIA. */
|
|
32
|
+
paidWeb?: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
/** The browse actions a call asked for, nested `if` branches included. */
|
|
35
|
+
export declare function browseActionNames(actions: unknown): string[];
|
|
36
|
+
/** What a browse call's arguments would write, or null when it only reads. */
|
|
37
|
+
export declare function browseWrites(input: Record<string, unknown>): string | null;
|
|
38
|
+
/**
|
|
39
|
+
* Whether one tool call can have changed anything outside the model's head,
|
|
40
|
+
* and if so, a few words saying what. Pure.
|
|
41
|
+
*/
|
|
42
|
+
export declare function classifyCall(tool: string, input: Record<string, unknown> | undefined, ctx?: ClassifyContext): CallKind;
|
|
43
|
+
/** Paid web actions, from a step's env (FOLDRUN_WEB_<ACTION>_VIA=provider). */
|
|
44
|
+
export declare function paidWebActions(env: Record<string, string | undefined>): Record<string, string>;
|
|
45
|
+
/** One line of the web tool's pod log. */
|
|
46
|
+
export interface PodEvent {
|
|
47
|
+
kind: "reconnect" | "lost" | "needs-full";
|
|
48
|
+
/** reconnect: which try, of how many, and whether it got through. */
|
|
49
|
+
attempt?: number;
|
|
50
|
+
of?: number;
|
|
51
|
+
ok?: boolean;
|
|
52
|
+
/** lost: whether the call ran on the pod at all before it was lost — a
|
|
53
|
+
* call that never connected changed nothing, whatever it asked for. */
|
|
54
|
+
ran?: boolean;
|
|
55
|
+
error?: string;
|
|
56
|
+
why?: string;
|
|
57
|
+
/** When the web tool wrote the line (ISO). */
|
|
58
|
+
at?: string;
|
|
59
|
+
}
|
|
60
|
+
/** ", at 10:15:15Z" from a pod log line's `at`; empty when it has none. */
|
|
61
|
+
export declare function podAt(at: unknown): string;
|
|
62
|
+
/** Lines of the pod log from byte `offset` on, and where the next read
|
|
63
|
+
* starts. A torn last line waits for the next read. */
|
|
64
|
+
export declare function readPodEvents(text: string, offset: number): {
|
|
65
|
+
events: PodEvent[];
|
|
66
|
+
next: number;
|
|
67
|
+
};
|
|
68
|
+
/** What crosses back from a step that browsed through the pod. */
|
|
69
|
+
export interface BrowserPodOutcome {
|
|
70
|
+
/** Reconnect tries the web tool made, and how many got through: the pod
|
|
71
|
+
* answered and the call went on, rather than dropping again at once. */
|
|
72
|
+
reconnects: number;
|
|
73
|
+
reconnected: number;
|
|
74
|
+
/** Reconnects that reached the pod, after which the call dropped again at
|
|
75
|
+
* once — a pod still answering while it shut down. Never "got through". */
|
|
76
|
+
closedAgain?: number;
|
|
77
|
+
/** Set when the step stopped because the pod was gone (or a call needed
|
|
78
|
+
* a browser in the step, which slim does not have). */
|
|
79
|
+
lost?: {
|
|
80
|
+
cause: "lost" | "needs-full";
|
|
81
|
+
detail: string;
|
|
82
|
+
};
|
|
83
|
+
/** What the step had written by then, worded — empty means reads only. */
|
|
84
|
+
writes?: string[];
|
|
85
|
+
}
|
|
86
|
+
/** "a.md (Write), crm POST and 2 more". */
|
|
87
|
+
export declare function describeWrites(writes: string[]): string;
|
|
88
|
+
/** The decision, once the pod is gone: re-run on full, or fail and say why. */
|
|
89
|
+
export declare function podLossDecision(pod: BrowserPodOutcome): {
|
|
90
|
+
rerun: true;
|
|
91
|
+
note: string;
|
|
92
|
+
} | {
|
|
93
|
+
rerun: false;
|
|
94
|
+
message: string;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Why a browsing step must have a browser in its own sandbox, from what the
|
|
98
|
+
* step start can see (the agent's `web.browse` block, as env), or null when
|
|
99
|
+
* the account's pod can serve every call it could make by default. A call
|
|
100
|
+
* that picks an in-step engine itself is caught at the call (needs-full).
|
|
101
|
+
*/
|
|
102
|
+
export declare function slimBrowseBlocker(env: Record<string, string | undefined>): string | null;
|
|
103
|
+
/** The outcome as it crosses back from the sandbox, checked field by field
|
|
104
|
+
* (the done line is the pod's word); null when absent or not this shape. */
|
|
105
|
+
export declare function parseBrowserPod(v: unknown): BrowserPodOutcome | null;
|
|
106
|
+
/** One line for a person: "2 reconnects (2 got through)", "browser pod
|
|
107
|
+
* lost; re-ran on full", or the failure. What the run page, `foldrun
|
|
108
|
+
* report` and an attempt row say. A pod that was lost in the end is never
|
|
109
|
+
* said to have let a reconnect through — "1 reconnect (1 got through); pod
|
|
110
|
+
* lost" read live as a contradiction — and a reconnect that reached the pod
|
|
111
|
+
* only for the call to drop again says that. */
|
|
112
|
+
export declare function browserPodLine(p: BrowserPodOutcome & {
|
|
113
|
+
fallback?: string;
|
|
114
|
+
failure?: string;
|
|
115
|
+
}): string;
|
|
116
|
+
/** What podTriesLine reads of a StepAttempt (store.ts) — structural, so
|
|
117
|
+
* this module stays free of the store's imports for the CLI and the web. */
|
|
118
|
+
export interface TryRow {
|
|
119
|
+
n: number;
|
|
120
|
+
status: string;
|
|
121
|
+
image?: string;
|
|
122
|
+
costUsd: number | null;
|
|
123
|
+
}
|
|
124
|
+
/** A step's tries in one line when the browser pod was lost under one of
|
|
125
|
+
* them — "slim · lost · $0.0040 → full · completed · $0.0060" — so the run
|
|
126
|
+
* page and `foldrun report` show the slim go and the full re-run apart.
|
|
127
|
+
* `#n` leads each try when the step had more than one attempt. Null when
|
|
128
|
+
* no try was lost. */
|
|
129
|
+
export declare function podTriesLine(tries: readonly TryRow[] | null | undefined): string | null;
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Slim browsing: a step whose only reason for the full runner image is that
|
|
2
|
+
// it browses may run on the slim image and drive the account's browser pod
|
|
3
|
+
// over Playwright's protocol. The pod can go away mid-step — a node drained,
|
|
4
|
+
// the idle reaper, an OOM — and a slim step has no browser of its own to fall
|
|
5
|
+
// back on. So:
|
|
6
|
+
//
|
|
7
|
+
// 1. The web tool reconnects first: a few tries, short backoff (the tool's
|
|
8
|
+
// run.mjs; each try is an event in FOLDRUN_BROWSER_POD_EVENTS).
|
|
9
|
+
// 2. Still gone: the step stops, and what it has done so far decides.
|
|
10
|
+
// Only reads — re-run the whole step from the start on the full image,
|
|
11
|
+
// and say so on the run ("browser pod lost; re-ran on full").
|
|
12
|
+
// 3. Anything written — fail the step: "browser pod died after the step had
|
|
13
|
+
// written <what>". Doing it again could send, post or charge twice, so
|
|
14
|
+
// retry:, on-fail: or a person decides.
|
|
15
|
+
//
|
|
16
|
+
// Read or write is decided from the calls the step made, as the model asked
|
|
17
|
+
// for them (step-exec.ts keeps the ledger). When a call could be either it
|
|
18
|
+
// counts as a write: the cost of being wrong that way is a failed step a
|
|
19
|
+
// retry can redo; the other way it is a duplicate email.
|
|
20
|
+
//
|
|
21
|
+
// Steps that act outward (a tool marked `outward: true`) never run slim; nor
|
|
22
|
+
// does anything the step start can already see needs a browser in the step
|
|
23
|
+
// (lightpanda, obscura, live sessions, extensions) — slimBrowseBlocker.
|
|
24
|
+
/** Set by the platform on a step it put on the slim image to browse
|
|
25
|
+
* through the pod. The web tool then never launches a browser of its own. */
|
|
26
|
+
export const POD_ONLY_ENV = "FOLDRUN_BROWSER_POD_ONLY";
|
|
27
|
+
/** Where the web tool appends one JSON line per reconnect, and the line
|
|
28
|
+
* that says the pod is lost or the call needs the full image. */
|
|
29
|
+
export const POD_EVENTS_ENV = "FOLDRUN_BROWSER_POD_EVENTS";
|
|
30
|
+
/** The file, inside the step's sandbox. */
|
|
31
|
+
export const POD_EVENTS_FILE = "/tmp/foldrun-browser-pod.jsonl";
|
|
32
|
+
/** The note the run carries when the fallback was taken. Worded once. */
|
|
33
|
+
export const RERAN_ON_FULL = "browser pod lost; re-ran on full";
|
|
34
|
+
/**
|
|
35
|
+
* Browse actions that can change something beyond the page in front of the
|
|
36
|
+
* model: anything that types, picks, submits, signs in, uploads, runs script
|
|
37
|
+
* or pays. A click is here because a click can submit and nothing before
|
|
38
|
+
* the click can tell. The web tool's run.mjs carries a copy (it decides
|
|
39
|
+
* whether a dropped call may be repeated); the gallery test holds the two
|
|
40
|
+
* equal.
|
|
41
|
+
*/
|
|
42
|
+
export const BROWSE_WRITE_ACTIONS = [
|
|
43
|
+
"click", "dblclick", "rightclick", "tap", "swipe", "mouse", "drag",
|
|
44
|
+
"fill", "type", "insert", "paste", "press", "keyboard", "keydown", "keyup", "clear",
|
|
45
|
+
"select", "check", "uncheck", "upload", "dialog", "login", "eval", "webmcp", "solve",
|
|
46
|
+
];
|
|
47
|
+
/** Call arguments of web action=browse that are writes whatever the actions:
|
|
48
|
+
* page script, a paid captcha solver, a confirmation of a credential step. */
|
|
49
|
+
const BROWSE_WRITE_ARGS = ["js", "captcha", "captcha_solver", "confirm"];
|
|
50
|
+
/** Web actions that only read — when foldrun's own answers them. A paid
|
|
51
|
+
* provider answering one (FOLDRUN_WEB_<ACTION>_VIA) makes it a write: doing
|
|
52
|
+
* it twice is paying twice. `monitor` keeps state, so it always writes. */
|
|
53
|
+
const WEB_READ_ACTIONS = new Set(["search", "fetch", "browse", "crawl", "map", "extract", "answer"]);
|
|
54
|
+
/** Built-ins that read and change nothing. */
|
|
55
|
+
const READ_BUILTINS = new Set(["Read", "Glob", "Grep", "WebSearch", "WebFetch", "TodoWrite", "LS", "NotebookRead"]);
|
|
56
|
+
/** The platform's own tool groups that only read. Consults are a toolless
|
|
57
|
+
* answer; a delegation's own calls come through on their own. */
|
|
58
|
+
const READ_SERVERS = new Set(["foldrun_search", "foldrun_history", "foldrun_desks", "foldrun_agents"]);
|
|
59
|
+
const DELEGATE = new Set(["Agent", "Task"]);
|
|
60
|
+
/** The browse actions a call asked for, nested `if` branches included. */
|
|
61
|
+
export function browseActionNames(actions) {
|
|
62
|
+
let steps = actions;
|
|
63
|
+
if (typeof steps === "string") {
|
|
64
|
+
try {
|
|
65
|
+
steps = JSON.parse(steps);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return ["(unreadable actions)"];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const out = [];
|
|
72
|
+
const walk = (v, depth) => {
|
|
73
|
+
if (depth > 8 || v === null || typeof v !== "object")
|
|
74
|
+
return;
|
|
75
|
+
if (Array.isArray(v)) {
|
|
76
|
+
for (const x of v)
|
|
77
|
+
walk(x, depth + 1);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
for (const [k, x] of Object.entries(v)) {
|
|
81
|
+
out.push(k);
|
|
82
|
+
walk(x, depth + 1);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
walk(steps, 0);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** What a browse call's arguments would write, or null when it only reads. */
|
|
89
|
+
export function browseWrites(input) {
|
|
90
|
+
const names = browseActionNames(input.actions);
|
|
91
|
+
if (names.includes("(unreadable actions)"))
|
|
92
|
+
return "browse actions it could not read";
|
|
93
|
+
const writes = [...new Set(names.filter((n) => BROWSE_WRITE_ACTIONS.includes(n)))];
|
|
94
|
+
if (writes.length)
|
|
95
|
+
return `browse ${writes.join("/")}`;
|
|
96
|
+
const arg = BROWSE_WRITE_ARGS.find((a) => input[a] !== undefined && input[a] !== null && input[a] !== "" && input[a] !== false);
|
|
97
|
+
if (arg)
|
|
98
|
+
return `browse ${arg}=`;
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Whether one tool call can have changed anything outside the model's head,
|
|
103
|
+
* and if so, a few words saying what. Pure.
|
|
104
|
+
*/
|
|
105
|
+
export function classifyCall(tool, input, ctx = {}) {
|
|
106
|
+
const args = input ?? {};
|
|
107
|
+
const write = (what) => ({ write: true, what });
|
|
108
|
+
if (READ_BUILTINS.has(tool) || DELEGATE.has(tool))
|
|
109
|
+
return { write: false };
|
|
110
|
+
if (tool === "Write" || tool === "Edit" || tool === "MultiEdit" || tool === "NotebookEdit") {
|
|
111
|
+
const raw = String(args.file_path ?? args.notebook_path ?? args.path ?? "");
|
|
112
|
+
const file = raw.startsWith("/workspace/") ? raw.slice("/workspace/".length) : raw.split("/").slice(-3).join("/");
|
|
113
|
+
return write(file ? `${file} (${tool})` : `a file (${tool})`);
|
|
114
|
+
}
|
|
115
|
+
if (tool === "Bash")
|
|
116
|
+
return write("a shell command");
|
|
117
|
+
const mcp = /^mcp__(.+?)__(.+)$/.exec(tool);
|
|
118
|
+
if (!mcp)
|
|
119
|
+
return write(tool); // unknown: a write until shown otherwise
|
|
120
|
+
const [, server, name] = mcp;
|
|
121
|
+
if (READ_SERVERS.has(server))
|
|
122
|
+
return { write: false };
|
|
123
|
+
if (server === "foldrun_ask")
|
|
124
|
+
return write("a question to a person");
|
|
125
|
+
if (server === "foldrun_scripts") {
|
|
126
|
+
if (name !== "web")
|
|
127
|
+
return write(`script ${name}`);
|
|
128
|
+
const action = String(args.action ?? "").trim().toLowerCase();
|
|
129
|
+
if (!WEB_READ_ACTIONS.has(action))
|
|
130
|
+
return write(action === "monitor" ? "web monitor state" : `web ${action || "call"}`);
|
|
131
|
+
const paid = ctx.paidWeb?.[action];
|
|
132
|
+
if (paid)
|
|
133
|
+
return write(`a paid ${paid} ${action}`);
|
|
134
|
+
if (action === "browse") {
|
|
135
|
+
const w = browseWrites(args);
|
|
136
|
+
if (w)
|
|
137
|
+
return write(w);
|
|
138
|
+
}
|
|
139
|
+
return { write: false };
|
|
140
|
+
}
|
|
141
|
+
if (server === "foldrun_apis") {
|
|
142
|
+
const method = String(name.startsWith("call_") ? (args.method ?? "GET") : (ctx.methods?.[tool] ?? "")).toUpperCase();
|
|
143
|
+
const api = name.replace(/^call_/, "");
|
|
144
|
+
if (method === "GET" || method === "HEAD")
|
|
145
|
+
return { write: false };
|
|
146
|
+
return write(`${api} ${method || "call"}`);
|
|
147
|
+
}
|
|
148
|
+
// Any other MCP server: its tools could do anything.
|
|
149
|
+
return write(`${server} ${name}`);
|
|
150
|
+
}
|
|
151
|
+
/** Paid web actions, from a step's env (FOLDRUN_WEB_<ACTION>_VIA=provider). */
|
|
152
|
+
export function paidWebActions(env) {
|
|
153
|
+
const out = {};
|
|
154
|
+
for (const [k, v] of Object.entries(env)) {
|
|
155
|
+
const m = /^FOLDRUN_WEB_([A-Z]+)_VIA$/.exec(k);
|
|
156
|
+
if (m && v)
|
|
157
|
+
out[m[1].toLowerCase()] = v;
|
|
158
|
+
}
|
|
159
|
+
if (env.FOLDRUN_BROWSER_VENDOR)
|
|
160
|
+
out.browse = env.FOLDRUN_BROWSER_VENDOR;
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
/** ", at 10:15:15Z" from a pod log line's `at`; empty when it has none. */
|
|
164
|
+
export function podAt(at) {
|
|
165
|
+
if (typeof at !== "string")
|
|
166
|
+
return "";
|
|
167
|
+
const t = Date.parse(at);
|
|
168
|
+
return Number.isNaN(t) ? "" : `, at ${new Date(t).toISOString().slice(11, 19)}Z`;
|
|
169
|
+
}
|
|
170
|
+
/** Lines of the pod log from byte `offset` on, and where the next read
|
|
171
|
+
* starts. A torn last line waits for the next read. */
|
|
172
|
+
export function readPodEvents(text, offset) {
|
|
173
|
+
const chunk = text.slice(offset);
|
|
174
|
+
const end = chunk.lastIndexOf("\n");
|
|
175
|
+
if (end < 0)
|
|
176
|
+
return { events: [], next: offset };
|
|
177
|
+
const events = [];
|
|
178
|
+
for (const line of chunk.slice(0, end).split("\n")) {
|
|
179
|
+
try {
|
|
180
|
+
const e = JSON.parse(line);
|
|
181
|
+
if (e && (e.kind === "reconnect" || e.kind === "lost" || e.kind === "needs-full"))
|
|
182
|
+
events.push(e);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// not ours
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { events, next: offset + end + 1 };
|
|
189
|
+
}
|
|
190
|
+
/** "a.md (Write), crm POST and 2 more". */
|
|
191
|
+
export function describeWrites(writes) {
|
|
192
|
+
const uniq = [...new Set(writes)];
|
|
193
|
+
if (uniq.length > 3)
|
|
194
|
+
return `${uniq.slice(0, 3).join(", ")} and ${uniq.length - 3} more`;
|
|
195
|
+
if (uniq.length <= 1)
|
|
196
|
+
return uniq[0] ?? "something";
|
|
197
|
+
return `${uniq.slice(0, -1).join(", ")} and ${uniq.at(-1)}`;
|
|
198
|
+
}
|
|
199
|
+
/** The decision, once the pod is gone: re-run on full, or fail and say why. */
|
|
200
|
+
export function podLossDecision(pod) {
|
|
201
|
+
const writes = pod.writes ?? [];
|
|
202
|
+
const cause = pod.lost?.cause === "needs-full" ? `a call needed a browser in the step (${pod.lost.detail})` : `browser pod lost (${pod.lost?.detail ?? "connection closed"})`;
|
|
203
|
+
if (!writes.length)
|
|
204
|
+
return { rerun: true, note: `${RERAN_ON_FULL} — ${cause}; the step had only read, so it ran again from the start on the full image` };
|
|
205
|
+
const head = pod.lost?.cause === "needs-full"
|
|
206
|
+
? `a call needed a browser in the step after the step had written ${describeWrites(writes)}`
|
|
207
|
+
: `browser pod died after the step had written ${describeWrites(writes)}`;
|
|
208
|
+
return { rerun: false, message: `${head} — not re-run, since doing it again could repeat that; retry:, on-fail: or a person decides (${pod.lost?.detail ?? "connection closed"})` };
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Why a browsing step must have a browser in its own sandbox, from what the
|
|
212
|
+
* step start can see (the agent's `web.browse` block, as env), or null when
|
|
213
|
+
* the account's pod can serve every call it could make by default. A call
|
|
214
|
+
* that picks an in-step engine itself is caught at the call (needs-full).
|
|
215
|
+
*/
|
|
216
|
+
export function slimBrowseBlocker(env) {
|
|
217
|
+
if (env.FOLDRUN_BROWSER_VENDOR)
|
|
218
|
+
return `web.browse names ${env.FOLDRUN_BROWSER_VENDOR}`;
|
|
219
|
+
if (env.FOLDRUN_BROWSER_LIVE === "1")
|
|
220
|
+
return "web.browse live: sessions run in the step";
|
|
221
|
+
const engine = (env.FOLDRUN_BROWSER_ENGINE ?? "").trim().toLowerCase();
|
|
222
|
+
if (engine === "lightpanda" || engine === "obscura")
|
|
223
|
+
return `engine ${engine} runs in the step`;
|
|
224
|
+
if (env.FOLDRUN_BROWSER_EXTENSIONS)
|
|
225
|
+
return "extensions load into a browser in the step";
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
/** The outcome as it crosses back from the sandbox, checked field by field
|
|
229
|
+
* (the done line is the pod's word); null when absent or not this shape. */
|
|
230
|
+
export function parseBrowserPod(v) {
|
|
231
|
+
if (!v || typeof v !== "object")
|
|
232
|
+
return null;
|
|
233
|
+
const o = v;
|
|
234
|
+
const n = (x) => (typeof x === "number" && Number.isFinite(x) && x >= 0 ? Math.floor(x) : 0);
|
|
235
|
+
const out = { reconnects: n(o.reconnects), reconnected: n(o.reconnected) };
|
|
236
|
+
if (n(o.closedAgain))
|
|
237
|
+
out.closedAgain = n(o.closedAgain);
|
|
238
|
+
const lost = o.lost;
|
|
239
|
+
if (lost && typeof lost === "object" && (lost.cause === "lost" || lost.cause === "needs-full")) {
|
|
240
|
+
out.lost = { cause: lost.cause, detail: String(lost.detail ?? "").slice(0, 300) };
|
|
241
|
+
out.writes = Array.isArray(o.writes) ? o.writes.slice(0, 50).map((w) => String(w).slice(0, 120)) : [];
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
/** One line for a person: "2 reconnects (2 got through)", "browser pod
|
|
246
|
+
* lost; re-ran on full", or the failure. What the run page, `foldrun
|
|
247
|
+
* report` and an attempt row say. A pod that was lost in the end is never
|
|
248
|
+
* said to have let a reconnect through — "1 reconnect (1 got through); pod
|
|
249
|
+
* lost" read live as a contradiction — and a reconnect that reached the pod
|
|
250
|
+
* only for the call to drop again says that. */
|
|
251
|
+
export function browserPodLine(p) {
|
|
252
|
+
const count = `${p.reconnects} reconnect${p.reconnects === 1 ? "" : "s"}`;
|
|
253
|
+
const tries = !p.reconnects
|
|
254
|
+
? "no reconnects"
|
|
255
|
+
: !p.lost
|
|
256
|
+
? `${count} (${p.reconnected} got through)`
|
|
257
|
+
: p.closedAgain
|
|
258
|
+
? `${count}, ${p.reconnects === 1 ? "which" : "the last"} reached the pod but it closed again`
|
|
259
|
+
: count;
|
|
260
|
+
if (p.failure)
|
|
261
|
+
return `${tries}; ${p.failure}`;
|
|
262
|
+
if (p.fallback)
|
|
263
|
+
return `${tries}; ${p.fallback}${p.lost ? ` (${p.lost.detail})` : ""}`;
|
|
264
|
+
if (p.lost)
|
|
265
|
+
return `${tries}; ${p.lost.cause === "needs-full" ? "needed a browser in the step" : "pod lost"}: ${p.lost.detail}`;
|
|
266
|
+
return tries;
|
|
267
|
+
}
|
|
268
|
+
/** A step's tries in one line when the browser pod was lost under one of
|
|
269
|
+
* them — "slim · lost · $0.0040 → full · completed · $0.0060" — so the run
|
|
270
|
+
* page and `foldrun report` show the slim go and the full re-run apart.
|
|
271
|
+
* `#n` leads each try when the step had more than one attempt. Null when
|
|
272
|
+
* no try was lost. */
|
|
273
|
+
export function podTriesLine(tries) {
|
|
274
|
+
if (!tries?.some((t) => t.status === "lost"))
|
|
275
|
+
return null;
|
|
276
|
+
const many = new Set(tries.map((t) => t.n)).size > 1;
|
|
277
|
+
return tries
|
|
278
|
+
.map((t) => [
|
|
279
|
+
`${many ? `#${t.n} ` : ""}${t.image ?? "?"}`,
|
|
280
|
+
t.status,
|
|
281
|
+
typeof t.costUsd === "number" ? `$${t.costUsd.toFixed(4)}` : null,
|
|
282
|
+
].filter(Boolean).join(" · "))
|
|
283
|
+
.join(" → ");
|
|
284
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type BudgetPeriod = "run" | "day" | "week" | "month";
|
|
2
|
+
export interface Budget {
|
|
3
|
+
usd: number;
|
|
4
|
+
period: BudgetPeriod;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The budget a frontmatter value declares, or null for "no cap".
|
|
8
|
+
*
|
|
9
|
+
* Lenient on purpose: a value nobody can read is treated as no cap rather
|
|
10
|
+
* than as a cap of zero, because refusing every run over a typo is the worse
|
|
11
|
+
* failure. `budgetProblem` is the strict reading, for lint and the editor.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseBudget(raw: unknown, defaultPeriod: BudgetPeriod): Budget | null;
|
|
14
|
+
/**
|
|
15
|
+
* What is wrong with a `budget:` value, in a sentence, or null when it is
|
|
16
|
+
* fine. `allowed` names the periods this file may use — an agent's or a
|
|
17
|
+
* flow's budget is per run and nothing else; a workspace's or the account's
|
|
18
|
+
* is over time and never per run.
|
|
19
|
+
*/
|
|
20
|
+
export declare function budgetProblem(raw: unknown, allowed: BudgetPeriod[]): string | null;
|
|
21
|
+
/** "$60/day", "$500/month", "$6/run". */
|
|
22
|
+
export declare function formatBudget(b: Budget): string;
|
|
23
|
+
/** "today", "this week", "this month" — the window a spend is measured in. */
|
|
24
|
+
export declare function windowName(period: BudgetPeriod): string;
|
|
25
|
+
/** What to wait for when the cap is reached. */
|
|
26
|
+
export declare function windowTurnsHint(period: BudgetPeriod): string;
|
|
27
|
+
/**
|
|
28
|
+
* When the current window began, as a UTC instant — the moment spend is
|
|
29
|
+
* summed from. Today's midnight, Monday's midnight, or the first of the
|
|
30
|
+
* month, all in the given calendar. A per-run budget has no window.
|
|
31
|
+
*/
|
|
32
|
+
export declare function windowStartMs(period: BudgetPeriod, timeZone: string, now?: Date): number | null;
|