@sparkleideas/browser 3.0.0-alpha.3
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/README.md +730 -0
- package/agents/architect.yaml +11 -0
- package/agents/coder.yaml +11 -0
- package/agents/reviewer.yaml +10 -0
- package/agents/security-architect.yaml +10 -0
- package/agents/tester.yaml +10 -0
- package/docker/Dockerfile +22 -0
- package/docker/docker-compose.yml +52 -0
- package/docker/test-fixtures/index.html +61 -0
- package/package.json +56 -0
- package/skills/browser/SKILL.md +204 -0
- package/src/agent/index.ts +35 -0
- package/src/application/browser-service.ts +570 -0
- package/src/domain/types.ts +324 -0
- package/src/index.ts +156 -0
- package/src/infrastructure/agent-browser-adapter.ts +654 -0
- package/src/infrastructure/hooks-integration.ts +170 -0
- package/src/infrastructure/memory-integration.ts +449 -0
- package/src/infrastructure/reasoningbank-adapter.ts +282 -0
- package/src/infrastructure/security-integration.ts +528 -0
- package/src/infrastructure/workflow-templates.ts +479 -0
- package/src/mcp-tools/browser-tools.ts +1210 -0
- package/src/mcp-tools/index.ts +6 -0
- package/src/skill/index.ts +24 -0
- package/tests/agent-browser-adapter.test.ts +328 -0
- package/tests/browser-service.test.ts +137 -0
- package/tests/e2e/browser-e2e.test.ts +175 -0
- package/tests/memory-integration.test.ts +277 -0
- package/tests/reasoningbank-adapter.test.ts +219 -0
- package/tests/security-integration.test.ts +194 -0
- package/tests/workflow-templates.test.ts +231 -0
- package/tmp.json +0 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sparkleideas/browser - Workflow Templates
|
|
3
|
+
* Pre-built workflow templates for common browser automation tasks
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { BrowserTrajectoryStep } from '../domain/types.js';
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Workflow Types
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
export interface WorkflowTemplate {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
category: WorkflowCategory;
|
|
17
|
+
steps: WorkflowStep[];
|
|
18
|
+
variables: WorkflowVariable[];
|
|
19
|
+
tags: string[];
|
|
20
|
+
estimatedDuration: number; // ms
|
|
21
|
+
successRate?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type WorkflowCategory =
|
|
25
|
+
| 'authentication'
|
|
26
|
+
| 'data-extraction'
|
|
27
|
+
| 'form-submission'
|
|
28
|
+
| 'navigation'
|
|
29
|
+
| 'testing'
|
|
30
|
+
| 'monitoring';
|
|
31
|
+
|
|
32
|
+
export interface WorkflowStep {
|
|
33
|
+
id: string;
|
|
34
|
+
action: BrowserAction;
|
|
35
|
+
target?: string; // Selector or variable reference ${var}
|
|
36
|
+
value?: string; // Value or variable reference
|
|
37
|
+
waitAfter?: number;
|
|
38
|
+
optional?: boolean;
|
|
39
|
+
onError?: 'continue' | 'abort' | 'retry';
|
|
40
|
+
maxRetries?: number;
|
|
41
|
+
condition?: string; // JavaScript condition
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type BrowserAction =
|
|
45
|
+
| 'open'
|
|
46
|
+
| 'click'
|
|
47
|
+
| 'fill'
|
|
48
|
+
| 'type'
|
|
49
|
+
| 'press'
|
|
50
|
+
| 'select'
|
|
51
|
+
| 'check'
|
|
52
|
+
| 'uncheck'
|
|
53
|
+
| 'hover'
|
|
54
|
+
| 'scroll'
|
|
55
|
+
| 'wait'
|
|
56
|
+
| 'screenshot'
|
|
57
|
+
| 'snapshot'
|
|
58
|
+
| 'get'
|
|
59
|
+
| 'eval'
|
|
60
|
+
| 'assert';
|
|
61
|
+
|
|
62
|
+
export interface WorkflowVariable {
|
|
63
|
+
name: string;
|
|
64
|
+
type: 'string' | 'number' | 'boolean' | 'selector';
|
|
65
|
+
required: boolean;
|
|
66
|
+
default?: string | number | boolean;
|
|
67
|
+
description: string;
|
|
68
|
+
sensitive?: boolean; // Will be masked in logs
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface WorkflowExecution {
|
|
72
|
+
templateId: string;
|
|
73
|
+
variables: Record<string, unknown>;
|
|
74
|
+
status: 'pending' | 'running' | 'completed' | 'failed';
|
|
75
|
+
currentStep: number;
|
|
76
|
+
results: WorkflowStepResult[];
|
|
77
|
+
startedAt: string;
|
|
78
|
+
completedAt?: string;
|
|
79
|
+
error?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface WorkflowStepResult {
|
|
83
|
+
stepId: string;
|
|
84
|
+
success: boolean;
|
|
85
|
+
duration: number;
|
|
86
|
+
data?: unknown;
|
|
87
|
+
error?: string;
|
|
88
|
+
retries?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ============================================================================
|
|
92
|
+
// Built-in Workflow Templates
|
|
93
|
+
// ============================================================================
|
|
94
|
+
|
|
95
|
+
export const WORKFLOW_TEMPLATES: WorkflowTemplate[] = [
|
|
96
|
+
// ============ Authentication ============
|
|
97
|
+
{
|
|
98
|
+
id: 'login-basic',
|
|
99
|
+
name: 'Basic Login',
|
|
100
|
+
description: 'Standard username/password login flow',
|
|
101
|
+
category: 'authentication',
|
|
102
|
+
tags: ['login', 'auth', 'form'],
|
|
103
|
+
estimatedDuration: 5000,
|
|
104
|
+
variables: [
|
|
105
|
+
{ name: 'url', type: 'string', required: true, description: 'Login page URL' },
|
|
106
|
+
{ name: 'usernameSelector', type: 'selector', required: false, default: '#username, #email, [name="email"], [type="email"]', description: 'Username/email field selector' },
|
|
107
|
+
{ name: 'passwordSelector', type: 'selector', required: false, default: '#password, [name="password"], [type="password"]', description: 'Password field selector' },
|
|
108
|
+
{ name: 'submitSelector', type: 'selector', required: false, default: '[type="submit"], button[type="submit"], #login-btn', description: 'Submit button selector' },
|
|
109
|
+
{ name: 'username', type: 'string', required: true, description: 'Username or email', sensitive: false },
|
|
110
|
+
{ name: 'password', type: 'string', required: true, description: 'Password', sensitive: true },
|
|
111
|
+
{ name: 'successIndicator', type: 'selector', required: false, default: '.dashboard, .home, #welcome', description: 'Element that indicates successful login' },
|
|
112
|
+
],
|
|
113
|
+
steps: [
|
|
114
|
+
{ id: 'navigate', action: 'open', target: '\${url}', waitAfter: 1000 },
|
|
115
|
+
{ id: 'snapshot-login', action: 'snapshot', onError: 'continue' },
|
|
116
|
+
{ id: 'enter-username', action: 'fill', target: '\${usernameSelector}', value: '\${username}', onError: 'abort' },
|
|
117
|
+
{ id: 'enter-password', action: 'fill', target: '\${passwordSelector}', value: '\${password}', onError: 'abort' },
|
|
118
|
+
{ id: 'submit', action: 'click', target: '\${submitSelector}', waitAfter: 2000, onError: 'retry', maxRetries: 2 },
|
|
119
|
+
{ id: 'verify-success', action: 'wait', target: '\${successIndicator}', onError: 'abort' },
|
|
120
|
+
{ id: 'snapshot-dashboard', action: 'snapshot', optional: true },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: 'login-oauth',
|
|
125
|
+
name: 'OAuth/SSO Login',
|
|
126
|
+
description: 'Login via OAuth provider (Google, GitHub, etc.)',
|
|
127
|
+
category: 'authentication',
|
|
128
|
+
tags: ['login', 'oauth', 'sso', 'google', 'github'],
|
|
129
|
+
estimatedDuration: 8000,
|
|
130
|
+
variables: [
|
|
131
|
+
{ name: 'url', type: 'string', required: true, description: 'App login page URL' },
|
|
132
|
+
{ name: 'providerButton', type: 'selector', required: true, description: 'OAuth provider button selector (e.g., "Continue with Google")' },
|
|
133
|
+
{ name: 'email', type: 'string', required: true, description: 'OAuth account email' },
|
|
134
|
+
{ name: 'password', type: 'string', required: true, description: 'OAuth account password', sensitive: true },
|
|
135
|
+
{ name: 'successUrl', type: 'string', required: false, description: 'URL pattern after successful login' },
|
|
136
|
+
],
|
|
137
|
+
steps: [
|
|
138
|
+
{ id: 'navigate', action: 'open', target: '\${url}', waitAfter: 1000 },
|
|
139
|
+
{ id: 'click-oauth', action: 'click', target: '\${providerButton}', waitAfter: 2000 },
|
|
140
|
+
{ id: 'enter-email', action: 'fill', target: '[type="email"], #identifierId', value: '\${email}' },
|
|
141
|
+
{ id: 'next-email', action: 'click', target: '#identifierNext, [type="submit"]', waitAfter: 1500 },
|
|
142
|
+
{ id: 'enter-password', action: 'fill', target: '[type="password"], [name="password"]', value: '\${password}' },
|
|
143
|
+
{ id: 'submit', action: 'click', target: '#passwordNext, [type="submit"]', waitAfter: 3000 },
|
|
144
|
+
{ id: 'wait-redirect', action: 'wait', target: '\${successUrl}', onError: 'continue' },
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: 'logout',
|
|
149
|
+
name: 'Logout',
|
|
150
|
+
description: 'Standard logout flow',
|
|
151
|
+
category: 'authentication',
|
|
152
|
+
tags: ['logout', 'auth', 'session'],
|
|
153
|
+
estimatedDuration: 3000,
|
|
154
|
+
variables: [
|
|
155
|
+
{ name: 'menuSelector', type: 'selector', required: false, default: '.user-menu, #user-dropdown, .avatar', description: 'User menu selector (if needed)' },
|
|
156
|
+
{ name: 'logoutSelector', type: 'selector', required: true, description: 'Logout button/link selector' },
|
|
157
|
+
{ name: 'confirmSelector', type: 'selector', required: false, description: 'Confirmation button if needed' },
|
|
158
|
+
],
|
|
159
|
+
steps: [
|
|
160
|
+
{ id: 'open-menu', action: 'click', target: '\${menuSelector}', optional: true, waitAfter: 500 },
|
|
161
|
+
{ id: 'click-logout', action: 'click', target: '\${logoutSelector}', waitAfter: 1000 },
|
|
162
|
+
{ id: 'confirm', action: 'click', target: '\${confirmSelector}', optional: true, waitAfter: 1000 },
|
|
163
|
+
{ id: 'verify', action: 'wait', target: '/login, /signin, .login-form', onError: 'continue' },
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
// ============ Data Extraction ============
|
|
168
|
+
{
|
|
169
|
+
id: 'scrape-table',
|
|
170
|
+
name: 'Scrape Table Data',
|
|
171
|
+
description: 'Extract data from HTML tables',
|
|
172
|
+
category: 'data-extraction',
|
|
173
|
+
tags: ['scrape', 'table', 'data', 'extract'],
|
|
174
|
+
estimatedDuration: 3000,
|
|
175
|
+
variables: [
|
|
176
|
+
{ name: 'url', type: 'string', required: true, description: 'Page URL containing the table' },
|
|
177
|
+
{ name: 'tableSelector', type: 'selector', required: false, default: 'table', description: 'Table selector' },
|
|
178
|
+
{ name: 'includeHeaders', type: 'boolean', required: false, default: true, description: 'Include table headers' },
|
|
179
|
+
],
|
|
180
|
+
steps: [
|
|
181
|
+
{ id: 'navigate', action: 'open', target: '\${url}', waitAfter: 1000 },
|
|
182
|
+
{ id: 'wait-table', action: 'wait', target: '\${tableSelector}' },
|
|
183
|
+
{ id: 'extract-data', action: 'eval', value: `
|
|
184
|
+
(() => {
|
|
185
|
+
const table = document.querySelector('\${tableSelector}');
|
|
186
|
+
if (!table) return { error: 'Table not found' };
|
|
187
|
+
|
|
188
|
+
const rows = Array.from(table.querySelectorAll('tr'));
|
|
189
|
+
const data = rows.map(row => {
|
|
190
|
+
const cells = Array.from(row.querySelectorAll('td, th'));
|
|
191
|
+
return cells.map(cell => cell.textContent.trim());
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return { headers: data[0], rows: data.slice(1), totalRows: data.length - 1 };
|
|
195
|
+
})()
|
|
196
|
+
`},
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: 'scrape-list',
|
|
201
|
+
name: 'Scrape List Items',
|
|
202
|
+
description: 'Extract items from lists or repeated elements',
|
|
203
|
+
category: 'data-extraction',
|
|
204
|
+
tags: ['scrape', 'list', 'data', 'extract'],
|
|
205
|
+
estimatedDuration: 3000,
|
|
206
|
+
variables: [
|
|
207
|
+
{ name: 'url', type: 'string', required: true, description: 'Page URL' },
|
|
208
|
+
{ name: 'itemSelector', type: 'selector', required: true, description: 'Selector for each item' },
|
|
209
|
+
{ name: 'fields', type: 'string', required: true, description: 'JSON object mapping field names to sub-selectors' },
|
|
210
|
+
],
|
|
211
|
+
steps: [
|
|
212
|
+
{ id: 'navigate', action: 'open', target: '\${url}', waitAfter: 1000 },
|
|
213
|
+
{ id: 'wait-items', action: 'wait', target: '\${itemSelector}' },
|
|
214
|
+
{ id: 'extract-items', action: 'eval', value: `
|
|
215
|
+
(() => {
|
|
216
|
+
const items = document.querySelectorAll('\${itemSelector}');
|
|
217
|
+
const fields = JSON.parse('\${fields}');
|
|
218
|
+
|
|
219
|
+
return Array.from(items).map(item => {
|
|
220
|
+
const result = {};
|
|
221
|
+
for (const [name, selector] of Object.entries(fields)) {
|
|
222
|
+
const el = item.querySelector(selector);
|
|
223
|
+
result[name] = el ? el.textContent.trim() : null;
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
226
|
+
});
|
|
227
|
+
})()
|
|
228
|
+
`},
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
// ============ Form Submission ============
|
|
233
|
+
{
|
|
234
|
+
id: 'contact-form',
|
|
235
|
+
name: 'Contact Form Submission',
|
|
236
|
+
description: 'Fill and submit a contact form',
|
|
237
|
+
category: 'form-submission',
|
|
238
|
+
tags: ['form', 'contact', 'submit'],
|
|
239
|
+
estimatedDuration: 5000,
|
|
240
|
+
variables: [
|
|
241
|
+
{ name: 'url', type: 'string', required: true, description: 'Contact page URL' },
|
|
242
|
+
{ name: 'name', type: 'string', required: true, description: 'Your name' },
|
|
243
|
+
{ name: 'email', type: 'string', required: true, description: 'Your email' },
|
|
244
|
+
{ name: 'message', type: 'string', required: true, description: 'Message content' },
|
|
245
|
+
{ name: 'submitSelector', type: 'selector', required: false, default: '[type="submit"], button[type="submit"]', description: 'Submit button' },
|
|
246
|
+
],
|
|
247
|
+
steps: [
|
|
248
|
+
{ id: 'navigate', action: 'open', target: '\${url}', waitAfter: 1000 },
|
|
249
|
+
{ id: 'fill-name', action: 'fill', target: '#name, [name="name"], [placeholder*="name" i]', value: '\${name}' },
|
|
250
|
+
{ id: 'fill-email', action: 'fill', target: '#email, [name="email"], [type="email"]', value: '\${email}' },
|
|
251
|
+
{ id: 'fill-message', action: 'fill', target: '#message, [name="message"], textarea', value: '\${message}' },
|
|
252
|
+
{ id: 'submit', action: 'click', target: '\${submitSelector}', waitAfter: 2000 },
|
|
253
|
+
{ id: 'screenshot', action: 'screenshot', optional: true },
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
// ============ Testing ============
|
|
258
|
+
{
|
|
259
|
+
id: 'visual-regression',
|
|
260
|
+
name: 'Visual Regression Test',
|
|
261
|
+
description: 'Take screenshots for visual comparison',
|
|
262
|
+
category: 'testing',
|
|
263
|
+
tags: ['test', 'visual', 'screenshot', 'regression'],
|
|
264
|
+
estimatedDuration: 5000,
|
|
265
|
+
variables: [
|
|
266
|
+
{ name: 'urls', type: 'string', required: true, description: 'Comma-separated list of URLs to test' },
|
|
267
|
+
{ name: 'viewport', type: 'string', required: false, default: '1280x720', description: 'Viewport size (WxH)' },
|
|
268
|
+
],
|
|
269
|
+
steps: [
|
|
270
|
+
{ id: 'set-viewport', action: 'eval', value: `
|
|
271
|
+
(() => {
|
|
272
|
+
const [w, h] = '\${viewport}'.split('x').map(Number);
|
|
273
|
+
return { width: w, height: h };
|
|
274
|
+
})()
|
|
275
|
+
`},
|
|
276
|
+
{ id: 'test-urls', action: 'eval', value: `'\${urls}'.split(',').map(u => u.trim())` },
|
|
277
|
+
],
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
id: 'smoke-test',
|
|
281
|
+
name: 'Smoke Test',
|
|
282
|
+
description: 'Basic smoke test to verify page loads correctly',
|
|
283
|
+
category: 'testing',
|
|
284
|
+
tags: ['test', 'smoke', 'health'],
|
|
285
|
+
estimatedDuration: 3000,
|
|
286
|
+
variables: [
|
|
287
|
+
{ name: 'url', type: 'string', required: true, description: 'URL to test' },
|
|
288
|
+
{ name: 'expectedTitle', type: 'string', required: false, description: 'Expected page title (partial match)' },
|
|
289
|
+
{ name: 'requiredElements', type: 'string', required: false, description: 'Comma-separated selectors that must exist' },
|
|
290
|
+
],
|
|
291
|
+
steps: [
|
|
292
|
+
{ id: 'navigate', action: 'open', target: '\${url}', waitAfter: 1000, onError: 'abort' },
|
|
293
|
+
{ id: 'check-title', action: 'get', target: 'title', condition: '\${expectedTitle}' },
|
|
294
|
+
{ id: 'check-elements', action: 'eval', value: `
|
|
295
|
+
(() => {
|
|
296
|
+
const selectors = '\${requiredElements}'.split(',').map(s => s.trim()).filter(Boolean);
|
|
297
|
+
const results = selectors.map(sel => ({
|
|
298
|
+
selector: sel,
|
|
299
|
+
found: document.querySelector(sel) !== null
|
|
300
|
+
}));
|
|
301
|
+
const allFound = results.every(r => r.found);
|
|
302
|
+
return { results, allFound };
|
|
303
|
+
})()
|
|
304
|
+
`, optional: true },
|
|
305
|
+
{ id: 'screenshot', action: 'screenshot' },
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
// ============ Monitoring ============
|
|
310
|
+
{
|
|
311
|
+
id: 'uptime-check',
|
|
312
|
+
name: 'Uptime Check',
|
|
313
|
+
description: 'Check if a page is accessible and loads within timeout',
|
|
314
|
+
category: 'monitoring',
|
|
315
|
+
tags: ['monitor', 'uptime', 'health', 'availability'],
|
|
316
|
+
estimatedDuration: 10000,
|
|
317
|
+
variables: [
|
|
318
|
+
{ name: 'url', type: 'string', required: true, description: 'URL to check' },
|
|
319
|
+
{ name: 'timeout', type: 'number', required: false, default: 10000, description: 'Timeout in ms' },
|
|
320
|
+
{ name: 'expectedStatus', type: 'number', required: false, default: 200, description: 'Expected HTTP status' },
|
|
321
|
+
],
|
|
322
|
+
steps: [
|
|
323
|
+
{ id: 'navigate', action: 'open', target: '\${url}', onError: 'abort' },
|
|
324
|
+
{ id: 'measure', action: 'eval', value: `
|
|
325
|
+
(() => {
|
|
326
|
+
const timing = performance.timing;
|
|
327
|
+
return {
|
|
328
|
+
dns: timing.domainLookupEnd - timing.domainLookupStart,
|
|
329
|
+
connection: timing.connectEnd - timing.connectStart,
|
|
330
|
+
ttfb: timing.responseStart - timing.requestStart,
|
|
331
|
+
domLoad: timing.domContentLoadedEventEnd - timing.navigationStart,
|
|
332
|
+
fullLoad: timing.loadEventEnd - timing.navigationStart
|
|
333
|
+
};
|
|
334
|
+
})()
|
|
335
|
+
`},
|
|
336
|
+
{ id: 'screenshot', action: 'screenshot', optional: true },
|
|
337
|
+
],
|
|
338
|
+
},
|
|
339
|
+
];
|
|
340
|
+
|
|
341
|
+
// ============================================================================
|
|
342
|
+
// Workflow Manager
|
|
343
|
+
// ============================================================================
|
|
344
|
+
|
|
345
|
+
export class WorkflowManager {
|
|
346
|
+
private templates: Map<string, WorkflowTemplate> = new Map();
|
|
347
|
+
private executions: Map<string, WorkflowExecution> = new Map();
|
|
348
|
+
|
|
349
|
+
constructor() {
|
|
350
|
+
// Load built-in templates
|
|
351
|
+
for (const template of WORKFLOW_TEMPLATES) {
|
|
352
|
+
this.templates.set(template.id, template);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Get all available templates
|
|
358
|
+
*/
|
|
359
|
+
listTemplates(category?: WorkflowCategory): WorkflowTemplate[] {
|
|
360
|
+
const templates = Array.from(this.templates.values());
|
|
361
|
+
if (category) {
|
|
362
|
+
return templates.filter((t) => t.category === category);
|
|
363
|
+
}
|
|
364
|
+
return templates;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Get a specific template
|
|
369
|
+
*/
|
|
370
|
+
getTemplate(id: string): WorkflowTemplate | undefined {
|
|
371
|
+
return this.templates.get(id);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Register a custom template
|
|
376
|
+
*/
|
|
377
|
+
registerTemplate(template: WorkflowTemplate): void {
|
|
378
|
+
this.templates.set(template.id, template);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Search templates by tags
|
|
383
|
+
*/
|
|
384
|
+
searchTemplates(query: string): WorkflowTemplate[] {
|
|
385
|
+
const terms = query.toLowerCase().split(/\s+/);
|
|
386
|
+
return Array.from(this.templates.values()).filter((t) => {
|
|
387
|
+
const searchText = `${t.name} ${t.description} ${t.tags.join(' ')}`.toLowerCase();
|
|
388
|
+
return terms.every((term) => searchText.includes(term));
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Validate variables for a template
|
|
394
|
+
*/
|
|
395
|
+
validateVariables(
|
|
396
|
+
templateId: string,
|
|
397
|
+
variables: Record<string, unknown>
|
|
398
|
+
): { valid: boolean; errors: string[] } {
|
|
399
|
+
const template = this.templates.get(templateId);
|
|
400
|
+
if (!template) {
|
|
401
|
+
return { valid: false, errors: [`Template '${templateId}' not found`] };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const errors: string[] = [];
|
|
405
|
+
|
|
406
|
+
for (const varDef of template.variables) {
|
|
407
|
+
const value = variables[varDef.name];
|
|
408
|
+
|
|
409
|
+
if (varDef.required && (value === undefined || value === null || value === '')) {
|
|
410
|
+
errors.push(`Required variable '${varDef.name}' is missing`);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (value !== undefined && value !== null) {
|
|
415
|
+
switch (varDef.type) {
|
|
416
|
+
case 'number':
|
|
417
|
+
if (typeof value !== 'number' && isNaN(Number(value))) {
|
|
418
|
+
errors.push(`Variable '${varDef.name}' must be a number`);
|
|
419
|
+
}
|
|
420
|
+
break;
|
|
421
|
+
case 'boolean':
|
|
422
|
+
if (typeof value !== 'boolean' && !['true', 'false'].includes(String(value))) {
|
|
423
|
+
errors.push(`Variable '${varDef.name}' must be a boolean`);
|
|
424
|
+
}
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return { valid: errors.length === 0, errors };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Interpolate variables in step values
|
|
435
|
+
*/
|
|
436
|
+
interpolateStep(step: WorkflowStep, variables: Record<string, unknown>): WorkflowStep {
|
|
437
|
+
const interpolate = (value: string | undefined): string | undefined => {
|
|
438
|
+
if (!value) return value;
|
|
439
|
+
return value.replace(/\$\{(\w+)\}/g, (_, name) => {
|
|
440
|
+
const val = variables[name];
|
|
441
|
+
return val !== undefined ? String(val) : `\${${name}}`;
|
|
442
|
+
});
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
...step,
|
|
447
|
+
target: interpolate(step.target),
|
|
448
|
+
value: interpolate(step.value),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Get execution status
|
|
454
|
+
*/
|
|
455
|
+
getExecution(executionId: string): WorkflowExecution | undefined {
|
|
456
|
+
return this.executions.get(executionId);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ============================================================================
|
|
461
|
+
// Factory
|
|
462
|
+
// ============================================================================
|
|
463
|
+
|
|
464
|
+
let defaultManager: WorkflowManager | null = null;
|
|
465
|
+
|
|
466
|
+
export function getWorkflowManager(): WorkflowManager {
|
|
467
|
+
if (!defaultManager) {
|
|
468
|
+
defaultManager = new WorkflowManager();
|
|
469
|
+
}
|
|
470
|
+
return defaultManager;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export function listWorkflows(category?: WorkflowCategory): WorkflowTemplate[] {
|
|
474
|
+
return getWorkflowManager().listTemplates(category);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function getWorkflow(id: string): WorkflowTemplate | undefined {
|
|
478
|
+
return getWorkflowManager().getTemplate(id);
|
|
479
|
+
}
|