@hasna/loops 0.3.46 → 0.3.48

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,9 @@ 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;
589
626
  function rowToLoop(row) {
590
627
  return {
591
628
  id: row.id,
@@ -852,6 +889,7 @@ class Store {
852
889
  ensurePrivateStorePath(file);
853
890
  this.rootDir = file === ":memory:" ? mkdtempSync(join3(tmpdir(), "open-loops-store-")) : dirname(file);
854
891
  this.db = new Database(file);
892
+ this.db.exec("PRAGMA foreign_keys = ON;");
855
893
  this.db.exec("PRAGMA busy_timeout = 5000;");
856
894
  this.db.exec("PRAGMA journal_mode = WAL;");
857
895
  if (file !== ":memory:")
@@ -915,6 +953,7 @@ class Store {
915
953
  );
916
954
  CREATE INDEX IF NOT EXISTS idx_runs_loop ON loop_runs(loop_id, created_at);
917
955
  CREATE INDEX IF NOT EXISTS idx_runs_status ON loop_runs(status);
956
+ CREATE INDEX IF NOT EXISTS idx_runs_status_lease ON loop_runs(status, lease_expires_at);
918
957
  CREATE INDEX IF NOT EXISTS idx_runs_scheduled ON loop_runs(scheduled_for);
919
958
 
920
959
  CREATE TABLE IF NOT EXISTS daemon_lease (
@@ -1242,39 +1281,51 @@ class Store {
1242
1281
  }
1243
1282
  return rows.map(rowToLoop);
1244
1283
  }
1245
- dueLoops(now) {
1284
+ dueLoops(now, limit = 500) {
1246
1285
  const rows = this.db.query(`SELECT * FROM loops
1247
1286
  WHERE status = 'active'
1248
1287
  AND archived_at IS NULL
1249
1288
  AND next_run_at IS NOT NULL
1250
1289
  AND next_run_at <= ?
1251
- ORDER BY next_run_at ASC`).all(now.toISOString());
1290
+ ORDER BY next_run_at ASC
1291
+ LIMIT ?`).all(now.toISOString(), limit);
1252
1292
  return rows.map(rowToLoop);
1253
1293
  }
1254
1294
  updateLoop(id, patch, opts = {}) {
1255
- const current = this.getLoop(id);
1256
- if (!current)
1257
- throw new Error(`loop not found: ${id}`);
1258
1295
  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);
1296
+ this.db.exec("BEGIN IMMEDIATE");
1297
+ try {
1298
+ const current = this.getLoop(id);
1299
+ if (!current)
1300
+ throw new Error(`loop not found: ${id}`);
1301
+ const merged = { ...current, ...patch, updatedAt: updated };
1302
+ const res = this.db.query(`UPDATE loops SET status=$status, next_run_at=$nextRun, retry_scheduled_for=$retrySlot,
1303
+ expires_at=$expiresAt, updated_at=$updated
1304
+ WHERE id=$id
1305
+ AND ($daemonLeaseId IS NULL OR EXISTS (
1306
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
1307
+ ))`).run({
1308
+ $id: id,
1309
+ $status: merged.status,
1310
+ $nextRun: merged.nextRunAt ?? null,
1311
+ $retrySlot: merged.retryScheduledFor ?? null,
1312
+ $expiresAt: merged.expiresAt ?? null,
1313
+ $updated: merged.updatedAt,
1314
+ $daemonLeaseId: opts.daemonLeaseId ?? null,
1315
+ $now: updated
1316
+ });
1317
+ if (res.changes !== 1)
1318
+ throw new Error("daemon lease lost");
1319
+ if (patch.status && patch.status !== "active") {
1320
+ const status = patch.status === "paused" ? "deferred" : "cancelled";
1321
+ this.setWorkflowWorkItemsForLoop(id, status, `loop ${patch.status}`, updated);
1322
+ }
1323
+ this.db.exec("COMMIT");
1324
+ } catch (error) {
1325
+ try {
1326
+ this.db.exec("ROLLBACK");
1327
+ } catch {}
1328
+ throw error;
1278
1329
  }
1279
1330
  const after = this.getLoop(id);
1280
1331
  if (!after)
@@ -2505,14 +2556,40 @@ class Store {
2505
2556
  }
2506
2557
  return rows.map(rowToRun);
2507
2558
  }
2559
+ deferLiveExpiredRun(id, now, opts = {}) {
2560
+ const updated = now.toISOString();
2561
+ const deferredUntil = new Date(now.getTime() + LIVE_EXPIRED_RUN_GRACE_MS).toISOString();
2562
+ this.db.query(`UPDATE loop_runs SET lease_expires_at=$deferredUntil, updated_at=$updated
2563
+ WHERE id=$id AND status='running' AND lease_expires_at <= $now
2564
+ AND ($daemonLeaseId IS NULL OR EXISTS (
2565
+ SELECT 1 FROM daemon_lease WHERE id=$daemonLeaseId AND expires_at > $now
2566
+ ))`).run({
2567
+ $id: id,
2568
+ $deferredUntil: deferredUntil,
2569
+ $updated: updated,
2570
+ $now: updated,
2571
+ $daemonLeaseId: opts.daemonLeaseId ?? null
2572
+ });
2573
+ }
2508
2574
  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());
2575
+ const limit = Math.max(1, Math.min(1000, Math.floor(opts.limit ?? DEFAULT_RECOVERY_BATCH_LIMIT)));
2576
+ const scanLimit = Math.max(limit, Math.min(5000, Math.floor(opts.scanLimit ?? limit * DEFAULT_RECOVERY_SCAN_MULTIPLIER)));
2577
+ const rows = this.db.query(`SELECT * FROM loop_runs
2578
+ WHERE status = 'running' AND lease_expires_at <= ?
2579
+ ORDER BY lease_expires_at ASC
2580
+ LIMIT ?`).all(now.toISOString(), scanLimit);
2510
2581
  const recovered = [];
2511
2582
  for (const row of rows) {
2512
- if (row.pid && isProcessAlive(row.pid))
2583
+ if (recovered.length >= limit)
2584
+ break;
2585
+ if (row.pid && isProcessAlive(row.pid)) {
2586
+ this.deferLiveExpiredRun(row.id, now, opts);
2513
2587
  continue;
2514
- if (this.hasLiveWorkflowStepProcesses(row.id))
2588
+ }
2589
+ if (this.hasLiveWorkflowStepProcesses(row.id)) {
2590
+ this.deferLiveExpiredRun(row.id, now, opts);
2515
2591
  continue;
2592
+ }
2516
2593
  const finished = now.toISOString();
2517
2594
  this.db.exec("BEGIN IMMEDIATE");
2518
2595
  try {
@@ -3025,6 +3102,7 @@ var AUTH_ENV_KEYS = [
3025
3102
  "CODEWITH_HOME",
3026
3103
  "CODEX_HOME",
3027
3104
  "CURSOR_CONFIG_DIR",
3105
+ "CURSOR_API_KEY",
3028
3106
  "OPENCODE_CONFIG_DIR",
3029
3107
  "AICOPILOT_CONFIG_DIR",
3030
3108
  "ANTHROPIC_API_KEY",
@@ -3154,6 +3232,10 @@ function assertSupportedAgentOptions(target) {
3154
3232
  assertStringOption(target.model, `${target.provider}.model`);
3155
3233
  assertStringOption(target.agent, `${target.provider}.agent`);
3156
3234
  assertStringOption(target.authProfile, `${target.provider}.authProfile`);
3235
+ assertStringOption(target.configIsolation, `${target.provider}.configIsolation`);
3236
+ if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
3237
+ throw new Error(`${target.provider}.configIsolation must be safe or none`);
3238
+ }
3157
3239
  if (target.authProfile !== undefined && target.provider !== "codewith") {
3158
3240
  throw new Error(`${target.provider}.authProfile is supported only for codewith`);
3159
3241
  }
@@ -3166,6 +3248,8 @@ function assertSupportedAgentOptions(target) {
3166
3248
  if (target.sandbox && !["read-only", "workspace-write", "danger-full-access", "enabled", "disabled"].includes(target.sandbox)) {
3167
3249
  throw new Error(`${target.provider}.sandbox is not supported: ${target.sandbox}`);
3168
3250
  }
3251
+ if (target.provider === "codex" && target.agent !== undefined)
3252
+ throw new Error("codex.agent is not supported");
3169
3253
  if (["codewith", "codex"].includes(target.provider)) {
3170
3254
  if (target.permissionMode && !["default", "bypass"].includes(target.permissionMode)) {
3171
3255
  throw new Error(`${target.provider}.permissionMode supports only default or bypass`);
@@ -3180,6 +3264,8 @@ function assertSupportedAgentOptions(target) {
3180
3264
  return;
3181
3265
  }
3182
3266
  if (target.provider === "cursor") {
3267
+ if (target.variant !== undefined)
3268
+ throw new Error("cursor.variant is not supported");
3183
3269
  if (target.permissionMode === "auto")
3184
3270
  throw new Error("cursor.permissionMode auto is not supported; use provider-specific extraArgs for Cursor auto-review");
3185
3271
  if (target.sandbox !== undefined && target.sandbox !== "enabled" && target.sandbox !== "disabled") {
@@ -3221,10 +3307,8 @@ function agentArgs(target) {
3221
3307
  "set -eu",
3222
3308
  "if command -v agent >/dev/null 2>&1; then",
3223
3309
  ' exec agent "$@"',
3224
- "elif command -v cursor >/dev/null 2>&1; then",
3225
- ' exec cursor agent "$@"',
3226
3310
  "else",
3227
- " echo 'Executable not found in PATH: cursor agent or agent' >&2",
3311
+ " echo 'Executable not found in PATH: agent' >&2",
3228
3312
  " exit 127",
3229
3313
  "fi"
3230
3314
  ].join(`
@@ -3265,7 +3349,7 @@ function agentArgs(target) {
3265
3349
  case "codex":
3266
3350
  if (target.variant)
3267
3351
  args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
3268
- args.push("exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
3352
+ args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
3269
3353
  if (isolation === "safe")
3270
3354
  args.push("--ignore-rules");
3271
3355
  if (target.cwd)
@@ -3335,7 +3419,7 @@ function commandSpec(target) {
3335
3419
  account: agentTarget.account,
3336
3420
  accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
3337
3421
  nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
3338
- preflightAnyOf: agentTarget.provider === "cursor" ? ["cursor", "agent"] : undefined,
3422
+ preflightAnyOf: agentTarget.provider === "cursor" ? ["agent"] : undefined,
3339
3423
  stdin: agentTarget.prompt,
3340
3424
  allowlist: agentTarget.allowlist
3341
3425
  };
@@ -4718,12 +4802,14 @@ function claimDueRuns(deps) {
4718
4802
  const claimed = [];
4719
4803
  const skipped = [];
4720
4804
  const maxClaims = Math.max(0, deps.maxClaims ?? Number.POSITIVE_INFINITY);
4805
+ if (maxClaims === 0)
4806
+ return { claims, claimed, completed: [], skipped, recovered, expired };
4721
4807
  for (const loop of deps.store.dueLoops(now)) {
4722
- if (claims.length >= maxClaims)
4808
+ if (claims.length + skipped.length >= maxClaims)
4723
4809
  break;
4724
4810
  const plan = dueSlots(loop, now);
4725
4811
  for (const slot of plan.slots) {
4726
- if (claims.length >= maxClaims)
4812
+ if (claims.length + skipped.length >= maxClaims)
4727
4813
  break;
4728
4814
  const run = claimSlot(deps, loop, slot);
4729
4815
  if (!run)
@@ -5179,35 +5265,17 @@ import { spawnSync as spawnSync4 } from "child_process";
5179
5265
  import { accessSync as accessSync2, constants as constants2 } from "fs";
5180
5266
  var PROVIDER_COMMANDS = [
5181
5267
  "claude",
5182
- "cursor agent",
5268
+ "agent",
5183
5269
  "codewith",
5184
5270
  "aicopilot",
5185
5271
  "opencode",
5186
5272
  "codex"
5187
5273
  ];
5188
5274
  function hasCommand(command) {
5189
- if (command === "cursor agent") {
5190
- return hasCommand("cursor") || hasCommand("agent");
5191
- }
5192
5275
  const result = spawnSync4("sh", ["-c", 'command -v "$1" >/dev/null', "sh", command], { stdio: "ignore" });
5193
5276
  return (result.status ?? 1) === 0;
5194
5277
  }
5195
5278
  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
5279
  const result = spawnSync4(command, ["--version"], {
5212
5280
  encoding: "utf8",
5213
5281
  stdio: ["ignore", "pipe", "pipe"]
@@ -5743,7 +5811,7 @@ function buildScriptInventoryReport(store, opts = {}) {
5743
5811
  // package.json
5744
5812
  var package_default = {
5745
5813
  name: "@hasna/loops",
5746
- version: "0.3.46",
5814
+ version: "0.3.48",
5747
5815
  description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
5748
5816
  type: "module",
5749
5817
  main: "dist/index.js",
@@ -6809,6 +6877,38 @@ function preflightFailed(error, context) {
6809
6877
  });
6810
6878
  process.exit(1);
6811
6879
  }
6880
+ function validationFailed(error, context) {
6881
+ if (!isJson())
6882
+ throw error;
6883
+ const message = error instanceof Error ? error.message : String(error);
6884
+ print({
6885
+ ok: false,
6886
+ created: false,
6887
+ validation: {
6888
+ ok: false,
6889
+ error: redact(message, 320)
6890
+ },
6891
+ ...context
6892
+ });
6893
+ process.exit(1);
6894
+ }
6895
+ function validateLoopTargetForStorage(target, context) {
6896
+ try {
6897
+ workflowBodyFromJson({
6898
+ name: "loop-target-validation",
6899
+ steps: [{ id: "target", target }]
6900
+ });
6901
+ } catch (error) {
6902
+ validationFailed(error, context);
6903
+ }
6904
+ }
6905
+ function normalizeWorkflowForStorage(body, context) {
6906
+ try {
6907
+ return workflowBodyFromJson(body);
6908
+ } catch (error) {
6909
+ validationFailed(error, context);
6910
+ }
6911
+ }
6812
6912
  function preflightLoopTarget(target, context, metadata, opts) {
6813
6913
  try {
6814
6914
  return preflightTarget(target, metadata, opts);
@@ -7508,7 +7608,7 @@ function routeTodosTaskEvent(event, opts) {
7508
7608
  const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
7509
7609
  const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
7510
7610
  const throttleLimits = routeThrottleLimitsFromOpts(opts);
7511
- const idempotencyKey = `todos-task:${taskId}:${event.type}`;
7611
+ const idempotencyKey = `todos-task:${taskId}`;
7512
7612
  const idempotencySuffix = stableSuffix(idempotencyKey);
7513
7613
  const namePrefix = opts.namePrefix ?? "event:todos-task";
7514
7614
  const workflowName = `${namePrefix}:${taskId.slice(0, 8)}:${idempotencySuffix}:workflow`;
@@ -7546,7 +7646,7 @@ function routeTodosTaskEvent(event, opts) {
7546
7646
  const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
7547
7647
  const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
7548
7648
  const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
7549
- const workflowBody = renderTodosTaskWorkerVerifierWorkflow({
7649
+ let workflowBody = renderTodosTaskWorkerVerifierWorkflow({
7550
7650
  taskId,
7551
7651
  taskTitle,
7552
7652
  taskDescription,
@@ -7578,6 +7678,11 @@ function routeTodosTaskEvent(event, opts) {
7578
7678
  });
7579
7679
  workflowBody.name = workflowName;
7580
7680
  workflowBody.description = `Task-triggered worker/verifier workflow for ${taskTitle ?? taskId} from ${event.source}/${event.type}; ` + `idempotency=${idempotencyKey}; event=${event.id}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
7681
+ workflowBody = normalizeWorkflowForStorage(workflowBody, {
7682
+ name: workflowName,
7683
+ type: "todos-task-event-workflow",
7684
+ event: event.id
7685
+ });
7581
7686
  const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
7582
7687
  const invocationInput = {
7583
7688
  templateId: "todos-task-worker-verifier",
@@ -7757,7 +7862,7 @@ function routeGenericEvent(event, opts) {
7757
7862
  const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode ?? "bypass" }, provider);
7758
7863
  const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
7759
7864
  const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
7760
- const workflowBody = renderEventWorkerVerifierWorkflow({
7865
+ let workflowBody = renderEventWorkerVerifierWorkflow({
7761
7866
  eventId: event.id,
7762
7867
  eventType: event.type,
7763
7868
  eventSource: event.source,
@@ -7789,6 +7894,11 @@ function routeGenericEvent(event, opts) {
7789
7894
  });
7790
7895
  workflowBody.name = workflowName;
7791
7896
  workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
7897
+ workflowBody = normalizeWorkflowForStorage(workflowBody, {
7898
+ name: workflowName,
7899
+ type: "generic-event-workflow",
7900
+ event: event.id
7901
+ });
7792
7902
  const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
7793
7903
  const invocationInput = {
7794
7904
  templateId: "event-worker-verifier",
@@ -8171,6 +8281,7 @@ addGoalOptions(addAccountOptions(addMachineOptions(addScheduleOptions(create.com
8171
8281
  preflight: runtimePreflightFromOpts(opts)
8172
8282
  };
8173
8283
  const input = baseCreateInput(name, opts, target);
8284
+ validateLoopTargetForStorage(input.target, { name, type: "agent", provider });
8174
8285
  const preflight = opts.preflight ? preflightLoopTarget(input.target, { name, type: "agent", provider }, { loopName: name }, { machine: input.machine }) : undefined;
8175
8286
  const loop = store.createLoop(input);
8176
8287
  printCreatedLoop(loop, `created loop ${loop.id} (${loop.name}) next=${loop.nextRunAt}`, preflight);
@@ -8204,8 +8315,12 @@ var goal = program.command("goal").description("inspect goal runs");
8204
8315
  function addRouteEventOptions(command) {
8205
8316
  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");
8206
8317
  }
8207
- function addTodosDrainOptions(command) {
8208
- 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");
8318
+ function addTodosDrainOptions(command, opts = {}) {
8319
+ 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");
8320
+ if (opts.includeDryRun ?? true) {
8321
+ configured = configured.option("--dry-run", "preview selected tasks and generated workflow loops without storing anything");
8322
+ }
8323
+ return configured;
8209
8324
  }
8210
8325
  function routeEventByKind(kind, event, opts) {
8211
8326
  if (kind === "todos-task")
@@ -8446,7 +8561,10 @@ addTodosDrainOptions(routes.command("drain <kind>").description("drain a durable
8446
8561
  throw new Error("route drain currently supports kind todos-task");
8447
8562
  drainTodosTaskRoutes(opts);
8448
8563
  });
8449
- addScheduleOptions(addTodosDrainOptions(routes.command("schedule <kind> <name>").description("schedule a deterministic route drain loop"))).action((kind, name, opts) => {
8564
+ addScheduleOptions(addTodosDrainOptions(routes.command("schedule <kind> <name>").description("schedule a deterministic route drain loop"), {
8565
+ includeDryRun: false,
8566
+ preflightDescription: "forward generated workflow preflight checks into each future drain run"
8567
+ })).action((kind, name, opts) => {
8450
8568
  if (kind !== "todos-task")
8451
8569
  throw new Error("route schedule currently supports kind todos-task");
8452
8570
  const store = new Store;
@@ -8473,288 +8591,13 @@ eventsHandle.command("todos-task").description("create a one-shot worker/verifie
8473
8591
  print(result.value, result.human);
8474
8592
  });
8475
8593
  var eventsDrain = events.command("drain").description("drain durable source queues into bounded OpenLoops workflows");
8476
- 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) => {
8477
- const maxDispatch = positiveInteger(opts.maxDispatch ?? "1", "--max-dispatch") ?? 1;
8478
- const todosProject = opts.todosProject ?? defaultLoopsProject();
8479
- const requiredTags = splitList(opts.tags ?? opts.tag) ?? [];
8480
- const taskListFilter = resolveTaskListFilter(todosProject, opts.taskList);
8481
- const candidateLimit = positiveInteger(opts.limit ?? "50", "--limit") ?? 50;
8482
- const hasPostFilters = Boolean(opts.todosProjectId || taskListFilter || opts.projectPathPrefix || requiredTags.length);
8483
- const defaultScanLimit = hasPostFilters ? Math.max(candidateLimit, 500) : candidateLimit;
8484
- const scanLimit = positiveInteger(opts.scanLimit ?? String(defaultScanLimit), "--scan-limit") ?? defaultScanLimit;
8485
- const ready = loadReadyTodosTasks(opts, scanLimit);
8486
- const filteredCandidates = ready.filter((task) => taskMatchesDrainFilters(task, {
8487
- projectId: opts.todosProjectId,
8488
- taskListId: taskListFilter,
8489
- projectPathPrefix: opts.projectPathPrefix,
8490
- tags: requiredTags
8491
- }));
8492
- const candidates = filteredCandidates.slice(0, candidateLimit);
8493
- const results = [];
8494
- let created = 0;
8495
- for (const task of candidates) {
8496
- if (created >= maxDispatch)
8497
- break;
8498
- const event = taskDrainEvent(task);
8499
- const result = routeTodosTaskEvent(event, opts);
8500
- results.push(result);
8501
- if (result.kind === "created" && !opts.dryRun)
8502
- created += 1;
8503
- if (result.kind === "created" && opts.dryRun)
8504
- created += 1;
8505
- }
8506
- const report = {
8507
- drainedAt: new Date().toISOString(),
8508
- todosProject,
8509
- todosProjectId: opts.todosProjectId,
8510
- taskList: opts.taskList,
8511
- taskListId: taskListFilter,
8512
- projectPathPrefix: opts.projectPathPrefix,
8513
- tags: requiredTags,
8514
- limit: candidateLimit,
8515
- scanLimit,
8516
- filtersApplied: hasPostFilters,
8517
- scanned: ready.length,
8518
- candidates: candidates.length,
8519
- filteredCandidates: filteredCandidates.length,
8520
- scanExhausted: ready.length >= scanLimit && filteredCandidates.length < candidateLimit,
8521
- considered: results.length,
8522
- created: results.filter((result) => result.kind === "created" && !result.value.deduped).length,
8523
- deduped: results.filter((result) => result.kind === "deduped").length,
8524
- throttled: results.filter((result) => result.kind === "throttled").length,
8525
- skipped: results.filter((result) => result.kind === "skipped").length,
8526
- maxDispatch,
8527
- source: "todos ready",
8528
- dryRun: Boolean(opts.dryRun),
8529
- results: results.map((result) => ({ kind: result.kind, ...result.value }))
8530
- };
8531
- const evidencePath = writeRouteEvidence("todos-task-drain", report, opts.evidenceDir);
8532
- const output = opts.compact ? {
8533
- drainedAt: report.drainedAt,
8534
- todosProject: report.todosProject,
8535
- todosProjectId: report.todosProjectId,
8536
- taskList: report.taskList,
8537
- taskListId: report.taskListId,
8538
- projectPathPrefix: report.projectPathPrefix,
8539
- tags: report.tags,
8540
- limit: report.limit,
8541
- scanLimit: report.scanLimit,
8542
- filtersApplied: report.filtersApplied,
8543
- scanned: report.scanned,
8544
- candidates: report.candidates,
8545
- filteredCandidates: report.filteredCandidates,
8546
- scanExhausted: report.scanExhausted,
8547
- considered: report.considered,
8548
- created: report.created,
8549
- deduped: report.deduped,
8550
- throttled: report.throttled,
8551
- skipped: report.skipped,
8552
- maxDispatch: report.maxDispatch,
8553
- source: report.source,
8554
- dryRun: report.dryRun,
8555
- evidencePath,
8556
- results: results.map(compactDrainResult)
8557
- } : { ...report, evidencePath };
8558
- print(output, `drained todos ready queue: considered=${report.considered} created=${report.created} deduped=${report.deduped} throttled=${report.throttled} skipped=${report.skipped}`);
8594
+ addTodosDrainOptions(eventsDrain.command("todos-task").description("drain ready todos tasks into bounded worker/verifier workflow loops")).action((opts) => {
8595
+ drainTodosTaskRoutes(opts);
8559
8596
  });
8560
8597
  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) => {
8561
8598
  const event = await readEventEnvelopeFromStdin();
8562
- const data = eventData(event);
8563
- const metadata = eventMetadata(event);
8564
- 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();
8565
- const routeProjectPath = normalizeRoutePath(projectPath) ?? resolve2(projectPath);
8566
- const projectGroup = routeProjectGroup(opts.projectGroup, data, metadata);
8567
- const throttleLimits = routeThrottleLimitsFromOpts(opts);
8568
- const eventSuffix = event.id.slice(0, 8);
8569
- const source = slugSegment2(event.source, "source");
8570
- const type = slugSegment2(event.type, "type");
8571
- const workflowName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:workflow`;
8572
- const loopName = `${opts.namePrefix}:${source}:${type}:${eventSuffix}:run`;
8573
- const idempotencyKey = `generic-event:${event.source}:${event.type}:${event.id}`;
8574
- const provider = opts.provider;
8575
- if (!["claude", "cursor", "codewith", "aicopilot", "opencode", "codex"].includes(provider))
8576
- throw new Error("unsupported provider");
8577
- const permissionMode = permissionModeFromOpts({ permissionMode: opts.permissionMode }, provider);
8578
- const sandbox = sandboxFromOpts({ sandbox: opts.sandbox }, provider);
8579
- const authProfile = providerAuthProfileFromOpts({ authProfile: opts.authProfile }, provider);
8580
- const workflowBody = renderEventWorkerVerifierWorkflow({
8581
- eventId: event.id,
8582
- eventType: event.type,
8583
- eventSource: event.source,
8584
- eventSubject: stringField(event.subject),
8585
- eventMessage: stringField(event.message),
8586
- eventJson: JSON.stringify(event),
8587
- projectPath,
8588
- routeProjectPath,
8589
- projectGroup,
8590
- provider,
8591
- authProfile,
8592
- authProfilePool: splitList(opts.authProfilePool),
8593
- workerAuthProfile: opts.workerAuthProfile,
8594
- verifierAuthProfile: opts.verifierAuthProfile,
8595
- account: accountFromOpts(opts),
8596
- accountPool: accountPoolFromOpts(opts),
8597
- workerAccount: roleAccountFromOpts(opts, opts.workerAccount),
8598
- verifierAccount: roleAccountFromOpts(opts, opts.verifierAccount),
8599
- model: opts.model,
8600
- variant: opts.variant,
8601
- agent: opts.agent,
8602
- addDirs: listFromRepeatedOpts(opts.addDir),
8603
- permissionMode,
8604
- sandbox,
8605
- manualBreakGlass: Boolean(opts.manualBreakGlass),
8606
- worktreeMode: opts.worktreeMode,
8607
- worktreeRoot: opts.worktreeRoot,
8608
- worktreeBranchPrefix: opts.worktreeBranchPrefix
8609
- });
8610
- workflowBody.name = workflowName;
8611
- workflowBody.description = `Event-triggered worker/verifier workflow for ${event.source}/${event.type}; project=${projectPath}; projectGroup=${projectGroup ?? "-"}`;
8612
- const sandboxPreflight = generatedRouteSandboxPreflight(workflowBody);
8613
- const invocationInput = {
8614
- templateId: "event-worker-verifier",
8615
- sourceRef: {
8616
- kind: "event",
8617
- id: event.id,
8618
- dedupeKey: idempotencyKey,
8619
- raw: { source: event.source, type: event.type }
8620
- },
8621
- subjectRef: {
8622
- kind: "event",
8623
- id: stringField(event.subject) ?? event.id,
8624
- path: routeProjectPath,
8625
- raw: { message: stringField(event.message) }
8626
- },
8627
- intent: "route",
8628
- scope: {
8629
- projectPath: routeProjectPath,
8630
- projectGroup,
8631
- worktreePolicy: opts.worktreeMode ?? "auto",
8632
- permissions: permissionMode,
8633
- manualBreakGlass: Boolean(opts.manualBreakGlass),
8634
- accountPolicy: opts.authProfilePool || opts.accountPool ? "pool" : "single",
8635
- concurrencyGroup: projectGroup ?? routeProjectPath
8636
- },
8637
- outputPolicy: {
8638
- report: "always",
8639
- createTask: "on_failure"
8640
- }
8641
- };
8642
- const workItemInput = {
8643
- routeKey: "generic-event",
8644
- idempotencyKey,
8645
- invocationId: "<created-invocation-id>",
8646
- sourceType: event.type,
8647
- sourceRef: event.id,
8648
- subjectRef: stringField(event.subject) ?? event.id,
8649
- projectKey: routeProjectPath,
8650
- projectGroup,
8651
- priority: 0,
8652
- status: "queued"
8653
- };
8654
- const loopInput = {
8655
- name: loopName,
8656
- description: `Run ${workflowBody.name} once for event ${event.id}; idempotency=${idempotencyKey}`,
8657
- schedule: { type: "once", at: new Date(Date.now() + 1000).toISOString() },
8658
- target: { type: "workflow", workflowId: "<created-workflow-id>", input: {} },
8659
- overlap: "skip",
8660
- maxAttempts: 1,
8661
- retryDelayMs: 60000,
8662
- leaseMs: 90 * 60000
8663
- };
8664
- if (opts.dryRun) {
8665
- const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDryRunPreview({ projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
8666
- const preflight = opts.preflight ? preflightStoredWorkflow(workflowSpecForPreflight(workflowBody, "event-preflight"), {
8667
- name: workflowBody.name,
8668
- type: "generic-event-workflow",
8669
- event: event.id
8670
- }, {}) : undefined;
8671
- print({ event, idempotencyKey, invocation: invocationInput, workItem: workItemInput, workflow: workflowBody, loop: loopInput, throttle, sandboxPreflight, preflight }, `dry-run ${loopName}`);
8672
- return;
8673
- }
8674
- const store = new Store;
8675
- try {
8676
- const workflowPreflightSpec = workflowSpecForPreflight(workflowBody, "event-preflight");
8677
- generatedRouteSandboxPreflight(workflowPreflightSpec);
8678
- const preflight = opts.preflight ? preflightStoredWorkflow(workflowPreflightSpec, {
8679
- name: workflowBody.name,
8680
- type: "generic-event-workflow",
8681
- event: event.id
8682
- }, {}) : undefined;
8683
- const outcome = store.writeTransaction(() => {
8684
- const invocation = store.createWorkflowInvocation(invocationInput);
8685
- const existingItem = store.findWorkflowWorkItem("generic-event", idempotencyKey);
8686
- if (existingItem?.loopId && ["admitted", "running", "succeeded"].includes(existingItem.status)) {
8687
- const existingLoop = store.getLoop(existingItem.loopId);
8688
- const existingWorkflow = existingItem.workflowId ? store.getWorkflow(existingItem.workflowId) : undefined;
8689
- return { kind: "deduped", existingItem, existingLoop, existingWorkflow, invocation };
8690
- }
8691
- const throttle = hasThrottleLimits(throttleLimits) ? routeThrottleDecision(store, { projectPath: routeProjectPath, projectGroup, limits: throttleLimits }) : undefined;
8692
- const workItem = store.upsertWorkflowWorkItem({
8693
- ...workItemInput,
8694
- invocationId: invocation.id,
8695
- status: throttle && !throttle.allowed ? "deferred" : "queued",
8696
- lastReason: throttle && !throttle.allowed ? throttle.reason : undefined
8697
- });
8698
- if (throttle && !throttle.allowed)
8699
- return { kind: "throttled", invocation, workItem, throttle };
8700
- const workflow = routeWorkflowForStorage(store, workflowBody);
8701
- const loop = store.createLoop({
8702
- ...loopInput,
8703
- target: {
8704
- type: "workflow",
8705
- workflowId: workflow.id,
8706
- input: {
8707
- workflowInvocationId: invocation.id,
8708
- workflowWorkItemId: workItem.id
8709
- }
8710
- }
8711
- });
8712
- const admitted = store.admitWorkflowWorkItem(workItem.id, { workflowId: workflow.id, loopId: loop.id, reason: "admitted by generic-event route" });
8713
- return { kind: "created", invocation, workItem: admitted, workflow, loop, throttle };
8714
- });
8715
- if (outcome.kind === "deduped") {
8716
- print({
8717
- deduped: true,
8718
- idempotencyKey,
8719
- dedupedBy: "work-item",
8720
- event,
8721
- invocation: publicWorkflowInvocation(outcome.invocation),
8722
- workItem: publicWorkflowWorkItem(outcome.existingItem),
8723
- workflow: outcome.existingWorkflow ? publicWorkflow(outcome.existingWorkflow) : undefined,
8724
- loop: outcome.existingLoop ? publicLoop(outcome.existingLoop) : undefined
8725
- }, `deduped existing work item ${outcome.existingItem.id} for event=${event.id} idempotency=${idempotencyKey}`);
8726
- return;
8727
- }
8728
- if (outcome.kind === "throttled") {
8729
- print({
8730
- skipped: true,
8731
- queuedAtSource: true,
8732
- reason: outcome.throttle.reason,
8733
- idempotencyKey,
8734
- event,
8735
- invocation: publicWorkflowInvocation(outcome.invocation),
8736
- workItem: publicWorkflowWorkItem(outcome.workItem),
8737
- throttle: outcome.throttle,
8738
- workflow: workflowBody,
8739
- loop: loopInput
8740
- }, `skipped event ${event.id}: ${outcome.throttle.reason}`);
8741
- return;
8742
- }
8743
- print({
8744
- deduped: false,
8745
- idempotencyKey,
8746
- event,
8747
- invocation: publicWorkflowInvocation(outcome.invocation),
8748
- workItem: publicWorkflowWorkItem(outcome.workItem),
8749
- workflow: publicWorkflow(outcome.workflow),
8750
- loop: publicLoop(outcome.loop),
8751
- throttle: outcome.throttle,
8752
- sandboxPreflight,
8753
- preflight
8754
- }, `created ${outcome.loop.id} (${outcome.loop.name}) workflow=${outcome.workflow.name} event=${event.id} idempotency=${idempotencyKey}`);
8755
- } finally {
8756
- store.close();
8757
- }
8599
+ const result = routeGenericEvent(event, opts);
8600
+ print(result.value, result.human);
8758
8601
  });
8759
8602
  goal.command("show <idOrName>").description("show a goal or configured loop/workflow goal").action((idOrName) => {
8760
8603
  const store = new Store;