@jiggai/recipes 0.4.39 → 0.4.41
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/docs/WORKFLOW_NODES.md +30 -0
- package/index.ts +13 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/handlers/team.ts +4 -0
- package/src/handlers/tickets.ts +3 -0
- package/src/lib/kitchen-manifest.ts +222 -0
- 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-utils.ts +14 -1
- package/src/lib/workflows/workflow-worker.ts +67 -20
- package/src/toolsInvoke.ts +14 -1
package/docs/WORKFLOW_NODES.md
CHANGED
|
@@ -116,9 +116,39 @@ Tool nodes call a tool by name with JSON args. Example:
|
|
|
116
116
|
- `fs.append`
|
|
117
117
|
- `outbound.post`
|
|
118
118
|
- `message.send`
|
|
119
|
+
- `exec` (shell command execution)
|
|
119
120
|
|
|
120
121
|
Tool nodes support template vars inside string args.
|
|
121
122
|
|
|
123
|
+
#### Exec tool nodes
|
|
124
|
+
|
|
125
|
+
Nodes with `"tool": "exec"` run shell commands via the plugin runtime (not the gateway). This means **any agent** can execute them — there is no need to assign exec nodes to `main`.
|
|
126
|
+
|
|
127
|
+
Config fields:
|
|
128
|
+
- `args.command` (string): the shell command to run (passed to `bash -c`)
|
|
129
|
+
- `args.workdir` (string, optional): working directory (defaults to the team workspace)
|
|
130
|
+
- `args.timeout` (number, optional): timeout in seconds (default: 120)
|
|
131
|
+
|
|
132
|
+
**Agent assignment:** Assign exec nodes to the same agent that handles the surrounding workflow — typically the team lead. Avoid assigning to `main` unless the node specifically needs the personal workspace context.
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"id": "run_script",
|
|
137
|
+
"type": "tool",
|
|
138
|
+
"name": "Run deploy script",
|
|
139
|
+
"config": {
|
|
140
|
+
"tool": "exec",
|
|
141
|
+
"args": {
|
|
142
|
+
"command": "bash scripts/deploy.sh --env={{run.id}}",
|
|
143
|
+
"timeout": 60
|
|
144
|
+
},
|
|
145
|
+
"agentId": "my-team-lead"
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
> **Why not `main`?** Each agent has its own worker queue and cron. If you assign a node to `main` but there's no worker cron for `main` on that team, the task will sit in the queue indefinitely. Use the team's existing agents to keep things flowing.
|
|
151
|
+
|
|
122
152
|
---
|
|
123
153
|
|
|
124
154
|
### Human approval nodes
|
package/index.ts
CHANGED
|
@@ -1034,6 +1034,19 @@ workflows
|
|
|
1034
1034
|
logScaffoldResult(res, String(options.recipe));
|
|
1035
1035
|
});
|
|
1036
1036
|
|
|
1037
|
+
cmd
|
|
1038
|
+
.command("kitchen-manifest")
|
|
1039
|
+
.description("Generate the Kitchen manifest file (pre-computed nav/shell data for ClawKitchen)")
|
|
1040
|
+
.option("--output <path>", "Override output path (default: ~/.openclaw/kitchen-manifest.json)")
|
|
1041
|
+
.action(async (options: { output?: string }) => {
|
|
1042
|
+
const { generateKitchenManifest } = await import("./src/lib/kitchen-manifest");
|
|
1043
|
+
const manifest = await generateKitchenManifest({
|
|
1044
|
+
api,
|
|
1045
|
+
outputPath: options.output || undefined,
|
|
1046
|
+
});
|
|
1047
|
+
console.log(JSON.stringify({ ok: true, generatedAt: manifest.generatedAt, teams: Object.keys(manifest.teams).length, agents: manifest.agents.length, recipes: manifest.recipes.length }));
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1037
1050
|
},
|
|
1038
1051
|
{ commands: ["recipes"] },
|
|
1039
1052
|
);
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/handlers/team.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { pickRecipeId } from "../lib/recipe-id";
|
|
|
13
13
|
import { recipeIdTakenForTeam, validateRecipeAndSkills, writeWorkspaceRecipeFile } from "../lib/scaffold-utils";
|
|
14
14
|
import { scaffoldAgentFromRecipe } from "./scaffold";
|
|
15
15
|
import { renderTemplate } from "../lib/template";
|
|
16
|
+
import { scheduleManifestRegeneration } from "../lib/kitchen-manifest";
|
|
16
17
|
import { reconcileRecipeCronJobs } from "./cron";
|
|
17
18
|
import { lintRecipe } from "../lib/recipe-lint";
|
|
18
19
|
|
|
@@ -510,6 +511,7 @@ export async function handleScaffoldTeam(
|
|
|
510
511
|
scope: { kind: "team", teamId, recipeId: recipe.id, stateDir: teamDir },
|
|
511
512
|
cronInstallation: cfg.cronInstallation,
|
|
512
513
|
});
|
|
514
|
+
scheduleManifestRegeneration(api);
|
|
513
515
|
return {
|
|
514
516
|
ok: true as const,
|
|
515
517
|
teamId,
|
|
@@ -645,6 +647,7 @@ export async function executeMigrateTeamPlan(
|
|
|
645
647
|
await applyAgentSnippetsToOpenClawConfig(api, agentSnippets);
|
|
646
648
|
}
|
|
647
649
|
|
|
650
|
+
scheduleManifestRegeneration(api);
|
|
648
651
|
return { ok: true as const, migrated: plan.teamId, destTeamDir: dest.teamDir, agentIds: plan.agentIds };
|
|
649
652
|
}
|
|
650
653
|
|
|
@@ -707,5 +710,6 @@ export async function handleRemoveTeam(
|
|
|
707
710
|
});
|
|
708
711
|
await writeOpenClawConfig(api, cfgObj);
|
|
709
712
|
await saveCronStore(cronJobsPath, cronStore);
|
|
713
|
+
scheduleManifestRegeneration(api);
|
|
710
714
|
return { ok: true as const, result };
|
|
711
715
|
}
|
package/src/handlers/tickets.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { ticketStageDir } from "../lib/lanes";
|
|
|
7
7
|
import { computeNextTicketNumber, TICKET_FILENAME_REGEX } from "../lib/ticket-finder";
|
|
8
8
|
import { resolveTeamContext } from "../lib/workspace";
|
|
9
9
|
import { VALID_ROLES, VALID_STAGES } from "../lib/constants";
|
|
10
|
+
import { scheduleManifestRegeneration } from "../lib/kitchen-manifest";
|
|
10
11
|
|
|
11
12
|
export function patchTicketField(md: string, key: string, value: string): string {
|
|
12
13
|
const lineRe = new RegExp(`^${key}:\\s.*$`, "m");
|
|
@@ -110,6 +111,7 @@ export async function handleMoveTicket(
|
|
|
110
111
|
|
|
111
112
|
// Assignment stubs are deprecated; no archival behavior.
|
|
112
113
|
|
|
114
|
+
scheduleManifestRegeneration(api);
|
|
113
115
|
return { ok: true, from: srcPath, to: destPath };
|
|
114
116
|
}
|
|
115
117
|
|
|
@@ -293,6 +295,7 @@ export async function handleDispatch(
|
|
|
293
295
|
// Dispatch still succeeds; nudgeQueued stays false so caller knows the nudge was skipped.
|
|
294
296
|
nudgeQueued = false;
|
|
295
297
|
}
|
|
298
|
+
scheduleManifestRegeneration(api);
|
|
296
299
|
return { ok: true as const, wrote: plan.files.map((f) => f.path), nudgeQueued };
|
|
297
300
|
}
|
|
298
301
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
5
|
+
|
|
6
|
+
// ── Manifest types ──────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface KitchenManifest {
|
|
9
|
+
version: 1;
|
|
10
|
+
generatedAt: string;
|
|
11
|
+
teams: Record<string, TeamManifestEntry>;
|
|
12
|
+
agents: AgentManifestEntry[];
|
|
13
|
+
recipes: RecipeManifestEntry[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TeamManifestEntry {
|
|
17
|
+
teamId: string;
|
|
18
|
+
displayName: string | null;
|
|
19
|
+
roles: string[];
|
|
20
|
+
ticketCounts: {
|
|
21
|
+
backlog: number;
|
|
22
|
+
'in-progress': number;
|
|
23
|
+
testing: number;
|
|
24
|
+
done: number;
|
|
25
|
+
total: number;
|
|
26
|
+
};
|
|
27
|
+
activeRunCount: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AgentManifestEntry {
|
|
31
|
+
id: string;
|
|
32
|
+
identityName?: string;
|
|
33
|
+
workspace?: string;
|
|
34
|
+
model?: string;
|
|
35
|
+
isDefault?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RecipeManifestEntry {
|
|
39
|
+
id: string;
|
|
40
|
+
name: string;
|
|
41
|
+
kind: 'agent' | 'team';
|
|
42
|
+
source: 'builtin' | 'workspace';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const OPENCLAW_DIR = path.join(os.homedir(), '.openclaw');
|
|
48
|
+
const MANIFEST_FILENAME = 'kitchen-manifest.json';
|
|
49
|
+
|
|
50
|
+
export function defaultManifestPath(): string {
|
|
51
|
+
return path.join(OPENCLAW_DIR, MANIFEST_FILENAME);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function countMdFiles(dir: string): Promise<number> {
|
|
55
|
+
try {
|
|
56
|
+
const entries = await fs.readdir(dir);
|
|
57
|
+
return entries.filter((e) => e.endsWith('.md')).length;
|
|
58
|
+
} catch {
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function countActiveRuns(teamDir: string): Promise<number> {
|
|
64
|
+
const runsDir = path.join(teamDir, 'shared-context', 'workflow-runs');
|
|
65
|
+
let runDirs: string[];
|
|
66
|
+
try {
|
|
67
|
+
runDirs = await fs.readdir(runsDir);
|
|
68
|
+
} catch {
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let count = 0;
|
|
73
|
+
for (const d of runDirs) {
|
|
74
|
+
const runJson = path.join(runsDir, d, 'run.json');
|
|
75
|
+
try {
|
|
76
|
+
const raw = await fs.readFile(runJson, 'utf8');
|
|
77
|
+
const run = JSON.parse(raw) as { status?: string };
|
|
78
|
+
const s = run.status ?? '';
|
|
79
|
+
if (s === 'running' || s === 'waiting_workers' || s === 'waiting_for_approval') {
|
|
80
|
+
count++;
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// run.json missing or malformed — skip
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return count;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function listRoles(teamDir: string): Promise<string[]> {
|
|
90
|
+
const rolesDir = path.join(teamDir, 'roles');
|
|
91
|
+
try {
|
|
92
|
+
const entries = await fs.readdir(rolesDir, { withFileTypes: true });
|
|
93
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function readTeamDisplayName(teamDir: string): Promise<string | null> {
|
|
100
|
+
const teamJsonPath = path.join(teamDir, 'shared-context', 'workflows', 'team.json');
|
|
101
|
+
try {
|
|
102
|
+
const raw = await fs.readFile(teamJsonPath, 'utf8');
|
|
103
|
+
const parsed = JSON.parse(raw) as { recipeName?: string };
|
|
104
|
+
return parsed.recipeName?.trim() || null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Generator ───────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export interface GenerateManifestOptions {
|
|
113
|
+
api: OpenClawPluginApi;
|
|
114
|
+
outputPath?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function generateKitchenManifest(opts: GenerateManifestOptions): Promise<KitchenManifest> {
|
|
118
|
+
const { api } = opts;
|
|
119
|
+
const outputPath = opts.outputPath ?? defaultManifestPath();
|
|
120
|
+
|
|
121
|
+
// Discover teams
|
|
122
|
+
let dirEntries: string[];
|
|
123
|
+
try {
|
|
124
|
+
dirEntries = await fs.readdir(OPENCLAW_DIR);
|
|
125
|
+
} catch {
|
|
126
|
+
dirEntries = [];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const teamDirNames = dirEntries.filter((e) => e.startsWith('workspace-'));
|
|
130
|
+
const teams: Record<string, TeamManifestEntry> = {};
|
|
131
|
+
|
|
132
|
+
for (const dirName of teamDirNames) {
|
|
133
|
+
const teamId = dirName.slice('workspace-'.length);
|
|
134
|
+
if (!teamId) continue;
|
|
135
|
+
|
|
136
|
+
const teamDir = path.join(OPENCLAW_DIR, dirName);
|
|
137
|
+
const workDir = path.join(teamDir, 'work');
|
|
138
|
+
|
|
139
|
+
const [backlog, inProgress, testing, done, activeRunCount, roles, displayName] = await Promise.all([
|
|
140
|
+
countMdFiles(path.join(workDir, 'backlog')),
|
|
141
|
+
countMdFiles(path.join(workDir, 'in-progress')),
|
|
142
|
+
countMdFiles(path.join(workDir, 'testing')),
|
|
143
|
+
countMdFiles(path.join(workDir, 'done')),
|
|
144
|
+
countActiveRuns(teamDir),
|
|
145
|
+
listRoles(teamDir),
|
|
146
|
+
readTeamDisplayName(teamDir),
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
teams[teamId] = {
|
|
150
|
+
teamId,
|
|
151
|
+
displayName,
|
|
152
|
+
roles,
|
|
153
|
+
ticketCounts: {
|
|
154
|
+
backlog,
|
|
155
|
+
'in-progress': inProgress,
|
|
156
|
+
testing,
|
|
157
|
+
done,
|
|
158
|
+
total: backlog + inProgress + testing + done,
|
|
159
|
+
},
|
|
160
|
+
activeRunCount,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Fetch agents and recipes via CLI (reuses existing OpenClaw infrastructure)
|
|
165
|
+
let agents: AgentManifestEntry[] = [];
|
|
166
|
+
try {
|
|
167
|
+
const res = await api.runtime.system.runCommandWithTimeout(
|
|
168
|
+
['openclaw', 'agents', 'list', '--json'],
|
|
169
|
+
{ timeoutMs: 15_000 },
|
|
170
|
+
);
|
|
171
|
+
if (res.code === 0 && res.stdout) {
|
|
172
|
+
agents = JSON.parse(res.stdout) as AgentManifestEntry[];
|
|
173
|
+
}
|
|
174
|
+
} catch { /* best-effort */ }
|
|
175
|
+
|
|
176
|
+
let recipes: RecipeManifestEntry[] = [];
|
|
177
|
+
try {
|
|
178
|
+
const res = await api.runtime.system.runCommandWithTimeout(
|
|
179
|
+
['openclaw', 'recipes', 'list'],
|
|
180
|
+
{ timeoutMs: 15_000 },
|
|
181
|
+
);
|
|
182
|
+
if (res.code === 0 && res.stdout) {
|
|
183
|
+
recipes = JSON.parse(res.stdout) as RecipeManifestEntry[];
|
|
184
|
+
}
|
|
185
|
+
} catch { /* best-effort */ }
|
|
186
|
+
|
|
187
|
+
const manifest: KitchenManifest = {
|
|
188
|
+
version: 1,
|
|
189
|
+
generatedAt: new Date().toISOString(),
|
|
190
|
+
teams,
|
|
191
|
+
agents,
|
|
192
|
+
recipes,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Atomic write: tmp file + rename
|
|
196
|
+
const tmpPath = outputPath + '.tmp';
|
|
197
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
198
|
+
await fs.writeFile(tmpPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
199
|
+
await fs.rename(tmpPath, outputPath);
|
|
200
|
+
|
|
201
|
+
return manifest;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Debounced regeneration ──────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
207
|
+
const DEBOUNCE_MS = 500;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Schedule a manifest regeneration. Multiple calls within DEBOUNCE_MS are
|
|
211
|
+
* coalesced into a single generation. Fire-and-forget — errors are logged
|
|
212
|
+
* but never propagated.
|
|
213
|
+
*/
|
|
214
|
+
export function scheduleManifestRegeneration(api: OpenClawPluginApi): void {
|
|
215
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
216
|
+
debounceTimer = setTimeout(() => {
|
|
217
|
+
debounceTimer = null;
|
|
218
|
+
generateKitchenManifest({ api }).catch((err) => {
|
|
219
|
+
console.error('[kitchen-manifest] regeneration failed:', err instanceof Error ? err.message : String(err));
|
|
220
|
+
});
|
|
221
|
+
}, DEBOUNCE_MS);
|
|
222
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -192,7 +192,7 @@ export async function moveRunTicket(opts: {
|
|
|
192
192
|
return { ticketPath: dest };
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
export function loadNodeStatesFromRun(run: RunLog): Record<string, { status: 'success' | 'error' | 'waiting'; ts: string }> {
|
|
195
|
+
export function loadNodeStatesFromRun(run: RunLog, opts?: { workflow?: Workflow }): Record<string, { status: 'success' | 'error' | 'waiting'; ts: string }> {
|
|
196
196
|
const out: Record<string, { status: 'success' | 'error' | 'waiting'; ts: string }> = {};
|
|
197
197
|
|
|
198
198
|
const cur = run.nodeStates;
|
|
@@ -217,6 +217,19 @@ export function loadNodeStatesFromRun(run: RunLog): Record<string, { status: 'su
|
|
|
217
217
|
if (type === 'node.approved') out[nodeId] = { status: 'success', ts };
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
// Revision semantics: when a run is in needs_revision, the approval handler
|
|
221
|
+
// clears nodeStates from nextNodeIndex onward, but events are append-only.
|
|
222
|
+
// The event-based reconstruction above would re-populate states that were
|
|
223
|
+
// deliberately cleared. Remove them so the stale-task guard in the worker
|
|
224
|
+
// does not reject re-enqueued revision tasks.
|
|
225
|
+
if (run.status === 'needs_revision' && typeof run.nextNodeIndex === 'number' && opts?.workflow) {
|
|
226
|
+
const nodes = Array.isArray(opts.workflow.nodes) ? opts.workflow.nodes : [];
|
|
227
|
+
for (let i = Math.max(0, run.nextNodeIndex); i < nodes.length; i++) {
|
|
228
|
+
const id = asString(nodes[i]?.id).trim();
|
|
229
|
+
if (id) delete out[id];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
220
233
|
return out;
|
|
221
234
|
}
|
|
222
235
|
|
|
@@ -4,6 +4,7 @@ import crypto from 'node:crypto';
|
|
|
4
4
|
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
5
5
|
import type { ToolTextResult } from '../../toolsInvoke';
|
|
6
6
|
import { toolsInvoke } from '../../toolsInvoke';
|
|
7
|
+
import { classifyError, errorCategoryLabel } from './workflow-error-classify';
|
|
7
8
|
import { resolveTeamDir } from '../workspace';
|
|
8
9
|
import { getDriver } from './media-drivers/registry';
|
|
9
10
|
import { GenericDriver } from './media-drivers/generic.driver';
|
|
@@ -668,7 +669,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
668
669
|
// cursor. Before executing a dequeued task, verify that this node is still actually runnable
|
|
669
670
|
// for the current run state. Otherwise we can resurrect pre-approval work and overwrite
|
|
670
671
|
// canonical node outputs for runs that already advanced.
|
|
671
|
-
const currentNodeStates = loadNodeStatesFromRun(run);
|
|
672
|
+
const currentNodeStates = loadNodeStatesFromRun(run, { workflow });
|
|
672
673
|
const currentStatus = currentNodeStates[String(node.id)]?.status;
|
|
673
674
|
const currentlyRunnableIdx = pickNextRunnableNodeIndex({ workflow, run });
|
|
674
675
|
if (
|
|
@@ -748,9 +749,20 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
748
749
|
'workflow.id': String(workflow.id ?? ''),
|
|
749
750
|
'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
|
|
750
751
|
};
|
|
751
|
-
|
|
752
|
+
|
|
752
753
|
// Load node outputs and make them available as template variables
|
|
753
754
|
const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
|
|
755
|
+
|
|
756
|
+
// Expose triggerInput as template variables (for handoff-injected data)
|
|
757
|
+
if (runSnap.triggerInput && typeof runSnap.triggerInput === 'object') {
|
|
758
|
+
for (const [key, value] of Object.entries(runSnap.triggerInput)) {
|
|
759
|
+
if (typeof value === 'string') {
|
|
760
|
+
vars[`trigger.${key}`] = value;
|
|
761
|
+
} else if (value !== null && value !== undefined) {
|
|
762
|
+
vars[`trigger.${key}`] = JSON.stringify(value);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
754
766
|
for (const nr of (runSnap.nodeResults ?? [])) {
|
|
755
767
|
const nid = String((nr as Record<string, unknown>).nodeId ?? '');
|
|
756
768
|
const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
|
|
@@ -911,6 +923,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
911
923
|
text = JSON.stringify(payload, null, 2);
|
|
912
924
|
} catch (e) {
|
|
913
925
|
const eRec = asRecord(e);
|
|
926
|
+
const errorCategory = classifyError(e);
|
|
914
927
|
const errorDetails = {
|
|
915
928
|
message: e instanceof Error ? e.message : String(e),
|
|
916
929
|
name: e instanceof Error ? e.name : undefined,
|
|
@@ -919,6 +932,8 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
919
932
|
details: eRec['details'],
|
|
920
933
|
data: eRec['data'],
|
|
921
934
|
cause: e instanceof Error && 'cause' in e ? (e as Error & { cause?: unknown }).cause : undefined,
|
|
935
|
+
errorCategory,
|
|
936
|
+
errorCategoryLabel: errorCategory !== 'unknown' ? errorCategoryLabel(errorCategory) : undefined,
|
|
922
937
|
};
|
|
923
938
|
const errMsg = `LLM execution failed for node ${nodeLabel(node)}: ${errorDetails.message}`;
|
|
924
939
|
const errorTs = new Date().toISOString();
|
|
@@ -928,18 +943,18 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
928
943
|
updatedAt: errorTs,
|
|
929
944
|
nodeStates: {
|
|
930
945
|
...(cur.nodeStates ?? {}),
|
|
931
|
-
[node.id]: { status: 'error', ts: errorTs, error: errMsg, details: errorDetails },
|
|
946
|
+
[node.id]: { status: 'error', ts: errorTs, error: errMsg, details: errorDetails, errorCategory },
|
|
932
947
|
},
|
|
933
948
|
events: [
|
|
934
949
|
...cur.events,
|
|
935
|
-
{ ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, details: errorDetails },
|
|
950
|
+
{ ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, details: errorDetails, errorCategory },
|
|
936
951
|
],
|
|
937
952
|
nodeResults: [
|
|
938
953
|
...(cur.nodeResults ?? []),
|
|
939
|
-
{ nodeId: node.id, kind: node.kind, agentId: agentIdExec, error: errMsg, details: errorDetails },
|
|
954
|
+
{ nodeId: node.id, kind: node.kind, agentId: agentIdExec, error: errMsg, details: errorDetails, errorCategory },
|
|
940
955
|
],
|
|
941
956
|
}));
|
|
942
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error' });
|
|
957
|
+
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', errorCategory });
|
|
943
958
|
continue;
|
|
944
959
|
}
|
|
945
960
|
|
|
@@ -1254,9 +1269,20 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1254
1269
|
'workflow.id': String(workflow.id ?? ''),
|
|
1255
1270
|
'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
|
|
1256
1271
|
};
|
|
1257
|
-
|
|
1272
|
+
|
|
1258
1273
|
// Load node outputs and make them available as template variables
|
|
1259
1274
|
const { run: runSnap } = await loadRunFile(teamDir, runsDir, task.runId);
|
|
1275
|
+
|
|
1276
|
+
// Expose triggerInput as template variables (for handoff-injected data)
|
|
1277
|
+
if (runSnap.triggerInput && typeof runSnap.triggerInput === 'object') {
|
|
1278
|
+
for (const [key, value] of Object.entries(runSnap.triggerInput)) {
|
|
1279
|
+
if (typeof value === 'string') {
|
|
1280
|
+
vars[`trigger.${key}`] = value;
|
|
1281
|
+
} else if (value !== null && value !== undefined) {
|
|
1282
|
+
vars[`trigger.${key}`] = JSON.stringify(value);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1260
1286
|
for (const nr of (runSnap.nodeResults ?? [])) {
|
|
1261
1287
|
const nid = String((nr as Record<string, unknown>).nodeId ?? '');
|
|
1262
1288
|
const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
|
|
@@ -1325,10 +1351,28 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1325
1351
|
}
|
|
1326
1352
|
}
|
|
1327
1353
|
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1354
|
+
let toolRes: unknown;
|
|
1355
|
+
if (toolName === 'exec') {
|
|
1356
|
+
// Route exec tool calls through the plugin SDK runtime instead of
|
|
1357
|
+
// the gateway — the gateway exec tool is session-gated and unavailable
|
|
1358
|
+
// to most workflow worker sessions.
|
|
1359
|
+
const command = String((processedToolArgs as Record<string, unknown>).command ?? '');
|
|
1360
|
+
const workdir = String((processedToolArgs as Record<string, unknown>).workdir ?? teamDir);
|
|
1361
|
+
const timeoutSec = Number((processedToolArgs as Record<string, unknown>).timeout) || 120;
|
|
1362
|
+
const result = await api.runtime.system.runCommandWithTimeout(
|
|
1363
|
+
['bash', '-c', command],
|
|
1364
|
+
{ timeoutMs: timeoutSec * 1000, cwd: workdir },
|
|
1365
|
+
);
|
|
1366
|
+
if (result.code !== 0) {
|
|
1367
|
+
throw new Error(`exec failed (code=${result.code}):\n${result.stderr || result.stdout}`);
|
|
1368
|
+
}
|
|
1369
|
+
toolRes = { stdout: result.stdout, stderr: result.stderr, code: result.code };
|
|
1370
|
+
} else {
|
|
1371
|
+
toolRes = await toolsInvoke<unknown>(api, {
|
|
1372
|
+
tool: toolName,
|
|
1373
|
+
args: processedToolArgs,
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1332
1376
|
|
|
1333
1377
|
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: processedToolArgs, result: toolRes }, null, 2) + '\n', 'utf8');
|
|
1334
1378
|
}
|
|
@@ -1356,16 +1400,17 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1356
1400
|
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath), nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
|
|
1357
1401
|
}));
|
|
1358
1402
|
} catch (e) {
|
|
1359
|
-
|
|
1403
|
+
const errorCategory = classifyError(e);
|
|
1404
|
+
await fs.writeFile(artifactPath, JSON.stringify({ ok: false, tool: toolName, error: (e as Error).message, errorCategory }, null, 2) + '\n', 'utf8');
|
|
1360
1405
|
const errorTs = new Date().toISOString();
|
|
1361
1406
|
await appendRunLog(runPath, (cur) => ({
|
|
1362
1407
|
...cur,
|
|
1363
1408
|
status: 'error',
|
|
1364
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs } },
|
|
1365
|
-
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) }],
|
|
1366
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, error: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
1409
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, errorCategory } },
|
|
1410
|
+
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 }],
|
|
1411
|
+
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, error: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath), errorCategory }],
|
|
1367
1412
|
}));
|
|
1368
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', error: (e as Error).message });
|
|
1413
|
+
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', error: (e as Error).message, errorCategory });
|
|
1369
1414
|
continue;
|
|
1370
1415
|
}
|
|
1371
1416
|
} else if (kind === 'media-image' || kind === 'media-video' || kind === 'media-audio') {
|
|
@@ -1477,6 +1522,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1477
1522
|
let payload: Record<string, unknown>;
|
|
1478
1523
|
if (driver) {
|
|
1479
1524
|
const result = await driver.invoke({
|
|
1525
|
+
api,
|
|
1480
1526
|
prompt: refinedPrompt,
|
|
1481
1527
|
outputDir: mediaDir,
|
|
1482
1528
|
env: mergedEnv,
|
|
@@ -1503,6 +1549,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1503
1549
|
}
|
|
1504
1550
|
text = JSON.stringify(payload, null, 2);
|
|
1505
1551
|
} catch (e) {
|
|
1552
|
+
const errorCategory = classifyError(e);
|
|
1506
1553
|
const errDetails = e instanceof Error
|
|
1507
1554
|
? { message: e.message, name: e.name, stack: e.stack?.split('\n').slice(0, 5).join(' | ') }
|
|
1508
1555
|
: { message: String(e) };
|
|
@@ -1512,11 +1559,11 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1512
1559
|
...cur,
|
|
1513
1560
|
status: 'error',
|
|
1514
1561
|
updatedAt: errorTs,
|
|
1515
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, error: errMsg } },
|
|
1516
|
-
events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg }],
|
|
1517
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdMedia || agentId, error: errMsg }],
|
|
1562
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs, error: errMsg, errorCategory } },
|
|
1563
|
+
events: [...cur.events, { ts: errorTs, type: 'node.error', nodeId: node.id, kind: node.kind, message: errMsg, errorCategory }],
|
|
1564
|
+
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdMedia || agentId, error: errMsg, errorCategory }],
|
|
1518
1565
|
}));
|
|
1519
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error' });
|
|
1566
|
+
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', errorCategory });
|
|
1520
1567
|
continue;
|
|
1521
1568
|
}
|
|
1522
1569
|
|
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
|
|