@pgflow/core 0.13.2 → 0.14.0

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.
@@ -0,0 +1,1955 @@
1
+ -- Modify "step_states" table
2
+ ALTER TABLE "pgflow"."step_states" DROP CONSTRAINT "completed_at_or_failed_at", DROP CONSTRAINT "remaining_tasks_state_consistency", ADD CONSTRAINT "remaining_tasks_state_consistency" CHECK ((remaining_tasks IS NULL) OR (status <> ALL (ARRAY['created'::text, 'skipped'::text]))), DROP CONSTRAINT "status_is_valid", ADD CONSTRAINT "status_is_valid" CHECK (status = ANY (ARRAY['created'::text, 'started'::text, 'completed'::text, 'failed'::text, 'skipped'::text])), ADD CONSTRAINT "completed_at_or_failed_at_or_skipped_at" CHECK (((
3
+ CASE
4
+ WHEN (completed_at IS NOT NULL) THEN 1
5
+ ELSE 0
6
+ END +
7
+ CASE
8
+ WHEN (failed_at IS NOT NULL) THEN 1
9
+ ELSE 0
10
+ END) +
11
+ CASE
12
+ WHEN (skipped_at IS NOT NULL) THEN 1
13
+ ELSE 0
14
+ END) <= 1), ADD CONSTRAINT "skip_reason_matches_status" CHECK (((status = 'skipped'::text) AND (skip_reason IS NOT NULL)) OR ((status <> 'skipped'::text) AND (skip_reason IS NULL))), ADD CONSTRAINT "skipped_at_is_after_created_at" CHECK ((skipped_at IS NULL) OR (skipped_at >= created_at)), ADD COLUMN "skip_reason" text NULL, ADD COLUMN "skipped_at" timestamptz NULL;
15
+ -- Create index "idx_step_states_skipped" to table: "step_states"
16
+ CREATE INDEX "idx_step_states_skipped" ON "pgflow"."step_states" ("run_id", "step_slug") WHERE (status = 'skipped'::text);
17
+ -- Modify "steps" table
18
+ ALTER TABLE "pgflow"."steps" ADD CONSTRAINT "forbidden_input_pattern_is_object" CHECK ((forbidden_input_pattern IS NULL) OR (jsonb_typeof(forbidden_input_pattern) = 'object'::text)), ADD CONSTRAINT "required_input_pattern_is_object" CHECK ((required_input_pattern IS NULL) OR (jsonb_typeof(required_input_pattern) = 'object'::text)), ADD CONSTRAINT "when_exhausted_is_valid" CHECK (when_exhausted = ANY (ARRAY['fail'::text, 'skip'::text, 'skip-cascade'::text])), ADD CONSTRAINT "when_unmet_is_valid" CHECK (when_unmet = ANY (ARRAY['fail'::text, 'skip'::text, 'skip-cascade'::text])), ADD COLUMN "required_input_pattern" jsonb NULL, ADD COLUMN "forbidden_input_pattern" jsonb NULL, ADD COLUMN "when_unmet" text NOT NULL DEFAULT 'skip', ADD COLUMN "when_exhausted" text NOT NULL DEFAULT 'fail';
19
+ -- Modify "_compare_flow_shapes" function
20
+ CREATE OR REPLACE FUNCTION "pgflow"."_compare_flow_shapes" ("p_local" jsonb, "p_db" jsonb) RETURNS text[] LANGUAGE plpgsql STABLE SET "search_path" = '' AS $BODY$
21
+ DECLARE
22
+ v_differences text[] := '{}';
23
+ v_local_steps jsonb;
24
+ v_db_steps jsonb;
25
+ v_local_count int;
26
+ v_db_count int;
27
+ v_max_count int;
28
+ v_idx int;
29
+ v_local_step jsonb;
30
+ v_db_step jsonb;
31
+ v_local_deps text;
32
+ v_db_deps text;
33
+ BEGIN
34
+ v_local_steps := p_local->'steps';
35
+ v_db_steps := p_db->'steps';
36
+ v_local_count := jsonb_array_length(COALESCE(v_local_steps, '[]'::jsonb));
37
+ v_db_count := jsonb_array_length(COALESCE(v_db_steps, '[]'::jsonb));
38
+
39
+ -- Compare step counts
40
+ IF v_local_count != v_db_count THEN
41
+ v_differences := array_append(
42
+ v_differences,
43
+ format('Step count differs: %s vs %s', v_local_count, v_db_count)
44
+ );
45
+ END IF;
46
+
47
+ -- Compare steps by index
48
+ v_max_count := GREATEST(v_local_count, v_db_count);
49
+
50
+ FOR v_idx IN 0..(v_max_count - 1) LOOP
51
+ v_local_step := v_local_steps->v_idx;
52
+ v_db_step := v_db_steps->v_idx;
53
+
54
+ IF v_local_step IS NULL THEN
55
+ v_differences := array_append(
56
+ v_differences,
57
+ format(
58
+ $$Step at index %s: missing in first shape (second has '%s')$$,
59
+ v_idx,
60
+ v_db_step->>'slug'
61
+ )
62
+ );
63
+ ELSIF v_db_step IS NULL THEN
64
+ v_differences := array_append(
65
+ v_differences,
66
+ format(
67
+ $$Step at index %s: missing in second shape (first has '%s')$$,
68
+ v_idx,
69
+ v_local_step->>'slug'
70
+ )
71
+ );
72
+ ELSE
73
+ -- Compare slug
74
+ IF v_local_step->>'slug' != v_db_step->>'slug' THEN
75
+ v_differences := array_append(
76
+ v_differences,
77
+ format(
78
+ $$Step at index %s: slug differs '%s' vs '%s'$$,
79
+ v_idx,
80
+ v_local_step->>'slug',
81
+ v_db_step->>'slug'
82
+ )
83
+ );
84
+ END IF;
85
+
86
+ -- Compare step type
87
+ IF v_local_step->>'stepType' != v_db_step->>'stepType' THEN
88
+ v_differences := array_append(
89
+ v_differences,
90
+ format(
91
+ $$Step at index %s: type differs '%s' vs '%s'$$,
92
+ v_idx,
93
+ v_local_step->>'stepType',
94
+ v_db_step->>'stepType'
95
+ )
96
+ );
97
+ END IF;
98
+
99
+ -- Compare dependencies (convert arrays to comma-separated strings)
100
+ SELECT string_agg(dep, ', ' ORDER BY dep)
101
+ INTO v_local_deps
102
+ FROM jsonb_array_elements_text(COALESCE(v_local_step->'dependencies', '[]'::jsonb)) AS dep;
103
+
104
+ SELECT string_agg(dep, ', ' ORDER BY dep)
105
+ INTO v_db_deps
106
+ FROM jsonb_array_elements_text(COALESCE(v_db_step->'dependencies', '[]'::jsonb)) AS dep;
107
+
108
+ IF COALESCE(v_local_deps, '') != COALESCE(v_db_deps, '') THEN
109
+ v_differences := array_append(
110
+ v_differences,
111
+ format(
112
+ $$Step at index %s: dependencies differ [%s] vs [%s]$$,
113
+ v_idx,
114
+ COALESCE(v_local_deps, ''),
115
+ COALESCE(v_db_deps, '')
116
+ )
117
+ );
118
+ END IF;
119
+
120
+ -- Compare whenUnmet (structural - affects DAG execution semantics)
121
+ IF v_local_step->>'whenUnmet' != v_db_step->>'whenUnmet' THEN
122
+ v_differences := array_append(
123
+ v_differences,
124
+ format(
125
+ $$Step at index %s: whenUnmet differs '%s' vs '%s'$$,
126
+ v_idx,
127
+ v_local_step->>'whenUnmet',
128
+ v_db_step->>'whenUnmet'
129
+ )
130
+ );
131
+ END IF;
132
+
133
+ -- Compare whenExhausted (structural - affects DAG execution semantics)
134
+ IF v_local_step->>'whenExhausted' != v_db_step->>'whenExhausted' THEN
135
+ v_differences := array_append(
136
+ v_differences,
137
+ format(
138
+ $$Step at index %s: whenExhausted differs '%s' vs '%s'$$,
139
+ v_idx,
140
+ v_local_step->>'whenExhausted',
141
+ v_db_step->>'whenExhausted'
142
+ )
143
+ );
144
+ END IF;
145
+
146
+ -- Compare requiredInputPattern (structural - affects DAG execution semantics)
147
+ -- Uses -> (jsonb) not ->> (text) to properly compare wrapper objects
148
+ IF v_local_step->'requiredInputPattern' IS DISTINCT FROM v_db_step->'requiredInputPattern' THEN
149
+ v_differences := array_append(
150
+ v_differences,
151
+ format(
152
+ $$Step at index %s: requiredInputPattern differs '%s' vs '%s'$$,
153
+ v_idx,
154
+ v_local_step->'requiredInputPattern',
155
+ v_db_step->'requiredInputPattern'
156
+ )
157
+ );
158
+ END IF;
159
+
160
+ -- Compare forbiddenInputPattern (structural - affects DAG execution semantics)
161
+ -- Uses -> (jsonb) not ->> (text) to properly compare wrapper objects
162
+ IF v_local_step->'forbiddenInputPattern' IS DISTINCT FROM v_db_step->'forbiddenInputPattern' THEN
163
+ v_differences := array_append(
164
+ v_differences,
165
+ format(
166
+ $$Step at index %s: forbiddenInputPattern differs '%s' vs '%s'$$,
167
+ v_idx,
168
+ v_local_step->'forbiddenInputPattern',
169
+ v_db_step->'forbiddenInputPattern'
170
+ )
171
+ );
172
+ END IF;
173
+ END IF;
174
+ END LOOP;
175
+
176
+ RETURN v_differences;
177
+ END;
178
+ $BODY$;
179
+ -- Create "add_step" function
180
+ CREATE FUNCTION "pgflow"."add_step" ("flow_slug" text, "step_slug" text, "deps_slugs" text[] DEFAULT '{}', "max_attempts" integer DEFAULT NULL::integer, "base_delay" integer DEFAULT NULL::integer, "timeout" integer DEFAULT NULL::integer, "start_delay" integer DEFAULT NULL::integer, "step_type" text DEFAULT 'single', "required_input_pattern" jsonb DEFAULT NULL::jsonb, "forbidden_input_pattern" jsonb DEFAULT NULL::jsonb, "when_unmet" text DEFAULT 'skip', "when_exhausted" text DEFAULT 'fail') RETURNS "pgflow"."steps" LANGUAGE plpgsql SET "search_path" = '' AS $$
181
+ DECLARE
182
+ result_step pgflow.steps;
183
+ next_idx int;
184
+ BEGIN
185
+ -- Validate map step constraints
186
+ -- Map steps can have either:
187
+ -- 0 dependencies (root map - maps over flow input array)
188
+ -- 1 dependency (dependent map - maps over dependency output array)
189
+ IF COALESCE(add_step.step_type, 'single') = 'map' AND COALESCE(array_length(add_step.deps_slugs, 1), 0) > 1 THEN
190
+ RAISE EXCEPTION 'Map step "%" can have at most one dependency, but % were provided: %',
191
+ add_step.step_slug,
192
+ COALESCE(array_length(add_step.deps_slugs, 1), 0),
193
+ array_to_string(add_step.deps_slugs, ', ');
194
+ END IF;
195
+
196
+ -- Get next step index
197
+ SELECT COALESCE(MAX(s.step_index) + 1, 0) INTO next_idx
198
+ FROM pgflow.steps s
199
+ WHERE s.flow_slug = add_step.flow_slug;
200
+
201
+ -- Create the step
202
+ INSERT INTO pgflow.steps (
203
+ flow_slug, step_slug, step_type, step_index, deps_count,
204
+ opt_max_attempts, opt_base_delay, opt_timeout, opt_start_delay,
205
+ required_input_pattern, forbidden_input_pattern, when_unmet, when_exhausted
206
+ )
207
+ VALUES (
208
+ add_step.flow_slug,
209
+ add_step.step_slug,
210
+ COALESCE(add_step.step_type, 'single'),
211
+ next_idx,
212
+ COALESCE(array_length(add_step.deps_slugs, 1), 0),
213
+ add_step.max_attempts,
214
+ add_step.base_delay,
215
+ add_step.timeout,
216
+ add_step.start_delay,
217
+ add_step.required_input_pattern,
218
+ add_step.forbidden_input_pattern,
219
+ add_step.when_unmet,
220
+ add_step.when_exhausted
221
+ )
222
+ ON CONFLICT ON CONSTRAINT steps_pkey
223
+ DO UPDATE SET step_slug = EXCLUDED.step_slug
224
+ RETURNING * INTO result_step;
225
+
226
+ -- Insert dependencies
227
+ INSERT INTO pgflow.deps (flow_slug, dep_slug, step_slug)
228
+ SELECT add_step.flow_slug, d.dep_slug, add_step.step_slug
229
+ FROM unnest(COALESCE(add_step.deps_slugs, '{}')) AS d(dep_slug)
230
+ WHERE add_step.deps_slugs IS NOT NULL AND array_length(add_step.deps_slugs, 1) > 0
231
+ ON CONFLICT ON CONSTRAINT deps_pkey DO NOTHING;
232
+
233
+ RETURN result_step;
234
+ END;
235
+ $$;
236
+ -- Modify "_create_flow_from_shape" function
237
+ CREATE OR REPLACE FUNCTION "pgflow"."_create_flow_from_shape" ("p_flow_slug" text, "p_shape" jsonb) RETURNS void LANGUAGE plpgsql SET "search_path" = '' AS $$
238
+ DECLARE
239
+ v_step jsonb;
240
+ v_deps text[];
241
+ v_flow_options jsonb;
242
+ v_step_options jsonb;
243
+ BEGIN
244
+ -- Extract flow-level options (may be null)
245
+ v_flow_options := p_shape->'options';
246
+
247
+ -- Create the flow with options (NULL = use default)
248
+ PERFORM pgflow.create_flow(
249
+ p_flow_slug,
250
+ (v_flow_options->>'maxAttempts')::int,
251
+ (v_flow_options->>'baseDelay')::int,
252
+ (v_flow_options->>'timeout')::int
253
+ );
254
+
255
+ -- Iterate over steps in order and add each one
256
+ FOR v_step IN SELECT * FROM jsonb_array_elements(p_shape->'steps')
257
+ LOOP
258
+ -- Convert dependencies jsonb array to text array
259
+ SELECT COALESCE(array_agg(dep), '{}')
260
+ INTO v_deps
261
+ FROM jsonb_array_elements_text(COALESCE(v_step->'dependencies', '[]'::jsonb)) AS dep;
262
+
263
+ -- Extract step options (may be null)
264
+ v_step_options := v_step->'options';
265
+
266
+ -- Add the step with options (NULL = use default/inherit)
267
+ PERFORM pgflow.add_step(
268
+ flow_slug => p_flow_slug,
269
+ step_slug => v_step->>'slug',
270
+ deps_slugs => v_deps,
271
+ max_attempts => (v_step_options->>'maxAttempts')::int,
272
+ base_delay => (v_step_options->>'baseDelay')::int,
273
+ timeout => (v_step_options->>'timeout')::int,
274
+ start_delay => (v_step_options->>'startDelay')::int,
275
+ step_type => v_step->>'stepType',
276
+ when_unmet => COALESCE(v_step->>'whenUnmet', 'skip'),
277
+ when_exhausted => COALESCE(v_step->>'whenExhausted', 'fail'),
278
+ required_input_pattern => CASE
279
+ WHEN (v_step->'requiredInputPattern'->>'defined')::boolean
280
+ THEN v_step->'requiredInputPattern'->'value'
281
+ ELSE NULL
282
+ END,
283
+ forbidden_input_pattern => CASE
284
+ WHEN (v_step->'forbiddenInputPattern'->>'defined')::boolean
285
+ THEN v_step->'forbiddenInputPattern'->'value'
286
+ ELSE NULL
287
+ END
288
+ );
289
+ END LOOP;
290
+ END;
291
+ $$;
292
+ -- Modify "_get_flow_shape" function
293
+ CREATE OR REPLACE FUNCTION "pgflow"."_get_flow_shape" ("p_flow_slug" text) RETURNS jsonb LANGUAGE sql STABLE SET "search_path" = '' AS $$
294
+ SELECT jsonb_build_object(
295
+ 'steps',
296
+ COALESCE(
297
+ jsonb_agg(
298
+ jsonb_build_object(
299
+ 'slug', step.step_slug,
300
+ 'stepType', step.step_type,
301
+ 'dependencies', COALESCE(
302
+ (
303
+ SELECT jsonb_agg(dep.dep_slug ORDER BY dep.dep_slug)
304
+ FROM pgflow.deps AS dep
305
+ WHERE dep.flow_slug = step.flow_slug
306
+ AND dep.step_slug = step.step_slug
307
+ ),
308
+ '[]'::jsonb
309
+ ),
310
+ 'whenUnmet', step.when_unmet,
311
+ 'whenExhausted', step.when_exhausted,
312
+ 'requiredInputPattern', CASE
313
+ WHEN step.required_input_pattern IS NULL
314
+ THEN '{"defined": false}'::jsonb
315
+ ELSE jsonb_build_object('defined', true, 'value', step.required_input_pattern)
316
+ END,
317
+ 'forbiddenInputPattern', CASE
318
+ WHEN step.forbidden_input_pattern IS NULL
319
+ THEN '{"defined": false}'::jsonb
320
+ ELSE jsonb_build_object('defined', true, 'value', step.forbidden_input_pattern)
321
+ END
322
+ )
323
+ ORDER BY step.step_index
324
+ ),
325
+ '[]'::jsonb
326
+ )
327
+ )
328
+ FROM pgflow.steps AS step
329
+ WHERE step.flow_slug = p_flow_slug;
330
+ $$;
331
+ -- Create "_cascade_force_skip_steps" function
332
+ CREATE FUNCTION "pgflow"."_cascade_force_skip_steps" ("run_id" uuid, "step_slug" text, "skip_reason" text) RETURNS integer LANGUAGE plpgsql AS $$
333
+ DECLARE
334
+ v_flow_slug text;
335
+ v_total_skipped int := 0;
336
+ BEGIN
337
+ -- Get flow_slug for this run
338
+ SELECT r.flow_slug INTO v_flow_slug
339
+ FROM pgflow.runs r
340
+ WHERE r.run_id = _cascade_force_skip_steps.run_id;
341
+
342
+ IF v_flow_slug IS NULL THEN
343
+ RAISE EXCEPTION 'Run not found: %', _cascade_force_skip_steps.run_id;
344
+ END IF;
345
+
346
+ -- ==========================================
347
+ -- SKIP STEPS IN TOPOLOGICAL ORDER
348
+ -- ==========================================
349
+ -- Use recursive CTE to find all downstream dependents,
350
+ -- then skip them in topological order (by step_index)
351
+ WITH RECURSIVE
352
+ -- ---------- Find all downstream steps ----------
353
+ downstream_steps AS (
354
+ -- Base case: the trigger step
355
+ SELECT
356
+ s.flow_slug,
357
+ s.step_slug,
358
+ s.step_index,
359
+ _cascade_force_skip_steps.skip_reason AS reason -- Original reason for trigger step
360
+ FROM pgflow.steps s
361
+ WHERE s.flow_slug = v_flow_slug
362
+ AND s.step_slug = _cascade_force_skip_steps.step_slug
363
+
364
+ UNION ALL
365
+
366
+ -- Recursive case: steps that depend on already-found steps
367
+ SELECT
368
+ s.flow_slug,
369
+ s.step_slug,
370
+ s.step_index,
371
+ 'dependency_skipped'::text AS reason -- Downstream steps get this reason
372
+ FROM pgflow.steps s
373
+ JOIN pgflow.deps d ON d.flow_slug = s.flow_slug AND d.step_slug = s.step_slug
374
+ JOIN downstream_steps ds ON ds.flow_slug = d.flow_slug AND ds.step_slug = d.dep_slug
375
+ ),
376
+ -- ---------- Deduplicate and order by step_index ----------
377
+ steps_to_skip AS (
378
+ SELECT DISTINCT ON (ds.step_slug)
379
+ ds.flow_slug,
380
+ ds.step_slug,
381
+ ds.step_index,
382
+ ds.reason
383
+ FROM downstream_steps ds
384
+ ORDER BY ds.step_slug, ds.step_index -- Keep first occurrence (trigger step has original reason)
385
+ ),
386
+ -- ---------- Skip the steps ----------
387
+ skipped AS (
388
+ UPDATE pgflow.step_states ss
389
+ SET status = 'skipped',
390
+ skip_reason = sts.reason,
391
+ skipped_at = now(),
392
+ remaining_tasks = NULL -- Clear remaining_tasks for skipped steps
393
+ FROM steps_to_skip sts
394
+ WHERE ss.run_id = _cascade_force_skip_steps.run_id
395
+ AND ss.step_slug = sts.step_slug
396
+ AND ss.status IN ('created', 'started') -- Only skip non-terminal steps
397
+ RETURNING
398
+ ss.*,
399
+ -- Broadcast step:skipped event
400
+ realtime.send(
401
+ jsonb_build_object(
402
+ 'event_type', 'step:skipped',
403
+ 'run_id', ss.run_id,
404
+ 'flow_slug', ss.flow_slug,
405
+ 'step_slug', ss.step_slug,
406
+ 'status', 'skipped',
407
+ 'skip_reason', ss.skip_reason,
408
+ 'skipped_at', ss.skipped_at
409
+ ),
410
+ concat('step:', ss.step_slug, ':skipped'),
411
+ concat('pgflow:run:', ss.run_id),
412
+ false
413
+ ) as _broadcast_result
414
+ ),
415
+ -- ---------- Archive queued/started task messages for skipped steps ----------
416
+ archived_messages AS (
417
+ SELECT pgmq.archive(v_flow_slug, ARRAY_AGG(st.message_id)) as result
418
+ FROM pgflow.step_tasks st
419
+ WHERE st.run_id = _cascade_force_skip_steps.run_id
420
+ AND st.step_slug IN (SELECT sk.step_slug FROM skipped sk)
421
+ AND st.status IN ('queued', 'started')
422
+ AND st.message_id IS NOT NULL
423
+ HAVING COUNT(st.message_id) > 0
424
+ ),
425
+ -- ---------- Update run counters ----------
426
+ run_updates AS (
427
+ UPDATE pgflow.runs r
428
+ SET remaining_steps = r.remaining_steps - skipped_count.count
429
+ FROM (SELECT COUNT(*) AS count FROM skipped) skipped_count
430
+ WHERE r.run_id = _cascade_force_skip_steps.run_id
431
+ AND skipped_count.count > 0
432
+ )
433
+ SELECT skipped_count.count
434
+ INTO v_total_skipped
435
+ FROM (SELECT COUNT(*) AS count FROM skipped) skipped_count
436
+ LEFT JOIN archived_messages ON true;
437
+
438
+ RETURN v_total_skipped;
439
+ END;
440
+ $$;
441
+ -- Create "cascade_resolve_conditions" function
442
+ CREATE FUNCTION "pgflow"."cascade_resolve_conditions" ("run_id" uuid) RETURNS boolean LANGUAGE plpgsql SET "search_path" = '' AS $$
443
+ DECLARE
444
+ v_run_input jsonb;
445
+ v_run_status text;
446
+ v_first_fail record;
447
+ v_iteration_count int := 0;
448
+ v_max_iterations int := 50;
449
+ v_processed_count int;
450
+ BEGIN
451
+ -- ==========================================
452
+ -- GUARD: Early return if run is already terminal
453
+ -- ==========================================
454
+ SELECT r.status, r.input INTO v_run_status, v_run_input
455
+ FROM pgflow.runs r
456
+ WHERE r.run_id = cascade_resolve_conditions.run_id;
457
+
458
+ IF v_run_status IN ('failed', 'completed') THEN
459
+ RETURN v_run_status != 'failed';
460
+ END IF;
461
+
462
+ -- ==========================================
463
+ -- ITERATE UNTIL CONVERGENCE
464
+ -- ==========================================
465
+ -- After skipping steps, dependents may become ready and need evaluation.
466
+ -- Loop until no more steps are processed.
467
+ LOOP
468
+ v_iteration_count := v_iteration_count + 1;
469
+ IF v_iteration_count > v_max_iterations THEN
470
+ RAISE EXCEPTION 'cascade_resolve_conditions exceeded safety limit of % iterations', v_max_iterations;
471
+ END IF;
472
+
473
+ v_processed_count := 0;
474
+
475
+ -- ==========================================
476
+ -- PHASE 1a: CHECK FOR FAIL CONDITIONS
477
+ -- ==========================================
478
+ -- Find first step (by topological order) with unmet condition and 'fail' mode.
479
+ -- Condition is unmet when:
480
+ -- (required_input_pattern is set AND input does NOT contain it) OR
481
+ -- (forbidden_input_pattern is set AND input DOES contain it)
482
+ WITH steps_with_conditions AS (
483
+ SELECT
484
+ step_state.flow_slug,
485
+ step_state.step_slug,
486
+ step.required_input_pattern,
487
+ step.forbidden_input_pattern,
488
+ step.when_unmet,
489
+ step.deps_count,
490
+ step.step_index
491
+ FROM pgflow.step_states AS step_state
492
+ JOIN pgflow.steps AS step
493
+ ON step.flow_slug = step_state.flow_slug
494
+ AND step.step_slug = step_state.step_slug
495
+ WHERE step_state.run_id = cascade_resolve_conditions.run_id
496
+ AND step_state.status = 'created'
497
+ AND step_state.remaining_deps = 0
498
+ AND (step.required_input_pattern IS NOT NULL OR step.forbidden_input_pattern IS NOT NULL)
499
+ ),
500
+ step_deps_output AS (
501
+ SELECT
502
+ swc.step_slug,
503
+ jsonb_object_agg(dep_state.step_slug, dep_state.output) AS deps_output
504
+ FROM steps_with_conditions swc
505
+ JOIN pgflow.deps dep ON dep.flow_slug = swc.flow_slug AND dep.step_slug = swc.step_slug
506
+ JOIN pgflow.step_states dep_state
507
+ ON dep_state.run_id = cascade_resolve_conditions.run_id
508
+ AND dep_state.step_slug = dep.dep_slug
509
+ AND dep_state.status = 'completed' -- Only completed deps (not skipped)
510
+ WHERE swc.deps_count > 0
511
+ GROUP BY swc.step_slug
512
+ ),
513
+ condition_evaluations AS (
514
+ SELECT
515
+ swc.*,
516
+ -- condition_met = (if IS NULL OR input @> if) AND (ifNot IS NULL OR NOT(input @> ifNot))
517
+ (swc.required_input_pattern IS NULL OR
518
+ CASE WHEN swc.deps_count = 0 THEN v_run_input ELSE COALESCE(sdo.deps_output, '{}'::jsonb) END @> swc.required_input_pattern)
519
+ AND
520
+ (swc.forbidden_input_pattern IS NULL OR
521
+ NOT (CASE WHEN swc.deps_count = 0 THEN v_run_input ELSE COALESCE(sdo.deps_output, '{}'::jsonb) END @> swc.forbidden_input_pattern))
522
+ AS condition_met
523
+ FROM steps_with_conditions swc
524
+ LEFT JOIN step_deps_output sdo ON sdo.step_slug = swc.step_slug
525
+ )
526
+ SELECT
527
+ flow_slug,
528
+ step_slug,
529
+ required_input_pattern,
530
+ forbidden_input_pattern
531
+ INTO v_first_fail
532
+ FROM condition_evaluations
533
+ WHERE NOT condition_met AND when_unmet = 'fail'
534
+ ORDER BY step_index
535
+ LIMIT 1;
536
+
537
+ -- Handle fail mode: fail step and run, return false
538
+ -- Note: Cannot use "v_first_fail IS NOT NULL" because records with NULL fields
539
+ -- evaluate to NULL in IS NOT NULL checks. Use FOUND instead.
540
+ IF FOUND THEN
541
+ UPDATE pgflow.step_states
542
+ SET status = 'failed',
543
+ failed_at = now(),
544
+ error_message = 'Condition not met'
545
+ WHERE pgflow.step_states.run_id = cascade_resolve_conditions.run_id
546
+ AND pgflow.step_states.step_slug = v_first_fail.step_slug;
547
+
548
+ UPDATE pgflow.runs
549
+ SET status = 'failed',
550
+ failed_at = now()
551
+ WHERE pgflow.runs.run_id = cascade_resolve_conditions.run_id;
552
+
553
+ PERFORM realtime.send(
554
+ jsonb_build_object(
555
+ 'event_type', 'step:failed',
556
+ 'run_id', cascade_resolve_conditions.run_id,
557
+ 'step_slug', v_first_fail.step_slug,
558
+ 'status', 'failed',
559
+ 'error_message', 'Condition not met',
560
+ 'failed_at', now()
561
+ ),
562
+ concat('step:', v_first_fail.step_slug, ':failed'),
563
+ concat('pgflow:run:', cascade_resolve_conditions.run_id),
564
+ false
565
+ );
566
+
567
+ PERFORM realtime.send(
568
+ jsonb_build_object(
569
+ 'event_type', 'run:failed',
570
+ 'run_id', cascade_resolve_conditions.run_id,
571
+ 'flow_slug', v_first_fail.flow_slug,
572
+ 'status', 'failed',
573
+ 'error_message', 'Condition not met',
574
+ 'failed_at', now()
575
+ ),
576
+ 'run:failed',
577
+ concat('pgflow:run:', cascade_resolve_conditions.run_id),
578
+ false
579
+ );
580
+
581
+ PERFORM pgmq.archive(r.flow_slug, ARRAY_AGG(st.message_id))
582
+ FROM pgflow.step_tasks st
583
+ JOIN pgflow.runs r ON st.run_id = r.run_id
584
+ WHERE st.run_id = cascade_resolve_conditions.run_id
585
+ AND st.status IN ('queued', 'started')
586
+ AND st.message_id IS NOT NULL
587
+ GROUP BY r.flow_slug
588
+ HAVING COUNT(st.message_id) > 0;
589
+
590
+ RETURN false;
591
+ END IF;
592
+
593
+ -- ==========================================
594
+ -- PHASE 1b: HANDLE SKIP CONDITIONS (with propagation)
595
+ -- ==========================================
596
+ -- Skip steps with unmet conditions and whenUnmet='skip'.
597
+ -- Also decrement remaining_deps on dependents and set initial_tasks=0 for map dependents.
598
+ WITH steps_with_conditions AS (
599
+ SELECT
600
+ step_state.flow_slug,
601
+ step_state.step_slug,
602
+ step.required_input_pattern,
603
+ step.forbidden_input_pattern,
604
+ step.when_unmet,
605
+ step.deps_count,
606
+ step.step_index
607
+ FROM pgflow.step_states AS step_state
608
+ JOIN pgflow.steps AS step
609
+ ON step.flow_slug = step_state.flow_slug
610
+ AND step.step_slug = step_state.step_slug
611
+ WHERE step_state.run_id = cascade_resolve_conditions.run_id
612
+ AND step_state.status = 'created'
613
+ AND step_state.remaining_deps = 0
614
+ AND (step.required_input_pattern IS NOT NULL OR step.forbidden_input_pattern IS NOT NULL)
615
+ ),
616
+ step_deps_output AS (
617
+ SELECT
618
+ swc.step_slug,
619
+ jsonb_object_agg(dep_state.step_slug, dep_state.output) AS deps_output
620
+ FROM steps_with_conditions swc
621
+ JOIN pgflow.deps dep ON dep.flow_slug = swc.flow_slug AND dep.step_slug = swc.step_slug
622
+ JOIN pgflow.step_states dep_state
623
+ ON dep_state.run_id = cascade_resolve_conditions.run_id
624
+ AND dep_state.step_slug = dep.dep_slug
625
+ AND dep_state.status = 'completed' -- Only completed deps (not skipped)
626
+ WHERE swc.deps_count > 0
627
+ GROUP BY swc.step_slug
628
+ ),
629
+ condition_evaluations AS (
630
+ SELECT
631
+ swc.*,
632
+ -- condition_met = (if IS NULL OR input @> if) AND (ifNot IS NULL OR NOT(input @> ifNot))
633
+ (swc.required_input_pattern IS NULL OR
634
+ CASE WHEN swc.deps_count = 0 THEN v_run_input ELSE COALESCE(sdo.deps_output, '{}'::jsonb) END @> swc.required_input_pattern)
635
+ AND
636
+ (swc.forbidden_input_pattern IS NULL OR
637
+ NOT (CASE WHEN swc.deps_count = 0 THEN v_run_input ELSE COALESCE(sdo.deps_output, '{}'::jsonb) END @> swc.forbidden_input_pattern))
638
+ AS condition_met
639
+ FROM steps_with_conditions swc
640
+ LEFT JOIN step_deps_output sdo ON sdo.step_slug = swc.step_slug
641
+ ),
642
+ unmet_skip_steps AS (
643
+ SELECT * FROM condition_evaluations
644
+ WHERE NOT condition_met AND when_unmet = 'skip'
645
+ ),
646
+ skipped_steps AS (
647
+ UPDATE pgflow.step_states ss
648
+ SET status = 'skipped',
649
+ skip_reason = 'condition_unmet',
650
+ skipped_at = now()
651
+ FROM unmet_skip_steps uss
652
+ WHERE ss.run_id = cascade_resolve_conditions.run_id
653
+ AND ss.step_slug = uss.step_slug
654
+ AND ss.status = 'created'
655
+ RETURNING
656
+ ss.*,
657
+ realtime.send(
658
+ jsonb_build_object(
659
+ 'event_type', 'step:skipped',
660
+ 'run_id', ss.run_id,
661
+ 'flow_slug', ss.flow_slug,
662
+ 'step_slug', ss.step_slug,
663
+ 'status', 'skipped',
664
+ 'skip_reason', 'condition_unmet',
665
+ 'skipped_at', ss.skipped_at
666
+ ),
667
+ concat('step:', ss.step_slug, ':skipped'),
668
+ concat('pgflow:run:', ss.run_id),
669
+ false
670
+ ) AS _broadcast_result
671
+ ),
672
+ -- NEW: Update dependent steps (decrement remaining_deps by count of skipped parents, set initial_tasks=0 for maps)
673
+ skipped_parent_counts AS (
674
+ -- Count how many skipped parents each child has
675
+ SELECT
676
+ dep.step_slug AS child_step_slug,
677
+ dep.flow_slug AS child_flow_slug,
678
+ COUNT(*) AS skipped_parent_count
679
+ FROM skipped_steps parent
680
+ JOIN pgflow.deps dep ON dep.flow_slug = parent.flow_slug AND dep.dep_slug = parent.step_slug
681
+ GROUP BY dep.step_slug, dep.flow_slug
682
+ ),
683
+ dependent_updates AS (
684
+ UPDATE pgflow.step_states child_state
685
+ SET remaining_deps = child_state.remaining_deps - spc.skipped_parent_count,
686
+ -- If child is a map step and this skipped step is its only dependency,
687
+ -- set initial_tasks = 0 (skipped dep = empty array)
688
+ initial_tasks = CASE
689
+ WHEN child_step.step_type = 'map' AND child_step.deps_count = 1 THEN 0
690
+ ELSE child_state.initial_tasks
691
+ END
692
+ FROM skipped_parent_counts spc
693
+ JOIN pgflow.steps child_step ON child_step.flow_slug = spc.child_flow_slug AND child_step.step_slug = spc.child_step_slug
694
+ WHERE child_state.run_id = cascade_resolve_conditions.run_id
695
+ AND child_state.step_slug = spc.child_step_slug
696
+ ),
697
+ run_update AS (
698
+ UPDATE pgflow.runs r
699
+ SET remaining_steps = r.remaining_steps - (SELECT COUNT(*) FROM skipped_steps)
700
+ WHERE r.run_id = cascade_resolve_conditions.run_id
701
+ AND (SELECT COUNT(*) FROM skipped_steps) > 0
702
+ )
703
+ SELECT COUNT(*)::int INTO v_processed_count FROM skipped_steps;
704
+
705
+ -- ==========================================
706
+ -- PHASE 1c: HANDLE SKIP-CASCADE CONDITIONS
707
+ -- ==========================================
708
+ -- Call _cascade_force_skip_steps for each step with unmet condition and whenUnmet='skip-cascade'.
709
+ -- Process in topological order; _cascade_force_skip_steps is idempotent.
710
+ PERFORM pgflow._cascade_force_skip_steps(cascade_resolve_conditions.run_id, ready_step.step_slug, 'condition_unmet')
711
+ FROM pgflow.step_states AS ready_step
712
+ JOIN pgflow.steps AS step
713
+ ON step.flow_slug = ready_step.flow_slug
714
+ AND step.step_slug = ready_step.step_slug
715
+ LEFT JOIN LATERAL (
716
+ SELECT jsonb_object_agg(dep_state.step_slug, dep_state.output) AS deps_output
717
+ FROM pgflow.deps dep
718
+ JOIN pgflow.step_states dep_state
719
+ ON dep_state.run_id = cascade_resolve_conditions.run_id
720
+ AND dep_state.step_slug = dep.dep_slug
721
+ AND dep_state.status = 'completed' -- Only completed deps (not skipped)
722
+ WHERE dep.flow_slug = ready_step.flow_slug
723
+ AND dep.step_slug = ready_step.step_slug
724
+ ) AS agg_deps ON step.deps_count > 0
725
+ WHERE ready_step.run_id = cascade_resolve_conditions.run_id
726
+ AND ready_step.status = 'created'
727
+ AND ready_step.remaining_deps = 0
728
+ AND (step.required_input_pattern IS NOT NULL OR step.forbidden_input_pattern IS NOT NULL)
729
+ AND step.when_unmet = 'skip-cascade'
730
+ -- Condition is NOT met when: (if fails) OR (ifNot fails)
731
+ AND NOT (
732
+ (step.required_input_pattern IS NULL OR
733
+ CASE WHEN step.deps_count = 0 THEN v_run_input ELSE COALESCE(agg_deps.deps_output, '{}'::jsonb) END @> step.required_input_pattern)
734
+ AND
735
+ (step.forbidden_input_pattern IS NULL OR
736
+ NOT (CASE WHEN step.deps_count = 0 THEN v_run_input ELSE COALESCE(agg_deps.deps_output, '{}'::jsonb) END @> step.forbidden_input_pattern))
737
+ )
738
+ ORDER BY step.step_index;
739
+
740
+ -- Check if run was failed during cascade (e.g., if _cascade_force_skip_steps triggers fail)
741
+ SELECT r.status INTO v_run_status
742
+ FROM pgflow.runs r
743
+ WHERE r.run_id = cascade_resolve_conditions.run_id;
744
+
745
+ IF v_run_status IN ('failed', 'completed') THEN
746
+ RETURN v_run_status != 'failed';
747
+ END IF;
748
+
749
+ -- Exit loop if no steps were processed in this iteration
750
+ EXIT WHEN v_processed_count = 0;
751
+ END LOOP;
752
+
753
+ RETURN true;
754
+ END;
755
+ $$;
756
+ -- Modify "start_ready_steps" function
757
+ CREATE OR REPLACE FUNCTION "pgflow"."start_ready_steps" ("run_id" uuid) RETURNS void LANGUAGE plpgsql SET "search_path" = '' AS $$
758
+ BEGIN
759
+ -- ==========================================
760
+ -- GUARD: No mutations on terminal runs
761
+ -- ==========================================
762
+ IF EXISTS (
763
+ SELECT 1 FROM pgflow.runs
764
+ WHERE pgflow.runs.run_id = start_ready_steps.run_id
765
+ AND pgflow.runs.status IN ('failed', 'completed')
766
+ ) THEN
767
+ RETURN;
768
+ END IF;
769
+
770
+ -- ==========================================
771
+ -- PHASE 1: START READY STEPS
772
+ -- ==========================================
773
+ -- NOTE: Condition evaluation and empty map handling are done by
774
+ -- cascade_resolve_conditions() and cascade_complete_taskless_steps()
775
+ -- which are called before this function.
776
+ WITH
777
+ -- ---------- Find ready steps ----------
778
+ -- Steps with no remaining deps and known task count
779
+ ready_steps AS (
780
+ SELECT *
781
+ FROM pgflow.step_states AS step_state
782
+ WHERE step_state.run_id = start_ready_steps.run_id
783
+ AND step_state.status = 'created'
784
+ AND step_state.remaining_deps = 0
785
+ AND step_state.initial_tasks IS NOT NULL -- Cannot start with unknown count
786
+ AND step_state.initial_tasks > 0 -- Don't start taskless steps (handled by cascade_complete_taskless_steps)
787
+ ORDER BY step_state.step_slug
788
+ FOR UPDATE
789
+ ),
790
+ -- ---------- Mark steps as started ----------
791
+ started_step_states AS (
792
+ UPDATE pgflow.step_states
793
+ SET status = 'started',
794
+ started_at = now(),
795
+ remaining_tasks = ready_steps.initial_tasks -- Copy initial_tasks to remaining_tasks when starting
796
+ FROM ready_steps
797
+ WHERE pgflow.step_states.run_id = start_ready_steps.run_id
798
+ AND pgflow.step_states.step_slug = ready_steps.step_slug
799
+ RETURNING pgflow.step_states.*,
800
+ -- Broadcast step:started event atomically with the UPDATE
801
+ -- Using RETURNING ensures this executes during row processing
802
+ -- and cannot be optimized away by the query planner
803
+ realtime.send(
804
+ jsonb_build_object(
805
+ 'event_type', 'step:started',
806
+ 'run_id', pgflow.step_states.run_id,
807
+ 'step_slug', pgflow.step_states.step_slug,
808
+ 'status', 'started',
809
+ 'started_at', pgflow.step_states.started_at,
810
+ 'remaining_tasks', pgflow.step_states.remaining_tasks,
811
+ 'remaining_deps', pgflow.step_states.remaining_deps
812
+ ),
813
+ concat('step:', pgflow.step_states.step_slug, ':started'),
814
+ concat('pgflow:run:', pgflow.step_states.run_id),
815
+ false
816
+ ) as _broadcast_result -- Prefix with _ to indicate internal use only
817
+ ),
818
+
819
+ -- ==========================================
820
+ -- PHASE 2: TASK GENERATION AND QUEUE MESSAGES
821
+ -- ==========================================
822
+ -- ---------- Generate tasks and batch messages ----------
823
+ -- Single steps: 1 task (index 0)
824
+ -- Map steps: N tasks (indices 0..N-1)
825
+ message_batches AS (
826
+ SELECT
827
+ started_step.flow_slug,
828
+ started_step.run_id,
829
+ started_step.step_slug,
830
+ COALESCE(step.opt_start_delay, 0) as delay,
831
+ array_agg(
832
+ jsonb_build_object(
833
+ 'flow_slug', started_step.flow_slug,
834
+ 'run_id', started_step.run_id,
835
+ 'step_slug', started_step.step_slug,
836
+ 'task_index', task_idx.task_index
837
+ ) ORDER BY task_idx.task_index
838
+ ) AS messages,
839
+ array_agg(task_idx.task_index ORDER BY task_idx.task_index) AS task_indices
840
+ FROM started_step_states AS started_step
841
+ JOIN pgflow.steps AS step
842
+ ON step.flow_slug = started_step.flow_slug
843
+ AND step.step_slug = started_step.step_slug
844
+ -- Generate task indices from 0 to initial_tasks-1
845
+ CROSS JOIN LATERAL generate_series(0, started_step.initial_tasks - 1) AS task_idx(task_index)
846
+ GROUP BY started_step.flow_slug, started_step.run_id, started_step.step_slug, step.opt_start_delay
847
+ ),
848
+ -- ---------- Send messages to queue ----------
849
+ -- Uses batch sending for performance with large arrays
850
+ sent_messages AS (
851
+ SELECT
852
+ mb.flow_slug,
853
+ mb.run_id,
854
+ mb.step_slug,
855
+ task_indices.task_index,
856
+ msg_ids.msg_id
857
+ FROM message_batches mb
858
+ CROSS JOIN LATERAL unnest(mb.task_indices) WITH ORDINALITY AS task_indices(task_index, idx_ord)
859
+ CROSS JOIN LATERAL pgmq.send_batch(mb.flow_slug, mb.messages, mb.delay) WITH ORDINALITY AS msg_ids(msg_id, msg_ord)
860
+ WHERE task_indices.idx_ord = msg_ids.msg_ord
861
+ )
862
+
863
+ -- ==========================================
864
+ -- PHASE 3: RECORD TASKS IN DATABASE
865
+ -- ==========================================
866
+ INSERT INTO pgflow.step_tasks (flow_slug, run_id, step_slug, task_index, message_id)
867
+ SELECT
868
+ sent_messages.flow_slug,
869
+ sent_messages.run_id,
870
+ sent_messages.step_slug,
871
+ sent_messages.task_index,
872
+ sent_messages.msg_id
873
+ FROM sent_messages;
874
+
875
+ END;
876
+ $$;
877
+ -- Modify "complete_task" function
878
+ CREATE OR REPLACE FUNCTION "pgflow"."complete_task" ("run_id" uuid, "step_slug" text, "task_index" integer, "output" jsonb) RETURNS SETOF "pgflow"."step_tasks" LANGUAGE plpgsql SET "search_path" = '' AS $$
879
+ declare
880
+ v_step_state pgflow.step_states%ROWTYPE;
881
+ v_dependent_map_slug text;
882
+ v_run_record pgflow.runs%ROWTYPE;
883
+ v_step_record pgflow.step_states%ROWTYPE;
884
+ begin
885
+
886
+ -- ==========================================
887
+ -- GUARD: No mutations on failed runs
888
+ -- ==========================================
889
+ IF EXISTS (SELECT 1 FROM pgflow.runs WHERE pgflow.runs.run_id = complete_task.run_id AND pgflow.runs.status = 'failed') THEN
890
+ RETURN QUERY SELECT * FROM pgflow.step_tasks
891
+ WHERE pgflow.step_tasks.run_id = complete_task.run_id
892
+ AND pgflow.step_tasks.step_slug = complete_task.step_slug
893
+ AND pgflow.step_tasks.task_index = complete_task.task_index;
894
+ RETURN;
895
+ END IF;
896
+
897
+ -- ==========================================
898
+ -- LOCK ACQUISITION AND TYPE VALIDATION
899
+ -- ==========================================
900
+ -- Acquire locks first to prevent race conditions
901
+ SELECT * INTO v_run_record FROM pgflow.runs
902
+ WHERE pgflow.runs.run_id = complete_task.run_id
903
+ FOR UPDATE;
904
+
905
+ SELECT * INTO v_step_record FROM pgflow.step_states
906
+ WHERE pgflow.step_states.run_id = complete_task.run_id
907
+ AND pgflow.step_states.step_slug = complete_task.step_slug
908
+ FOR UPDATE;
909
+
910
+ -- ==========================================
911
+ -- GUARD: Late callback - step not started
912
+ -- ==========================================
913
+ -- If the step is not in 'started' state, this is a late callback.
914
+ -- Do not mutate step_states or runs, archive message, return task row.
915
+ IF v_step_record.status != 'started' THEN
916
+ -- Archive the task message if present (prevents stuck work)
917
+ PERFORM pgmq.archive(
918
+ v_run_record.flow_slug,
919
+ st.message_id
920
+ )
921
+ FROM pgflow.step_tasks st
922
+ WHERE st.run_id = complete_task.run_id
923
+ AND st.step_slug = complete_task.step_slug
924
+ AND st.task_index = complete_task.task_index
925
+ AND st.message_id IS NOT NULL;
926
+ -- Return the current task row without any mutations
927
+ RETURN QUERY SELECT * FROM pgflow.step_tasks
928
+ WHERE pgflow.step_tasks.run_id = complete_task.run_id
929
+ AND pgflow.step_tasks.step_slug = complete_task.step_slug
930
+ AND pgflow.step_tasks.task_index = complete_task.task_index;
931
+ RETURN;
932
+ END IF;
933
+
934
+ -- Check for type violations AFTER acquiring locks
935
+ SELECT child_step.step_slug INTO v_dependent_map_slug
936
+ FROM pgflow.deps dependency
937
+ JOIN pgflow.steps child_step ON child_step.flow_slug = dependency.flow_slug
938
+ AND child_step.step_slug = dependency.step_slug
939
+ JOIN pgflow.steps parent_step ON parent_step.flow_slug = dependency.flow_slug
940
+ AND parent_step.step_slug = dependency.dep_slug
941
+ JOIN pgflow.step_states child_state ON child_state.flow_slug = child_step.flow_slug
942
+ AND child_state.step_slug = child_step.step_slug
943
+ WHERE dependency.dep_slug = complete_task.step_slug -- parent is the completing step
944
+ AND dependency.flow_slug = v_run_record.flow_slug
945
+ AND parent_step.step_type = 'single' -- Only validate single steps
946
+ AND child_step.step_type = 'map'
947
+ AND child_state.run_id = complete_task.run_id
948
+ AND child_state.initial_tasks IS NULL
949
+ AND (complete_task.output IS NULL OR jsonb_typeof(complete_task.output) != 'array')
950
+ LIMIT 1;
951
+
952
+ -- Handle type violation if detected
953
+ IF v_dependent_map_slug IS NOT NULL THEN
954
+ -- Mark run as failed immediately
955
+ UPDATE pgflow.runs
956
+ SET status = 'failed',
957
+ failed_at = now()
958
+ WHERE pgflow.runs.run_id = complete_task.run_id;
959
+
960
+ -- Broadcast run:failed event
961
+ -- Uses PERFORM pattern to ensure execution (proven reliable pattern in this function)
962
+ PERFORM realtime.send(
963
+ jsonb_build_object(
964
+ 'event_type', 'run:failed',
965
+ 'run_id', complete_task.run_id,
966
+ 'flow_slug', v_run_record.flow_slug,
967
+ 'status', 'failed',
968
+ 'failed_at', now()
969
+ ),
970
+ 'run:failed',
971
+ concat('pgflow:run:', complete_task.run_id),
972
+ false
973
+ );
974
+
975
+ -- Archive all active messages (both queued and started) to prevent orphaned messages
976
+ PERFORM pgmq.archive(
977
+ v_run_record.flow_slug,
978
+ array_agg(st.message_id)
979
+ )
980
+ FROM pgflow.step_tasks st
981
+ WHERE st.run_id = complete_task.run_id
982
+ AND st.status IN ('queued', 'started')
983
+ AND st.message_id IS NOT NULL
984
+ HAVING count(*) > 0; -- Only call archive if there are messages to archive
985
+
986
+ -- Mark current task as failed and store the output
987
+ UPDATE pgflow.step_tasks
988
+ SET status = 'failed',
989
+ failed_at = now(),
990
+ output = complete_task.output, -- Store the output that caused the violation
991
+ error_message = '[TYPE_VIOLATION] Produced ' ||
992
+ CASE WHEN complete_task.output IS NULL THEN 'null'
993
+ ELSE jsonb_typeof(complete_task.output) END ||
994
+ ' instead of array'
995
+ WHERE pgflow.step_tasks.run_id = complete_task.run_id
996
+ AND pgflow.step_tasks.step_slug = complete_task.step_slug
997
+ AND pgflow.step_tasks.task_index = complete_task.task_index;
998
+
999
+ -- Mark step state as failed
1000
+ UPDATE pgflow.step_states
1001
+ SET status = 'failed',
1002
+ failed_at = now(),
1003
+ error_message = '[TYPE_VIOLATION] Map step ' || v_dependent_map_slug ||
1004
+ ' expects array input but dependency ' || complete_task.step_slug ||
1005
+ ' produced ' || CASE WHEN complete_task.output IS NULL THEN 'null'
1006
+ ELSE jsonb_typeof(complete_task.output) END
1007
+ WHERE pgflow.step_states.run_id = complete_task.run_id
1008
+ AND pgflow.step_states.step_slug = complete_task.step_slug;
1009
+
1010
+ -- Broadcast step:failed event
1011
+ -- Uses PERFORM pattern to ensure execution (proven reliable pattern in this function)
1012
+ PERFORM realtime.send(
1013
+ jsonb_build_object(
1014
+ 'event_type', 'step:failed',
1015
+ 'run_id', complete_task.run_id,
1016
+ 'step_slug', complete_task.step_slug,
1017
+ 'status', 'failed',
1018
+ 'error_message', '[TYPE_VIOLATION] Map step ' || v_dependent_map_slug ||
1019
+ ' expects array input but dependency ' || complete_task.step_slug ||
1020
+ ' produced ' || CASE WHEN complete_task.output IS NULL THEN 'null'
1021
+ ELSE jsonb_typeof(complete_task.output) END,
1022
+ 'failed_at', now()
1023
+ ),
1024
+ concat('step:', complete_task.step_slug, ':failed'),
1025
+ concat('pgflow:run:', complete_task.run_id),
1026
+ false
1027
+ );
1028
+
1029
+ -- Archive the current task's message (it was started, now failed)
1030
+ PERFORM pgmq.archive(
1031
+ v_run_record.flow_slug,
1032
+ st.message_id -- Single message, use scalar form
1033
+ )
1034
+ FROM pgflow.step_tasks st
1035
+ WHERE st.run_id = complete_task.run_id
1036
+ AND st.step_slug = complete_task.step_slug
1037
+ AND st.task_index = complete_task.task_index
1038
+ AND st.message_id IS NOT NULL;
1039
+
1040
+ -- Return the failed task row (API contract: always return task row)
1041
+ RETURN QUERY
1042
+ SELECT * FROM pgflow.step_tasks st
1043
+ WHERE st.run_id = complete_task.run_id
1044
+ AND st.step_slug = complete_task.step_slug
1045
+ AND st.task_index = complete_task.task_index;
1046
+ RETURN;
1047
+ END IF;
1048
+
1049
+ -- ==========================================
1050
+ -- MAIN CTE CHAIN: Update task and propagate changes
1051
+ -- ==========================================
1052
+ WITH
1053
+ -- ---------- Task completion ----------
1054
+ -- Update the task record with completion status and output
1055
+ task AS (
1056
+ UPDATE pgflow.step_tasks
1057
+ SET
1058
+ status = 'completed',
1059
+ completed_at = now(),
1060
+ output = complete_task.output
1061
+ WHERE pgflow.step_tasks.run_id = complete_task.run_id
1062
+ AND pgflow.step_tasks.step_slug = complete_task.step_slug
1063
+ AND pgflow.step_tasks.task_index = complete_task.task_index
1064
+ AND pgflow.step_tasks.status = 'started'
1065
+ RETURNING *
1066
+ ),
1067
+ -- ---------- Get step type for output handling ----------
1068
+ step_def AS (
1069
+ SELECT step.step_type
1070
+ FROM pgflow.steps step
1071
+ JOIN pgflow.runs run ON run.flow_slug = step.flow_slug
1072
+ WHERE run.run_id = complete_task.run_id
1073
+ AND step.step_slug = complete_task.step_slug
1074
+ ),
1075
+ -- ---------- Step state update ----------
1076
+ -- Decrement remaining_tasks and potentially mark step as completed
1077
+ -- Also store output atomically with status transition to completed
1078
+ step_state AS (
1079
+ UPDATE pgflow.step_states
1080
+ SET
1081
+ status = CASE
1082
+ WHEN pgflow.step_states.remaining_tasks = 1 THEN 'completed' -- Will be 0 after decrement
1083
+ ELSE 'started'
1084
+ END,
1085
+ completed_at = CASE
1086
+ WHEN pgflow.step_states.remaining_tasks = 1 THEN now() -- Will be 0 after decrement
1087
+ ELSE NULL
1088
+ END,
1089
+ remaining_tasks = pgflow.step_states.remaining_tasks - 1,
1090
+ -- Store output atomically with completion (only when remaining_tasks = 1, meaning step completes)
1091
+ output = CASE
1092
+ -- Single step: store task output directly when completing
1093
+ WHEN (SELECT step_type FROM step_def) = 'single' AND pgflow.step_states.remaining_tasks = 1 THEN
1094
+ complete_task.output
1095
+ -- Map step: aggregate on completion (ordered by task_index)
1096
+ WHEN (SELECT step_type FROM step_def) = 'map' AND pgflow.step_states.remaining_tasks = 1 THEN
1097
+ (SELECT COALESCE(jsonb_agg(all_outputs.output ORDER BY all_outputs.task_index), '[]'::jsonb)
1098
+ FROM (
1099
+ -- All previously completed tasks
1100
+ SELECT st.output, st.task_index
1101
+ FROM pgflow.step_tasks st
1102
+ WHERE st.run_id = complete_task.run_id
1103
+ AND st.step_slug = complete_task.step_slug
1104
+ AND st.status = 'completed'
1105
+ UNION ALL
1106
+ -- Current task being completed (not yet visible as completed in snapshot)
1107
+ SELECT complete_task.output, complete_task.task_index
1108
+ ) all_outputs)
1109
+ ELSE pgflow.step_states.output
1110
+ END
1111
+ FROM task
1112
+ WHERE pgflow.step_states.run_id = complete_task.run_id
1113
+ AND pgflow.step_states.step_slug = complete_task.step_slug
1114
+ RETURNING pgflow.step_states.*
1115
+ ),
1116
+ -- ---------- Dependency resolution ----------
1117
+ -- Find all child steps that depend on the completed parent step (only if parent completed)
1118
+ child_steps AS (
1119
+ SELECT deps.step_slug AS child_step_slug
1120
+ FROM pgflow.deps deps
1121
+ JOIN step_state parent_state ON parent_state.status = 'completed' AND deps.flow_slug = parent_state.flow_slug
1122
+ WHERE deps.dep_slug = complete_task.step_slug -- dep_slug is the parent, step_slug is the child
1123
+ ORDER BY deps.step_slug -- Ensure consistent ordering
1124
+ ),
1125
+ -- ---------- Lock child steps ----------
1126
+ -- Acquire locks on all child steps before updating them
1127
+ child_steps_lock AS (
1128
+ SELECT * FROM pgflow.step_states
1129
+ WHERE pgflow.step_states.run_id = complete_task.run_id
1130
+ AND pgflow.step_states.step_slug IN (SELECT child_step_slug FROM child_steps)
1131
+ FOR UPDATE
1132
+ ),
1133
+ -- ---------- Update child steps ----------
1134
+ -- Decrement remaining_deps and resolve NULL initial_tasks for map steps
1135
+ child_steps_update AS (
1136
+ UPDATE pgflow.step_states child_state
1137
+ SET remaining_deps = child_state.remaining_deps - 1,
1138
+ -- Resolve NULL initial_tasks for child map steps
1139
+ -- This is where child maps learn their array size from the parent
1140
+ -- This CTE only runs when the parent step is complete (see child_steps JOIN)
1141
+ initial_tasks = CASE
1142
+ WHEN child_step.step_type = 'map' AND child_state.initial_tasks IS NULL THEN
1143
+ CASE
1144
+ WHEN parent_step.step_type = 'map' THEN
1145
+ -- Map->map: Count all completed tasks from parent map
1146
+ -- We add 1 because the current task is being completed in this transaction
1147
+ -- but isn't yet visible as 'completed' in the step_tasks table
1148
+ -- TODO: Refactor to use future column step_states.total_tasks
1149
+ -- Would eliminate the COUNT query and just use parent_state.total_tasks
1150
+ (SELECT COUNT(*)::int + 1
1151
+ FROM pgflow.step_tasks parent_tasks
1152
+ WHERE parent_tasks.run_id = complete_task.run_id
1153
+ AND parent_tasks.step_slug = complete_task.step_slug
1154
+ AND parent_tasks.status = 'completed'
1155
+ AND parent_tasks.task_index != complete_task.task_index)
1156
+ ELSE
1157
+ -- Single->map: Use output array length (single steps complete immediately)
1158
+ CASE
1159
+ WHEN complete_task.output IS NOT NULL
1160
+ AND jsonb_typeof(complete_task.output) = 'array' THEN
1161
+ jsonb_array_length(complete_task.output)
1162
+ ELSE NULL -- Keep NULL if not an array
1163
+ END
1164
+ END
1165
+ ELSE child_state.initial_tasks -- Keep existing value (including NULL)
1166
+ END
1167
+ FROM child_steps children
1168
+ JOIN pgflow.steps child_step ON child_step.flow_slug = (SELECT r.flow_slug FROM pgflow.runs r WHERE r.run_id = complete_task.run_id)
1169
+ AND child_step.step_slug = children.child_step_slug
1170
+ JOIN pgflow.steps parent_step ON parent_step.flow_slug = (SELECT r.flow_slug FROM pgflow.runs r WHERE r.run_id = complete_task.run_id)
1171
+ AND parent_step.step_slug = complete_task.step_slug
1172
+ WHERE child_state.run_id = complete_task.run_id
1173
+ AND child_state.step_slug = children.child_step_slug
1174
+ )
1175
+ -- ---------- Update run remaining_steps ----------
1176
+ -- Decrement the run's remaining_steps counter if step completed
1177
+ UPDATE pgflow.runs
1178
+ SET remaining_steps = pgflow.runs.remaining_steps - 1
1179
+ FROM step_state
1180
+ WHERE pgflow.runs.run_id = complete_task.run_id
1181
+ AND step_state.status = 'completed';
1182
+
1183
+ -- ==========================================
1184
+ -- POST-COMPLETION ACTIONS
1185
+ -- ==========================================
1186
+
1187
+ -- ---------- Get updated state for broadcasting ----------
1188
+ SELECT * INTO v_step_state FROM pgflow.step_states
1189
+ WHERE pgflow.step_states.run_id = complete_task.run_id AND pgflow.step_states.step_slug = complete_task.step_slug;
1190
+
1191
+ -- ---------- Handle step completion ----------
1192
+ IF v_step_state.status = 'completed' THEN
1193
+ -- Broadcast step:completed event FIRST (before cascade)
1194
+ -- This ensures parent broadcasts before its dependent children
1195
+ -- Use stored output from step_states (set atomically during status transition)
1196
+ PERFORM realtime.send(
1197
+ jsonb_build_object(
1198
+ 'event_type', 'step:completed',
1199
+ 'run_id', complete_task.run_id,
1200
+ 'step_slug', complete_task.step_slug,
1201
+ 'status', 'completed',
1202
+ 'output', v_step_state.output, -- Use stored output instead of re-aggregating
1203
+ 'completed_at', v_step_state.completed_at
1204
+ ),
1205
+ concat('step:', complete_task.step_slug, ':completed'),
1206
+ concat('pgflow:run:', complete_task.run_id),
1207
+ false
1208
+ );
1209
+
1210
+ -- THEN evaluate conditions on newly-ready dependent steps
1211
+ -- This must happen before cascade_complete_taskless_steps so that
1212
+ -- skipped steps can set initial_tasks=0 for their map dependents
1213
+ IF NOT pgflow.cascade_resolve_conditions(complete_task.run_id) THEN
1214
+ -- Run was failed due to a condition with when_unmet='fail'
1215
+ -- Archive the current task's message before returning
1216
+ PERFORM pgmq.archive(
1217
+ (SELECT r.flow_slug FROM pgflow.runs r WHERE r.run_id = complete_task.run_id),
1218
+ (SELECT st.message_id FROM pgflow.step_tasks st
1219
+ WHERE st.run_id = complete_task.run_id
1220
+ AND st.step_slug = complete_task.step_slug
1221
+ AND st.task_index = complete_task.task_index)
1222
+ );
1223
+ RETURN QUERY SELECT * FROM pgflow.step_tasks
1224
+ WHERE pgflow.step_tasks.run_id = complete_task.run_id
1225
+ AND pgflow.step_tasks.step_slug = complete_task.step_slug
1226
+ AND pgflow.step_tasks.task_index = complete_task.task_index;
1227
+ RETURN;
1228
+ END IF;
1229
+
1230
+ -- THEN cascade complete any taskless steps that are now ready
1231
+ -- This ensures dependent children broadcast AFTER their parent
1232
+ PERFORM pgflow.cascade_complete_taskless_steps(complete_task.run_id);
1233
+ END IF;
1234
+
1235
+ -- ---------- Archive completed task message ----------
1236
+ -- Move message from active queue to archive table
1237
+ PERFORM (
1238
+ WITH completed_tasks AS (
1239
+ SELECT r.flow_slug, st.message_id
1240
+ FROM pgflow.step_tasks st
1241
+ JOIN pgflow.runs r ON st.run_id = r.run_id
1242
+ WHERE st.run_id = complete_task.run_id
1243
+ AND st.step_slug = complete_task.step_slug
1244
+ AND st.task_index = complete_task.task_index
1245
+ AND st.status = 'completed'
1246
+ )
1247
+ SELECT pgmq.archive(ct.flow_slug, ct.message_id)
1248
+ FROM completed_tasks ct
1249
+ WHERE EXISTS (SELECT 1 FROM completed_tasks)
1250
+ );
1251
+
1252
+ -- ---------- Trigger next steps ----------
1253
+ -- Start any steps that are now ready (deps satisfied)
1254
+ PERFORM pgflow.start_ready_steps(complete_task.run_id);
1255
+
1256
+ -- Check if the entire run is complete
1257
+ PERFORM pgflow.maybe_complete_run(complete_task.run_id);
1258
+
1259
+ -- ---------- Return completed task ----------
1260
+ RETURN QUERY SELECT *
1261
+ FROM pgflow.step_tasks AS step_task
1262
+ WHERE step_task.run_id = complete_task.run_id
1263
+ AND step_task.step_slug = complete_task.step_slug
1264
+ AND step_task.task_index = complete_task.task_index;
1265
+
1266
+ end;
1267
+ $$;
1268
+ -- Create "_archive_task_message" function
1269
+ CREATE FUNCTION "pgflow"."_archive_task_message" ("p_run_id" uuid, "p_step_slug" text, "p_task_index" integer) RETURNS void LANGUAGE sql SET "search_path" = '' AS $$
1270
+ SELECT pgmq.archive(
1271
+ r.flow_slug,
1272
+ ARRAY_AGG(st.message_id)
1273
+ )
1274
+ FROM pgflow.step_tasks st
1275
+ JOIN pgflow.runs r ON st.run_id = r.run_id
1276
+ WHERE st.run_id = p_run_id
1277
+ AND st.step_slug = p_step_slug
1278
+ AND st.task_index = p_task_index
1279
+ AND st.message_id IS NOT NULL
1280
+ GROUP BY r.flow_slug
1281
+ HAVING COUNT(st.message_id) > 0;
1282
+ $$;
1283
+ -- Modify "fail_task" function
1284
+ CREATE OR REPLACE FUNCTION "pgflow"."fail_task" ("run_id" uuid, "step_slug" text, "task_index" integer, "error_message" text) RETURNS SETOF "pgflow"."step_tasks" LANGUAGE plpgsql SET "search_path" = '' AS $$
1285
+ DECLARE
1286
+ v_run_failed boolean;
1287
+ v_step_failed boolean;
1288
+ v_step_skipped boolean;
1289
+ v_when_exhausted text;
1290
+ v_task_exhausted boolean;
1291
+ v_flow_slug_for_deps text;
1292
+ v_prev_step_status text;
1293
+ v_flow_slug text;
1294
+ begin
1295
+
1296
+ -- If run is already failed, no retries allowed
1297
+ IF EXISTS (SELECT 1 FROM pgflow.runs WHERE pgflow.runs.run_id = fail_task.run_id AND pgflow.runs.status = 'failed') THEN
1298
+ UPDATE pgflow.step_tasks
1299
+ SET status = 'failed',
1300
+ failed_at = now(),
1301
+ error_message = fail_task.error_message
1302
+ WHERE pgflow.step_tasks.run_id = fail_task.run_id
1303
+ AND pgflow.step_tasks.step_slug = fail_task.step_slug
1304
+ AND pgflow.step_tasks.task_index = fail_task.task_index
1305
+ AND pgflow.step_tasks.status = 'started';
1306
+
1307
+ -- Archive the task's message
1308
+ PERFORM pgmq.archive(r.flow_slug, ARRAY_AGG(st.message_id))
1309
+ FROM pgflow.step_tasks st
1310
+ JOIN pgflow.runs r ON st.run_id = r.run_id
1311
+ WHERE st.run_id = fail_task.run_id
1312
+ AND st.step_slug = fail_task.step_slug
1313
+ AND st.task_index = fail_task.task_index
1314
+ AND st.message_id IS NOT NULL
1315
+ GROUP BY r.flow_slug
1316
+ HAVING COUNT(st.message_id) > 0;
1317
+
1318
+ RETURN QUERY SELECT * FROM pgflow.step_tasks
1319
+ WHERE pgflow.step_tasks.run_id = fail_task.run_id
1320
+ AND pgflow.step_tasks.step_slug = fail_task.step_slug
1321
+ AND pgflow.step_tasks.task_index = fail_task.task_index;
1322
+ RETURN;
1323
+ END IF;
1324
+
1325
+ -- Late callback guard: lock run + step rows and use current step status
1326
+ -- under lock so concurrent fail_task calls cannot read stale status.
1327
+ SELECT ss.status, r.flow_slug INTO v_prev_step_status, v_flow_slug
1328
+ FROM pgflow.runs r
1329
+ JOIN pgflow.step_states ss ON ss.run_id = r.run_id
1330
+ WHERE ss.run_id = fail_task.run_id
1331
+ AND ss.step_slug = fail_task.step_slug
1332
+ FOR UPDATE OF r, ss;
1333
+
1334
+ IF v_prev_step_status IS NOT NULL AND v_prev_step_status != 'started' THEN
1335
+ -- Archive the task message if present
1336
+ PERFORM pgmq.archive(v_flow_slug, ARRAY_AGG(st.message_id))
1337
+ FROM pgflow.step_tasks st
1338
+ WHERE st.run_id = fail_task.run_id
1339
+ AND st.step_slug = fail_task.step_slug
1340
+ AND st.task_index = fail_task.task_index
1341
+ AND st.message_id IS NOT NULL
1342
+ HAVING COUNT(st.message_id) > 0;
1343
+
1344
+ RETURN QUERY SELECT * FROM pgflow.step_tasks
1345
+ WHERE pgflow.step_tasks.run_id = fail_task.run_id
1346
+ AND pgflow.step_tasks.step_slug = fail_task.step_slug
1347
+ AND pgflow.step_tasks.task_index = fail_task.task_index;
1348
+ RETURN;
1349
+ END IF;
1350
+
1351
+ WITH flow_info AS (
1352
+ SELECT r.flow_slug
1353
+ FROM pgflow.runs r
1354
+ WHERE r.run_id = fail_task.run_id
1355
+ ),
1356
+ config AS (
1357
+ SELECT
1358
+ COALESCE(s.opt_max_attempts, f.opt_max_attempts) AS opt_max_attempts,
1359
+ COALESCE(s.opt_base_delay, f.opt_base_delay) AS opt_base_delay,
1360
+ s.when_exhausted
1361
+ FROM pgflow.steps s
1362
+ JOIN pgflow.flows f ON f.flow_slug = s.flow_slug
1363
+ JOIN flow_info fi ON fi.flow_slug = s.flow_slug
1364
+ WHERE s.flow_slug = fi.flow_slug AND s.step_slug = fail_task.step_slug
1365
+ ),
1366
+ fail_or_retry_task as (
1367
+ UPDATE pgflow.step_tasks as task
1368
+ SET
1369
+ status = CASE
1370
+ WHEN task.attempts_count < (SELECT opt_max_attempts FROM config) THEN 'queued'
1371
+ ELSE 'failed'
1372
+ END,
1373
+ failed_at = CASE
1374
+ WHEN task.attempts_count >= (SELECT opt_max_attempts FROM config) THEN now()
1375
+ ELSE NULL
1376
+ END,
1377
+ started_at = CASE
1378
+ WHEN task.attempts_count < (SELECT opt_max_attempts FROM config) THEN NULL
1379
+ ELSE task.started_at
1380
+ END,
1381
+ error_message = fail_task.error_message
1382
+ WHERE task.run_id = fail_task.run_id
1383
+ AND task.step_slug = fail_task.step_slug
1384
+ AND task.task_index = fail_task.task_index
1385
+ AND task.status = 'started'
1386
+ RETURNING *
1387
+ ),
1388
+ -- Determine if task exhausted retries and get when_exhausted mode
1389
+ task_status AS (
1390
+ SELECT
1391
+ (select status from fail_or_retry_task) AS new_task_status,
1392
+ (select when_exhausted from config) AS when_exhausted_mode,
1393
+ -- Task is exhausted when it's failed (no more retries)
1394
+ ((select status from fail_or_retry_task) = 'failed') AS is_exhausted
1395
+ ),
1396
+ maybe_fail_step AS (
1397
+ UPDATE pgflow.step_states
1398
+ SET
1399
+ -- Status logic:
1400
+ -- - If task not exhausted (retrying): keep current status
1401
+ -- - If exhausted AND when_exhausted='fail': set to 'failed'
1402
+ -- - If exhausted AND when_exhausted IN ('skip', 'skip-cascade'): set to 'skipped'
1403
+ status = CASE
1404
+ WHEN NOT (select is_exhausted from task_status) THEN pgflow.step_states.status
1405
+ WHEN (select when_exhausted_mode from task_status) = 'fail' THEN 'failed'
1406
+ ELSE 'skipped' -- skip or skip-cascade
1407
+ END,
1408
+ failed_at = CASE
1409
+ WHEN (select is_exhausted from task_status) AND (select when_exhausted_mode from task_status) = 'fail' THEN now()
1410
+ ELSE NULL
1411
+ END,
1412
+ error_message = CASE
1413
+ WHEN (select is_exhausted from task_status) THEN fail_task.error_message
1414
+ ELSE NULL
1415
+ END,
1416
+ skip_reason = CASE
1417
+ WHEN (select is_exhausted from task_status) AND (select when_exhausted_mode from task_status) IN ('skip', 'skip-cascade') THEN 'handler_failed'
1418
+ ELSE pgflow.step_states.skip_reason
1419
+ END,
1420
+ skipped_at = CASE
1421
+ WHEN (select is_exhausted from task_status) AND (select when_exhausted_mode from task_status) IN ('skip', 'skip-cascade') THEN now()
1422
+ ELSE pgflow.step_states.skipped_at
1423
+ END,
1424
+ -- Clear remaining_tasks when skipping (required by remaining_tasks_state_consistency constraint)
1425
+ remaining_tasks = CASE
1426
+ WHEN (select is_exhausted from task_status) AND (select when_exhausted_mode from task_status) IN ('skip', 'skip-cascade') THEN NULL
1427
+ ELSE pgflow.step_states.remaining_tasks
1428
+ END
1429
+ FROM fail_or_retry_task
1430
+ WHERE pgflow.step_states.run_id = fail_task.run_id
1431
+ AND pgflow.step_states.step_slug = fail_task.step_slug
1432
+ RETURNING pgflow.step_states.*
1433
+ ),
1434
+ run_update AS (
1435
+ -- Update run status: only fail when when_exhausted='fail' and step was failed
1436
+ UPDATE pgflow.runs
1437
+ SET status = CASE
1438
+ WHEN (select status from maybe_fail_step) = 'failed' THEN 'failed'
1439
+ ELSE status
1440
+ END,
1441
+ failed_at = CASE
1442
+ WHEN (select status from maybe_fail_step) = 'failed' THEN now()
1443
+ ELSE NULL
1444
+ END,
1445
+ -- Decrement remaining_steps only on FIRST transition to skipped
1446
+ -- (not when step was already skipped and a second task fails)
1447
+ -- Uses PL/pgSQL variable captured before CTE chain
1448
+ remaining_steps = CASE
1449
+ WHEN (select status from maybe_fail_step) = 'skipped'
1450
+ AND v_prev_step_status != 'skipped'
1451
+ THEN pgflow.runs.remaining_steps - 1
1452
+ ELSE pgflow.runs.remaining_steps
1453
+ END
1454
+ WHERE pgflow.runs.run_id = fail_task.run_id
1455
+ RETURNING pgflow.runs.status
1456
+ )
1457
+ SELECT
1458
+ COALESCE((SELECT status = 'failed' FROM run_update), false),
1459
+ COALESCE((SELECT status = 'failed' FROM maybe_fail_step), false),
1460
+ COALESCE((SELECT status = 'skipped' FROM maybe_fail_step), false),
1461
+ COALESCE((SELECT is_exhausted FROM task_status), false)
1462
+ INTO v_run_failed, v_step_failed, v_step_skipped, v_task_exhausted;
1463
+
1464
+ -- Capture when_exhausted mode for later skip handling
1465
+ SELECT s.when_exhausted INTO v_when_exhausted
1466
+ FROM pgflow.steps s
1467
+ JOIN pgflow.runs r ON r.flow_slug = s.flow_slug
1468
+ WHERE r.run_id = fail_task.run_id
1469
+ AND s.step_slug = fail_task.step_slug;
1470
+
1471
+ -- Send broadcast event for step failure if the step was failed
1472
+ IF v_task_exhausted AND v_step_failed THEN
1473
+ PERFORM realtime.send(
1474
+ jsonb_build_object(
1475
+ 'event_type', 'step:failed',
1476
+ 'run_id', fail_task.run_id,
1477
+ 'step_slug', fail_task.step_slug,
1478
+ 'status', 'failed',
1479
+ 'error_message', fail_task.error_message,
1480
+ 'failed_at', now()
1481
+ ),
1482
+ concat('step:', fail_task.step_slug, ':failed'),
1483
+ concat('pgflow:run:', fail_task.run_id),
1484
+ false
1485
+ );
1486
+ END IF;
1487
+
1488
+ -- Handle step skipping (when_exhausted = 'skip' or 'skip-cascade')
1489
+ IF v_task_exhausted AND v_step_skipped THEN
1490
+ -- Archive all queued/started sibling task messages for this step
1491
+ PERFORM pgmq.archive(r.flow_slug, ARRAY_AGG(st.message_id))
1492
+ FROM pgflow.step_tasks st
1493
+ JOIN pgflow.runs r ON st.run_id = r.run_id
1494
+ WHERE st.run_id = fail_task.run_id
1495
+ AND st.step_slug = fail_task.step_slug
1496
+ AND st.status IN ('queued', 'started')
1497
+ AND st.message_id IS NOT NULL
1498
+ GROUP BY r.flow_slug
1499
+ HAVING COUNT(st.message_id) > 0;
1500
+
1501
+ -- Send broadcast event for step skipped
1502
+ PERFORM realtime.send(
1503
+ jsonb_build_object(
1504
+ 'event_type', 'step:skipped',
1505
+ 'run_id', fail_task.run_id,
1506
+ 'step_slug', fail_task.step_slug,
1507
+ 'status', 'skipped',
1508
+ 'skip_reason', 'handler_failed',
1509
+ 'error_message', fail_task.error_message,
1510
+ 'skipped_at', now()
1511
+ ),
1512
+ concat('step:', fail_task.step_slug, ':skipped'),
1513
+ concat('pgflow:run:', fail_task.run_id),
1514
+ false
1515
+ );
1516
+
1517
+ -- For skip-cascade: cascade skip to all downstream dependents
1518
+ IF v_when_exhausted = 'skip-cascade' THEN
1519
+ PERFORM pgflow._cascade_force_skip_steps(fail_task.run_id, fail_task.step_slug, 'handler_failed');
1520
+ ELSE
1521
+ -- For plain 'skip': decrement remaining_deps on dependent steps
1522
+ -- (This mirrors the pattern in cascade_resolve_conditions.sql for when_unmet='skip')
1523
+ SELECT flow_slug INTO v_flow_slug_for_deps
1524
+ FROM pgflow.runs
1525
+ WHERE pgflow.runs.run_id = fail_task.run_id;
1526
+
1527
+ UPDATE pgflow.step_states AS child_state
1528
+ SET remaining_deps = child_state.remaining_deps - 1,
1529
+ -- If child is a map step and this skipped step is its only dependency,
1530
+ -- set initial_tasks = 0 (skipped dep = empty array)
1531
+ initial_tasks = CASE
1532
+ WHEN child_step.step_type = 'map' AND child_step.deps_count = 1 THEN 0
1533
+ ELSE child_state.initial_tasks
1534
+ END
1535
+ FROM pgflow.deps AS dep
1536
+ JOIN pgflow.steps AS child_step ON child_step.flow_slug = dep.flow_slug AND child_step.step_slug = dep.step_slug
1537
+ WHERE child_state.run_id = fail_task.run_id
1538
+ AND dep.flow_slug = v_flow_slug_for_deps
1539
+ AND dep.dep_slug = fail_task.step_slug
1540
+ AND child_state.step_slug = dep.step_slug;
1541
+
1542
+ -- Evaluate conditions on newly-ready dependent steps
1543
+ -- This must happen before cascade_complete_taskless_steps so that
1544
+ -- skipped steps can set initial_tasks=0 for their map dependents
1545
+ IF NOT pgflow.cascade_resolve_conditions(fail_task.run_id) THEN
1546
+ -- Run was failed due to a condition with when_unmet='fail'
1547
+ -- Archive the failed task's message before returning
1548
+ PERFORM pgflow._archive_task_message(fail_task.run_id, fail_task.step_slug, fail_task.task_index);
1549
+ -- Return the task row (API contract)
1550
+ RETURN QUERY SELECT * FROM pgflow.step_tasks
1551
+ WHERE pgflow.step_tasks.run_id = fail_task.run_id
1552
+ AND pgflow.step_tasks.step_slug = fail_task.step_slug
1553
+ AND pgflow.step_tasks.task_index = fail_task.task_index;
1554
+ RETURN;
1555
+ END IF;
1556
+
1557
+ -- Auto-complete taskless steps (e.g., map steps with initial_tasks=0 from skipped dep)
1558
+ PERFORM pgflow.cascade_complete_taskless_steps(fail_task.run_id);
1559
+
1560
+ -- Start steps that became ready after condition resolution and taskless completion
1561
+ PERFORM pgflow.start_ready_steps(fail_task.run_id);
1562
+ END IF;
1563
+
1564
+ -- Try to complete the run (remaining_steps may now be 0)
1565
+ PERFORM pgflow.maybe_complete_run(fail_task.run_id);
1566
+ END IF;
1567
+
1568
+ -- Send broadcast event for run failure if the run was failed
1569
+ IF v_run_failed THEN
1570
+ DECLARE
1571
+ v_flow_slug text;
1572
+ BEGIN
1573
+ SELECT flow_slug INTO v_flow_slug FROM pgflow.runs WHERE pgflow.runs.run_id = fail_task.run_id;
1574
+
1575
+ PERFORM realtime.send(
1576
+ jsonb_build_object(
1577
+ 'event_type', 'run:failed',
1578
+ 'run_id', fail_task.run_id,
1579
+ 'flow_slug', v_flow_slug,
1580
+ 'status', 'failed',
1581
+ 'error_message', fail_task.error_message,
1582
+ 'failed_at', now()
1583
+ ),
1584
+ 'run:failed',
1585
+ concat('pgflow:run:', fail_task.run_id),
1586
+ false
1587
+ );
1588
+ END;
1589
+ END IF;
1590
+
1591
+ -- Archive all active messages (both queued and started) when run fails
1592
+ IF v_run_failed THEN
1593
+ PERFORM pgmq.archive(r.flow_slug, ARRAY_AGG(st.message_id))
1594
+ FROM pgflow.step_tasks st
1595
+ JOIN pgflow.runs r ON st.run_id = r.run_id
1596
+ WHERE st.run_id = fail_task.run_id
1597
+ AND st.status IN ('queued', 'started')
1598
+ AND st.message_id IS NOT NULL
1599
+ GROUP BY r.flow_slug
1600
+ HAVING COUNT(st.message_id) > 0;
1601
+ END IF;
1602
+
1603
+ -- For queued tasks: delay the message for retry with exponential backoff
1604
+ PERFORM (
1605
+ WITH retry_config AS (
1606
+ SELECT
1607
+ COALESCE(s.opt_base_delay, f.opt_base_delay) AS base_delay
1608
+ FROM pgflow.steps s
1609
+ JOIN pgflow.flows f ON f.flow_slug = s.flow_slug
1610
+ JOIN pgflow.runs r ON r.flow_slug = f.flow_slug
1611
+ WHERE r.run_id = fail_task.run_id
1612
+ AND s.step_slug = fail_task.step_slug
1613
+ ),
1614
+ queued_tasks AS (
1615
+ SELECT
1616
+ r.flow_slug,
1617
+ st.message_id,
1618
+ pgflow.calculate_retry_delay((SELECT base_delay FROM retry_config), st.attempts_count) AS calculated_delay
1619
+ FROM pgflow.step_tasks st
1620
+ JOIN pgflow.runs r ON st.run_id = r.run_id
1621
+ WHERE st.run_id = fail_task.run_id
1622
+ AND st.step_slug = fail_task.step_slug
1623
+ AND st.task_index = fail_task.task_index
1624
+ AND st.status = 'queued'
1625
+ )
1626
+ SELECT pgmq.set_vt(qt.flow_slug, qt.message_id, qt.calculated_delay)
1627
+ FROM queued_tasks qt
1628
+ WHERE EXISTS (SELECT 1 FROM queued_tasks)
1629
+ );
1630
+
1631
+ -- For failed tasks: archive the message
1632
+ PERFORM pgmq.archive(r.flow_slug, ARRAY_AGG(st.message_id))
1633
+ FROM pgflow.step_tasks st
1634
+ JOIN pgflow.runs r ON st.run_id = r.run_id
1635
+ WHERE st.run_id = fail_task.run_id
1636
+ AND st.step_slug = fail_task.step_slug
1637
+ AND st.task_index = fail_task.task_index
1638
+ AND st.status = 'failed'
1639
+ AND st.message_id IS NOT NULL
1640
+ GROUP BY r.flow_slug
1641
+ HAVING COUNT(st.message_id) > 0;
1642
+
1643
+ return query select *
1644
+ from pgflow.step_tasks st
1645
+ where st.run_id = fail_task.run_id
1646
+ and st.step_slug = fail_task.step_slug
1647
+ and st.task_index = fail_task.task_index;
1648
+
1649
+ end;
1650
+ $$;
1651
+ -- Modify "start_flow" function
1652
+ CREATE OR REPLACE FUNCTION "pgflow"."start_flow" ("flow_slug" text, "input" jsonb, "run_id" uuid DEFAULT NULL::uuid) RETURNS SETOF "pgflow"."runs" LANGUAGE plpgsql SET "search_path" = '' AS $$
1653
+ declare
1654
+ v_created_run pgflow.runs%ROWTYPE;
1655
+ v_root_map_count int;
1656
+ begin
1657
+
1658
+ -- ==========================================
1659
+ -- VALIDATION: Root map array input
1660
+ -- ==========================================
1661
+ WITH root_maps AS (
1662
+ SELECT step_slug
1663
+ FROM pgflow.steps
1664
+ WHERE steps.flow_slug = start_flow.flow_slug
1665
+ AND steps.step_type = 'map'
1666
+ AND steps.deps_count = 0
1667
+ )
1668
+ SELECT COUNT(*) INTO v_root_map_count FROM root_maps;
1669
+
1670
+ -- If we have root map steps, validate that input is an array
1671
+ IF v_root_map_count > 0 THEN
1672
+ -- First check for NULL (should be caught by NOT NULL constraint, but be defensive)
1673
+ IF start_flow.input IS NULL THEN
1674
+ RAISE EXCEPTION 'Flow % has root map steps but input is NULL', start_flow.flow_slug;
1675
+ END IF;
1676
+
1677
+ -- Then check if it's not an array
1678
+ IF jsonb_typeof(start_flow.input) != 'array' THEN
1679
+ RAISE EXCEPTION 'Flow % has root map steps but input is not an array (got %)',
1680
+ start_flow.flow_slug, jsonb_typeof(start_flow.input);
1681
+ END IF;
1682
+ END IF;
1683
+
1684
+ -- ==========================================
1685
+ -- MAIN CTE CHAIN: Create run and step states
1686
+ -- ==========================================
1687
+ WITH
1688
+ -- ---------- Gather flow metadata ----------
1689
+ flow_steps AS (
1690
+ SELECT steps.flow_slug, steps.step_slug, steps.step_type, steps.deps_count
1691
+ FROM pgflow.steps
1692
+ WHERE steps.flow_slug = start_flow.flow_slug
1693
+ ),
1694
+ -- ---------- Create run record ----------
1695
+ created_run AS (
1696
+ INSERT INTO pgflow.runs (run_id, flow_slug, input, remaining_steps)
1697
+ VALUES (
1698
+ COALESCE(start_flow.run_id, gen_random_uuid()),
1699
+ start_flow.flow_slug,
1700
+ start_flow.input,
1701
+ (SELECT count(*) FROM flow_steps)
1702
+ )
1703
+ RETURNING *
1704
+ ),
1705
+ -- ---------- Create step states ----------
1706
+ -- Sets initial_tasks: known for root maps, NULL for dependent maps
1707
+ created_step_states AS (
1708
+ INSERT INTO pgflow.step_states (flow_slug, run_id, step_slug, remaining_deps, initial_tasks)
1709
+ SELECT
1710
+ fs.flow_slug,
1711
+ (SELECT created_run.run_id FROM created_run),
1712
+ fs.step_slug,
1713
+ fs.deps_count,
1714
+ -- Updated logic for initial_tasks:
1715
+ CASE
1716
+ WHEN fs.step_type = 'map' AND fs.deps_count = 0 THEN
1717
+ -- Root map: get array length from input
1718
+ CASE
1719
+ WHEN jsonb_typeof(start_flow.input) = 'array' THEN
1720
+ jsonb_array_length(start_flow.input)
1721
+ ELSE
1722
+ 1
1723
+ END
1724
+ WHEN fs.step_type = 'map' AND fs.deps_count > 0 THEN
1725
+ -- Dependent map: unknown until dependencies complete
1726
+ NULL
1727
+ ELSE
1728
+ -- Single steps: always 1 task
1729
+ 1
1730
+ END
1731
+ FROM flow_steps fs
1732
+ )
1733
+ SELECT * FROM created_run INTO v_created_run;
1734
+
1735
+ -- ==========================================
1736
+ -- POST-CREATION ACTIONS
1737
+ -- ==========================================
1738
+
1739
+ -- ---------- Broadcast run:started event ----------
1740
+ PERFORM realtime.send(
1741
+ jsonb_build_object(
1742
+ 'event_type', 'run:started',
1743
+ 'run_id', v_created_run.run_id,
1744
+ 'flow_slug', v_created_run.flow_slug,
1745
+ 'input', v_created_run.input,
1746
+ 'status', 'started',
1747
+ 'remaining_steps', v_created_run.remaining_steps,
1748
+ 'started_at', v_created_run.started_at
1749
+ ),
1750
+ 'run:started',
1751
+ concat('pgflow:run:', v_created_run.run_id),
1752
+ false
1753
+ );
1754
+
1755
+ -- ---------- Evaluate conditions on ready steps ----------
1756
+ -- Skip steps with unmet conditions, propagate to dependents
1757
+ IF NOT pgflow.cascade_resolve_conditions(v_created_run.run_id) THEN
1758
+ -- Run was failed due to a condition with when_unmet='fail'
1759
+ RETURN QUERY SELECT * FROM pgflow.runs where pgflow.runs.run_id = v_created_run.run_id;
1760
+ RETURN;
1761
+ END IF;
1762
+
1763
+ -- ---------- Complete taskless steps ----------
1764
+ -- Handle empty array maps that should auto-complete
1765
+ PERFORM pgflow.cascade_complete_taskless_steps(v_created_run.run_id);
1766
+
1767
+ -- ---------- Start initial steps ----------
1768
+ -- Start root steps (those with no dependencies)
1769
+ PERFORM pgflow.start_ready_steps(v_created_run.run_id);
1770
+
1771
+ -- ---------- Check for run completion ----------
1772
+ -- If cascade completed all steps (zero-task flows), finalize the run
1773
+ PERFORM pgflow.maybe_complete_run(v_created_run.run_id);
1774
+
1775
+ RETURN QUERY SELECT * FROM pgflow.runs where pgflow.runs.run_id = v_created_run.run_id;
1776
+
1777
+ end;
1778
+ $$;
1779
+ -- Modify "start_tasks" function
1780
+ CREATE OR REPLACE FUNCTION "pgflow"."start_tasks" ("flow_slug" text, "msg_ids" bigint[], "worker_id" uuid) RETURNS SETOF "pgflow"."step_task_record" LANGUAGE sql SET "search_path" = '' AS $$
1781
+ with tasks as (
1782
+ select
1783
+ task.flow_slug,
1784
+ task.run_id,
1785
+ task.step_slug,
1786
+ task.task_index,
1787
+ task.message_id
1788
+ from pgflow.step_tasks as task
1789
+ join pgflow.runs r on r.run_id = task.run_id
1790
+ where task.flow_slug = start_tasks.flow_slug
1791
+ and task.message_id = any(msg_ids)
1792
+ and task.status = 'queued'
1793
+ and r.status = 'started'
1794
+ and exists (
1795
+ select 1
1796
+ from pgflow.step_states ss
1797
+ where ss.run_id = task.run_id
1798
+ and ss.step_slug = task.step_slug
1799
+ and ss.status = 'started'
1800
+ )
1801
+ ),
1802
+ start_tasks_update as (
1803
+ update pgflow.step_tasks
1804
+ set
1805
+ attempts_count = attempts_count + 1,
1806
+ status = 'started',
1807
+ started_at = now(),
1808
+ last_worker_id = worker_id
1809
+ from tasks
1810
+ where step_tasks.message_id = tasks.message_id
1811
+ and step_tasks.flow_slug = tasks.flow_slug
1812
+ and step_tasks.status = 'queued'
1813
+ ),
1814
+ runs as (
1815
+ select
1816
+ r.run_id,
1817
+ r.input
1818
+ from pgflow.runs r
1819
+ where r.run_id in (select run_id from tasks)
1820
+ ),
1821
+ deps as (
1822
+ select
1823
+ st.run_id,
1824
+ st.step_slug,
1825
+ dep.dep_slug,
1826
+ -- Read output directly from step_states (already aggregated by writers)
1827
+ dep_state.output as dep_output
1828
+ from tasks st
1829
+ join pgflow.deps dep on dep.flow_slug = st.flow_slug and dep.step_slug = st.step_slug
1830
+ join pgflow.step_states dep_state on
1831
+ dep_state.run_id = st.run_id and
1832
+ dep_state.step_slug = dep.dep_slug and
1833
+ dep_state.status = 'completed' -- Only include completed deps (not skipped)
1834
+ ),
1835
+ deps_outputs as (
1836
+ select
1837
+ d.run_id,
1838
+ d.step_slug,
1839
+ jsonb_object_agg(d.dep_slug, d.dep_output) as deps_output,
1840
+ count(*) as dep_count
1841
+ from deps d
1842
+ group by d.run_id, d.step_slug
1843
+ ),
1844
+ timeouts as (
1845
+ select
1846
+ task.message_id,
1847
+ task.flow_slug,
1848
+ coalesce(step.opt_timeout, flow.opt_timeout) + 2 as vt_delay
1849
+ from tasks task
1850
+ join pgflow.flows flow on flow.flow_slug = task.flow_slug
1851
+ join pgflow.steps step on step.flow_slug = task.flow_slug and step.step_slug = task.step_slug
1852
+ ),
1853
+ -- Batch update visibility timeouts for all messages
1854
+ set_vt_batch as (
1855
+ select pgflow.set_vt_batch(
1856
+ start_tasks.flow_slug,
1857
+ array_agg(t.message_id order by t.message_id),
1858
+ array_agg(t.vt_delay order by t.message_id)
1859
+ )
1860
+ from timeouts t
1861
+ )
1862
+ select
1863
+ st.flow_slug,
1864
+ st.run_id,
1865
+ st.step_slug,
1866
+ -- ==========================================
1867
+ -- INPUT CONSTRUCTION LOGIC
1868
+ -- ==========================================
1869
+ -- This nested CASE statement determines how to construct the input
1870
+ -- for each task based on the step type (map vs non-map).
1871
+ --
1872
+ -- The fundamental difference:
1873
+ -- - Map steps: Receive RAW array elements (e.g., just 42 or "hello")
1874
+ -- - Non-map steps: Receive structured objects with named keys
1875
+ -- (e.g., {"run": {...}, "dependency1": {...}})
1876
+ -- ==========================================
1877
+ CASE
1878
+ -- -------------------- MAP STEPS --------------------
1879
+ -- Map steps process arrays element-by-element.
1880
+ -- Each task receives ONE element from the array at its task_index position.
1881
+ WHEN step.step_type = 'map' THEN
1882
+ -- Map steps get raw array elements without any wrapper object
1883
+ CASE
1884
+ -- ROOT MAP: Gets array from run input
1885
+ -- Example: run input = [1, 2, 3]
1886
+ -- task 0 gets: 1
1887
+ -- task 1 gets: 2
1888
+ -- task 2 gets: 3
1889
+ WHEN step.deps_count = 0 THEN
1890
+ -- Root map (deps_count = 0): no dependencies, reads from run input.
1891
+ -- Extract the element at task_index from the run's input array.
1892
+ -- Note: If run input is not an array, this will return NULL
1893
+ -- and the flow will fail (validated in start_flow).
1894
+ jsonb_array_element(r.input, st.task_index)
1895
+
1896
+ -- DEPENDENT MAP: Gets array from its single dependency
1897
+ -- Example: dependency output = ["a", "b", "c"]
1898
+ -- task 0 gets: "a"
1899
+ -- task 1 gets: "b"
1900
+ -- task 2 gets: "c"
1901
+ ELSE
1902
+ -- Has dependencies (should be exactly 1 for map steps).
1903
+ -- Extract the element at task_index from the dependency's output array.
1904
+ --
1905
+ -- Why the subquery with jsonb_each?
1906
+ -- - The dependency outputs a raw array: [1, 2, 3]
1907
+ -- - deps_outputs aggregates it into: {"dep_name": [1, 2, 3]}
1908
+ -- - We need to unwrap and get just the array value
1909
+ -- - Map steps have exactly 1 dependency (enforced by add_step)
1910
+ -- - So jsonb_each will return exactly 1 row
1911
+ -- - We extract the 'value' which is the raw array [1, 2, 3]
1912
+ -- - Then get the element at task_index from that array
1913
+ (SELECT jsonb_array_element(value, st.task_index)
1914
+ FROM jsonb_each(dep_out.deps_output)
1915
+ LIMIT 1)
1916
+ END
1917
+
1918
+ -- -------------------- NON-MAP STEPS --------------------
1919
+ -- Regular (non-map) steps receive dependency outputs as a structured object.
1920
+ -- Root steps (no dependencies) get empty object - they access flowInput via context.
1921
+ -- Dependent steps get only their dependency outputs.
1922
+ ELSE
1923
+ -- Non-map steps get structured input with dependency keys only
1924
+ -- Example for dependent step: {
1925
+ -- "step1": {"output": "from_step1"},
1926
+ -- "step2": {"output": "from_step2"}
1927
+ -- }
1928
+ -- Example for root step: {}
1929
+ --
1930
+ -- Note: flow_input is available separately in the returned record
1931
+ -- for workers to access via context.flowInput
1932
+ coalesce(dep_out.deps_output, '{}'::jsonb)
1933
+ END as input,
1934
+ st.message_id as msg_id,
1935
+ st.task_index as task_index,
1936
+ -- flow_input: Original run input for worker context
1937
+ -- Only included for root non-map steps to avoid data duplication.
1938
+ -- Root map steps: flowInput IS the array, useless to include
1939
+ -- Dependent steps: lazy load via ctx.flowInput when needed
1940
+ CASE
1941
+ WHEN step.step_type != 'map' AND step.deps_count = 0
1942
+ THEN r.input
1943
+ ELSE NULL
1944
+ END as flow_input
1945
+ from tasks st
1946
+ join runs r on st.run_id = r.run_id
1947
+ join pgflow.steps step on
1948
+ step.flow_slug = st.flow_slug and
1949
+ step.step_slug = st.step_slug
1950
+ left join deps_outputs dep_out on
1951
+ dep_out.run_id = st.run_id and
1952
+ dep_out.step_slug = st.step_slug
1953
+ $$;
1954
+ -- Drop "add_step" function
1955
+ DROP FUNCTION "pgflow"."add_step" (text, text, text[], integer, integer, integer, integer, text);