Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions examples/app-todo/src/flows/task.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
58 changes: 58 additions & 0 deletions examples/app-todo/test/schedule-start-no-object-name.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
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<string, unknown> {
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([]);
});
});
Loading