@foldrun/core 9.0.0 → 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.
@@ -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
- * Release a run parked on `wait: event` with what arrived. The payload is
131
- * stored on the step — not as an approval note, which is a person's
132
- * guidance and capped as such — and the release itself is an approval
133
- * "by an external event", so the record reads the same way whichever door
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
- * Only steps waiting on an event are released: a run that is also asking a
137
- * person a question keeps asking.
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
- * `by` names who delivered it when that is known — a signed-in person or a
140
- * key through the dashboard's approve route — and `step` releases one
141
- * waiting step rather than all of them.
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;
@@ -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)
@@ -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;
@@ -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/");
@@ -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);
@@ -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
- export declare function accountMail(tenant: string): {
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;
@@ -33,7 +33,7 @@
33
33
  import crypto from "node:crypto";
34
34
  import { readAgentsMd } from "./runner.js";
35
35
  import { accountDir, workspaceDir, runCost, readFlow, runVerdict } from "./store.js";
36
- import { getSecret, secretSites } from "./secrets.js";
36
+ import { getSecret, secretSites, signingSecretShown, accountSecretUsableIn } from "./secrets.js";
37
37
  import { siteCovers } from "./network.js";
38
38
  import { publicUrl } from "./webhook.js";
39
39
  import { approveLinkPath, approveLinkTtlMs } from "./approvals.js";
@@ -62,12 +62,16 @@ export function platformMail(tenant) {
62
62
  return { key, from: platform.setting("FOLDRUN_EMAIL_FROM") || "foldrun <hello@foldrun.io>" };
63
63
  return own;
64
64
  }
65
- /** The account's own Resend key and sender, or null when it has none. */
66
- export function accountMail(tenant) {
67
- const own = getSecret(tenant, "RESEND_API_KEY");
65
+ /** The account's own Resend key and sender, or null when it has none.
66
+ * With a workspace — mail a workspace's run causes — an account key or
67
+ * sender limited to other workspaces is not used (30 Oct 2026); the
68
+ * account's own mail (resets, invites) names none and is not limited. */
69
+ export function accountMail(tenant, workspace) {
70
+ const usable = (name) => !workspace || accountSecretUsableIn(tenant, name, workspace);
71
+ const own = usable("RESEND_API_KEY") ? getSecret(tenant, "RESEND_API_KEY") : null;
68
72
  if (!own)
69
73
  return null;
70
- const from = getSecret(tenant, "EMAIL_FROM")?.value?.trim() || "foldrun <onboarding@resend.dev>";
74
+ const from = (usable("EMAIL_FROM") ? getSecret(tenant, "EMAIL_FROM")?.value?.trim() : "") || "foldrun <onboarding@resend.dev>";
71
75
  return { key: own.value, from };
72
76
  }
73
77
  /**
@@ -80,8 +84,17 @@ export function accountMail(tenant) {
80
84
  * owner's bin. The platform's key is the fallback for an account that set
81
85
  * none, so `notify:` works before anyone has configured mail at all.
82
86
  */
83
- export function notifyMail(tenant) {
84
- return accountMail(tenant) ?? platformMail(tenant);
87
+ export function notifyMail(tenant, workspace) {
88
+ if (!workspace)
89
+ return accountMail(tenant) ?? platformMail(tenant);
90
+ // A workspace's notification: the account's sender only where it may be
91
+ // used, else the platform's — never platformMail's fall back to the
92
+ // account's own key, which would walk around the limit.
93
+ const own = accountMail(tenant, workspace);
94
+ if (own)
95
+ return own;
96
+ const key = process.env.FOLDRUN_RESEND_API_KEY;
97
+ return key ? { key, from: platform.setting("FOLDRUN_EMAIL_FROM") || "foldrun <hello@foldrun.io>" } : null;
85
98
  }
86
99
  /** Was the mail sent on the account's own key? Only then is a refusal from
87
100
  * Resend a fact about a secret the account holds. Recording a platform-key
@@ -146,7 +159,7 @@ export function signatureHeaders(tenant, workspace, config, body) {
146
159
  return {};
147
160
  const secret = getSecret(tenant, config.signingSecret, workspace);
148
161
  if (!secret) {
149
- console.error(`[foldrun] notify: signing_secret names ${config.signingSecret}, which is not in the vault for ${tenant}/${workspace} — sending unsigned`);
162
+ console.error(`[foldrun] notify: signing_secret names ${signingSecretShown(config.signingSecret)}, which is not in the vault for ${tenant}/${workspace} — sending unsigned`);
150
163
  return {};
151
164
  }
152
165
  const timestamp = Math.floor(Date.now() / 1000).toString();
@@ -404,7 +417,7 @@ export async function sendPlainNotificationOutcome(tenant, workspace, msg) {
404
417
  };
405
418
  try {
406
419
  if (config.email) {
407
- const mail = notifyMail(tenant);
420
+ const mail = notifyMail(tenant, workspace);
408
421
  if (!mail)
409
422
  return no;
410
423
  const r = await sendMail({
@@ -455,7 +468,7 @@ export async function sendTestNotification(tenant, workspace) {
455
468
  const headline = "\u2713 test notification";
456
469
  const detail = `Sent from ${workspace} at ${sent}. If you are reading this, failures and approval gates will reach you here too.`;
457
470
  if (config.email) {
458
- const mail = notifyMail(tenant);
471
+ const mail = notifyMail(tenant, workspace);
459
472
  if (!mail) {
460
473
  return {
461
474
  ok: false,
@@ -493,7 +506,7 @@ export async function sendTestNotification(tenant, workspace) {
493
506
  ? {
494
507
  ok: true,
495
508
  destination: shown,
496
- detail: `${config.signingSecret ? "accepted, signed with " + config.signingSecret : "accepted"} (delivery ${r.deliveryId})`,
509
+ detail: `${config.signingSecret ? "accepted, signed with " + signingSecretShown(config.signingSecret) : "accepted"} (delivery ${r.deliveryId})`,
497
510
  }
498
511
  : { ok: false, destination: shown, detail: `${r.statusCode ? `HTTP ${r.statusCode} — ` : ""}${r.detail.replace(/\s+/g, " ").slice(0, 400)}` };
499
512
  }
@@ -579,7 +592,7 @@ export async function sendRunNotification(tenant, workspace, run) {
579
592
  };
580
593
  try {
581
594
  if (config.email) {
582
- const mail = notifyMail(tenant);
595
+ const mail = notifyMail(tenant, workspace);
583
596
  if (!mail) {
584
597
  console.error(`[foldrun] notify: email configured but no mail credential — set RESEND_API_KEY (and EMAIL_FROM) on the account ${tenant}, or FOLDRUN_RESEND_API_KEY on the platform`);
585
598
  return false;
@@ -20,7 +20,11 @@ export interface Package {
20
20
  /** An error the API layer turns into a status rather than a 500. */
21
21
  export declare class PackageError extends Error {
22
22
  readonly status: number;
23
- constructor(message: string, status: number);
23
+ /** `cause`: the error underneath a 500, so an HTTP caller can log it and
24
+ * answer without it — a Node error names the server's paths (30 Oct 2026). */
25
+ constructor(message: string, status: number, opts?: {
26
+ cause?: unknown;
27
+ });
24
28
  }
25
29
  export declare const MANIFEST_FILE = "foldrun-package.json";
26
30
  export declare function isPackagedPath(rel: string): boolean;
@@ -37,8 +37,10 @@ import { zip, unzip, ZipError } from "./zip.js";
37
37
  /** An error the API layer turns into a status rather than a 500. */
38
38
  export class PackageError extends Error {
39
39
  status;
40
- constructor(message, status) {
41
- super(message);
40
+ /** `cause`: the error underneath a 500, so an HTTP caller can log it and
41
+ * answer without it — a Node error names the server's paths (30 Oct 2026). */
42
+ constructor(message, status, opts) {
43
+ super(message, opts);
42
44
  this.status = status;
43
45
  }
44
46
  }
@@ -371,7 +373,7 @@ export function applyImport(tenant, workspace, pkg, opts = {}) {
371
373
  // best effort — the error below is the one to report
372
374
  }
373
375
  }
374
- throw new PackageError(`the import could not be written, and nothing was kept: ${err instanceof Error ? err.message : String(err)}`, 500);
376
+ throw new PackageError(`the import could not be written, and nothing was kept: ${err instanceof Error ? err.message : String(err)}`, 500, { cause: err });
375
377
  }
376
378
  for (const c of changes) {
377
379
  const abs = path.join(dir, c.path);
@@ -4804,15 +4804,18 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4804
4804
  if (needsApproval.length) {
4805
4805
  for (const step of needsApproval) {
4806
4806
  step.status = "awaiting-approval";
4807
+ const t = new Date().toISOString();
4807
4808
  step.events.push({
4808
- t: new Date().toISOString(),
4809
+ t,
4809
4810
  type: "info",
4810
4811
  // ask: is the same gate carrying a question — the answer
4811
4812
  // arrives as the approval note and rides into the prompt.
4813
+ // An event URL is this step's and this park's alone (30 Oct
4814
+ // 2026): the index and this line's `t` are in its token.
4812
4815
  text: step.ask
4813
4816
  ? `waiting for an answer: ${step.ask}`
4814
4817
  : step.waitFor === "event"
4815
- ? `waiting for an external event — release it with POST ${eventUrl(tenant, workspace, run.id)}`
4818
+ ? `waiting for an external event — release it with POST ${eventUrl(tenant, workspace, run.id, { step: run.steps.indexOf(step), at: t })}`
4816
4819
  : "waiting for approval before running",
4817
4820
  });
4818
4821
  }
package/dist/src/scan.js CHANGED
@@ -202,6 +202,27 @@ export const SCAN_RULES = [
202
202
  title: "A required frontmatter field is missing",
203
203
  help: "A skill with no description is never loaded: the model chooses skills by their descriptions.",
204
204
  },
205
+ {
206
+ // 30 Oct 2026: check and the scheduler read a schedule with the same
207
+ // cronCanFire (core cron.ts), so check says what the scheduler does.
208
+ id: "config/cron-never-fires",
209
+ category: "config",
210
+ level: "warn",
211
+ title: "A schedule that names a day no month has",
212
+ help: "`0 0 30 2 *` is a valid cron — every field is in range — but no February has a 30th, so the flow never runs and the " +
213
+ "scheduler never lists a next run for it. Pick a day every named month has, or add a weekday (either then matches).",
214
+ },
215
+ {
216
+ // 30 Oct 2026: a signing secret is looked up in the vault by name.
217
+ id: "secret/signing-secret-value",
218
+ category: "secret",
219
+ level: "warn",
220
+ title: "A signing_secret: that holds a value, not a secret's name",
221
+ help: "A flow's `signing_secret:` and `notify: signing_secret:` name a vault entry (`WEBHOOK_SIGNING_KEY`, or `${WEBHOOK_SIGNING_KEY}`). " +
222
+ "Anything else is looked up as a name and never found — every signed delivery is refused, every notification goes unsigned — " +
223
+ "and the value itself is deployed and versioned in plain text. Put it in the vault (`foldrun secrets set NAME`), write the name, and rotate it.",
224
+ owasp: ["ASI03"],
225
+ },
205
226
  {
206
227
  id: "check/ignore-needs-reason",
207
228
  category: "config",