-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathvalidate-flow-trigger-readiness.ts
More file actions
187 lines (172 loc) · 8.96 KB
/
Copy pathvalidate-flow-trigger-readiness.ts
File metadata and controls
187 lines (172 loc) · 8.96 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Build-time guardrail for auto-launched flow trigger wiring (2026-07-17
// third-party eval: a record-change flow that silently never fires).
//
// A pure `(stack) => Finding[]` rule (ADR-0019), run from `os validate` and
// reusable by AI authoring. It catches the two authoring mistakes that produce
// a flow which LOOKS armed but never launches — with zero runtime output:
//
// 1. `objectName` mismatch — the start node targets an object name that is
// not defined in this stack. The runtime binds an ObjectQL hook filtered
// to that exact name; if nobody writes it, the flow never fires. Names
// match exactly (`eval_app_candidate`, not `candidate`). Objects owned by
// other packages (`sys_*`, dependency packages) are legitimate targets,
// so this is a warning with the cross-package caveat, not an error.
//
// 2. `status: 'draft'` on an auto-triggered flow — the schema default when
// no status is authored (defineFlow parses at definition time, so by the
// time this rule runs an unauthored status is indistinguishable from an
// explicit 'draft'). Either way the intent is ambiguous: the engine still
// binds and fires draft flows (only `obsolete`/`invalid` disable), which
// surprises authors in both directions. Declare `'active'` to arm
// deliberately or `'obsolete'` to disable. Only auto-triggered flows are
// flagged (manual/screen flows have no arming semantics to be unclear
// about).
export type FlowTriggerReadinessSeverity = 'error' | 'warning';
export interface FlowTriggerReadinessFinding {
severity: FlowTriggerReadinessSeverity;
rule: string;
/** Human-readable location, e.g. `flow "notify_on_done" › start node`. */
where: string;
/** Config path, e.g. `flows[0].nodes[0].config.objectName`. */
path: string;
message: string;
hint: string;
}
// Rule ids (registry entries).
export const FLOW_TRIGGER_UNKNOWN_OBJECT = 'flow-trigger-unknown-object';
export const FLOW_DRAFT_STATUS_AMBIGUOUS = 'flow-draft-status-ambiguous';
export const FLOW_TRIGGER_UNKNOWN_EVENT = 'flow-trigger-unknown-event';
type AnyRec = Record<string, unknown>;
/**
* Recognized record-change lifecycle ops. A start node's `triggerType` fires only
* when it matches `record-(before|after)-<op>` with `op` in this set — the exact
* grammar the record-change trigger's `triggerTypeToHookEvents` maps to ObjectQL
* hooks. `insert` is a synonym for `create`; `write` is the create-OR-update union
* (#3427). Kept in sync with that trigger (one small, stable contract).
*/
const RECORD_TRIGGER_OPS = new Set(['create', 'insert', 'update', 'delete', 'write']);
/** A record-lifecycle-SHAPED token: `record-before-…` / `record-after-…`. */
const RECORD_EVENT_SHAPE = /^record-(?:before|after)-(.+)$/;
/** Coerce an array-or-name-keyed-map collection to an array (name injected). */
function asArray(v: unknown): AnyRec[] {
if (Array.isArray(v)) return v as AnyRec[];
if (v && typeof v === 'object') {
return Object.entries(v as AnyRec).map(([name, def]) => ({
name,
...(def as AnyRec),
}));
}
return [];
}
/** The start node of a flow definition, if any. */
function startNodeOf(flow: AnyRec): { node: AnyRec; index: number } | undefined {
const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : [];
const index = nodes.findIndex((n) => n?.type === 'start');
return index >= 0 ? { node: nodes[index], index } : undefined;
}
/**
* Validate auto-launched flow trigger wiring against the stack definition.
* Pure and dependency-free; safe on pre- or post-parse stacks.
*/
export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadinessFinding[] {
const findings: FlowTriggerReadinessFinding[] = [];
const flows = asArray(stack.flows);
if (flows.length === 0) return findings;
const objectNames = new Set(
asArray(stack.objects)
.map((o) => (typeof o.name === 'string' ? o.name : undefined))
.filter((n): n is string => !!n),
);
flows.forEach((flow, flowIndex) => {
const flowName = typeof flow.name === 'string' ? flow.name : `#${flowIndex}`;
const start = startNodeOf(flow);
const config = (start?.node.config ?? {}) as AnyRec;
const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined;
const isRecordTriggered = !!triggerType && triggerType.startsWith('record-');
const isTimeRelative = config.timeRelative != null && typeof config.timeRelative === 'object';
const isAutoTriggered =
isRecordTriggered || triggerType === 'api' || config.schedule != null ||
isTimeRelative || flow.type === 'schedule' || flow.type === 'api';
// 1. Record-triggered flow targeting an object this stack does not define.
if (isRecordTriggered && start) {
const objectName = typeof config.objectName === 'string' ? config.objectName : undefined;
if (objectName && !objectNames.has(objectName) && !objectName.startsWith('sys_')) {
findings.push({
severity: 'warning',
rule: FLOW_TRIGGER_UNKNOWN_OBJECT,
where: `flow "${flowName}" › start node`,
path: `flows[${flowIndex}].nodes[${start.index}].config.objectName`,
message:
`targets object '${objectName}', which this stack does not define — if the name is wrong, ` +
`the flow will never fire (and the runtime stays silent about it).`,
hint:
`Object names match exactly. Check config.objectName against the object's registered name ` +
`(e.g. 'app_candidate', not 'candidate'). If the object comes from another installed package, ` +
`this warning can be ignored.`,
});
}
}
// 1b. Time-relative flow sweeping an object this stack does not define. Like
// the record-change case, a wrong object name makes the sweep match
// nothing forever with no runtime output.
if (isTimeRelative && start) {
const tr = config.timeRelative as AnyRec;
const objectName = typeof tr.object === 'string' ? tr.object : undefined;
if (objectName && !objectNames.has(objectName) && !objectName.startsWith('sys_')) {
findings.push({
severity: 'warning',
rule: FLOW_TRIGGER_UNKNOWN_OBJECT,
where: `flow "${flowName}" › start node`,
path: `flows[${flowIndex}].nodes[${start.index}].config.timeRelative.object`,
message:
`sweeps object '${objectName}', which this stack does not define — if the name is wrong, ` +
`the sweep will match nothing (and the runtime stays quiet about it).`,
hint:
`Object names match exactly. Check config.timeRelative.object against the object's registered name. ` +
`If the object comes from another installed package, this warning can be ignored.`,
});
}
}
// 1c. Record-lifecycle-SHAPED triggerType (`record-before|after-…`) whose op
// is not one the trigger can map — a typo like `record-after-updated`. The
// engine still routes any `record-` token to the record-change trigger,
// which then maps it to NO hook and never fires (only a runtime warn). This
// is a definite never-fire defect, so surface it at authoring time. (Bare
// `record-<noun>` shapes without a before/after phase — e.g. `record-change`
// — are a separate concern and not flagged here.)
if (start && triggerType) {
const shape = RECORD_EVENT_SHAPE.exec(triggerType);
if (shape && !RECORD_TRIGGER_OPS.has(shape[1])) {
findings.push({
severity: 'warning',
rule: FLOW_TRIGGER_UNKNOWN_EVENT,
where: `flow "${flowName}" › start node`,
path: `flows[${flowIndex}].nodes[${start.index}].config.triggerType`,
message:
`triggerType '${triggerType}' names an unrecognized lifecycle event '${shape[1]}' — the flow binds to ` +
`the record-change trigger but never fires (the runtime stays silent about it).`,
hint:
`Use record-{before,after}-{create,update,delete,write}. 'write' fires on create OR update in one ` +
`flow (#3427); create/insert are synonyms.`,
});
}
}
// 2. Auto-triggered flow whose status is 'draft' — authored or defaulted
// (defineFlow parses at definition time, so the two are the same here).
if (isAutoTriggered && (flow.status == null || flow.status === 'draft')) {
findings.push({
severity: 'warning',
rule: FLOW_DRAFT_STATUS_AMBIGUOUS,
where: `flow "${flowName}"`,
path: `flows[${flowIndex}].status`,
message:
`has status 'draft' (the default when none is authored). Draft flows DO still fire their ` +
`triggers (only 'obsolete'/'invalid' disable), so the intent is ambiguous.`,
hint: `Declare status: 'active' to arm it deliberately, or status: 'obsolete' to disable it.`,
});
}
});
return findings;
}