Skip to content

fix(workflows): pin a stored block retry policy when loading it - #6614

Merged
waleedlatif1 merged 1 commit into
stagingfrom
fix/w1-block-retry-normalization
Aug 12, 2026
Merged

fix(workflows): pin a stored block retry policy when loading it#6614
waleedlatif1 merged 1 commit into
stagingfrom
fix/w1-block-retry-normalization

Conversation

@waleedlatif1

@waleedlatif1waleedlatif1 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

A single out-of-range value in workflow_blocks.retry makes GET /api/workflows/[id] return 500 forever, bricking the workflow in the editor with no in-product repair. This makes the read tolerant while leaving the write strict.

retry is bare jsonb typed as unknown, and load.ts asserted the stored blob was already a BlockRetryConfig:

retry: (block.retryasBlockState['retry'])??undefined,

It then handed that straight to the HTTP boundary, where workflowBlockStateSchema bounds maxTries to 2..5 and waitBetweenTriesMs to 0..5000. That schema is shared between the PUT /state body — where the bound is right — and the GET /api/workflows/[id] and /state responses, where it is fatal. The response .parse in the shared route builder throws a ZodError, which is not an OrchestrationError, so the error policy declines it and it falls through to a 500. Every UI write path reads the workflow first, so there is no way to fix the row from inside the product.

The fix constructs a real BlockRetryConfig from the blob using the feature's own normalizeBlockRetryTries / normalizeBlockRetryWaitMs, filling defaults for missing fields and carrying enabled across unchanged.

This lands ahead of the data. The retry column itself is brand new — migration 0288_workflow_blocks_retry landed two days ago in #6458 — so this is a pre-emptive guard arriving essentially alongside the column, not a repair of existing rows. That is the ideal time to add it: once a row goes out of range, the workflow is unopenable and there is no in-product way to fix it.

Why the read is the right seam

loadWorkflowFromNormalizedTablesRaw is the single read choke point for both apps — @sim/workflow-persistence for the Next app and the realtime server's full-state emit — so one edit covers every reader, and a normalized row self-heals on the next save. It matches clampParallelBatchSize a few lines below, which already pins a stored subflow value on the same path.

The feature already declares clamp-on-read as its contract: the TSDoc on resolveBlockRetryConfig says bounds are clamped on read rather than rejected, and block-retry.test.ts asserts it. Execution has always honoured that. Only the read boundary disagreed.

Six writers persist this column without bounding it:

  • workflow:batch-add-blocks, workflow:replace-state, and workflow:update-block-retry on the realtime server — the first two take untyped block records
  • v1/admin/workflows/import, v1/admin/workspaces/[id]/import, and superuser/import-workflow, which persist externally-authored workflow JSON server-side

"Doesn't this silently change a value the user configured?"

No — it makes the UI stop lying. The executor already clamps: resolveBlockRetryConfig pins the same bounds before a retry runs. Before this change, a block storing maxTries: 999 displayed 999 in the editor while execution ran 5. Now the editor shows 5, which is what actually happens. The displayed value and the executed value agree for the first time.

enabled is carried across rather than resolved, so the numbers a builder configured survive switching retry off and back on.

Alternatives rejected

  • Validating on write. Leaves every existing bad row fatal forever, and would have to be repeated across three realtime ops plus roughly a dozen saveWorkflowToNormalizedTables callers, none of which share a validation seam.
  • Bounding BlockRetrySchema in @sim/realtime-protocol. Its own TSDoc is correct that batch-add and replace-state bypass it, so this closes one writer and leaves the 500.
  • Relaxing the response contract. Stops the 500 but leaves the editor rendering a number execution will never run. A contract test now pins the write bound so this shortcut fails loudly.
  • Reusing resolveBlockRetryConfig. It returns null for a disabled policy, so it would erase the numbers a builder configured on every read. A dedicated test pins the opposite behavior.

Type of Change

  • Bug fix

Testing

Six cases in packages/workflow-persistence/src/load.test.ts, four of which are red without the load.ts change (verified by reverting the file and re-running):

  • out-of-range enabled policy pinned to the bounds
  • out-of-range disabled policy pinned without being discarded — the case that would let a resolveBlockRetryConfig-based patch ship green and be wrong
  • missing fields filled with defaults
  • non-boolean enabled resolved the way execution reads it
  • in-range policy left untouched
  • NULL reported as no policy (block runs once)

Plus a contract test in apps/sim/lib/api/contracts/workflows.test.ts that the write bound still rejects out-of-range input.

bun run type-check clean in apps/sim and packages/workflow-persistence; bun run check:api-validation passes; biome clean.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

