@hasna/loops 0.3.47 → 0.3.49

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/dist/cli/index.js CHANGED
@@ -329,6 +329,13 @@ function optionalPositiveInteger(value, label) {
329
329
  throw new Error(`${label} must be a positive integer`);
330
330
  return value;
331
331
  }
332
+ function optionalBoolean(value, label) {
333
+ if (value === undefined)
334
+ return;
335
+ if (typeof value !== "boolean")
336
+ throw new Error(`${label} must be a boolean`);
337
+ return value;
338
+ }
332
339
  function optionalStringArray(value, label) {
333
340
  if (value === undefined)
334
341
  return;
@@ -340,6 +347,18 @@ function optionalStringArray(value, label) {
340
347
  }).filter(Boolean);
341
348
  return values.length ? values : undefined;
342
349
  }
350
+ function optionalAccountRef(value, label) {
351
+ if (value === undefined)
352
+ return;
353
+ assertObject(value, label);
354
+ assertString(value.profile, `${label}.profile`);
355
+ if (value.tool !== undefined)
356
+ assertString(value.tool, `${label}.tool`);
357
+ return {
358
+ profile: value.profile.trim(),
359
+ tool: typeof value.tool === "string" ? value.tool.trim() : undefined
360
+ };
361
+ }
343
362
  function normalizeGoalSpec(value, label = "goal") {
344
363
  if (value === undefined)
345
364
  return;
@@ -386,8 +405,21 @@ function validateTarget(value, label) {
386
405
  if (value.provider !== "codewith")
387
406
  throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
388
407
  }
408
+ if (value.configIsolation !== undefined) {
409
+ assertString(value.configIsolation, `${label}.configIsolation`);
410
+ if (!["safe", "none"].includes(value.configIsolation))
411
+ throw new Error(`${label}.configIsolation must be safe or none`);
412
+ }
413
+ if (value.model !== undefined)
414
+ assertString(value.model, `${label}.model`);
389
415
  if (value.variant !== undefined)
390
416
  assertString(value.variant, `${label}.variant`);
417
+ if (value.agent !== undefined)
418
+ assertString(value.agent, `${label}.agent`);
419
+ if (value.provider === "cursor" && value.variant !== undefined)
420
+ throw new Error(`${label}.variant is not supported for provider cursor`);
421
+ if (value.provider === "codex" && value.agent !== undefined)
422
+ throw new Error(`${label}.agent is not supported for provider codex`);
391
423
  optionalStringArray(value.addDirs, `${label}.addDirs`);
392
424
  if (Array.isArray(value.addDirs) && value.addDirs.length > 0 && !["codewith", "codex"].includes(value.provider)) {
393
425
  throw new Error(`${label}.addDirs is currently supported only for provider codewith or codex`);
@@ -484,8 +516,10 @@ function normalizeCreateWorkflowInput(input) {
484
516
  id: step.id,
485
517
  goal: normalizeGoalSpec(step.goal, `workflow.steps[${index}].goal`),
486
518
  target: validateTarget(step.target, `workflow.steps[${index}].target`),
487
- dependsOn: step.dependsOn ?? [],
488
- continueOnFailure: step.continueOnFailure ?? false
519
+ dependsOn: optionalStringArray(step.dependsOn, `workflow.steps[${index}].dependsOn`) ?? [],
520
+ continueOnFailure: optionalBoolean(step.continueOnFailure, `workflow.steps[${index}].continueOnFailure`) ?? false,
521
+ timeoutMs: optionalPositiveInteger(step.timeoutMs, `workflow.steps[${index}].timeoutMs`),
522
+ account: optionalAccountRef(step.account, `workflow.steps[${index}].account`)
489
523
  };
490
524
  });
