diff --git a/.changeset/todo-recurrence-next-due-date.md b/.changeset/todo-recurrence-next-due-date.md new file mode 100644 index 0000000000..f707ffd7e7 --- /dev/null +++ b/.changeset/todo-recurrence-next-due-date.md @@ -0,0 +1,53 @@ +--- +"@objectstack/example-todo": patch +--- + +fix(example-todo): `task_completion`'s recurrence branch computes a real next due date — it wrote a literal `DATEADD(...)` string the driver refused (#7037) + +`examples/app-todo`'s `TaskCompletionFlow` spawned the next occurrence of a recurring task +with + +``` +due_date: 'DATEADD({completedTask.due_date}, {completedTask.recurrence_interval}, "{completedTask.recurrence_type}")' +``` + +**Two independent faults, stacked.** `DATEADD` exists nowhere in the platform — not a CEL +builtin, not registered by `packages/formula` under any casing. And a `create_record` +node's `fields` values are TEMPLATE-interpolated, never evaluated: the `{…}` holes are +filled and the surrounding text passes through verbatim. So what reached the engine was +the literal string `DATEADD(2026-08-10, 1, "daily")`, and the field's own coercion refused +it with `Due Date must be a valid date (ISO-8601)`, failing the whole run. + +**Reachability changed with #6882; the defect did not.** While the flow was unbound the +node never executed and the dead function text was inert. Armed, every completion of a +*recurring* task produced a failed run, so the recurrence feature the node exists for had +never once worked. + +**Why the repair is a `script` node and not a better expression.** No flow node evaluates +a value-producing expression. The builtin vocabulary's only expression slots are +PREDICATES (`config.condition`, `edge.condition`, `decision.conditions[].expression`, +`screen.fields[].visibleWhen`) and `flow-template` REFERENCES (`loop.collection`, +`map.collection`) — the ledger is `FLOW_NODE_EXPRESSION_PATHS` in +`@objectstack/spec/automation` — and an `assignment` node interpolates rather than +evaluates. The next due date therefore has to be computed *before* the create node runs. + +A `compute_next_due_date` `script` node now calls `computeNextTaskDueDate`, registered +through `defineStack({ functions })` — the pure-function shape (#1870, #4396) that +`showcase_task_completed` already uses: it takes `input`, returns the date, and +`create_next_task` persists it by reading the whole-string token `{nextDueDate}`. The +function handles all four authored cadences (daily / weekly / monthly / yearly × interval), +clamps a monthly shift to the target month's last day exactly as `@objectstack/formula`'s +`addMonths` does — so the app cannot teach a recurrence semantic that disagrees with the +platform's own formula function — and refuses an unknown `recurrence_type` or an interval +`min: 1` forbids instead of guessing a cadence. + +The non-recurring path is unchanged: the `check_recurring` gate still routes straight to +`end`, skipping both nodes. + +New suite `test/task-recurrence.test.ts` drives the app's real metadata, real object and +real function registry through a real kernel over sqlite: the spawned task's `due_date` is +asserted for daily / weekly / monthly completions, and a reverse fixture rebuilt from the +live flow shows the pre-fix shape — any function-call text left in a `create_record` field +value — still failing inside `create_next_task` with the date refusal, and notably *not* +with "no function named …", because nothing ever tried to call one. A class-level guard +asserts no write node in any of the app's flows leaves function-call text in a field value. diff --git a/examples/app-todo/objectstack.config.ts b/examples/app-todo/objectstack.config.ts index ab46b17dcb..2611a80b51 100644 --- a/examples/app-todo/objectstack.config.ts +++ b/examples/app-todo/objectstack.config.ts @@ -10,6 +10,7 @@ import * as datasets from './src/datasets/index.js'; import * as reports from './src/reports/index.js'; import * as views from './src/views/index.js'; import { allFlows } from './src/flows/index.js'; +import { todoFunctions } from './src/functions/index.js'; import * as apps from './src/apps/index.js'; import { TodoSeedData } from './src/data/index.js'; import * as translations from './src/translations/index.js'; @@ -53,6 +54,18 @@ export default defineStack({ datasets: Object.values(datasets), reports: Object.values(reports), flows: allFlows, + + // Named callables a `script` flow node invokes (#1870) — the automation + // plugin bridges this map to `AutomationEngine.resolveFunction`, so a node's + // `config.function` resolves by name at run time. A flow function is PURE: it + // takes `input`, RETURNS a value, and a later declarative node persists it + // (#4396), which is why none of these declares an `effect`. + // + // `computeNextTaskDueDate` is what makes `task_completion`'s recurrence branch + // work: no flow node evaluates a value-producing expression, so the next due + // date has to be computed before `create_next_task` runs (#7037). + functions: todoFunctions, + apps: Object.values(apps), // I18n Configuration — per-locale file organization diff --git a/examples/app-todo/src/flows/task.flow.ts b/examples/app-todo/src/flows/task.flow.ts index 70433f56d3..d26037c204 100644 --- a/examples/app-todo/src/flows/task.flow.ts +++ b/examples/app-todo/src/flows/task.flow.ts @@ -143,6 +143,11 @@ export const TaskCompletionFlow: Flow = { // in the corpus addresses it, so the declaration is gone rather than // re-plumbed — declared means bound. { name: 'completedTask', type: 'record', isInput: false, isOutput: false }, + // #7037 — the next due date, computed by the `compute_next_due_date` script + // node below and persisted by `create_next_task`. Declared for the same + // reason `showcase_task_completed` declares its `summary`: a script node's + // `outputVariable` is the flow's contract with the node after it. + { name: 'nextDueDate', type: 'date', isInput: false, isOutput: false }, ], nodes: [ @@ -188,6 +193,45 @@ export const TaskCompletionFlow: Flow = { // on a `start` node and inert everywhere else, so it was a third copy of the // same predicate, doing nothing (#4414). { id: 'check_recurring', type: 'decision', label: 'Is Recurring Task?' }, + // #7037 — the date arithmetic the recurrence needs, on the one surface that + // actually evaluates anything. + // + // `due_date` below used to read + // `DATEADD({completedTask.due_date}, {completedTask.recurrence_interval}, "…")`. + // Two independent faults: `DATEADD` exists nowhere in the platform (not a + // CEL builtin, not registered by `packages/formula` under any casing), and a + // `create_record` node's `fields` are TEMPLATE-interpolated rather than + // evaluated — the `{…}` holes are filled and the surrounding text passed + // through verbatim. So the driver received the literal string + // `DATEADD(2026-08-10, 1, "daily")` and refused the write with `Due Date + // must be a valid date (ISO-8601)`, failing the whole run. Dead while the + // flow was unbound; live on every recurring completion once #6882 armed it. + // + // Computing it here is not a stylistic preference — no flow node evaluates a + // value-producing expression. The builtin vocabulary's only expression slots + // are PREDICATES (`config.condition`, `edge.condition`, + // `decision.conditions[].expression`, `screen.fields[].visibleWhen`) and + // `flow-template` REFERENCES (`loop.collection`, `map.collection`) — the + // ledger is `FLOW_NODE_EXPRESSION_PATHS` in `@objectstack/spec/automation`, + // and an `assignment` node interpolates rather than evaluates. A `script` + // node calling a registered function is the shipped way to compute a value + // mid-flow (#1870), and the pure-function shape — takes `input`, RETURNS a + // value, a later declarative node persists it (#4396) — is the one + // `showcase_task_completed` already uses. + { + id: 'compute_next_due_date', type: 'script', label: 'Compute Next Due Date', + config: { + // Registered in `defineStack({ functions })` — see objectstack.config.ts + // and src/functions/task.functions.ts. + function: 'computeNextTaskDueDate', + inputs: { + dueDate: '{completedTask.due_date}', + recurrenceType: '{completedTask.recurrence_type}', + interval: '{completedTask.recurrence_interval}', + }, + outputVariable: 'nextDueDate', + }, + }, { id: 'create_next_task', type: 'create_record', label: 'Create Next Recurring Task', config: { @@ -198,7 +242,9 @@ export const TaskCompletionFlow: Flow = { owner: '{completedTask.owner}', is_recurring: true, recurrence_type: '{completedTask.recurrence_type}', recurrence_interval: '{completedTask.recurrence_interval}', - due_date: 'DATEADD({completedTask.due_date}, {completedTask.recurrence_interval}, "{completedTask.recurrence_type}")', + // A whole-string token, so `interpolate()` hands the create the RAW + // value the script node returned instead of a stringified copy. + due_date: '{nextDueDate}', status: 'not_started', is_completed: false, }, outputVariable: 'newTaskId', @@ -210,9 +256,13 @@ export const TaskCompletionFlow: Flow = { edges: [ { id: 'e1', source: 'start', target: 'get_task', type: 'default' }, { id: 'e2', source: 'get_task', target: 'check_recurring', type: 'default' }, - { id: 'e3', source: 'check_recurring', target: 'create_next_task', type: 'default', condition: 'vars.completedTask.is_recurring == true', label: 'Yes' }, + // The recurring branch now runs `compute_next_due_date` first (#7037); the + // gate itself is unchanged, so the non-recurring path still routes straight + // to `end` and skips both nodes. + { id: 'e3', source: 'check_recurring', target: 'compute_next_due_date', type: 'default', condition: 'vars.completedTask.is_recurring == true', label: 'Yes' }, { id: 'e4', source: 'check_recurring', target: 'end', type: 'default', condition: 'vars.completedTask.is_recurring != true', label: 'No' }, { id: 'e5', source: 'create_next_task', target: 'end', type: 'default' }, + { id: 'e6', source: 'compute_next_due_date', target: 'create_next_task', type: 'default' }, ], }; diff --git a/examples/app-todo/src/functions/index.ts b/examples/app-todo/src/functions/index.ts new file mode 100644 index 0000000000..debeda330b --- /dev/null +++ b/examples/app-todo/src/functions/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Flow Functions Barrel — the named callables `script` nodes invoke. + * + * `todoFunctions` is what `defineStack({ functions })` registers; the automation + * plugin bridges that map to `AutomationEngine.resolveFunction`, which is how a + * `script` node's `config.function` resolves at run time (#1870). + */ + +export { computeNextTaskDueDate } from './task.functions'; + +import { computeNextTaskDueDate } from './task.functions'; + +/** Name → handler map for `defineStack({ functions })`. */ +export const todoFunctions = { + computeNextTaskDueDate, +}; diff --git a/examples/app-todo/src/functions/task.functions.ts b/examples/app-todo/src/functions/task.functions.ts new file mode 100644 index 0000000000..75182d2fea --- /dev/null +++ b/examples/app-todo/src/functions/task.functions.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Named callables the app's `script` flow nodes invoke, registered through + * `defineStack({ functions })` in `objectstack.config.ts` (#1870). + * + * A flow function is PURE (#4396): it takes `input`, RETURNS a value, and a + * later declarative node persists it. It does no data I/O of its own, which is + * why none of these declares an `effect`. + */ + +/** The recurrence cadences `todo_task.recurrence_type` offers. */ +const RECURRENCE_TYPES = ['daily', 'weekly', 'monthly', 'yearly'] as const; +type RecurrenceType = (typeof RECURRENCE_TYPES)[number]; + +/** Coerce a `Field.date` value to a `Date`. It reaches a flow as a `Date`, an + * ISO string, or an epoch number depending on the driver, so all three are + * accepted — the same coercion `@objectstack/formula`'s date functions do. */ +function toDate(value: unknown): Date { + if (value instanceof Date) return new Date(value.getTime()); + if (typeof value === 'number') return new Date(value); + return new Date(String(value)); +} + +/** Add `n` days in UTC. */ +function addDaysUtc(d: Date, n: number): Date { + const out = new Date(d.getTime()); + out.setUTCDate(out.getUTCDate() + n); + return out; +} + +/** + * Add `n` calendar months in UTC, clamping the day to the target month's last + * day so Jan 31 + 1 month is Feb 28 rather than Mar 3. + * + * Deliberately the same rule as `@objectstack/formula`'s `addMonths` (see + * `packages/formula/src/stdlib.ts`), so a monthly recurrence spawned by this + * flow lands on the same day a `addMonths(...)` formula field would compute for + * it. Two different answers for "one month after Jan 31" inside one app is the + * kind of drift an example must not demonstrate. + */ +function addMonthsUtc(d: Date, n: number): Date { + const out = new Date(d.getTime()); + const day = out.getUTCDate(); + out.setUTCDate(1); + out.setUTCMonth(out.getUTCMonth() + n); + const lastDay = new Date(Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0)).getUTCDate(); + out.setUTCDate(Math.min(day, lastDay)); + return out; +} + +/** `YYYY-MM-DD` — the calendar-date form a `Field.date` stores and compares on. */ +function toCalendarDate(d: Date): string { + return d.toISOString().slice(0, 10); +} + +/** One cadence per authored `recurrence_type` option — total over the select. */ +const NEXT_DUE_BY_TYPE: Record Date> = { + daily: (due, interval) => addDaysUtc(due, interval), + weekly: (due, interval) => addDaysUtc(due, interval * 7), + monthly: (due, interval) => addMonthsUtc(due, interval), + yearly: (due, interval) => addMonthsUtc(due, interval * 12), +}; + +/** + * Next due date for a recurring task: the completed task's `due_date` shifted + * by `recurrence_interval` units of `recurrence_type`. + * + * **Why this is a function and not an expression** (#7037). `TaskCompletionFlow` + * used to write the literal string + * `DATEADD({completedTask.due_date}, {completedTask.recurrence_interval}, "...")` + * into `create_record`'s `due_date`. `DATEADD` exists nowhere in the platform, + * and a `create_record` node's `fields` are TEMPLATE-interpolated rather than + * evaluated — the `{...}` holes are filled and the surrounding text passed + * through verbatim — so the driver received `DATEADD(2026-08-10, 1, "daily")` + * and refused the write with `Due Date must be a valid date (ISO-8601)`. + * + * No flow node evaluates a value-producing expression: the builtin vocabulary's + * only expression slots are PREDICATES (`config.condition`, `edge.condition`, + * `decision.conditions[].expression`, `screen.fields[].visibleWhen`) and + * `flow-template` references (`loop.collection`, `map.collection`) — see + * `FLOW_NODE_EXPRESSION_PATHS` in `@objectstack/spec/automation`. An + * `assignment` node interpolates too; it does not evaluate. So the value has to + * be computed BEFORE the create node, and a `script` node calling this function + * is the shipped way to do that. + * + * @param input.dueDate the completed task's `due_date` + * @param input.recurrenceType one of daily / weekly / monthly / yearly + * @param input.interval `recurrence_interval` (schema default 1, min 1) + * @returns the next due date as `YYYY-MM-DD`, or `null` when the completed task + * carried no `due_date` — with no previous due date there is nothing to shift, + * and the spawned task starts undated rather than on an invented day. + */ +export function computeNextTaskDueDate({ input }: { input: Record }): string | null { + const { dueDate, recurrenceType, interval } = input; + + if (dueDate === null || dueDate === undefined || dueDate === '') return null; + const due = toDate(dueDate); + if (Number.isNaN(due.getTime())) { + throw new Error(`computeNextTaskDueDate: '${String(dueDate)}' is not a valid due date`); + } + + const type = String(recurrenceType ?? '').trim().toLowerCase(); + const next = NEXT_DUE_BY_TYPE[type as RecurrenceType]; + // Refuse loudly rather than guessing a cadence. An unknown value here means + // the record disagrees with `todo_task.recurrence_type`'s own option list, and + // a silently-skipped recurrence is the failure mode this whole card is about. + if (!next) { + throw new Error( + `computeNextTaskDueDate: unknown recurrence_type '${String(recurrenceType)}' ` + + `(expected one of ${RECURRENCE_TYPES.join(', ')})`, + ); + } + + // An absent interval takes the field's own declared default (1), which is what + // the schema would have written; anything present but not a positive whole + // number is a contradiction of `min: 1` and refuses rather than being coerced. + const steps = interval === null || interval === undefined || interval === '' ? 1 : Number(interval); + if (!Number.isFinite(steps) || steps < 1) { + throw new Error( + `computeNextTaskDueDate: recurrence_interval must be a number >= 1, got '${String(interval)}'`, + ); + } + + return toCalendarDate(next(due, Math.trunc(steps))); +} diff --git a/examples/app-todo/test/task-recurrence.test.ts b/examples/app-todo/test/task-recurrence.test.ts new file mode 100644 index 0000000000..74ee26990d --- /dev/null +++ b/examples/app-todo/test/task-recurrence.test.ts @@ -0,0 +1,416 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#7037] `task_completion`'s recurrence branch computes a real next due date. + * + * The defect: `create_next_task` wrote + * `DATEADD({completedTask.due_date}, {completedTask.recurrence_interval}, "…")` + * into `due_date`. Two independent faults stacked: + * + * 1. `DATEADD` exists nowhere in the platform — not a CEL builtin, not + * registered by `packages/formula` under any casing. + * 2. a `create_record` node's `fields` are TEMPLATE-interpolated, never + * evaluated: the `{…}` holes are filled and the surrounding text passes + * through verbatim. So the engine received the literal string + * `DATEADD(2026-08-10, 1, "daily")` and the field's own coercion refused it + * with `Due Date must be a valid date (ISO-8601)`, failing the run. + * + * Reachability, not the defect, changed with #6882: while the flow was unbound + * the node never executed. Armed, every completion of a RECURRING task produced + * a failed run, so the recurrence feature the node exists for had never worked. + * + * Why a `script` node is the repair and not a stylistic choice: no flow node + * evaluates a value-producing expression. The builtin vocabulary's only + * expression slots are PREDICATES (`config.condition`, `edge.condition`, + * `decision.conditions[].expression`, `screen.fields[].visibleWhen`) and + * `flow-template` REFERENCES (`loop.collection`, `map.collection`) — the ledger + * is `FLOW_NODE_EXPRESSION_PATHS` in `@objectstack/spec/automation` — and an + * `assignment` node interpolates rather than evaluates. The value therefore has + * to be computed BEFORE the create node, which is what a registered function + * invoked by a `script` node is for (#1870, pure per #4396). + * + * The suite drives the app's REAL metadata (`allFlows`, the real `Task` object, + * the real `todoFunctions` registry) through a real kernel over sqlite, so it + * fails if any link is re-broken: the function name, the node wiring, the + * arithmetic, or the interpolation shape of the `due_date` slot. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; +import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change'; + +import { allFlows, TaskCompletionFlow } from '../src/flows/index.js'; +import { todoFunctions, computeNextTaskDueDate } from '../src/functions/index.js'; +import { Task } from '../src/objects/task.object.js'; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Every driver a test opened, closed when that test ends. */ +const openDrivers: Array<{ disconnect?: () => Promise }> = []; +afterEach(async () => { + while (openDrivers.length) { + try { await openDrivers.pop()?.disconnect?.(); } catch { /* noop */ } + } +}); + +/** + * A real kernel with the app's real `todo_task` object and real flows — the + * same harness `test/task-completion-trigger.test.ts` boots, plus the one wire + * that card did not need: the function resolver. + * + * `defineStack({ functions })` is bridged to `AutomationEngine.resolveFunction` + * by the automation plugin when an app bundle is loaded through `AppPlugin`. + * This suite registers the app's metadata directly (no bundle), so it wires the + * app's OWN `todoFunctions` map here. That keeps the test honest about what it + * proves: the arithmetic and the flow wiring, with the same registry the app + * ships — not a stand-in the app does not contain. + */ +async function bootTodoKernel(): Promise<{ + automation: AutomationEngine & Record; + data: any; +}> { + const kernel = new ObjectKernel({ logger: { level: 'silent' } } as any); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin()); + await kernel.use(new RecordChangeTriggerPlugin()); + await kernel.bootstrap(); + + const objectql: any = kernel.getService('objectql'); + const data: any = kernel.getService('data'); + const automation = kernel.getService('automation') as AutomationEngine & Record; + + const driver: any = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + objectql.registerDriver(driver, true); + openDrivers.push(driver); + objectql.registry.registerObject(Task, 'todo', 'todo'); + await objectql.syncSchemas(); + + automation.setFunctionResolver( + (name: string) => (todoFunctions as Record)[name] as any, + ); + for (const flow of allFlows) automation.registerFlow(flow.name, flow); + return { automation, data }; +} + +/** Find a node of `TaskCompletionFlow` by id. */ +function node(id: string): any { + return (TaskCompletionFlow.nodes as any[]).find((n) => n.id === id); +} + +/** `YYYY-MM-DD` for whatever shape the driver returned a `Field.date` in. */ +function calendarDate(value: unknown): string { + if (value === null || value === undefined) return ''; + if (value instanceof Date) return value.toISOString().slice(0, 10); + return String(value).slice(0, 10); +} + +/** + * Complete one recurring task through the real write path and return the task + * the flow spawned, plus the run that spawned it. + * + * `completed_date` is seeded on CREATE for the same reason + * `test/task-completion-trigger.test.ts` seeds it: the object's + * `completed_date_required` rule refuses a completion write without it, and the + * ordinary-user completion path is a separate card's subject (#7036). This card + * is about what the flow does once it fires, so the completion is driven the way + * the #6882 suite already drives it. + */ +async function completeRecurringTask(opts: { + subject: string; + dueDate: string; + recurrenceType: string; + interval?: number; +}): Promise<{ spawned: any; run: any; data: any }> { + const { automation, data } = await bootTodoKernel(); + const ctx = { context: { userId: 'u_todo' } }; + + const created = await data.insert('todo_task', { + subject: opts.subject, + status: 'not_started', + priority: 'normal', + owner: 'u_todo', + due_date: opts.dueDate, + is_recurring: true, + recurrence_type: opts.recurrenceType, + recurrence_interval: opts.interval ?? 1, + completed_date: '2026-08-09T10:00:00.000Z', + }, ctx); + const id = Array.isArray(created) ? created[0].id : created.id; + + await data.update('todo_task', { status: 'completed' }, { where: { id }, ...ctx }); + await sleep(600); + + const runs = await automation.listRuns('task_completion'); + const all = await data.find('todo_task', { where: { subject: opts.subject }, ...ctx }); + const spawned = (Array.isArray(all) ? all : []).find((r: any) => r.id !== id); + return { spawned, run: runs[0], data }; +} + +describe('#7037 — the recurrence branch computes a real next due date', () => { + describe('computeNextTaskDueDate — the arithmetic', () => { + it('shifts by the cadence the recurrence_type names', () => { + const at = (recurrenceType: string, interval: number) => + computeNextTaskDueDate({ input: { dueDate: '2026-08-10', recurrenceType, interval } }); + + expect(at('daily', 1)).toBe('2026-08-11'); + expect(at('daily', 3)).toBe('2026-08-13'); + expect(at('weekly', 1)).toBe('2026-08-17'); + expect(at('weekly', 2)).toBe('2026-08-24'); + expect(at('monthly', 1)).toBe('2026-09-10'); + expect(at('monthly', 4)).toBe('2026-12-10'); + expect(at('yearly', 1)).toBe('2027-08-10'); + }); + + it('clamps a monthly shift to the target month, like the platform addMonths does', () => { + // `@objectstack/formula`'s `addMonths` clamps Jan 31 + 1mo to Feb 28 + // rather than overflowing into March. An example app that answered + // differently from the platform's own formula function would be teaching + // two incompatible recurrence semantics inside one product. + expect( + computeNextTaskDueDate({ input: { dueDate: '2026-01-31', recurrenceType: 'monthly', interval: 1 } }), + ).toBe('2026-02-28'); + expect( + computeNextTaskDueDate({ input: { dueDate: '2024-01-31', recurrenceType: 'monthly', interval: 1 } }), + ).toBe('2024-02-29'); + expect( + computeNextTaskDueDate({ input: { dueDate: '2026-02-28', recurrenceType: 'yearly', interval: 1 } }), + ).toBe('2027-02-28'); + }); + + it('accepts every shape a Field.date reaches a flow in', () => { + const expected = '2026-08-11'; + for (const dueDate of [ + '2026-08-10', + '2026-08-10T00:00:00.000Z', + new Date('2026-08-10T00:00:00.000Z'), + Date.parse('2026-08-10T00:00:00.000Z'), + ]) { + expect( + computeNextTaskDueDate({ input: { dueDate, recurrenceType: 'daily', interval: 1 } }), + `due date shape ${String(dueDate)}`, + ).toBe(expected); + } + }); + + it('takes the field default when the interval is absent, and refuses a value min:1 forbids', () => { + // `recurrence_interval` declares `defaultValue: 1, min: 1`. An absent + // interval takes that declared default — not a lenient fallback, the same + // number the schema itself would have written. A PRESENT value the schema + // forbids refuses instead of being quietly coerced. + expect( + computeNextTaskDueDate({ input: { dueDate: '2026-08-10', recurrenceType: 'daily' } }), + ).toBe('2026-08-11'); + for (const interval of [0, -2, 'soon']) { + expect( + () => computeNextTaskDueDate({ input: { dueDate: '2026-08-10', recurrenceType: 'daily', interval } }), + `interval ${String(interval)}`, + ).toThrow(/recurrence_interval must be a number >= 1/); + } + }); + + it('refuses an unknown recurrence_type instead of guessing a cadence', () => { + expect( + () => computeNextTaskDueDate({ input: { dueDate: '2026-08-10', recurrenceType: 'fortnightly', interval: 1 } }), + ).toThrow(/unknown recurrence_type 'fortnightly'/); + }); + + it('returns null when there is no previous due date to shift', () => { + // Nothing to add to: the spawned task starts undated rather than on an + // invented day derived from "now". + for (const dueDate of [null, undefined, '']) { + expect( + computeNextTaskDueDate({ input: { dueDate, recurrenceType: 'daily', interval: 1 } }), + ).toBeNull(); + } + }); + }); + + describe('the flow wiring', () => { + it('the script node names a function the app actually registers', () => { + const script = node('compute_next_due_date'); + expect(script?.type).toBe('script'); + // The failure this pins is a RUNTIME one: the executor resolves + // `config.function` against the host registry and fails the step with + // "no function named 'x' is registered" — invisible to build. + expect(Object.keys(todoFunctions)).toContain(script.config.function); + expect(script.config.outputVariable).toBe('nextDueDate'); + }); + + it('create_next_task reads the computed variable as a WHOLE-STRING token', () => { + // A whole-string `{token}` is the one form `interpolateString` returns the + // raw value for. Any surrounding text turns the slot back into + // text-with-holes — which is precisely the shape that shipped `DATEADD`. + const fields = node('create_next_task').config.fields; + expect(fields.due_date).toBe('{nextDueDate}'); + }); + + it('the recurring branch runs the computation BEFORE the create', () => { + const edges = TaskCompletionFlow.edges as any[]; + const yes = edges.find((e) => e.source === 'check_recurring' && e.condition?.includes('== true')); + expect(yes.target).toBe('compute_next_due_date'); + expect(edges.some((e) => e.source === 'compute_next_due_date' && e.target === 'create_next_task')).toBe(true); + // The non-recurring leg is untouched: it still skips both nodes. + const no = edges.find((e) => e.source === 'check_recurring' && e.condition?.includes('!= true')); + expect(no.target).toBe('end'); + }); + + it('GUARD: the dead function name is gone from the app\'s authored metadata', () => { + // Named explicitly, not just covered by the shape check below. The reason + // this card insists on the name is that an invented function in a SHIPPED + // example is the exact thing an AI author copies — so its absence is + // asserted rather than merely current. (The name still appears in this + // file and in the flow's comments, describing the defect; what must never + // come back is an authored VALUE containing it.) + expect(JSON.stringify(allFlows)).not.toContain('DATEADD'); + }); + + it('GUARD: no write node in any app flow puts function-call text in a field value', () => { + // The class, not the instance. `fields` values are interpolated, never + // evaluated, so ANY `NAME(...)` text in one reaches the driver verbatim — + // whatever the invented function is called. This is the check that would + // have caught the original defect at authoring time. + const callShaped = /^[A-Za-z_][A-Za-z0-9_]*\s*\(/; + for (const flow of allFlows) { + for (const n of (flow.nodes ?? []) as any[]) { + if (n.type !== 'create_record' && n.type !== 'update_record') continue; + for (const [field, value] of Object.entries((n.config?.fields ?? {}) as Record)) { + if (typeof value !== 'string') continue; + expect( + callShaped.test(value.trim()), + `flow '${flow.name}' node '${n.id}' field '${field}' = ${value} — ` + + `a create/update field value is template-interpolated, not evaluated`, + ).toBe(false); + } + } + } + }); + }); + + describe('end to end, on a real kernel over sqlite', () => { + it.each([ + { recurrenceType: 'daily', interval: 1, dueDate: '2026-08-10', expected: '2026-08-11' }, + { recurrenceType: 'weekly', interval: 1, dueDate: '2026-08-10', expected: '2026-08-17' }, + { recurrenceType: 'monthly', interval: 1, dueDate: '2026-01-31', expected: '2026-02-28' }, + ])( + 'completing a $recurrenceType task spawns the next one due $expected', + async ({ recurrenceType, interval, dueDate, expected }) => { + const subject = `Recurring ${recurrenceType} task`; + const { spawned, run } = await completeRecurringTask({ subject, dueDate, recurrenceType, interval }); + + // The run this card is about used to end `failed` on + // `create_record(todo_task) failed: Due Date must be a valid date + // (ISO-8601)`. It completes now, through every node. + expect(run?.status, `run error: ${JSON.stringify((run as any)?.error ?? null)}`).toBe('completed'); + const nodeIds = (run.steps ?? []).map((s: any) => s.nodeId); + expect(nodeIds).toContain('compute_next_due_date'); + expect(nodeIds).toContain('create_next_task'); + + // And the write it was refused for actually landed, on the right day. + expect(spawned, 'the recurrence spawned a next task').toBeDefined(); + expect(calendarDate(spawned.due_date)).toBe(expected); + expect(spawned.status).toBe('not_started'); + expect(spawned.is_recurring).toBeTruthy(); + expect(spawned.recurrence_type).toBe(recurrenceType); + }, + 30000, + ); + + it('a NON-recurring completion still spawns nothing', async () => { + const { automation, data } = await bootTodoKernel(); + const ctx = { context: { userId: 'u_todo' } }; + + const created = await data.insert('todo_task', { + subject: 'One-off task', status: 'not_started', priority: 'normal', owner: 'u_todo', + due_date: '2026-08-10', is_recurring: false, + completed_date: '2026-08-09T10:00:00.000Z', + }, ctx); + const id = Array.isArray(created) ? created[0].id : created.id; + + await data.update('todo_task', { status: 'completed' }, { where: { id }, ...ctx }); + await sleep(600); + + const runs = await automation.listRuns('task_completion'); + expect(runs[0].status).toBe('completed'); + // The gate still routes past BOTH nodes — adding the script node did not + // put date arithmetic on the non-recurring path. + const executed = (runs[0].steps ?? []).filter((s: any) => s.status !== 'skipped').map((s: any) => s.nodeId); + expect(executed).not.toContain('compute_next_due_date'); + expect(executed).not.toContain('create_next_task'); + + const all = await data.find('todo_task', { where: { subject: 'One-off task' }, ...ctx }); + expect((Array.isArray(all) ? all : []).length).toBe(1); + }, 30000); + + it('REVERSE: the pre-fix shape — a function call left in the field value — is refused by the driver', async () => { + // The pre-#7037 defect, rebuilt from the REAL flow so it cannot drift: the + // computation node removed, the recurring edge pointed straight at the + // create, and `due_date` written as text-with-holes wrapping a call to a + // function that does not exist. + // + // The invented name is deliberately NOT the historical one. What failed + // was never specific to `DATEADD`: a `create_record` field value is + // interpolated and passed through verbatim, so ANY function-call text + // reaches the driver as a literal string. Pinning the class keeps the + // dead name out of the repo entirely, which is the other half of this + // card — an invented function name in a shipped example is exactly the + // shape an AI author copies. + const { automation, data } = await bootTodoKernel(); + const ctx = { context: { userId: 'u_todo' } }; + + const broken = JSON.parse(JSON.stringify(TaskCompletionFlow)) as any; + broken.name = 'task_completion_uncomputed_fixture'; + broken.nodes = broken.nodes.filter((n: any) => n.id !== 'compute_next_due_date'); + broken.edges = broken.edges.filter((e: any) => e.source !== 'compute_next_due_date'); + broken.edges.find((e: any) => e.source === 'check_recurring' && e.condition?.includes('== true')).target = + 'create_next_task'; + broken.nodes.find((n: any) => n.id === 'create_next_task').config.fields.due_date = + 'SHIFT_DATE({completedTask.due_date}, {completedTask.recurrence_interval}, "{completedTask.recurrence_type}")'; + // The fixture is a manual flow: it must not race the real one on the same + // record-change hook. + delete broken.nodes.find((n: any) => n.type === 'start').config.triggerType; + broken.type = 'autolaunched'; + automation.registerFlow(broken.name, broken); + + const created = await data.insert('todo_task', { + subject: 'Reverse fixture task', status: 'completed', priority: 'normal', owner: 'u_todo', + due_date: '2026-08-10', is_recurring: true, recurrence_type: 'daily', recurrence_interval: 1, + completed_date: '2026-08-09T10:00:00.000Z', + }, ctx); + const id = Array.isArray(created) ? created[0].id : created.id; + + // Driven directly rather than through the record-change hook, so the + // context has to supply what the hook would: the triggering record AND the + // `previous` row the start predicate transitions from. Without `previous` + // the CEL gate aborts with `No such key: status` — the same totality trap + // #6882 documented on the insert leg — and the run would fail for a reason + // that has nothing to do with this card. + const record = { ...(Array.isArray(created) ? created[0] : created), id }; + const trigger = { record, previous: { ...record, status: 'in_progress' }, userId: 'u_todo' }; + + const result: any = await automation.execute(broken.name, trigger); + + // The direction that matters: the run FAILS, and it fails inside the + // create with the field's own date refusal — NOT with "no function named + // 'SHIFT_DATE' is registered", because nothing ever tried to CALL one. + // The value was never a call; it was always just text. + expect(result?.success, `unexpected result: ${JSON.stringify(result)}`).toBe(false); + const text = String(result?.error ?? '') + JSON.stringify(result?.output ?? null); + expect(text).toContain('create_next_task'); + expect(text).toMatch(/valid date|ISO-8601|invalid_date/); + expect(text).not.toMatch(/no function named/); + + // Non-vacuous: the REAL flow, on the very same kernel and record, computes + // the date and completes. So the failure above is the missing computation, + // not a broken fixture or an unusable harness. + const good: any = await automation.execute('task_completion', trigger); + expect(good?.success, `unexpected result: ${JSON.stringify(good)}`).toBe(true); + const spawned = (await data.find('todo_task', { where: { subject: 'Reverse fixture task' }, ...ctx })) + .find((r: any) => r.id !== id); + expect(calendarDate(spawned.due_date)).toBe('2026-08-11'); + }, 30000); + }); +});