voyageai-cli 1.30.0 → 1.30.1
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/package.json +1 -1
- package/src/cli.js +6 -0
- package/src/commands/chat.js +32 -11
- package/src/commands/export.js +124 -0
- package/src/commands/import.js +195 -0
- package/src/commands/index-workspace.js +239 -0
- package/src/commands/mcp-server.js +113 -3
- package/src/commands/playground.js +111 -3
- package/src/lib/export/contexts/benchmark-export.js +27 -0
- package/src/lib/export/contexts/chat-export.js +41 -0
- package/src/lib/export/contexts/explore-export.js +22 -0
- package/src/lib/export/contexts/search-export.js +54 -0
- package/src/lib/export/contexts/workflow-export.js +80 -0
- package/src/lib/export/formats/clipboard-export.js +29 -0
- package/src/lib/export/formats/csv-export.js +45 -0
- package/src/lib/export/formats/json-export.js +50 -0
- package/src/lib/export/formats/markdown-export.js +189 -0
- package/src/lib/export/formats/mermaid-export.js +274 -0
- package/src/lib/export/formats/pdf-export.js +117 -0
- package/src/lib/export/formats/png-export.js +96 -0
- package/src/lib/export/formats/svg-export.js +116 -0
- package/src/lib/export/index.js +175 -0
- package/src/lib/workflow.js +206 -27
- package/src/mcp/install.js +280 -7
- package/src/mcp/schemas/index.js +40 -0
- package/src/mcp/server.js +2 -0
- package/src/mcp/tools/workspace.js +463 -0
- package/src/playground/announcements.md +52 -5
- package/src/playground/index.html +11125 -7796
- package/src/playground/vendor/mermaid.min.js +2811 -0
- package/src/workflows/rag-chat.json +165 -0
- package/src/workflows/tests/consistency-check.happy-path.test.json +28 -0
- package/src/workflows/tests/consistency-check.missing-source.test.json +26 -0
- package/src/workflows/tests/cost-analysis.happy-path.test.json +28 -0
- package/src/workflows/tests/enrich-and-ingest.happy-path.test.json +38 -0
- package/src/workflows/tests/enrich-and-ingest.notify-fails.test.json +38 -0
- package/src/workflows/tests/intelligent-ingest.all-filtered.test.json +26 -0
- package/src/workflows/tests/intelligent-ingest.happy-path.test.json +28 -0
- package/src/workflows/tests/kb-health-report.custom-queries.test.json +24 -0
- package/src/workflows/tests/kb-health-report.happy-path.test.json +26 -0
- package/src/workflows/tests/multi-collection-search.happy-path.test.json +40 -0
- package/src/workflows/tests/multi-collection-search.one-empty.test.json +28 -0
- package/src/workflows/tests/rag-chat.happy-path.test.json +26 -0
- package/src/workflows/tests/rag-chat.no-relevant-results.test.json +25 -0
- package/src/workflows/tests/research-and-summarize.happy-path.test.json +33 -0
- package/src/workflows/tests/research-and-summarize.no-results.test.json +29 -0
- package/src/workflows/tests/search-with-fallback.empty-both.test.json +24 -0
- package/src/workflows/tests/search-with-fallback.fallback-branch.test.json +24 -0
- package/src/workflows/tests/search-with-fallback.happy-path.test.json +27 -0
- package/src/workflows/tests/smart-ingest.duplicate-detected.test.json +34 -0
- package/src/workflows/tests/smart-ingest.happy-path.test.json +31 -0
- package/src/playground/assets/announcements/appstore.jpg +0 -0
- package/src/playground/assets/announcements/circuits.jpg +0 -0
- package/src/playground/assets/announcements/csvingest.jpg +0 -0
- package/src/playground/assets/announcements/green-wave.jpg +0 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render an SVG string for export.
|
|
5
|
+
* In CLI context, this generates SVG from Mermaid via Playwright.
|
|
6
|
+
* In Electron/browser context, this serializes and cleans the DOM SVG.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} normalized - Normalized data (must have _context = 'workflow' or provide _svgContent)
|
|
9
|
+
* @param {object} options
|
|
10
|
+
* @param {string} [options.background='transparent'] - 'transparent' | 'dark' | 'light'
|
|
11
|
+
* @param {boolean} [options.includeWatermark=true]
|
|
12
|
+
* @param {boolean} [options.fitToContent=true]
|
|
13
|
+
* @returns {{ content: string, mimeType: string }}
|
|
14
|
+
*/
|
|
15
|
+
function renderSvg(normalized, options = {}) {
|
|
16
|
+
// If raw SVG content is provided (from browser/Electron capture), clean and return it
|
|
17
|
+
if (normalized._svgContent) {
|
|
18
|
+
const cleaned = cleanSvg(normalized._svgContent, options);
|
|
19
|
+
return { content: cleaned, mimeType: 'image/svg+xml' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// For CLI: generate SVG from Mermaid (async path handled by renderSvgAsync)
|
|
23
|
+
throw new Error('SVG export requires either _svgContent (browser) or use renderSvgAsync (CLI)');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Async SVG render — generates SVG from Mermaid using Playwright.
|
|
28
|
+
* @param {object} normalized
|
|
29
|
+
* @param {object} options
|
|
30
|
+
* @returns {Promise<{ content: string, mimeType: string }>}
|
|
31
|
+
*/
|
|
32
|
+
async function renderSvgAsync(normalized, options = {}) {
|
|
33
|
+
if (normalized._svgContent) {
|
|
34
|
+
return renderSvg(normalized, options);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Generate Mermaid source, then render via Playwright
|
|
38
|
+
const { workflowToMermaid } = require('./mermaid-export');
|
|
39
|
+
if (normalized._context !== 'workflow' && normalized._context !== 'benchmark') {
|
|
40
|
+
throw new Error(`SVG export not supported for context: ${normalized._context}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const mermaidSrc = normalized._context === 'workflow'
|
|
44
|
+
? workflowToMermaid(normalized, options)
|
|
45
|
+
: null;
|
|
46
|
+
|
|
47
|
+
if (!mermaidSrc) {
|
|
48
|
+
throw new Error('No Mermaid source available for SVG rendering');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const svg = await renderMermaidToSvg(mermaidSrc, options);
|
|
52
|
+
return { content: svg, mimeType: 'image/svg+xml' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render Mermaid syntax to SVG using Playwright.
|
|
57
|
+
*/
|
|
58
|
+
async function renderMermaidToSvg(mermaidSrc, options = {}) {
|
|
59
|
+
const { chromium } = require('playwright');
|
|
60
|
+
const background = options.background || 'transparent';
|
|
61
|
+
const bgColor = background === 'dark' ? '#1e1e1e' : background === 'light' ? '#ffffff' : 'transparent';
|
|
62
|
+
|
|
63
|
+
const browser = await chromium.launch({ headless: true });
|
|
64
|
+
try {
|
|
65
|
+
const page = await browser.newPage();
|
|
66
|
+
|
|
67
|
+
const html = `<!DOCTYPE html>
|
|
68
|
+
<html><head>
|
|
69
|
+
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
|
|
70
|
+
</head><body style="margin:0;background:${bgColor};">
|
|
71
|
+
<pre class="mermaid">${escapeHtml(mermaidSrc)}</pre>
|
|
72
|
+
<script>mermaid.initialize({ startOnLoad: true, theme: '${options.theme || 'dark'}' });</script>
|
|
73
|
+
</body></html>`;
|
|
74
|
+
|
|
75
|
+
await page.setContent(html, { waitUntil: 'networkidle' });
|
|
76
|
+
await page.waitForSelector('svg', { timeout: 10000 });
|
|
77
|
+
|
|
78
|
+
const svg = await page.evaluate(() => {
|
|
79
|
+
const el = document.querySelector('svg');
|
|
80
|
+
// Remove interactive attributes
|
|
81
|
+
el.removeAttribute('style');
|
|
82
|
+
const scripts = el.querySelectorAll('script');
|
|
83
|
+
scripts.forEach(s => s.remove());
|
|
84
|
+
return el.outerHTML;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const cleaned = cleanSvg(svg, options);
|
|
88
|
+
return cleaned;
|
|
89
|
+
} finally {
|
|
90
|
+
await browser.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Clean an SVG string: remove scripts, event handlers, optionally add watermark.
|
|
96
|
+
*/
|
|
97
|
+
function cleanSvg(svgStr, options = {}) {
|
|
98
|
+
// Strip event handlers
|
|
99
|
+
let svg = svgStr.replace(/\s+on\w+="[^"]*"/g, '');
|
|
100
|
+
// Strip <script> tags
|
|
101
|
+
svg = svg.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
102
|
+
|
|
103
|
+
if (options.includeWatermark !== false) {
|
|
104
|
+
// Insert watermark before closing </svg>
|
|
105
|
+
const watermark = '<text x="99%" y="99%" text-anchor="end" font-size="10" fill="#666" opacity="0.5">Generated by vai</text>';
|
|
106
|
+
svg = svg.replace('</svg>', `${watermark}</svg>`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return svg;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function escapeHtml(str) {
|
|
113
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { renderSvg, renderSvgAsync, renderMermaidToSvg, cleanSvg };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { renderJson, renderJsonl } = require('./formats/json-export');
|
|
4
|
+
const { renderCsv } = require('./formats/csv-export');
|
|
5
|
+
const { renderMarkdown } = require('./formats/markdown-export');
|
|
6
|
+
const { renderMermaid } = require('./formats/mermaid-export');
|
|
7
|
+
const { copyToClipboard } = require('./formats/clipboard-export');
|
|
8
|
+
const { renderSvgAsync } = require('./formats/svg-export');
|
|
9
|
+
const { renderPng } = require('./formats/png-export');
|
|
10
|
+
const { renderPdf } = require('./formats/pdf-export');
|
|
11
|
+
|
|
12
|
+
const { normalizeWorkflow, WORKFLOW_FORMATS } = require('./contexts/workflow-export');
|
|
13
|
+
const { normalizeSearch, SEARCH_FORMATS } = require('./contexts/search-export');
|
|
14
|
+
const { normalizeChat, CHAT_FORMATS } = require('./contexts/chat-export');
|
|
15
|
+
const { normalizeBenchmark, BENCHMARK_FORMATS } = require('./contexts/benchmark-export');
|
|
16
|
+
const { normalizeExplore, EXPLORE_FORMATS } = require('./contexts/explore-export');
|
|
17
|
+
|
|
18
|
+
// ════════════════════════════════════════════════════════════════════
|
|
19
|
+
// Context → Formats mapping
|
|
20
|
+
// ════════════════════════════════════════════════════════════════════
|
|
21
|
+
|
|
22
|
+
const FORMAT_MAP = {
|
|
23
|
+
workflow: WORKFLOW_FORMATS,
|
|
24
|
+
search: SEARCH_FORMATS,
|
|
25
|
+
chat: CHAT_FORMATS,
|
|
26
|
+
benchmark: BENCHMARK_FORMATS,
|
|
27
|
+
explore: EXPLORE_FORMATS,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const TRANSFORMERS = {
|
|
31
|
+
workflow: normalizeWorkflow,
|
|
32
|
+
search: normalizeSearch,
|
|
33
|
+
chat: normalizeChat,
|
|
34
|
+
benchmark: normalizeBenchmark,
|
|
35
|
+
explore: normalizeExplore,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const RENDERERS = {
|
|
39
|
+
json: renderJson,
|
|
40
|
+
jsonl: renderJsonl,
|
|
41
|
+
csv: renderCsv,
|
|
42
|
+
markdown: renderMarkdown,
|
|
43
|
+
mermaid: renderMermaid,
|
|
44
|
+
svg: renderSvgAsync,
|
|
45
|
+
png: renderPng,
|
|
46
|
+
pdf: renderPdf,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Formats that produce binary (Buffer) output
|
|
50
|
+
const BINARY_FORMATS = new Set(['png', 'pdf']);
|
|
51
|
+
|
|
52
|
+
const EXT_MAP = {
|
|
53
|
+
json: '.json',
|
|
54
|
+
jsonl: '.jsonl',
|
|
55
|
+
csv: '.csv',
|
|
56
|
+
markdown: '.md',
|
|
57
|
+
mermaid: '.mmd',
|
|
58
|
+
svg: '.svg',
|
|
59
|
+
png: '.png',
|
|
60
|
+
pdf: '.pdf',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ════════════════════════════════════════════════════════════════════
|
|
64
|
+
// Public API
|
|
65
|
+
// ════════════════════════════════════════════════════════════════════
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get supported formats for a given context.
|
|
69
|
+
* @param {string} context
|
|
70
|
+
* @returns {string[]}
|
|
71
|
+
*/
|
|
72
|
+
function getFormatsForContext(context) {
|
|
73
|
+
return FORMAT_MAP[context] || [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build a deterministic filename for the export.
|
|
78
|
+
* Pattern: {context}-{name}-{timestamp}.{ext}
|
|
79
|
+
* @param {string} context
|
|
80
|
+
* @param {object} data
|
|
81
|
+
* @param {string} format
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
function buildFilename(context, data, format) {
|
|
85
|
+
const name = (data.name || data.sessionId || data.query || context)
|
|
86
|
+
.toLowerCase()
|
|
87
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
88
|
+
.replace(/^-|-$/g, '')
|
|
89
|
+
.slice(0, 40);
|
|
90
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
|
91
|
+
const ext = EXT_MAP[format] || '.txt';
|
|
92
|
+
return `${context}-${name}-${ts}${ext}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Main export entry point.
|
|
97
|
+
*
|
|
98
|
+
* @param {object} params
|
|
99
|
+
* @param {string} params.context - 'workflow' | 'search' | 'chat' | 'benchmark' | 'explore'
|
|
100
|
+
* @param {string} params.format - 'json' | 'jsonl' | 'csv' | 'markdown' | 'mermaid' | 'clipboard'
|
|
101
|
+
* @param {object} params.data - Raw source data
|
|
102
|
+
* @param {object} [params.options] - Format & context specific options
|
|
103
|
+
* @returns {{ content: string, mimeType: string, suggestedFilename: string, format: string }}
|
|
104
|
+
*/
|
|
105
|
+
async function exportArtifact({ context, format, data, options = {} }) {
|
|
106
|
+
// Handle clipboard as a meta-format: pick the best underlying format and copy
|
|
107
|
+
const isClipboard = format === 'clipboard';
|
|
108
|
+
const effectiveFormat = isClipboard ? pickClipboardFormat(context) : format;
|
|
109
|
+
|
|
110
|
+
// Validate
|
|
111
|
+
const supported = getFormatsForContext(context);
|
|
112
|
+
if (!supported.includes(format)) {
|
|
113
|
+
throw new ExportError(
|
|
114
|
+
`Format "${format}" not supported for ${context}. Supported: ${supported.join(', ')}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Transform
|
|
119
|
+
const transformer = TRANSFORMERS[context];
|
|
120
|
+
if (!transformer) {
|
|
121
|
+
throw new ExportError(`Unknown export context: "${context}"`);
|
|
122
|
+
}
|
|
123
|
+
const normalized = transformer(data, options);
|
|
124
|
+
|
|
125
|
+
// Render
|
|
126
|
+
const renderer = RENDERERS[effectiveFormat];
|
|
127
|
+
if (!renderer) {
|
|
128
|
+
throw new ExportError(`No renderer for format: "${effectiveFormat}"`);
|
|
129
|
+
}
|
|
130
|
+
const output = await renderer(normalized, options);
|
|
131
|
+
const isBinary = BINARY_FORMATS.has(effectiveFormat);
|
|
132
|
+
|
|
133
|
+
// Clipboard side-effect (text only — binary clipboard handled by Electron)
|
|
134
|
+
if (isClipboard) {
|
|
135
|
+
const ok = copyToClipboard(typeof output.content === 'string' ? output.content : output.content.toString());
|
|
136
|
+
if (!ok) {
|
|
137
|
+
throw new ExportError('Failed to copy to clipboard — unsupported platform or missing clipboard tool');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
content: output.content,
|
|
143
|
+
mimeType: output.mimeType,
|
|
144
|
+
suggestedFilename: buildFilename(context, data, effectiveFormat),
|
|
145
|
+
format: effectiveFormat,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Pick the best text format for clipboard based on context.
|
|
151
|
+
*/
|
|
152
|
+
function pickClipboardFormat(context) {
|
|
153
|
+
switch (context) {
|
|
154
|
+
case 'workflow': return 'mermaid';
|
|
155
|
+
case 'search': return 'json';
|
|
156
|
+
case 'chat': return 'markdown';
|
|
157
|
+
case 'benchmark': return 'json';
|
|
158
|
+
case 'explore': return 'json';
|
|
159
|
+
default: return 'json';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
class ExportError extends Error {
|
|
164
|
+
constructor(message) {
|
|
165
|
+
super(message);
|
|
166
|
+
this.name = 'ExportError';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
exportArtifact,
|
|
172
|
+
getFormatsForContext,
|
|
173
|
+
buildFilename,
|
|
174
|
+
ExportError,
|
|
175
|
+
};
|
package/src/lib/workflow.js
CHANGED
|
@@ -45,32 +45,55 @@ const SCHEMA_LIMITS = {
|
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* Validate a workflow definition object.
|
|
48
|
-
*
|
|
48
|
+
* Two modes: strict (default) returns error strings for backward compatibility,
|
|
49
|
+
* draft mode returns structured issue objects with severity levels.
|
|
49
50
|
*
|
|
50
51
|
* @param {object} definition - Parsed workflow JSON
|
|
51
|
-
* @
|
|
52
|
+
* @param {object} [options]
|
|
53
|
+
* @param {'strict'|'draft'} [options.mode='strict'] - Validation mode
|
|
54
|
+
* @returns {string[]|object} In strict: string[] errors. In draft: { valid, mode, issues, stats }
|
|
52
55
|
*/
|
|
53
|
-
function validateWorkflow(definition) {
|
|
54
|
-
const
|
|
56
|
+
function validateWorkflow(definition, { mode = 'strict' } = {}) {
|
|
57
|
+
const issues = [];
|
|
58
|
+
|
|
59
|
+
// Helper to add issue
|
|
60
|
+
function addIssue(severity, stepId, code, message, field = null, referencedStep = null) {
|
|
61
|
+
issues.push({
|
|
62
|
+
severity,
|
|
63
|
+
stepId,
|
|
64
|
+
code,
|
|
65
|
+
message,
|
|
66
|
+
...(field && { field }),
|
|
67
|
+
...(referencedStep && { referencedStep })
|
|
68
|
+
});
|
|
69
|
+
}
|
|
55
70
|
|
|
56
71
|
// Top-level required fields
|
|
57
72
|
if (!definition || typeof definition !== 'object') {
|
|
58
|
-
|
|
73
|
+
addIssue('error', null, 'INVALID_DEFINITION', 'Workflow definition must be a JSON object');
|
|
74
|
+
return mode === 'strict' ? ['Workflow definition must be a JSON object'] : { valid: false, mode, issues, stats: { totalSteps: 0, errors: 1, warnings: 0, info: 0 } };
|
|
59
75
|
}
|
|
76
|
+
|
|
60
77
|
if (!definition.name || typeof definition.name !== 'string') {
|
|
61
|
-
|
|
78
|
+
const severity = mode === 'draft' ? 'info' : 'error';
|
|
79
|
+
addIssue(severity, null, 'MISSING_WORKFLOW_NAME', 'Workflow must have a "name" string');
|
|
62
80
|
}
|
|
81
|
+
|
|
63
82
|
if (!Array.isArray(definition.steps) || definition.steps.length === 0) {
|
|
64
|
-
|
|
83
|
+
addIssue('error', null, 'MISSING_STEPS', 'Workflow must have a non-empty "steps" array');
|
|
65
84
|
}
|
|
66
85
|
|
|
67
|
-
|
|
86
|
+
const structuralErrors = issues.filter(i => i.severity === 'error');
|
|
87
|
+
if (structuralErrors.length > 0 && !Array.isArray(definition.steps)) {
|
|
88
|
+
// Can't validate steps without them
|
|
89
|
+
return formatResponse(mode, issues, 0);
|
|
90
|
+
}
|
|
68
91
|
|
|
69
92
|
// Validate inputs schema
|
|
70
93
|
if (definition.inputs) {
|
|
71
94
|
for (const [key, schema] of Object.entries(definition.inputs)) {
|
|
72
95
|
if (schema.type && !['string', 'number', 'boolean', 'array'].includes(schema.type)) {
|
|
73
|
-
|
|
96
|
+
addIssue('error', null, 'INVALID_INPUT_TYPE', `Input "${key}" has invalid type "${schema.type}" (must be string, number, boolean, or array)`);
|
|
74
97
|
}
|
|
75
98
|
}
|
|
76
99
|
}
|
|
@@ -84,7 +107,7 @@ function validateWorkflow(definition) {
|
|
|
84
107
|
const prefix = `Step ${i}`;
|
|
85
108
|
|
|
86
109
|
if (!step.id || typeof step.id !== 'string') {
|
|
87
|
-
|
|
110
|
+
addIssue('error', step.id || `step${i}`, 'MISSING_STEP_ID', `${prefix}: must have a string "id"`);
|
|
88
111
|
continue;
|
|
89
112
|
}
|
|
90
113
|
|
|
@@ -98,14 +121,20 @@ function validateWorkflow(definition) {
|
|
|
98
121
|
|
|
99
122
|
// Tool validation
|
|
100
123
|
if (!step.tool || typeof step.tool !== 'string') {
|
|
101
|
-
|
|
124
|
+
addIssue('error', step.id, 'MISSING_TOOL', `${stepPrefix}: must have a string "tool"`);
|
|
102
125
|
} else if (!ALL_TOOLS.has(step.tool)) {
|
|
103
|
-
|
|
126
|
+
addIssue('error', step.id, 'INVALID_TOOL', `${stepPrefix}: unknown tool "${step.tool}" (available: ${[...ALL_TOOLS].join(', ')})`);
|
|
104
127
|
}
|
|
105
128
|
|
|
106
129
|
// Inputs validation
|
|
107
130
|
if (step.tool !== 'generate' && (!step.inputs || typeof step.inputs !== 'object')) {
|
|
108
|
-
|
|
131
|
+
const severity = mode === 'draft' ? 'info' : 'error';
|
|
132
|
+
addIssue(severity, step.id, 'MISSING_INPUTS', `${stepPrefix}: must have an "inputs" object`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Step name validation (only in draft mode for better UX)
|
|
136
|
+
if (!step.name && mode === 'draft') {
|
|
137
|
+
addIssue('info', step.id, 'MISSING_STEP_NAME', `${stepPrefix}: missing "name" field`);
|
|
109
138
|
}
|
|
110
139
|
|
|
111
140
|
// Check template references point to known step IDs or reserved prefixes
|
|
@@ -127,25 +156,32 @@ function validateWorkflow(definition) {
|
|
|
127
156
|
const deps = extractDependencies(inputsToCheck);
|
|
128
157
|
for (const dep of deps) {
|
|
129
158
|
if (!forEachVars.has(dep) && !loopVars.has(dep) && !stepIds.has(dep) && !definition.steps.some(s => s.id === dep)) {
|
|
130
|
-
|
|
159
|
+
const severity = mode === 'draft' ? 'warning' : 'error';
|
|
160
|
+
addIssue(severity, step.id, 'UNKNOWN_STEP_REF', `${stepPrefix}: references unknown step "${dep}"`, null, dep);
|
|
131
161
|
}
|
|
132
162
|
}
|
|
133
163
|
}
|
|
134
164
|
|
|
135
165
|
// Condition validation (if present, should be a string)
|
|
136
166
|
if (step.condition !== undefined && typeof step.condition !== 'string') {
|
|
137
|
-
|
|
167
|
+
addIssue('error', step.id, 'INVALID_CONDITION', `${stepPrefix}: "condition" must be a string`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Empty condition validation
|
|
171
|
+
if (step.condition !== undefined && typeof step.condition === 'string' && step.condition.trim() === '') {
|
|
172
|
+
const severity = mode === 'draft' ? 'info' : 'error';
|
|
173
|
+
addIssue(severity, step.id, 'EMPTY_CONDITION', `${stepPrefix}: "condition" is empty`);
|
|
138
174
|
}
|
|
139
175
|
|
|
140
176
|
// forEach validation (if present, should be a template string)
|
|
141
177
|
if (step.forEach !== undefined && typeof step.forEach !== 'string') {
|
|
142
|
-
|
|
178
|
+
addIssue('error', step.id, 'INVALID_FOREACH', `${stepPrefix}: "forEach" must be a string`);
|
|
143
179
|
}
|
|
144
180
|
}
|
|
145
181
|
|
|
146
182
|
// Report duplicates
|
|
147
183
|
for (const id of duplicateIds) {
|
|
148
|
-
|
|
184
|
+
addIssue('error', id, 'DUPLICATE_ID', `Duplicate step id: "${id}"`);
|
|
149
185
|
}
|
|
150
186
|
|
|
151
187
|
// Validate conditional branch references
|
|
@@ -157,40 +193,183 @@ function validateWorkflow(definition) {
|
|
|
157
193
|
if (refs && Array.isArray(refs)) {
|
|
158
194
|
for (const ref of refs) {
|
|
159
195
|
if (!stepIds.has(ref)) {
|
|
160
|
-
|
|
196
|
+
const severity = mode === 'draft' ? 'warning' : 'error';
|
|
197
|
+
addIssue(severity, step.id, 'UNKNOWN_STEP_REF', `Step "${step.id}": conditional ${branch} references unknown step "${ref}"`, null, ref);
|
|
161
198
|
}
|
|
162
199
|
}
|
|
163
200
|
}
|
|
164
201
|
}
|
|
165
202
|
if (!step.inputs.condition) {
|
|
166
|
-
|
|
203
|
+
const severity = mode === 'draft' ? 'info' : 'error';
|
|
204
|
+
addIssue(severity, step.id, 'MISSING_REQUIRED_INPUT', `Step "${step.id}": conditional must have a "condition" input`, 'inputs.condition');
|
|
167
205
|
}
|
|
168
206
|
if (!step.inputs.then || !Array.isArray(step.inputs.then)) {
|
|
169
|
-
|
|
207
|
+
const severity = mode === 'draft' ? 'info' : 'error';
|
|
208
|
+
addIssue(severity, step.id, 'MISSING_REQUIRED_INPUT', `Step "${step.id}": conditional must have a "then" array`, 'inputs.then');
|
|
170
209
|
}
|
|
171
210
|
}
|
|
172
211
|
|
|
173
212
|
// Validate loop inline step
|
|
174
213
|
if (step.tool === 'loop' && step.inputs) {
|
|
175
214
|
if (!step.inputs.items) {
|
|
176
|
-
|
|
215
|
+
const severity = mode === 'draft' ? 'info' : 'error';
|
|
216
|
+
addIssue(severity, step.id, 'MISSING_REQUIRED_INPUT', `Step "${step.id}": loop must have an "items" input`, 'inputs.items');
|
|
177
217
|
}
|
|
178
218
|
if (!step.inputs.as || typeof step.inputs.as !== 'string') {
|
|
179
|
-
|
|
219
|
+
const severity = mode === 'draft' ? 'info' : 'error';
|
|
220
|
+
addIssue(severity, step.id, 'MISSING_REQUIRED_INPUT', `Step "${step.id}": loop must have a string "as" input`, 'inputs.as');
|
|
180
221
|
}
|
|
181
222
|
if (!step.inputs.step || typeof step.inputs.step !== 'object') {
|
|
182
|
-
|
|
223
|
+
const severity = mode === 'draft' ? 'info' : 'error';
|
|
224
|
+
addIssue(severity, step.id, 'MISSING_REQUIRED_INPUT', `Step "${step.id}": loop must have a "step" object`, 'inputs.step');
|
|
183
225
|
} else if (step.inputs.step.tool && !ALL_TOOLS.has(step.inputs.step.tool)) {
|
|
184
|
-
|
|
226
|
+
addIssue('error', step.id, 'INVALID_TOOL', `Step "${step.id}": loop sub-step has unknown tool "${step.inputs.step.tool}"`);
|
|
185
227
|
}
|
|
186
228
|
}
|
|
187
229
|
}
|
|
188
230
|
|
|
189
231
|
// Check for circular dependencies
|
|
190
|
-
const
|
|
191
|
-
|
|
232
|
+
const cycleResult = detectCyclesAsIssues(definition.steps);
|
|
233
|
+
for (const issue of cycleResult) {
|
|
234
|
+
addIssue('error', issue.stepId, 'CIRCULAR_DEPENDENCY', issue.message);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Draft-only checks: orphan nodes
|
|
238
|
+
if (mode === 'draft') {
|
|
239
|
+
for (const step of definition.steps || []) {
|
|
240
|
+
const isReferenced = (definition.steps || []).some(
|
|
241
|
+
other => other.id !== step.id &&
|
|
242
|
+
extractStepReferences(other).some(ref => ref.stepId === step.id)
|
|
243
|
+
);
|
|
244
|
+
const hasReferences = extractStepReferences(step).length > 0;
|
|
245
|
+
if (!isReferenced && !hasReferences && (definition.steps || []).length > 1) {
|
|
246
|
+
addIssue('info', step.id, 'ORPHAN_NODE', `Step "${step.id}" is not connected to any other step`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
192
250
|
|
|
193
|
-
return
|
|
251
|
+
return formatResponse(mode, issues, (definition.steps || []).length);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Extract step references from a step for orphan detection.
|
|
256
|
+
* @param {object} step
|
|
257
|
+
* @returns {Array<{stepId: string, field?: string}>}
|
|
258
|
+
*/
|
|
259
|
+
function extractStepReferences(step) {
|
|
260
|
+
const refs = [];
|
|
261
|
+
if (!step.inputs) return refs;
|
|
262
|
+
|
|
263
|
+
const deps = extractDependencies(step.inputs);
|
|
264
|
+
for (const dep of deps) {
|
|
265
|
+
if (dep !== 'inputs' && dep !== 'defaults' && dep !== 'item' && dep !== 'index') {
|
|
266
|
+
refs.push({ stepId: dep });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return refs;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Format the response based on validation mode.
|
|
275
|
+
* @param {string} mode
|
|
276
|
+
* @param {Array} issues
|
|
277
|
+
* @param {number} totalSteps
|
|
278
|
+
* @returns {string[]|object}
|
|
279
|
+
*/
|
|
280
|
+
function formatResponse(mode, issues, totalSteps) {
|
|
281
|
+
const errors = issues.filter(i => i.severity === 'error');
|
|
282
|
+
const warnings = issues.filter(i => i.severity === 'warning');
|
|
283
|
+
const info = issues.filter(i => i.severity === 'info');
|
|
284
|
+
|
|
285
|
+
if (mode === 'draft') {
|
|
286
|
+
return {
|
|
287
|
+
valid: errors.length === 0,
|
|
288
|
+
mode: 'draft',
|
|
289
|
+
issues,
|
|
290
|
+
stats: {
|
|
291
|
+
totalSteps,
|
|
292
|
+
errors: errors.length,
|
|
293
|
+
warnings: warnings.length,
|
|
294
|
+
info: info.length
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Strict mode: backward-compatible format
|
|
300
|
+
if (errors.length > 0) {
|
|
301
|
+
return errors.map(e => e.message);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// In strict mode, warnings and info are also treated as errors
|
|
305
|
+
const allIssues = issues.filter(i => i.severity !== 'info' || mode === 'strict');
|
|
306
|
+
if (allIssues.length > 0) {
|
|
307
|
+
return allIssues.map(e => e.message);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return [];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Detect circular dependencies and return as issue objects.
|
|
315
|
+
* @param {Array} steps
|
|
316
|
+
* @returns {Array<{stepId: string, message: string}>}
|
|
317
|
+
*/
|
|
318
|
+
function detectCyclesAsIssues(steps) {
|
|
319
|
+
const issues = [];
|
|
320
|
+
const stepMap = new Map(steps.map(s => [s.id, s]));
|
|
321
|
+
const adjList = new Map();
|
|
322
|
+
|
|
323
|
+
// Build adjacency list from template dependencies
|
|
324
|
+
for (const step of steps) {
|
|
325
|
+
const deps = extractDependencies(step.inputs || {});
|
|
326
|
+
if (step.condition) {
|
|
327
|
+
const condDeps = extractDependencies(step.condition);
|
|
328
|
+
for (const d of condDeps) deps.add(d);
|
|
329
|
+
}
|
|
330
|
+
if (step.forEach) {
|
|
331
|
+
const forDeps = extractDependencies(step.forEach);
|
|
332
|
+
for (const d of forDeps) deps.add(d);
|
|
333
|
+
}
|
|
334
|
+
adjList.set(step.id, deps);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// DFS cycle detection
|
|
338
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
339
|
+
const color = new Map(steps.map(s => [s.id, WHITE]));
|
|
340
|
+
|
|
341
|
+
function dfs(nodeId, path) {
|
|
342
|
+
color.set(nodeId, GRAY);
|
|
343
|
+
path.push(nodeId);
|
|
344
|
+
|
|
345
|
+
const neighbors = adjList.get(nodeId) || new Set();
|
|
346
|
+
for (const dep of neighbors) {
|
|
347
|
+
if (!stepMap.has(dep)) continue; // Unknown deps caught by other checks
|
|
348
|
+
if (color.get(dep) === GRAY) {
|
|
349
|
+
const cycleStart = path.indexOf(dep);
|
|
350
|
+
const cycle = path.slice(cycleStart).concat(dep);
|
|
351
|
+
issues.push({
|
|
352
|
+
stepId: cycle[0],
|
|
353
|
+
message: `Circular dependency: ${cycle.join(' -> ')}`
|
|
354
|
+
});
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (color.get(dep) === WHITE) {
|
|
358
|
+
dfs(dep, path);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
path.pop();
|
|
363
|
+
color.set(nodeId, BLACK);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (const step of steps) {
|
|
367
|
+
if (color.get(step.id) === WHITE) {
|
|
368
|
+
dfs(step.id, []);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return issues;
|
|
194
373
|
}
|
|
195
374
|
|
|
196
375
|
/**
|