@sweny-ai/core 0.1.128 → 0.1.130

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/cli/main.js CHANGED
@@ -27,6 +27,8 @@ import * as readline from "node:readline";
27
27
  import { loadDotenv, loadConfigFile, applyAgentFileConfig } from "./config-file.js";
28
28
  import { buildCredentialMap } from "./credentials.js";
29
29
  import { nonInteractiveUsage, runNew } from "./new.js";
30
+ import { buildRunRecord, createNodeTimer, historyDisabled, newRunId, recordRun } from "./run-history.js";
31
+ import { registerRunsCommand } from "./runs.js";
30
32
  import { runE2eRun, runWithWallClockBudget, DEFAULT_WORKFLOW_TIMEOUT_MS } from "./e2e.js";
31
33
  import { createVerboseToolObserver } from "./verbose-observer.js";
32
34
  import { createRunLogger, renderReceiptLine, summarizeRun, writeStepSummary, WORKFLOW_RUN_DESCRIPTION, WORKFLOW_RUN_OPTIONS, } from "./run-output.js";
@@ -884,7 +886,31 @@ export async function workflowRunAction(file, options) {
884
886
  if (wfCloudHandle?.dashboardUrl && !isJson) {
885
887
  console.log(c.subtle(` cloud: ${wfCloudHandle.dashboardUrl}`));
886
888
  }
887
- const observer = composeObservers(wfProgressObserver, options.verbose ? createVerboseToolObserver() : undefined, options.stream ? createStreamObserver() : undefined, createCloudStreamObserver(config, wfCloudHandle));
889
+ // Run history (#388): metadata-only record under .sweny/runs/, written once at run end.
890
+ const nodeTimer = createNodeTimer();
891
+ const runId = newRunId(runStart);
892
+ let historyRecorded = false;
893
+ const recordHistory = (results, trace, crashed) => {
894
+ if (historyRecorded || historyDisabled(options.history, fileConfig["history"]))
895
+ return;
896
+ historyRecorded = true;
897
+ try {
898
+ recordRun(buildRunRecord({
899
+ runId,
900
+ workflow,
901
+ startedAtMs: runStart,
902
+ durationMs: Date.now() - runStart,
903
+ results,
904
+ trace,
905
+ nodeDurations: nodeTimer.durations,
906
+ crashed,
907
+ }));
908
+ }
909
+ catch {
910
+ // history must never fail a run
911
+ }
912
+ };
913
+ const observer = composeObservers(wfProgressObserver, nodeTimer.observer, options.verbose ? createVerboseToolObserver() : undefined, options.stream ? createStreamObserver() : undefined, createCloudStreamObserver(config, wfCloudHandle));
888
914
  // #325: this is the primary run path (GitHub Action / CLI / MCP all land
889
915
  // here), and previously never passed signal/timeoutMs/max_steps to
890
916
  // execute() at all: a wedged node (hung tool call, runaway model turn)
@@ -912,6 +938,7 @@ export async function workflowRunAction(file, options) {
912
938
  }), wfTimeoutMs, `Workflow ${workflow.name}`);
913
939
  const wfDurationMs = Date.now() - runStart;
914
940
  const wfHasFailed = [...results.values()].some((r) => r.status === "failed");
941
+ recordHistory(results, trace, false);
915
942
  // Close the cloud lifecycle session BEFORE the JSON early-exit so
916
943
  // every workflow run reports a terminal status, regardless of how
917
944
  // the CLI returns (json, mermaid, or normal stdout).
