@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.
@@ -0,0 +1,520 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
4
+ import type { ToolTextResult } from '../../toolsInvoke';
5
+ import { toolsInvoke } from '../../toolsInvoke';
6
+ import { loadOpenClawConfig } from '../recipes-config';
7
+ import type { Workflow, WorkflowEdge, WorkflowLane, WorkflowNode, RunLog } from './workflow-types';
8
+ import { outboundPublish, type OutboundApproval, type OutboundMedia, type OutboundPlatform } from './outbound-client';
9
+ import { sanitizeOutboundPostText } from './outbound-sanitize';
10
+ import { loadPriorLlmInput, loadProposedPostTextFromPriorNode } from './workflow-node-output-readers';
11
+ import { readTextFile } from './workflow-runner-io';
12
+ import {
13
+ asRecord, asString,
14
+ ensureDir, fileExists,
15
+ moveRunTicket, appendRunLog, nodeLabel,
16
+ loadNodeStatesFromRun, sanitizeDraftOnlyText, templateReplace,
17
+ } from './workflow-utils';
18
+
19
+ export async function resolveApprovalBindingTarget(api: OpenClawPluginApi, bindingId: string): Promise<{ channel: string; target: string; accountId?: string }> {
20
+ const cfgObj = await loadOpenClawConfig(api);
21
+ const bindings = (cfgObj as { bindings?: Array<{ agentId?: string; match?: { channel?: string; accountId?: string; peer?: { id?: string } } }> }).bindings;
22
+ const m = Array.isArray(bindings)
23
+ ? bindings.find((b) => String(b?.agentId ?? '') === String(bindingId) && b?.match?.channel && b?.match?.peer?.id)
24
+ : null;
25
+ if (!m?.match?.channel || !m.match.peer?.id) {
26
+ throw new Error(
27
+ `Missing approval binding: approvalBindingId=${bindingId}. Expected an openclaw config binding entry like {agentId: "${bindingId}", match: {channel, peer:{id}}}.`
28
+ );
29
+ }
30
+ return { channel: String(m.match.channel), target: String(m.match.peer.id), ...(m.match.accountId ? { accountId: String(m.match.accountId) } : {}) };
31
+ }
32
+
33
+ // eslint-disable-next-line complexity, max-lines-per-function
34
+ export async function executeWorkflowNodes(opts: {
35
+ api: OpenClawPluginApi;
36
+ teamId: string;
37
+ teamDir: string;
38
+ workflow: Workflow;
39
+ workflowPath: string;
40
+ workflowFile: string;
41
+ runId: string;
42
+ runLogPath: string;
43
+ ticketPath: string;
44
+ initialLane: WorkflowLane;
45
+ startNodeIndex?: number;
46
+ }): Promise<{ ticketPath: string; lane: WorkflowLane; status: 'completed' | 'awaiting_approval' | 'rejected' }> {
47
+ const { api, teamId, teamDir, workflow, workflowFile, runId, runLogPath } = opts;
48
+
49
+ const hasEdges = Array.isArray(workflow.edges) && workflow.edges.length > 0;
50
+
51
+ let curLane: WorkflowLane = opts.initialLane;
52
+ let curTicketPath = opts.ticketPath;
53
+
54
+ // Load the current run log so we can resume deterministically (approval resumes, partial runs, etc.).
55
+ const curRunRaw = await readTextFile(runLogPath);
56
+ const curRun = JSON.parse(curRunRaw) as RunLog;
57
+
58
+ const nodeIndexById = new Map<string, number>();
59
+ for (let i = 0; i < workflow.nodes.length; i++) nodeIndexById.set(String(workflow.nodes[i]?.id ?? ''), i);
60
+
61
+ const nodeStates = loadNodeStatesFromRun(curRun);
62
+
63
+ const incomingEdgesByNodeId = new Map<string, WorkflowEdge[]>();
64
+ const edges = Array.isArray(workflow.edges) ? workflow.edges : [];
65
+ for (const e of edges) {
66
+ const to = String(e?.to ?? '');
67
+ if (!to) continue;
68
+ const list = incomingEdgesByNodeId.get(to) ?? [];
69
+ list.push(e as WorkflowEdge);
70
+ incomingEdgesByNodeId.set(to, list);
71
+ }
72
+
73
+ function edgeSatisfied(e: WorkflowEdge): boolean {
74
+ const fromId = String(e.from ?? '');
75
+ const from = nodeStates[fromId]?.status;
76
+ const on = String(e.on ?? 'success');
77
+ if (!from) return false;
78
+ if (on === 'always') return from === 'success' || from === 'error';
79
+ if (on === 'error') return from === 'error';
80
+ return from === 'success';
81
+ }
82
+
83
+ function nodeReady(node: WorkflowNode): boolean {
84
+ const nodeId = String(node?.id ?? '');
85
+ if (!nodeId) return false;
86
+ const st = nodeStates[nodeId]?.status;
87
+ if (st === 'success' || st === 'error' || st === 'waiting') return false;
88
+
89
+ // Explicit input dependencies are AND semantics.
90
+ const inputFrom = node.input?.from;
91
+ if (Array.isArray(inputFrom) && inputFrom.length) {
92
+ return inputFrom.every((dep) => nodeStates[String(dep)]?.status === 'success');
93
+ }
94
+
95
+ if (!hasEdges) return true;
96
+
97
+ const incoming = incomingEdgesByNodeId.get(nodeId) ?? [];
98
+ if (!incoming.length) return true; // root
99
+
100
+ // Minimal semantics: OR. If any incoming edge condition is satisfied, the node can run.
101
+ return incoming.some(edgeSatisfied);
102
+ }
103
+
104
+ function pickNextIndex(): number | null {
105
+ if (!hasEdges) {
106
+ const start = opts.startNodeIndex ?? 0;
107
+ for (let i = start; i < workflow.nodes.length; i++) {
108
+ const nodeId = String(workflow.nodes[i]?.id ?? '');
109
+ if (!nodeId) continue;
110
+ const st = nodeStates[nodeId]?.status;
111
+ if (st === 'success' || st === 'error' || st === 'waiting') continue;
112
+ return i;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ const ready: number[] = [];
118
+ for (let i = 0; i < workflow.nodes.length; i++) {
119
+ const n = workflow.nodes[i]!;
120
+ if (nodeReady(n)) ready.push(i);
121
+ }
122
+ if (!ready.length) return null;
123
+ ready.sort((a, b) => a - b);
124
+ return ready[0] ?? null;
125
+ }
126
+
127
+ // Execute until we either complete or hit a wait state.
128
+ while (true) {
129
+ const i = pickNextIndex();
130
+ if (i === null) break;
131
+
132
+ const node = workflow.nodes[i]!;
133
+ const ts = new Date().toISOString();
134
+
135
+ const laneRaw = node?.lane ? String(node.lane) : null;
136
+ if (laneRaw) {
137
+ if (laneRaw !== curLane) {
138
+ const moved = await moveRunTicket({ teamDir, ticketPath: curTicketPath, toLane: laneRaw });
139
+ curLane = laneRaw;
140
+ curTicketPath = moved.ticketPath;
141
+ await appendRunLog(runLogPath, (cur) => ({
142
+ ...cur,
143
+ ticket: { ...cur.ticket, file: path.relative(teamDir, curTicketPath), lane: curLane },
144
+ events: [...cur.events, { ts, type: 'ticket.moved', lane: curLane, nodeId: node.id }],
145
+ }));
146
+ }
147
+ }
148
+
149
+ const kind = String(node.kind ?? '');
150
+
151
+ // ClawKitchen workflows include explicit start/end nodes; treat them as no-op.
152
+ if (kind === 'start' || kind === 'end') {
153
+ await appendRunLog(runLogPath, (cur) => ({
154
+ ...cur,
155
+ nextNodeIndex: i + 1,
156
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts } },
157
+ events: [...cur.events, { ts, type: 'node.completed', nodeId: node.id, kind }],
158
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, noop: true }],
159
+ }));
160
+ nodeStates[String(node.id)] = { status: 'success', ts };
161
+ continue;
162
+ }
163
+
164
+
165
+ if (kind === 'llm') {
166
+ const agentId = String(node?.assignedTo?.agentId ?? '');
167
+ const action = asRecord(node.action);
168
+ const promptTemplatePath = asString(action['promptTemplatePath']).trim();
169
+ const promptTemplateInline = asString(action['promptTemplate']).trim();
170
+ if (!agentId) throw new Error(`Node ${nodeLabel(node)} missing assignedTo.agentId`);
171
+ if (!promptTemplatePath && !promptTemplateInline) throw new Error(`Node ${nodeLabel(node)} missing action.promptTemplatePath or action.promptTemplate`);
172
+
173
+ const promptPathAbs = promptTemplatePath ? path.resolve(teamDir, promptTemplatePath) : '';
174
+ const runDir = path.dirname(runLogPath);
175
+ const defaultNodeOutputRel = path.join('node-outputs', `${String(i).padStart(3, '0')}-${node.id}.json`);
176
+ const nodeOutputRel = String(node?.output?.path ?? '').trim() || defaultNodeOutputRel;
177
+ const nodeOutputAbs = path.resolve(runDir, nodeOutputRel);
178
+ if (!nodeOutputAbs.startsWith(runDir + path.sep) && nodeOutputAbs !== runDir) {
179
+ throw new Error(`Node output.path must be within the run directory: ${nodeOutputRel}`);
180
+ }
181
+ await ensureDir(path.dirname(nodeOutputAbs));
182
+
183
+ const prompt = promptTemplateInline ? promptTemplateInline : await readTextFile(promptPathAbs);
184
+ const task = [
185
+ `You are executing a workflow run for teamId=${teamId}.`,
186
+ `Workflow: ${workflow.name ?? workflow.id ?? workflowFile}`,
187
+ `RunId: ${runId}`,
188
+ `Node: ${nodeLabel(node)}`,
189
+ `\n---\nPROMPT TEMPLATE\n---\n`,
190
+ prompt.trim(),
191
+ `\n---\nOUTPUT FORMAT\n---\n`,
192
+ `Return ONLY the final content (the runner will store it as JSON).`,
193
+ ].join('\n');
194
+
195
+ // Prefer llm-task-fixed when installed; fall back to llm-task.
196
+ // Avoid depending on sessions_spawn (not always exposed via gateway tools/invoke).
197
+ let text = '';
198
+ try {
199
+ let llmRes: unknown;
200
+ const priorInput = await loadPriorLlmInput({ runDir, workflow, currentNode: node, currentNodeIndex: i });
201
+ try {
202
+ llmRes = await toolsInvoke<unknown>(api, {
203
+ tool: 'llm-task-fixed',
204
+ action: 'json',
205
+ args: {
206
+ prompt: task,
207
+ input: { teamId, runId, nodeId: node.id, agentId, ...priorInput },
208
+ },
209
+ });
210
+ } catch { // intentional: fallback from llm-task-fixed to llm-task
211
+ llmRes = await toolsInvoke<unknown>(api, {
212
+ tool: 'llm-task',
213
+ action: 'json',
214
+ args: {
215
+ prompt: task,
216
+ input: { teamId, runId, nodeId: node.id, agentId, ...priorInput },
217
+ },
218
+ });
219
+ }
220
+
221
+ const llmRec = asRecord(llmRes);
222
+ const details = asRecord(llmRec['details']);
223
+ const payload = details['json'] ?? (Object.keys(details).length ? details : llmRes) ?? null;
224
+ text = JSON.stringify(payload, null, 2);
225
+ } catch (e) {
226
+ throw new Error(`LLM execution failed for node ${nodeLabel(node)}: ${e instanceof Error ? e.message : String(e)}`);
227
+ }
228
+
229
+ const outputObj = {
230
+ runId,
231
+ teamId,
232
+ nodeId: node.id,
233
+ kind: node.kind,
234
+ agentId,
235
+ completedAt: new Date().toISOString(),
236
+ text,
237
+ };
238
+ await fs.writeFile(nodeOutputAbs, JSON.stringify(outputObj, null, 2) + '\n', 'utf8');
239
+
240
+ const completedTs = new Date().toISOString();
241
+ await appendRunLog(runLogPath, (cur) => ({
242
+ ...cur,
243
+ nextNodeIndex: i + 1,
244
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
245
+ events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind: node.kind, nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
246
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId, nodeOutputPath: path.relative(teamDir, nodeOutputAbs), bytes: Buffer.byteLength(text, 'utf8') }],
247
+ }));
248
+ nodeStates[String(node.id)] = { status: 'success', ts: completedTs };
249
+
250
+ continue;
251
+ }
252
+
253
+ if (kind === 'human_approval') {
254
+ const agentId = String(node?.assignedTo?.agentId ?? '');
255
+ const approvalBindingId = String(node?.action?.approvalBindingId ?? '');
256
+ if (!agentId) throw new Error(`Node ${nodeLabel(node)} missing assignedTo.agentId`);
257
+ if (!approvalBindingId) throw new Error(`Node ${nodeLabel(node)} missing action.approvalBindingId`);
258
+
259
+ const { channel, target, accountId } = await resolveApprovalBindingTarget(api, approvalBindingId);
260
+
261
+ // Write a durable approval request file (runner can resume later via CLI).
262
+ // n8n-inspired: approvals live inside the run folder.
263
+ const runDir = path.dirname(runLogPath);
264
+ const approvalsDir = path.join(runDir, 'approvals');
265
+ await ensureDir(approvalsDir);
266
+ const approvalPath = path.join(approvalsDir, 'approval.json');
267
+ const approvalObj = {
268
+ runId,
269
+ teamId,
270
+ workflowFile,
271
+ nodeId: node.id,
272
+ bindingId: approvalBindingId,
273
+ requestedAt: new Date().toISOString(),
274
+ status: 'pending',
275
+ ticket: path.relative(teamDir, curTicketPath),
276
+ runLog: path.relative(teamDir, runLogPath),
277
+ };
278
+ await fs.writeFile(approvalPath, JSON.stringify(approvalObj, null, 2), 'utf8');
279
+
280
+ // Include the proposed post text in the approval request (what will actually be posted).
281
+ const nodeOutputsDir = path.join(runDir, 'node-outputs');
282
+ let proposed = '';
283
+ try {
284
+ // Heuristic: use qc_brand output if present (finalized drafts), otherwise use the immediately prior node.
285
+ const qcId = 'qc_brand';
286
+ const hasQc = (await fileExists(nodeOutputsDir)) && (await fs.readdir(nodeOutputsDir)).some((f) => f.endsWith(`-${qcId}.json`));
287
+ const priorId = hasQc ? qcId : String(workflow.nodes?.[Math.max(0, i - 1)]?.id ?? '');
288
+ if (priorId) proposed = await loadProposedPostTextFromPriorNode({ runDir, nodeOutputsDir, priorNodeId: priorId });
289
+ } catch { // intentional: best-effort proposed text load
290
+ proposed = '';
291
+ }
292
+ proposed = sanitizeDraftOnlyText(proposed);
293
+
294
+ const msg = [
295
+ `Approval requested for workflow run: ${workflow.name ?? workflow.id ?? workflowFile}`,
296
+ `RunId: ${runId}`,
297
+ `Node: ${node.name ?? node.id}`,
298
+ `Ticket: ${path.relative(teamDir, curTicketPath)}`,
299
+ `Run log: ${path.relative(teamDir, runLogPath)}`,
300
+ `Approval file: ${path.relative(teamDir, approvalPath)}`,
301
+ proposed ? `\n---\nPROPOSED POST (X)\n---\n${proposed}` : `\n(Warning: no proposed text found to preview)`,
302
+ `\nTo approve/reject:`,
303
+ `- approve ${String(approvalObj['code'] ?? '').trim() || '(code in approval file)'}`,
304
+ `- decline ${String(approvalObj['code'] ?? '').trim() || '(code in approval file)'}`,
305
+ `\n(Or via CLI)`,
306
+ `- openclaw recipes workflows approve --team-id ${teamId} --run-id ${runId} --approved true`,
307
+ `- openclaw recipes workflows approve --team-id ${teamId} --run-id ${runId} --approved false --note "<what to change>"`,
308
+ `Then resume:`,
309
+ `- openclaw recipes workflows resume --team-id ${teamId} --run-id ${runId}`,
310
+ ].join('\n');
311
+
312
+ await toolsInvoke<ToolTextResult>(api, {
313
+ tool: 'message',
314
+ args: {
315
+ action: 'send',
316
+ channel,
317
+ target,
318
+ ...(accountId ? { accountId } : {}),
319
+ message: msg,
320
+ },
321
+ });
322
+
323
+ const waitingTs = new Date().toISOString();
324
+ await appendRunLog(runLogPath, (cur) => ({
325
+ ...cur,
326
+ status: 'awaiting_approval',
327
+ nextNodeIndex: i + 1,
328
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'waiting', ts: waitingTs } },
329
+ events: [...cur.events, { ts: waitingTs, type: 'node.awaiting_approval', nodeId: node.id, bindingId: approvalBindingId, approvalFile: path.relative(teamDir, approvalPath) }],
330
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, approvalBindingId, approvalFile: path.relative(teamDir, approvalPath) }],
331
+ }));
332
+
333
+ nodeStates[String(node.id)] = { status: 'waiting', ts: waitingTs };
334
+ return { ticketPath: curTicketPath, lane: curLane, status: 'awaiting_approval' };
335
+ }
336
+
337
+ if (kind === 'writeback') {
338
+ const agentId = String(node?.assignedTo?.agentId ?? '');
339
+ const writebackPaths = Array.isArray(node?.action?.writebackPaths) ? node.action.writebackPaths.map(String) : [];
340
+ if (!agentId) throw new Error(`Node ${nodeLabel(node)} missing assignedTo.agentId`);
341
+ if (!writebackPaths.length) throw new Error(`Node ${nodeLabel(node)} missing action.writebackPaths[]`);
342
+
343
+ const stamp = `\n\n---\nWorkflow writeback (${runId}) @ ${new Date().toISOString()}\n---\n`;
344
+ const content = `${stamp}Run log: ${path.relative(teamDir, runLogPath)}\nTicket: ${path.relative(teamDir, curTicketPath)}\n`;
345
+
346
+ for (const p of writebackPaths) {
347
+ const abs = path.resolve(teamDir, p);
348
+ await ensureDir(path.dirname(abs));
349
+ const prev = (await fileExists(abs)) ? await readTextFile(abs) : '';
350
+ await fs.writeFile(abs, prev + content, 'utf8');
351
+ }
352
+
353
+ const completedTs = new Date().toISOString();
354
+ await appendRunLog(runLogPath, (cur) => ({
355
+ ...cur,
356
+ nextNodeIndex: i + 1,
357
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
358
+ events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind: node.kind, writebackPaths }],
359
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, writebackPaths }],
360
+ }));
361
+ nodeStates[String(node.id)] = { status: 'success', ts: completedTs };
362
+
363
+ continue;
364
+ }
365
+
366
+ if (kind === 'tool') {
367
+ const toolName = String(node?.action?.tool ?? '');
368
+ const toolArgs = (node?.action?.args ?? {}) as Record<string, unknown>;
369
+ if (!toolName) throw new Error(`Node ${nodeLabel(node)} missing action.tool`);
370
+
371
+ const runDir = path.dirname(runLogPath);
372
+ const artifactsDir = path.join(runDir, 'artifacts');
373
+ await ensureDir(artifactsDir);
374
+ const artifactPath = path.join(artifactsDir, `${String(i).padStart(3, '0')}-${node.id}.tool.json`);
375
+
376
+ const vars = {
377
+ date: new Date().toISOString(),
378
+ 'run.id': runId,
379
+ 'workflow.id': String(workflow.id ?? ''),
380
+ 'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
381
+ };
382
+
383
+ try {
384
+ // Runner-native tools (preferred): do NOT depend on gateway tool exposure.
385
+ if (toolName === 'fs.append') {
386
+ const relPathRaw = String(toolArgs.path ?? '').trim();
387
+ const contentRaw = String(toolArgs.content ?? '');
388
+ if (!relPathRaw) throw new Error('fs.append requires args.path');
389
+ if (!contentRaw) throw new Error('fs.append requires args.content');
390
+
391
+ const relPath = templateReplace(relPathRaw, vars);
392
+ const abs = path.resolve(teamDir, relPath);
393
+ if (!abs.startsWith(teamDir + path.sep) && abs !== teamDir) {
394
+ throw new Error('fs.append path must be within the team workspace');
395
+ }
396
+
397
+ await ensureDir(path.dirname(abs));
398
+ const content = templateReplace(contentRaw, vars);
399
+ await fs.appendFile(abs, content, 'utf8');
400
+
401
+ const result = { appendedTo: path.relative(teamDir, abs), bytes: Buffer.byteLength(content, 'utf8') };
402
+ await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2), 'utf8');
403
+
404
+ const completedTs = new Date().toISOString();
405
+ await appendRunLog(runLogPath, (cur) => ({
406
+ ...cur,
407
+ nextNodeIndex: i + 1,
408
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
409
+ events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
410
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
411
+ }));
412
+ nodeStates[String(node.id)] = { status: 'success', ts: completedTs };
413
+
414
+ continue;
415
+ }
416
+
417
+
418
+
419
+ if (toolName === 'outbound.post') {
420
+ // Outbound posting (local-first v0.1): publish via an external HTTP service.
421
+ // IMPORTANT: this runner-native tool intentionally does NOT read draft text from disk.
422
+ // Provide `args.text` directly from upstream LLM nodes, and (optionally) an approval receipt.
423
+ const pluginCfg = asRecord(asRecord(api)['pluginConfig']);
424
+ const outboundCfg = asRecord(pluginCfg['outbound']);
425
+
426
+ const baseUrl = String(outboundCfg['baseUrl'] ?? '').trim();
427
+ const apiKey = String(outboundCfg['apiKey'] ?? '').trim();
428
+ if (!baseUrl) throw new Error('outbound.post requires plugin config outbound.baseUrl');
429
+ if (!apiKey) throw new Error('outbound.post requires plugin config outbound.apiKey');
430
+ const platform = String(toolArgs.platform ?? '').trim();
431
+ const textRaw = String(toolArgs.text ?? '');
432
+ const text = sanitizeOutboundPostText(textRaw);
433
+ const idempotencyKey = String(toolArgs.idempotencyKey ?? `${task.runId}:${node.id}`).trim();
434
+ const runContext = asRecord(toolArgs.runContext);
435
+ const approval = toolArgs.approval ? asRecord(toolArgs.approval) : undefined;
436
+ const media = Array.isArray(toolArgs.media) ? toolArgs.media : undefined;
437
+ const dryRun = toolArgs.dryRun === true;
438
+
439
+ if (!platform) throw new Error('outbound.post requires args.platform');
440
+ if (!text) throw new Error('outbound.post requires args.text');
441
+ if (!idempotencyKey) throw new Error('outbound.post requires args.idempotencyKey');
442
+
443
+ const workflowId = String(workflow.id ?? '');
444
+
445
+ const result = await outboundPublish({
446
+ baseUrl,
447
+ apiKey,
448
+ platform: platform as OutboundPlatform,
449
+ idempotencyKey,
450
+ request: {
451
+ text,
452
+ media: media as unknown as OutboundMedia[],
453
+ runContext: {
454
+ teamId: String(runContext.teamId ?? ''),
455
+ workflowId: String(runContext.workflowId ?? workflowId),
456
+ workflowRunId: String(runContext.workflowRunId ?? task.runId),
457
+ nodeId: String(runContext.nodeId ?? node.id),
458
+ ticketPath: typeof runContext.ticketPath === 'string' ? runContext.ticketPath : undefined,
459
+ },
460
+ approval: approval as unknown as OutboundApproval,
461
+ dryRun,
462
+ },
463
+ });
464
+
465
+ await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2), 'utf8');
466
+
467
+ const completedTs = new Date().toISOString();
468
+ await appendRunLog(runLogPath, (cur) => ({
469
+ ...cur,
470
+ nextNodeIndex: i + 1,
471
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
472
+ events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
473
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
474
+ }));
475
+ nodeStates[String(node.id)] = { status: 'success', ts: completedTs };
476
+
477
+ continue;
478
+ }
479
+
480
+ // Fallback: attempt to invoke a gateway tool by name.
481
+ const result = await toolsInvoke(api, { tool: toolName, args: toolArgs });
482
+ await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2), 'utf8');
483
+
484
+ const completedTs = new Date().toISOString();
485
+ await appendRunLog(runLogPath, (cur) => ({
486
+ ...cur,
487
+ nextNodeIndex: i + 1,
488
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
489
+ events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
490
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
491
+ }));
492
+ nodeStates[String(node.id)] = { status: 'success', ts: completedTs };
493
+
494
+ continue;
495
+ } catch (e) {
496
+ await fs.writeFile(artifactPath, JSON.stringify({ ok: false, tool: toolName, args: toolArgs, error: (e as Error).message }, null, 2), 'utf8');
497
+ const errTs = new Date().toISOString();
498
+ await appendRunLog(runLogPath, (cur) => ({
499
+ ...cur,
500
+ nextNodeIndex: i + 1,
501
+ nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errTs, message: (e as Error).message } },
502
+ events: [...cur.events, { ts: errTs, type: 'node.error', nodeId: node.id, kind, tool: toolName, message: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
503
+ nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, tool: toolName, error: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
504
+ }));
505
+ nodeStates[String(node.id)] = { status: 'error', ts: errTs };
506
+ throw e;
507
+ }
508
+ }
509
+
510
+ throw new Error(`Unsupported node kind: ${node.kind} (${nodeLabel(node)})`);
511
+ }
512
+
513
+ await appendRunLog(runLogPath, (cur) => ({
514
+ ...cur,
515
+ status: 'completed',
516
+ events: [...cur.events, { ts: new Date().toISOString(), type: 'run.completed', lane: curLane }],
517
+ }));
518
+
519
+ return { ticketPath: curTicketPath, lane: curLane, status: 'completed' };
520
+ }
@@ -139,7 +139,7 @@ export async function loadPriorLlmInput(opts: {
139
139
  const inputs: Array<Record<string, unknown> & { idx: number; nodeId: string }> = [];
140
140
  for (const nodeId of upstreamNodeIds) {
141
141
  const loaded = await parseNodeOutput(nodeId);
142
- if (loaded) inputs.push(loaded as any);
142
+ if (loaded) inputs.push(loaded);
143
143
  }
144
144
 
145
145
  // IMPORTANT: when there are multiple upstream nodes, pick the most recently-executed one
@@ -26,7 +26,7 @@ async function fileExists(p: string) {
26
26
  try {
27
27
  await fs.stat(p);
28
28
  return true;
29
- } catch {
29
+ } catch { // intentional: best-effort file existence check
30
30
  return false;
31
31
  }
32
32
  }
@@ -48,7 +48,7 @@ async function loadClaim(teamDir: string, agentId: string, taskId: string) {
48
48
  try {
49
49
  const raw = await fs.readFile(p, 'utf8');
50
50
  return JSON.parse(raw) as { workerId?: string; claimedAt?: string; leaseSeconds?: number };
51
- } catch {
51
+ } catch { // intentional: best-effort JSON parse
52
52
  return null;
53
53
  }
54
54
  }
@@ -63,7 +63,7 @@ function isExpiredClaim(claim: { claimedAt?: string; leaseSeconds?: number } | n
63
63
  export async function releaseTaskClaim(teamDir: string, agentId: string, taskId: string) {
64
64
  try {
65
65
  await fs.unlink(claimPathFor(teamDir, agentId, taskId));
66
- } catch {
66
+ } catch { // intentional: best-effort claim cleanup
67
67
  // ignore missing claims
68
68
  }
69
69
  }
@@ -101,7 +101,7 @@ async function loadState(teamDir: string, agentId: string): Promise<QueueState>
101
101
  const parsed = JSON.parse(raw) as QueueState;
102
102
  if (!parsed || typeof parsed.offsetBytes !== 'number') throw new Error('invalid');
103
103
  return parsed;
104
- } catch {
104
+ } catch { // intentional: best-effort state parse, reset to defaults
105
105
  return { offsetBytes: 0, updatedAt: new Date().toISOString() };
106
106
  }
107
107
  }
@@ -146,7 +146,7 @@ export async function readNextTasks(teamDir: string, agentId: string, opts?: { l
146
146
  try {
147
147
  const t = JSON.parse(line) as QueueTask;
148
148
  if (t && t.runId && t.nodeId) tasks.push(t);
149
- } catch {
149
+ } catch { // intentional: skip malformed queue line
150
150
  // ignore malformed line
151
151
  }
152
152
  if (tasks.length >= limit) break;
@@ -194,7 +194,7 @@ export async function dequeueNextTask(
194
194
 
195
195
  try {
196
196
  await writeClaim(false);
197
- } catch {
197
+ } catch { // intentional: lock contention — check existing claim
198
198
  const existing = await loadClaim(teamDir, agentId, t.id);
199
199
  if (String(existing?.workerId ?? '') !== workerId) {
200
200
  if (!isExpiredClaim(existing, leaseSeconds)) {
@@ -243,7 +243,7 @@ export async function dequeueNextTask(
243
243
  let t: QueueTask | null = null;
244
244
  try {
245
245
  t = JSON.parse(line) as QueueTask;
246
- } catch {
246
+ } catch { // intentional: skip malformed queue line
247
247
  await writeState(teamDir, agentId, { offsetBytes: cursor, updatedAt: new Date().toISOString() });
248
248
  continue;
249
249
  }
@@ -272,7 +272,7 @@ export async function dequeueNextTask(
272
272
  let t: QueueTask | null = null;
273
273
  try {
274
274
  t = JSON.parse(line) as QueueTask;
275
- } catch {
275
+ } catch { // intentional: skip malformed queue line
276
276
  continue;
277
277
  }
278
278
  if (!t || !t.id || !t.runId || !t.nodeId) continue;
@@ -288,3 +288,51 @@ export async function dequeueNextTask(
288
288
  await fh.close();
289
289
  }
290
290
  }
291
+
292
+ /**
293
+ * Compact a per-agent queue file by discarding all entries before the current cursor offset.
294
+ * This prevents unbounded queue growth from old processed/stale entries.
295
+ *
296
+ * Safe to call periodically (e.g. at the end of a worker-tick).
297
+ * Only compacts when the consumed prefix exceeds `minWasteBytes` (default 4 KB).
298
+ */
299
+ export async function compactQueue(teamDir: string, agentId: string, opts?: { minWasteBytes?: number }) {
300
+ const minWaste = typeof opts?.minWasteBytes === 'number' ? opts.minWasteBytes : 4096;
301
+ const qPath = queuePathFor(teamDir, agentId);
302
+ if (!(await fileExists(qPath))) return { ok: true as const, compacted: false, reason: 'no queue file' };
303
+
304
+ const st = await loadState(teamDir, agentId);
305
+ if (st.offsetBytes < minWaste) return { ok: true as const, compacted: false, reason: 'below threshold' };
306
+
307
+ // Read the full file, keep only the portion after the cursor.
308
+ const raw = await fs.readFile(qPath);
309
+ const remaining = raw.subarray(st.offsetBytes);
310
+
311
+ // Atomic-ish write: write to temp then rename.
312
+ const tmpPath = qPath + '.compact.tmp';
313
+ await fs.writeFile(tmpPath, remaining);
314
+ await fs.rename(tmpPath, qPath);
315
+
316
+ // Reset offset to 0 since we removed the consumed prefix.
317
+ await writeState(teamDir, agentId, { offsetBytes: 0, updatedAt: new Date().toISOString() });
318
+
319
+ // Also clean up stale claim files for this agent (expired leases with no matching pending task).
320
+ try {
321
+ const claimsBase = claimsDir(teamDir);
322
+ if (await fileExists(claimsBase)) {
323
+ const prefix = `${agentId}.`;
324
+ const files = (await fs.readdir(claimsBase)).filter((f) => f.startsWith(prefix) && f.endsWith('.json'));
325
+ for (const f of files) {
326
+ try {
327
+ const claimRaw = await fs.readFile(path.join(claimsBase, f), 'utf8');
328
+ const claim = JSON.parse(claimRaw) as { claimedAt?: string; leaseSeconds?: number };
329
+ if (isExpiredClaim(claim, 120)) {
330
+ await fs.unlink(path.join(claimsBase, f));
331
+ }
332
+ } catch { /* intentional: best-effort stale claim cleanup */ }
333
+ }
334
+ }
335
+ } catch { /* intentional: best-effort claims cleanup */ }
336
+
337
+ return { ok: true as const, compacted: true, removedBytes: st.offsetBytes, remainingBytes: remaining.length };
338
+ }