`workflow_blocks.retry` is a jsonb column written verbatim. Three of its
writers never validate what they store: the realtime batch-add and
replace-state ops take untyped block records, and the admin/superuser
import routes persist externally-authored workflow JSON. `load.ts` then
asserted the blob was already a `BlockRetryConfig` and handed it straight
to the HTTP boundary, where `workflowBlockStateSchema` bounds `maxTries`
to 2..5 and `waitBetweenTriesMs` to 0..5000.
That schema is shared between the PUT `/state` body, where the bound is
right, and the GET `/api/workflows/[id]` and `/state` responses, where it
is fatal. The response `.parse` in the shared route builder throws a
ZodError, which is not an `OrchestrationError`, so the error policy
declines it and it falls through to a 500. One out-of-range or partial
stored value therefore made a workflow permanently unopenable, with no
in-product repair — every UI write path reads the workflow first.
The feature already declares clamp-on-read as its contract: the commit
that added it says bounds are clamped on read rather than rejected, the
TSDoc on `resolveBlockRetryConfig` says the same, and `block-retry.test.ts`
asserts it. Execution has always honoured that. Only the read boundary
disagreed, so that is what this fixes: the loader now constructs a real
`BlockRetryConfig` from the blob through `normalizeBlockRetryTries` /
`normalizeBlockRetryWaitMs`, filling defaults for missing fields and
carrying `enabled` across unchanged.
`loadWorkflowFromNormalizedTablesRaw` is the single read choke point for
both apps — `@sim/workflow-persistence` for the Next app and the realtime
server's full-state emit — so one edit repairs every reader, including
rows that are already out of range, and the row self-heals on the next
save. It matches `clampParallelBatchSize` a few lines below, which already
pins a stored subflow value on the same path.
Alternatives rejected:
- Validating on write. It leaves every existing bad row fatal forever, and
it would have to be repeated across three realtime ops plus roughly a
dozen `saveWorkflowToNormalizedTables` callers, none of which share a
validation seam.
- Bounding `BlockRetrySchema` in `@sim/realtime-protocol`. Its own TSDoc is
correct that batch-add and replace-state bypass it, so this closes one
writer and leaves the 500.
- Relaxing the response contract. It stops the 500 but leaves the editor
rendering a number execution will never run. A test now pins the write
bound so that shortcut fails loudly.
- `resolveBlockRetryConfig`. It returns null for a disabled policy, which
would erase the numbers a builder configured every time state is read.
Tests: six cases in `packages/workflow-persistence/src/load.test.ts` (four
red before this change) covering out-of-range enabled, out-of-range
disabled with `enabled` preserved, missing fields, a non-boolean flag, an
untouched in-range policy, and NULL meaning "runs once"; plus a contract
test that the write bound still rejects out-of-range input.
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
docsSkippedSkippedAug 12, 2026 8:35am

Request Review

@cursor

cursorBot commented Aug 12, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches the shared workflow load path used by the editor and realtime readers, and silently remaps stored retry values on read. Behavior matches existing execution clamps, and the change is well covered by new tests.

Overview
Prevents GET /api/workflows/[id] from 500ing when a stored block retry jsonb value is out of range. The write contract stays strict; the read path now clamps.

In loadWorkflowFromNormalizedTablesRaw, replaces an unsafe cast of the raw retry blob with normalizeStoredBlockRetry, which rebuilds a real BlockRetryConfig via normalizeBlockRetryTries / normalizeBlockRetryWaitMs. Out-of-range values are pinned, missing fields get defaults, and enabled is preserved so disabled policies keep their numbers.

Adds load-path coverage for out-of-range, disabled, incomplete, and null policies, plus a contract test that the write schema still rejects unbounded retry input.

Reviewed by Cursor Bugbot for commit bfcec6e. Configure here.

@greptile-apps

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR normalizes retry policies loaded from normalized workflow storage so malformed or out-of-range persisted values satisfy the strict API response contract and match executor behavior.

  • Reconstructs stored retry policies with bounded retry counts and wait durations.
  • Preserves disabled policies while filling missing values with defaults.
  • Adds loader coverage for malformed, missing, disabled, valid, and null policies.
  • Adds contract coverage confirming write-time bounds remain strict.

Confidence Score: 5/5

The PR appears safe to merge with no actionable correctness, security, or repository-rule issues identified.

The loader now produces schema-valid retry configurations using the executor’s established normalization helpers, preserves disabled settings, and retains strict validation at the write boundary.

Important Files Changed

FilenameOverview
packages/workflow-persistence/src/load.tsAdds centralized read-time retry-policy normalization using the same numeric bounds and coercion semantics as execution.
packages/workflow-persistence/src/load.test.tsCovers bounded, disabled, incomplete, non-boolean, valid, and absent persisted retry policies.
apps/sim/lib/api/contracts/workflows.test.tsVerifies that read tolerance does not weaken the workflow-state write contract.

Reviews (1): Last reviewed commit: "fix(workflows): pin a stored block retry..." | Re-trigger Greptile

@waleedlatif1
waleedlatif1 merged commit 8f85ded into stagingAug 12, 2026
31 checks passed
@waleedlatif1
waleedlatif1 deleted the fix/w1-block-retry-normalization branch August 12, 2026 08:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@waleedlatif1