@joshualelon/clawdbot-skill-flow 2.3.4 → 2.4.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/package.json +1 -1
- package/src/commands/flow-step.ts +0 -28
- package/src/engine/executor.ts +108 -281
- package/src/engine/renderer.ts +3 -9
- package/src/engine/transitions.ts +27 -85
- package/src/state/history-store.ts +39 -5
- package/src/engine/hooks-loader.ts +0 -305
package/package.json
CHANGED
|
@@ -13,11 +13,6 @@ import {
|
|
|
13
13
|
} from "../state/session-store.js";
|
|
14
14
|
import { saveFlowHistory } from "../state/history-store.js";
|
|
15
15
|
import { processStep } from "../engine/executor.js";
|
|
16
|
-
import {
|
|
17
|
-
loadHooks,
|
|
18
|
-
resolveFlowPath,
|
|
19
|
-
safeExecuteHook,
|
|
20
|
-
} from "../engine/hooks-loader.js";
|
|
21
16
|
|
|
22
17
|
export function createFlowStepCommand(api: ClawdbotPluginApi) {
|
|
23
18
|
return async (args: {
|
|
@@ -68,29 +63,6 @@ export function createFlowStepCommand(api: ClawdbotPluginApi) {
|
|
|
68
63
|
const session = getSession(sessionKey);
|
|
69
64
|
|
|
70
65
|
if (!session) {
|
|
71
|
-
// Load hooks and call onFlowAbandoned
|
|
72
|
-
if (flow.hooks) {
|
|
73
|
-
const hooksPath = resolveFlowPath(api, flow.name, flow.hooks);
|
|
74
|
-
const hooks = await loadHooks(api, hooksPath);
|
|
75
|
-
if (hooks?.lifecycle?.onFlowAbandoned) {
|
|
76
|
-
await safeExecuteHook(
|
|
77
|
-
api,
|
|
78
|
-
"onFlowAbandoned",
|
|
79
|
-
hooks.lifecycle.onFlowAbandoned,
|
|
80
|
-
{
|
|
81
|
-
flowName,
|
|
82
|
-
currentStepId: stepId,
|
|
83
|
-
senderId: args.senderId,
|
|
84
|
-
channel: args.channel,
|
|
85
|
-
variables: {},
|
|
86
|
-
startedAt: 0,
|
|
87
|
-
lastActivityAt: 0,
|
|
88
|
-
},
|
|
89
|
-
"timeout"
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
66
|
return {
|
|
95
67
|
text: `Session expired or not found.\n\nUse /flow_start ${flowName} to restart the flow.`,
|
|
96
68
|
};
|
package/src/engine/executor.ts
CHANGED
|
@@ -8,272 +8,148 @@ import type {
|
|
|
8
8
|
FlowSession,
|
|
9
9
|
FlowStep,
|
|
10
10
|
ReplyPayload,
|
|
11
|
-
LoadedHooks,
|
|
12
|
-
ConditionalAction,
|
|
13
|
-
FetchAction,
|
|
14
|
-
BeforeRenderAction,
|
|
15
|
-
EnhancedPluginApi,
|
|
16
11
|
DeclarativeAction,
|
|
17
12
|
} from "../types.js";
|
|
18
13
|
import { renderStep } from "./renderer.js";
|
|
19
14
|
import { executeTransition } from "./transitions.js";
|
|
20
|
-
import { loadHooks, resolveFlowPath, safeExecuteHook, safeExecuteAction, validateFlowActions } from "./hooks-loader.js";
|
|
21
|
-
import * as pluginHooks from "../hooks/index.js";
|
|
22
15
|
import { loadActionRegistry, type ActionRegistry } from "./action-loader.js";
|
|
23
16
|
import { evaluateCondition } from "./condition-evaluator.js";
|
|
24
17
|
import { executeDeclarativeAction } from "./action-executor.js";
|
|
25
18
|
import { createInterpolationContext, interpolateConfig } from "./interpolation.js";
|
|
26
19
|
|
|
27
|
-
/**
|
|
28
|
-
* Determine if an action should execute based on conditional logic
|
|
29
|
-
*/
|
|
30
|
-
export function shouldExecuteAction(
|
|
31
|
-
action: ConditionalAction,
|
|
32
|
-
session: FlowSession
|
|
33
|
-
): { execute: boolean; actionName: string } {
|
|
34
|
-
// Check if condition is specified
|
|
35
|
-
if (action.if) {
|
|
36
|
-
const conditionValue = session.variables[action.if];
|
|
37
|
-
const execute = Boolean(conditionValue);
|
|
38
|
-
return { execute, actionName: action.action };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// No condition, always execute
|
|
42
|
-
return { execute: true, actionName: action.action };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
20
|
/**
|
|
46
21
|
* Execute step-level actions (fetch, beforeRender)
|
|
47
|
-
* Supports both declarative actions (new) and hooks (legacy)
|
|
48
22
|
*/
|
|
49
23
|
async function executeStepActions(
|
|
50
24
|
api: ClawdbotPluginApi,
|
|
51
25
|
step: FlowStep,
|
|
52
26
|
session: FlowSession,
|
|
53
27
|
flow: FlowMetadata,
|
|
54
|
-
|
|
55
|
-
actionRegistry: ActionRegistry | null
|
|
28
|
+
actionRegistry: ActionRegistry
|
|
56
29
|
): Promise<{ step: FlowStep; session: FlowSession }> {
|
|
30
|
+
api.logger.info(`[ACTIONS] executeStepActions called for step: ${step.id}`);
|
|
31
|
+
|
|
57
32
|
if (!step.actions) {
|
|
33
|
+
api.logger.debug(`[ACTIONS] No actions defined for step ${step.id}`);
|
|
58
34
|
return { step, session };
|
|
59
35
|
}
|
|
60
36
|
|
|
37
|
+
api.logger.info(`[ACTIONS] Step ${step.id} has actions. Fetch: ${step.actions.fetch ? Object.keys(step.actions.fetch).length : 0}, BeforeRender: ${step.actions.beforeRender?.length || 0}, AfterCapture: ${step.actions.afterCapture?.length || 0}`);
|
|
38
|
+
|
|
61
39
|
let modifiedStep = step;
|
|
62
40
|
let modifiedSession = { ...session };
|
|
41
|
+
const context = createInterpolationContext(modifiedSession, flow.env || {});
|
|
63
42
|
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
step.actions.beforeRender?.[0];
|
|
68
|
-
|
|
69
|
-
const isDeclarative = firstAction && "type" in firstAction;
|
|
70
|
-
|
|
71
|
-
if (isDeclarative && actionRegistry) {
|
|
72
|
-
// NEW: Declarative action system
|
|
73
|
-
const context = createInterpolationContext(modifiedSession, flow.env || {});
|
|
43
|
+
// 1. Execute fetch actions
|
|
44
|
+
if (step.actions.fetch) {
|
|
45
|
+
api.logger.info(`[FETCH] Executing ${Object.keys(step.actions.fetch).length} fetch action(s) for step ${step.id}`);
|
|
74
46
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const declarativeAction = action as DeclarativeAction;
|
|
47
|
+
for (const [varName, action] of Object.entries(step.actions.fetch)) {
|
|
48
|
+
const declarativeAction = action as DeclarativeAction;
|
|
49
|
+
api.logger.info(`[FETCH] Starting fetch action: ${declarativeAction.type} -> ${varName}`);
|
|
79
50
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
51
|
+
// Evaluate condition
|
|
52
|
+
if (declarativeAction.if && !evaluateCondition(declarativeAction.if, modifiedSession)) {
|
|
53
|
+
api.logger.debug(`[FETCH] Skipping fetch action ${declarativeAction.type} - condition not met`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
85
56
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
57
|
+
// Interpolate config
|
|
58
|
+
const config = interpolateConfig(declarativeAction.config, context);
|
|
59
|
+
api.logger.debug(`[FETCH] Interpolated config: ${JSON.stringify(config)}`);
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
// Execute action
|
|
63
|
+
api.logger.info(`[FETCH] Executing ${declarativeAction.type}...`);
|
|
64
|
+
const result = await executeDeclarativeAction(
|
|
65
|
+
declarativeAction.type,
|
|
66
|
+
config,
|
|
67
|
+
{ session: modifiedSession, api, step: modifiedStep },
|
|
68
|
+
actionRegistry
|
|
69
|
+
);
|
|
70
|
+
api.logger.info(`[FETCH] Action ${declarativeAction.type} completed. Result type: ${typeof result}`);
|
|
71
|
+
|
|
72
|
+
// Inject result into session
|
|
73
|
+
if (result !== null && result !== undefined) {
|
|
74
|
+
if (typeof result === "object") {
|
|
75
|
+
// If result is an object, inject all its fields as separate variables
|
|
76
|
+
const resultObj = result as Record<string, unknown>;
|
|
77
|
+
api.logger.info(`[FETCH] Injecting ${Object.keys(resultObj).length} variables from result: ${Object.keys(resultObj).join(', ')}`);
|
|
78
|
+
|
|
79
|
+
for (const [key, value] of Object.entries(resultObj)) {
|
|
80
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
81
|
+
modifiedSession = {
|
|
82
|
+
...modifiedSession,
|
|
83
|
+
variables: {
|
|
84
|
+
...modifiedSession.variables,
|
|
85
|
+
[key]: value,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
api.logger.debug(`[FETCH] Injected ${key} = ${value}`);
|
|
113
89
|
}
|
|
114
|
-
// Update context for next actions
|
|
115
|
-
context.variables = modifiedSession.variables;
|
|
116
|
-
} else if (typeof result === "string" || typeof result === "number" || typeof result === "boolean") {
|
|
117
|
-
// If result is a primitive, inject it under varName
|
|
118
|
-
modifiedSession = {
|
|
119
|
-
...modifiedSession,
|
|
120
|
-
variables: {
|
|
121
|
-
...modifiedSession.variables,
|
|
122
|
-
[varName]: result,
|
|
123
|
-
},
|
|
124
|
-
};
|
|
125
|
-
// Update context for next actions
|
|
126
|
-
context.variables = modifiedSession.variables;
|
|
127
90
|
}
|
|
91
|
+
// Update context for next actions
|
|
92
|
+
context.variables = modifiedSession.variables;
|
|
93
|
+
} else if (typeof result === "string" || typeof result === "number" || typeof result === "boolean") {
|
|
94
|
+
// If result is a primitive, inject it under varName
|
|
95
|
+
api.logger.info(`[FETCH] Injecting primitive result as ${varName} = ${result}`);
|
|
96
|
+
modifiedSession = {
|
|
97
|
+
...modifiedSession,
|
|
98
|
+
variables: {
|
|
99
|
+
...modifiedSession.variables,
|
|
100
|
+
[varName]: result,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
// Update context for next actions
|
|
104
|
+
context.variables = modifiedSession.variables;
|
|
128
105
|
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
api.logger.error(`Fetch action ${declarativeAction.type} failed: ${errorMsg}`);
|
|
132
|
-
// Continue with other actions
|
|
106
|
+
} else {
|
|
107
|
+
api.logger.warn(`[FETCH] Action ${declarativeAction.type} returned null/undefined`);
|
|
133
108
|
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const declarativeAction = action as DeclarativeAction;
|
|
141
|
-
|
|
142
|
-
// Evaluate condition
|
|
143
|
-
if (declarativeAction.if && !evaluateCondition(declarativeAction.if, modifiedSession)) {
|
|
144
|
-
api.logger.debug(`Skipping beforeRender action ${declarativeAction.type} - condition not met`);
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Interpolate config
|
|
149
|
-
const config = interpolateConfig(declarativeAction.config, context);
|
|
150
|
-
|
|
151
|
-
try {
|
|
152
|
-
// Execute action
|
|
153
|
-
const result = await executeDeclarativeAction(
|
|
154
|
-
declarativeAction.type,
|
|
155
|
-
config,
|
|
156
|
-
{ session: modifiedSession, api, step: modifiedStep },
|
|
157
|
-
actionRegistry
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
if (result && typeof result === "object") {
|
|
161
|
-
modifiedStep = result as FlowStep;
|
|
162
|
-
}
|
|
163
|
-
} catch (error) {
|
|
164
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
165
|
-
api.logger.error(`BeforeRender action ${declarativeAction.type} failed: ${errorMsg}`);
|
|
166
|
-
// Continue with other actions
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
111
|
+
const errorStack = error instanceof Error ? error.stack : '';
|
|
112
|
+
api.logger.error(`[FETCH] Fetch action ${declarativeAction.type} failed: ${errorMsg}`);
|
|
113
|
+
if (errorStack) {
|
|
114
|
+
api.logger.debug(`[FETCH] Stack trace: ${errorStack}`);
|
|
167
115
|
}
|
|
116
|
+
// Continue with other actions
|
|
168
117
|
}
|
|
169
118
|
}
|
|
170
|
-
} else if (hooks) {
|
|
171
|
-
// LEGACY: Hook-based action system
|
|
172
|
-
const enhancedApi: EnhancedPluginApi = {
|
|
173
|
-
...api,
|
|
174
|
-
hooks: pluginHooks,
|
|
175
|
-
};
|
|
176
119
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
for (const [varName, action] of Object.entries(step.actions.fetch)) {
|
|
180
|
-
// Check if it's a legacy action (has "action" field instead of "type")
|
|
181
|
-
if (!("action" in action)) continue;
|
|
182
|
-
|
|
183
|
-
const legacyAction = action as unknown as ConditionalAction;
|
|
184
|
-
// Check if action should execute
|
|
185
|
-
const { execute, actionName} = shouldExecuteAction(legacyAction, modifiedSession);
|
|
186
|
-
|
|
187
|
-
if (!execute) {
|
|
188
|
-
api.logger.debug(
|
|
189
|
-
`Skipping fetch action "${actionName}" for variable "${varName}" - condition not met`
|
|
190
|
-
);
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
120
|
+
api.logger.info(`[FETCH] Fetch actions complete. Variables in session: ${Object.keys(modifiedSession.variables).join(', ')}`);
|
|
121
|
+
}
|
|
193
122
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
api.logger.error(
|
|
199
|
-
`Fetch action "${actionName}" has invalid signature. Expected 1-2 parameters (session, api?), got ${fetchFn.length}`
|
|
200
|
-
);
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
123
|
+
// 2. Execute beforeRender actions
|
|
124
|
+
if (step.actions.beforeRender) {
|
|
125
|
+
for (const action of step.actions.beforeRender) {
|
|
126
|
+
const declarativeAction = action as DeclarativeAction;
|
|
203
127
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
modifiedSession,
|
|
209
|
-
enhancedApi
|
|
210
|
-
);
|
|
211
|
-
if (result !== null && result !== undefined) {
|
|
212
|
-
if (typeof result === "object") {
|
|
213
|
-
// If result is an object, inject all its fields as separate variables
|
|
214
|
-
for (const [key, value] of Object.entries(result)) {
|
|
215
|
-
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
216
|
-
modifiedSession = {
|
|
217
|
-
...modifiedSession,
|
|
218
|
-
variables: {
|
|
219
|
-
...modifiedSession.variables,
|
|
220
|
-
[key]: value,
|
|
221
|
-
},
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
} else if (typeof result === "string" || typeof result === "number" || typeof result === "boolean") {
|
|
226
|
-
// If result is a primitive, inject it under varName
|
|
227
|
-
modifiedSession = {
|
|
228
|
-
...modifiedSession,
|
|
229
|
-
variables: {
|
|
230
|
-
...modifiedSession.variables,
|
|
231
|
-
[varName]: result,
|
|
232
|
-
},
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
128
|
+
// Evaluate condition
|
|
129
|
+
if (declarativeAction.if && !evaluateCondition(declarativeAction.if, modifiedSession)) {
|
|
130
|
+
api.logger.debug(`Skipping beforeRender action ${declarativeAction.type} - condition not met`);
|
|
131
|
+
continue;
|
|
237
132
|
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// 2. Execute beforeRender actions - modify step
|
|
241
|
-
if (step.actions.beforeRender) {
|
|
242
|
-
for (const action of step.actions.beforeRender) {
|
|
243
|
-
// Check if it's a legacy action (has "action" field instead of "type")
|
|
244
|
-
if (!("action" in action)) continue;
|
|
245
133
|
|
|
246
|
-
|
|
247
|
-
|
|
134
|
+
// Interpolate config
|
|
135
|
+
const config = interpolateConfig(declarativeAction.config, context);
|
|
248
136
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
if (beforeRenderFn) {
|
|
258
|
-
if (beforeRenderFn.length > 3) {
|
|
259
|
-
api.logger.error(
|
|
260
|
-
`BeforeRender action "${actionName}" has invalid signature. Expected 2-3 parameters (step, session, api?), got ${beforeRenderFn.length}`
|
|
261
|
-
);
|
|
262
|
-
continue;
|
|
263
|
-
}
|
|
137
|
+
try {
|
|
138
|
+
// Execute action
|
|
139
|
+
const result = await executeDeclarativeAction(
|
|
140
|
+
declarativeAction.type,
|
|
141
|
+
config,
|
|
142
|
+
{ session: modifiedSession, api, step: modifiedStep },
|
|
143
|
+
actionRegistry
|
|
144
|
+
);
|
|
264
145
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
actionName,
|
|
268
|
-
beforeRenderFn,
|
|
269
|
-
modifiedStep,
|
|
270
|
-
modifiedSession,
|
|
271
|
-
enhancedApi
|
|
272
|
-
);
|
|
273
|
-
if (result) {
|
|
274
|
-
modifiedStep = result as FlowStep;
|
|
275
|
-
}
|
|
146
|
+
if (result && typeof result === "object") {
|
|
147
|
+
modifiedStep = result as FlowStep;
|
|
276
148
|
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
151
|
+
api.logger.error(`BeforeRender action ${declarativeAction.type} failed: ${errorMsg}`);
|
|
152
|
+
// Continue with other actions
|
|
277
153
|
}
|
|
278
154
|
}
|
|
279
155
|
}
|
|
@@ -297,29 +173,9 @@ export async function startFlow(
|
|
|
297
173
|
};
|
|
298
174
|
}
|
|
299
175
|
|
|
300
|
-
// Load action registry
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
try {
|
|
304
|
-
actionRegistry = await loadActionRegistry(flow.actions?.imports);
|
|
305
|
-
api.logger.debug(`Loaded action registry with ${actionRegistry.list().length} actions`);
|
|
306
|
-
} catch (error) {
|
|
307
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
308
|
-
api.logger.error(`Failed to load action registry: ${errorMsg}`);
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Load hooks if configured (legacy support)
|
|
313
|
-
let hooks: LoadedHooks | null = null;
|
|
314
|
-
if (flow.hooks) {
|
|
315
|
-
const hooksPath = resolveFlowPath(api, flow.name, flow.hooks);
|
|
316
|
-
hooks = await loadHooks(api, hooksPath);
|
|
317
|
-
|
|
318
|
-
// Validate that all action references in the flow exist
|
|
319
|
-
if (hooks) {
|
|
320
|
-
validateFlowActions(flow, hooks, api);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
176
|
+
// Load action registry
|
|
177
|
+
const actionRegistry = await loadActionRegistry(flow.actions?.imports);
|
|
178
|
+
api.logger.debug(`Loaded action registry with ${actionRegistry.list().length} actions`);
|
|
323
179
|
|
|
324
180
|
// Inject environment variables into session
|
|
325
181
|
if (flow.env) {
|
|
@@ -347,11 +203,11 @@ export async function startFlow(
|
|
|
347
203
|
}
|
|
348
204
|
|
|
349
205
|
// Execute step actions before rendering
|
|
350
|
-
const actionResult = await executeStepActions(api, firstStep, session, flow,
|
|
206
|
+
const actionResult = await executeStepActions(api, firstStep, session, flow, actionRegistry);
|
|
351
207
|
firstStep = actionResult.step;
|
|
352
208
|
session = actionResult.session;
|
|
353
209
|
|
|
354
|
-
return renderStep(api, flow, firstStep, session, session.channel
|
|
210
|
+
return renderStep(api, flow, firstStep, session, session.channel);
|
|
355
211
|
}
|
|
356
212
|
|
|
357
213
|
/**
|
|
@@ -368,28 +224,8 @@ export async function processStep(
|
|
|
368
224
|
complete: boolean;
|
|
369
225
|
updatedVariables: Record<string, string | number | boolean>;
|
|
370
226
|
}> {
|
|
371
|
-
// Load action registry
|
|
372
|
-
|
|
373
|
-
if (flow.actions?.imports || !flow.hooks) {
|
|
374
|
-
try {
|
|
375
|
-
actionRegistry = await loadActionRegistry(flow.actions?.imports);
|
|
376
|
-
} catch (error) {
|
|
377
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
378
|
-
api.logger.error(`Failed to load action registry: ${errorMsg}`);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// Load hooks if configured (legacy support)
|
|
383
|
-
let hooks: LoadedHooks | null = null;
|
|
384
|
-
if (flow.hooks) {
|
|
385
|
-
const hooksPath = resolveFlowPath(api, flow.name, flow.hooks);
|
|
386
|
-
hooks = await loadHooks(api, hooksPath);
|
|
387
|
-
|
|
388
|
-
// Validate that all action references in the flow exist
|
|
389
|
-
if (hooks) {
|
|
390
|
-
validateFlowActions(flow, hooks, api);
|
|
391
|
-
}
|
|
392
|
-
}
|
|
227
|
+
// Load action registry
|
|
228
|
+
const actionRegistry = await loadActionRegistry(flow.actions?.imports);
|
|
393
229
|
|
|
394
230
|
const result = await executeTransition(
|
|
395
231
|
api,
|
|
@@ -397,7 +233,6 @@ export async function processStep(
|
|
|
397
233
|
session,
|
|
398
234
|
stepId,
|
|
399
235
|
value,
|
|
400
|
-
hooks,
|
|
401
236
|
actionRegistry
|
|
402
237
|
);
|
|
403
238
|
|
|
@@ -412,13 +247,6 @@ export async function processStep(
|
|
|
412
247
|
|
|
413
248
|
// Handle completion
|
|
414
249
|
if (result.complete) {
|
|
415
|
-
const updatedSession = { ...session, variables: result.variables };
|
|
416
|
-
|
|
417
|
-
// Call onFlowComplete hook
|
|
418
|
-
if (hooks?.lifecycle?.onFlowComplete) {
|
|
419
|
-
await safeExecuteHook(api, "onFlowComplete", hooks.lifecycle.onFlowComplete, updatedSession);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
250
|
const completionMessage = generateCompletionMessage(flow, result.variables);
|
|
423
251
|
return {
|
|
424
252
|
reply: { text: completionMessage },
|
|
@@ -448,7 +276,7 @@ export async function processStep(
|
|
|
448
276
|
let updatedSession = { ...session, variables: result.variables };
|
|
449
277
|
|
|
450
278
|
// Execute step actions before rendering
|
|
451
|
-
const actionResult = await executeStepActions(api, nextStep, updatedSession, flow,
|
|
279
|
+
const actionResult = await executeStepActions(api, nextStep, updatedSession, flow, actionRegistry);
|
|
452
280
|
nextStep = actionResult.step;
|
|
453
281
|
updatedSession = actionResult.session;
|
|
454
282
|
|
|
@@ -457,8 +285,7 @@ export async function processStep(
|
|
|
457
285
|
flow,
|
|
458
286
|
nextStep,
|
|
459
287
|
updatedSession,
|
|
460
|
-
session.channel
|
|
461
|
-
hooks
|
|
288
|
+
session.channel
|
|
462
289
|
);
|
|
463
290
|
|
|
464
291
|
return {
|
package/src/engine/renderer.ts
CHANGED
|
@@ -8,7 +8,6 @@ import type {
|
|
|
8
8
|
FlowStep,
|
|
9
9
|
FlowSession,
|
|
10
10
|
ReplyPayload,
|
|
11
|
-
LoadedHooks,
|
|
12
11
|
} from "../types.js";
|
|
13
12
|
import { normalizeButton } from "../validation.js";
|
|
14
13
|
|
|
@@ -107,18 +106,14 @@ function renderFallback(
|
|
|
107
106
|
/**
|
|
108
107
|
* Render a flow step for the user's channel
|
|
109
108
|
*
|
|
110
|
-
* Note: Step actions (fetch, beforeRender) are
|
|
111
|
-
* before this function is called.
|
|
112
|
-
* - API consistency with other engine functions
|
|
113
|
-
* - Future extensibility (e.g., channel-specific rendering hooks)
|
|
114
|
-
* - Backwards compatibility if deprecated hooks are re-enabled
|
|
109
|
+
* Note: Step actions (fetch, beforeRender) are executed in executor.ts
|
|
110
|
+
* before this function is called.
|
|
115
111
|
*
|
|
116
112
|
* @param _api - Plugin API (reserved for future use)
|
|
117
113
|
* @param flow - Flow metadata
|
|
118
114
|
* @param step - Step to render (already modified by beforeRender actions)
|
|
119
115
|
* @param session - Current session with variables (already populated by fetch actions)
|
|
120
116
|
* @param channel - Target channel (telegram, slack, etc.)
|
|
121
|
-
* @param _hooks - Loaded hooks (reserved for future use)
|
|
122
117
|
* @returns Rendered message payload for the channel
|
|
123
118
|
*/
|
|
124
119
|
export async function renderStep(
|
|
@@ -126,8 +121,7 @@ export async function renderStep(
|
|
|
126
121
|
flow: FlowMetadata,
|
|
127
122
|
step: FlowStep,
|
|
128
123
|
session: FlowSession,
|
|
129
|
-
channel: string
|
|
130
|
-
_hooks?: LoadedHooks | null
|
|
124
|
+
channel: string
|
|
131
125
|
): Promise<ReplyPayload> {
|
|
132
126
|
// Channel-specific rendering
|
|
133
127
|
if (channel === "telegram") {
|
|
@@ -8,18 +8,11 @@ import type {
|
|
|
8
8
|
FlowStep,
|
|
9
9
|
FlowSession,
|
|
10
10
|
TransitionResult,
|
|
11
|
-
LoadedHooks,
|
|
12
|
-
AfterCaptureAction,
|
|
13
|
-
EnhancedPluginApi,
|
|
14
11
|
DeclarativeAction,
|
|
15
|
-
ConditionalAction,
|
|
16
12
|
} from "../types.js";
|
|
17
13
|
import { normalizeButton, validateInput } from "../validation.js";
|
|
18
|
-
import { safeExecuteAction } from "./hooks-loader.js";
|
|
19
14
|
import { sanitizeInput } from "../security/input-sanitization.js";
|
|
20
15
|
import { getPluginConfig } from "../config.js";
|
|
21
|
-
import { shouldExecuteAction } from "./executor.js";
|
|
22
|
-
import * as pluginHooks from "../hooks/index.js";
|
|
23
16
|
import type { ActionRegistry } from "./action-loader.js";
|
|
24
17
|
import { evaluateCondition as evaluateDeclarativeCondition } from "./condition-evaluator.js";
|
|
25
18
|
import { executeDeclarativeAction } from "./action-executor.js";
|
|
@@ -106,15 +99,8 @@ export async function executeTransition(
|
|
|
106
99
|
session: FlowSession,
|
|
107
100
|
stepId: string,
|
|
108
101
|
value: string | number,
|
|
109
|
-
|
|
110
|
-
actionRegistry?: ActionRegistry | null
|
|
102
|
+
actionRegistry: ActionRegistry
|
|
111
103
|
): Promise<TransitionResult> {
|
|
112
|
-
// Create enhanced API with plugin utilities
|
|
113
|
-
const enhancedApi: EnhancedPluginApi = {
|
|
114
|
-
...api,
|
|
115
|
-
hooks: pluginHooks,
|
|
116
|
-
};
|
|
117
|
-
|
|
118
104
|
// Find current step
|
|
119
105
|
const step = flow.steps.find((s) => s.id === stepId);
|
|
120
106
|
|
|
@@ -171,82 +157,38 @@ export async function executeTransition(
|
|
|
171
157
|
// Execute afterCapture actions
|
|
172
158
|
if (step.actions?.afterCapture) {
|
|
173
159
|
const updatedSession = { ...session, variables: updatedVariables };
|
|
160
|
+
const context = createInterpolationContext(updatedSession, flow.env || {});
|
|
174
161
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const isDeclarative = firstAction && "type" in firstAction;
|
|
178
|
-
|
|
179
|
-
if (isDeclarative && actionRegistry) {
|
|
180
|
-
// NEW: Declarative action system
|
|
181
|
-
const context = createInterpolationContext(updatedSession, flow.env || {});
|
|
182
|
-
|
|
183
|
-
for (const action of step.actions.afterCapture) {
|
|
184
|
-
const declarativeAction = action as DeclarativeAction;
|
|
162
|
+
for (const action of step.actions.afterCapture) {
|
|
163
|
+
const declarativeAction = action as DeclarativeAction;
|
|
185
164
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Interpolate config
|
|
193
|
-
const config = interpolateConfig(declarativeAction.config, context);
|
|
194
|
-
|
|
195
|
-
try {
|
|
196
|
-
// Execute action
|
|
197
|
-
await executeDeclarativeAction(
|
|
198
|
-
declarativeAction.type,
|
|
199
|
-
config,
|
|
200
|
-
{
|
|
201
|
-
session: updatedSession,
|
|
202
|
-
api,
|
|
203
|
-
step,
|
|
204
|
-
capturedVariable: step.capture,
|
|
205
|
-
capturedValue: capturedValue,
|
|
206
|
-
},
|
|
207
|
-
actionRegistry
|
|
208
|
-
);
|
|
209
|
-
} catch (error) {
|
|
210
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
211
|
-
api.logger.error(`AfterCapture action ${declarativeAction.type} failed: ${errorMsg}`);
|
|
212
|
-
// Continue with other actions
|
|
213
|
-
}
|
|
165
|
+
// Evaluate condition
|
|
166
|
+
if (declarativeAction.if && !evaluateDeclarativeCondition(declarativeAction.if, updatedSession)) {
|
|
167
|
+
api.logger.debug(`Skipping afterCapture action ${declarativeAction.type} - condition not met`);
|
|
168
|
+
continue;
|
|
214
169
|
}
|
|
215
|
-
} else if (hooks) {
|
|
216
|
-
// LEGACY: Hook-based action system
|
|
217
|
-
for (const action of step.actions.afterCapture) {
|
|
218
|
-
// Check if it's a legacy action (has "action" field instead of "type")
|
|
219
|
-
if (!("action" in action)) continue;
|
|
220
|
-
|
|
221
|
-
const legacyAction = action as unknown as ConditionalAction;
|
|
222
|
-
const { execute, actionName } = shouldExecuteAction(legacyAction, updatedSession);
|
|
223
|
-
|
|
224
|
-
if (!execute) {
|
|
225
|
-
api.logger.debug(
|
|
226
|
-
`Skipping afterCapture action "${actionName}" - condition not met`
|
|
227
|
-
);
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
170
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (afterCaptureFn.length > 4) {
|
|
234
|
-
api.logger.error(
|
|
235
|
-
`AfterCapture action "${actionName}" has invalid signature. Expected 3-4 parameters (variable, value, session, api?), got ${afterCaptureFn.length}`
|
|
236
|
-
);
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
171
|
+
// Interpolate config
|
|
172
|
+
const config = interpolateConfig(declarativeAction.config, context);
|
|
239
173
|
|
|
240
|
-
|
|
174
|
+
try {
|
|
175
|
+
// Execute action
|
|
176
|
+
await executeDeclarativeAction(
|
|
177
|
+
declarativeAction.type,
|
|
178
|
+
config,
|
|
179
|
+
{
|
|
180
|
+
session: updatedSession,
|
|
241
181
|
api,
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
182
|
+
step,
|
|
183
|
+
capturedVariable: step.capture,
|
|
184
|
+
capturedValue: capturedValue,
|
|
185
|
+
},
|
|
186
|
+
actionRegistry
|
|
187
|
+
);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
190
|
+
api.logger.error(`AfterCapture action ${declarativeAction.type} failed: ${errorMsg}`);
|
|
191
|
+
// Continue with other actions
|
|
250
192
|
}
|
|
251
193
|
}
|
|
252
194
|
}
|
|
@@ -5,13 +5,10 @@
|
|
|
5
5
|
import { promises as fs } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
|
8
|
-
import type { FlowSession, FlowMetadata } from "../types.js";
|
|
8
|
+
import type { FlowSession, FlowMetadata, StorageBackend } from "../types.js";
|
|
9
9
|
import type { SkillFlowConfig } from "../config.js";
|
|
10
10
|
import { getPluginConfig } from "../config.js";
|
|
11
|
-
import {
|
|
12
|
-
loadStorageBackend,
|
|
13
|
-
resolveFlowPath,
|
|
14
|
-
} from "../engine/hooks-loader.js";
|
|
11
|
+
import { resolvePathSafely, validatePathWithinBase } from "../security/path-validation.js";
|
|
15
12
|
|
|
16
13
|
/**
|
|
17
14
|
* Get the flows directory path (same logic as flow-store.ts)
|
|
@@ -26,6 +23,43 @@ function getFlowsDir(api: ClawdbotPluginApi): string {
|
|
|
26
23
|
return path.join(stateDir, "flows");
|
|
27
24
|
}
|
|
28
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Resolve relative path to absolute (relative to flow directory)
|
|
28
|
+
*/
|
|
29
|
+
function resolveFlowPath(
|
|
30
|
+
api: ClawdbotPluginApi,
|
|
31
|
+
flowName: string,
|
|
32
|
+
relativePath: string
|
|
33
|
+
): string {
|
|
34
|
+
const flowsDir = getFlowsDir(api);
|
|
35
|
+
const flowDir = path.join(flowsDir, flowName);
|
|
36
|
+
return resolvePathSafely(flowDir, relativePath, "flow path");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load custom storage backend module
|
|
41
|
+
*/
|
|
42
|
+
async function loadStorageBackend(
|
|
43
|
+
api: ClawdbotPluginApi,
|
|
44
|
+
backendPath: string
|
|
45
|
+
): Promise<StorageBackend | null> {
|
|
46
|
+
try {
|
|
47
|
+
const flowsDir = getFlowsDir(api);
|
|
48
|
+
validatePathWithinBase(backendPath, flowsDir, "storage backend");
|
|
49
|
+
|
|
50
|
+
await fs.access(backendPath);
|
|
51
|
+
|
|
52
|
+
const backendModule = await import(backendPath);
|
|
53
|
+
const backend: StorageBackend = backendModule.default ?? backendModule;
|
|
54
|
+
|
|
55
|
+
api.logger.debug(`Loaded storage backend from ${backendPath}`);
|
|
56
|
+
return backend;
|
|
57
|
+
} catch (error) {
|
|
58
|
+
api.logger.warn(`Failed to load storage backend from ${backendPath}:`, error);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
29
63
|
/**
|
|
30
64
|
* Get history file path for a flow
|
|
31
65
|
*/
|
|
@@ -1,305 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hooks loader - Dynamically loads and executes flow hooks
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { FlowHooks, StorageBackend, LoadedHooks } from "../types.js";
|
|
6
|
-
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
|
7
|
-
import { promises as fs } from "node:fs";
|
|
8
|
-
import path from "node:path";
|
|
9
|
-
import {
|
|
10
|
-
resolvePathSafely,
|
|
11
|
-
validatePathWithinBase,
|
|
12
|
-
} from "../security/path-validation.js";
|
|
13
|
-
import { getPluginConfig } from "../config.js";
|
|
14
|
-
import { withTimeout, TimeoutError } from "../security/timeout.js";
|
|
15
|
-
import * as pluginHooks from "../hooks/index.js";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Get the flows directory path (same logic as flow-store.ts)
|
|
19
|
-
*/
|
|
20
|
-
function getFlowsDir(api: ClawdbotPluginApi): string {
|
|
21
|
-
const config = getPluginConfig();
|
|
22
|
-
if (config.flowsDir) {
|
|
23
|
-
const expandedPath = config.flowsDir.replace(/^~/, process.env.HOME || "~");
|
|
24
|
-
return path.resolve(expandedPath);
|
|
25
|
-
}
|
|
26
|
-
const stateDir = api.runtime.state.resolveStateDir();
|
|
27
|
-
return path.join(stateDir, "flows");
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Load hooks from a file path
|
|
32
|
-
* @param api - Plugin API for logging
|
|
33
|
-
* @param hooksPath - Absolute path to hooks file
|
|
34
|
-
* @returns LoadedHooks object with lifecycle hooks and actions, or null if loading fails
|
|
35
|
-
*/
|
|
36
|
-
export async function loadHooks(
|
|
37
|
-
api: ClawdbotPluginApi,
|
|
38
|
-
hooksPath: string
|
|
39
|
-
): Promise<LoadedHooks | null> {
|
|
40
|
-
try {
|
|
41
|
-
// Validate path is within flows directory
|
|
42
|
-
const flowsDir = getFlowsDir(api);
|
|
43
|
-
validatePathWithinBase(hooksPath, flowsDir, "hooks file");
|
|
44
|
-
|
|
45
|
-
// Check if file exists
|
|
46
|
-
await fs.access(hooksPath);
|
|
47
|
-
|
|
48
|
-
// Dynamically import the hooks module
|
|
49
|
-
const hooksModule = await import(hooksPath);
|
|
50
|
-
|
|
51
|
-
// Extract default export for lifecycle hooks
|
|
52
|
-
let moduleExport = hooksModule.default ?? {};
|
|
53
|
-
|
|
54
|
-
// Support factory functions that take API and return lifecycle hooks
|
|
55
|
-
// Inject plugin hooks utilities into the API for easy access
|
|
56
|
-
if (typeof moduleExport === "function") {
|
|
57
|
-
const enhancedApi = {
|
|
58
|
-
...api,
|
|
59
|
-
hooks: pluginHooks, // Make plugin utilities available via api.hooks
|
|
60
|
-
};
|
|
61
|
-
moduleExport = moduleExport(enhancedApi);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Extract global lifecycle hooks from default export
|
|
65
|
-
const lifecycle: FlowHooks = {
|
|
66
|
-
onFlowComplete: moduleExport?.onFlowComplete,
|
|
67
|
-
onFlowAbandoned: moduleExport?.onFlowAbandoned,
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// Extract all named exports as step-level actions
|
|
71
|
-
const actions: Record<string, Function> = {};
|
|
72
|
-
for (const [key, value] of Object.entries(hooksModule)) {
|
|
73
|
-
if (key !== "default" && typeof value === "function") {
|
|
74
|
-
actions[key] = value;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
api.logger.debug(`Loaded hooks from ${hooksPath}: ${Object.keys(actions).length} actions, ${Object.keys(lifecycle).filter(k => lifecycle[k as keyof FlowHooks]).length} lifecycle hooks`);
|
|
79
|
-
return { lifecycle, actions };
|
|
80
|
-
} catch (error) {
|
|
81
|
-
api.logger.warn(`Failed to load hooks from ${hooksPath}:`, error);
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Load storage backend from a file path
|
|
88
|
-
* @param api - Plugin API for logging
|
|
89
|
-
* @param backendPath - Absolute path to storage backend file
|
|
90
|
-
* @returns StorageBackend or null if loading fails
|
|
91
|
-
*/
|
|
92
|
-
export async function loadStorageBackend(
|
|
93
|
-
api: ClawdbotPluginApi,
|
|
94
|
-
backendPath: string
|
|
95
|
-
): Promise<StorageBackend | null> {
|
|
96
|
-
try {
|
|
97
|
-
// Validate path is within flows directory
|
|
98
|
-
const flowsDir = getFlowsDir(api);
|
|
99
|
-
validatePathWithinBase(backendPath, flowsDir, "storage backend");
|
|
100
|
-
|
|
101
|
-
// Check if file exists
|
|
102
|
-
await fs.access(backendPath);
|
|
103
|
-
|
|
104
|
-
// Dynamically import the backend module
|
|
105
|
-
const backendModule = await import(backendPath);
|
|
106
|
-
|
|
107
|
-
// Support both default export and named export
|
|
108
|
-
const backend: StorageBackend = backendModule.default ?? backendModule;
|
|
109
|
-
|
|
110
|
-
api.logger.debug(`Loaded storage backend from ${backendPath}`);
|
|
111
|
-
return backend;
|
|
112
|
-
} catch (error) {
|
|
113
|
-
api.logger.warn(`Failed to load storage backend from ${backendPath}:`, error);
|
|
114
|
-
return null;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Resolve relative path to absolute (relative to flow directory)
|
|
120
|
-
* @param api - Plugin API
|
|
121
|
-
* @param flowName - Name of the flow
|
|
122
|
-
* @param relativePath - Relative path from flow config
|
|
123
|
-
* @returns Absolute path
|
|
124
|
-
*/
|
|
125
|
-
export function resolveFlowPath(
|
|
126
|
-
api: ClawdbotPluginApi,
|
|
127
|
-
flowName: string,
|
|
128
|
-
relativePath: string
|
|
129
|
-
): string {
|
|
130
|
-
const flowsDir = getFlowsDir(api);
|
|
131
|
-
const flowDir = path.join(flowsDir, flowName);
|
|
132
|
-
|
|
133
|
-
// Validate that relativePath doesn't escape flowDir
|
|
134
|
-
return resolvePathSafely(flowDir, relativePath, "flow path");
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Safe hook executor - wraps hook calls with error handling
|
|
139
|
-
* @param api - Plugin API for logging
|
|
140
|
-
* @param hookName - Name of the hook being called
|
|
141
|
-
* @param hookFn - The hook function to execute
|
|
142
|
-
* @param args - Arguments to pass to the hook
|
|
143
|
-
* @returns Result of hook or undefined if hook fails
|
|
144
|
-
*/
|
|
145
|
-
export async function safeExecuteHook<T, Args extends unknown[]>(
|
|
146
|
-
api: ClawdbotPluginApi,
|
|
147
|
-
hookName: string,
|
|
148
|
-
hookFn: ((...args: Args) => T | Promise<T>) | undefined,
|
|
149
|
-
...args: Args
|
|
150
|
-
): Promise<T | undefined> {
|
|
151
|
-
if (!hookFn) {
|
|
152
|
-
return undefined;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const config = getPluginConfig();
|
|
156
|
-
const timeout = config.security.hookTimeout;
|
|
157
|
-
|
|
158
|
-
try {
|
|
159
|
-
const result = await withTimeout(
|
|
160
|
-
() => Promise.resolve(hookFn(...args)),
|
|
161
|
-
timeout,
|
|
162
|
-
`Hook "${hookName}" timed out after ${timeout}ms`
|
|
163
|
-
);
|
|
164
|
-
return result;
|
|
165
|
-
} catch (error) {
|
|
166
|
-
const isTimeout = error instanceof TimeoutError;
|
|
167
|
-
const errorMsg = isTimeout
|
|
168
|
-
? `Hook ${hookName} timed out after ${timeout}ms`
|
|
169
|
-
: `Hook ${hookName} failed: ${error}`;
|
|
170
|
-
|
|
171
|
-
api.logger.error(errorMsg);
|
|
172
|
-
return undefined;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Safe action executor - wraps action calls with error handling
|
|
178
|
-
* @param api - Plugin API for logging
|
|
179
|
-
* @param actionName - Name of the action being called
|
|
180
|
-
* @param actionFn - The action function to execute
|
|
181
|
-
* @param args - Arguments to pass to the action
|
|
182
|
-
* @returns Result of action or undefined if action fails
|
|
183
|
-
*/
|
|
184
|
-
export async function safeExecuteAction<T, Args extends unknown[]>(
|
|
185
|
-
api: ClawdbotPluginApi,
|
|
186
|
-
actionName: string,
|
|
187
|
-
actionFn: ((...args: Args) => T | Promise<T>) | undefined,
|
|
188
|
-
...args: Args
|
|
189
|
-
): Promise<T | undefined> {
|
|
190
|
-
if (!actionFn) {
|
|
191
|
-
api.logger.warn(`Action ${actionName} not found in hooks`);
|
|
192
|
-
return undefined;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const config = getPluginConfig();
|
|
196
|
-
const strategy = config.actions?.fetchFailureStrategy || "warn";
|
|
197
|
-
const timeout = config.security.actionTimeout;
|
|
198
|
-
|
|
199
|
-
try {
|
|
200
|
-
const result = await withTimeout(
|
|
201
|
-
() => Promise.resolve(actionFn(...args)),
|
|
202
|
-
timeout,
|
|
203
|
-
`Action "${actionName}" timed out after ${timeout}ms`
|
|
204
|
-
);
|
|
205
|
-
return result;
|
|
206
|
-
} catch (error) {
|
|
207
|
-
const isTimeout = error instanceof TimeoutError;
|
|
208
|
-
const errorMsg = isTimeout
|
|
209
|
-
? `Action ${actionName} timed out after ${timeout}ms`
|
|
210
|
-
: `Action ${actionName} failed: ${error}`;
|
|
211
|
-
|
|
212
|
-
if (strategy === "stop") {
|
|
213
|
-
api.logger.error(errorMsg);
|
|
214
|
-
throw error; // Re-throw to stop flow execution
|
|
215
|
-
} else if (strategy === "warn") {
|
|
216
|
-
api.logger.warn(errorMsg);
|
|
217
|
-
}
|
|
218
|
-
// 'silent' strategy logs nothing
|
|
219
|
-
|
|
220
|
-
return undefined;
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Extract action name from ConditionalAction or DeclarativeAction
|
|
226
|
-
*/
|
|
227
|
-
function getActionName(
|
|
228
|
-
action: import("../types.js").ConditionalAction | import("../types.js").DeclarativeAction
|
|
229
|
-
): string {
|
|
230
|
-
// Check if it's a legacy ConditionalAction with "action" field
|
|
231
|
-
if ("action" in action) {
|
|
232
|
-
return action.action;
|
|
233
|
-
}
|
|
234
|
-
// Otherwise it's a DeclarativeAction with "type" field
|
|
235
|
-
if ("type" in action) {
|
|
236
|
-
return action.type;
|
|
237
|
-
}
|
|
238
|
-
return "unknown";
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Validate that all actions referenced in flow steps exist in hooks
|
|
243
|
-
* @param flow - Flow metadata with steps
|
|
244
|
-
* @param hooks - Loaded hooks with actions
|
|
245
|
-
* @param api - Plugin API for logging
|
|
246
|
-
* @throws Error if any action references are invalid
|
|
247
|
-
*/
|
|
248
|
-
export function validateFlowActions(
|
|
249
|
-
flow: import("../types.js").FlowMetadata,
|
|
250
|
-
hooks: LoadedHooks,
|
|
251
|
-
api: ClawdbotPluginApi
|
|
252
|
-
): void {
|
|
253
|
-
const availableActions = Object.keys(hooks.actions);
|
|
254
|
-
const errors: string[] = [];
|
|
255
|
-
|
|
256
|
-
for (const step of flow.steps) {
|
|
257
|
-
if (!step.actions) continue;
|
|
258
|
-
|
|
259
|
-
// Validate fetch actions
|
|
260
|
-
if (step.actions.fetch) {
|
|
261
|
-
for (const [varName, action] of Object.entries(step.actions.fetch)) {
|
|
262
|
-
const actionName = getActionName(action);
|
|
263
|
-
if (!availableActions.includes(actionName)) {
|
|
264
|
-
errors.push(
|
|
265
|
-
`Step "${step.id}": fetch action "${actionName}" not found (for variable "${varName}")`
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Validate beforeRender actions
|
|
272
|
-
if (step.actions.beforeRender) {
|
|
273
|
-
for (const action of step.actions.beforeRender) {
|
|
274
|
-
const actionName = getActionName(action);
|
|
275
|
-
if (!availableActions.includes(actionName)) {
|
|
276
|
-
errors.push(
|
|
277
|
-
`Step "${step.id}": beforeRender action "${actionName}" not found`
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Validate afterCapture actions
|
|
284
|
-
if (step.actions.afterCapture) {
|
|
285
|
-
for (const action of step.actions.afterCapture) {
|
|
286
|
-
const actionName = getActionName(action);
|
|
287
|
-
if (!availableActions.includes(actionName)) {
|
|
288
|
-
errors.push(
|
|
289
|
-
`Step "${step.id}": afterCapture action "${actionName}" not found`
|
|
290
|
-
);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
if (errors.length > 0) {
|
|
297
|
-
const errorMsg = `Flow "${flow.name}" has invalid action references:\n${errors.join('\n')}`;
|
|
298
|
-
api.logger.error(errorMsg);
|
|
299
|
-
|
|
300
|
-
throw new Error(
|
|
301
|
-
`Flow "${flow.name}" references ${errors.length} action(s) that don't exist in hooks file. ` +
|
|
302
|
-
`Available actions: ${availableActions.join(', ') || '(none)'}`
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
}
|