@@ -957,6 +984,7 @@ export async function workflowRunAction(file, options) {
957
984
  const crashMsg = err instanceof Error ? err.message : String(err);
958
985
  console.error(chalk.red(`\n Error: ${crashMsg}\n`));
959
986
  runLogger.flush();
987
+ recordHistory(nodeTimer.lastResults, undefined, true);
960
988
  console.error(` ${renderReceiptLine(summarizeRun(new Map(), Date.now() - runStart, true), isTTY)}\n`);
961
989
  // Finalize the cloud run as failed (covers thrown errors, incl.
962
990
  // RouteEvaluationError). Without this a crashed workflow run stays
@@ -1032,6 +1060,8 @@ const workflowRunCmd = workflowCmd
1032
1060
  for (const [flags, description] of WORKFLOW_RUN_OPTIONS) {
1033
1061
  workflowRunCmd.option(flags, description);
1034
1062
  }
1063
+ workflowRunCmd.option("--no-history", "Do not record this run in .sweny/runs/ (or set `history: off` in .sweny.yml)");
1064
+ registerRunsCommand(program);
1035
1065
  workflowCmd
1036
1066
  .command("diagram <file>")
1037
1067
  .description("Render a workflow as a Mermaid diagram (raw .mmd by default; .md output auto-fences)")
package/dist/cli/new.d.ts CHANGED
@@ -120,11 +120,10 @@ export declare function writeWorkflowFile(cwd: string, id: string, yaml: string,
120
120
  exists?: boolean;
121
121
  path: string;
122
122
  };
123
- /**
124
- * Make sure `.env` is gitignored: create `.gitignore` if missing, append
125
- * `.env` if absent. Returns what happened.
126
- */
123
+ /** Make sure `.env` is gitignored. */
127
124
  export declare function ensureGitignoreEnv(cwd: string): "created" | "appended" | "present";
125
+ /** Make sure local run history (`.sweny/runs/`) is gitignored. */
126
+ export declare function ensureGitignoreRuns(cwd: string): "created" | "appended" | "present";
128
127
  /** Usage printed (exit 2) when `sweny new` has no terminal and no --yes. */
129
128
  export declare function nonInteractiveUsage(): string;
130
129
  /**
package/dist/cli/new.js CHANGED
@@ -443,22 +443,30 @@ export function writeWorkflowFile(cwd, id, yaml, options) {
443
443
  return { written: true, path: target };
444
444
  }
445
445
  /**
446
- * Make sure `.env` is gitignored: create `.gitignore` if missing, append
447
- * `.env` if absent. Returns what happened.
446
+ * Make sure `line` is in `.gitignore`: create the file if missing, append the
447
+ * line if absent. Returns what happened.
448
448
  */
449
- export function ensureGitignoreEnv(cwd) {
449
+ function ensureGitignoreLine(cwd, line) {
450
450
  const gitignorePath = path.join(cwd, ".gitignore");
451
451
  if (!fs.existsSync(gitignorePath)) {
452
- fs.writeFileSync(gitignorePath, ".env\n", "utf-8");
452
+ fs.writeFileSync(gitignorePath, `${line}\n`, "utf-8");
453
453
  return "created";
454
454
  }
455
455
  const content = fs.readFileSync(gitignorePath, "utf-8");
456
- if (content.split(/\r?\n/).some((line) => line.trim() === ".env"))
456
+ if (content.split(/\r?\n/).some((l) => l.trim() === line))
457
457
  return "present";
458
458
  const sep = content.length === 0 || content.endsWith("\n") ? "" : "\n";
459
- fs.appendFileSync(gitignorePath, `${sep}.env\n`, "utf-8");
459
+ fs.appendFileSync(gitignorePath, `${sep}${line}\n`, "utf-8");
460
460
  return "appended";
461
461
  }
462
+ /** Make sure `.env` is gitignored. */
463
+ export function ensureGitignoreEnv(cwd) {
464
+ return ensureGitignoreLine(cwd, ".env");
465
+ }
466
+ /** Make sure local run history (`.sweny/runs/`) is gitignored. */
467
+ export function ensureGitignoreRuns(cwd) {
468
+ return ensureGitignoreLine(cwd, ".sweny/runs/");
469
+ }
462
470
  /** The command to show in next steps: `npx` users have no `sweny` on PATH. */
463
471
  function cliCommand() {
464
472
  return process.env.npm_command === "exec" ? "npx @sweny-ai/core" : "sweny";
@@ -784,6 +792,7 @@ export async function runNew(options) {
784
792
  p.log.success("Created .gitignore with .env");
785
793
  else if (ignoreResult === "appended")
786
794
  p.log.success("Added .env to .gitignore");
795
+ ensureGitignoreRuns(cwd);
787
796
  // 5. Next steps
788
797
  const requiredCreds = credentials.filter((c) => !c.optional);
789
798
  const credUrls = requiredCreds.filter((c) => c.url).map((c) => ` ${c.key}: ${c.url}`);
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Local run history for `sweny workflow run`: one small JSON record per run
3
+ * under `.sweny/runs/`, read back by `sweny runs` and `sweny runs diff`.
4
+ *
5
+ * METADATA ONLY. A record holds ids, statuses, durations, counts, token and
6
+ * cost totals, and routing edges. It never holds prompts, node outputs, tool
7
+ * inputs/outputs, error text, or environment values.
8
+ *
9
+ * `schema_version` is the compatibility contract: a future resume journal
10
+ * builds on this format, so readers skip records with a version they do not
11
+ * know instead of guessing.
12
+ */
13
+ import type { ExecutionEvent, ExecutionTrace, NodeResult, Workflow } from "../types.js";
14
+ export declare const RUN_HISTORY_SCHEMA_VERSION = 1;
15
+ export declare const RUN_HISTORY_DIR: string;
16
+ export declare const RUN_HISTORY_KEEP = 200;
17
+ export type RunStatus = "success" | "failed" | "crashed";
18
+ export interface RunNodeRecord {
19
+ id: string;
20
+ status: "success" | "failed" | "skipped";
21
+ duration_ms: number;
22
+ tool_calls: number;
23
+ /** input + output tokens; null when the node reported no usage. */
24
+ tokens: number | null;
25
+ /** SDK-reported cost; null when none was reported (never estimated). */
26
+ cost_usd: number | null;
27
+ retries: number;
28
+ }
29
+ export interface RunTotals {
30
+ nodes_total: number;
31
+ nodes_ok: number;
32
+ nodes_skipped: number;
33
+ tool_calls: number;
34
+ tokens: number | null;
35
+ cost_usd: number | null;
36
+ }
37
+ export interface RunRecord {
38
+ schema_version: number;
39
+ run_id: string;
40
+ workflow_id: string;
41
+ workflow_hash: string;
42
+ started_at: string;
43
+ duration_ms: number;
44
+ status: RunStatus;
45
+ nodes: RunNodeRecord[];
46
+ routes: Array<{
47
+ from: string;
48
+ to: string;
49
+ }>;
50
+ totals: RunTotals;
51
+ }
52
+ /** sha256 (hex) of the workflow as canonical JSON: keys sorted, undefined dropped. */
53
+ export declare function hashWorkflow(workflow: Workflow): string;
54
+ /** Sortable id: `YYYYMMDD-HHMMSS-<6 hex>` in UTC, so filename order is run order. */
55
+ export declare function newRunId(startedAtMs: number, rand?: () => string): string;
56
+ export interface NodeTimer {
57
+ observer: (event: ExecutionEvent) => void;
58
+ durations: Map<string, number>;
59
+ /** Last result seen per node: lets a crashed run still record the nodes that finished. */
60
+ lastResults: Map<string, NodeResult>;
61
+ }
62
+ export declare function createNodeTimer(now?: () => number): NodeTimer;
63
+ export interface BuildRunRecordInput {
64
+ runId: string;
65
+ workflow: Workflow;
66
+ startedAtMs: number;
67
+ durationMs: number;
68
+ results: Map<string, NodeResult>;
69
+ trace?: ExecutionTrace;
70
+ nodeDurations?: Map<string, number>;
71
+ crashed?: boolean;
72
+ }
73
+ export declare function buildRunRecord(i: BuildRunRecordInput): RunRecord;
74
+ /** Atomic write: temp file in the same directory, fsync, rename. Returns the path, or null on failure. Never throws. */
75
+ export declare function writeRunRecord(record: RunRecord, cwd?: string): string | null;
76
+ /** Delete the oldest records beyond `keep`. Returns the number removed. Never throws. */
77
+ export declare function pruneRuns(cwd?: string, keep?: number): number;
78
+ /** History is on unless `--no-history` (options.history === false) or `.sweny.yml` has `history: off`. */
79
+ export declare function historyDisabled(optionHistory: unknown, configHistory: unknown): boolean;
80
+ /** Write one record then prune. Never throws; warns on stderr if the write failed. */
81
+ export declare function recordRun(record: RunRecord, cwd?: string): boolean;
82
+ /** All readable records, newest first. Corrupt files and unknown schema versions are skipped. */
83
+ export declare function listRuns(cwd?: string): RunRecord[];
84
+ /** Exact run id, or a unique prefix. */
85
+ export declare function findRun(runs: RunRecord[], ref: string): RunRecord | undefined;
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Local run history for `sweny workflow run`: one small JSON record per run
3
+ * under `.sweny/runs/`, read back by `sweny runs` and `sweny runs diff`.
4
+ *
5
+ * METADATA ONLY. A record holds ids, statuses, durations, counts, token and
6
+ * cost totals, and routing edges. It never holds prompts, node outputs, tool
7
+ * inputs/outputs, error text, or environment values.
8
+ *
9
+ * `schema_version` is the compatibility contract: a future resume journal
10
+ * builds on this format, so readers skip records with a version they do not
11
+ * know instead of guessing.
12
+ */
13
+ import crypto from "node:crypto";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { summarizeRun } from "./run-output.js";
17
+ export const RUN_HISTORY_SCHEMA_VERSION = 1;
18
+ export const RUN_HISTORY_DIR = path.join(".sweny", "runs");
19
+ export const RUN_HISTORY_KEEP = 200;
20
+ // ── Hash + ids ──────────────────────────────────────────────────
21
+ function canonicalize(value) {
22
+ if (Array.isArray(value))
23
+ return value.map(canonicalize);
24
+ if (value && typeof value === "object") {
25
+ const out = {};
26
+ for (const k of Object.keys(value).sort()) {
27
+ const v = value[k];
28
+ if (v !== undefined)
29
+ out[k] = canonicalize(v);
30
+ }
31
+ return out;
32
+ }
33
+ return value;
34
+ }
35
+ /** sha256 (hex) of the workflow as canonical JSON: keys sorted, undefined dropped. */
36
+ export function hashWorkflow(workflow) {
37
+ return crypto
38
+ .createHash("sha256")
39
+ .update(JSON.stringify(canonicalize(workflow)))
40
+ .digest("hex");
41
+ }
42
+ const RUN_ID_RE = /^\d{8}-\d{6}-[0-9a-f]{6}$/;
43
+ /** Sortable id: `YYYYMMDD-HHMMSS-<6 hex>` in UTC, so filename order is run order. */
44
+ export function newRunId(startedAtMs, rand = () => crypto.randomBytes(3).toString("hex")) {
45
+ const d = new Date(startedAtMs);
46
+ const p = (n) => String(n).padStart(2, "0");
47
+ return (`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}-` +
48
+ `${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}-${rand()}`);
49
+ }
50
+ export function createNodeTimer(now = Date.now) {
51
+ const entered = new Map();
52
+ const durations = new Map();
53
+ const lastResults = new Map();
54
+ return {
55
+ durations,
56
+ lastResults,
57
+ observer(event) {
58
+ if (event.type === "node:enter")
59
+ entered.set(event.node, now());
60
+ else if (event.type === "node:exit") {
61
+ const start = entered.get(event.node);
62
+ if (start !== undefined)
63
+ durations.set(event.node, (durations.get(event.node) ?? 0) + (now() - start));
64
+ lastResults.set(event.node, event.result);
65
+ }
66
+ },
67
+ };
68
+ }
69
+ // ── Build the record ────────────────────────────────────────────
70
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v);
71
+ export function buildRunRecord(i) {
72
+ const summary = summarizeRun(i.results, i.durationMs, i.crashed);
73
+ const retries = new Map();
74
+ for (const s of i.trace?.steps ?? []) {
75
+ if (s.retryAttempt !== undefined)
76
+ retries.set(s.node, Math.max(retries.get(s.node) ?? 0, s.retryAttempt));
77
+ }
78
+ const nodes = [...i.results].map(([id, r]) => {
79
+ const u = r.usage;
80
+ const hasTokens = !!u && (isNum(u.inputTokens) || isNum(u.outputTokens));
81
+ const tokens = hasTokens
82
+ ? (isNum(u.inputTokens) ? u.inputTokens : 0) + (isNum(u.outputTokens) ? u.outputTokens : 0)
83
+ : null;
84
+ return {
85
+ id,
86
+ status: r.status,
87
+ duration_ms: Math.max(0, Math.round(i.nodeDurations?.get(id) ?? 0)),
88
+ tool_calls: r.toolCalls?.length ?? 0,
89
+ tokens,
90
+ cost_usd: u && isNum(u.costUsd) ? u.costUsd : null,
91
+ retries: retries.get(id) ?? 0,
92
+ };
93
+ });
94
+ return {
95
+ schema_version: RUN_HISTORY_SCHEMA_VERSION,
96
+ run_id: i.runId,
97
+ workflow_id: i.workflow.id,
98
+ workflow_hash: hashWorkflow(i.workflow),
99
+ started_at: new Date(i.startedAtMs).toISOString(),
100
+ duration_ms: Math.max(0, Math.round(i.durationMs)),
101
+ status: i.crashed ? "crashed" : summary.ok ? "success" : "failed",
102
+ nodes,
103
+ routes: (i.trace?.edges ?? []).map((e) => ({ from: e.from, to: e.to })),
104
+ totals: {
105
+ nodes_total: summary.nodesTotal,
106
+ nodes_ok: summary.nodesOk,
107
+ nodes_skipped: summary.nodesSkipped,
108
+ tool_calls: summary.toolCalls,
109
+ tokens: summary.tokens ?? null,
110
+ cost_usd: summary.costUsd ?? null,
111
+ },
112
+ };
113
+ }
114
+ // ── Write, prune, read ──────────────────────────────────────────
115
+ /** Atomic write: temp file in the same directory, fsync, rename. Returns the path, or null on failure. Never throws. */
116
+ export function writeRunRecord(record, cwd = process.cwd()) {
117
+ const dir = path.join(cwd, RUN_HISTORY_DIR);
118
+ const final = path.join(dir, `${record.run_id}.json`);
119
+ const tmp = path.join(dir, `.${record.run_id}.json.${process.pid}.tmp`);
120
+ try {
121
+ fs.mkdirSync(dir, { recursive: true });
122
+ const fd = fs.openSync(tmp, "w");
123
+ try {
124
+ fs.writeSync(fd, JSON.stringify(record, null, 2) + "\n");
125
+ fs.fsyncSync(fd);
126
+ }
127
+ finally {
128
+ fs.closeSync(fd);
129
+ }
130
+ fs.renameSync(tmp, final);
131
+ return final;
132
+ }
133
+ catch {
134
+ try {
135
+ fs.rmSync(tmp, { force: true });
136
+ }
137
+ catch {
138
+ // nothing to clean
139
+ }
140
+ return null;
141
+ }
142
+ }
143
+ /** Delete the oldest records beyond `keep`. Returns the number removed. Never throws. */
144
+ export function pruneRuns(cwd = process.cwd(), keep = RUN_HISTORY_KEEP) {
145
+ try {
146
+ const dir = path.join(cwd, RUN_HISTORY_DIR);
147
+ const files = fs
148
+ .readdirSync(dir)
149
+ .filter((f) => f.endsWith(".json") && RUN_ID_RE.test(f.slice(0, -5)))
150
+ .sort();
151
+ const extra = files.slice(0, Math.max(0, files.length - keep));
152
+ for (const f of extra)
153
+ fs.rmSync(path.join(dir, f), { force: true });
154
+ return extra.length;
155
+ }
156
+ catch {
157
+ return 0;
158
+ }
159
+ }
160
+ /** History is on unless `--no-history` (options.history === false) or `.sweny.yml` has `history: off`. */
161
+ export function historyDisabled(optionHistory, configHistory) {
162
+ if (optionHistory === false)
163
+ return true;
164
+ return typeof configHistory === "string" && ["off", "false", "no", "0"].includes(configHistory.trim().toLowerCase());
165
+ }
166
+ /** Write one record then prune. Never throws; warns on stderr if the write failed. */
167
+ export function recordRun(record, cwd = process.cwd()) {
168
+ const written = writeRunRecord(record, cwd);
169
+ if (!written) {
170
+ process.stderr.write(` ⚠ could not write run history to ${RUN_HISTORY_DIR}\n`);
171
+ return false;
172
+ }
173
+ pruneRuns(cwd);
174
+ return true;
175
+ }
176
+ function isRecord(v) {
177
+ const r = v;
178
+ return (!!r &&
179
+ typeof r === "object" &&
180
+ r.schema_version === RUN_HISTORY_SCHEMA_VERSION &&
181
+ typeof r.run_id === "string" &&
182
+ typeof r.workflow_id === "string" &&
183
+ typeof r.started_at === "string" &&
184
+ Array.isArray(r.nodes) &&
185
+ Array.isArray(r.routes) &&
186
+ !!r.totals &&
187
+ typeof r.totals === "object");
188
+ }
189
+ /** All readable records, newest first. Corrupt files and unknown schema versions are skipped. */
190
+ export function listRuns(cwd = process.cwd()) {
191
+ const dir = path.join(cwd, RUN_HISTORY_DIR);
192
+ let files;
193
+ try {
194
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".json") && !f.startsWith("."));
195
+ }
196
+ catch {
197
+ return [];
198
+ }
199
+ const out = [];
200
+ for (const f of files) {
201
+ try {
202
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
203
+ if (isRecord(parsed))
204
+ out.push(parsed);
205
+ }
206
+ catch {
207
+ // corrupt record: skip
208
+ }
209
+ }
210
+ return out.sort((a, b) => a.started_at === b.started_at ? b.run_id.localeCompare(a.run_id) : b.started_at.localeCompare(a.started_at));
211
+ }
212
+ /** Exact run id, or a unique prefix. */
213
+ export function findRun(runs, ref) {
214
+ const exact = runs.find((r) => r.run_id === ref);
215
+ if (exact)
216
+ return exact;
217
+ const matches = runs.filter((r) => r.run_id.startsWith(ref));
218
+ return matches.length === 1 ? matches[0] : undefined;
219
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `sweny runs` and `sweny runs diff`: read-only views over `.sweny/runs/`.
3
+ * Metadata only. Color only when `color` is true (TTY).
4
+ */
5
+ import type { Command } from "commander";
6
+ import { type RunRecord } from "./run-history.js";
7
+ export declare function formatAge(startedAt: string, nowMs?: number): string;
8
+ export declare function formatRunsTable(runs: RunRecord[], color: boolean, nowMs?: number): string;
9
+ export declare function formatRunDiff(a: RunRecord, b: RunRecord, color: boolean): string;
10
+ /** Pick two runs: explicit refs, or the newest two runs of one workflow (--workflow, else the newest workflow with two or more runs). a = older, b = newer. */
11
+ export declare function pickRunsForDiff(runs: RunRecord[], refs: string[], workflowId?: string): {
12
+ a: RunRecord;
13
+ b: RunRecord;
14
+ } | {
15
+ error: string;
16
+ };
17
+ export declare function registerRunsCommand(program: Command): Command;
@@ -0,0 +1,216 @@
1
+ /**
2
+ * `sweny runs` and `sweny runs diff`: read-only views over `.sweny/runs/`.
3
+ * Metadata only. Color only when `color` is true (TTY).
4
+ */
5
+ import chalk from "chalk";
6
+ import { findRun, listRuns } from "./run-history.js";
7
+ import { formatCost, formatReceiptDuration, formatTokenCount } from "./run-output.js";
8
+ // ── Small formatters ────────────────────────────────────────────
9
+ export function formatAge(startedAt, nowMs = Date.now()) {
10
+ const s = Math.max(0, Math.round((nowMs - Date.parse(startedAt)) / 1000));
11
+ if (s < 60)
12
+ return "just now";
13
+ const m = Math.floor(s / 60);
14
+ if (m < 60)
15
+ return `${m}m ago`;
16
+ const h = Math.floor(m / 60);
17
+ if (h < 48)
18
+ return `${h}h ago`;
19
+ return `${Math.floor(h / 24)}d ago`;
20
+ }
21
+ const ICON = { success: "✓", failed: "✗", crashed: "✗", skipped: "−" };
22
+ function paint(text, status, color) {
23
+ if (!color)
24
+ return text;
25
+ if (status === "success")
26
+ return chalk.green(text);
27
+ if (status === "skipped")
28
+ return chalk.dim(text);
29
+ return chalk.red(text);
30
+ }
31
+ const tokensText = (n) => (n === null ? "-" : formatTokenCount(n));
32
+ const costText = (n) => (n === null ? "-" : formatCost(n));
33
+ function pad(s, w, right = false) {
34
+ return right ? s.padStart(w) : s.padEnd(w);
35
+ }
36
+ // ── sweny runs ──────────────────────────────────────────────────
37
+ export function formatRunsTable(runs, color, nowMs = Date.now()) {
38
+ if (runs.length === 0)
39
+ return " No runs recorded yet. Run `sweny workflow run <file>` to start a history.\n";
40
+ const rows = runs.map((r) => ({
41
+ status: r.status,
42
+ cells: [
43
+ r.run_id,
44
+ r.workflow_id,
45
+ `${ICON[r.status] ?? "?"} ${r.status}`,
46
+ formatReceiptDuration(r.duration_ms),
47
+ tokensText(r.totals?.tokens ?? null),
48
+ costText(r.totals?.cost_usd ?? null),
49
+ formatAge(r.started_at, nowMs),
50
+ ],
51
+ }));
52
+ const head = ["ID", "WORKFLOW", "STATUS", "DURATION", "TOKENS", "COST", "AGE"];
53
+ const rightAligned = new Set([3, 4, 5]);
54
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r.cells[i].length)));
55
+ const line = (cells, status) => " " +
56
+ cells
57
+ .map((c, i) => {
58
+ let t = pad(c, widths[i], rightAligned.has(i));
59
+ if (i === 2 && status)
60
+ t = paint(t, status, color);
61
+ return t;
62
+ })
63
+ .join(" ")
64
+ .trimEnd();
65
+ const headLine = line(head);
66
+ return [color ? chalk.dim(headLine) : headLine, ...rows.map((r) => line(r.cells, r.status))].join("\n") + "\n";
67
+ }
68
+ // ── sweny runs diff ─────────────────────────────────────────────
69
+ const sign = (n) => (n > 0 ? "+" : n < 0 ? "-" : "");
70
+ /** Like the receipt duration, but keeps one decimal under 10s so small deltas stay visible. */
71
+ function fmtDur(ms) {
72
+ return ms >= 1000 && ms < 10_000 ? `${Math.round(ms / 100) / 10}s` : formatReceiptDuration(ms);
73
+ }
74
+ const deltaDuration = (a, b) => `${sign(b - a)}${fmtDur(Math.abs(b - a))}`;
75
+ const deltaTokens = (a, b) => `${sign(b - a)}${formatTokenCount(Math.abs(b - a))}`;
76
+ const deltaCost = (a, b) => `${sign(b - a)}${formatCost(Math.abs(b - a))}`;
77
+ const deltaCount = (a, b) => `${sign(b - a)}${Math.abs(b - a)}`;
78
+ /** `label a → b (+delta)` for a numeric metric; null when unchanged. */
79
+ function metric(label, a, b, show, delta) {
80
+ if (a === b)
81
+ return null;
82
+ if (a === null || b === null)
83
+ return `${label} ${a === null ? "-" : show(a)} → ${b === null ? "-" : show(b)}`;
84
+ return `${label} ${show(a)} → ${show(b)} (${delta(a, b)})`;
85
+ }
86
+ function nodeChanges(a, b) {
87
+ return [
88
+ metric("duration", a.duration_ms, b.duration_ms, fmtDur, deltaDuration),
89
+ metric("tokens", a.tokens, b.tokens, formatTokenCount, deltaTokens),
90
+ metric("cost", a.cost_usd, b.cost_usd, formatCost, deltaCost),
91
+ metric("tools", a.tool_calls, b.tool_calls, String, deltaCount),
92
+ metric("retries", a.retries, b.retries, String, deltaCount),
93
+ ].filter((x) => x !== null);
94
+ }
95
+ const routeKey = (r) => `${r.from} → ${r.to}`;
96
+ export function formatRunDiff(a, b, color) {
97
+ const dim = (s) => (color ? chalk.dim(s) : s);
98
+ const out = [];
99
+ const hdr = (tag, r) => ` ${tag} ${r.run_id} ${r.workflow_id} ${paint(`${ICON[r.status] ?? "?"} ${r.status}`, r.status, color)} ${formatReceiptDuration(r.duration_ms)}`;
100
+ out.push(hdr("a", a), hdr("b", b), "");
101
+ const short = (h) => h.slice(0, 7);
102
+ out.push(a.workflow_hash === b.workflow_hash
103
+ ? ` workflow unchanged (${short(a.workflow_hash)})`
104
+ : ` workflow changed ${short(a.workflow_hash)} → ${short(b.workflow_hash)}`);
105
+ if (a.workflow_id !== b.workflow_id)
106
+ out.push(` id ${a.workflow_id} → ${b.workflow_id}`);
107
+ out.push(a.status === b.status
108
+ ? ` status ${ICON[b.status]} ${b.status} (same)`
109
+ : ` status ${ICON[a.status]} ${a.status} → ${paint(`${ICON[b.status]} ${b.status}`, b.status, color)}`);
110
+ const tot = [
111
+ metric("duration ", a.duration_ms, b.duration_ms, formatReceiptDuration, deltaDuration),
112
+ metric("tokens ", a.totals.tokens, b.totals.tokens, formatTokenCount, deltaTokens),
113
+ metric("cost ", a.totals.cost_usd, b.totals.cost_usd, formatCost, deltaCost),
114
+ ];
115
+ for (const t of tot)
116
+ if (t)
117
+ out.push(` ${t}`);
118
+ out.push("", " nodes");
119
+ const aNodes = new Map(a.nodes.map((n) => [n.id, n]));
120
+ const bNodes = new Map(b.nodes.map((n) => [n.id, n]));
121
+ const ids = [...new Set([...b.nodes.map((n) => n.id), ...a.nodes.map((n) => n.id)])];
122
+ const w = Math.max(4, ...ids.map((i) => i.length));
123
+ for (const id of ids) {
124
+ const na = aNodes.get(id);
125
+ const nb = bNodes.get(id);
126
+ if (na && !nb) {
127
+ out.push(` ${pad(id, w)} ${dim("removed")} (was ${ICON[na.status]} ${na.status})`);
128
+ }
129
+ else if (!na && nb) {
130
+ out.push(` ${pad(id, w)} ${paint(`added ${ICON[nb.status]} ${nb.status}`, nb.status, color)}`);
131
+ }
132
+ else if (na && nb) {
133
+ const st = na.status === nb.status ? ICON[nb.status] : `${ICON[na.status]} → ${paint(ICON[nb.status], nb.status, color)}`;
134
+ const ch = nodeChanges(na, nb);
135
+ out.push(` ${pad(id, w)} ${st} ${ch.length ? ch.join(" ") : dim("no change")}`);
136
+ }
137
+ }
138
+ out.push("", " routes");
139
+ const ar = new Set(a.routes.map(routeKey));
140
+ const br = new Set(b.routes.map(routeKey));
141
+ const onlyA = [...ar].filter((r) => !br.has(r));
142
+ const onlyB = [...br].filter((r) => !ar.has(r));
143
+ if (onlyA.length === 0 && onlyB.length === 0)
144
+ out.push(" identical");
145
+ for (const r of onlyA)
146
+ out.push(` - ${r} ${dim("(only in a)")}`);
147
+ for (const r of onlyB)
148
+ out.push(` + ${r} ${dim("(only in b)")}`);
149
+ return out.join("\n") + "\n";
150
+ }
151
+ /** Pick two runs: explicit refs, or the newest two runs of one workflow (--workflow, else the newest workflow with two or more runs). a = older, b = newer. */
152
+ export function pickRunsForDiff(runs, refs, workflowId) {
153
+ if (refs.length === 1)
154
+ return { error: "Pass two run ids, or none to diff the last two runs of a workflow." };
155
+ if (refs.length === 2) {
156
+ const a = findRun(runs, refs[0]);
157
+ const b = findRun(runs, refs[1]);
158
+ if (!a)
159
+ return { error: `No run matches "${refs[0]}". Run \`sweny runs\` to list ids.` };
160
+ if (!b)
161
+ return { error: `No run matches "${refs[1]}". Run \`sweny runs\` to list ids.` };
162
+ return { a, b };
163
+ }
164
+ // No --workflow: the newest workflow that has at least two runs to compare.
165
+ const counts = new Map();
166
+ for (const r of runs)
167
+ counts.set(r.workflow_id, (counts.get(r.workflow_id) ?? 0) + 1);
168
+ const wf = workflowId ?? runs.find((r) => (counts.get(r.workflow_id) ?? 0) >= 2)?.workflow_id ?? runs[0]?.workflow_id;
169
+ const same = wf ? runs.filter((r) => r.workflow_id === wf) : [];
170
+ if (same.length < 2) {
171
+ return { error: wf ? `Need two runs of "${wf}" to diff; found ${same.length}.` : "No runs recorded yet." };
172
+ }
173
+ return { a: same[1], b: same[0] };
174
+ }
175
+ // ── Command wiring ──────────────────────────────────────────────
176
+ export function registerRunsCommand(program) {
177
+ const runsCmd = program
178
+ .command("runs")
179
+ .description("List recent workflow runs recorded in .sweny/runs/ (metadata only)")
180
+ .option("--workflow <id>", "Only runs of this workflow")
181
+ .option("--limit <n>", "Max runs to show", "20")
182
+ .option("--json", "Output run records as JSON")
183
+ .action((opts) => {
184
+ const limit = Number(opts.limit ?? "20");
185
+ if (!Number.isInteger(limit) || limit < 1) {
186
+ console.error(chalk.red(" --limit must be a positive integer"));
187
+ process.exitCode = 1;
188
+ return;
189
+ }
190
+ let runs = listRuns();
191
+ if (opts.workflow)
192
+ runs = runs.filter((r) => r.workflow_id === opts.workflow);
193
+ runs = runs.slice(0, limit);
194
+ if (opts.json) {
195
+ process.stdout.write(JSON.stringify(runs, null, 2) + "\n");
196
+ return;
197
+ }
198
+ process.stdout.write("\n" + formatRunsTable(runs, process.stdout.isTTY ?? false) + "\n");
199
+ });
200
+ runsCmd
201
+ .command("diff [a] [b]")
202
+ .description("Compare two runs (default: the last two runs of the same workflow)")
203
+ .option("--workflow <id>", "Workflow to pick the default pair from")
204
+ .action((a, b, opts) => {
205
+ const refs = [a, b].filter((x) => x !== undefined);
206
+ const runs = listRuns();
207
+ const picked = pickRunsForDiff(runs, refs, opts.workflow);
208
+ if ("error" in picked) {
209
+ console.error(chalk.red(` ${picked.error}`));
210
+ process.exitCode = 1;
211
+ return;
212
+ }
213
+ process.stdout.write("\n" + formatRunDiff(picked.a, picked.b, process.stdout.isTTY ?? false) + "\n");
214
+ });
215
+ return runsCmd;
216
+ }
package/dist/executor.js CHANGED
@@ -19,6 +19,7 @@ import { evaluateRequires } from "./requires.js";
19
19
  import { buildRetryPreamble } from "./retry.js";
