@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.
- package/LICENSE +21 -0
- package/README.md +818 -0
- package/dist/config.js +195 -0
- package/dist/entity-registry.js +418 -0
- package/dist/entity-tools.js +414 -0
- package/dist/environment-tools.js +573 -0
- package/dist/errors.js +150 -0
- package/dist/event-payload.js +22 -0
- package/dist/fluent-client.js +229 -0
- package/dist/index.js +47 -0
- package/dist/resilience.js +52 -0
- package/dist/response-shaper.js +361 -0
- package/dist/sdk-client.js +237 -0
- package/dist/settings-tools.js +348 -0
- package/dist/test-tools.js +388 -0
- package/dist/tools.js +3254 -0
- package/dist/workflow-tools.js +752 -0
- package/docs/CONTRIBUTING.md +100 -0
- package/docs/E2E_TESTING.md +739 -0
- package/docs/HANDOVER_COPILOT_SETUP_STEPS.example.yml +35 -0
- package/docs/HANDOVER_ENV.example +29 -0
- package/docs/HANDOVER_GITHUB_COPILOT.md +165 -0
- package/docs/HANDOVER_GITHUB_REPO_MCP_CONFIG.example.json +31 -0
- package/docs/HANDOVER_VSCODE_MCP_JSON.example.json +10 -0
- package/docs/IMPLEMENTATION_GUIDE.md +299 -0
- package/docs/RUNBOOK.md +312 -0
- package/docs/TOOL_REFERENCE.md +1810 -0
- package/package.json +68 -0
|
@@ -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
|
+
}
|