diff --git a/examples/app-todo/src/flows/task.flow.ts b/examples/app-todo/src/flows/task.flow.ts index 25c82e0223c..bf97d6a9016 100644 --- a/examples/app-todo/src/flows/task.flow.ts +++ b/examples/app-todo/src/flows/task.flow.ts @@ -49,7 +49,12 @@ export const TaskReminderFlow: Flow = { ], nodes: [ - { id: 'start', type: 'start', label: 'Start (Daily 8 AM)', config: { schedule: '0 8 * * *', objectName: 'todo_task' } }, + // [#24067] No `objectName` on a schedule start node: the schedule binding + // carries no object (`AutomationEngine.deriveTriggerBinding` lifts + // `objectName` into the record-change binding only), so the key would be + // stored and never read. The sweep names its object where it reads, on the + // `get_record` step below. + { id: 'start', type: 'start', label: 'Start (Daily 8 AM)', config: { schedule: '0 8 * * *' } }, { id: 'get_upcoming_tasks', type: 'get_record', label: 'Get Tasks Due Tomorrow', // `limit > 1` is the declared way to make this a LIST read (`find`, not @@ -158,7 +163,8 @@ export const OverdueEscalationFlow: Flow = { ], nodes: [ - { id: 'start', type: 'start', label: 'Start (Daily 9 AM)', config: { schedule: '0 9 * * *', objectName: 'todo_task' } }, + // No `objectName` here either: see `task_reminder`'s start node. + { id: 'start', type: 'start', label: 'Start (Daily 9 AM)', config: { schedule: '0 9 * * *' } }, { id: 'get_overdue_tasks', type: 'get_record', label: 'Get Severely Overdue Tasks', // `limit > 1` = LIST read; the undeclared `getAll` was never read (#4277). diff --git a/examples/app-todo/test/schedule-start-no-object-name.test.ts b/examples/app-todo/test/schedule-start-no-object-name.test.ts new file mode 100644 index 00000000000..97a22996c4f --- /dev/null +++ b/examples/app-todo/test/schedule-start-no-object-name.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24067] No schedule flow this example ships stores `objectName` on its + * start node. + * + * A schedule binding carries no object. `AutomationEngine.deriveTriggerBinding` + * lifts the start node's `config.objectName` into the record-change binding + * (and, as a fallback behind `timeRelative.object`, into the time-relative + * one); its `schedule` branch hands the trigger `schedule` and `organization` + * only, and `ScheduleTrigger` never reads `objectName`. On a schedule start + * node the key is stored and never acted on, and the console's flow designer + * labels it "kept, not in effect". An example is what an AI copies, so the + * example must not model it: a sweep names its object where it reads, on its + * `get_record` step, as both daily sweeps here do. + * + * The flows are read off the stack the example ships (its default + * `defineStack` export), never a hand list, so a schedule flow added later is + * covered on the day it lands. "Schedule" is the spec's + * `resolveFlowTriggerKind` answer, the one predicate the engine binds by: a + * time-relative sweep, whose binding DOES fall back to `objectName`, is + * correctly left out, and every other `type: 'schedule'` flow is in. + */ + +import { describe, it, expect } from 'vitest'; +import { resolveFlowTriggerKind } from '@objectstack/spec/automation'; + +import TodoApp from '../objectstack.config.js'; + +type NodeLike = { id?: string; type?: string; config?: Record }; +type FlowLike = { name?: string; type?: string; nodes?: NodeLike[] }; + +const shippedFlows = ((TodoApp as unknown as { flows?: FlowLike[] }).flows ?? []); +const scheduleFlows = shippedFlows.filter((flow) => resolveFlowTriggerKind(flow) === 'schedule'); + +function startConfig(flow: FlowLike): Record { + const start = (flow.nodes ?? []).find((node) => node.type === 'start'); + if (!start) throw new Error(`harness: flow '${flow.name}' has no start node`); + return start.config ?? {}; +} + +describe('#24067: a schedule start node carries no objectName', () => { + it('reads real schedule flows off the shipped stack (anti-vacuity)', () => { + expect(scheduleFlows.length).toBeGreaterThan(0); + // Each start node read here is the one the schedule binding reads: it + // carries the cadence that binding DOES act on. + for (const flow of scheduleFlows) { + expect(startConfig(flow), `flow '${flow.name}' start node`).toHaveProperty('schedule'); + } + }); + + it('no schedule flow stores objectName on its start node', () => { + const offenders = scheduleFlows + .filter((flow) => Object.hasOwn(startConfig(flow), 'objectName')) + .map((flow) => `flow '${flow.name}' ยท start node config.objectName`); + expect(offenders).toEqual([]); + }); +});