@octaviaflow/flow-rules 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.
Files changed (146) hide show
  1. package/README.md +96 -0
  2. package/dist/catalog/actions/console-log/config.d.ts +58 -0
  3. package/dist/catalog/actions/console-log/config.js +70 -0
  4. package/dist/catalog/actions/console-log/index.d.ts +7 -0
  5. package/dist/catalog/actions/console-log/index.js +89 -0
  6. package/dist/catalog/actions/delay/config.d.ts +50 -0
  7. package/dist/catalog/actions/delay/config.js +79 -0
  8. package/dist/catalog/actions/delay/index.d.ts +7 -0
  9. package/dist/catalog/actions/delay/index.js +71 -0
  10. package/dist/catalog/actions/delay/migrate.d.ts +18 -0
  11. package/dist/catalog/actions/delay/migrate.js +38 -0
  12. package/dist/catalog/actions/delay/types.d.ts +58 -0
  13. package/dist/catalog/actions/delay/types.js +17 -0
  14. package/dist/catalog/actions/http-request/config.d.ts +114 -0
  15. package/dist/catalog/actions/http-request/config.js +111 -0
  16. package/dist/catalog/actions/http-request/index.d.ts +7 -0
  17. package/dist/catalog/actions/http-request/index.js +223 -0
  18. package/dist/catalog/ai/ai-agent/index.d.ts +12 -0
  19. package/dist/catalog/ai/ai-agent/index.js +72 -0
  20. package/dist/catalog/ai/idp-extract/index.d.ts +18 -0
  21. package/dist/catalog/ai/idp-extract/index.js +90 -0
  22. package/dist/catalog/api/graphql-query/config.d.ts +36 -0
  23. package/dist/catalog/api/graphql-query/config.js +31 -0
  24. package/dist/catalog/api/graphql-query/index.d.ts +7 -0
  25. package/dist/catalog/api/graphql-query/index.js +75 -0
  26. package/dist/catalog/communication/email-send/config.d.ts +80 -0
  27. package/dist/catalog/communication/email-send/config.js +80 -0
  28. package/dist/catalog/communication/email-send/index.d.ts +11 -0
  29. package/dist/catalog/communication/email-send/index.js +152 -0
  30. package/dist/catalog/communication/google-chat/config.d.ts +45 -0
  31. package/dist/catalog/communication/google-chat/config.js +28 -0
  32. package/dist/catalog/communication/google-chat/index.d.ts +5 -0
  33. package/dist/catalog/communication/google-chat/index.js +36 -0
  34. package/dist/catalog/communication/slack-message/config.d.ts +47 -0
  35. package/dist/catalog/communication/slack-message/config.js +36 -0
  36. package/dist/catalog/communication/slack-message/index.d.ts +5 -0
  37. package/dist/catalog/communication/slack-message/index.js +37 -0
  38. package/dist/catalog/communication/teams-message/config.d.ts +48 -0
  39. package/dist/catalog/communication/teams-message/config.js +29 -0
  40. package/dist/catalog/communication/teams-message/index.d.ts +5 -0
  41. package/dist/catalog/communication/teams-message/index.js +39 -0
  42. package/dist/catalog/communication/webhook-call/config.d.ts +36 -0
  43. package/dist/catalog/communication/webhook-call/config.js +31 -0
  44. package/dist/catalog/communication/webhook-call/index.d.ts +5 -0
  45. package/dist/catalog/communication/webhook-call/index.js +43 -0
  46. package/dist/catalog/communication/whatsapp-send/config.d.ts +23 -0
  47. package/dist/catalog/communication/whatsapp-send/config.js +36 -0
  48. package/dist/catalog/communication/whatsapp-send/index.d.ts +15 -0
  49. package/dist/catalog/communication/whatsapp-send/index.js +57 -0
  50. package/dist/catalog/general/query/config.d.ts +36 -0
  51. package/dist/catalog/general/query/config.js +257 -0
  52. package/dist/catalog/general/query/index.d.ts +11 -0
  53. package/dist/catalog/general/query/index.js +271 -0
  54. package/dist/catalog/general/query/types.d.ts +205 -0
  55. package/dist/catalog/general/query/types.js +82 -0
  56. package/dist/catalog/general/transform/config.d.ts +117 -0
  57. package/dist/catalog/general/transform/config.js +95 -0
  58. package/dist/catalog/general/transform/index.d.ts +8 -0
  59. package/dist/catalog/general/transform/index.js +63 -0
  60. package/dist/catalog/index.d.ts +57 -0
  61. package/dist/catalog/index.js +168 -0
  62. package/dist/catalog/logic/condition/config.d.ts +54 -0
  63. package/dist/catalog/logic/condition/config.js +93 -0
  64. package/dist/catalog/logic/condition/index.d.ts +7 -0
  65. package/dist/catalog/logic/condition/index.js +64 -0
  66. package/dist/catalog/logic/for-each/config.d.ts +100 -0
  67. package/dist/catalog/logic/for-each/config.js +75 -0
  68. package/dist/catalog/logic/for-each/index.d.ts +7 -0
  69. package/dist/catalog/logic/for-each/index.js +92 -0
  70. package/dist/catalog/logic/for-each/pathOptions.d.ts +62 -0
  71. package/dist/catalog/logic/for-each/pathOptions.js +151 -0
  72. package/dist/catalog/logic/group/index.d.ts +15 -0
  73. package/dist/catalog/logic/group/index.js +46 -0
  74. package/dist/catalog/policies.d.ts +75 -0
  75. package/dist/catalog/policies.js +62 -0
  76. package/dist/catalog/storage/csv-action/index.d.ts +2 -0
  77. package/dist/catalog/storage/csv-action/index.js +38 -0
  78. package/dist/catalog/storage/dropbox-action/index.d.ts +2 -0
  79. package/dist/catalog/storage/dropbox-action/index.js +33 -0
  80. package/dist/catalog/storage/excel-action/index.d.ts +2 -0
  81. package/dist/catalog/storage/excel-action/index.js +32 -0
  82. package/dist/catalog/storage/file-action/index.d.ts +2 -0
  83. package/dist/catalog/storage/file-action/index.js +119 -0
  84. package/dist/catalog/storage/file-operation/config.d.ts +48 -0
  85. package/dist/catalog/storage/file-operation/config.js +45 -0
  86. package/dist/catalog/storage/file-operation/index.d.ts +8 -0
  87. package/dist/catalog/storage/file-operation/index.js +111 -0
  88. package/dist/catalog/storage/ftp-action/index.d.ts +2 -0
  89. package/dist/catalog/storage/ftp-action/index.js +33 -0
  90. package/dist/catalog/storage/google-drive-action/index.d.ts +2 -0
  91. package/dist/catalog/storage/google-drive-action/index.js +38 -0
  92. package/dist/catalog/storage/json-action/index.d.ts +2 -0
  93. package/dist/catalog/storage/json-action/index.js +31 -0
  94. package/dist/catalog/storage/onedrive-action/index.d.ts +2 -0
  95. package/dist/catalog/storage/onedrive-action/index.js +33 -0
  96. package/dist/catalog/storage/s3-action/index.d.ts +2 -0
  97. package/dist/catalog/storage/s3-action/index.js +36 -0
  98. package/dist/catalog/storage/storage-action/index.d.ts +13 -0
  99. package/dist/catalog/storage/storage-action/index.js +72 -0
  100. package/dist/catalog/storage/storageCapabilities.d.ts +57 -0
  101. package/dist/catalog/storage/storageCapabilities.js +207 -0
  102. package/dist/catalog/storage/xml-action/index.d.ts +2 -0
  103. package/dist/catalog/storage/xml-action/index.js +31 -0
  104. package/dist/catalog/transforms/format/config.d.ts +23 -0
  105. package/dist/catalog/transforms/format/config.js +19 -0
  106. package/dist/catalog/transforms/format/index.d.ts +5 -0
  107. package/dist/catalog/transforms/format/index.js +39 -0
  108. package/dist/catalog/triggers/connector-trigger/index.d.ts +7 -0
  109. package/dist/catalog/triggers/connector-trigger/index.js +72 -0
  110. package/dist/catalog/triggers/manual-trigger/config.d.ts +40 -0
  111. package/dist/catalog/triggers/manual-trigger/config.js +60 -0
  112. package/dist/catalog/triggers/manual-trigger/index.d.ts +10 -0
  113. package/dist/catalog/triggers/manual-trigger/index.js +74 -0
  114. package/dist/catalog/triggers/schedule-trigger/config.d.ts +42 -0
  115. package/dist/catalog/triggers/schedule-trigger/config.js +42 -0
  116. package/dist/catalog/triggers/schedule-trigger/index.d.ts +7 -0
  117. package/dist/catalog/triggers/schedule-trigger/index.js +102 -0
  118. package/dist/catalog/triggers/webhook-trigger/config.d.ts +31 -0
  119. package/dist/catalog/triggers/webhook-trigger/config.js +27 -0
  120. package/dist/catalog/triggers/webhook-trigger/index.d.ts +7 -0
  121. package/dist/catalog/triggers/webhook-trigger/index.js +89 -0
  122. package/dist/catalog/types.d.ts +360 -0
  123. package/dist/catalog/types.js +135 -0
  124. package/dist/catalog/utility/debug/config.d.ts +72 -0
  125. package/dist/catalog/utility/debug/config.js +90 -0
  126. package/dist/catalog/utility/debug/index.d.ts +11 -0
  127. package/dist/catalog/utility/debug/index.js +38 -0
  128. package/dist/catalog/utility/error/config.d.ts +26 -0
  129. package/dist/catalog/utility/error/config.js +25 -0
  130. package/dist/catalog/utility/error/index.d.ts +7 -0
  131. package/dist/catalog/utility/error/index.js +46 -0
  132. package/dist/catalog/utility/variables/config.d.ts +76 -0
  133. package/dist/catalog/utility/variables/config.js +70 -0
  134. package/dist/catalog/utility/variables/index.d.ts +13 -0
  135. package/dist/catalog/utility/variables/index.js +40 -0
  136. package/dist/catalog/workflow.d.ts +308 -0
  137. package/dist/catalog/workflow.js +25 -0
  138. package/dist/ids.d.ts +31 -0
  139. package/dist/ids.js +53 -0
  140. package/dist/index.d.ts +25 -0
  141. package/dist/index.js +28 -0
  142. package/dist/rules/nodeConfigStatus.d.ts +33 -0
  143. package/dist/rules/nodeConfigStatus.js +217 -0
  144. package/dist/rules/workflowValidator.d.ts +53 -0
  145. package/dist/rules/workflowValidator.js +358 -0
  146. package/package.json +41 -0
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Node Config Status Utility
3
+ *
4
+ * Determines the status indicator for a workflow node based on
5
+ * how completely its configuration has been filled out relative
6
+ * to the action definition's input schema.
7
+ *
8
+ * Handles:
9
+ * - Nested config keys (e.g., "visualConfig.tables" → config.visualConfig.tables)
10
+ * - Conditional required fields (only enforced when their condition is met)
11
+ * - Default values (count as filled if present)
12
+ */
13
+ import { getActionById } from '../catalog/index';
14
+ /**
15
+ * Action IDs whose nodes should not display a config status indicator.
16
+ */
17
+ const HIDDEN_STATUS_ACTIONS = new Set(['console_log']);
18
+ /**
19
+ * Check if a config value is considered "filled" (non-empty).
20
+ */
21
+ function isFieldFilled(value) {
22
+ if (value === undefined || value === null)
23
+ return false;
24
+ if (typeof value === 'string')
25
+ return value.trim().length > 0;
26
+ if (typeof value === 'number')
27
+ return true;
28
+ if (typeof value === 'boolean')
29
+ return true;
30
+ if (Array.isArray(value))
31
+ return value.length > 0;
32
+ if (typeof value === 'object')
33
+ return Object.keys(value).length > 0;
34
+ return false;
35
+ }
36
+ /**
37
+ * Some actions persist part of their required configuration out-of-band rather
38
+ * than in `node.config`. The Connector Trigger is the canonical case: its
39
+ * watched entity/operation events are registered in the backend webhook
40
+ * subscription store (keyed by flow + connector), and the node config only
41
+ * carries a `subscribed` / `eventCount` summary of that subscription. For such
42
+ * fields, that summary — not a literal `config.<key>` value — is the source of
43
+ * truth for whether the field is configured.
44
+ *
45
+ * Returns true when the given required field should be treated as satisfied by
46
+ * an out-of-band signal, so it is not falsely reported as missing. Removing a
47
+ * subscription resets the summary (eventCount → 0), so an unconfigured trigger
48
+ * still fails validation as expected.
49
+ */
50
+ function isSatisfiedOutOfBand(actionId, fieldKey, config) {
51
+ if (actionId === 'connector_trigger' && fieldKey === 'events') {
52
+ return Number(config.eventCount ?? 0) > 0;
53
+ }
54
+ // Messaging actions: the body can be plain text OR Block Kit, and the config
55
+ // panel stores the text under `text` while the action definition names the
56
+ // field `message` (its legacy alias). Without this, a Slack step that sends
57
+ // perfectly well reported "Missing required settings: message" — and since
58
+ // Flow Doctor errors BLOCK ACTIVATION, such a flow could run manually but
59
+ // never be scheduled.
60
+ if (MESSAGE_BODY_ACTIONS.has(actionId) && (fieldKey === 'message' || fieldKey === 'text')) {
61
+ const text = config.text ?? config.message;
62
+ if (typeof text === 'string' && text.trim().length > 0)
63
+ return true;
64
+ return Array.isArray(config.blocks) && config.blocks.length > 0;
65
+ }
66
+ return false;
67
+ }
68
+ /** Actions whose body may be text or blocks — see `isSatisfiedOutOfBand`. */
69
+ const MESSAGE_BODY_ACTIONS = new Set([
70
+ 'slack_message',
71
+ 'teams_message',
72
+ 'google_chat',
73
+ ]);
74
+ /**
75
+ * Resolve a nested key path (e.g., "visualConfig.tables") from a config object.
76
+ */
77
+ function getNestedValue(config, keyPath) {
78
+ const parts = keyPath.split('.');
79
+ let current = config;
80
+ for (const part of parts) {
81
+ if (current === null || current === undefined || typeof current !== 'object') {
82
+ return undefined;
83
+ }
84
+ current = current[part];
85
+ }
86
+ return current;
87
+ }
88
+ /**
89
+ * Evaluate whether a conditional field should be active (visible/required)
90
+ * based on the current config values.
91
+ *
92
+ * A field with conditionals is only required when at least one "show" or
93
+ * "require" condition is satisfied.
94
+ */
95
+ function isFieldActive(field, config) {
96
+ if (!field.conditional || field.conditional.length === 0) {
97
+ return true;
98
+ }
99
+ // Check if any "show" or "require" condition is met
100
+ return field.conditional.some((cond) => {
101
+ if (cond.action !== 'show' && cond.action !== 'require') {
102
+ return false;
103
+ }
104
+ const fieldValue = getNestedValue(config, cond.field);
105
+ switch (cond.operator) {
106
+ case '==': return fieldValue === cond.value;
107
+ case '!=': return fieldValue !== cond.value;
108
+ case '>': return fieldValue > cond.value;
109
+ case '<': return fieldValue < cond.value;
110
+ case '>=': return fieldValue >= cond.value;
111
+ case '<=': return fieldValue <= cond.value;
112
+ case 'in': return Array.isArray(cond.value) && cond.value.includes(fieldValue);
113
+ case 'not_in': return Array.isArray(cond.value) && !cond.value.includes(fieldValue);
114
+ case 'contains':
115
+ return typeof fieldValue === 'string' && fieldValue.includes(cond.value);
116
+ default: return false;
117
+ }
118
+ });
119
+ }
120
+ /**
121
+ * Determine the config completeness status of a workflow node.
122
+ *
123
+ * Status logic:
124
+ * - "valid" → All active required fields are filled (or no required fields exist)
125
+ * - "warning" → Some active required fields are missing, but at least one field has data
126
+ * - "error" → Has active required fields and none are filled, config is empty
127
+ * - "unconfigured" → Action definition not found (unknown action type)
128
+ */
129
+ export function getNodeConfigStatus(node) {
130
+ // Some actions should never show a status indicator
131
+ if (HIDDEN_STATUS_ACTIONS.has(node.actionDefinitionId)) {
132
+ return {
133
+ status: 'hidden',
134
+ requiredTotal: 0,
135
+ requiredFilled: 0,
136
+ optionalTotal: 0,
137
+ optionalFilled: 0,
138
+ missingRequired: [],
139
+ filledOptional: [],
140
+ };
141
+ }
142
+ const definition = getActionById(node.actionDefinitionId);
143
+ if (!definition) {
144
+ return {
145
+ status: 'unconfigured',
146
+ requiredTotal: 0,
147
+ requiredFilled: 0,
148
+ optionalTotal: 0,
149
+ optionalFilled: 0,
150
+ missingRequired: [],
151
+ filledOptional: [],
152
+ };
153
+ }
154
+ const fields = definition.inputSchema?.fields ?? [];
155
+ const config = node.config ?? {};
156
+ // Filter to only active (visible) fields based on conditionals
157
+ const activeFields = fields.filter(f => !f.hidden && isFieldActive(f, config));
158
+ const requiredFields = activeFields.filter(f => f.validation?.required);
159
+ const optionalFields = activeFields.filter(f => !f.validation?.required);
160
+ const missingRequired = [];
161
+ let requiredFilled = 0;
162
+ for (const field of requiredFields) {
163
+ const value = getNestedValue(config, field.key);
164
+ if (isFieldFilled(value)) {
165
+ requiredFilled++;
166
+ }
167
+ else if (isFieldFilled(field.defaultValue)) {
168
+ // Field has a defaultValue — count as filled
169
+ requiredFilled++;
170
+ }
171
+ else if (isSatisfiedOutOfBand(node.actionDefinitionId, field.key, config)) {
172
+ // Field is configured out-of-band (e.g. Connector Trigger events live in
173
+ // the backend subscription store, mirrored here as an eventCount summary)
174
+ requiredFilled++;
175
+ }
176
+ else {
177
+ missingRequired.push(field.key);
178
+ }
179
+ }
180
+ const filledOptional = [];
181
+ let optionalFilled = 0;
182
+ for (const field of optionalFields) {
183
+ const value = getNestedValue(config, field.key);
184
+ if (isFieldFilled(value)) {
185
+ optionalFilled++;
186
+ filledOptional.push(field.key);
187
+ }
188
+ }
189
+ const requiredTotal = requiredFields.length;
190
+ const optionalTotal = optionalFields.length;
191
+ let status;
192
+ if (requiredTotal === 0) {
193
+ // No required fields → always valid (e.g., manual_trigger)
194
+ status = 'valid';
195
+ }
196
+ else if (missingRequired.length === 0) {
197
+ // All required fields filled
198
+ status = 'valid';
199
+ }
200
+ else if (requiredFilled > 0 || optionalFilled > 0) {
201
+ // Partially configured — some required fields still missing
202
+ status = 'warning';
203
+ }
204
+ else {
205
+ // Nothing configured at all but has required fields
206
+ status = 'error';
207
+ }
208
+ return {
209
+ status,
210
+ requiredTotal,
211
+ requiredFilled,
212
+ optionalTotal,
213
+ optionalFilled,
214
+ missingRequired,
215
+ filledOptional,
216
+ };
217
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Workflow Validation System — rules engine v2 (DEV-220)
3
+ *
4
+ * Validates nodes, edges, and overall workflow structure on every
5
+ * STRUCTURAL canvas change (WorkflowContext applies it selectively —
6
+ * selection/viewport/drag churn never re-runs it). Results feed:
7
+ * - the FlowToolbarIssues indicator (badge + hover overlay)
8
+ * - per-node error/warning badges (flowAdapter → DS BaseNode status)
9
+ * - activation gating (useFlowLifecycle: errors block Activate;
10
+ * Save is NEVER blocked; warnings never block anything)
11
+ *
12
+ * Severity model (ADR-flow-pause-and-rules-v2):
13
+ * error → the flow cannot work as built (missing trigger, broken edge,
14
+ * cycle, missing required config). Blocks Activate.
15
+ * warning → it will run, but probably not the way the user thinks
16
+ * ("won't run" orphans, multi-flow canvas, duplicate edges).
17
+ */
18
+ import { WorkflowNode, WorkflowEdge } from '../catalog/workflow';
19
+ export interface ValidationError {
20
+ id: string;
21
+ type: 'node' | 'edge' | 'workflow';
22
+ severity: 'error' | 'warning';
23
+ message: string;
24
+ nodeId?: string;
25
+ edgeId?: string;
26
+ }
27
+ export interface ValidationResult {
28
+ isValid: boolean;
29
+ errors: ValidationError[];
30
+ warnings: ValidationError[];
31
+ }
32
+ /**
33
+ * Validate a single node.
34
+ */
35
+ export declare function validateNode(node: WorkflowNode, allNodes: WorkflowNode[], allEdges: WorkflowEdge[]): ValidationError[];
36
+ /**
37
+ * Validate a single edge. `allEdges` powers real duplicate detection —
38
+ * only the SECOND-and-later instance of a duplicate is flagged, so one
39
+ * duplicate pair yields one warning, not two.
40
+ */
41
+ export declare function validateEdge(edge: WorkflowEdge, allNodes: WorkflowNode[], allEdges?: WorkflowEdge[]): ValidationError[];
42
+ /**
43
+ * Validate entire workflow.
44
+ */
45
+ export declare function validateWorkflow(nodes: WorkflowNode[], edges: WorkflowEdge[]): ValidationResult;
46
+ /**
47
+ * Get validation status for a specific node
48
+ */
49
+ export declare function getNodeValidationStatus(nodeId: string, validationResult: ValidationResult): 'valid' | 'error' | 'warning';
50
+ /**
51
+ * Get validation status for a specific edge
52
+ */
53
+ export declare function getEdgeValidationStatus(edgeId: string, validationResult: ValidationResult): 'valid' | 'error' | 'warning';
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Workflow Validation System — rules engine v2 (DEV-220)
3
+ *
4
+ * Validates nodes, edges, and overall workflow structure on every
5
+ * STRUCTURAL canvas change (WorkflowContext applies it selectively —
6
+ * selection/viewport/drag churn never re-runs it). Results feed:
7
+ * - the FlowToolbarIssues indicator (badge + hover overlay)
8
+ * - per-node error/warning badges (flowAdapter → DS BaseNode status)
9
+ * - activation gating (useFlowLifecycle: errors block Activate;
10
+ * Save is NEVER blocked; warnings never block anything)
11
+ *
12
+ * Severity model (ADR-flow-pause-and-rules-v2):
13
+ * error → the flow cannot work as built (missing trigger, broken edge,
14
+ * cycle, missing required config). Blocks Activate.
15
+ * warning → it will run, but probably not the way the user thinks
16
+ * ("won't run" orphans, multi-flow canvas, duplicate edges).
17
+ */
18
+ import { getNodeConfigStatus } from './nodeConfigStatus';
19
+ const nodeName = (n) => n.label || n.id;
20
+ /**
21
+ * Validate a single node.
22
+ */
23
+ export function validateNode(node, allNodes, allEdges) {
24
+ const errors = [];
25
+ // Check required fields
26
+ if (!node.id || node.id.trim().length === 0) {
27
+ errors.push({
28
+ id: `node-${node.id}-id`,
29
+ type: 'node',
30
+ severity: 'error',
31
+ message: 'Node ID is required',
32
+ nodeId: node.id,
33
+ });
34
+ }
35
+ if (!node.label || node.label.trim().length === 0) {
36
+ errors.push({
37
+ id: `node-${node.id}-label`,
38
+ type: 'node',
39
+ severity: 'error',
40
+ message: 'Node label is required',
41
+ nodeId: node.id,
42
+ });
43
+ }
44
+ // Config completeness — computed LIVE from the action definition's
45
+ // required fields (the persisted `isValid` flag goes stale between the
46
+ // loader and the current edit). Missing required fields are an ERROR and
47
+ // are named so the issues list is actionable.
48
+ const cfg = getNodeConfigStatus(node);
49
+ if (cfg.missingRequired.length > 0) {
50
+ const fields = cfg.missingRequired.slice(0, 4).join(', ');
51
+ const more = cfg.missingRequired.length > 4 ? ` +${cfg.missingRequired.length - 4} more` : '';
52
+ errors.push({
53
+ id: `node-${node.id}-config`,
54
+ type: 'node',
55
+ severity: 'error',
56
+ // Actionable for END USERS: name the exact fields AND the fix action.
57
+ message: `Missing required settings: ${fields}${more}. Open the step and fill them in.`,
58
+ nodeId: node.id,
59
+ });
60
+ }
61
+ // Trigger nodes must have at least one outgoing connection to be useful
62
+ const hasOutput = allEdges.some(edge => edge.source === node.id);
63
+ if (!hasOutput && node.type === 'trigger') {
64
+ errors.push({
65
+ id: `node-${node.id}-output`,
66
+ type: 'node',
67
+ severity: 'warning',
68
+ message: `Trigger "${nodeName(node)}" has no outgoing connections`,
69
+ nodeId: node.id,
70
+ });
71
+ }
72
+ return errors;
73
+ }
74
+ /**
75
+ * Validate a single edge. `allEdges` powers real duplicate detection —
76
+ * only the SECOND-and-later instance of a duplicate is flagged, so one
77
+ * duplicate pair yields one warning, not two.
78
+ */
79
+ export function validateEdge(edge, allNodes, allEdges = []) {
80
+ const errors = [];
81
+ // Check if source node exists
82
+ const sourceNode = allNodes.find(n => n.id === edge.source);
83
+ if (!sourceNode) {
84
+ errors.push({
85
+ id: `edge-${edge.id}-source`,
86
+ type: 'edge',
87
+ severity: 'error',
88
+ message: `Source node "${edge.source}" does not exist`,
89
+ edgeId: edge.id,
90
+ });
91
+ }
92
+ // Check if target node exists
93
+ const targetNode = allNodes.find(n => n.id === edge.target);
94
+ if (!targetNode) {
95
+ errors.push({
96
+ id: `edge-${edge.id}-target`,
97
+ type: 'edge',
98
+ severity: 'error',
99
+ message: `Target node "${edge.target}" does not exist`,
100
+ edgeId: edge.id,
101
+ });
102
+ }
103
+ // Check for self-loops
104
+ if (edge.source === edge.target) {
105
+ errors.push({
106
+ id: `edge-${edge.id}-loop`,
107
+ type: 'edge',
108
+ severity: 'error',
109
+ message: 'Nodes cannot connect to themselves',
110
+ edgeId: edge.id,
111
+ });
112
+ }
113
+ // Duplicate edges: same source/target (and same handles when present).
114
+ // (v1 shipped with `duplicateCount = 1` hard-coded — the rule could
115
+ // never fire. DEV-220 fixed it.)
116
+ const key = (e) => `${e.source}|${e.sourceHandle ?? ''}→${e.target}|${e.targetHandle ?? ''}`;
117
+ const myKey = key(edge);
118
+ const firstIndex = allEdges.findIndex(e => key(e) === myKey);
119
+ const myIndex = allEdges.findIndex(e => e.id === edge.id);
120
+ if (firstIndex !== -1 && myIndex > firstIndex) {
121
+ errors.push({
122
+ id: `edge-${edge.id}-duplicate`,
123
+ type: 'edge',
124
+ severity: 'warning',
125
+ message: `Duplicate connection between "${sourceNode ? nodeName(sourceNode) : edge.source}" and "${targetNode ? nodeName(targetNode) : edge.target}"`,
126
+ edgeId: edge.id,
127
+ });
128
+ }
129
+ return errors;
130
+ }
131
+ /**
132
+ * Validate entire workflow.
133
+ */
134
+ export function validateWorkflow(nodes, edges) {
135
+ const allErrors = [];
136
+ // Check for at least one trigger node
137
+ const hasTrigger = nodes.some(node => node.type === 'trigger');
138
+ if (!hasTrigger) {
139
+ allErrors.push({
140
+ id: 'workflow-trigger',
141
+ type: 'workflow',
142
+ severity: 'error',
143
+ message: 'Workflow must have at least one trigger node',
144
+ });
145
+ }
146
+ // Check for at least one action node
147
+ const hasAction = nodes.some(node => node.type === 'action');
148
+ if (!hasAction && nodes.length > 0) {
149
+ allErrors.push({
150
+ id: 'workflow-action',
151
+ type: 'workflow',
152
+ severity: 'warning',
153
+ message: 'Workflow should have at least one action node',
154
+ });
155
+ }
156
+ // Validate individual nodes
157
+ nodes.forEach(node => {
158
+ const nodeErrors = validateNode(node, nodes, edges);
159
+ allErrors.push(...nodeErrors);
160
+ });
161
+ // Validate individual edges
162
+ edges.forEach(edge => {
163
+ const edgeErrors = validateEdge(edge, nodes, edges);
164
+ allErrors.push(...edgeErrors);
165
+ });
166
+ // ── Cycle detection (DEV-220) ──
167
+ // Kahn's algorithm over the execution edges; anything the topological
168
+ // sort can't reach sits on (or strictly behind) a cycle. The Engine
169
+ // APPENDS such steps to the order silently, so a cycle produces
170
+ // undefined ordering at run time — an error here, not a curiosity.
171
+ if (nodes.length > 0 && edges.length > 0) {
172
+ const inDegree = new Map(nodes.map(n => [n.id, 0]));
173
+ const out = new Map();
174
+ for (const e of edges) {
175
+ if (e.source === e.target)
176
+ continue; // self-loops flagged per-edge already
177
+ if (!inDegree.has(e.source) || !inDegree.has(e.target))
178
+ continue; // dangling flagged already
179
+ inDegree.set(e.target, (inDegree.get(e.target) ?? 0) + 1);
180
+ const list = out.get(e.source);
181
+ if (list)
182
+ list.push(e.target);
183
+ else
184
+ out.set(e.source, [e.target]);
185
+ }
186
+ const queue = nodes.filter(n => (inDegree.get(n.id) ?? 0) === 0).map(n => n.id);
187
+ let visited = 0;
188
+ while (queue.length) {
189
+ const cur = queue.shift();
190
+ visited++;
191
+ for (const next of out.get(cur) ?? []) {
192
+ const d = (inDegree.get(next) ?? 1) - 1;
193
+ inDegree.set(next, d);
194
+ if (d === 0)
195
+ queue.push(next);
196
+ }
197
+ }
198
+ if (visited < nodes.length) {
199
+ const stuck = nodes.filter(n => (inDegree.get(n.id) ?? 0) > 0);
200
+ const names = stuck.slice(0, 3).map(nodeName).join(', ');
201
+ const more = stuck.length > 3 ? ` +${stuck.length - 3} more` : '';
202
+ allErrors.push({
203
+ id: 'workflow-cycle',
204
+ type: 'workflow',
205
+ severity: 'error',
206
+ message: `Circular connection detected involving: ${names}${more}. Break the loop — execution order is undefined inside a cycle.`,
207
+ nodeId: stuck[0]?.id,
208
+ });
209
+ }
210
+ }
211
+ // ── DEV-207: "won't run" warnings ──
212
+ // Execution is reachability-from-entry: a node runs only if it is reachable,
213
+ // following edges, from a trigger. Flag configured nodes that WON'T run so
214
+ // the consequence is explicit (a stray, unconnected Slack step silently not
215
+ // firing is the exact footgun this prevents).
216
+ const isTrigger = (n) => n.type === 'trigger' || String(n.actionDefinitionId ?? '').endsWith('_trigger');
217
+ const triggerIds = nodes.filter(isTrigger).map(n => n.id);
218
+ // Forward reachability from every trigger, closed over parent/child so a
219
+ // container's body counts as reachable when the container is (mirrors the
220
+ // Engine's executionScope).
221
+ const adjacency = new Map();
222
+ for (const e of edges) {
223
+ const list = adjacency.get(e.source);
224
+ if (list)
225
+ list.push(e.target);
226
+ else
227
+ adjacency.set(e.source, [e.target]);
228
+ }
229
+ const reachable = new Set(triggerIds);
230
+ const queue = [...triggerIds];
231
+ while (queue.length) {
232
+ const cur = queue.shift();
233
+ for (const next of adjacency.get(cur) ?? []) {
234
+ if (!reachable.has(next)) {
235
+ reachable.add(next);
236
+ queue.push(next);
237
+ }
238
+ }
239
+ }
240
+ let changed = true;
241
+ while (changed) {
242
+ changed = false;
243
+ for (const n of nodes) {
244
+ if (reachable.has(n.id)) {
245
+ if (n.parentId && !reachable.has(n.parentId)) {
246
+ reachable.add(n.parentId);
247
+ changed = true;
248
+ }
249
+ }
250
+ else if (n.parentId && reachable.has(n.parentId)) {
251
+ reachable.add(n.id);
252
+ changed = true;
253
+ }
254
+ }
255
+ }
256
+ nodes.forEach(node => {
257
+ if (isTrigger(node))
258
+ return;
259
+ const isConnected = edges.some(e => e.source === node.id || e.target === node.id);
260
+ // With ≥1 trigger, "won't run" is precise: not reachable from any trigger.
261
+ // With NO trigger yet (a draft), fall back to the connectivity check so we
262
+ // still flag a fully-orphaned node without spamming every step of a
263
+ // trigger-less work-in-progress.
264
+ const wontRun = triggerIds.length > 0 ? !reachable.has(node.id) : !isConnected;
265
+ if (wontRun) {
266
+ allErrors.push({
267
+ id: `node-${node.id}-orphan`,
268
+ type: 'node',
269
+ severity: 'warning',
270
+ message: isConnected
271
+ ? `"${nodeName(node)}" isn't reachable from a trigger and won't run.`
272
+ : `"${nodeName(node)}" isn't connected to a flow and won't run.`,
273
+ nodeId: node.id,
274
+ });
275
+ }
276
+ });
277
+ // ── Multi-flow canvas advisory (DEV-220 / ADR multi-graph decision) ──
278
+ // Multiple disconnected trigger trees on one canvas are LEGAL (component-
279
+ // scoped execution isolates them), but usually a maintainability smell —
280
+ // warn, never block. Components are weakly-connected (undirected) with the
281
+ // same parent/child closure as reachability.
282
+ if (triggerIds.length > 1) {
283
+ const parentOf = new Map(nodes.filter(n => n.parentId).map(n => [n.id, n.parentId]));
284
+ const undirected = new Map();
285
+ const link = (a, b) => {
286
+ const la = undirected.get(a);
287
+ if (la)
288
+ la.push(b);
289
+ else
290
+ undirected.set(a, [b]);
291
+ };
292
+ for (const e of edges) {
293
+ link(e.source, e.target);
294
+ link(e.target, e.source);
295
+ }
296
+ for (const [child, parent] of parentOf) {
297
+ link(child, parent);
298
+ link(parent, child);
299
+ }
300
+ const seen = new Set();
301
+ let flowsWithTrigger = 0;
302
+ for (const t of triggerIds) {
303
+ if (seen.has(t))
304
+ continue;
305
+ flowsWithTrigger++;
306
+ const stack = [t];
307
+ seen.add(t);
308
+ while (stack.length) {
309
+ const cur = stack.pop();
310
+ for (const next of undirected.get(cur) ?? []) {
311
+ if (!seen.has(next)) {
312
+ seen.add(next);
313
+ stack.push(next);
314
+ }
315
+ }
316
+ }
317
+ }
318
+ if (flowsWithTrigger > 1) {
319
+ allErrors.push({
320
+ id: 'workflow-multi-flow',
321
+ type: 'workflow',
322
+ severity: 'warning',
323
+ message: `This canvas holds ${flowsWithTrigger} separate flows. Each trigger runs only its own flow — consider splitting them into separate flows for clarity.`,
324
+ });
325
+ }
326
+ }
327
+ const errors = allErrors.filter(e => e.severity === 'error');
328
+ const warnings = allErrors.filter(e => e.severity === 'warning');
329
+ return {
330
+ isValid: errors.length === 0,
331
+ errors,
332
+ warnings,
333
+ };
334
+ }
335
+ /**
336
+ * Get validation status for a specific node
337
+ */
338
+ export function getNodeValidationStatus(nodeId, validationResult) {
339
+ const hasError = validationResult.errors.some(e => e.nodeId === nodeId);
340
+ if (hasError)
341
+ return 'error';
342
+ const hasWarning = validationResult.warnings.some(e => e.nodeId === nodeId);
343
+ if (hasWarning)
344
+ return 'warning';
345
+ return 'valid';
346
+ }
347
+ /**
348
+ * Get validation status for a specific edge
349
+ */
350
+ export function getEdgeValidationStatus(edgeId, validationResult) {
351
+ const hasError = validationResult.errors.some(e => e.edgeId === edgeId);
352
+ if (hasError)
353
+ return 'error';
354
+ const hasWarning = validationResult.warnings.some(e => e.edgeId === edgeId);
355
+ if (hasWarning)
356
+ return 'warning';
357
+ return 'valid';
358
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@octaviaflow/flow-rules",
3
+ "version": "0.1.0",
4
+ "description": "The flow action catalog and the rules engine behind Flow Doctor — one definition of what a step accepts and what makes a flow valid, shared by the editor and the server",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": ["dist", "README.md"],
16
+ "scripts": {
17
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
18
+ "test": "bun test",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "devDependencies": {
22
+ "@types/bun": "^1.3.13",
23
+ "typescript": "^5.7.0"
24
+ },
25
+ "engines": {
26
+ "bun": ">=1.2.0"
27
+ },
28
+ "keywords": ["octaviaflow", "workflow", "actions", "validation", "flow-doctor"],
29
+ "author": "Octaviaflow Team",
30
+ "license": "Apache-2.0",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/OctaviaFlow/Octaviaflow-System.git",
34
+ "directory": "Octaviaflow-Flow-Rules"
35
+ },
36
+ "bugs": "https://github.com/OctaviaFlow/Octaviaflow-System/issues",
37
+ "homepage": "https://github.com/OctaviaFlow/Octaviaflow-System/tree/main/Octaviaflow-Flow-Rules#readme",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }