@sweny-ai/core 0.1.26 → 0.1.28
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/__tests__/executor.test.js +17 -10
- package/dist/__tests__/integration/e2e-workflow.integration.test.js +1 -1
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +47 -3
- package/dist/executor.d.ts +5 -3
- package/dist/executor.js +26 -7
- package/dist/executor.test.js +3 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/mermaid.d.ts +43 -0
- package/dist/mermaid.js +158 -0
- package/dist/mermaid.test.d.ts +1 -0
- package/dist/mermaid.test.js +254 -0
- package/dist/schema.d.ts +19 -16
- package/dist/schema.js +4 -1
- package/dist/skills/github.js +23 -9
- package/dist/types.d.ts +30 -0
- package/dist/workflows/browser.js +2 -2
- package/dist/workflows/implement.yml +4 -0
- package/dist/workflows/triage.yml +4 -0
- package/package.json +1 -1
|
@@ -66,7 +66,7 @@ describe("executor", () => {
|
|
|
66
66
|
},
|
|
67
67
|
});
|
|
68
68
|
const events = [];
|
|
69
|
-
const results = await execute(linearWorkflow, { alert: "test" }, {
|
|
69
|
+
const { results } = await execute(linearWorkflow, { alert: "test" }, {
|
|
70
70
|
skills: createSkillMap([fileSkill]),
|
|
71
71
|
claude,
|
|
72
72
|
observer: (e) => events.push(e),
|
|
@@ -92,7 +92,7 @@ describe("executor", () => {
|
|
|
92
92
|
},
|
|
93
93
|
routes: { check: "high" },
|
|
94
94
|
});
|
|
95
|
-
const results = await execute(branchingWorkflow, { alert: "cpu" }, {
|
|
95
|
+
const { results } = await execute(branchingWorkflow, { alert: "cpu" }, {
|
|
96
96
|
skills: createSkillMap([]),
|
|
97
97
|
claude,
|
|
98
98
|
config: {},
|
|
@@ -111,7 +111,7 @@ describe("executor", () => {
|
|
|
111
111
|
},
|
|
112
112
|
routes: { check: "low" },
|
|
113
113
|
});
|
|
114
|
-
const results = await execute(branchingWorkflow, { alert: "minor" }, {
|
|
114
|
+
const { results } = await execute(branchingWorkflow, { alert: "minor" }, {
|
|
115
115
|
skills: createSkillMap([]),
|
|
116
116
|
claude,
|
|
117
117
|
config: {},
|
|
@@ -154,7 +154,7 @@ describe("executor", () => {
|
|
|
154
154
|
c: { data: {} },
|
|
155
155
|
},
|
|
156
156
|
});
|
|
157
|
-
const results = await execute(linearWorkflow, {}, {
|
|
157
|
+
const { results } = await execute(linearWorkflow, {}, {
|
|
158
158
|
skills: createSkillMap([]),
|
|
159
159
|
claude,
|
|
160
160
|
observer: () => {
|
|
@@ -193,7 +193,7 @@ describe("executor", () => {
|
|
|
193
193
|
const claude = new MockClaude({
|
|
194
194
|
responses: { only: { data: { result: "done" } } },
|
|
195
195
|
});
|
|
196
|
-
const results = await execute(singleNode, {}, {
|
|
196
|
+
const { results } = await execute(singleNode, {}, {
|
|
197
197
|
skills: createSkillMap([]),
|
|
198
198
|
claude,
|
|
199
199
|
config: {},
|
|
@@ -252,7 +252,7 @@ describe("executor", () => {
|
|
|
252
252
|
claude,
|
|
253
253
|
})).rejects.toThrow("Missing required config");
|
|
254
254
|
// Should succeed with override
|
|
255
|
-
const results = await execute(linearWorkflow, {}, {
|
|
255
|
+
const { results } = await execute(linearWorkflow, {}, {
|
|
256
256
|
skills: createSkillMap([skillWithConfig]),
|
|
257
257
|
claude,
|
|
258
258
|
config: { MY_KEY: "provided" },
|
|
@@ -267,7 +267,7 @@ describe("executor", () => {
|
|
|
267
267
|
c: { data: {} },
|
|
268
268
|
},
|
|
269
269
|
});
|
|
270
|
-
const results = await execute(linearWorkflow, {}, {
|
|
270
|
+
const { results } = await execute(linearWorkflow, {}, {
|
|
271
271
|
skills: createSkillMap([]),
|
|
272
272
|
claude,
|
|
273
273
|
config: {},
|
|
@@ -316,13 +316,20 @@ describe("executor", () => {
|
|
|
316
316
|
return opts.choices[0].id;
|
|
317
317
|
},
|
|
318
318
|
};
|
|
319
|
-
const results = await execute(retryWorkflow, {}, { skills: createSkillMap([]), claude: mockClaude, config: {} });
|
|
319
|
+
const { results, trace } = await execute(retryWorkflow, {}, { skills: createSkillMap([]), claude: mockClaude, config: {} });
|
|
320
320
|
// extract ran twice (initial + 1 retry), judge ran twice, done ran once
|
|
321
321
|
expect(extractCount).toBe(2);
|
|
322
322
|
expect(judgeCount).toBe(2);
|
|
323
323
|
expect(results.has("done")).toBe(true);
|
|
324
324
|
// The second extract result overwrites the first
|
|
325
325
|
expect(results.get("extract")?.data.attempt).toBe(2);
|
|
326
|
+
// Trace captures the full execution path including loops
|
|
327
|
+
expect(trace.steps).toHaveLength(5); // extract, judge, extract(retry), judge, done
|
|
328
|
+
expect(trace.steps[0]).toEqual({ node: "extract", status: "success", iteration: 1 });
|
|
329
|
+
expect(trace.steps[2]).toEqual({ node: "extract", status: "success", iteration: 2 });
|
|
330
|
+
// Trace edges include the retry loop
|
|
331
|
+
const retryEdge = trace.edges.find((e) => e.from === "judge" && e.to === "extract");
|
|
332
|
+
expect(retryEdge).toBeDefined();
|
|
326
333
|
});
|
|
327
334
|
it("respects max_iterations=1 as single retry", async () => {
|
|
328
335
|
const singleRetry = {
|
|
@@ -356,7 +363,7 @@ describe("executor", () => {
|
|
|
356
363
|
return opts.choices[0].id;
|
|
357
364
|
},
|
|
358
365
|
};
|
|
359
|
-
const results = await execute(singleRetry, {}, { skills: createSkillMap([]), claude: mockClaude, config: {} });
|
|
366
|
+
const { results } = await execute(singleRetry, {}, { skills: createSkillMap([]), claude: mockClaude, config: {} });
|
|
360
367
|
// A runs twice (once initially, once via retry), then edge is exhausted → falls to "end"
|
|
361
368
|
expect(aCount).toBe(2);
|
|
362
369
|
expect(results.has("end")).toBe(true);
|
|
@@ -371,7 +378,7 @@ describe("executor", () => {
|
|
|
371
378
|
return "nonexistent_node"; // invalid target
|
|
372
379
|
},
|
|
373
380
|
};
|
|
374
|
-
const results = await execute(branchingWorkflow, {}, {
|
|
381
|
+
const { results } = await execute(branchingWorkflow, {}, {
|
|
375
382
|
skills: createSkillMap([]),
|
|
376
383
|
claude: badRouteClaude,
|
|
377
384
|
config: {},
|
|
@@ -47,7 +47,7 @@ describe.runIf(available.claude)("e2e workflow with real Claude", () => {
|
|
|
47
47
|
maxTurns: 3,
|
|
48
48
|
});
|
|
49
49
|
const events = [];
|
|
50
|
-
const results = await execute(analysisWorkflow, {
|
|
50
|
+
const { results } = await execute(analysisWorkflow, {
|
|
51
51
|
alert: "CPU usage at 98% for 15 minutes on production server web-03",
|
|
52
52
|
service: "api-gateway",
|
|
53
53
|
timestamp: "2025-03-25T08:00:00Z",
|
package/dist/cli/main.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export declare function loadWorkflowFile(filePath: string): Workflow;
|
|
|
4
4
|
export declare function workflowRunAction(file: string, options: Record<string, unknown> & {
|
|
5
5
|
json?: boolean;
|
|
6
6
|
stream?: boolean;
|
|
7
|
+
mermaid?: boolean;
|
|
7
8
|
}): Promise<void>;
|
|
8
9
|
export declare function workflowExportAction(name: string): void;
|
|
9
10
|
export declare function workflowValidateAction(file: string, options: {
|
package/dist/cli/main.js
CHANGED
|
@@ -16,6 +16,7 @@ import { loadAdditionalContext } from "../templates.js";
|
|
|
16
16
|
import { validateWorkflow as validateWorkflowSchema } from "../schema.js";
|
|
17
17
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
18
18
|
import { buildWorkflow, refineWorkflow } from "../workflow-builder.js";
|
|
19
|
+
import { toMermaid, toMermaidBlock } from "../mermaid.js";
|
|
19
20
|
import { DagRenderer } from "./renderer.js";
|
|
20
21
|
import * as readline from "node:readline";
|
|
21
22
|
import { loadDotenv, loadConfigFile, STARTER_CONFIG } from "./config-file.js";
|
|
@@ -357,7 +358,7 @@ triageCmd.action(async (options) => {
|
|
|
357
358
|
contextUrls,
|
|
358
359
|
};
|
|
359
360
|
try {
|
|
360
|
-
const results = await execute(triageWorkflow, workflowInput, {
|
|
361
|
+
const { results } = await execute(triageWorkflow, workflowInput, {
|
|
361
362
|
skills,
|
|
362
363
|
claude,
|
|
363
364
|
observer,
|
|
@@ -473,7 +474,7 @@ implementCmd.action(async (issueId, options) => {
|
|
|
473
474
|
contextUrls,
|
|
474
475
|
};
|
|
475
476
|
try {
|
|
476
|
-
const results = await execute(implementWorkflow, workflowInput, {
|
|
477
|
+
const { results } = await execute(implementWorkflow, workflowInput, {
|
|
477
478
|
skills,
|
|
478
479
|
claude,
|
|
479
480
|
observer,
|
|
@@ -623,7 +624,7 @@ export async function workflowRunAction(file, options) {
|
|
|
623
624
|
};
|
|
624
625
|
}
|
|
625
626
|
try {
|
|
626
|
-
const results = await execute(workflow, workflowInput, {
|
|
627
|
+
const { results, trace } = await execute(workflow, workflowInput, {
|
|
627
628
|
skills,
|
|
628
629
|
claude,
|
|
629
630
|
observer,
|
|
@@ -635,6 +636,14 @@ export async function workflowRunAction(file, options) {
|
|
|
635
636
|
process.exit(hasFailed ? 1 : 0);
|
|
636
637
|
return;
|
|
637
638
|
}
|
|
639
|
+
// Mermaid diagram with execution state
|
|
640
|
+
if (options.mermaid) {
|
|
641
|
+
const state = {};
|
|
642
|
+
for (const [nodeId, result] of results) {
|
|
643
|
+
state[nodeId] = result.status === "success" ? "success" : result.status === "failed" ? "failed" : "skipped";
|
|
644
|
+
}
|
|
645
|
+
process.stdout.write(toMermaidBlock(workflow, { state, trace, title: workflow.name }) + "\n");
|
|
646
|
+
}
|
|
638
647
|
const hasFailed = [...results.values()].some((r) => r.status === "failed");
|
|
639
648
|
if (hasFailed) {
|
|
640
649
|
console.error(chalk.red(` Workflow failed\n`));
|
|
@@ -722,8 +731,43 @@ workflowCmd
|
|
|
722
731
|
.option("--dry-run", "Validate workflow without running")
|
|
723
732
|
.option("--json", "Output result as JSON on stdout; suppress progress output")
|
|
724
733
|
.option("--stream", "Stream NDJSON events to stdout (for Studio / automation)")
|
|
734
|
+
.option("--mermaid", "Output a Mermaid diagram with execution state after run")
|
|
725
735
|
.option("--input <json>", "JSON string of input data to pass to the workflow")
|
|
726
736
|
.action(workflowRunAction);
|
|
737
|
+
workflowCmd
|
|
738
|
+
.command("diagram <file>")
|
|
739
|
+
.description("Render a workflow as a Mermaid diagram")
|
|
740
|
+
.option("--direction <dir>", "Graph direction: TB (top-bottom) or LR (left-right)", "TB")
|
|
741
|
+
.option("--title <title>", "Diagram title (defaults to workflow name)")
|
|
742
|
+
.option("--block", "Wrap in ```mermaid fenced code block (default)", true)
|
|
743
|
+
.option("--no-block", "Output raw Mermaid without code fence")
|
|
744
|
+
.action((file, options) => {
|
|
745
|
+
let workflow;
|
|
746
|
+
// Support builtin workflow names
|
|
747
|
+
if (file === "triage") {
|
|
748
|
+
workflow = triageWorkflow;
|
|
749
|
+
}
|
|
750
|
+
else if (file === "implement") {
|
|
751
|
+
workflow = implementWorkflow;
|
|
752
|
+
}
|
|
753
|
+
else if (file === "seed-content") {
|
|
754
|
+
workflow = seedContentWorkflow;
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
try {
|
|
758
|
+
workflow = loadWorkflowFile(file);
|
|
759
|
+
}
|
|
760
|
+
catch (err) {
|
|
761
|
+
console.error(chalk.red(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
762
|
+
process.exit(1);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const direction = (options.direction === "LR" ? "LR" : "TB");
|
|
767
|
+
const title = options.title ?? workflow.name;
|
|
768
|
+
const render = options.block !== false ? toMermaidBlock : toMermaid;
|
|
769
|
+
process.stdout.write(render(workflow, { direction, title }) + "\n");
|
|
770
|
+
});
|
|
727
771
|
workflowCmd
|
|
728
772
|
.command("export <name>")
|
|
729
773
|
.description("Print a built-in workflow as YAML (triage or implement)")
|
package/dist/executor.d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* This replaces ~8k lines of engine + recipe step code.
|
|
14
14
|
*/
|
|
15
|
-
import type { Workflow, Skill, Claude, Observer,
|
|
15
|
+
import type { Workflow, Skill, Claude, Observer, Logger, ExecutionResult } from "./types.js";
|
|
16
16
|
export interface ExecuteOptions {
|
|
17
17
|
/** Registered skills (id → Skill) */
|
|
18
18
|
skills: Map<string, Skill>;
|
|
@@ -28,6 +28,8 @@ export interface ExecuteOptions {
|
|
|
28
28
|
/**
|
|
29
29
|
* Execute a workflow from entry to completion.
|
|
30
30
|
*
|
|
31
|
-
* Returns
|
|
31
|
+
* Returns an ExecutionResult with:
|
|
32
|
+
* - `results`: map of node ID → final result (last execution if retried)
|
|
33
|
+
* - `trace`: full ordered execution trace including loops and routing decisions
|
|
32
34
|
*/
|
|
33
|
-
export declare function execute(workflow: Workflow, input: unknown, options: ExecuteOptions): Promise<
|
|
35
|
+
export declare function execute(workflow: Workflow, input: unknown, options: ExecuteOptions): Promise<ExecutionResult>;
|
package/dist/executor.js
CHANGED
|
@@ -16,7 +16,9 @@ import { consoleLogger } from "./types.js";
|
|
|
16
16
|
/**
|
|
17
17
|
* Execute a workflow from entry to completion.
|
|
18
18
|
*
|
|
19
|
-
* Returns
|
|
19
|
+
* Returns an ExecutionResult with:
|
|
20
|
+
* - `results`: map of node ID → final result (last execution if retried)
|
|
21
|
+
* - `trace`: full ordered execution trace including loops and routing decisions
|
|
20
22
|
*/
|
|
21
23
|
export async function execute(workflow, input, options) {
|
|
22
24
|
const { skills, claude, observer } = options;
|
|
@@ -24,6 +26,8 @@ export async function execute(workflow, input, options) {
|
|
|
24
26
|
const logger = options.logger ?? consoleLogger;
|
|
25
27
|
const results = new Map();
|
|
26
28
|
const edgeCounts = new Map(); // "from→to" → times followed
|
|
29
|
+
const nodeRunCounts = new Map(); // node → times executed
|
|
30
|
+
const trace = { steps: [], edges: [] };
|
|
27
31
|
validate(workflow, skills);
|
|
28
32
|
safeObserve(observer, { type: "workflow:start", workflow: workflow.id }, logger);
|
|
29
33
|
let currentId = workflow.entry;
|
|
@@ -31,6 +35,8 @@ export async function execute(workflow, input, options) {
|
|
|
31
35
|
const node = workflow.nodes[currentId];
|
|
32
36
|
if (!node)
|
|
33
37
|
throw new Error(`Unknown node: "${currentId}"`);
|
|
38
|
+
const iteration = (nodeRunCounts.get(currentId) ?? 0) + 1;
|
|
39
|
+
nodeRunCounts.set(currentId, iteration);
|
|
34
40
|
safeObserve(observer, { type: "node:enter", node: currentId, instruction: node.instruction }, logger);
|
|
35
41
|
logger.info(`→ ${node.name}`, { node: currentId });
|
|
36
42
|
// Gather tools from the node's skills
|
|
@@ -64,6 +70,7 @@ export async function execute(workflow, input, options) {
|
|
|
64
70
|
},
|
|
65
71
|
});
|
|
66
72
|
results.set(currentId, result);
|
|
73
|
+
trace.steps.push({ node: currentId, status: result.status, iteration });
|
|
67
74
|
safeObserve(observer, { type: "node:exit", node: currentId, result }, logger);
|
|
68
75
|
logger.info(` ✓ ${result.status}`, { node: currentId, toolCalls: result.toolCalls.length });
|
|
69
76
|
// Dry run hard gate — stop at the first conditional routing decision.
|
|
@@ -80,13 +87,20 @@ export async function execute(workflow, input, options) {
|
|
|
80
87
|
}
|
|
81
88
|
}
|
|
82
89
|
// Resolve next node via edge conditions
|
|
83
|
-
|
|
90
|
+
const prevId = currentId;
|
|
91
|
+
currentId = await resolveNext(workflow, currentId, results, input, claude, observer, edgeCounts, logger);
|
|
92
|
+
// Record the routing decision in the trace
|
|
93
|
+
if (currentId) {
|
|
94
|
+
const edgeKey = `${prevId}→${currentId}`;
|
|
95
|
+
const reason = workflow.edges.find((e) => e.from === prevId && e.to === currentId)?.when ?? "only path";
|
|
96
|
+
trace.edges.push({ from: prevId, to: currentId, reason });
|
|
97
|
+
}
|
|
84
98
|
}
|
|
85
99
|
safeObserve(observer, {
|
|
86
100
|
type: "workflow:end",
|
|
87
101
|
results: Object.fromEntries(results),
|
|
88
102
|
}, logger);
|
|
89
|
-
return results;
|
|
103
|
+
return { results, trace };
|
|
90
104
|
}
|
|
91
105
|
// ─── Internals ───────────────────────────────────────────────────
|
|
92
106
|
/**
|
|
@@ -119,7 +133,12 @@ function buildNodeInstruction(baseInstruction, input) {
|
|
|
119
133
|
return baseInstruction;
|
|
120
134
|
return `${sections.join("\n\n")}\n\n---\n\n${baseInstruction}`;
|
|
121
135
|
}
|
|
122
|
-
/**
|
|
136
|
+
/**
|
|
137
|
+
* Call observer without letting exceptions crash the workflow.
|
|
138
|
+
*
|
|
139
|
+
* Accepts a typed ExecutionEvent so TypeScript catches mistakes in
|
|
140
|
+
* event construction at compile time rather than silently at runtime.
|
|
141
|
+
*/
|
|
123
142
|
function safeObserve(observer, event, logger) {
|
|
124
143
|
if (!observer)
|
|
125
144
|
return;
|
|
@@ -168,7 +187,7 @@ function resolveConfig(skills, overrides) {
|
|
|
168
187
|
*
|
|
169
188
|
* Edges with max_iterations are filtered out once exhausted.
|
|
170
189
|
*/
|
|
171
|
-
async function resolveNext(workflow, current, results, input, claude, observer, edgeCounts) {
|
|
190
|
+
async function resolveNext(workflow, current, results, input, claude, observer, edgeCounts, logger) {
|
|
172
191
|
// Filter out edges that have exceeded their max_iterations
|
|
173
192
|
const outEdges = workflow.edges.filter((e) => {
|
|
174
193
|
if (e.from !== current)
|
|
@@ -189,7 +208,7 @@ async function resolveNext(workflow, current, results, input, claude, observer,
|
|
|
189
208
|
const key = `${current}→${outEdges[0].to}`;
|
|
190
209
|
edgeCounts.set(key, (edgeCounts.get(key) ?? 0) + 1);
|
|
191
210
|
}
|
|
192
|
-
safeObserve(observer, { type: "route", from: current, to: outEdges[0].to, reason: "only path" });
|
|
211
|
+
safeObserve(observer, { type: "route", from: current, to: outEdges[0].to, reason: "only path" }, logger);
|
|
193
212
|
return outEdges[0].to;
|
|
194
213
|
}
|
|
195
214
|
// Check for a default (unconditional) edge among conditionals
|
|
@@ -226,7 +245,7 @@ async function resolveNext(workflow, current, results, input, claude, observer,
|
|
|
226
245
|
from: current,
|
|
227
246
|
to: resolved,
|
|
228
247
|
reason: choices.find((c) => c.id === resolved)?.description ?? "default",
|
|
229
|
-
});
|
|
248
|
+
}, logger);
|
|
230
249
|
return resolved;
|
|
231
250
|
}
|
|
232
251
|
/**
|
package/dist/executor.test.js
CHANGED
|
@@ -167,7 +167,7 @@ describe("executor", () => {
|
|
|
167
167
|
},
|
|
168
168
|
});
|
|
169
169
|
const events = [];
|
|
170
|
-
const results = await execute(simpleWorkflow, { alert: "test" }, {
|
|
170
|
+
const { results } = await execute(simpleWorkflow, { alert: "test" }, {
|
|
171
171
|
skills,
|
|
172
172
|
claude,
|
|
173
173
|
observer: (e) => events.push(e),
|
|
@@ -202,7 +202,7 @@ describe("executor", () => {
|
|
|
202
202
|
check: "handle_high", // Route to high severity handler
|
|
203
203
|
},
|
|
204
204
|
});
|
|
205
|
-
const results = await execute(branchingWorkflow, { alert: "cpu spike" }, {
|
|
205
|
+
const { results } = await execute(branchingWorkflow, { alert: "cpu spike" }, {
|
|
206
206
|
skills,
|
|
207
207
|
claude,
|
|
208
208
|
config: {},
|
|
@@ -224,7 +224,7 @@ describe("executor", () => {
|
|
|
224
224
|
check: "handle_low",
|
|
225
225
|
},
|
|
226
226
|
});
|
|
227
|
-
const results = await execute(branchingWorkflow, { alert: "minor" }, {
|
|
227
|
+
const { results } = await execute(branchingWorkflow, { alert: "minor" }, {
|
|
228
228
|
skills,
|
|
229
229
|
claude,
|
|
230
230
|
config: {},
|
package/dist/index.d.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* })
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
|
-
export type { Skill, SkillCategory, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, } from "./types.js";
|
|
24
|
+
export type { Skill, SkillCategory, Tool, ToolContext, ConfigField, JSONSchema, Workflow, Node, Edge, NodeResult, ToolCall, ExecutionEvent, Observer, Claude, Logger, TraceStep, TraceEdge, ExecutionTrace, ExecutionResult, } from "./types.js";
|
|
25
25
|
export { consoleLogger } from "./types.js";
|
|
26
26
|
export { execute } from "./executor.js";
|
|
27
27
|
export type { ExecuteOptions } from "./executor.js";
|
|
@@ -38,5 +38,7 @@ export { buildWorkflow, refineWorkflow } from "./workflow-builder.js";
|
|
|
38
38
|
export type { BuildWorkflowOptions } from "./workflow-builder.js";
|
|
39
39
|
export { resolveTemplates, loadAdditionalContext, loadTemplate } from "./templates.js";
|
|
40
40
|
export type { Templates } from "./templates.js";
|
|
41
|
+
export { toMermaid, toMermaidBlock } from "./mermaid.js";
|
|
42
|
+
export type { MermaidOptions, NodeStatus } from "./mermaid.js";
|
|
41
43
|
export { loadConfigFile } from "./cli/config-file.js";
|
|
42
44
|
export type { FileConfig } from "./cli/config-file.js";
|
package/dist/index.js
CHANGED
|
@@ -36,5 +36,7 @@ export { buildAutoMcpServers, buildProviderContext } from "./mcp.js";
|
|
|
36
36
|
export { buildWorkflow, refineWorkflow } from "./workflow-builder.js";
|
|
37
37
|
// Templates
|
|
38
38
|
export { resolveTemplates, loadAdditionalContext, loadTemplate } from "./templates.js";
|
|
39
|
+
// Mermaid export
|
|
40
|
+
export { toMermaid, toMermaidBlock } from "./mermaid.js";
|
|
39
41
|
// Config file
|
|
40
42
|
export { loadConfigFile } from "./cli/config-file.js";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid diagram export
|
|
3
|
+
*
|
|
4
|
+
* Converts a Workflow into a Mermaid flowchart string.
|
|
5
|
+
* Renders natively in GitHub (job summaries, READMEs, issues, PRs),
|
|
6
|
+
* VS Code preview, documentation sites, and anywhere Mermaid is supported.
|
|
7
|
+
*
|
|
8
|
+
* Accepts optional execution state to color nodes by status
|
|
9
|
+
* (current/success/failed/skipped) and optional execution trace
|
|
10
|
+
* to highlight which edges were taken and show loop iterations.
|
|
11
|
+
*/
|
|
12
|
+
import type { Workflow, ExecutionTrace } from "./types.js";
|
|
13
|
+
export type NodeStatus = "current" | "success" | "failed" | "skipped";
|
|
14
|
+
export interface MermaidOptions {
|
|
15
|
+
/** Execution state — nodes not in this map render as default (pending) */
|
|
16
|
+
state?: Record<string, NodeStatus>;
|
|
17
|
+
/** Execution trace — highlights taken/not-taken edges and loop counts */
|
|
18
|
+
trace?: ExecutionTrace;
|
|
19
|
+
/** Graph direction: TB (top-bottom) or LR (left-right). Default: TB */
|
|
20
|
+
direction?: "TB" | "LR";
|
|
21
|
+
/** Title rendered above the diagram */
|
|
22
|
+
title?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Convert a Workflow to a Mermaid flowchart diagram string.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { toMermaid } from '@sweny-ai/core';
|
|
30
|
+
*
|
|
31
|
+
* const md = toMermaid(workflow, {
|
|
32
|
+
* state: { triage: 'success', investigate: 'current' },
|
|
33
|
+
* trace: executionResult.trace,
|
|
34
|
+
* });
|
|
35
|
+
* // Wrap in ```mermaid fence for GitHub rendering
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export declare function toMermaid(workflow: Workflow, options?: MermaidOptions): string;
|
|
39
|
+
/**
|
|
40
|
+
* Convenience: wrap toMermaid output in a fenced code block
|
|
41
|
+
* ready for GitHub markdown.
|
|
42
|
+
*/
|
|
43
|
+
export declare function toMermaidBlock(workflow: Workflow, options?: MermaidOptions): string;
|
package/dist/mermaid.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid diagram export
|
|
3
|
+
*
|
|
4
|
+
* Converts a Workflow into a Mermaid flowchart string.
|
|
5
|
+
* Renders natively in GitHub (job summaries, READMEs, issues, PRs),
|
|
6
|
+
* VS Code preview, documentation sites, and anywhere Mermaid is supported.
|
|
7
|
+
*
|
|
8
|
+
* Accepts optional execution state to color nodes by status
|
|
9
|
+
* (current/success/failed/skipped) and optional execution trace
|
|
10
|
+
* to highlight which edges were taken and show loop iterations.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Convert a Workflow to a Mermaid flowchart diagram string.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { toMermaid } from '@sweny-ai/core';
|
|
18
|
+
*
|
|
19
|
+
* const md = toMermaid(workflow, {
|
|
20
|
+
* state: { triage: 'success', investigate: 'current' },
|
|
21
|
+
* trace: executionResult.trace,
|
|
22
|
+
* });
|
|
23
|
+
* // Wrap in ```mermaid fence for GitHub rendering
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function toMermaid(workflow, options = {}) {
|
|
27
|
+
const { state = {}, trace, direction = "TB", title } = options;
|
|
28
|
+
const lines = [];
|
|
29
|
+
if (title) {
|
|
30
|
+
lines.push("---");
|
|
31
|
+
lines.push(`title: ${title}`);
|
|
32
|
+
lines.push("---");
|
|
33
|
+
}
|
|
34
|
+
lines.push(`graph ${direction}`);
|
|
35
|
+
// Build node iteration counts from trace
|
|
36
|
+
const nodeIterations = new Map();
|
|
37
|
+
if (trace) {
|
|
38
|
+
for (const step of trace.steps) {
|
|
39
|
+
nodeIterations.set(step.node, Math.max(nodeIterations.get(step.node) ?? 0, step.iteration));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Nodes
|
|
43
|
+
for (const [id, node] of Object.entries(workflow.nodes)) {
|
|
44
|
+
let label = escapeLabel(node.name);
|
|
45
|
+
const isEntry = id === workflow.entry;
|
|
46
|
+
const entryTag = isEntry ? " \\u25B6" : "";
|
|
47
|
+
// Show iteration count for nodes that ran multiple times
|
|
48
|
+
const iterations = nodeIterations.get(id);
|
|
49
|
+
const iterTag = iterations && iterations > 1 ? ` ×${iterations}` : "";
|
|
50
|
+
if (isEntry) {
|
|
51
|
+
lines.push(` ${sanitizeId(id)}([${label}${entryTag}${iterTag}])`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
lines.push(` ${sanitizeId(id)}[${label}${iterTag}]`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
lines.push("");
|
|
58
|
+
// Build set of taken edges from trace for lookup
|
|
59
|
+
const takenEdges = new Set();
|
|
60
|
+
const edgeTakenCounts = new Map();
|
|
61
|
+
if (trace) {
|
|
62
|
+
for (const te of trace.edges) {
|
|
63
|
+
const key = `${te.from}→${te.to}`;
|
|
64
|
+
takenEdges.add(key);
|
|
65
|
+
edgeTakenCounts.set(key, (edgeTakenCounts.get(key) ?? 0) + 1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Edges — track index for linkStyle directives
|
|
69
|
+
const takenIndices = [];
|
|
70
|
+
const notTakenIndices = [];
|
|
71
|
+
let edgeIndex = 0;
|
|
72
|
+
for (const edge of workflow.edges) {
|
|
73
|
+
const from = sanitizeId(edge.from);
|
|
74
|
+
const to = sanitizeId(edge.to);
|
|
75
|
+
const key = `${edge.from}→${edge.to}`;
|
|
76
|
+
const taken = takenEdges.has(key);
|
|
77
|
+
const takenCount = edgeTakenCounts.get(key) ?? 0;
|
|
78
|
+
// Build label parts
|
|
79
|
+
const labelParts = [];
|
|
80
|
+
if (edge.when)
|
|
81
|
+
labelParts.push(edge.when);
|
|
82
|
+
if (edge.max_iterations)
|
|
83
|
+
labelParts.push(`max ${edge.max_iterations}x`);
|
|
84
|
+
// Show how many times this edge was actually followed
|
|
85
|
+
if (takenCount > 1)
|
|
86
|
+
labelParts.push(`taken ${takenCount}x`);
|
|
87
|
+
if (labelParts.length > 0) {
|
|
88
|
+
const label = escapeLabel(labelParts.join(" · "));
|
|
89
|
+
lines.push(` ${from} -->|"${label}"| ${to}`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
lines.push(` ${from} --> ${to}`);
|
|
93
|
+
}
|
|
94
|
+
// Track edge indices for styling
|
|
95
|
+
if (trace) {
|
|
96
|
+
if (taken) {
|
|
97
|
+
takenIndices.push(edgeIndex);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
notTakenIndices.push(edgeIndex);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
edgeIndex++;
|
|
104
|
+
}
|
|
105
|
+
// Node status styling
|
|
106
|
+
const statusNodes = groupByStatus(state);
|
|
107
|
+
if (statusNodes.size > 0) {
|
|
108
|
+
lines.push("");
|
|
109
|
+
lines.push(" classDef current fill:#3b82f6,stroke:#2563eb,color:#fff,stroke-width:2px");
|
|
110
|
+
lines.push(" classDef success fill:#22c55e,stroke:#16a34a,color:#fff,stroke-width:2px");
|
|
111
|
+
lines.push(" classDef failed fill:#ef4444,stroke:#dc2626,color:#fff,stroke-width:2px");
|
|
112
|
+
lines.push(" classDef skipped fill:#6b7280,stroke:#4b5563,color:#fff,stroke-dasharray:5 5");
|
|
113
|
+
for (const [status, ids] of statusNodes) {
|
|
114
|
+
const sanitized = ids.map(sanitizeId).join(",");
|
|
115
|
+
lines.push(` class ${sanitized} ${status}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Edge styling — taken edges bold green, not-taken edges dashed gray
|
|
119
|
+
if (trace) {
|
|
120
|
+
lines.push("");
|
|
121
|
+
if (takenIndices.length > 0) {
|
|
122
|
+
lines.push(` linkStyle ${takenIndices.join(",")} stroke:#22c55e,stroke-width:3px`);
|
|
123
|
+
}
|
|
124
|
+
if (notTakenIndices.length > 0) {
|
|
125
|
+
lines.push(` linkStyle ${notTakenIndices.join(",")} stroke:#6b7280,stroke-width:1px,stroke-dasharray:5 5`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return lines.join("\n");
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Convenience: wrap toMermaid output in a fenced code block
|
|
132
|
+
* ready for GitHub markdown.
|
|
133
|
+
*/
|
|
134
|
+
export function toMermaidBlock(workflow, options = {}) {
|
|
135
|
+
return "```mermaid\n" + toMermaid(workflow, options) + "\n```";
|
|
136
|
+
}
|
|
137
|
+
// ─── Helpers ───────────────────────────────────────────────────────
|
|
138
|
+
/** Mermaid node IDs must be alphanumeric + underscores + hyphens */
|
|
139
|
+
function sanitizeId(id) {
|
|
140
|
+
return id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
141
|
+
}
|
|
142
|
+
/** Escape characters that break Mermaid labels */
|
|
143
|
+
function escapeLabel(text) {
|
|
144
|
+
return text.replace(/"/g, """).replace(/\[/g, "[").replace(/\]/g, "]");
|
|
145
|
+
}
|
|
146
|
+
/** Group node IDs by their status */
|
|
147
|
+
function groupByStatus(state) {
|
|
148
|
+
const groups = new Map();
|
|
149
|
+
for (const [id, status] of Object.entries(state)) {
|
|
150
|
+
let list = groups.get(status);
|
|
151
|
+
if (!list) {
|
|
152
|
+
list = [];
|
|
153
|
+
groups.set(status, list);
|
|
154
|
+
}
|
|
155
|
+
list.push(id);
|
|
156
|
+
}
|
|
157
|
+
return groups;
|
|
158
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { toMermaid, toMermaidBlock } from "./mermaid.js";
|
|
3
|
+
import { triageWorkflow, implementWorkflow } from "./workflows/index.js";
|
|
4
|
+
// ─── Fixtures ──────────────────────────────────────────────────────
|
|
5
|
+
const simple = {
|
|
6
|
+
id: "simple",
|
|
7
|
+
name: "Simple",
|
|
8
|
+
description: "A→B→C",
|
|
9
|
+
entry: "a",
|
|
10
|
+
nodes: {
|
|
11
|
+
a: { name: "Triage", instruction: "Triage the alert", skills: ["github"] },
|
|
12
|
+
b: { name: "Investigate", instruction: "Investigate the issue", skills: ["sentry"] },
|
|
13
|
+
c: { name: "Report", instruction: "Write report", skills: ["slack"] },
|
|
14
|
+
},
|
|
15
|
+
edges: [
|
|
16
|
+
{ from: "a", to: "b" },
|
|
17
|
+
{ from: "b", to: "c" },
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
const branching = {
|
|
21
|
+
id: "branching",
|
|
22
|
+
name: "Branching",
|
|
23
|
+
description: "Conditional routing",
|
|
24
|
+
entry: "triage",
|
|
25
|
+
nodes: {
|
|
26
|
+
triage: { name: "Triage", instruction: "Classify severity", skills: [] },
|
|
27
|
+
investigate: { name: "Investigate", instruction: "Deep dive", skills: [] },
|
|
28
|
+
notify: { name: "Notify", instruction: "Send alert", skills: [] },
|
|
29
|
+
close: { name: "Close", instruction: "Close ticket", skills: [] },
|
|
30
|
+
},
|
|
31
|
+
edges: [
|
|
32
|
+
{ from: "triage", to: "investigate", when: "severity is high or critical" },
|
|
33
|
+
{ from: "triage", to: "notify", when: "severity is low" },
|
|
34
|
+
{ from: "investigate", to: "close" },
|
|
35
|
+
{ from: "notify", to: "close" },
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
const withLoop = {
|
|
39
|
+
id: "loop",
|
|
40
|
+
name: "Loop",
|
|
41
|
+
description: "Retry loop",
|
|
42
|
+
entry: "a",
|
|
43
|
+
nodes: {
|
|
44
|
+
a: { name: "Try", instruction: "Attempt fix", skills: [] },
|
|
45
|
+
b: { name: "Done", instruction: "Wrap up", skills: [] },
|
|
46
|
+
},
|
|
47
|
+
edges: [
|
|
48
|
+
{ from: "a", to: "a", when: "needs retry", max_iterations: 3 },
|
|
49
|
+
{ from: "a", to: "b" },
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
// ─── Tests ─────────────────────────────────────────────────────────
|
|
53
|
+
describe("toMermaid", () => {
|
|
54
|
+
it("renders a simple linear workflow", () => {
|
|
55
|
+
const result = toMermaid(simple);
|
|
56
|
+
expect(result).toContain("graph TB");
|
|
57
|
+
// Entry node gets stadium shape
|
|
58
|
+
expect(result).toContain("a([Triage \\u25B6])");
|
|
59
|
+
// Non-entry nodes get default shape
|
|
60
|
+
expect(result).toContain("b[Investigate]");
|
|
61
|
+
expect(result).toContain("c[Report]");
|
|
62
|
+
// Edges
|
|
63
|
+
expect(result).toContain("a --> b");
|
|
64
|
+
expect(result).toContain("b --> c");
|
|
65
|
+
});
|
|
66
|
+
it("renders conditional edges with labels", () => {
|
|
67
|
+
const result = toMermaid(branching);
|
|
68
|
+
expect(result).toContain('triage -->|"severity is high or critical"| investigate');
|
|
69
|
+
expect(result).toContain('triage -->|"severity is low"| notify');
|
|
70
|
+
expect(result).toContain("investigate --> close");
|
|
71
|
+
expect(result).toContain("notify --> close");
|
|
72
|
+
});
|
|
73
|
+
it("renders max_iterations on edges", () => {
|
|
74
|
+
const result = toMermaid(withLoop);
|
|
75
|
+
expect(result).toContain('a -->|"needs retry · max 3x"| a');
|
|
76
|
+
expect(result).toContain("a --> b");
|
|
77
|
+
});
|
|
78
|
+
it("renders max_iterations without condition", () => {
|
|
79
|
+
const wf = {
|
|
80
|
+
...simple,
|
|
81
|
+
edges: [
|
|
82
|
+
{ from: "a", to: "b", max_iterations: 5 },
|
|
83
|
+
{ from: "b", to: "c" },
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
const result = toMermaid(wf);
|
|
87
|
+
expect(result).toContain('a -->|"max 5x"| b');
|
|
88
|
+
});
|
|
89
|
+
it("applies execution state styling", () => {
|
|
90
|
+
const result = toMermaid(simple, {
|
|
91
|
+
state: {
|
|
92
|
+
a: "success",
|
|
93
|
+
b: "current",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
expect(result).toContain("classDef current fill:#3b82f6");
|
|
97
|
+
expect(result).toContain("classDef success fill:#22c55e");
|
|
98
|
+
expect(result).toContain("class a success");
|
|
99
|
+
expect(result).toContain("class b current");
|
|
100
|
+
// c has no state — no class applied
|
|
101
|
+
expect(result).not.toContain("class c");
|
|
102
|
+
});
|
|
103
|
+
it("applies failed and skipped styling", () => {
|
|
104
|
+
const result = toMermaid(simple, {
|
|
105
|
+
state: {
|
|
106
|
+
a: "success",
|
|
107
|
+
b: "failed",
|
|
108
|
+
c: "skipped",
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
expect(result).toContain("classDef failed fill:#ef4444");
|
|
112
|
+
expect(result).toContain("classDef skipped fill:#6b7280");
|
|
113
|
+
expect(result).toContain("class b failed");
|
|
114
|
+
expect(result).toContain("class c skipped");
|
|
115
|
+
});
|
|
116
|
+
it("omits style block when no state provided", () => {
|
|
117
|
+
const result = toMermaid(simple);
|
|
118
|
+
expect(result).not.toContain("classDef");
|
|
119
|
+
expect(result).not.toContain("class ");
|
|
120
|
+
});
|
|
121
|
+
it("supports LR direction", () => {
|
|
122
|
+
const result = toMermaid(simple, { direction: "LR" });
|
|
123
|
+
expect(result).toContain("graph LR");
|
|
124
|
+
});
|
|
125
|
+
it("supports title", () => {
|
|
126
|
+
const result = toMermaid(simple, { title: "My Workflow" });
|
|
127
|
+
expect(result).toContain("---");
|
|
128
|
+
expect(result).toContain("title: My Workflow");
|
|
129
|
+
});
|
|
130
|
+
it("sanitizes node IDs with special characters", () => {
|
|
131
|
+
const wf = {
|
|
132
|
+
id: "special",
|
|
133
|
+
name: "Special",
|
|
134
|
+
description: "",
|
|
135
|
+
entry: "step.one",
|
|
136
|
+
nodes: {
|
|
137
|
+
"step.one": { name: "Step One", instruction: "Do it", skills: [] },
|
|
138
|
+
"step two": { name: "Step Two", instruction: "Do it", skills: [] },
|
|
139
|
+
},
|
|
140
|
+
edges: [{ from: "step.one", to: "step two" }],
|
|
141
|
+
};
|
|
142
|
+
const result = toMermaid(wf);
|
|
143
|
+
expect(result).toContain("step_one");
|
|
144
|
+
expect(result).toContain("step_two");
|
|
145
|
+
expect(result).not.toContain("step.one");
|
|
146
|
+
expect(result).not.toContain("step two");
|
|
147
|
+
});
|
|
148
|
+
it("escapes special characters in labels", () => {
|
|
149
|
+
const wf = {
|
|
150
|
+
id: "escape",
|
|
151
|
+
name: "Escape",
|
|
152
|
+
description: "",
|
|
153
|
+
entry: "a",
|
|
154
|
+
nodes: {
|
|
155
|
+
a: { name: 'Say "hello"', instruction: "x", skills: [] },
|
|
156
|
+
b: { name: "Done", instruction: "x", skills: [] },
|
|
157
|
+
},
|
|
158
|
+
edges: [{ from: "a", to: "b" }],
|
|
159
|
+
};
|
|
160
|
+
const result = toMermaid(wf);
|
|
161
|
+
expect(result).toContain(""");
|
|
162
|
+
expect(result).not.toContain('Say "hello"');
|
|
163
|
+
});
|
|
164
|
+
it("groups multiple nodes with same status", () => {
|
|
165
|
+
const result = toMermaid(simple, {
|
|
166
|
+
state: { a: "success", b: "success", c: "current" },
|
|
167
|
+
});
|
|
168
|
+
expect(result).toContain("class a,b success");
|
|
169
|
+
expect(result).toContain("class c current");
|
|
170
|
+
});
|
|
171
|
+
it("highlights taken edges and dims not-taken edges with trace", () => {
|
|
172
|
+
const trace = {
|
|
173
|
+
steps: [
|
|
174
|
+
{ node: "triage", status: "success", iteration: 1 },
|
|
175
|
+
{ node: "investigate", status: "success", iteration: 1 },
|
|
176
|
+
{ node: "close", status: "success", iteration: 1 },
|
|
177
|
+
],
|
|
178
|
+
edges: [
|
|
179
|
+
{ from: "triage", to: "investigate", reason: "severity is high or critical" },
|
|
180
|
+
{ from: "investigate", to: "close", reason: "only path" },
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
const result = toMermaid(branching, { trace });
|
|
184
|
+
// Taken edges get green bold styling (edges 0 and 2 in the workflow)
|
|
185
|
+
expect(result).toContain("linkStyle");
|
|
186
|
+
expect(result).toContain("stroke:#22c55e,stroke-width:3px");
|
|
187
|
+
// Not-taken edges get dashed gray
|
|
188
|
+
expect(result).toContain("stroke:#6b7280,stroke-width:1px,stroke-dasharray:5 5");
|
|
189
|
+
});
|
|
190
|
+
it("shows iteration count on nodes and edges from trace", () => {
|
|
191
|
+
const trace = {
|
|
192
|
+
steps: [
|
|
193
|
+
{ node: "a", status: "success", iteration: 1 },
|
|
194
|
+
{ node: "a", status: "success", iteration: 2 },
|
|
195
|
+
{ node: "b", status: "success", iteration: 1 },
|
|
196
|
+
],
|
|
197
|
+
edges: [
|
|
198
|
+
{ from: "a", to: "a", reason: "needs retry" },
|
|
199
|
+
{ from: "a", to: "b", reason: "only path" },
|
|
200
|
+
],
|
|
201
|
+
};
|
|
202
|
+
const result = toMermaid(withLoop, { trace });
|
|
203
|
+
// Node a ran twice — should show ×2
|
|
204
|
+
expect(result).toContain("×2");
|
|
205
|
+
// Node b ran once — no iteration tag
|
|
206
|
+
expect(result).not.toContain("Done ×");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
describe("builtin workflows", () => {
|
|
210
|
+
it("renders triage workflow", () => {
|
|
211
|
+
const result = toMermaid(triageWorkflow);
|
|
212
|
+
expect(result).toContain("graph TB");
|
|
213
|
+
// Every node should appear
|
|
214
|
+
for (const [id, node] of Object.entries(triageWorkflow.nodes)) {
|
|
215
|
+
expect(result).toContain(node.name);
|
|
216
|
+
}
|
|
217
|
+
// Every edge should appear
|
|
218
|
+
for (const edge of triageWorkflow.edges) {
|
|
219
|
+
expect(result).toContain(edge.from);
|
|
220
|
+
expect(result).toContain(edge.to);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
it("renders implement workflow", () => {
|
|
224
|
+
const result = toMermaid(implementWorkflow);
|
|
225
|
+
expect(result).toContain("graph TB");
|
|
226
|
+
for (const node of Object.values(implementWorkflow.nodes)) {
|
|
227
|
+
expect(result).toContain(node.name);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
it("renders triage with execution state", () => {
|
|
231
|
+
const ids = Object.keys(triageWorkflow.nodes);
|
|
232
|
+
const state = {};
|
|
233
|
+
state[ids[0]] = "success";
|
|
234
|
+
if (ids[1])
|
|
235
|
+
state[ids[1]] = "current";
|
|
236
|
+
const result = toMermaid(triageWorkflow, { state, title: triageWorkflow.name });
|
|
237
|
+
expect(result).toContain("classDef success");
|
|
238
|
+
expect(result).toContain("classDef current");
|
|
239
|
+
expect(result).toContain(`class ${ids[0]} success`);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
describe("toMermaidBlock", () => {
|
|
243
|
+
it("wraps in fenced code block", () => {
|
|
244
|
+
const result = toMermaidBlock(simple);
|
|
245
|
+
expect(result).toMatch(/^```mermaid\n/);
|
|
246
|
+
expect(result).toMatch(/\n```$/);
|
|
247
|
+
expect(result).toContain("graph TB");
|
|
248
|
+
});
|
|
249
|
+
it("passes options through", () => {
|
|
250
|
+
const result = toMermaidBlock(simple, { direction: "LR", state: { a: "success" } });
|
|
251
|
+
expect(result).toContain("graph LR");
|
|
252
|
+
expect(result).toContain("class a success");
|
|
253
|
+
});
|
|
254
|
+
});
|
package/dist/schema.d.ts
CHANGED
|
@@ -109,12 +109,12 @@ export declare const nodeZ: z.ZodObject<{
|
|
|
109
109
|
output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
110
110
|
}, "strip", z.ZodTypeAny, {
|
|
111
111
|
skills: string[];
|
|
112
|
-
name: string;
|
|
113
112
|
instruction: string;
|
|
113
|
+
name: string;
|
|
114
114
|
output?: Record<string, unknown> | undefined;
|
|
115
115
|
}, {
|
|
116
|
-
name: string;
|
|
117
116
|
instruction: string;
|
|
117
|
+
name: string;
|
|
118
118
|
skills?: string[] | undefined;
|
|
119
119
|
output?: Record<string, unknown> | undefined;
|
|
120
120
|
}>;
|
|
@@ -124,13 +124,13 @@ export declare const edgeZ: z.ZodObject<{
|
|
|
124
124
|
when: z.ZodOptional<z.ZodString>;
|
|
125
125
|
max_iterations: z.ZodOptional<z.ZodNumber>;
|
|
126
126
|
}, "strip", z.ZodTypeAny, {
|
|
127
|
-
from: string;
|
|
128
127
|
to: string;
|
|
128
|
+
from: string;
|
|
129
129
|
when?: string | undefined;
|
|
130
130
|
max_iterations?: number | undefined;
|
|
131
131
|
}, {
|
|
132
|
-
from: string;
|
|
133
132
|
to: string;
|
|
133
|
+
from: string;
|
|
134
134
|
when?: string | undefined;
|
|
135
135
|
max_iterations?: number | undefined;
|
|
136
136
|
}>;
|
|
@@ -145,12 +145,12 @@ export declare const workflowZ: z.ZodObject<{
|
|
|
145
145
|
output: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
146
146
|
}, "strip", z.ZodTypeAny, {
|
|
147
147
|
skills: string[];
|
|
148
|
-
name: string;
|
|
149
148
|
instruction: string;
|
|
149
|
+
name: string;
|
|
150
150
|
output?: Record<string, unknown> | undefined;
|
|
151
151
|
}, {
|
|
152
|
-
name: string;
|
|
153
152
|
instruction: string;
|
|
153
|
+
name: string;
|
|
154
154
|
skills?: string[] | undefined;
|
|
155
155
|
output?: Record<string, unknown> | undefined;
|
|
156
156
|
}>>;
|
|
@@ -160,13 +160,13 @@ export declare const workflowZ: z.ZodObject<{
|
|
|
160
160
|
when: z.ZodOptional<z.ZodString>;
|
|
161
161
|
max_iterations: z.ZodOptional<z.ZodNumber>;
|
|
162
162
|
}, "strip", z.ZodTypeAny, {
|
|
163
|
-
from: string;
|
|
164
163
|
to: string;
|
|
164
|
+
from: string;
|
|
165
165
|
when?: string | undefined;
|
|
166
166
|
max_iterations?: number | undefined;
|
|
167
167
|
}, {
|
|
168
|
-
from: string;
|
|
169
168
|
to: string;
|
|
169
|
+
from: string;
|
|
170
170
|
when?: string | undefined;
|
|
171
171
|
max_iterations?: number | undefined;
|
|
172
172
|
}>, "many">;
|
|
@@ -177,13 +177,13 @@ export declare const workflowZ: z.ZodObject<{
|
|
|
177
177
|
description: string;
|
|
178
178
|
nodes: Record<string, {
|
|
179
179
|
skills: string[];
|
|
180
|
-
name: string;
|
|
181
180
|
instruction: string;
|
|
181
|
+
name: string;
|
|
182
182
|
output?: Record<string, unknown> | undefined;
|
|
183
183
|
}>;
|
|
184
184
|
edges: {
|
|
185
|
-
from: string;
|
|
186
185
|
to: string;
|
|
186
|
+
from: string;
|
|
187
187
|
when?: string | undefined;
|
|
188
188
|
max_iterations?: number | undefined;
|
|
189
189
|
}[];
|
|
@@ -192,14 +192,14 @@ export declare const workflowZ: z.ZodObject<{
|
|
|
192
192
|
id: string;
|
|
193
193
|
name: string;
|
|
194
194
|
nodes: Record<string, {
|
|
195
|
-
name: string;
|
|
196
195
|
instruction: string;
|
|
196
|
+
name: string;
|
|
197
197
|
skills?: string[] | undefined;
|
|
198
198
|
output?: Record<string, unknown> | undefined;
|
|
199
199
|
}>;
|
|
200
200
|
edges: {
|
|
201
|
-
from: string;
|
|
202
201
|
to: string;
|
|
202
|
+
from: string;
|
|
203
203
|
when?: string | undefined;
|
|
204
204
|
max_iterations?: number | undefined;
|
|
205
205
|
}[];
|
|
@@ -213,20 +213,20 @@ export declare function parseWorkflow(raw: unknown): {
|
|
|
213
213
|
description: string;
|
|
214
214
|
nodes: Record<string, {
|
|
215
215
|
skills: string[];
|
|
216
|
-
name: string;
|
|
217
216
|
instruction: string;
|
|
217
|
+
name: string;
|
|
218
218
|
output?: Record<string, unknown> | undefined;
|
|
219
219
|
}>;
|
|
220
220
|
edges: {
|
|
221
|
-
from: string;
|
|
222
221
|
to: string;
|
|
222
|
+
from: string;
|
|
223
223
|
when?: string | undefined;
|
|
224
224
|
max_iterations?: number | undefined;
|
|
225
225
|
}[];
|
|
226
226
|
entry: string;
|
|
227
227
|
};
|
|
228
228
|
export interface WorkflowError {
|
|
229
|
-
code: "MISSING_ENTRY" | "UNKNOWN_EDGE_SOURCE" | "UNKNOWN_EDGE_TARGET" | "UNREACHABLE_NODE" | "UNKNOWN_SKILL" | "SELF_LOOP" | "UNBOUNDED_CYCLE"
|
|
229
|
+
code: "MISSING_ENTRY" | "UNKNOWN_EDGE_SOURCE" | "UNKNOWN_EDGE_TARGET" | "UNREACHABLE_NODE" | "UNKNOWN_SKILL" | "SELF_LOOP" | "UNBOUNDED_CYCLE";
|
|
230
230
|
message: string;
|
|
231
231
|
nodeId?: string;
|
|
232
232
|
}
|
|
@@ -238,8 +238,11 @@ export interface WorkflowError {
|
|
|
238
238
|
* - All edge sources and targets reference existing nodes
|
|
239
239
|
* - No self-loops
|
|
240
240
|
* - All nodes are reachable from entry
|
|
241
|
-
* -
|
|
241
|
+
* - Unbounded cycles (cycles with no max_iterations guard)
|
|
242
242
|
* - (optional) All referenced skills exist in the provided skill set
|
|
243
|
+
*
|
|
244
|
+
* Note: nodes with no outgoing edges are valid terminal nodes — the executor
|
|
245
|
+
* returns null when it reaches one, ending the workflow cleanly.
|
|
243
246
|
*/
|
|
244
247
|
export declare function validateWorkflow(workflow: z.infer<typeof workflowZ>, knownSkills?: Set<string>): WorkflowError[];
|
|
245
248
|
/**
|
package/dist/schema.js
CHANGED
|
@@ -65,8 +65,11 @@ export function parseWorkflow(raw) {
|
|
|
65
65
|
* - All edge sources and targets reference existing nodes
|
|
66
66
|
* - No self-loops
|
|
67
67
|
* - All nodes are reachable from entry
|
|
68
|
-
* -
|
|
68
|
+
* - Unbounded cycles (cycles with no max_iterations guard)
|
|
69
69
|
* - (optional) All referenced skills exist in the provided skill set
|
|
70
|
+
*
|
|
71
|
+
* Note: nodes with no outgoing edges are valid terminal nodes — the executor
|
|
72
|
+
* returns null when it reaches one, ending the workflow cleanly.
|
|
70
73
|
*/
|
|
71
74
|
export function validateWorkflow(workflow, knownSkills) {
|
|
72
75
|
const errors = [];
|
package/dist/skills/github.js
CHANGED
|
@@ -123,15 +123,29 @@ export const github = {
|
|
|
123
123
|
},
|
|
124
124
|
required: ["repo", "title", "head"],
|
|
125
125
|
},
|
|
126
|
-
handler: async (input, ctx) =>
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
126
|
+
handler: async (input, ctx) => {
|
|
127
|
+
const pr = (await gh(`/repos/${input.repo}/pulls`, ctx, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
body: JSON.stringify({
|
|
130
|
+
title: input.title,
|
|
131
|
+
body: input.body,
|
|
132
|
+
head: input.head,
|
|
133
|
+
base: input.base ?? "main",
|
|
134
|
+
}),
|
|
135
|
+
}));
|
|
136
|
+
try {
|
|
137
|
+
if (pr.number) {
|
|
138
|
+
await gh(`/repos/${input.repo}/issues/${pr.number}/labels`, ctx, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
body: JSON.stringify({ labels: ["sweny"] }),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// Label failure is non-fatal
|
|
146
|
+
}
|
|
147
|
+
return pr;
|
|
148
|
+
},
|
|
135
149
|
},
|
|
136
150
|
{
|
|
137
151
|
name: "github_list_recent_commits",
|
package/dist/types.d.ts
CHANGED
|
@@ -107,6 +107,36 @@ export type ExecutionEvent = {
|
|
|
107
107
|
results: Record<string, NodeResult>;
|
|
108
108
|
};
|
|
109
109
|
export type Observer = (event: ExecutionEvent) => void;
|
|
110
|
+
/** A single node execution within the trace */
|
|
111
|
+
export interface TraceStep {
|
|
112
|
+
/** Node ID */
|
|
113
|
+
node: string;
|
|
114
|
+
/** Outcome of this execution */
|
|
115
|
+
status: "success" | "failed" | "skipped";
|
|
116
|
+
/** 1-based iteration count (2 = second time this node ran) */
|
|
117
|
+
iteration: number;
|
|
118
|
+
}
|
|
119
|
+
/** A routing decision between nodes */
|
|
120
|
+
export interface TraceEdge {
|
|
121
|
+
from: string;
|
|
122
|
+
to: string;
|
|
123
|
+
/** Why this edge was chosen (condition text or "only path") */
|
|
124
|
+
reason: string;
|
|
125
|
+
}
|
|
126
|
+
/** Full execution trace — ordered steps + edges taken */
|
|
127
|
+
export interface ExecutionTrace {
|
|
128
|
+
/** Ordered list of node executions (includes repeats from retry loops) */
|
|
129
|
+
steps: TraceStep[];
|
|
130
|
+
/** Ordered list of routing decisions */
|
|
131
|
+
edges: TraceEdge[];
|
|
132
|
+
}
|
|
133
|
+
/** Result of execute() — final node results + full execution trace */
|
|
134
|
+
export interface ExecutionResult {
|
|
135
|
+
/** Final result per node (last execution if retried) */
|
|
136
|
+
results: Map<string, NodeResult>;
|
|
137
|
+
/** Full execution trace including loops and routing decisions */
|
|
138
|
+
trace: ExecutionTrace;
|
|
139
|
+
}
|
|
110
140
|
export interface Claude {
|
|
111
141
|
/** Run a node: give Claude an instruction, context, and tools */
|
|
112
142
|
run(opts: {
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export const triageWorkflow = {"id":"triage","name":"Alert Triage","description":"Investigate a production alert, determine root cause, create an issue, implement a fix, and notify the team","entry":"prepare","nodes":{"prepare":{"name":"Load Rules & Context","instruction":"You are preparing for a triage workflow. Your job is to fetch and review any knowledge documents listed in the input.\n\n1. Check input for `rulesUrls`, `contextUrls`, `rules_urls`, and `context_urls` arrays (any of these may be present).\n2. For each URL, fetch its content:\n - Linear document URLs → use the Linear MCP tools (get_document or search_documentation)\n - Other HTTP URLs → fetch directly\n3. Summarize the key rules and context that downstream workflow nodes should follow.\n4. Output the consolidated information so it's available to all subsequent steps.\n\nIf there are no URLs to fetch, just pass through — this step is a no-op.","skills":["linear"]},"gather":{"name":"Gather Context","instruction":"You are investigating a production alert. Gather all relevant context using the available tools:\n\n1. **Observability**: Pull error details, stack traces, recent logs, metrics, and active incidents around the time of the alert. Use whichever observability tools are available to you.\n2. **Source control**: Check recent commits, pull requests, and deploys that might be related.\n3. **Issue tracker**: Search for similar past issues or known problems.\n\nBe thorough — the investigation step depends on complete context. Use every tool available to you.","skills":["github","sentry","datadog","betterstack","linear"]},"investigate":{"name":"Root Cause Analysis","instruction":"Based on the gathered context, classify every distinct issue you found into one of two buckets: **novel** or **duplicate**.\n\nFor EACH issue found:\n1. Identify the root cause and affected code/service.\n2. Assess severity: critical (service down), high (major feature broken), medium (degraded), low (cosmetic/minor).\n3. Assess fix complexity: \"simple\" (a few lines, clear change), \"moderate\" (multiple files but well-understood), or \"complex\" (architectural, risky, or unclear).\n4. **Novelty check (REQUIRED):** Search the issue tracker for existing issues (BOTH open AND closed) that cover the same root cause, error pattern, or affected service. Use github_search_issues and/or linear_search_issues with multiple keyword variations.\n - A match = same root cause, same error message/pattern, or a human would call it \"the same bug.\"\n - If matched → it's a **duplicate**. Record the existing issue ID.\n - If no match → it's **novel**.\n\n**Output rules:**\n- `findings`: array of ALL issues found (both novel and duplicate).\n- `novel_count`: how many findings are novel (not duplicates).\n- `highest_severity`: the highest severity across ALL findings.\n- `recommendation`: what should happen next.\n\nDownstream nodes will act ONLY on novel findings. Duplicates will be +1'd automatically.","skills":["github","linear"],"output":{"type":"object","properties":{"findings":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string","description":"Short description of the issue"},"root_cause":{"type":"string"},"severity":{"type":"string","enum":["critical","high","medium","low"]},"affected_services":{"type":"array","items":{"type":"string"}},"is_duplicate":{"type":"boolean"},"duplicate_of":{"type":"string","description":"Existing issue ID/URL if duplicate"},"fix_approach":{"type":"string"},"fix_complexity":{"type":"string","enum":["simple","moderate","complex"]}},"required":["title","root_cause","severity","is_duplicate"]}},"novel_count":{"type":"number","description":"Count of novel (non-duplicate) findings"},"highest_severity":{"type":"string","enum":["critical","high","medium","low"]},"recommendation":{"type":"string"}},"required":["findings","novel_count","highest_severity","recommendation"]}},"create_issue":{"name":"Create Issues & Triage Duplicates","instruction":"Process ALL findings from the investigation. The findings array contains both novel and duplicate issues.\n\n**For each NOVEL finding** (is_duplicate = false):\n1. Create a new issue with a clear, actionable title.\n2. Include: root cause, severity, affected services, reproduction steps, and recommended fix.\n3. Add appropriate labels (bug, severity level, affected service).\n4. Link to relevant commits, PRs, or existing issues.\n\n**For each DUPLICATE finding** (is_duplicate = true):\n1. Find the existing issue (check duplicate_of field).\n2. Check the issue's comments — if the most recent comment is already from SWEny (contains \"+1\") within the last 24 hours, skip adding another comment.\n3. Otherwise add a SHORT comment: \"+1 — seen again {UTC timestamp}. {one sentence of new context}.\" (Keep it under 2 lines. No markdown headers, no emoji, no formatting.)\n4. If the existing issue is closed/done, reopen it.\n\nIf context.issueTemplate is provided, use it as the format for new issue bodies. Otherwise use a clear structure with: Summary, Root Cause, Impact, Steps to Reproduce, and Recommended Fix.\n\nUse the issue tracker specified in context (e.g. Linear, Jira, or GitHub Issues) — do NOT fall back to a different tracker. Output the created/updated issue identifiers.","skills":["linear","github"]},"skip":{"name":"Skip — All Duplicates or Low Priority","instruction":"Every finding from the investigation was either a duplicate or low-priority. No new issues need to be created.\n\nFor each **duplicate** finding (check the findings array for items where is_duplicate = true):\n1. Find the existing issue (check duplicate_of field).\n2. Check the issue's comments — if the most recent comment is already from SWEny (contains \"+1\") within the last 24 hours, skip adding another comment.\n3. Otherwise add a SHORT comment: \"+1 — seen again {UTC timestamp}. {one sentence of new context}.\" (Keep it under 2 lines. No markdown headers, no emoji, no formatting.)\n4. If the issue is closed/done, reopen it.\n\nFor **low priority** findings, log a brief note about why they were skipped.","skills":["linear","github"]},"implement":{"name":"Implement Fix","instruction":"Implement the fix identified during investigation:\n\n1. Create a feature branch from the base branch (check context for baseBranch, default \"main\").\n2. Read the relevant source files to understand the current code.\n3. Make the necessary code changes — fix the bug, nothing more.\n4. Run any existing tests if available to verify the fix doesn't break anything.\n5. Stage and commit with a clear commit message referencing the issue.\n\nKeep changes minimal and focused. Do not refactor surrounding code or add unrelated improvements.\n\nIf the fix turns out to be more complex than expected, stop and explain why — do not force a bad fix.","skills":["github"]},"create_pr":{"name":"Open Pull Request","instruction":"Open a pull request for the fix:\n\n1. Push the branch to the remote.\n2. Create a PR with a clear title referencing the issue (e.g. \"[OFF-1020] fix: guard empty pdf_texts before access\").\n3. In the PR body, include: summary of the bug, what the fix does, and a link to the issue.\n4. Add appropriate labels if available.\n\nIf context.prTemplate is provided, use it as the format for the PR body. Otherwise use a clear structure with: Summary, Changes, Testing, and Related Issues.\n\nReturn the PR URL and number.","skills":["github"]},"notify":{"name":"Notify Team","instruction":"Send a notification summarizing the triage result:\n\n1. Include: alert summary, severity, root cause (1-2 sentences), and links to any created issues or PRs.\n2. For critical/high severity, make the notification urgent.\n3. For medium/low, a standard notification is fine.\n\nUse whichever notification channel is available to you.","skills":["slack","notification"]}},"edges":[{"from":"prepare","to":"gather"},{"from":"gather","to":"investigate"},{"from":"investigate","to":"create_issue","when":"novel_count is greater than 0 AND highest_severity is medium or higher"},{"from":"investigate","to":"skip","when":"novel_count is 0, OR highest_severity is low"},{"from":"create_issue","to":"implement","when":"at least one novel finding has fix_complexity simple or moderate AND fix_approach is provided"},{"from":"create_issue","to":"notify","when":"all novel findings have fix_complexity complex, OR no clear fix_approach"},{"from":"skip","to":"notify"},{"from":"implement","to":"create_pr"},{"from":"create_pr","to":"notify"}]};
|
|
2
|
-
export const implementWorkflow = {"id":"implement","name":"Implement Fix","description":"Implement a code fix for an issue and open a pull request","entry":"analyze","nodes":{"analyze":{"name":"Analyze Issue","instruction":"Read the issue details and understand what needs to be fixed:\n\n1. Fetch the issue from the tracker (GitHub or Linear).\n2. Read the relevant source files to understand the current code.\n3. Identify the exact files and lines that need to change.\n4. Plan the fix approach.\n\nOutput a clear analysis of what needs to change and why.","skills":["github","linear"],"output":{"type":"object","properties":{"issue_summary":{"type":"string"},"files_to_change":{"type":"array","items":{"type":"string"}},"fix_plan":{"type":"string"},"risk_level":{"type":"string","enum":["low","medium","high"]}},"required":["issue_summary","fix_plan"]}},"implement":{"name":"Implement Fix","instruction":"Implement the planned fix:\n\n1. Create a feature branch.\n2. Make the necessary code changes.\n3. Ensure changes are minimal and focused — fix the bug, nothing more.\n4. Stage and commit with a clear commit message referencing the issue.\n\nIf the fix is too risky or complex, explain why and skip.","skills":["github"]},"create_pr":{"name":"Open Pull Request","instruction":"Open a pull request for the fix:\n\n1. Push the branch to the remote.\n2. Create a PR with a clear title and description.\n3. Reference the original issue in the PR body.\n4. Add appropriate reviewers or labels if possible.\n\nIf context.prTemplate is provided, use it as the format for the PR body. Otherwise use a clear structure with: Summary, Changes, Testing, and Related Issues.\n\nReturn the PR URL.","skills":["github"]},"notify":{"name":"Notify","instruction":"Send a notification about the implementation result:\n\n1. Include: issue reference, PR link, brief summary of changes.\n2. Keep it concise — one message, not a wall of text.","skills":["slack","notification"]},"skip":{"name":"Skip — Too Complex","instruction":"The fix was determined to be too complex or risky for automated implementation.\nAdd a comment to the issue explaining what was found and why it needs manual attention.","skills":["github","linear"]}},"edges":[{"from":"analyze","to":"implement","when":"Fix risk level is low or medium and a clear plan exists"},{"from":"analyze","to":"skip","when":"Fix is too complex, risky, or unclear"},{"from":"implement","to":"create_pr"},{"from":"skip","to":"notify"},{"from":"create_pr","to":"notify"}]};
|
|
1
|
+
export const triageWorkflow = {"id":"triage","name":"Alert Triage","description":"Investigate a production alert, determine root cause, create an issue, implement a fix, and notify the team","entry":"prepare","nodes":{"prepare":{"name":"Load Rules & Context","instruction":"You are preparing for a triage workflow. Your job is to fetch and review any knowledge documents listed in the input.\n\n1. Check input for `rulesUrls`, `contextUrls`, `rules_urls`, and `context_urls` arrays (any of these may be present).\n2. For each URL, fetch its content:\n - Linear document URLs → use the Linear MCP tools (get_document or search_documentation)\n - Other HTTP URLs → fetch directly\n3. Summarize the key rules and context that downstream workflow nodes should follow.\n4. Output the consolidated information so it's available to all subsequent steps.\n\nIf there are no URLs to fetch, just pass through — this step is a no-op.","skills":["linear"]},"gather":{"name":"Gather Context","instruction":"You are investigating a production alert. Gather all relevant context using the available tools:\n\n1. **Observability**: Pull error details, stack traces, recent logs, metrics, and active incidents around the time of the alert. Use whichever observability tools are available to you.\n2. **Source control**: Check recent commits, pull requests, and deploys that might be related.\n3. **Issue tracker**: Search for similar past issues or known problems.\n\nBe thorough — the investigation step depends on complete context. Use every tool available to you.","skills":["github","sentry","datadog","betterstack","linear"]},"investigate":{"name":"Root Cause Analysis","instruction":"Based on the gathered context, classify every distinct issue you found into one of two buckets: **novel** or **duplicate**.\n\nFor EACH issue found:\n1. Identify the root cause and affected code/service.\n2. Assess severity: critical (service down), high (major feature broken), medium (degraded), low (cosmetic/minor).\n3. Assess fix complexity: \"simple\" (a few lines, clear change), \"moderate\" (multiple files but well-understood), or \"complex\" (architectural, risky, or unclear).\n4. **Novelty check (REQUIRED):** Search the issue tracker for existing issues (BOTH open AND closed) that cover the same root cause, error pattern, or affected service. Use github_search_issues and/or linear_search_issues with multiple keyword variations.\n - A match = same root cause, same error message/pattern, or a human would call it \"the same bug.\"\n - If matched → it's a **duplicate**. Record the existing issue ID.\n - If no match → it's **novel**.\n\n**Output rules:**\n- `findings`: array of ALL issues found (both novel and duplicate).\n- `novel_count`: how many findings are novel (not duplicates).\n- `highest_severity`: the highest severity across ALL findings.\n- `recommendation`: what should happen next.\n\nDownstream nodes will act ONLY on novel findings. Duplicates will be +1'd automatically.","skills":["github","linear"],"output":{"type":"object","properties":{"findings":{"type":"array","items":{"type":"object","properties":{"title":{"type":"string","description":"Short description of the issue"},"root_cause":{"type":"string"},"severity":{"type":"string","enum":["critical","high","medium","low"]},"affected_services":{"type":"array","items":{"type":"string"}},"is_duplicate":{"type":"boolean"},"duplicate_of":{"type":"string","description":"Existing issue ID/URL if duplicate"},"fix_approach":{"type":"string"},"fix_complexity":{"type":"string","enum":["simple","moderate","complex"]}},"required":["title","root_cause","severity","is_duplicate"]}},"novel_count":{"type":"number","description":"Count of novel (non-duplicate) findings"},"highest_severity":{"type":"string","enum":["critical","high","medium","low"]},"recommendation":{"type":"string"}},"required":["findings","novel_count","highest_severity","recommendation"]}},"create_issue":{"name":"Create Issues & Triage Duplicates","instruction":"Process ALL findings from the investigation. The findings array contains both novel and duplicate issues.\n\n**For each NOVEL finding** (is_duplicate = false):\n1. Create a new issue with a clear, actionable title.\n2. Include: root cause, severity, affected services, reproduction steps, and recommended fix.\n3. Add appropriate labels (bug, severity level, affected service).\n4. Link to relevant commits, PRs, or existing issues.\n\n**For each DUPLICATE finding** (is_duplicate = true):\n1. Find the existing issue (check duplicate_of field).\n2. Check the issue's comments — if the most recent comment is already from SWEny (contains \"+1\") within the last 24 hours, skip adding another comment.\n3. Otherwise add a SHORT comment: \"+1 — seen again {UTC timestamp}. {one sentence of new context}.\" (Keep it under 2 lines. No markdown headers, no emoji, no formatting.)\n4. If the existing issue is closed/done, reopen it.\n\nIf context.issueTemplate is provided, use it as the format for new issue bodies. Otherwise use a clear structure with: Summary, Root Cause, Impact, Steps to Reproduce, and Recommended Fix.\n\nUse the issue tracker specified in context (e.g. Linear, Jira, or GitHub Issues) — do NOT fall back to a different tracker. Output the created/updated issue identifiers.","skills":["linear","github"]},"skip":{"name":"Skip — All Duplicates or Low Priority","instruction":"Every finding from the investigation was either a duplicate or low-priority. No new issues need to be created.\n\nFor each **duplicate** finding (check the findings array for items where is_duplicate = true):\n1. Find the existing issue (check duplicate_of field).\n2. Check the issue's comments — if the most recent comment is already from SWEny (contains \"+1\") within the last 24 hours, skip adding another comment.\n3. Otherwise add a SHORT comment: \"+1 — seen again {UTC timestamp}. {one sentence of new context}.\" (Keep it under 2 lines. No markdown headers, no emoji, no formatting.)\n4. If the issue is closed/done, reopen it.\n\nFor **low priority** findings, log a brief note about why they were skipped.","skills":["linear","github"]},"implement":{"name":"Implement Fix","instruction":"Implement the fix identified during investigation:\n\n1. Create a feature branch from the base branch (check context for baseBranch, default \"main\").\n2. Read the relevant source files to understand the current code.\n3. Make the necessary code changes — fix the bug, nothing more.\n4. Run any existing tests if available to verify the fix doesn't break anything.\n5. Stage and commit with a clear commit message referencing the issue.\n\nKeep changes minimal and focused. Do not refactor surrounding code or add unrelated improvements.\n\nIf the fix turns out to be more complex than expected, stop and explain why — do not force a bad fix.","skills":["github"]},"create_pr":{"name":"Open Pull Request","instruction":"Open a pull request for the fix:\n\n1. Push the branch to the remote.\n2. Create a PR with a clear title referencing the issue (e.g. \"[OFF-1020] fix: guard empty pdf_texts before access\").\n3. In the PR body, include: summary of the bug, what the fix does, and a link to the issue.\n4. Add appropriate labels if available.\n\nIf context.prTemplate is provided, use it as the format for the PR body. Otherwise use a clear structure with: Summary, Changes, Testing, and Related Issues.\n\nIf context.cloudRunId is provided, append this HTML comment to the end of the PR body: <!-- sweny:run_id:{context.cloudRunId} -->\n\nReturn the PR URL and number.","skills":["github"]},"notify":{"name":"Notify Team","instruction":"Send a notification summarizing the triage result:\n\n1. Include: alert summary, severity, root cause (1-2 sentences), and links to any created issues or PRs.\n2. For critical/high severity, make the notification urgent.\n3. For medium/low, a standard notification is fine.\n\nUse whichever notification channel is available to you.","skills":["slack","notification"]}},"edges":[{"from":"prepare","to":"gather"},{"from":"gather","to":"investigate"},{"from":"investigate","to":"create_issue","when":"novel_count is greater than 0 AND highest_severity is medium or higher"},{"from":"investigate","to":"skip","when":"novel_count is 0, OR highest_severity is low"},{"from":"create_issue","to":"implement","when":"at least one novel finding has fix_complexity simple or moderate AND fix_approach is provided"},{"from":"create_issue","to":"notify","when":"all novel findings have fix_complexity complex, OR no clear fix_approach"},{"from":"skip","to":"notify"},{"from":"implement","to":"create_pr"},{"from":"create_pr","to":"notify"}]};
|
|
2
|
+
export const implementWorkflow = {"id":"implement","name":"Implement Fix","description":"Implement a code fix for an issue and open a pull request","entry":"analyze","nodes":{"analyze":{"name":"Analyze Issue","instruction":"Read the issue details and understand what needs to be fixed:\n\n1. Fetch the issue from the tracker (GitHub or Linear).\n2. Read the relevant source files to understand the current code.\n3. Identify the exact files and lines that need to change.\n4. Plan the fix approach.\n\nOutput a clear analysis of what needs to change and why.","skills":["github","linear"],"output":{"type":"object","properties":{"issue_summary":{"type":"string"},"files_to_change":{"type":"array","items":{"type":"string"}},"fix_plan":{"type":"string"},"risk_level":{"type":"string","enum":["low","medium","high"]}},"required":["issue_summary","fix_plan"]}},"implement":{"name":"Implement Fix","instruction":"Implement the planned fix:\n\n1. Create a feature branch.\n2. Make the necessary code changes.\n3. Ensure changes are minimal and focused — fix the bug, nothing more.\n4. Stage and commit with a clear commit message referencing the issue.\n\nIf the fix is too risky or complex, explain why and skip.","skills":["github"]},"create_pr":{"name":"Open Pull Request","instruction":"Open a pull request for the fix:\n\n1. Push the branch to the remote.\n2. Create a PR with a clear title and description.\n3. Reference the original issue in the PR body.\n4. Add appropriate reviewers or labels if possible.\n\nIf context.prTemplate is provided, use it as the format for the PR body. Otherwise use a clear structure with: Summary, Changes, Testing, and Related Issues.\n\nIf context.cloudRunId is provided, append this HTML comment to the end of the PR body: <!-- sweny:run_id:{context.cloudRunId} -->\n\nReturn the PR URL.","skills":["github"]},"notify":{"name":"Notify","instruction":"Send a notification about the implementation result:\n\n1. Include: issue reference, PR link, brief summary of changes.\n2. Keep it concise — one message, not a wall of text.","skills":["slack","notification"]},"skip":{"name":"Skip — Too Complex","instruction":"The fix was determined to be too complex or risky for automated implementation.\nAdd a comment to the issue explaining what was found and why it needs manual attention.","skills":["github","linear"]}},"edges":[{"from":"analyze","to":"implement","when":"Fix risk level is low or medium and a clear plan exists"},{"from":"analyze","to":"skip","when":"Fix is too complex, risky, or unclear"},{"from":"implement","to":"create_pr"},{"from":"skip","to":"notify"},{"from":"create_pr","to":"notify"}]};
|
|
3
3
|
export const seedContentWorkflow = {"id":"seed-content","name":"Content Generation Pipeline","description":"Audit content gaps, generate educational content, validate quality, and publish to Supabase","entry":"audit","nodes":{"audit":{"name":"Audit Content Gaps","instruction":"You are auditing an educational platform's content library.\n\nUse supabase_count to check how many items exist for each combination of:\n- Content types: worksheets, games, lessons\n- Subjects: math, science, english, history, geography, economics, music, art, life-skills, foreign-language\n- Difficulties: beginner, easy, medium, hard\n\nAlso check the input parameters — if the user specified a subject, topic, difficulty, and count,\nskip the full audit and just report what they want generated.\n\nReturn a structured content plan: what to generate, how many, at what difficulty and grade level.\nPrioritize gaps where a subject has ZERO content of a given type.","skills":["supabase"],"output":{"type":"object","properties":{"plan":{"type":"array","items":{"type":"object","properties":{"contentType":{"type":"string"},"subject":{"type":"string"},"topic":{"type":"string"},"difficulty":{"type":"string"},"gradeLevel":{"type":"string"},"count":{"type":"number"},"reason":{"type":"string"}},"required":["contentType","subject","topic","difficulty","count"]}},"summary":{"type":"string"}},"required":["plan","summary"]}},"generate":{"name":"Generate Content","instruction":"You are an expert educator creating content for children ages 5-14.\n\nFor each item in the content plan from the previous step, generate the content directly.\nThe content must be high-quality, engaging, age-appropriate, and educationally sound.\n\n**For worksheets:**\nGenerate a JSON array of problems. Each problem needs: \"problem\", \"answer\", \"format\" (answer-line | multiple-choice | write-on).\nRequirements:\n- Every answer MUST be correct. Double-check math, facts, spelling.\n- Problems must progress in difficulty within the set.\n- Use at least 2 different problem structures (e.g., equations + word problems for math).\n- Vary phrasing — no two problems should feel copy-pasted.\n- For the target grade level, use appropriate vocabulary and complexity.\n\n**For lessons:**\nGenerate complete HTML + CSS + JS for an interactive lesson.\nRequirements:\n- Must have substantial educational content (not just a shell with navigation).\n- Include interactive elements: quizzes, click-to-reveal, drag-and-drop.\n- Must be visually engaging with colors, icons, and clear typography.\n- Include practice exercises with immediate feedback.\n- Minimum 5 teaching sections plus 2 practice activities.\n\n**For games:**\nGenerate a description and educational requirements only (actual game code is generated\nby the platform's Phaser-based generator).\n\nUse supabase_insert to write each piece of content to the appropriate table.\n\nTable schemas:\n- worksheets: id (auto), title, subject, topic, difficulty, grade_level, num_questions, problems (jsonb), status='published', source='ai-seed'\n- lessons: id (auto), title, subject, difficulty, grade_level, lesson_type, html_code, css_code, js_code, status='published', source='ai-seed'\n- games: id (auto), title, subject, difficulty, grade_level, game_type, html_code, css_code, js_code, status='published', source='ai-seed'\n\nReturn a summary of what was generated and inserted.","skills":["supabase"],"output":{"type":"object","properties":{"generated":{"type":"array","items":{"type":"object","properties":{"contentType":{"type":"string"},"title":{"type":"string"},"subject":{"type":"string"},"id":{"type":"string"},"status":{"type":"string"}}}},"totalGenerated":{"type":"number"},"errors":{"type":"array","items":{"type":"string"}}},"required":["generated","totalGenerated"]}},"validate":{"name":"Validate Content Quality","instruction":"Review the content that was just generated and inserted.\n\nFor each item, use supabase_query to fetch it back and verify:\n\n**Worksheets:**\n- Parse the problems JSON — are all answers correct?\n- Are problems diverse (not repetitive)?\n- Is difficulty appropriate for the stated grade level?\n- Are there the right number of questions?\n\n**Lessons:**\n- Is the HTML substantial (> 500 characters)?\n- Does it contain actual educational content (not just navigation shells)?\n- Are there interactive elements?\n\n**Games:**\n- Does the HTML contain a working game structure?\n\nFlag any issues. If critical issues are found (wrong answers, empty content),\nuse supabase_update to set status='draft' and add a note.\n\nReturn a quality report.","skills":["supabase"],"output":{"type":"object","properties":{"totalReviewed":{"type":"number"},"passed":{"type":"number"},"flagged":{"type":"number"},"issues":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"issue":{"type":"string"},"severity":{"type":"string"},"action":{"type":"string"}}}},"summary":{"type":"string"}},"required":["totalReviewed","passed","flagged","summary"]}},"report":{"name":"Generate Report","instruction":"Compile a final report of the content generation run.\n\nInclude:\n- Total content generated by type and subject\n- Quality validation results\n- Any issues found and actions taken\n- Recommendations for next content generation run (what gaps remain)\n\nFormat as a clean markdown summary that a solo operator can scan in 30 seconds.","skills":[],"output":{"type":"object","properties":{"markdown":{"type":"string"},"nextPriorities":{"type":"array","items":{"type":"string"}}},"required":["markdown"]}}},"edges":[{"from":"audit","to":"generate"},{"from":"generate","to":"validate"},{"from":"validate","to":"report"}]};
|
|
@@ -239,6 +239,10 @@ nodes:
|
|
|
239
239
|
Summary, Changes, Testing, and Related Issues.
|
|
240
240
|
|
|
241
241
|
|
|
242
|
+
If context.cloudRunId is provided, append this HTML comment to the end of the PR body: <!--
|
|
243
|
+
sweny:run_id:{context.cloudRunId} -->
|
|
244
|
+
|
|
245
|
+
|
|
242
246
|
Return the PR URL and number.
|
|
243
247
|
skills:
|
|
244
248
|
- github
|