@lokascript/domain-flow 2.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/README.md +120 -0
- package/dist/index.cjs +2251 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +643 -0
- package/dist/index.d.ts +643 -0
- package/dist/index.js +2169 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
- package/src/__test__/flow-domain.test.ts +696 -0
- package/src/__test__/hateoas-commands.test.ts +520 -0
- package/src/__test__/htmx-generator.test.ts +100 -0
- package/src/__test__/mcp-workflow-server.test.ts +317 -0
- package/src/__test__/pipeline-parser.test.ts +188 -0
- package/src/__test__/route-extractor.test.ts +94 -0
- package/src/generators/flow-generator.ts +338 -0
- package/src/generators/flow-renderer.ts +262 -0
- package/src/generators/htmx-generator.ts +129 -0
- package/src/generators/route-extractor.ts +105 -0
- package/src/generators/workflow-generator.ts +129 -0
- package/src/index.ts +210 -0
- package/src/parser/pipeline-parser.ts +151 -0
- package/src/profiles/index.ts +186 -0
- package/src/runtime/mcp-workflow-server.ts +409 -0
- package/src/runtime/workflow-executor.ts +171 -0
- package/src/schemas/hateoas-schemas.ts +152 -0
- package/src/schemas/index.ts +320 -0
- package/src/siren-agent.d.ts +14 -0
- package/src/tokenizers/index.ts +592 -0
- package/src/types.ts +108 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Workflow Server — HATEOAS → MCP tool bridge
|
|
3
|
+
*
|
|
4
|
+
* An MCP server that wraps a Siren API, exposing current entity affordances
|
|
5
|
+
* (actions and links) as dynamic MCP tools. As the agent navigates through
|
|
6
|
+
* hypermedia states, tools appear and disappear — `tools/list_changed` fires
|
|
7
|
+
* on every state transition.
|
|
8
|
+
*
|
|
9
|
+
* This is the novel contribution: MCP's tool protocol becomes a first-class
|
|
10
|
+
* HATEOAS mechanism. External LLM clients (Claude Code, Cursor, etc.) see
|
|
11
|
+
* affordance-driven tool sets without needing to understand Siren.
|
|
12
|
+
*
|
|
13
|
+
* Tool naming convention:
|
|
14
|
+
* action__{name} — Execute a Siren action
|
|
15
|
+
* navigate__{rel} — Follow a Siren link
|
|
16
|
+
* resolve__{name} — Resolve a 409 prerequisite action
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* import { createMcpWorkflowServer } from '@lokascript/domain-flow/runtime';
|
|
21
|
+
*
|
|
22
|
+
* const server = createMcpWorkflowServer({
|
|
23
|
+
* entryPoint: 'http://api.example.com/',
|
|
24
|
+
* name: 'order-api',
|
|
25
|
+
* version: '1.0.0',
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* await server.start(); // Connects to API, builds initial tools, listens on stdio
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { WorkflowSpec } from '../types.js';
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// Types
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
/** Siren entity structure (subset for our needs) */
|
|
39
|
+
interface SirenEntity {
|
|
40
|
+
class?: string[];
|
|
41
|
+
properties?: Record<string, unknown>;
|
|
42
|
+
entities?: Array<Record<string, unknown>>;
|
|
43
|
+
actions?: SirenAction[];
|
|
44
|
+
links?: SirenLink[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface SirenAction {
|
|
48
|
+
name: string;
|
|
49
|
+
title?: string;
|
|
50
|
+
href: string;
|
|
51
|
+
method?: string;
|
|
52
|
+
type?: string;
|
|
53
|
+
fields?: SirenField[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface SirenField {
|
|
57
|
+
name: string;
|
|
58
|
+
type?: string;
|
|
59
|
+
value?: unknown;
|
|
60
|
+
title?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface SirenLink {
|
|
64
|
+
rel: string[];
|
|
65
|
+
href: string;
|
|
66
|
+
title?: string;
|
|
67
|
+
type?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** MCP tool definition */
|
|
71
|
+
interface McpTool {
|
|
72
|
+
name: string;
|
|
73
|
+
description: string;
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object';
|
|
76
|
+
properties: Record<string, unknown>;
|
|
77
|
+
required?: string[];
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Configuration for the MCP workflow server */
|
|
82
|
+
export interface McpWorkflowServerConfig {
|
|
83
|
+
/** API entry point URL */
|
|
84
|
+
entryPoint: string;
|
|
85
|
+
/** Server name for MCP identification */
|
|
86
|
+
name?: string;
|
|
87
|
+
/** Server version */
|
|
88
|
+
version?: string;
|
|
89
|
+
/** HTTP headers (e.g., auth) for all API requests */
|
|
90
|
+
headers?: Record<string, string>;
|
|
91
|
+
/** Enable verbose logging */
|
|
92
|
+
verbose?: boolean;
|
|
93
|
+
/** Optional pre-compiled WorkflowSpec to auto-execute */
|
|
94
|
+
workflow?: WorkflowSpec;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// =============================================================================
|
|
98
|
+
// Tool Generation from Siren Affordances
|
|
99
|
+
// =============================================================================
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Convert Siren actions to MCP tool definitions.
|
|
103
|
+
*
|
|
104
|
+
* Each action becomes an `action__{name}` tool with input schema
|
|
105
|
+
* generated from the action's fields.
|
|
106
|
+
*/
|
|
107
|
+
export function actionsToTools(actions: SirenAction[]): McpTool[] {
|
|
108
|
+
return actions.map(action => {
|
|
109
|
+
const properties: Record<string, unknown> = {};
|
|
110
|
+
const required: string[] = [];
|
|
111
|
+
|
|
112
|
+
for (const field of action.fields ?? []) {
|
|
113
|
+
properties[field.name] = {
|
|
114
|
+
type: fieldTypeToJsonSchema(field.type),
|
|
115
|
+
...(field.title ? { description: field.title } : {}),
|
|
116
|
+
...(field.value !== undefined ? { default: field.value } : {}),
|
|
117
|
+
};
|
|
118
|
+
// Siren fields are required by default unless they have a default value
|
|
119
|
+
if (field.value === undefined) {
|
|
120
|
+
required.push(field.name);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
name: `action__${action.name}`,
|
|
126
|
+
description:
|
|
127
|
+
action.title ??
|
|
128
|
+
`Execute the "${action.name}" action (${action.method ?? 'POST'} ${action.href})`,
|
|
129
|
+
inputSchema: {
|
|
130
|
+
type: 'object' as const,
|
|
131
|
+
properties,
|
|
132
|
+
...(required.length > 0 ? { required } : {}),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Convert Siren links to MCP tool definitions.
|
|
140
|
+
*
|
|
141
|
+
* Each link becomes a `navigate__{rel}` tool with no required inputs.
|
|
142
|
+
*/
|
|
143
|
+
export function linksToTools(links: SirenLink[]): McpTool[] {
|
|
144
|
+
// Deduplicate by first rel
|
|
145
|
+
const seen = new Set<string>();
|
|
146
|
+
const tools: McpTool[] = [];
|
|
147
|
+
|
|
148
|
+
for (const link of links) {
|
|
149
|
+
const rel = link.rel[0];
|
|
150
|
+
if (!rel || rel === 'self' || seen.has(rel)) continue;
|
|
151
|
+
seen.add(rel);
|
|
152
|
+
|
|
153
|
+
tools.push({
|
|
154
|
+
name: `navigate__${rel}`,
|
|
155
|
+
description: link.title ?? `Navigate to "${rel}" (${link.href})`,
|
|
156
|
+
inputSchema: {
|
|
157
|
+
type: 'object' as const,
|
|
158
|
+
properties: {},
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return tools;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Convert Siren field type to JSON Schema type.
|
|
168
|
+
*/
|
|
169
|
+
function fieldTypeToJsonSchema(type?: string): string {
|
|
170
|
+
switch (type) {
|
|
171
|
+
case 'number':
|
|
172
|
+
case 'range':
|
|
173
|
+
return 'number';
|
|
174
|
+
case 'checkbox':
|
|
175
|
+
return 'boolean';
|
|
176
|
+
case 'hidden':
|
|
177
|
+
case 'text':
|
|
178
|
+
case 'email':
|
|
179
|
+
case 'url':
|
|
180
|
+
case 'password':
|
|
181
|
+
case 'search':
|
|
182
|
+
case 'tel':
|
|
183
|
+
case 'date':
|
|
184
|
+
case 'datetime-local':
|
|
185
|
+
case 'time':
|
|
186
|
+
case 'month':
|
|
187
|
+
case 'week':
|
|
188
|
+
default:
|
|
189
|
+
return 'string';
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Build the complete tool set from a Siren entity.
|
|
195
|
+
*/
|
|
196
|
+
export function entityToTools(entity: SirenEntity): McpTool[] {
|
|
197
|
+
return [...actionsToTools(entity.actions ?? []), ...linksToTools(entity.links ?? [])];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// =============================================================================
|
|
201
|
+
// MCP Server (protocol-level, transport-agnostic)
|
|
202
|
+
// =============================================================================
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* MCP Workflow Server state machine.
|
|
206
|
+
*
|
|
207
|
+
* Manages the Siren agent state and translates between MCP tool
|
|
208
|
+
* calls and Siren API interactions. Transport (stdio, SSE, etc.)
|
|
209
|
+
* is handled externally.
|
|
210
|
+
*
|
|
211
|
+
* The server lifecycle:
|
|
212
|
+
* 1. initialize() — fetch entry point, build initial tool set
|
|
213
|
+
* 2. listTools() — return current affordance-derived tools
|
|
214
|
+
* 3. callTool(name, args) — execute action or navigate, rebuild tools
|
|
215
|
+
* 4. On each state change, emit tools/list_changed notification
|
|
216
|
+
*/
|
|
217
|
+
export class McpWorkflowServer {
|
|
218
|
+
private config: Required<Pick<McpWorkflowServerConfig, 'entryPoint' | 'name' | 'version'>> &
|
|
219
|
+
McpWorkflowServerConfig;
|
|
220
|
+
private currentEntity: SirenEntity | null = null;
|
|
221
|
+
private currentUrl: string;
|
|
222
|
+
private currentTools: McpTool[] = [];
|
|
223
|
+
private toolChangeListeners: Array<() => void> = [];
|
|
224
|
+
|
|
225
|
+
constructor(config: McpWorkflowServerConfig) {
|
|
226
|
+
this.config = {
|
|
227
|
+
name: 'hateoas-mcp-server',
|
|
228
|
+
version: '1.0.0',
|
|
229
|
+
...config,
|
|
230
|
+
};
|
|
231
|
+
this.currentUrl = config.entryPoint;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Register a listener for tool list changes (maps to tools/list_changed).
|
|
236
|
+
*/
|
|
237
|
+
onToolsChanged(listener: () => void): void {
|
|
238
|
+
this.toolChangeListeners.push(listener);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Initialize the server by fetching the entry point entity.
|
|
243
|
+
*/
|
|
244
|
+
async initialize(): Promise<void> {
|
|
245
|
+
const entity = await this.fetchEntity(this.config.entryPoint);
|
|
246
|
+
this.updateState(entity, this.config.entryPoint);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Get the current list of available MCP tools.
|
|
251
|
+
*/
|
|
252
|
+
listTools(): McpTool[] {
|
|
253
|
+
return this.currentTools;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get the current entity as an MCP resource.
|
|
258
|
+
*/
|
|
259
|
+
getCurrentEntity(): SirenEntity | null {
|
|
260
|
+
return this.currentEntity;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Get the current URL.
|
|
265
|
+
*/
|
|
266
|
+
getCurrentUrl(): string {
|
|
267
|
+
return this.currentUrl;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Call a tool (action or navigation).
|
|
272
|
+
*
|
|
273
|
+
* Returns the result text and triggers tools/list_changed if the
|
|
274
|
+
* entity state changed.
|
|
275
|
+
*/
|
|
276
|
+
async callTool(
|
|
277
|
+
name: string,
|
|
278
|
+
args: Record<string, unknown>
|
|
279
|
+
): Promise<{ content: string; isError?: boolean }> {
|
|
280
|
+
if (!this.currentEntity) {
|
|
281
|
+
return { content: 'Server not initialized. Call initialize() first.', isError: true };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Parse tool name
|
|
285
|
+
const [prefix, ...rest] = name.split('__');
|
|
286
|
+
const identifier = rest.join('__'); // Handle names with double underscores
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
switch (prefix) {
|
|
290
|
+
case 'action': {
|
|
291
|
+
const action = this.currentEntity.actions?.find(a => a.name === identifier);
|
|
292
|
+
if (!action) {
|
|
293
|
+
return {
|
|
294
|
+
content: `Action "${identifier}" not available. Available: ${(this.currentEntity.actions ?? []).map(a => a.name).join(', ')}`,
|
|
295
|
+
isError: true,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const result = await this.executeAction(action, args);
|
|
299
|
+
return { content: JSON.stringify(result.properties ?? result, null, 2) };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
case 'navigate': {
|
|
303
|
+
const link = this.currentEntity.links?.find(l => l.rel.includes(identifier));
|
|
304
|
+
if (!link) {
|
|
305
|
+
return {
|
|
306
|
+
content: `Link "${identifier}" not available. Available: ${(this.currentEntity.links ?? []).map(l => l.rel[0]).join(', ')}`,
|
|
307
|
+
isError: true,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const entity = await this.fetchEntity(link.href);
|
|
311
|
+
this.updateState(entity, link.href);
|
|
312
|
+
return { content: JSON.stringify(entity.properties ?? entity, null, 2) };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
default:
|
|
316
|
+
return { content: `Unknown tool prefix: ${prefix}`, isError: true };
|
|
317
|
+
}
|
|
318
|
+
} catch (error) {
|
|
319
|
+
return {
|
|
320
|
+
content: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
321
|
+
isError: true,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Get MCP server capabilities for the initialize response.
|
|
328
|
+
*/
|
|
329
|
+
getCapabilities() {
|
|
330
|
+
return {
|
|
331
|
+
tools: { listChanged: true },
|
|
332
|
+
resources: {},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Get MCP server info for the initialize response.
|
|
338
|
+
*/
|
|
339
|
+
getServerInfo() {
|
|
340
|
+
return {
|
|
341
|
+
name: this.config.name,
|
|
342
|
+
version: this.config.version,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ===========================================================================
|
|
347
|
+
// Private
|
|
348
|
+
// ===========================================================================
|
|
349
|
+
|
|
350
|
+
private async fetchEntity(url: string): Promise<SirenEntity> {
|
|
351
|
+
const response = await fetch(url, {
|
|
352
|
+
headers: {
|
|
353
|
+
Accept: 'application/vnd.siren+json, application/json',
|
|
354
|
+
...this.config.headers,
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
if (!response.ok) {
|
|
359
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return (await response.json()) as SirenEntity;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private async executeAction(
|
|
366
|
+
action: SirenAction,
|
|
367
|
+
data: Record<string, unknown>
|
|
368
|
+
): Promise<SirenEntity> {
|
|
369
|
+
const method = action.method?.toUpperCase() ?? 'POST';
|
|
370
|
+
const headers: Record<string, string> = {
|
|
371
|
+
Accept: 'application/vnd.siren+json, application/json',
|
|
372
|
+
...this.config.headers,
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
let body: string | undefined;
|
|
376
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
377
|
+
headers['Content-Type'] = action.type ?? 'application/json';
|
|
378
|
+
body = JSON.stringify(data);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const response = await fetch(action.href, { method, headers, body });
|
|
382
|
+
|
|
383
|
+
if (!response.ok) {
|
|
384
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const entity = (await response.json()) as SirenEntity;
|
|
388
|
+
this.updateState(entity, action.href);
|
|
389
|
+
return entity;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private updateState(entity: SirenEntity, url: string): void {
|
|
393
|
+
this.currentEntity = entity;
|
|
394
|
+
this.currentUrl = url;
|
|
395
|
+
this.currentTools = entityToTools(entity);
|
|
396
|
+
|
|
397
|
+
// Notify listeners (maps to MCP tools/list_changed)
|
|
398
|
+
for (const listener of this.toolChangeListeners) {
|
|
399
|
+
listener();
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Create a new MCP workflow server instance.
|
|
406
|
+
*/
|
|
407
|
+
export function createMcpWorkflowServer(config: McpWorkflowServerConfig): McpWorkflowServer {
|
|
408
|
+
return new McpWorkflowServer(config);
|
|
409
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Executor — siren-grail adapter
|
|
3
|
+
*
|
|
4
|
+
* Thin adapter that takes a WorkflowSpec (from FlowScript parsing)
|
|
5
|
+
* and executes it against a Siren API using siren-grail's OODAAgent
|
|
6
|
+
* and compileWorkflow().
|
|
7
|
+
*
|
|
8
|
+
* This bridges FlowScript's natural-language parsing to siren-grail's
|
|
9
|
+
* HATEOAS agent runtime.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* import { executeWorkflow } from '@lokascript/domain-flow/runtime';
|
|
14
|
+
* import { createFlowDSL, toFlowSpec } from '@lokascript/domain-flow';
|
|
15
|
+
* import { toWorkflowSpec } from '@lokascript/domain-flow/generators/workflow-generator';
|
|
16
|
+
*
|
|
17
|
+
* const flow = createFlowDSL();
|
|
18
|
+
* const specs = [
|
|
19
|
+
* toFlowSpec(flow.parse('enter /api', 'en'), 'en'),
|
|
20
|
+
* toFlowSpec(flow.parse('follow orders', 'en'), 'en'),
|
|
21
|
+
* toFlowSpec(flow.parse('perform createOrder with #checkout', 'en'), 'en'),
|
|
22
|
+
* toFlowSpec(flow.parse('capture as orderId', 'en'), 'en'),
|
|
23
|
+
* ];
|
|
24
|
+
*
|
|
25
|
+
* const workflow = toWorkflowSpec(specs);
|
|
26
|
+
* const result = await executeWorkflow(workflow, { verbose: true });
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { WorkflowSpec, WorkflowStep } from '../types.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Result from a workflow execution.
|
|
34
|
+
* Maps to siren-grail's OODAResult.
|
|
35
|
+
*/
|
|
36
|
+
export interface WorkflowResult {
|
|
37
|
+
status: 'stopped' | 'error' | 'maxSteps';
|
|
38
|
+
reason: string;
|
|
39
|
+
result?: unknown;
|
|
40
|
+
steps: number;
|
|
41
|
+
history: Array<Record<string, unknown>>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Options for workflow execution.
|
|
46
|
+
*/
|
|
47
|
+
export interface ExecuteWorkflowOptions {
|
|
48
|
+
/** Maximum OODA steps before stopping (default: 50) */
|
|
49
|
+
maxSteps?: number;
|
|
50
|
+
/** HTTP headers to send with all requests */
|
|
51
|
+
headers?: Record<string, string>;
|
|
52
|
+
/** Enable verbose logging (default: false) */
|
|
53
|
+
verbose?: boolean;
|
|
54
|
+
/** Timeout per request in ms (default: 30000) */
|
|
55
|
+
timeout?: number;
|
|
56
|
+
/** Enable auto-pursuit on 409 Conflict responses */
|
|
57
|
+
autoPursue?: boolean;
|
|
58
|
+
/** Callback on each entity navigation */
|
|
59
|
+
onEntity?: (entity: unknown, url: string) => void;
|
|
60
|
+
/** Callback on each decision */
|
|
61
|
+
onDecision?: (decision: unknown) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Convert a WorkflowSpec to siren-grail's step format.
|
|
66
|
+
*
|
|
67
|
+
* This is a pure-data transformation — no siren-grail dependency required.
|
|
68
|
+
* The step types map 1:1:
|
|
69
|
+
* WorkflowStep.navigate → { type: 'navigate', rel }
|
|
70
|
+
* WorkflowStep.action → { type: 'action', action, data?, dataSource? }
|
|
71
|
+
* WorkflowStep.stop → { type: 'stop', result?, reason? }
|
|
72
|
+
*/
|
|
73
|
+
function toSirenSteps(steps: WorkflowStep[]): Array<Record<string, unknown>> {
|
|
74
|
+
return steps.map(step => {
|
|
75
|
+
switch (step.type) {
|
|
76
|
+
case 'navigate':
|
|
77
|
+
return {
|
|
78
|
+
type: 'navigate',
|
|
79
|
+
rel: step.rel,
|
|
80
|
+
...(step.capture ? { capture: step.capture } : {}),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
case 'action':
|
|
84
|
+
return {
|
|
85
|
+
type: 'action',
|
|
86
|
+
action: step.action,
|
|
87
|
+
...(step.data ? { data: step.data } : {}),
|
|
88
|
+
...(step.dataSource ? { dataSource: step.dataSource } : {}),
|
|
89
|
+
...(step.capture ? { capture: step.capture } : {}),
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
case 'stop':
|
|
93
|
+
return {
|
|
94
|
+
type: 'stop',
|
|
95
|
+
...(step.result ? { result: step.result } : {}),
|
|
96
|
+
...(step.reason ? { reason: step.reason } : {}),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
default:
|
|
100
|
+
return step as Record<string, unknown>;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Execute a WorkflowSpec against a Siren API using siren-grail.
|
|
107
|
+
*
|
|
108
|
+
* Requires `siren-agent` to be installed as a peer dependency.
|
|
109
|
+
* Dynamically imports it to avoid hard dependency.
|
|
110
|
+
*
|
|
111
|
+
* @param spec - The compiled WorkflowSpec from toWorkflowSpec()
|
|
112
|
+
* @param options - Execution options (headers, timeout, etc.)
|
|
113
|
+
* @returns The execution result with status, history, and captured values
|
|
114
|
+
*
|
|
115
|
+
* @throws Error if siren-agent is not installed
|
|
116
|
+
*/
|
|
117
|
+
export async function executeWorkflow(
|
|
118
|
+
spec: WorkflowSpec,
|
|
119
|
+
options: ExecuteWorkflowOptions = {}
|
|
120
|
+
): Promise<WorkflowResult> {
|
|
121
|
+
// Dynamic import to keep siren-agent as optional peer dep
|
|
122
|
+
let sirenAgent: {
|
|
123
|
+
OODAAgent: new (
|
|
124
|
+
url: string,
|
|
125
|
+
opts: Record<string, unknown>
|
|
126
|
+
) => { run(): Promise<WorkflowResult> };
|
|
127
|
+
compileWorkflow: (steps: Array<Record<string, unknown>>) => unknown;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
sirenAgent = await import('siren-agent');
|
|
132
|
+
} catch {
|
|
133
|
+
throw new Error(
|
|
134
|
+
'siren-agent is required for workflow execution. Install it with: npm install siren-agent'
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { OODAAgent, compileWorkflow } = sirenAgent;
|
|
139
|
+
const steps = toSirenSteps(spec.steps);
|
|
140
|
+
const decide = compileWorkflow(steps);
|
|
141
|
+
|
|
142
|
+
const agent = new OODAAgent(spec.entryPoint, {
|
|
143
|
+
maxSteps: options.maxSteps ?? 50,
|
|
144
|
+
headers: options.headers,
|
|
145
|
+
verbose: options.verbose ?? false,
|
|
146
|
+
timeout: options.timeout ?? 30000,
|
|
147
|
+
decide,
|
|
148
|
+
...(options.autoPursue ? { autoPursue: { maxDepth: 5 } } : {}),
|
|
149
|
+
...(options.onEntity ? { onEntity: options.onEntity } : {}),
|
|
150
|
+
...(options.onDecision ? { onDecision: options.onDecision } : {}),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return agent.run();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Create a reusable workflow executor bound to specific options.
|
|
158
|
+
*
|
|
159
|
+
* Useful when executing multiple workflows against the same API
|
|
160
|
+
* with shared headers/auth.
|
|
161
|
+
*/
|
|
162
|
+
export function createWorkflowExecutor(defaultOptions: ExecuteWorkflowOptions = {}) {
|
|
163
|
+
return {
|
|
164
|
+
execute(spec: WorkflowSpec, overrides?: ExecuteWorkflowOptions): Promise<WorkflowResult> {
|
|
165
|
+
return executeWorkflow(spec, { ...defaultOptions, ...overrides });
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Re-export for convenience
|
|
171
|
+
export { toSirenSteps };
|