Skip to content

feat(workflow-executor): add 3-way execution mode to the load related record step (PRD-148)#1728

Open
matthv wants to merge 5 commits into
mainfrom
feature/prd-148-ai-assisted-and-full-ai-load-related-record-step
Open

feat(workflow-executor): add 3-way execution mode to the load related record step (PRD-148)#1728
matthv wants to merge 5 commits into
mainfrom
feature/prd-148-ai-assisted-and-full-ai-load-related-record-step

Conversation

@matthv

@matthv matthv commented Jun 30, 2026

Copy link
Copy Markdown
Member

What

Execution logic for the 3-way Execution mode (Manual / AI-assisted / Full AI) on the workflow Load Related Record step. Part of PRD-148 — 3-PR set (executor + ForestAdmin/forestadmin-server orchestrator + ForestAdmin/forestadmin editor).

  • Manual (manual): present the narrowed candidate list with no AI — no relation chooser, no record suggestion. Single-record relations (xToOne, or HasMany with 1 candidate) still pre-fill, since that's the only option, not an AI pick. The Zod schema drops .catch(AutomatedWithConfirmation) and adds Manual, so a manual value is no longer silently coerced back into AI prefill (same reasoning as TriggerAction).
  • AI-assisted (automated-with-confirmation, default): AI suggests a record, user confirms (unchanged).
  • Full AI (fully-automated): AI selects and loads. Auto-skips (persists executionResult: { skipped: true } + success) when there is no source record (SourceRecordMissingError, Full-AI only), an empty candidate list, or the AI judges none relevant (select-record-by-content returns -1 via the new Full-AI-only allowNone).

selectedRecordRef is now optional on LoadRelatedRecordStepExecutionData (absent only on the no-source Full-AI skip); the wire serializer omits it instead of dereferencing undefined.

Tests

97 tests in the executor suite. Added: Manual = no AI / no pre-selection (incl. relation switch), Manual single-record pre-fill, Full-AI skip on each of the 3 causes, await-mode no-source still errors (regression guard), serializer no-source round-trip.

Validation

Validated end-to-end on the local stack (account → bills) across the 3 modes, plus a local multi-agent code review — 1 Critical found & fixed: serializer crash (503) on the no-source Full-AI skip.

Relates to PRD-148.

🤖 Generated with Claude Code

Note

Add Manual execution mode to LoadRelatedRecordStepExecutor with auto-skip for FullyAutomated

  • Adds Manual as a valid executionType for the load-related-record step; in Manual mode, AI is not called for relation selection or candidate suggestion — the first eligible relation is picked deterministically and no suggestedRecord is included.
  • In FullyAutomated mode, steps now auto-skip (via new persistSkip) when there is no source record, no linked xToOne record, or when the AI ranks all candidates as irrelevant (returns -1).
  • selectBestRecordIndex gains an allowNone option so AI can return -1 only when the candidate list was not truncated, preventing forced selections.
  • fetchRecordForRelation and fetchXToOneRecordRef now return null on absence instead of throwing, with callers deciding skip vs. error behavior.
  • selectedRecordRef is made optional in LoadRelatedRecordStepExecutionData and the wire serializer skips the field when absent, fixing a serialization crash on auto-skipped steps.
  • Behavioral Change: executionType schema no longer coerces unknown values to AutomatedWithConfirmation; invalid values will now fail validation instead of being silently swallowed.

Changes since #1728 opened

  • Added 3-way execution mode allowing AI to decline record selection by returning -1 when no candidate satisfies the request [9ee1164]
  • Changed FullyAutomated execution mode to fall back to awaiting-input with suggestNoRecord flag instead of auto-skipping when no relevant record exists [9ee1164]
  • Added suggestNoRecord field to LoadRelatedRecordPendingData and updated LoadRelatedRecordStepExecutionData interface documentation [9ee1164]
  • Removed helper methods and unified record resolution logic through collectCandidateIds and resolveAndLoadAutomatic [9ee1164]
  • Updated test suite to validate new awaiting-input fallback behavior and AI -1 guidance [9ee1164]

