@foldrun/core 0.2.0 → 0.4.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 (85) hide show
  1. package/README.md +31 -10
  2. package/dist/src/approvals.d.ts +104 -1
  3. package/dist/src/approvals.js +192 -2
  4. package/dist/src/budget.d.ts +32 -0
  5. package/dist/src/budget.js +177 -0
  6. package/dist/src/catalog.js +2 -1
  7. package/dist/src/confine.js +6 -1
  8. package/dist/src/deploy.js +4 -2
  9. package/dist/src/egress.d.ts +19 -2
  10. package/dist/src/egress.js +2 -1
  11. package/dist/src/evals.d.ts +4 -0
  12. package/dist/src/evals.js +20 -11
  13. package/dist/src/flow-lint.d.ts +4 -0
  14. package/dist/src/flow-lint.js +108 -0
  15. package/dist/src/host-env.d.ts +8 -0
  16. package/dist/src/host-env.js +48 -0
  17. package/dist/src/json-schema.d.ts +12 -0
  18. package/dist/src/json-schema.js +202 -0
  19. package/dist/src/library.js +13 -1
  20. package/dist/src/notify.d.ts +79 -1
  21. package/dist/src/notify.js +243 -9
  22. package/dist/src/paths.d.ts +5 -0
  23. package/dist/src/paths.js +32 -0
  24. package/dist/src/platform.d.ts +112 -2
  25. package/dist/src/platform.js +20 -2
  26. package/dist/src/provider-health.d.ts +55 -0
  27. package/dist/src/provider-health.js +232 -0
  28. package/dist/src/run-container.d.ts +80 -4
  29. package/dist/src/run-container.js +176 -37
  30. package/dist/src/runner.d.ts +41 -5
  31. package/dist/src/runner.js +513 -81
  32. package/dist/src/script-tools.d.ts +12 -0
  33. package/dist/src/script-tools.js +10 -2
  34. package/dist/src/secret-health.d.ts +59 -0
  35. package/dist/src/secret-health.js +155 -0
  36. package/dist/src/secrets.d.ts +19 -1
  37. package/dist/src/secrets.js +44 -12
  38. package/dist/src/starter.js +4 -0
  39. package/dist/src/step-exec.d.ts +62 -6
  40. package/dist/src/step-exec.js +199 -68
  41. package/dist/src/store.d.ts +300 -3
  42. package/dist/src/store.js +574 -26
  43. package/dist/src/tar.js +2 -1
  44. package/dist/src/test-mode.d.ts +111 -0
  45. package/dist/src/test-mode.js +285 -0
  46. package/dist/src/tool-test.js +48 -21
  47. package/dist/src/translator.js +2 -1
  48. package/dist/src/trigger-log.d.ts +43 -0
  49. package/dist/src/trigger-log.js +122 -0
  50. package/dist/src/triggers.d.ts +20 -0
  51. package/dist/src/triggers.js +91 -10
  52. package/dist/src/webhook.d.ts +15 -3
  53. package/dist/src/webhook.js +11 -11
  54. package/package.json +6 -5
  55. package/src/approvals.ts +253 -2
  56. package/src/budget.ts +178 -0
  57. package/src/catalog.ts +2 -1
  58. package/src/confine.ts +6 -1
  59. package/src/deploy.ts +4 -1
  60. package/src/egress.ts +20 -4
  61. package/src/evals.ts +25 -9
  62. package/src/flow-lint.ts +118 -0
  63. package/src/host-env.ts +51 -0
  64. package/src/json-schema.ts +184 -0
  65. package/src/library.ts +13 -1
  66. package/src/notify.ts +272 -11
  67. package/src/paths.ts +29 -0
  68. package/src/platform.ts +119 -3
  69. package/src/provider-health.ts +274 -0
  70. package/src/run-container.ts +215 -35
  71. package/src/runner.ts +525 -73
  72. package/src/script-tools.ts +18 -2
  73. package/src/secret-health.ts +185 -0
  74. package/src/secrets.ts +44 -10
  75. package/src/starter.ts +4 -0
  76. package/src/step-exec.ts +176 -21
  77. package/src/storage.ts +8 -2
  78. package/src/store.ts +785 -25
  79. package/src/tar.ts +2 -1
  80. package/src/test-mode.ts +311 -0
  81. package/src/tool-test.ts +49 -20
  82. package/src/translator.ts +2 -1
  83. package/src/trigger-log.ts +159 -0
  84. package/src/triggers.ts +92 -10
  85. package/src/webhook.ts +17 -11
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # foldrun
2
2
 
