@foldrun/core 8.0.1 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/approvals.d.ts +18 -10
- package/dist/src/approvals.js +51 -1
- package/dist/src/catalog.d.ts +14 -0
- package/dist/src/catalog.js +28 -0
- package/dist/src/cron.d.ts +9 -0
- package/dist/src/cron.js +20 -0
- package/dist/src/frontmatter-fields.js +1 -1
- package/dist/src/frontmatter-schema.d.ts +5 -1
- package/dist/src/frontmatter-schema.js +40 -1
- package/dist/src/history.d.ts +5 -0
- package/dist/src/history.js +31 -4
- package/dist/src/hooks.d.ts +2 -0
- package/dist/src/hooks.js +13 -0
- package/dist/src/library.js +4 -0
- package/dist/src/notify.d.ts +21 -4
- package/dist/src/notify.js +105 -22
- package/dist/src/package.d.ts +5 -1
- package/dist/src/package.js +5 -3
- package/dist/src/runner.js +17 -9
- package/dist/src/scan.js +21 -0
- package/dist/src/secrets.d.ts +92 -10
- package/dist/src/secrets.js +258 -28
- package/dist/src/storage.d.ts +76 -4
- package/dist/src/storage.js +219 -22
- package/dist/src/store.d.ts +20 -2
- package/dist/src/store.js +50 -15
- package/dist/src/triggers.js +2 -2
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +19 -6
- package/package.json +1 -1
- package/src/approvals.ts +55 -1
- package/src/catalog.ts +27 -0
- package/src/cron.ts +19 -0
- package/src/frontmatter-fields.ts +1 -1
- package/src/frontmatter-schema.ts +38 -2
- package/src/history.ts +31 -4
- package/src/hooks.ts +14 -0
- package/src/library.ts +3 -0
- package/src/notify.ts +104 -23
- package/src/package.ts +5 -3
- package/src/runner.ts +17 -8
- package/src/scan.ts +23 -0
- package/src/secrets.ts +283 -34
- package/src/storage.ts +223 -19
- package/src/store.ts +49 -14
- package/src/triggers.ts +2 -2
- package/src/webhook.ts +26 -6
package/dist/src/approvals.d.ts
CHANGED
|
@@ -127,19 +127,27 @@ export declare function decideApproval(tenant: string, workspace: string, runId:
|
|
|
127
127
|
*/
|
|
128
128
|
export declare function expireStaleGate(tenant: string, workspace: string, run: RunRecord, now?: number): Promise<boolean>;
|
|
129
129
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* a park was opened through.
|
|
130
|
+
* What a presented event token opens on a run (30 Oct 2026): `known` when
|
|
131
|
+
* it is a real token of this run — one of its parks, or the old per-run
|
|
132
|
+
* form — and `steps`, the waiting steps it releases now (none: a 409, the
|
|
133
|
+
* gate it was for is no longer waiting).
|
|
135
134
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
135
|
+
* With `step` (the `&step=` every URL printed since carries), the token is
|
|
136
|
+
* that step's, bound to the moment it parked: the `t` of its "waiting for an
|
|
137
|
+
* external event" line. It releases that step only while that park is the
|
|
138
|
+
* step's current one — a released step is no longer waiting, and a step
|
|
139
|
+
* parked again (a loop) has a new `t` — so a URL handed out for one gate
|
|
140
|
+
* opens neither a later gate nor a later park.
|
|
138
141
|
*
|
|
139
|
-
* `
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
+
* Without `step`, the per-run token URLs carried before then. It releases
|
|
143
|
+
* only a step still waiting under such a URL — its park line has no
|
|
144
|
+
* `step=` — so a run parked when this shipped can still be released, and
|
|
145
|
+
* nothing parked since can be released by the old form.
|
|
142
146
|
*/
|
|
147
|
+
export declare function eventGatesFor(tenant: string, workspace: string, run: RunRecord, presented: string, step: number | null): {
|
|
148
|
+
known: boolean;
|
|
149
|
+
steps: number[];
|
|
150
|
+
};
|
|
143
151
|
export declare function deliverEvent(tenant: string, workspace: string, runId: string, payload: string, opts?: {
|
|
144
152
|
by?: string;
|
|
145
153
|
step?: number;
|
package/dist/src/approvals.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import crypto from "node:crypto";
|
|
13
13
|
import { readRun, writeRun, readFlow, mayApprove } from "./store.js";
|
|
14
14
|
import { platform } from "./platform.js";
|
|
15
|
-
import { approveToken, installKey } from "./webhook.js";
|
|
15
|
+
import { approveToken, eventToken, installKey } from "./webhook.js";
|
|
16
16
|
// ----------------------------------------------------------------- links
|
|
17
17
|
/** How long an emailed link decides for when the flow does not say. */
|
|
18
18
|
export const APPROVE_LINK_DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -276,6 +276,56 @@ export async function expireStaleGate(tenant, workspace, run, now = Date.now())
|
|
|
276
276
|
* key through the dashboard's approve route — and `step` releases one
|
|
277
277
|
* waiting step rather than all of them.
|
|
278
278
|
*/
|
|
279
|
+
const EVENT_WAIT_LINE = "waiting for an external event — release it with POST ";
|
|
280
|
+
/**
|
|
281
|
+
* What a presented event token opens on a run (30 Oct 2026): `known` when
|
|
282
|
+
* it is a real token of this run — one of its parks, or the old per-run
|
|
283
|
+
* form — and `steps`, the waiting steps it releases now (none: a 409, the
|
|
284
|
+
* gate it was for is no longer waiting).
|
|
285
|
+
*
|
|
286
|
+
* With `step` (the `&step=` every URL printed since carries), the token is
|
|
287
|
+
* that step's, bound to the moment it parked: the `t` of its "waiting for an
|
|
288
|
+
* external event" line. It releases that step only while that park is the
|
|
289
|
+
* step's current one — a released step is no longer waiting, and a step
|
|
290
|
+
* parked again (a loop) has a new `t` — so a URL handed out for one gate
|
|
291
|
+
* opens neither a later gate nor a later park.
|
|
292
|
+
*
|
|
293
|
+
* Without `step`, the per-run token URLs carried before then. It releases
|
|
294
|
+
* only a step still waiting under such a URL — its park line has no
|
|
295
|
+
* `step=` — so a run parked when this shipped can still be released, and
|
|
296
|
+
* nothing parked since can be released by the old form.
|
|
297
|
+
*/
|
|
298
|
+
export function eventGatesFor(tenant, workspace, run, presented, step) {
|
|
299
|
+
const none = { known: false, steps: [] };
|
|
300
|
+
if (!presented)
|
|
301
|
+
return none;
|
|
302
|
+
const same = (want) => {
|
|
303
|
+
const a = Buffer.from(presented), b = Buffer.from(want);
|
|
304
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
305
|
+
};
|
|
306
|
+
const parks = (i) => (run.steps[i]?.events ?? []).filter((e) => e?.text?.startsWith(EVENT_WAIT_LINE));
|
|
307
|
+
const waiting = (i) => {
|
|
308
|
+
const s = run.steps[i];
|
|
309
|
+
return !!s && s.status === "awaiting-approval" && s.waitFor === "event";
|
|
310
|
+
};
|
|
311
|
+
if (step !== null) {
|
|
312
|
+
const lines = parks(step);
|
|
313
|
+
const hit = lines.findIndex((e) => same(eventToken(tenant, workspace, run.id, { step, at: e.t })));
|
|
314
|
+
if (hit < 0)
|
|
315
|
+
return none;
|
|
316
|
+
// Only the step's latest park is open, and only while it waits.
|
|
317
|
+
return { known: true, steps: hit === lines.length - 1 && waiting(step) ? [step] : [] };
|
|
318
|
+
}
|
|
319
|
+
if (!same(eventToken(tenant, workspace, run.id)))
|
|
320
|
+
return none;
|
|
321
|
+
const legacy = [];
|
|
322
|
+
for (let i = 0; i < run.steps.length; i++) {
|
|
323
|
+
const last = parks(i).at(-1);
|
|
324
|
+
if (last && waiting(i) && !/[?&]step=\d/.test(last.text))
|
|
325
|
+
legacy.push(i);
|
|
326
|
+
}
|
|
327
|
+
return { known: true, steps: legacy };
|
|
328
|
+
}
|
|
279
329
|
export async function deliverEvent(tenant, workspace, runId, payload, opts = {}) {
|
|
280
330
|
const run = readRun(tenant, workspace, runId);
|
|
281
331
|
if (!run)
|
package/dist/src/catalog.d.ts
CHANGED
|
@@ -45,6 +45,20 @@ export declare const CATALOG_MAX_BYTES: number;
|
|
|
45
45
|
* there is nothing at all to fall back on.
|
|
46
46
|
*/
|
|
47
47
|
export declare function loadCatalog(baseUrl: string): Promise<Catalog | null>;
|
|
48
|
+
/** How long after an attempt a background refresh of the same gateway is
|
|
49
|
+
* not tried again, whether it worked or not. */
|
|
50
|
+
export declare const CATALOG_RETRY_MS: number;
|
|
51
|
+
/**
|
|
52
|
+
* Refresh a gateway's catalogue behind the caller, at most once per
|
|
53
|
+
* CATALOG_RETRY_MS per gateway (30 Oct 2026). For the editor's completions and
|
|
54
|
+
* the flows page, which ask on every request: a stale or missing catalogue
|
|
55
|
+
* used to start a fresh outbound fetch each time — a 10 s one per keystroke
|
|
56
|
+
* against an unreachable gateway, nothing remembering that it had just
|
|
57
|
+
* failed. The attempt is noted before it starts, so callers arriving while
|
|
58
|
+
* one is in flight do not start another. True when one was started. A run's
|
|
59
|
+
* own read (loadCatalog) is untouched.
|
|
60
|
+
*/
|
|
61
|
+
export declare function refreshCatalogSoon(baseUrl: string, load?: (baseUrl: string) => Promise<unknown>, now?: number): boolean;
|
|
48
62
|
/** A preset reference — settings live in the gateway's dashboard, opaque to
|
|
49
63
|
* us on purpose. The catalogue offers no opinion on one. */
|
|
50
64
|
export declare const isPresetRef: (model: string) => boolean;
|
package/dist/src/catalog.js
CHANGED
|
@@ -118,6 +118,34 @@ export async function loadCatalog(baseUrl) {
|
|
|
118
118
|
return cached; // stale beats nothing; null when there is no nothing
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
|
+
/** How long after an attempt a background refresh of the same gateway is
|
|
122
|
+
* not tried again, whether it worked or not. */
|
|
123
|
+
export const CATALOG_RETRY_MS = 5 * 60_000;
|
|
124
|
+
const lastTried = new Map();
|
|
125
|
+
/**
|
|
126
|
+
* Refresh a gateway's catalogue behind the caller, at most once per
|
|
127
|
+
* CATALOG_RETRY_MS per gateway (30 Oct 2026). For the editor's completions and
|
|
128
|
+
* the flows page, which ask on every request: a stale or missing catalogue
|
|
129
|
+
* used to start a fresh outbound fetch each time — a 10 s one per keystroke
|
|
130
|
+
* against an unreachable gateway, nothing remembering that it had just
|
|
131
|
+
* failed. The attempt is noted before it starts, so callers arriving while
|
|
132
|
+
* one is in flight do not start another. True when one was started. A run's
|
|
133
|
+
* own read (loadCatalog) is untouched.
|
|
134
|
+
*/
|
|
135
|
+
export function refreshCatalogSoon(baseUrl, load = loadCatalog, now = Date.now()) {
|
|
136
|
+
const cached = readCatalog(baseUrl);
|
|
137
|
+
if (cached && now - Date.parse(cached.fetchedAt) < TTL_MS)
|
|
138
|
+
return false; // fresh: nothing to fetch
|
|
139
|
+
const last = lastTried.get(baseUrl);
|
|
140
|
+
if (last !== undefined && now - last < CATALOG_RETRY_MS)
|
|
141
|
+
return false;
|
|
142
|
+
lastTried.delete(baseUrl);
|
|
143
|
+
lastTried.set(baseUrl, now);
|
|
144
|
+
if (lastTried.size > 1000)
|
|
145
|
+
lastTried.delete(lastTried.keys().next().value);
|
|
146
|
+
void load(baseUrl).catch(() => null);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
121
149
|
/** A preset reference — settings live in the gateway's dashboard, opaque to
|
|
122
150
|
* us on purpose. The catalogue offers no opinion on one. */
|
|
123
151
|
export const isPresetRef = (model) => model.startsWith("@preset/");
|
package/dist/src/cron.d.ts
CHANGED
|
@@ -15,3 +15,12 @@ export interface Cron {
|
|
|
15
15
|
export declare function parseCron(expr: string): Cron | null;
|
|
16
16
|
/** Why `schedule:` is not a cron the scheduler can run, or null when it is. */
|
|
17
17
|
export declare function cronProblem(expr: unknown): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Whether the calendar has any day this cron names (30 Oct 2026). `0 0 30 2 *`
|
|
20
|
+
* parses — every field is in range — yet no February has a 30th, so a search
|
|
21
|
+
* for its next fire walked the whole window every time it was asked (the
|
|
22
|
+
* schedule page did, 57,600 minutes per flow per request). Answered from the
|
|
23
|
+
* fields alone: only a day of the month with no weekday beside it can miss,
|
|
24
|
+
* since with a weekday either may match and every month has every weekday.
|
|
25
|
+
*/
|
|
26
|
+
export declare function cronCanFire(cron: Cron): boolean;
|
package/dist/src/cron.js
CHANGED
|
@@ -105,3 +105,23 @@ export function cronProblem(expr) {
|
|
|
105
105
|
? `schedule: "${expr}" has ${fields} field${fields === 1 ? "" : "s"}; cron has five — minute hour day-of-month month day-of-week ("0 9 * * MON"), or @hourly, @daily, @weekly, @monthly. As written the flow never fires`
|
|
106
106
|
: `schedule: "${expr}" is not a cron the scheduler can read — a field is out of range or not a number (minute 0-59, hour 0-23, day 1-31, month 1-12, weekday 0-7 or sun-sat). As written the flow never fires`;
|
|
107
107
|
}
|
|
108
|
+
/** Days in each month, February at its leap-year 29: a cron naming the 29th
|
|
109
|
+
* of February does fire, every fourth year. */
|
|
110
|
+
const MONTH_DAYS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
111
|
+
/**
|
|
112
|
+
* Whether the calendar has any day this cron names (30 Oct 2026). `0 0 30 2 *`
|
|
113
|
+
* parses — every field is in range — yet no February has a 30th, so a search
|
|
114
|
+
* for its next fire walked the whole window every time it was asked (the
|
|
115
|
+
* schedule page did, 57,600 minutes per flow per request). Answered from the
|
|
116
|
+
* fields alone: only a day of the month with no weekday beside it can miss,
|
|
117
|
+
* since with a weekday either may match and every month has every weekday.
|
|
118
|
+
*/
|
|
119
|
+
export function cronCanFire(cron) {
|
|
120
|
+
if (!cron.domRestricted || cron.dowRestricted)
|
|
121
|
+
return true;
|
|
122
|
+
const firstDay = Math.min(...cron.dom);
|
|
123
|
+
for (const m of cron.month)
|
|
124
|
+
if (firstDay <= MONTH_DAYS[m - 1])
|
|
125
|
+
return true;
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
@@ -109,7 +109,7 @@ const FLOW = [
|
|
|
109
109
|
{ key: "catchup", type: "string", validated: true, help: "last | none" },
|
|
110
110
|
{ key: "on", type: "string", validated: true, help: "trigger: flow — completed | failed | blocked | any" },
|
|
111
111
|
{ key: "signature", type: "string", validated: true, help: "github | stripe | slack | hmac" },
|
|
112
|
-
{ key: "signing_secret", type: "string", help: "the secret a webhook's signature is checked with" },
|
|
112
|
+
{ key: "signing_secret", type: "string", help: "the NAME of the vault secret a webhook's signature is checked with — never the value" },
|
|
113
113
|
{ key: "after", type: ["string", "list"], help: "trigger: flow — the flow this one follows" },
|
|
114
114
|
{ key: "at", type: "string", help: "trigger: once — when" },
|
|
115
115
|
{ key: "url", type: "string", help: "trigger: watch — the page watched" },
|
|
@@ -2,7 +2,7 @@ import { type SchemaKind } from "./frontmatter-fields.ts";
|
|
|
2
2
|
export { FRONTMATTER, schemaKindOf, type FieldSpec, type FieldType, type SchemaKind } from "./frontmatter-fields.ts";
|
|
3
3
|
export interface FieldProblem {
|
|
4
4
|
key: string;
|
|
5
|
-
rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field" | "config/conflict" | "config/misplaced";
|
|
5
|
+
rule: "config/unknown-field" | "config/wrong-type" | "config/invalid-value" | "config/missing-field" | "config/conflict" | "config/misplaced" | "config/cron-never-fires" | "secret/signing-secret-value";
|
|
6
6
|
level: "error" | "warn" | "info";
|
|
7
7
|
message: string;
|
|
8
8
|
}
|
|
@@ -13,3 +13,7 @@ export interface FieldProblem {
|
|
|
13
13
|
* `near` is the typo matcher; scan.ts passes its own.
|
|
14
14
|
*/
|
|
15
15
|
export declare function frontmatterProblems(kind: SchemaKind, data: Record<string, unknown>, near: (key: string, known: readonly string[]) => string | null): FieldProblem[];
|
|
16
|
+
/** Why a `signing_secret:` value is not a vault secret's name, or null. The
|
|
17
|
+
* runtime reads `NAME` and `${NAME}` alike (notify.ts, store.ts). Never
|
|
18
|
+
* says what the value is: it may be the secret itself. */
|
|
19
|
+
export declare function signingSecretProblem(v: unknown): string | null;
|
|
@@ -5,7 +5,8 @@ import { FRONTMATTER, JOBS } from "./frontmatter-fields.js";
|
|
|
5
5
|
import { jobFold } from "./matter.js";
|
|
6
6
|
import { resolveEffort, parseDuration, parseInstant, parseRate, STEP_OPTION_KEYS } from "./store.js";
|
|
7
7
|
import { budgetProblem } from "./budget.js";
|
|
8
|
-
import { cronProblem } from "./cron.js";
|
|
8
|
+
import { cronProblem, parseCron, cronCanFire } from "./cron.js";
|
|
9
|
+
import { SECRET_NAME } from "./secrets.js";
|
|
9
10
|
import { modelSecretCollision } from "./model-credential.js";
|
|
10
11
|
import { resourcesProblem, computeProblem } from "./resources.js";
|
|
11
12
|
export { FRONTMATTER, schemaKindOf } from "./frontmatter-fields.js";
|
|
@@ -163,11 +164,49 @@ export function frontmatterProblems(kind, data, near) {
|
|
|
163
164
|
if (why)
|
|
164
165
|
out.push({ key, rule: "config/invalid-value", level: "warn", message: `\`${key}:\` ${why.replace(/^\w+: /, "")}` });
|
|
165
166
|
}
|
|
167
|
+
// A cron every field of which is in range, on a day no month has —
|
|
168
|
+
// `0 0 30 2 *`. The scheduler's own cronCanFire decides (30 Oct 2026), so
|
|
169
|
+
// check and the scheduler say the same about it: it never fires.
|
|
170
|
+
if (kind === "flow" && typeof data.schedule === "string") {
|
|
171
|
+
const cron = parseCron(data.schedule);
|
|
172
|
+
if (cron && !cronCanFire(cron)) {
|
|
173
|
+
out.push({ key: "schedule", rule: "config/cron-never-fires", level: "warn", message: `\`schedule: "${data.schedule}"\` names a day no month it allows has — the flow never fires. Pick a day every named month has, or add a weekday` });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// A signing secret is the NAME of a vault entry (30 Oct 2026): a flow's
|
|
177
|
+
// `signing_secret:` and notify's `signing_secret:` are looked up with
|
|
178
|
+
// getSecret, so a literal value there is a secret written into a
|
|
179
|
+
// versioned, deployed file — and never matches anything in the vault.
|
|
180
|
+
// The message never repeats the value.
|
|
181
|
+
if (kind === "flow" && data.signing_secret !== undefined) {
|
|
182
|
+
const why = signingSecretProblem(data.signing_secret);
|
|
183
|
+
if (why)
|
|
184
|
+
out.push({ key: "signing_secret", rule: "secret/signing-secret-value", level: "warn", message: `\`signing_secret:\` ${why}` });
|
|
185
|
+
}
|
|
186
|
+
if (kind === "agents-md" && data.notify && typeof data.notify === "object" && !Array.isArray(data.notify)) {
|
|
187
|
+
const raw = data.notify.signing_secret;
|
|
188
|
+
const why = raw === undefined ? null : signingSecretProblem(raw);
|
|
189
|
+
if (why)
|
|
190
|
+
out.push({ key: "notify", rule: "secret/signing-secret-value", level: "warn", message: `\`notify: signing_secret:\` ${why}` });
|
|
191
|
+
}
|
|
166
192
|
if (kind === "skill" && !(typeof data.description === "string" && data.description.trim())) {
|
|
167
193
|
out.push({ key: "description", rule: "config/missing-field", level: "warn", message: "no `description:` — a skill without one is never loaded; the model chooses a skill by it" });
|
|
168
194
|
}
|
|
169
195
|
return out;
|
|
170
196
|
}
|
|
197
|
+
/** Why a `signing_secret:` value is not a vault secret's name, or null. The
|
|
198
|
+
* runtime reads `NAME` and `${NAME}` alike (notify.ts, store.ts). Never
|
|
199
|
+
* says what the value is: it may be the secret itself. */
|
|
200
|
+
export function signingSecretProblem(v) {
|
|
201
|
+
if (typeof v !== "string")
|
|
202
|
+
return null; // config/wrong-type says so
|
|
203
|
+
const name = v.trim().replace(/^\$\{|\}$/g, "");
|
|
204
|
+
if (!name)
|
|
205
|
+
return null;
|
|
206
|
+
return SECRET_NAME.test(name)
|
|
207
|
+
? null
|
|
208
|
+
: "holds something that is not a secret's name (UPPER_SNAKE_CASE, like WEBHOOK_SIGNING_KEY) — it is looked up in the vault by name, so a value written here is never found, and is deployed and versioned in plain text. Put the value in the vault (`foldrun secrets set NAME`), write the name here, and rotate it";
|
|
209
|
+
}
|
|
171
210
|
function askProblem(v) {
|
|
172
211
|
const t = v?.timeout;
|
|
173
212
|
return t === undefined ? null : DURATION(30, 86400, "30s to 24h")(t);
|
package/dist/src/history.d.ts
CHANGED
|
@@ -35,6 +35,11 @@ export interface RevisionSummary {
|
|
|
35
35
|
commit: string | null;
|
|
36
36
|
paths: string[];
|
|
37
37
|
}
|
|
38
|
+
/** Refuse (400) a deploy's `commit` that cannot be a revision id. A writer
|
|
39
|
+
* calls this BEFORE it writes: checked only in recordRevision, after the
|
|
40
|
+
* files were replaced, a bad commit left the new source live with no
|
|
41
|
+
* revision, no snapshot and no audit entry (30 Oct 2026). */
|
|
42
|
+
export declare function assertCommitId(commit: string): void;
|
|
38
43
|
/** A local revision id: sortable by time, unique enough, visibly not a sha. */
|
|
39
44
|
export declare function newRevisionId(now?: Date): string;
|
|
40
45
|
/**
|
package/dist/src/history.js
CHANGED
|
@@ -55,6 +55,15 @@ const historyDir = (tenant, scope) => {
|
|
|
55
55
|
assertSafeName(scope, "workspace");
|
|
56
56
|
return path.join(dataRoot(), tenant, ".history", scope);
|
|
57
57
|
};
|
|
58
|
+
/** Refuse (400) a deploy's `commit` that cannot be a revision id. A writer
|
|
59
|
+
* calls this BEFORE it writes: checked only in recordRevision, after the
|
|
60
|
+
* files were replaced, a bad commit left the new source live with no
|
|
61
|
+
* revision, no snapshot and no audit entry (30 Oct 2026). */
|
|
62
|
+
export function assertCommitId(commit) {
|
|
63
|
+
if (typeof commit !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._@-]{0,120}$/.test(commit)) {
|
|
64
|
+
throw Object.assign(new Error(`commit "${String(commit).slice(0, 130)}" is not a commit id — letters, digits and . _ @ -, at most 121 characters`), { status: 400 });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
58
67
|
/** A local revision id: sortable by time, unique enough, visibly not a sha. */
|
|
59
68
|
export function newRevisionId(now = new Date()) {
|
|
60
69
|
return `r-${now.getTime().toString(36)}-${crypto.randomBytes(3).toString("hex")}`;
|
|
@@ -72,8 +81,17 @@ export function recordRevision(tenant, scope, files, meta = {}) {
|
|
|
72
81
|
return null;
|
|
73
82
|
const dir = historyDir(tenant, scope);
|
|
74
83
|
fs.mkdirSync(dir, { recursive: true });
|
|
84
|
+
// A commit is the revision's id only the first time it is recorded. A
|
|
85
|
+
// second deploy of one commit (a CI re-run after a dashboard edit), or a
|
|
86
|
+
// `commit` that names a revision already here, overwrote that revision's
|
|
87
|
+
// record and its snapshot (30 Oct 2026) — History could no longer restore
|
|
88
|
+
// to it. It gets its own id now; `commit` still says which one it was,
|
|
89
|
+
// and a restore by commit (resolveRevision) still finds it.
|
|
90
|
+
if (meta.commit)
|
|
91
|
+
assertCommitId(meta.commit);
|
|
92
|
+
const commitId = meta.commit && !fs.existsSync(path.join(dir, `${meta.commit}.json`)) ? meta.commit : null;
|
|
75
93
|
const rev = {
|
|
76
|
-
id: meta.id ??
|
|
94
|
+
id: meta.id ?? commitId ?? newRevisionId(),
|
|
77
95
|
at: new Date().toISOString(),
|
|
78
96
|
by: meta.by ?? "system",
|
|
79
97
|
message: meta.message ?? describe(changed),
|
|
@@ -252,7 +270,11 @@ export function resolveRevision(tenant, scope, to, now = Date.now()) {
|
|
|
252
270
|
else {
|
|
253
271
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/.test(t))
|
|
254
272
|
throw Object.assign(new Error(`not a revision or a time: ${to}`), { status: 400 });
|
|
255
|
-
|
|
273
|
+
// An exact id first, then a commit: a commit deployed twice is recorded
|
|
274
|
+
// once under its own id and again under a fresh one naming it (30 Oct
|
|
275
|
+
// 2026, recordRevision), and newest-first with one predicate answered
|
|
276
|
+
// the first revision's id with the second's snapshot.
|
|
277
|
+
hit = all.find((r) => r.id === t) ?? all.find((r) => r.commit === t || (t.length >= 7 && r.commit?.startsWith(t)));
|
|
256
278
|
}
|
|
257
279
|
return hit ? { id: hit.id, at: hit.at, by: hit.by, message: hit.message } : null;
|
|
258
280
|
}
|
|
@@ -353,8 +375,13 @@ export function changedPathsBetween(tenant, scope, from, to) {
|
|
|
353
375
|
if (from === to)
|
|
354
376
|
return [];
|
|
355
377
|
const revs = listRevisions(tenant, scope, { limit: 500 }); // newest first
|
|
356
|
-
|
|
357
|
-
const
|
|
378
|
+
// An exact id before a commit, as resolveRevision.
|
|
379
|
+
const at = (x) => {
|
|
380
|
+
const byId = revs.findIndex((r) => r.id === x);
|
|
381
|
+
return byId >= 0 ? byId : revs.findIndex((r) => r.commit === x);
|
|
382
|
+
};
|
|
383
|
+
const ti = at(to);
|
|
384
|
+
const fi = at(from);
|
|
358
385
|
if (ti < 0 || fi < 0 || ti > fi)
|
|
359
386
|
return null;
|
|
360
387
|
return [...new Set(revs.slice(ti, fi).flatMap((r) => r.paths))].sort();
|
package/dist/src/hooks.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export declare const HOOK_TIMEOUT_DEFAULT: {
|
|
|
6
6
|
};
|
|
7
7
|
export declare const HOOK_TIMEOUT_MAX = 120;
|
|
8
8
|
export declare const HOOK_CODE_MAX: number;
|
|
9
|
+
/** A hook's name: its folder's name, never a path. */
|
|
10
|
+
export declare function isHookName(name: unknown): name is string;
|
|
9
11
|
export interface HookDef {
|
|
10
12
|
name: string;
|
|
11
13
|
/** The folder holding hook.md and run.mjs. */
|
package/dist/src/hooks.js
CHANGED
|
@@ -38,6 +38,10 @@ export const HOOK_TIMEOUT_DEFAULT = { gate: 30, watch: 60 };
|
|
|
38
38
|
export const HOOK_TIMEOUT_MAX = 120;
|
|
39
39
|
export const HOOK_CODE_MAX = 100 * 1024;
|
|
40
40
|
const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
41
|
+
/** A hook's name: its folder's name, never a path. */
|
|
42
|
+
export function isHookName(name) {
|
|
43
|
+
return typeof name === "string" && NAME.test(name);
|
|
44
|
+
}
|
|
41
45
|
/** Read hook.md, or say in one sentence why it is not a hook. */
|
|
42
46
|
export function readHookDef(dir, scope) {
|
|
43
47
|
const name = path.basename(dir);
|
|
@@ -117,6 +121,15 @@ export function resolveHooks(args) {
|
|
|
117
121
|
if (seen.has(name))
|
|
118
122
|
continue;
|
|
119
123
|
seen.add(name);
|
|
124
|
+
// Checked before the name is joined to anything (30 Oct 2026): readHookDef
|
|
125
|
+
// tested only basename(dir), so `../../<tenant>/workspaces/<ws>/hooks/<h>`
|
|
126
|
+
// passed, and the hook test route answered which folders exist anywhere
|
|
127
|
+
// on the server — on a laptop it would have run that run.mjs. An error,
|
|
128
|
+
// not missing: a step refuses either way, and the name is the problem.
|
|
129
|
+
if (!isHookName(name)) {
|
|
130
|
+
errors.push(`"${name}" is not a hook name — lowercase letters, digits and dashes (the hook's folder name)`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
120
133
|
const own = path.join(args.workspaceRoot, "hooks", name);
|
|
121
134
|
const shared = path.join(args.libraryRoot, "hooks", name);
|
|
122
135
|
const dir = fs.existsSync(own) ? own : fs.existsSync(shared) ? shared : null;
|
package/dist/src/library.js
CHANGED
|
@@ -234,6 +234,10 @@ export function deleteLibraryPath(tenant, kind, rel, meta = {}) {
|
|
|
234
234
|
const norm = path.normalize(rel);
|
|
235
235
|
if (norm.startsWith("..") || path.isAbsolute(norm))
|
|
236
236
|
throw new Error(`illegal path: ${rel}`);
|
|
237
|
+
// "." (or "./", "a/..") is the shelf itself: one call took every shared
|
|
238
|
+
// tool or skill from every workspace (30 Oct 2026). Name what goes.
|
|
239
|
+
if (norm === "." || norm === "./")
|
|
240
|
+
throw new Error(`illegal path: ${rel} — name the file or folder to delete, not the whole ${kind} shelf`);
|
|
237
241
|
const target = path.join(libraryDir(tenant, kind), norm);
|
|
238
242
|
const gone = [];
|
|
239
243
|
if (fs.existsSync(target)) {
|
package/dist/src/notify.d.ts
CHANGED
|
@@ -15,8 +15,11 @@ export declare function platformMail(tenant: string): {
|
|
|
15
15
|
key: string;
|
|
16
16
|
from: string;
|
|
17
17
|
} | null;
|
|
18
|
-
/** The account's own Resend key and sender, or null when it has none.
|
|
19
|
-
|
|
18
|
+
/** The account's own Resend key and sender, or null when it has none.
|
|
19
|
+
* With a workspace — mail a workspace's run causes — an account key or
|
|
20
|
+
* sender limited to other workspaces is not used (30 Oct 2026); the
|
|
21
|
+
* account's own mail (resets, invites) names none and is not limited. */
|
|
22
|
+
export declare function accountMail(tenant: string, workspace?: string): {
|
|
20
23
|
key: string;
|
|
21
24
|
from: string;
|
|
22
25
|
} | null;
|
|
@@ -30,7 +33,7 @@ export declare function accountMail(tenant: string): {
|
|
|
30
33
|
* owner's bin. The platform's key is the fallback for an account that set
|
|
31
34
|
* none, so `notify:` works before anyone has configured mail at all.
|
|
32
35
|
*/
|
|
33
|
-
export declare function notifyMail(tenant: string): {
|
|
36
|
+
export declare function notifyMail(tenant: string, workspace?: string): {
|
|
34
37
|
key: string;
|
|
35
38
|
from: string;
|
|
36
39
|
} | null;
|
|
@@ -78,10 +81,24 @@ export declare function nextAttemptAt(n: number, at: number): number | null;
|
|
|
78
81
|
* delivery, redeliveries included, so a receiver dedupes on it. */
|
|
79
82
|
export declare function newDeliveryId(): string;
|
|
80
83
|
/** The URL with ${SECRET}s resolved, and how it may be shown: a Slack hook
|
|
81
|
-
* URL is itself a credential, so it is never echoed resolved.
|
|
84
|
+
* URL is itself a credential, so it is never echoed resolved.
|
|
85
|
+
*
|
|
86
|
+
* A secret bound to sites (`foldrun secrets sites`) is filled only when the
|
|
87
|
+
* resolved URL's host is one of them — the rule the egress proxy and the
|
|
88
|
+
* tool tester keep (30 Oct 2026). Before, notify.url filled any secret into
|
|
89
|
+
* any host, so an editor could write `https://their.server/?k=${STRIPE_KEY}`
|
|
90
|
+
* and press Test. The host is read AFTER filling, because the usual shape is
|
|
91
|
+
* the whole URL as one secret (`${SLACK_WEBHOOK_URL}`, bound to
|
|
92
|
+
* hooks.slack.com). An unbound secret fills as it does for an agent's
|
|
93
|
+
* `secrets:` and a tool test — workspace first, then the account — since
|
|
94
|
+
* binding is how an account says where a value may go. `refused` says why
|
|
95
|
+
* `url` is null when a binding stopped it; `filled` maps each value put
|
|
96
|
+
* into the URL to its name, so an error that echoes the URL can be scrubbed. */
|
|
82
97
|
export declare function webhookTarget(tenant: string, workspace: string, config?: NotifyConfig | null): {
|
|
83
98
|
url: string | null;
|
|
84
99
|
shown: string;
|
|
100
|
+
refused?: string;
|
|
101
|
+
filled: Record<string, string>;
|
|
85
102
|
};
|
|
86
103
|
export interface WebhookAttempt {
|
|
87
104
|
/** 1-based. */
|