From 7746bb2359a84743ae7f4e7dc6483fbf4242dbc5 Mon Sep 17 00:00:00 2001 From: huyplb Date: Sat, 17 Oct 2026 10:39:18 -0600 Subject: [PATCH] refactor(workflow): one file boundary, one batcher, one reader per format, and the engine's own types The workflow review's remaining structural items. File boundary. Every file pipe resolved its configured path inside the files root and then called node:fs itself, so the rule that keeps an editor from reading /etc/passwd held only while each pipe remembered one call. openWorkflowFile resolves once and hands back the only handle that can read or write; CSV, text, JSON, the CSV sink and the preview route use it, and pipes-fs-boundary.test.ts fails the build when anything else under pipes/ imports node:fs. One batcher. CSV, text and JSON each kept their own onInvalid/reject batching and had drifted on where they cut, what the last cursor named and how errors read. BatchEmitter in delimited.ts does it once: `record` cuts for CSV/JSON, `boundary` cuts for text (a line never straddles batches), and the final cursor names the last position read, so trailing refused rows are not read again on resume (JSON now gets that too). One reader per format. The preview re-implemented each source's loop and had diverged: CSV numbered rows as preamble + header + index, wrong after any quoted field spanning lines. csvRows and TextRowReader are shared by run and preview; CSV rows carry the physical line they start on. Engine types. The HTTP request model, column rules, credential kinds and the designer's saved pipes now come from @foxschema/workflow-engine (type-only; z.input where the designer writes what the engine parses). The swap surfaced two more drops on save: a pipe's retry policy beyond `attempts`, and a column type typed as any string. Both fixed; the e2e round trip now covers retry, and the trigger dialog keeping its section while typing. Verified: full vitest (timing tests pass alone), apps/web tsc, eslint + security lint, build, bundle 146.2 KB; e2e workflow suite in a real browser; every new test fails on the code before it. Co-Authored-By: Claude Opus 5.5 --- .../tests/workflow-designer-roundtrip.test.ts | 31 ++- .../workflow/components/CredentialsPanel.tsx | 4 +- .../components/DelimitedSourceEditor.tsx | 44 ++-- .../workflow/components/HttpRequestEditor.tsx | 103 +++----- .../features/workflow/components/PipeNode.tsx | 4 + .../workflow/components/WorkflowDesigner.tsx | 39 ++-- apps/workflow-server/src/preview.test.ts | 12 + apps/workflow-server/src/routes/preview.ts | 45 +--- packages/workflow-engine/src/index.ts | 2 + .../src/pipes/pipes-fs-boundary.test.ts | 42 ++++ .../src/pipes/utility/batch-emitter.test.ts | 66 ++++++ .../src/pipes/utility/csv-parse.test.ts | 18 +- .../workflow-engine/src/pipes/utility/csv.ts | 187 +++++++-------- .../src/pipes/utility/delimited.ts | 221 +++++++++++++++--- .../src/pipes/utility/file-sink.ts | 30 +-- .../src/pipes/utility/index.ts | 1 + .../workflow-engine/src/pipes/utility/json.ts | 159 +++---------- .../utility/preview-run-agreement.test.ts | 71 ++++++ .../workflow-engine/src/pipes/utility/text.ts | 128 ++-------- .../src/pipes/utility/workflow-files.test.ts | 53 +++++ .../src/pipes/utility/workflow-files.ts | 87 +++++++ 21 files changed, 788 insertions(+), 559 deletions(-) create mode 100644 packages/workflow-engine/src/pipes/pipes-fs-boundary.test.ts create mode 100644 packages/workflow-engine/src/pipes/utility/batch-emitter.test.ts create mode 100644 packages/workflow-engine/src/pipes/utility/preview-run-agreement.test.ts create mode 100644 packages/workflow-engine/src/pipes/utility/workflow-files.test.ts create mode 100644 packages/workflow-engine/src/pipes/utility/workflow-files.ts diff --git a/apps/e2e/src/tests/workflow-designer-roundtrip.test.ts b/apps/e2e/src/tests/workflow-designer-roundtrip.test.ts index d3b7641df..229b12a63 100644 --- a/apps/e2e/src/tests/workflow-designer-roundtrip.test.ts +++ b/apps/e2e/src/tests/workflow-designer-roundtrip.test.ts @@ -5,8 +5,9 @@ * * Opening a workflow in the designer and saving it keeps what the designer * does not edit — a pipe's timeout, the workflow's version, error handler, - * display name, and the trust its origin records. Saving used to drop all - * five: an imported workflow came back `authored`, renamed to its id. + * display name, the trust its origin records, and a retry policy beyond its + * attempts. Saving used to drop each: an imported workflow came back + * `authored`, renamed to its id, with the engine's default retry. * * Needs the web app at E2E_BASE_URL and workflow-server on :8081 with no * service token (loopback dev); skips when the engine is not there. @@ -30,7 +31,17 @@ const doc = (id: string, extra: Record = {}) => ({ { id: 'main', name: 'Main', - pipes: [{ id: 'start', type: 'source.triggerPayload', role: 'source', config: {}, timeoutMs: 1234 }], + pipes: [ + { + id: 'start', + type: 'source.triggerPayload', + role: 'source', + config: {}, + timeoutMs: 1234, + // Beyond the attempts the canvas edits: a save used to drop the rest. + retry: { attempts: 5, backoff: 'fixed', maxDelayMs: 900, jitter: false, on: 'all' }, + }, + ], edges: [], }, ], @@ -99,6 +110,7 @@ describe('workflow designer round trip', () => { errorHandler: { workflowId: HANDLER }, }); expect(after.pipelines[0].pipes[0].timeoutMs).toBe(1234); + expect(after.pipelines[0].pipes[0].retry).toEqual({ attempts: 5, backoff: 'fixed', maxDelayMs: 900, jitter: false, on: 'all' }); }); it('shows only the trigger sections that have a form, and no dead buttons', async (ctx) => { @@ -117,4 +129,17 @@ describe('workflow designer round trip', () => { ]); await driver.screenshot({ path: process.env.E2E_SHOT ?? '/tmp/workflow-trigger-dialog.png' }); }); + + it('keeps the section you are in while you type in a trigger', async (ctx) => { + // The dialog used to re-sync its selection from props in an effect; with + // the triggers among its dependencies, every keystroke snapped it back to + // General. Mounted only while open, it starts once and stays put. + if (!engineUp) ctx.skip(); + const section = driver.locator('[data-testid="workflow-trigger-config-section-input parameters"]'); + await section.click(); + await driver.locator(`${byTestId('workflow-trigger-config')} .monaco-editor .view-lines`).click(); + await driver.keyboard.type('{"region": "west"}'); + expect(await section.getAttribute('class')).toContain('active'); + expect(await driver.locator(`${byTestId('workflow-trigger-config')} .monaco-editor`).isVisible()).toBe(true); + }); }); diff --git a/apps/web/src/frontend/features/workflow/components/CredentialsPanel.tsx b/apps/web/src/frontend/features/workflow/components/CredentialsPanel.tsx index 52cff2ac4..3f9a96d93 100644 --- a/apps/web/src/frontend/features/workflow/components/CredentialsPanel.tsx +++ b/apps/web/src/frontend/features/workflow/components/CredentialsPanel.tsx @@ -25,8 +25,8 @@ const _kindsMatch: SameMembers<(typeof KINDS)[number], CredentialKind> = true; const _sourcesMatch: SameMembers<(typeof SOURCES)[number], Exclude> = true; void [_kindsMatch, _sourcesMatch]; -type Kind = (typeof KINDS)[number]; -type Source = (typeof SOURCES)[number]; +type Kind = CredentialKind; +type Source = Exclude; const KIND_FIELDS: Record> = { database: [ diff --git a/apps/web/src/frontend/features/workflow/components/DelimitedSourceEditor.tsx b/apps/web/src/frontend/features/workflow/components/DelimitedSourceEditor.tsx index c289381c9..c0bf0baa4 100644 --- a/apps/web/src/frontend/features/workflow/components/DelimitedSourceEditor.tsx +++ b/apps/web/src/frontend/features/workflow/components/DelimitedSourceEditor.tsx @@ -12,10 +12,11 @@ import { compileZodSchema } from '../lib/zodSchema'; import type { ColumnCheck as EngineColumnCheck, ColumnCheckKind, + ColumnRule, ColumnType, FixedWidthColumn, } from '@foxschema/workflow-engine/definitions'; -import type { KnowsEveryField, SameMembers } from '../lib/engineShapes'; +import type { SameMembers } from '../lib/engineShapes'; /** * Config editor for the delimited file sources (`source.file.csv`, @@ -59,33 +60,14 @@ const COLUMN_TYPES = ['string', 'integer', 'number', 'boolean', 'date'] as const const CHECK_KINDS = ['null', 'length', 'format'] as const; const _columnTypesMatch: SameMembers<(typeof COLUMN_TYPES)[number], ColumnType> = true; const _checkKindsMatch: SameMembers<(typeof CHECK_KINDS)[number], ColumnCheckKind> = true; -const _checksKnown: KnowsEveryField = true; -const _fixedKnown: KnowsEveryField = true; -void [_columnTypesMatch, _checkKindsMatch, _checksKnown, _fixedKnown]; - -interface ColumnCheck { - pattern: string; - kind?: string; - message?: string; -} - -interface ColumnRuleFields { - type?: string; - /** Legacy single regex — migrated into `checks` in the editor when present. */ - pattern?: string; - checks?: ColumnCheck[]; - required?: boolean; -} +void [_columnTypesMatch, _checkKindsMatch]; -interface FixedColumn extends ColumnRuleFields { - name: string; - start: number; - length: number; -} - -interface FieldRule extends ColumnRuleFields { - name: string; -} +// The engine's own column shapes, type-only. Hand-kept copies here typed a +// column's `type` as any string, so a value the engine rejects compiled. +type ColumnCheck = EngineColumnCheck; +type ColumnRuleFields = Omit; +type FixedColumn = FixedWidthColumn; +type FieldRule = ColumnRule; const parseChecks = (value: unknown): ColumnCheck[] => { if (!Array.isArray(value)) return []; @@ -103,6 +85,10 @@ const parseChecks = (value: unknown): ColumnCheck[] => { }); }; +/** A column type the engine knows, or undefined — the engine refuses any other. */ +const asColumnType = (value: unknown): ColumnType | undefined => + (COLUMN_TYPES as readonly unknown[]).includes(value) ? (value as ColumnType) : undefined; + /** Normalize rule fields: fold legacy `pattern` into `checks` for editing. */ const ruleFields = (raw: ColumnRuleFields): ColumnRuleFields => { const checks = parseChecks(raw.checks); @@ -111,7 +97,7 @@ const ruleFields = (raw: ColumnRuleFields): ColumnRuleFields => { checks.unshift({ pattern: legacy }); } return { - ...(raw.type ? { type: String(raw.type) } : {}), + ...(asColumnType(raw.type) ? { type: asColumnType(raw.type) } : {}), ...(checks.length > 0 ? { checks } : {}), ...(raw.required ? { required: true } : {}), }; @@ -209,7 +195,7 @@ function ColumnRuleEditor({