3
+ [![ci](https://github.com/foldrun-io/foldrun-core/actions/workflows/ci.yml/badge.svg)](https://github.com/foldrun-io/foldrun-core/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/@foldrun/core)](https://www.npmjs.com/package/@foldrun/core)
5
+ [![licence](https://img.shields.io/badge/licence-Apache--2.0-blue)](LICENSE)
6
+
3
7
  **Agents are just folders. Write them, check them, run them.**
4
8
 
5
9
  An agent is a folder. Its instructions, skills, knowledge, memory, tools and
@@ -184,19 +188,36 @@ a hand-edited template cannot ship an index that disagrees with its own files.
184
188
  ## Hosting it
185
189
 
186
190
  This package is the framework: everything needed to read a workspace and
187
- run it on the machine you are on. The hosted platform at
188
- [foldrun.io](https://foldrun.io) — a queue and its worker, one gVisor pod
189
- per step, a vault, metering, a team — is built on it, in a separate private
190
- package that plugs in through `src/platform.ts`. Nothing in this repository
191
- imports it, so what is here is exactly what is open. There is no
192
- self-hosted platform; `foldrun deploy` and `git push` move a workspace from
193
- your machine to the hosted one unchanged.
191
+ run it on the machine you are on. The platform — the dashboard, a queue and
192
+ its worker, one sandbox per step, a vault, metering, a team — is built on
193
+ it, in a separate package that plugs in through `src/platform.ts`. Nothing
194
+ in this repository imports it, so what is here is exactly what is open.
195
+
196
+ **Run the platform yourself:** one container, one command —
197
+ [self-hosting/](self-hosting/). Sign up at `localhost:3900`, and
198
+ `foldrun deploy` moves a workspace from your machine to it unchanged. The
199
+ images are the same builds that run foldrun's own installation, published
200
+ under the same versions.
194
201
 
195
202
  ## Requirements
196
203
 
197
- Node 22+. Docker or Podman if your agents run scripts. Credentials come from
198
- `ANTHROPIC_API_KEY` or an existing Claude Code login — `foldrun check` needs
199
- neither.
204
+ Node 22+. Docker or Podman if your agents run scripts. `foldrun check` needs
205
+ no credential at all.
206
+
207
+ To *run* an agent you need one of:
208
+
209
+ - **`ANTHROPIC_API_KEY`** — an API key from
210
+ [console.anthropic.com](https://console.anthropic.com).
211
+ - **Your own provider.** An agent that names a `provider:` needs no
212
+ Anthropic key: 29 endpoints are known by name, including `ollama`,
213
+ `lmstudio` and `vllm`, so a local model is a complete answer. Anything
214
+ not in that list works by spelling out `base_url:`, `format:` and
215
+ `auth:`. See [providers](https://docs.foldrun.io/providers).
216
+
217
+ A **Claude Code or claude.ai subscription login is not one of them**, and
218
+ the CLI will not look for one. Anthropic does not permit products built on
219
+ its Agent SDK to run on consumer subscriptions, so using that login here
220
+ would put you outside their terms — an API key is the supported path.
200
221
 
201
222
  ## Status
202
223
 
@@ -1,4 +1,4 @@
1
- import { type RunRecord } from "./store.ts";
1
+ import { type FlowInfo, type RunRecord } from "./store.ts";
2
2
  export interface ApprovalDecision {
3
3
  decision: "approve" | "reject";
4
4
  /** Guidance for the step being approved — rides the record into its prompt. */
@@ -9,7 +9,97 @@ export interface ApprovalDecision {
9
9
  step?: number;
10
10
  /** Who decided, as it reads after the verb: "by a human", "via emailed link". */
11
11
  by: string;
12
+ /**
13
+ * The person behind the decision, when a session made it: their email and
14
+ * the role they hold. A link and an external event have neither, and are
15
+ * checked by their token instead — the token IS the authority there.
16
+ *
17
+ * Two rules read this. `approvers:` in the flow file names who may decide
18
+ * that flow's gates at all. And nobody approves a run they started
19
+ * themselves: a gate exists so that a second person looks, and a flow a
20
+ * person kicked off and then waved through is a gate that never happened.
21
+ */
22
+ actor?: {
23
+ email?: string | null;
24
+ role?: string | null;
25
+ } | null;
26
+ /**
27
+ * The emailed link's token, when a link is deciding. Read here rather
28
+ * than trusted from the route, because what a link may decide is policy:
29
+ * the step it was minted for, before it expires, once, and never a gate
30
+ * whose flow names its approvers unless the link was minted for one of
31
+ * them. See readApproveLink.
32
+ */
33
+ link?: string;
12
34
  }
35
+ /** How long an emailed link decides for when the flow does not say. */
36
+ export declare const APPROVE_LINK_DEFAULT_TTL_MS: number;
37
+ /** What a link may decide: one step, until a moment, as somebody or as
38
+ * nobody. A `run` link is the older kind — bound to the run alone, and
39
+ * kept working, once, for runs that were already waiting when this
40
+ * changed. */
41
+ export type ApproveLink = {
42
+ kind: "step";
43
+ step: number;
44
+ expiresAt: string;
45
+ approver: string | null;
46
+ } | {
47
+ kind: "run";
48
+ };
49
+ /** The link's lifetime: the flow's `approve_within:` when it has one —
50
+ * after that the gate has expired on its own — else a week. */
51
+ export declare function approveLinkTtlMs(flow: Pick<FlowInfo, "approveWithin"> | null | undefined): number;
52
+ /**
53
+ * The token an emailed link carries. Bound to one step of one run and to
54
+ * a moment, and — when the notification went to a named approver — to
55
+ * that person, so the link decides as them. A link that was only bound
56
+ * to the run approved every later gate of that run for as long as it ran,
57
+ * and a forwarded one let anyone past `approvers:`.
58
+ *
59
+ * `step.expires.approver.signature`; the signature covers all of it plus
60
+ * the run's identity, under the install key, so rotating the key kills
61
+ * every link at once, the way it kills every hook.
62
+ */
63
+ export declare function approveLinkToken(tenant: string, workspace: string, runId: string, link: {
64
+ step: number;
65
+ expiresAt: number;
66
+ approver?: string | null;
67
+ }): string;
68
+ /** The path an emailed link opens — absolute when the caller prefixes the
69
+ * install's public origin. `decision=reject` pre-selects; a GET never
70
+ * decides. */
71
+ export declare function approveLinkPath(tenant: string, workspace: string, runId: string, link: {
72
+ step: number;
73
+ expiresAt: number;
74
+ approver?: string | null;
75
+ }): string;
76
+ /**
77
+ * What a presented token is, or why it is nothing. 401 for a token that
78
+ * was never minted for this run, 410 for one that was and has expired —
79
+ * the second deserves a sentence, because the person holding it did
80
+ * nothing wrong.
81
+ */
82
+ export declare function readApproveLink(tenant: string, workspace: string, runId: string, presented: string, now?: number): {
83
+ link: ApproveLink;
84
+ } | {
85
+ error: string;
86
+ status: 401 | 410;
87
+ };
88
+ /**
89
+ * Everything a link has to pass before it decides anything, answered the
90
+ * same way for the page that shows the gate and the POST that decides it.
91
+ * Returns which waiting steps the link may decide and who it decides as.
92
+ */
93
+ export declare function checkApproveLink(tenant: string, workspace: string, run: RunRecord, presented: string, now?: number): {
94
+ ok: true;
95
+ link: ApproveLink;
96
+ steps: number[];
97
+ approver: string | null;
98
+ } | {
99
+ ok: false;
100
+ status: number;
101
+ message: string;
102
+ };
13
103
  /**
14
104
  * Apply a decision to every step the run is waiting on, and line the run up
15
105
  * again if a worker had parked it. Returns the record as written and which
@@ -23,6 +113,19 @@ export declare function decideApproval(tenant: string, workspace: string, runId:
23
113
  run: RunRecord;
24
114
  steps: number[];
25
115
  }>;
116
+ /**
117
+ * A gate that ran out of time. `approve_within:` in the flow file stamps a
118
+ * deadline when the run parks; past it, the gate is rejected and the run
119
+ * fails, exactly as if a person had said no.
120
+ *
121
+ * Rejection, not approval, is the only safe way for a clock to answer a
122
+ * question a person was asked. The whole reason the step is parked is that
123
+ * someone wanted to look at it first.
124
+ *
125
+ * Returns whether it acted. Called from the same sweep that closes abandoned
126
+ * runs, so an expiry needs nothing running of its own.
127
+ */
128
+ export declare function expireStaleGate(tenant: string, workspace: string, run: RunRecord, now?: number): Promise<boolean>;
26
129
  /**
27
130
  * Release a run parked on `wait: event` with what arrived. The payload is
28
131
  * stored on the step — not as an approval note, which is a person's
@@ -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 { readRun, writeRun } from "./store.js";
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,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;
@@ -0,0 +1,177 @@
1
+ // A budget is a number and a period — what may be spent, and over what.
2
+ //
3
+ // One grammar, read in four places: an agent's `budget:` (the most that
4
+ // agent may spend in one run), a flow's (the most one run may spend), a
5
+ // workspace's and the account's (a cap over a window of time). The value
6
+ // was a bare number meaning "per month" in a workspace or account file and
7
+ // "per run" in a flow; it still is, and it can now also say which:
8
+ //
9
+ // budget: 60 a number alone: per run in a flow or an agent,
10
+ // per month in a workspace or the account
11
+ // budget: 60/day or 60/week, 60/month
12
+ // budget: unlimited no cap — the same as leaving the key out
13
+ //
14
+ // Unset means no limit. That is the platform's rule, not an accident of
15
+ // parsing: a customer who has not written a cap has not asked for one, and
16
+ // nothing here invents a default they would then have to find and raise.
17
+ //
18
+ // Windows follow the account's calendar (`timezone:` in AGENTS.md), the
19
+ // same one the scheduler and "today" already use, because a daily cap that
20
+ // resets at 10am Sydney time is not a daily cap anyone meant.
21
+ /** Words that mean "no cap", case-insensitively. */
22
+ const UNLIMITED = new Set(["unlimited", "none", "no limit", "no-limit", "off", "-", "~"]);
23
+ const PERIODS = {
24
+ run: "run",
25
+ day: "day",
26
+ daily: "day",
27
+ week: "week",
28
+ weekly: "week",
29
+ month: "month",
30
+ monthly: "month",
31
+ };
32
+ // `\d+(?:\.\d+)?` with an optional separator and one period word: every
33
+ // part has one way to match, so a hostile string cannot make it backtrack.
34
+ const FORM_RE = /^\$?(\d+(?:\.\d+)?)(?:\s*(?:\/|per|a|each)?\s*([a-z]+))?$/;
35
+ /**
36
+ * The budget a frontmatter value declares, or null for "no cap".
37
+ *
38
+ * Lenient on purpose: a value nobody can read is treated as no cap rather
39
+ * than as a cap of zero, because refusing every run over a typo is the worse
40
+ * failure. `budgetProblem` is the strict reading, for lint and the editor.
41
+ */
42
+ export function parseBudget(raw, defaultPeriod) {
43
+ if (raw === undefined || raw === null)
44
+ return null;
45
+ if (typeof raw === "number")
46
+ return Number.isFinite(raw) && raw > 0 ? { usd: raw, period: defaultPeriod } : null;
47
+ if (typeof raw !== "string")
48
+ return null;
49
+ const s = raw.trim().toLowerCase();
50
+ if (!s || UNLIMITED.has(s))
51
+ return null;
52
+ const m = FORM_RE.exec(s);
53
+ if (!m)
54
+ return null;
55
+ const usd = Number(m[1]);
56
+ if (!Number.isFinite(usd) || usd <= 0)
57
+ return null;
58
+ const period = m[2] ? PERIODS[m[2]] : defaultPeriod;
59
+ if (!period)
60
+ return null;
61
+ return { usd, period };
62
+ }
63
+ /**
64
+ * What is wrong with a `budget:` value, in a sentence, or null when it is
65
+ * fine. `allowed` names the periods this file may use — an agent's or a
66
+ * flow's budget is per run and nothing else; a workspace's or the account's
67
+ * is over time and never per run.
68
+ */
69
+ export function budgetProblem(raw, allowed) {
70
+ if (raw === undefined || raw === null)
71
+ return null;
72
+ if (typeof raw === "number") {
73
+ if (!Number.isFinite(raw) || raw < 0)
74
+ return `budget: ${String(raw)} is not an amount in USD`;
75
+ return null;
76
+ }
77
+ if (typeof raw !== "string")
78
+ return `budget: must be a number, "<number>/<day|week|month>", or unlimited`;
79
+ const s = raw.trim().toLowerCase();
80
+ if (!s || UNLIMITED.has(s))
81
+ return null;
82
+ const m = FORM_RE.exec(s);
83
+ if (!m)
84
+ return `budget: "${raw}" is not a number, "<number>/<day|week|month>", or unlimited`;
85
+ if (m[2]) {
86
+ const period = PERIODS[m[2]];
87
+ if (!period)
88
+ return `budget: "${m[2]}" is not a period — day, week or month`;
89
+ if (!allowed.includes(period)) {
90
+ return period === "run"
91
+ ? `budget: is per run here already — write the number alone`
92
+ : `budget: "${period}" does not apply here — this cap is per run; a cap over time belongs in the workspace's or the account's AGENTS.md`;
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+ /** "$60/day", "$500/month", "$6/run". */
98
+ export function formatBudget(b) {
99
+ const usd = Number.isInteger(b.usd) ? String(b.usd) : b.usd.toFixed(2);
100
+ return `$${usd}/${b.period}`;
101
+ }
102
+ /** "today", "this week", "this month" — the window a spend is measured in. */
103
+ export function windowName(period) {
104
+ return period === "day" ? "today" : period === "week" ? "this week" : period === "month" ? "this month" : "this run";
105
+ }
106
+ /** What to wait for when the cap is reached. */
107
+ export function windowTurnsHint(period) {
108
+ return period === "day" ? "wait for tomorrow" : period === "week" ? "wait for next week" : "wait for the new month";
109
+ }
110
+ // ------------------------------------------------------------ the window
111
+ function partsIn(timeZone, at) {
112
+ const f = new Intl.DateTimeFormat("en-US", {
113
+ timeZone,
114
+ hourCycle: "h23",
115
+ year: "numeric",
116
+ month: "numeric",
117
+ day: "numeric",
118
+ hour: "numeric",
119
+ minute: "numeric",
120
+ second: "numeric",
121
+ weekday: "short",
122
+ });
123
+ const p = {};
124
+ for (const { type, value } of f.formatToParts(at))
125
+ p[type] = value;
126
+ const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
127
+ return {
128
+ y: Number(p.year),
129
+ m: Number(p.month),
130
+ d: Number(p.day),
131
+ h: Number(p.hour),
132
+ min: Number(p.minute),
133
+ s: Number(p.second),
134
+ weekday: weekdays.indexOf(p.weekday),
135
+ };
136
+ }
137
+ /** The zone's offset from UTC at an instant, in ms (positive east). */
138
+ function offsetMs(timeZone, atMs) {
139
+ const p = partsIn(timeZone, new Date(atMs));
140
+ return Date.UTC(p.y, p.m - 1, p.d, p.h, p.min, p.s) - Math.floor(atMs / 1000) * 1000;
141
+ }
142
+ /** Midnight at the start of a local calendar date, as a UTC instant. */
143
+ function localMidnightMs(timeZone, y, m, d) {
144
+ const naive = Date.UTC(y, m - 1, d);
145
+ let t = naive - offsetMs(timeZone, naive);
146
+ // Once more from the answer: across a DST change the first guess can be
147
+ // off by the shift, and the second pass lands on the real midnight.
148
+ const again = naive - offsetMs(timeZone, t);
149
+ if (again !== t)
150
+ t = again;
151
+ return t;
152
+ }
153
+ /**
154
+ * When the current window began, as a UTC instant — the moment spend is
155
+ * summed from. Today's midnight, Monday's midnight, or the first of the
156
+ * month, all in the given calendar. A per-run budget has no window.
157
+ */
158
+ export function windowStartMs(period, timeZone, now = new Date()) {
159
+ if (period === "run")
160
+ return null;
161
+ let tz = timeZone;
162
+ try {
163
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
164
+ }
165
+ catch {
166
+ tz = "UTC";
167
+ }
168
+ const p = partsIn(tz, now);
169
+ if (period === "day")
170
+ return localMidnightMs(tz, p.y, p.m, p.d);
171
+ if (period === "month")
172
+ return localMidnightMs(tz, p.y, p.m, 1);
173
+ // Monday. Walk back on the naive calendar, then take that date's midnight.
174
+ const back = (p.weekday + 6) % 7;
175
+ const monday = new Date(Date.UTC(p.y, p.m - 1, p.d - back));
176
+ return localMidnightMs(tz, monday.getUTCFullYear(), monday.getUTCMonth() + 1, monday.getUTCDate());
177
+ }
@@ -21,6 +21,7 @@ import fs from "node:fs";
21
21
  import path from "node:path";
22
22
  import { dataRoot } from "./paths.js";
23
23
  import { resolveModel, resolveTier, MODEL_TIERS, EFFORT_LEVELS } from "./store.js";
24
+ import { trimSlashes } from "./paths.js";
24
25
  /** A day. Model lists move weekly, not hourly, and a stale price in an
25
26
  * autocomplete hint is a smaller failure than a fetch on every keystroke. */
26
27
  const TTL_MS = 24 * 60 * 60 * 1000;
@@ -83,7 +84,7 @@ export async function loadCatalog(baseUrl) {
83
84
  if (cached && Date.now() - Date.parse(cached.fetchedAt) < TTL_MS)
84
85
  return cached;
85
86
  try {
86
- const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/v1/models`, {
87
+ const res = await fetch(`${trimSlashes(baseUrl)}/v1/models`, {
87
88
  signal: AbortSignal.timeout(10_000),
88
89
  });
89
90
  if (!res.ok)
@@ -123,7 +123,12 @@ export const isFilesystemTool = (toolName) => FS_TOOLS.has(toolName);
123
123
  */
124
124
  export function suggestPath(raw) {
125
125
  const posix = raw.split(path.sep).join("/");
126
- const m = posix.match(/(?:^|\/)(outputs|storage|state|memory|knowledge)\/(.+)$/);
126
+ // By segments, not by regex: an unanchored `(.+)$` re-scans from every
127
+ // slash, and this runs on a path an agent chose.
128
+ const KNOWN = ["outputs", "storage", "state", "memory", "knowledge"];
129
+ const segs = posix.split("/");
130
+ const at = segs.findIndex((seg, i) => KNOWN.includes(seg) && segs.slice(i + 1).join("/") !== "");
131
+ const m = at === -1 ? null : [null, segs[at], segs.slice(at + 1).join("/")];
127
132
  if (m) {
128
133
  const [, dir, rest] = m;
129
134
  return dir === "outputs"