gencow 0.1.250 → 0.1.251

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/core/index.js CHANGED
@@ -2085,6 +2085,7 @@ function getJobAdmissionStatus(code) {
2085
2085
  return 503;
2086
2086
  case "job_rate_limited":
2087
2087
  case "job_backlog_limit_exceeded":
2088
+ case "workflow_parked_capacity_exceeded":
2088
2089
  case "job_queue_depth_limit_exceeded":
2089
2090
  return 429;
2090
2091
  }
@@ -2146,11 +2147,69 @@ function isJobAdmissionKillSwitchActive(env, scope) {
2146
2147
  }
2147
2148
 
2148
2149
  // ../core/src/workflow.ts
2149
- import { sql as sql3 } from "drizzle-orm";
2150
+ import { sql as sql5 } from "drizzle-orm";
2150
2151
 
2151
- // ../core/src/workflow-admission.ts
2152
+ // ../core/src/workflow-v2-start-schema.ts
2152
2153
  import { sql } from "drizzle-orm";
2154
+ function rows(result) {
2155
+ if (Array.isArray(result)) return result;
2156
+ const nested = result?.rows;
2157
+ return Array.isArray(nested) ? nested : [];
2158
+ }
2159
+ function numberFrom(value) {
2160
+ const parsed = Number(value ?? 0);
2161
+ return Number.isFinite(parsed) ? parsed : 0;
2162
+ }
2163
+ async function hasWorkflowV2StartSchema(db) {
2164
+ const runColumns = [
2165
+ "id",
2166
+ "workflow_name",
2167
+ "workflow_version",
2168
+ "args_json",
2169
+ "user_id",
2170
+ "max_active_duration_ms",
2171
+ "lifecycle_deadline_at",
2172
+ "retry_count",
2173
+ "max_retries",
2174
+ "max_attempts",
2175
+ "command_protocol",
2176
+ // tryInsertWorkflowV2Run writes it on every start, so the capability must prove it exists.
2177
+ "idempotency_key"
2178
+ ];
2179
+ const outboxColumns = ["id", "run_id", "kind", "available_at", "status"];
2180
+ const runColumnList = sql.join(
2181
+ runColumns.map((column) => sql`${column}`),
2182
+ sql`, `
2183
+ );
2184
+ const outboxColumnList = sql.join(
2185
+ outboxColumns.map((column) => sql`${column}`),
2186
+ sql`, `
2187
+ );
2188
+ const result = await db.execute(sql`
2189
+ SELECT
2190
+ (
2191
+ SELECT COUNT(*)::int
2192
+ FROM information_schema.columns
2193
+ WHERE table_schema = 'public'
2194
+ AND table_name = '_gencow_workflow_runs_v2'
2195
+ AND column_name IN (${runColumnList})
2196
+ ) AS run_column_count,
2197
+ (
2198
+ SELECT COUNT(*)::int
2199
+ FROM information_schema.columns
2200
+ WHERE table_schema = 'public'
2201
+ AND table_name = '_gencow_workflow_outbox_v2'
2202
+ AND column_name IN (${outboxColumnList})
2203
+ ) AS outbox_column_count
2204
+ `);
2205
+ const row = rows(result)[0];
2206
+ return numberFrom(row?.run_column_count) === runColumns.length && numberFrom(row?.outbox_column_count) === outboxColumns.length;
2207
+ }
2208
+
2209
+ // ../core/src/workflow-admission.ts
2210
+ import { sql as sql2 } from "drizzle-orm";
2153
2211
  var DEFAULT_WORKFLOW_PENDING_PER_APP = 1e3;
2212
+ var DEFAULT_WORKFLOW_PARKED_PER_APP = 1e4;
2154
2213
  var DEFAULT_WORKFLOW_RUNNING_PER_APP = 50;
2155
2214
  var DEFAULT_WORKFLOW_PENDING_PER_NAME = 300;
2156
2215
  var DEFAULT_WORKFLOW_STARTS_PER_MINUTE_PER_APP = 120;
