Skip to content

feat(workflow-executor): deterministic source record for Get Data steps (PRD-775)#1760

Open
EnkiP wants to merge 1 commit into
mainfrom
workflow/deterministic-get-data
Open

feat(workflow-executor): deterministic source record for Get Data steps (PRD-775)#1760
EnkiP wants to merge 1 commit into
mainfrom
workflow/deterministic-get-data

Conversation

@EnkiP

@EnkiP EnkiP commented Jul 15, 2026

Copy link
Copy Markdown
Member

Part of the deterministic Get Data step work.

fixes PRD-775

What

The read-record (Get Data) step can now resolve its source record deterministically from a build-time-configured stable BPMN step id (selectedRecordStepId), mirroring how trigger-action and load-related-record already work — instead of always asking the AI to pick among the available records.

Changes

  • ReadRecordStepDefinitionSchema.preRecordedArgs gains selectedRecordStepId (legacy selectedRecordStepIndex kept for back-compat).
  • read-record-step-executor: when selectedRecordStepId is set, resolve via resolveSourceRecordRef (revise-safe; WORKFLOW_START_STEP_ID → base record). Falls back to legacy index / AI selection when unset.
  • Server contract (ServerWorkflowTaskGetData) + mapper forward preRecordedArgs for get-data.

The fieldNames pre-recorded path (build-time field selection) already existed and is unchanged.

Tests

  • Deterministic resolve skips the select-record AI round; WORKFLOW_START_STEP_ID → base record; unresolvable id → error.
  • Mapper forwards preRecordedArgs.selectedRecordStepId.

Companion PRs: forestadmin-server (orchestrator), forestadmin (editor UI).

🤖 Generated with Claude Code

Note

Add deterministic source record resolution by step ID to ReadRecordStepExecutor

  • Adds an optional selectedRecordStepId field to preRecordedArgs in ReadRecordStepDefinition and the ServerWorkflowTaskGetData interface, alongside the legacy selectedRecordStepIndex for back-compat.
  • When preRecordedArgs.selectedRecordStepId is set, ReadRecordStepExecutor.doExecute calls resolveSourceRecordRef(selectedRecordStepId) and skips the interactive record selection flow entirely.
  • The mapTask mapper in step-definition-mapper.ts now forwards preRecordedArgs from GetData server tasks to the resulting ReadRecord step definition.
  • Behavioral Change: executions with selectedRecordStepId set will bypass record selection; an unmatched step ID results in an error rather than falling back to selection.

Macroscope summarized b0f298f.

@linear-code

linear-code Bot commented Jul 15, 2026

Copy link
Copy Markdown

PRD-775

@qltysh

qltysh Bot commented Jul 15, 2026

Copy link
Copy Markdown

1 new issue

Tool Category Rule Count
qlty Structure Function with many returns (count = 6): mapTask 1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@EnkiP EnkiP force-pushed the workflow/deterministic-get-data branch from 5314018 to b0f298f Compare July 15, 2026 10:21
@qltysh

qltysh Bot commented Jul 15, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (2)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
...s/workflow-executor/src/executors/read-record-step-executor.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/adapters/step-definition-mapper.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.

step.prompt,
preRecordedArgs?.selectedRecordStepIndex,
);
const selectedRecordRef = preRecordedArgs?.selectedRecordStepId

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.

🟠 Truthiness gate: an empty-string selectedRecordStepId silently downgrades to AI/legacy resolution

The branch is preRecordedArgs?.selectedRecordStepId ? … (truthiness), but the zod schema types it z.string().optional(), which accepts ''. If a config ever persists selectedRecordStepId: '' (partial revision, serialization glitch, a cleared field), '' ? A : B takes the legacy/AI branch — a step the operator configured as deterministic silently reads an AI-chosen record.

Suggest gating on presence rather than truthiness (!= null) or rejecting empty in the schema (z.string().min(1)), so a malformed id fails loudly instead of degrading.

🟡 Related (observability): nothing here logs which path ran. When selectedRecordStepId/fieldNames are absent (e.g. after a revision that dropped them), the step runs fully AI-driven with status: success and no trace distinguishing "resolved deterministically" from "fell back". On a feature branch literally named deterministic-get-data, a Warn log on the fallback would save a lot of debugging.

);
const schema = await this.getCollectionSchema(selectedRecordRef.collectionName);
const fieldNames =
preRecordedArgs?.fieldNames ?? (await this.selectFields(schema, step.prompt));

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.

🟠 An explicitly-configured empty fieldNames: [] throws an error that blames the AI

preRecordedArgs?.fieldNames ?? (await this.selectFields(...)) uses ??, so an explicit empty array [] is kept (not replaced by the AI). Downstream that yields resolvedFieldNames.length === 0NoResolvedFieldsError, whose userMessage is "The AI selected fields that don't exist on this record. Try rephrasing the step's prompt." — but in this case the AI was never invoked; the cause is an empty/misconfigured deterministic fieldNames. The operator will chase a non-existent AI/prompt issue.

The message (and ideally the error type) should branch on whether the fields came from config vs the AI. Note this path is newly reachable: before this PR the mapper didn't forward preRecordedArgs to get-data, so fieldNames was always undefined here.

interface ServerWorkflowTaskGetData extends ServerWorkflowTaskBase {
taskType: ServerTaskTypeEnum.GetData;
executionType: ServerStepExecutionTypeEnum.FullyAutomated;
preRecordedArgs?: { selectedRecordStepId?: 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.

🟡 This "local mirror of the orchestrator contract" is now inaccurate for get-data

The added type declares only { selectedRecordStepId?: string }, but doExecute actively consumes two more keys the orchestrator sends in the same object: fieldNames (read-record-step-executor.ts) and selectedRecordStepIndex (legacy). Runtime is fine (the mapper forwards task.preRecordedArgs wholesale and the zod schema keeps both), so this is type-only — but since this file is documented as the orchestrator contract mirror, it should reflect what read-record actually reads:

preRecordedArgs?: {
  selectedRecordStepId?: string;
  selectedRecordStepIndex?: number; // legacy
  fieldNames?: string[];
};


const result = await executor.execute();

expect(result.stepOutcome.status).toBe('error');

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.

🟡 This negative test is too weak to tell the two error paths apart

It asserts only status).toBe('error'). Per this repo's CLAUDE.md ("assertions must verify behavior, not just that something happened") — and the file's own pattern elsewhere (expect(result.stepOutcome.error).toBe(...)) — it should assert the error type/message (InvalidPreRecordedArgsError, "No source record found for step…"). As-is, this test still passes if InvalidPreRecordedArgsError and SourceRecordMissingError were swapped, since both produce status: 'error'.

Two coverage gaps in the same area worth adding:

  • SourceRecordMissingError (source step pinned but it loaded nothing) — the one branch of the new resolution chain with zero coverage.
  • Fully-deterministic → zero AI: a test with both selectedRecordStepId and fieldNames asserting bindTools is called 0 times. That's the headline promise of PRD-775; the current deterministic test still runs selectFields (1 AI round).

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