Skip to content

Commit 243dd3c

Browse files
fix(service-automation)!: flow CEL record is the record the run was handed, or unbound (#22674)
Fixes #22642 Clause-②: no (narrowing) In flow CEL, `record` is now the record the run was handed, or unbound. It is never the run's variables map. `AutomationEngine.celScope` handed the formula engine `record: vars`. `buildScope` assigns `extra` after `record`, so a `record` variable already won. With no record in hand, though, CEL `record` was the variables map, and `record.assignee` silently read a flow variable named `assignee`. The builder now passes no `record` slot. `record` resolves through the variable spread like any other name: - it is bound when an entrance handed the run a record (`seedRunVariables` binds `context.record` as `record`), or when the flow binds a `record` variable itself; - otherwise `record.X` faults `Unknown variable: record`, with the source, as every other unbound root does. Bare names (`assignee`) and `vars.assignee` resolve as before. The diff is one statement and its docblock in `packages/services/service-automation/src/engine.ts`, one new test file, and the changeset. ## The open decision: this PR is NOT ready until it is settled `check-adr-0087-registration` is red on one cause: the changeset claims `registered flow-cel-record-variables-alias-retired`, and that D3 entry does not exist yet. I stopped before editing the migrations registry, as the dispatch asks. The two routes and the evidence are below. The full analysis is in the `os-dev-report` comment on #22642. - **The conflict.** The dispatch says "zero reach, no migration entry owed", and it also asks the changeset to carry the FROM → TO remedy. With the remedy in the body, the gate's own detector reads a prescription. `hasMigrationPrescription` on this changeset answers `true`; the control, `.changeset/15206-managed-content-sealed.md`, which validly holds `no-migration-prescription`, answers `false`. So `no-migration-prescription` is refused, and an honest `registered` needs a ledger entry. PR #22609 (landed) took the same route at zero measured reach. - **Route A (recommended): register a D3 semantic entry.** Two files in `packages/spec`, outside this claim's surface: - new: `packages/spec/src/migrations/entries/semantic/18.flow-cel-record-variables-alias-retired.ts`; - regenerated by `pnpm --filter @objectstack/spec gen:migration-registry`: `packages/spec/src/migrations/registry.ts`. - **Route B: drop the remedy from the changeset** and claim `no-migration-prescription`. This leaves a breaking runtime narrowing with no FROM → TO, against AGENTS.md's Post-Task Checklist step 3 and the dispatch. ## Step 1: the census (measured before any edit) I used a static TypeScript-AST pass that executes nothing from the corpus. It reads every flow CEL slot: `condition`, `expression`, `visibleWhen`, and the `{ dialect: 'cel', source }` value envelopes (tagged templates included). Sources it cannot resolve statically are listed and resolved by hand, never dropped. Recall control: every file carrying start-node text yielded a flow literal (0 misses on both trees). | tree | flows | CEL slots | read `record` | of those, with no record entrance | |:--|--:|--:|--:|--:| | this repo at `0ec4268972`: `examples/**`, `packages/platform-objects` (no flows), `packages/qa/dogfood` | 64 | 51 | 2 (`showcase_inbound_task_webhook`, an `api` hook; a dogfood `record-after-update` flow) | **0** | | `objectstack-ai/hotcrm` at `f0afcbda07` (`src/`, `test/`) | 45 | 57 (5 resolved by hand: `vars.*` only) | 12, all `record_change` | **0** | **Reach: zero**, so no flow is rewritten. One cron string read as an `expression` was excluded from the 51. Deployed metadata and other repositories were not measured. ## The entrance map: how each door hands a record today Every entrance hands its record through `AutomationContext.record`, and `seedRunVariables` binds it as `record` and `$record`. Nothing else binds `record` except the flow's own variables. | entrance | what it hands | where | |:--|:--|:--| | record-change trigger | the written row | `trigger-record-change` | | time-relative sweep | each matched row | `trigger-schedule` `time-relative-trigger.ts` | | inbound hook | the request body | `trigger-api` | | `type: 'flow'` action (REST `/actions`, MCP `run_action`) | the loaded row, or an empty record carrying at most the given `id` | `runtime` `dispatchFlowAction` / `loadActionSubjectRecord` | | `subflow` parent | the parent's `context.record` (the child context spreads the parent's) | `subflow-node.ts` | | `map` item | the item, when it carries a string `id`; otherwise the parent's | `map-node.ts` | | REST trigger route, declared endpoint, cron schedule | **none** | `buildAutomationContext`; `schedule-trigger.ts` | ## Pins and the ablation `packages/services/service-automation/src/flow-cel-record-binding.test.ts` has 17 cases, through `registerFlow` + `execute` and the two primitives: - **The defect:** `record.assignee`, and the card's `has(record.assignee) ? record.assignee : null`, with no record and an `assignee` variable: the run fails (`success: false`, `status: 'failed'`), naming `Unknown variable: record` and the source, and never answers `u9`. An edge predicate and `evaluateValueEnvelope` / `evaluateCondition` refuse it too. - **Controls:** a record-triggered run reads `record.assignee` from its record over a same-named variable. Bare `assignee` and `vars.assignee` still read the variable. A flow-declared `record` variable is read. - **Each entrance:** record-change, time-relative, inbound hook, flow action on a row; an object-less action's empty record faults on the key and never reads the variable; a `subflow` child reads its parent's record, and a record-less parent hands none; a `map` item with an `id` is the child's record, and an id-less item leaves the parent's. **Ablation** (`scripts/ablation-replace.mjs`, WRAP mode, run from the committed state): `record: vars` put back. The anchor hit 1 → 0, and the blob went `69bb14b848c5` → `0451cf389cbd`. Result: **5 failed / 12 passed**, exactly the five no-record pins (both value pins, the edge predicate, the primitives, and the record-less subflow parent). Controls and entrances stayed green. Restore proven: the blob after restore equals HEAD (`69bb14b848c5`), `git diff HEAD` is empty, and `git status --porcelain` shows 0 lines. ## Verification Every reading below was taken at HEAD `e87a793ce6` (this branch merged with `origin/main` `5fb1746611`) unless it says otherwise. Each exit status was captured before any pipe. - **Build** (the closure, `turbo run build --filter='@objectstack/service-automation...' --concurrency=1`): 30/30 tasks, exit 0. - **`@objectstack/service-automation` tests** (`vitest run --maxWorkers=2`): 184 files, **2348 passed**, exit 0. The new file alone: 17/17. - **`@objectstack/service-automation` typecheck** (`tsc --noEmit` and `check:test-typecheck`): exit 0. `tsc --listFiles` counts the new test in the program (1 hit; 184 test files; 0 `error TS`). - **`@objectstack/spec` `test:repo`**: 54 files, **915 passed**, exit 0. Its repo tests walk the tree, which includes the new file and the edited docblock. - **Derived gates** (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no paths): 65 commands. Reconciled with `--ran` and exit codes recorded: **65 derived, 64 run, 1 NOT-MEASURED, 0 unrun**. - 63 exit 0; - **red, the named cause above:** `node scripts/check-adr-0087-registration.mjs --base origin/main` exits 1 because the claimed id `flow-cel-record-variables-alias-retired` does not exist in the registries; - **NOT MEASURED:** `pnpm check:dual-build-cjs-loads` exits 3 with PREREQUISITE NOT MET, because it needs every package's `dist` and only the `service-automation` closure is built here. Declared narrowing: the one package this diff changes loads under `require` (`node -e "require('./dist/index.cjs')"` in `packages/services/service-automation`, exit 0, `AutomationEngine` is a function). The full gate is CI's. - The `dist`-reading gates (`check:dts-closure`, `check:sourcemap-no-sources-content`) swept the 30 built packages, which is the `service-automation` closure. ## Acceptance notes - **The lint twin, a follow-up the seat files.** `packages/lint/src/flow-cel-root-scope.ts` (landed in #22609) lists `record` in `ENGINE_BOUND_ROOTS`, the always-bound set. After this lands, `record` is entrance-derived, so `objectstack validate` still passes a `record.X` that now faults at run time when the flow has no record entrance. That PR's D3 entry `flow-cel-unbound-root-refused` also says `record` is "bound by the engine". This PR does not touch `packages/lint` or that entry, per the dispatch. - **Not in scope, not measured:** the `{var}` template dialect (`loop` / `map` `collection`, text slots) is interpolated from the variables map, not evaluated through `celScope`, so this change does not reach it. What `{record.x}` does on a run with no record was not measured here. - The census tool lived in the session scratchpad and is not committed. ## Seat's append: patch round 1 (head `e4977aa6d2`) Appended by `domain:services` seat 1 (`session_013j5gkUCpqQiti4GgPqqmnt`) at 2026-10-10T11:23Z, from the dev's round-1 report on #22642. The open decision above is settled: **A**. The seat answered in-seat (`6096468599`, which also amends the claim's file surface), and the spec lane was told on #6017 (`6096471320`). The changeset is unchanged: it keeps its FROM → TO remedy and `registered flow-cel-record-variables-alias-retired`. - **The D3 entry.** NEW `packages/spec/src/migrations/entries/semantic/18.flow-cel-record-variables-alias-retired.ts`, in the shape of its sibling `flow-cel-unbound-root-refused`. - **Surface:** every flow CEL slot reading `record.X`, or bare `record`, on a run that holds no record, where X names a flow variable. The slots are node and edge `condition`, a decision branch `expression`, a screen field `visibleWhen`, and the `assignment` and `create_record` / `update_record` value envelopes. - **Replacement:** the variable by its name, or `vars.X`. - **Reason:** the entrance map, and why no D2 conversion exists: a flow's record entrances are not visible from the flow alone. - **Acceptance:** list the flow's entrances, then rewrite where an entrance hands no record. Re-run each path, once started with no record and once started with a record. - No `conversionIds` and no `relevantWhen`. - **The registry.** `packages/spec/src/migrations/registry.ts` is regenerated by `gen:migration-registry` only (+39, −0). `check:migration-registry` exits 0 (418 semantic). - **Merges.** Both go through `bash scripts/pm/os-regen-merge.sh`: - `d9ebbee173`, from origin/main `ee3ae0360d`; - `e4977aa6d2`, from origin/main `6a3fe2517b`, which carries #22647's registry entry. The post-merge regeneration reproduced the merged bytes exactly. - **Head `e4977aa6d2`.** The delta against origin/main `6a3fe2517b` is 5 files, +418 / −2. **Gate exits at `e4977aa6d2`**, each captured before any pipe: - `check-adr-0087-registration --base origin/main`: exit 0 (registered, new here). - spec `check:migration-registry`: exit 0. `check:generated`: exit 0, all 15 current. - `service-automation`: 184 files, 2348 passed. Typecheck exit 0. - spec local tests (where `migrations.test.ts` lives): 642 files, 19180 passed. `test:repo`: 54 files, 915 passed. - Derived gates: 92 derived, 92 run, 0 NOT-MEASURED, all exit 0. `check:dual-build-cjs-loads` is included. --- _Generated by [Claude Code](https://claude.ai/code/session_013j5gkUCpqQiti4GgPqqmnt)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a00cf99 commit 243dd3c

5 files changed

Lines changed: 418 additions & 2 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
fix(service-automation)!: in flow CEL, `record` is the record the run was handed, or unbound — never the run's variables
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: registered flow-cel-record-variables-alias-retired -->
10+
11+
**BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`.
12+
13+
**What changed.** A flow CEL expression — a node's or an edge's `condition`, a `decision` branch `expression`, a screen field's `visibleWhen`, and the CEL value envelopes of an `assignment` and of a `create_record` / `update_record` `fields` map — evaluates in the scope `AutomationEngine.celScope` builds. That scope used to bind `record` to the run's variables map whenever the run held no record, so `record.assignee` read a flow variable named `assignee` instead of failing. Now `record` is bound only when:
14+
15+
- an entrance handed the run a record: a record-change trigger's row, a time-relative sweep's row, the inbound hook's request body, a `type: 'flow'` action's record (the loaded row, or an empty record carrying at most the id it was given), a `subflow` or `map` parent's record, or a `map` item that carries a string `id`;
16+
- or the flow binds a variable named `record` itself (a declared variable with a value, or an assignment target).
17+
18+
With neither — a run started through the REST trigger route or a declared endpoint, a cron schedule, or a child of such a run — `record.X` fails the run with `Unknown variable: record` and the expression's source, as every other unbound root does.
19+
20+
**What does not change.** A run that was handed a record reads `record.X` from that record, as before, over any variable of the same name. A bare variable name (`assignee`) and the `vars` namespace (`vars.assignee`) resolve as before. `$record`, `previous`, `vars` and `current_user` are untouched.
21+
22+
## Migration: FROM → TO
23+
24+
In a flow whose run holds no record, read a variable by its name:
25+
26+
| you wrote | write instead |
27+
|:--|:--|
28+
| `record.assignee` (a flow variable `assignee`) | `assignee`, or `vars.assignee` |
29+
| `has(record.assignee) ? record.assignee : null` | `has(vars.assignee) ? vars.assignee : null` |
30+
31+
**The one-line fix: read a flow variable by its name or through `vars`; `record` is only ever the record the run was handed.** A flow that is also launched with a record (an action on a row, a record trigger) keeps reading that record's fields as `record.X`.
32+
33+
**Who is affected, measured.** Read statically over every flow CEL slot — 0 flows read `record` with no record entrance:
34+
35+
- this repository at `0ec4268972` (`examples/**`, `packages/platform-objects`, `packages/qa/dogfood`): 64 flows, 51 CEL slots, no flow in `packages/platform-objects`; the 2 flows that read `record` are an `api` hook flow and a record-change flow;
36+
- `objectstack-ai/hotcrm` at `f0afcbda07` (`src/`, `test/`): 45 flows, 57 CEL slots; the 12 flows that read `record` are all record-change flows.
37+
38+
Deployed metadata and other repositories were not measured. `objectstack validate` does not refuse such a `record` read yet; the run fails it, naming the root and the source.

‎packages/services/service-automation/src/engine.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12097,11 +12097,40 @@ export class AutomationEngine implements IAutomationService {
1209712097
* each wins over a flow variable of the same name — which is then read as
1209812098
* `vars["vars"]` / `vars["current_user"]` (the spec's
1209912099
* `FLOW_SCOPE_CLAIMED_IDENTIFIERS`, measured from here).
12100+
*
12101+
* ## `record` — the record the run was handed, or unbound (#22642)
12102+
*
12103+
* This builder binds no `record` of its own. `record` resolves through the
12104+
* spread like any other name, so it is bound exactly when the variable map
12105+
* holds a `record` key:
12106+
*
12107+
* - an entrance handed the run a record. {@link seedRunVariables} binds
12108+
* `context.record` as `record` (and `$record`), and every entrance hands
12109+
* its record there: a record-change trigger's row, a time-relative
12110+
* sweep's row, the inbound hook's request body (`trigger-api`), a
12111+
* `type: 'flow'` action's record (the row it loaded, or an empty record
12112+
* carrying at most the `id` it was given), a parent's (`subflow` and
12113+
* `map` spread the parent's context into the child's) and a `map` item
12114+
* that carries a string `id`. The REST trigger route, a declared
12115+
* endpoint and a cron schedule hand none;
12116+
* - or the flow binds a variable named `record` itself: a declared
12117+
* variable that has a value, or an assignment target.
12118+
*
12119+
* With neither, `record.X` faults `Unknown variable: record`, as every
12120+
* other unbound root does. It used to be bound to the variables map
12121+
* (`record: vars`, the formula engine's `record` slot, which `extra`
12122+
* overrides only when a `record` variable exists), so with no record in
12123+
* hand `record.assignee` silently read a variable named `assignee`. A
12124+
* variable is read by its name (`assignee`) or through `vars`
12125+
* (`vars.assignee`). ⛔ Never bind `record` to the variable map again: a
12126+
* record root that answers when the run holds no record is a second
12127+
* spelling of every variable, and it hides the author's mistake instead
12128+
* of naming it.
1210012129
*/
1210112130
private celScope(
1210212131
variables: Map<string, unknown>,
1210312132
context?: AutomationContext,
12104-
): { extra: Record<string, unknown>; record: Record<string, unknown> } {
12133+
): { extra: Record<string, unknown> } {
1210512134
const vars: Record<string, unknown> = {};
1210612135
for (const [key, value] of variables) {
1210712136
// Convert "step.result" keys into nested object paths.
@@ -12115,7 +12144,9 @@ export class AutomationEngine implements IAutomationService {
1211512144
}
1211612145
cursor[segs[segs.length - 1]] = value;
1211712146
}
12118-
return { extra: { ...vars, vars, current_user: runUserOf(context) }, record: vars };
12147+
// [#22642] No `record` slot: `record` is bound by the spread when the
12148+
// run holds one, and unbound otherwise (see the docblock).
12149+
return { extra: { ...vars, vars, current_user: runUserOf(context) } };
1211912150
}
1212012151

1212112152
/**
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22642] Flow CEL `record` is the record the run was handed, or unbound —
5+
* never the run's variables map.
6+
*
7+
* `AutomationEngine.celScope` used to hand the formula engine `record: vars`,
8+
* and `extra` overrides that slot only when a `record` variable exists. So a
9+
* run with no record in hand bound `record` to its own variables, and
10+
* `record.assignee` silently read a variable named `assignee` instead of
11+
* failing as an unbound root.
12+
*
13+
* Triage's direction, pinned here through the public doors (`registerFlow` +
14+
* `execute`) and the two engine primitives the executors call:
15+
*
16+
* - bare variable names resolve through the spread (`extra`) only;
17+
* - `record` is bound only when an entrance handed the run a record
18+
* (`seedRunVariables` binds `context.record` as `record`), or the flow binds
19+
* a `record` variable itself;
20+
* - with neither, `record.X` faults `Unknown variable: record`, as every other
21+
* unbound root does.
22+
*
23+
* Each entrance's context is spelled the way its door builds it: the
24+
* record-change trigger, the time-relative sweep (`trigger-schedule`), the
25+
* inbound hook (`trigger-api`), a `type: 'flow'` action (`dispatchFlowAction`
26+
* in `@objectstack/runtime`, which always hands a record — the loaded row, or
27+
* an empty one carrying at most the id it was given), a `subflow` parent and a
28+
* `map` item.
29+
*/
30+
31+
import { describe, expect, it } from 'vitest';
32+
33+
import { AutomationEngine } from './engine.js';
34+
import { installBuiltinNodes } from './builtin/index.js';
35+
36+
function silentLogger(): any {
37+
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } };
38+
}
39+
40+
function makeEngine(): AutomationEngine {
41+
const engine = new AutomationEngine(silentLogger());
42+
installBuiltinNodes(engine, { logger: silentLogger(), getService() { return undefined; } } as any);
43+
return engine;
44+
}
45+
46+
/**
47+
* start → assignment (`v` = the CEL `source`) → end. `assignee` and `title` are
48+
* input variables, so a run can bind a variable of the same name as a record
49+
* field and the pin can tell which one `record.X` read.
50+
*/
51+
function readerFlow(name: string, source: string, variables: unknown[] = []) {
52+
return {
53+
name, label: name, type: 'autolaunched',
54+
variables: [
55+
{ name: 'assignee', type: 'text', isInput: true },
56+
{ name: 'title', type: 'text', isInput: true },
57+
{ name: 'v', type: 'text', isOutput: true },
58+
...variables,
59+
],
60+
nodes: [
61+
{ id: 'start', type: 'start', label: 'Start' },
62+
{ id: 'set', type: 'assignment', label: 'Set', config: { assignments: { v: { dialect: 'cel', source } } } },
63+
{ id: 'end', type: 'end', label: 'End' },
64+
],
65+
edges: [
66+
{ id: 'e1', source: 'start', target: 'set' },
67+
{ id: 'e2', source: 'set', target: 'end' },
68+
],
69+
} as any;
70+
}
71+
72+
async function run(source: string, context: Record<string, unknown>, variables: unknown[] = []) {
73+
const engine = makeEngine();
74+
engine.registerFlow('reader', readerFlow('reader', source, variables));
75+
return engine.execute('reader', context as any);
76+
}
77+
78+
const outputOf = (result: { output?: unknown }) => (result.output as { v?: unknown } | undefined)?.v;
79+
80+
/** A run with an `assignee` variable and no record in hand: the REST trigger route's shape. */
81+
const NO_RECORD = { params: { assignee: 'u9' } };
82+
83+
describe('no record in hand: `record` is unbound, never the variables map', () => {
84+
it.each([
85+
['record.assignee'],
86+
// The card's own shape: the guard reads the alias as present and answers the variable.
87+
['has(record.assignee) ? record.assignee : null'],
88+
])('`%s` fails the run on `record` and never answers the `assignee` variable', async (source) => {
89+
const result = await run(source, NO_RECORD);
90+
expect(result.success).toBe(false);
91+
expect(result.status).toBe('failed');
92+
expect(result.error).toMatch(/Unknown variable: record\b/);
93+
expect(result.error).toContain(source);
94+
expect(outputOf(result)).not.toBe('u9');
95+
});
96+
97+
it('a predicate site reads the same scope: an edge condition on `record.assignee` fails the run', async () => {
98+
const engine = makeEngine();
99+
engine.registerFlow('gate', {
100+
name: 'gate', label: 'Gate', type: 'autolaunched',
101+
variables: [{ name: 'assignee', type: 'text', isInput: true }],
102+
nodes: [
103+
{ id: 'start', type: 'start', label: 'Start' },
104+
{ id: 'hit', type: 'end', label: 'Hit' },
105+
{ id: 'miss', type: 'end', label: 'Miss' },
106+
],
107+
edges: [
108+
{ id: 'e1', source: 'start', target: 'hit', condition: 'record.assignee == "u9"' },
109+
{ id: 'e2', source: 'start', target: 'miss', condition: 'record.assignee != "u9"' },
110+
],
111+
} as any);
112+
const result = await engine.execute('gate', NO_RECORD as any);
113+
expect(result.success).toBe(false);
114+
expect(result.status).toBe('failed');
115+
expect(result.error).toMatch(/Unknown variable: record\b/);
116+
});
117+
118+
it('the engine primitives the executors call refuse it too, naming `record`', () => {
119+
const engine = makeEngine();
120+
const variables = new Map<string, unknown>([['assignee', 'u9']]);
121+
expect(() => engine.evaluateValueEnvelope(
122+
{ dialect: 'cel', source: 'has(record.assignee) ? record.assignee : null' }, variables, 'where',
123+
)).toThrow(/Unknown variable: record\b/);
124+
expect(() => engine.evaluateCondition('record.assignee == "u9"', variables)).toThrow(/Unknown variable: record\b/);
125+
});
126+
});
127+
128+
describe('controls: what stays as it was', () => {
129+
it('a record-triggered run reads `record.X` from its record, over a variable of the same name', async () => {
130+
const result = await run('record.assignee', {
131+
record: { id: 'r1', assignee: 'rec_owner' },
132+
object: 'task',
133+
event: 'record-after-update',
134+
params: { assignee: 'u9' },
135+
});
136+
expect(result.success, result.error).toBe(true);
137+
expect(outputOf(result)).toBe('rec_owner');
138+
});
139+
140+
it.each([['assignee'], ['vars.assignee']])('`%s` still reads the variable, with no record in hand', async (source) => {
141+
const result = await run(source, NO_RECORD);
142+
expect(result.success, result.error).toBe(true);
143+
expect(outputOf(result)).toBe('u9');
144+
});
145+
146+
it('a flow that declares a `record` variable reads it, with no entrance record', async () => {
147+
const result = await run('record.assignee', NO_RECORD, [
148+
{ name: 'record', type: 'object', defaultValue: { assignee: 'declared' } },
149+
]);
150+
expect(result.success, result.error).toBe(true);
151+
expect(outputOf(result)).toBe('declared');
152+
});
153+
});
154+
155+
describe('each entrance hands its record: `record` is that record, never the variables', () => {
156+
const ROW = { id: 'r1', title: 'From the record' };
157+
const VARIABLE = { title: 'From a variable' };
158+
159+
it.each([
160+
['a record-change trigger', { record: ROW, object: 'task', event: 'record-after-update', previous: { id: 'r1' } }],
161+
['a time-relative sweep', { record: ROW, object: 'task', event: 'time_relative' }],
162+
['the inbound hook (the request body is the record)', { record: ROW, params: { ...ROW }, event: 'api' }],
163+
['a `type: \'flow\'` action on a row', { record: ROW, object: 'task', params: { recordId: 'r1' } }],
164+
])('%s', async (_entrance, context) => {
165+
const result = await run('record.title', {
166+
...context,
167+
params: { ...VARIABLE, ...((context as { params?: object }).params ?? {}) },
168+
});
169+
expect(result.success, result.error).toBe(true);
170+
expect(outputOf(result)).toBe('From the record');
171+
});
172+
173+
it('an object-less action hands an empty record: `record.X` fails on the key and never reads the variable', async () => {
174+
const result = await run('record.assignee', { record: {}, params: { assignee: 'u9' } });
175+
expect(result.success).toBe(false);
176+
expect(result.status).toBe('failed');
177+
expect(result.error).toMatch(/\bassignee\b/);
178+
expect(result.error).not.toMatch(/Unknown variable: record\b/);
179+
expect(outputOf(result)).not.toBe('u9');
180+
});
181+
182+
/** parent: start → subflow (`child`, input `title` = 'From a variable') → end, child output under `sub`. */
183+
function withSubflow(engine: AutomationEngine) {
184+
engine.registerFlow('child', readerFlow('child', 'record.title'));
185+
engine.registerFlow('parent', {
186+
name: 'parent', label: 'Parent', type: 'autolaunched',
187+
variables: [{ name: 'sub', type: 'object', isOutput: true }],
188+
nodes: [
189+
{ id: 'start', type: 'start', label: 'Start' },
190+
{
191+
id: 'call', type: 'subflow', label: 'Call',
192+
config: { flowName: 'child', input: { title: 'From a variable' }, outputVariable: 'sub' },
193+
},
194+
{ id: 'end', type: 'end', label: 'End' },
195+
],
196+
edges: [
197+
{ id: 'e1', source: 'start', target: 'call' },
198+
{ id: 'e2', source: 'call', target: 'end' },
199+
],
200+
} as any);
201+
}
202+
203+
it('a `subflow` child reads its parent\'s record', async () => {
204+
const engine = makeEngine();
205+
withSubflow(engine);
206+
const result = await engine.execute('parent', { record: ROW, object: 'task', event: 'record-after-update' } as any);
207+
expect(result.success, result.error).toBe(true);
208+
expect((result.output as { sub?: { v?: unknown } }).sub?.v).toBe('From the record');
209+
});
210+
211+
it('a parent with no record hands none: the child\'s `record.X` fails, though the child has the variable', async () => {
212+
const engine = makeEngine();
213+
withSubflow(engine);
214+
const result = await engine.execute('parent', {} as any);
215+
expect(result.success).toBe(false);
216+
expect(result.error).toMatch(/Unknown variable: record\b/);
217+
});
218+
219+
/** parent: start → map over `items` (child per item) → end, results under `mapped`. */
220+
function withMap(engine: AutomationEngine) {
221+
engine.registerFlow('child', readerFlow('child', 'record.title'));
222+
engine.registerFlow('parent', {
223+
name: 'parent', label: 'Parent', type: 'autolaunched',
224+
variables: [
225+
{ name: 'items', type: 'list', isInput: true },
226+
{ name: 'mapped', type: 'list', isOutput: true },
227+
],
228+
nodes: [
229+
{ id: 'start', type: 'start', label: 'Start' },
230+
{
231+
id: 'each', type: 'map', label: 'Each',
232+
config: {
233+
flowName: 'child', collection: '{items}', iteratorVariable: 'item',
234+
input: { title: 'From a variable' }, outputVariable: 'mapped',
235+
},
236+
},
237+
{ id: 'end', type: 'end', label: 'End' },
238+
],
239+
edges: [
240+
{ id: 'e1', source: 'start', target: 'each' },
241+
{ id: 'e2', source: 'each', target: 'end' },
242+
],
243+
} as any);
244+
}
245+
246+
it('a `map` item that carries an id is the child\'s record', async () => {
247+
const engine = makeEngine();
248+
withMap(engine);
249+
const result = await engine.execute('parent', {
250+
params: { items: [{ id: 'i1', title: 'One' }, { id: 'i2', title: 'Two' }] },
251+
} as any);
252+
expect(result.success, result.error).toBe(true);
253+
expect((result.output as { mapped?: unknown }).mapped).toEqual([{ v: 'One' }, { v: 'Two' }]);
254+
});
255+
256+
it('a `map` item with no id is not a record: the child reads the parent\'s record', async () => {
257+
const engine = makeEngine();
258+
withMap(engine);
259+
const result = await engine.execute('parent', {
260+
record: ROW, object: 'task', event: 'record-after-update', params: { items: ['a'] },
261+
} as any);
262+
expect(result.success, result.error).toBe(true);
263+
expect((result.output as { mapped?: unknown }).mapped).toEqual([{ v: 'From the record' }]);
264+
});
265+
});

0 commit comments

Comments
 (0)