@jiggai/recipes 0.4.40 → 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/workflow-utils.ts +14 -1
- package/src/lib/workflows/workflow-worker.ts +47 -7
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
|
+
}
|
|
@@ -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
|
|
|
@@ -669,7 +669,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
669
669
|
// cursor. Before executing a dequeued task, verify that this node is still actually runnable
|
|
670
670
|
// for the current run state. Otherwise we can resurrect pre-approval work and overwrite
|
|
671
671
|
// canonical node outputs for runs that already advanced.
|
|
672
|
-
const currentNodeStates = loadNodeStatesFromRun(run);
|
|
672
|
+
const currentNodeStates = loadNodeStatesFromRun(run, { workflow });
|
|
673
673
|
const currentStatus = currentNodeStates[String(node.id)]?.status;
|
|
674
674
|
const currentlyRunnableIdx = pickNextRunnableNodeIndex({ workflow, run });
|
|
675
675
|
if (
|
|
@@ -749,9 +749,20 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
749
749
|
'workflow.id': String(workflow.id ?? ''),
|
|
750
750
|
'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
|
|
751
751
|
};
|
|
752
|
-
|
|
752
|
+
|
|
753
753
|
// Load node outputs and make them available as template variables
|
|
754
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
|
+
}
|
|
755
766
|
for (const nr of (runSnap.nodeResults ?? [])) {
|
|
756
767
|
const nid = String((nr as Record<string, unknown>).nodeId ?? '');
|
|
757
768
|
const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
|
|
@@ -1258,9 +1269,20 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1258
1269
|
'workflow.id': String(workflow.id ?? ''),
|
|
1259
1270
|
'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
|
|
1260
1271
|
};
|
|
1261
|
-
|
|
1272
|
+
|
|
1262
1273
|
// Load node outputs and make them available as template variables
|
|
1263
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
|
+
}
|
|
1264
1286
|
for (const nr of (runSnap.nodeResults ?? [])) {
|
|
1265
1287
|
const nid = String((nr as Record<string, unknown>).nodeId ?? '');
|
|
1266
1288
|
const nrOutPath = String((nr as Record<string, unknown>).nodeOutputPath ?? '');
|
|
@@ -1329,10 +1351,28 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
|
1329
1351
|
}
|
|
1330
1352
|
}
|
|
1331
1353
|
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
+
}
|
|
1336
1376
|
|
|
1337
1377
|
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: processedToolArgs, result: toolRes }, null, 2) + '\n', 'utf8');
|
|
1338
1378
|
}
|