Skip to content

fix(workflow-executor): preserve AI suggestion in pending data#1586

Open
Tonours wants to merge 5 commits into
feat/prd-214-server-step-mapperfrom
fix/workflow-executor-preserve-ai-suggestion-in-pending-data
Open

fix(workflow-executor): preserve AI suggestion in pending data#1586
Tonours wants to merge 5 commits into
feat/prd-214-server-step-mapperfrom
fix/workflow-executor-preserve-ai-suggestion-in-pending-data

Conversation

@Tonours
Copy link
Copy Markdown
Member

@Tonours Tonours commented May 19, 2026

Summary

  • Keeps pendingData immutable so the AI proposal can be diffed against the user's final choice. User overrides now land in a sibling userConfirmation: Record<string, unknown> populated by patchAndReloadPendingData from the validated PATCH body.
  • update-record, trigger-action and load-related-record executors read user-confirmed values (value, actionResult, name/displayName/selectedRecordId) from userConfirmation with fallback to the AI suggestion in pendingData.
  • userConfirmation is replaced last-write-wins (no spread-merge) to avoid stale keys bleeding across sequential PATCHes.

Test plan

  • yarn workspace @forestadmin/workflow-executor build
  • yarn workspace @forestadmin/workflow-executor test (726 tests pass)
  • yarn workspace @forestadmin/workflow-executor lint
  • Regression tests added: AI suggestion preserved with user-override of value / selectedRecordId / relation name+displayName, accept-via-PATCH without override (fallback), rejection via PATCH with userConfirmation set.

@qltysh
Copy link
Copy Markdown

qltysh Bot commented May 19, 2026

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on feat/prd-214-server-step-mapper by 0.01%.

Modified Files with Diff Coverage (5)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
...ow-executor/src/executors/load-related-record-step-executor.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/http/pending-data-validators.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/executors/base-step-executor.ts100.0%
Coverage rating: A Coverage rating: A
...workflow-executor/src/executors/update-record-step-executor.ts100.0%
Coverage rating: A Coverage rating: A
...-executor/src/executors/trigger-record-action-step-executor.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

@Tonours Tonours force-pushed the fix/workflow-executor-preserve-ai-suggestion-in-pending-data branch from c797768 to 7966b7f Compare May 19, 2026 19:22
Comment thread packages/workflow-executor/src/executors/load-related-record-step-executor.ts Outdated
@Tonours Tonours force-pushed the fix/workflow-executor-preserve-ai-suggestion-in-pending-data branch from 7966b7f to d58877a Compare May 19, 2026 19:30
Comment thread packages/workflow-executor/src/executors/base-step-executor.ts
Comment on lines +151 to +153
const isString = (v: unknown): v is string => typeof v === 'string';
const isRecordId = (v: unknown): v is Array<string | number> =>
Array.isArray(v) && v.every(e => typeof e === 'string' || typeof e === 'number');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Data are already validated by zod when we receive the http request. I think we don't need to validate two times

// Re-derive relatedCollectionName from schema using the (possibly updated) relation name.
// `name` is always a fieldName (set from field.fieldName in buildTarget) — search directly.
const name = isString(userConfirmation?.name) ? userConfirmation.name : pendingData.name;
const displayName = isString(userConfirmation?.displayName)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

displayName seems fully derivable from the schema once we have name, so could we remove it from the HTTP payload entirely? That would avoid having to keep name / displayName in sync and reduce the risk of persisting an inconsistent state.

More generally, it feels like the invariant here should be:

changing only selectedRecordId while keeping the same name is valid,
but if name changes, then selectedRecordId should also be required,
and displayName should be recomputed from the schema rather than trusted from the client.
So maybe we should tighten this in two places:

in the Zod schema, remove displayName and require selectedRecordId whenever name is provided,
in the executor, use name as the source of truth and recompute displayName from the resolved field/schema.

Copy link
Copy Markdown
Member

@Scra3 Scra3 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two blocking type-safety issues need to be addressed before merge.

  • WithUserConfirmation.userConfirmation is typed as Record<string, unknown>, erasing the per-step shape that Zod already validated — executors end up re-implementing validation with runtime guards.
  • McpStepExecutionData (line 106 in step-execution-data.ts) is the only executor that calls patchAndReloadPendingData but does not extend WithUserConfirmation — the userConfirmation field will be set at runtime but is invisible to TypeScript, breaking the promise of the PR.

Fix: export one inferred type per Zod schema in pending-data-validators.ts (e.g. export type LoadRelatedRecordConfirmation = z.infer<typeof loadRelatedRecordPatchSchema>) and use those precise types in the interfaces. The runtime guards in resolveFromSelection can then be dropped.

// Parsed PATCH body kept beside `pendingData` so executors can read the user's
// final input without overwriting the AI suggestion.
export interface WithUserConfirmation {
userConfirmation?: Record<string, unknown>;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): Record<string, unknown> erases the per-step shape that Zod already validated — executors must then add runtime type guards for fields the schema already knows. Export one inferred type per schema in pending-data-validators.ts and use them here per step instead of this catch-all.

);
}

// Keeps `pendingData` immutable; mirrors `userConfirmed` only because
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "immutable" is misleading — userConfirmed is written back into pendingData four lines below. The intent is that the original AI suggestion is preserved (not overwritten by a full merge), which is different from immutability.

}

const { name, displayName, selectedRecordId } = pendingData;
const isString = (v: unknown): v is string => typeof v === 'string';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (non-blocking): these guards exist only because userConfirmation is typed as Record<string, unknown> — Zod already validated that name is a string and selectedRecordId is Array<string | number>. Fix the WithUserConfirmation typing and both guards can be dropped in favour of direct property access.

// Re-derive relatedCollectionName from schema using the (possibly updated) relation name.
// `name` is always a fieldName (set from field.fieldName in buildTarget) — search directly.
const name = isString(userConfirmation?.name) ? userConfirmation.name : pendingData.name;
const displayName = isString(userConfirmation?.displayName)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: displayName can be re-derived from the schema once name is resolved (field.displayName), so there is no need to accept it from the PATCH body — trusting client-provided display names risks surfacing stale or inconsistent data.

… and re-derive displayName from schema

- Export named Zod schemas and inferred types from pending-data-validators (UpdateRecordConfirmation, TriggerActionConfirmation, McpConfirmation, LoadRelatedRecordConfirmation)
- Make WithUserConfirmation generic so each interface carries the exact confirmation shape instead of Record<string, unknown>
- Add WithUserConfirmation<McpConfirmation> to McpStepExecutionData (was missing despite mcp executor writing userConfirmation)
- Remove isString/isRecordId runtime guards in resolveFromSelection — now unnecessary with precise typing
- Re-derive displayName from FieldSchema instead of accepting it from the PATCH body; remove displayName from loadRelatedRecordPatchSchema and contract
- Fix inaccurate comment "Keeps pendingData immutable" in patchAndReloadPendingData

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… in load-related-record patch

Sending a different relation name without a new record ID would silently reuse the AI-suggested
record ID from the original collection, producing a wrong or non-existent record in the new
relation. Zod refine now enforces that selectedRecordId is required when name is overridden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread packages/workflow-executor/src/http/pending-data-validators.ts Outdated
alban bertolini and others added 2 commits May 19, 2026 23:35
…ata and validators test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion for load-related-record

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Scra3
Copy link
Copy Markdown
Member

Scra3 commented May 20, 2026

@Scra3
Copy link
Copy Markdown
Member

Scra3 commented May 20, 2026

linked to: PRD-378

Sign up for free to 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.

2 participants