@protoyard/flows 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -2
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +2 -0
  5. package/dist/model/conditions.d.ts +75 -0
  6. package/dist/model/conditions.js +297 -0
  7. package/dist/model/data.d.ts +14 -0
  8. package/dist/model/data.js +73 -0
  9. package/dist/model/flow.d.ts +53 -0
  10. package/dist/model/flow.js +93 -0
  11. package/dist/model/index.d.ts +17 -0
  12. package/dist/model/index.js +17 -0
  13. package/dist/model/limits.d.ts +32 -0
  14. package/dist/model/limits.js +32 -0
  15. package/dist/model/ops.d.ts +122 -0
  16. package/dist/model/ops.js +311 -0
  17. package/dist/model/recipes.d.ts +12 -0
  18. package/dist/model/recipes.js +209 -0
  19. package/dist/model/refs.d.ts +30 -0
  20. package/dist/model/refs.js +48 -0
  21. package/dist/model/steps.d.ts +276 -0
  22. package/dist/model/steps.js +206 -0
  23. package/dist/model/text.d.ts +30 -0
  24. package/dist/model/text.js +33 -0
  25. package/dist/model/triggers.d.ts +42 -0
  26. package/dist/model/triggers.js +68 -0
  27. package/dist/model/validate.d.ts +19 -0
  28. package/dist/model/validate.js +207 -0
  29. package/dist/model/walk.d.ts +40 -0
  30. package/dist/model/walk.js +68 -0
  31. package/dist/runtime/clock.d.ts +12 -0
  32. package/dist/runtime/clock.js +40 -0
  33. package/dist/runtime/engine.d.ts +15 -0
  34. package/dist/runtime/engine.js +578 -0
  35. package/dist/runtime/index.d.ts +11 -0
  36. package/dist/runtime/index.js +10 -0
  37. package/dist/runtime/keeps.d.ts +8 -0
  38. package/dist/runtime/keeps.js +33 -0
  39. package/dist/runtime/outbox.d.ts +8 -0
  40. package/dist/runtime/outbox.js +27 -0
  41. package/dist/runtime/seed.d.ts +7 -0
  42. package/dist/runtime/seed.js +45 -0
  43. package/dist/runtime/signals.d.ts +4 -0
  44. package/dist/runtime/signals.js +13 -0
  45. package/dist/runtime/types.d.ts +127 -0
  46. package/dist/runtime/types.js +5 -0
  47. package/package.json +62 -3
