@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
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
|
|
4
|
+
import { resolveTeamDir } from '../workspace';
|
|
5
|
+
import type { ApprovalRecord } from './workflow-types';
|
|
6
|
+
import { enqueueTask } from './workflow-queue';
|
|
7
|
+
import { readTextFile, readJsonFile } from './workflow-runner-io';
|
|
8
|
+
import {
|
|
9
|
+
asRecord, asString,
|
|
10
|
+
normalizeWorkflow,
|
|
11
|
+
fileExists,
|
|
12
|
+
appendRunLog, writeRunFile, loadRunFile,
|
|
13
|
+
pickNextRunnableNodeIndex,
|
|
14
|
+
} from './workflow-utils';
|
|
15
|
+
|
|
16
|
+
async function approvalsPathFor(teamDir: string, runId: string) {
|
|
17
|
+
const runsDir = path.join(teamDir, 'shared-context', 'workflow-runs');
|
|
18
|
+
return path.join(runsDir, runId, 'approvals', 'approval.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function pollWorkflowApprovals(api: OpenClawPluginApi, opts: {
|
|
22
|
+
teamId: string;
|
|
23
|
+
limit?: number;
|
|
24
|
+
}) {
|
|
25
|
+
const teamId = String(opts.teamId);
|
|
26
|
+
const teamDir = resolveTeamDir(api, teamId);
|
|
27
|
+
const runsDir = path.join(teamDir, 'shared-context', 'workflow-runs');
|
|
28
|
+
|
|
29
|
+
if (!(await fileExists(runsDir))) {
|
|
30
|
+
return { ok: true as const, teamId, polled: 0, resumed: 0, skipped: 0, message: 'No workflow-runs directory present.' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const approvalPaths: string[] = [];
|
|
34
|
+
const entries = await fs.readdir(runsDir);
|
|
35
|
+
for (const e of entries) {
|
|
36
|
+
const p = path.join(runsDir, e, 'approvals', 'approval.json');
|
|
37
|
+
if (await fileExists(p)) approvalPaths.push(p);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const limitedPaths = approvalPaths.slice(0, typeof opts.limit === 'number' && opts.limit > 0 ? opts.limit : undefined);
|
|
41
|
+
if (!limitedPaths.length) {
|
|
42
|
+
return { ok: true as const, teamId, polled: 0, resumed: 0, skipped: 0, message: 'No approval records present.' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let resumed = 0;
|
|
46
|
+
let skipped = 0;
|
|
47
|
+
const results: Array<{ runId: string; status: string; action: 'resumed' | 'skipped' | 'error'; message?: string }> = [];
|
|
48
|
+
|
|
49
|
+
for (const approvalPath of limitedPaths) {
|
|
50
|
+
let approval: ApprovalRecord;
|
|
51
|
+
try {
|
|
52
|
+
approval = await readJsonFile<ApprovalRecord>(approvalPath);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
skipped++;
|
|
55
|
+
results.push({ runId: path.basename(path.dirname(path.dirname(approvalPath))), status: 'unknown', action: 'error', message: `Failed to parse: ${(e as Error).message}` });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (approval.status === 'pending') {
|
|
60
|
+
skipped++;
|
|
61
|
+
results.push({ runId: approval.runId, status: approval.status, action: 'skipped' });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (approval.resumedAt) {
|
|
66
|
+
skipped++;
|
|
67
|
+
results.push({ runId: approval.runId, status: approval.status, action: 'skipped', message: 'Already resumed.' });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const res = await resumeWorkflowRun(api, { teamId, runId: approval.runId });
|
|
73
|
+
resumed++;
|
|
74
|
+
results.push({ runId: approval.runId, status: approval.status, action: 'resumed', message: `resume status=${(res as { status?: string }).status ?? 'ok'}` });
|
|
75
|
+
const next: ApprovalRecord = {
|
|
76
|
+
...approval,
|
|
77
|
+
resumedAt: new Date().toISOString(),
|
|
78
|
+
resumedStatus: String((res as { status?: string }).status ?? 'ok'),
|
|
79
|
+
};
|
|
80
|
+
await fs.writeFile(approvalPath, JSON.stringify(next, null, 2), 'utf8');
|
|
81
|
+
} catch (e) {
|
|
82
|
+
results.push({ runId: approval.runId, status: approval.status, action: 'error', message: (e as Error).message });
|
|
83
|
+
const next: ApprovalRecord = {
|
|
84
|
+
...approval,
|
|
85
|
+
resumedAt: new Date().toISOString(),
|
|
86
|
+
resumedStatus: 'error',
|
|
87
|
+
resumeError: (e as Error).message,
|
|
88
|
+
};
|
|
89
|
+
await fs.writeFile(approvalPath, JSON.stringify(next, null, 2), 'utf8');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { ok: true as const, teamId, polled: limitedPaths.length, resumed, skipped, results };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function approveWorkflowRun(api: OpenClawPluginApi, opts: {
|
|
97
|
+
teamId: string;
|
|
98
|
+
runId: string;
|
|
99
|
+
approved: boolean;
|
|
100
|
+
note?: string;
|
|
101
|
+
}) {
|
|
102
|
+
const teamId = String(opts.teamId);
|
|
103
|
+
const runId = String(opts.runId);
|
|
104
|
+
const teamDir = resolveTeamDir(api, teamId);
|
|
105
|
+
|
|
106
|
+
const approvalPath = await approvalsPathFor(teamDir, runId);
|
|
107
|
+
if (!(await fileExists(approvalPath))) {
|
|
108
|
+
throw new Error(`Approval file not found for runId=${runId}: ${path.relative(teamDir, approvalPath)}`);
|
|
109
|
+
}
|
|
110
|
+
const raw = await readTextFile(approvalPath);
|
|
111
|
+
const cur = JSON.parse(raw) as ApprovalRecord;
|
|
112
|
+
const next: ApprovalRecord = {
|
|
113
|
+
...cur,
|
|
114
|
+
status: opts.approved ? 'approved' : 'rejected',
|
|
115
|
+
decidedAt: new Date().toISOString(),
|
|
116
|
+
...(opts.note ? { note: String(opts.note) } : {}),
|
|
117
|
+
};
|
|
118
|
+
await fs.writeFile(approvalPath, JSON.stringify(next, null, 2), 'utf8');
|
|
119
|
+
|
|
120
|
+
return { ok: true as const, runId, status: next.status, approvalFile: path.relative(teamDir, approvalPath) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function resumeWorkflowRun(api: OpenClawPluginApi, opts: {
|
|
124
|
+
teamId: string;
|
|
125
|
+
runId: string;
|
|
126
|
+
}) {
|
|
127
|
+
const teamId = String(opts.teamId);
|
|
128
|
+
const runId = String(opts.runId);
|
|
129
|
+
const teamDir = resolveTeamDir(api, teamId);
|
|
130
|
+
const sharedContextDir = path.join(teamDir, 'shared-context');
|
|
131
|
+
const runsDir = path.join(sharedContextDir, 'workflow-runs');
|
|
132
|
+
const workflowsDir = path.join(sharedContextDir, 'workflows');
|
|
133
|
+
|
|
134
|
+
const loaded = await loadRunFile(teamDir, runsDir, runId);
|
|
135
|
+
const runLogPath = loaded.path;
|
|
136
|
+
const runLog = loaded.run;
|
|
137
|
+
|
|
138
|
+
if (runLog.status === 'completed' || runLog.status === 'rejected') {
|
|
139
|
+
return { ok: true as const, runId, status: runLog.status, message: 'No-op; run already finished.' };
|
|
140
|
+
}
|
|
141
|
+
if (runLog.status !== 'awaiting_approval' && runLog.status !== 'running') {
|
|
142
|
+
throw new Error(`Run is not awaiting approval (status=${runLog.status}).`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const workflowFile = String(runLog.workflow.file);
|
|
146
|
+
const workflowPath = path.join(workflowsDir, workflowFile);
|
|
147
|
+
const workflowRaw = await readTextFile(workflowPath);
|
|
148
|
+
const workflow = normalizeWorkflow(JSON.parse(workflowRaw));
|
|
149
|
+
|
|
150
|
+
const approvalPath = await approvalsPathFor(teamDir, runId);
|
|
151
|
+
if (!(await fileExists(approvalPath))) throw new Error(`Missing approval file: ${path.relative(teamDir, approvalPath)}`);
|
|
152
|
+
const approvalRaw = await readTextFile(approvalPath);
|
|
153
|
+
const approval = JSON.parse(approvalRaw) as ApprovalRecord;
|
|
154
|
+
if (approval.status === 'pending') {
|
|
155
|
+
throw new Error(`Approval still pending. Update ${path.relative(teamDir, approvalPath)} first.`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const ticketPath = path.join(teamDir, runLog.ticket.file);
|
|
159
|
+
|
|
160
|
+
// Find the approval node index.
|
|
161
|
+
const approvalIdx = workflow.nodes.findIndex((n) => n.kind === 'human_approval' && String(n.id) === String(approval.nodeId));
|
|
162
|
+
if (approvalIdx < 0) throw new Error(`Approval node not found in workflow: nodeId=${approval.nodeId}`);
|
|
163
|
+
|
|
164
|
+
if (approval.status === 'rejected') {
|
|
165
|
+
// Denial flow: mark run as needs_revision and loop back to the draft step (or closest prior llm node).
|
|
166
|
+
// This keeps workflows non-terminal on rejection.
|
|
167
|
+
|
|
168
|
+
const approvalNote = String(approval.note ?? '').trim();
|
|
169
|
+
|
|
170
|
+
// Find a reasonable "revise" node: prefer a node with id=draft_assets, else the closest prior llm node.
|
|
171
|
+
let reviseIdx = workflow.nodes.findIndex((n, idx) => idx < approvalIdx && String(n.id) === 'draft_assets');
|
|
172
|
+
if (reviseIdx < 0) {
|
|
173
|
+
for (let i = approvalIdx - 1; i >= 0; i--) {
|
|
174
|
+
if (workflow.nodes[i]?.kind === 'llm') {
|
|
175
|
+
reviseIdx = i;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (reviseIdx < 0) reviseIdx = 0;
|
|
181
|
+
|
|
182
|
+
const reviseNode = workflow.nodes[reviseIdx]!;
|
|
183
|
+
const reviseAgentId = String(reviseNode?.assignedTo?.agentId ?? '').trim();
|
|
184
|
+
if (!reviseAgentId) throw new Error(`Revision node ${reviseNode.id} missing assignedTo.agentId`);
|
|
185
|
+
|
|
186
|
+
// Mark run state as needing revision, and clear nodeStates for nodes from reviseIdx onward.
|
|
187
|
+
const now = new Date().toISOString();
|
|
188
|
+
await writeRunFile(runLogPath, (cur) => {
|
|
189
|
+
const nextStates: Record<string, { status: 'success' | 'error' | 'waiting'; ts: string; message?: string }> = {
|
|
190
|
+
...(cur.nodeStates ?? {}),
|
|
191
|
+
[approval.nodeId]: { status: 'error', ts: now, message: 'rejected' },
|
|
192
|
+
};
|
|
193
|
+
for (let i = reviseIdx; i < (workflow.nodes?.length ?? 0); i++) {
|
|
194
|
+
const id = String(workflow.nodes[i]?.id ?? '').trim();
|
|
195
|
+
if (id) delete nextStates[id];
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
...cur,
|
|
199
|
+
updatedAt: now,
|
|
200
|
+
status: 'needs_revision',
|
|
201
|
+
nextNodeIndex: reviseIdx,
|
|
202
|
+
nodeStates: nextStates,
|
|
203
|
+
events: [
|
|
204
|
+
...cur.events,
|
|
205
|
+
{
|
|
206
|
+
ts: now,
|
|
207
|
+
type: 'run.revision_requested',
|
|
208
|
+
nodeId: approval.nodeId,
|
|
209
|
+
reviseNodeId: reviseNode.id,
|
|
210
|
+
reviseAgentId,
|
|
211
|
+
...(approvalNote ? { note: approvalNote } : {}),
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Clear any stale node locks from the revise node onward.
|
|
218
|
+
// (A revision is a deliberate re-run; prior locks must not permanently block it.)
|
|
219
|
+
try {
|
|
220
|
+
const runPath = runLogPath;
|
|
221
|
+
const runDir = path.dirname(runPath);
|
|
222
|
+
const lockDir = path.join(runDir, 'locks');
|
|
223
|
+
for (let i = reviseIdx; i < (workflow.nodes?.length ?? 0); i++) {
|
|
224
|
+
const id = String(workflow.nodes[i]?.id ?? '').trim();
|
|
225
|
+
if (!id) continue;
|
|
226
|
+
const lp = path.join(lockDir, `${id}.lock`);
|
|
227
|
+
try {
|
|
228
|
+
await fs.unlink(lp);
|
|
229
|
+
} catch { // intentional: best-effort lock cleanup
|
|
230
|
+
// ignore
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} catch { // intentional: best-effort cleanup
|
|
234
|
+
// ignore
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Enqueue the revision node.
|
|
238
|
+
await enqueueTask(teamDir, reviseAgentId, {
|
|
239
|
+
teamId,
|
|
240
|
+
runId,
|
|
241
|
+
nodeId: reviseNode.id,
|
|
242
|
+
kind: 'execute_node',
|
|
243
|
+
// Include human feedback in the packet so prompt templates can use it.
|
|
244
|
+
packet: approvalNote ? { revisionNote: approvalNote } : {},
|
|
245
|
+
} as unknown as Record<string, unknown>);
|
|
246
|
+
|
|
247
|
+
return { ok: true as const, runId, status: 'needs_revision' as const, ticketPath, runLogPath };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Mark node approved if not already recorded.
|
|
251
|
+
const approvedTs = new Date().toISOString();
|
|
252
|
+
await appendRunLog(runLogPath, (cur) => ({
|
|
253
|
+
...cur,
|
|
254
|
+
status: 'running',
|
|
255
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [approval.nodeId]: { status: 'success', ts: approvedTs } },
|
|
256
|
+
events: (cur.events ?? []).some((eRaw) => {
|
|
257
|
+
const e = asRecord(eRaw);
|
|
258
|
+
return asString(e['type']) === 'node.approved' && asString(e['nodeId']) === String(approval.nodeId);
|
|
259
|
+
})
|
|
260
|
+
? cur.events
|
|
261
|
+
: [...cur.events, { ts: approvedTs, type: 'node.approved', nodeId: approval.nodeId }],
|
|
262
|
+
}));
|
|
263
|
+
|
|
264
|
+
// Pull-based execution: enqueue the next runnable node and return.
|
|
265
|
+
let updated = (await loadRunFile(teamDir, runsDir, runId)).run;
|
|
266
|
+
let enqueueIdx = pickNextRunnableNodeIndex({ workflow, run: updated });
|
|
267
|
+
|
|
268
|
+
// Auto-complete start/end nodes.
|
|
269
|
+
while (enqueueIdx !== null) {
|
|
270
|
+
const n = workflow.nodes[enqueueIdx]!;
|
|
271
|
+
const k = String(n.kind ?? '');
|
|
272
|
+
if (k !== 'start' && k !== 'end') break;
|
|
273
|
+
const ts = new Date().toISOString();
|
|
274
|
+
await appendRunLog(runLogPath, (cur) => ({
|
|
275
|
+
...cur,
|
|
276
|
+
nextNodeIndex: enqueueIdx! + 1,
|
|
277
|
+
nodeStates: { ...(cur.nodeStates ?? {}), [n.id]: { status: 'success', ts } },
|
|
278
|
+
events: [...cur.events, { ts, type: 'node.completed', nodeId: n.id, kind: k, noop: true }],
|
|
279
|
+
nodeResults: [...(cur.nodeResults ?? []), { nodeId: n.id, kind: k, noop: true }],
|
|
280
|
+
}));
|
|
281
|
+
updated = (await loadRunFile(teamDir, runsDir, runId)).run;
|
|
282
|
+
enqueueIdx = pickNextRunnableNodeIndex({ workflow, run: updated });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (enqueueIdx === null) {
|
|
286
|
+
await writeRunFile(runLogPath, (cur) => ({
|
|
287
|
+
...cur,
|
|
288
|
+
updatedAt: new Date().toISOString(),
|
|
289
|
+
status: 'completed',
|
|
290
|
+
events: [...cur.events, { ts: new Date().toISOString(), type: 'run.completed' }],
|
|
291
|
+
}));
|
|
292
|
+
return { ok: true as const, runId, status: 'completed' as const, ticketPath, runLogPath };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const node = workflow.nodes[enqueueIdx]!;
|
|
296
|
+
const nextKind = String(node.kind ?? '');
|
|
297
|
+
const nextAgentId = String(node?.assignedTo?.agentId ?? '').trim();
|
|
298
|
+
if (!nextAgentId) throw new Error(`Next runnable node ${node.id} (${nextKind}) missing assignedTo.agentId (required for pull-based execution)`);
|
|
299
|
+
|
|
300
|
+
await enqueueTask(teamDir, nextAgentId, {
|
|
301
|
+
teamId,
|
|
302
|
+
runId,
|
|
303
|
+
nodeId: node.id,
|
|
304
|
+
kind: 'execute_node',
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
await writeRunFile(runLogPath, (cur) => ({
|
|
308
|
+
...cur,
|
|
309
|
+
updatedAt: new Date().toISOString(),
|
|
310
|
+
status: 'waiting_workers',
|
|
311
|
+
nextNodeIndex: enqueueIdx,
|
|
312
|
+
events: [...cur.events, { ts: new Date().toISOString(), type: 'node.enqueued', nodeId: node.id, agentId: nextAgentId }],
|
|
313
|
+
}));
|
|
314
|
+
|
|
315
|
+
return { ok: true as const, runId, status: 'waiting_workers' as const, ticketPath, runLogPath };
|
|
316
|
+
}
|