@@ -2161,6 +2220,7 @@ var WORKFLOW_ADMISSION_ENV_KEYS = [
2161
2220
  "GENCOW_JOB_KILL_SWITCH",
2162
2221
  "GENCOW_PLATFORM_JOB_KILL_SWITCH",
2163
2222
  "GENCOW_WORKFLOW_PENDING_PER_APP",
2223
+ "GENCOW_WORKFLOW_PARKED_PER_APP",
2164
2224
  "GENCOW_WORKFLOW_RUNNING_PER_APP",
2165
2225
  "GENCOW_WORKFLOW_PENDING_PER_NAME",
2166
2226
  "GENCOW_WORKFLOW_STARTS_PER_MINUTE_PER_APP",
@@ -2170,14 +2230,14 @@ var WORKFLOW_ADMISSION_ENV_KEYS = [
2170
2230
  "GENCOW_JOB_CREATES_PER_MINUTE_PER_APP",
2171
2231
  "GENCOW_JOB_PAYLOAD_MAX_BYTES"
2172
2232
  ];
2173
- function rows(result) {
2233
+ function rows2(result) {
2174
2234
  if (Array.isArray(result)) return result;
2175
2235
  if (result && typeof result === "object" && Array.isArray(result.rows)) {
2176
2236
  return result.rows;
2177
2237
  }
2178
2238
  return [];
2179
2239
  }
2180
- function numberFrom(value) {
2240
+ function numberFrom2(value) {
2181
2241
  const parsed = Number(value ?? 0);
2182
2242
  return Number.isFinite(parsed) ? parsed : 0;
2183
2243
  }
@@ -2203,6 +2263,7 @@ function isWorkflowAdmissionEnabled(env) {
2203
2263
  function resolveWorkflowAdmissionPolicy(env) {
2204
2264
  return {
2205
2265
  pendingPerApp: readPositiveIntegerEnv(env, "GENCOW_WORKFLOW_PENDING_PER_APP") ?? readPositiveIntegerEnv(env, "GENCOW_JOB_PENDING_PER_APP") ?? DEFAULT_WORKFLOW_PENDING_PER_APP,
2266
+ parkedPerApp: readPositiveIntegerEnv(env, "GENCOW_WORKFLOW_PARKED_PER_APP") ?? DEFAULT_WORKFLOW_PARKED_PER_APP,
2206
2267
  runningPerApp: readPositiveIntegerEnv(env, "GENCOW_WORKFLOW_RUNNING_PER_APP") ?? DEFAULT_WORKFLOW_RUNNING_PER_APP,
2207
2268
  pendingPerName: readPositiveIntegerEnv(env, "GENCOW_WORKFLOW_PENDING_PER_NAME") ?? DEFAULT_WORKFLOW_PENDING_PER_NAME,
2208
2269
  startsPerMinutePerApp: readPositiveIntegerEnv(env, "GENCOW_WORKFLOW_STARTS_PER_MINUTE_PER_APP") ?? readPositiveIntegerEnv(env, "GENCOW_JOB_CREATES_PER_MINUTE_PER_APP") ?? DEFAULT_WORKFLOW_STARTS_PER_MINUTE_PER_APP,
@@ -2211,33 +2272,37 @@ function resolveWorkflowAdmissionPolicy(env) {
2211
2272
  };
2212
2273
  }
2213
2274
  async function countWorkflowV2Backlog(db, workflowName) {
2214
- const result = await db.execute(sql`
2275
+ const result = await db.execute(sql2`
2215
2276
  SELECT
2216
- COALESCE(SUM(CASE WHEN completed_at IS NULL AND status IN ('queued', 'waiting', 'sleeping') THEN 1 ELSE 0 END), 0)::int AS pending,
2217
- COALESCE(SUM(CASE WHEN completed_at IS NULL AND status = 'running' THEN 1 ELSE 0 END), 0)::int AS running,
2218
- COALESCE(SUM(CASE WHEN completed_at IS NULL AND status IN ('queued', 'waiting', 'sleeping') AND workflow_name = ${workflowName} THEN 1 ELSE 0 END), 0)::int AS pending_by_name
2277
+ COALESCE(SUM(CASE WHEN status IN ('queued', 'running') THEN 1 ELSE 0 END), 0)::int AS pending,
2278
+ COALESCE(SUM(CASE WHEN status IN ('waiting', 'sleeping') THEN 1 ELSE 0 END), 0)::int AS parked,
2279
+ COALESCE(SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END), 0)::int AS running,
2280
+ COALESCE(SUM(CASE WHEN status IN ('queued', 'running') AND workflow_name = ${workflowName} THEN 1 ELSE 0 END), 0)::int AS pending_by_name
2219
2281
  FROM _gencow_workflow_runs_v2
2282
+ WHERE completed_at IS NULL
2220
2283
  `);
2221
- const row = rows(result)[0];
2284
+ const row = rows2(result)[0];
2222
2285
  return {
2223
- pending: numberFrom(row?.pending),
2224
- running: numberFrom(row?.running),
2225
- pendingByName: numberFrom(row?.pending_by_name)
2286
+ pending: numberFrom2(row?.pending),
2287
+ parked: numberFrom2(row?.parked),
2288
+ running: numberFrom2(row?.running),
2289
+ pendingByName: numberFrom2(row?.pending_by_name)
2226
2290
  };
2227
2291
  }
2228
2292
  async function countWorkflowV1Backlog(db, workflowName) {
2229
- const result = await db.execute(sql`
2293
+ const result = await db.execute(sql2`
2230
2294
  SELECT
2231
2295
  COALESCE(SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), 0)::int AS pending,
2232
2296
  COALESCE(SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END), 0)::int AS running,
2233
2297
  COALESCE(SUM(CASE WHEN status = 'pending' AND name = ${workflowName} THEN 1 ELSE 0 END), 0)::int AS pending_by_name
2234
2298
  FROM _gencow_workflows
2235
2299
  `);
2236
- const row = rows(result)[0];
2300
+ const row = rows2(result)[0];
2237
2301
  return {
2238
- pending: numberFrom(row?.pending),
2239
- running: numberFrom(row?.running),
2240
- pendingByName: numberFrom(row?.pending_by_name)
2302
+ pending: numberFrom2(row?.pending),
2303
+ parked: 0,
2304
+ running: numberFrom2(row?.running),
2305
+ pendingByName: numberFrom2(row?.pending_by_name)
2241
2306
  };
2242
2307
  }
2243
2308
  async function countWorkflowBacklog(db, workflowName, requireV2 = false) {
@@ -2250,7 +2315,7 @@ async function countWorkflowBacklog(db, workflowName, requireV2 = false) {
2250
2315
  }
2251
2316
  async function consumeWorkflowRateBucket(db, scope, limit, now) {
2252
2317
  try {
2253
- const result = await db.execute(sql`
2318
+ const result = await db.execute(sql2`
2254
2319
  INSERT INTO _gencow_job_admission_buckets (scope, bucket_start, count, updated_at)
2255
2320
  VALUES (${scope}, date_trunc('minute', ${now.toISOString()}::timestamptz), 1, NOW())
2256
2321
  ON CONFLICT (scope, bucket_start) DO UPDATE
@@ -2259,7 +2324,7 @@ async function consumeWorkflowRateBucket(db, scope, limit, now) {
2259
2324
  WHERE _gencow_job_admission_buckets.count < ${limit}
2260
2325
  RETURNING count
2261
2326
  `);
2262
- return rows(result).length > 0;
2327
+ return rows2(result).length > 0;
2263
2328
  } catch (error) {
2264
2329
  if (!isMissingAdmissionSchemaError(error)) throw error;
2265
2330
  throw new JobAdmissionError(
@@ -2269,7 +2334,7 @@ async function consumeWorkflowRateBucket(db, scope, limit, now) {
2269
2334
  }
2270
2335
  }
2271
2336
  async function acquireWorkflowStartAdmissionLock(db) {
2272
- await db.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended('workflow:create', 0))`);
2337
+ await db.execute(sql2`SELECT pg_advisory_xact_lock(hashtextextended('workflow:create', 0))`);
2273
2338
  }
2274
2339
  async function applyWorkflowStartAdmission(options) {
2275
2340
  const env = options.env ?? (typeof process === "undefined" ? {} : process.env);
@@ -2291,6 +2356,23 @@ async function applyWorkflowStartAdmission(options) {
2291
2356
  details: { limit: policy.pendingPerApp, pending: counts.pending }
2292
2357
  });
2293
2358
  }
2359
+ if (counts.parked >= policy.parkedPerApp) {
2360
+ throw new JobAdmissionError(
2361
+ "workflow_parked_capacity_exceeded",
2362
+ "workflow sleeping/waiting limit exceeded",
2363
+ {
2364
+ details: {
2365
+ limit: policy.parkedPerApp,
2366
+ parked: counts.parked,
2367
+ stage: "admission",
2368
+ stateChanged: false,
2369
+ retryable: true,
2370
+ retryCondition: "Retry after sleeping or waiting runs complete, are signalled, or are cancelled.",
2371
+ nextAction: "Cancel or signal parked runs, or raise GENCOW_WORKFLOW_PARKED_PER_APP; no workflow was started."
2372
+ }
2373
+ }
2374
+ );
2375
+ }
2294
2376
  if (counts.running >= policy.runningPerApp) {
2295
2377
  throw new JobAdmissionError("job_backlog_limit_exceeded", "workflow running backlog limit exceeded", {
2296
2378
  details: { limit: policy.runningPerApp, running: counts.running }
@@ -2343,10 +2425,83 @@ async function runWorkflowAdmissionCriticalSection(db, callback) {
2343
2425
  }
2344
2426
 
2345
2427
  // ../core/src/workflow-json.ts
2346
- import { sql as sql2 } from "drizzle-orm";
2428
+ import { sql as sql3 } from "drizzle-orm";
2347
2429
  function workflowJsonb(value) {
2348
2430
  const json = JSON.stringify(value ?? {});
2349
- return sql2`${json}::text::jsonb`;
2431
+ return sql3`${json}::text::jsonb`;
2432
+ }
2433
+
2434
+ // ../core/src/workflow-idempotent-start.ts
2435
+ import { sql as sql4 } from "drizzle-orm";
2436
+ var MAX_IDEMPOTENCY_KEY_LENGTH = 200;
2437
+ function normalizeWorkflowIdempotencyKey(value) {
2438
+ if (value === null || value === void 0 || value === "") return null;
2439
+ if (typeof value !== "string") {
2440
+ throw new GencowValidationError(`Workflow idempotencyKey: expected a string or null`);
2441
+ }
2442
+ if (value.length > MAX_IDEMPOTENCY_KEY_LENGTH) {
2443
+ throw new GencowValidationError(
2444
+ `Workflow idempotencyKey: at most ${MAX_IDEMPOTENCY_KEY_LENGTH} characters`
2445
+ );
2446
+ }
2447
+ return value;
2448
+ }
2449
+ function rows3(result) {
2450
+ if (Array.isArray(result)) return result;
2451
+ const nested = result?.rows;
2452
+ return Array.isArray(nested) ? nested : [];
2453
+ }
2454
+ async function hashWorkflowIdempotencyKey(db, workflowName, userId, key) {
2455
+ const hashed = rows3(
2456
+ await db.execute(sql4`
2457
+ SELECT encode(sha256(convert_to(
2458
+ ${workflowName} || chr(31) || ${userId ?? "anon"} || chr(31) || ${key},
2459
+ 'UTF8')), 'hex') AS key_hash
2460
+ `)
2461
+ );
2462
+ return String(hashed[0]?.key_hash ?? "");
2463
+ }
2464
+ function deduplicatedWorkflowStart(workflowName, existing) {
2465
+ return {
2466
+ id: existing.id,
2467
+ name: workflowName,
2468
+ status: existing.status === "running" ? "running" : "pending",
2469
+ scheduledJobId: `start:${existing.id}`,
2470
+ deduplicated: true
2471
+ };
2472
+ }
2473
+ function isWorkflowIdempotencyConflict(error) {
2474
+ const source = error?.cause ?? error;
2475
+ const { code, constraint, message } = source ?? {};
2476
+ return code === "23505" && `${constraint ?? ""} ${message ?? ""}`.includes("idempotency");
2477
+ }
2478
+ async function findLiveWorkflowIdempotentRun(options) {
2479
+ const keyHash = await hashWorkflowIdempotencyKey(options.db, options.workflowName, options.userId, options.key);
2480
+ const found = rows3(
2481
+ await options.db.execute(sql4`
2482
+ SELECT id, status FROM _gencow_workflow_runs_v2
2483
+ WHERE idempotency_key = ${keyHash} AND completed_at IS NULL AND cancel_requested_at IS NULL LIMIT 1
2484
+ `)
2485
+ )[0];
2486
+ return found ? { id: String(found.id), status: String(found.status) } : null;
2487
+ }
2488
+ async function lookupWorkflowIdempotentStart(options) {
2489
+ const keyHash = await hashWorkflowIdempotencyKey(options.db, options.workflowName, options.userId, options.key);
2490
+ await options.db.execute(sql4`SELECT pg_advisory_xact_lock(hashtextextended(${keyHash}, 0))`);
2491
+ await options.db.execute(sql4`
2492
+ UPDATE _gencow_workflow_runs_v2
2493
+ SET idempotency_key = NULL
2494
+ WHERE idempotency_key = ${keyHash} AND (completed_at IS NOT NULL OR cancel_requested_at IS NOT NULL)
2495
+ `);
2496
+ const found = rows3(
2497
+ await options.db.execute(sql4`
2498
+ SELECT id, status FROM _gencow_workflow_runs_v2 WHERE idempotency_key = ${keyHash} LIMIT 1
2499
+ `)
2500
+ )[0];
2501
+ return {
2502
+ keyHash,
2503
+ existing: found ? { id: String(found.id), status: String(found.status) } : null
2504
+ };
2350
2505
  }
2351
2506
 
2352
2507
  // ../core/src/workflow-wake-signal.ts
@@ -2505,63 +2660,15 @@ function isMissingWorkflowV2SchemaError2(error) {
2505
2660
  const message = error instanceof Error ? error.message : String(error);
2506
2661
  return code === "42P01" || code === "42703" || causeCode === "42P01" || causeCode === "42703" || message.includes('relation "_gencow_workflow_runs_v2" does not exist') || message.includes("relation _gencow_workflow_runs_v2 does not exist") || message.includes('relation "_gencow_workflow_outbox_v2" does not exist') || message.includes("relation _gencow_workflow_outbox_v2 does not exist") || message.includes('column "max_active_duration_ms"') || message.includes('column "retry_count"') || message.includes('column "user_id"');
2507
2662
  }
2508
- function rows2(result) {
2509
- if (Array.isArray(result)) return result;
2510
- if (result && typeof result === "object" && Array.isArray(result.rows)) {
2511
- return result.rows;
2512
- }
2513
- return [];
2514
- }
2515
- function numberFrom2(value) {
2516
- const parsed = Number(value ?? 0);
2517
- return Number.isFinite(parsed) ? parsed : 0;
2518
- }
2519
- async function hasWorkflowV2StartSchema(db) {
2520
- const runColumns = [
2521
- "id",
2522
- "workflow_name",
2523
- "workflow_version",
2524
- "args_json",
2525
- "user_id",
2526
- "max_active_duration_ms",
2527
- "lifecycle_deadline_at",
2528
- "retry_count",
2529
- "max_retries",
2530
- "max_attempts"
2531
- ];
2532
- const outboxColumns = ["id", "run_id", "kind", "available_at", "status"];
2533
- const runColumnList = sql3.join(
2534
- runColumns.map((column) => sql3`${column}`),
2535
- sql3`, `
2536
- );
2537
- const outboxColumnList = sql3.join(
2538
- outboxColumns.map((column) => sql3`${column}`),
2539
- sql3`, `
2540
- );
2541
- const result = await db.execute(sql3`
2542
- SELECT
2543
- (
2544
- SELECT COUNT(*)::int
2545
- FROM information_schema.columns
2546
- WHERE table_schema = 'public'
2547
- AND table_name = '_gencow_workflow_runs_v2'
2548
- AND column_name IN (${runColumnList})
2549
- ) AS run_column_count,
2550
- (
2551
- SELECT COUNT(*)::int
2552
- FROM information_schema.columns
2553
- WHERE table_schema = 'public'
2554
- AND table_name = '_gencow_workflow_outbox_v2'
2555
- AND column_name IN (${outboxColumnList})
2556
- ) AS outbox_column_count
2557
- `);
2558
- const row = rows2(result)[0];
2559
- return numberFrom2(row?.run_column_count) === runColumns.length && numberFrom2(row?.outbox_column_count) === outboxColumns.length;
2663
+ var COMMAND_PROTOCOL_1_WORKFLOW_NAMES = /* @__PURE__ */ new Set(["__gencow.documents.ingest.run"]);
2664
+ function defaultWorkflowCommandProtocol(workflowName, env = typeof process !== "undefined" ? process.env : {}) {
2665
+ if (COMMAND_PROTOCOL_1_WORKFLOW_NAMES.has(workflowName)) return 1;
2666
+ return env.GENCOW_WORKFLOW_COMMAND_PROTOCOL_DEFAULT === "1" ? 1 : 2;
2560
2667
  }
2561
2668
  async function tryInsertWorkflowV2Run(options) {
2562
2669
  try {
2563
2670
  if (!await hasWorkflowV2StartSchema(options.db)) return false;
2564
- await options.db.execute(sql3`
2671
+ await options.db.execute(sql5`
2565
2672
  WITH inserted_run AS (
2566
2673
  INSERT INTO _gencow_workflow_runs_v2 (
2567
2674
  id,
@@ -2573,7 +2680,9 @@ async function tryInsertWorkflowV2Run(options) {
2573
2680
  lifecycle_deadline_at,
2574
2681
  retry_count,
2575
2682
  max_retries,
2576
- max_attempts
2683
+ max_attempts,
2684
+ command_protocol,
2685
+ idempotency_key
2577
2686
  )
2578
2687
  VALUES (
2579
2688
  ${options.workflowId},
@@ -2588,7 +2697,9 @@ async function tryInsertWorkflowV2Run(options) {
2588
2697
  END,
2589
2698
  0,
2590
2699
  ${options.maxRetries},
2591
- ${options.maxRetries + 1}
2700
+ ${options.maxRetries + 1},
2701
+ ${options.commandProtocol ?? defaultWorkflowCommandProtocol(options.workflowName)},
2702
+ ${options.idempotencyKeyHash ?? null}
2592
2703
  )
2593
2704
  RETURNING id
2594
2705
  ),
@@ -2626,7 +2737,7 @@ async function tryInsertWorkflowV2Run(options) {
2626
2737
  }
2627
2738
  async function tryDeleteWorkflowV2Run(db, workflowId) {
2628
2739
  try {
2629
- await db.execute(sql3`
2740
+ await db.execute(sql5`
2630
2741
  DELETE FROM _gencow_workflow_runs_v2
2631
2742
  WHERE id = ${workflowId}
2632
2743
  `);
@@ -2683,16 +2794,37 @@ function composeWorkflowStartMiddleware(options) {
2683
2794
  return await run(0, initialContext, initialInput);
2684
2795
  };
2685
2796
  }
2686
- async function startWorkflowRun(ctx, args, def) {
2797
+ async function startWorkflowRun(ctx, args, def, conflictRetries = 2) {
2687
2798
  const workflowId = crypto.randomUUID();
2688
2799
  const resumeAction = getWorkflowResumeActionName(def.name);
2689
2800
  const ownerId = ctx.auth.getUserIdentity()?.id ?? null;
2690
2801
  const persistedArgs = serializeWorkflowValue(args ?? {});
2691
2802
  assertWorkflowPayloadWithinLimit({ workflowName: def.name, kind: "args", serializedValue: persistedArgs });
2692
2803
  const realtimeToken = createWorkflowRealtimeToken();
2804
+ const idempotencyKey = ownerId ? normalizeWorkflowIdempotencyKey(def.idempotencyKey?.(args, ctx)) : null;
2693
2805
  let insertedWorkflowV2 = false;
2806
+ let deduplicated = null;
2694
2807
  try {
2695
2808
  await runWorkflowAdmissionCriticalSection(ctx.unsafeDb, async (db) => {
2809
+ let idempotencyKeyHash = null;
2810
+ if (idempotencyKey !== null) {
2811
+ if (!await hasWorkflowV2StartSchema(db)) {
2812
+ throw new GencowValidationError(
2813
+ `Workflow ${def.name}: idempotencyKey needs the Workflow V2 tables. Redeploy so the app schema is upgraded, then start again.`
2814
+ );
2815
+ }
2816
+ const lookup = await lookupWorkflowIdempotentStart({
2817
+ db,
2818
+ workflowName: def.name,
2819
+ userId: ownerId,
2820
+ key: idempotencyKey
2821
+ });
2822
+ if (lookup.existing) {
2823
+ deduplicated = deduplicatedWorkflowStart(def.name, lookup.existing);
2824
+ return;
2825
+ }
2826
+ idempotencyKeyHash = lookup.keyHash;
2827
+ }
2696
2828
  await applyWorkflowStartAdmission({ db, workflowName: def.name, args: persistedArgs });
2697
2829
  insertedWorkflowV2 = await tryInsertWorkflowV2Run({
2698
2830
  db,
@@ -2703,10 +2835,11 @@ async function startWorkflowRun(ctx, args, def) {
2703
2835
  userId: ownerId,
2704
2836
  maxActiveDurationMs: def.maxActiveDurationMs,
2705
2837
  lifecycleTimeoutMs: def.lifecycleTimeoutMs,
2706
- maxRetries: def.maxRetries
2838
+ maxRetries: def.maxRetries,
2839
+ idempotencyKeyHash
2707
2840
  });
2708
2841
  const legacyArgs = insertedWorkflowV2 ? serializeWorkflowValue(void 0) : persistedArgs;
2709
- await db.execute(sql3`
2842
+ await db.execute(sql5`
2710
2843
  INSERT INTO _gencow_workflows (
2711
2844
  id, name, args, realtime_token, status, retry_count, max_retries, max_duration_ms, user_id
2712
2845
  ) VALUES (
@@ -2715,6 +2848,7 @@ async function startWorkflowRun(ctx, args, def) {
2715
2848
  )
2716
2849
  `);
2717
2850
  });
2851
+ if (deduplicated) return deduplicated;
2718
2852
  return {
2719
2853
  id: workflowId,
2720
2854
  name: def.name,
@@ -2722,8 +2856,18 @@ async function startWorkflowRun(ctx, args, def) {
2722
2856
  scheduledJobId: insertedWorkflowV2 ? `start:${workflowId}` : ctx.scheduler.runAfter(0, resumeAction, { workflowId })
2723
2857
  };
2724
2858
  } catch (error) {
2725
- await ctx.unsafeDb.execute(sql3`DELETE FROM _gencow_workflows WHERE id = ${workflowId}`);
2859
+ await ctx.unsafeDb.execute(sql5`DELETE FROM _gencow_workflows WHERE id = ${workflowId}`);
2726
2860
  if (insertedWorkflowV2) await tryDeleteWorkflowV2Run(ctx.unsafeDb, workflowId);
2861
+ if (idempotencyKey !== null && isWorkflowIdempotencyConflict(error)) {
2862
+ const winner = await findLiveWorkflowIdempotentRun({
2863
+ db: ctx.unsafeDb,
2864
+ workflowName: def.name,
2865
+ userId: ownerId,
2866
+ key: idempotencyKey
2867
+ });
2868
+ if (winner) return deduplicatedWorkflowStart(def.name, winner);
2869
+ if (conflictRetries > 0) return startWorkflowRun(ctx, args, def, conflictRetries - 1);
2870
+ }
2727
2871
  throw error;
2728
2872
  }
2729
2873
  }
@@ -2810,7 +2954,7 @@ function workflow(name, options) {
2810
2954
  return metadata.legacyStart;
2811
2955
  }
2812
2956
  var GencowWorkflowBuilderImpl = class _GencowWorkflowBuilderImpl {
2813
- constructor(workflowName, middlewares = [], inputSchema, inputValidationIndex = -1, anonymousAllowed = false, workflowVersion, maxDurationValue, lifecycleTimeoutValue, concurrencyValue, retriesValue) {
2957
+ constructor(workflowName, middlewares = [], inputSchema, inputValidationIndex = -1, anonymousAllowed = false, workflowVersion, maxDurationValue, lifecycleTimeoutValue, concurrencyValue, retriesValue, idempotencyKeyValue) {
2814
2958
  this.workflowName = workflowName;
2815
2959
  this.middlewares = middlewares;
2816
2960
  this.inputSchema = inputSchema;
@@ -2821,6 +2965,7 @@ var GencowWorkflowBuilderImpl = class _GencowWorkflowBuilderImpl {
2821
2965
  this.lifecycleTimeoutValue = lifecycleTimeoutValue;
2822
2966
  this.concurrencyValue = concurrencyValue;
2823
2967
  this.retriesValue = retriesValue;
2968
+ this.idempotencyKeyValue = idempotencyKeyValue;
2824
2969
  }
2825
2970
  workflowName;
2826
2971
  middlewares;
@@ -2832,6 +2977,7 @@ var GencowWorkflowBuilderImpl = class _GencowWorkflowBuilderImpl {
2832
2977
  lifecycleTimeoutValue;
2833
2978
  concurrencyValue;
2834
2979
  retriesValue;
2980
+ idempotencyKeyValue;
2835
2981
  copy(changes = {}) {
2836
2982
  return new _GencowWorkflowBuilderImpl(
2837
2983
  changes.workflowName ?? this.workflowName,
@@ -2843,7 +2989,8 @@ var GencowWorkflowBuilderImpl = class _GencowWorkflowBuilderImpl {
2843
2989
  changes.maxDurationValue ?? this.maxDurationValue,
2844
2990
  changes.lifecycleTimeoutValue ?? this.lifecycleTimeoutValue,
2845
2991
  changes.concurrencyValue ?? this.concurrencyValue,
2846
- changes.retriesValue ?? this.retriesValue
2992
+ changes.retriesValue ?? this.retriesValue,
2993
+ changes.idempotencyKeyValue ?? this.idempotencyKeyValue
2847
2994
  );
2848
2995
  }
2849
2996
  name(name) {
@@ -2876,6 +3023,9 @@ var GencowWorkflowBuilderImpl = class _GencowWorkflowBuilderImpl {
2876
3023
  retries(count) {
2877
3024
  return this.copy({ retriesValue: count });
2878
3025
  }
3026
+ idempotencyKey(derive) {
3027
+ return this.copy({ idempotencyKeyValue: derive });
3028
+ }
2879
3029
  handler(handler) {
2880
3030
  if (!this.workflowName)
2881
3031
  throw new Error("Workflow name is required. Call .name(...) before .handler(...)");
@@ -2893,6 +3043,7 @@ var GencowWorkflowBuilderImpl = class _GencowWorkflowBuilderImpl {
2893
3043
  ),
2894
3044
  concurrency: normalizeConcurrency(this.concurrencyValue),
2895
3045
  maxRetries: clampRetries(this.retriesValue),
3046
+ idempotencyKey: this.idempotencyKeyValue,
2896
3047
  handler
2897
3048
  };
2898
3049
  return createWorkflowMetadata({
@@ -2920,8 +3071,254 @@ function deriveWorkflowStatus(status, currentStep) {
2920
3071
  return "queued";
2921
3072
  }
2922
3073
 
3074
+ // ../core/src/workflow-errors.ts
3075
+ var WORKFLOW_STEP_FAILED_CODE = "workflow_step_failed";
3076
+ var WORKFLOW_WAIT_TIMEOUT_CODE = "workflow_wait_timeout";
3077
+ var WorkflowStepFailedError = class extends Error {
3078
+ constructor(stepName, attempt, message, branchIndex) {
3079
+ super(message);
3080
+ this.stepName = stepName;
3081
+ this.attempt = attempt;
3082
+ this.branchIndex = branchIndex;
3083
+ this.name = "WorkflowStepFailedError";
3084
+ }
3085
+ stepName;
3086
+ attempt;
3087
+ branchIndex;
3088
+ code = WORKFLOW_STEP_FAILED_CODE;
3089
+ };
3090
+ var WorkflowWaitTimeoutError = class extends Error {
3091
+ constructor(event) {
3092
+ super(`waitForEvent("${event}") timed out`);
3093
+ this.event = event;
3094
+ this.name = "WorkflowWaitTimeoutError";
3095
+ }
3096
+ event;
3097
+ code = WORKFLOW_WAIT_TIMEOUT_CODE;
3098
+ };
3099
+ var WorkflowNonRetryableError = class extends Error {
3100
+ constructor(message, options) {
3101
+ super(message, options);
3102
+ this.name = "WorkflowNonRetryableError";
3103
+ }
3104
+ };
3105
+ var WORKFLOW_STEP_TIMEOUT_CODE = "workflow_step_timeout";
3106
+ var WORKFLOW_STEP_OPTIONS_INVALID_CODE = "workflow_step_options_invalid";
3107
+ var WorkflowStepTimeoutError = class extends Error {
3108
+ constructor(stepName, timeoutMs) {
3109
+ super(`workflow step "${stepName}" timed out after ${timeoutMs}ms`);
3110
+ this.stepName = stepName;
3111
+ this.timeoutMs = timeoutMs;
3112
+ this.name = "WorkflowStepTimeoutError";
3113
+ }
3114
+ stepName;
3115
+ timeoutMs;
3116
+ code = WORKFLOW_STEP_TIMEOUT_CODE;
3117
+ };
3118
+ var WorkflowStepOptionsError = class extends Error {
3119
+ code = WORKFLOW_STEP_OPTIONS_INVALID_CODE;
3120
+ constructor(stepName, reason) {
3121
+ super(`wf.step(${JSON.stringify(stepName)}) options: ${reason}`);
3122
+ this.name = "WorkflowStepOptionsError";
3123
+ }
3124
+ };
3125
+ var WorkflowNestedCommandError = class extends Error {
3126
+ constructor(command) {
3127
+ super(
3128
+ `wf.${command}() cannot be called inside a step or parallel branch. Call it from the workflow handler itself; a step body is plain code.`
3129
+ );
3130
+ this.name = "WorkflowNestedCommandError";
3131
+ }
3132
+ };
3133
+ var WorkflowCommandNameError = class extends Error {
3134
+ constructor(command, name, reason) {
3135
+ super(`wf.${command}(${JSON.stringify(name)}): ${reason}`);
3136
+ this.name = "WorkflowCommandNameError";
3137
+ }
3138
+ };
3139
+ var MAX_COMMAND_NAME_LENGTH = 200;
3140
+ function normalizeWorkflowCommandName(command, name, options) {
3141
+ if (typeof name !== "string") {
3142
+ throw new WorkflowCommandNameError(command, String(name), "name must be a string");
3143
+ }
3144
+ const normalized = name.normalize("NFC");
3145
+ if (normalized.length === 0) throw new WorkflowCommandNameError(command, name, "name must not be empty");
3146
+ if (normalized.length > MAX_COMMAND_NAME_LENGTH) {
3147
+ throw new WorkflowCommandNameError(command, name, `name must be at most ${MAX_COMMAND_NAME_LENGTH} characters`);
3148
+ }
3149
+ if (/[\u0000-\u001f\u007f]/.test(normalized)) {
3150
+ throw new WorkflowCommandNameError(command, name, "name must not contain control characters");
3151
+ }
3152
+ if (normalized.includes("/")) throw new WorkflowCommandNameError(command, name, 'name must not contain "/"');
3153
+ if (!options?.allowHash && normalized.includes("#")) {
3154
+ throw new WorkflowCommandNameError(command, name, 'name must not contain "#"');
3155
+ }
3156
+ return normalized;
3157
+ }
3158
+
3159
+ // ../core/src/workflow-step-options.ts
3160
+ var MIN_BACKOFF_INITIAL_MS = 100;
3161
+ var MAX_BACKOFF_MS = 24 * 60 * 60 * 1e3;
3162
+ function validateWorkflowStepOptions(stepName, options) {
3163
+ if (!options) return { timeoutMs: null };
3164
+ const fail = (reason) => {
3165
+ throw new WorkflowStepOptionsError(stepName, reason);
3166
+ };
3167
+ const duration = (value, label) => {
3168
+ try {
3169
+ return parseWorkflowDurationMs(value, label);
3170
+ } catch (error) {
3171
+ return fail(error instanceof Error ? error.message : `${label} is invalid`);
3172
+ }
3173
+ };
3174
+ if (options.retries != null && (!Number.isInteger(options.retries) || options.retries < 0)) {
3175
+ fail("retries must be a non-negative integer");
3176
+ }
3177
+ let timeoutMs = null;
3178
+ if (options.timeout != null) {
3179
+ timeoutMs = duration(options.timeout, "timeout");
3180
+ if (timeoutMs <= 0) fail("timeout must be greater than 0");
3181
+ }
3182
+ const backoff = options.backoff;
3183
+ if (backoff) {
3184
+ if (backoff.initial != null && duration(backoff.initial, "backoff.initial") < MIN_BACKOFF_INITIAL_MS) {
3185
+ fail(`backoff.initial must be at least ${MIN_BACKOFF_INITIAL_MS}ms`);
3186
+ }
3187
+ if (backoff.factor != null && !(backoff.factor >= 1 && backoff.factor <= 10)) {
3188
+ fail("backoff.factor must be between 1 and 10");
3189
+ }
3190
+ if (backoff.max != null && duration(backoff.max, "backoff.max") > MAX_BACKOFF_MS) {
3191
+ fail("backoff.max must be at most 24h");
3192
+ }
3193
+ }
3194
+ return { timeoutMs };
3195
+ }
3196
+
2923
3197
  // ../core/src/workflows-api.ts
2924
- import { sql as sql4 } from "drizzle-orm";
3198
+ import { sql as sql8 } from "drizzle-orm";
3199
+
3200
+ // ../core/src/workflows-signal-record.ts
3201
+ import { sql as sql6 } from "drizzle-orm";
3202
+ function rowsFromResult(result) {
3203
+ if (Array.isArray(result)) return result;
3204
+ if (result && typeof result === "object" && Array.isArray(result.rows)) {
3205
+ return result.rows;
3206
+ }
3207
+ return [];
3208
+ }
3209
+ function isMissingWorkflowV2SignalSchemaError(error) {
3210
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
3211
+ const cause = error && typeof error === "object" && "cause" in error ? error.cause : null;
3212
+ const causeCode = cause && typeof cause === "object" && "code" in cause ? String(cause.code) : "";
3213
+ const message = error instanceof Error ? error.message : String(error);
3214
+ return code === "42P01" || code === "42703" || causeCode === "42P01" || causeCode === "42703" || (code === "23503" || causeCode === "23503") && message.includes("_gencow_workflow_signals_v2") || message.includes('relation "_gencow_workflow_signals_v2" does not exist') || message.includes("relation _gencow_workflow_signals_v2 does not exist") || message.includes('relation "_gencow_workflow_runs_v2" does not exist') || message.includes("relation _gencow_workflow_runs_v2 does not exist") || message.includes('relation "_gencow_workflow_outbox_v2" does not exist') || message.includes("relation _gencow_workflow_outbox_v2 does not exist") || message.includes('relation "_gencow_workflow_steps_v2" does not exist') || message.includes("relation _gencow_workflow_steps_v2 does not exist");
3215
+ }
3216
+ var WAKES_PARKED_WAIT = sql6.raw(`(run.status = 'waiting'
3217
+ AND (run.current_step_key IS NULL
3218
+ OR starts_with(run.current_step_key, 'wait:' || inserted.event_name || '#'))
3219
+ AND NOT EXISTS (
3220
+ SELECT 1 FROM _gencow_workflow_timers_v2 expired
3221
+ WHERE expired.run_id = run.id AND expired.kind = 'wait_timeout'
3222
+ AND expired.status = 'pending' AND expired.fire_at <= NOW()
3223
+ AND (
3224
+ EXISTS (SELECT 1 FROM _gencow_workflow_steps_v2 parked
3225
+ WHERE parked.run_id = expired.run_id AND parked.step_key = expired.command_key
3226
+ AND parked.status = 'pending')
3227
+ OR (run.command_protocol = 1
3228
+ AND expired.dedupe_key = run.current_step_key || ':timeout')
3229
+ )))`);
3230
+ async function tryRecordWorkflowV2Signal(options) {
3231
+ const eventName = options.event.normalize("NFC");
3232
+ try {
3233
+ const result = await options.db.execute(sql6`
3234
+ WITH eligible_run AS (
3235
+ SELECT id
3236
+ FROM _gencow_workflow_runs_v2
3237
+ WHERE id = ${options.workflowId}
3238
+ AND status IN ('queued', 'running', 'sleeping', 'waiting')
3239
+ AND completed_at IS NULL
3240
+ AND cancel_requested_at IS NULL
3241
+ FOR UPDATE
3242
+ ), inserted AS (
3243
+ INSERT INTO _gencow_workflow_signals_v2 (
3244
+ id,
3245
+ run_id,
3246
+ event_name,
3247
+ payload_json,
3248
+ idempotency_key
3249
+ )
3250
+ SELECT
3251
+ ${options.signalId},
3252
+ eligible_run.id,
3253
+ ${eventName},
3254
+ ${workflowJsonb(options.payload)},
3255
+ ${options.idempotencyKey ?? options.signalId}
3256
+ FROM eligible_run
3257
+ ON CONFLICT (run_id, event_name, idempotency_key) DO NOTHING
3258
+ RETURNING id, run_id, event_name
3259
+ ),
3260
+ queued_run AS (
3261
+ -- SET expressions run on the newest row version after any concurrent commit. A wait that parks
3262
+ -- while this statement is blocked on the run lock is therefore still woken; a WHERE on status
3263
+ -- would be judged against the statement snapshot and miss it.
3264
+ UPDATE _gencow_workflow_runs_v2 run
3265
+ SET
3266
+ status = CASE WHEN ${WAKES_PARKED_WAIT} THEN 'queued' ELSE run.status END,
3267
+ runnable_at = CASE WHEN ${WAKES_PARKED_WAIT} THEN NOW() ELSE run.runnable_at END,
3268
+ lease_owner = CASE WHEN ${WAKES_PARKED_WAIT} THEN NULL ELSE run.lease_owner END,
3269
+ lease_expires_at = CASE WHEN ${WAKES_PARKED_WAIT} THEN NULL ELSE run.lease_expires_at END,
3270
+ heartbeat_at = CASE WHEN ${WAKES_PARKED_WAIT} THEN NULL ELSE run.heartbeat_at END,
3271
+ updated_at = CASE WHEN ${WAKES_PARKED_WAIT} THEN NOW() ELSE run.updated_at END
3272
+ FROM inserted
3273
+ WHERE run.id = inserted.run_id
3274
+ AND run.completed_at IS NULL
3275
+ AND run.cancel_requested_at IS NULL
3276
+ RETURNING run.id, (run.status = 'queued') AS woken
3277
+ ),
3278
+ wake_outbox AS (
3279
+ INSERT INTO _gencow_workflow_outbox_v2 (
3280
+ id,
3281
+ run_id,
3282
+ kind,
3283
+ available_at,
3284
+ status
3285
+ )
3286
+ SELECT
3287
+ 'signal:' || inserted.id,
3288
+ inserted.run_id,
3289
+ 'wake_signal',
3290
+ NOW(),
3291
+ 'pending'
3292
+ FROM inserted
3293
+ JOIN queued_run ON queued_run.id = inserted.run_id AND queued_run.woken
3294
+ ON CONFLICT (id) DO NOTHING
3295
+ RETURNING id
3296
+ ),
3297
+ wake_notified AS (
3298
+ SELECT pg_notify(${WORKFLOW_WAKE_SIGNAL_CHANNEL}, ${WORKFLOW_WAKE_SIGNAL_PAYLOAD})
3299
+ FROM wake_outbox
3300
+ )
3301
+ SELECT 1
3302
+ FROM inserted
3303
+ LEFT JOIN wake_notified ON TRUE
3304
+ `);
3305
+ if (rowsFromResult(result).length > 0) return "accepted";
3306
+ if (options.idempotencyKey) {
3307
+ const prior = await options.db.execute(sql6`
3308
+ SELECT 1 FROM _gencow_workflow_signals_v2
3309
+ WHERE run_id = ${options.workflowId}
3310
+ AND event_name = ${eventName}
3311
+ AND idempotency_key = ${options.idempotencyKey}
3312
+ LIMIT 1
3313
+ `);
3314
+ if (rowsFromResult(prior).length > 0) return "duplicate";
3315
+ }
3316
+ return "rejected";
3317
+ } catch (error) {
3318
+ if (isMissingWorkflowV2SignalSchemaError(error)) return "absent";
3319
+ throw error;
3320
+ }
3321
+ }
2925
3322
 
2926
3323
  // ../core/src/workflow-public-read-model.ts
2927
3324
  function parseJsonField(value) {
@@ -2942,9 +3339,55 @@ function toOptionalIsoString(value) {
2942
3339
  if (typeof value === "number" && !Number.isFinite(value)) return toIsoString(value);
2943
3340
  return value ? toIsoString(value) : null;
2944
3341
  }
3342
+ function publicWorkflowErrorCode(code) {
3343
+ if (!code) return null;
3344
+ return code === "active_timeout" ? "workflow_active_timeout" : code;
3345
+ }
3346
+ function publicV1WorkflowStatus(status, errorCode) {
3347
+ return status === "failed" && errorCode === "cancelled" ? "cancelled" : status;
3348
+ }
3349
+ var PUBLIC_ERROR_SUMMARIES = {
3350
+ workflow_error: "A workflow step failed.",
3351
+ workflow_step_timeout: "A workflow step exceeded its timeout.",
3352
+ workflow_step_options_invalid: "A workflow step has invalid options.",
3353
+ workflow_active_timeout: "The workflow exceeded its maximum active duration.",
3354
+ workflow_nondeterministic: "The workflow code changed in a way that no longer matches this run's history.",
3355
+ definition_missing: "The workflow definition is not deployed.",
3356
+ cancelled: "The workflow was cancelled."
3357
+ };
3358
+ var ERROR_DETAIL_MAX_CHARS = 300;
3359
+ var SQLSTATE = /^[0-9A-Z]{5}$/;
3360
+ function looksLikeDriverFailure(body, message) {
3361
+ return body?.name === "DrizzleQueryError" || (message?.startsWith("Failed query:") ?? false);
3362
+ }
3363
+ function publicStepError(errorJson) {
3364
+ if (errorJson === null || errorJson === void 0) return null;
3365
+ const body = errorJson && typeof errorJson === "object" ? errorJson : null;
3366
+ const message = typeof errorJson === "string" ? errorJson : body && typeof body.message === "string" ? body.message : null;
3367
+ const driverCode = body && typeof body.code === "string" ? body.code : null;
3368
+ if (driverCode && SQLSTATE.test(driverCode) || looksLikeDriverFailure(body, message)) {
3369
+ return "The step failed.";
3370
+ }
3371
+ return message ? message.slice(0, ERROR_DETAIL_MAX_CHARS) : null;
3372
+ }
3373
+ function summarizeWorkflowError(rawCode, errorJson) {
3374
+ const errorCode = publicWorkflowErrorCode(rawCode);
3375
+ if (!errorCode && (errorJson === null || errorJson === void 0)) {
3376
+ return { error: null, errorCode: null, errorDetail: null };
3377
+ }
3378
+ const error = errorCode && PUBLIC_ERROR_SUMMARIES[errorCode] || "The workflow failed.";
3379
+ const body = errorJson && typeof errorJson === "object" ? errorJson : null;
3380
+ const message = typeof errorJson === "string" ? errorJson : body && typeof body.message === "string" ? body.message : null;
3381
+ const driverCode = body && typeof body.code === "string" ? body.code : null;
3382
+ const userThrown = errorCode === "workflow_error" && !(driverCode && SQLSTATE.test(driverCode)) && !looksLikeDriverFailure(body, message);
3383
+ return {
3384
+ error,
3385
+ errorCode,
3386
+ errorDetail: userThrown && message ? message.slice(0, ERROR_DETAIL_MAX_CHARS) : null
3387
+ };
3388
+ }
2945
3389
  function publicV2Status(status) {
2946
- if (status === "completed" || status === "failed") return status;
2947
- if (status === "cancelled") return "failed";
3390
+ if (status === "completed" || status === "failed" || status === "cancelled") return status;
2948
3391
  if (status === "running") return "running";
2949
3392
  return "pending";
2950
3393
  }
@@ -2953,15 +3396,19 @@ function publicV2DerivedStatus(status) {
2953
3396
  return publicV2Status(status);
2954
3397
  }
2955
3398
  function mapV2WorkflowSummary(row) {
2956
- const error = parseJsonField(row.error_json);
3399
+ const summary = summarizeWorkflowError(
3400
+ row.status === "cancelled" ? "cancelled" : row.error_code,
3401
+ parseJsonField(row.error_json)
3402
+ );
2957
3403
  return {
2958
3404
  id: row.id,
2959
3405
  name: row.workflow_name,
2960
3406
  status: publicV2Status(row.status),
2961
3407
  derivedStatus: publicV2DerivedStatus(row.status),
2962
3408
  currentStep: row.current_step_key,
2963
- error: typeof error === "string" ? error : error && typeof error === "object" && "message" in error ? String(error.message) : null,
2964
- errorCode: row.status === "cancelled" ? "cancelled" : row.error_code,
3409
+ error: summary.error,
3410
+ errorCode: summary.errorCode,
3411
+ errorDetail: summary.errorDetail,
2965
3412
  retryCount: row.retry_count,
2966
3413
  maxRetries: row.max_retries,
2967
3414
  maxDurationMs: Number(row.max_active_duration_ms),
@@ -2970,23 +3417,84 @@ function mapV2WorkflowSummary(row) {
2970
3417
  completedAt: toOptionalIsoString(row.completed_at)
2971
3418
  };
2972
3419
  }
3420
+ var BRANCH_STEP_KEY = /^parallel#\d+\/b\d+$/;
3421
+ function publicV2StepKind(row) {
3422
+ if (row.command_kind === "sleep" || row.command_kind === "wait") return row.command_kind;
3423
+ if (row.command_kind !== "step") return void 0;
3424
+ return BRANCH_STEP_KEY.test(row.step_key) ? "branch" : "step";
3425
+ }
2973
3426
  function mapV2WorkflowStep(row) {
2974
3427
  const error = parseJsonField(row.error_json);
2975
3428
  return {
2976
3429
  name: row.step_key,
2977
- status: row.status === "running" || row.status === "failed" || row.status === "completed" ? row.status : row.status === "skipped" ? "completed" : "pending",
2978
- output: parseJsonField(row.output_json),
2979
- error: typeof error === "string" ? error : error && typeof error === "object" && "message" in error ? String(error.message) : null,
3430
+ kind: publicV2StepKind(row),
3431
+ status: row.status === "running" || row.status === "failed" || row.status === "completed" || row.status === "cancelled" ? row.status : row.status === "skipped" ? "completed" : "pending",
3432
+ output: row.output_codec === "envelope" ? deserializeWorkflowValue(parseJsonField(row.output_json)) : parseJsonField(row.output_json),
3433
+ error: publicStepError(error),
2980
3434
  startedAt: toOptionalIsoString(row.started_at),
2981
3435
  updatedAt: toIsoString(row.updated_at),
2982
3436
  completedAt: toOptionalIsoString(row.completed_at)
2983
3437
  };
2984
3438
  }
2985
3439
 
3440
+ // ../core/src/workflows-cancel-api.ts
3441
+ import { sql as sql7 } from "drizzle-orm";
3442
+ function rowsOf(result) {
3443
+ if (Array.isArray(result)) return result;
3444
+ const rows4 = result?.rows;
3445
+ return Array.isArray(rows4) ? rows4 : [];
3446
+ }
3447
+ function buildWorkflowCancelDefinition() {
3448
+ return {
3449
+ name: "workflows.cancel",
3450
+ argsSchema: { id: v.string() },
3451
+ isPublic: true,
3452
+ handler: async (ctx, args) => {
3453
+ const viewerId = ctx.auth.requireAuth().id;
3454
+ const notFound = { ok: false, workflowId: args.id, status: "not_found" };
3455
+ let run;
3456
+ try {
3457
+ run = rowsOf(
3458
+ await ctx.unsafeDb.execute(
3459
+ sql7`SELECT id, user_id, status, completed_at FROM _gencow_workflow_runs_v2 WHERE id = ${args.id} LIMIT 1`
3460
+ )
3461
+ )[0];
3462
+ } catch (error) {
3463
+ const wrapped = error;
3464
+ if (wrapped?.code !== "42P01" && wrapped?.cause?.code !== "42P01") throw error;
3465
+ }
3466
+ if (!run) {
3467
+ const legacy = await ctx.unsafeDb.execute(sql7`SELECT id, status FROM _gencow_workflows WHERE id = ${args.id} AND user_id = ${viewerId} LIMIT 1`).then((result) => rowsOf(result)[0] ?? null).catch(() => null);
3468
+ if (!legacy) return notFound;
3469
+ const finished = legacy.status === "completed" || legacy.status === "failed";
3470
+ return finished ? { ok: true, workflowId: args.id, status: "already_terminal" } : { ok: false, workflowId: args.id, status: "not_cancellable" };
3471
+ }
3472
+ if (!run.user_id || run.user_id !== viewerId) return notFound;
3473
+ if (run.completed_at !== null || ["completed", "failed", "cancelled"].includes(run.status)) {
3474
+ return { ok: true, workflowId: run.id, status: "already_terminal" };
3475
+ }
3476
+ const updated = rowsOf(
3477
+ await ctx.unsafeDb.execute(sql7`
3478
+ WITH marked AS (
3479
+ UPDATE _gencow_workflow_runs_v2
3480
+ SET cancel_requested_at = COALESCE(cancel_requested_at, NOW()), updated_at = NOW(),
3481
+ idempotency_key = NULL
3482
+ WHERE id = ${run.id} AND user_id = ${viewerId} AND completed_at IS NULL
3483
+ RETURNING id
3484
+ )
3485
+ SELECT id, pg_notify(${WORKFLOW_WAKE_SIGNAL_CHANNEL}, ${WORKFLOW_WAKE_SIGNAL_PAYLOAD}) FROM marked
3486
+ `)
3487
+ );
3488
+ if (updated.length === 0) return { ok: true, workflowId: run.id, status: "already_terminal" };
3489
+ return { ok: true, workflowId: run.id, status: "cancel_requested" };
3490
+ }
3491
+ };
3492
+ }
3493
+
2986
3494
  // ../core/src/workflows-api.ts
2987
- var WORKFLOW_STATUSES = /* @__PURE__ */ new Set(["pending", "running", "completed", "failed"]);
3495
+ var WORKFLOW_STATUSES = /* @__PURE__ */ new Set(["pending", "running", "completed", "failed", "cancelled"]);
2988
3496
  var WORKFLOW_DERIVED_PENDING_STATUSES = /* @__PURE__ */ new Set(["queued", "waiting", "sleeping"]);
2989
- function rowsFromResult(result) {
3497
+ function rowsFromResult2(result) {
2990
3498
  if (Array.isArray(result)) return result;
2991
3499
  if (result && typeof result === "object" && Array.isArray(result.rows)) {
2992
3500
  return result.rows;
@@ -2997,11 +3505,11 @@ function mapWorkflowSummary(row) {
2997
3505
  return {
2998
3506
  id: row.id,
2999
3507
  name: row.name,
3000
- status: row.status,
3001
- derivedStatus: deriveWorkflowStatus(row.status, row.current_step),
3508
+ status: publicV1WorkflowStatus(row.status, row.error_code),
3509
+ derivedStatus: deriveWorkflowStatus(publicV1WorkflowStatus(row.status, row.error_code), row.current_step),
3002
3510
  currentStep: row.current_step,
3003
3511
  error: row.error,
3004
- errorCode: row.error_code,
3512
+ errorCode: publicWorkflowErrorCode(row.error_code),
3005
3513
  retryCount: row.retry_count,
3006
3514
  maxRetries: row.max_retries,
3007
3515
  maxDurationMs: Number(row.max_duration_ms),
@@ -3065,27 +3573,27 @@ function normalizeDerivedStatus(status) {
3065
3573
  async function ensureWorkflowRealtimeToken(db, workflowId, currentToken) {
3066
3574
  if (currentToken && currentToken.trim() !== "") return currentToken;
3067
3575
  const nextToken = createWorkflowRealtimeToken();
3068
- const updateResult = await db.execute(sql4`
3576
+ const updateResult = await db.execute(sql8`
3069
3577
  UPDATE _gencow_workflows
3070
3578
  SET realtime_token = ${nextToken}
3071
3579
  WHERE id = ${workflowId}
3072
3580
  AND (realtime_token IS NULL OR realtime_token = '')
3073
3581
  RETURNING realtime_token
3074
3582
  `);
3075
- const updatedToken = rowsFromResult(updateResult)[0]?.realtime_token ?? null;
3583
+ const updatedToken = rowsFromResult2(updateResult)[0]?.realtime_token ?? null;
3076
3584
  if (updatedToken && updatedToken.trim() !== "") return updatedToken;
3077
- const rereadResult = await db.execute(sql4`
3585
+ const rereadResult = await db.execute(sql8`
3078
3586
  SELECT realtime_token
3079
3587
  FROM _gencow_workflows
3080
3588
  WHERE id = ${workflowId}
3081
3589
  LIMIT 1
3082
3590
  `);
3083
- const rereadToken = rowsFromResult(rereadResult)[0]?.realtime_token ?? null;
3591
+ const rereadToken = rowsFromResult2(rereadResult)[0]?.realtime_token ?? null;
3084
3592
  return rereadToken && rereadToken.trim() !== "" ? rereadToken : null;
3085
3593
  }
3086
3594
  async function loadWorkflowSignalTarget(db, workflowId) {
3087
3595
  try {
3088
- const result = await db.execute(sql4`
3596
+ const result = await db.execute(sql8`
3089
3597
  SELECT
3090
3598
  id,
3091
3599
  name,
@@ -3096,19 +3604,12 @@ async function loadWorkflowSignalTarget(db, workflowId) {
3096
3604
  WHERE id = ${workflowId}
3097
3605
  LIMIT 1
3098
3606
  `);
3099
- return rowsFromResult(result)[0] ?? null;
3607
+ return rowsFromResult2(result)[0] ?? null;
3100
3608
  } catch (error) {
3101
3609
  if (isMissingWorkflowV1Table(error)) return null;
3102
3610
  throw error;
3103
3611
  }
3104
3612
  }
3105
- function isMissingWorkflowV2SignalSchemaError(error) {
3106
- const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
3107
- const cause = error && typeof error === "object" && "cause" in error ? error.cause : null;
3108
- const causeCode = cause && typeof cause === "object" && "code" in cause ? String(cause.code) : "";
3109
- const message = error instanceof Error ? error.message : String(error);
3110
- return code === "42P01" || code === "42703" || causeCode === "42P01" || causeCode === "42703" || (code === "23503" || causeCode === "23503") && message.includes("_gencow_workflow_signals_v2") || message.includes('relation "_gencow_workflow_signals_v2" does not exist') || message.includes("relation _gencow_workflow_signals_v2 does not exist") || message.includes('relation "_gencow_workflow_runs_v2" does not exist') || message.includes("relation _gencow_workflow_runs_v2 does not exist") || message.includes('relation "_gencow_workflow_outbox_v2" does not exist') || message.includes("relation _gencow_workflow_outbox_v2 does not exist") || message.includes('relation "_gencow_workflow_steps_v2" does not exist') || message.includes("relation _gencow_workflow_steps_v2 does not exist");
3111
- }
3112
3613
  function isMissingWorkflowV2PayloadSchemaError(error) {
3113
3614
  const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
3114
3615
  const cause = error && typeof error === "object" && "cause" in error ? error.cause : null;
@@ -3125,7 +3626,7 @@ function isMissingWorkflowV1Table(error) {
3125
3626
  }
3126
3627
  async function loadWorkflowV2Run(db, workflowId) {
3127
3628
  try {
3128
- const result = await db.execute(sql4`
3629
+ const result = await db.execute(sql8`
3129
3630
  SELECT id, workflow_name, status, args_json, result_json, error_json, error_code,
3130
3631
  current_step_key, retry_count, max_retries, max_active_duration_ms,
3131
3632
  created_at, updated_at, completed_at, user_id
@@ -3133,106 +3634,47 @@ async function loadWorkflowV2Run(db, workflowId) {
3133
3634
  WHERE id = ${workflowId}
3134
3635
  LIMIT 1
3135
3636
  `);
3136
- return rowsFromResult(result)[0] ?? null;
3637
+ return rowsFromResult2(result)[0] ?? null;
3137
3638
  } catch (error) {
3138
3639
  if (isMissingWorkflowV2PayloadSchemaError(error)) return null;
3139
3640
  throw error;
3140
3641
  }
3141
3642
  }
3643
+ function isMissingWorkflowV2JournalColumnError(error) {
3644
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
3645
+ const cause = error && typeof error === "object" && "cause" in error ? error.cause : null;
3646
+ const causeCode = cause && typeof cause === "object" && "code" in cause ? String(cause.code) : "";
3647
+ return code === "42703" || causeCode === "42703";
3648
+ }
3142
3649
  async function loadWorkflowV2Steps(db, workflowId) {
3143
3650
  try {
3144
- const stepResult = await db.execute(sql4`
3145
- SELECT step_key, status, output_json, error_json, started_at, updated_at, completed_at
3146
- FROM _gencow_workflow_steps_v2
3147
- WHERE run_id = ${workflowId}
3148
- ORDER BY step_index ASC, COALESCE(started_at, updated_at) ASC, step_key ASC
3149
- `);
3150
- return rowsFromResult(stepResult);
3651
+ try {
3652
+ const stepResult = await db.execute(sql8`
3653
+ SELECT step_key, status, command_kind, output_json, output_codec, error_json, started_at, updated_at, completed_at
3654
+ FROM _gencow_workflow_steps_v2
3655
+ WHERE run_id = ${workflowId}
3656
+ AND (command_kind IS NULL OR command_kind = 'step')
3657
+ ORDER BY step_index ASC, COALESCE(started_at, updated_at) ASC, step_key ASC
3658
+ `);
3659
+ return rowsFromResult2(stepResult);
3660
+ } catch (error) {
3661
+ if (!isMissingWorkflowV2JournalColumnError(error)) throw error;
3662
+ const stepResult = await db.execute(sql8`
3663
+ SELECT step_key, status, output_json, error_json, started_at, updated_at, completed_at
3664
+ FROM _gencow_workflow_steps_v2
3665
+ WHERE run_id = ${workflowId}
3666
+ ORDER BY step_index ASC, COALESCE(started_at, updated_at) ASC, step_key ASC
3667
+ `);
3668
+ return rowsFromResult2(stepResult);
3669
+ }
3151
3670
  } catch (error) {
3152
3671
  if (isMissingWorkflowV2PayloadSchemaError(error)) return [];
3153
3672
  throw error;
3154
3673
  }
3155
3674
  }
3156
- async function tryRecordWorkflowV2Signal(options) {
3157
- try {
3158
- const result = await options.db.execute(sql4`
3159
- WITH eligible_run AS (
3160
- SELECT id
3161
- FROM _gencow_workflow_runs_v2
3162
- WHERE id = ${options.workflowId}
3163
- AND status IN ('queued', 'running', 'sleeping', 'waiting')
3164
- AND completed_at IS NULL
3165
- AND cancel_requested_at IS NULL
3166
- FOR UPDATE
3167
- ), inserted AS (
3168
- INSERT INTO _gencow_workflow_signals_v2 (
3169
- id,
3170
- run_id,
3171
- event_name,
3172
- payload_json,
3173
- idempotency_key
3174
- )
3175
- SELECT
3176
- ${options.signalId},
3177
- eligible_run.id,
3178
- ${options.event},
3179
- ${workflowJsonb(options.payload)},
3180
- ${options.signalId}
3181
- FROM eligible_run
3182
- RETURNING id, run_id
3183
- ),
3184
- queued_run AS (
3185
- UPDATE _gencow_workflow_runs_v2 run
3186
- SET
3187
- status = 'queued',
3188
- runnable_at = NOW(),
3189
- lease_owner = NULL,
3190
- lease_expires_at = NULL,
3191
- heartbeat_at = NULL,
3192
- updated_at = NOW()
3193
- FROM inserted
3194
- WHERE run.id = inserted.run_id
3195
- AND run.status = 'waiting'
3196
- AND run.completed_at IS NULL
3197
- AND run.cancel_requested_at IS NULL
3198
- RETURNING run.id
3199
- ),
3200
- wake_outbox AS (
3201
- INSERT INTO _gencow_workflow_outbox_v2 (
3202
- id,
3203
- run_id,
3204
- kind,
3205
- available_at,
3206
- status
3207
- )
3208
- SELECT
3209
- 'signal:' || inserted.id,
3210
- inserted.run_id,
3211
- 'wake_signal',
3212
- NOW(),
3213
- 'pending'
3214
- FROM inserted
3215
- JOIN queued_run ON queued_run.id = inserted.run_id
3216
- ON CONFLICT (id) DO NOTHING
3217
- RETURNING id
3218
- ),
3219
- wake_notified AS (
3220
- SELECT pg_notify(${WORKFLOW_WAKE_SIGNAL_CHANNEL}, ${WORKFLOW_WAKE_SIGNAL_PAYLOAD})
3221
- FROM wake_outbox
3222
- )
3223
- SELECT 1
3224
- FROM inserted
3225
- LEFT JOIN wake_notified ON TRUE
3226
- `);
3227
- return rowsFromResult(result).length > 0 ? "accepted" : "rejected";
3228
- } catch (error) {
3229
- if (isMissingWorkflowV2SignalSchemaError(error)) return "absent";
3230
- throw error;
3231
- }
3232
- }
3233
3675
  async function mirrorWorkflowV2SignalToV1(options) {
3234
3676
  try {
3235
- await options.db.execute(sql4`
3677
+ await options.db.execute(sql8`
3236
3678
  INSERT INTO _gencow_workflow_events (id, workflow_id, event_name, payload)
3237
3679
  SELECT ${options.signalId}, workflow.id, ${options.event}, NULL
3238
3680
  FROM _gencow_workflows workflow
@@ -3249,7 +3691,7 @@ async function loadWorkflowSnapshot(db, workflowId, options) {
3249
3691
  const v2Run = await loadWorkflowV2Run(db, workflowId);
3250
3692
  let workflowResult;
3251
3693
  try {
3252
- workflowResult = await db.execute(sql4`
3694
+ workflowResult = await db.execute(sql8`
3253
3695
  SELECT
3254
3696
  id,
3255
3697
  name,
@@ -3275,7 +3717,7 @@ async function loadWorkflowSnapshot(db, workflowId, options) {
3275
3717
  if (!v2Run || !isMissingWorkflowV1Table(error)) throw error;
3276
3718
  workflowResult = [];
3277
3719
  }
3278
- const row = rowsFromResult(workflowResult)[0] ?? null;
3720
+ const row = rowsFromResult2(workflowResult)[0] ?? null;
3279
3721
  if (!v2Run && !row) return null;
3280
3722
  const viewerUserId = options?.viewerUserId ?? null;
3281
3723
  const ownerId = v2Run ? v2Run.user_id : row?.user_id;
@@ -3289,7 +3731,7 @@ async function loadWorkflowSnapshot(db, workflowId, options) {
3289
3731
  if (steps.length === 0) {
3290
3732
  let stepsResult;
3291
3733
  try {
3292
- stepsResult = await db.execute(sql4`
3734
+ stepsResult = await db.execute(sql8`
3293
3735
  SELECT
3294
3736
  step_name,
3295
3737
  status,
@@ -3306,7 +3748,7 @@ async function loadWorkflowSnapshot(db, workflowId, options) {
3306
3748
  if (!v2Run || !isMissingWorkflowV1Table(error)) throw error;
3307
3749
  stepsResult = [];
3308
3750
  }
3309
- steps = rowsFromResult(stepsResult).map(mapWorkflowStep);
3751
+ steps = rowsFromResult2(stepsResult).map(mapWorkflowStep);
3310
3752
  }
3311
3753
  return {
3312
3754
  ...v2Run ? mapV2WorkflowSummary(v2Run) : mapWorkflowSummary(row),
@@ -3333,7 +3775,8 @@ function buildWorkflowBuiltinDefinitions() {
3333
3775
  argsSchema: {
3334
3776
  id: v.string(),
3335
3777
  event: v.string(),
3336
- payload: v.optional(v.any())
3778
+ payload: v.optional(v.any()),
3779
+ idempotencyKey: v.optional(v.string())
3337
3780
  },
3338
3781
  isPublic: true,
3339
3782
  handler: async (ctx, args) => {
@@ -3370,14 +3813,19 @@ function buildWorkflowBuiltinDefinitions() {
3370
3813
  serializedValue: persistedPayload
3371
3814
  });
3372
3815
  const signalId = crypto.randomUUID();
3816
+ const idempotencyKey = normalizeWorkflowIdempotencyKey(args.idempotencyKey);
3373
3817
  if (v2Run) {
3374
3818
  const recordedV2 = await tryRecordWorkflowV2Signal({
3375
3819
  db: ctx.unsafeDb,
3376
3820
  workflowId,
3377
3821
  event: normalizedEvent,
3378
3822
  signalId,
3379
- payload: persistedPayload
3823
+ payload: persistedPayload,
3824
+ idempotencyKey
3380
3825
  });
3826
+ if (recordedV2 === "duplicate") {
3827
+ return { ok: true, workflowId, event: normalizedEvent, scheduledJobId: null, deduplicated: true };
3828
+ }
3381
3829
  if (recordedV2 !== "accepted") {
3382
3830
  return { ok: false, workflowId, event: normalizedEvent, scheduledJobId: null };
3383
3831
  }
@@ -3390,7 +3838,7 @@ function buildWorkflowBuiltinDefinitions() {
3390
3838
  });
3391
3839
  }
3392
3840
  } else {
3393
- await ctx.unsafeDb.execute(sql4`
3841
+ await ctx.unsafeDb.execute(sql8`
3394
3842
  INSERT INTO _gencow_workflow_events (
3395
3843
  id,
3396
3844
  workflow_id,
@@ -3434,23 +3882,21 @@ function buildWorkflowBuiltinDefinitions() {
3434
3882
  const userId = ctx.auth.requireAuth().id;
3435
3883
  const limit = normalizeListLimit(args.limit);
3436
3884
  const requestedStatus = normalizeDerivedStatus(args.status);
3437
- const v1Derived = sql4`CASE WHEN w.status = 'pending' AND w.current_step LIKE 'sleep#%' THEN 'sleeping'
3885
+ const v1Derived = sql8`CASE WHEN w.status = 'pending' AND w.current_step LIKE 'sleep#%' THEN 'sleeping'
3438
3886
  WHEN w.status = 'pending' AND w.current_step LIKE 'wait:%' THEN 'waiting'
3439
- WHEN w.status = 'pending' THEN 'queued' ELSE w.status END`;
3440
- const effectiveStatus = sql4`CASE WHEN v.id IS NOT NULL THEN
3441
- CASE WHEN v.status = 'cancelled' THEN 'failed'
3442
- WHEN v.status IN ('queued', 'waiting', 'sleeping') THEN 'pending'
3443
- ELSE v.status END
3444
- ELSE w.status END`;
3445
- const effectiveDerived = sql4`CASE WHEN v.id IS NOT NULL THEN
3446
- CASE WHEN v.status = 'cancelled' THEN 'failed'
3447
- WHEN v.status IN ('queued', 'waiting', 'sleeping') THEN v.status
3887
+ WHEN w.status = 'pending' THEN 'queued'
3888
+ WHEN w.status = 'failed' AND w.error_code = 'cancelled' THEN 'cancelled' ELSE w.status END`;
3889
+ const effectiveStatus = sql8`CASE WHEN v.id IS NOT NULL THEN
3890
+ CASE WHEN v.status IN ('queued', 'waiting', 'sleeping') THEN 'pending'
3448
3891
  ELSE v.status END
3892
+ ELSE CASE WHEN w.status = 'failed' AND w.error_code = 'cancelled' THEN 'cancelled' ELSE w.status END END`;
3893
+ const effectiveDerived = sql8`CASE WHEN v.id IS NOT NULL THEN
3894
+ v.status
3449
3895
  ELSE ${v1Derived} END`;
3450
3896
  const statusForFilter = requestedStatus === "pending" ? effectiveStatus : effectiveDerived;
3451
- const v1StatusForFilter = requestedStatus === "pending" ? sql4`w.status` : v1Derived;
3897
+ const v1StatusForFilter = requestedStatus === "pending" ? sql8`w.status` : v1Derived;
3452
3898
  try {
3453
- const result = await ctx.unsafeDb.execute(sql4`
3899
+ const result = await ctx.unsafeDb.execute(sql8`
3454
3900
  SELECT w.*, v.id AS v2_id, v.workflow_name AS v2_name,
3455
3901
  v.status AS v2_status, v.error_json AS v2_error_json,
3456
3902
  v.error_code AS v2_error_code, v.current_step_key AS v2_current_step,
@@ -3465,12 +3911,11 @@ function buildWorkflowBuiltinDefinitions() {
3465
3911
  ORDER BY COALESCE(v.created_at, w.started_at) DESC, COALESCE(v.id, w.id) DESC
3466
3912
  LIMIT ${limit}
3467
3913
  `);
3468
- return rowsFromResult(result).map(mapJoinedWorkflowSummary);
3914
+ return rowsFromResult2(result).map(mapJoinedWorkflowSummary);
3469
3915
  } catch (error) {
3470
3916
  if (isMissingWorkflowV1Table(error)) {
3471
- const v2StatusForFilter = requestedStatus === "pending" ? sql4`CASE WHEN status IN ('queued', 'waiting', 'sleeping') THEN 'pending'
3472
- WHEN status = 'cancelled' THEN 'failed' ELSE status END` : sql4`CASE WHEN status = 'cancelled' THEN 'failed' ELSE status END`;
3473
- const result2 = await ctx.unsafeDb.execute(sql4`
3917
+ const v2StatusForFilter = requestedStatus === "pending" ? sql8`CASE WHEN status IN ('queued', 'waiting', 'sleeping') THEN 'pending' ELSE status END` : sql8`status`;
3918
+ const result2 = await ctx.unsafeDb.execute(sql8`
3474
3919
  SELECT id, workflow_name, status, args_json, result_json, error_json, error_code,
3475
3920
  current_step_key, retry_count, max_retries, max_active_duration_ms,
3476
3921
  created_at, updated_at, completed_at, user_id
@@ -3480,24 +3925,25 @@ function buildWorkflowBuiltinDefinitions() {
3480
3925
  ORDER BY created_at DESC, id DESC
3481
3926
  LIMIT ${limit}
3482
3927
  `);
3483
- return rowsFromResult(result2).map(mapV2WorkflowSummary);
3928
+ return rowsFromResult2(result2).map(mapV2WorkflowSummary);
3484
3929
  }
3485
3930
  if (!isMissingWorkflowV2PayloadSchemaError(error)) throw error;
3486
- const result = await ctx.unsafeDb.execute(sql4`
3931
+ const result = await ctx.unsafeDb.execute(sql8`
3487
3932
  SELECT w.* FROM _gencow_workflows w
3488
3933
  WHERE w.user_id = ${userId}
3489
3934
  AND (${requestedStatus ?? null}::text IS NULL OR ${v1StatusForFilter} = ${requestedStatus ?? null})
3490
3935
  ORDER BY w.started_at DESC, w.id DESC
3491
3936
  LIMIT ${limit}
3492
3937
  `);
3493
- return rowsFromResult(result).map(mapWorkflowSummary);
3938
+ return rowsFromResult2(result).map(mapWorkflowSummary);
3494
3939
  }
3495
3940
  }
3496
3941
  };
3497
3942
  const definitions = Object.freeze({
3498
3943
  get: Object.freeze(get),
3499
3944
  list: Object.freeze(list),
3500
- signal: Object.freeze(signal)
3945
+ signal: Object.freeze(signal),
3946
+ cancel: Object.freeze(buildWorkflowCancelDefinition())
3501
3947
  });
3502
3948
  return definitions;
3503
3949
  }
@@ -3511,6 +3957,7 @@ function registerWorkflowsApi() {
3511
3957
  queryRegistry.set(definitions.get.key, definitions.get);
3512
3958
  queryRegistry.set(definitions.list.key, definitions.list);
3513
3959
  mutationRegistry.push(definitions.signal);
3960
+ mutationRegistry.push(definitions.cancel);
3514
3961
  globalThis.__gencow_workflowsApiRegistered = definitions;
3515
3962
  return definitions;
3516
3963
  }
@@ -3839,11 +4286,11 @@ function defineConfig(config) {
3839
4286
  }
3840
4287
 
3841
4288
  // ../core/src/rls-context-key.ts
3842
- import { sql as sql5 } from "drizzle-orm";
4289
+ import { sql as sql9 } from "drizzle-orm";
3843
4290
  function sessionSettingSqlForKey(sessionKey) {
3844
4291
  const guc = sessionKeyToGucName(sessionKey);
3845
4292
  const escaped = guc.replace(/'/g, "''");
3846
- return sql5.raw(`current_setting('${escaped}', true)`);
4293
+ return sql9.raw(`current_setting('${escaped}', true)`);
3847
4294
  }
3848
4295
  function rlsContextKey(key) {
3849
4296
  return sessionSettingSqlForKey(key);
@@ -3852,11 +4299,11 @@ function rlsContextKeys() {
3852
4299
  return (key) => rlsContextKey(key);
3853
4300
  }
3854
4301
  function eqRlsContextColumn(column, key) {
3855
- return sql5`${column} = ${rlsContextKey(key)}`;
4302
+ return sql9`${column} = ${rlsContextKey(key)}`;
3856
4303
  }
3857
4304
 
3858
4305
  // ../core/src/rls-define-table.ts
3859
- import { sql as sql6 } from "drizzle-orm";
4306
+ import { sql as sql10 } from "drizzle-orm";
3860
4307
  import { getTableName } from "drizzle-orm";
3861
4308
  import { pgPolicy } from "drizzle-orm/pg-core";
3862
4309
  var ALL_COMMANDS = ["select", "insert", "update", "delete"];
@@ -3868,22 +4315,22 @@ function escapeSqlStringLiteral(value) {
3868
4315
  return value.replace(/'/g, "''");
3869
4316
  }
3870
4317
  function sqlStringLiteral(value) {
3871
- return sql6.raw(`'${escapeSqlStringLiteral(value)}'`);
4318
+ return sql10.raw(`'${escapeSqlStringLiteral(value)}'`);
3872
4319
  }
3873
4320
  function andExprs(exprs) {
3874
- if (exprs.length === 0) return sql6`true`;
4321
+ if (exprs.length === 0) return sql10`true`;
3875
4322
  if (exprs.length === 1) return exprs[0];
3876
- return sql6.join(
3877
- exprs.map((e) => sql6`(${e})`),
3878
- sql6` AND `
4323
+ return sql10.join(
4324
+ exprs.map((e) => sql10`(${e})`),
4325
+ sql10` AND `
3879
4326
  );
3880
4327
  }
3881
4328
  function orExprs(exprs) {
3882
- if (exprs.length === 0) return sql6`false`;
4329
+ if (exprs.length === 0) return sql10`false`;
3883
4330
  if (exprs.length === 1) return exprs[0];
3884
- return sql6.join(
3885
- exprs.map((e) => sql6`(${e})`),
3886
- sql6` OR `
4331
+ return sql10.join(
4332
+ exprs.map((e) => sql10`(${e})`),
4333
+ sql10` OR `
3887
4334
  );
3888
4335
  }
3889
4336
  function combinedExprForCommand(constraints, command) {
@@ -3919,20 +4366,20 @@ var TableRlsBuilder = class _TableRlsBuilder {
3919
4366
  constraints = [];
3920
4367
  readPublicSelect = false;
3921
4368
  matchColumn(column, keySql, options) {
3922
- const expr = sql6`${column} = ${keySql}`;
4369
+ const expr = sql10`${column} = ${keySql}`;
3923
4370
  this.constraints.push({ commands: normalizeCommands(options?.commands), expr });
3924
4371
  return this;
3925
4372
  }
3926
4373
  sessionEq(keySql, value, options) {
3927
- const expr = sql6`${keySql} = ${sqlStringLiteral(stringifySessionValue(value))}`;
4374
+ const expr = sql10`${keySql} = ${sqlStringLiteral(stringifySessionValue(value))}`;
3928
4375
  this.constraints.push({ commands: normalizeCommands(options?.commands), expr });
3929
4376
  return this;
3930
4377
  }
3931
4378
  sessionIn(keySql, values, options) {
3932
4379
  const literals = values.map((v2) => stringifySessionValue(v2));
3933
- const expr = literals.length === 0 ? sql6`false` : sql6`${keySql} IN (${sql6.join(
4380
+ const expr = literals.length === 0 ? sql10`false` : sql10`${keySql} IN (${sql10.join(
3934
4381
  literals.map((v2) => sqlStringLiteral(v2)),
3935
- sql6`, `
4382
+ sql10`, `
3936
4383
  )})`;
3937
4384
  this.constraints.push({ commands: normalizeCommands(options?.commands), expr });
3938
4385
  return this;
@@ -3971,17 +4418,17 @@ var TableRlsBuilder = class _TableRlsBuilder {
3971
4418
  return this.sessionCompare(keySql, "<", n, options);
3972
4419
  }
3973
4420
  sessionCompare(keySql, op, n, options) {
3974
- const bound = sql6.raw(String(n));
4421
+ const bound = sql10.raw(String(n));
3975
4422
  const expr = (() => {
3976
4423
  switch (op) {
3977
4424
  case ">=":
3978
- return sql6`(${keySql})::numeric >= ${bound}`;
4425
+ return sql10`(${keySql})::numeric >= ${bound}`;
3979
4426
  case ">":
3980
- return sql6`(${keySql})::numeric > ${bound}`;
4427
+ return sql10`(${keySql})::numeric > ${bound}`;
3981
4428
  case "<=":
3982
- return sql6`(${keySql})::numeric <= ${bound}`;
4429
+ return sql10`(${keySql})::numeric <= ${bound}`;
3983
4430
  case "<":
3984
- return sql6`(${keySql})::numeric < ${bound}`;
4431
+ return sql10`(${keySql})::numeric < ${bound}`;
3985
4432
  }
3986
4433
  })();
3987
4434
  this.constraints.push({ commands: normalizeCommands(options?.commands), expr });
@@ -4020,7 +4467,7 @@ var TableRlsBuilder = class _TableRlsBuilder {
4020
4467
  policies.push(
4021
4468
  pgPolicy(this.policyName(command, counters[command]++), {
4022
4469
  for: "select",
4023
- using: sql6`true`
4470
+ using: sql10`true`
4024
4471
  })
4025
4472
  );
4026
4473
  continue;
@@ -4084,7 +4531,7 @@ import { pgTable } from "drizzle-orm/pg-core";
4084
4531
  var pgBaseTable = pgTable;
4085
4532
 
4086
4533
  // ../core/src/rls-db.ts
4087
- import { sql as sql7 } from "drizzle-orm";
4534
+ import { sql as sql11 } from "drizzle-orm";
4088
4535
 
4089
4536
  // ../core/src/rls-db-scoped-query.ts
4090
4537
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -4258,7 +4705,7 @@ function transactionConfigSql(config) {
4258
4705
  if (typeof value.deferrable !== "boolean") return null;
4259
4706
  chunks.push(value.deferrable ? "deferrable" : "not deferrable");
4260
4707
  }
4261
- return chunks.length > 0 ? sql7.raw(`SET TRANSACTION ${chunks.join(", ")}`) : null;
4708
+ return chunks.length > 0 ? sql11.raw(`SET TRANSACTION ${chunks.join(", ")}`) : null;
4262
4709
  }
4263
4710
  async function applyTransactionConfig(tx, config) {
4264
4711
  const statement = transactionConfigSql(config);
@@ -4300,7 +4747,7 @@ async function applyRlsSessionVars(client, rls) {
4300
4747
  }
4301
4748
  async function injectRlsVarsOnTx(tx, rls, onTransactionContextFailure) {
4302
4749
  try {
4303
- await forEachSetConfig(rls, (name, value) => tx.execute(sql7`SELECT set_config(${name}, ${value}, true)`));
4750
+ await forEachSetConfig(rls, (name, value) => tx.execute(sql11`SELECT set_config(${name}, ${value}, true)`));
4304
4751
  } catch (error) {
4305
4752
  try {
4306
4753
  onTransactionContextFailure?.(error);
@@ -5042,7 +5489,7 @@ function buildRowOutputValidator(columns) {
5042
5489
  }
5043
5490
 
5044
5491
  // ../core/src/createCrud.cursor.ts
5045
- import { sql as sql8 } from "drizzle-orm";
5492
+ import { sql as sql12 } from "drizzle-orm";
5046
5493
  function normalizeOrderDir(raw) {
5047
5494
  const d = typeof raw === "string" ? raw.toLowerCase() : "";
5048
5495
  return d === "asc" ? "asc" : "desc";
@@ -5075,16 +5522,16 @@ function buildTupleComparePredicate(columns, values, direction) {
5075
5522
  if (columns.length !== values.length || columns.length === 0) {
5076
5523
  throw new Error("cursor column count mismatch");
5077
5524
  }
5078
- const opToken = direction === "asc" ? sql8`>` : sql8`<`;
5079
- const colTuple = sql8.join(
5080
- columns.map((c) => sql8`${c}`),
5081
- sql8`, `
5525
+ const opToken = direction === "asc" ? sql12`>` : sql12`<`;
5526
+ const colTuple = sql12.join(
5527
+ columns.map((c) => sql12`${c}`),
5528
+ sql12`, `
5082
5529
  );
5083
- const valTuple = sql8.join(
5084
- values.map((val) => sql8`${val}`),
5085
- sql8`, `
5530
+ const valTuple = sql12.join(
5531
+ values.map((val) => sql12`${val}`),
5532
+ sql12`, `
5086
5533
  );
5087
- return sql8`(${colTuple}) ${opToken} (${valTuple})`;
5534
+ return sql12`(${colTuple}) ${opToken} (${valTuple})`;
5088
5535
  }
5089
5536
 
5090
5537
  // ../core/src/createCrud.ts
@@ -5646,7 +6093,18 @@ export {
5646
6093
  TableRlsBuilder,
5647
6094
  WORKFLOW_REALTIME_KEY_PREFIX,
5648
6095
  WORKFLOW_RESUME_ACTION_PREFIX,
6096
+ WORKFLOW_STEP_FAILED_CODE,
6097
+ WORKFLOW_STEP_OPTIONS_INVALID_CODE,
6098
+ WORKFLOW_STEP_TIMEOUT_CODE,
6099
+ WORKFLOW_WAIT_TIMEOUT_CODE,
6100
+ WorkflowCommandNameError,
6101
+ WorkflowNestedCommandError,
6102
+ WorkflowNonRetryableError,
5649
6103
  WorkflowPayloadPolicyError,
6104
+ WorkflowStepFailedError,
6105
+ WorkflowStepOptionsError,
6106
+ WorkflowStepTimeoutError,
6107
+ WorkflowWaitTimeoutError,
5650
6108
  advanceUnreadCursor,
5651
6109
  applyFilterOp,
5652
6110
  assertWorkflowPayloadWithinLimit,
@@ -5678,6 +6136,7 @@ export {
5678
6136
  cron,
5679
6137
  cronJobs,
5680
6138
  defaultRlsSessionContext,
6139
+ defaultWorkflowCommandProtocol,
5681
6140
  defineApi,
5682
6141
  defineAuth,
5683
6142
  defineConfig,
@@ -5719,6 +6178,7 @@ export {
5719
6178
  normalizeRealtimeChannelEpoch,
5720
6179
  normalizeRealtimeQuerySubscriptionEpoch,
5721
6180
  normalizeUnreadCursor,
6181
+ normalizeWorkflowCommandName,
5722
6182
  ownerRls,
5723
6183
  parseArgs,
5724
6184
  parseFilterNode,
@@ -5750,6 +6210,7 @@ export {
5750
6210
  tableRls,
5751
6211
  v,
5752
6212
  validateRlsSessionContext,
6213
+ validateWorkflowStepOptions,
5753
6214
  verifyRealtimeChannelToken,
5754
6215
  verifyRealtimeQuerySubscriptionToken,
5755
6216
  verifyRealtimeTicket,