Skip to content

Commit 218f834

Browse files
fix(app-shell): clearing the flow designer's cron no longer leaves a schedule block with no cadence (#31412)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XSU6NATZRao9u3ah6yKg179)_ Fixes #31383 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:流程设计器开始节点的「Cron schedule」输入框被清空时,如果剩下的 `config.schedule` 已经没有任何周期(只剩 `type`、`timezone` 之类),现在整块删掉,不再写出 `{ type: 'cron' }` 或 `{ type: 'cron', timezone }`。无类型的 `{ expression, timezone }` 清空后原来会剩 `{ timezone }`,同一个缺陷,一并处理。输入新的 cron 时行为不变:带类型的块照旧保留 `type` 和 `timezone`。 - 为什么改:留下的无周期块引擎读不出调度,定时流程静默不绑定,时间相对扫描也不会按帮助文字说的「留空默认每天」处理;PR #31376 落地后,同样的草稿保存会被 `FlowSchema` 以 422 拒绝,而这个形状正是设计器自己写出来的。 - 风险与回滚:只改 `@object-ui/app-shell` 里 `scheduleWithExpression` 一个函数的「清空」分支;不改 spec、不改服务端、不加 i18n 文案。回滚即 revert。 - 先看:`ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts` 中 `scheduleWithExpression` 的清空分支(`return normalizeScheduleCadence(next) === null ? undefined : next;`),以及两个测试文件末尾新增的 `objectstack#31383` describe。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.scheduleCadence-12779.test.ts packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.scheduleCadence-12779.test.tsx`,预期 2 个文件 68 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes - **`scheduleWithExpression`** (`flow-node-config.ts`, `@object-ui/app-shell`). The clearing arm used to delete `expression` and `cron` and keep whatever else the object held, collapsing only an object left empty. It now returns `undefined` whenever the remaining object resolves to no cadence through `normalizeScheduleCadence`, the designer's branch-for-branch mirror of the engine's `normalizeSchedule`. So: - a cleared `{ type: 'cron', expression }` or `{ type: 'cron', expression, timezone }` stores no `config.schedule`; - a cleared untyped `{ expression, timezone }` stores no `config.schedule` either (it used to leave `{ timezone }`); - an untyped `{ expression }` stores nothing, as before; - a key the cron stood in front of and that still binds is kept: clearing `{ expression, intervalMs }` leaves `{ intervalMs }`, which the engine binds as an interval, as before. The set arm is unchanged. Its tail is rewritten without the `else`, because the set arm always writes `expression`, so the old empty-object check could only fire on the clearing arm. The function's doc comment and the field's comment say what clearing now stores. - **Other designer write paths.** None writes `config.schedule`. `FlowNodeInspector.tsx` routes every commit on the Cron schedule path through `scheduleWithExpression` (line 552); the other `schedule*` helpers in the file (`normalizeScheduleCadence`, `scheduleCadenceOf`, `scheduleFieldValue`, `readOnlyScheduleCadence`) only read. The Advanced JSON box writes whatever the author types and is not a designer-produced shape. - **Changeset** `.changeset/31383-flow-designer-cleared-cron-drops-typed-schedule.md`: `@object-ui/app-shell` patch, both declaration lines. ## Tests - `flow-node-config.scheduleCadence-12779.test.ts`: the old assertion that pinned the defect (clearing `{ type: 'cron', expression, timezone: 'UTC' }` returned `{ type: 'cron', timezone: 'UTC' }`) is removed from the existing clearing case, and a new describe for objectstack#31383 adds 11 cases: - clearing a typed cron, a typed cron with a timezone, a typed cron cleared to `undefined`, a typed `{ cron }` with a timezone, a typed cron that already had no cadence, an `interval` type that carried only an expression, and an unknown type beside an expression: each returns `undefined`; - clearing an untyped `{ expression, timezone }` returns `undefined`; - CONTROLS: an untyped `{ expression }` still clears to `undefined`; a cron typed over `{ type: 'cron', expression, timezone }` or `{ type: 'cron', timezone }` keeps `type` and `timezone`; clearing `{ expression, intervalMs }` keeps the interval the engine then binds. - `FlowNodeInspector.scheduleCadence-12779.test.tsx` (rendered, through the real Cron schedule input and `fireEvent.change`): clearing the box on a typed cron, and on a typed cron with a timezone, leaves the start node's `config` with no `schedule` key and its `objectName` kept, and the box reads empty. CONTROL: clearing an untyped `{ expression }` stores no schedule, as before. The file now imports `@objectstack/lint` at module top, as its sibling inspector tests do, because the inspector reaches it through function-body `import()`s and `check:test-source-alias` asks an edited test file to pay that load at collection. - Ablation through `scripts/ablation-replace.mjs`: the clearing arm's return put back to the old `Object.keys(next).length === 0 ? undefined : next` (anchor 1 to 0, blob `aa3d830d` to `91bc6d94`). Result: 10 failed, 58 passed (68): the 8 new table cases and the 2 rendered typed cases red; every CONTROL green. Restored: blob equal to HEAD (`aa3d830d`), `git diff HEAD` empty. Both tests import `flow-node-config.ts` from source by relative path, so no build sat between the mutation and the run. ## Acceptance notes - **The untyped `{ timezone }` leftover is fixed in place.** The grade record says the untyped form already collapses; that holds for `{ expression }` but not for `{ expression, timezone }`, which left `{ timezone }`. The engine binds nothing from it, and PR #31376's `flowStartScheduleReadsNothing` refuses an untyped object it reads nothing from, so it is the same defect in the same function. All four conditions of the bounded in-place fix hold: same defect class, a mechanical fix pinned here, the file is inside this claim's surface, and no new gate family. Named here so the seat can rule it out if it prefers the narrower reading. - **PR #31376 has not merged** (open, draft, at the time of this PR), so the cleared case was not run through its `FlowSchema` refusals. The shapes this PR stops writing are the ones its review named: a typed cron with no `expression`, which the published `ScheduleSchema` already refuses on `main`. - **Declaration lines measured on the diff:** no published key, export, schema arm or i18n string is added or removed. `Clause-②: no`, `Metadata-protocol: none`, as the claim opened. ## Verification Run on head `3251f97c` (base `origin/main` `03533d2c`). <details><summary>Build, tests and lint</summary> - Build under the verify lock: `pnpm --workspace-concurrency=2 --filter '@objectstack/spec...' --filter '@objectstack/formula...' --filter '@objectstack/client...' --filter '@objectstack/lint...' --filter '@objectstack/sdui-parser...' build`: VERDICT command-exit 0. - `pnpm --dir ui exec vitest run --maxWorkers=2` on the two touched test files: Test Files 2 passed (2), Tests 68 passed (68). - Every inspector test that imports `scheduleWithExpression`'s module or renders `FlowNodeInspector` (`inspectors/FlowNodeInspector*` and `inspectors/flow-node-config*`, 39 files): Test Files 39 passed (39), Tests 667 passed, 1 skipped. - eslint on the three changed source and test files: exit 0. `pnpm --dir ui run check:new-line-citations`: 0 new citations, exit 0. - `@object-ui/app-shell` `type-check` was not run locally (it needs every `@object-ui/*` dist built); `check:type-check-debt` below exits 0, and CI's type-check lane runs the package. </details> <details><summary>Derived gates (36)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 36 families on this diff. On head `3251f97c` all 36 exited 0, among them `pnpm --dir ui exec vitest run scripts/` and `pnpm check:type-check-debt`. `--ran` reconciliation with exit codes recorded: 36 derived, 36 run, 0 NOT-MEASURED, 0 UNRUN. The first round, on `93b35e72`, had one red: `check:test-source-alias` asked the edited rendered test to load `@objectstack/lint` at module top; `3251f97c` adds that import, and the whole list was re-run. Changed lines: 106, under the 3,000 threshold. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5oAnL2z711MLVm7ZnB)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5oAnL2z711MLVm7ZnB --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1f029a7 commit 218f834

