@fluentcommerce/fluent-mcp-extn 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,752 @@
1
+ /**
2
+ * Workflow management tools: workflow.upload, workflow.diff, workflow.simulate
3
+ *
4
+ * Closes the deploy loop — agents can analyze, modify, and deploy
5
+ * workflow JSON without falling back to CLI.
6
+ * workflow.simulate adds lightweight static prediction for reasoning about
7
+ * event outcomes without live execution.
8
+ */
9
+ import { z } from "zod";
10
+ import { ToolError } from "./errors.js";
11
+ // ---------------------------------------------------------------------------
12
+ // Input schemas
13
+ // ---------------------------------------------------------------------------
14
+ export const WorkflowUploadInputSchema = z.object({
15
+ workflow: z
16
+ .union([z.string(), z.record(z.string(), z.unknown())])
17
+ .describe("Workflow JSON definition (as object or JSON string). Must include name, type, version, statuses, and rulesets."),
18
+ retailerId: z
19
+ .string()
20
+ .optional()
21
+ .describe("Target retailer ID. Falls back to FLUENT_RETAILER_ID."),
22
+ validate: z
23
+ .boolean()
24
+ .default(true)
25
+ .describe("Validate workflow structure before uploading (default: true)."),
26
+ dryRun: z
27
+ .boolean()
28
+ .default(false)
29
+ .describe("If true, validate only without deploying."),
30
+ });
31
+ export const WorkflowDiffInputSchema = z.object({
32
+ base: z
33
+ .union([z.string(), z.record(z.string(), z.unknown())])
34
+ .describe("Base workflow JSON (before changes)"),
35
+ target: z
36
+ .union([z.string(), z.record(z.string(), z.unknown())])
37
+ .describe("Target workflow JSON (after changes)"),
38
+ format: z
39
+ .enum(["summary", "detailed", "mermaid"])
40
+ .default("summary")
41
+ .describe("Output format: summary (default), detailed, or mermaid"),
42
+ });
43
+ export const WorkflowSimulateInputSchema = z.object({
44
+ workflow: z
45
+ .union([z.string(), z.record(z.string(), z.unknown())])
46
+ .describe("Workflow JSON definition (as object or JSON string)."),
47
+ currentStatus: z
48
+ .string()
49
+ .describe("Current entity status to simulate from (e.g., CREATED, BOOKED)."),
50
+ eventName: z
51
+ .string()
52
+ .optional()
53
+ .describe("Optional event name to match against ruleset names. If omitted, all rulesets triggered by currentStatus are returned."),
54
+ entityType: z
55
+ .string()
56
+ .optional()
57
+ .describe("Optional entity type filter for trigger matching."),
58
+ entitySubtype: z
59
+ .string()
60
+ .optional()
61
+ .describe("Optional entity subtype filter for trigger matching."),
62
+ });
63
+ // ---------------------------------------------------------------------------
64
+ // Tool definitions (JSON Schema for MCP)
65
+ // ---------------------------------------------------------------------------
66
+ export const WORKFLOW_TOOL_DEFINITIONS = [
67
+ {
68
+ name: "workflow.upload",
69
+ description: [
70
+ "Deploy a workflow JSON definition to the Fluent environment.",
71
+ "",
72
+ "Uploads via REST API POST /api/v4.1/workflow/{retailerId} — same endpoint as the CLI.",
73
+ "",
74
+ "KEY FEATURES:",
75
+ "- Structure validation before upload (states, rulesets, triggers)",
76
+ "- dryRun mode validates without deploying",
77
+ "- Returns new version number for verification",
78
+ "- Requires retailerId (from input or FLUENT_RETAILER_ID)",
79
+ "",
80
+ "IMPORTANT: For production deployments, prefer 'fluent module install' via CLI",
81
+ "which bundles workflows with settings, rules, and data in a versioned module.",
82
+ "Use this tool for interactive editing, hotfixes, or when CLI is unavailable.",
83
+ "",
84
+ "VALIDATION checks:",
85
+ "- name field present",
86
+ "- At least one status defined",
87
+ "- All rulesets have name, triggers, and rules (warns if empty)",
88
+ ].join("\n"),
89
+ inputSchema: {
90
+ type: "object",
91
+ properties: {
92
+ workflow: {
93
+ oneOf: [
94
+ { type: "string", description: "Workflow as JSON string" },
95
+ {
96
+ type: "object",
97
+ additionalProperties: true,
98
+ description: "Workflow as JSON object",
99
+ },
100
+ ],
101
+ description: "Workflow definition to upload",
102
+ },
103
+ retailerId: {
104
+ type: "string",
105
+ description: "Target retailer ID.",
106
+ },
107
+ validate: {
108
+ type: "boolean",
109
+ description: "Validate structure before uploading (default: true).",
110
+ },
111
+ dryRun: {
112
+ type: "boolean",
113
+ description: "Validate only, do not deploy.",
114
+ },
115
+ },
116
+ required: ["workflow"],
117
+ additionalProperties: false,
118
+ },
119
+ },
120
+ {
121
+ name: "workflow.diff",
122
+ description: [
123
+ "Compare two workflow JSON definitions and identify changes.",
124
+ "",
125
+ "Pure local computation — no API calls.",
126
+ "",
127
+ "Compares: rulesets (added/removed/modified), statuses, triggers, rule props.",
128
+ "Risk assessment: removing rulesets = HIGH, adding = LOW, modifying props = MEDIUM.",
129
+ "",
130
+ "Formats:",
131
+ "- summary: change counts and risk level",
132
+ "- detailed: per-ruleset change breakdown",
133
+ "- mermaid: stateDiagram-v2 with color-coded added/removed/modified states and transitions",
134
+ ].join("\n"),
135
+ inputSchema: {
136
+ type: "object",
137
+ properties: {
138
+ base: {
139
+ oneOf: [
140
+ { type: "string" },
141
+ { type: "object", additionalProperties: true },
142
+ ],
143
+ description: "Base workflow (before)",
144
+ },
145
+ target: {
146
+ oneOf: [
147
+ { type: "string" },
148
+ { type: "object", additionalProperties: true },
149
+ ],
150
+ description: "Target workflow (after)",
151
+ },
152
+ format: {
153
+ type: "string",
154
+ enum: ["summary", "detailed", "mermaid"],
155
+ description: "Output format (default: summary)",
156
+ },
157
+ },
158
+ required: ["base", "target"],
159
+ additionalProperties: false,
160
+ },
161
+ },
162
+ {
163
+ name: "workflow.simulate",
164
+ description: [
165
+ "Static prediction of workflow event outcomes from workflow JSON.",
166
+ "",
167
+ "Pure local computation — no API calls. Parses workflow JSON to find",
168
+ "matching rulesets and predict state transitions, follow-on events, and",
169
+ "webhook side effects.",
170
+ "",
171
+ "WHAT IT DOES:",
172
+ "- Finds rulesets whose triggers match the given currentStatus (and optionally eventName)",
173
+ "- Extracts SetState rules to predict the next status",
174
+ "- Extracts SendEvent rules to predict follow-on events",
175
+ "- Extracts SendWebhook rules to identify webhook side effects",
176
+ "- Identifies custom rules that cannot be statically predicted",
177
+ "",
178
+ "IMPORTANT LIMITATIONS (always disclosed in response):",
179
+ "- STATIC ONLY: Cannot evaluate runtime conditions, entity attributes, or settings values",
180
+ "- CUSTOM RULES OPAQUE: Java plugin rules (e.g., ForwardIfOrderCoordinatesPresent) may",
181
+ " conditionally execute; their behavior cannot be predicted without live state",
182
+ "- NO CROSS-ENTITY: Does not follow SendEvent chains into child entity workflows",
183
+ "- Use workflow.transitions for AUTHORITATIVE live validation of available actions",
184
+ "",
185
+ "USE CASES:",
186
+ "- Understand workflow structure before firing events",
187
+ "- Pre-flight check: 'what rulesets would match if I send event X from status Y?'",
188
+ "- Documentation: enumerate all possible paths from a given status",
189
+ "- Debug: 'why did nothing happen?' — check if any rulesets trigger on the status",
190
+ ].join("\n"),
191
+ inputSchema: {
192
+ type: "object",
193
+ properties: {
194
+ workflow: {
195
+ oneOf: [
196
+ { type: "string", description: "Workflow as JSON string" },
197
+ {
198
+ type: "object",
199
+ additionalProperties: true,
200
+ description: "Workflow as JSON object",
201
+ },
202
+ ],
203
+ description: "Workflow JSON definition",
204
+ },
205
+ currentStatus: {
206
+ type: "string",
207
+ description: "Current entity status to simulate from (e.g., CREATED, BOOKED)",
208
+ },
209
+ eventName: {
210
+ type: "string",
211
+ description: "Optional event name to match against ruleset names. If omitted, returns all rulesets triggered by currentStatus.",
212
+ },
213
+ entityType: {
214
+ type: "string",
215
+ description: "Optional entity type filter for trigger matching.",
216
+ },
217
+ entitySubtype: {
218
+ type: "string",
219
+ description: "Optional entity subtype filter for trigger matching.",
220
+ },
221
+ },
222
+ required: ["workflow", "currentStatus"],
223
+ additionalProperties: false,
224
+ },
225
+ },
226
+ ];
227
+ function parseWorkflow(input) {
228
+ if (typeof input === "string") {
229
+ try {
230
+ return JSON.parse(input);
231
+ }
232
+ catch {
233
+ throw new ToolError("VALIDATION_ERROR", "Invalid JSON string for workflow definition.");
234
+ }
235
+ }
236
+ return input;
237
+ }
238
+ function validateWorkflowStructure(wf) {
239
+ const errors = [];
240
+ const warnings = [];
241
+ // Required top-level fields
242
+ if (!wf.name || typeof wf.name !== "string") {
243
+ errors.push("Missing or invalid 'name' field.");
244
+ }
245
+ // Check for statuses/subStatuses
246
+ const statuses = wf.statuses ?? wf.subStatuses;
247
+ if (!statuses || !Array.isArray(statuses) || statuses.length === 0) {
248
+ errors.push("Missing or empty 'statuses' array.");
249
+ }
250
+ // Check rulesets
251
+ const rulesets = wf.rulesets;
252
+ if (!rulesets || !Array.isArray(rulesets)) {
253
+ errors.push("Missing or invalid 'rulesets' array.");
254
+ }
255
+ else {
256
+ for (let i = 0; i < rulesets.length; i++) {
257
+ const rs = rulesets[i];
258
+ if (!rs)
259
+ continue;
260
+ if (!rs.name) {
261
+ errors.push(`Ruleset[${i}]: missing 'name'.`);
262
+ }
263
+ const triggers = rs.triggers;
264
+ if (!triggers || !Array.isArray(triggers) || triggers.length === 0) {
265
+ warnings.push(`Ruleset[${i}] "${rs.name ?? "unnamed"}": no triggers defined.`);
266
+ }
267
+ const rules = rs.rules;
268
+ if (!rules || !Array.isArray(rules) || rules.length === 0) {
269
+ warnings.push(`Ruleset[${i}] "${rs.name ?? "unnamed"}": no rules defined.`);
270
+ }
271
+ }
272
+ }
273
+ return {
274
+ valid: errors.length === 0,
275
+ errors,
276
+ warnings,
277
+ };
278
+ }
279
+ function extractRulesetMap(wf) {
280
+ const rulesets = wf.rulesets;
281
+ if (!rulesets || !Array.isArray(rulesets))
282
+ return new Map();
283
+ const map = new Map();
284
+ for (const rs of rulesets) {
285
+ const name = rs.name;
286
+ if (name)
287
+ map.set(name, rs);
288
+ }
289
+ return map;
290
+ }
291
+ function extractStatuses(wf) {
292
+ const statuses = (wf.statuses ?? wf.subStatuses);
293
+ if (!statuses || !Array.isArray(statuses))
294
+ return new Set();
295
+ return new Set(statuses
296
+ .map((s) => (s.name ?? s.status))
297
+ .filter(Boolean));
298
+ }
299
+ function generateMermaidDiff(base, target, diff) {
300
+ const lines = ["stateDiagram-v2"];
301
+ // Styles
302
+ lines.push(" classDef added fill:#d4edda,stroke:#28a745,color:#155724");
303
+ lines.push(" classDef removed fill:#f8d7da,stroke:#dc3545,color:#721c24");
304
+ lines.push(" classDef modified fill:#fff3cd,stroke:#ffc107,color:#856404");
305
+ // Helper to get transitions from a workflow
306
+ const getTransitions = (wf) => {
307
+ const transitions = [];
308
+ const rulesets = wf.rulesets ?? [];
309
+ for (const rs of rulesets) {
310
+ const name = rs.name;
311
+ const triggers = rs.triggers ?? [];
312
+ const rules = rs.rules ??
313
+ [];
314
+ // Find SetState rules
315
+ const setStateRules = rules.filter((r) => r.name.includes("SetState") ||
316
+ r.name.includes("ChangeState") ||
317
+ r.name.includes("Forward"));
318
+ for (const trigger of triggers) {
319
+ if (!trigger.status)
320
+ continue;
321
+ for (const rule of setStateRules) {
322
+ if (rule.props?.status) {
323
+ transitions.push({
324
+ from: trigger.status,
325
+ to: rule.props.status,
326
+ label: name,
327
+ });
328
+ }
329
+ }
330
+ }
331
+ }
332
+ return transitions;
333
+ };
334
+ const baseTransitions = getTransitions(base);
335
+ const targetTransitions = getTransitions(target);
336
+ // Helper to format transition for set comparison
337
+ const tKey = (t) => `${t.from}->${t.to}:${t.label}`;
338
+ const baseSet = new Set(baseTransitions.map(tKey));
339
+ const targetSet = new Set(targetTransitions.map(tKey));
340
+ // Render nodes (Statuses)
341
+ const allStatuses = new Set([
342
+ ...diff.statuses.added,
343
+ ...diff.statuses.removed,
344
+ ...extractStatuses(target),
345
+ ]);
346
+ for (const status of allStatuses) {
347
+ if (diff.statuses.added.includes(status)) {
348
+ lines.push(` ${status}:::added`);
349
+ }
350
+ else if (diff.statuses.removed.includes(status)) {
351
+ lines.push(` ${status}:::removed`);
352
+ }
353
+ }
354
+ // Render edges
355
+ // 1. Target transitions (Existing + New)
356
+ for (const t of targetTransitions) {
357
+ const key = tKey(t);
358
+ if (!baseSet.has(key)) {
359
+ lines.push(` ${t.from} --> ${t.to}: ${t.label} (NEW)`);
360
+ }
361
+ else {
362
+ lines.push(` ${t.from} --> ${t.to}: ${t.label}`);
363
+ }
364
+ }
365
+ // 2. Removed transitions
366
+ for (const t of baseTransitions) {
367
+ const key = tKey(t);
368
+ if (!targetSet.has(key)) {
369
+ // Only show removed transition if both nodes still exist or were removed
370
+ // (Mermaid might error if we link to a non-existent node, but we added all removed statuses above)
371
+ lines.push(` ${t.from} --> ${t.to}: ${t.label} (REMOVED)`);
372
+ }
373
+ }
374
+ return lines.join("\n");
375
+ }
376
+ function diffRuleset(base, target) {
377
+ const diffs = [];
378
+ // Compare triggers
379
+ const baseTriggers = JSON.stringify(base.triggers ?? []);
380
+ const targetTriggers = JSON.stringify(target.triggers ?? []);
381
+ if (baseTriggers !== targetTriggers) {
382
+ diffs.push("Triggers modified");
383
+ }
384
+ // Compare rules
385
+ const baseRules = base.rules;
386
+ const targetRules = target.rules;
387
+ const baseRuleNames = new Set((baseRules ?? []).map((r) => (r.name ?? r.ruleType)).filter(Boolean));
388
+ const targetRuleNames = new Set((targetRules ?? []).map((r) => (r.name ?? r.ruleType)).filter(Boolean));
389
+ for (const name of targetRuleNames) {
390
+ if (!baseRuleNames.has(name))
391
+ diffs.push(`Rule added: ${name}`);
392
+ }
393
+ for (const name of baseRuleNames) {
394
+ if (!targetRuleNames.has(name))
395
+ diffs.push(`Rule removed: ${name}`);
396
+ }
397
+ // Compare rule props
398
+ const baseRuleMap = new Map((baseRules ?? []).map((r) => [
399
+ (r.name ?? r.ruleType),
400
+ JSON.stringify(r.props ?? {}),
401
+ ]));
402
+ const targetRuleMap = new Map((targetRules ?? []).map((r) => [
403
+ (r.name ?? r.ruleType),
404
+ JSON.stringify(r.props ?? {}),
405
+ ]));
406
+ for (const [name, baseProps] of baseRuleMap) {
407
+ const targetProps = targetRuleMap.get(name);
408
+ if (targetProps && baseProps !== targetProps) {
409
+ diffs.push(`Rule props modified: ${name}`);
410
+ }
411
+ }
412
+ return diffs;
413
+ }
414
+ function computeWorkflowDiff(base, target) {
415
+ const baseRulesets = extractRulesetMap(base);
416
+ const targetRulesets = extractRulesetMap(target);
417
+ const baseStatuses = extractStatuses(base);
418
+ const targetStatuses = extractStatuses(target);
419
+ const added = [];
420
+ const removed = [];
421
+ const modified = [];
422
+ // Find added rulesets
423
+ for (const [name] of targetRulesets) {
424
+ if (!baseRulesets.has(name)) {
425
+ added.push({ name, changeType: "added" });
426
+ }
427
+ }
428
+ // Find removed rulesets
429
+ for (const [name] of baseRulesets) {
430
+ if (!targetRulesets.has(name)) {
431
+ removed.push({ name, changeType: "removed" });
432
+ }
433
+ }
434
+ // Find modified rulesets
435
+ for (const [name, targetRs] of targetRulesets) {
436
+ const baseRs = baseRulesets.get(name);
437
+ if (!baseRs)
438
+ continue;
439
+ const details = diffRuleset(baseRs, targetRs);
440
+ if (details.length > 0) {
441
+ modified.push({ name, changeType: "modified", details });
442
+ }
443
+ }
444
+ // Status diffs
445
+ const addedStatuses = [...targetStatuses].filter((s) => !baseStatuses.has(s));
446
+ const removedStatuses = [...baseStatuses].filter((s) => !targetStatuses.has(s));
447
+ // Risk assessment
448
+ let riskLevel = "low";
449
+ if (removed.length > 0 || removedStatuses.length > 0) {
450
+ riskLevel = "high";
451
+ }
452
+ else if (modified.length > 0) {
453
+ riskLevel = "medium";
454
+ }
455
+ const totalChanges = added.length + removed.length + modified.length;
456
+ const summary = [
457
+ `${totalChanges} ruleset change(s): ${added.length} added, ${removed.length} removed, ${modified.length} modified.`,
458
+ `${addedStatuses.length + removedStatuses.length} status change(s): ${addedStatuses.length} added, ${removedStatuses.length} removed.`,
459
+ `Risk level: ${riskLevel.toUpperCase()}.`,
460
+ ].join(" ");
461
+ return {
462
+ baseWorkflow: base.name ?? "unknown",
463
+ targetWorkflow: target.name ?? "unknown",
464
+ riskLevel,
465
+ rulesets: { added, removed, modified },
466
+ statuses: { added: addedStatuses, removed: removedStatuses },
467
+ summary,
468
+ };
469
+ }
470
+ function requireWorkflowClient(ctx) {
471
+ if (!ctx.client) {
472
+ throw new ToolError("CONFIG_ERROR", "SDK client is not available. Run config.validate and fix auth/base URL.");
473
+ }
474
+ return ctx.client;
475
+ }
476
+ /**
477
+ * Handle workflow.upload tool call.
478
+ */
479
+ export async function handleWorkflowUpload(args, ctx) {
480
+ const parsed = WorkflowUploadInputSchema.parse(args);
481
+ const wf = parseWorkflow(parsed.workflow);
482
+ // Resolve retailer ID
483
+ const retailerId = parsed.retailerId ?? ctx.config.retailerId;
484
+ if (!retailerId) {
485
+ throw new ToolError("VALIDATION_ERROR", "retailerId is required for workflow upload. Set FLUENT_RETAILER_ID or pass retailerId.");
486
+ }
487
+ // Validate if requested
488
+ if (parsed.validate) {
489
+ const validation = validateWorkflowStructure(wf);
490
+ if (!validation.valid) {
491
+ return {
492
+ ok: true,
493
+ valid: false,
494
+ validationErrors: validation.errors,
495
+ validationWarnings: validation.warnings,
496
+ note: "Workflow failed structural validation. Fix errors before uploading.",
497
+ };
498
+ }
499
+ if (parsed.dryRun) {
500
+ return {
501
+ ok: true,
502
+ dryRun: true,
503
+ valid: true,
504
+ validationWarnings: validation.warnings.length > 0 ? validation.warnings : undefined,
505
+ workflowName: wf.name,
506
+ retailerId,
507
+ note: "Validation passed. Set dryRun=false to deploy.",
508
+ };
509
+ }
510
+ }
511
+ else if (parsed.dryRun) {
512
+ return {
513
+ ok: true,
514
+ dryRun: true,
515
+ validationSkipped: true,
516
+ workflowName: wf.name,
517
+ retailerId,
518
+ note: "Validation skipped. Set dryRun=false to deploy.",
519
+ };
520
+ }
521
+ // Upload via REST API POST /api/v4.1/workflow/{retailerId}
522
+ // This is the same endpoint the Fluent CLI uses.
523
+ // NOTE: For production deployments, prefer `fluent module install` via CLI
524
+ // which bundles workflows with settings, rules, and data in a versioned module.
525
+ const client = requireWorkflowClient(ctx);
526
+ // FluentClientAdapter may expose request() for custom REST endpoints
527
+ const clientAny = client;
528
+ if (typeof clientAny.request !== "function") {
529
+ throw new ToolError("CONFIG_ERROR", "SDK client does not expose request() method — cannot call REST workflow upload API. " +
530
+ "Use the Fluent CLI instead: fluent workflow merge <file> <name> -p <profile> -r <retailer>");
531
+ }
532
+ const requestFn = clientAny.request;
533
+ const response = await requestFn(`/api/v4.1/workflow/${retailerId}`, {
534
+ method: "POST",
535
+ headers: { "Content-Type": "application/json" },
536
+ body: wf,
537
+ });
538
+ return {
539
+ ok: true,
540
+ uploaded: true,
541
+ workflowName: wf.name,
542
+ retailerId,
543
+ response,
544
+ note: "For production deployments, prefer 'fluent module install' via CLI for versioned, bundled deployments.",
545
+ };
546
+ }
547
+ /**
548
+ * Handle workflow.diff tool call.
549
+ * Pure computation — no API calls.
550
+ */
551
+ export async function handleWorkflowDiff(args, _ctx) {
552
+ const parsed = WorkflowDiffInputSchema.parse(args);
553
+ const base = parseWorkflow(parsed.base);
554
+ const target = parseWorkflow(parsed.target);
555
+ const diff = computeWorkflowDiff(base, target);
556
+ if (parsed.format === "detailed") {
557
+ return {
558
+ ok: true,
559
+ diff,
560
+ };
561
+ }
562
+ if (parsed.format === "mermaid") {
563
+ const diagram = generateMermaidDiff(base, target, diff);
564
+ return {
565
+ ok: true,
566
+ diagram,
567
+ summary: diff.summary,
568
+ riskLevel: diff.riskLevel,
569
+ };
570
+ }
571
+ // Summary format (default)
572
+ return {
573
+ ok: true,
574
+ summary: diff.summary,
575
+ riskLevel: diff.riskLevel,
576
+ rulesetChanges: {
577
+ added: diff.rulesets.added.length,
578
+ removed: diff.rulesets.removed.length,
579
+ modified: diff.rulesets.modified.length,
580
+ },
581
+ statusChanges: {
582
+ added: diff.statuses.added,
583
+ removed: diff.statuses.removed,
584
+ },
585
+ };
586
+ }
587
+ function classifyRule(rule) {
588
+ const ruleName = rule.name;
589
+ const props = rule.props ?? {};
590
+ // SetState / ChangeState
591
+ if (/SetState|ChangeState/i.test(ruleName)) {
592
+ return {
593
+ name: ruleName,
594
+ type: "setState",
595
+ props,
596
+ prediction: props.status ?? undefined,
597
+ };
598
+ }
599
+ // SendEvent
600
+ if (/SendEvent/i.test(ruleName)) {
601
+ return {
602
+ name: ruleName,
603
+ type: "sendEvent",
604
+ props,
605
+ prediction: props.eventName ?? undefined,
606
+ };
607
+ }
608
+ // SendWebhook
609
+ if (/SendWebhook|Webhook/i.test(ruleName)) {
610
+ return {
611
+ name: ruleName,
612
+ type: "sendWebhook",
613
+ props,
614
+ prediction: props.setting ?? undefined,
615
+ };
616
+ }
617
+ // Everything else is custom/opaque
618
+ return {
619
+ name: ruleName,
620
+ type: "custom",
621
+ props,
622
+ };
623
+ }
624
+ function simulateWorkflow(wf, currentStatus, eventName, entityType, entitySubtype) {
625
+ const rulesets = wf.rulesets;
626
+ if (!rulesets || !Array.isArray(rulesets))
627
+ return [];
628
+ const matches = [];
629
+ for (const rs of rulesets) {
630
+ const rsName = rs.name;
631
+ const triggers = rs.triggers;
632
+ const rules = rs.rules;
633
+ if (!rsName || !triggers || !Array.isArray(triggers))
634
+ continue;
635
+ // Check each trigger for match
636
+ for (const trigger of triggers) {
637
+ const triggerStatus = trigger.status;
638
+ const triggerEntityType = trigger.entityType;
639
+ const triggerSubtype = trigger.entitySubtype;
640
+ // Status must match
641
+ const statusMatches = triggerStatus === currentStatus;
642
+ // Event name match: in Fluent, event name must match the ruleset name
643
+ // When eventName is provided, only matching rulesets pass
644
+ const eventMatches = !eventName || rsName === eventName || rsName.toLowerCase() === eventName.toLowerCase();
645
+ // Entity type filter
646
+ const typeMatches = !entityType || !triggerEntityType || triggerEntityType === entityType;
647
+ const subtypeMatches = !entitySubtype || !triggerSubtype || triggerSubtype === entitySubtype;
648
+ if (!statusMatches || !eventMatches || !typeMatches || !subtypeMatches)
649
+ continue;
650
+ // Determine match type
651
+ let matchType;
652
+ if (statusMatches && eventName && rsName === eventName) {
653
+ matchType = "statusAndEvent";
654
+ }
655
+ else if (statusMatches && !eventName) {
656
+ matchType = "statusOnly";
657
+ }
658
+ else {
659
+ matchType = eventMatches ? "statusAndEvent" : "statusOnly";
660
+ }
661
+ // Classify rules
662
+ const classifiedRules = (rules ?? []).map(classifyRule);
663
+ const predictedStatus = classifiedRules.find((r) => r.type === "setState" && r.prediction)
664
+ ?.prediction ?? null;
665
+ const predictedEvents = classifiedRules
666
+ .filter((r) => r.type === "sendEvent" && r.prediction)
667
+ .map((r) => r.prediction);
668
+ const predictedWebhooks = classifiedRules
669
+ .filter((r) => r.type === "sendWebhook" && r.prediction)
670
+ .map((r) => r.prediction);
671
+ const customRules = classifiedRules
672
+ .filter((r) => r.type === "custom")
673
+ .map((r) => r.name);
674
+ matches.push({
675
+ name: rsName,
676
+ description: rs.description,
677
+ triggerMatch: trigger,
678
+ matchType,
679
+ rules: classifiedRules,
680
+ predictedStatus,
681
+ predictedEvents,
682
+ predictedWebhooks,
683
+ customRules,
684
+ });
685
+ // Only match once per ruleset (first matching trigger)
686
+ break;
687
+ }
688
+ }
689
+ // Sort: statusAndEvent matches first (most specific)
690
+ matches.sort((a, b) => {
691
+ if (a.matchType === "statusAndEvent" && b.matchType !== "statusAndEvent")
692
+ return -1;
693
+ if (b.matchType === "statusAndEvent" && a.matchType !== "statusAndEvent")
694
+ return 1;
695
+ return 0;
696
+ });
697
+ return matches;
698
+ }
699
+ /**
700
+ * Handle workflow.simulate tool call.
701
+ * Pure computation — no API calls.
702
+ */
703
+ export async function handleWorkflowSimulate(args, _ctx) {
704
+ const parsed = WorkflowSimulateInputSchema.parse(args);
705
+ const wf = parseWorkflow(parsed.workflow);
706
+ const matches = simulateWorkflow(wf, parsed.currentStatus, parsed.eventName, parsed.entityType, parsed.entitySubtype);
707
+ // Collect aggregated predictions
708
+ const hasCustomRules = matches.some((m) => m.customRules.length > 0);
709
+ const allCustomRules = [
710
+ ...new Set(matches.flatMap((m) => m.customRules)),
711
+ ];
712
+ const limitations = [
713
+ "STATIC PREDICTION ONLY — does not account for runtime conditions, entity attributes, or settings values.",
714
+ ];
715
+ if (hasCustomRules) {
716
+ limitations.push(`${allCustomRules.length} custom rule(s) found whose behavior cannot be predicted statically: ${allCustomRules.join(", ")}.`);
717
+ }
718
+ limitations.push("Use workflow.transitions for AUTHORITATIVE live validation of available actions.");
719
+ return {
720
+ ok: true,
721
+ workflowName: wf.name ?? "unknown",
722
+ currentStatus: parsed.currentStatus,
723
+ eventName: parsed.eventName ?? null,
724
+ matchedRulesets: matches.length,
725
+ rulesets: matches.map((m) => ({
726
+ name: m.name,
727
+ description: m.description,
728
+ matchType: m.matchType,
729
+ predictedStatus: m.predictedStatus,
730
+ predictedEvents: m.predictedEvents,
731
+ predictedWebhooks: m.predictedWebhooks,
732
+ customRules: m.customRules.length > 0 ? m.customRules : undefined,
733
+ ruleCount: m.rules.length,
734
+ })),
735
+ prediction: matches.length > 0
736
+ ? {
737
+ likelyNextStatus: matches.find((m) => m.predictedStatus)?.predictedStatus ?? null,
738
+ followOnEvents: [
739
+ ...new Set(matches.flatMap((m) => m.predictedEvents)),
740
+ ],
741
+ webhookSideEffects: [
742
+ ...new Set(matches.flatMap((m) => m.predictedWebhooks)),
743
+ ],
744
+ confidence: hasCustomRules ? "low" : "medium",
745
+ note: hasCustomRules
746
+ ? "Custom rules present — prediction may be incomplete or wrong."
747
+ : "No custom rules — prediction is based on standard Fluent rules only.",
748
+ }
749
+ : null,
750
+ limitations,
751
+ };
752
+ }