@foldrun/core 0.3.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.
- package/README.md +27 -10
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/confine.js +6 -1
- package/dist/src/egress.d.ts +19 -2
- package/dist/src/egress.js +2 -1
- package/dist/src/evals.d.ts +4 -0
- package/dist/src/evals.js +20 -11
- package/dist/src/flow-lint.d.ts +4 -0
- package/dist/src/flow-lint.js +108 -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/library.js +13 -1
- package/dist/src/notify.d.ts +79 -1
- package/dist/src/notify.js +243 -9
- package/dist/src/paths.d.ts +5 -0
- package/dist/src/paths.js +32 -0
- package/dist/src/platform.d.ts +102 -2
- package/dist/src/platform.js +18 -2
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +232 -0
- package/dist/src/run-container.d.ts +57 -4
- package/dist/src/run-container.js +147 -41
- package/dist/src/runner.d.ts +39 -3
- package/dist/src/runner.js +422 -73
- package/dist/src/script-tools.d.ts +12 -0
- package/dist/src/script-tools.js +10 -2
- package/dist/src/secret-health.d.ts +59 -0
- package/dist/src/secret-health.js +155 -0
- package/dist/src/secrets.d.ts +19 -1
- package/dist/src/secrets.js +44 -12
- package/dist/src/starter.js +3 -0
- package/dist/src/step-exec.d.ts +47 -1
- package/dist/src/step-exec.js +155 -60
- package/dist/src/store.d.ts +276 -3
- package/dist/src/store.js +552 -26
- 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-test.js +48 -21
- package/dist/src/translator.js +2 -1
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +20 -0
- package/dist/src/triggers.js +91 -10
- package/dist/src/webhook.d.ts +15 -3
- package/dist/src/webhook.js +11 -11
- package/package.json +4 -4
- package/src/approvals.ts +253 -2
- package/src/budget.ts +178 -0
- package/src/catalog.ts +2 -1
- package/src/confine.ts +6 -1
- package/src/egress.ts +20 -4
- package/src/evals.ts +25 -9
- package/src/flow-lint.ts +118 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/library.ts +13 -1
- package/src/notify.ts +272 -11
- package/src/paths.ts +29 -0
- package/src/platform.ts +107 -3
- package/src/provider-health.ts +274 -0
- package/src/run-container.ts +175 -36
- package/src/runner.ts +435 -65
- package/src/script-tools.ts +18 -2
- package/src/secret-health.ts +185 -0
- package/src/secrets.ts +44 -10
- package/src/starter.ts +3 -0
- package/src/step-exec.ts +128 -13
- package/src/storage.ts +8 -2
- package/src/store.ts +746 -25
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-test.ts +49 -20
- package/src/translator.ts +2 -1
- package/src/trigger-log.ts +159 -0
- package/src/triggers.ts +92 -10
- package/src/webhook.ts +17 -11
package/README.md
CHANGED
|
@@ -188,19 +188,36 @@ a hand-edited template cannot ship an index that disagrees with its own files.
|
|
|
188
188
|
## Hosting it
|
|
189
189
|
|
|
190
190
|
This package is the framework: everything needed to read a workspace and
|
|
191
|
-
run it on the machine you are on. The
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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.
|
|
198
201
|
|
|
199
202
|
## Requirements
|
|
200
203
|
|
|
201
|
-
Node 22+. Docker or Podman if your agents run scripts.
|
|
202
|
-
|
|
203
|
-
|
|
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.
|
|
204
221
|
|
|
205
222
|
## Status
|
|
206
223
|
|
package/dist/src/approvals.d.ts
CHANGED
|
@@ -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
|
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,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
|
+
}
|
package/dist/src/catalog.js
CHANGED
|
@@ -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
|
|
87
|
+
const res = await fetch(`${trimSlashes(baseUrl)}/v1/models`, {
|
|
87
88
|
signal: AbortSignal.timeout(10_000),
|
|
88
89
|
});
|
|
89
90
|
if (!res.ok)
|
package/dist/src/confine.js
CHANGED
|
@@ -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
|
-
|
|
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"
|