@jiggai/recipes 0.4.38 → 0.4.40
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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/lib/workflows/media-drivers/generic.driver.ts +2 -1
- package/src/lib/workflows/media-drivers/kling-video.driver.ts +2 -1
- package/src/lib/workflows/media-drivers/luma-video.driver.ts +3 -2
- package/src/lib/workflows/media-drivers/nano-banana-pro.driver.ts +2 -1
- package/src/lib/workflows/media-drivers/openai-image-gen.driver.ts +2 -1
- package/src/lib/workflows/media-drivers/runway-video.driver.ts +3 -2
- package/src/lib/workflows/media-drivers/types.ts +3 -0
- package/src/lib/workflows/media-drivers/utils.ts +75 -23
- package/src/lib/workflows/workflow-error-classify.ts +69 -0
- package/src/lib/workflows/workflow-node-executor.ts +6 -0
- package/src/lib/workflows/workflow-runner.ts +2 -0
- package/src/lib/workflows/workflow-types.ts +2 -1
- package/src/lib/workflows/workflow-worker.ts +556 -14
- package/src/toolsInvoke.ts +14 -1
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -43,7 +43,8 @@ export class GenericDriver implements MediaDriver {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// Execute the script with stdin input (most common interface)
|
|
46
|
-
const scriptOutput = runScript({
|
|
46
|
+
const scriptOutput = await runScript({
|
|
47
|
+
api: opts.api,
|
|
47
48
|
runner,
|
|
48
49
|
script: scriptPath,
|
|
49
50
|
stdin: prompt,
|
|
@@ -78,7 +78,8 @@ export class KlingVideo implements MediaDriver {
|
|
|
78
78
|
// The official skill is a Node.js script (not Python)
|
|
79
79
|
const runner = 'node';
|
|
80
80
|
|
|
81
|
-
const scriptOutput = runScript({
|
|
81
|
+
const scriptOutput = await runScript({
|
|
82
|
+
api: opts.api,
|
|
82
83
|
runner,
|
|
83
84
|
script: scriptPath,
|
|
84
85
|
args: [
|
|
@@ -26,7 +26,8 @@ export class LumaVideo implements MediaDriver {
|
|
|
26
26
|
const runner = await findVenvPython(skillDir);
|
|
27
27
|
|
|
28
28
|
// Execute the script with stdin input
|
|
29
|
-
const scriptOutput = runScript({
|
|
29
|
+
const scriptOutput = await runScript({
|
|
30
|
+
api: opts.api,
|
|
30
31
|
runner,
|
|
31
32
|
script: scriptPath,
|
|
32
33
|
stdin: prompt,
|
|
@@ -42,7 +43,7 @@ export class LumaVideo implements MediaDriver {
|
|
|
42
43
|
|
|
43
44
|
// Parse the MEDIA: output
|
|
44
45
|
const filePath = parseMediaOutput(scriptOutput);
|
|
45
|
-
|
|
46
|
+
|
|
46
47
|
if (!filePath) {
|
|
47
48
|
throw new Error(`No MEDIA: path found in script output. Output: ${scriptOutput}`);
|
|
48
49
|
}
|
|
@@ -39,7 +39,8 @@ export class NanoBananaPro implements MediaDriver {
|
|
|
39
39
|
const resolution = maxDim >= 3840 ? '4K' : maxDim >= 1792 ? '2K' : '1K';
|
|
40
40
|
|
|
41
41
|
// Execute the script with argparse CLI interface
|
|
42
|
-
const scriptOutput = runScript({
|
|
42
|
+
const scriptOutput = await runScript({
|
|
43
|
+
api: opts.api,
|
|
43
44
|
runner,
|
|
44
45
|
script: scriptPath,
|
|
45
46
|
args: ['--prompt', prompt, '--filename', filename, '--resolution', resolution],
|
|
@@ -28,7 +28,8 @@ export class OpenAIImageGen implements MediaDriver {
|
|
|
28
28
|
const size = String(config?.size ?? '1024x1024');
|
|
29
29
|
|
|
30
30
|
// Execute the script with stdin input
|
|
31
|
-
const scriptOutput = runScript({
|
|
31
|
+
const scriptOutput = await runScript({
|
|
32
|
+
api: opts.api,
|
|
32
33
|
runner,
|
|
33
34
|
script: scriptPath,
|
|
34
35
|
stdin: prompt,
|
|
@@ -26,7 +26,8 @@ export class RunwayVideo implements MediaDriver {
|
|
|
26
26
|
const runner = await findVenvPython(skillDir);
|
|
27
27
|
|
|
28
28
|
// Execute the script with stdin input
|
|
29
|
-
const scriptOutput = runScript({
|
|
29
|
+
const scriptOutput = await runScript({
|
|
30
|
+
api: opts.api,
|
|
30
31
|
runner,
|
|
31
32
|
script: scriptPath,
|
|
32
33
|
stdin: prompt,
|
|
@@ -42,7 +43,7 @@ export class RunwayVideo implements MediaDriver {
|
|
|
42
43
|
|
|
43
44
|
// Parse the MEDIA: output
|
|
44
45
|
const filePath = parseMediaOutput(scriptOutput);
|
|
45
|
-
|
|
46
|
+
|
|
46
47
|
if (!filePath) {
|
|
47
48
|
throw new Error(`No MEDIA: path found in script output. Output: ${scriptOutput}`);
|
|
48
49
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from 'fs/promises';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import {
|
|
3
|
+
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Find a skill directory by searching common skill roots
|
|
@@ -33,7 +33,7 @@ export async function findSkillDir(slug: string): Promise<string | null> {
|
|
|
33
33
|
*/
|
|
34
34
|
export async function findVenvPython(skillDir: string): Promise<string> {
|
|
35
35
|
const venvPython = path.join(skillDir, '.venv', 'bin', 'python');
|
|
36
|
-
|
|
36
|
+
|
|
37
37
|
try {
|
|
38
38
|
await fs.access(venvPython);
|
|
39
39
|
return venvPython;
|
|
@@ -48,7 +48,7 @@ export async function findVenvPython(skillDir: string): Promise<string> {
|
|
|
48
48
|
export async function loadConfigEnv(): Promise<Record<string, string>> {
|
|
49
49
|
const homedir = process.env.HOME || '/home/control';
|
|
50
50
|
const configPath = path.join(homedir, '.openclaw', 'openclaw.json');
|
|
51
|
-
|
|
51
|
+
|
|
52
52
|
try {
|
|
53
53
|
const cfgRaw = await fs.readFile(configPath, 'utf8');
|
|
54
54
|
const cfgParsed = JSON.parse(cfgRaw);
|
|
@@ -82,9 +82,12 @@ export function parseMediaOutput(stdout: string): string {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
* Execute a script
|
|
85
|
+
* Execute a script via the OpenClaw exec tool so this plugin package does not
|
|
86
|
+
* directly import child_process. We still pass argv as discrete args and feed
|
|
87
|
+
* prompt text via stdin through a small Python wrapper script.
|
|
86
88
|
*/
|
|
87
89
|
export interface RunScriptOpts {
|
|
90
|
+
api: OpenClawPluginApi;
|
|
88
91
|
runner: string;
|
|
89
92
|
script: string;
|
|
90
93
|
args?: string[];
|
|
@@ -94,26 +97,75 @@ export interface RunScriptOpts {
|
|
|
94
97
|
timeout: number;
|
|
95
98
|
}
|
|
96
99
|
|
|
97
|
-
|
|
100
|
+
function buildPythonExecSnippet(opts: RunScriptOpts): string {
|
|
98
101
|
const { runner, script, args = [], stdin, env, cwd, timeout } = opts;
|
|
102
|
+
const mergedEnv = {
|
|
103
|
+
...env,
|
|
104
|
+
MEDIA_OUTPUT_DIR: cwd,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const payload = {
|
|
108
|
+
runner,
|
|
109
|
+
script,
|
|
110
|
+
args,
|
|
111
|
+
stdin: stdin ?? '',
|
|
112
|
+
env: mergedEnv,
|
|
113
|
+
cwd,
|
|
114
|
+
timeoutMs: timeout,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Base64-encode the payload to avoid shell injection and heredoc delimiter collisions.
|
|
118
|
+
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
119
|
+
|
|
120
|
+
return [
|
|
121
|
+
`python3 -c '`,
|
|
122
|
+
`import base64, json, os, subprocess, sys;`,
|
|
123
|
+
`payload = json.loads(base64.b64decode("${payloadB64}").decode());`,
|
|
124
|
+
`env = os.environ.copy();`,
|
|
125
|
+
`env.update({k: str(v) for k, v in payload["env"].items()});`,
|
|
126
|
+
`res = subprocess.run(`,
|
|
127
|
+
` [payload["runner"], payload["script"], *payload.get("args", [])],`,
|
|
128
|
+
` input=payload.get("stdin", ""),`,
|
|
129
|
+
` text=True,`,
|
|
130
|
+
` capture_output=True,`,
|
|
131
|
+
` cwd=payload["cwd"],`,
|
|
132
|
+
` env=env,`,
|
|
133
|
+
` timeout=max(1, int(payload.get("timeoutMs", 1000) / 1000))`,
|
|
134
|
+
`);`,
|
|
135
|
+
`sys.stdout.write(res.stdout);`,
|
|
136
|
+
`sys.stderr.write(res.stderr);`,
|
|
137
|
+
`raise SystemExit(res.returncode)`,
|
|
138
|
+
`'`,
|
|
139
|
+
].join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function runScript(opts: RunScriptOpts): Promise<string> {
|
|
143
|
+
const { api, timeout } = opts;
|
|
144
|
+
const timeoutMs = Math.max(1000, timeout + 5000);
|
|
145
|
+
const command = buildPythonExecSnippet(opts);
|
|
99
146
|
|
|
100
147
|
try {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
148
|
+
// Use the plugin SDK's runtime exec — available to all plugins without
|
|
149
|
+
// gateway tool permissions (unlike toolsInvoke('exec') which is session-gated).
|
|
150
|
+
const result = await api.runtime.system.runCommandWithTimeout(
|
|
151
|
+
['bash', '-c', command],
|
|
152
|
+
{ timeoutMs, cwd: opts.cwd },
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (result.code !== 0) {
|
|
156
|
+
const msg = [
|
|
157
|
+
`Script execution failed with exit code ${result.code}`,
|
|
158
|
+
result.stdout ? `\n--- stdout ---\n${result.stdout.trim()}` : '',
|
|
159
|
+
result.stderr ? `\n--- stderr ---\n${result.stderr.trim()}` : '',
|
|
160
|
+
].filter(Boolean).join('');
|
|
161
|
+
throw new Error(msg);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return (result.stdout || '').trim();
|
|
112
165
|
} catch (err) {
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const stderr = typeof e?.stderr === 'string' ? e.stderr : (Buffer.isBuffer(e?.stderr) ? e.stderr.toString('utf8') : '');
|
|
166
|
+
const e = err as Error & { stdout?: string; stderr?: string };
|
|
167
|
+
const stdout = e?.stdout ?? '';
|
|
168
|
+
const stderr = e?.stderr ?? '';
|
|
117
169
|
const msg = [
|
|
118
170
|
e?.message ? String(e.message) : 'Script execution failed',
|
|
119
171
|
stdout ? `\n--- stdout ---\n${stdout.trim()}` : '',
|
|
@@ -128,7 +180,7 @@ export function runScript(opts: RunScriptOpts): string {
|
|
|
128
180
|
*/
|
|
129
181
|
export async function findScriptInSkill(skillDir: string, scriptCandidates: string[]): Promise<string | null> {
|
|
130
182
|
const searchDirs = [skillDir, path.join(skillDir, 'scripts')];
|
|
131
|
-
|
|
183
|
+
|
|
132
184
|
for (const dir of searchDirs) {
|
|
133
185
|
for (const candidate of scriptCandidates) {
|
|
134
186
|
const scriptPath = path.join(dir, candidate);
|
|
@@ -140,6 +192,6 @@ export async function findScriptInSkill(skillDir: string, scriptCandidates: stri
|
|
|
140
192
|
}
|
|
141
193
|
}
|
|
142
194
|
}
|
|
143
|
-
|
|
195
|
+
|
|
144
196
|
return null;
|
|
145
|
-
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ToolsInvokeError } from '../../toolsInvoke.js';
|
|
2
|
+
|
|
3
|
+
export type ErrorCategory = 'funding' | 'rate-limit' | 'auth' | 'timeout' | 'unknown';
|
|
4
|
+
|
|
5
|
+
const FUNDING_PATTERNS = [
|
|
6
|
+
/insufficient.*(credits?|funds?|balance)/i,
|
|
7
|
+
/billing/i,
|
|
8
|
+
/payment\s+required/i,
|
|
9
|
+
/quota\s+exceeded/i,
|
|
10
|
+
/out\s+of\s+credits/i,
|
|
11
|
+
/budget\s+(exceeded|limit)/i,
|
|
12
|
+
/no\s+(active\s+)?subscription/i,
|
|
13
|
+
/plan\s+(limit|exceeded)/i,
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const RATE_LIMIT_PATTERNS = [
|
|
17
|
+
/rate\s+limit/i,
|
|
18
|
+
/too\s+many\s+requests/i,
|
|
19
|
+
/throttl/i,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const AUTH_PATTERNS = [
|
|
23
|
+
/unauthorized/i,
|
|
24
|
+
/invalid.*api.?key/i,
|
|
25
|
+
/forbidden/i,
|
|
26
|
+
/authentication\s+failed/i,
|
|
27
|
+
/access\s+denied/i,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function classifyByHttpStatus(status: number): ErrorCategory | null {
|
|
31
|
+
if (status === 402) return 'funding';
|
|
32
|
+
if (status === 429) return 'rate-limit';
|
|
33
|
+
if (status === 401 || status === 403) return 'auth';
|
|
34
|
+
if (status === 408 || status === 504) return 'timeout';
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function classifyByMessage(message: string, error: unknown): ErrorCategory | null {
|
|
39
|
+
if (FUNDING_PATTERNS.some((p) => p.test(message))) return 'funding';
|
|
40
|
+
if (RATE_LIMIT_PATTERNS.some((p) => p.test(message))) return 'rate-limit';
|
|
41
|
+
if (AUTH_PATTERNS.some((p) => p.test(message))) return 'auth';
|
|
42
|
+
if (error instanceof Error && error.name === 'AbortError') return 'timeout';
|
|
43
|
+
if (/timed?\s*out/i.test(message)) return 'timeout';
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Classify an error into a category based on HTTP status and message content.
|
|
49
|
+
* Returns 'unknown' if the error doesn't match any known pattern.
|
|
50
|
+
*/
|
|
51
|
+
export function classifyError(error: unknown): ErrorCategory {
|
|
52
|
+
const httpStatus = error instanceof ToolsInvokeError ? error.httpStatus : 0;
|
|
53
|
+
const message = error instanceof Error ? error.message : String(error ?? '');
|
|
54
|
+
|
|
55
|
+
return classifyByHttpStatus(httpStatus) ?? classifyByMessage(message, error) ?? 'unknown';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const CATEGORY_LABELS: Record<ErrorCategory, string> = {
|
|
59
|
+
'funding': 'Funding issue — the model provider may be out of credits or require payment',
|
|
60
|
+
'rate-limit': 'Rate limit — the model provider is throttling requests',
|
|
61
|
+
'auth': 'Authentication failure — the API key may be invalid or expired',
|
|
62
|
+
'timeout': 'Timeout — the request took too long to complete',
|
|
63
|
+
'unknown': 'Unknown error',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Human-readable label for an error category. */
|
|
67
|
+
export function errorCategoryLabel(category: ErrorCategory): string {
|
|
68
|
+
return CATEGORY_LABELS[category] ?? CATEGORY_LABELS['unknown'];
|
|
69
|
+
}
|
|
@@ -499,6 +499,12 @@ export async function executeWorkflowNodes(opts: {
|
|
|
499
499
|
}
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
+
if (kind === 'handoff') {
|
|
503
|
+
// Handoff nodes are supported in the pull-based worker (workflow-worker.ts).
|
|
504
|
+
// The synchronous executor doesn't support them yet — use `enqueue` + worker-tick instead.
|
|
505
|
+
throw new Error(`Node ${nodeLabel(node)}: handoff nodes require pull-based execution (use 'openclaw recipes workflows enqueue' + worker-tick)`);
|
|
506
|
+
}
|
|
507
|
+
|
|
502
508
|
throw new Error(`Unsupported node kind: ${node.kind} (${nodeLabel(node)})`);
|
|
503
509
|
}
|
|
504
510
|
|
|
@@ -27,6 +27,7 @@ export async function enqueueWorkflowRun(api: OpenClawPluginApi, opts: {
|
|
|
27
27
|
teamId: string;
|
|
28
28
|
workflowFile: string; // filename under shared-context/workflows/
|
|
29
29
|
trigger?: { kind: string; at?: string };
|
|
30
|
+
triggerInput?: Record<string, unknown>;
|
|
30
31
|
}) {
|
|
31
32
|
const teamId = String(opts.teamId);
|
|
32
33
|
const teamDir = resolveTeamDir(api, teamId);
|
|
@@ -94,6 +95,7 @@ export async function enqueueWorkflowRun(api: OpenClawPluginApi, opts: {
|
|
|
94
95
|
workflow: { file: opts.workflowFile, id: workflow.id ?? null, name: workflow.name ?? null },
|
|
95
96
|
ticket: { file: path.relative(teamDir, ticketPath), number: ticketNum, lane: initialLane },
|
|
96
97
|
trigger,
|
|
98
|
+
...(opts.triggerInput && Object.keys(opts.triggerInput).length > 0 ? { triggerInput: opts.triggerInput } : {}),
|
|
97
99
|
status: 'queued',
|
|
98
100
|
priority: 0,
|
|
99
101
|
claimedBy: null,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type WorkflowLane = 'backlog' | 'in-progress' | 'testing' | 'done';
|
|
2
2
|
|
|
3
|
-
export type WorkflowNodeKind = 'llm' | 'human_approval' | 'writeback' | 'tool' | 'start' | 'end' | string;
|
|
3
|
+
export type WorkflowNodeKind = 'llm' | 'human_approval' | 'writeback' | 'tool' | 'handoff' | 'start' | 'end' | string;
|
|
4
4
|
|
|
5
5
|
export type WorkflowEdgeOn = 'success' | 'error' | 'always';
|
|
6
6
|
|
|
@@ -85,6 +85,7 @@ export type RunLog = {
|
|
|
85
85
|
workflow: { file: string; id: string | null; name: string | null };
|
|
86
86
|
ticket: { file: string; number: string; lane: WorkflowLane };
|
|
87
87
|
trigger: { kind: string; at?: string };
|
|
88
|
+
triggerInput?: Record<string, unknown>;
|
|
88
89
|
status: string;
|
|
89
90
|
// Scheduler/runner fields
|
|
90
91
|
priority?: number;
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
3
4
|
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
4
5
|
import type { ToolTextResult } from '../../toolsInvoke';
|
|
5
6
|
import { toolsInvoke } from '../../toolsInvoke';
|
|
7
|
+
import { classifyError, errorCategoryLabel } from './workflow-error-classify';
|
|
6
8
|
import { resolveTeamDir } from '../workspace';
|
|
7
9
|
import { getDriver } from './media-drivers/registry';
|
|
8
10
|
import { GenericDriver } from './media-drivers/generic.driver';
|
|
9
11
|
import { loadConfigEnv } from './media-drivers/utils';
|
|
10
|
-
import type { WorkflowLane } from './workflow-types';
|
|
12
|
+
import type { WorkflowLane, WorkflowNode, RunLog } from './workflow-types';
|
|
11
13
|
import { dequeueNextTask, enqueueTask, releaseTaskClaim, compactQueue } from './workflow-queue';
|
|
12
14
|
import { loadPriorLlmInput, loadProposedPostTextFromPriorNode } from './workflow-node-output-readers';
|
|
13
15
|
import { readTextFile } from './workflow-runner-io';
|
|
@@ -16,6 +18,7 @@ import {
|
|
|
16
18
|
asRecord, asString, isRecord,
|
|
17
19
|
normalizeWorkflow,
|
|
18
20
|
assertLane, ensureDir, fileExists,
|
|
21
|
+
isoCompact, nextTicketNumber, laneToStatus,
|
|
19
22
|
moveRunTicket, appendRunLog, writeRunFile, loadRunFile,
|
|
20
23
|
runFilePathFor, nodeLabel,
|
|
21
24
|
loadNodeStatesFromRun, pickNextRunnableNodeIndex,
|
|
@@ -129,6 +132,18 @@ async function buildTemplateVars(
|
|
|
129
132
|
} as Record<string, string>;
|
|
130
133
|
|
|
131
134
|
const { run: runSnap } = await loadRunFile(teamDir, runsDir, runId);
|
|
135
|
+
|
|
136
|
+
// Expose triggerInput as template variables (for handoff-injected data)
|
|
137
|
+
if (runSnap.triggerInput && typeof runSnap.triggerInput === 'object') {
|
|
138
|
+
for (const [key, value] of Object.entries(runSnap.triggerInput)) {
|
|
139
|
+
if (typeof value === 'string') {
|
|
140
|
+
vars[`trigger.${key}`] = value;
|
|
141
|
+
} else if (value !== null && value !== undefined) {
|
|
142
|
+
vars[`trigger.${key}`] = JSON.stringify(value);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
132
147
|
for (const nr of (runSnap.nodeResults ?? [])) {
|
|
133
148
|
const nid = String((nr as Record<string, unknown>).nodeId ?? '');
|
|
134
149
|
const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
|
|
@@ -171,6 +186,330 @@ async function buildTemplateVars(
|
|
|
171
186
|
return vars;
|
|
172
187
|
}
|
|
173
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Enqueue a workflow run from a handoff node.
|
|
191
|
+
* This is a lightweight version of enqueueWorkflowRun that lives in the worker
|
|
192
|
+
* to avoid circular imports (workflow-runner re-exports workflow-worker).
|
|
193
|
+
*/
|
|
194
|
+
async function enqueueWorkflowRunForHandoff(api: OpenClawPluginApi, opts: {
|
|
195
|
+
teamId: string;
|
|
196
|
+
workflowFile: string;
|
|
197
|
+
trigger?: { kind: string; at?: string };
|
|
198
|
+
triggerInput?: Record<string, unknown>;
|
|
199
|
+
}): Promise<{ runId: string; runLogPath: string }> {
|
|
200
|
+
const teamId = String(opts.teamId);
|
|
201
|
+
const teamDir = resolveTeamDir(api, teamId);
|
|
202
|
+
const sharedContextDir = path.join(teamDir, 'shared-context');
|
|
203
|
+
const workflowsDir = path.join(sharedContextDir, 'workflows');
|
|
204
|
+
const runsDir = path.join(sharedContextDir, 'workflow-runs');
|
|
205
|
+
|
|
206
|
+
const workflowPath = path.join(workflowsDir, opts.workflowFile);
|
|
207
|
+
const raw = await readTextFile(workflowPath);
|
|
208
|
+
const workflow = normalizeWorkflow(JSON.parse(raw));
|
|
209
|
+
|
|
210
|
+
if (!workflow.nodes?.length) throw new Error('Handoff target workflow has no nodes');
|
|
211
|
+
|
|
212
|
+
const firstLaneRaw = String(
|
|
213
|
+
workflow.nodes.find(n => n?.config && typeof n.config === 'object' && 'lane' in n.config)?.config?.lane ?? 'backlog'
|
|
214
|
+
);
|
|
215
|
+
assertLane(firstLaneRaw);
|
|
216
|
+
const initialLane: WorkflowLane = firstLaneRaw;
|
|
217
|
+
|
|
218
|
+
const runId = `${isoCompact()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
219
|
+
await ensureDir(runsDir);
|
|
220
|
+
|
|
221
|
+
const runDir = path.join(runsDir, runId);
|
|
222
|
+
await ensureDir(runDir);
|
|
223
|
+
await Promise.all([
|
|
224
|
+
ensureDir(path.join(runDir, 'node-outputs')),
|
|
225
|
+
ensureDir(path.join(runDir, 'artifacts')),
|
|
226
|
+
ensureDir(path.join(runDir, 'approvals')),
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
const runLogPath = path.join(runDir, 'run.json');
|
|
230
|
+
|
|
231
|
+
const ticketNum = await nextTicketNumber(teamDir);
|
|
232
|
+
const slug = `workflow-run-${(workflow.id ?? path.basename(opts.workflowFile, path.extname(opts.workflowFile))).replace(/[^a-z0-9-]+/gi, '-').toLowerCase()}`;
|
|
233
|
+
const ticketFile = `${ticketNum}-${slug}.md`;
|
|
234
|
+
|
|
235
|
+
const laneDir = path.join(teamDir, 'work', initialLane);
|
|
236
|
+
await ensureDir(laneDir);
|
|
237
|
+
const ticketPath = path.join(laneDir, ticketFile);
|
|
238
|
+
|
|
239
|
+
const trigger = opts.trigger ?? { kind: 'handoff' };
|
|
240
|
+
const createdAt = new Date().toISOString();
|
|
241
|
+
const handoffMeta = opts.triggerInput?._handoff as Record<string, unknown> | undefined;
|
|
242
|
+
|
|
243
|
+
const md = [
|
|
244
|
+
`# ${ticketNum} — Workflow run: ${workflow.name ?? workflow.id ?? opts.workflowFile}\n\n`,
|
|
245
|
+
`Owner: lead`,
|
|
246
|
+
`Status: ${laneToStatus(initialLane)}`,
|
|
247
|
+
`\n## Run`,
|
|
248
|
+
`- workflow: ${path.relative(teamDir, workflowPath)}`,
|
|
249
|
+
`- run dir: ${path.relative(teamDir, runDir)}`,
|
|
250
|
+
`- run file: ${path.relative(teamDir, runLogPath)}`,
|
|
251
|
+
`- trigger: ${trigger.kind}${trigger.at ? ` @ ${trigger.at}` : ''}`,
|
|
252
|
+
`- runId: ${runId}`,
|
|
253
|
+
handoffMeta ? `- handoff from: team=${handoffMeta.sourceTeamId}, workflow=${handoffMeta.sourceWorkflowName}, run=${handoffMeta.sourceRunId}` : '',
|
|
254
|
+
`\n## Notes`,
|
|
255
|
+
`- Created by: handoff node`,
|
|
256
|
+
``,
|
|
257
|
+
].filter(Boolean).join('\n');
|
|
258
|
+
|
|
259
|
+
const initialLog: RunLog = {
|
|
260
|
+
runId,
|
|
261
|
+
createdAt,
|
|
262
|
+
updatedAt: createdAt,
|
|
263
|
+
teamId,
|
|
264
|
+
workflow: { file: opts.workflowFile, id: workflow.id ?? null, name: workflow.name ?? null },
|
|
265
|
+
ticket: { file: path.relative(teamDir, ticketPath), number: ticketNum, lane: initialLane },
|
|
266
|
+
trigger,
|
|
267
|
+
...(opts.triggerInput && Object.keys(opts.triggerInput).length > 0 ? { triggerInput: opts.triggerInput } : {}),
|
|
268
|
+
status: 'queued',
|
|
269
|
+
priority: 0,
|
|
270
|
+
claimedBy: null,
|
|
271
|
+
claimExpiresAt: null,
|
|
272
|
+
nextNodeIndex: 0,
|
|
273
|
+
events: [{ ts: createdAt, type: 'run.enqueued', lane: initialLane, trigger: trigger.kind }],
|
|
274
|
+
nodeResults: [],
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
await Promise.all([
|
|
278
|
+
fs.writeFile(ticketPath, md, 'utf8'),
|
|
279
|
+
fs.writeFile(runLogPath, JSON.stringify(initialLog, null, 2), 'utf8'),
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
return { runId, runLogPath };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Check for waiting_handoff runs and resolve them if the target run has completed.
|
|
287
|
+
* Called at the start of each worker tick before processing the normal queue.
|
|
288
|
+
*/
|
|
289
|
+
async function checkWaitingHandoffs(api: OpenClawPluginApi, teamId: string, teamDir: string): Promise<Array<{ runId: string; nodeId: string; status: string }>> {
|
|
290
|
+
const results: Array<{ runId: string; nodeId: string; status: string }> = [];
|
|
291
|
+
const runsDir = path.join(teamDir, 'shared-context', 'workflow-runs');
|
|
292
|
+
|
|
293
|
+
// Scan all active runs for handoff-waits directories
|
|
294
|
+
let runDirs: string[] = [];
|
|
295
|
+
try {
|
|
296
|
+
const entries = await fs.readdir(runsDir, { withFileTypes: true });
|
|
297
|
+
runDirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
298
|
+
} catch { return results; }
|
|
299
|
+
|
|
300
|
+
for (const runDirName of runDirs) {
|
|
301
|
+
const runDir = path.join(runsDir, runDirName);
|
|
302
|
+
const handoffWaitDir = path.join(runDir, 'handoff-waits');
|
|
303
|
+
|
|
304
|
+
let waitFiles: string[] = [];
|
|
305
|
+
try {
|
|
306
|
+
const entries = await fs.readdir(handoffWaitDir);
|
|
307
|
+
waitFiles = entries.filter(f => f.endsWith('.json'));
|
|
308
|
+
} catch { continue; } // No handoff-waits dir
|
|
309
|
+
|
|
310
|
+
if (waitFiles.length === 0) continue;
|
|
311
|
+
|
|
312
|
+
// Load current run to verify it's still waiting_handoff
|
|
313
|
+
const runPath = path.join(runDir, 'run.json');
|
|
314
|
+
let run: RunLog;
|
|
315
|
+
try {
|
|
316
|
+
const raw = await fs.readFile(runPath, 'utf8');
|
|
317
|
+
run = JSON.parse(raw) as RunLog;
|
|
318
|
+
} catch { continue; }
|
|
319
|
+
|
|
320
|
+
if (run.status !== 'waiting_handoff') {
|
|
321
|
+
// Clean up stale wait markers
|
|
322
|
+
for (const wf of waitFiles) {
|
|
323
|
+
try { await fs.unlink(path.join(handoffWaitDir, wf)); } catch { /* ignore */ }
|
|
324
|
+
}
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
for (const waitFile of waitFiles) {
|
|
329
|
+
const waitPath = path.join(handoffWaitDir, waitFile);
|
|
330
|
+
let marker: {
|
|
331
|
+
nodeId: string; nodeIdx: number;
|
|
332
|
+
targetTeamId: string; targetWorkflowId: string; targetWorkflowFile: string;
|
|
333
|
+
targetRunId: string; startedAt: string; timeoutAt: string;
|
|
334
|
+
nodeOutputRel: string;
|
|
335
|
+
};
|
|
336
|
+
try {
|
|
337
|
+
marker = JSON.parse(await fs.readFile(waitPath, 'utf8'));
|
|
338
|
+
} catch { continue; }
|
|
339
|
+
|
|
340
|
+
// Check timeout
|
|
341
|
+
const now = Date.now();
|
|
342
|
+
if (new Date(marker.timeoutAt).getTime() <= now) {
|
|
343
|
+
// Timeout — fail the node
|
|
344
|
+
const failTs = new Date().toISOString();
|
|
345
|
+
await appendRunLog(runPath, (cur) => ({
|
|
346
|
+
...cur,
|
|
347
|
+
status: 'error',
|
|
348
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [marker.nodeId]: { status: 'error', ts: failTs, message: 'Handoff wait timed out' } },
|
|
349
|
+
events: [...cur.events, {
|
|
350
|
+
ts: failTs, type: 'node.error', nodeId: marker.nodeId, kind: 'handoff',
|
|
351
|
+
error: `Handoff wait timed out after ${Math.round((now - new Date(marker.startedAt).getTime()) / 1000)}s`,
|
|
352
|
+
}],
|
|
353
|
+
}));
|
|
354
|
+
try { await fs.unlink(waitPath); } catch { /* ignore */ }
|
|
355
|
+
results.push({ runId: run.runId, nodeId: marker.nodeId, status: 'timeout' });
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Check target run status
|
|
360
|
+
const targetTeamDir = resolveTeamDir(api, marker.targetTeamId);
|
|
361
|
+
const targetRunsDir = path.join(targetTeamDir, 'shared-context', 'workflow-runs');
|
|
362
|
+
let targetRun: RunLog;
|
|
363
|
+
try {
|
|
364
|
+
const loaded = await loadRunFile(targetTeamDir, targetRunsDir, marker.targetRunId);
|
|
365
|
+
targetRun = loaded.run;
|
|
366
|
+
} catch {
|
|
367
|
+
// Target run not found — may have been cleaned up; fail
|
|
368
|
+
const failTs = new Date().toISOString();
|
|
369
|
+
await appendRunLog(runPath, (cur) => ({
|
|
370
|
+
...cur,
|
|
371
|
+
status: 'error',
|
|
372
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [marker.nodeId]: { status: 'error', ts: failTs, message: 'Target run not found' } },
|
|
373
|
+
events: [...cur.events, {
|
|
374
|
+
ts: failTs, type: 'node.error', nodeId: marker.nodeId, kind: 'handoff',
|
|
375
|
+
error: `Target run ${marker.targetRunId} not found in team ${marker.targetTeamId}`,
|
|
376
|
+
}],
|
|
377
|
+
}));
|
|
378
|
+
try { await fs.unlink(waitPath); } catch { /* ignore */ }
|
|
379
|
+
results.push({ runId: run.runId, nodeId: marker.nodeId, status: 'error' });
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (targetRun.status === 'completed' || targetRun.status === 'done') {
|
|
384
|
+
// Target completed — resolve handoff node with target's output
|
|
385
|
+
const targetOutput: Record<string, unknown> = {};
|
|
386
|
+
if (Array.isArray(targetRun.nodeResults)) {
|
|
387
|
+
for (const nr of targetRun.nodeResults) {
|
|
388
|
+
if (nr.nodeId && typeof nr.nodeId === 'string') {
|
|
389
|
+
targetOutput[nr.nodeId as string] = nr;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const nodeOutputAbs = path.resolve(runDir, marker.nodeOutputRel);
|
|
395
|
+
await ensureDir(path.dirname(nodeOutputAbs));
|
|
396
|
+
const outputObj = {
|
|
397
|
+
runId: run.runId,
|
|
398
|
+
teamId,
|
|
399
|
+
nodeId: marker.nodeId,
|
|
400
|
+
kind: 'handoff',
|
|
401
|
+
completedAt: new Date().toISOString(),
|
|
402
|
+
text: JSON.stringify({
|
|
403
|
+
targetTeamId: marker.targetTeamId,
|
|
404
|
+
targetWorkflowId: marker.targetWorkflowId,
|
|
405
|
+
targetRunId: marker.targetRunId,
|
|
406
|
+
status: 'completed',
|
|
407
|
+
targetOutput,
|
|
408
|
+
}, null, 2),
|
|
409
|
+
};
|
|
410
|
+
await fs.writeFile(nodeOutputAbs, JSON.stringify(outputObj, null, 2) + '\n', 'utf8');
|
|
411
|
+
|
|
412
|
+
const completedTs = new Date().toISOString();
|
|
413
|
+
|
|
414
|
+
// Load workflow to find next node
|
|
415
|
+
const workflowsDir = path.join(teamDir, 'shared-context', 'workflows');
|
|
416
|
+
let workflow;
|
|
417
|
+
try {
|
|
418
|
+
const wfRaw = await fs.readFile(path.join(workflowsDir, run.workflow.file), 'utf8');
|
|
419
|
+
workflow = normalizeWorkflow(JSON.parse(wfRaw));
|
|
420
|
+
} catch { workflow = null; }
|
|
421
|
+
|
|
422
|
+
await appendRunLog(runPath, (cur) => ({
|
|
423
|
+
...cur,
|
|
424
|
+
status: 'waiting_workers',
|
|
425
|
+
nextNodeIndex: marker.nodeIdx + 1,
|
|
426
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [marker.nodeId]: { status: 'success', ts: completedTs } },
|
|
427
|
+
events: [...cur.events, {
|
|
428
|
+
ts: completedTs, type: 'node.completed', nodeId: marker.nodeId, kind: 'handoff',
|
|
429
|
+
targetTeamId: marker.targetTeamId, targetWorkflowId: marker.targetWorkflowId,
|
|
430
|
+
targetRunId: marker.targetRunId, mode: 'wait-for-completion',
|
|
431
|
+
nodeOutputPath: marker.nodeOutputRel,
|
|
432
|
+
}],
|
|
433
|
+
}));
|
|
434
|
+
|
|
435
|
+
// Enqueue next node if workflow is available
|
|
436
|
+
if (workflow) {
|
|
437
|
+
const updatedRun = (await loadRunFile(teamDir, runsDir, run.runId)).run;
|
|
438
|
+
const nextIdx = pickNextRunnableNodeIndex({ workflow, run: updatedRun });
|
|
439
|
+
|
|
440
|
+
if (nextIdx !== null && nextIdx >= 0 && nextIdx < workflow.nodes.length) {
|
|
441
|
+
const nextNode = workflow.nodes[nextIdx];
|
|
442
|
+
if (nextNode.type === 'end' || nextNode.type === 'start') {
|
|
443
|
+
// Auto-complete start/end
|
|
444
|
+
const autoTs = new Date().toISOString();
|
|
445
|
+
await appendRunLog(runPath, (cur) => ({
|
|
446
|
+
...cur,
|
|
447
|
+
nextNodeIndex: nextIdx + 1,
|
|
448
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [nextNode.id]: { status: 'success', ts: autoTs } },
|
|
449
|
+
events: [...cur.events, { ts: autoTs, type: 'node.completed', nodeId: nextNode.id, kind: nextNode.type }],
|
|
450
|
+
}));
|
|
451
|
+
// Check if run is done
|
|
452
|
+
const afterAutoRun = (await loadRunFile(teamDir, runsDir, run.runId)).run;
|
|
453
|
+
const afterNext = pickNextRunnableNodeIndex({ workflow, run: afterAutoRun });
|
|
454
|
+
if (afterNext === null) {
|
|
455
|
+
const doneTs = new Date().toISOString();
|
|
456
|
+
await appendRunLog(runPath, (cur) => ({
|
|
457
|
+
...cur,
|
|
458
|
+
status: 'completed',
|
|
459
|
+
events: [...cur.events, { ts: doneTs, type: 'run.completed' }],
|
|
460
|
+
}));
|
|
461
|
+
}
|
|
462
|
+
} else {
|
|
463
|
+
// Enqueue next real node to the appropriate agent's queue
|
|
464
|
+
const assignedAgent = String(nextNode.assignedTo ?? '').trim();
|
|
465
|
+
const targetAgent = assignedAgent || run.claimedBy || '';
|
|
466
|
+
if (targetAgent) {
|
|
467
|
+
await enqueueTask(teamDir, targetAgent, {
|
|
468
|
+
teamId,
|
|
469
|
+
runId: run.runId,
|
|
470
|
+
nodeId: nextNode.id,
|
|
471
|
+
kind: 'execute_node',
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
} else if (nextIdx === null) {
|
|
476
|
+
// All nodes done
|
|
477
|
+
const doneTs = new Date().toISOString();
|
|
478
|
+
await appendRunLog(runPath, (cur) => ({
|
|
479
|
+
...cur,
|
|
480
|
+
status: 'completed',
|
|
481
|
+
events: [...cur.events, { ts: doneTs, type: 'run.completed' }],
|
|
482
|
+
}));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
try { await fs.unlink(waitPath); } catch { /* ignore */ }
|
|
487
|
+
results.push({ runId: run.runId, nodeId: marker.nodeId, status: 'completed' });
|
|
488
|
+
} else if (targetRun.status === 'error' || targetRun.status === 'failed') {
|
|
489
|
+
// Target failed — fail the handoff node too
|
|
490
|
+
const failTs = new Date().toISOString();
|
|
491
|
+
const lastError = targetRun.events?.filter(e => e.type === 'node.error').pop();
|
|
492
|
+
await appendRunLog(runPath, (cur) => ({
|
|
493
|
+
...cur,
|
|
494
|
+
status: 'error',
|
|
495
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [marker.nodeId]: {
|
|
496
|
+
status: 'error', ts: failTs,
|
|
497
|
+
message: `Target workflow failed: ${lastError?.error ?? 'unknown error'}`,
|
|
498
|
+
} },
|
|
499
|
+
events: [...cur.events, {
|
|
500
|
+
ts: failTs, type: 'node.error', nodeId: marker.nodeId, kind: 'handoff',
|
|
501
|
+
error: `Target run ${marker.targetRunId} failed`,
|
|
502
|
+
}],
|
|
503
|
+
}));
|
|
504
|
+
try { await fs.unlink(waitPath); } catch { /* ignore */ }
|
|
505
|
+
results.push({ runId: run.runId, nodeId: marker.nodeId, status: 'error' });
|
|
506
|
+
}
|
|
507
|
+
// else: still running — do nothing, check again next tick
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return results;
|
|
511
|
+
}
|
|
512
|
+
|
|
174
513
|
// eslint-disable-next-line complexity, max-lines-per-function
|
|
175
514
|
export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
176
515
|
teamId: string;
|
|
@@ -193,6 +532,14 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
193
532
|
|
|
194
533
|
const results: Array<{ taskId: string; runId: string; nodeId: string; status: string }> = [];
|
|
195
534
|
|
|
535
|
+
// Check for waiting_handoff runs before processing normal queue
|
|
536
|
+
try {
|
|
537
|
+
const handoffResults = await checkWaitingHandoffs(api, teamId, teamDir);
|
|
538
|
+
for (const hr of handoffResults) {
|
|
539
|
+
results.push({ taskId: '', runId: hr.runId, nodeId: hr.nodeId, status: `handoff:${hr.status}` });
|
|
540
|
+
}
|
|
541
|
+
} catch { /* handoff check is best-effort */ }
|
|
542
|
+
|
|
196
543
|
// Default lock TTL (used when we don't know the node config yet).
|
|
197
544
|
// This must be comfortably larger than typical media generation durations.
|
|
198
545
|
const DEFAULT_LOCK_TTL_MS = 30 * 60 * 1000;
|
|
@@ -565,6 +912,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
565
912
|
text = JSON.stringify(payload, null, 2);
|
|
566
913
|
} catch (e) {
|
|
567
914
|
const eRec = asRecord(e);
|
|
915
|
+
const errorCategory = classifyError(e);
|
|
568
916
|
const errorDetails = {
|
|
569
917
|
message: e instanceof Error ? e.message : String(e),
|
|
570
918
|
name: e instanceof Error ? e.name : undefined,
|
|
@@ -573,6 +921,8 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
573
921
|
details: eRec['details'],
|
|
574
922
|
data: eRec['data'],
|
|
575
923
|
cause: e instanceof Error && 'cause' in e ? (e as Error & { cause?: unknown }).cause : undefined,
|
|
924
|
+
errorCategory,
|
|
925
|
+
errorCategoryLabel: errorCategory !== 'unknown' ? errorCategoryLabel(errorCategory) : undefined,
|
|
576
926
|
};
|
|
577
927
|
const errMsg = `LLM execution failed for node ${nodeLabel(node)}: ${errorDetails.message}`;
|
|
578
928
|
const errorTs = new Date().toISOString();
|
|
@@ -582,18 +932,18 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
582
932
|
updatedAt: errorTs,
|
|
583
933
|
nodeStates: {
|
|
584
934
|
...(cur.nodeStates ?? {}),
|
|
585
|
-
[node.id]: { status: 'error', ts: errorTs, error: errMsg, details: errorDetails },
|
|
935
|
+
[node.id]: { status: 'error', ts: errorTs, error: errMsg, details: errorDetails, errorCategory },
|
|
586
936
|
},
|
|
587
937
|
events: [
|
|
588
938
|
...cur.events,
|
|
589
|
-
{ ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, details: errorDetails },
|
|
939
|
+
{ ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, details: errorDetails, errorCategory },
|
|
590
940
|
],
|
|
591
941
|
nodeResults: [
|
|
592
942
|
...(cur.nodeResults ?? []),
|
|
593
|
-
{ nodeId: node.id, kind: node.kind, agentId: agentIdExec, error: errMsg, details: errorDetails },
|
|
943
|
+
{ nodeId: node.id, kind: node.kind, agentId: agentIdExec, error: errMsg, details: errorDetails, errorCategory },
|
|
594
944
|
],
|
|
595
945
|
}));
|
|
596
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error' });
|
|
946
|
+
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', errorCategory });
|
|
597
947
|
continue;
|
|
598
948
|
}
|
|
599
949
|
|
|
@@ -1010,16 +1360,17 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1010
1360
|
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath), nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
|
|
1011
1361
|
}));
|
|
1012
1362
|
} catch (e) {
|
|
1013
|
-
|
|
1363
|
+
const errorCategory = classifyError(e);
|
|
1364
|
+
await fs.writeFile(artifactPath, JSON.stringify({ ok: false, tool: toolName, error: (e as Error).message, errorCategory }, null, 2) + '\n', 'utf8');
|
|
1014
1365
|
const errorTs = new Date().toISOString();
|
|
1015
1366
|
await appendRunLog(runPath, (cur) => ({
|
|
1016
1367
|
...cur,
|
|
1017
1368
|
status: 'error',
|
|
1018
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs } },
|
|
1019
|
-
events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, tool: toolName, message: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
1020
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, error: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
1369
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, errorCategory } },
|
|
1370
|
+
events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, tool: toolName, message: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath), errorCategory }],
|
|
1371
|
+
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, error: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath), errorCategory }],
|
|
1021
1372
|
}));
|
|
1022
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', error: (e as Error).message });
|
|
1373
|
+
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', error: (e as Error).message, errorCategory });
|
|
1023
1374
|
continue;
|
|
1024
1375
|
}
|
|
1025
1376
|
} else if (kind === 'media-image' || kind === 'media-video' || kind === 'media-audio') {
|
|
@@ -1131,6 +1482,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1131
1482
|
let payload: Record<string, unknown>;
|
|
1132
1483
|
if (driver) {
|
|
1133
1484
|
const result = await driver.invoke({
|
|
1485
|
+
api,
|
|
1134
1486
|
prompt: refinedPrompt,
|
|
1135
1487
|
outputDir: mediaDir,
|
|
1136
1488
|
env: mergedEnv,
|
|
@@ -1157,6 +1509,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1157
1509
|
}
|
|
1158
1510
|
text = JSON.stringify(payload, null, 2);
|
|
1159
1511
|
} catch (e) {
|
|
1512
|
+
const errorCategory = classifyError(e);
|
|
1160
1513
|
const errDetails = e instanceof Error
|
|
1161
1514
|
? { message: e.message, name: e.name, stack: e.stack?.split('\n').slice(0, 5).join(' | ') }
|
|
1162
1515
|
: { message: String(e) };
|
|
@@ -1166,11 +1519,11 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1166
1519
|
...cur,
|
|
1167
1520
|
status: 'error',
|
|
1168
1521
|
updatedAt: errorTs,
|
|
1169
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, error: errMsg } },
|
|
1170
|
-
events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg }],
|
|
1171
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdMedia || agentId, error: errMsg }],
|
|
1522
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, error: errMsg, errorCategory } },
|
|
1523
|
+
events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, errorCategory }],
|
|
1524
|
+
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdMedia || agentId, error: errMsg, errorCategory }],
|
|
1172
1525
|
}));
|
|
1173
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error' });
|
|
1526
|
+
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', errorCategory });
|
|
1174
1527
|
continue;
|
|
1175
1528
|
}
|
|
1176
1529
|
|
|
@@ -1198,6 +1551,190 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1198
1551
|
events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind: node.kind, nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
|
|
1199
1552
|
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, mediaType, agentId: agentIdMedia || agentId, nodeOutputPath: path.relative(teamDir, nodeOutputAbs), bytes: new TextEncoder().encode(text).byteLength }],
|
|
1200
1553
|
}));
|
|
1554
|
+
} else if (kind === 'handoff') {
|
|
1555
|
+
// ── Handoff node: trigger a run on another workflow (optionally on a different team) ──
|
|
1556
|
+
const config = asRecord((node as unknown as Record<string, unknown>)['config']);
|
|
1557
|
+
const action = asRecord(node.action);
|
|
1558
|
+
|
|
1559
|
+
const targetTeamId = asString(config['targetTeamId'] ?? action['targetTeamId']).trim() || teamId;
|
|
1560
|
+
const targetWorkflowId = asString(config['targetWorkflowId'] ?? action['targetWorkflowId']).trim();
|
|
1561
|
+
if (!targetWorkflowId) throw new Error(`Node ${nodeLabel(node)} missing config.targetWorkflowId`);
|
|
1562
|
+
|
|
1563
|
+
// Resolve variable mapping: each key is the target's trigger input key, each value is a {{template}} expression
|
|
1564
|
+
const variableMapping = asRecord(config['variableMapping'] ?? action['variableMapping']);
|
|
1565
|
+
|
|
1566
|
+
// Build template vars from prior node outputs
|
|
1567
|
+
const vars = await buildTemplateVars(teamDir, runsDir, task.runId, workflowFile, workflow);
|
|
1568
|
+
vars['node.id'] = node.id;
|
|
1569
|
+
|
|
1570
|
+
// Resolve mapped variables
|
|
1571
|
+
const triggerInput: Record<string, unknown> = {
|
|
1572
|
+
_handoff: {
|
|
1573
|
+
sourceTeamId: teamId,
|
|
1574
|
+
sourceWorkflowId: String(workflow.id ?? ''),
|
|
1575
|
+
sourceWorkflowName: String(workflow.name ?? workflow.id ?? workflowFile),
|
|
1576
|
+
sourceRunId: task.runId,
|
|
1577
|
+
sourceNodeId: node.id,
|
|
1578
|
+
},
|
|
1579
|
+
};
|
|
1580
|
+
for (const [targetKey, templateExpr] of Object.entries(variableMapping)) {
|
|
1581
|
+
if (typeof templateExpr === 'string') {
|
|
1582
|
+
triggerInput[targetKey] = templateReplace(templateExpr, vars);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// Find the target workflow file
|
|
1587
|
+
const targetTeamDir = resolveTeamDir(api, targetTeamId);
|
|
1588
|
+
const targetWorkflowsDir = path.join(targetTeamDir, 'shared-context', 'workflows');
|
|
1589
|
+
let targetWorkflowFile = '';
|
|
1590
|
+
|
|
1591
|
+
// Try exact filename match first, then search by workflow id
|
|
1592
|
+
const candidateFiles = [
|
|
1593
|
+
`${targetWorkflowId}.json`,
|
|
1594
|
+
`${targetWorkflowId}`,
|
|
1595
|
+
];
|
|
1596
|
+
for (const candidate of candidateFiles) {
|
|
1597
|
+
const candidatePath = path.join(targetWorkflowsDir, candidate);
|
|
1598
|
+
if (await fileExists(candidatePath)) {
|
|
1599
|
+
targetWorkflowFile = candidate;
|
|
1600
|
+
break;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// If not found by filename, scan workflows for matching id
|
|
1605
|
+
if (!targetWorkflowFile) {
|
|
1606
|
+
try {
|
|
1607
|
+
const wfFiles = await fs.readdir(targetWorkflowsDir);
|
|
1608
|
+
for (const wf of wfFiles) {
|
|
1609
|
+
if (!wf.endsWith('.json')) continue;
|
|
1610
|
+
try {
|
|
1611
|
+
const wfPath = path.join(targetWorkflowsDir, wf);
|
|
1612
|
+
const wfRaw = await fs.readFile(wfPath, 'utf8');
|
|
1613
|
+
const wfParsed = JSON.parse(wfRaw);
|
|
1614
|
+
if (String(wfParsed.id ?? '') === targetWorkflowId || String(wfParsed.name ?? '') === targetWorkflowId) {
|
|
1615
|
+
targetWorkflowFile = wf;
|
|
1616
|
+
break;
|
|
1617
|
+
}
|
|
1618
|
+
} catch { /* skip unparseable workflows */ }
|
|
1619
|
+
}
|
|
1620
|
+
} catch { /* target workflows dir may not exist */ }
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
if (!targetWorkflowFile) {
|
|
1624
|
+
throw new Error(`Handoff target workflow "${targetWorkflowId}" not found in team "${targetTeamId}"`);
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// Enqueue the target workflow run with triggerInput
|
|
1628
|
+
const enqueueResult = await enqueueWorkflowRunForHandoff(api, {
|
|
1629
|
+
teamId: targetTeamId,
|
|
1630
|
+
workflowFile: targetWorkflowFile,
|
|
1631
|
+
trigger: { kind: 'handoff', at: new Date().toISOString() },
|
|
1632
|
+
triggerInput,
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
const handoffMode = asString(config['mode'] ?? 'fire-and-forget').trim() || 'fire-and-forget';
|
|
1636
|
+
|
|
1637
|
+
// Save initial handoff output
|
|
1638
|
+
const defaultNodeOutputRel = path.join('node-outputs', `${String(nodeIdx).padStart(3, '0')}-${node.id}.json`);
|
|
1639
|
+
const nodeOutputRel = String(node?.output?.path ?? '').trim() || defaultNodeOutputRel;
|
|
1640
|
+
const nodeOutputAbs = path.resolve(runDir, nodeOutputRel);
|
|
1641
|
+
await ensureDir(path.dirname(nodeOutputAbs));
|
|
1642
|
+
|
|
1643
|
+
if (handoffMode === 'wait-for-completion') {
|
|
1644
|
+
// Phase 2: Wait for target run to complete
|
|
1645
|
+
const waitTimeoutMs = typeof config['waitTimeoutMs'] === 'number' ? config['waitTimeoutMs'] as number : 5 * 60 * 1000;
|
|
1646
|
+
|
|
1647
|
+
const outputObj = {
|
|
1648
|
+
runId: task.runId,
|
|
1649
|
+
teamId,
|
|
1650
|
+
nodeId: node.id,
|
|
1651
|
+
kind: 'handoff',
|
|
1652
|
+
text: JSON.stringify({
|
|
1653
|
+
targetTeamId,
|
|
1654
|
+
targetWorkflowId,
|
|
1655
|
+
targetWorkflowFile,
|
|
1656
|
+
targetRunId: enqueueResult.runId,
|
|
1657
|
+
status: 'waiting',
|
|
1658
|
+
triggerInputKeys: Object.keys(triggerInput),
|
|
1659
|
+
}, null, 2),
|
|
1660
|
+
};
|
|
1661
|
+
await fs.writeFile(nodeOutputAbs, JSON.stringify(outputObj, null, 2) + '\n', 'utf8');
|
|
1662
|
+
|
|
1663
|
+
// Write handoff wait marker so the polling loop can find it
|
|
1664
|
+
const handoffWaitDir = path.join(runDir, 'handoff-waits');
|
|
1665
|
+
await ensureDir(handoffWaitDir);
|
|
1666
|
+
const waitMarker = {
|
|
1667
|
+
nodeId: node.id,
|
|
1668
|
+
nodeIdx,
|
|
1669
|
+
targetTeamId,
|
|
1670
|
+
targetWorkflowId,
|
|
1671
|
+
targetWorkflowFile,
|
|
1672
|
+
targetRunId: enqueueResult.runId,
|
|
1673
|
+
startedAt: new Date().toISOString(),
|
|
1674
|
+
timeoutAt: new Date(Date.now() + waitTimeoutMs).toISOString(),
|
|
1675
|
+
nodeOutputRel,
|
|
1676
|
+
};
|
|
1677
|
+
await fs.writeFile(
|
|
1678
|
+
path.join(handoffWaitDir, `${node.id}.json`),
|
|
1679
|
+
JSON.stringify(waitMarker, null, 2) + '\n',
|
|
1680
|
+
'utf8',
|
|
1681
|
+
);
|
|
1682
|
+
|
|
1683
|
+
const waitingTs = new Date().toISOString();
|
|
1684
|
+
await appendRunLog(runPath, (cur) => ({
|
|
1685
|
+
...cur,
|
|
1686
|
+
status: 'waiting_handoff',
|
|
1687
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'waiting', ts: waitingTs } },
|
|
1688
|
+
events: [...cur.events, {
|
|
1689
|
+
ts: waitingTs, type: 'node.waiting_handoff', nodeId: node.id, kind: 'handoff',
|
|
1690
|
+
targetTeamId, targetWorkflowId, targetRunId: enqueueResult.runId,
|
|
1691
|
+
mode: 'wait-for-completion', timeoutAt: waitMarker.timeoutAt,
|
|
1692
|
+
}],
|
|
1693
|
+
nodeResults: [...(cur.nodeResults ?? []), {
|
|
1694
|
+
nodeId: node.id, kind: 'handoff',
|
|
1695
|
+
targetTeamId, targetWorkflowId, targetRunId: enqueueResult.runId,
|
|
1696
|
+
nodeOutputPath: path.relative(teamDir, nodeOutputAbs),
|
|
1697
|
+
}],
|
|
1698
|
+
}));
|
|
1699
|
+
|
|
1700
|
+
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'waiting_handoff' });
|
|
1701
|
+
continue; // Skip the normal next-node enqueue logic
|
|
1702
|
+
} else {
|
|
1703
|
+
// Fire-and-forget: complete immediately
|
|
1704
|
+
const outputObj = {
|
|
1705
|
+
runId: task.runId,
|
|
1706
|
+
teamId,
|
|
1707
|
+
nodeId: node.id,
|
|
1708
|
+
kind: 'handoff',
|
|
1709
|
+
completedAt: new Date().toISOString(),
|
|
1710
|
+
text: JSON.stringify({
|
|
1711
|
+
targetTeamId,
|
|
1712
|
+
targetWorkflowId,
|
|
1713
|
+
targetWorkflowFile,
|
|
1714
|
+
targetRunId: enqueueResult.runId,
|
|
1715
|
+
status: 'enqueued',
|
|
1716
|
+
triggerInputKeys: Object.keys(triggerInput),
|
|
1717
|
+
}, null, 2),
|
|
1718
|
+
};
|
|
1719
|
+
await fs.writeFile(nodeOutputAbs, JSON.stringify(outputObj, null, 2) + '\n', 'utf8');
|
|
1720
|
+
|
|
1721
|
+
const completedTs = new Date().toISOString();
|
|
1722
|
+
await appendRunLog(runPath, (cur) => ({
|
|
1723
|
+
...cur,
|
|
1724
|
+
nextNodeIndex: nodeIdx + 1,
|
|
1725
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
|
|
1726
|
+
events: [...cur.events, {
|
|
1727
|
+
ts: completedTs, type: 'node.completed', nodeId: node.id, kind: 'handoff',
|
|
1728
|
+
targetTeamId, targetWorkflowId, targetRunId: enqueueResult.runId,
|
|
1729
|
+
nodeOutputPath: path.relative(teamDir, nodeOutputAbs),
|
|
1730
|
+
}],
|
|
1731
|
+
nodeResults: [...(cur.nodeResults ?? []), {
|
|
1732
|
+
nodeId: node.id, kind: 'handoff',
|
|
1733
|
+
targetTeamId, targetWorkflowId, targetRunId: enqueueResult.runId,
|
|
1734
|
+
nodeOutputPath: path.relative(teamDir, nodeOutputAbs),
|
|
1735
|
+
}],
|
|
1736
|
+
}));
|
|
1737
|
+
}
|
|
1201
1738
|
} else {
|
|
1202
1739
|
throw new Error(`Worker does not yet support node kind: ${kind}`);
|
|
1203
1740
|
}
|
|
@@ -1212,6 +1749,11 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1212
1749
|
continue;
|
|
1213
1750
|
}
|
|
1214
1751
|
|
|
1752
|
+
if (updated.status === 'waiting_handoff') {
|
|
1753
|
+
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'waiting_handoff' });
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1215
1757
|
let enqueueIdx = pickNextRunnableNodeIndex({ workflow, run: updated });
|
|
1216
1758
|
|
|
1217
1759
|
// Auto-complete start/end nodes.
|
package/src/toolsInvoke.ts
CHANGED
|
@@ -6,6 +6,19 @@ export const TOOLS_INVOKE_TIMEOUT_MS = 120_000;
|
|
|
6
6
|
export const RETRY_DELAY_BASE_MS = 150;
|
|
7
7
|
export const GATEWAY_DEFAULT_PORT = 18789;
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Custom error class that preserves HTTP status from gateway responses.
|
|
11
|
+
* Used downstream to classify errors (e.g. 402 → funding, 429 → rate-limit).
|
|
12
|
+
*/
|
|
13
|
+
export class ToolsInvokeError extends Error {
|
|
14
|
+
httpStatus: number;
|
|
15
|
+
constructor(message: string, httpStatus: number) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'ToolsInvokeError';
|
|
18
|
+
this.httpStatus = httpStatus;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
export type ToolTextResult = { content?: Array<{ type: string; text?: string }> };
|
|
10
23
|
|
|
11
24
|
export type ToolsInvokeRequest = {
|
|
@@ -43,7 +56,7 @@ async function doSingleToolsInvoke<T>(url: string, token: string, req: ToolsInvo
|
|
|
43
56
|
}).finally(() => clearTimeout(t));
|
|
44
57
|
|
|
45
58
|
const json = (await res.json()) as ToolsInvokeResponse;
|
|
46
|
-
if (!res.ok || !json.ok) throw new
|
|
59
|
+
if (!res.ok || !json.ok) throw new ToolsInvokeError(parseToolsInvokeError(json, res.status), res.status);
|
|
47
60
|
return json.result as T;
|
|
48
61
|
}
|
|
49
62
|
|