Skip to content

Commit 1b0b4cf

Browse files
committed
wip(#15556): reproduction probe for the subflow-hosted approval bubble
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
1 parent f141e15 commit 1b0b4cf

1 file changed

Lines changed: 246 additions & 0 deletions

File tree

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* REPRODUCTION PROBE for #15556 — an approval hosted inside a SUBFLOW CHILD.
5+
* Not a deliverable yet: this file exists to measure what the decision door
6+
* actually answers when `bubbleToParent` fails, with its controls in the
7+
* same run.
8+
*/
9+
10+
import { describe, it, expect, beforeEach } from 'vitest';
11+
import { AutomationEngine, InMemorySuspendedRunStore, installBuiltinNodes } from '@objectstack/service-automation';
12+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/objectql';
13+
import { strandedDecisionDetails } from '@objectstack/types';
14+
import { ApprovalService } from './approval-service.js';
15+
import { registerApprovalNode } from './approval-node.js';
16+
17+
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any;
18+
19+
/** Records every level so the "only artefact is a log line" claim is measurable. */
20+
function recordingLogger() {
21+
const lines: Array<{ level: string; msg: string; meta?: unknown }> = [];
22+
const mk = (level: string) => (msg: string, meta?: unknown) => { lines.push({ level, msg, meta }); };
23+
const self: any = {
24+
lines,
25+
info: mk('info'), warn: mk('warn'), error: mk('error'), debug: mk('debug'),
26+
child() { return self; },
27+
};
28+
return self;
29+
}
30+
31+
function makeFakeEngine() {
32+
const tables = new Map<string, any[]>();
33+
const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!));
34+
const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => {
35+
if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`);
36+
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]);
37+
if (v && typeof v === 'object' && '$ne' in (v as any)) return row[k] !== (v as any).$ne;
38+
return row[k] === v;
39+
});
40+
return {
41+
tables,
42+
async find(object: string, opts: any = {}) {
43+
const where = opts.where ?? opts.filter ?? {};
44+
const out = rows(object).filter(r => matches(r, where));
45+
const start = opts.offset ?? 0;
46+
const page = typeof opts.limit === 'number' ? out.slice(start, start + opts.limit) : out.slice(start);
47+
return page.map(r => ({ ...r }));
48+
},
49+
async insert(object: string, data: any) { rows(object).push({ ...data }); return { ...data }; },
50+
async update(object: string, data: any, options?: any) {
51+
const dispatch = assertEngineUpdateDispatch(data, options);
52+
const table = rows(object);
53+
if (dispatch.kind === 'multi') {
54+
let n = 0;
55+
for (let i = 0; i < table.length; i++) {
56+
if (matches(table[i], options?.where)) { table[i] = { ...table[i], ...data }; n++; }
57+
}
58+
return { updated: n };
59+
}
60+
const i = table.findIndex(r => r.id === dispatch.id);
61+
if (i >= 0) table[i] = { ...table[i], ...data };
62+
return i >= 0 ? { ...table[i] } : null;
63+
},
64+
async delete(object: string, options?: any) {
65+
const dispatch = assertEngineDeleteDispatch(options);
66+
const table = rows(object);
67+
if (dispatch.kind === 'multi') {
68+
const survivors = table.filter(r => !matches(r, options?.where));
69+
const deleted = table.length - survivors.length;
70+
table.splice(0, table.length, ...survivors);
71+
return { deleted };
72+
}
73+
const i = table.findIndex(r => r.id === dispatch.id);
74+
if (i >= 0) table.splice(i, 1);
75+
return { id: dispatch.id };
76+
},
77+
};
78+
}
79+
80+
/** The CHILD: an approval node, exactly the #13807 fixture. */
81+
const CHILD = {
82+
name: 'deal_approval',
83+
label: 'Deal Approval',
84+
type: 'autolaunched',
85+
nodes: [
86+
{ id: 'start', type: 'start', label: 'Start' },
87+
{ id: 'approve_step', type: 'approval', label: 'Manager Approval', config: { approvers: [{ type: 'user', value: 'u1' }] } },
88+
{ id: 'on_approved', type: 'mark', label: 'Approved' },
89+
{ id: 'mark_rejected', type: 'mark', label: 'Rejected' },
90+
{ id: 'end', type: 'end', label: 'End' },
91+
],
92+
edges: [
93+
{ id: 'e1', source: 'start', target: 'approve_step' },
94+
{ id: 'e2', source: 'approve_step', target: 'on_approved', label: 'approve' },
95+
{ id: 'e3', source: 'approve_step', target: 'mark_rejected', label: 'reject' },
96+
{ id: 'e4', source: 'on_approved', target: 'end' },
97+
{ id: 'e5', source: 'mark_rejected', target: 'end' },
98+
],
99+
};
100+
101+
/** The PARENT: hosts the child in a `subflow` node, then does more work. */
102+
const PARENT = {
103+
name: 'deal_parent',
104+
label: 'Deal Parent',
105+
type: 'autolaunched',
106+
nodes: [
107+
{ id: 'pstart', type: 'start', label: 'Start' },
108+
{ id: 'sub', type: 'subflow', label: 'Run the approval subflow', config: { flowName: 'deal_approval', outputVariable: 'subOut' } },
109+
{ id: 'after_sub', type: 'mark', label: 'After the subflow' },
110+
{ id: 'pend', type: 'end', label: 'End' },
111+
],
112+
edges: [
113+
{ id: 'p1', source: 'pstart', target: 'sub' },
114+
{ id: 'p2', source: 'sub', target: 'after_sub' },
115+
{ id: 'p3', source: 'after_sub', target: 'pend' },
116+
],
117+
};
118+
119+
describe('#15556 probe — approval inside a subflow child, parent bubble fails', () => {
120+
let data: ReturnType<typeof makeFakeEngine>;
121+
let service: ApprovalService;
122+
let logger: ReturnType<typeof recordingLogger>;
123+
let marks: string[];
124+
let throwOn: Record<string, string | undefined>;
125+
126+
beforeEach(() => {
127+
marks = [];
128+
throwOn = {};
129+
logger = recordingLogger();
130+
data = makeFakeEngine();
131+
service = new ApprovalService({ engine: data as any, logger });
132+
});
133+
134+
function boot() {
135+
const automation = new AutomationEngine(logger, new InMemorySuspendedRunStore());
136+
installBuiltinNodes(automation, { logger, getService() { throw new Error('none'); } } as any);
137+
registerApprovalNode(automation, service, logger);
138+
automation.registerNodeExecutor({
139+
type: 'mark',
140+
async execute(node: any) {
141+
const boom = throwOn[node.id];
142+
if (boom) throw new Error(boom);
143+
marks.push(node.id);
144+
return { success: true };
145+
},
146+
} as never);
147+
automation.registerFlow('deal_approval', CHILD as never);
148+
automation.registerFlow('deal_parent', PARENT as never);
149+
service.attachAutomation(automation);
150+
return automation;
151+
}
152+
153+
const pendingRequest = async () =>
154+
(await data.find('sys_approval_request', { where: { status: 'pending' } }))[0];
155+
156+
it('MEASUREMENT — parent bubble fails: what does the door answer?', async () => {
157+
throwOn.after_sub = 'update_record(crm_leave_request) failed: Record 9SEmlyRfw8D9-J7Z not found';
158+
const automation = boot();
159+
160+
const started = await automation.execute('deal_parent', {
161+
object: 'crm_deal', record: { id: 'd1', amount: 100 }, userId: 'submitter',
162+
} as never);
163+
// eslint-disable-next-line no-console
164+
console.log('PROBE started =', JSON.stringify(started));
165+
const parentRunId = (started as any).runId as string;
166+
167+
const req = await pendingRequest();
168+
// eslint-disable-next-line no-console
169+
console.log('PROBE request =', JSON.stringify(req && { id: req.id, run: req.flow_run_id, status: req.status }));
170+
const childRunId = req?.flow_run_id as string;
171+
expect(childRunId, 'the request must name the CHILD run').toBeTruthy();
172+
expect(childRunId).not.toBe(parentRunId);
173+
expect(await automation.hasSuspendedRun(parentRunId)).toBe(true);
174+
175+
// Capture the envelope `bubbleToParent` receives for the PARENT resume —
176+
// the thing the swallowing catch throws away.
177+
const bubbled: any[] = [];
178+
const realInternal = (automation as any).resumeInternal.bind(automation);
179+
(automation as any).resumeInternal = async (...args: any[]) => {
180+
const r = await realInternal(...args);
181+
if (args[0] === parentRunId) bubbled.push(r);
182+
return r;
183+
};
184+
185+
const outcome = await service
186+
.decide(req.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX)
187+
.then(r => ({ ok: true as const, r }), (e: Error) => ({ ok: false as const, e }));
188+
189+
// eslint-disable-next-line no-console
190+
console.log('PROBE door =', JSON.stringify(outcome.ok ? outcome.r : { threw: outcome.e.message, details: strandedDecisionDetails(outcome.e) }));
191+
// eslint-disable-next-line no-console
192+
console.log('PROBE marks =', JSON.stringify(marks));
193+
// eslint-disable-next-line no-console
194+
console.log('PROBE parent suspended?', await automation.hasSuspendedRun(parentRunId));
195+
// eslint-disable-next-line no-console
196+
console.log('PROBE parent resume =', JSON.stringify(await automation.resume(parentRunId)));
197+
const parentRow = await automation.getRun(parentRunId);
198+
// eslint-disable-next-line no-console
199+
console.log('PROBE parent run row =', JSON.stringify(parentRow && {
200+
status: (parentRow as any).status, error: (parentRow as any).error,
201+
consumedSuspension: Boolean((parentRow as any).consumedSuspension),
202+
}));
203+
// eslint-disable-next-line no-console
204+
console.log('PROBE child run row =', JSON.stringify(await automation.getRun(childRunId).then(r => r && { status: (r as any).status })));
205+
// eslint-disable-next-line no-console
206+
console.log('PROBE request row =', JSON.stringify((await data.find('sys_approval_request', { where: { id: req.id } }))[0]?.status));
207+
// eslint-disable-next-line no-console
208+
console.log('PROBE parent bubble envelope =', JSON.stringify(bubbled.map(b => ({ success: b.success, code: b.code, status: b.status, error: b.error }))));
209+
// eslint-disable-next-line no-console
210+
console.log('PROBE parent restore =', JSON.stringify(await automation.restoreConsumedSuspension(parentRunId, { requestedBy: 'probe' })));
211+
// eslint-disable-next-line no-console
212+
console.log('PROBE log lines =', JSON.stringify(logger.lines.filter((l: any) => l.level !== 'debug' && l.level !== 'info').map((l: any) => [l.level, l.msg])));
213+
});
214+
215+
it('CONTROL A — same composition, parent downstream node healthy', async () => {
216+
const automation = boot();
217+
const started = await automation.execute('deal_parent', {
218+
object: 'crm_deal', record: { id: 'd2', amount: 100 }, userId: 'submitter',
219+
} as never);
220+
const parentRunId = (started as any).runId as string;
221+
const req = await pendingRequest();
222+
const outcome = await service
223+
.decide(req.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX)
224+
.then(r => ({ ok: true as const, r }), (e: Error) => ({ ok: false as const, e }));
225+
// eslint-disable-next-line no-console
226+
console.log('CTRL-A door =', JSON.stringify(outcome.ok ? outcome.r : { threw: outcome.e.message }));
227+
// eslint-disable-next-line no-console
228+
console.log('CTRL-A marks =', JSON.stringify(marks));
229+
// eslint-disable-next-line no-console
230+
console.log('CTRL-A parent run row =', JSON.stringify(await automation.getRun(parentRunId).then(r => r && { status: (r as any).status })));
231+
});
232+
233+
it('CONTROL B — no subflow: the #13807 shape still throws at this door', async () => {
234+
throwOn.on_approved = 'the child branch blew up';
235+
const automation = boot();
236+
await automation.execute('deal_approval', {
237+
object: 'crm_deal', record: { id: 'd3', amount: 100 }, userId: 'submitter',
238+
} as never);
239+
const req = await pendingRequest();
240+
const outcome = await service
241+
.decide(req.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX)
242+
.then(r => ({ ok: true as const, r }), (e: Error) => ({ ok: false as const, e }));
243+
// eslint-disable-next-line no-console
244+
console.log('CTRL-B door =', JSON.stringify(outcome.ok ? outcome.r : { threw: outcome.e.message, details: strandedDecisionDetails(outcome.e) }));
245+
});
246+
});

0 commit comments

Comments
 (0)