4 files changed

Lines changed: 98 additions & 8 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): clearing a start node's Cron schedule in the flow designer no longer leaves a typed schedule block with no cadence
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Clearing the Cron schedule box on a typed `config.schedule` such as `{ type: 'cron', expression, timezone }` used to store `{ type: 'cron', timezone }`. The engine binds nothing from that block, so a schedule flow silently never ran and a time-relative sweep did not fall back to its daily default the way the field's help says. The published `ScheduleSchema` refuses the same block. An untyped `{ expression, timezone }` had the same fault: clearing it left `{ timezone }`, which binds nothing either. The designer now removes `config.schedule` entirely whenever clearing the cron leaves no cadence, as it already did for an untyped `{ expression }`. Typing a cron over a typed block still keeps its `type` and `timezone`.

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.scheduleCadence-12779.test.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ import * as React from 'react';
2828
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2929
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
3030
import { resolveFlowTriggerKind } from '@objectstack/spec/automation';
31+
// The inspector's object picker and CEL authoring load `@objectstack/lint` with
32+
// function-body `import()`s. Warmed here so its first load is paid at
33+
// collection, not inside a case's bounded wait.
34+
import '@objectstack/lint';
3135

3236
vi.mock('../previews/useFlowNodePalette', () => ({
3337
useActionConfigSchemas: () => ({}),
@@ -238,6 +242,30 @@ describe('a typed cron stores a descriptor the engine resolves to it (objectui#1
238242
});
239243
});
240244

