@jiggai/recipes 0.4.21 → 0.4.22
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/recipes/default/business-team.md +33 -9
- package/recipes/default/customer-support-team.md +9 -6
- package/recipes/default/marketing-team.md +6 -0
- package/recipes/default/product-team.md +11 -8
- package/recipes/default/research-team.md +9 -6
- package/recipes/default/social-team.md +27 -24
- package/recipes/default/writing-team.md +9 -6
- package/src/handlers/cron.ts +28 -19
- package/src/handlers/team.ts +46 -0
- package/src/lib/recipe-frontmatter.ts +4 -0
- package/src/lib/workflows/workflow-approvals.ts +316 -0
- package/src/lib/workflows/workflow-node-executor.ts +520 -0
- package/src/lib/workflows/workflow-node-output-readers.ts +1 -1
- package/src/lib/workflows/workflow-queue.ts +56 -8
- package/src/lib/workflows/workflow-runner.ts +43 -1934
- package/src/lib/workflows/workflow-tick.ts +196 -0
- package/src/lib/workflows/workflow-types.ts +39 -0
- package/src/lib/workflows/workflow-utils.ts +330 -0
- package/src/lib/workflows/workflow-worker.ts +586 -0
- package/src/toolsInvoke.ts +1 -1
|
@@ -3,866 +3,25 @@ import path from 'node:path';
|
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
4
|
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
5
5
|
import { resolveTeamDir } from '../workspace';
|
|
6
|
-
import type {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
return typeof v === 'string' ? v : (v == null ? fallback : String(v));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function asArray(v: unknown): unknown[] {
|
|
29
|
-
return Array.isArray(v) ? v : [];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
function normalizeWorkflow(raw: unknown): Workflow {
|
|
34
|
-
const w = asRecord(raw);
|
|
35
|
-
const id = asString(w['id']).trim();
|
|
36
|
-
if (!id) throw new Error('Workflow missing required field: id');
|
|
37
|
-
|
|
38
|
-
const meta = asRecord(w['meta']);
|
|
39
|
-
const approvalBindingId = asString(meta['approvalBindingId']).trim();
|
|
40
|
-
|
|
41
|
-
// Accept both canonical schema (node.kind/assignedTo/action/output) and ClawKitchen UI schema
|
|
42
|
-
// (node.type + node.config). Normalize into the canonical in-memory shape.
|
|
43
|
-
const nodes: WorkflowNode[] = asArray(w['nodes']).map((nRaw) => {
|
|
44
|
-
const n = asRecord(nRaw);
|
|
45
|
-
const config = asRecord(n['config']);
|
|
46
|
-
|
|
47
|
-
const kind = asString(n['kind'] ?? n['type']).trim();
|
|
48
|
-
|
|
49
|
-
const assignedToRec = asRecord(n['assignedTo']);
|
|
50
|
-
const agentId = asString(assignedToRec['agentId'] ?? config['agentId']).trim();
|
|
51
|
-
const assignedTo = agentId ? { agentId } : undefined;
|
|
52
|
-
|
|
53
|
-
const actionRaw = asRecord(n['action']);
|
|
54
|
-
const action = {
|
|
55
|
-
...actionRaw,
|
|
56
|
-
// LLM: allow either promptTemplatePath (preferred) or inline promptTemplate string
|
|
57
|
-
...(config['promptTemplate'] != null ? { promptTemplate: asString(config['promptTemplate']) } : {}),
|
|
58
|
-
...(config['promptTemplatePath'] != null ? { promptTemplatePath: asString(config['promptTemplatePath']) } : {}),
|
|
59
|
-
|
|
60
|
-
// Tool
|
|
61
|
-
...(config['tool'] != null ? { tool: asString(config['tool']) } : {}),
|
|
62
|
-
...(isRecord(config['args']) ? { args: config['args'] } : {}),
|
|
63
|
-
|
|
64
|
-
// Human approval
|
|
65
|
-
...(config['approvalBindingId'] != null ? { approvalBindingId: asString(config['approvalBindingId']) } : {}),
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
// Prefer explicit per-node approval binding, else fall back to workflow meta.approvalBindingId.
|
|
69
|
-
if (kind == 'human_approval' && !asString(action['approvalBindingId']).trim() && approvalBindingId) {
|
|
70
|
-
action['approvalBindingId'] = approvalBindingId;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
...n,
|
|
75
|
-
id: asString(n['id']).trim(),
|
|
76
|
-
kind,
|
|
77
|
-
assignedTo,
|
|
78
|
-
action,
|
|
79
|
-
// Keep config around for debugging/back-compat, but don't depend on it.
|
|
80
|
-
config,
|
|
81
|
-
} as WorkflowNode;
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
const edges: WorkflowEdge[] | undefined = Array.isArray(w['edges'])
|
|
85
|
-
? asArray(w['edges']).map((eRaw) => {
|
|
86
|
-
const e = asRecord(eRaw);
|
|
87
|
-
return {
|
|
88
|
-
...e,
|
|
89
|
-
from: asString(e['from']).trim(),
|
|
90
|
-
to: asString(e['to']).trim(),
|
|
91
|
-
on: (asString(e['on']).trim() || 'success') as WorkflowEdge['on'],
|
|
92
|
-
} as WorkflowEdge;
|
|
93
|
-
})
|
|
94
|
-
: undefined;
|
|
95
|
-
|
|
96
|
-
return { ...w, id, nodes, ...(edges ? { edges } : {}) } as Workflow;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function isoCompact(ts = new Date()) {
|
|
100
|
-
// Runner runIds appear in filenames + URLs. Keep them conservative + URL-safe.
|
|
101
|
-
// - lowercase
|
|
102
|
-
// - no ':' or '.'
|
|
103
|
-
// - avoid 'T'/'Z' uppercase markers from ISO strings
|
|
104
|
-
return ts
|
|
105
|
-
.toISOString()
|
|
106
|
-
.toLowerCase()
|
|
107
|
-
.replace(/[:.]/g, '-');
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function assertLane(lane: string): asserts lane is WorkflowLane {
|
|
111
|
-
if (lane !== 'backlog' && lane !== 'in-progress' && lane !== 'testing' && lane !== 'done') {
|
|
112
|
-
throw new Error(`Invalid lane: ${lane}`);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function ensureDir(p: string) {
|
|
117
|
-
await fs.mkdir(p, { recursive: true });
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function fileExists(p: string) {
|
|
121
|
-
try {
|
|
122
|
-
await fs.stat(p);
|
|
123
|
-
return true;
|
|
124
|
-
} catch {
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async function listTicketNumbers(teamDir: string): Promise<number[]> {
|
|
130
|
-
const workDir = path.join(teamDir, 'work');
|
|
131
|
-
const lanes = ['backlog', 'in-progress', 'testing', 'done'];
|
|
132
|
-
const nums: number[] = [];
|
|
133
|
-
|
|
134
|
-
for (const lane of lanes) {
|
|
135
|
-
const laneDir = path.join(workDir, lane);
|
|
136
|
-
if (!(await fileExists(laneDir))) continue;
|
|
137
|
-
const files = await fs.readdir(laneDir);
|
|
138
|
-
for (const f of files) {
|
|
139
|
-
const m = f.match(/^(\d{4})-/);
|
|
140
|
-
if (m) nums.push(Number(m[1]));
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return nums;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async function nextTicketNumber(teamDir: string) {
|
|
147
|
-
const nums = await listTicketNumbers(teamDir);
|
|
148
|
-
const max = nums.length ? Math.max(...nums) : 0;
|
|
149
|
-
const next = max + 1;
|
|
150
|
-
return String(next).padStart(4, '0');
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function laneToStatus(lane: WorkflowLane) {
|
|
154
|
-
if (lane === 'backlog') return 'queued';
|
|
155
|
-
if (lane === 'in-progress') return 'in-progress';
|
|
156
|
-
if (lane === 'testing') return 'testing';
|
|
157
|
-
return 'done';
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function templateReplace(input: string, vars: Record<string, string>) {
|
|
161
|
-
let out = String(input ?? '');
|
|
162
|
-
for (const [k, v] of Object.entries(vars)) {
|
|
163
|
-
out = out.replaceAll(`{{${k}}}`, v);
|
|
164
|
-
}
|
|
165
|
-
return out;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function sanitizeDraftOnlyText(text: string): string {
|
|
169
|
-
// Back-compat: older workflow nodes mention 'draft only'.
|
|
170
|
-
// New canonical sanitizer also strips other internal-only disclaimer lines.
|
|
171
|
-
return sanitizeOutboundPostText(text);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
async function moveRunTicket(opts: {
|
|
175
|
-
teamDir: string;
|
|
176
|
-
ticketPath: string;
|
|
177
|
-
toLane: WorkflowLane;
|
|
178
|
-
}): Promise<{ ticketPath: string }> {
|
|
179
|
-
const { teamDir, ticketPath, toLane } = opts;
|
|
180
|
-
const workDir = path.join(teamDir, 'work');
|
|
181
|
-
const toDir = path.join(workDir, toLane);
|
|
182
|
-
await ensureDir(toDir);
|
|
183
|
-
const file = path.basename(ticketPath);
|
|
184
|
-
const dest = path.join(toDir, file);
|
|
185
|
-
|
|
186
|
-
if (path.resolve(ticketPath) !== path.resolve(dest)) {
|
|
187
|
-
await fs.rename(ticketPath, dest);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Best-effort: update Status: line.
|
|
191
|
-
try {
|
|
192
|
-
const md = await readTextFile(dest);
|
|
193
|
-
const next = md.replace(/^Status: .*$/m, `Status: ${laneToStatus(toLane)}`);
|
|
194
|
-
if (next !== md) await fs.writeFile(dest, next, 'utf8');
|
|
195
|
-
} catch {
|
|
196
|
-
// ignore
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
return { ticketPath: dest };
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
type RunEvent = Record<string, unknown> & { ts: string; type: string };
|
|
203
|
-
|
|
204
|
-
type RunLog = {
|
|
205
|
-
runId: string;
|
|
206
|
-
createdAt: string;
|
|
207
|
-
updatedAt?: string;
|
|
208
|
-
teamId: string;
|
|
209
|
-
workflow: { file: string; id: string | null; name: string | null };
|
|
210
|
-
ticket: { file: string; number: string; lane: WorkflowLane };
|
|
211
|
-
trigger: { kind: string; at?: string };
|
|
212
|
-
status: string;
|
|
213
|
-
// Scheduler/runner fields
|
|
214
|
-
priority?: number;
|
|
215
|
-
claimedBy?: string | null;
|
|
216
|
-
claimExpiresAt?: string | null;
|
|
217
|
-
nextNodeIndex?: number;
|
|
218
|
-
// File-first workflow run state (graph-friendly)
|
|
219
|
-
nodeStates?: Record<string, { status: 'success' | 'error' | 'waiting'; ts: string; message?: string }>;
|
|
220
|
-
events: RunEvent[];
|
|
221
|
-
nodeResults?: Array<Record<string, unknown>>;
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
function loadNodeStatesFromRun(run: RunLog): Record<string, { status: 'success' | 'error' | 'waiting'; ts: string }> {
|
|
225
|
-
const out: Record<string, { status: 'success' | 'error' | 'waiting'; ts: string }> = {};
|
|
226
|
-
|
|
227
|
-
const cur = run.nodeStates;
|
|
228
|
-
if (cur) {
|
|
229
|
-
for (const [nodeId, st] of Object.entries(cur)) {
|
|
230
|
-
if (st?.status === 'success' || st?.status === 'error' || st?.status === 'waiting') {
|
|
231
|
-
out[String(nodeId)] = { status: st.status, ts: st.ts };
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
for (const evRaw of Array.isArray(run.events) ? run.events : []) {
|
|
237
|
-
const ev = asRecord(evRaw);
|
|
238
|
-
const nodeId = asString(ev['nodeId']).trim();
|
|
239
|
-
if (!nodeId) continue;
|
|
240
|
-
const ts = asString(ev['ts']) || new Date().toISOString();
|
|
241
|
-
const type = asString(ev['type']).trim();
|
|
242
|
-
|
|
243
|
-
if (type === 'node.completed') out[nodeId] = { status: 'success', ts };
|
|
244
|
-
if (type === 'node.error') out[nodeId] = { status: 'error', ts };
|
|
245
|
-
if (type === 'node.awaiting_approval') out[nodeId] = { status: 'waiting', ts };
|
|
246
|
-
if (type === 'node.approved') out[nodeId] = { status: 'success', ts };
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
return out;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function pickNextRunnableNodeIndex(opts: { workflow: Workflow; run: RunLog }): number | null {
|
|
253
|
-
const { workflow, run } = opts;
|
|
254
|
-
const nodes = Array.isArray(workflow.nodes) ? workflow.nodes : [];
|
|
255
|
-
if (!nodes.length) return null;
|
|
256
|
-
|
|
257
|
-
const hasEdges = Array.isArray(workflow.edges) && workflow.edges.length > 0;
|
|
258
|
-
if (!hasEdges) {
|
|
259
|
-
// Sequential fallback for legacy/no-edge workflows.
|
|
260
|
-
const start = typeof run.nextNodeIndex === 'number' ? run.nextNodeIndex : 0;
|
|
261
|
-
for (let i = Math.max(0, start); i < nodes.length; i++) {
|
|
262
|
-
const n = nodes[i]!;
|
|
263
|
-
const id = asString(n.id).trim();
|
|
264
|
-
if (!id) continue;
|
|
265
|
-
const st = (run.nodeStates ?? {})[id]?.status;
|
|
266
|
-
if (st === 'success' || st === 'error' || st === 'waiting') continue;
|
|
267
|
-
return i;
|
|
268
|
-
}
|
|
269
|
-
return null;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const nodeStates = loadNodeStatesFromRun(run);
|
|
273
|
-
|
|
274
|
-
// Revision semantics: if the run is in needs_revision, we intentionally allow
|
|
275
|
-
// re-execution of nodes from nextNodeIndex onward even if they previously
|
|
276
|
-
// completed in an earlier attempt. Events are append-only, so earlier
|
|
277
|
-
// node.completed events would otherwise make the graph think everything is
|
|
278
|
-
// already satisfied and incorrectly mark the run completed.
|
|
279
|
-
if (run.status === 'needs_revision' && typeof run.nextNodeIndex === 'number') {
|
|
280
|
-
for (let i = Math.max(0, run.nextNodeIndex); i < nodes.length; i++) {
|
|
281
|
-
const id = asString(nodes[i]?.id).trim();
|
|
282
|
-
if (id) delete nodeStates[id];
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const incomingEdgesByNodeId = new Map<string, WorkflowEdge[]>();
|
|
287
|
-
const edges = Array.isArray(workflow.edges) ? workflow.edges : [];
|
|
288
|
-
for (const e of edges) {
|
|
289
|
-
const to = asString(e.to).trim();
|
|
290
|
-
if (!to) continue;
|
|
291
|
-
const list = incomingEdgesByNodeId.get(to) ?? [];
|
|
292
|
-
list.push(e);
|
|
293
|
-
incomingEdgesByNodeId.set(to, list);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
function edgeSatisfied(e: WorkflowEdge): boolean {
|
|
297
|
-
const fromId = asString(e.from).trim();
|
|
298
|
-
const from = nodeStates[fromId]?.status;
|
|
299
|
-
const on = (e.on ?? 'success') as string;
|
|
300
|
-
if (!from) return false;
|
|
301
|
-
if (on === 'always') return from === 'success' || from === 'error';
|
|
302
|
-
if (on === 'error') return from === 'error';
|
|
303
|
-
return from === 'success';
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function nodeReady(node: WorkflowNode): boolean {
|
|
307
|
-
const nodeId = asString(node.id).trim();
|
|
308
|
-
if (!nodeId) return false;
|
|
309
|
-
|
|
310
|
-
const st = nodeStates[nodeId]?.status;
|
|
311
|
-
if (st === 'success' || st === 'error' || st === 'waiting') return false;
|
|
312
|
-
|
|
313
|
-
const inputFrom = node.input?.from;
|
|
314
|
-
if (Array.isArray(inputFrom) && inputFrom.length) {
|
|
315
|
-
return inputFrom.every((dep) => nodeStates[asString(dep)]?.status === 'success');
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const incoming = incomingEdgesByNodeId.get(nodeId) ?? [];
|
|
319
|
-
if (!incoming.length) return true;
|
|
320
|
-
return incoming.some(edgeSatisfied);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
for (let i = 0; i < nodes.length; i++) {
|
|
324
|
-
if (nodeReady(nodes[i]!)) return i;
|
|
325
|
-
}
|
|
326
|
-
return null;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
async function appendRunLog(runLogPath: string, fn: (cur: RunLog) => RunLog) {
|
|
330
|
-
const raw = await readTextFile(runLogPath);
|
|
331
|
-
const cur = JSON.parse(raw) as RunLog;
|
|
332
|
-
const next0 = fn(cur);
|
|
333
|
-
const next = {
|
|
334
|
-
...next0,
|
|
335
|
-
updatedAt: new Date().toISOString(),
|
|
336
|
-
};
|
|
337
|
-
await fs.writeFile(runLogPath, JSON.stringify(next, null, 2), 'utf8');
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function nodeLabel(n: WorkflowNode) {
|
|
341
|
-
return `${n.kind}:${n.id}${n.name ? ` (${n.name})` : ''}`;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
async function resolveApprovalBindingTarget(api: OpenClawPluginApi, bindingId: string): Promise<{ channel: string; target: string; accountId?: string }> {
|
|
345
|
-
const cfgObj = await loadOpenClawConfig(api);
|
|
346
|
-
const bindings = (cfgObj as { bindings?: Array<{ agentId?: string; match?: { channel?: string; accountId?: string; peer?: { id?: string } } }> }).bindings;
|
|
347
|
-
const m = Array.isArray(bindings)
|
|
348
|
-
? bindings.find((b) => String(b?.agentId ?? '') === String(bindingId) && b?.match?.channel && b?.match?.peer?.id)
|
|
349
|
-
: null;
|
|
350
|
-
if (!m?.match?.channel || !m.match.peer?.id) {
|
|
351
|
-
throw new Error(
|
|
352
|
-
`Missing approval binding: approvalBindingId=${bindingId}. Expected an openclaw config binding entry like {agentId: "${bindingId}", match: {channel, peer:{id}}}.`
|
|
353
|
-
);
|
|
354
|
-
}
|
|
355
|
-
return { channel: String(m.match.channel), target: String(m.match.peer.id), ...(m.match.accountId ? { accountId: String(m.match.accountId) } : {}) };
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// eslint-disable-next-line complexity, max-lines-per-function
|
|
359
|
-
async function executeWorkflowNodes(opts: {
|
|
360
|
-
api: OpenClawPluginApi;
|
|
361
|
-
teamId: string;
|
|
362
|
-
teamDir: string;
|
|
363
|
-
workflow: Workflow;
|
|
364
|
-
workflowPath: string;
|
|
365
|
-
workflowFile: string;
|
|
366
|
-
runId: string;
|
|
367
|
-
runLogPath: string;
|
|
368
|
-
ticketPath: string;
|
|
369
|
-
initialLane: WorkflowLane;
|
|
370
|
-
startNodeIndex?: number;
|
|
371
|
-
}): Promise<{ ticketPath: string; lane: WorkflowLane; status: 'completed' | 'awaiting_approval' | 'rejected' }> {
|
|
372
|
-
const { api, teamId, teamDir, workflow, workflowFile, runId, runLogPath } = opts;
|
|
373
|
-
|
|
374
|
-
const hasEdges = Array.isArray(workflow.edges) && workflow.edges.length > 0;
|
|
375
|
-
|
|
376
|
-
let curLane: WorkflowLane = opts.initialLane;
|
|
377
|
-
let curTicketPath = opts.ticketPath;
|
|
378
|
-
|
|
379
|
-
// Load the current run log so we can resume deterministically (approval resumes, partial runs, etc.).
|
|
380
|
-
const curRunRaw = await readTextFile(runLogPath);
|
|
381
|
-
const curRun = JSON.parse(curRunRaw) as RunLog;
|
|
382
|
-
|
|
383
|
-
const nodeIndexById = new Map<string, number>();
|
|
384
|
-
for (let i = 0; i < workflow.nodes.length; i++) nodeIndexById.set(String(workflow.nodes[i]?.id ?? ''), i);
|
|
385
|
-
|
|
386
|
-
const nodeStates = loadNodeStatesFromRun(curRun);
|
|
387
|
-
|
|
388
|
-
const incomingEdgesByNodeId = new Map<string, WorkflowEdge[]>();
|
|
389
|
-
const edges = Array.isArray(workflow.edges) ? workflow.edges : [];
|
|
390
|
-
for (const e of edges) {
|
|
391
|
-
const to = String(e?.to ?? '');
|
|
392
|
-
if (!to) continue;
|
|
393
|
-
const list = incomingEdgesByNodeId.get(to) ?? [];
|
|
394
|
-
list.push(e as WorkflowEdge);
|
|
395
|
-
incomingEdgesByNodeId.set(to, list);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function edgeSatisfied(e: WorkflowEdge): boolean {
|
|
399
|
-
const fromId = String(e.from ?? '');
|
|
400
|
-
const from = nodeStates[fromId]?.status;
|
|
401
|
-
const on = String(e.on ?? 'success');
|
|
402
|
-
if (!from) return false;
|
|
403
|
-
if (on === 'always') return from === 'success' || from === 'error';
|
|
404
|
-
if (on === 'error') return from === 'error';
|
|
405
|
-
return from === 'success';
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
function nodeReady(node: WorkflowNode): boolean {
|
|
409
|
-
const nodeId = String(node?.id ?? '');
|
|
410
|
-
if (!nodeId) return false;
|
|
411
|
-
const st = nodeStates[nodeId]?.status;
|
|
412
|
-
if (st === 'success' || st === 'error' || st === 'waiting') return false;
|
|
413
|
-
|
|
414
|
-
// Explicit input dependencies are AND semantics.
|
|
415
|
-
const inputFrom = node.input?.from;
|
|
416
|
-
if (Array.isArray(inputFrom) && inputFrom.length) {
|
|
417
|
-
return inputFrom.every((dep) => nodeStates[String(dep)]?.status === 'success');
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (!hasEdges) return true;
|
|
421
|
-
|
|
422
|
-
const incoming = incomingEdgesByNodeId.get(nodeId) ?? [];
|
|
423
|
-
if (!incoming.length) return true; // root
|
|
424
|
-
|
|
425
|
-
// Minimal semantics: OR. If any incoming edge condition is satisfied, the node can run.
|
|
426
|
-
return incoming.some(edgeSatisfied);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
function pickNextIndex(): number | null {
|
|
430
|
-
if (!hasEdges) {
|
|
431
|
-
const start = opts.startNodeIndex ?? 0;
|
|
432
|
-
for (let i = start; i < workflow.nodes.length; i++) {
|
|
433
|
-
const nodeId = String(workflow.nodes[i]?.id ?? '');
|
|
434
|
-
if (!nodeId) continue;
|
|
435
|
-
const st = nodeStates[nodeId]?.status;
|
|
436
|
-
if (st === 'success' || st === 'error' || st === 'waiting') continue;
|
|
437
|
-
return i;
|
|
438
|
-
}
|
|
439
|
-
return null;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
const ready: number[] = [];
|
|
443
|
-
for (let i = 0; i < workflow.nodes.length; i++) {
|
|
444
|
-
const n = workflow.nodes[i]!;
|
|
445
|
-
if (nodeReady(n)) ready.push(i);
|
|
446
|
-
}
|
|
447
|
-
if (!ready.length) return null;
|
|
448
|
-
ready.sort((a, b) => a - b);
|
|
449
|
-
return ready[0] ?? null;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// Execute until we either complete or hit a wait state.
|
|
453
|
-
while (true) {
|
|
454
|
-
const i = pickNextIndex();
|
|
455
|
-
if (i === null) break;
|
|
456
|
-
|
|
457
|
-
const node = workflow.nodes[i]!;
|
|
458
|
-
const ts = new Date().toISOString();
|
|
459
|
-
|
|
460
|
-
const laneRaw = node?.lane ? String(node.lane) : null;
|
|
461
|
-
if (laneRaw) {
|
|
462
|
-
if (laneRaw !== curLane) {
|
|
463
|
-
const moved = await moveRunTicket({ teamDir, ticketPath: curTicketPath, toLane: laneRaw });
|
|
464
|
-
curLane = laneRaw;
|
|
465
|
-
curTicketPath = moved.ticketPath;
|
|
466
|
-
await appendRunLog(runLogPath, (cur) => ({
|
|
467
|
-
...cur,
|
|
468
|
-
ticket: { ...cur.ticket, file: path.relative(teamDir, curTicketPath), lane: curLane },
|
|
469
|
-
events: [...cur.events, { ts, type: 'ticket.moved', lane: curLane, nodeId: node.id }],
|
|
470
|
-
}));
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
const kind = String(node.kind ?? '');
|
|
475
|
-
|
|
476
|
-
// ClawKitchen workflows include explicit start/end nodes; treat them as no-op.
|
|
477
|
-
if (kind === 'start' || kind === 'end') {
|
|
478
|
-
await appendRunLog(runLogPath, (cur) => ({
|
|
479
|
-
...cur,
|
|
480
|
-
nextNodeIndex: i + 1,
|
|
481
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts } },
|
|
482
|
-
events: [...cur.events, { ts, type: 'node.completed', nodeId: node.id, kind }],
|
|
483
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, noop: true }],
|
|
484
|
-
}));
|
|
485
|
-
nodeStates[String(node.id)] = { status: 'success', ts };
|
|
486
|
-
continue;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (kind === 'llm') {
|
|
491
|
-
const agentId = String(node?.assignedTo?.agentId ?? '');
|
|
492
|
-
const action = asRecord(node.action);
|
|
493
|
-
const promptTemplatePath = asString(action['promptTemplatePath']).trim();
|
|
494
|
-
const promptTemplateInline = asString(action['promptTemplate']).trim();
|
|
495
|
-
if (!agentId) throw new Error(`Node ${nodeLabel(node)} missing assignedTo.agentId`);
|
|
496
|
-
if (!promptTemplatePath && !promptTemplateInline) throw new Error(`Node ${nodeLabel(node)} missing action.promptTemplatePath or action.promptTemplate`);
|
|
497
|
-
|
|
498
|
-
const promptPathAbs = promptTemplatePath ? path.resolve(teamDir, promptTemplatePath) : '';
|
|
499
|
-
const runDir = path.dirname(runLogPath);
|
|
500
|
-
const defaultNodeOutputRel = path.join('node-outputs', `${String(i).padStart(3, '0')}-${node.id}.json`);
|
|
501
|
-
const nodeOutputRel = String(node?.output?.path ?? '').trim() || defaultNodeOutputRel;
|
|
502
|
-
const nodeOutputAbs = path.resolve(runDir, nodeOutputRel);
|
|
503
|
-
if (!nodeOutputAbs.startsWith(runDir + path.sep) && nodeOutputAbs !== runDir) {
|
|
504
|
-
throw new Error(`Node output.path must be within the run directory: ${nodeOutputRel}`);
|
|
505
|
-
}
|
|
506
|
-
await ensureDir(path.dirname(nodeOutputAbs));
|
|
507
|
-
|
|
508
|
-
const prompt = promptTemplateInline ? promptTemplateInline : await readTextFile(promptPathAbs);
|
|
509
|
-
const task = [
|
|
510
|
-
`You are executing a workflow run for teamId=${teamId}.`,
|
|
511
|
-
`Workflow: ${workflow.name ?? workflow.id ?? workflowFile}`,
|
|
512
|
-
`RunId: ${runId}`,
|
|
513
|
-
`Node: ${nodeLabel(node)}`,
|
|
514
|
-
`\n---\nPROMPT TEMPLATE\n---\n`,
|
|
515
|
-
prompt.trim(),
|
|
516
|
-
`\n---\nOUTPUT FORMAT\n---\n`,
|
|
517
|
-
`Return ONLY the final content (the runner will store it as JSON).`,
|
|
518
|
-
].join('\n');
|
|
519
|
-
|
|
520
|
-
// Prefer llm-task-fixed when installed; fall back to llm-task.
|
|
521
|
-
// Avoid depending on sessions_spawn (not always exposed via gateway tools/invoke).
|
|
522
|
-
let text = '';
|
|
523
|
-
try {
|
|
524
|
-
let llmRes: unknown;
|
|
525
|
-
const priorInput = await loadPriorLlmInput({ runDir, workflow, currentNode: node, currentNodeIndex: i });
|
|
526
|
-
try {
|
|
527
|
-
llmRes = await toolsInvoke<unknown>(api, {
|
|
528
|
-
tool: 'llm-task-fixed',
|
|
529
|
-
action: 'json',
|
|
530
|
-
args: {
|
|
531
|
-
prompt: task,
|
|
532
|
-
input: { teamId, runId, nodeId: node.id, agentId, ...priorInput },
|
|
533
|
-
},
|
|
534
|
-
});
|
|
535
|
-
} catch {
|
|
536
|
-
llmRes = await toolsInvoke<unknown>(api, {
|
|
537
|
-
tool: 'llm-task',
|
|
538
|
-
action: 'json',
|
|
539
|
-
args: {
|
|
540
|
-
prompt: task,
|
|
541
|
-
input: { teamId, runId, nodeId: node.id, agentId, ...priorInput },
|
|
542
|
-
},
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
const llmRec = asRecord(llmRes);
|
|
547
|
-
const details = asRecord(llmRec['details']);
|
|
548
|
-
const payload = details['json'] ?? (Object.keys(details).length ? details : llmRes) ?? null;
|
|
549
|
-
text = JSON.stringify(payload, null, 2);
|
|
550
|
-
} catch (e) {
|
|
551
|
-
throw new Error(`LLM execution failed for node ${nodeLabel(node)}: ${e instanceof Error ? e.message : String(e)}`);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
const outputObj = {
|
|
555
|
-
runId,
|
|
556
|
-
teamId,
|
|
557
|
-
nodeId: node.id,
|
|
558
|
-
kind: node.kind,
|
|
559
|
-
agentId,
|
|
560
|
-
completedAt: new Date().toISOString(),
|
|
561
|
-
text,
|
|
562
|
-
};
|
|
563
|
-
await fs.writeFile(nodeOutputAbs, JSON.stringify(outputObj, null, 2) + '\n', 'utf8');
|
|
564
|
-
|
|
565
|
-
const completedTs = new Date().toISOString();
|
|
566
|
-
await appendRunLog(runLogPath, (cur) => ({
|
|
567
|
-
...cur,
|
|
568
|
-
nextNodeIndex: i + 1,
|
|
569
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
|
|
570
|
-
events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind: node.kind, nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
|
|
571
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId, nodeOutputPath: path.relative(teamDir, nodeOutputAbs), bytes: Buffer.byteLength(text, 'utf8') }],
|
|
572
|
-
}));
|
|
573
|
-
nodeStates[String(node.id)] = { status: 'success', ts: completedTs };
|
|
574
|
-
|
|
575
|
-
continue;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
if (kind === 'human_approval') {
|
|
579
|
-
const agentId = String(node?.assignedTo?.agentId ?? '');
|
|
580
|
-
const approvalBindingId = String(node?.action?.approvalBindingId ?? '');
|
|
581
|
-
if (!agentId) throw new Error(`Node ${nodeLabel(node)} missing assignedTo.agentId`);
|
|
582
|
-
if (!approvalBindingId) throw new Error(`Node ${nodeLabel(node)} missing action.approvalBindingId`);
|
|
583
|
-
|
|
584
|
-
const { channel, target, accountId } = await resolveApprovalBindingTarget(api, approvalBindingId);
|
|
585
|
-
|
|
586
|
-
// Write a durable approval request file (runner can resume later via CLI).
|
|
587
|
-
// n8n-inspired: approvals live inside the run folder.
|
|
588
|
-
const runDir = path.dirname(runLogPath);
|
|
589
|
-
const approvalsDir = path.join(runDir, 'approvals');
|
|
590
|
-
await ensureDir(approvalsDir);
|
|
591
|
-
const approvalPath = path.join(approvalsDir, 'approval.json');
|
|
592
|
-
const approvalObj = {
|
|
593
|
-
runId,
|
|
594
|
-
teamId,
|
|
595
|
-
workflowFile,
|
|
596
|
-
nodeId: node.id,
|
|
597
|
-
bindingId: approvalBindingId,
|
|
598
|
-
requestedAt: new Date().toISOString(),
|
|
599
|
-
status: 'pending',
|
|
600
|
-
ticket: path.relative(teamDir, curTicketPath),
|
|
601
|
-
runLog: path.relative(teamDir, runLogPath),
|
|
602
|
-
};
|
|
603
|
-
await fs.writeFile(approvalPath, JSON.stringify(approvalObj, null, 2), 'utf8');
|
|
604
|
-
|
|
605
|
-
// Include the proposed post text in the approval request (what will actually be posted).
|
|
606
|
-
const nodeOutputsDir = path.join(runDir, 'node-outputs');
|
|
607
|
-
let proposed = '';
|
|
608
|
-
try {
|
|
609
|
-
// Heuristic: use qc_brand output if present (finalized drafts), otherwise use the immediately prior node.
|
|
610
|
-
const qcId = 'qc_brand';
|
|
611
|
-
const hasQc = (await fileExists(nodeOutputsDir)) && (await fs.readdir(nodeOutputsDir)).some((f) => f.endsWith(`-${qcId}.json`));
|
|
612
|
-
const priorId = hasQc ? qcId : String(workflow.nodes?.[Math.max(0, i - 1)]?.id ?? '');
|
|
613
|
-
if (priorId) proposed = await loadProposedPostTextFromPriorNode({ runDir, nodeOutputsDir, priorNodeId: priorId });
|
|
614
|
-
} catch {
|
|
615
|
-
proposed = '';
|
|
616
|
-
}
|
|
617
|
-
proposed = sanitizeDraftOnlyText(proposed);
|
|
618
|
-
|
|
619
|
-
const msg = [
|
|
620
|
-
`Approval requested for workflow run: ${workflow.name ?? workflow.id ?? workflowFile}`,
|
|
621
|
-
`RunId: ${runId}`,
|
|
622
|
-
`Node: ${node.name ?? node.id}`,
|
|
623
|
-
`Ticket: ${path.relative(teamDir, curTicketPath)}`,
|
|
624
|
-
`Run log: ${path.relative(teamDir, runLogPath)}`,
|
|
625
|
-
`Approval file: ${path.relative(teamDir, approvalPath)}`,
|
|
626
|
-
proposed ? `\n---\nPROPOSED POST (X)\n---\n${proposed}` : `\n(Warning: no proposed text found to preview)`,
|
|
627
|
-
`\nTo approve/reject:`,
|
|
628
|
-
`- approve ${String(approvalObj['code'] ?? '').trim() || '(code in approval file)'}`,
|
|
629
|
-
`- decline ${String(approvalObj['code'] ?? '').trim() || '(code in approval file)'}`,
|
|
630
|
-
`\n(Or via CLI)`,
|
|
631
|
-
`- openclaw recipes workflows approve --team-id ${teamId} --run-id ${runId} --approved true`,
|
|
632
|
-
`- openclaw recipes workflows approve --team-id ${teamId} --run-id ${runId} --approved false --note "<what to change>"`,
|
|
633
|
-
`Then resume:`,
|
|
634
|
-
`- openclaw recipes workflows resume --team-id ${teamId} --run-id ${runId}`,
|
|
635
|
-
].join('\n');
|
|
636
|
-
|
|
637
|
-
await toolsInvoke<ToolTextResult>(api, {
|
|
638
|
-
tool: 'message',
|
|
639
|
-
args: {
|
|
640
|
-
action: 'send',
|
|
641
|
-
channel,
|
|
642
|
-
target,
|
|
643
|
-
...(accountId ? { accountId } : {}),
|
|
644
|
-
message: msg,
|
|
645
|
-
},
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
const waitingTs = new Date().toISOString();
|
|
649
|
-
await appendRunLog(runLogPath, (cur) => ({
|
|
650
|
-
...cur,
|
|
651
|
-
status: 'awaiting_approval',
|
|
652
|
-
nextNodeIndex: i + 1,
|
|
653
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'waiting', ts: waitingTs } },
|
|
654
|
-
events: [...cur.events, { ts: waitingTs, type: 'node.awaiting_approval', nodeId: node.id, bindingId: approvalBindingId, approvalFile: path.relative(teamDir, approvalPath) }],
|
|
655
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, approvalBindingId, approvalFile: path.relative(teamDir, approvalPath) }],
|
|
656
|
-
}));
|
|
657
|
-
|
|
658
|
-
nodeStates[String(node.id)] = { status: 'waiting', ts: waitingTs };
|
|
659
|
-
return { ticketPath: curTicketPath, lane: curLane, status: 'awaiting_approval' };
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
if (kind === 'writeback') {
|
|
663
|
-
const agentId = String(node?.assignedTo?.agentId ?? '');
|
|
664
|
-
const writebackPaths = Array.isArray(node?.action?.writebackPaths) ? node.action.writebackPaths.map(String) : [];
|
|
665
|
-
if (!agentId) throw new Error(`Node ${nodeLabel(node)} missing assignedTo.agentId`);
|
|
666
|
-
if (!writebackPaths.length) throw new Error(`Node ${nodeLabel(node)} missing action.writebackPaths[]`);
|
|
667
|
-
|
|
668
|
-
const stamp = `\n\n---\nWorkflow writeback (${runId}) @ ${new Date().toISOString()}\n---\n`;
|
|
669
|
-
const content = `${stamp}Run log: ${path.relative(teamDir, runLogPath)}\nTicket: ${path.relative(teamDir, curTicketPath)}\n`;
|
|
670
|
-
|
|
671
|
-
for (const p of writebackPaths) {
|
|
672
|
-
const abs = path.resolve(teamDir, p);
|
|
673
|
-
await ensureDir(path.dirname(abs));
|
|
674
|
-
const prev = (await fileExists(abs)) ? await readTextFile(abs) : '';
|
|
675
|
-
await fs.writeFile(abs, prev + content, 'utf8');
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
const completedTs = new Date().toISOString();
|
|
679
|
-
await appendRunLog(runLogPath, (cur) => ({
|
|
680
|
-
...cur,
|
|
681
|
-
nextNodeIndex: i + 1,
|
|
682
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
|
|
683
|
-
events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind: node.kind, writebackPaths }],
|
|
684
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, writebackPaths }],
|
|
685
|
-
}));
|
|
686
|
-
nodeStates[String(node.id)] = { status: 'success', ts: completedTs };
|
|
687
|
-
|
|
688
|
-
continue;
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
if (kind === 'tool') {
|
|
692
|
-
const toolName = String(node?.action?.tool ?? '');
|
|
693
|
-
const toolArgs = (node?.action?.args ?? {}) as Record<string, unknown>;
|
|
694
|
-
if (!toolName) throw new Error(`Node ${nodeLabel(node)} missing action.tool`);
|
|
695
|
-
|
|
696
|
-
const runDir = path.dirname(runLogPath);
|
|
697
|
-
const artifactsDir = path.join(runDir, 'artifacts');
|
|
698
|
-
await ensureDir(artifactsDir);
|
|
699
|
-
const artifactPath = path.join(artifactsDir, `${String(i).padStart(3, '0')}-${node.id}.tool.json`);
|
|
700
|
-
|
|
701
|
-
const vars = {
|
|
702
|
-
date: new Date().toISOString(),
|
|
703
|
-
'run.id': runId,
|
|
704
|
-
'workflow.id': String(workflow.id ?? ''),
|
|
705
|
-
'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
|
|
706
|
-
};
|
|
707
|
-
|
|
708
|
-
try {
|
|
709
|
-
// Runner-native tools (preferred): do NOT depend on gateway tool exposure.
|
|
710
|
-
if (toolName === 'fs.append') {
|
|
711
|
-
const relPathRaw = String(toolArgs.path ?? '').trim();
|
|
712
|
-
const contentRaw = String(toolArgs.content ?? '');
|
|
713
|
-
if (!relPathRaw) throw new Error('fs.append requires args.path');
|
|
714
|
-
if (!contentRaw) throw new Error('fs.append requires args.content');
|
|
715
|
-
|
|
716
|
-
const relPath = templateReplace(relPathRaw, vars);
|
|
717
|
-
const abs = path.resolve(teamDir, relPath);
|
|
718
|
-
if (!abs.startsWith(teamDir + path.sep) && abs !== teamDir) {
|
|
719
|
-
throw new Error('fs.append path must be within the team workspace');
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
await ensureDir(path.dirname(abs));
|
|
723
|
-
const content = templateReplace(contentRaw, vars);
|
|
724
|
-
await fs.appendFile(abs, content, 'utf8');
|
|
725
|
-
|
|
726
|
-
const result = { appendedTo: path.relative(teamDir, abs), bytes: Buffer.byteLength(content, 'utf8') };
|
|
727
|
-
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2), 'utf8');
|
|
728
|
-
|
|
729
|
-
const completedTs = new Date().toISOString();
|
|
730
|
-
await appendRunLog(runLogPath, (cur) => ({
|
|
731
|
-
...cur,
|
|
732
|
-
nextNodeIndex: i + 1,
|
|
733
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
|
|
734
|
-
events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
735
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
736
|
-
}));
|
|
737
|
-
nodeStates[String(node.id)] = { status: 'success', ts: completedTs };
|
|
738
|
-
|
|
739
|
-
continue;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
if (toolName === 'outbound.post') {
|
|
745
|
-
// Outbound posting (local-first v0.1): publish via an external HTTP service.
|
|
746
|
-
// IMPORTANT: this runner-native tool intentionally does NOT read draft text from disk.
|
|
747
|
-
// Provide `args.text` directly from upstream LLM nodes, and (optionally) an approval receipt.
|
|
748
|
-
const pluginCfg = asRecord(asRecord(api)['pluginConfig']);
|
|
749
|
-
const outboundCfg = asRecord(pluginCfg['outbound']);
|
|
750
|
-
|
|
751
|
-
const baseUrl = String(outboundCfg['baseUrl'] ?? '').trim();
|
|
752
|
-
const apiKey = String(outboundCfg['apiKey'] ?? '').trim();
|
|
753
|
-
if (!baseUrl) throw new Error('outbound.post requires plugin config outbound.baseUrl');
|
|
754
|
-
if (!apiKey) throw new Error('outbound.post requires plugin config outbound.apiKey');
|
|
755
|
-
const platform = String(toolArgs.platform ?? '').trim();
|
|
756
|
-
const textRaw = String(toolArgs.text ?? '');
|
|
757
|
-
const text = sanitizeOutboundPostText(textRaw);
|
|
758
|
-
const idempotencyKey = String(toolArgs.idempotencyKey ?? `${task.runId}:${node.id}`).trim();
|
|
759
|
-
const runContext = asRecord(toolArgs.runContext);
|
|
760
|
-
const approval = toolArgs.approval ? asRecord(toolArgs.approval) : undefined;
|
|
761
|
-
const media = Array.isArray(toolArgs.media) ? toolArgs.media : undefined;
|
|
762
|
-
const dryRun = toolArgs.dryRun === true;
|
|
763
|
-
|
|
764
|
-
if (!platform) throw new Error('outbound.post requires args.platform');
|
|
765
|
-
if (!text) throw new Error('outbound.post requires args.text');
|
|
766
|
-
if (!idempotencyKey) throw new Error('outbound.post requires args.idempotencyKey');
|
|
767
|
-
|
|
768
|
-
const workflowId = String(workflow.id ?? '');
|
|
769
|
-
|
|
770
|
-
const result = await outboundPublish({
|
|
771
|
-
baseUrl,
|
|
772
|
-
apiKey,
|
|
773
|
-
platform: platform as OutboundPlatform,
|
|
774
|
-
idempotencyKey,
|
|
775
|
-
request: {
|
|
776
|
-
text,
|
|
777
|
-
media: media as unknown as OutboundMedia[],
|
|
778
|
-
runContext: {
|
|
779
|
-
teamId: String(runContext.teamId ?? ''),
|
|
780
|
-
workflowId: String(runContext.workflowId ?? workflowId),
|
|
781
|
-
workflowRunId: String(runContext.workflowRunId ?? task.runId),
|
|
782
|
-
nodeId: String(runContext.nodeId ?? node.id),
|
|
783
|
-
ticketPath: typeof runContext.ticketPath === 'string' ? runContext.ticketPath : undefined,
|
|
784
|
-
},
|
|
785
|
-
approval: approval as unknown as OutboundApproval,
|
|
786
|
-
dryRun,
|
|
787
|
-
},
|
|
788
|
-
});
|
|
789
|
-
|
|
790
|
-
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2), 'utf8');
|
|
791
|
-
|
|
792
|
-
const completedTs = new Date().toISOString();
|
|
793
|
-
await appendRunLog(runLogPath, (cur) => ({
|
|
794
|
-
...cur,
|
|
795
|
-
nextNodeIndex: i + 1,
|
|
796
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
|
|
797
|
-
events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
798
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
799
|
-
}));
|
|
800
|
-
nodeStates[String(node.id)] = { status: 'success', ts: completedTs };
|
|
801
|
-
|
|
802
|
-
continue;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
// Fallback: attempt to invoke a gateway tool by name.
|
|
806
|
-
const result = await toolsInvoke(api, { tool: toolName, args: toolArgs });
|
|
807
|
-
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2), 'utf8');
|
|
808
|
-
|
|
809
|
-
const completedTs = new Date().toISOString();
|
|
810
|
-
await appendRunLog(runLogPath, (cur) => ({
|
|
811
|
-
...cur,
|
|
812
|
-
nextNodeIndex: i + 1,
|
|
813
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
|
|
814
|
-
events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
815
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
816
|
-
}));
|
|
817
|
-
nodeStates[String(node.id)] = { status: 'success', ts: completedTs };
|
|
818
|
-
|
|
819
|
-
continue;
|
|
820
|
-
} catch (e) {
|
|
821
|
-
await fs.writeFile(artifactPath, JSON.stringify({ ok: false, tool: toolName, args: toolArgs, error: (e as Error).message }, null, 2), 'utf8');
|
|
822
|
-
const errTs = new Date().toISOString();
|
|
823
|
-
await appendRunLog(runLogPath, (cur) => ({
|
|
824
|
-
...cur,
|
|
825
|
-
nextNodeIndex: i + 1,
|
|
826
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errTs, message: (e as Error).message } },
|
|
827
|
-
events: [...cur.events, { ts: errTs, type: 'node.error', nodeId: node.id, kind, tool: toolName, message: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
828
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, tool: toolName, error: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
829
|
-
}));
|
|
830
|
-
nodeStates[String(node.id)] = { status: 'error', ts: errTs };
|
|
831
|
-
throw e;
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
throw new Error(`Unsupported node kind: ${node.kind} (${nodeLabel(node)})`);
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
await appendRunLog(runLogPath, (cur) => ({
|
|
839
|
-
...cur,
|
|
840
|
-
status: 'completed',
|
|
841
|
-
events: [...cur.events, { ts: new Date().toISOString(), type: 'run.completed', lane: curLane }],
|
|
842
|
-
}));
|
|
843
|
-
|
|
844
|
-
return { ticketPath: curTicketPath, lane: curLane, status: 'completed' };
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
function runFilePathFor(runsDir: string, runId: string) {
|
|
849
|
-
// File-first: one directory per run.
|
|
850
|
-
return path.join(runsDir, runId, 'run.json');
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
async function loadRunFile(teamDir: string, runsDir: string, runId: string): Promise<{ path: string; run: RunLog }> {
|
|
854
|
-
const runPath = runFilePathFor(runsDir, runId);
|
|
855
|
-
if (!(await fileExists(runPath))) throw new Error(`Run file not found: ${path.relative(teamDir, runPath)}`);
|
|
856
|
-
const raw = await readTextFile(runPath);
|
|
857
|
-
return { path: runPath, run: JSON.parse(raw) as RunLog };
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
async function writeRunFile(runPath: string, fn: (cur: RunLog) => RunLog) {
|
|
861
|
-
const raw = await readTextFile(runPath);
|
|
862
|
-
const cur = JSON.parse(raw) as RunLog;
|
|
863
|
-
const next = fn(cur);
|
|
864
|
-
await fs.writeFile(runPath, JSON.stringify(next, null, 2), 'utf8');
|
|
865
|
-
}
|
|
6
|
+
import type { WorkflowLane, RunLog } from './workflow-types';
|
|
7
|
+
import { enqueueTask } from './workflow-queue';
|
|
8
|
+
import { readTextFile } from './workflow-runner-io';
|
|
9
|
+
import {
|
|
10
|
+
normalizeWorkflow,
|
|
11
|
+
isoCompact, assertLane,
|
|
12
|
+
ensureDir, fileExists,
|
|
13
|
+
nextTicketNumber, laneToStatus,
|
|
14
|
+
appendRunLog, writeRunFile, loadRunFile,
|
|
15
|
+
pickNextRunnableNodeIndex,
|
|
16
|
+
} from './workflow-utils';
|
|
17
|
+
import { executeWorkflowNodes } from './workflow-node-executor';
|
|
18
|
+
|
|
19
|
+
// Re-export all decomposed modules so existing consumers don't break.
|
|
20
|
+
export * from './workflow-utils';
|
|
21
|
+
export * from './workflow-node-executor';
|
|
22
|
+
export * from './workflow-worker';
|
|
23
|
+
export * from './workflow-tick';
|
|
24
|
+
export * from './workflow-approvals';
|
|
866
25
|
|
|
867
26
|
export async function enqueueWorkflowRun(api: OpenClawPluginApi, opts: {
|
|
868
27
|
teamId: string;
|
|
@@ -992,7 +151,7 @@ export async function runWorkflowRunnerOnce(api: OpenClawPluginApi, opts: {
|
|
|
992
151
|
const p = path.join(abs, 'run.json');
|
|
993
152
|
if (await fileExists(p)) runPath = p;
|
|
994
153
|
}
|
|
995
|
-
} catch {
|
|
154
|
+
} catch { // intentional: best-effort directory traversal
|
|
996
155
|
// ignore
|
|
997
156
|
}
|
|
998
157
|
|
|
@@ -1005,7 +164,7 @@ export async function runWorkflowRunnerOnce(api: OpenClawPluginApi, opts: {
|
|
|
1005
164
|
const claimed = !!run.claimedBy && exp > now;
|
|
1006
165
|
if (claimed) continue;
|
|
1007
166
|
candidates.push({ file: runPath, run });
|
|
1008
|
-
} catch {
|
|
167
|
+
} catch { // intentional: skip malformed run.json
|
|
1009
168
|
// ignore parse errors
|
|
1010
169
|
}
|
|
1011
170
|
}
|
|
@@ -1112,222 +271,40 @@ export async function runWorkflowRunnerOnce(api: OpenClawPluginApi, opts: {
|
|
|
1112
271
|
}
|
|
1113
272
|
}
|
|
1114
273
|
|
|
1115
|
-
|
|
1116
|
-
export async function
|
|
274
|
+
// eslint-disable-next-line complexity, max-lines-per-function
|
|
275
|
+
export async function runWorkflowOnce(api: OpenClawPluginApi, opts: {
|
|
1117
276
|
teamId: string;
|
|
1118
|
-
|
|
1119
|
-
|
|
277
|
+
workflowFile: string; // filename under shared-context/workflows/
|
|
278
|
+
trigger?: { kind: string; at?: string };
|
|
1120
279
|
}) {
|
|
1121
280
|
const teamId = String(opts.teamId);
|
|
1122
281
|
const teamDir = resolveTeamDir(api, teamId);
|
|
1123
282
|
const sharedContextDir = path.join(teamDir, 'shared-context');
|
|
1124
|
-
const runsDir = path.join(sharedContextDir, 'workflow-runs');
|
|
1125
283
|
const workflowsDir = path.join(sharedContextDir, 'workflows');
|
|
284
|
+
const runsDir = path.join(sharedContextDir, 'workflow-runs');
|
|
1126
285
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
286
|
+
const workflowPath = path.join(workflowsDir, opts.workflowFile);
|
|
287
|
+
const raw = await readTextFile(workflowPath);
|
|
288
|
+
const workflow = normalizeWorkflow(JSON.parse(raw));
|
|
1130
289
|
|
|
1131
|
-
|
|
1132
|
-
const leaseSeconds = typeof opts.leaseSeconds === 'number' && opts.leaseSeconds > 0 ? opts.leaseSeconds : 300;
|
|
1133
|
-
const now = Date.now();
|
|
290
|
+
if (!workflow.nodes?.length) throw new Error('Workflow has no nodes');
|
|
1134
291
|
|
|
1135
|
-
|
|
1136
|
-
const
|
|
292
|
+
// Determine initial lane from first node that declares lane.
|
|
293
|
+
const firstLaneRaw = String(workflow.nodes.find(n => n?.config && typeof n.config === 'object' && 'lane' in n.config)?.config?.lane ?? 'backlog');
|
|
294
|
+
assertLane(firstLaneRaw);
|
|
295
|
+
const initialLane: WorkflowLane = firstLaneRaw;
|
|
1137
296
|
|
|
1138
|
-
|
|
1139
|
-
|
|
297
|
+
const runId = `${isoCompact()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
298
|
+
await ensureDir(runsDir);
|
|
299
|
+
const runLogPath = path.join(runsDir, `${runId}.json`);
|
|
1140
300
|
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
if (st.isDirectory()) {
|
|
1145
|
-
const p = path.join(abs, 'run.json');
|
|
1146
|
-
if (await fileExists(p)) runPath = p;
|
|
1147
|
-
}
|
|
1148
|
-
} catch {
|
|
1149
|
-
// ignore
|
|
1150
|
-
}
|
|
301
|
+
const ticketNum = await nextTicketNumber(teamDir);
|
|
302
|
+
const slug = `workflow-run-${(workflow.id ?? path.basename(opts.workflowFile, path.extname(opts.workflowFile))).replace(/[^a-z0-9-]+/gi, '-').toLowerCase()}`;
|
|
303
|
+
const ticketFile = `${ticketNum}-${slug}.md`;
|
|
1151
304
|
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
const run = JSON.parse(await readTextFile(runPath)) as RunLog;
|
|
1156
|
-
if (run.status !== 'queued') continue;
|
|
1157
|
-
const exp = run.claimExpiresAt ? Date.parse(String(run.claimExpiresAt)) : 0;
|
|
1158
|
-
const claimed = !!run.claimedBy && exp > now;
|
|
1159
|
-
if (claimed) continue;
|
|
1160
|
-
candidates.push({ file: runPath, run });
|
|
1161
|
-
} catch {
|
|
1162
|
-
// ignore parse errors
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
if (!candidates.length) {
|
|
1167
|
-
return { ok: true as const, teamId, claimed: 0, message: 'No queued runs available.' };
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
|
-
candidates.sort((a, b) => {
|
|
1171
|
-
const pa = typeof a.run.priority === 'number' ? a.run.priority : 0;
|
|
1172
|
-
const pb = typeof b.run.priority === 'number' ? b.run.priority : 0;
|
|
1173
|
-
if (pa !== pb) return pb - pa;
|
|
1174
|
-
return String(a.run.createdAt).localeCompare(String(b.run.createdAt));
|
|
1175
|
-
});
|
|
1176
|
-
|
|
1177
|
-
const runnerIdBase = `workflow-runner:${process.pid}`;
|
|
1178
|
-
|
|
1179
|
-
async function tryClaim(runPath: string): Promise<RunLog | null> {
|
|
1180
|
-
const raw = await readTextFile(runPath);
|
|
1181
|
-
const cur = JSON.parse(raw) as RunLog;
|
|
1182
|
-
if (cur.status !== 'queued') return null;
|
|
1183
|
-
const exp = cur.claimExpiresAt ? Date.parse(String(cur.claimExpiresAt)) : 0;
|
|
1184
|
-
const claimed = !!cur.claimedBy && exp > Date.now();
|
|
1185
|
-
if (claimed) return null;
|
|
1186
|
-
|
|
1187
|
-
const claimExpiresAt = new Date(Date.now() + leaseSeconds * 1000).toISOString();
|
|
1188
|
-
const claimedBy = `${runnerIdBase}:${crypto.randomBytes(3).toString('hex')}`;
|
|
1189
|
-
|
|
1190
|
-
const next: RunLog = {
|
|
1191
|
-
...cur,
|
|
1192
|
-
updatedAt: new Date().toISOString(),
|
|
1193
|
-
status: 'running',
|
|
1194
|
-
claimedBy,
|
|
1195
|
-
claimExpiresAt,
|
|
1196
|
-
events: [...(cur.events ?? []), { ts: new Date().toISOString(), type: 'run.claimed', claimedBy, claimExpiresAt }],
|
|
1197
|
-
};
|
|
1198
|
-
|
|
1199
|
-
await fs.writeFile(runPath, JSON.stringify(next, null, 2), 'utf8');
|
|
1200
|
-
return next;
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
const claimed: Array<{ file: string; run: RunLog }> = [];
|
|
1204
|
-
for (const c of candidates) {
|
|
1205
|
-
if (claimed.length >= concurrency) break;
|
|
1206
|
-
const run = await tryClaim(c.file);
|
|
1207
|
-
if (run) claimed.push({ file: c.file, run });
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
if (!claimed.length) {
|
|
1211
|
-
return { ok: true as const, teamId, claimed: 0, message: 'No queued runs available (raced on claim).' };
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
async function execClaimed(runPath: string, run: RunLog) {
|
|
1215
|
-
const workflowFile = String(run.workflow.file);
|
|
1216
|
-
const workflowPath = path.join(workflowsDir, workflowFile);
|
|
1217
|
-
const workflowRaw = await readTextFile(workflowPath);
|
|
1218
|
-
const workflow = normalizeWorkflow(JSON.parse(workflowRaw));
|
|
1219
|
-
|
|
1220
|
-
try {
|
|
1221
|
-
// Scheduler-only: do NOT execute nodes directly here.
|
|
1222
|
-
// Instead, enqueue the next runnable node onto the assigned agent's pull queue.
|
|
1223
|
-
// Graph-aware: if workflow.edges exist, choose the next runnable node by edge conditions.
|
|
1224
|
-
|
|
1225
|
-
let runCur = (await loadRunFile(teamDir, runsDir, run.runId)).run;
|
|
1226
|
-
let idx = pickNextRunnableNodeIndex({ workflow, run: runCur });
|
|
1227
|
-
|
|
1228
|
-
// Auto-complete start/end nodes.
|
|
1229
|
-
while (idx !== null) {
|
|
1230
|
-
const n = workflow.nodes[idx]!;
|
|
1231
|
-
const k = String(n.kind ?? '');
|
|
1232
|
-
if (k !== 'start' && k !== 'end') break;
|
|
1233
|
-
const ts = new Date().toISOString();
|
|
1234
|
-
await appendRunLog(runPath, (cur) => ({
|
|
1235
|
-
...cur,
|
|
1236
|
-
nextNodeIndex: idx! + 1,
|
|
1237
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [n.id]: { status: 'success', ts } },
|
|
1238
|
-
events: [...cur.events, { ts, type: 'node.completed', nodeId: n.id, kind: k, noop: true }],
|
|
1239
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: n.id, kind: k, noop: true }],
|
|
1240
|
-
}));
|
|
1241
|
-
runCur = (await loadRunFile(teamDir, runsDir, run.runId)).run;
|
|
1242
|
-
idx = pickNextRunnableNodeIndex({ workflow, run: runCur });
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
if (idx === null) {
|
|
1246
|
-
await writeRunFile(runPath, (cur) => ({
|
|
1247
|
-
...cur,
|
|
1248
|
-
updatedAt: new Date().toISOString(),
|
|
1249
|
-
status: 'completed',
|
|
1250
|
-
claimedBy: null,
|
|
1251
|
-
claimExpiresAt: null,
|
|
1252
|
-
nextNodeIndex: cur.nextNodeIndex,
|
|
1253
|
-
events: [...cur.events, { ts: new Date().toISOString(), type: 'run.completed' }],
|
|
1254
|
-
}));
|
|
1255
|
-
return { runId: run.runId, status: 'completed' };
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
const node = workflow.nodes[idx]!;
|
|
1259
|
-
const assignedAgentId = String(node?.assignedTo?.agentId ?? '').trim();
|
|
1260
|
-
if (!assignedAgentId) throw new Error(`Node ${node.id} missing assignedTo.agentId (required for pull-based execution)`);
|
|
1261
|
-
|
|
1262
|
-
await enqueueTask(teamDir, assignedAgentId, {
|
|
1263
|
-
teamId,
|
|
1264
|
-
runId: run.runId,
|
|
1265
|
-
nodeId: node.id,
|
|
1266
|
-
kind: 'execute_node',
|
|
1267
|
-
});
|
|
1268
|
-
|
|
1269
|
-
await writeRunFile(runPath, (cur) => ({
|
|
1270
|
-
...cur,
|
|
1271
|
-
updatedAt: new Date().toISOString(),
|
|
1272
|
-
status: 'waiting_workers',
|
|
1273
|
-
claimedBy: null,
|
|
1274
|
-
claimExpiresAt: null,
|
|
1275
|
-
nextNodeIndex: idx,
|
|
1276
|
-
events: [...cur.events, { ts: new Date().toISOString(), type: 'node.enqueued', nodeId: node.id, agentId: assignedAgentId }],
|
|
1277
|
-
}));
|
|
1278
|
-
|
|
1279
|
-
return { runId: run.runId, status: 'waiting_workers' };
|
|
1280
|
-
} catch (e) {
|
|
1281
|
-
await writeRunFile(runPath, (cur) => ({
|
|
1282
|
-
...cur,
|
|
1283
|
-
updatedAt: new Date().toISOString(),
|
|
1284
|
-
status: 'error',
|
|
1285
|
-
claimedBy: null,
|
|
1286
|
-
claimExpiresAt: null,
|
|
1287
|
-
events: [...cur.events, { ts: new Date().toISOString(), type: 'run.error', message: (e as Error).message }],
|
|
1288
|
-
}));
|
|
1289
|
-
return { runId: run.runId, status: 'error', error: (e as Error).message };
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
const results = await Promise.all(claimed.map((c) => execClaimed(c.file, c.run)));
|
|
1294
|
-
return { ok: true as const, teamId, claimed: claimed.length, results };
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
// eslint-disable-next-line complexity, max-lines-per-function
|
|
1298
|
-
export async function runWorkflowOnce(api: OpenClawPluginApi, opts: {
|
|
1299
|
-
teamId: string;
|
|
1300
|
-
workflowFile: string; // filename under shared-context/workflows/
|
|
1301
|
-
trigger?: { kind: string; at?: string };
|
|
1302
|
-
}) {
|
|
1303
|
-
const teamId = String(opts.teamId);
|
|
1304
|
-
const teamDir = resolveTeamDir(api, teamId);
|
|
1305
|
-
const sharedContextDir = path.join(teamDir, 'shared-context');
|
|
1306
|
-
const workflowsDir = path.join(sharedContextDir, 'workflows');
|
|
1307
|
-
const runsDir = path.join(sharedContextDir, 'workflow-runs');
|
|
1308
|
-
|
|
1309
|
-
const workflowPath = path.join(workflowsDir, opts.workflowFile);
|
|
1310
|
-
const raw = await readTextFile(workflowPath);
|
|
1311
|
-
const workflow = normalizeWorkflow(JSON.parse(raw));
|
|
1312
|
-
|
|
1313
|
-
if (!workflow.nodes?.length) throw new Error('Workflow has no nodes');
|
|
1314
|
-
|
|
1315
|
-
// Determine initial lane from first node that declares lane.
|
|
1316
|
-
const firstLaneRaw = String(workflow.nodes.find(n => n?.config && typeof n.config === 'object' && 'lane' in n.config)?.config?.lane ?? 'backlog');
|
|
1317
|
-
assertLane(firstLaneRaw);
|
|
1318
|
-
const initialLane: WorkflowLane = firstLaneRaw;
|
|
1319
|
-
|
|
1320
|
-
const runId = `${isoCompact()}-${crypto.randomBytes(4).toString('hex')}`;
|
|
1321
|
-
await ensureDir(runsDir);
|
|
1322
|
-
const runLogPath = path.join(runsDir, `${runId}.json`);
|
|
1323
|
-
|
|
1324
|
-
const ticketNum = await nextTicketNumber(teamDir);
|
|
1325
|
-
const slug = `workflow-run-${(workflow.id ?? path.basename(opts.workflowFile, path.extname(opts.workflowFile))).replace(/[^a-z0-9-]+/gi, '-').toLowerCase()}`;
|
|
1326
|
-
const ticketFile = `${ticketNum}-${slug}.md`;
|
|
1327
|
-
|
|
1328
|
-
const laneDir = path.join(teamDir, 'work', initialLane);
|
|
1329
|
-
await ensureDir(laneDir);
|
|
1330
|
-
const ticketPath = path.join(laneDir, ticketFile);
|
|
305
|
+
const laneDir = path.join(teamDir, 'work', initialLane);
|
|
306
|
+
await ensureDir(laneDir);
|
|
307
|
+
const ticketPath = path.join(laneDir, ticketFile);
|
|
1331
308
|
|
|
1332
309
|
const header = `# ${ticketNum} — Workflow run: ${workflow.name ?? workflow.id ?? opts.workflowFile}\n\n`;
|
|
1333
310
|
const md = [
|
|
@@ -1389,871 +366,3 @@ export async function runWorkflowOnce(api: OpenClawPluginApi, opts: {
|
|
|
1389
366
|
status: execRes.status,
|
|
1390
367
|
};
|
|
1391
368
|
}
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
type ApprovalRecord = {
|
|
1395
|
-
runId: string;
|
|
1396
|
-
teamId: string;
|
|
1397
|
-
workflowFile: string;
|
|
1398
|
-
nodeId: string;
|
|
1399
|
-
bindingId: string;
|
|
1400
|
-
requestedAt: string;
|
|
1401
|
-
status: 'pending' | 'approved' | 'rejected';
|
|
1402
|
-
decidedAt?: string;
|
|
1403
|
-
ticket: string;
|
|
1404
|
-
runLog: string;
|
|
1405
|
-
note?: string;
|
|
1406
|
-
resumedAt?: string;
|
|
1407
|
-
resumedStatus?: string;
|
|
1408
|
-
resumeError?: string;
|
|
1409
|
-
};
|
|
1410
|
-
|
|
1411
|
-
async function approvalsPathFor(teamDir: string, runId: string) {
|
|
1412
|
-
const runsDir = path.join(teamDir, 'shared-context', 'workflow-runs');
|
|
1413
|
-
return path.join(runsDir, runId, 'approvals', 'approval.json');
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
export async function pollWorkflowApprovals(api: OpenClawPluginApi, opts: {
|
|
1417
|
-
teamId: string;
|
|
1418
|
-
limit?: number;
|
|
1419
|
-
}) {
|
|
1420
|
-
const teamId = String(opts.teamId);
|
|
1421
|
-
const teamDir = resolveTeamDir(api, teamId);
|
|
1422
|
-
const runsDir = path.join(teamDir, 'shared-context', 'workflow-runs');
|
|
1423
|
-
|
|
1424
|
-
if (!(await fileExists(runsDir))) {
|
|
1425
|
-
return { ok: true as const, teamId, polled: 0, resumed: 0, skipped: 0, message: 'No workflow-runs directory present.' };
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
const approvalPaths: string[] = [];
|
|
1429
|
-
const entries = await fs.readdir(runsDir);
|
|
1430
|
-
for (const e of entries) {
|
|
1431
|
-
const p = path.join(runsDir, e, 'approvals', 'approval.json');
|
|
1432
|
-
if (await fileExists(p)) approvalPaths.push(p);
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
const limitedPaths = approvalPaths.slice(0, typeof opts.limit === 'number' && opts.limit > 0 ? opts.limit : undefined);
|
|
1436
|
-
if (!limitedPaths.length) {
|
|
1437
|
-
return { ok: true as const, teamId, polled: 0, resumed: 0, skipped: 0, message: 'No approval records present.' };
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
let resumed = 0;
|
|
1441
|
-
let skipped = 0;
|
|
1442
|
-
const results: Array<{ runId: string; status: string; action: 'resumed' | 'skipped' | 'error'; message?: string }> = [];
|
|
1443
|
-
|
|
1444
|
-
for (const approvalPath of limitedPaths) {
|
|
1445
|
-
let approval: ApprovalRecord;
|
|
1446
|
-
try {
|
|
1447
|
-
approval = await readJsonFile<ApprovalRecord>(approvalPath);
|
|
1448
|
-
} catch (e) {
|
|
1449
|
-
skipped++;
|
|
1450
|
-
results.push({ runId: path.basename(path.dirname(path.dirname(approvalPath))), status: 'unknown', action: 'error', message: `Failed to parse: ${(e as Error).message}` });
|
|
1451
|
-
continue;
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
if (approval.status === 'pending') {
|
|
1455
|
-
skipped++;
|
|
1456
|
-
results.push({ runId: approval.runId, status: approval.status, action: 'skipped' });
|
|
1457
|
-
continue;
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
if (approval.resumedAt) {
|
|
1461
|
-
skipped++;
|
|
1462
|
-
results.push({ runId: approval.runId, status: approval.status, action: 'skipped', message: 'Already resumed.' });
|
|
1463
|
-
continue;
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
try {
|
|
1467
|
-
const res = await resumeWorkflowRun(api, { teamId, runId: approval.runId });
|
|
1468
|
-
resumed++;
|
|
1469
|
-
results.push({ runId: approval.runId, status: approval.status, action: 'resumed', message: `resume status=${(res as { status?: string }).status ?? 'ok'}` });
|
|
1470
|
-
const next: ApprovalRecord = {
|
|
1471
|
-
...approval,
|
|
1472
|
-
resumedAt: new Date().toISOString(),
|
|
1473
|
-
resumedStatus: String((res as { status?: string }).status ?? 'ok'),
|
|
1474
|
-
};
|
|
1475
|
-
await fs.writeFile(approvalPath, JSON.stringify(next, null, 2), 'utf8');
|
|
1476
|
-
} catch (e) {
|
|
1477
|
-
results.push({ runId: approval.runId, status: approval.status, action: 'error', message: (e as Error).message });
|
|
1478
|
-
const next: ApprovalRecord = {
|
|
1479
|
-
...approval,
|
|
1480
|
-
resumedAt: new Date().toISOString(),
|
|
1481
|
-
resumedStatus: 'error',
|
|
1482
|
-
resumeError: (e as Error).message,
|
|
1483
|
-
};
|
|
1484
|
-
await fs.writeFile(approvalPath, JSON.stringify(next, null, 2), 'utf8');
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
return { ok: true as const, teamId, polled: limitedPaths.length, resumed, skipped, results };
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
export async function approveWorkflowRun(api: OpenClawPluginApi, opts: {
|
|
1492
|
-
teamId: string;
|
|
1493
|
-
runId: string;
|
|
1494
|
-
approved: boolean;
|
|
1495
|
-
note?: string;
|
|
1496
|
-
}) {
|
|
1497
|
-
const teamId = String(opts.teamId);
|
|
1498
|
-
const runId = String(opts.runId);
|
|
1499
|
-
const teamDir = resolveTeamDir(api, teamId);
|
|
1500
|
-
|
|
1501
|
-
const approvalPath = await approvalsPathFor(teamDir, runId);
|
|
1502
|
-
if (!(await fileExists(approvalPath))) {
|
|
1503
|
-
throw new Error(`Approval file not found for runId=${runId}: ${path.relative(teamDir, approvalPath)}`);
|
|
1504
|
-
}
|
|
1505
|
-
const raw = await readTextFile(approvalPath);
|
|
1506
|
-
const cur = JSON.parse(raw) as ApprovalRecord;
|
|
1507
|
-
const next: ApprovalRecord = {
|
|
1508
|
-
...cur,
|
|
1509
|
-
status: opts.approved ? 'approved' : 'rejected',
|
|
1510
|
-
decidedAt: new Date().toISOString(),
|
|
1511
|
-
...(opts.note ? { note: String(opts.note) } : {}),
|
|
1512
|
-
};
|
|
1513
|
-
await fs.writeFile(approvalPath, JSON.stringify(next, null, 2), 'utf8');
|
|
1514
|
-
|
|
1515
|
-
return { ok: true as const, runId, status: next.status, approvalFile: path.relative(teamDir, approvalPath) };
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
|
-
export async function resumeWorkflowRun(api: OpenClawPluginApi, opts: {
|
|
1519
|
-
teamId: string;
|
|
1520
|
-
runId: string;
|
|
1521
|
-
}) {
|
|
1522
|
-
const teamId = String(opts.teamId);
|
|
1523
|
-
const runId = String(opts.runId);
|
|
1524
|
-
const teamDir = resolveTeamDir(api, teamId);
|
|
1525
|
-
const sharedContextDir = path.join(teamDir, 'shared-context');
|
|
1526
|
-
const runsDir = path.join(sharedContextDir, 'workflow-runs');
|
|
1527
|
-
const workflowsDir = path.join(sharedContextDir, 'workflows');
|
|
1528
|
-
|
|
1529
|
-
const loaded = await loadRunFile(teamDir, runsDir, runId);
|
|
1530
|
-
const runLogPath = loaded.path;
|
|
1531
|
-
const runLog = loaded.run;
|
|
1532
|
-
|
|
1533
|
-
if (runLog.status === 'completed' || runLog.status === 'rejected') {
|
|
1534
|
-
return { ok: true as const, runId, status: runLog.status, message: 'No-op; run already finished.' };
|
|
1535
|
-
}
|
|
1536
|
-
if (runLog.status !== 'awaiting_approval' && runLog.status !== 'running') {
|
|
1537
|
-
throw new Error(`Run is not awaiting approval (status=${runLog.status}).`);
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
const workflowFile = String(runLog.workflow.file);
|
|
1541
|
-
const workflowPath = path.join(workflowsDir, workflowFile);
|
|
1542
|
-
const workflowRaw = await readTextFile(workflowPath);
|
|
1543
|
-
const workflow = normalizeWorkflow(JSON.parse(workflowRaw));
|
|
1544
|
-
|
|
1545
|
-
const approvalPath = await approvalsPathFor(teamDir, runId);
|
|
1546
|
-
if (!(await fileExists(approvalPath))) throw new Error(`Missing approval file: ${path.relative(teamDir, approvalPath)}`);
|
|
1547
|
-
const approvalRaw = await readTextFile(approvalPath);
|
|
1548
|
-
const approval = JSON.parse(approvalRaw) as ApprovalRecord;
|
|
1549
|
-
if (approval.status === 'pending') {
|
|
1550
|
-
throw new Error(`Approval still pending. Update ${path.relative(teamDir, approvalPath)} first.`);
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
const ticketPath = path.join(teamDir, runLog.ticket.file);
|
|
1554
|
-
|
|
1555
|
-
// Find the approval node index.
|
|
1556
|
-
const approvalIdx = workflow.nodes.findIndex((n) => n.kind === 'human_approval' && String(n.id) === String(approval.nodeId));
|
|
1557
|
-
if (approvalIdx < 0) throw new Error(`Approval node not found in workflow: nodeId=${approval.nodeId}`);
|
|
1558
|
-
|
|
1559
|
-
if (approval.status === 'rejected') {
|
|
1560
|
-
// Denial flow: mark run as needs_revision and loop back to the draft step (or closest prior llm node).
|
|
1561
|
-
// This keeps workflows non-terminal on rejection.
|
|
1562
|
-
|
|
1563
|
-
const approvalNote = String(approval.note ?? '').trim();
|
|
1564
|
-
|
|
1565
|
-
// Find a reasonable "revise" node: prefer a node with id=draft_assets, else the closest prior llm node.
|
|
1566
|
-
let reviseIdx = workflow.nodes.findIndex((n, idx) => idx < approvalIdx && String(n.id) === 'draft_assets');
|
|
1567
|
-
if (reviseIdx < 0) {
|
|
1568
|
-
for (let i = approvalIdx - 1; i >= 0; i--) {
|
|
1569
|
-
if (workflow.nodes[i]?.kind === 'llm') {
|
|
1570
|
-
reviseIdx = i;
|
|
1571
|
-
break;
|
|
1572
|
-
}
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
if (reviseIdx < 0) reviseIdx = 0;
|
|
1576
|
-
|
|
1577
|
-
const reviseNode = workflow.nodes[reviseIdx]!;
|
|
1578
|
-
const reviseAgentId = String(reviseNode?.assignedTo?.agentId ?? '').trim();
|
|
1579
|
-
if (!reviseAgentId) throw new Error(`Revision node ${reviseNode.id} missing assignedTo.agentId`);
|
|
1580
|
-
|
|
1581
|
-
// Mark run state as needing revision, and clear nodeStates for nodes from reviseIdx onward.
|
|
1582
|
-
const now = new Date().toISOString();
|
|
1583
|
-
await writeRunFile(runLogPath, (cur) => {
|
|
1584
|
-
const nextStates: Record<string, { status: 'success' | 'error' | 'waiting'; ts: string; message?: string }> = {
|
|
1585
|
-
...(cur.nodeStates ?? {}),
|
|
1586
|
-
[approval.nodeId]: { status: 'error', ts: now, message: 'rejected' },
|
|
1587
|
-
};
|
|
1588
|
-
for (let i = reviseIdx; i < (workflow.nodes?.length ?? 0); i++) {
|
|
1589
|
-
const id = String(workflow.nodes[i]?.id ?? '').trim();
|
|
1590
|
-
if (id) delete nextStates[id];
|
|
1591
|
-
}
|
|
1592
|
-
return {
|
|
1593
|
-
...cur,
|
|
1594
|
-
updatedAt: now,
|
|
1595
|
-
status: 'needs_revision',
|
|
1596
|
-
nextNodeIndex: reviseIdx,
|
|
1597
|
-
nodeStates: nextStates,
|
|
1598
|
-
events: [
|
|
1599
|
-
...cur.events,
|
|
1600
|
-
{
|
|
1601
|
-
ts: now,
|
|
1602
|
-
type: 'run.revision_requested',
|
|
1603
|
-
nodeId: approval.nodeId,
|
|
1604
|
-
reviseNodeId: reviseNode.id,
|
|
1605
|
-
reviseAgentId,
|
|
1606
|
-
...(approvalNote ? { note: approvalNote } : {}),
|
|
1607
|
-
},
|
|
1608
|
-
],
|
|
1609
|
-
};
|
|
1610
|
-
});
|
|
1611
|
-
|
|
1612
|
-
// Clear any stale node locks from the revise node onward.
|
|
1613
|
-
// (A revision is a deliberate re-run; prior locks must not permanently block it.)
|
|
1614
|
-
try {
|
|
1615
|
-
const runPath = runLogPath;
|
|
1616
|
-
const runDir = path.dirname(runPath);
|
|
1617
|
-
const lockDir = path.join(runDir, 'locks');
|
|
1618
|
-
for (let i = reviseIdx; i < (workflow.nodes?.length ?? 0); i++) {
|
|
1619
|
-
const id = String(workflow.nodes[i]?.id ?? '').trim();
|
|
1620
|
-
if (!id) continue;
|
|
1621
|
-
const lp = path.join(lockDir, `${id}.lock`);
|
|
1622
|
-
try {
|
|
1623
|
-
await fs.unlink(lp);
|
|
1624
|
-
} catch {
|
|
1625
|
-
// ignore
|
|
1626
|
-
}
|
|
1627
|
-
}
|
|
1628
|
-
} catch {
|
|
1629
|
-
// ignore
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
// Enqueue the revision node.
|
|
1633
|
-
await enqueueTask(teamDir, reviseAgentId, {
|
|
1634
|
-
teamId,
|
|
1635
|
-
runId,
|
|
1636
|
-
nodeId: reviseNode.id,
|
|
1637
|
-
kind: 'execute_node',
|
|
1638
|
-
// Include human feedback in the packet so prompt templates can use it.
|
|
1639
|
-
packet: approvalNote ? { revisionNote: approvalNote } : {},
|
|
1640
|
-
} as unknown as Record<string, unknown>);
|
|
1641
|
-
|
|
1642
|
-
return { ok: true as const, runId, status: 'needs_revision' as const, ticketPath, runLogPath };
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1645
|
-
// Mark node approved if not already recorded.
|
|
1646
|
-
const approvedTs = new Date().toISOString();
|
|
1647
|
-
await appendRunLog(runLogPath, (cur) => ({
|
|
1648
|
-
...cur,
|
|
1649
|
-
status: 'running',
|
|
1650
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [approval.nodeId]: { status: 'success', ts: approvedTs } },
|
|
1651
|
-
events: (cur.events ?? []).some((eRaw) => {
|
|
1652
|
-
const e = asRecord(eRaw);
|
|
1653
|
-
return asString(e['type']) === 'node.approved' && asString(e['nodeId']) === String(approval.nodeId);
|
|
1654
|
-
})
|
|
1655
|
-
? cur.events
|
|
1656
|
-
: [...cur.events, { ts: approvedTs, type: 'node.approved', nodeId: approval.nodeId }],
|
|
1657
|
-
}));
|
|
1658
|
-
|
|
1659
|
-
// Pull-based execution: enqueue the next runnable node and return.
|
|
1660
|
-
let updated = (await loadRunFile(teamDir, runsDir, runId)).run;
|
|
1661
|
-
let enqueueIdx = pickNextRunnableNodeIndex({ workflow, run: updated });
|
|
1662
|
-
|
|
1663
|
-
// Auto-complete start/end nodes.
|
|
1664
|
-
while (enqueueIdx !== null) {
|
|
1665
|
-
const n = workflow.nodes[enqueueIdx]!;
|
|
1666
|
-
const k = String(n.kind ?? '');
|
|
1667
|
-
if (k !== 'start' && k !== 'end') break;
|
|
1668
|
-
const ts = new Date().toISOString();
|
|
1669
|
-
await appendRunLog(runLogPath, (cur) => ({
|
|
1670
|
-
...cur,
|
|
1671
|
-
nextNodeIndex: enqueueIdx! + 1,
|
|
1672
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [n.id]: { status: 'success', ts } },
|
|
1673
|
-
events: [...cur.events, { ts, type: 'node.completed', nodeId: n.id, kind: k, noop: true }],
|
|
1674
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: n.id, kind: k, noop: true }],
|
|
1675
|
-
}));
|
|
1676
|
-
updated = (await loadRunFile(teamDir, runsDir, runId)).run;
|
|
1677
|
-
enqueueIdx = pickNextRunnableNodeIndex({ workflow, run: updated });
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
if (enqueueIdx === null) {
|
|
1681
|
-
await writeRunFile(runLogPath, (cur) => ({
|
|
1682
|
-
...cur,
|
|
1683
|
-
updatedAt: new Date().toISOString(),
|
|
1684
|
-
status: 'completed',
|
|
1685
|
-
events: [...cur.events, { ts: new Date().toISOString(), type: 'run.completed' }],
|
|
1686
|
-
}));
|
|
1687
|
-
return { ok: true as const, runId, status: 'completed' as const, ticketPath, runLogPath };
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
const node = workflow.nodes[enqueueIdx]!;
|
|
1691
|
-
const nextKind = String(node.kind ?? '');
|
|
1692
|
-
const nextAgentId = String(node?.assignedTo?.agentId ?? '').trim();
|
|
1693
|
-
if (!nextAgentId) throw new Error(`Next runnable node ${node.id} (${nextKind}) missing assignedTo.agentId (required for pull-based execution)`);
|
|
1694
|
-
|
|
1695
|
-
await enqueueTask(teamDir, nextAgentId, {
|
|
1696
|
-
teamId,
|
|
1697
|
-
runId,
|
|
1698
|
-
nodeId: node.id,
|
|
1699
|
-
kind: 'execute_node',
|
|
1700
|
-
});
|
|
1701
|
-
|
|
1702
|
-
await writeRunFile(runLogPath, (cur) => ({
|
|
1703
|
-
...cur,
|
|
1704
|
-
updatedAt: new Date().toISOString(),
|
|
1705
|
-
status: 'waiting_workers',
|
|
1706
|
-
nextNodeIndex: enqueueIdx,
|
|
1707
|
-
events: [...cur.events, { ts: new Date().toISOString(), type: 'node.enqueued', nodeId: node.id, agentId: nextAgentId }],
|
|
1708
|
-
}));
|
|
1709
|
-
|
|
1710
|
-
return { ok: true as const, runId, status: 'waiting_workers' as const, ticketPath, runLogPath };
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
|
|
1714
|
-
teamId: string;
|
|
1715
|
-
agentId: string;
|
|
1716
|
-
limit?: number;
|
|
1717
|
-
workerId?: string;
|
|
1718
|
-
}) {
|
|
1719
|
-
const teamId = String(opts.teamId);
|
|
1720
|
-
const agentId = String(opts.agentId);
|
|
1721
|
-
if (!teamId) throw new Error('--team-id is required');
|
|
1722
|
-
if (!agentId) throw new Error('--agent-id is required');
|
|
1723
|
-
|
|
1724
|
-
const teamDir = resolveTeamDir(api, teamId);
|
|
1725
|
-
const sharedContextDir = path.join(teamDir, 'shared-context');
|
|
1726
|
-
const workflowsDir = path.join(sharedContextDir, 'workflows');
|
|
1727
|
-
const runsDir = path.join(sharedContextDir, 'workflow-runs');
|
|
1728
|
-
|
|
1729
|
-
const workerId = String(opts.workerId ?? `workflow-worker:${process.pid}`);
|
|
1730
|
-
const limit = typeof opts.limit === 'number' && opts.limit > 0 ? Math.floor(opts.limit) : 1;
|
|
1731
|
-
|
|
1732
|
-
const results: Array<{ taskId: string; runId: string; nodeId: string; status: string }> = [];
|
|
1733
|
-
|
|
1734
|
-
for (let i = 0; i < limit; i++) {
|
|
1735
|
-
const dq = await dequeueNextTask(teamDir, agentId, { workerId, leaseSeconds: 120 });
|
|
1736
|
-
if (!dq.ok || !dq.task) break;
|
|
1737
|
-
|
|
1738
|
-
const { task } = dq.task;
|
|
1739
|
-
const runPath = runFilePathFor(runsDir, task.runId);
|
|
1740
|
-
const runDir = path.dirname(runPath);
|
|
1741
|
-
const lockDir = path.join(runDir, 'locks');
|
|
1742
|
-
const lockPath = path.join(lockDir, `${task.nodeId}.lock`);
|
|
1743
|
-
let lockHeld = false;
|
|
1744
|
-
|
|
1745
|
-
try {
|
|
1746
|
-
if (task.kind !== 'execute_node') continue;
|
|
1747
|
-
|
|
1748
|
-
await ensureDir(lockDir);
|
|
1749
|
-
|
|
1750
|
-
// Node-level lock to prevent double execution.
|
|
1751
|
-
try {
|
|
1752
|
-
await fs.writeFile(lockPath, JSON.stringify({ workerId, taskId: task.id, claimedAt: new Date().toISOString() }, null, 2), { encoding: 'utf8', flag: 'wx' });
|
|
1753
|
-
lockHeld = true;
|
|
1754
|
-
} catch {
|
|
1755
|
-
// Lock exists. Treat it as contention unless it looks stale.
|
|
1756
|
-
// (If a worker crashed, the lock file can stick around and block retries/revisions forever.)
|
|
1757
|
-
let unlocked = false;
|
|
1758
|
-
try {
|
|
1759
|
-
const raw = await readTextFile(lockPath);
|
|
1760
|
-
const parsed = JSON.parse(raw) as { claimedAt?: string };
|
|
1761
|
-
const claimedAtMs = parsed?.claimedAt ? Date.parse(String(parsed.claimedAt)) : NaN;
|
|
1762
|
-
const ageMs = Number.isFinite(claimedAtMs) ? Date.now() - claimedAtMs : NaN;
|
|
1763
|
-
const stale = Number.isFinite(ageMs) && ageMs > 10 * 60 * 1000;
|
|
1764
|
-
if (stale) {
|
|
1765
|
-
await fs.unlink(lockPath);
|
|
1766
|
-
unlocked = true;
|
|
1767
|
-
}
|
|
1768
|
-
} catch {
|
|
1769
|
-
// ignore
|
|
1770
|
-
}
|
|
1771
|
-
|
|
1772
|
-
if (unlocked) {
|
|
1773
|
-
try {
|
|
1774
|
-
await fs.writeFile(lockPath, JSON.stringify({ workerId, taskId: task.id, claimedAt: new Date().toISOString() }, null, 2), { encoding: 'utf8', flag: 'wx' });
|
|
1775
|
-
lockHeld = true;
|
|
1776
|
-
} catch {
|
|
1777
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_locked' });
|
|
1778
|
-
continue;
|
|
1779
|
-
}
|
|
1780
|
-
} else {
|
|
1781
|
-
// Requeue to avoid task loss since dequeueNextTask already advanced the queue cursor.
|
|
1782
|
-
await enqueueTask(teamDir, agentId, {
|
|
1783
|
-
teamId,
|
|
1784
|
-
runId: task.runId,
|
|
1785
|
-
nodeId: task.nodeId,
|
|
1786
|
-
kind: 'execute_node',
|
|
1787
|
-
});
|
|
1788
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_locked' });
|
|
1789
|
-
continue;
|
|
1790
|
-
}
|
|
1791
|
-
}
|
|
1792
|
-
|
|
1793
|
-
const runId = task.runId;
|
|
1794
|
-
|
|
1795
|
-
const { run } = await loadRunFile(teamDir, runsDir, runId);
|
|
1796
|
-
const workflowFile = String(run.workflow.file);
|
|
1797
|
-
const workflowPath = path.join(workflowsDir, workflowFile);
|
|
1798
|
-
const workflowRaw = await readTextFile(workflowPath);
|
|
1799
|
-
const workflow = normalizeWorkflow(JSON.parse(workflowRaw));
|
|
1800
|
-
|
|
1801
|
-
const nodeIdx = workflow.nodes.findIndex((n) => String(n.id) === String(task.nodeId));
|
|
1802
|
-
if (nodeIdx < 0) throw new Error(`Node not found in workflow: ${task.nodeId}`);
|
|
1803
|
-
const node = workflow.nodes[nodeIdx]!;
|
|
1804
|
-
|
|
1805
|
-
// Stale-task guard: expired claim recovery can surface older queue entries from behind the
|
|
1806
|
-
// cursor. Before executing a dequeued task, verify that this node is still actually runnable
|
|
1807
|
-
// for the current run state. Otherwise we can resurrect pre-approval work and overwrite
|
|
1808
|
-
// canonical node outputs for runs that already advanced.
|
|
1809
|
-
const currentRun = (await loadRunFile(teamDir, runsDir, task.runId)).run;
|
|
1810
|
-
const currentNodeStates = loadNodeStatesFromRun(currentRun);
|
|
1811
|
-
const currentStatus = currentNodeStates[String(node.id)]?.status;
|
|
1812
|
-
const currentlyRunnableIdx = pickNextRunnableNodeIndex({ workflow, run: currentRun });
|
|
1813
|
-
if (
|
|
1814
|
-
currentStatus === 'success' ||
|
|
1815
|
-
currentStatus === 'error' ||
|
|
1816
|
-
currentStatus === 'waiting' ||
|
|
1817
|
-
currentlyRunnableIdx === null ||
|
|
1818
|
-
String(workflow.nodes[currentlyRunnableIdx]?.id ?? '') !== String(node.id)
|
|
1819
|
-
) {
|
|
1820
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_stale' });
|
|
1821
|
-
continue;
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
// Determine current lane + ticket path.
|
|
1825
|
-
const laneRaw = String(run.ticket.lane);
|
|
1826
|
-
assertLane(laneRaw);
|
|
1827
|
-
let curLane: WorkflowLane = laneRaw as WorkflowLane;
|
|
1828
|
-
let curTicketPath = path.join(teamDir, run.ticket.file);
|
|
1829
|
-
|
|
1830
|
-
// Lane transitions.
|
|
1831
|
-
const laneNodeRaw = node?.lane ? String(node.lane) : null;
|
|
1832
|
-
if (laneNodeRaw) {
|
|
1833
|
-
assertLane(laneNodeRaw);
|
|
1834
|
-
if (laneNodeRaw !== curLane) {
|
|
1835
|
-
const moved = await moveRunTicket({ teamDir, ticketPath: curTicketPath, toLane: laneNodeRaw });
|
|
1836
|
-
curLane = laneNodeRaw;
|
|
1837
|
-
curTicketPath = moved.ticketPath;
|
|
1838
|
-
await appendRunLog(runPath, (cur) => ({
|
|
1839
|
-
...cur,
|
|
1840
|
-
ticket: { ...cur.ticket, file: path.relative(teamDir, curTicketPath), lane: curLane },
|
|
1841
|
-
events: [...cur.events, { ts: new Date().toISOString(), type: 'ticket.moved', lane: curLane, nodeId: node.id }],
|
|
1842
|
-
}));
|
|
1843
|
-
}
|
|
1844
|
-
}
|
|
1845
|
-
|
|
1846
|
-
const kind = String(node.kind ?? '');
|
|
1847
|
-
|
|
1848
|
-
// start/end are no-op.
|
|
1849
|
-
if (kind === 'start' || kind === 'end') {
|
|
1850
|
-
const completedTs = new Date().toISOString();
|
|
1851
|
-
await appendRunLog(runPath, (cur) => ({
|
|
1852
|
-
...cur,
|
|
1853
|
-
nextNodeIndex: nodeIdx + 1,
|
|
1854
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
|
|
1855
|
-
events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind, noop: true }],
|
|
1856
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, noop: true }],
|
|
1857
|
-
}));
|
|
1858
|
-
} else if (kind === 'llm') {
|
|
1859
|
-
// Reuse the existing runner logic by executing just this node (sequential model).
|
|
1860
|
-
// This keeps the worker deterministic and file-first.
|
|
1861
|
-
const runLogPath = runPath;
|
|
1862
|
-
const runId = task.runId;
|
|
1863
|
-
|
|
1864
|
-
const agentIdExec = String(node?.assignedTo?.agentId ?? '');
|
|
1865
|
-
const action = asRecord(node.action);
|
|
1866
|
-
const promptTemplatePath = asString(action['promptTemplatePath']).trim();
|
|
1867
|
-
const promptTemplateInline = asString(action['promptTemplate']).trim();
|
|
1868
|
-
if (!agentIdExec) throw new Error(`Node ${nodeLabel(node)} missing assignedTo.agentId`);
|
|
1869
|
-
if (!promptTemplatePath && !promptTemplateInline) throw new Error(`Node ${nodeLabel(node)} missing action.promptTemplatePath or action.promptTemplate`);
|
|
1870
|
-
|
|
1871
|
-
const promptPathAbs = promptTemplatePath ? path.resolve(teamDir, promptTemplatePath) : '';
|
|
1872
|
-
const defaultNodeOutputRel = path.join('node-outputs', `${String(nodeIdx).padStart(3, '0')}-${node.id}.json`);
|
|
1873
|
-
const nodeOutputRel = String(node?.output?.path ?? '').trim() || defaultNodeOutputRel;
|
|
1874
|
-
const nodeOutputAbs = path.resolve(runDir, nodeOutputRel);
|
|
1875
|
-
if (!nodeOutputAbs.startsWith(runDir + path.sep) && nodeOutputAbs !== runDir) {
|
|
1876
|
-
throw new Error(`Node output.path must be within the run directory: ${nodeOutputRel}`);
|
|
1877
|
-
}
|
|
1878
|
-
await ensureDir(path.dirname(nodeOutputAbs));
|
|
1879
|
-
|
|
1880
|
-
const prompt = promptTemplateInline ? promptTemplateInline : await readTextFile(promptPathAbs);
|
|
1881
|
-
const taskText = [
|
|
1882
|
-
`You are executing a workflow run for teamId=${teamId}.`,
|
|
1883
|
-
`Workflow: ${workflow.name ?? workflow.id ?? workflowFile}`,
|
|
1884
|
-
`RunId: ${runId}`,
|
|
1885
|
-
`Node: ${nodeLabel(node)}`,
|
|
1886
|
-
`\n---\nPROMPT TEMPLATE\n---\n`,
|
|
1887
|
-
prompt.trim(),
|
|
1888
|
-
`\n---\nOUTPUT FORMAT\n---\n`,
|
|
1889
|
-
`Return ONLY the final content (the worker will store it as JSON).`,
|
|
1890
|
-
].join('\n');
|
|
1891
|
-
|
|
1892
|
-
let text = '';
|
|
1893
|
-
try {
|
|
1894
|
-
let llmRes: unknown;
|
|
1895
|
-
const priorInput = await loadPriorLlmInput({ runDir, workflow, currentNode: node, currentNodeIndex: nodeIdx });
|
|
1896
|
-
try {
|
|
1897
|
-
llmRes = await toolsInvoke<unknown>(api, {
|
|
1898
|
-
tool: 'llm-task-fixed',
|
|
1899
|
-
action: 'json',
|
|
1900
|
-
args: {
|
|
1901
|
-
prompt: taskText,
|
|
1902
|
-
input: { teamId, runId, nodeId: node.id, agentId, ...priorInput },
|
|
1903
|
-
},
|
|
1904
|
-
});
|
|
1905
|
-
} catch {
|
|
1906
|
-
llmRes = await toolsInvoke<unknown>(api, {
|
|
1907
|
-
tool: 'llm-task',
|
|
1908
|
-
action: 'json',
|
|
1909
|
-
args: {
|
|
1910
|
-
prompt: taskText,
|
|
1911
|
-
input: { teamId, runId, nodeId: node.id, agentId, ...priorInput },
|
|
1912
|
-
},
|
|
1913
|
-
});
|
|
1914
|
-
}
|
|
1915
|
-
|
|
1916
|
-
const llmRec = asRecord(llmRes);
|
|
1917
|
-
const details = asRecord(llmRec['details']);
|
|
1918
|
-
const payload = details['json'] ?? (Object.keys(details).length ? details : llmRes) ?? null;
|
|
1919
|
-
text = JSON.stringify(payload, null, 2);
|
|
1920
|
-
} catch (e) {
|
|
1921
|
-
throw new Error(`LLM execution failed for node ${nodeLabel(node)}: ${e instanceof Error ? e.message : String(e)}`);
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
const outputObj = {
|
|
1925
|
-
runId,
|
|
1926
|
-
teamId,
|
|
1927
|
-
nodeId: node.id,
|
|
1928
|
-
kind: node.kind,
|
|
1929
|
-
agentId: agentIdExec,
|
|
1930
|
-
completedAt: new Date().toISOString(),
|
|
1931
|
-
text,
|
|
1932
|
-
};
|
|
1933
|
-
await fs.writeFile(nodeOutputAbs, JSON.stringify(outputObj, null, 2) + '\n', 'utf8');
|
|
1934
|
-
|
|
1935
|
-
const completedTs = new Date().toISOString();
|
|
1936
|
-
await appendRunLog(runLogPath, (cur) => ({
|
|
1937
|
-
...cur,
|
|
1938
|
-
nextNodeIndex: nodeIdx + 1,
|
|
1939
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
|
|
1940
|
-
events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind: node.kind, nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
|
|
1941
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId: agentIdExec, nodeOutputPath: path.relative(teamDir, nodeOutputAbs), bytes: Buffer.byteLength(text, 'utf8') }],
|
|
1942
|
-
}));
|
|
1943
|
-
} else if (kind === 'human_approval') {
|
|
1944
|
-
// For now, approval nodes are executed by workers (message send + awaiting state).
|
|
1945
|
-
// Note: approval files live inside the run folder.
|
|
1946
|
-
const approvalBindingId = String(node?.action?.approvalBindingId ?? '');
|
|
1947
|
-
const config = asRecord((node as unknown as Record<string, unknown>)['config']);
|
|
1948
|
-
const action = asRecord(node.action);
|
|
1949
|
-
const provider = asString(config['provider'] ?? action['provider']).trim();
|
|
1950
|
-
const targetRaw = config['target'] ?? action['target'];
|
|
1951
|
-
const accountIdRaw = config['accountId'] ?? action['accountId'];
|
|
1952
|
-
|
|
1953
|
-
let channel = provider || 'telegram';
|
|
1954
|
-
let target = String(targetRaw ?? '');
|
|
1955
|
-
let accountId = accountIdRaw ? String(accountIdRaw) : undefined;
|
|
1956
|
-
|
|
1957
|
-
// ClawKitchen UI sometimes stores placeholder targets like "(set in UI)".
|
|
1958
|
-
// Treat these as unset.
|
|
1959
|
-
if (target && /^\(set in ui\)$/i.test(target.trim())) {
|
|
1960
|
-
target = '';
|
|
1961
|
-
}
|
|
1962
|
-
|
|
1963
|
-
if (approvalBindingId) {
|
|
1964
|
-
try {
|
|
1965
|
-
const resolved = await resolveApprovalBindingTarget(api, approvalBindingId);
|
|
1966
|
-
channel = resolved.channel;
|
|
1967
|
-
target = resolved.target;
|
|
1968
|
-
accountId = resolved.accountId;
|
|
1969
|
-
} catch {
|
|
1970
|
-
// Back-compat for ClawKitchen UI: treat approvalBindingId as an inline provider/target hint if it looks like one.
|
|
1971
|
-
// Example: "telegram:account:shawnjbot".
|
|
1972
|
-
if (!target && approvalBindingId.startsWith('telegram:')) {
|
|
1973
|
-
channel = 'telegram';
|
|
1974
|
-
accountId = approvalBindingId.replace(/^telegram:account:/, '');
|
|
1975
|
-
} else {
|
|
1976
|
-
// If it's a telegram account hint, we can still proceed as long as we can derive a target.
|
|
1977
|
-
// Otherwise, fail loudly.
|
|
1978
|
-
throw new Error(
|
|
1979
|
-
`Missing approval binding: approvalBindingId=${approvalBindingId}. Expected a config binding entry OR provide config.target.`
|
|
1980
|
-
);
|
|
1981
|
-
}
|
|
1982
|
-
}
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
if (!target && channel === 'telegram') {
|
|
1986
|
-
// Back-compat shims (dev/testing):
|
|
1987
|
-
// - If Kitchen stored a telegram account hint (telegram:account:<id>) without a full binding,
|
|
1988
|
-
// use known chat ids for local testing.
|
|
1989
|
-
if (accountId === 'shawnjbot') target = '6477250615';
|
|
1990
|
-
}
|
|
1991
|
-
|
|
1992
|
-
if (!target) {
|
|
1993
|
-
throw new Error(`Node ${nodeLabel(node)} missing approval target (provide config.target or binding mapping)`);
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
const approvalsDir = path.join(runDir, 'approvals');
|
|
1997
|
-
await ensureDir(approvalsDir);
|
|
1998
|
-
const approvalPath = path.join(approvalsDir, 'approval.json');
|
|
1999
|
-
|
|
2000
|
-
const code = Math.random().toString(36).slice(2, 8).toUpperCase();
|
|
2001
|
-
|
|
2002
|
-
const approvalObj = {
|
|
2003
|
-
runId: task.runId,
|
|
2004
|
-
teamId,
|
|
2005
|
-
workflowFile,
|
|
2006
|
-
nodeId: node.id,
|
|
2007
|
-
bindingId: approvalBindingId || undefined,
|
|
2008
|
-
requestedAt: new Date().toISOString(),
|
|
2009
|
-
status: 'pending',
|
|
2010
|
-
code,
|
|
2011
|
-
ticket: path.relative(teamDir, curTicketPath),
|
|
2012
|
-
runLog: path.relative(teamDir, runPath),
|
|
2013
|
-
};
|
|
2014
|
-
await fs.writeFile(approvalPath, JSON.stringify(approvalObj, null, 2), 'utf8');
|
|
2015
|
-
|
|
2016
|
-
// Include a proposed-post preview in the approval request.
|
|
2017
|
-
let proposed = '';
|
|
2018
|
-
try {
|
|
2019
|
-
const nodeOutputsDir = path.join(runDir, 'node-outputs');
|
|
2020
|
-
// Prefer qc_brand output if present; otherwise use the most recent prior node.
|
|
2021
|
-
const qcId = 'qc_brand';
|
|
2022
|
-
const hasQc = (await fileExists(nodeOutputsDir)) && (await fs.readdir(nodeOutputsDir)).some((f) => f.endsWith(`-${qcId}.json`));
|
|
2023
|
-
const priorId = hasQc ? qcId : String(workflow.nodes?.[Math.max(0, nodeIdx - 1)]?.id ?? '');
|
|
2024
|
-
if (priorId) proposed = await loadProposedPostTextFromPriorNode({ runDir, nodeOutputsDir, priorNodeId: priorId });
|
|
2025
|
-
} catch {
|
|
2026
|
-
proposed = '';
|
|
2027
|
-
}
|
|
2028
|
-
proposed = sanitizeDraftOnlyText(proposed);
|
|
2029
|
-
|
|
2030
|
-
const msg = [
|
|
2031
|
-
`Approval requested: ${workflow.name ?? workflow.id ?? workflowFile}`,
|
|
2032
|
-
`Ticket: ${path.relative(teamDir, curTicketPath)}`,
|
|
2033
|
-
`Code: ${code}`,
|
|
2034
|
-
proposed ? `\n---\nPROPOSED POST (X)\n---\n${proposed}` : `\n(Warning: no proposed text found to preview)`,
|
|
2035
|
-
`\nReply with:`,
|
|
2036
|
-
`- approve ${code}`,
|
|
2037
|
-
`- decline ${code} <what to change>`,
|
|
2038
|
-
`\n(You can also review in Kitchen: http://localhost:7777/teams/${teamId}/workflows/${workflow.id ?? ''})`,
|
|
2039
|
-
].join('\n');
|
|
2040
|
-
|
|
2041
|
-
await toolsInvoke<ToolTextResult>(api, {
|
|
2042
|
-
tool: 'message',
|
|
2043
|
-
args: {
|
|
2044
|
-
action: 'send',
|
|
2045
|
-
channel,
|
|
2046
|
-
target,
|
|
2047
|
-
...(accountId ? { accountId } : {}),
|
|
2048
|
-
message: msg,
|
|
2049
|
-
},
|
|
2050
|
-
});
|
|
2051
|
-
|
|
2052
|
-
const waitingTs = new Date().toISOString();
|
|
2053
|
-
await appendRunLog(runPath, (cur) => ({
|
|
2054
|
-
...cur,
|
|
2055
|
-
status: 'awaiting_approval',
|
|
2056
|
-
nextNodeIndex: nodeIdx + 1,
|
|
2057
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'waiting', ts: waitingTs } },
|
|
2058
|
-
events: [...cur.events, { ts: waitingTs, type: 'node.awaiting_approval', nodeId: node.id, bindingId: approvalBindingId, approvalFile: path.relative(teamDir, approvalPath) }],
|
|
2059
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, approvalBindingId, approvalFile: path.relative(teamDir, approvalPath) }],
|
|
2060
|
-
}));
|
|
2061
|
-
|
|
2062
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'awaiting_approval' });
|
|
2063
|
-
continue;
|
|
2064
|
-
} else if (kind === 'tool') {
|
|
2065
|
-
const action = asRecord(node.action);
|
|
2066
|
-
const toolName = asString(action['tool']).trim();
|
|
2067
|
-
const toolArgs = isRecord(action['args']) ? (action['args'] as Record<string, unknown>) : {};
|
|
2068
|
-
if (!toolName) throw new Error(`Node ${nodeLabel(node)} missing action.tool`);
|
|
2069
|
-
|
|
2070
|
-
const artifactsDir = path.join(runDir, 'artifacts');
|
|
2071
|
-
await ensureDir(artifactsDir);
|
|
2072
|
-
const artifactPath = path.join(artifactsDir, `${String(nodeIdx).padStart(3, '0')}-${node.id}.tool.json`);
|
|
2073
|
-
try {
|
|
2074
|
-
// Runner-native tools (preferred): do NOT depend on gateway tool exposure.
|
|
2075
|
-
if (toolName === 'fs.append') {
|
|
2076
|
-
const relPathRaw = String(toolArgs.path ?? '').trim();
|
|
2077
|
-
const contentRaw = String(toolArgs.content ?? '');
|
|
2078
|
-
if (!relPathRaw) throw new Error('fs.append requires args.path');
|
|
2079
|
-
if (!contentRaw) throw new Error('fs.append requires args.content');
|
|
2080
|
-
|
|
2081
|
-
const vars = {
|
|
2082
|
-
date: new Date().toISOString(),
|
|
2083
|
-
'run.id': runId,
|
|
2084
|
-
'workflow.id': String(workflow.id ?? ''),
|
|
2085
|
-
'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
|
|
2086
|
-
};
|
|
2087
|
-
const relPath = templateReplace(relPathRaw, vars);
|
|
2088
|
-
const content = templateReplace(contentRaw, vars);
|
|
2089
|
-
|
|
2090
|
-
const abs = path.resolve(teamDir, relPath);
|
|
2091
|
-
if (!abs.startsWith(teamDir + path.sep) && abs !== teamDir) {
|
|
2092
|
-
throw new Error('fs.append path must be within the team workspace');
|
|
2093
|
-
}
|
|
2094
|
-
|
|
2095
|
-
await ensureDir(path.dirname(abs));
|
|
2096
|
-
await fs.appendFile(abs, content, 'utf8');
|
|
2097
|
-
|
|
2098
|
-
const result = { appendedTo: path.relative(teamDir, abs), bytes: Buffer.byteLength(content, 'utf8') };
|
|
2099
|
-
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2) + '\n', 'utf8');
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
} else if (toolName === 'marketing.post_all') {
|
|
2103
|
-
// Disabled by default: do not ship plugins that spawn local processes for posting.
|
|
2104
|
-
// Use an approval-gated workflow node that calls a dedicated posting tool/plugin instead.
|
|
2105
|
-
throw new Error(
|
|
2106
|
-
'marketing.post_all is disabled in this build (install safety). Use an external posting tool/plugin (approval-gated) instead.'
|
|
2107
|
-
);
|
|
2108
|
-
} else {
|
|
2109
|
-
const toolRes = await toolsInvoke<unknown>(api, {
|
|
2110
|
-
tool: toolName,
|
|
2111
|
-
args: toolArgs,
|
|
2112
|
-
});
|
|
2113
|
-
|
|
2114
|
-
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, result: toolRes }, null, 2) + '\n', 'utf8');
|
|
2115
|
-
}
|
|
2116
|
-
|
|
2117
|
-
const defaultNodeOutputRel = path.join('node-outputs', `${String(nodeIdx).padStart(3, '0')}-${node.id}.json`);
|
|
2118
|
-
const nodeOutputRel = String(node?.output?.path ?? '').trim() || defaultNodeOutputRel;
|
|
2119
|
-
const nodeOutputAbs = path.resolve(runDir, nodeOutputRel);
|
|
2120
|
-
await ensureDir(path.dirname(nodeOutputAbs));
|
|
2121
|
-
await fs.writeFile(nodeOutputAbs, JSON.stringify({
|
|
2122
|
-
runId: task.runId,
|
|
2123
|
-
teamId,
|
|
2124
|
-
nodeId: node.id,
|
|
2125
|
-
kind: node.kind,
|
|
2126
|
-
completedAt: new Date().toISOString(),
|
|
2127
|
-
tool: toolName,
|
|
2128
|
-
artifactPath: path.relative(teamDir, artifactPath),
|
|
2129
|
-
}, null, 2) + '\n', 'utf8');
|
|
2130
|
-
|
|
2131
|
-
const completedTs = new Date().toISOString();
|
|
2132
|
-
await appendRunLog(runPath, (cur) => ({
|
|
2133
|
-
...cur,
|
|
2134
|
-
nextNodeIndex: nodeIdx + 1,
|
|
2135
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
|
|
2136
|
-
events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind: node.kind, artifactPath: path.relative(teamDir, artifactPath), nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
|
|
2137
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath), nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
|
|
2138
|
-
}));
|
|
2139
|
-
} catch (e) {
|
|
2140
|
-
await fs.writeFile(artifactPath, JSON.stringify({ ok: false, tool: toolName, error: (e as Error).message }, null, 2) + '\n', 'utf8');
|
|
2141
|
-
const errorTs = new Date().toISOString();
|
|
2142
|
-
await appendRunLog(runPath, (cur) => ({
|
|
2143
|
-
...cur,
|
|
2144
|
-
status: 'error',
|
|
2145
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errorTs } },
|
|
2146
|
-
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) }],
|
|
2147
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, tool: toolName, error: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
2148
|
-
}));
|
|
2149
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'error', error: (e as Error).message });
|
|
2150
|
-
continue;
|
|
2151
|
-
}
|
|
2152
|
-
} else {
|
|
2153
|
-
throw new Error(`Worker does not yet support node kind: ${kind}`);
|
|
2154
|
-
}
|
|
2155
|
-
|
|
2156
|
-
// After node completion, enqueue next node.
|
|
2157
|
-
// Graph-aware: if workflow.edges exist, compute the next runnable node from nodeStates + edges.
|
|
2158
|
-
|
|
2159
|
-
let updated = (await loadRunFile(teamDir, runsDir, task.runId)).run;
|
|
2160
|
-
|
|
2161
|
-
if (updated.status === 'awaiting_approval') {
|
|
2162
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'awaiting_approval' });
|
|
2163
|
-
continue;
|
|
2164
|
-
}
|
|
2165
|
-
|
|
2166
|
-
let enqueueIdx = pickNextRunnableNodeIndex({ workflow, run: updated });
|
|
2167
|
-
|
|
2168
|
-
// Auto-complete start/end nodes.
|
|
2169
|
-
while (enqueueIdx !== null) {
|
|
2170
|
-
const n = workflow.nodes[enqueueIdx]!;
|
|
2171
|
-
const k = String(n.kind ?? '');
|
|
2172
|
-
if (k !== 'start' && k !== 'end') break;
|
|
2173
|
-
const ts = new Date().toISOString();
|
|
2174
|
-
await appendRunLog(runPath, (cur) => ({
|
|
2175
|
-
...cur,
|
|
2176
|
-
nextNodeIndex: enqueueIdx! + 1,
|
|
2177
|
-
nodeStates: { ...(cur.nodeStates ?? {}), [n.id]: { status: 'success', ts } },
|
|
2178
|
-
events: [...cur.events, { ts, type: 'node.completed', nodeId: n.id, kind: k, noop: true }],
|
|
2179
|
-
nodeResults: [...(cur.nodeResults ?? []), { nodeId: n.id, kind: k, noop: true }],
|
|
2180
|
-
}));
|
|
2181
|
-
updated = (await loadRunFile(teamDir, runsDir, task.runId)).run;
|
|
2182
|
-
enqueueIdx = pickNextRunnableNodeIndex({ workflow, run: updated });
|
|
2183
|
-
}
|
|
2184
|
-
|
|
2185
|
-
if (enqueueIdx === null) {
|
|
2186
|
-
await writeRunFile(runPath, (cur) => ({
|
|
2187
|
-
...cur,
|
|
2188
|
-
updatedAt: new Date().toISOString(),
|
|
2189
|
-
status: 'completed',
|
|
2190
|
-
events: [...cur.events, { ts: new Date().toISOString(), type: 'run.completed' }],
|
|
2191
|
-
}));
|
|
2192
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'completed' });
|
|
2193
|
-
continue;
|
|
2194
|
-
}
|
|
2195
|
-
|
|
2196
|
-
const nextNode = workflow.nodes[enqueueIdx]!;
|
|
2197
|
-
|
|
2198
|
-
// Some nodes (human approval) may not have an assigned agent; they are executed
|
|
2199
|
-
// by the runner/worker loop itself (they send a message + set awaiting state).
|
|
2200
|
-
const nextKind = String(nextNode.kind ?? '');
|
|
2201
|
-
if (nextKind === 'human_approval' || nextKind === 'start' || nextKind === 'end') {
|
|
2202
|
-
// Re-enqueue onto the same agent so it can execute the next node deterministically.
|
|
2203
|
-
await enqueueTask(teamDir, agentId, {
|
|
2204
|
-
teamId,
|
|
2205
|
-
runId: task.runId,
|
|
2206
|
-
nodeId: nextNode.id,
|
|
2207
|
-
kind: 'execute_node',
|
|
2208
|
-
});
|
|
2209
|
-
|
|
2210
|
-
await writeRunFile(runPath, (cur) => ({
|
|
2211
|
-
...cur,
|
|
2212
|
-
updatedAt: new Date().toISOString(),
|
|
2213
|
-
status: 'waiting_workers',
|
|
2214
|
-
nextNodeIndex: enqueueIdx,
|
|
2215
|
-
events: [...cur.events, { ts: new Date().toISOString(), type: 'node.enqueued', nodeId: nextNode.id, agentId }],
|
|
2216
|
-
}));
|
|
2217
|
-
|
|
2218
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'ok' });
|
|
2219
|
-
continue;
|
|
2220
|
-
}
|
|
2221
|
-
|
|
2222
|
-
const nextAgentId = String(nextNode?.assignedTo?.agentId ?? '').trim();
|
|
2223
|
-
if (!nextAgentId) throw new Error(`Next node ${nextNode.id} missing assignedTo.agentId`);
|
|
2224
|
-
|
|
2225
|
-
await enqueueTask(teamDir, nextAgentId, {
|
|
2226
|
-
teamId,
|
|
2227
|
-
runId: task.runId,
|
|
2228
|
-
nodeId: nextNode.id,
|
|
2229
|
-
kind: 'execute_node',
|
|
2230
|
-
});
|
|
2231
|
-
|
|
2232
|
-
await writeRunFile(runPath, (cur) => ({
|
|
2233
|
-
...cur,
|
|
2234
|
-
updatedAt: new Date().toISOString(),
|
|
2235
|
-
status: 'waiting_workers',
|
|
2236
|
-
nextNodeIndex: enqueueIdx,
|
|
2237
|
-
events: [...cur.events, { ts: new Date().toISOString(), type: 'node.enqueued', nodeId: nextNode.id, agentId: nextAgentId }],
|
|
2238
|
-
}));
|
|
2239
|
-
|
|
2240
|
-
results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'ok' });
|
|
2241
|
-
} finally {
|
|
2242
|
-
if (lockHeld) {
|
|
2243
|
-
try {
|
|
2244
|
-
await fs.unlink(lockPath);
|
|
2245
|
-
} catch {
|
|
2246
|
-
// ignore
|
|
2247
|
-
}
|
|
2248
|
-
}
|
|
2249
|
-
try {
|
|
2250
|
-
await releaseTaskClaim(teamDir, agentId, task.id);
|
|
2251
|
-
} catch {
|
|
2252
|
-
// ignore
|
|
2253
|
-
}
|
|
2254
|
-
}
|
|
2255
|
-
|
|
2256
|
-
}
|
|
2257
|
-
|
|
2258
|
-
return { ok: true as const, teamId, agentId, workerId, results };
|
|
2259
|
-
}
|