491
525
  for (const step of steps) {
@@ -586,6 +620,11 @@ function writeWorkflowRunManifest(args) {
586
620
  }
587
621
 
588
622
  // src/lib/store.ts
623
+ var DEFAULT_RECOVERY_BATCH_LIMIT = 100;
624
+ var DEFAULT_RECOVERY_SCAN_MULTIPLIER = 5;
625
+ var LIVE_EXPIRED_RUN_GRACE_MS = 60000;
626
+ var GENERATED_ROUTE_TEMPLATE_IDS = new Set(["todos-task-worker-verifier", "event-worker-verifier"]);
627
+ var GENERATED_ROUTE_KEYS = new Set(["todos-task", "generic-event"]);
589
628
  function rowToLoop(row) {
590
629
  return {
591
630
  id: row.id,
@@ -852,6 +891,7 @@ class Store {
852
891
  ensurePrivateStorePath(file);
853
892
  this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
854
893
  this.db = new Database(file);
894
+ this.db.exec("PRAGMA foreign_keys = ON;");
855
895
  this.db.exec("PRAGMA busy_timeout = 5000;");
856
896
  this.db.exec("PRAGMA journal_mode = WAL;");
857
897
  if (file !== ":memory:")
@@ -915,6 +955,7 @@ class Store {
915
955
  );
916
956
  CREATE INDEX IF NOT EXISTS idx_runs_loop ON loop_runs(loop_id, created_at);
917
957
  CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
958
+ CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
918
959
  CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
919
960
 
920
961
  CREATE TABLE IF NOT EXISTS daemon_lease (
@@ -1242,39 +1283,51 @@ class Store {
1242
1283
  }
1243
1284
  return rows.map(rowToLoop);
1244
1285
  }
1245
- dueLoops(now) {
1286
+ dueLoops(now, limit = 500) {
1246
1287
  const rows = this.db.query(`SELECT * FROM loops
1247
1288
  WHERE status = 'active'
1248
1289
  AND archived_at IS NULL
1249
1290
  AND next_run_at IS NOT NULL
1250
1291
  AND next_run_at <= ?
1251
- ORDER BY next_run_at ASC`).all(now.toISOString());
1292
+ ORDER BY next_run_at ASC
1293
+ LIMIT ?`).all(now.toISOString(), limit);
1252
1294
  return rows.map(rowToLoop);
1253
1295
  }
1254
1296
  updateLoop(id, patch, opts = {}) {
1255
- const current = this.getLoop(id);
1256
- if (!current)
1257
- throw new Error(`loop not found: ${id}`);
1258
1297
  const updated = (opts.now ?? new Date).toISOString();
1259
- const merged = { ...current, ...patch, updatedAt: updated };
1260
- this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
1261
- expires_at=$expiresAt, updated_at=$updated
1262
- WHERE id=$id
1263
- AND ($daemonLeaseId IS NULL OR EXISTS (
1264
- SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1265
- ))`).run({
1266
- $id: id,
1267
- $status: merged.status,
1268
- $nextRun: merged.nextRunAt ?? null,
1269
- $retrySlot: merged.retryScheduledFor ?? null,
1270
- $expiresAt: merged.expiresAt ?? null,
1271
- $updated: merged.updatedAt,
1272
- $daemonLeaseId: opts.daemonLeaseId ?? null,
1273
- $now: updated
1274
- });
1275
- if (patch.status && patch.status !== "active") {
1276
- const status = patch.status === "paused" ? "deferred" : "cancelled";
1277
- this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
1298
+ this.db.exec("BEGIN IMMEDIATE");
1299
+ try {
1300
+ const current = this.getLoop(id);
1301
+ if (!current)
1302
+ throw new Error(`loop not found: ${id}`);
1303
+ const merged = { ...current, ...patch, updatedAt: updated };
1304
+ const res = this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
1305
+ expires_at=$expiresAt, updated_at=$updated
1306
+ WHERE id=$id
1307
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1308
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1309
+ ))`).run({
1310
+ $id: id,
1311
+ $status: merged.status,
1312
+ $nextRun: merged.nextRunAt ?? null,
1313
+ $retrySlot: merged.retryScheduledFor ?? null,
1314
+ $expiresAt: merged.expiresAt ?? null,
1315
+ $updated: merged.updatedAt,
1316
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1317
+ $now: updated
1318
+ });
1319
+ if (res.changes !== 1)
1320
+ throw new Error("daemon lease lost");
1321
+ if (patch.status && patch.status !== "active") {
1322
+ const status = patch.status === "paused" ? "deferred" : "cancelled";
1323
+ this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
1324
+ }
1325
+ this.db.exec("COMMIT");
1326
+ } catch (error) {
1327
+ try {
1328
+ this.db.exec("ROLLBACK");
1329
+ } catch {}
1330
+ throw error;
1278
1331
  }
1279
1332
  const after = this.getLoop(id);
1280
1333
  if (!after)
@@ -1392,10 +1445,23 @@ class Store {
1392
1445
  })();
1393
1446
  }
1394
1447
  listWorkflows(opts = {}) {
1395
- const limit = opts.limit ?? 200;
1396
- const rows = opts.status ? this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ?").all(opts.status, limit) : this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ?").all(limit);
1448
+ const offset = Math.max(0, opts.offset ?? 0);
1449
+ let rows;
1450
+ if (opts.status && opts.limit !== undefined) {
1451
+ rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?").all(opts.status, opts.limit, offset);
1452
+ } else if (opts.status) {
1453
+ rows = this.db.query("SELECT * FROM workflow_specs WHERE status = ? ORDER BY updated_at DESC LIMIT -1 OFFSET ?").all(opts.status, offset);
1454
+ } else if (opts.limit !== undefined) {
1455
+ rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT ? OFFSET ?").all(opts.limit, offset);
1456
+ } else {
1457
+ rows = this.db.query("SELECT * FROM workflow_specs ORDER BY status ASC, updated_at DESC LIMIT -1 OFFSET ?").all(offset);
1458
+ }
1397
1459
  return rows.map(rowToWorkflow);
1398
1460
  }
1461
+ countWorkflows(opts = {}) {
1462
+ const row = opts.status ? this.db.query("SELECT COUNT(*) AS count FROM workflow_specs WHERE status = ?").get(opts.status) : this.db.query("SELECT COUNT(*) AS count FROM workflow_specs").get();
1463
+ return row?.count ?? 0;
1464
+ }
1399
1465
  archiveWorkflow(idOrName) {
1400
1466
  const workflow = this.requireWorkflow(idOrName);
1401
1467
  const updated = nowIso();
@@ -1405,6 +1471,64 @@ class Store {
1405
1471
  throw new Error(`workflow not found after archive: ${workflow.id}`);
1406
1472
  return archived;
1407
1473
  }
1474
+ generatedRouteArchiveContext(args) {
1475
+ if (!args.loopId || !args.workItemId)
1476
+ return;
1477
+ const workItem = this.getWorkflowWorkItem(args.workItemId);
1478
+ if (!workItem || !GENERATED_ROUTE_KEYS.has(workItem.routeKey))
1479
+ return;
1480
+ const invocation = this.getWorkflowInvocation(workItem.invocationId);
1481
+ if (!invocation?.templateId || !GENERATED_ROUTE_TEMPLATE_IDS.has(invocation.templateId))
1482
+ return;
1483
+ const loop = this.getLoop(args.loopId);
1484
+ if (!loop || loop.schedule.type !== "once" || loop.target.type !== "workflow" || loop.target.workflowId !== args.workflowId)
1485
+ return;
1486
+ const input = loop.target.input ?? {};
1487
+ if (input.workflowWorkItemId && input.workflowWorkItemId !== workItem.id)
1488
+ return;
1489
+ if (input.workflowInvocationId && input.workflowInvocationId !== invocation.id)
1490
+ return;
1491
+ if (workItem.workflowId && workItem.workflowId !== args.workflowId)
1492
+ return;
1493
+ const workflow = this.getWorkflow(args.workflowId);
1494
+ if (!workflow)
1495
+ return;
1496
+ return { workflow, loop, workItem, invocation };
1497
+ }
1498
+ maybeArchiveGeneratedRouteWorkflow(args) {
1499
+ const context = this.generatedRouteArchiveContext(args);
1500
+ if (!context)
1501
+ return;
1502
+ const { workflow, loop, workItem } = context;
1503
+ if (!workflow || workflow.status !== "active")
1504
+ return;
1505
+ const nonTerminal = this.db.query(`SELECT COUNT(*) AS count FROM workflow_runs
1506
+ WHERE workflow_id = ? AND status NOT IN ('succeeded', 'failed', 'timed_out', 'cancelled')`).get(args.workflowId)?.count ?? 0;
1507
+ if (nonTerminal > 0)
1508
+ return;
1509
+ const res = this.db.query("UPDATE workflow_specs SET status='archived', updated_at=? WHERE id=? AND status='active'").run(args.updated, args.workflowId);
1510
+ if (res.changes === 1 && args.workflowRunId) {
1511
+ this.appendWorkflowEvent(args.workflowRunId, "workflow_archived", undefined, {
1512
+ workflowId: args.workflowId,
1513
+ loopId: loop.id,
1514
+ workItemId: workItem.id,
1515
+ routeKey: workItem.routeKey,
1516
+ reason: "terminal generated one-shot route workflow"
1517
+ });
1518
+ }
1519
+ }
1520
+ maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, updated) {
1521
+ const run = this.getWorkflowRun(workflowRunId);
1522
+ if (!run)
1523
+ return;
1524
+ this.maybeArchiveGeneratedRouteWorkflow({
1525
+ workflowId: run.workflowId,
1526
+ loopId: run.loopId,
1527
+ workItemId: run.workItemId,
1528
+ workflowRunId,
1529
+ updated
1530
+ });
1531
+ }
1408
1532
  createWorkflowInvocation(input) {
1409
1533
  const now = nowIso();
1410
1534
  const sourceDedupeKey = input.sourceRef.dedupeKey ?? undefined;
@@ -2190,6 +2314,7 @@ class Store {
2190
2314
  if (changed) {
2191
2315
  const itemStatus = status === "succeeded" ? "succeeded" : status === "cancelled" ? "cancelled" : "failed";
2192
2316
  this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, itemStatus, patch.error, finishedAt);
2317
+ this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, finishedAt);
2193
2318
  }
2194
2319
  this.db.exec("COMMIT");
2195
2320
  } catch (error) {
@@ -2217,6 +2342,7 @@ class Store {
2217
2342
  WHERE workflow_run_id=$workflowRunId AND status IN ('pending', 'running')`).run({ $workflowRunId: workflowRunId, $finished: now, $reason: reason, $updated: now });
2218
2343
  this.setWorkflowWorkItemsForWorkflowRun(workflowRunId, "cancelled", reason, now);
2219
2344
  this.appendWorkflowEvent(workflowRunId, "cancelled", undefined, { reason });
2345
+ this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRunId, now);
2220
2346
  }
2221
2347
  this.db.exec("COMMIT");
2222
2348
  return this.requireWorkflowRun(workflowRunId);
@@ -2469,8 +2595,20 @@ class Store {
2469
2595
  throw new Error(`run not found after finalize: ${id}`);
2470
2596
  if (opts.claimedBy && res.changes !== 1)
2471
2597
  return run;
2472
- if (res.changes === 1)
2598
+ if (res.changes === 1) {
2473
2599
  this.setWorkflowWorkItemsForLoopRun(run, patch.error, finishedAt);
2600
+ const loop = this.getLoop(run.loopId);
2601
+ const itemStatus = workItemStatusForLoopRun(run.status, run.attempt, loop?.maxAttempts);
2602
+ if (loop?.target.type === "workflow" && itemStatus && itemStatus !== "admitted") {
2603
+ const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
2604
+ this.maybeArchiveGeneratedRouteWorkflow({
2605
+ workflowId: loop.target.workflowId,
2606
+ loopId: loop.id,
2607
+ workItemId,
2608
+ updated: finishedAt
2609
+ });
2610
+ }
2611
+ }
2474
2612
  return run;
2475
2613
  }
2476
2614
  heartbeatRunLease(id, claimedBy, leaseMs, now = new Date, opts = {}) {
@@ -2505,14 +2643,40 @@ class Store {
2505
2643
  }
2506
2644
  return rows.map(rowToRun);
2507
2645
  }
2646
+ deferLiveExpiredRun(id, now, opts = {}) {
2647
+ const updated = now.toISOString();
2648
+ const deferredUntil = new Date(now.getTime() + LIVE_EXPIRED_RUN_GRACE_MS).toISOString();
2649
+ this.db.query(`UPDATE loop_runs SET lease_expires_at=$deferredUntil, updated_at=$updated
2650
+ WHERE id=$id AND status='running' AND lease_expires_at <= $now
2651
+ AND ($daemonLeaseId IS NULL OR EXISTS (
2652
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
2653
+ ))`).run({
2654
+ $id: id,
2655
+ $deferredUntil: deferredUntil,
2656
+ $updated: updated,
2657
+ $now: updated,
2658
+ $daemonLeaseId: opts.daemonLeaseId ?? null
2659
+ });
2660
+ }
2508
2661
  recoverExpiredRunLeases(now = new Date, opts = {}) {
2509
- const rows = this.db.query("SELECT * FROM loop_runs WHERE status = 'running' AND lease_expires_at <= ?").all(now.toISOString());
2662
+ const limit = Math.max(1, Math.min(1000, Math.floor(opts.limit ?? DEFAULT_RECOVERY_BATCH_LIMIT)));
2663
+ const scanLimit = Math.max(limit, Math.min(5000, Math.floor(opts.scanLimit ?? limit * DEFAULT_RECOVERY_SCAN_MULTIPLIER)));
2664
+ const rows = this.db.query(`SELECT * FROM loop_runs
2665
+ WHERE status = 'running' AND lease_expires_at <= ?
2666
+ ORDER BY lease_expires_at ASC
2667
+ LIMIT ?`).all(now.toISOString(), scanLimit);
2510
2668
  const recovered = [];
2511
2669
  for (const row of rows) {
2512
- if (row.pid && isProcessAlive(row.pid))
2670
+ if (recovered.length >= limit)
2671
+ break;
2672
+ if (row.pid && isProcessAlive(row.pid)) {
2673
+ this.deferLiveExpiredRun(row.id, now, opts);
2513
2674
  continue;
2514
- if (this.hasLiveWorkflowStepProcesses(row.id))
2675
+ }
2676
+ if (this.hasLiveWorkflowStepProcesses(row.id)) {
2677
+ this.deferLiveExpiredRun(row.id, now, opts);
2515
2678
  continue;
2679
+ }
2516
2680
  const finished = now.toISOString();
2517
2681
  this.db.exec("BEGIN IMMEDIATE");
2518
2682
  try {
@@ -2565,6 +2729,7 @@ class Store {
2565
2729
  loopRunId: row.id
2566
2730
  });
2567
2731
  this.setWorkflowWorkItemsForWorkflowRun(workflowRow.id, "failed", "parent loop run lease expired before completion", finished);
2732
+ this.maybeArchiveTerminalGeneratedRouteWorkflow(workflowRow.id, finished);
2568
2733
  }
2569
2734
  const loop = this.getLoop(row.loop_id);
2570
2735
  const itemStatus = workItemStatusForLoopRun("abandoned", row.attempt, loop?.maxAttempts);
@@ -2572,6 +2737,15 @@ class Store {
2572
2737
  const statuses = itemStatus === "admitted" ? ["admitted", "running", "failed"] : ["admitted", "running"];
2573
2738
  const reason = itemStatus === "admitted" ? "run lease expired before completion; retry pending" : "run lease expired before completion";
2574
2739
  this.setWorkflowWorkItemsForLoop(row.loop_id, itemStatus, reason, finished, statuses);
2740
+ if (loop?.target.type === "workflow" && itemStatus !== "admitted") {
2741
+ const workItemId = loop.target.input?.workflowWorkItemId ?? loop.target.input?.workItemId;
2742
+ this.maybeArchiveGeneratedRouteWorkflow({
2743
+ workflowId: loop.target.workflowId,
2744
+ loopId: loop.id,
2745
+ workItemId,
2746
+ updated: finished
2747
+ });
2748
+ }
2575
2749
  }
2576
2750
  this.db.exec("COMMIT");
2577
2751
  } catch (error) {
@@ -3025,6 +3199,7 @@ var AUTH_ENV_KEYS = [
3025
3199
  "CODEWITH_HOME",
3026
3200
  "CODEX_HOME",
3027
3201
  "CURSOR_CONFIG_DIR",
3202
+ "CURSOR_API_KEY",
3028
3203
  "OPENCODE_CONFIG_DIR",
3029
3204
  "AICOPILOT_CONFIG_DIR",
3030
3205
  "ANTHROPIC_API_KEY",
@@ -3154,6 +3329,10 @@ function assertSupportedAgentOptions(target) {
3154
3329
  assertStringOption(target.model, `${target.provider}.model`);
3155
3330
  assertStringOption(target.agent, `${target.provider}.agent`);
3156
3331
  assertStringOption(target.authProfile, `${target.provider}.authProfile`);
3332
+ assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
3333
+ if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
3334
+ throw new Error(`${target.provider}.configIsolation must be safe or none`);
3335
+ }
3157
3336
  if (target.authProfile !== undefined && target.provider !== "codewith") {
3158
3337
  throw new Error(`${target.provider}.authProfile is supported only for codewith`);
3159
3338
  }
@@ -3166,6 +3345,8 @@ function assertSupportedAgentOptions(target) {
3166
3345
  if (target.sandbox && !["read-only", "workspace-write", "danger-full-access", "enabled", "disabled"].includes(target.sandbox)) {
3167
3346
  throw new Error(`${target.provider}.sandbox is not supported: ${target.sandbox}`);
3168
3347
  }
3348
+ if (target.provider === "codex" && target.agent !== undefined)
3349
+ throw new Error("codex.agent is not supported");
3169
3350
  if (["codewith", "codex"].includes(target.provider)) {
3170
3351
  if (target.permissionMode && !["default", "bypass"].includes(target.permissionMode)) {
3171
3352
  throw new Error(`${target.provider}.permissionMode supports only default or bypass`);
@@ -3180,6 +3361,8 @@ function assertSupportedAgentOptions(target) {
3180
3361
  return;
3181
3362
  }
3182
3363
  if (target.provider === "cursor") {
3364
+ if (target.variant !== undefined)
3365
+ throw new Error("cursor.variant is not supported");
3183
3366
  if (target.permissionMode === "auto")
3184
3367
  throw new Error("cursor.permissionMode auto is not supported; use provider-specific extraArgs for Cursor auto-review");
3185
3368
  if (target.sandbox !== undefined && target.sandbox !== "enabled" && target.sandbox !== "disabled") {
@@ -3221,10 +3404,8 @@ function agentArgs(target) {
3221
3404
  "set -eu",
3222
3405
  "if command -v agent >/dev/null 2>&1; then",
3223
3406
  ' exec agent "$@"',
3224
- "elif command -v cursor >/dev/null 2>&1; then",
3225
- ' exec cursor agent "$@"',
3226
3407
  "else",
3227
- " echo 'Executable not found in PATH: cursor agent or agent' >&2",
3408
+ " echo 'Executable not found in PATH: agent' >&2",
3228
3409
  " exit 127",
3229
3410
  "fi"
3230
3411
  ].join(`
@@ -3265,7 +3446,7 @@ function agentArgs(target) {
3265
3446
  case "codex":
3266
3447
  if (target.variant)
3267
3448
  args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
3268
- args.push("exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
3449
+ args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
3269
3450
  if (isolation === "safe")
3270
3451
  args.push("--ignore-rules");
3271
3452
  if (target.cwd)
@@ -3335,7 +3516,7 @@ function commandSpec(target) {
3335
3516
  account: agentTarget.account,
3336
3517
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
3337
3518
  nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
3338
- preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
3519
+ preflightAnyOf: agentTarget.provider === "cursor" ? ["agent"] : undefined,
3339
3520
  stdin: agentTarget.prompt,
3340
3521
  allowlist: agentTarget.allowlist
3341
3522
  };
@@ -4718,12 +4899,14 @@ function claimDueRuns(deps) {
4718
4899
  const claimed = [];
4719
4900
  const skipped = [];
4720
4901
  const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
4902
+ if (maxClaims === 0)
4903
+ return { claims, claimed, completed: [], skipped, recovered, expired };
4721
4904
  for (const loop of deps.store.dueLoops(now)) {
4722
- if (claims.length >= maxClaims)
4905
+ if (claims.length + skipped.length >= maxClaims)
4723
4906
  break;
4724
4907
  const plan = dueSlots(loop, now);
4725
4908
  for (const slot of plan.slots) {
4726
- if (claims.length >= maxClaims)
4909
+ if (claims.length + skipped.length >= maxClaims)
4727
4910
  break;
4728
4911
  const run = claimSlot(deps, loop, slot);
4729
4912
  if (!run)
@@ -5179,35 +5362,17 @@ import { spawnSync as spawnSync4 } from "child_process";
5179
5362
  import { accessSync as accessSync2, constants as constants2 } from "fs";
5180
5363
  var PROVIDER_COMMANDS = [
5181
5364
  "claude",
5182
- "cursor agent",
5365
+ "agent",
5183
5366
  "codewith",
5184
5367
  "aicopilot",
5185
5368
  "opencode",
5186
5369
  "codex"
5187
5370
  ];
5188
5371
  function hasCommand(command) {
5189
- if (command === "cursor agent") {
5190
- return hasCommand("cursor") || hasCommand("agent");
5191
- }
5192
5372
  const result = spawnSync4("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
5193
5373
  return (result.status ?? 1) === 0;
5194
5374
  }
5195
5375
  function commandVersion(command) {
5196
- if (command === "cursor agent") {
5197
- const cursorResult = spawnSync4("cursor", ["agent", "--version"], {
5198
- encoding: "utf8",
5199
- stdio: ["ignore", "pipe", "pipe"]
5200
- });
5201
- if ((cursorResult.status ?? 1) === 0)
5202
- return (cursorResult.stdout || cursorResult.stderr).trim().split(/\r?\n/)[0];
5203
- const agentResult = spawnSync4("agent", ["--version"], {
5204
- encoding: "utf8",
5205
- stdio: ["ignore", "pipe", "pipe"]
5206
- });
5207
- if ((agentResult.status ?? 1) === 0)
5208
- return (agentResult.stdout || agentResult.stderr).trim().split(/\r?\n/)[0];
5209
- return;
5210
- }
5211
5376
  const result = spawnSync4(command, ["--version"], {
5212
5377
  encoding: "utf8",
5213
5378
  stdio: ["ignore", "pipe", "pipe"]
@@ -5743,7 +5908,7 @@ function buildScriptInventoryReport(store, opts = {}) {
5743
5908
  // package.json
5744
5909
  var package_default = {
5745
5910
  name: "@hasna/loops",
5746
- version: "0.3.47",
5911
+ version: "0.3.49",
5747
5912
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5748
5913
  type: "module",
5749
5914
  main: "dist/index.js",
@@ -6834,6 +6999,13 @@ function validateLoopTargetForStorage(target, context) {
6834
6999
  validationFailed(error, context);
6835
7000
  }
6836
7001
  }
7002
+ function normalizeWorkflowForStorage(body, context) {
7003
+ try {
7004
+ return workflowBodyFromJson(body);
7005
+ } catch (error) {
7006
+ validationFailed(error, context);
7007
+ }
7008
+ }
6837
7009
  function preflightLoopTarget(target, context, metadata, opts) {
6838
7010
  try {
6839
7011
  return preflightTarget(target, metadata, opts);
@@ -6886,6 +7058,14 @@ function positiveInteger(raw, label) {
6886
7058
  throw new Error(`${label} must be a positive integer`);
6887
7059
  return value;
6888
7060
  }
7061
+ function nonNegativeInteger(raw, label) {
7062
+ if (raw === undefined)
7063
+ return;
7064
+ const value = Number(raw);
7065
+ if (!Number.isInteger(value) || value < 0)
7066
+ throw new Error(`${label} must be a non-negative integer`);
7067
+ return value;
7068
+ }
6889
7069
  function positiveDuration(raw, label) {
6890
7070
  if (raw === undefined)
6891
7071
  return;
@@ -7367,6 +7547,23 @@ function taskRouteEligibility(data, metadata) {
7367
7547
  }
7368
7548
  return { eligible: true, tags };
7369
7549
  }
7550
+ function skippedDrainTask(task, event, reason) {
7551
+ const taskId = taskField(task, ["id", "task_id", "taskId"]) ?? event?.subject ?? "unknown";
7552
+ return {
7553
+ kind: "skipped",
7554
+ value: {
7555
+ skipped: true,
7556
+ reason,
7557
+ taskId,
7558
+ event,
7559
+ routeError: true
7560
+ },
7561
+ human: `skipped task ${taskId}: ${reason}`
7562
+ };
7563
+ }
7564
+ function isSkippableDrainRouteError(message) {
7565
+ return message.startsWith("worktreeMode=required but projectPath is not an existing git repository:");
7566
+ }
7370
7567
  function generatedRouteSandboxPreflight(workflow) {
7371
7568
  const checks = [];
7372
7569
  for (const step of workflow.steps) {
@@ -7533,7 +7730,7 @@ function routeTodosTaskEvent(event, opts) {
7533
7730
  const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
7534
7731
  const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
7535
7732
  const throttleLimits = routeThrottleLimitsFromOpts(opts);
7536
- const idempotencyKey = `todos-task:${taskId}:${event.type}`;
7733
+ const idempotencyKey = `todos-task:${taskId}`;
7537
7734
  const idempotencySuffix = stableSuffix(idempotencyKey);
7538
7735
  const namePrefix = opts.namePrefix ?? "event:todos-task";
7539
7736
  const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
@@ -7571,7 +7768,7 @@ function routeTodosTaskEvent(event, opts) {
7571
7768
  const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
7572
7769
  const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
7573
7770
  const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
7574
- const workflowBody = renderTodosTaskWorkerVerifierWorkflow({
7771
+ let workflowBody = renderTodosTaskWorkerVerifierWorkflow({
7575
7772
  taskId,
7576
7773
  taskTitle,
7577
7774
  taskDescription,
@@ -7603,6 +7800,11 @@ function routeTodosTaskEvent(event, opts) {
7603
7800
  });
7604
7801
  workflowBody.name = workflowName;
7605
7802
  workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
7803
+ workflowBody = normalizeWorkflowForStorage(workflowBody, {
7804
+ name: workflowName,
7805
+ type: "todos-task-event-workflow",
7806
+ event: event.id
7807
+ });
7606
7808
  const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
7607
7809
  const invocationInput = {
7608
7810
  templateId: "todos-task-worker-verifier",
@@ -7782,7 +7984,7 @@ function routeGenericEvent(event, opts) {
7782
7984
  const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
7783
7985
  const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
7784
7986
  const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
7785
- const workflowBody = renderEventWorkerVerifierWorkflow({
7987
+ let workflowBody = renderEventWorkerVerifierWorkflow({
7786
7988
  eventId: event.id,
7787
7989
  eventType: event.type,
7788
7990
  eventSource: event.source,
@@ -7814,6 +8016,11 @@ function routeGenericEvent(event, opts) {
7814
8016
  });
7815
8017
  workflowBody.name = workflowName;
7816
8018
  workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
8019
+ workflowBody = normalizeWorkflowForStorage(workflowBody, {
8020
+ name: workflowName,
8021
+ type: "generic-event-workflow",
8022
+ event: event.id
8023
+ });
7817
8024
  const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
7818
8025
  const invocationInput = {
7819
8026
  templateId: "event-worker-verifier",
@@ -8230,8 +8437,12 @@ var goal = program.command("goal").description("inspect goal runs");
8230
8437
  function addRouteEventOptions(command) {
8231
8438
  return command.option("--event-file <file>", "read event envelope JSON from a file instead of stdin/HASNA_EVENT_JSON").option("--event-json <json>", "read event envelope JSON from this string instead of stdin/HASNA_EVENT_JSON").option("--todos-project <path>", "todos storage project path for generated task commands", defaultLoopsProject()).option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix").option("--preflight", "check generated workflow steps before storing the workflow loop");
8232
8439
  }
8233
- function addTodosDrainOptions(command) {
8234
- return command.option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing workflow loops").option("--dry-run", "preview selected tasks and generated workflow loops without storing anything");
8440
+ function addTodosDrainOptions(command, opts = {}) {
8441
+ let configured = command.option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", opts.preflightDescription ?? "check generated workflow steps before storing workflow loops");
8442
+ if (opts.includeDryRun ?? true) {
8443
+ configured = configured.option("--dry-run", "preview selected tasks and generated workflow loops without storing anything");
8444
+ }
8445
+ return configured;
8235
8446
  }
8236
8447
  function routeEventByKind(kind, event, opts) {
8237
8448
  if (kind === "todos-task")
@@ -8312,8 +8523,17 @@ function drainTodosTaskRoutes(opts) {
8312
8523
  for (const task of candidates) {
8313
8524
  if (created >= maxDispatch)
8314
8525
  break;
8315
- const event = taskDrainEvent(task);
8316
- const result = routeTodosTaskEvent(event, opts);
8526
+ let event;
8527
+ let result;
8528
+ try {
8529
+ event = taskDrainEvent(task);
8530
+ result = routeTodosTaskEvent(event, opts);
8531
+ } catch (error) {
8532
+ const message = error instanceof Error ? error.message : String(error);
8533
+ if (!isSkippableDrainRouteError(message))
8534
+ throw error;
8535
+ result = skippedDrainTask(task, event, redact(message, 640) ?? "route task failed");
8536
+ }
8317
8537
  results.push(result);
8318
8538
  if (result.kind === "created" && !opts.dryRun)
8319
8539
  created += 1;
@@ -8374,6 +8594,57 @@ function drainTodosTaskRoutes(opts) {
8374
8594
  } : { ...report, evidencePath };
8375
8595
  print(output, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
8376
8596
  }
8597
+ function formatTemplateVariable(template, name) {
8598
+ const variable = template.variables.find((entry) => entry.name === name);
8599
+ const placeholder = variable?.default ? variable.default : `<${name}>`;
8600
+ return ` --var ${name}=${placeholder}`;
8601
+ }
8602
+ function printTemplateDetails(template) {
8603
+ console.log(`${template.id} (${template.kind})`);
8604
+ console.log(template.name);
8605
+ console.log("");
8606
+ console.log(template.description);
8607
+ console.log("");
8608
+ console.log("Variables:");
8609
+ const nameWidth = Math.max(...template.variables.map((variable) => variable.name.length), 4);
8610
+ for (const variable of template.variables) {
8611
+ const required = variable.required ? "required" : "optional";
8612
+ const defaultValue = variable.default ? ` default=${variable.default}` : "";
8613
+ const description = variable.description ? ` ${variable.description}` : "";
8614
+ console.log(` ${variable.name.padEnd(nameWidth)} ${required}${defaultValue}${description}`);
8615
+ }
8616
+ const requiredVariables = template.variables.filter((variable) => variable.required).map((variable) => variable.name);
8617
+ const hintVariables = requiredVariables.length ? requiredVariables : template.variables.slice(0, 2).map((variable) => variable.name);
8618
+ const renderArgs = hintVariables.map((name) => formatTemplateVariable(template, name));
8619
+ const renderHint = renderArgs.length ? ` \\
8620
+ ${renderArgs.join(" \\\n")}` : "";
8621
+ console.log("");
8622
+ console.log("Usage:");
8623
+ console.log(` loops templates render ${template.id}${renderHint}`);
8624
+ if (template.kind === "workflow")
8625
+ console.log(` loops templates create-workflow ${template.id}${renderHint}`);
8626
+ }
8627
+ function workflowStatusFromOpts(status, all) {
8628
+ if (all) {
8629
+ if (status && status !== "active")
8630
+ throw new Error("use either --all or --status, not both");
8631
+ return;
8632
+ }
8633
+ const value = status ?? "active";
8634
+ if (value === "all")
8635
+ return;
8636
+ if (value === "active" || value === "archived")
8637
+ return value;
8638
+ throw new Error("--status must be active, archived, or all");
8639
+ }
8640
+ function printWorkflowListWarning(args) {
8641
+ if (args.shown + args.offset >= args.total && args.offset === 0)
8642
+ return;
8643
+ const scope = args.status ?? "all";
8644
+ const nextOffset = args.offset + args.shown;
8645
+ const next = args.limit && nextOffset < args.total ? ` next page: --limit ${args.limit} --offset ${nextOffset}` : "";
8646
+ console.error(`showing ${args.offset + args.shown} of ${args.total} ${scope} workflows.${next}`);
8647
+ }
8377
8648
  templates.command("list").alias("ls").description("list built-in OpenLoops templates").action(() => {
8378
8649
  const values = listLoopTemplates();
8379
8650
  if (isJson())
@@ -8388,7 +8659,10 @@ templates.command("show <id>").description("show a built-in template").action((i
8388
8659
  const template = getLoopTemplate(id);
8389
8660
  if (!template)
8390
8661
  throw new Error(`template not found: ${id}`);
8391
- print(template, `${template.id} ${template.kind}`);
8662
+ if (isJson())
8663
+ print(template);
8664
+ else
8665
+ printTemplateDetails(template);
8392
8666
  });
8393
8667
  templates.command("render <id>").description("render a template as workflow JSON").option("--var <key=value>", "template variable; may be repeated", collectValues, []).action((id, opts) => {
8394
8668
  const workflow = renderLoopTemplate(id, parseVars(opts.var));
@@ -8472,7 +8746,10 @@ addTodosDrainOptions(routes.command("drain <kind>").description("drain a durable
8472
8746
  throw new Error("route drain currently supports kind todos-task");
8473
8747
  drainTodosTaskRoutes(opts);
8474
8748
  });
8475
- addScheduleOptions(addTodosDrainOptions(routes.command("schedule <kind> <name>").description("schedule a deterministic route drain loop"))).action((kind, name, opts) => {
8749
+ addScheduleOptions(addTodosDrainOptions(routes.command("schedule <kind> <name>").description("schedule a deterministic route drain loop"), {
8750
+ includeDryRun: false,
8751
+ preflightDescription: "forward generated workflow preflight checks into each future drain run"
8752
+ })).action((kind, name, opts) => {
8476
8753
  if (kind !== "todos-task")
8477
8754
  throw new Error("route schedule currently supports kind todos-task");
8478
8755
  const store = new Store;
@@ -8499,288 +8776,13 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
8499
8776
  print(result.value, result.human);
8500
8777
  });
8501
8778
  var eventsDrain = events.command("drain").description("drain durable source queues into bounded OpenLoops workflows");
8502
- eventsDrain.command("todos-task").description("drain ready todos tasks into bounded worker/verifier workflow loops").option("--todos-project <path>", "todos storage project path", defaultLoopsProject()).option("--todos-project-id <id>", "filter todos ready output to one todos project id").option("--task-list <id-or-slug>", "filter ready tasks to one task-list id, slug, or name").option("--project-path-prefix <path>", "filter ready tasks to a project/repo path prefix").option("--tags <tags>", "require all comma-separated tags before routing").option("--tag <tags>", "alias for --tags").option("--limit <n>", "maximum filtered ready-task candidates to consider", "50").option("--scan-limit <n>", "maximum raw todos ready rows to fetch before filters; defaults to 500 when filters are used").option("--max-dispatch <n>", "maximum new workflow loops to create in this drain run", "1").option("--evidence-dir <path>", "write a JSON drain report to this directory").option("--compact", "print compact JSON to stdout while preserving the full evidence file").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated task worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:todos-task").option("--preflight", "check generated workflow steps before storing workflow loops").option("--dry-run", "preview selected tasks and generated workflow loops without storing anything").action((opts) => {
8503
- const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
8504
- const todosProject = opts.todosProject ?? defaultLoopsProject();
8505
- const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
8506
- const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
8507
- const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
8508
- const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
8509
- const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
8510
- const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
8511
- const ready = loadReadyTodosTasks(opts, scanLimit);
8512
- const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
8513
- projectId: opts.todosProjectId,
8514
- taskListId: taskListFilter,
8515
- projectPathPrefix: opts.projectPathPrefix,
8516
- tags: requiredTags
8517
- }));
8518
- const candidates = filteredCandidates.slice(0, candidateLimit);
8519
- const results = [];
8520
- let created = 0;
8521
- for (const task of candidates) {
8522
- if (created >= maxDispatch)
8523
- break;
8524
- const event = taskDrainEvent(task);
8525
- const result = routeTodosTaskEvent(event, opts);
8526
- results.push(result);
8527
- if (result.kind === "created" && !opts.dryRun)
8528
- created += 1;
8529
- if (result.kind === "created" && opts.dryRun)
8530
- created += 1;
8531
- }
8532
- const report = {
8533
- drainedAt: new Date().toISOString(),
8534
- todosProject,
8535
- todosProjectId: opts.todosProjectId,
8536
- taskList: opts.taskList,
8537
- taskListId: taskListFilter,
8538
- projectPathPrefix: opts.projectPathPrefix,
8539
- tags: requiredTags,
8540
- limit: candidateLimit,
8541
- scanLimit,
8542
- filtersApplied: hasPostFilters,
8543
- scanned: ready.length,
8544
- candidates: candidates.length,
8545
- filteredCandidates: filteredCandidates.length,
8546
- scanExhausted: ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
8547
- considered: results.length,
8548
- created: results.filter((result) => result.kind === "created" && !result.value.deduped).length,
8549
- deduped: results.filter((result) => result.kind === "deduped").length,
8550
- throttled: results.filter((result) => result.kind === "throttled").length,
8551
- skipped: results.filter((result) => result.kind === "skipped").length,
8552
- maxDispatch,
8553
- source: "todos ready",
8554
- dryRun: Boolean(opts.dryRun),
8555
- results: results.map((result) => ({ kind: result.kind, ...result.value }))
8556
- };
8557
- const evidencePath = writeRouteEvidence("todos-task-drain", report, opts.evidenceDir);
8558
- const output = opts.compact ? {
8559
- drainedAt: report.drainedAt,
8560
- todosProject: report.todosProject,
8561
- todosProjectId: report.todosProjectId,
8562
- taskList: report.taskList,
8563
- taskListId: report.taskListId,
8564
- projectPathPrefix: report.projectPathPrefix,
8565
- tags: report.tags,
8566
- limit: report.limit,
8567
- scanLimit: report.scanLimit,
8568
- filtersApplied: report.filtersApplied,
8569
- scanned: report.scanned,
8570
- candidates: report.candidates,
8571
- filteredCandidates: report.filteredCandidates,
8572
- scanExhausted: report.scanExhausted,
8573
- considered: report.considered,
8574
- created: report.created,
8575
- deduped: report.deduped,
8576
- throttled: report.throttled,
8577
- skipped: report.skipped,
8578
- maxDispatch: report.maxDispatch,
8579
- source: report.source,
8580
- dryRun: report.dryRun,
8581
- evidencePath,
8582
- results: results.map(compactDrainResult)
8583
- } : { ...report, evidencePath };
8584
- print(output, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
8779
+ addTodosDrainOptions(eventsDrain.command("todos-task").description("drain ready todos tasks into bounded worker/verifier workflow loops")).action((opts) => {
8780
+ drainTodosTaskRoutes(opts);
8585
8781
  });
8586
8782
  eventsHandle.command("generic").description("create a one-shot worker/verifier workflow loop for any Hasna event").option("--provider <provider>", "agent provider", "codewith").option("--auth-profile <profile>", "provider-native auth profile; currently supported for codewith").option("--auth-profile-pool <profiles>", "comma-separated provider-native auth profile pool").option("--worker-auth-profile <profile>", "provider-native auth profile for worker step").option("--verifier-auth-profile <profile>", "provider-native auth profile for verifier step").option("--account <profile>", "OpenAccounts profile name").option("--account-pool <profiles>", "comma-separated OpenAccounts profile pool").option("--worker-account <profile>", "OpenAccounts profile for worker step").option("--verifier-account <profile>", "OpenAccounts profile for verifier step").option("--account-tool <tool>", "OpenAccounts tool id").option("--model <model>", "provider model").option("--variant <variant>", "provider-specific model variant or reasoning effort").option("--agent <agent>", "provider-specific agent").option("--add-dir <dir>", "additional writable directory for provider sandboxes; may be repeated or comma-separated", collectValues, []).option("--permission-mode <mode>", "provider permission mode: default, plan, auto, or bypass", "bypass").option("--sandbox <mode>", "provider sandbox").option("--manual-break-glass", "allow danger-full-access in generated worker/verifier workflow metadata; for explicit operator emergency use only").option("--project-path <path>", "fallback project/repo working directory").option("--project-group <name>", "optional project group for concurrency limits").option("--max-active <n>", "skip creating a workflow when this many active routed workflows already exist globally").option("--max-active-per-project <n>", "skip creating a workflow when this many active routed workflows already exist for the project").option("--max-active-per-project-group <n>", "skip creating a workflow when this many active routed workflows already exist for the project group").option("--worktree-mode <mode>", "worktree isolation mode: auto, required, off, or main", "auto").option("--worktree-root <path>", "base directory for OpenLoops-managed git worktrees").option("--worktree-branch-prefix <prefix>", "branch prefix for generated event worktrees", "openloops").option("--name-prefix <prefix>", "workflow/loop name prefix", "event:generic").option("--preflight", "check generated workflow steps before storing the workflow loop").option("--dry-run", "print the workflow and loop input without storing anything").action(async (opts) => {
8587
8783
  const event = await readEventEnvelopeFromStdin();
8588
- const data = eventData(event);
8589
- const metadata = eventMetadata(event);
8590
- const projectPath = opts.projectPath ?? taskEventField(data, ["working_dir", "workingDir", "project_path", "projectPath", "cwd", "repo_path", "repoPath"]) ?? taskEventField(metadata, ["working_dir", "workingDir", "project_path", "projectPath", "project_canonical_path", "cwd", "repo_path", "repoPath"]) ?? process.cwd();
8591
- const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
8592
- const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
8593
- const throttleLimits = routeThrottleLimitsFromOpts(opts);
8594
- const eventSuffix = event.id.slice(0, 8);
8595
- const source = slugSegment2(event.source, "source");
8596
- const type = slugSegment2(event.type, "type");
8597
- const workflowName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:workflow`;
8598
- const loopName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:run`;
8599
- const idempotencyKey = `generic-event:${event.source}:${event.type}:${event.id}`;
8600
- const provider = opts.provider;
8601
- if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
8602
- throw new Error("unsupported provider");
8603
- const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode }, provider);
8604
- const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
8605
- const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
8606
- const workflowBody = renderEventWorkerVerifierWorkflow({
8607
- eventId: event.id,
8608
- eventType: event.type,
8609
- eventSource: event.source,
8610
- eventSubject: stringField(event.subject),
8611
- eventMessage: stringField(event.message),
8612
- eventJson: JSON.stringify(event),
8613
- projectPath,
8614
- routeProjectPath,
8615
- projectGroup,
8616
- provider,
8617
- authProfile,
8618
- authProfilePool: splitList(opts.authProfilePool),
8619
- workerAuthProfile: opts.workerAuthProfile,
8620
- verifierAuthProfile: opts.verifierAuthProfile,
8621
- account: accountFromOpts(opts),
8622
- accountPool: accountPoolFromOpts(opts),
8623
- workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
8624
- verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
8625
- model: opts.model,
8626
- variant: opts.variant,
8627
- agent: opts.agent,
8628
- addDirs: listFromRepeatedOpts(opts.addDir),
8629
- permissionMode,
8630
- sandbox,
8631
- manualBreakGlass: Boolean(opts.manualBreakGlass),
8632
- worktreeMode: opts.worktreeMode,
8633
- worktreeRoot: opts.worktreeRoot,
8634
- worktreeBranchPrefix: opts.worktreeBranchPrefix
8635
- });
8636
- workflowBody.name = workflowName;
8637
- workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
8638
- const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
8639
- const invocationInput = {
8640
- templateId: "event-worker-verifier",
8641
- sourceRef: {
8642
- kind: "event",
8643
- id: event.id,
8644
- dedupeKey: idempotencyKey,
8645
- raw: { source: event.source, type: event.type }
8646
- },
8647
- subjectRef: {
8648
- kind: "event",
8649
- id: stringField(event.subject) ?? event.id,
8650
- path: routeProjectPath,
8651
- raw: { message: stringField(event.message) }
8652
- },
8653
- intent: "route",
8654
- scope: {
8655
- projectPath: routeProjectPath,
8656
- projectGroup,
8657
- worktreePolicy: opts.worktreeMode ?? "auto",
8658
- permissions: permissionMode,
8659
- manualBreakGlass: Boolean(opts.manualBreakGlass),
8660
- accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
8661
- concurrencyGroup: projectGroup ?? routeProjectPath
8662
- },
8663
- outputPolicy: {
8664
- report: "always",
8665
- createTask: "on_failure"
8666
- }
8667
- };
8668
- const workItemInput = {
8669
- routeKey: "generic-event",
8670
- idempotencyKey,
8671
- invocationId: "<created-invocation-id>",
8672
- sourceType: event.type,
8673
- sourceRef: event.id,
8674
- subjectRef: stringField(event.subject) ?? event.id,
8675
- projectKey: routeProjectPath,
8676
- projectGroup,
8677
- priority: 0,
8678
- status: "queued"
8679
- };
8680
- const loopInput = {
8681
- name: loopName,
8682
- description: `Run ${workflowBody.name} once for event ${event.id}; idempotency=${idempotencyKey}`,
8683
- schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
8684
- target: { type: "workflow", workflowId: "<created-workflow-id>", input: {} },
8685
- overlap: "skip",
8686
- maxAttempts: 1,
8687
- retryDelayMs: 60000,
8688
- leaseMs: 90 * 60000
8689
- };
8690
- if (opts.dryRun) {
8691
- const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDryRunPreview({ projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
8692
- const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(workflowBody, "event-preflight"), {
8693
- name: workflowBody.name,
8694
- type: "generic-event-workflow",
8695
- event: event.id
8696
- }, {}) : undefined;
8697
- print({ event, idempotencyKey, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight }, `dry-run ${loopName}`);
8698
- return;
8699
- }
8700
- const store = new Store;
8701
- try {
8702
- const workflowPreflightSpec = workflowSpecForPreflight(workflowBody, "event-preflight");
8703
- generatedRouteSandboxPreflight(workflowPreflightSpec);
8704
- const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
8705
- name: workflowBody.name,
8706
- type: "generic-event-workflow",
8707
- event: event.id
8708
- }, {}) : undefined;
8709
- const outcome = store.writeTransaction(() => {
8710
- const invocation = store.createWorkflowInvocation(invocationInput);
8711
- const existingItem = store.findWorkflowWorkItem("generic-event", idempotencyKey);
8712
- if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
8713
- const existingLoop = store.getLoop(existingItem.loopId);
8714
- const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
8715
- return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
8716
- }
8717
- const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
8718
- const workItem = store.upsertWorkflowWorkItem({
8719
- ...workItemInput,
8720
- invocationId: invocation.id,
8721
- status: throttle && !throttle.allowed ? "deferred" : "queued",
8722
- lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
8723
- });
8724
- if (throttle && !throttle.allowed)
8725
- return { kind: "throttled", invocation, workItem, throttle };
8726
- const workflow = routeWorkflowForStorage(store, workflowBody);
8727
- const loop = store.createLoop({
8728
- ...loopInput,
8729
- target: {
8730
- type: "workflow",
8731
- workflowId: workflow.id,
8732
- input: {
8733
- workflowInvocationId: invocation.id,
8734
- workflowWorkItemId: workItem.id
8735
- }
8736
- }
8737
- });
8738
- const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by generic-event route" });
8739
- return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
8740
- });
8741
- if (outcome.kind === "deduped") {
8742
- print({
8743
- deduped: true,
8744
- idempotencyKey,
8745
- dedupedBy: "work-item",
8746
- event,
8747
- invocation: publicWorkflowInvocation(outcome.invocation),
8748
- workItem: publicWorkflowWorkItem(outcome.existingItem),
8749
- workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
8750
- loop: outcome.existingLoop ? publicLoop(outcome.existingLoop) : undefined
8751
- }, `deduped existing work item ${outcome.existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`);
8752
- return;
8753
- }
8754
- if (outcome.kind === "throttled") {
8755
- print({
8756
- skipped: true,
8757
- queuedAtSource: true,
8758
- reason: outcome.throttle.reason,
8759
- idempotencyKey,
8760
- event,
8761
- invocation: publicWorkflowInvocation(outcome.invocation),
8762
- workItem: publicWorkflowWorkItem(outcome.workItem),
8763
- throttle: outcome.throttle,
8764
- workflow: workflowBody,
8765
- loop: loopInput
8766
- }, `skipped event ${event.id}: ${outcome.throttle.reason}`);
8767
- return;
8768
- }
8769
- print({
8770
- deduped: false,
8771
- idempotencyKey,
8772
- event,
8773
- invocation: publicWorkflowInvocation(outcome.invocation),
8774
- workItem: publicWorkflowWorkItem(outcome.workItem),
8775
- workflow: publicWorkflow(outcome.workflow),
8776
- loop: publicLoop(outcome.loop),
8777
- throttle: outcome.throttle,
8778
- sandboxPreflight,
8779
- preflight
8780
- }, `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`);
8781
- } finally {
8782
- store.close();
8783
- }
8784
+ const result = routeGenericEvent(event, opts);
8785
+ print(result.value, result.human);
8784
8786
  });
8785
8787
  goal.command("show <idOrName>").description("show a goal or configured loop/workflow goal").action((idOrName) => {
8786
8788
  const store = new Store;
@@ -8860,10 +8862,14 @@ workflows.command("create <file>").description("validate and store a workflow JS
8860
8862
  store.close();
8861
8863
  }
8862
8864
  });
8863
- workflows.command("list").alias("ls").option("--status <status>", "active or archived", "active").action((opts) => {
8865
+ workflows.command("list").alias("ls").option("--status <status>", "active, archived, or all", "active").option("--all", "include active and archived workflows").option("--limit <n>", "maximum rows to print; omitted means all matching workflows").option("--offset <n>", "number of matching rows to skip before printing", "0").action((opts) => {
8864
8866
  const store = new Store;
8865
8867
  try {
8866
- const workflowsList = store.listWorkflows({ status: opts.status });
8868
+ const status = workflowStatusFromOpts(opts.status, opts.all);
8869
+ const limit = positiveInteger(opts.limit, "--limit");
8870
+ const offset = nonNegativeInteger(opts.offset, "--offset") ?? 0;
8871
+ const workflowsList = store.listWorkflows({ status, limit, offset });
8872
+ const total = store.countWorkflows({ status });
8867
8873
  if (isJson())
8868
8874
  print(workflowsList.map(publicWorkflow));
8869
8875
  else {
@@ -8871,6 +8877,8 @@ workflows.command("list").alias("ls").option("--status <status>", "active or arc
8871
8877
  console.log(`${workflow.id} ${workflow.status.padEnd(8)} steps=${workflow.steps.length} ${workflow.name}`);
8872
8878
  }
8873
8879
  }
8880
+ if (limit !== undefined || offset > 0)
8881
+ printWorkflowListWarning({ shown: workflowsList.length, total, status, offset, limit });
8874
8882
  } finally {
8875
8883
  store.close();
8876
8884
  }