Macroscope summarized 4f8b398.

… record step (PRD-148)

Manual presents the narrowed list with no AI; AI-assisted suggests a record; Full AI
selects + loads and auto-skips on no source record, empty list, or AI judging none
relevant. selectedRecordRef is optional now (no-source skip); the wire serializer guards it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jun 30, 2026

Copy link
Copy Markdown

PRD-148

@qltysh

qltysh Bot commented Jun 30, 2026

Copy link
Copy Markdown

2 new issues

Tool Category Rule Count
qlty Structure Function with many parameters (count = 4): selectBestRecordIndex 1
qlty Structure Function with high complexity (count = 13): selectBestRecordIndex 1

@qltysh

qltysh Bot commented Jun 30, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on main by 0.07%.

Modified Files with Diff Coverage (1)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
...ow-executor/src/executors/load-related-record-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.

…resolveTarget (PRD-148)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
matthv and others added 2 commits June 30, 2026 15:13
… candidate list (PRD-148)

When the HasMany candidate list is truncated to fit the AI prompt budget, -1 (none relevant) is no
longer offered — the AI picks from what it saw, so a match in the unseen tail is not silently skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…PRD-148)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
target = await this.resolveTarget(useAi);
} catch (error) {
// No source record to load from → Full AI auto-continues. (Manual/AI-assisted let this error
// surface so the front can offer "continue without" — PRD-550.)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Opus 4.8 (1M context)] · Violates conventions

Ticket ID PRD-550 in a code comment. Project convention: no PRD/ticket IDs in comments (applies to code and tests). Drop — PRD-550; the sentence reads fine without it. Heads-up: the linter doesn't catch this — worth a lint rule if the team wants it enforced.


it('surfaces a "no source record" error in await modes when the source step loaded nothing', async () => {
// Only Full AI auto-skips; Manual / AI-assisted surface the error so the front can offer
// "continue without" (PRD-550).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Opus 4.8 (1M context)] · Violates conventions

(PRD-550) in a // comment — the no-ticket-IDs-in-comments rule applies to tests too. Drop the ID; the behavioral note is enough. (The describe('… (PRD-148)') titles are borderline and out of the comment rule, so left alone.)


// "None relevant" (-1) is only trustworthy when the AI saw the whole list. If it was truncated,
// force a pick from what was shown — otherwise a match in the unseen tail would be silently skipped.
const noneAllowed = allowNone && !truncated;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Opus 4.8 (1M context)] · Should fix

The noneAllowed = allowNone && !truncated rule — the safety core of the feature (-1 "none relevant" only trusted when the AI saw the whole list) — has no test. The truncation tests all run the await path (allowNone:false); the none-relevant test uses a 2-candidate, non-truncated list. So the Full-AI + truncated combination, where noneAllowed collapses to false, minIndex→0, and a -1 response is rejected by the guard at line 764, is unverified — a regression dropping !truncated (silently skipping a record that matches in the unseen tail) would pass the whole suite. Add: Full-AI HasMany, candidates wide enough to truncate, AI returns -1 → assert InvalidAIResponseError, not a skip.

.enum([AutomatedWithConfirmation, FullyAutomated])
.default(AutomatedWithConfirmation)
.catch(AutomatedWithConfirmation),
.enum([Manual, AutomatedWithConfirmation, FullyAutomated])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Opus 4.8 (1M context)] · Preferential

No test asserts this schema parses executionType: 'manual' and returns 'manual'. The executor tests build definitions directly via makeStep(...), bypassing the schema, and the only no-coercion tests live in an out-of-PR file (trigger-action / condition). A one-line LoadRelatedRecordStepDefinitionSchema.parse({ …, executionType: 'manual' })toBe('manual') cheaply guards Manual mode against the enum being narrowed back later.

private async selectBestFromRelatedData(
target: Pick<RelationTarget, 'selectedRecordRef' | 'name' | 'relatedCollectionName'>,
limit: number,
opts: { rank: boolean; allowNone: boolean } = { rank: true, allowNone: false },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Opus 4.8 (1M context)] · Preferential

{ rank; allowNone } permits the meaningless { rank:false, allowNone:true } — when rank:false the method returns (line 492-494) before allowNone is ever read. A union { rank: false } | { rank: true; allowNone: boolean } makes that illegal state unrepresentable. Minor (private, single-file); flag only for clarity should it grow more callers.

pendingData?: LoadRelatedRecordPendingData;
selectedRecordRef: RecordRef;
// Absent only on the Full AI no-source auto-skip; always set on the await/load paths.
selectedRecordRef?: RecordRef;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Opus 4.8 (1M context)] · Preferential