20
20
  import { resolveExecutionModel } from "./model.js";
21
21
  import { buildToolAliases } from "./skills/index.js";
22
+ import { validateWorkflow } from "./schema.js";
22
23
  import { resolveAgentAccess } from "./agent-env.js";
23
24
  import { fenceUntrusted } from "./untrusted.js";
24
25
  /**
@@ -1052,6 +1053,16 @@ function validate(workflow, skills) {
1052
1053
  if (!workflow.nodes[edge.to])
1053
1054
  throw new Error(`Edge references unknown node: "${edge.to}"`);
1054
1055
  }
1056
+ // Same structural validation the CLI loader runs (reachability, self-loops,
1057
+ // ambiguous edges, unbounded cycles, reserved eval policies, retry and
1058
+ // max_iterations ceilings). Library callers, Studio's simulator and run.ts
1059
+ // reach execute() without the loader, so without this they got none of it
1060
+ // (#326). Runs once per execute() call, before any node runs. Skill
1061
+ // availability stays a warning below, so no knownSkills here.
1062
+ const structural = validateWorkflow(workflow);
1063
+ if (structural.length > 0) {
1064
+ throw new Error(`Invalid workflow "${workflow.id}":\n${structural.map((e) => ` ${e.code}: ${e.message}`).join("\n")}`);
1065
+ }
1055
1066
  // Check that each node has at least one available skill (if it lists any)
1056
1067
  for (const [nodeId, node] of Object.entries(workflow.nodes)) {
1057
1068
  if (node.skills.length === 0)
package/dist/schema.d.ts CHANGED
@@ -776,6 +776,22 @@ export declare const nodeToolsZ: z.ZodEffects<z.ZodObject<{
776
776
  deny?: string[] | undefined;
777
777
  allow?: string[] | undefined;
778
778
  }>;
779
+ /**
780
+ * Ceiling on `retry.max`. Each retry attempt re-invokes the agent on the
781
+ * node (full model spend), so an unbounded value here is an unbounded spend
782
+ * multiplier per node, independent of the workflow-level `max_steps` budget.
783
+ * 10 comfortably covers every declared workflow today (max observed: 1) while
784
+ * still catching a fat-fingered value (e.g. a missing digit) at author time
785
+ * instead of at runtime. See #325.
786
+ */
787
+ export declare const NODE_RETRY_MAX_CEILING = 10;
788
+ /**
789
+ * Ceiling on an edge's `max_iterations`. A bounded back-edge is still a spend
790
+ * multiplier (each lap re-runs model nodes), and the default `max_steps` is
791
+ * 200, so a value above this can never be reached in a default run and only
792
+ * hides a typo. See #325.
793
+ */
794
+ export declare const EDGE_MAX_ITERATIONS_CEILING = 100;
779
795
  export declare const nodeRetryZ: z.ZodObject<{
780
796
  max: z.ZodNumber;
781
797
  instruction: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodObject<{
@@ -2735,7 +2751,7 @@ export declare function parseWorkflow(raw: unknown): {
2735
2751
  }> | undefined;
2736
2752
  };
2737
2753
  export interface WorkflowError {
2738
- code: "MISSING_ENTRY" | "UNKNOWN_EDGE_SOURCE" | "UNKNOWN_EDGE_TARGET" | "UNREACHABLE_NODE" | "UNKNOWN_SKILL" | "SELF_LOOP" | "UNBOUNDED_CYCLE" | "AMBIGUOUS_EDGES" | "UNSUPPORTED_EVAL_POLICY" | "INVALID_INLINE_SKILL";
2754
+ code: "MISSING_ENTRY" | "UNKNOWN_EDGE_SOURCE" | "UNKNOWN_EDGE_TARGET" | "UNREACHABLE_NODE" | "UNKNOWN_SKILL" | "SELF_LOOP" | "UNBOUNDED_CYCLE" | "AMBIGUOUS_EDGES" | "UNSUPPORTED_EVAL_POLICY" | "INVALID_INLINE_SKILL" | "EDGE_ITERATIONS_EXCEEDED" | "RETRY_MAX_EXCEEDED";
2739
2755
  message: string;
2740
2756
  nodeId?: string;
2741
2757
  }
@@ -3347,6 +3363,7 @@ export declare const workflowJsonSchema: {
3347
3363
  readonly max_iterations: {
3348
3364
  readonly type: "integer";
3349
3365
  readonly minimum: 1;
3366
+ readonly maximum: 100;
3350
3367
  readonly description: "Max times this edge can be followed. Enables controlled retry loops.";
3351
3368
  };
3352
3369
  };
package/dist/schema.js CHANGED
@@ -230,7 +230,14 @@ const retryInstructionReflectZ = z.object({ reflect: z.string().min(1) }).strict
230
230
  * still catching a fat-fingered value (e.g. a missing digit) at author time
231
231
  * instead of at runtime. See #325.
232
232
  */
233
- const NODE_RETRY_MAX_CEILING = 10;
233
+ export const NODE_RETRY_MAX_CEILING = 10;
234
+ /**
235
+ * Ceiling on an edge's `max_iterations`. A bounded back-edge is still a spend
236
+ * multiplier (each lap re-runs model nodes), and the default `max_steps` is
237
+ * 200, so a value above this can never be reached in a default run and only
238
+ * hides a typo. See #325.
239
+ */
240
+ export const EDGE_MAX_ITERATIONS_CEILING = 100;
234
241
  export const nodeRetryZ = z
235
242
  .object({
236
243
  max: z.number().int().min(1).max(NODE_RETRY_MAX_CEILING),
@@ -263,7 +270,7 @@ export const edgeZ = z
263
270
  from: z.string().min(1),
264
271
  to: z.string().min(1),
265
272
  when: z.string().optional(),
266
- max_iterations: z.number().int().min(1).optional(),
273
+ max_iterations: z.number().int().min(1).max(EDGE_MAX_ITERATIONS_CEILING).optional(),
267
274
  })
268
275
  .strict();
269
276
  /**
@@ -352,13 +359,25 @@ export function parseWorkflow(raw) {
352
359
  export function validateWorkflow(workflow, knownSkills) {
353
360
  const errors = [];
354
361
  const nodeIds = new Set(Object.keys(workflow.nodes));
362
+ const entryExists = nodeIds.has(workflow.entry);
355
363
  // Entry must exist
356
- if (!nodeIds.has(workflow.entry)) {
364
+ if (!entryExists) {
357
365
  errors.push({
358
366
  code: "MISSING_ENTRY",
359
367
  message: `Entry node "${workflow.entry}" does not exist`,
360
368
  });
361
369
  }
370
+ // Adjacency over real nodes, built once (O(nodes + edges)) so reachability
371
+ // and cycle detection stay linear on large graphs.
372
+ const outAll = new Map();
373
+ const outUnbounded = new Map();
374
+ const push = (m, k, v) => {
375
+ const l = m.get(k);
376
+ if (l)
377
+ l.push(v);
378
+ else
379
+ m.set(k, [v]);
380
+ };
362
381
  // Edge targets must exist
363
382
  for (const edge of workflow.edges) {
364
383
  if (!nodeIds.has(edge.from)) {
@@ -382,6 +401,18 @@ export function validateWorkflow(workflow, knownSkills) {
382
401
  nodeId: edge.from,
383
402
  });
384
403
  }
404
+ if (edge.max_iterations != null && edge.max_iterations > EDGE_MAX_ITERATIONS_CEILING) {
405
+ errors.push({
406
+ code: "EDGE_ITERATIONS_EXCEEDED",
407
+ message: `Edge from "${edge.from}" to "${edge.to}" declares max_iterations ${edge.max_iterations}, above the ceiling of ${EDGE_MAX_ITERATIONS_CEILING}`,
408
+ nodeId: edge.from,
409
+ });
410
+ }
411
+ if (nodeIds.has(edge.from) && nodeIds.has(edge.to)) {
412
+ push(outAll, edge.from, edge.to);
413
+ if (!edge.max_iterations && edge.from !== edge.to)
414
+ push(outUnbounded, edge.from, edge.to);
415
+ }
385
416
  }
386
417
  // Edge determinism: a node with two or more out-edges that have no `when`
387
418
  // clause is ambiguous. The executor's resolveNext picks the first such edge
@@ -396,9 +427,7 @@ export function validateWorkflow(workflow, knownSkills) {
396
427
  continue;
397
428
  if (edge.when)
398
429
  continue;
399
- const list = unconditionalByNode.get(edge.from) ?? [];
400
- list.push(edge.to);
401
- unconditionalByNode.set(edge.from, list);
430
+ push(unconditionalByNode, edge.from, edge.to);
402
431
  }
403
432
  for (const [from, targets] of unconditionalByNode) {
404
433
  if (targets.length > 1) {
@@ -409,60 +438,63 @@ export function validateWorkflow(workflow, knownSkills) {
409
438
  });
410
439
  }
411
440
  }
412
- // Don't check reachability if there are structural errors
413
- if (errors.length > 0)
414
- return errors;
415
- // Reachability: BFS from entry
416
- const visited = new Set();
417
- const queue = [workflow.entry];
418
- while (queue.length > 0) {
419
- const id = queue.shift();
420
- if (visited.has(id))
421
- continue;
422
- visited.add(id);
423
- for (const edge of workflow.edges) {
424
- if (edge.from === id && !visited.has(edge.to)) {
425
- queue.push(edge.to);
441
+ // Reachability: BFS from entry. Skipped only when the entry is missing
442
+ // (every node would be reported, which is noise). All other diagnostics
443
+ // accumulate so one pass reports every structural problem.
444
+ if (entryExists) {
445
+ const visited = new Set([workflow.entry]);
446
+ const queue = [workflow.entry];
447
+ for (let i = 0; i < queue.length; i++) {
448
+ for (const to of outAll.get(queue[i]) ?? []) {
449
+ if (!visited.has(to)) {
450
+ visited.add(to);
451
+ queue.push(to);
452
+ }
426
453
  }
427
454
  }
428
- }
429
- for (const nodeId of nodeIds) {
430
- if (!visited.has(nodeId)) {
431
- errors.push({
432
- code: "UNREACHABLE_NODE",
433
- message: `Node "${nodeId}" is unreachable from entry "${workflow.entry}"`,
434
- nodeId,
435
- });
455
+ for (const nodeId of nodeIds) {
456
+ if (!visited.has(nodeId)) {
457
+ errors.push({
458
+ code: "UNREACHABLE_NODE",
459
+ message: `Node "${nodeId}" is unreachable from entry "${workflow.entry}"`,
460
+ nodeId,
461
+ });
462
+ }
436
463
  }
437
464
  }
438
465
  // Detect unbounded cycles: the graph with max_iterations edges removed must be acyclic.
439
466
  // If removing bounded edges still leaves a cycle, it can loop forever.
440
- const unboundedEdges = workflow.edges.filter((e) => !e.max_iterations && e.from !== e.to);
441
- // DFS-based cycle detection on the unbounded subgraph
467
+ // Iterative DFS with an explicit stack so a deep chain cannot overflow the
468
+ // call stack (#326).
442
469
  const WHITE = 0, GRAY = 1, BLACK = 2;
443
470
  const color = new Map();
444
471
  for (const id of nodeIds)
445
472
  color.set(id, WHITE);
446
- function dfs(node) {
447
- color.set(node, GRAY);
448
- for (const e of unboundedEdges) {
449
- if (e.from !== node)
473
+ const findCycle = (start) => {
474
+ const stack = [{ node: start, i: 0 }];
475
+ color.set(start, GRAY);
476
+ while (stack.length > 0) {
477
+ const top = stack[stack.length - 1];
478
+ const outs = outUnbounded.get(top.node) ?? [];
479
+ if (top.i >= outs.length) {
480
+ color.set(top.node, BLACK);
481
+ stack.pop();
450
482
  continue;
451
- const c = color.get(e.to);
483
+ }
484
+ const to = outs[top.i++];
485
+ const c = color.get(to);
452
486
  if (c === GRAY)
453
- return e.to; // back-edge found → cycle
487
+ return to; // back-edge found → cycle
454
488
  if (c === WHITE) {
455
- const cycle = dfs(e.to);
456
- if (cycle)
457
- return cycle;
489
+ color.set(to, GRAY);
490
+ stack.push({ node: to, i: 0 });
458
491
  }
459
492
  }
460
- color.set(node, BLACK);
461
493
  return null;
462
- }
494
+ };
463
495
  for (const id of nodeIds) {
464
496
  if (color.get(id) === WHITE) {
465
- const cycleNode = dfs(id);
497
+ const cycleNode = findCycle(id);
466
498
  if (cycleNode) {
467
499
  errors.push({
468
500
  code: "UNBOUNDED_CYCLE",
@@ -472,6 +504,18 @@ export function validateWorkflow(workflow, knownSkills) {
472
504
  }
473
505
  }
474
506
  }
507
+ // Retry ceiling for workflows that never went through zod (library callers,
508
+ // the e2e batch runner). Mirrors nodeRetryZ so every path enforces it (#325).
509
+ for (const [nodeId, node] of Object.entries(workflow.nodes)) {
510
+ const max = node.retry?.max;
511
+ if (max != null && max > NODE_RETRY_MAX_CEILING) {
512
+ errors.push({
513
+ code: "RETRY_MAX_EXCEEDED",
514
+ message: `Node "${nodeId}" declares retry.max ${max}, above the ceiling of ${NODE_RETRY_MAX_CEILING}`,
515
+ nodeId,
516
+ });
517
+ }
518
+ }
475
519
  // Reject eval policies that are reserved in the vocabulary but not yet
476
520
  // implemented. The Zod schema accepts the whole EVAL_POLICIES enum (the
477
521
  // published JSON schema advertises them as reserved), but only `all_pass`
@@ -941,6 +985,7 @@ export const workflowJsonSchema = {
941
985
  max_iterations: {
942
986
  type: "integer",
943
987
  minimum: 1,
988
+ maximum: EDGE_MAX_ITERATIONS_CEILING,
944
989
  description: "Max times this edge can be followed. Enables controlled retry loops.",
945
990
  },
946
991
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.128",
3
+ "version": "0.1.130",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"