245+
describe('clearing the Cron schedule box drops a typed block it would leave with no cadence (objectstack#31383)', () => {
246+
const typed: Array<[string, Record<string, unknown>]> = [
247+
['a typed cron', { type: 'cron', expression: '0 7 * * *' }],
248+
['a typed cron with a timezone', { type: 'cron', expression: '0 7 * * *', timezone: 'Asia/Shanghai' }],
249+
];
250+
for (const [name, schedule] of typed) {
251+
it(`clearing ${name} stores no schedule`, () => {
252+
const { latest, patches } = mount(scheduleFlow({ schedule, objectName: 'todo_task' }));
253+
fireEvent.change(controlOf('Cron schedule'), { target: { value: '' } });
254+
expect(patches.length).toBeGreaterThan(0);
255+
const config = startConfig(latest());
256+
expect('schedule' in config).toBe(false);
257+
expect(config.objectName).toBe('todo_task');
258+
expect(controlOf('Cron schedule').value).toBe('');
259+
});
260+
}
261+
262+
it('CONTROL: clearing an untyped { expression } stores no schedule, as before', () => {
263+
const { latest } = mount(scheduleFlow({ schedule: { expression: '0 6 * * *' }, objectName: 'todo_task' }));
264+
fireEvent.change(controlOf('Cron schedule'), { target: { value: '' } });
265+
expect('schedule' in startConfig(latest())).toBe(false);
266+
});
267+
});
268+
241269
describe('CONTROLS: the forms the field already read behave as before (objectui#12779)', () => {
242270
it('a typed cron with a timezone reads and writes with its type and timezone kept', () => {
243271
const { latest } = mount(scheduleFlow({ schedule: { type: 'cron', expression: '0 7 * * *', timezone: 'Asia/Shanghai' } }));

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.scheduleCadence-12779.test.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,54 @@ describe('a typed cron stores a descriptor that resolves to it (objectui#12779)'
171171
expect(scheduleWithExpression({ cron: '0 4 * * *' }, '')).toBeUndefined();
172172
expect(scheduleWithExpression({ expression: '0 4 * * *' }, undefined)).toBeUndefined();
173173
expect(scheduleWithExpression({ type: 'interval', intervalMs: 60000 }, undefined)).toBeUndefined();
174-
expect(scheduleWithExpression({ type: 'cron', expression: '0 7 * * *', timezone: 'UTC' }, '')).toEqual({
174+
});
175+
});
176+
177+
describe('clearing never leaves a schedule block with no cadence (objectstack#31383)', () => {
178+
// A typed block is judged by the published `ScheduleSchema`, which refuses a
179+
// `type: 'cron'` with no `expression`; the engine binds nothing from it.
180+
const typedCleared: Array<[string, Record<string, unknown>, unknown]> = [
181+
["a typed cron's expression", { type: 'cron', expression: '0 7 * * *' }, ''],
182+
["a typed cron's expression beside a timezone", { type: 'cron', expression: '0 7 * * *', timezone: 'UTC' }, ''],
183+
['a typed cron cleared to undefined', { type: 'cron', expression: '0 7 * * *', timezone: 'Asia/Shanghai' }, undefined],
184+
["a typed { cron }'s value", { type: 'cron', cron: '0 5 * * *', timezone: 'UTC' }, ''],
185+
['a typed cron that already carried no cadence', { type: 'cron', timezone: 'UTC' }, ''],
186+
['an interval type that carried only an expression', { type: 'interval', expression: '0 7 * * *' }, ''],
187+
['an unknown type beside an expression', { type: 'weekly', expression: '0 8 * * *', timezone: 'UTC' }, ''],
188+
];
189+
for (const [name, schedule, value] of typedCleared) {
190+
it(`clearing ${name} removes the whole schedule`, () => {
191+
expect(normalizeScheduleCadence(scheduleWithExpression(schedule, value))).toBeNull();
192+
expect(scheduleWithExpression(schedule, value)).toBeUndefined();
193+
});
194+
}
195+
196+
it('clearing an untyped { expression } beside a timezone removes the whole schedule', () => {
197+
// `{ timezone }` alone binds nothing, and the flow parse reads nothing from it.
198+
expect(normalizeScheduleCadence({ timezone: 'UTC' })).toBeNull();
199+
expect(scheduleWithExpression({ expression: '0 4 * * *', timezone: 'UTC' }, '')).toBeUndefined();
200+
});
201+
202+
it('CONTROL: clearing an untyped { expression } still removes the whole schedule', () => {
203+
expect(scheduleWithExpression({ expression: '0 4 * * *' }, '')).toBeUndefined();
204+
});
205+
206+
it('CONTROL: a cadence the cron stood in front of is kept when the cron is cleared', () => {
207+
// The engine resolves `expression` ahead of `intervalMs`; with the cron gone the interval binds.
208+
const stored = scheduleWithExpression({ expression: '0 4 * * *', intervalMs: 60000 }, '');
209+
expect(stored).toEqual({ intervalMs: 60000 });
210+
expect(normalizeScheduleCadence(stored)).toEqual({ type: 'interval', intervalMs: 60000 });
211+
});
212+
213+
it('CONTROL: a cron typed over a typed block keeps its type and the other keys', () => {
214+
expect(scheduleWithExpression({ type: 'cron', expression: '0 7 * * *', timezone: 'UTC' }, '0 9 * * *')).toEqual({
215+
type: 'cron',
216+
expression: '0 9 * * *',
217+
timezone: 'UTC',
218+
});
219+
expect(scheduleWithExpression({ type: 'cron', timezone: 'UTC' }, '0 9 * * *')).toEqual({
175220
type: 'cron',
221+
expression: '0 9 * * *',
176222
timezone: 'UTC',
177223
});
178224
});

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,8 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
526526
// it is (`readOnlyScheduleCadence`): the designer edits cron cadences only.
527527
// A commit rewrites the whole block through `scheduleWithExpression`, which
528528
// keeps a bare string a string and an object's `type` / `timezone`, so the
529-
// stored cadence resolves to the cron the field shows.
529+
// stored cadence resolves to the cron the field shows; clearing it never
530+
// leaves a block with no cadence (objectstack#31383).
530531
//
531532
// objectui#12672 — gated on the flow's RESOLVED kind as well as on the
532533
// select: a `type: 'schedule'` flow, or a start node carrying
@@ -1705,7 +1706,12 @@ const NON_CRON_CADENCE_KEYS = ['intervalMs', 'every', 'at'] as const;
17051706
* only met on a descriptor the engine binds nothing from, since a live
17061707
* interval or once cadence offers no cron edit at all.
17071708
* - Clearing removes `expression` and `cron`, or the whole schedule when it is
1708-
* a string, an interval or once cadence, or an object left empty.
1709+
* a string, an interval or once cadence, or an object left with no cadence
1710+
* (objectstack#31383). A `type` or `timezone` left behind is not a cadence:
1711+
* the engine binds nothing from `{ type: 'cron', timezone }` or
1712+
* `{ timezone }`, and the published `ScheduleSchema` refuses a typed block
1713+
* with no `expression`. So a cleared `{ type: 'cron', expression, timezone }`
1714+
* leaves no schedule, as an untyped `{ expression }` always did.
17091715
*/
17101716
export function scheduleWithExpression(schedule: unknown, value: unknown): unknown {
17111717
const expression = typeof value === 'string' ? value : '';
@@ -1717,12 +1723,12 @@ export function scheduleWithExpression(schedule: unknown, value: unknown): unkno
17171723
const cadence = normalizeScheduleCadence(schedule);
17181724
if (cadence && cadence.type !== 'cron') return undefined;
17191725
delete next.expression;
1720-
} else {
1721-
if (typeof next.type === 'string' && next.type !== 'cron') delete next.type;
1722-
for (const key of NON_CRON_CADENCE_KEYS) delete next[key];
1723-
next.expression = expression;
1726+
return normalizeScheduleCadence(next) === null ? undefined : next;
17241727
}
1725-
return Object.keys(next).length === 0 ? undefined : next;
1728+
if (typeof next.type === 'string' && next.type !== 'cron') delete next.type;
1729+
for (const key of NON_CRON_CADENCE_KEYS) delete next[key];
1730+
next.expression = expression;
1731+
return next;
17261732
}
17271733

17281734
/**

0 commit comments

Comments
 (0)