@sweny-ai/core 0.1.160 → 0.1.162
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/browser.d.ts +3 -1
- package/dist/browser.js +2 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.js +30 -1
- package/dist/cli/run-output.js +1 -1
- package/dist/cli/workflow-upgrade.d.ts +35 -0
- package/dist/cli/workflow-upgrade.js +139 -0
- package/dist/executor.js +89 -9
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/loader.d.ts +8 -1
- package/dist/loader.js +12 -2
- package/dist/mermaid.js +4 -2
- package/dist/migrations.d.ts +63 -0
- package/dist/migrations.js +93 -0
- package/dist/schema.d.ts +62 -12
- package/dist/schema.js +94 -3
- package/dist/studio.d.ts +2 -2
- package/dist/types.d.ts +18 -2
- package/dist/when.d.ts +114 -0
- package/dist/when.js +581 -0
- package/dist/workflows/browser.js +1 -1
- package/dist/workflows/triage.yml +15 -4
- package/package.json +1 -1
package/dist/browser.d.ts
CHANGED
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
* - `buildWorkflow` / `refineWorkflow` — depend on the Claude interface.
|
|
14
14
|
* - Testing helpers — `MockClaude`, `createFileSkill` use `node:fs`.
|
|
15
15
|
*/
|
|
16
|
-
export type { Skill, SkillCategory, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, NodeOnFail, McpTransport, WorkflowType, SkillHarnessKey, } from "./types.js";
|
|
16
|
+
export type { Skill, SkillCategory, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, EdgeWhen, WhenExpression, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, NodeOnFail, McpTransport, WorkflowType, SkillHarnessKey, } from "./types.js";
|
|
17
17
|
export { consoleLogger } from "./types.js";
|
|
18
18
|
export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, } from "./types.js";
|
|
19
19
|
export { github, linear, slack, sentry, datadog, betterstack, notification, supabase, builtinSkills, createSkillMap, allSkills, isSkillConfigured, configuredBuiltinSkills, validateWorkflowSkills, } from "./skills/index.js";
|
|
20
20
|
export type { SkillValidationResult } from "./skills/index.js";
|
|
21
21
|
export { workflowZ, nodeZ, edgeZ, skillZ, parseWorkflow, validateWorkflow, workflowJsonSchema, skillJsonSchema, } from "./schema.js";
|
|
22
22
|
export type { WorkflowError } from "./schema.js";
|
|
23
|
+
export { isWhenExpression, whenLabel, parseExpression, evaluateExpression, checkExpression, ExpressionSyntaxError, } from "./when.js";
|
|
24
|
+
export type { ExprNode, ExpressionScope, ExpressionResult } from "./when.js";
|
|
23
25
|
export { workflowToFlow, flowToWorkflow, applyExecutionEvent, exportAsTypescript, getSkillCatalog } from "./studio.js";
|
|
24
26
|
export type { FlowNode, SkillNodeData, FlowEdge } from "./studio.js";
|
package/dist/browser.js
CHANGED
|
@@ -23,5 +23,7 @@ export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRA
|
|
|
23
23
|
export { github, linear, slack, sentry, datadog, betterstack, notification, supabase, builtinSkills, createSkillMap, allSkills, isSkillConfigured, configuredBuiltinSkills, validateWorkflowSkills, } from "./skills/index.js";
|
|
24
24
|
// Schema & validation
|
|
25
25
|
export { workflowZ, nodeZ, edgeZ, skillZ, parseWorkflow, validateWorkflow, workflowJsonSchema, skillJsonSchema, } from "./schema.js";
|
|
26
|
+
// Deterministic `when` expressions (#461). Pure, browser-safe.
|
|
27
|
+
export { isWhenExpression, whenLabel, parseExpression, evaluateExpression, checkExpression, ExpressionSyntaxError, } from "./when.js";
|
|
26
28
|
// Studio adapter
|
|
27
29
|
export { workflowToFlow, flowToWorkflow, applyExecutionEvent, exportAsTypescript, getSkillCatalog } from "./studio.js";
|
package/dist/cli/main.d.ts
CHANGED
|
@@ -17,3 +17,6 @@ export declare function workflowExportAction(name: string): void;
|
|
|
17
17
|
export declare function workflowValidateAction(file: string, options: {
|
|
18
18
|
json?: boolean;
|
|
19
19
|
}): void;
|
|
20
|
+
export declare function workflowUpgradeAction(file: string, options: {
|
|
21
|
+
dryRun?: boolean;
|
|
22
|
+
}): void;
|
package/dist/cli/main.js
CHANGED
|
@@ -19,6 +19,7 @@ import { configuredSkills, configuredSkillsWithDiagnostics } from "../skills/cus
|
|
|
19
19
|
import { buildAutoMcpServers, buildSkillMcpServers, buildProviderContext } from "../mcp.js";
|
|
20
20
|
import { loadAdditionalContext } from "../templates.js";
|
|
21
21
|
import { loadAndValidateWorkflow } from "../loader.js";
|
|
22
|
+
import { upgradeWorkflowFile } from "./workflow-upgrade.js";
|
|
22
23
|
import { validateRuntimeInput } from "../inputs.js";
|
|
23
24
|
import { mergeDryRunIntoInput, parseInputFlag, parseRunBudgetFlags, parseSpendFlags } from "./workflow-input.js";
|
|
24
25
|
import { stringify as stringifyYaml } from "yaml";
|
|
@@ -725,6 +726,8 @@ export function loadWorkflowFile(filePath, knownSkills) {
|
|
|
725
726
|
if (!result.ok) {
|
|
726
727
|
throw new Error(`Invalid workflow file:\n${result.errors.map((e) => ` ${e.message}`).join("\n")}`);
|
|
727
728
|
}
|
|
729
|
+
for (const w of result.warnings ?? [])
|
|
730
|
+
console.error(chalk.yellow(` \u26A0 ${w}`));
|
|
728
731
|
return result.workflow;
|
|
729
732
|
}
|
|
730
733
|
export async function workflowRunAction(file, options) {
|
|
@@ -1122,7 +1125,10 @@ export function workflowValidateAction(file, options) {
|
|
|
1122
1125
|
const result = loadAndValidateWorkflow(file);
|
|
1123
1126
|
// Missing skill env is a warning here, not a failure: `run` is where it blocks.
|
|
1124
1127
|
const warnings = result.ok
|
|
1125
|
-
?
|
|
1128
|
+
? [
|
|
1129
|
+
...(result.warnings ?? []),
|
|
1130
|
+
...skillEnvWarnings(result.workflow, process.env, builtinSkills.concat(configuredSkills(process.env, process.cwd()))),
|
|
1131
|
+
]
|
|
1126
1132
|
: [];
|
|
1127
1133
|
if (options.json) {
|
|
1128
1134
|
const errs = result.ok ? [] : result.errors;
|
|
@@ -1149,6 +1155,29 @@ workflowCmd
|
|
|
1149
1155
|
.description("Validate a workflow YAML or JSON file")
|
|
1150
1156
|
.option("--json", "Output result as JSON")
|
|
1151
1157
|
.action(workflowValidateAction);
|
|
1158
|
+
export function workflowUpgradeAction(file, options) {
|
|
1159
|
+
const result = upgradeWorkflowFile(file, { dryRun: options.dryRun });
|
|
1160
|
+
if (result.status === "error") {
|
|
1161
|
+
console.error(chalk.red(` \u2717 ${result.message}`));
|
|
1162
|
+
process.exit(1);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
if (result.status === "current") {
|
|
1166
|
+
console.log(chalk.green(` \u2713 ${file} is already at spec_version "${result.version}"`));
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
const verb = result.status === "dry-run" ? "Would upgrade" : "Upgraded";
|
|
1170
|
+
console.log(chalk.green(` \u2713 ${verb} ${file}: spec_version "${result.from}" -> "${result.to}"`));
|
|
1171
|
+
for (const step of result.steps)
|
|
1172
|
+
console.log(chalk.dim(` - ${step}`));
|
|
1173
|
+
if (result.status === "dry-run")
|
|
1174
|
+
console.log(chalk.dim(" (dry run, file not changed)"));
|
|
1175
|
+
}
|
|
1176
|
+
workflowCmd
|
|
1177
|
+
.command("upgrade <file>")
|
|
1178
|
+
.description("Rewrite a workflow file at the current spec version (comments are kept where possible)")
|
|
1179
|
+
.option("--dry-run", "Show what would change without writing the file")
|
|
1180
|
+
.action(workflowUpgradeAction);
|
|
1152
1181
|
const workflowRunCmd = workflowCmd
|
|
1153
1182
|
.command("run [file]")
|
|
1154
1183
|
.description(WORKFLOW_RUN_DESCRIPTION)
|
package/dist/cli/run-output.js
CHANGED
|
@@ -262,7 +262,7 @@ export const WORKFLOW_RUN_OPTIONS = [
|
|
|
262
262
|
["-y, --yes", "Skip the batch confirmation prompt (for CI)"],
|
|
263
263
|
[
|
|
264
264
|
"--dry-run",
|
|
265
|
-
"Run with read-only tools only: write tools, external MCP servers, and shell/file-edit tools are withheld, so nothing is created, posted, or sent. Stops at the first conditional edge. To inspect nodes without running, use --list-nodes.",
|
|
265
|
+
"Run with read-only tools only: write tools, external MCP servers, and shell/file-edit tools are withheld, so nothing is created, posted, or sent. Stops at the first natural-language conditional edge. To inspect nodes without running, use --list-nodes.",
|
|
266
266
|
],
|
|
267
267
|
[
|
|
268
268
|
"--stage",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sweny workflow upgrade <file> [--dry-run]`
|
|
3
|
+
*
|
|
4
|
+
* Rewrites a workflow file at the current spec version by running the same
|
|
5
|
+
* migration chain the loader applies in memory.
|
|
6
|
+
*
|
|
7
|
+
* Comment preservation (YAML): the file is edited as a YAML document, not
|
|
8
|
+
* re-serialized from scratch. Comments, blank lines, key order and quoting on
|
|
9
|
+
* everything a migration did not touch are kept. NOT preserved: a comment
|
|
10
|
+
* attached to a value a migration replaced or removed, the original position of
|
|
11
|
+
* keys a migration renamed (they are added at the end of their map), and
|
|
12
|
+
* formatting of arrays whose length a migration changed (rewritten whole).
|
|
13
|
+
* JSON files carry no comments and are rewritten with 2-space indentation.
|
|
14
|
+
*/
|
|
15
|
+
import { type MigrationConfig } from "../migrations.js";
|
|
16
|
+
export type UpgradeResult = {
|
|
17
|
+
status: "error";
|
|
18
|
+
message: string;
|
|
19
|
+
} | {
|
|
20
|
+
status: "current";
|
|
21
|
+
version: number;
|
|
22
|
+
} | {
|
|
23
|
+
status: "upgraded" | "dry-run";
|
|
24
|
+
from: number;
|
|
25
|
+
to: number;
|
|
26
|
+
/** Migration descriptions applied, in order. Empty when only `spec_version` was stamped. */
|
|
27
|
+
steps: string[];
|
|
28
|
+
/** The full text that was (or, for dry-run, would be) written. */
|
|
29
|
+
content: string;
|
|
30
|
+
};
|
|
31
|
+
export interface UpgradeOptions {
|
|
32
|
+
dryRun?: boolean;
|
|
33
|
+
migration?: MigrationConfig;
|
|
34
|
+
}
|
|
35
|
+
export declare function upgradeWorkflowFile(filePath: string, options?: UpgradeOptions): UpgradeResult;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sweny workflow upgrade <file> [--dry-run]`
|
|
3
|
+
*
|
|
4
|
+
* Rewrites a workflow file at the current spec version by running the same
|
|
5
|
+
* migration chain the loader applies in memory.
|
|
6
|
+
*
|
|
7
|
+
* Comment preservation (YAML): the file is edited as a YAML document, not
|
|
8
|
+
* re-serialized from scratch. Comments, blank lines, key order and quoting on
|
|
9
|
+
* everything a migration did not touch are kept. NOT preserved: a comment
|
|
10
|
+
* attached to a value a migration replaced or removed, the original position of
|
|
11
|
+
* keys a migration renamed (they are added at the end of their map), and
|
|
12
|
+
* formatting of arrays whose length a migration changed (rewritten whole).
|
|
13
|
+
* JSON files carry no comments and are rewritten with 2-space indentation.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { isMap, parseDocument } from "yaml";
|
|
18
|
+
import { DEFAULT_MIGRATION_CONFIG, migrateWorkflow } from "../migrations.js";
|
|
19
|
+
import { validateParsed } from "../loader.js";
|
|
20
|
+
function isPlainObject(v) {
|
|
21
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
22
|
+
}
|
|
23
|
+
function withoutSpecVersion(obj) {
|
|
24
|
+
const copy = { ...obj };
|
|
25
|
+
delete copy.spec_version;
|
|
26
|
+
return copy;
|
|
27
|
+
}
|
|
28
|
+
function sameJson(a, b) {
|
|
29
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
30
|
+
}
|
|
31
|
+
/** Apply the difference old -> next onto the YAML document, touching only what changed. */
|
|
32
|
+
function applyDiff(doc, at, old, next) {
|
|
33
|
+
if (sameJson(old, next))
|
|
34
|
+
return;
|
|
35
|
+
if (isPlainObject(old) && isPlainObject(next)) {
|
|
36
|
+
for (const key of Object.keys(old)) {
|
|
37
|
+
if (!(key in next))
|
|
38
|
+
doc.deleteIn([...at, key]);
|
|
39
|
+
}
|
|
40
|
+
for (const key of Object.keys(next)) {
|
|
41
|
+
if (key in old)
|
|
42
|
+
applyDiff(doc, [...at, key], old[key], next[key]);
|
|
43
|
+
else
|
|
44
|
+
doc.setIn([...at, key], next[key]);
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (Array.isArray(old) && Array.isArray(next) && old.length === next.length) {
|
|
49
|
+
next.forEach((item, i) => applyDiff(doc, [...at, i], old[i], item));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (at.length === 0)
|
|
53
|
+
doc.contents = doc.createNode(next);
|
|
54
|
+
else
|
|
55
|
+
doc.setIn(at, next);
|
|
56
|
+
}
|
|
57
|
+
function setSpecVersion(doc, version) {
|
|
58
|
+
const root = doc.contents;
|
|
59
|
+
if (!isMap(root))
|
|
60
|
+
return;
|
|
61
|
+
if (root.has("spec_version")) {
|
|
62
|
+
root.set("spec_version", version);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// Insert as the first key. A comment sitting directly above the old first key
|
|
66
|
+
// is the file's header comment; carry it onto the new pair so it stays on top.
|
|
67
|
+
const first = root.items[0];
|
|
68
|
+
const pair = doc.createPair("spec_version", version);
|
|
69
|
+
const firstKey = first?.key;
|
|
70
|
+
if (firstKey && firstKey.commentBefore) {
|
|
71
|
+
pair.key.commentBefore = firstKey.commentBefore;
|
|
72
|
+
firstKey.commentBefore = undefined;
|
|
73
|
+
}
|
|
74
|
+
root.items.unshift(pair);
|
|
75
|
+
}
|
|
76
|
+
export function upgradeWorkflowFile(filePath, options = {}) {
|
|
77
|
+
const config = options.migration ?? DEFAULT_MIGRATION_CONFIG;
|
|
78
|
+
let content;
|
|
79
|
+
try {
|
|
80
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
return { status: "error", message: `Could not read "${filePath}": ${err instanceof Error ? err.message : err}` };
|
|
84
|
+
}
|
|
85
|
+
const isYaml = [".yaml", ".yml"].includes(path.extname(filePath).toLowerCase());
|
|
86
|
+
let doc;
|
|
87
|
+
let raw;
|
|
88
|
+
try {
|
|
89
|
+
if (isYaml) {
|
|
90
|
+
doc = parseDocument(content);
|
|
91
|
+
if (doc.errors.length > 0)
|
|
92
|
+
throw doc.errors[0];
|
|
93
|
+
raw = doc.toJS();
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
raw = JSON.parse(content);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
return { status: "error", message: `Could not parse "${filePath}": ${err instanceof Error ? err.message : err}` };
|
|
101
|
+
}
|
|
102
|
+
if (!isPlainObject(raw))
|
|
103
|
+
return { status: "error", message: `Expected a workflow object in "${filePath}"` };
|
|
104
|
+
const migrated = migrateWorkflow(raw, config);
|
|
105
|
+
if (!migrated.ok)
|
|
106
|
+
return { status: "error", message: migrated.message };
|
|
107
|
+
const versionText = String(migrated.to);
|
|
108
|
+
if (migrated.applied.length === 0 && raw.spec_version === versionText) {
|
|
109
|
+
return { status: "current", version: migrated.to };
|
|
110
|
+
}
|
|
111
|
+
// Never write a file that would not load.
|
|
112
|
+
const check = validateParsed(migrated.workflow, { migration: config });
|
|
113
|
+
if (!check.ok) {
|
|
114
|
+
return {
|
|
115
|
+
status: "error",
|
|
116
|
+
message: `The upgraded workflow is not valid, so "${filePath}" was left unchanged:\n${check.errors
|
|
117
|
+
.map((e) => ` ${e.message}`)
|
|
118
|
+
.join("\n")}`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
let out;
|
|
122
|
+
if (doc) {
|
|
123
|
+
applyDiff(doc, [], withoutSpecVersion(raw), withoutSpecVersion(migrated.workflow));
|
|
124
|
+
setSpecVersion(doc, versionText);
|
|
125
|
+
out = doc.toString();
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
out = JSON.stringify({ spec_version: versionText, ...withoutSpecVersion(migrated.workflow) }, null, 2) + "\n";
|
|
129
|
+
}
|
|
130
|
+
if (!options.dryRun)
|
|
131
|
+
fs.writeFileSync(filePath, out, "utf-8");
|
|
132
|
+
return {
|
|
133
|
+
status: options.dryRun ? "dry-run" : "upgraded",
|
|
134
|
+
from: migrated.from,
|
|
135
|
+
to: migrated.to,
|
|
136
|
+
steps: migrated.applied.map((m) => m.description),
|
|
137
|
+
content: out,
|
|
138
|
+
};
|
|
139
|
+
}
|
package/dist/executor.js
CHANGED
|
@@ -16,6 +16,7 @@ import { consoleLogger } from "./types.js";
|
|
|
16
16
|
import { resolveSources } from "./source-resolver.js";
|
|
17
17
|
import { evaluateAll, aggregateEval } from "./eval/index.js";
|
|
18
18
|
import { evaluateRequires } from "./requires.js";
|
|
19
|
+
import { evaluateExpression, isWhenExpression, parseExpression, whenLabel } from "./when.js";
|
|
19
20
|
import { buildRetryPreamble } from "./retry.js";
|
|
20
21
|
import { resolveExecutionModel } from "./model.js";
|
|
21
22
|
import { buildToolAliases } from "./skills/index.js";
|
|
@@ -1118,15 +1119,17 @@ function resolveConfig(skills, overrides, env = process.env) {
|
|
|
1118
1119
|
* Returns the next node ID, or null when execution should stop.
|
|
1119
1120
|
*/
|
|
1120
1121
|
async function advanceFromNode(workflow, currentId, results, input, claude, observer, edgeCounts, logger, trace, abort) {
|
|
1121
|
-
// Dry run path gate: stop at the first
|
|
1122
|
+
// Dry run path gate: stop at the first natural-language routing decision.
|
|
1122
1123
|
// Safety does not depend on this (#380): under dry-run every node already
|
|
1123
|
-
// runs read-only (see execute()). The stop keeps
|
|
1124
|
-
//
|
|
1125
|
-
//
|
|
1126
|
-
//
|
|
1124
|
+
// runs read-only (see execute()). The stop keeps dry-run routing free of
|
|
1125
|
+
// model route evaluation, so the path is reproducible from the node outputs
|
|
1126
|
+
// alone. Expression edges (#461) are evaluated by sweny with no model call,
|
|
1127
|
+
// so dry-run follows them: a preview can reach the nodes past a
|
|
1128
|
+
// deterministic branch (still read-only, outputs staged) and stops only
|
|
1129
|
+
// where a model would have to pick the branch.
|
|
1127
1130
|
if (isDryRunInput(input)) {
|
|
1128
1131
|
const outEdges = workflow.edges.filter((e) => e.from === currentId);
|
|
1129
|
-
if (outEdges.some((e) => e.when)) {
|
|
1132
|
+
if (outEdges.some((e) => e.when && !isWhenExpression(e.when))) {
|
|
1130
1133
|
safeObserve(observer, { type: "route", from: currentId, to: "(end)", reason: "dry run" }, logger);
|
|
1131
1134
|
return null;
|
|
1132
1135
|
}
|
|
@@ -1134,7 +1137,7 @@ async function advanceFromNode(workflow, currentId, results, input, claude, obse
|
|
|
1134
1137
|
const prevId = currentId;
|
|
1135
1138
|
const nextId = await resolveNext(workflow, currentId, results, input, claude, observer, edgeCounts, logger, abort);
|
|
1136
1139
|
if (nextId) {
|
|
1137
|
-
const reason = workflow.edges.find((e) => e.from === prevId && e.to === nextId)?.when ?? "only path";
|
|
1140
|
+
const reason = whenLabel(workflow.edges.find((e) => e.from === prevId && e.to === nextId)?.when) ?? "only path";
|
|
1138
1141
|
trace.edges.push({ from: prevId, to: nextId, reason });
|
|
1139
1142
|
}
|
|
1140
1143
|
return nextId;
|
|
@@ -1144,7 +1147,8 @@ async function advanceFromNode(workflow, currentId, results, input, claude, obse
|
|
|
1144
1147
|
*
|
|
1145
1148
|
* - 0 out-edges → terminal (return null)
|
|
1146
1149
|
* - 1 unconditional edge → follow it
|
|
1147
|
-
* -
|
|
1150
|
+
* - Every conditional edge is an `{ expr }` expression → sweny evaluates them, no model call
|
|
1151
|
+
* - Otherwise multiple or conditional → Claude evaluates
|
|
1148
1152
|
*
|
|
1149
1153
|
* Edges with max_iterations are filtered out once exhausted.
|
|
1150
1154
|
*/
|
|
@@ -1192,6 +1196,12 @@ async function resolveNext(workflow, current, results, input, claude, observer,
|
|
|
1192
1196
|
safeObserve(observer, { type: "route", from: current, to: defaultEdge.to, reason: "only path" }, logger);
|
|
1193
1197
|
return defaultEdge.to;
|
|
1194
1198
|
}
|
|
1199
|
+
// Deterministic routing (#461): every conditional out-edge is an expression,
|
|
1200
|
+
// so sweny decides without a model call. validateWorkflow rejects a node
|
|
1201
|
+
// that mixes expression and natural-language edges.
|
|
1202
|
+
if (conditionalEdges.every((e) => isWhenExpression(e.when))) {
|
|
1203
|
+
return resolveByExpressions(workflow, current, results, conditionalEdges, defaultEdge, observer, edgeCounts, logger);
|
|
1204
|
+
}
|
|
1195
1205
|
// Claude evaluates which condition matches. Include input so conditions
|
|
1196
1206
|
// can reference workflow-level flags like dryRun, and expose evals so
|
|
1197
1207
|
// routing edges can read `priorNode.evals.X.pass`.
|
|
@@ -1233,7 +1243,7 @@ async function resolveNext(workflow, current, results, input, claude, observer,
|
|
|
1233
1243
|
}
|
|
1234
1244
|
const choices = conditionalEdges.map((e) => ({
|
|
1235
1245
|
id: e.to,
|
|
1236
|
-
description: e.when,
|
|
1246
|
+
description: whenLabel(e.when),
|
|
1237
1247
|
}));
|
|
1238
1248
|
if (defaultEdge) {
|
|
1239
1249
|
choices.push({ id: defaultEdge.to, description: "None of the above / default path" });
|
|
@@ -1306,6 +1316,76 @@ async function resolveNext(workflow, current, results, input, claude, observer,
|
|
|
1306
1316
|
}, logger);
|
|
1307
1317
|
return resolved;
|
|
1308
1318
|
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Pick the out-edge whose `{ expr }` condition is true, with no model call
|
|
1321
|
+
* (#461). Exactly one true edge is taken. None true takes the unconditional
|
|
1322
|
+
* default edge when there is one. Otherwise (no edge true and no default, or
|
|
1323
|
+
* two or more true) it fails closed with a RouteEvaluationError, the same
|
|
1324
|
+
* contract as a failed model route evaluation: sweny never guesses an edge.
|
|
1325
|
+
*
|
|
1326
|
+
* An expression reads only successful nodes' outputs. A missing field, or a
|
|
1327
|
+
* node that did not run or did not succeed, makes that expression false and
|
|
1328
|
+
* is logged as a warning, never a silent true.
|
|
1329
|
+
*/
|
|
1330
|
+
function resolveByExpressions(workflow, current, results, conditionalEdges, defaultEdge, observer, edgeCounts, logger) {
|
|
1331
|
+
const scope = {};
|
|
1332
|
+
for (const [id, r] of results.entries()) {
|
|
1333
|
+
if (r.status === "success")
|
|
1334
|
+
scope[id] = buildPriorNodeContext(r);
|
|
1335
|
+
}
|
|
1336
|
+
const matched = [];
|
|
1337
|
+
for (const edge of conditionalEdges) {
|
|
1338
|
+
const expr = whenLabel(edge.when);
|
|
1339
|
+
let outcome;
|
|
1340
|
+
try {
|
|
1341
|
+
outcome = evaluateExpression(parseExpression(expr), scope);
|
|
1342
|
+
}
|
|
1343
|
+
catch (err) {
|
|
1344
|
+
// validateWorkflow parses every expression before a run, so this only
|
|
1345
|
+
// fires for a workflow that skipped validation. Fail closed.
|
|
1346
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1347
|
+
safeObserve(observer, { type: "route", from: current, to: "(end)", reason: `invalid when expression` }, logger);
|
|
1348
|
+
throw new RouteEvaluationError(current, `invalid when expression on edge '${current}' -> '${edge.to}': ${msg}`);
|
|
1349
|
+
}
|
|
1350
|
+
if (outcome.problem) {
|
|
1351
|
+
logger?.warn(` route expr: '${current}' -> '${edge.to}' is false: ${outcome.problem} (${expr})`, {
|
|
1352
|
+
node: current,
|
|
1353
|
+
to: edge.to,
|
|
1354
|
+
});
|
|
1355
|
+
safeObserve(observer, {
|
|
1356
|
+
type: "node:warning",
|
|
1357
|
+
node: current,
|
|
1358
|
+
reason: `when expression on edge to '${edge.to}' evaluated false: ${outcome.problem}`,
|
|
1359
|
+
fields: [],
|
|
1360
|
+
}, logger);
|
|
1361
|
+
}
|
|
1362
|
+
if (outcome.value)
|
|
1363
|
+
matched.push({ to: edge.to, expr });
|
|
1364
|
+
}
|
|
1365
|
+
const take = (to, reason) => {
|
|
1366
|
+
if (edgeCounts) {
|
|
1367
|
+
const key = `${current}→${to}`;
|
|
1368
|
+
edgeCounts.set(key, (edgeCounts.get(key) ?? 0) + 1);
|
|
1369
|
+
}
|
|
1370
|
+
safeObserve(observer, { type: "route", from: current, to, reason }, logger);
|
|
1371
|
+
return to;
|
|
1372
|
+
};
|
|
1373
|
+
if (matched.length === 1)
|
|
1374
|
+
return take(matched[0].to, matched[0].expr);
|
|
1375
|
+
if (matched.length === 0 && defaultEdge) {
|
|
1376
|
+
logger?.info(` route expr: no expression matched for '${current}'; taking default edge '${defaultEdge.to}'`, {
|
|
1377
|
+
node: current,
|
|
1378
|
+
});
|
|
1379
|
+
return take(defaultEdge.to, "no expression matched; default edge");
|
|
1380
|
+
}
|
|
1381
|
+
const why = matched.length === 0
|
|
1382
|
+
? `no when expression on node '${current}' is true and there is no default (unconditional) edge`
|
|
1383
|
+
: `${matched.length} when expressions on node '${current}' are true (to ${matched.map((m) => m.to).join(", ")}); ` +
|
|
1384
|
+
`exactly one may match`;
|
|
1385
|
+
logger?.error(` route expr: ${why}; refusing to guess. Terminating the run.`, { node: current });
|
|
1386
|
+
safeObserve(observer, { type: "route", from: current, to: "(end)", reason: why }, logger);
|
|
1387
|
+
throw new RouteEvaluationError(current, `${why}; refusing to guess`);
|
|
1388
|
+
}
|
|
1309
1389
|
/**
|
|
1310
1390
|
* Validate a workflow definition before execution.
|
|
1311
1391
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* })
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
|
-
export type { Skill, SkillCategory, SkillDefinition, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, TraceStep, TraceEdge, ExecutionTrace, ExecutionResult, Source, ResolvedSource, SourceKind, SourceResolutionMap, WorkflowInputs, WorkflowInputField, WorkflowInputType, InputValidationError, InputValidationResult, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, NodeOnFail, McpTransport, WorkflowType, SkillHarnessKey, NodeAccess, NodePermissions, NodePermissionsSpec, SafeOutputType, SafeOutputDeclaration, SafeOutputsPolicy, SafeOutputReceipt, SafeOutputPin, AuthorAssociation, } from "./types.js";
|
|
24
|
+
export type { Skill, SkillCategory, SkillDefinition, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, EdgeWhen, WhenExpression, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, TraceStep, TraceEdge, ExecutionTrace, ExecutionResult, Source, ResolvedSource, SourceKind, SourceResolutionMap, WorkflowInputs, WorkflowInputField, WorkflowInputType, InputValidationError, InputValidationResult, Evaluator, EvaluatorRule, EvalResult, EvaluatorKind, NodeRequires, NodeRetry, NodeToolFilter, OutputMatch, NodeSources, EvalPolicy, RequiresOnFail, NodeOnFail, McpTransport, WorkflowType, SkillHarnessKey, NodeAccess, NodePermissions, NodePermissionsSpec, SafeOutputType, SafeOutputDeclaration, SafeOutputsPolicy, SafeOutputReceipt, SafeOutputPin, AuthorAssociation, } from "./types.js";
|
|
25
25
|
export { WORKFLOW_INPUT_TYPES } from "./types.js";
|
|
26
26
|
export { EVALUATOR_KINDS, EVAL_POLICIES, REQUIRES_ON_FAIL, NODE_ON_FAIL, MCP_TRANSPORTS, SKILL_CATEGORIES, SKILL_HARNESSES, SKILL_ID_PATTERN, SKILL_ID_MAX_LENGTH, isValidSkillId, NODE_ACCESS, TOOL_CLASSES, SAFE_OUTPUT_TYPES, SAFE_OUTPUT_APPLIERS, AUTHOR_ASSOCIATIONS, } from "./types.js";
|
|
27
27
|
export { consoleLogger } from "./types.js";
|
|
@@ -43,8 +43,12 @@ export type { SkillValidationResult } from "./skills/index.js";
|
|
|
43
43
|
export { loadCustomSkills, discoverSkills, configuredSkills } from "./skills/custom-loader.js";
|
|
44
44
|
export { workflowZ, nodeZ, edgeZ, skillZ, mcpServerConfigZ, skillDefinitionZ, sourceZ, workflowInputsZ, parseWorkflow, validateWorkflow, workflowJsonSchema, skillJsonSchema, } from "./schema.js";
|
|
45
45
|
export type { WorkflowError } from "./schema.js";
|
|
46
|
+
export { isWhenExpression, whenLabel, parseExpression, evaluateExpression, checkExpression, ExpressionSyntaxError, } from "./when.js";
|
|
47
|
+
export type { ExprNode, ExpressionScope, ExpressionResult } from "./when.js";
|
|
46
48
|
export { loadAndValidateWorkflow, validateParsed } from "./loader.js";
|
|
47
49
|
export type { LoaderResult, LoaderError, LoaderOptions } from "./loader.js";
|
|
50
|
+
export { CURRENT_SPEC_VERSION, MIGRATIONS, migrateWorkflow } from "./migrations.js";
|
|
51
|
+
export type { Migration, MigrationConfig, MigrateResult } from "./migrations.js";
|
|
48
52
|
export { validateRuntimeInput, summarizeInputShape } from "./inputs.js";
|
|
49
53
|
export { buildAutoMcpServers, buildSkillMcpServers, buildProviderContext } from "./mcp.js";
|
|
50
54
|
export type { ProviderContextOptions, SkillMcpOptions } from "./mcp.js";
|
package/dist/index.js
CHANGED
|
@@ -43,10 +43,13 @@ export { github, linear, slack, sentry, datadog, notification, supabase, builtin
|
|
|
43
43
|
export { loadCustomSkills, discoverSkills, configuredSkills } from "./skills/custom-loader.js";
|
|
44
44
|
// Schema & validation
|
|
45
45
|
export { workflowZ, nodeZ, edgeZ, skillZ, mcpServerConfigZ, skillDefinitionZ, sourceZ, workflowInputsZ, parseWorkflow, validateWorkflow, workflowJsonSchema, skillJsonSchema, } from "./schema.js";
|
|
46
|
+
// Deterministic `when` expressions (#461). Pure, browser-safe.
|
|
47
|
+
export { isWhenExpression, whenLabel, parseExpression, evaluateExpression, checkExpression, ExpressionSyntaxError, } from "./when.js";
|
|
46
48
|
// Loader — canonical read → parse → structural-validate pipeline (the path
|
|
47
49
|
// the CLI uses for `workflow run`, `workflow validate`, and `publish`).
|
|
48
50
|
// Node-only: loader.ts imports `node:fs`, so this is NOT mirrored in browser.ts.
|
|
49
51
|
export { loadAndValidateWorkflow, validateParsed } from "./loader.js";
|
|
52
|
+
export { CURRENT_SPEC_VERSION, MIGRATIONS, migrateWorkflow } from "./migrations.js";
|
|
50
53
|
// Workflow input validation
|
|
51
54
|
export { validateRuntimeInput, summarizeInputShape } from "./inputs.js";
|
|
52
55
|
// MCP auto-injection
|
package/dist/loader.d.ts
CHANGED
|
@@ -11,15 +11,18 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { Workflow } from "./types.js";
|
|
13
13
|
import { type WorkflowError } from "./schema.js";
|
|
14
|
+
import { type MigrationConfig } from "./migrations.js";
|
|
14
15
|
/** Structural/schema error. `code` is present only for structural errors. */
|
|
15
16
|
export interface LoaderError {
|
|
16
17
|
message: string;
|
|
17
|
-
code?: WorkflowError["code"] | "IO" | "PARSE" | "SCHEMA";
|
|
18
|
+
code?: WorkflowError["code"] | "IO" | "PARSE" | "SCHEMA" | "SPEC_VERSION";
|
|
18
19
|
nodeId?: string;
|
|
19
20
|
}
|
|
20
21
|
export type LoaderResult = {
|
|
21
22
|
ok: true;
|
|
22
23
|
workflow: Workflow;
|
|
24
|
+
/** Non-fatal notes, e.g. the one-line "migrated in memory" warning. */
|
|
25
|
+
warnings?: string[];
|
|
23
26
|
} | {
|
|
24
27
|
ok: false;
|
|
25
28
|
errors: LoaderError[];
|
|
@@ -27,6 +30,10 @@ export type LoaderResult = {
|
|
|
27
30
|
export interface LoaderOptions {
|
|
28
31
|
/** Optional set of known skill IDs for UNKNOWN_SKILL validation. */
|
|
29
32
|
knownSkills?: Set<string>;
|
|
33
|
+
/** Spec version + migration registry. Defaults to the built-in one; tests inject their own. */
|
|
34
|
+
migration?: MigrationConfig;
|
|
35
|
+
/** Path shown in the migration warning. Set by loadAndValidateWorkflow. */
|
|
36
|
+
filePath?: string;
|
|
30
37
|
}
|
|
31
38
|
/**
|
|
32
39
|
* Load a workflow YAML or JSON file, parse with the canonical Zod schema,
|
package/dist/loader.js
CHANGED
|
@@ -14,6 +14,7 @@ import * as path from "node:path";
|
|
|
14
14
|
import { parse as parseYaml } from "yaml";
|
|
15
15
|
import { ZodError } from "zod";
|
|
16
16
|
import { workflowZ, validateWorkflow, preflightLegacyVerify } from "./schema.js";
|
|
17
|
+
import { DEFAULT_MIGRATION_CONFIG, migrateWorkflow, migrationWarning, } from "./migrations.js";
|
|
17
18
|
/**
|
|
18
19
|
* Load a workflow YAML or JSON file, parse with the canonical Zod schema,
|
|
19
20
|
* and run structural validation. All three phases contribute to `errors`.
|
|
@@ -36,7 +37,7 @@ export function loadAndValidateWorkflow(filePath, options = {}) {
|
|
|
36
37
|
const msg = err instanceof Error ? err.message : String(err);
|
|
37
38
|
return { ok: false, errors: [{ message: `Could not parse "${filePath}": ${msg}`, code: "PARSE" }] };
|
|
38
39
|
}
|
|
39
|
-
return validateParsed(raw, options);
|
|
40
|
+
return validateParsed(raw, { ...options, filePath: options.filePath ?? filePath });
|
|
40
41
|
}
|
|
41
42
|
/**
|
|
42
43
|
* Validate an already-parsed workflow object (e.g. from a marketplace fetch
|
|
@@ -47,6 +48,15 @@ export function validateParsed(raw, options = {}) {
|
|
|
47
48
|
const kind = raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw;
|
|
48
49
|
return { ok: false, errors: [{ message: `Expected a workflow object, got ${kind}`, code: "SCHEMA" }] };
|
|
49
50
|
}
|
|
51
|
+
// Apply spec_version migrations in memory (older versions), or refuse a
|
|
52
|
+
// version newer than this build supports. Runs first so a migration can
|
|
53
|
+
// reshape a file before the checks below see it.
|
|
54
|
+
const migrated = migrateWorkflow(raw, options.migration ?? DEFAULT_MIGRATION_CONFIG);
|
|
55
|
+
if (!migrated.ok) {
|
|
56
|
+
return { ok: false, errors: [{ message: migrated.message, code: "SPEC_VERSION" }] };
|
|
57
|
+
}
|
|
58
|
+
raw = migrated.workflow;
|
|
59
|
+
const warnings = migrated.applied.length > 0 ? [migrationWarning(migrated.from, migrated.to, options.filePath)] : [];
|
|
50
60
|
// Detect legacy `verify:` blocks (renamed to `eval:` in v0.2.0) before the
|
|
51
61
|
// Zod parse. workflowZ is intentionally non-strict at the top level, so a
|
|
52
62
|
// top-level `verify:` would be silently STRIPPED instead of surfacing the
|
|
@@ -83,5 +93,5 @@ export function validateParsed(raw, options = {}) {
|
|
|
83
93
|
errors: structural.map((e) => ({ message: e.message, code: e.code, nodeId: e.nodeId })),
|
|
84
94
|
};
|
|
85
95
|
}
|
|
86
|
-
return { ok: true, workflow: parsed };
|
|
96
|
+
return warnings.length > 0 ? { ok: true, workflow: parsed, warnings } : { ok: true, workflow: parsed };
|
|
87
97
|
}
|
package/dist/mermaid.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* (current/success/failed/skipped) and optional execution trace
|
|
10
10
|
* to highlight which edges were taken and show loop iterations.
|
|
11
11
|
*/
|
|
12
|
+
import { whenLabel } from "./when.js";
|
|
12
13
|
/**
|
|
13
14
|
* Convert a Workflow to a Mermaid flowchart diagram string.
|
|
14
15
|
*
|
|
@@ -102,8 +103,9 @@ export function toMermaid(workflow, options = {}) {
|
|
|
102
103
|
const takenCount = edgeTakenCounts.get(key) ?? 0;
|
|
103
104
|
// Build label parts
|
|
104
105
|
const labelParts = [];
|
|
105
|
-
|
|
106
|
-
|
|
106
|
+
const when = whenLabel(edge.when);
|
|
107
|
+
if (when)
|
|
108
|
+
labelParts.push(when);
|
|
107
109
|
if (edge.max_iterations)
|
|
108
110
|
labelParts.push(`max ${edge.max_iterations}x`);
|
|
109
111
|
// Show how many times this edge was actually followed
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow spec versioning and migrations.
|
|
3
|
+
*
|
|
4
|
+
* Every workflow file carries an optional top-level `spec_version` (a string
|
|
5
|
+
* holding a positive integer, e.g. "1"). A file without the field is version 1:
|
|
6
|
+
* that is what every file written before the field existed means.
|
|
7
|
+
*
|
|
8
|
+
* Breaking spec changes bump CURRENT_SPEC_VERSION and add one entry to
|
|
9
|
+
* MIGRATIONS: a pure function from the raw (unvalidated) vN object to the raw
|
|
10
|
+
* vN+1 object. The loader applies the chain in memory so old files keep
|
|
11
|
+
* running; `sweny workflow upgrade <file>` writes the result back to disk.
|
|
12
|
+
*
|
|
13
|
+
* Adding a migration: see `__tests__/migrations.test.ts` for the harness
|
|
14
|
+
* (registry validity check plus a chain test to copy).
|
|
15
|
+
*/
|
|
16
|
+
/** A raw, not-yet-validated workflow object as parsed from YAML or JSON. */
|
|
17
|
+
export type RawWorkflow = Record<string, unknown>;
|
|
18
|
+
export interface Migration {
|
|
19
|
+
/** Version this migration reads. */
|
|
20
|
+
from: number;
|
|
21
|
+
/** Version it produces. Always `from + 1`. */
|
|
22
|
+
to: number;
|
|
23
|
+
/** One line shown by `sweny workflow upgrade`. */
|
|
24
|
+
description: string;
|
|
25
|
+
/** Pure: must not mutate its input. Returns the next-version object. */
|
|
26
|
+
migrate: (raw: RawWorkflow) => RawWorkflow;
|
|
27
|
+
}
|
|
28
|
+
/** The spec version this build of sweny reads natively. */
|
|
29
|
+
export declare const CURRENT_SPEC_VERSION = 1;
|
|
30
|
+
/**
|
|
31
|
+
* Ordered registry, one entry per version step. Empty while v1 is current.
|
|
32
|
+
* Append only; never edit a shipped migration.
|
|
33
|
+
*/
|
|
34
|
+
export declare const MIGRATIONS: readonly Migration[];
|
|
35
|
+
export interface MigrationConfig {
|
|
36
|
+
current: number;
|
|
37
|
+
migrations: readonly Migration[];
|
|
38
|
+
}
|
|
39
|
+
export declare const DEFAULT_MIGRATION_CONFIG: MigrationConfig;
|
|
40
|
+
export type MigrateError = {
|
|
41
|
+
code: "INVALID_VERSION" | "TOO_NEW" | "MIGRATION_FAILED";
|
|
42
|
+
message: string;
|
|
43
|
+
};
|
|
44
|
+
export type MigrateResult = {
|
|
45
|
+
ok: true;
|
|
46
|
+
/** The (possibly migrated) object. `spec_version` is a string when it was declared or migrated. */
|
|
47
|
+
workflow: RawWorkflow;
|
|
48
|
+
/** Version the file declared (1 when absent). */
|
|
49
|
+
from: number;
|
|
50
|
+
/** Version of `workflow` after migration. */
|
|
51
|
+
to: number;
|
|
52
|
+
/** Whether the input declared `spec_version`. */
|
|
53
|
+
declared: boolean;
|
|
54
|
+
/** Migrations applied, in order. Empty when the file is already current. */
|
|
55
|
+
applied: Migration[];
|
|
56
|
+
} | ({
|
|
57
|
+
ok: false;
|
|
58
|
+
} & MigrateError);
|
|
59
|
+
/** Throws if a registry is not an unbroken chain of single steps ending at `current`. */
|
|
60
|
+
export declare function assertRegistryValid(config: MigrationConfig): void;
|
|
61
|
+
export declare function migrateWorkflow(raw: RawWorkflow, config?: MigrationConfig): MigrateResult;
|
|
62
|
+
/** The single warning the loader emits when it migrated in memory. */
|
|
63
|
+
export declare function migrationWarning(from: number, to: number, filePath?: string): string;
|