From 73f5bf9968a8137cb4b55b82b0940071c9876254 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 04:36:44 +0000 Subject: [PATCH] fix(example-todo): drop objectName from the two schedule start nodes task_reminder and overdue_escalation stored objectName on a schedule start node. The schedule binding carries no object: deriveTriggerBinding lifts objectName into the record-change binding only, and ScheduleTrigger never reads it, so the key was stored and never acted on (the flow designer flags it "kept, not in effect"). Both sweeps already name todo_task on their get_record steps. Adds a pin over the shipped stack: no flow whose resolved trigger kind is schedule carries objectName on its start node. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_018VqJPGA1NDNSCZsnecsChP --- examples/app-todo/src/flows/task.flow.ts | 10 +++- .../schedule-start-no-object-name.test.ts | 58 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 examples/app-todo/test/schedule-start-no-object-name.test.ts 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([]); + }); +});