Skip to content

Commit d132bb5

Browse files
os-zhuangclaude
andauthored
fix(app-shell): a published configSchema can no longer delete a node's sibling-block editors (objectstack#4045) (#3082)
* fix(app-shell): a published configSchema can no longer delete a node's sibling-block editors (framework#4045) The inspector resolved its fields as `serverFields ?? fieldsForNodeType(type)` — an engine-published configSchema REPLACED the hand-written group wholesale. But a configSchema describes `node.config` and nothing else (ADR-0018), and `jsonSchemaToFlowFields` roots every field it emits at `['config', key]`, so the replacement silently deleted every editor rooted anywhere else. 18 fields are exposed to that: connectorConfig.* (3), waitEventConfig.* (5), boundaryConfig.* (6) and the top-level timeoutMs (4). For `wait` and `boundary_event` that is their ENTIRE contract. Not hypothetical — connector_action already lost this way. Its descriptor published a schema declaring connectorId/actionId/input as CONFIG keys, so against a live backend the generated form replaced the connectorConfig group, pickers included, and an author configuring a connector node in Studio wrote the trio where the executor never reads. The node then refused to dispatch with "connectorConfig.connectorId and .actionId are required". framework#4210 retired that schema; this stops the next mis-rooted one from doing the same. mergeServerFlowFields() splits the resolution by root: the server owns the config-rooted fields (it is the authority on what the executor reads, so a stale client key must not linger), and the hand-written non-config fields are always preserved in declared order. A server field duplicating a preserved sibling key is dropped rather than rendered twice — two editors for one value, one writing where nothing reads, is the same bug in a different hat. Verified by mutation: reverting to the old replacement turns all three new assertions red, including a replay of the connector_action incident. * chore: changeset for the flow-inspector sibling-block fix (objectstack#4045) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c4d7b20 commit d132bb5

4 files changed

Lines changed: 187 additions & 3 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@object-ui/app-shell": patch
3+
---
4+
5+
fix(flow-designer): a published `configSchema` can no longer delete a node's sibling-block editors — objectstack#4045
6+
7+
`FlowNodeInspector` resolved its form as `serverFields ?? fieldsForNodeType(type)`,
8+
so an engine-published `configSchema` **replaced the hand-written field group
9+
wholesale**. But a `configSchema` describes `node.config` and nothing else
10+
(ADR-0018), and `jsonSchemaToFlowFields` roots every field it emits at
11+
`['config', key]` — so the replacement silently deleted every editor rooted
12+
anywhere else.
13+
14+
18 fields sit in that blast radius: `connectorConfig.*` (3), `waitEventConfig.*`
15+
(5), `boundaryConfig.*` (6) and the top-level `timeoutMs` (4). For `wait` and
16+
`boundary_event` those blocks are the node's **entire** contract.
17+
18+
This already happened once. `connector_action`'s descriptor published a schema
19+
declaring `connectorId` / `actionId` / `input` as CONFIG keys, so against a live
20+
backend the generated form replaced the `connectorConfig` group — connector and
21+
action pickers included — and an author configuring a connector node in Studio
22+
wrote the trio to `node.config`, which the executor never reads. The node then
23+
refused to dispatch with `connectorConfig.connectorId and .actionId are
24+
required`. objectstack#4210 retired that schema on the server; this change is
25+
what stops the next mis-rooted one from doing the same to `wait` or
26+
`boundary_event`.
27+
28+
New `mergeServerFlowFields()` splits the resolution by root:
29+
30+
- **the server owns the config-rooted fields** — it is the authority on what the
31+
executor actually reads, so its set replaces the hand-written config fields
32+
rather than merging with them (a stale client key must not linger);
33+
- **non-config fields are always preserved** from the hand-written group, in
34+
declared order;
35+
- a server field duplicating a preserved sibling key is **dropped**, not rendered
36+
twice — two editors for one value, one of them writing where nothing reads, is
37+
the same bug wearing a different hat.
38+
39+
With no published schema the hand-written group is still used whole, unchanged.
40+
41+
Verified by mutation: reverting to the old replacement turns all three new
42+
assertions red, one of which replays the `connector_action` incident directly.

packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
InspectorEmptyState,
3232
} from './_shared';
3333
import {
34-
fieldsForNodeType,
34+
mergeServerFlowFields,
3535
localizeFlowFields,
3636
isFieldVisible,
3737
getFieldValue,
@@ -140,7 +140,11 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
140140
const fields = React.useMemo(() => {
141141
const schema = node?.type ? configSchemas[node.type] : undefined;
142142
const serverFields = schema !== undefined ? jsonSchemaToFlowFields(schema) : null;
143-
const resolved = serverFields ?? fieldsForNodeType(node?.type);
143+
// A published configSchema describes `node.config` ONLY, so it replaces just
144+
// the config-rooted fields — the spec-structured sibling blocks
145+
// (connectorConfig / waitEventConfig / boundaryConfig) and top-level
146+
// `timeoutMs` are always kept from the hand-written group (framework#4045).
147+
const resolved = mergeServerFlowFields(serverFields, node?.type);
144148
// Localize both the hardcoded table and the engine-published configSchema
145149
// fields (they share field ids for built-in nodes); no-op for English.
146150
return localizeFlowFields(node?.type, resolved, locale);

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

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { fieldsForNodeType, isFieldVisible, getFieldValue, configKeyOf } from './flow-node-config';
4+
import {
5+
fieldsForNodeType,
6+
mergeServerFlowFields,
7+
isFieldVisible,
8+
getFieldValue,
9+
configKeyOf,
10+
type FlowConfigField,
11+
} from './flow-node-config';
512

613
describe('start node trigger-field gating (#5)', () => {
714
const fields = fieldsForNodeType('start');
@@ -234,3 +241,85 @@ describe('notify node — first-class static config editor (#1895)', () => {
234241
expect(severity.options!.map((o) => o.value)).toEqual(['info', 'warning', 'critical']);
235242
});
236243
});
244+
245+
/**
246+
* A published `configSchema` describes `node.config` and nothing else, so it must
247+
* not be allowed to delete the editors for a node's spec-structured SIBLING
248+
* blocks (framework#4045).
249+
*
250+
* This is a real incident, not a hypothetical: `connector_action` shipped a
251+
* descriptor whose `configSchema` declared `connectorId` / `actionId` / `input`
252+
* as CONFIG keys. Against a live backend the generated form replaced this
253+
* table's `connectorConfig.*` group — connector and action pickers included —
254+
* so an author configuring a connector node in Studio wrote the trio to
255+
* `node.config`, which the executor never reads, and the node refused to
256+
* dispatch with "connectorConfig.connectorId and .actionId are required".
257+
* framework#4210 retired that schema; these tests are what stop the next
258+
* mis-rooted schema from doing the same to `wait` or `boundary_event`, whose
259+
* whole contract lives in a sibling block.
260+
*/
261+
describe('server configSchema ↔ hand-written field merge (framework#4045)', () => {
262+
const serverConnectorFields: FlowConfigField[] = [
263+
{ id: 'connectorId', path: ['config', 'connectorId'], label: 'Connector', kind: 'text' },
264+
{ id: 'actionId', path: ['config', 'actionId'], label: 'Action', kind: 'text' },
265+
{ id: 'input', path: ['config', 'input'], label: 'Input', kind: 'keyValue' },
266+
];
267+
268+
it('with no published schema, the hand-written group is used whole', () => {
269+
expect(mergeServerFlowFields(null, 'connector_action')).toEqual(fieldsForNodeType('connector_action'));
270+
expect(mergeServerFlowFields(undefined, 'wait')).toEqual(fieldsForNodeType('wait'));
271+
});
272+
273+
it('the exact incident: a config-rooted connector schema cannot delete the connectorConfig pickers', () => {
274+
const merged = mergeServerFlowFields(serverConnectorFields, 'connector_action');
275+
276+
// The structured editors survive, with their reference pickers intact.
277+
const connectorId = merged.find((f) => f.path.join('.') === 'connectorConfig.connectorId');
278+
const actionId = merged.find((f) => f.path.join('.') === 'connectorConfig.actionId');
279+
expect(connectorId, 'connectorConfig.connectorId must survive a published schema').toBeDefined();
280+
expect(actionId, 'connectorConfig.actionId must survive a published schema').toBeDefined();
281+
expect(connectorId!.kind).toBe('reference');
282+
expect(actionId!.ref?.kind).toBe('connector-action');
283+
284+
// And the server's mis-rooted duplicates do NOT ALSO appear — two editors for
285+
// one value, one of them writing where nothing reads, is the same bug wearing
286+
// a different hat.
287+
expect(merged.filter((f) => f.path.join('.') === 'config.connectorId')).toEqual([]);
288+
expect(merged.filter((f) => f.path.join('.') === 'config.actionId')).toEqual([]);
289+
expect(merged.filter((f) => f.path.join('.') === 'config.input')).toEqual([]);
290+
});
291+
292+
it('every node type with a sibling-block or top-level field keeps it under any schema', () => {
293+
// The latent blast radius, enumerated: if a schema is ever published for one
294+
// of these, this is what a plain replacement would have silently dropped.
295+
const serverFields: FlowConfigField[] = [
296+
{ id: 'whatever', path: ['config', 'whatever'], label: 'Whatever', kind: 'text' },
297+
];
298+
for (const type of ['connector_action', 'wait', 'boundary_event', 'subflow', 'script', 'http_request']) {
299+
const nonConfig = fieldsForNodeType(type).filter((f) => f.path[0] !== 'config');
300+
expect(nonConfig.length, `${type} should have sibling/top-level fields to protect`).toBeGreaterThan(0);
301+
const merged = mergeServerFlowFields(serverFields, type);
302+
for (const f of nonConfig) {
303+
expect(
304+
merged.some((m) => m.path.join('.') === f.path.join('.')),
305+
`${type}: ${f.path.join('.')} was dropped by the published schema`,
306+
).toBe(true);
307+
}
308+
}
309+
});
310+
311+
it('the server still owns the config-rooted fields', () => {
312+
// The other direction: the engine is the authority on what the executor
313+
// reads, so its config fields replace the hand-written ones rather than
314+
// merging with them — a stale client key must not linger.
315+
const merged = mergeServerFlowFields(
316+
[{ id: 'brandNew', path: ['config', 'brandNew'], label: 'Brand new', kind: 'text' }],
317+
'http_request',
318+
);
319+
expect(merged.some((f) => f.path.join('.') === 'config.brandNew')).toBe(true);
320+
// `url` is in the hand-written http_request group but not in this schema.
321+
expect(merged.some((f) => f.path.join('.') === 'config.url')).toBe(false);
322+
// …while the top-level timeoutMs, which no configSchema can describe, stays.
323+
expect(merged.some((f) => f.path.join('.') === 'timeoutMs')).toBe(true);
324+
});
325+
});

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,55 @@ export function fieldsForNodeType(type?: string): FlowConfigField[] {
881881
return FLOW_NODE_CONFIG[canonical] ?? [];
882882
}
883883

884+
/** A field that edits `node.config.<key>` rather than a spec-structured sibling block. */
885+
function isConfigRooted(field: FlowConfigField): boolean {
886+
return field.path[0] === 'config';
887+
}
888+
889+
/**
890+
* Merge the engine-published field set with the hand-written group for a node
891+
* type (framework#4045).
892+
*
893+
* A published `configSchema` describes **`node.config` and nothing else** — that
894+
* is what the descriptor's contract says (ADR-0018) and what
895+
* {@link jsonSchemaToFlowFields} produces, since it roots every field it emits
896+
* at `['config', key]`. But several node types keep part (or all) of their
897+
* contract in a spec-structured SIBLING block on the node — `connectorConfig`
898+
* (connector_action), `waitEventConfig` (wait), `boundaryConfig`
899+
* (boundary_event) — or at the node top level (`timeoutMs`, four types).
900+
*
901+
* Replacing the whole group with the server's therefore deletes editors the
902+
* server never claimed to describe. That is not hypothetical: `connector_action`
903+
* shipped a `configSchema` declaring `connectorId`/`actionId`/`input` as CONFIG
904+
* keys, and against a live backend it replaced this table's `connectorConfig.*`
905+
* fields — connector and action pickers included — so an author writing a
906+
* connector node online filled in keys the executor never reads, and the node
907+
* refused to dispatch. framework#4210 retired that schema; this merge is what
908+
* stops the next one from doing the same to `wait` or `boundary_event`.
909+
*
910+
* So the server owns the config-rooted fields (it is the authority on what the
911+
* executor reads), and the hand-written non-config fields are always preserved,
912+
* in their declared order, after them. When no schema is published the
913+
* hand-written group is used whole, unchanged.
914+
*/
915+
export function mergeServerFlowFields(
916+
serverFields: FlowConfigField[] | null | undefined,
917+
type?: string,
918+
): FlowConfigField[] {
919+
const handWritten = fieldsForNodeType(type);
920+
if (!serverFields) return handWritten;
921+
// A sibling-block field the server also described (by id) is not duplicated —
922+
// the server's config-rooted version is dropped in favour of the structured
923+
// editor, since a `config`-rooted duplicate would write where nothing reads.
924+
const serverConfigFields = serverFields.filter(isConfigRooted);
925+
const preserved = handWritten.filter((f) => !isConfigRooted(f));
926+
const preservedKeys = new Set(preserved.map((f) => f.path[f.path.length - 1]));
927+
return [
928+
...serverConfigFields.filter((f) => !preservedKeys.has(f.path[f.path.length - 1])),
929+
...preserved,
930+
];
931+
}
932+
884933
/** Overlay a column's zh label / option labels (English is the fallback). */
885934
function localizeColumn(
886935
col: FlowConfigColumn,

0 commit comments

Comments
 (0)