Skip to content

Commit 71d094c

Browse files
fix(spec)!: refuse a start-node filter beside timeRelative and a schedule the readers cannot use (#31376)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XF9CLMiiKFscnGZpZTirGFK)_ Refs #31224 Clause-②: yes (narrowing) Metadata-protocol: narrowed Skills-docs: none — `skills/**` and `content/docs/**` teach `timeRelative.filter` inside the descriptor and typed `{ type: 'cron', expression }` schedules, which this accepts. The generated `skills/objectstack-automation/references/_index.md` gains the source files the flow closure now reaches (`system/job.zod.ts` and its imports), regenerated by `check:generated --fix`. ## 维护者速读 - **改了什么**:`FlowSchema` 新增两类开始节点 `config` 拒绝(新文件 `flow-start-config-refusals.ts`):① `config.timeRelative` 为对象时,与之并列的 `config.filter`:没有任何读取方,时间相对扫描只读 `timeRelative.filter`(`timeRelative: null` 不判定);② `config.schedule` 读不出调度:带 `type` 的对象按已发布的 `ScheduleSchema` 判定(因此 `{ type: 'cron', cron }`、`{ type: 'interval', every }` 两个带类型别名也被拒绝,提示改用 `expression` / `intervalMs`),其余值只在 `normalizeSchedule` 什么都读不出时拒绝。裸 cron 字符串和运行时接受的无类型别名(`{ cron }` / `{ expression }` / `{ every }` / `{ intervalMs }` / `{ at }`)照旧接受。新增三个导出、spec minor changeset。 - **为什么改**:#31224 第 B 项,席位裁定 B1 选项 (a)。这两种写法解析通过却不生效:filter 放错位置时,日期窗口内每条记录都会触发流程;调度读不出时,定时流程不绑定(服务器日志里只有一条 warn),时间相对扫描退回每天 08:00 UTC。 - **风险与回滚**:收窄:所有解析流程的门(`defineStack`、`os validate/build`、保存门含草稿、发布、引擎注册)拒绝两种此前接受的写法。普查:`filter` 与 `timeRelative` 并列 0 处;对象形式的开始节点调度全部带 `type` 且合法(showcase、cli 夹具、dogfood 夹具);两个带类型别名 0 处;9 处裸字符串 cron(app-todo 2、hotcrm 7)照旧接受。回滚即 revert。 - **先看**:`packages/spec/src/automation/flow-start-config-refusals.ts` 的模块头和 `flowStartScheduleReadsNothing`;以及 `packages/triggers/trigger-schedule/src/schedule-reader-spec-parity.test.ts` 的 34 条对齐钉。 - **两分钟验证**:`pnpm --filter @objectstack/spec exec vitest run src/automation/flow-start-config-refusals.test.ts`(12 条)与 `pnpm --filter @objectstack/trigger-schedule exec vitest run src/schedule-reader-spec-parity.test.ts`(34 条),预期全部通过。 - **需要你做的**:无,席位做契约复审后落地。 ## What changes 1. `packages/spec/src/automation/flow-start-config-refusals.ts` (new) exports `flowStartConfigRefusals(flow)`, `flowStartScheduleReadsNothing(raw)` and the `FlowStartConfigRefusal` type. The rules cover top-level `start` nodes only, at `nodes[i].config.*`: - **`config.filter` beside `config.timeRelative`.** Judged only when `config.timeRelative` is a non-null object, the test the trigger-kind resolver (`flow-trigger-kind.ts`) routes a sweep by, so `timeRelative: null` is not judged. Refused at `nodes[i].config.filter`: "nothing reads it … launches for every record in the date window. Move it into `config.timeRelative.filter`." The sweep reads `timeRelative.filter` (`time-relative-trigger.ts`). - **`config.schedule`.** Both readers (`ScheduleTrigger`, and `TimeRelativeTrigger` for a sweep's cadence) pass it through `normalizeSchedule`. When that yields `null`, a schedule flow is "not bound" (one warn), and a sweep falls back to `TIME_RELATIVE_DEFAULT_CRON`. The verdict, at `nodes[i].config.schedule`, by form: - **bare string**: not judged, unless empty or blank; - **object with a `type` key**: judged against the published `ScheduleSchema`, so the discriminant, an IANA `timezone`, a positive integer `intervalMs` and an ISO `at` must all hold; the first issue is quoted, with its sub-path. An object that passes is then also judged for readability, so a typed cron whose `expression` is a `{ dialect, source }` envelope is refused, because the reader takes only a string. The schema does not declare `cron` or `every` on a typed object, so `{ type: 'cron', cron }` and `{ type: 'interval', every }` are refused although the reader binds them today (seat rulings 6714195854 (a), confirmed in 6717636410). When the refusal sees one of those keys it names the declared spelling (`expression`, `intervalMs`). Census for both: 0 in objectstack and hotcrm; - **any other value**: refused only when `flowStartScheduleReadsNothing` (an empty object, an unknown key, a number, an array, a non-positive interval). `null` or absent is not judged. - Each message names the shape, why it does nothing, and the spellings that work. 2. `packages/spec/src/automation/flow.zod.ts`: `FlowSchema.superRefine` calls `flowStartConfigRefusals` beside the api-trigger refusals. 3. `packages/spec/src/automation/index.ts`: re-exports the module. 4. **Generated** by `check:generated --fix`: - `api-surface/automation.json` and `export-origins/automation.json`, each +3 names; - `skills/objectstack-automation/references/_index.md`, because `job.zod.ts` and its imports now reach the flow closure through the `ScheduleSchema` import. 5. `.changeset/31224-flow-start-config-refusals.md`: `'@objectstack/spec': minor`, BREAKING banner, ADR-0087 `not-required (no-migration-prescription)`, per seat ruling 6717636410 §1. The reason says what each arm refuses: - the filter arm refuses an undeclared key on the open-record `config`, which the published descriptor declares only inside `timeRelative` (the #4277 class); - the schedule arm refuses values `ScheduleSchema` refuses, or that the reader reads nothing from. The release note carries no FROM/TO rewrite: the parse refusal names where the key belongs. **One reading, pinned.** `flowStartScheduleReadsNothing` mirrors `normalizeSchedule` (`@objectstack/trigger-schedule`) branch for branch, including its truthiness and its "first candidate present decides" precedence. `packages/triggers/trigger-schedule/src/schedule-reader-spec-parity.test.ts` pins `flowStartScheduleReadsNothing(x) === (normalizeSchedule(x) === null)` over 33 shapes, plus the absent-schedule case. If the two drift, the parse would either refuse a schedule that binds or accept one that never does. The pin is test-only, so `@objectstack/trigger-schedule` takes no changeset. ## Tests - `packages/spec/src/automation/flow-start-config-refusals.test.ts`, 12 cases: - **filter**: refused at the parse (path and message pinned); accepted inside `timeRelative`, and on a start node without `timeRelative`; not judged beside `timeRelative: null`, both through the module and at the parse. - **schedule accepted**: the bare string, the three typed forms (cron with an IANA zone), and the five untyped aliases. - **schedule refused at the parse**: a typed cron with `timezone: 'UTC+8'` (path and sub-path pinned). - **schedule refused for failing `ScheduleSchema`**: an unknown `type`, `intervalMs: 0`, a cron with no `expression`, a non-ISO `at`. - **typed aliases refused at the parse**: `{ type: 'cron', cron }` names `expression` and `{ type: 'interval', every }` names `intervalMs` (path, sub-path and hint pinned); a typed cron with no alias key carries no alias hint. - **schedule refused because the reader reads nothing**: `{ cadence }`, `{}`, `[]`, `42`, `true`, a blank string, `{ every: -5 }`, and a typed cron with an envelope expression. - **not judged**: an absent or `null` schedule, and a non-start node. - **precedence**: the reader's precedence, pinned. - `packages/triggers/trigger-schedule/src/schedule-reader-spec-parity.test.ts`: the 34-case parity pin above. - **Census** (text census of start-node config): - `filter` beside `timeRelative`: 0 in objectstack examples, fixtures and non-test packages, and 0 in hotcrm. - Object-form start schedules: `examples/app-showcase` (`{ type: 'interval', intervalMs: 60000 }`), the cli `option-b-collection-zoo` fixture and the dogfood `schedule-organization-fixture` (typed crons). All typed and all valid. - Typed aliases `{ type: 'cron', cron }` and `{ type: 'interval', every }`: 0 in objectstack and hotcrm. - Bare-string cadences: 2 in `examples/app-todo` and 7 in hotcrm, still accepted. ## Acceptance notes - Item B of #31224, the last item, built under seat ruling B1 option (a) (6714195854). Option (b), `ScheduleSchema` on every object form, was declined, because it would refuse untyped forms that run today. C landed as #31287 and D as #31337; A is not built. The seat closes the card, so `Refs`. - Declaration per the seat's acceptance: `yes (narrowing)` for the three new exports, `narrowed`, spec minor. - Second round, after contract review 6717630512 (FAIL on the ADR-0087 disposition) and seat ruling 6717636410. The changeset reason and release note are rewritten, the typed-alias hint is added, and the sweep guard now matches the resolver. The Studio designer's typed block with no cadence goes to a ui follow-up card, outside this PR. The trigger-schedule parity test is an accepted deviation from the claim surface (§5). ## Verification Head `bdc95ace`, two commits on the branch restarted from `origin/main` `c2bc76e3`, which includes #31337: `532b289f` and the review-round fix. These are the fast checks the seat asked for, on `bdc95ace`: - `pnpm --filter @objectstack/spec typecheck` exits 0, including `check:test-typecheck`. That needs `NODE_OPTIONS=--max-old-space-size=8192` locally; at the default heap it runs out of memory. - `pnpm --filter @objectstack/spec check:generated` exits 0 after a spec build, at the same heap. It covers `check:api-surface`, `check:export-origins` and the skills refs. - `check:docs` exits 0 (243 generated files in sync). - Touched tests: 181/181 across `flow-start-config-refusals.test.ts` (12), `flow.test.ts` and `scripts/export-origins.test.ts`. `schedule-reader-spec-parity.test.ts` 34/34. - `check-adr-0087-registration` exits 0, reading the new reason. - `check-changeset-no-major` with a `pull_request` event carrying this body exits 0. Earlier, on the pre-restart local build of the same change, full suites passed: - spec: 913 files after the regen; - `@objectstack/trigger-schedule`: 12/12 files; - `@objectstack/service-automation`: 264/264 files. CI runs the full set. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5oEQNYg7j3ueo5t6pV)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5oEQNYg7j3ueo5t6pV <!-- signed: domain:spec#1 · os-project-manager · session_01FEkQ5mw7HFYn9o4LEGMQdK --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3a0aa91 commit 71d094c

9 files changed

Lines changed: 388 additions & 0 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec)!: the flow parse refuses a start node's `config.filter` beside `config.timeRelative`, and a `config.schedule` the schedule readers cannot use
6+
7+
Clause-②: yes (narrowing) — `FlowSchema` refuses two start-node `config` shapes it accepted before, at every door that parses a flow; `@objectstack/spec/automation` also exports `flowStartConfigRefusals`, `flowStartScheduleReadsNothing` and the `FlowStartConfigRefusal` type.
8+
Metadata-protocol: narrowed
9+
Skills-docs: none — `skills/**` and `content/docs/**` teach `timeRelative.filter` inside the descriptor and typed `{ type: 'cron', expression }` schedules, which this accepts. The generated `skills/objectstack-automation/references/_index.md` gains the source files the flow closure now reaches (`system/job.zod.ts` and its imports).
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed or renamed: FlowNodeSchema.config stays an open record, and the parse now refuses two shapes on it. The filter arm refuses an undeclared key on that open-record config: the published time-relative descriptor declares filter inside timeRelative and nowhere else, so filter beside timeRelative was never a declared shape and no protocol step migrates from it (the #4277 class). The schedule arm refuses values the published ScheduleSchema refuses, or that the schedule reader reads nothing from. Each refusal message names the cure at the refused key. -->
12+
13+
**BREAKING** accept-set narrowing of `FlowSchema` (every door that parses a flow: `defineStack`, `os validate` / `os build`, the metadata save door including drafts, publish, and the automation engine's registration).
14+
15+
- **`config.filter` beside `config.timeRelative`.** Before, it parsed and nothing read it: the time-relative sweep reads its row filter from `timeRelative.filter`, so the flow launched for every record in the date window. Now it is refused at `nodes[i].config.filter`, when `config.timeRelative` is an object (the start node the engine routes as a sweep); a `timeRelative: null` start node is not judged.
16+
- **A `config.schedule` the readers cannot use.** Both readers (the schedule trigger, and the time-relative sweep's cadence) pass it through `normalizeSchedule`. When that reads nothing, a schedule flow stays unbound (one warn in the server log) and a sweep falls back to daily 08:00 UTC. Now, at `nodes[i].config.schedule`:
17+
- an object carrying `type` is judged against the published `ScheduleSchema` (the discriminant, a cron `timezone` that is an IANA zone, a positive integer `intervalMs`, an ISO `at`), and then for being readable at all (a cron `expression` written as an envelope is not). The schema does not declare the aliases `cron` and `every` on a typed object, so the two typed aliases the reader happens to bind today, `{ type: 'cron', cron }` and `{ type: 'interval', every }`, are refused too, and the refusal names the declared spelling (`expression`, `intervalMs`). Neither occurs in objectstack or hotcrm;
18+
- any other value is refused only when the reader reads nothing from it (an empty string, `{}`, `{ cadence: 'daily' }`, a number, an array, a non-positive interval);
19+
- accepted as before: a bare cron string, and the untyped aliases the reader takes (`{ cron }`, `{ expression }`, `{ every }`, `{ intervalMs }`, `{ at }`).
20+
- **One reading, pinned.** `flowStartScheduleReadsNothing` mirrors `normalizeSchedule` branch for branch, and `@objectstack/trigger-schedule` pins the two equal over every form either side distinguishes.
21+
22+
**What an author does.** Nothing for a sweep whose filter sits inside `timeRelative`, or a schedule that binds today in a form the published schema declares. For a refused shape, the parse refusal names where the key belongs, or the schedule spellings the readers take.

‎packages/spec/api-surface/automation.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@
201201
"FlowSchema (const)",
202202
"FlowSlotRefusalCode (type)",
203203
"FlowSlotRefusalParams (interface)",
204+
"FlowStartConfigRefusal (interface)",
204205
"FlowTextSlot (interface)",
205206
"FlowTriggerKind (type)",
206207
"FlowValueSlot (type)",
@@ -346,6 +347,8 @@
346347
"flowNodeTextSlotSources (function)",
347348
"flowNodeValueTemplateRefusals (function)",
348349
"flowScreenFieldOptionKey (function)",
350+
"flowStartConfigRefusals (function)",
351+
"flowStartScheduleReadsNothing (function)",
349352
"flowTextSlotSources (function)",
350353
"getApprovalNodeConfigJsonSchema (function)",
351354
"getBuiltinNodeConfigContracts (function)",

‎packages/spec/export-origins/automation.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@
195195
"FlowSchema": "src/automation/flow.zod.ts#FlowSchema (const)",
196196
"FlowSlotRefusalCode": "src/automation/flow-node-expression-paths.ts#FlowSlotRefusalCode (type)",
197197
"FlowSlotRefusalParams": "src/automation/flow-node-expression-paths.ts#FlowSlotRefusalParams (interface)",
198+
"FlowStartConfigRefusal": "src/automation/flow-start-config-refusals.ts#FlowStartConfigRefusal (interface)",
198199
"FlowTextSlot": "src/automation/flow-text-slot-template.ts#FlowTextSlot (interface)",
199200
"FlowTriggerKind": "src/automation/flow-trigger-kind.ts#FlowTriggerKind (type)",
200201
"FlowValueSlot": "src/automation/builtin-node-config.zod.ts#FlowValueSlot (type)",
@@ -339,6 +340,8 @@
339340
"flowNodeTextSlotSources": "src/automation/flow-text-slot-template.ts#flowNodeTextSlotSources (function)",
340341
"flowNodeValueTemplateRefusals": "src/automation/flow-value-slot-template.ts#flowNodeValueTemplateRefusals (function)",
341342
"flowScreenFieldOptionKey": "src/automation/flow-screen-option-key.ts#flowScreenFieldOptionKey (function)",
343+
"flowStartConfigRefusals": "src/automation/flow-start-config-refusals.ts#flowStartConfigRefusals (function)",
344+
"flowStartScheduleReadsNothing": "src/automation/flow-start-config-refusals.ts#flowStartScheduleReadsNothing (function)",
342345
"flowTextSlotSources": "src/automation/flow-text-slot-template.ts#flowTextSlotSources (function)",
343346
"getApprovalNodeConfigJsonSchema": "src/automation/approval.zod.ts#getApprovalNodeConfigJsonSchema (function)",
344347
"getBuiltinNodeConfigContracts": "src/automation/flow-node-config-refusals.ts#getBuiltinNodeConfigContracts (function)",
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
import { FlowSchema } from './flow.zod';
5+
import { flowStartConfigRefusals, flowStartScheduleReadsNothing } from './flow-start-config-refusals';
6+
7+
const flowWith = (config: Record<string, unknown>) => ({
8+
name: 'start_config_probe',
9+
label: 'Start Config Probe',
10+
type: 'schedule',
11+
runAs: 'system',
12+
nodes: [
13+
{ id: 'start', type: 'start', label: 'Start', config },
14+
{ id: 'end', type: 'end', label: 'End' },
15+
],
16+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
17+
});
18+
19+
/** The parse's issues anchored on the start node's config. */
20+
const startIssues = (config: Record<string, unknown>) => {
21+
const parsed = FlowSchema.safeParse(flowWith(config));
22+
if (parsed.success) return [];
23+
return parsed.error.issues
24+
.filter((i) => i.path[0] === 'nodes' && i.path[1] === 0 && i.path[2] === 'config')
25+
.map((i) => ({ path: i.path.join('.'), message: i.message }));
26+
};
27+
28+
const TIME_RELATIVE = {
29+
object: 'contract',
30+
dateField: 'end_date',
31+
offsetDays: -30,
32+
};
33+
34+
describe('flow start config — `filter` beside `timeRelative` (#31224 B1)', () => {
35+
it('refuses it at the parse, naming where the filter belongs', () => {
36+
const issues = startIssues({ triggerType: 'time_relative', timeRelative: TIME_RELATIVE, filter: { status: 'active' } });
37+
expect(issues).toHaveLength(1);
38+
expect(issues[0]!.path).toBe('nodes.0.config.filter');
39+
expect(issues[0]!.message).toMatch(/nothing reads it/);
40+
expect(issues[0]!.message).toMatch(/launches for every record in the date window/);
41+
expect(issues[0]!.message).toMatch(/Move it into `config\.timeRelative\.filter`/);
42+
});
43+
44+
it('accepts the filter inside `timeRelative`, and a `filter` on a start node with no `timeRelative`', () => {
45+
expect(flowStartConfigRefusals(flowWith({ timeRelative: { ...TIME_RELATIVE, filter: { status: 'active' } } }))).toEqual([]);
46+
expect(flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule: '0 8 * * *', filter: { a: 1 } }))).toEqual([]);
47+
});
48+
49+
it('judges no `filter` beside `timeRelative: null`: the trigger-kind resolver does not route that start node as a sweep', () => {
50+
expect(flowStartConfigRefusals(flowWith({ timeRelative: null, filter: { status: 'active' } }))).toEqual([]);
51+
expect(startIssues({ timeRelative: null, filter: { status: 'active' } })).toEqual([]);
52+
});
53+
});
54+
55+
describe('flow start config — `schedule` (#31224 B1, ruling option (a))', () => {
56+
it('accepts the bare cron string, the three typed forms, and the untyped aliases the reader takes', () => {
57+
for (const schedule of [
58+
'0 8 * * *',
59+
{ type: 'cron', expression: '0 8 * * *' },
60+
{ type: 'cron', expression: '0 8 * * *', timezone: 'America/New_York' },
61+
{ type: 'interval', intervalMs: 60000 },
62+
{ type: 'once', at: '2027-01-01T08:00:00Z' },
63+
{ cron: '0 8 * * *' },
64+
{ expression: '0 8 * * *' },
65+
{ every: 60000 },
66+
{ intervalMs: 60000 },
67+
{ at: '2027-01-01T08:00:00Z' },
68+
]) {
69+
expect(flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule })), JSON.stringify(schedule)).toEqual([]);
70+
}
71+
});
72+
73+
it('refuses a typed cron whose timezone is not an IANA zone, at the parse', () => {
74+
const issues = startIssues({ triggerType: 'schedule', schedule: { type: 'cron', expression: '0 8 * * *', timezone: 'UTC+8' } });
75+
expect(issues).toHaveLength(1);
76+
expect(issues[0]!.path).toBe('nodes.0.config.schedule');
77+
expect(issues[0]!.message).toMatch(/is not a schedule at `schedule\.timezone`/);
78+
expect(issues[0]!.message).toMatch(/a schedule flow stays unbound/);
79+
});
80+
81+
it('refuses a typed form the published ScheduleSchema refuses (unknown type, non-positive interval, no expression)', () => {
82+
for (const schedule of [
83+
{ type: 'hourly' },
84+
{ type: 'interval', intervalMs: 0 },
85+
{ type: 'cron' },
86+
{ type: 'once', at: 'tomorrow' },
87+
]) {
88+
const found = flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule }));
89+
expect(found.map((f) => f.path.join('.')), JSON.stringify(schedule)).toEqual(['nodes.0.config.schedule']);
90+
}
91+
});
92+
93+
it('refuses an untyped value the reader reads nothing from, and an empty string', () => {
94+
for (const schedule of [{ cadence: 'daily' }, {}, [], 42, true, ' ', { every: -5 }]) {
95+
const found = flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule }));
96+
expect(found, JSON.stringify(schedule)).toHaveLength(1);
97+
expect(found[0]!.message).toMatch(/schedule flow stays unbound and a time-relative sweep falls back to daily 08:00 UTC/);
98+
}
99+
});
100+
101+
it('refuses the typed aliases the published ScheduleSchema does not declare, naming the declared spelling', () => {
102+
const cron = startIssues({ triggerType: 'schedule', schedule: { type: 'cron', cron: '0 8 * * *' } });
103+
expect(cron).toHaveLength(1);
104+
expect(cron[0]!.path).toBe('nodes.0.config.schedule');
105+
expect(cron[0]!.message).toMatch(/is not a schedule at `schedule\.expression`/);
106+
expect(cron[0]!.message).toMatch(/names its cron in `expression`, not `cron`/);
107+
108+
const every = startIssues({ triggerType: 'schedule', schedule: { type: 'interval', every: 60000 } });
109+
expect(every).toHaveLength(1);
110+
expect(every[0]!.path).toBe('nodes.0.config.schedule');
111+
expect(every[0]!.message).toMatch(/is not a schedule at `schedule\.intervalMs`/);
112+
expect(every[0]!.message).toMatch(/names its period in `intervalMs`, not `every`/);
113+
114+
// Without the alias key, no alias hint.
115+
const bare = flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule: { type: 'cron' } }));
116+
expect(bare[0]!.message).not.toMatch(/not `cron`/);
117+
});
118+
119+
it('refuses a typed cron whose expression is an envelope: it passes the schema, and the reader reads nothing', () => {
120+
const found = flowStartConfigRefusals(flowWith({
121+
triggerType: 'schedule',
122+
schedule: { type: 'cron', expression: { dialect: 'cron', source: '0 8 * * *' } },
123+
}));
124+
expect(found).toHaveLength(1);
125+
expect(found[0]!.message).toMatch(/names no schedule the readers can use/);
126+
});
127+
128+
it('judges no absent or null schedule', () => {
129+
expect(flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule: null }))).toEqual([]);
130+
expect(flowStartConfigRefusals(flowWith({ triggerType: 'manual' }))).toEqual([]);
131+
});
132+
133+
it('reads only top-level start nodes', () => {
134+
const flow = flowWith({ triggerType: 'manual' }) as any;
135+
flow.nodes.splice(1, 0, { id: 'mid', type: 'assignment', label: 'Mid', config: { schedule: {} } });
136+
expect(flowStartConfigRefusals(flow)).toEqual([]);
137+
});
138+
});
139+
140+
describe('flowStartScheduleReadsNothing — the reader\'s precedence', () => {
141+
it('the first candidate present decides, as `normalizeSchedule` does', () => {
142+
// `intervalMs` is present and unusable, so the reader never reaches `every`.
143+
expect(flowStartScheduleReadsNothing({ intervalMs: -1, every: 60000 })).toBe(true);
144+
expect(flowStartScheduleReadsNothing({ expression: '', cron: '0 8 * * *' })).toBe(false);
145+
expect(flowStartScheduleReadsNothing(undefined)).toBe(false);
146+
});
147+
});

0 commit comments

Comments
 (0)