@jiggai/recipes 0.4.35 → 0.4.37

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/README.md CHANGED
@@ -4,6 +4,19 @@
4
4
  <img src="https://github.com/JIGGAI/ClawRecipes/blob/main/clawcipes_cook.jpg" alt="ClawRecipes logo" width="240" />
5
5
  </p>
6
6
 
7
+ <p align="center">
8
+ <img src="https://img.shields.io/github/v/release/JIGGAI/ClawRecipes?color=green&label=Latest%20Release" alt="Latest Release">
9
+ <img src="https://img.shields.io/github/license/JIGGAI/ClawRecipes?color=orange" alt="License Apache 2.0">
10
+ <img src="https://img.shields.io/github/actions/workflow/status/JIGGAI/ClawRecipes/release.yml?label=Build" alt="Build Status">
11
+ <img src="https://img.shields.io/badge/TypeScript-007ACC?logo=typescript&logoColor=white" alt="TypeScript">
12
+ <img src="https://img.shields.io/badge/Node.js-18+-339933?logo=node.js&logoColor=white" alt="Node.js 18+">
13
+ <br>
14
+ <img src="https://img.shields.io/badge/OpenClaw-Plugin-6366f1" alt="OpenClaw Plugin">
15
+ <img src="https://img.shields.io/badge/CLI-Tool-blue" alt="CLI Tool">
16
+ <img src="https://img.shields.io/badge/Team-Automation-8b5cf6" alt="Team Automation">
17
+ <img src="https://img.shields.io/badge/Workflow-Engine-0891b2" alt="Workflow Engine">
18
+ </p>
19
+
7
20
  ClawRecipes is an **OpenClaw plugin** for scaffolding agents, teams, and file-first workflows from Markdown recipes.
8
21
 
9
22
  If you want the short version:
@@ -2,7 +2,7 @@
2
2
  "id": "recipes",
3
3
  "name": "Recipes",
4
4
  "description": "Markdown recipes that scaffold agents and teams (workspace-local).",
5
- "version": "0.4.35",
5
+ "version": "0.4.37",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jiggai/recipes",
3
- "version": "0.4.35",
3
+ "version": "0.4.37",
4
4
  "description": "ClawRecipes plugin for OpenClaw (markdown recipes -> scaffold agents/teams)",
5
5
  "main": "index.ts",
6
6
  "type": "commonjs",
