From fc142dbc96c6088aca4c45be827fb21d8134786f Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:03:13 +0100 Subject: [PATCH] feat: state flowType in the details of FLOW_START_INTERVIEWS_BEGIN Flow and Process Builder are different tools, and the old suffix told them apart. The log line does not state which one started the interviews, so the type is read from the code unit above them: Flow is flow, Workflow is processBuilder, anything else is null. --- src/__tests__/Details.test.ts | 55 +++++++++++++++++++++++++++++++++++ src/views/details.ts | 32 +++++++++++++++++--- src/views/lines.ts | 26 +++++++++++++++-- 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/__tests__/Details.test.ts b/src/__tests__/Details.test.ts index 1a0eed6..19cc598 100644 --- a/src/__tests__/Details.test.ts +++ b/src/__tests__/Details.test.ts @@ -187,6 +187,61 @@ describe('FLOW_ELEMENT_ERROR details', () => { }); }); +describe('FLOW_START_INTERVIEWS_BEGIN details', () => { + const interviews = (unit: string | null): string => { + const body = [ + '09:18:22.6 (300)|FLOW_START_INTERVIEWS_BEGIN|1', + '09:18:22.6 (310)|FLOW_START_INTERVIEWS_END|1', + ]; + return unit === null + ? body.join('\n') + : [ + `09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|${unit}`, + ...body, + `09:18:22.6 (400)|CODE_UNIT_FINISHED|${unit}`, + ].join('\n'); + }; + const flowTypes = (text: string) => + parse(text) + .ofType('FLOW_START_INTERVIEWS_BEGIN') + .map((e) => e.details.flowType); + + it.each([ + ['a Flow code unit', 'Flow:01I000000000AAA', 'flow'], + ['a Workflow code unit', 'Workflow:01I000000000AAA', 'processBuilder'], + ['a code unit of another kind', 'Validation:MyObject:a00000000000AAA', null], + ['no code unit', null, null], + ])('is %s: %s', (_name, unit, expected) => { + expect(flowTypes(interviews(unit))).toEqual([expected]); + }); + + it('is null when the nearest code unit is of another kind, though a Flow is above it', () => { + const text = [ + '09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|Flow:01I000000000AAA', + '09:18:22.6 (210)|CODE_UNIT_STARTED|[EXTERNAL]|Validation:MyObject:a00000000000AAA', + '09:18:22.6 (300)|FLOW_START_INTERVIEWS_BEGIN|1', + '09:18:22.6 (310)|FLOW_START_INTERVIEWS_END|1', + '09:18:22.6 (390)|CODE_UNIT_FINISHED|Validation:MyObject:a00000000000AAA', + '09:18:22.6 (400)|CODE_UNIT_FINISHED|Flow:01I000000000AAA', + ].join('\n'); + expect(flowTypes(text)).toEqual([null]); + }); + + it('is flow for interviews inside another interviews frame, whatever started it', () => { + const text = [ + '09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|Workflow:01I000000000AAA', + '09:18:22.6 (300)|FLOW_START_INTERVIEWS_BEGIN|1', + '09:18:22.6 (310)|FLOW_START_INTERVIEW_BEGIN|01I000000000AAA|Outer', + '09:18:22.6 (320)|FLOW_START_INTERVIEWS_BEGIN|1', + '09:18:22.6 (330)|FLOW_START_INTERVIEWS_END|1', + '09:18:22.6 (340)|FLOW_START_INTERVIEW_END|01I000000000AAA|Outer', + '09:18:22.6 (350)|FLOW_START_INTERVIEWS_END|1', + '09:18:22.6 (400)|CODE_UNIT_FINISHED|Workflow:01I000000000AAA', + ].join('\n'); + expect(flowTypes(text)).toEqual(['processBuilder', 'flow']); + }); +}); + describe('limit and heap details', () => { const log = parse( inExecution( diff --git a/src/views/details.ts b/src/views/details.ts index 26cac1f..7456127 100644 --- a/src/views/details.ts +++ b/src/views/details.ts @@ -29,6 +29,18 @@ export interface ExplainPlan { readonly relativeCost: number | null; } +/** What started a flow interviews frame. */ +export type FlowType = 'flow' | 'processBuilder'; + +const FLOW_OF_UNIT = new Map([ + ['Flow', 'flow'], + ['Workflow', 'processBuilder'], +]); + +/** The flow type a code unit of `unit` type starts; null for any other unit, or none. */ +export const flowTypeOfUnit = (unit: string | null): FlowType | null => + FLOW_OF_UNIT.get(unit ?? '') ?? null; + /** * The values each type's line states beyond its text, or derives from the rows after it, by type. A * value is null when the log does not state it, or states it malformed; `field()` still gives the @@ -45,6 +57,8 @@ export interface EventDetails { readonly DML_BEGIN: { readonly operation: string | null; readonly sObjectType: string | null }; /** The kind of code, as `Flow` or `Validation`, from the unit's name. */ readonly CODE_UNIT_STARTED: { readonly codeUnitType: string | null }; + /** What started the interviews, from the code unit above them: `Flow` or nested is `flow`, `Workflow` is `processBuilder`. Null for any other unit. */ + readonly FLOW_START_INTERVIEWS_BEGIN: { readonly flowType: FlowType | null }; /** Bytes, as the line states them: positive for a deallocation. */ readonly HEAP_ALLOCATE: { readonly bytes: number | null }; readonly HEAP_DEALLOCATE: { readonly bytes: number | null }; @@ -80,10 +94,16 @@ export type AnyDetails = EventDetails[keyof EventDetails]; type Read = (f: Fields) => T; /** - * Reads a line's details; `explain` gives a query's explain line, or null when it has none, and - * `isCaught` the reading of a throw. + * Reads a line's details; `explain` gives a query's explain line, or null when it has none, + * `isCaught` the reading of a throw, and `flowType` what started an interviews frame. `explain` + * and `flowType` move the shared cursor, so read `f` first. */ -type Reader = (f: Fields, explain: () => Fields | null, isCaught: () => boolean | null) => T; +type Reader = ( + f: Fields, + explain: () => Fields | null, + isCaught: () => boolean | null, + flowType: () => FlowType | null, +) => T; /** Field `name`'s position in `typeId`'s line; throws at load for a name the entry does not list. */ function position(typeId: number, name: string): { at: number; last: boolean } { @@ -175,6 +195,9 @@ reader('DML_BEGIN', (t) => { return (f) => ({ operation: operation(f), sObjectType: sObjectType(f) }); }); reader('CODE_UNIT_STARTED', () => (f) => ({ codeUnitType: codeUnitType(f) })); +reader('FLOW_START_INTERVIEWS_BEGIN', () => (_f, _explain, _isCaught, flowType) => ({ + flowType: flowType(), +})); for (const type of ['HEAP_ALLOCATE', 'HEAP_DEALLOCATE', 'BULK_HEAP_ALLOCATE'] as const) { reader(type, (t) => { const bytes = int(t, 'bytes', 'Bytes:'); @@ -216,8 +239,9 @@ export function eventDetails( f: () => Fields, explain: () => Fields | null, isCaught: () => boolean | null, + flowType: () => FlowType | null, ): AnyDetails | null { const read = READERS[typeId]; - const details = read ? read(f(), explain, isCaught) : null; + const details = read ? read(f(), explain, isCaught, flowType) : null; return details && Object.freeze(details); } diff --git a/src/views/lines.ts b/src/views/lines.ts index c154d3e..9cbf8f8 100644 --- a/src/views/lines.ts +++ b/src/views/lines.ts @@ -3,15 +3,22 @@ */ import { ByteFields } from '../bytes/cursor.js'; import type { Source } from '../bytes/source.js'; -import { EVENT_TYPES, eventText, fieldPosition, idOfType } from '../catalog/catalog.js'; +import { + codeUnitType, + EVENT_TYPES, + eventText, + fieldPosition, + idOfType, +} from '../catalog/catalog.js'; import type { Issues } from '../engine/issues.js'; import { throwOutcomes } from '../engine/throws.js'; import type { Store } from '../store/store.js'; -import type { AnyDetails } from './details.js'; -import { eventDetails } from './details.js'; +import type { AnyDetails, FlowType } from './details.js'; +import { eventDetails, flowTypeOfUnit } from './details.js'; const INTERVIEWS = idOfType('FLOW_START_INTERVIEWS_BEGIN'); const INTERVIEW = idOfType('FLOW_START_INTERVIEW_BEGIN'); +const CODE_UNIT_STARTED = idOfType('CODE_UNIT_STARTED'); const EXPLAIN = idOfType('SOQL_EXECUTE_EXPLAIN'); /** @@ -71,6 +78,7 @@ export class EventLines { return plan < 0 ? null : this.at(plan); }, () => (this.outcomes ??= throwOutcomes(this.store, this.issues)).get(id) ?? null, + () => this.flowTypeOf(id), ); } @@ -86,6 +94,18 @@ export class EventLines { return this.fields; } + /** What started the interviews: the nearest code unit or interviews frame above them. */ + private flowTypeOf(id: number): FlowType | null { + const { parent } = this.store; + // Every parent is a row; row 0, the log, ends the walk. + for (let up = parent[id]!; up > 0; up = parent[up]!) { + const type = this.typeOf(up); + if (type === INTERVIEWS) return 'flow'; + if (type === CODE_UNIT_STARTED) return flowTypeOfUnit(codeUnitType(this.at(up))); + } + return null; + } + private firstChildOf(id: number, type: number): number { const { subtreeEnd } = this.store; // Each child's subtree ends where its next sibling starts; every id here is a row.