|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// [#20864, ADR-0126 §2 / §7.3] Boot-time flow precedence classifies its |
| 4 | +// contenders by THE LOADER'S SET — the reader the engine holds |
| 5 | +// (`setPackagedFlowSource` / `packagedFlowOwner`) — and never by the stamps a |
| 6 | +// flow body carries. |
| 7 | +// |
| 8 | +// For a flow, "packaged" means exactly "loaded by the loader from a managed |
| 9 | +// package", and every classification the engine makes reads that one |
| 10 | +// server-held fact. The §7.3 guards, the arming gate and the activation door |
| 11 | +// were moved onto it first (`packaged-flow-source.test.ts`); this file pins the |
| 12 | +// last reader, the precedence that decides which same-named body the boot pull |
| 13 | +// arms. Every pin hands precedence an EXPLICIT set and reads the answer off the |
| 14 | +// classification, the shadowing record and the armed body — and the last block |
| 15 | +// boots the real plugin, so the wiring from the metadata protocol's answer to |
| 16 | +// the boot pull is pinned too, not only the pure function. |
| 17 | + |
| 18 | +import { describe, it, expect, vi } from 'vitest'; |
| 19 | +import { LiteKernel } from '@objectstack/core'; |
| 20 | +import type { Plugin, PluginContext } from '@objectstack/core'; |
| 21 | +import { AutomationServicePlugin } from './plugin.js'; |
| 22 | +import type { AutomationEngine, FlowContender } from './engine.js'; |
| 23 | +import { resolveFlowPrecedence, describeFlowContender, renderFlowContender } from './flow-precedence.js'; |
| 24 | + |
| 25 | +const FLOW = 'order_sync'; |
| 26 | + |
| 27 | +function flowBody(name: string, label: string, extra: Record<string, unknown> = {}) { |
| 28 | + return { |
| 29 | + name, |
| 30 | + label, |
| 31 | + type: 'autolaunched', |
| 32 | + nodes: [ |
| 33 | + { id: 'start', type: 'start', label: 'Start', config: {} }, |
| 34 | + { id: 'end', type: 'end', label: 'End' }, |
| 35 | + ], |
| 36 | + edges: [{ id: 'e1', source: 'start', target: 'end' }], |
| 37 | + ...extra, |
| 38 | + }; |
| 39 | +} |
| 40 | + |
| 41 | +/** The stamps a body carries when it claims a package's provenance. */ |
| 42 | +const CLAIMED = { _packageId: 'crm', _provenance: 'package' }; |
| 43 | +/** What the boot hydration leaves on a stored row: the tenant marker, here with a binding. */ |
| 44 | +const TENANT = { _packageId: 'app.ops', _provenance: 'org' }; |
| 45 | + |
| 46 | +/** The loader's entry for FLOW, as the registry holds it once the loader stamped it. */ |
| 47 | +const loaderEntry = () => flowBody(FLOW, 'LOADER', CLAIMED); |
| 48 | +/** A stored tenant row of the same name, as the hydration registers it. */ |
| 49 | +const tenantRow = () => flowBody(FLOW, 'TENANT', TENANT); |
| 50 | + |
| 51 | +/** An explicit loader's set: flow name → the package that loaded it. */ |
| 52 | +const setOf = (set: Record<string, string>) => (name: string): string | undefined => set[name]; |
| 53 | +const HOLDS_FLOW = setOf({ [FLOW]: 'crm' }); |
| 54 | +const HOLDS_NOTHING = setOf({}); |
| 55 | + |
| 56 | +const silent = () => ({ warn: vi.fn() }); |
| 57 | + |
| 58 | +describe('[#20864] boot-time precedence classifies by the loader\'s set, not by body stamps', () => { |
| 59 | + it('a contender claiming a package ranks as tenant-authored when the set does not hold its name', () => { |
| 60 | + const claimed = loaderEntry(); |
| 61 | + expect(describeFlowContender(claimed, HOLDS_NOTHING)).toEqual({ source: 'runtime', packageId: 'crm' }); |
| 62 | + // The set is keyed by NAME: holding another flow vouches for nothing here. |
| 63 | + expect(describeFlowContender(claimed, setOf({ other_flow: 'crm' }))).toEqual({ |
| 64 | + source: 'runtime', |
| 65 | + packageId: 'crm', |
| 66 | + }); |
| 67 | + // The operator reads it as the tenant row it is; the claimed package |
| 68 | + // never reaches the sentence. |
| 69 | + expect(renderFlowContender(describeFlowContender(claimed, HOLDS_NOTHING))).toBe( |
| 70 | + renderFlowContender({ source: 'runtime' }), |
| 71 | + ); |
| 72 | + }); |
| 73 | + |
| 74 | + it('the same contender ranks as packaged when the set holds its name', () => { |
| 75 | + expect(describeFlowContender(loaderEntry(), HOLDS_FLOW)).toEqual({ source: 'package', packageId: 'crm' }); |
| 76 | + }); |
| 77 | + |
| 78 | + it('a stored tenant row stays tenant-authored inside a name the set holds', () => { |
| 79 | + expect(describeFlowContender(tenantRow(), HOLDS_FLOW)).toEqual({ source: 'runtime', packageId: 'app.ops' }); |
| 80 | + }); |
| 81 | + |
| 82 | + it('the same two bodies rank by the set: only a held name puts a packaged contender in the shadowing record', () => { |
| 83 | + const held = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), HOLDS_FLOW); |
| 84 | + expect(held).toHaveLength(1); |
| 85 | + expect((held[0].definition as { label: string }).label).toBe('TENANT'); |
| 86 | + expect(held[0].shadowing).toEqual({ |
| 87 | + name: FLOW, |
| 88 | + armed: { source: 'runtime', packageId: 'app.ops' }, |
| 89 | + shadowed: [{ source: 'package', packageId: 'crm' }], |
| 90 | + }); |
| 91 | + |
| 92 | + const unheld = resolveFlowPrecedence([tenantRow(), loaderEntry()], silent(), HOLDS_NOTHING); |
| 93 | + expect(unheld[0].shadowing).toEqual({ |
| 94 | + name: FLOW, |
| 95 | + armed: { source: 'runtime', packageId: 'app.ops' }, |
| 96 | + shadowed: [{ source: 'runtime', packageId: 'crm' }], |
| 97 | + }); |
| 98 | + }); |
| 99 | + |
| 100 | + it('the shadowing record names the armed body in either arrival order, and the pull warning agrees', () => { |
| 101 | + for (const listed of [ |
| 102 | + [loaderEntry(), tenantRow()], |
| 103 | + [tenantRow(), loaderEntry()], |
| 104 | + ]) { |
| 105 | + const logger = silent(); |
| 106 | + const [winner] = resolveFlowPrecedence(listed, logger, HOLDS_FLOW); |
| 107 | + expect((winner.definition as { label: string }).label).toBe('TENANT'); |
| 108 | + expect(winner.shadowing?.armed).toEqual({ source: 'runtime', packageId: 'app.ops' }); |
| 109 | + expect(winner.shadowing?.shadowed).toEqual([{ source: 'package', packageId: 'crm' }]); |
| 110 | + |
| 111 | + expect(logger.warn).toHaveBeenCalledTimes(1); |
| 112 | + const [message, meta] = logger.warn.mock.calls[0] as [string, { armed: FlowContender; shadowed: FlowContender[] }]; |
| 113 | + expect(message).toContain(`arming ${renderFlowContender({ source: 'runtime' })}`); |
| 114 | + expect(message).toContain(renderFlowContender({ source: 'package', packageId: 'crm' })); |
| 115 | + expect(meta.armed).toEqual(winner.shadowing?.armed); |
| 116 | + expect(meta.shadowed).toEqual(winner.shadowing?.shadowed); |
| 117 | + } |
| 118 | + }); |
| 119 | + |
| 120 | + it('tenant-ranked contenders keep arrival order: a body\'s own id does not order them', () => { |
| 121 | + // Both tenant-ranked (the set holds nothing). By id alone the tenant |
| 122 | + // row would sort first; arrival order decides instead, so a claimed id |
| 123 | + // cannot buy the armed slot. |
| 124 | + const first = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent(), HOLDS_NOTHING); |
| 125 | + expect((first[0].definition as { label: string }).label).toBe('LOADER'); |
| 126 | + const second = resolveFlowPrecedence([tenantRow(), loaderEntry()], silent(), HOLDS_NOTHING); |
| 127 | + expect((second[0].definition as { label: string }).label).toBe('TENANT'); |
| 128 | + }); |
| 129 | + |
| 130 | + it('with no reader nothing is packaged: the engine\'s fail-closed answer', () => { |
| 131 | + expect(describeFlowContender(loaderEntry())).toEqual({ source: 'runtime', packageId: 'crm' }); |
| 132 | + |
| 133 | + const [winner] = resolveFlowPrecedence([loaderEntry(), tenantRow()], silent()); |
| 134 | + const record = winner.shadowing!; |
| 135 | + expect([record.armed, ...record.shadowed].map((c) => c.source)).toEqual(['runtime', 'runtime']); |
| 136 | + |
| 137 | + // A reader answering an empty owner names no package either — the |
| 138 | + // normalization `AutomationEngine.packagedFlowOwner` applies. |
| 139 | + expect(describeFlowContender(loaderEntry(), () => '')).toEqual({ source: 'runtime', packageId: 'crm' }); |
| 140 | + }); |
| 141 | + |
| 142 | + it('asks the set once per contested name, and never for an uncontested one', () => { |
| 143 | + const reader = vi.fn(HOLDS_FLOW); |
| 144 | + resolveFlowPrecedence([flowBody('lonely_flow', 'L', CLAIMED), loaderEntry(), tenantRow()], silent(), reader); |
| 145 | + expect(reader.mock.calls).toEqual([[FLOW]]); |
| 146 | + }); |
| 147 | +}); |
| 148 | + |
| 149 | +// ── The boot pull, through the real plugin ───────────────────────────────── |
| 150 | + |
| 151 | +/** The one seam the boot pull reads — `registry.listItems('flow')`. */ |
| 152 | +function fakeObjectqlPlugin(flows: unknown[]): Plugin { |
| 153 | + return { |
| 154 | + name: 'fake-objectql', |
| 155 | + version: '1.0.0', |
| 156 | + async init(ctx: PluginContext) { |
| 157 | + (ctx as unknown as { registerService(n: string, s: unknown): void }).registerService('objectql', { |
| 158 | + registry: { |
| 159 | + listItems: (type: string) => (type === 'flow' ? flows : []), |
| 160 | + getObject: () => undefined, |
| 161 | + }, |
| 162 | + }); |
| 163 | + }, |
| 164 | + }; |
| 165 | +} |
| 166 | + |
| 167 | +/** |
| 168 | + * The metadata protocol, reduced to the loader's-set read the plugin's |
| 169 | + * `packagedFlowReader` asks, plus an empty execution view so the kernel:ready |
| 170 | + * sync registers nothing over the pull under test. |
| 171 | + */ |
| 172 | +function fakeProtocolPlugin(packagedArtifactOwner: (request: { type: string; name: string }) => string | undefined): Plugin { |
| 173 | + return { |
| 174 | + name: 'fake-protocol', |
| 175 | + version: '1.0.0', |
| 176 | + async init(ctx: PluginContext) { |
| 177 | + (ctx as unknown as { registerService(n: string, s: unknown): void }).registerService('protocol', { |
| 178 | + packagedArtifactOwner, |
| 179 | + async getMetaItemsForExecution() { |
| 180 | + return { items: [] }; |
| 181 | + }, |
| 182 | + }); |
| 183 | + }, |
| 184 | + }; |
| 185 | +} |
| 186 | + |
| 187 | +async function boot(flows: unknown[], protocol?: Plugin) { |
| 188 | + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); |
| 189 | + kernel.use(fakeObjectqlPlugin(flows)); |
| 190 | + if (protocol) kernel.use(protocol); |
| 191 | + kernel.use(new AutomationServicePlugin()); |
| 192 | + await kernel.bootstrap(); |
| 193 | + return { kernel, engine: kernel.getService<AutomationEngine>('automation') }; |
| 194 | +} |
| 195 | + |
| 196 | +describe('[#20864] the boot pull hands precedence the engine\'s loader\'s-set reader', () => { |
| 197 | + it('a held name: the shadowing receipt names the tenant row armed and the loader entry packaged', async () => { |
| 198 | + const owner = vi.fn((request: { type: string; name: string }) => |
| 199 | + request.type === 'flow' && request.name === FLOW ? 'crm' : undefined, |
| 200 | + ); |
| 201 | + const { kernel, engine } = await boot([loaderEntry(), tenantRow()], fakeProtocolPlugin(owner)); |
| 202 | + try { |
| 203 | + expect(engine.getShadowedFlows()).toEqual([ |
| 204 | + { |
| 205 | + name: FLOW, |
| 206 | + armed: { source: 'runtime', packageId: 'app.ops' }, |
| 207 | + shadowed: [{ source: 'package', packageId: 'crm' }], |
| 208 | + }, |
| 209 | + ]); |
| 210 | + expect(((await engine.getFlow(FLOW)) as { label?: string } | null)?.label).toBe('TENANT'); |
| 211 | + // One source: the pull asked the protocol for this flow, and the |
| 212 | + // engine's own reader gives the same answer. |
| 213 | + expect(owner).toHaveBeenCalledWith({ type: 'flow', name: FLOW }); |
| 214 | + expect(engine.packagedFlowOwner(FLOW)).toBe('crm'); |
| 215 | + } finally { |
| 216 | + await kernel.shutdown(); |
| 217 | + } |
| 218 | + }); |
| 219 | + |
| 220 | + it('an unheld name: no contender is packaged, whatever the bodies claim', async () => { |
| 221 | + const { kernel, engine } = await boot([tenantRow(), loaderEntry()], fakeProtocolPlugin(() => undefined)); |
| 222 | + try { |
| 223 | + const [record] = engine.getShadowedFlows(); |
| 224 | + expect(record.name).toBe(FLOW); |
| 225 | + expect([record.armed, ...record.shadowed].map((c) => c.source)).toEqual(['runtime', 'runtime']); |
| 226 | + } finally { |
| 227 | + await kernel.shutdown(); |
| 228 | + } |
| 229 | + }); |
| 230 | + |
| 231 | + it('no protocol service: fail closed, no contender is packaged', async () => { |
| 232 | + const { kernel, engine } = await boot([loaderEntry(), tenantRow()]); |
| 233 | + try { |
| 234 | + const [record] = engine.getShadowedFlows(); |
| 235 | + expect([record.armed, ...record.shadowed].map((c) => c.source)).toEqual(['runtime', 'runtime']); |
| 236 | + } finally { |
| 237 | + await kernel.shutdown(); |
| 238 | + } |
| 239 | + }); |
| 240 | +}); |
0 commit comments