fix(workflow-executor): preserve AI suggestion in pending data#1586
fix(workflow-executor): preserve AI suggestion in pending data#1586Tonours wants to merge 5 commits into
Conversation
|
Coverage Impact ⬆️ Merging this pull request will increase total coverage on Modified Files with Diff Coverage (5)
🛟 Help
|
c797768 to
7966b7f
Compare
7966b7f to
d58877a
Compare
| 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'); |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
Scra3
left a comment
There was a problem hiding this comment.
Two blocking type-safety issues need to be addressed before merge.
WithUserConfirmation.userConfirmationis typed asRecord<string, unknown>, erasing the per-step shape that Zod already validated — executors end up re-implementing validation with runtime guards.McpStepExecutionData(line 106 instep-execution-data.ts) is the only executor that callspatchAndReloadPendingDatabut does not extendWithUserConfirmation— theuserConfirmationfield 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>; |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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>
…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>
|
linked to: PRD-378 |

Summary
pendingDataimmutable so the AI proposal can be diffed against the user's final choice. User overrides now land in a siblinguserConfirmation: Record<string, unknown>populated bypatchAndReloadPendingDatafrom the validated PATCH body.update-record,trigger-actionandload-related-recordexecutors read user-confirmed values (value,actionResult,name/displayName/selectedRecordId) fromuserConfirmationwith fallback to the AI suggestion inpendingData.userConfirmationis replaced last-write-wins (no spread-merge) to avoid stale keys bleeding across sequential PATCHes.Test plan
yarn workspace @forestadmin/workflow-executor buildyarn workspace @forestadmin/workflow-executor test(726 tests pass)yarn workspace @forestadmin/workflow-executor lintvalue/selectedRecordId/ relationname+displayName, accept-via-PATCH without override (fallback), rejection via PATCH withuserConfirmationset.