@jiggai/recipes 0.4.21 → 0.4.23
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 +512 -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 +580 -0
- package/src/toolsInvoke.ts +1 -1
|
@@ -0,0 +1,512 @@
|
|
|
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
|
+
let text = '';
|
|
196
|
+
try {
|
|
197
|
+
|
|
198
|
+
const priorInput = await loadPriorLlmInput({ runDir, workflow, currentNode: node, currentNodeIndex: i });
|
|
199
|
+
|
|
200
|
+
const timeoutMsRaw = Number(asString(action['timeoutMs'] ?? (node as unknown as { config?: unknown })?.config?.['timeoutMs'] ?? '120000'));
|
|
201
|
+
const timeoutMs = Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0 ? timeoutMsRaw : 120000;
|
|
202
|
+
|
|
203
|
+
const llmRes = await toolsInvoke<unknown>(api, {
|
|
204
|
+
tool: 'llm-task',
|
|
205
|
+
action: 'json',
|
|
206
|
+
args: {
|
|
207
|
+
prompt: task,
|
|
208
|
+
input: { teamId, runId, nodeId: node.id, agentId, ...priorInput },
|
|
209
|
+
timeoutMs,
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const llmRec = asRecord(llmRes);
|
|
214
|
+
const details = asRecord(llmRec['details']);
|
|
215
|
+
const payload = details['json'] ?? (Object.keys(details).length ? details : llmRes) ?? null;
|
|
216
|
+
text = JSON.stringify(payload, null, 2);
|
|
217
|
+
} catch (e) {
|
|
218
|
+
throw new Error(`LLM execution failed for node ${nodeLabel(node)}: ${e instanceof Error ? e.message : String(e)}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const outputObj = {
|
|
222
|
+
runId,
|
|
223
|
+
teamId,
|
|
224
|
+
nodeId: node.id,
|
|
225
|
+
kind: node.kind,
|
|
226
|
+
agentId,
|
|
227
|
+
completedAt: new Date().toISOString(),
|
|
228
|
+
text,
|
|
229
|
+
};
|
|
230
|
+
await fs.writeFile(nodeOutputAbs, JSON.stringify(outputObj, null, 2) + '\n', 'utf8');
|
|
231
|
+
|
|
232
|
+
const completedTs = new Date().toISOString();
|
|
233
|
+
await appendRunLog(runLogPath, (cur) => ({
|
|
234
|
+
...cur,
|
|
235
|
+
nextNodeIndex: i + 1,
|
|
236
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
|
|
237
|
+
events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind: node.kind, nodeOutputPath: path.relative(teamDir, nodeOutputAbs) }],
|
|
238
|
+
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, agentId, nodeOutputPath: path.relative(teamDir, nodeOutputAbs), bytes: Buffer.byteLength(text, 'utf8') }],
|
|
239
|
+
}));
|
|
240
|
+
nodeStates[String(node.id)] = { status: 'success', ts: completedTs };
|
|
241
|
+
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (kind === 'human_approval') {
|
|
246
|
+
const agentId = String(node?.assignedTo?.agentId ?? '');
|
|
247
|
+
const approvalBindingId = String(node?.action?.approvalBindingId ?? '');
|
|
248
|
+
if (!agentId) throw new Error(`Node ${nodeLabel(node)} missing assignedTo.agentId`);
|
|
249
|
+
if (!approvalBindingId) throw new Error(`Node ${nodeLabel(node)} missing action.approvalBindingId`);
|
|
250
|
+
|
|
251
|
+
const { channel, target, accountId } = await resolveApprovalBindingTarget(api, approvalBindingId);
|
|
252
|
+
|
|
253
|
+
// Write a durable approval request file (runner can resume later via CLI).
|
|
254
|
+
// n8n-inspired: approvals live inside the run folder.
|
|
255
|
+
const runDir = path.dirname(runLogPath);
|
|
256
|
+
const approvalsDir = path.join(runDir, 'approvals');
|
|
257
|
+
await ensureDir(approvalsDir);
|
|
258
|
+
const approvalPath = path.join(approvalsDir, 'approval.json');
|
|
259
|
+
const approvalObj = {
|
|
260
|
+
runId,
|
|
261
|
+
teamId,
|
|
262
|
+
workflowFile,
|
|
263
|
+
nodeId: node.id,
|
|
264
|
+
bindingId: approvalBindingId,
|
|
265
|
+
requestedAt: new Date().toISOString(),
|
|
266
|
+
status: 'pending',
|
|
267
|
+
ticket: path.relative(teamDir, curTicketPath),
|
|
268
|
+
runLog: path.relative(teamDir, runLogPath),
|
|
269
|
+
};
|
|
270
|
+
await fs.writeFile(approvalPath, JSON.stringify(approvalObj, null, 2), 'utf8');
|
|
271
|
+
|
|
272
|
+
// Include the proposed post text in the approval request (what will actually be posted).
|
|
273
|
+
const nodeOutputsDir = path.join(runDir, 'node-outputs');
|
|
274
|
+
let proposed = '';
|
|
275
|
+
try {
|
|
276
|
+
// Heuristic: use qc_brand output if present (finalized drafts), otherwise use the immediately prior node.
|
|
277
|
+
const qcId = 'qc_brand';
|
|
278
|
+
const hasQc = (await fileExists(nodeOutputsDir)) && (await fs.readdir(nodeOutputsDir)).some((f) => f.endsWith(`-${qcId}.json`));
|
|
279
|
+
const priorId = hasQc ? qcId : String(workflow.nodes?.[Math.max(0, i - 1)]?.id ?? '');
|
|
280
|
+
if (priorId) proposed = await loadProposedPostTextFromPriorNode({ runDir, nodeOutputsDir, priorNodeId: priorId });
|
|
281
|
+
} catch { // intentional: best-effort proposed text load
|
|
282
|
+
proposed = '';
|
|
283
|
+
}
|
|
284
|
+
proposed = sanitizeDraftOnlyText(proposed);
|
|
285
|
+
|
|
286
|
+
const msg = [
|
|
287
|
+
`Approval requested for workflow run: ${workflow.name ?? workflow.id ?? workflowFile}`,
|
|
288
|
+
`RunId: ${runId}`,
|
|
289
|
+
`Node: ${node.name ?? node.id}`,
|
|
290
|
+
`Ticket: ${path.relative(teamDir, curTicketPath)}`,
|
|
291
|
+
`Run log: ${path.relative(teamDir, runLogPath)}`,
|
|
292
|
+
`Approval file: ${path.relative(teamDir, approvalPath)}`,
|
|
293
|
+
proposed ? `\n---\nPROPOSED POST (X)\n---\n${proposed}` : `\n(Warning: no proposed text found to preview)`,
|
|
294
|
+
`\nTo approve/reject:`,
|
|
295
|
+
`- approve ${String(approvalObj['code'] ?? '').trim() || '(code in approval file)'}`,
|
|
296
|
+
`- decline ${String(approvalObj['code'] ?? '').trim() || '(code in approval file)'}`,
|
|
297
|
+
`\n(Or via CLI)`,
|
|
298
|
+
`- openclaw recipes workflows approve --team-id ${teamId} --run-id ${runId} --approved true`,
|
|
299
|
+
`- openclaw recipes workflows approve --team-id ${teamId} --run-id ${runId} --approved false --note "<what to change>"`,
|
|
300
|
+
`Then resume:`,
|
|
301
|
+
`- openclaw recipes workflows resume --team-id ${teamId} --run-id ${runId}`,
|
|
302
|
+
].join('\n');
|
|
303
|
+
|
|
304
|
+
await toolsInvoke<ToolTextResult>(api, {
|
|
305
|
+
tool: 'message',
|
|
306
|
+
args: {
|
|
307
|
+
action: 'send',
|
|
308
|
+
channel,
|
|
309
|
+
target,
|
|
310
|
+
...(accountId ? { accountId } : {}),
|
|
311
|
+
message: msg,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const waitingTs = new Date().toISOString();
|
|
316
|
+
await appendRunLog(runLogPath, (cur) => ({
|
|
317
|
+
...cur,
|
|
318
|
+
status: 'awaiting_approval',
|
|
319
|
+
nextNodeIndex: i + 1,
|
|
320
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'waiting', ts: waitingTs } },
|
|
321
|
+
events: [...cur.events, { ts: waitingTs, type: 'node.awaiting_approval', nodeId: node.id, bindingId: approvalBindingId, approvalFile: path.relative(teamDir, approvalPath) }],
|
|
322
|
+
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, approvalBindingId, approvalFile: path.relative(teamDir, approvalPath) }],
|
|
323
|
+
}));
|
|
324
|
+
|
|
325
|
+
nodeStates[String(node.id)] = { status: 'waiting', ts: waitingTs };
|
|
326
|
+
return { ticketPath: curTicketPath, lane: curLane, status: 'awaiting_approval' };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (kind === 'writeback') {
|
|
330
|
+
const agentId = String(node?.assignedTo?.agentId ?? '');
|
|
331
|
+
const writebackPaths = Array.isArray(node?.action?.writebackPaths) ? node.action.writebackPaths.map(String) : [];
|
|
332
|
+
if (!agentId) throw new Error(`Node ${nodeLabel(node)} missing assignedTo.agentId`);
|
|
333
|
+
if (!writebackPaths.length) throw new Error(`Node ${nodeLabel(node)} missing action.writebackPaths[]`);
|
|
334
|
+
|
|
335
|
+
const stamp = `\n\n---\nWorkflow writeback (${runId}) @ ${new Date().toISOString()}\n---\n`;
|
|
336
|
+
const content = `${stamp}Run log: ${path.relative(teamDir, runLogPath)}\nTicket: ${path.relative(teamDir, curTicketPath)}\n`;
|
|
337
|
+
|
|
338
|
+
for (const p of writebackPaths) {
|
|
339
|
+
const abs = path.resolve(teamDir, p);
|
|
340
|
+
await ensureDir(path.dirname(abs));
|
|
341
|
+
const prev = (await fileExists(abs)) ? await readTextFile(abs) : '';
|
|
342
|
+
await fs.writeFile(abs, prev + content, 'utf8');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const completedTs = new Date().toISOString();
|
|
346
|
+
await appendRunLog(runLogPath, (cur) => ({
|
|
347
|
+
...cur,
|
|
348
|
+
nextNodeIndex: i + 1,
|
|
349
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
|
|
350
|
+
events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind: node.kind, writebackPaths }],
|
|
351
|
+
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind: node.kind, writebackPaths }],
|
|
352
|
+
}));
|
|
353
|
+
nodeStates[String(node.id)] = { status: 'success', ts: completedTs };
|
|
354
|
+
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (kind === 'tool') {
|
|
359
|
+
const toolName = String(node?.action?.tool ?? '');
|
|
360
|
+
const toolArgs = (node?.action?.args ?? {}) as Record<string, unknown>;
|
|
361
|
+
if (!toolName) throw new Error(`Node ${nodeLabel(node)} missing action.tool`);
|
|
362
|
+
|
|
363
|
+
const runDir = path.dirname(runLogPath);
|
|
364
|
+
const artifactsDir = path.join(runDir, 'artifacts');
|
|
365
|
+
await ensureDir(artifactsDir);
|
|
366
|
+
const artifactPath = path.join(artifactsDir, `${String(i).padStart(3, '0')}-${node.id}.tool.json`);
|
|
367
|
+
|
|
368
|
+
const vars = {
|
|
369
|
+
date: new Date().toISOString(),
|
|
370
|
+
'run.id': runId,
|
|
371
|
+
'workflow.id': String(workflow.id ?? ''),
|
|
372
|
+
'workflow.name': String(workflow.name ?? workflow.id ?? workflowFile),
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
// Runner-native tools (preferred): do NOT depend on gateway tool exposure.
|
|
377
|
+
if (toolName === 'fs.append') {
|
|
378
|
+
const relPathRaw = String(toolArgs.path ?? '').trim();
|
|
379
|
+
const contentRaw = String(toolArgs.content ?? '');
|
|
380
|
+
if (!relPathRaw) throw new Error('fs.append requires args.path');
|
|
381
|
+
if (!contentRaw) throw new Error('fs.append requires args.content');
|
|
382
|
+
|
|
383
|
+
const relPath = templateReplace(relPathRaw, vars);
|
|
384
|
+
const abs = path.resolve(teamDir, relPath);
|
|
385
|
+
if (!abs.startsWith(teamDir + path.sep) && abs !== teamDir) {
|
|
386
|
+
throw new Error('fs.append path must be within the team workspace');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
await ensureDir(path.dirname(abs));
|
|
390
|
+
const content = templateReplace(contentRaw, vars);
|
|
391
|
+
await fs.appendFile(abs, content, 'utf8');
|
|
392
|
+
|
|
393
|
+
const result = { appendedTo: path.relative(teamDir, abs), bytes: Buffer.byteLength(content, 'utf8') };
|
|
394
|
+
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2), 'utf8');
|
|
395
|
+
|
|
396
|
+
const completedTs = new Date().toISOString();
|
|
397
|
+
await appendRunLog(runLogPath, (cur) => ({
|
|
398
|
+
...cur,
|
|
399
|
+
nextNodeIndex: i + 1,
|
|
400
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
|
|
401
|
+
events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
402
|
+
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
403
|
+
}));
|
|
404
|
+
nodeStates[String(node.id)] = { status: 'success', ts: completedTs };
|
|
405
|
+
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
if (toolName === 'outbound.post') {
|
|
412
|
+
// Outbound posting (local-first v0.1): publish via an external HTTP service.
|
|
413
|
+
// IMPORTANT: this runner-native tool intentionally does NOT read draft text from disk.
|
|
414
|
+
// Provide `args.text` directly from upstream LLM nodes, and (optionally) an approval receipt.
|
|
415
|
+
const pluginCfg = asRecord(asRecord(api)['pluginConfig']);
|
|
416
|
+
const outboundCfg = asRecord(pluginCfg['outbound']);
|
|
417
|
+
|
|
418
|
+
const baseUrl = String(outboundCfg['baseUrl'] ?? '').trim();
|
|
419
|
+
const apiKey = String(outboundCfg['apiKey'] ?? '').trim();
|
|
420
|
+
if (!baseUrl) throw new Error('outbound.post requires plugin config outbound.baseUrl');
|
|
421
|
+
if (!apiKey) throw new Error('outbound.post requires plugin config outbound.apiKey');
|
|
422
|
+
const platform = String(toolArgs.platform ?? '').trim();
|
|
423
|
+
const textRaw = String(toolArgs.text ?? '');
|
|
424
|
+
const text = sanitizeOutboundPostText(textRaw);
|
|
425
|
+
const idempotencyKey = String(toolArgs.idempotencyKey ?? `${task.runId}:${node.id}`).trim();
|
|
426
|
+
const runContext = asRecord(toolArgs.runContext);
|
|
427
|
+
const approval = toolArgs.approval ? asRecord(toolArgs.approval) : undefined;
|
|
428
|
+
const media = Array.isArray(toolArgs.media) ? toolArgs.media : undefined;
|
|
429
|
+
const dryRun = toolArgs.dryRun === true;
|
|
430
|
+
|
|
431
|
+
if (!platform) throw new Error('outbound.post requires args.platform');
|
|
432
|
+
if (!text) throw new Error('outbound.post requires args.text');
|
|
433
|
+
if (!idempotencyKey) throw new Error('outbound.post requires args.idempotencyKey');
|
|
434
|
+
|
|
435
|
+
const workflowId = String(workflow.id ?? '');
|
|
436
|
+
|
|
437
|
+
const result = await outboundPublish({
|
|
438
|
+
baseUrl,
|
|
439
|
+
apiKey,
|
|
440
|
+
platform: platform as OutboundPlatform,
|
|
441
|
+
idempotencyKey,
|
|
442
|
+
request: {
|
|
443
|
+
text,
|
|
444
|
+
media: media as unknown as OutboundMedia[],
|
|
445
|
+
runContext: {
|
|
446
|
+
teamId: String(runContext.teamId ?? ''),
|
|
447
|
+
workflowId: String(runContext.workflowId ?? workflowId),
|
|
448
|
+
workflowRunId: String(runContext.workflowRunId ?? task.runId),
|
|
449
|
+
nodeId: String(runContext.nodeId ?? node.id),
|
|
450
|
+
ticketPath: typeof runContext.ticketPath === 'string' ? runContext.ticketPath : undefined,
|
|
451
|
+
},
|
|
452
|
+
approval: approval as unknown as OutboundApproval,
|
|
453
|
+
dryRun,
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2), 'utf8');
|
|
458
|
+
|
|
459
|
+
const completedTs = new Date().toISOString();
|
|
460
|
+
await appendRunLog(runLogPath, (cur) => ({
|
|
461
|
+
...cur,
|
|
462
|
+
nextNodeIndex: i + 1,
|
|
463
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
|
|
464
|
+
events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
465
|
+
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
466
|
+
}));
|
|
467
|
+
nodeStates[String(node.id)] = { status: 'success', ts: completedTs };
|
|
468
|
+
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Fallback: attempt to invoke a gateway tool by name.
|
|
473
|
+
const result = await toolsInvoke(api, { tool: toolName, args: toolArgs });
|
|
474
|
+
await fs.writeFile(artifactPath, JSON.stringify({ ok: true, tool: toolName, args: toolArgs, result }, null, 2), 'utf8');
|
|
475
|
+
|
|
476
|
+
const completedTs = new Date().toISOString();
|
|
477
|
+
await appendRunLog(runLogPath, (cur) => ({
|
|
478
|
+
...cur,
|
|
479
|
+
nextNodeIndex: i + 1,
|
|
480
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'success', ts: completedTs } },
|
|
481
|
+
events: [...cur.events, { ts: completedTs, type: 'node.completed', nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
482
|
+
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, tool: toolName, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
483
|
+
}));
|
|
484
|
+
nodeStates[String(node.id)] = { status: 'success', ts: completedTs };
|
|
485
|
+
|
|
486
|
+
continue;
|
|
487
|
+
} catch (e) {
|
|
488
|
+
await fs.writeFile(artifactPath, JSON.stringify({ ok: false, tool: toolName, args: toolArgs, error: (e as Error).message }, null, 2), 'utf8');
|
|
489
|
+
const errTs = new Date().toISOString();
|
|
490
|
+
await appendRunLog(runLogPath, (cur) => ({
|
|
491
|
+
...cur,
|
|
492
|
+
nextNodeIndex: i + 1,
|
|
493
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [node.id]: { status: 'error', ts: errTs, message: (e as Error).message } },
|
|
494
|
+
events: [...cur.events, { ts: errTs, type: 'node.error', nodeId: node.id, kind, tool: toolName, message: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
495
|
+
nodeResults: [...(cur.nodeResults ?? []), { nodeId: node.id, kind, tool: toolName, error: (e as Error).message, artifactPath: path.relative(teamDir, artifactPath) }],
|
|
496
|
+
}));
|
|
497
|
+
nodeStates[String(node.id)] = { status: 'error', ts: errTs };
|
|
498
|
+
throw e;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
throw new Error(`Unsupported node kind: ${node.kind} (${nodeLabel(node)})`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
await appendRunLog(runLogPath, (cur) => ({
|
|
506
|
+
...cur,
|
|
507
|
+
status: 'completed',
|
|
508
|
+
events: [...cur.events, { ts: new Date().toISOString(), type: 'run.completed', lane: curLane }],
|
|
509
|
+
}));
|
|
510
|
+
|
|
511
|
+
return { ticketPath: curTicketPath, lane: curLane, status: 'completed' };
|
|
512
|
+
}
|
|
@@ -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
|
|
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
|
+
}
|