@jiggai/recipes 0.4.20 → 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.
@@ -140,6 +140,45 @@ Default behavior: **quiet**. If there is nothing to report, do nothing.
140
140
  await writeFileSafely(path.join(teamDir, "TICKETS.md"), ticketsMd, mode);
141
141
  }
142
142
 
143
+ /**
144
+ * Write team-level files from the recipe files[] array.
145
+ *
146
+ * Per-role scaffolding (scaffoldTeamAgents) filters out paths starting with
147
+ * "shared-context/" and "notes/" because those belong to the team workspace root,
148
+ * not individual role directories. This function picks up those filtered paths
149
+ * and writes them to teamDir using the recipe's templates.
150
+ */
151
+ async function writeTeamLevelRecipeFiles(opts: {
152
+ recipe: RecipeFrontmatter;
153
+ teamId: string;
154
+ teamDir: string;
155
+ overwrite: boolean;
156
+ }) {
157
+ const { recipe, teamId, teamDir, overwrite } = opts;
158
+ const files = recipe.files ?? [];
159
+ if (!files.length) return;
160
+ const mode = overwrite ? "overwrite" : "createOnly";
161
+ const templates = (recipe.templates ?? {}) as Record<string, unknown>;
162
+ const vars = { teamId, teamDir };
163
+
164
+ for (const f of files) {
165
+ const filePath = String(f.path ?? "").trim();
166
+ if (!filePath) continue;
167
+ // Only process paths that are team-scoped (filtered out of per-role scaffolding).
168
+ if (!filePath.startsWith("shared-context/") && !filePath.startsWith("notes/")) continue;
169
+
170
+ const templateKey = String(f.template ?? "").trim();
171
+ if (!templateKey) continue;
172
+
173
+ const templateContent = templates[templateKey];
174
+ if (typeof templateContent !== "string") continue;
175
+
176
+ const rendered = renderTemplate(templateContent, vars);
177
+ const target = path.join(teamDir, filePath);
178
+ await writeFileSafely(target, rendered, mode);
179
+ }
180
+ }
181
+
143
182
  async function writeTeamMetadataAndConfig(opts: {
144
183
  api: OpenClawPluginApi;
145
184
  teamId: string;
@@ -392,6 +431,8 @@ export async function handleScaffoldTeam(
392
431
  const { loaded, recipe, cfg, workspaceRoot: baseWorkspace } = validation;
393
432
 
394
433
  // Lint (warn-only) for common team scaffolding pitfalls.
434
+ // NOTE: console.warn/error used throughout src/ for [recipes]-prefixed diagnostics.
435
+ // No plugin SDK logger available; these go to stderr which the host captures.
395
436
  for (const issue of lintRecipe(recipe)) {
396
437
  if (issue.level === "warn") console.warn(`[recipes] WARN ${issue.code}: ${issue.message}`);
397
438
  else console.warn(`[recipes] ${issue.code}: ${issue.message}`);
@@ -439,6 +480,11 @@ export async function handleScaffoldTeam(
439
480
  overwrite,
440
481
  qaChecklist,
441
482
  });
483
+ // Write team-level files from the recipe files[] array.
484
+ // Per-role scaffolding filters out shared-context/ and notes/ paths (those belong to teamDir).
485
+ // We render and write them here so recipe-defined team assets are not silently dropped.
486
+ await writeTeamLevelRecipeFiles({ recipe, teamId, teamDir, overwrite });
487
+
442
488
  const heartbeat = buildHeartbeatCronJobsFromTeamRecipe({
443
489
  teamId,
444
490
  recipe,
@@ -11,6 +11,8 @@ export type CronJobSpec = {
11
11
  to?: string;
12
12
  agentId?: string;
13
13
  enabledByDefault?: boolean;
14
+ /** Delivery mode: "none" suppresses announce; "announce" delivers to chat. Omit to use gateway default. */
15
+ delivery?: 'none' | 'announce';
14
16
  };
15
17
 
16
18
  /** Raw input for a cron job from YAML (supports message/task/prompt for backward compat). */
@@ -27,6 +29,7 @@ type CronJobInput = {
27
29
  to?: unknown;
28
30
  agentId?: unknown;
29
31
  enabledByDefault?: unknown;
32
+ delivery?: unknown;
30
33
  };
31
34
 
32
35
  export type RecipeFrontmatter = {
@@ -83,6 +86,7 @@ function buildCronJobSpec(j: CronJobInput, id: string): CronJobSpec {
83
86
  to: j.to != null ? String(j.to) : undefined,
84
87
  agentId: j.agentId != null ? String(j.agentId) : undefined,
85
88
  enabledByDefault: Boolean(j.enabledByDefault ?? false),
89
+ delivery: j.delivery === 'none' || j.delivery === 'announce' ? j.delivery : undefined,
86
90
  };
87
91
  }
88
92
 
@@ -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
+ }