@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
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
/** The spec version this build of sweny reads natively. */
|
|
17
|
+
export const CURRENT_SPEC_VERSION = 1;
|
|
18
|
+
/**
|
|
19
|
+
* Ordered registry, one entry per version step. Empty while v1 is current.
|
|
20
|
+
* Append only; never edit a shipped migration.
|
|
21
|
+
*/
|
|
22
|
+
export const MIGRATIONS = [];
|
|
23
|
+
export const DEFAULT_MIGRATION_CONFIG = {
|
|
24
|
+
current: CURRENT_SPEC_VERSION,
|
|
25
|
+
migrations: MIGRATIONS,
|
|
26
|
+
};
|
|
27
|
+
/** Throws if a registry is not an unbroken chain of single steps ending at `current`. */
|
|
28
|
+
export function assertRegistryValid(config) {
|
|
29
|
+
const { current, migrations } = config;
|
|
30
|
+
if (!Number.isInteger(current) || current < 1)
|
|
31
|
+
throw new Error("current spec version must be a positive integer");
|
|
32
|
+
if (migrations.length !== current - 1) {
|
|
33
|
+
throw new Error(`expected ${current - 1} migration(s) to reach v${current}, found ${migrations.length}`);
|
|
34
|
+
}
|
|
35
|
+
migrations.forEach((m, i) => {
|
|
36
|
+
if (m.from !== i + 1 || m.to !== i + 2) {
|
|
37
|
+
throw new Error(`migration #${i} must be v${i + 1} -> v${i + 2}, got v${m.from} -> v${m.to}`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function parseVersion(value) {
|
|
42
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 1)
|
|
43
|
+
return value;
|
|
44
|
+
if (typeof value === "string" && /^[1-9]\d*$/.test(value))
|
|
45
|
+
return Number(value);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
export function migrateWorkflow(raw, config = DEFAULT_MIGRATION_CONFIG) {
|
|
49
|
+
const declared = "spec_version" in raw && raw.spec_version !== undefined;
|
|
50
|
+
const from = declared ? parseVersion(raw.spec_version) : 1;
|
|
51
|
+
if (from === null) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
code: "INVALID_VERSION",
|
|
55
|
+
message: `spec_version must be a positive integer string like "1", got ${JSON.stringify(raw.spec_version)}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (from > config.current) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
code: "TOO_NEW",
|
|
62
|
+
message: `This workflow declares spec_version "${from}", but this version of sweny supports up to "${config.current}". ` +
|
|
63
|
+
`Upgrade sweny (run "sweny upgrade", or npm install -g @sweny-ai/core@latest) and try again.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
let current = declared ? { ...raw, spec_version: String(from) } : raw;
|
|
67
|
+
const applied = [];
|
|
68
|
+
for (let v = from; v < config.current; v++) {
|
|
69
|
+
const step = config.migrations.find((m) => m.from === v);
|
|
70
|
+
if (!step) {
|
|
71
|
+
return { ok: false, code: "MIGRATION_FAILED", message: `No migration registered from spec_version "${v}".` };
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
current = { ...step.migrate(current), spec_version: String(step.to) };
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
code: "MIGRATION_FAILED",
|
|
81
|
+
message: `Migration from spec_version "${step.from}" to "${step.to}" failed: ${msg}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
applied.push(step);
|
|
85
|
+
}
|
|
86
|
+
return { ok: true, workflow: current, from, to: config.current, declared, applied };
|
|
87
|
+
}
|
|
88
|
+
/** The single warning the loader emits when it migrated in memory. */
|
|
89
|
+
export function migrationWarning(from, to, filePath) {
|
|
90
|
+
const target = filePath ? `"${filePath}"` : "<file>";
|
|
91
|
+
return (`Workflow uses spec_version "${from}" (current is "${to}"); migrated in memory for this run. ` +
|
|
92
|
+
`Run \`sweny workflow upgrade ${target}\` to update the file.`);
|
|
93
|
+
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { z } from "zod";
|
|
|
10
10
|
import type { Workflow, WorkflowType } from "./types.js";
|
|
11
11
|
import { sourceZ } from "./sources.js";
|
|
12
12
|
import { workflowInputsZ } from "./inputs.js";
|
|
13
|
+
export { isWhenExpression, whenLabel } from "./when.js";
|
|
13
14
|
export { sourceZ };
|
|
14
15
|
export { workflowInputsZ };
|
|
15
16
|
export declare const jsonSchemaZ: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
@@ -1683,17 +1684,27 @@ export declare const nodeZ: z.ZodObject<{
|
|
|
1683
1684
|
export declare const edgeZ: z.ZodObject<{
|
|
1684
1685
|
from: z.ZodString;
|
|
1685
1686
|
to: z.ZodString;
|
|
1686
|
-
when: z.ZodOptional<z.ZodString
|
|
1687
|
+
when: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodObject<{
|
|
1688
|
+
expr: z.ZodString;
|
|
1689
|
+
}, "strict", z.ZodTypeAny, {
|
|
1690
|
+
expr: string;
|
|
1691
|
+
}, {
|
|
1692
|
+
expr: string;
|
|
1693
|
+
}>]>>;
|
|
1687
1694
|
max_iterations: z.ZodOptional<z.ZodNumber>;
|
|
1688
1695
|
}, "strict", z.ZodTypeAny, {
|
|
1689
1696
|
from: string;
|
|
1690
1697
|
to: string;
|
|
1691
|
-
when?: string |
|
|
1698
|
+
when?: string | {
|
|
1699
|
+
expr: string;
|
|
1700
|
+
} | undefined;
|
|
1692
1701
|
max_iterations?: number | undefined;
|
|
1693
1702
|
}, {
|
|
1694
1703
|
from: string;
|
|
1695
1704
|
to: string;
|
|
1696
|
-
when?: string |
|
|
1705
|
+
when?: string | {
|
|
1706
|
+
expr: string;
|
|
1707
|
+
} | undefined;
|
|
1697
1708
|
max_iterations?: number | undefined;
|
|
1698
1709
|
}>;
|
|
1699
1710
|
/**
|
|
@@ -1714,6 +1725,7 @@ export declare const workflowZ: z.ZodObject<{
|
|
|
1714
1725
|
id: z.ZodString;
|
|
1715
1726
|
name: z.ZodString;
|
|
1716
1727
|
description: z.ZodDefault<z.ZodString>;
|
|
1728
|
+
spec_version: z.ZodOptional<z.ZodString>;
|
|
1717
1729
|
workflow_type: z.ZodOptional<z.ZodEnum<["pr_review", "e2e_test", "content_generation", "monitor", "data_sync", "generic"]>>;
|
|
1718
1730
|
nodes: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
1719
1731
|
name: z.ZodString;
|
|
@@ -2465,17 +2477,27 @@ export declare const workflowZ: z.ZodObject<{
|
|
|
2465
2477
|
edges: z.ZodArray<z.ZodObject<{
|
|
2466
2478
|
from: z.ZodString;
|
|
2467
2479
|
to: z.ZodString;
|
|
2468
|
-
when: z.ZodOptional<z.ZodString
|
|
2480
|
+
when: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodObject<{
|
|
2481
|
+
expr: z.ZodString;
|
|
2482
|
+
}, "strict", z.ZodTypeAny, {
|
|
2483
|
+
expr: string;
|
|
2484
|
+
}, {
|
|
2485
|
+
expr: string;
|
|
2486
|
+
}>]>>;
|
|
2469
2487
|
max_iterations: z.ZodOptional<z.ZodNumber>;
|
|
2470
2488
|
}, "strict", z.ZodTypeAny, {
|
|
2471
2489
|
from: string;
|
|
2472
2490
|
to: string;
|
|
2473
|
-
when?: string |
|
|
2491
|
+
when?: string | {
|
|
2492
|
+
expr: string;
|
|
2493
|
+
} | undefined;
|
|
2474
2494
|
max_iterations?: number | undefined;
|
|
2475
2495
|
}, {
|
|
2476
2496
|
from: string;
|
|
2477
2497
|
to: string;
|
|
2478
|
-
when?: string |
|
|
2498
|
+
when?: string | {
|
|
2499
|
+
expr: string;
|
|
2500
|
+
} | undefined;
|
|
2479
2501
|
max_iterations?: number | undefined;
|
|
2480
2502
|
}>, "many">;
|
|
2481
2503
|
entry: z.ZodString;
|
|
@@ -2845,7 +2867,9 @@ export declare const workflowZ: z.ZodObject<{
|
|
|
2845
2867
|
edges: {
|
|
2846
2868
|
from: string;
|
|
2847
2869
|
to: string;
|
|
2848
|
-
when?: string |
|
|
2870
|
+
when?: string | {
|
|
2871
|
+
expr: string;
|
|
2872
|
+
} | undefined;
|
|
2849
2873
|
max_iterations?: number | undefined;
|
|
2850
2874
|
}[];
|
|
2851
2875
|
entry: string;
|
|
@@ -2876,6 +2900,7 @@ export declare const workflowZ: z.ZodObject<{
|
|
|
2876
2900
|
type?: string | undefined;
|
|
2877
2901
|
})[] | undefined;
|
|
2878
2902
|
judge_model?: string | undefined;
|
|
2903
|
+
spec_version?: string | undefined;
|
|
2879
2904
|
workflow_type?: "pr_review" | "e2e_test" | "content_generation" | "monitor" | "data_sync" | "generic" | undefined;
|
|
2880
2905
|
judge_budget?: number | undefined;
|
|
2881
2906
|
context_mode?: "full" | "bounded" | undefined;
|
|
@@ -3019,7 +3044,9 @@ export declare const workflowZ: z.ZodObject<{
|
|
|
3019
3044
|
edges: {
|
|
3020
3045
|
from: string;
|
|
3021
3046
|
to: string;
|
|
3022
|
-
when?: string |
|
|
3047
|
+
when?: string | {
|
|
3048
|
+
expr: string;
|
|
3049
|
+
} | undefined;
|
|
3023
3050
|
max_iterations?: number | undefined;
|
|
3024
3051
|
}[];
|
|
3025
3052
|
entry: string;
|
|
@@ -3064,6 +3091,7 @@ export declare const workflowZ: z.ZodObject<{
|
|
|
3064
3091
|
type?: string | undefined;
|
|
3065
3092
|
})[] | undefined;
|
|
3066
3093
|
judge_model?: string | undefined;
|
|
3094
|
+
spec_version?: string | undefined;
|
|
3067
3095
|
workflow_type?: "pr_review" | "e2e_test" | "content_generation" | "monitor" | "data_sync" | "generic" | undefined;
|
|
3068
3096
|
judge_budget?: number | undefined;
|
|
3069
3097
|
context_mode?: "full" | "bounded" | undefined;
|
|
@@ -3235,7 +3263,9 @@ export declare function parseWorkflow(raw: unknown): {
|
|
|
3235
3263
|
edges: {
|
|
3236
3264
|
from: string;
|
|
3237
3265
|
to: string;
|
|
3238
|
-
when?: string |
|
|
3266
|
+
when?: string | {
|
|
3267
|
+
expr: string;
|
|
3268
|
+
} | undefined;
|
|
3239
3269
|
max_iterations?: number | undefined;
|
|
3240
3270
|
}[];
|
|
3241
3271
|
entry: string;
|
|
@@ -3266,6 +3296,7 @@ export declare function parseWorkflow(raw: unknown): {
|
|
|
3266
3296
|
type?: string | undefined;
|
|
3267
3297
|
})[] | undefined;
|
|
3268
3298
|
judge_model?: string | undefined;
|
|
3299
|
+
spec_version?: string | undefined;
|
|
3269
3300
|
workflow_type?: "pr_review" | "e2e_test" | "content_generation" | "monitor" | "data_sync" | "generic" | undefined;
|
|
3270
3301
|
judge_budget?: number | undefined;
|
|
3271
3302
|
context_mode?: "full" | "bounded" | undefined;
|
|
@@ -3286,7 +3317,7 @@ export declare function parseWorkflow(raw: unknown): {
|
|
|
3286
3317
|
} | undefined;
|
|
3287
3318
|
};
|
|
3288
3319
|
export interface WorkflowError {
|
|
3289
|
-
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" | "PERMISSION_CEILING" | "BUDGET_CEILING" | "OUTPUT_NOT_ALLOWED" | "DUPLICATE_OUTPUT" | "UNSUPPORTED_OUTPUT";
|
|
3320
|
+
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" | "PERMISSION_CEILING" | "BUDGET_CEILING" | "OUTPUT_NOT_ALLOWED" | "DUPLICATE_OUTPUT" | "UNSUPPORTED_OUTPUT" | "INVALID_WHEN_EXPRESSION" | "MIXED_EDGE_CONDITIONS";
|
|
3290
3321
|
message: string;
|
|
3291
3322
|
nodeId?: string;
|
|
3292
3323
|
}
|
|
@@ -3649,6 +3680,11 @@ export declare const workflowJsonSchema: {
|
|
|
3649
3680
|
readonly description: {
|
|
3650
3681
|
readonly type: "string";
|
|
3651
3682
|
};
|
|
3683
|
+
readonly spec_version: {
|
|
3684
|
+
readonly type: "string";
|
|
3685
|
+
readonly pattern: "^[1-9][0-9]*$";
|
|
3686
|
+
readonly description: "Workflow spec version as a positive integer string. Absent means 1. Older versions are migrated in memory on load; newer than the CLI supports is refused. Run `sweny workflow upgrade <file>` to rewrite a file at the current version.";
|
|
3687
|
+
};
|
|
3652
3688
|
readonly entry: {
|
|
3653
3689
|
readonly type: "string";
|
|
3654
3690
|
readonly minLength: 1;
|
|
@@ -4090,8 +4126,22 @@ export declare const workflowJsonSchema: {
|
|
|
4090
4126
|
readonly minLength: 1;
|
|
4091
4127
|
};
|
|
4092
4128
|
readonly when: {
|
|
4093
|
-
readonly
|
|
4094
|
-
readonly
|
|
4129
|
+
readonly description: "Condition for taking this edge. A string is natural language, evaluated at runtime by the workflow's harness. An object { expr } is a deterministic expression over prior nodes' declared output fields, evaluated by sweny with no model call.";
|
|
4130
|
+
readonly oneOf: readonly [{
|
|
4131
|
+
readonly type: "string";
|
|
4132
|
+
}, {
|
|
4133
|
+
readonly type: "object";
|
|
4134
|
+
readonly required: readonly ["expr"];
|
|
4135
|
+
readonly additionalProperties: false;
|
|
4136
|
+
readonly properties: {
|
|
4137
|
+
readonly expr: {
|
|
4138
|
+
readonly type: "string";
|
|
4139
|
+
readonly minLength: 1;
|
|
4140
|
+
readonly maxLength: 2000;
|
|
4141
|
+
readonly description: "Boolean expression: == != < <= > >= in, && || !, exists, string/number/boolean/null literals, and node.field paths to declared output fields.";
|
|
4142
|
+
};
|
|
4143
|
+
};
|
|
4144
|
+
}];
|
|
4095
4145
|
};
|
|
4096
4146
|
readonly max_iterations: {
|
|
4097
4147
|
readonly type: "integer";
|
package/dist/schema.js
CHANGED
|
@@ -10,6 +10,9 @@ import { z } from "zod";
|
|
|
10
10
|
import { AUTHOR_ASSOCIATIONS, CONTEXT_MODES, EVALUATOR_KINDS, EVAL_POLICIES, MCP_TRANSPORTS, NODE_ACCESS, NODE_ON_FAIL, REQUIRES_ON_FAIL, SAFE_OUTPUT_APPLIERS, SAFE_OUTPUT_EXPIRES_PATTERN, SAFE_OUTPUT_MAX_CEILING, SAFE_OUTPUT_STATES, SAFE_OUTPUT_TYPES, SKILL_CATEGORIES, SKILL_ID_MAX_LENGTH, SKILL_ID_PATTERN, TOOL_ACCESS, TOOL_CLASSES, WORKFLOW_TYPES, } from "./types.js";
|
|
11
11
|
import { sourceZ } from "./sources.js";
|
|
12
12
|
import { workflowInputsZ, WORKFLOW_INPUT_TYPES } from "./inputs.js";
|
|
13
|
+
import { checkExpression, isWhenExpression, WHEN_EXPR_MAX_LENGTH } from "./when.js";
|
|
14
|
+
// Studio and other schema-only consumers label edges without importing the root entry.
|
|
15
|
+
export { isWhenExpression, whenLabel } from "./when.js";
|
|
13
16
|
export { sourceZ };
|
|
14
17
|
export { workflowInputsZ };
|
|
15
18
|
// ─── Zod Schemas ─────────────────────────────────────────────────
|
|
@@ -330,7 +333,9 @@ export const edgeZ = z
|
|
|
330
333
|
.object({
|
|
331
334
|
from: z.string().min(1),
|
|
332
335
|
to: z.string().min(1),
|
|
333
|
-
|
|
336
|
+
// Natural language (the harness picks the edge) or `{ expr }`, a
|
|
337
|
+
// deterministic expression sweny evaluates itself (#461). One or the other.
|
|
338
|
+
when: z.union([z.string(), z.object({ expr: z.string().min(1).max(WHEN_EXPR_MAX_LENGTH) }).strict()]).optional(),
|
|
334
339
|
max_iterations: z.number().int().min(1).max(EDGE_MAX_ITERATIONS_CEILING).optional(),
|
|
335
340
|
})
|
|
336
341
|
.strict();
|
|
@@ -357,6 +362,10 @@ export const workflowZ = z.object({
|
|
|
357
362
|
id: z.string().min(1),
|
|
358
363
|
name: z.string().min(1),
|
|
359
364
|
description: z.string().default(""),
|
|
365
|
+
spec_version: z
|
|
366
|
+
.string()
|
|
367
|
+
.regex(/^[1-9]\d*$/, "spec_version must be a positive integer string")
|
|
368
|
+
.optional(),
|
|
360
369
|
workflow_type: workflowTypeZ.optional(),
|
|
361
370
|
nodes: z.record(nodeZ),
|
|
362
371
|
edges: z.array(edgeZ),
|
|
@@ -503,6 +512,68 @@ export function validateWorkflow(workflow, knownSkills) {
|
|
|
503
512
|
});
|
|
504
513
|
}
|
|
505
514
|
}
|
|
515
|
+
// Deterministic `when` expressions (#461). Every path must name a declared
|
|
516
|
+
// output field of the edge's source node or one of its ancestors, so a typo
|
|
517
|
+
// is a load error rather than a branch that never fires. A node routes on
|
|
518
|
+
// expressions or on natural language, never both: a mixed node would hand
|
|
519
|
+
// the expression edges to the model and lose the determinism the author
|
|
520
|
+
// asked for.
|
|
521
|
+
const ancestorsCache = new Map();
|
|
522
|
+
const inAll = new Map();
|
|
523
|
+
for (const [from, tos] of outAll)
|
|
524
|
+
for (const to of tos)
|
|
525
|
+
push(inAll, to, from);
|
|
526
|
+
const ancestorsOf = (id) => {
|
|
527
|
+
let seen = ancestorsCache.get(id);
|
|
528
|
+
if (seen)
|
|
529
|
+
return seen;
|
|
530
|
+
seen = new Set([id]);
|
|
531
|
+
const queue = [id];
|
|
532
|
+
for (let i = 0; i < queue.length; i++) {
|
|
533
|
+
for (const from of inAll.get(queue[i]) ?? []) {
|
|
534
|
+
if (!seen.has(from)) {
|
|
535
|
+
seen.add(from);
|
|
536
|
+
queue.push(from);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
ancestorsCache.set(id, seen);
|
|
541
|
+
return seen;
|
|
542
|
+
};
|
|
543
|
+
const outputs = Object.fromEntries(Object.entries(workflow.nodes).map(([id, n]) => [id, n.output]));
|
|
544
|
+
const conditionKinds = new Map();
|
|
545
|
+
for (const edge of workflow.edges) {
|
|
546
|
+
if (!nodeIds.has(edge.from) || !edge.when)
|
|
547
|
+
continue;
|
|
548
|
+
const kinds = conditionKinds.get(edge.from) ?? { expr: 0, nl: 0 };
|
|
549
|
+
conditionKinds.set(edge.from, kinds);
|
|
550
|
+
if (!isWhenExpression(edge.when)) {
|
|
551
|
+
kinds.nl++;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
kinds.expr++;
|
|
555
|
+
const problems = checkExpression(edge.when.expr, {
|
|
556
|
+
from: edge.from,
|
|
557
|
+
ancestors: ancestorsOf(edge.from),
|
|
558
|
+
outputs,
|
|
559
|
+
});
|
|
560
|
+
for (const problem of problems) {
|
|
561
|
+
errors.push({
|
|
562
|
+
code: "INVALID_WHEN_EXPRESSION",
|
|
563
|
+
message: `Edge "${edge.from}" -> "${edge.to}": ${problem}`,
|
|
564
|
+
nodeId: edge.from,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
for (const [from, kinds] of conditionKinds) {
|
|
569
|
+
if (kinds.expr > 0 && kinds.nl > 0) {
|
|
570
|
+
errors.push({
|
|
571
|
+
code: "MIXED_EDGE_CONDITIONS",
|
|
572
|
+
message: `Node "${from}" mixes expression and natural-language 'when' conditions on its out-edges; use one kind per node (an unconditional default edge is fine with either)`,
|
|
573
|
+
nodeId: from,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
506
577
|
// Reachability: BFS from entry. Skipped only when the entry is missing
|
|
507
578
|
// (every node would be reported, which is noise). All other diagnostics
|
|
508
579
|
// accumulate so one pass reports every structural problem.
|
|
@@ -954,6 +1025,11 @@ export const workflowJsonSchema = {
|
|
|
954
1025
|
id: { type: "string", minLength: 1 },
|
|
955
1026
|
name: { type: "string", minLength: 1 },
|
|
956
1027
|
description: { type: "string" },
|
|
1028
|
+
spec_version: {
|
|
1029
|
+
type: "string",
|
|
1030
|
+
pattern: "^[1-9][0-9]*$",
|
|
1031
|
+
description: "Workflow spec version as a positive integer string. Absent means 1. Older versions are migrated in memory on load; newer than the CLI supports is refused. Run `sweny workflow upgrade <file>` to rewrite a file at the current version.",
|
|
1032
|
+
},
|
|
957
1033
|
entry: { type: "string", minLength: 1, description: "ID of the entry node" },
|
|
958
1034
|
rules: {
|
|
959
1035
|
type: "array",
|
|
@@ -1290,8 +1366,23 @@ export const workflowJsonSchema = {
|
|
|
1290
1366
|
from: { type: "string", minLength: 1 },
|
|
1291
1367
|
to: { type: "string", minLength: 1 },
|
|
1292
1368
|
when: {
|
|
1293
|
-
|
|
1294
|
-
|
|
1369
|
+
description: "Condition for taking this edge. A string is natural language, evaluated at runtime by the workflow's harness. An object { expr } is a deterministic expression over prior nodes' declared output fields, evaluated by sweny with no model call.",
|
|
1370
|
+
oneOf: [
|
|
1371
|
+
{ type: "string" },
|
|
1372
|
+
{
|
|
1373
|
+
type: "object",
|
|
1374
|
+
required: ["expr"],
|
|
1375
|
+
additionalProperties: false,
|
|
1376
|
+
properties: {
|
|
1377
|
+
expr: {
|
|
1378
|
+
type: "string",
|
|
1379
|
+
minLength: 1,
|
|
1380
|
+
maxLength: WHEN_EXPR_MAX_LENGTH,
|
|
1381
|
+
description: "Boolean expression: == != < <= > >= in, && || !, exists, string/number/boolean/null literals, and node.field paths to declared output fields.",
|
|
1382
|
+
},
|
|
1383
|
+
},
|
|
1384
|
+
},
|
|
1385
|
+
],
|
|
1295
1386
|
},
|
|
1296
1387
|
max_iterations: {
|
|
1297
1388
|
type: "integer",
|
package/dist/studio.d.ts
CHANGED
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* directly to node highlights. node:enter → glow blue,
|
|
39
39
|
* tool:call → show tool name, node:exit → green/red.
|
|
40
40
|
*/
|
|
41
|
-
import type { Workflow, Node, ExecutionEvent, NodeResult, Skill } from "./types.js";
|
|
41
|
+
import type { Workflow, Node, Edge, ExecutionEvent, NodeResult, Skill } from "./types.js";
|
|
42
42
|
export interface FlowNode {
|
|
43
43
|
id: string;
|
|
44
44
|
type: "skillNode";
|
|
@@ -74,7 +74,7 @@ export interface FlowEdge {
|
|
|
74
74
|
/** Array index of this edge in workflow.edges — used for stable lookups. */
|
|
75
75
|
edgeIndex: number;
|
|
76
76
|
data: {
|
|
77
|
-
when?:
|
|
77
|
+
when?: Edge["when"];
|
|
78
78
|
max_iterations?: number;
|
|
79
79
|
isConditional: boolean;
|
|
80
80
|
};
|
package/dist/types.d.ts
CHANGED
|
@@ -489,12 +489,26 @@ export interface NodeToolFilter {
|
|
|
489
489
|
/** Skill tool names removed from the node (applied after `allow`). */
|
|
490
490
|
deny?: string[];
|
|
491
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* A deterministic edge condition (#461): a boolean expression over prior
|
|
494
|
+
* nodes' declared output fields, evaluated by sweny with no model call.
|
|
495
|
+
* Grammar and semantics: `when.ts`.
|
|
496
|
+
*/
|
|
497
|
+
export interface WhenExpression {
|
|
498
|
+
expr: string;
|
|
499
|
+
}
|
|
500
|
+
/** An edge condition: natural language (model-evaluated) or a `{ expr }` expression. */
|
|
501
|
+
export type EdgeWhen = string | WhenExpression;
|
|
492
502
|
/** An edge connecting two nodes */
|
|
493
503
|
export interface Edge {
|
|
494
504
|
from: string;
|
|
495
505
|
to: string;
|
|
496
|
-
/**
|
|
497
|
-
|
|
506
|
+
/**
|
|
507
|
+
* Condition for taking this edge. A string is natural language, evaluated at
|
|
508
|
+
* runtime by the workflow's harness. `{ expr }` is a deterministic expression
|
|
509
|
+
* evaluated by sweny with no model call.
|
|
510
|
+
*/
|
|
511
|
+
when?: EdgeWhen;
|
|
498
512
|
/** Max times this edge can be followed (enables retry loops). Default: unlimited. */
|
|
499
513
|
max_iterations?: number;
|
|
500
514
|
}
|
|
@@ -514,6 +528,8 @@ export interface Workflow {
|
|
|
514
528
|
id: string;
|
|
515
529
|
name: string;
|
|
516
530
|
description: string;
|
|
531
|
+
/** Spec version, a positive integer string (e.g. "1"). Absent means "1". See `migrations.ts`. */
|
|
532
|
+
spec_version?: string;
|
|
517
533
|
/** Optional. Defaults to "generic" when absent. Required on marketplace templates. */
|
|
518
534
|
workflow_type?: WorkflowType;
|
|
519
535
|
nodes: Record<string, Node>;
|
package/dist/when.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic `when` expressions (#461).
|
|
3
|
+
*
|
|
4
|
+
* An edge's `when` is either natural language (a model picks the edge at
|
|
5
|
+
* runtime) or `{ expr: "..." }`, a small boolean expression over prior nodes'
|
|
6
|
+
* declared output fields that sweny evaluates itself, with no model call.
|
|
7
|
+
*
|
|
8
|
+
* Grammar (precedence low to high):
|
|
9
|
+
*
|
|
10
|
+
* expr := or
|
|
11
|
+
* or := and ( "||" and )*
|
|
12
|
+
* and := unary ( "&&" unary )*
|
|
13
|
+
* unary := "!" unary | comparison
|
|
14
|
+
* comparison := operand ( ( "==" | "!=" | "<" | "<=" | ">" | ">=" | "in" ) operand )?
|
|
15
|
+
* operand := literal | list | path | "exists" path | "(" expr ")"
|
|
16
|
+
* list := "[" ( literal ( "," literal )* )? "]"
|
|
17
|
+
* literal := number | 'string' | "string" | true | false | null
|
|
18
|
+
* path := ident ( "." ident )+ (node id, then field, then nested fields)
|
|
19
|
+
*
|
|
20
|
+
* Semantics are strict and total. There is no coercion and no truthiness:
|
|
21
|
+
* `&&`, `||`, `!` and the expression itself need booleans; `<` and friends
|
|
22
|
+
* need two numbers or two strings; `in` needs a list on the right. A missing
|
|
23
|
+
* field (undefined, or a node that did not run or did not succeed) or a type
|
|
24
|
+
* mismatch makes the WHOLE expression false and is reported, so a negation
|
|
25
|
+
* can never turn a missing field into a silent true. `exists path` is the
|
|
26
|
+
* one explicit presence test: true when the value is present and not null,
|
|
27
|
+
* never a warning. `&&` and `||` short-circuit, so `exists a.x && a.x > 0`
|
|
28
|
+
* is safe.
|
|
29
|
+
*
|
|
30
|
+
* Pure module: no I/O, no eval, no Function, browser-safe. Strings in the
|
|
31
|
+
* routed data are compared as data only; nothing in them is ever parsed.
|
|
32
|
+
*/
|
|
33
|
+
import type { EdgeWhen, JSONSchema, WhenExpression } from "./types.js";
|
|
34
|
+
/** Longest expression source accepted. Routing conditions are short. */
|
|
35
|
+
export declare const WHEN_EXPR_MAX_LENGTH = 2000;
|
|
36
|
+
/** True when an edge `when` is the structured `{ expr }` form. */
|
|
37
|
+
export declare function isWhenExpression(when: unknown): when is WhenExpression;
|
|
38
|
+
/** Human-readable label for a `when`: the NL text or the expression source. */
|
|
39
|
+
export declare function whenLabel(when: EdgeWhen | undefined): string | undefined;
|
|
40
|
+
export type Literal = string | number | boolean | null;
|
|
41
|
+
export type ExprNode = {
|
|
42
|
+
kind: "literal";
|
|
43
|
+
value: Literal;
|
|
44
|
+
} | {
|
|
45
|
+
kind: "list";
|
|
46
|
+
items: Literal[];
|
|
47
|
+
} | {
|
|
48
|
+
kind: "path";
|
|
49
|
+
segments: string[];
|
|
50
|
+
} | {
|
|
51
|
+
kind: "exists";
|
|
52
|
+
path: {
|
|
53
|
+
kind: "path";
|
|
54
|
+
segments: string[];
|
|
55
|
+
};
|
|
56
|
+
} | {
|
|
57
|
+
kind: "not";
|
|
58
|
+
operand: ExprNode;
|
|
59
|
+
} | {
|
|
60
|
+
kind: "and" | "or";
|
|
61
|
+
left: ExprNode;
|
|
62
|
+
right: ExprNode;
|
|
63
|
+
} | {
|
|
64
|
+
kind: "cmp";
|
|
65
|
+
op: CmpOp;
|
|
66
|
+
left: ExprNode;
|
|
67
|
+
right: ExprNode;
|
|
68
|
+
};
|
|
69
|
+
export type CmpOp = "==" | "!=" | "<" | "<=" | ">" | ">=" | "in";
|
|
70
|
+
export declare class ExpressionSyntaxError extends Error {
|
|
71
|
+
readonly position: number;
|
|
72
|
+
constructor(message: string, position: number);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Parse an expression source into an AST. Throws `ExpressionSyntaxError` on
|
|
76
|
+
* any syntax problem, including an empty source, an over-long source, or
|
|
77
|
+
* nesting deeper than the cap.
|
|
78
|
+
*/
|
|
79
|
+
export declare function parseExpression(src: string): ExprNode;
|
|
80
|
+
/**
|
|
81
|
+
* Per-node data an expression may read, keyed by node id. A node that did not
|
|
82
|
+
* run or did not succeed is absent, so every path into it reads as missing.
|
|
83
|
+
*/
|
|
84
|
+
export type ExpressionScope = Record<string, Record<string, unknown> | undefined>;
|
|
85
|
+
export interface ExpressionResult {
|
|
86
|
+
/** The edge is taken only when this is true. */
|
|
87
|
+
value: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Why the expression was forced false: a missing field or a type mismatch.
|
|
90
|
+
* Absent when it evaluated normally (to true or false).
|
|
91
|
+
*/
|
|
92
|
+
problem?: string;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Evaluate a parsed expression against node outputs. Never throws: a missing
|
|
96
|
+
* field or a type mismatch yields `{ value: false, problem }`.
|
|
97
|
+
*/
|
|
98
|
+
export declare function evaluateExpression(ast: ExprNode, scope: ExpressionScope): ExpressionResult;
|
|
99
|
+
/**
|
|
100
|
+
* Check an expression against the workflow it routes. Returns one message per
|
|
101
|
+
* problem, empty when the expression is valid:
|
|
102
|
+
* - the source must parse;
|
|
103
|
+
* - every path's node must be the edge's source or one of its ancestors;
|
|
104
|
+
* - that node must declare `output.properties`, and the field (and any
|
|
105
|
+
* nested field) must be declared there;
|
|
106
|
+
* - a literal compared with a declared field must fit its declared type,
|
|
107
|
+
* and a string literal compared with an enum field must be in the enum;
|
|
108
|
+
* - a field used where a boolean is needed must be declared boolean.
|
|
109
|
+
*/
|
|
110
|
+
export declare function checkExpression(src: string, ctx: {
|
|
111
|
+
from: string;
|
|
112
|
+
ancestors: Set<string>;
|
|
113
|
+
outputs: Record<string, JSONSchema | undefined>;
|
|
114
|
+
}): string[];
|