Making selectedRecordRef optional widens the type to "sometimes absent", but the real invariant is narrower: absent ⟺ the Full-AI no-source skip (executionResult: { skipped: true }). That coupling is enforced only by this comment + scattered runtime guards, and summary/step-execution-formatters.ts:99 derefs selectedRecordRef.recordId relying on it. Not a bug today (every producer upholds it), but a discriminated union (loaded vs skipped variant) would make the deref safe by construction and remove the !selectedRecordRef guards at lines 107/429.

.default(AutomatedWithConfirmation)
.catch(AutomatedWithConfirmation),
.enum([Manual, AutomatedWithConfirmation, FullyAutomated])
.default(AutomatedWithConfirmation),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Opus 4.8 (1M context)] · Preferential (discussion)

Dropping .catch is the right call — it stops silently coercing values into AI-assisted. One downstream consequence to be aware of: an unknown/future executionType now throws a raw ZodError, and on the pending-run path toStepDefinition (run-to-available-step-mapper.ts:165) runs outside the try/catch that maps to DomainValidationError — so getAvailableRuns doesn't see it as a WorkflowExecutorError and logs-and-drops the run rather than reporting it malformed. Low reachability (server contract enum has 3 values), but this is exactly the executor version-skew case the conventions call out: an older executor receiving a newer 4th mode would silently drop the run instead of surfacing an "update needed" error. Fix is downstream/out of scope here. Also stale as a result: the comment at step-definition-mapper.ts:22 ("each schema's .default().catch() handles unsupported values") no longer holds for this schema or trigger-action.

…e AI decline (PRD-148)

Full AI no longer silently skips when it can't load a relevant record. With
nothing relevant it degrades to an AI-assisted confirmation (a human decides,
"No X to load" pre-checked); no source record now surfaces an error like the
await modes instead of skipping. AI-assisted may decline too (allowNone), and
the record-selection prompt tells the AI to return -1 on an impossible request
rather than forcing a weak match. pendingData carries a suggestNoRecord flag so
the front distinguishes an active "nothing relevant" from a plain no-suggestion
(Manual).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
suggestViaAi,
);

await this.context.runStore.saveStepExecution(this.context.runId, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium executors/load-related-record-step-executor.ts:122

refreshCandidatesForField spreads the old pendingData (line 126) without clearing suggestNoRecord. When a prior relation set suggestNoRecord: true and the user switches to a relation that returns a suggestedRecord, the saved pendingData still carries suggestNoRecord: true, so the UI keeps the "No record to load" state even though a valid record was just suggested. Consider recomputing suggestNoRecord in refreshCandidatesForField the same way saveAndAwaitInput does.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/executors/load-related-record-step-executor.ts around line 122:

`refreshCandidatesForField` spreads the old `pendingData` (line 126) without clearing `suggestNoRecord`. When a prior relation set `suggestNoRecord: true` and the user switches to a relation that returns a `suggestedRecord`, the saved `pendingData` still carries `suggestNoRecord: true`, so the UI keeps the "No record to load" state even though a valid record was just suggested. Consider recomputing `suggestNoRecord` in `refreshCandidatesForField` the same way `saveAndAwaitInput` does.

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