@@ -0,0 +1,206 @@
1
+ import { z } from 'zod';
2
+ import { ConditionSchema } from "./conditions.js";
3
+ import { FLOW_ID_PATTERN, FLOW_LIMITS } from "./limits.js";
4
+ import { DataRefSchema, FlowIdSchema } from "./refs.js";
5
+ import { AudienceSchema, FlowTextSchema } from "./text.js";
6
+ import { TimeOfDaySchema } from "./triggers.js";
7
+ /*
8
+ * The step catalog. Every step is plain data that a runtime turns into a
9
+ * proposed effect for a service adapter (ADR-041): there is no step that
10
+ * holds code, a URL, headers or credentials, so a flow can never execute
11
+ * JavaScript or reach a network on its own.
12
+ */
13
+ const MINUTES_PER_YEAR = 525_600;
14
+ const common = {
15
+ id: FlowIdSchema,
16
+ /** A display name that wins over the derived sentence. */
17
+ label: z.string().trim().min(1).max(120).optional(),
18
+ /** Absent means enabled. */
19
+ enabled: z.boolean().optional(),
20
+ /** "Only if": the step is skipped when this fails. */
21
+ only: ConditionSchema.optional(),
22
+ /** Simulated minutes to wait before the step starts. */
23
+ delayMinutes: z.number().int().min(0).max(MINUTES_PER_YEAR).optional(),
24
+ };
25
+ export const QUESTION_KINDS = ['text', 'choice', 'boolean', 'number'];
26
+ export const QuestionSchema = z.strictObject({
27
+ id: FlowIdSchema,
28
+ kind: z.enum(QUESTION_KINDS),
29
+ prompt: z.string().trim().min(1).max(500),
30
+ required: z.boolean().optional(),
31
+ choices: z.array(z.string().trim().min(1).max(120)).max(FLOW_LIMITS.maxChoices).optional(),
32
+ });
33
+ const fieldsSchema = z
34
+ .record(z.string().regex(FLOW_ID_PATTERN, 'Field names are lowercase slugs'), FlowTextSchema)
35
+ .refine((fields) => Object.keys(fields).length <= 30, 'At most 30 fields');
36
+ const LEAF_STEP_SCHEMAS = [
37
+ z.strictObject({
38
+ ...common,
39
+ type: z.literal('ask'),
40
+ audience: AudienceSchema.min(1, 'Ask someone'),
41
+ questions: z.array(QuestionSchema).min(1, 'Add a question').max(FLOW_LIMITS.maxQuestions),
42
+ deadlineMinutes: z.number().int().min(1).max(MINUTES_PER_YEAR).optional(),
43
+ }),
44
+ z.strictObject({
45
+ ...common,
46
+ type: z.literal('approval'),
47
+ approvers: AudienceSchema.min(1, 'Choose who approves'),
48
+ prompt: FlowTextSchema,
49
+ }),
50
+ z.strictObject({
51
+ ...common,
52
+ type: z.literal('wait'),
53
+ minutes: z.number().int().min(1).max(MINUTES_PER_YEAR),
54
+ }),
55
+ z.strictObject({
56
+ ...common,
57
+ type: z.literal('wait_until'),
58
+ until: z.enum(['replies', 'approval', 'event', 'time']),
59
+ time: TimeOfDaySchema.optional(),
60
+ event: z
61
+ .string()
62
+ .regex(/^[a-z][a-z0-9_.:-]{0,63}$/)
63
+ .optional(),
64
+ }),
65
+ z.strictObject({
66
+ ...common,
67
+ type: z.literal('message'),
68
+ channel: z.enum(['chat', 'email', 'inbox', 'notification']),
69
+ to: AudienceSchema.min(1, 'Choose who gets it'),
70
+ subject: z.string().trim().min(1).max(200).optional(),
71
+ text: FlowTextSchema,
72
+ }),
73
+ z.strictObject({
74
+ ...common,
75
+ type: z.literal('reply'),
76
+ text: FlowTextSchema,
77
+ }),
78
+ z.strictObject({
79
+ ...common,
80
+ type: z.literal('assistant'),
81
+ task: z.enum(['summarize', 'classify', 'extract', 'draft', 'check']),
82
+ input: DataRefSchema.optional(),
83
+ instructions: FlowTextSchema.optional(),
84
+ labels: z.array(z.string().trim().min(1).max(60)).max(20).optional(),
85
+ }),
86
+ z.strictObject({
87
+ ...common,
88
+ type: z.literal('save_record'),
89
+ collection: FlowIdSchema,
90
+ title: FlowTextSchema.optional(),
91
+ fields: fieldsSchema.optional(),
92
+ state: FlowIdSchema.optional(),
93
+ }),
94
+ z.strictObject({
95
+ ...common,
96
+ type: z.literal('get_records'),
97
+ collection: FlowIdSchema,
98
+ sinceDays: z.number().int().min(1).max(366),
99
+ limit: z.number().int().min(1).max(500),
100
+ }),
101
+ z.strictObject({
102
+ ...common,
103
+ type: z.literal('set_state'),
104
+ state: FlowIdSchema,
105
+ }),
106
+ z.strictObject({
107
+ ...common,
108
+ type: z.literal('pick_from_list'),
109
+ list: FlowIdSchema,
110
+ order: z.enum(['next_unused', 'seeded']),
111
+ }),
112
+ z.strictObject({
113
+ ...common,
114
+ type: z.literal('update_list'),
115
+ list: FlowIdSchema,
116
+ action: z.enum(['add', 'remove']),
117
+ value: FlowTextSchema,
118
+ }),
119
+ z.strictObject({
120
+ ...common,
121
+ type: z.literal('group_people'),
122
+ from: AudienceSchema.min(1, 'Choose who to group'),
123
+ mode: z.enum(['pairs', 'groups', 'pick_one']),
124
+ size: z.number().int().min(2).max(20).optional(),
125
+ }),
126
+ z.strictObject({
127
+ ...common,
128
+ type: z.literal('call_tool'),
129
+ /** A tool the prototype registers with its service adapter, by id. */
130
+ tool: FlowIdSchema,
131
+ action: FlowIdSchema,
132
+ input: fieldsSchema.optional(),
133
+ }),
134
+ z.strictObject({
135
+ ...common,
136
+ type: z.literal('start_flow'),
137
+ flowId: FlowIdSchema,
138
+ }),
139
+ ];
140
+ export const StepSchema = z.lazy(() => z.discriminatedUnion('type', [
141
+ ...LEAF_STEP_SCHEMAS,
142
+ z.strictObject({
143
+ ...common,
144
+ type: z.literal('branch'),
145
+ when: ConditionSchema,
146
+ // biome-ignore lint/suspicious/noThenProperty: a branch arm, not a thenable
147
+ then: z.array(StepSchema).max(FLOW_LIMITS.maxSteps),
148
+ otherwise: z.array(StepSchema).max(FLOW_LIMITS.maxSteps),
149
+ }),
150
+ ]));
151
+ /** One entry per step type, in editor order. */
152
+ export const STEP_KINDS = [
153
+ { type: 'ask', label: 'Ask questions', category: 'people', output: 'replies', effect: true },
154
+ {
155
+ type: 'approval',
156
+ label: 'Ask for approval',
157
+ category: 'people',
158
+ output: 'decision',
159
+ effect: true,
160
+ },
161
+ {
162
+ type: 'group_people',
163
+ label: 'Group people',
164
+ category: 'people',
165
+ output: 'group',
166
+ effect: false,
167
+ },
168
+ { type: 'wait', label: 'Wait', category: 'timing', output: 'none', effect: false },
169
+ { type: 'wait_until', label: 'Wait until', category: 'timing', output: 'none', effect: false },
170
+ { type: 'branch', label: 'If / otherwise', category: 'logic', output: 'none', effect: false },
171
+ { type: 'message', label: 'Send a message', category: 'messages', output: 'none', effect: true },
172
+ { type: 'reply', label: 'Reply', category: 'messages', output: 'none', effect: true },
173
+ { type: 'assistant', label: 'Assistant task', category: 'tools', output: 'text', effect: false },
174
+ {
175
+ type: 'save_record',
176
+ label: 'Save a record',
177
+ category: 'data',
178
+ output: 'records',
179
+ effect: true,
180
+ },
181
+ { type: 'get_records', label: 'Get records', category: 'data', output: 'records', effect: false },
182
+ { type: 'set_state', label: 'Change state', category: 'data', output: 'none', effect: true },
183
+ {
184
+ type: 'pick_from_list',
185
+ label: 'Pick from a list',
186
+ category: 'data',
187
+ output: 'item',
188
+ effect: true,
189
+ },
190
+ { type: 'update_list', label: 'Update a list', category: 'data', output: 'none', effect: true },
191
+ { type: 'call_tool', label: 'Use a tool', category: 'tools', output: 'text', effect: true },
192
+ {
193
+ type: 'start_flow',
194
+ label: 'Start another flow',
195
+ category: 'tools',
196
+ output: 'none',
197
+ effect: true,
198
+ },
199
+ ];
200
+ export const STEP_TYPES = STEP_KINDS.map((kind) => kind.type);
201
+ export function stepKind(type) {
202
+ return STEP_KINDS.find((kind) => kind.type === type);
203
+ }
204
+ export function isStepEnabled(step) {
205
+ return step.enabled !== false;
206
+ }
@@ -0,0 +1,30 @@
1
+ import { z } from 'zod';
2
+ import { type DataRef } from './refs.ts';
3
+ /** Text people read: a string, or parts where a part is a string or a reference. */
4
+ export type FlowText = string | (string | {
5
+ ref: DataRef;
6
+ })[];
7
+ export declare const FlowTextSchema: z.ZodType<FlowText>;
8
+ /** Every reference a text interpolates. */
9
+ export declare function textRefs(value: FlowText | undefined): DataRef[];
10
+ /** The text with each reference shown as `{label}`, for editors and previews. */
11
+ export declare function textPreview(value: FlowText | undefined): string;
12
+ export declare const AUDIENCE_TYPES: readonly ["person", "team", "role", "channel", "list", "everyone"];
13
+ /** Who a step addresses. Names are display copy; ids point at fictional data. */
14
+ export interface AudienceItem {
15
+ type: (typeof AUDIENCE_TYPES)[number];
16
+ id: string;
17
+ name: string;
18
+ }
19
+ export declare const AudienceSchema: z.ZodArray<z.ZodObject<{
20
+ type: z.ZodEnum<{
21
+ person: "person";
22
+ team: "team";
23
+ role: "role";
24
+ channel: "channel";
25
+ list: "list";
26
+ everyone: "everyone";
27
+ }>;
28
+ id: z.ZodString;
29
+ name: z.ZodString;
30
+ }, z.core.$strict>>;
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+ import { FLOW_ID_PATTERN, FLOW_LIMITS } from "./limits.js";
3
+ import { DataRefSchema, describeRef } from "./refs.js";
4
+ export const FlowTextSchema = z.union([
5
+ z.string().max(2_000),
6
+ z
7
+ .array(z.union([z.string().max(2_000), z.strictObject({ ref: DataRefSchema })]))
8
+ .max(FLOW_LIMITS.maxTextParts),
9
+ ]);
10
+ /** Every reference a text interpolates. */
11
+ export function textRefs(value) {
12
+ if (value === undefined || typeof value === 'string')
13
+ return [];
14
+ return value.flatMap((part) => (typeof part === 'string' ? [] : [part.ref]));
15
+ }
16
+ /** The text with each reference shown as `{label}`, for editors and previews. */
17
+ export function textPreview(value) {
18
+ if (value === undefined)
19
+ return '';
20
+ if (typeof value === 'string')
21
+ return value;
22
+ return value
23
+ .map((part) => (typeof part === 'string' ? part : `{${describeRef(part.ref)}}`))
24
+ .join('');
25
+ }
26
+ export const AUDIENCE_TYPES = ['person', 'team', 'role', 'channel', 'list', 'everyone'];
27
+ export const AudienceSchema = z
28
+ .array(z.strictObject({
29
+ type: z.enum(AUDIENCE_TYPES),
30
+ id: z.string().regex(FLOW_ID_PATTERN),
31
+ name: z.string().trim().min(1).max(120),
32
+ }))
33
+ .max(FLOW_LIMITS.maxAudience);
@@ -0,0 +1,42 @@
1
+ import { z } from 'zod';
2
+ import { type Condition } from './conditions.ts';
3
+ import { type AudienceItem } from './text.ts';
4
+ export declare const DAYS: readonly ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
5
+ export type Day = (typeof DAYS)[number];
6
+ /** `HH:MM` on a 24-hour clock. */
7
+ export declare const TimeOfDaySchema: z.ZodString;
8
+ export declare const SURFACES: readonly ["app", "chat", "command", "link"];
9
+ export declare const EVENT_SOURCES: readonly ["records", "people", "app", "integration"];
10
+ /**
11
+ * How a run starts. Every start is simulated: a schedule fires from the
12
+ * simulated clock, a manual start from a button, an event or a webhook from a
13
+ * scenario. A webhook carries a sample payload, never a secret.
14
+ */
15
+ export type Trigger = {
16
+ id: string;
17
+ type: 'schedule';
18
+ days: Day[];
19
+ time: string;
20
+ timezone: string;
21
+ everyWeeks?: number;
22
+ } | {
23
+ id: string;
24
+ type: 'manual';
25
+ label: string;
26
+ who?: AudienceItem[];
27
+ surfaces?: (typeof SURFACES)[number][];
28
+ command?: string;
29
+ } | {
30
+ id: string;
31
+ type: 'event';
32
+ event: string;
33
+ source?: (typeof EVENT_SOURCES)[number];
34
+ filter?: Condition;
35
+ } | {
36
+ id: string;
37
+ type: 'webhook';
38
+ eventType: string;
39
+ samplePayload?: Record<string, unknown>;
40
+ };
41
+ export type TriggerType = Trigger['type'];
42
+ export declare const TriggerSchema: z.ZodType<Trigger>;
@@ -0,0 +1,68 @@
1
+ import { z } from 'zod';
2
+ import { ConditionSchema } from "./conditions.js";
3
+ import { FORBIDDEN_KEYS } from "./data.js";
4
+ import { FlowIdSchema } from "./refs.js";
5
+ import { AudienceSchema } from "./text.js";
6
+ export const DAYS = [
7
+ 'monday',
8
+ 'tuesday',
9
+ 'wednesday',
10
+ 'thursday',
11
+ 'friday',
12
+ 'saturday',
13
+ 'sunday',
14
+ ];
15
+ /** `HH:MM` on a 24-hour clock. */
16
+ export const TimeOfDaySchema = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/, 'Use HH:MM');
17
+ /** `local`, `utc` or an IANA-style zone name such as `America/Bogota`. */
18
+ const TimezoneSchema = z
19
+ .string()
20
+ .regex(/^(?:local|utc|[A-Z][A-Za-z_]+(?:\/[A-Za-z0-9_+-]+){1,2})$/, 'Use local, utc or a zone name');
21
+ export const SURFACES = ['app', 'chat', 'command', 'link'];
22
+ export const EVENT_SOURCES = ['records', 'people', 'app', 'integration'];
23
+ const EventNameSchema = z
24
+ .string()
25
+ .regex(/^[a-z][a-z0-9_.:-]{0,63}$/, 'Use a lowercase event name such as record.created');
26
+ export const TriggerSchema = z.discriminatedUnion('type', [
27
+ z.strictObject({
28
+ id: FlowIdSchema,
29
+ type: z.literal('schedule'),
30
+ days: z
31
+ .array(z.enum(DAYS))
32
+ .min(1, 'Pick at least one day')
33
+ .max(7)
34
+ .refine((days) => new Set(days).size === days.length, 'Each day appears once'),
35
+ time: TimeOfDaySchema,
36
+ timezone: TimezoneSchema,
37
+ everyWeeks: z.number().int().min(1).max(12).optional(),
38
+ }),
39
+ z.strictObject({
40
+ id: FlowIdSchema,
41
+ type: z.literal('manual'),
42
+ label: z.string().trim().min(1).max(80),
43
+ who: AudienceSchema.optional(),
44
+ surfaces: z.array(z.enum(SURFACES)).min(1).max(SURFACES.length).optional(),
45
+ command: z
46
+ .string()
47
+ .regex(/^[a-z0-9][a-z0-9_-]{0,30}$/, 'Commands use lowercase letters, digits, - and _')
48
+ .optional(),
49
+ }),
50
+ z.strictObject({
51
+ id: FlowIdSchema,
52
+ type: z.literal('event'),
53
+ event: EventNameSchema,
54
+ source: z.enum(EVENT_SOURCES).optional(),
55
+ filter: ConditionSchema.optional(),
56
+ }),
57
+ z.strictObject({
58
+ id: FlowIdSchema,
59
+ type: z.literal('webhook'),
60
+ eventType: EventNameSchema,
61
+ samplePayload: z
62
+ .record(z
63
+ .string()
64
+ .regex(/^[A-Za-z0-9_-]{1,64}$/)
65
+ .refine((key) => !FORBIDDEN_KEYS.has(key), 'This key is not allowed'), z.unknown())
66
+ .optional(),
67
+ }),
68
+ ]);
@@ -0,0 +1,19 @@
1
+ import type { Flow } from './flow.ts';
2
+ export type FlowDiagnosticCode = 'not_plain_data' | 'schema' | 'duplicate_id' | 'unknown_step_ref' | 'ref_not_before' | 'record_ref_without_collection' | 'unknown_collection' | 'unknown_state' | 'unknown_list' | 'choice_without_options' | 'options_on_non_choice' | 'missing_wait_detail' | 'empty_branch' | 'starts_itself' | 'nothing_to_run';
3
+ export interface FlowDiagnostic {
4
+ severity: 'error' | 'warning';
5
+ code: FlowDiagnosticCode;
6
+ /** Where it applies: `steps[0].then[1].only`, `triggers[0]` or `$`. */
7
+ path: string;
8
+ message: string;
9
+ stepId?: string;
10
+ }
11
+ /**
12
+ * The semantic checks a schema cannot express: unique ids, references that
13
+ * point at steps which have certainly run, collections, states and lists that
14
+ * exist, and shapes that parse but cannot work. Pure and deterministic; the
15
+ * result is ordered by where each problem sits.
16
+ */
17
+ export declare function validateFlow(flow: Flow): FlowDiagnostic[];
18
+ /** Diagnostics grouped by step id (`$flow` for the ones that belong to no step). */
19
+ export declare function diagnosticsByStep(diagnostics: readonly FlowDiagnostic[]): Record<string, FlowDiagnostic[]>;
@@ -0,0 +1,207 @@
1
+ import { conditionRefs } from "./conditions.js";
2
+ import { isStepEnabled } from "./steps.js";
3
+ import { textRefs } from "./text.js";
4
+ import { formatPath, listSteps, runsBefore } from "./walk.js";
5
+ /** Every reference a step reads, with where it sits inside the step. */
6
+ function stepRefs(step) {
7
+ const out = [];
8
+ const add = (refs, at) => {
9
+ for (const ref of refs)
10
+ out.push({ ref, at });
11
+ };
12
+ if (step.only)
13
+ add(conditionRefs(step.only), ['only']);
14
+ switch (step.type) {
15
+ case 'branch':
16
+ add(conditionRefs(step.when), ['when']);
17
+ break;
18
+ case 'approval':
19
+ add(textRefs(step.prompt), ['prompt']);
20
+ break;
21
+ case 'message':
22
+ case 'reply':
23
+ add(textRefs(step.text), ['text']);
24
+ break;
25
+ case 'assistant':
26
+ if (step.input)
27
+ add([step.input], ['input']);
28
+ add(textRefs(step.instructions), ['instructions']);
29
+ break;
30
+ case 'save_record':
31
+ add(textRefs(step.title), ['title']);
32
+ for (const [field, value] of Object.entries(step.fields ?? {}))
33
+ add(textRefs(value), ['fields', field]);
34
+ break;
35
+ case 'update_list':
36
+ add(textRefs(step.value), ['value']);
37
+ break;
38
+ case 'call_tool':
39
+ for (const [field, value] of Object.entries(step.input ?? {}))
40
+ add(textRefs(value), ['input', field]);
41
+ break;
42
+ default:
43
+ break;
44
+ }
45
+ return out;
46
+ }
47
+ function duplicates(ids) {
48
+ const seen = new Set();
49
+ const dup = new Set();
50
+ for (const id of ids)
51
+ (seen.has(id) ? dup : seen).add(id);
52
+ return dup;
53
+ }
54
+ /**
55
+ * The semantic checks a schema cannot express: unique ids, references that
56
+ * point at steps which have certainly run, collections, states and lists that
57
+ * exist, and shapes that parse but cannot work. Pure and deterministic; the
58
+ * result is ordered by where each problem sits.
59
+ */
60
+ export function validateFlow(flow) {
61
+ const out = [];
62
+ const push = (d) => out.push(d);
63
+ const all = listSteps(flow.steps);
64
+ const order = new Map();
65
+ const byId = new Map();
66
+ for (const [i, location] of all.entries()) {
67
+ if (!order.has(location.step.id))
68
+ order.set(location.step.id, i);
69
+ if (!byId.has(location.step.id))
70
+ byId.set(location.step.id, location);
71
+ }
72
+ const dupSteps = duplicates(all.map((l) => l.step.id));
73
+ const reported = new Set();
74
+ for (const location of all) {
75
+ if (dupSteps.has(location.step.id) && reported.has(location.step.id)) {
76
+ push({
77
+ severity: 'error',
78
+ code: 'duplicate_id',
79
+ path: formatPath(location.path),
80
+ stepId: location.step.id,
81
+ message: `Another step already uses the id "${location.step.id}"`,
82
+ });
83
+ }
84
+ reported.add(location.step.id);
85
+ }
86
+ for (const id of duplicates(flow.triggers.map((t) => t.id))) {
87
+ const index = flow.triggers.findLastIndex((t) => t.id === id);
88
+ push({
89
+ severity: 'error',
90
+ code: 'duplicate_id',
91
+ path: `triggers[${index}]`,
92
+ message: `Another start already uses the id "${id}"`,
93
+ });
94
+ }
95
+ for (const [key, list] of [
96
+ ['collections', flow.collections ?? []],
97
+ ['lists', flow.lists ?? []],
98
+ ]) {
99
+ for (const id of duplicates(list.map((item) => item.id))) {
100
+ const index = list.findLastIndex((item) => item.id === id);
101
+ push({
102
+ severity: 'error',
103
+ code: 'duplicate_id',
104
+ path: `${key}[${index}]`,
105
+ message: `The id "${id}" is used twice`,
106
+ });
107
+ }
108
+ }
109
+ const collections = new Map((flow.collections ?? []).map((c) => [c.id, c]));
110
+ const states = new Set((flow.collections ?? []).flatMap((c) => c.states.map((s) => s.id)));
111
+ const lists = new Set((flow.lists ?? []).map((l) => l.id));
112
+ const recordTrigger = flow.triggers.some((t) => t.type === 'event' && t.source === 'records');
113
+ for (const location of all) {
114
+ const { step } = location;
115
+ const at = (...rest) => formatPath([...location.path, ...rest]);
116
+ const stepDiag = (severity, code, path, message) => push({ severity, code, path, message, stepId: step.id });
117
+ for (const { ref, at: inner } of stepRefs(step)) {
118
+ if (ref.from === 'step') {
119
+ const source = byId.get(ref.stepId);
120
+ if (!source) {
121
+ stepDiag('error', 'unknown_step_ref', at(...inner), `No step has the id "${ref.stepId}"`);
122
+ }
123
+ else if (!runsBefore(source, location, order)) {
124
+ stepDiag('error', 'ref_not_before', at(...inner), `Step "${ref.stepId}" has not certainly run before this step`);
125
+ }
126
+ }
127
+ else if (ref.from === 'record' && collections.size === 0 && !recordTrigger) {
128
+ stepDiag('warning', 'record_ref_without_collection', at(...inner), 'This reads a record, but the flow keeps no records');
129
+ }
130
+ }
131
+ switch (step.type) {
132
+ case 'ask':
133
+ for (const [i, question] of step.questions.entries()) {
134
+ if (question.kind === 'choice' && (question.choices?.length ?? 0) < 2) {
135
+ stepDiag('error', 'choice_without_options', at('questions', i), 'A choice question needs at least two options');
136
+ }
137
+ if (question.kind !== 'choice' && question.choices?.length) {
138
+ stepDiag('warning', 'options_on_non_choice', at('questions', i, 'choices'), 'Options only apply to choice questions');
139
+ }
140
+ }
141
+ for (const id of duplicates(step.questions.map((q) => q.id))) {
142
+ stepDiag('error', 'duplicate_id', at('questions'), `Two questions use the id "${id}"`);
143
+ }
144
+ break;
145
+ case 'save_record':
146
+ case 'get_records':
147
+ if (!collections.has(step.collection)) {
148
+ stepDiag('error', 'unknown_collection', at('collection'), `No collection has the id "${step.collection}"`);
149
+ }
150
+ else if (step.type === 'save_record' && step.state) {
151
+ const known = collections.get(step.collection)?.states.some((s) => s.id === step.state);
152
+ if (!known)
153
+ stepDiag('error', 'unknown_state', at('state'), `"${step.collection}" has no state "${step.state}"`);
154
+ }
155
+ break;
156
+ case 'set_state':
157
+ if (!states.has(step.state)) {
158
+ stepDiag('error', 'unknown_state', at('state'), `No collection has the state "${step.state}"`);
159
+ }
160
+ break;
161
+ case 'pick_from_list':
162
+ case 'update_list':
163
+ if (!lists.has(step.list)) {
164
+ stepDiag('error', 'unknown_list', at('list'), `No list has the id "${step.list}"`);
165
+ }
166
+ break;
167
+ case 'wait_until':
168
+ if (step.until === 'time' && !step.time) {
169
+ stepDiag('error', 'missing_wait_detail', at('time'), 'Choose the time to wait for');
170
+ }
171
+ if (step.until === 'event' && !step.event) {
172
+ stepDiag('error', 'missing_wait_detail', at('event'), 'Choose the event to wait for');
173
+ }
174
+ break;
175
+ case 'branch':
176
+ if (step.then.length === 0 && step.otherwise.length === 0) {
177
+ stepDiag('warning', 'empty_branch', at(), 'Neither side of this branch does anything');
178
+ }
179
+ break;
180
+ case 'start_flow':
181
+ if (step.flowId === flow.id) {
182
+ stepDiag('warning', 'starts_itself', at('flowId'), 'This flow starts itself; runs may repeat');
183
+ }
184
+ break;
185
+ default:
186
+ break;
187
+ }
188
+ }
189
+ if (flow.status === 'active' && !all.some((l) => isStepEnabled(l.step))) {
190
+ push({
191
+ severity: 'warning',
192
+ code: 'nothing_to_run',
193
+ path: 'steps',
194
+ message: 'An active flow has no enabled step',
195
+ });
196
+ }
197
+ return out;
198
+ }
199
+ /** Diagnostics grouped by step id (`$flow` for the ones that belong to no step). */
200
+ export function diagnosticsByStep(diagnostics) {
201
+ const map = {};
202
+ for (const d of diagnostics) {
203
+ const key = d.stepId ?? '$flow';
204
+ map[key] = [...(map[key] ?? []), d];
205
+ }
206
+ return map;
207
+ }
@@ -0,0 +1,40 @@
1
+ import type { Step } from './steps.ts';
2
+ export type BranchArm = 'then' | 'otherwise';
3
+ export type PathSegment = string | number;
4
+ /** Where a step sits in the tree. */
5
+ export interface StepLocation {
6
+ step: Step;
7
+ /** From the flow root, for diagnostics: `['steps', 0, 'then', 1]`. */
8
+ path: PathSegment[];
9
+ parentId: string | null;
10
+ branch: BranchArm | null;
11
+ index: number;
12
+ /** 0 for a top-level step. */
13
+ depth: number;
14
+ /** The branch arms the step is inside, outermost first (`branchId:arm`). */
15
+ arms: string[];
16
+ }
17
+ /** The arms of a step, empty for anything but a branch. */
18
+ export declare function childArms(step: Step): {
19
+ branch: BranchArm;
20
+ steps: Step[];
21
+ }[];
22
+ /** Visits every step in document (pre-)order, which is also run order. */
23
+ export declare function walkSteps(steps: readonly Step[], visit: (location: StepLocation) => void, parent?: {
24
+ id: string;
25
+ branch: BranchArm;
26
+ path: PathSegment[];
27
+ depth: number;
28
+ arms: string[];
29
+ } | null): void;
30
+ export declare function listSteps(steps: readonly Step[]): StepLocation[];
31
+ export declare function findStep(steps: readonly Step[], stepId: string): StepLocation | null;
32
+ /**
33
+ * True when `earlier` has certainly run by the time `later` starts: it comes
34
+ * first in run order and is not inside a branch arm that `later` is outside of.
35
+ */
36
+ export declare function runsBefore(earlier: StepLocation, later: StepLocation, order: Map<string, number>): boolean;
37
+ /** The steps whose output a step may reference. */
38
+ export declare function stepsBefore(steps: readonly Step[], stepId: string): Step[];
39
+ /** `['steps', 0, 'then', 1]` → `steps[0].then[1]`. */
40
+ export declare function formatPath(path: readonly PathSegment[]): string;