@@ -3,7 +3,7 @@ import { MediaDriver, MediaDriverInvokeOpts, MediaDriverResult, DurationConstrai
3
3
  import { findSkillDir, findVenvPython, runScript, parseMediaOutput } from './utils';
4
4
 
5
5
  export class LumaVideo implements MediaDriver {
6
- slug = 'skill-luma-video';
6
+ slug = 'luma-video';
7
7
  mediaType = 'video' as const;
8
8
  displayName = 'Luma Video Generation';
9
9
  requiredEnvVars = ['LUMAAI_API_KEY'];
@@ -3,7 +3,7 @@ import { MediaDriver, MediaDriverInvokeOpts, MediaDriverResult, DurationConstrai
3
3
  import { findSkillDir, findVenvPython, runScript, parseMediaOutput } from './utils';
4
4
 
5
5
  export class RunwayVideo implements MediaDriver {
6
- slug = 'skill-runway-video';
6
+ slug = 'runway-video';
7
7
  mediaType = 'video' as const;
8
8
  displayName = 'Runway Video Generation';
9
9
  requiredEnvVars = ['RUNWAYML_API_SECRET'];
@@ -193,6 +193,19 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
193
193
 
194
194
  const results: Array<{ taskId: string; runId: string; nodeId: string; status: string }> = [];
195
195
 
196
+ // Default lock TTL (used when we don't know the node config yet).
197
+ // This must be comfortably larger than typical media generation durations.
198
+ const DEFAULT_LOCK_TTL_MS = 30 * 60 * 1000;
199
+
200
+ // Once we know the node config, we can set a tighter (but still safe) TTL.
201
+ const MIN_NODE_LOCK_TTL_MS = 10 * 60 * 1000;
202
+ const LOCK_TTL_BUFFER_MS = 2 * 60 * 1000;
203
+ const getNodeLockTtlMs = (node: WorkflowNode): number => {
204
+ const timeoutMsRaw = asRecord(node?.config ?? {})['timeoutMs'];
205
+ const timeoutMs = typeof timeoutMsRaw === 'number' && Number.isFinite(timeoutMsRaw) ? timeoutMsRaw : 0;
206
+ return Math.max(MIN_NODE_LOCK_TTL_MS, timeoutMs + LOCK_TTL_BUFFER_MS);
207
+ };
208
+
196
209
  for (let i = 0; i < limit; i++) {
197
210
  const dq = await dequeueNextTask(teamDir, agentId, { workerId, leaseSeconds: 120 });
198
211
  if (!dq.ok || !dq.task) break;
@@ -209,9 +222,19 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
209
222
 
210
223
  await ensureDir(lockDir);
211
224
 
225
+ const claimedAtIso = new Date().toISOString();
226
+ const lockInfo = {
227
+ workerId,
228
+ pid: process.pid,
229
+ taskId: task.id,
230
+ claimedAt: claimedAtIso,
231
+ ttlMs: DEFAULT_LOCK_TTL_MS,
232
+ expiresAt: new Date(Date.now() + DEFAULT_LOCK_TTL_MS).toISOString(),
233
+ };
234
+
212
235
  // Node-level lock to prevent double execution.
213
236
  try {
214
- await fs.writeFile(lockPath, JSON.stringify({ workerId, taskId: task.id, claimedAt: new Date().toISOString() }, null, 2), { encoding: 'utf8', flag: 'wx' });
237
+ await fs.writeFile(lockPath, JSON.stringify(lockInfo, null, 2), { encoding: 'utf8', flag: 'wx' });
215
238
  lockHeld = true;
216
239
  } catch {
217
240
  // Lock exists. Treat it as contention unless it looks stale.
@@ -219,10 +242,27 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
219
242
  let unlocked = false;
220
243
  try {
221
244
  const raw = await readTextFile(lockPath);
222
- const parsed = JSON.parse(raw) as { claimedAt?: string };
245
+ const parsed = JSON.parse(raw) as { claimedAt?: string; ttlMs?: number; expiresAt?: string };
246
+
247
+ const expiresAtMs = parsed?.expiresAt ? Date.parse(String(parsed.expiresAt)) : NaN;
223
248
  const claimedAtMs = parsed?.claimedAt ? Date.parse(String(parsed.claimedAt)) : NaN;
224
- const ageMs = Number.isFinite(claimedAtMs) ? Date.now() - claimedAtMs : NaN;
225
- const stale = Number.isFinite(ageMs) && ageMs > 10 * 60 * 1000;
249
+ const parsedTtlMs = typeof parsed?.ttlMs === 'number' && Number.isFinite(parsed.ttlMs) ? parsed.ttlMs : NaN;
250
+
251
+ const computedExpiryMs = Number.isFinite(claimedAtMs) && Number.isFinite(parsedTtlMs)
252
+ ? claimedAtMs + parsedTtlMs
253
+ : NaN;
254
+
255
+ // Prefer explicit expiresAt from the lock file; otherwise fall back to (claimedAt + ttlMs).
256
+ // If neither exists (older locks), fall back to DEFAULT_LOCK_TTL_MS.
257
+ const effectiveExpiryMs = Number.isFinite(expiresAtMs)
258
+ ? expiresAtMs
259
+ : Number.isFinite(computedExpiryMs)
260
+ ? computedExpiryMs
261
+ : Number.isFinite(claimedAtMs)
262
+ ? claimedAtMs + DEFAULT_LOCK_TTL_MS
263
+ : NaN;
264
+
265
+ const stale = Number.isFinite(effectiveExpiryMs) && Date.now() > effectiveExpiryMs;
226
266
  if (stale) {
227
267
  await fs.unlink(lockPath);
228
268
  unlocked = true;
@@ -233,7 +273,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
233
273
 
234
274
  if (unlocked) {
235
275
  try {
236
- await fs.writeFile(lockPath, JSON.stringify({ workerId, taskId: task.id, claimedAt: new Date().toISOString() }, null, 2), { encoding: 'utf8', flag: 'wx' });
276
+ await fs.writeFile(lockPath, JSON.stringify(lockInfo, null, 2), { encoding: 'utf8', flag: 'wx' });
237
277
  lockHeld = true;
238
278
  } catch { // intentional: lock contention, skip task
239
279
  results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_locked' });
@@ -255,35 +295,48 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
255
295
  const runId = task.runId;
256
296
 
257
297
  const { run } = await loadRunFile(teamDir, runsDir, runId);
258
- const workflowFile = String(run.workflow.file);
259
- const workflowPath = path.join(workflowsDir, workflowFile);
260
- const workflowRaw = await readTextFile(workflowPath);
261
- const workflow = normalizeWorkflow(JSON.parse(workflowRaw));
262
-
263
- const nodeIdx = workflow.nodes.findIndex((n) => String(n.id) === String(task.nodeId));
264
- if (nodeIdx < 0) throw new Error(`Node not found in workflow: ${task.nodeId}`);
265
- const node = workflow.nodes[nodeIdx]!;
266
-
267
- // Stale-task guard: expired claim recovery can surface older queue entries from behind the
268
- // cursor. Before executing a dequeued task, verify that this node is still actually runnable
269
- // for the current run state. Otherwise we can resurrect pre-approval work and overwrite
270
- // canonical node outputs for runs that already advanced.
271
- const currentRun = (await loadRunFile(teamDir, runsDir, task.runId)).run;
272
- const currentNodeStates = loadNodeStatesFromRun(currentRun);
273
- const currentStatus = currentNodeStates[String(node.id)]?.status;
274
- const currentlyRunnableIdx = pickNextRunnableNodeIndex({ workflow, run: currentRun });
275
- if (
276
- currentStatus === 'success' ||
277
- currentStatus === 'error' ||
278
- currentStatus === 'waiting' ||
279
- currentlyRunnableIdx === null ||
280
- String(workflow.nodes[currentlyRunnableIdx]?.id ?? '') !== String(node.id)
281
- ) {
282
- results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_stale' });
283
- continue;
284
- }
298
+ const workflowFile = String(run.workflow.file);
299
+ const workflowPath = path.join(workflowsDir, workflowFile);
300
+ const workflowRaw = await readTextFile(workflowPath);
301
+ const workflow = normalizeWorkflow(JSON.parse(workflowRaw));
302
+
303
+ const nodeIdx = workflow.nodes.findIndex((n) => String(n.id) === String(task.nodeId));
304
+ if (nodeIdx < 0) throw new Error(`Node not found in workflow: ${task.nodeId}`);
305
+ const node = workflow.nodes[nodeIdx]!;
306
+
307
+ // Now that we know the node, tighten the lock TTL based on node.config.timeoutMs.
308
+ try {
309
+ const nodeLockTtlMs = getNodeLockTtlMs(node);
310
+ if (nodeLockTtlMs !== lockInfo.ttlMs) {
311
+ await fs.writeFile(
312
+ lockPath,
313
+ JSON.stringify({ ...lockInfo, ttlMs: nodeLockTtlMs, expiresAt: new Date(Date.now() + nodeLockTtlMs).toISOString() }, null, 2),
314
+ { encoding: 'utf8' },
315
+ );
316
+ }
317
+ } catch { // intentional: best-effort lock metadata update
318
+ // ignore
319
+ }
320
+
321
+ // Stale-task guard: expired claim recovery can surface older queue entries from behind the
322
+ // cursor. Before executing a dequeued task, verify that this node is still actually runnable
323
+ // for the current run state. Otherwise we can resurrect pre-approval work and overwrite
324
+ // canonical node outputs for runs that already advanced.
325
+ const currentNodeStates = loadNodeStatesFromRun(run);
326
+ const currentStatus = currentNodeStates[String(node.id)]?.status;
327
+ const currentlyRunnableIdx = pickNextRunnableNodeIndex({ workflow, run });
328
+ if (
329
+ currentStatus === 'success' ||
330
+ currentStatus === 'error' ||
331
+ currentStatus === 'waiting' ||
332
+ currentlyRunnableIdx === null ||
333
+ String(workflow.nodes[currentlyRunnableIdx]?.id ?? '') !== String(node.id)
334
+ ) {
335
+ results.push({ taskId: task.id, runId: task.runId, nodeId: task.nodeId, status: 'skipped_stale' });
336
+ continue;
337
+ }
285
338
 
286
- // Determine current lane + ticket path.
339
+ // Determine current lane + ticket path.
287
340
  const laneRaw = String(run.ticket.lane);
288
341
  assertLane(laneRaw);
289
342
  let curLane: WorkflowLane = laneRaw as WorkflowLane;
@@ -1064,7 +1117,7 @@ export async function runWorkflowWorkerTick(api: OpenClawPluginApi, opts: {
1064
1117
  }
1065
1118
 
1066
1119
  // ── Step 2: Invoke the media driver to generate actual media ─────
1067
- const providerSlug = provider.startsWith('skill-') ? provider.replace(/^skill-/, '') : provider;
1120
+ const providerSlug = provider;
1068
1121
  const configEnv = await loadConfigEnv();
1069
1122
  const mergedEnv = { ...process.env, ...configEnv } as Record<string, string>;
1070
1123