@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,93 @@
1
+ import { z } from 'zod';
2
+ import { inspectPlainData } from "./data.js";
3
+ import { FLOW_LIMITS } from "./limits.js";
4
+ import { FlowIdSchema } from "./refs.js";
5
+ import { StepSchema } from "./steps.js";
6
+ import { TriggerSchema } from "./triggers.js";
7
+ import { validateFlow } from "./validate.js";
8
+ import { formatPath, listSteps } from "./walk.js";
9
+ export const FLOW_FORMAT_VERSION = 1;
10
+ export const FLOW_STATUSES = ['draft', 'active', 'paused'];
11
+ export const CollectionSchema = z.strictObject({
12
+ id: FlowIdSchema,
13
+ noun: z.string().trim().min(1).max(60),
14
+ plural: z.string().trim().min(1).max(60).optional(),
15
+ states: z
16
+ .array(z.strictObject({
17
+ id: FlowIdSchema,
18
+ label: z.string().trim().min(1).max(60),
19
+ final: z.boolean().optional(),
20
+ }))
21
+ .max(FLOW_LIMITS.maxStates),
22
+ });
23
+ export const ListSchema = z.strictObject({
24
+ id: FlowIdSchema,
25
+ name: z.string().trim().min(1).max(80),
26
+ items: z.array(z.string().trim().min(1).max(200)).max(FLOW_LIMITS.maxListItems),
27
+ });
28
+ export const FlowSchema = z
29
+ .strictObject({
30
+ formatVersion: z.literal(FLOW_FORMAT_VERSION),
31
+ id: FlowIdSchema,
32
+ name: z.string().trim().min(1).max(80),
33
+ description: z.string().trim().max(500).optional(),
34
+ status: z.enum(FLOW_STATUSES),
35
+ triggers: z.array(TriggerSchema).min(1, 'Add a way to start').max(FLOW_LIMITS.maxTriggers),
36
+ steps: z.array(StepSchema).max(FLOW_LIMITS.maxSteps),
37
+ collections: z.array(CollectionSchema).max(FLOW_LIMITS.maxCollections).optional(),
38
+ lists: z.array(ListSchema).max(FLOW_LIMITS.maxLists).optional(),
39
+ labels: z.array(z.string().trim().min(1).max(40)).max(10).optional(),
40
+ })
41
+ .superRefine((flow, ctx) => {
42
+ const all = listSteps(flow.steps);
43
+ if (all.length > FLOW_LIMITS.maxSteps) {
44
+ ctx.addIssue({
45
+ code: 'custom',
46
+ path: ['steps'],
47
+ message: `A flow holds at most ${FLOW_LIMITS.maxSteps} steps`,
48
+ });
49
+ }
50
+ for (const location of all) {
51
+ if (location.depth >= FLOW_LIMITS.maxStepDepth) {
52
+ ctx.addIssue({
53
+ code: 'custom',
54
+ path: location.path,
55
+ message: `Branches nest at most ${FLOW_LIMITS.maxStepDepth} levels`,
56
+ });
57
+ break;
58
+ }
59
+ }
60
+ });
61
+ /**
62
+ * Parses untrusted input into a flow: plain-data and size checks first (no
63
+ * recursion over hostile structures), then the strict schema, then the
64
+ * semantic checks of `validateFlow`. Errors fail the parse; warnings ride
65
+ * along with a successful one.
66
+ */
67
+ export function parseFlow(input) {
68
+ const problem = inspectPlainData(input);
69
+ if (problem) {
70
+ return {
71
+ ok: false,
72
+ diagnostics: [
73
+ { severity: 'error', code: 'not_plain_data', path: problem.path, message: problem.message },
74
+ ],
75
+ };
76
+ }
77
+ const parsed = FlowSchema.safeParse(input);
78
+ if (!parsed.success) {
79
+ return {
80
+ ok: false,
81
+ diagnostics: parsed.error.issues.map((issue) => ({
82
+ severity: 'error',
83
+ code: 'schema',
84
+ path: formatPath(issue.path.filter((s) => typeof s !== 'symbol')) || '$',
85
+ message: issue.message,
86
+ })),
87
+ };
88
+ }
89
+ const diagnostics = validateFlow(parsed.data);
90
+ if (diagnostics.some((d) => d.severity === 'error'))
91
+ return { ok: false, diagnostics };
92
+ return { ok: true, flow: parsed.data, diagnostics };
93
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@protoyard/flows/model` — the serializable workflow model: triggers,
3
+ * steps, data-only conditions, edit operations, recipes and validation
4
+ * (ADR-041). Pure data and pure functions; no Svelte, no clock, no network.
5
+ */
6
+ export { COMPARE_OPERATORS, type CompareOperator, type Condition, type ConditionOutcome, ConditionSchema, conditionRefs, conditionStats, describeCondition, evaluateCondition, type FlowJudge, type JudgeRequest, type Operand, OperandSchema, type Scalar, VALUE_TESTS, type ValueTest, } from './conditions.ts';
7
+ export { type DataProblem, FORBIDDEN_KEYS, inspectPlainData } from './data.ts';
8
+ export { CollectionSchema, FLOW_FORMAT_VERSION, FLOW_STATUSES, type Flow, type FlowCollection, type FlowList, FlowSchema, type FlowStatus, ListSchema, type ParseFlowResult, parseFlow, } from './flow.ts';
9
+ export { FLOW_ID_PATTERN, FLOW_LIMITS } from './limits.ts';
10
+ export { type ApplyOperationsResult, applyOperations, type FlowOperation, OperationSchema, } from './ops.ts';
11
+ export { FLOW_RECIPES, type FlowRecipe, findRecipe } from './recipes.ts';
12
+ export { type DataRef, DataRefSchema, describeRef, FlowIdSchema, type RefScope, resolveRef, } from './refs.ts';
13
+ export { type BranchStep, isStepEnabled, QUESTION_KINDS, type Question, QuestionSchema, STEP_KINDS, STEP_TYPES, type Step, type StepCategory, type StepKind, type StepOf, StepSchema, type StepType, stepKind, } from './steps.ts';
14
+ export { AUDIENCE_TYPES, type AudienceItem, AudienceSchema, type FlowText, FlowTextSchema, textPreview, textRefs, } from './text.ts';
15
+ export { DAYS, type Day, EVENT_SOURCES, SURFACES, TimeOfDaySchema, type Trigger, TriggerSchema, type TriggerType, } from './triggers.ts';
16
+ export { diagnosticsByStep, type FlowDiagnostic, type FlowDiagnosticCode, validateFlow, } from './validate.ts';
17
+ export { type BranchArm, childArms, findStep, formatPath, listSteps, type PathSegment, runsBefore, type StepLocation, stepsBefore, walkSteps, } from './walk.ts';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@protoyard/flows/model` — the serializable workflow model: triggers,
3
+ * steps, data-only conditions, edit operations, recipes and validation
4
+ * (ADR-041). Pure data and pure functions; no Svelte, no clock, no network.
5
+ */
6
+ export { COMPARE_OPERATORS, ConditionSchema, conditionRefs, conditionStats, describeCondition, evaluateCondition, OperandSchema, VALUE_TESTS, } from "./conditions.js";
7
+ export { FORBIDDEN_KEYS, inspectPlainData } from "./data.js";
8
+ export { CollectionSchema, FLOW_FORMAT_VERSION, FLOW_STATUSES, FlowSchema, ListSchema, parseFlow, } from "./flow.js";
9
+ export { FLOW_ID_PATTERN, FLOW_LIMITS } from "./limits.js";
10
+ export { applyOperations, OperationSchema, } from "./ops.js";
11
+ export { FLOW_RECIPES, findRecipe } from "./recipes.js";
12
+ export { DataRefSchema, describeRef, FlowIdSchema, resolveRef, } from "./refs.js";
13
+ export { isStepEnabled, QUESTION_KINDS, QuestionSchema, STEP_KINDS, STEP_TYPES, StepSchema, stepKind, } from "./steps.js";
14
+ export { AUDIENCE_TYPES, AudienceSchema, FlowTextSchema, textPreview, textRefs, } from "./text.js";
15
+ export { DAYS, EVENT_SOURCES, SURFACES, TimeOfDaySchema, TriggerSchema, } from "./triggers.js";
16
+ export { diagnosticsByStep, validateFlow, } from "./validate.js";
17
+ export { childArms, findStep, formatPath, listSteps, runsBefore, stepsBefore, walkSteps, } from "./walk.js";
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Hard bounds for flow documents. A flow can arrive from an agent, a share
3
+ * link or a snapshot, so every list, string and tree is capped, and the caps
4
+ * are checked before any recursive parse (see `inspectPlainData`).
5
+ */
6
+ export declare const FLOW_LIMITS: {
7
+ /** Steps in the whole flow, nested branches included. */
8
+ readonly maxSteps: 200;
9
+ /** Branch nesting (a branch inside a branch counts 2). */
10
+ readonly maxStepDepth: 8;
11
+ readonly maxTriggers: 8;
12
+ /** Condition nesting through `all`, `any` and `not`. */
13
+ readonly maxConditionDepth: 6;
14
+ /** Nodes in one condition tree. */
15
+ readonly maxConditionNodes: 64;
16
+ readonly maxQuestions: 30;
17
+ readonly maxChoices: 20;
18
+ readonly maxAudience: 50;
19
+ readonly maxCollections: 10;
20
+ readonly maxStates: 20;
21
+ readonly maxLists: 10;
22
+ readonly maxListItems: 200;
23
+ readonly maxTextParts: 50;
24
+ readonly maxPathSegments: 8;
25
+ readonly maxOperations: 200;
26
+ /** Raw JSON structure, measured before zod sees it. */
27
+ readonly maxDataDepth: 48;
28
+ readonly maxDataNodes: 50000;
29
+ readonly maxStringLength: 4000;
30
+ };
31
+ /** Ids are short slugs, so they are safe in paths, URLs and DOM ids. */
32
+ export declare const FLOW_ID_PATTERN: RegExp;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Hard bounds for flow documents. A flow can arrive from an agent, a share
3
+ * link or a snapshot, so every list, string and tree is capped, and the caps
4
+ * are checked before any recursive parse (see `inspectPlainData`).
5
+ */
6
+ export const FLOW_LIMITS = {
7
+ /** Steps in the whole flow, nested branches included. */
8
+ maxSteps: 200,
9
+ /** Branch nesting (a branch inside a branch counts 2). */
10
+ maxStepDepth: 8,
11
+ maxTriggers: 8,
12
+ /** Condition nesting through `all`, `any` and `not`. */
13
+ maxConditionDepth: 6,
14
+ /** Nodes in one condition tree. */
15
+ maxConditionNodes: 64,
16
+ maxQuestions: 30,
17
+ maxChoices: 20,
18
+ maxAudience: 50,
19
+ maxCollections: 10,
20
+ maxStates: 20,
21
+ maxLists: 10,
22
+ maxListItems: 200,
23
+ maxTextParts: 50,
24
+ maxPathSegments: 8,
25
+ maxOperations: 200,
26
+ /** Raw JSON structure, measured before zod sees it. */
27
+ maxDataDepth: 48,
28
+ maxDataNodes: 50_000,
29
+ maxStringLength: 4_000,
30
+ };
31
+ /** Ids are short slugs, so they are safe in paths, URLs and DOM ids. */
32
+ export const FLOW_ID_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/;
@@ -0,0 +1,122 @@
1
+ import { z } from 'zod';
2
+ import { type Flow } from './flow.ts';
3
+ import { type Step } from './steps.ts';
4
+ import type { FlowDiagnostic } from './validate.ts';
5
+ export declare const OperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
6
+ op: z.ZodLiteral<"update_flow">;
7
+ patch: z.ZodObject<{
8
+ name: z.ZodOptional<z.ZodString>;
9
+ description: z.ZodOptional<z.ZodString>;
10
+ labels: z.ZodOptional<z.ZodArray<z.ZodString>>;
11
+ }, z.core.$strict>;
12
+ }, z.core.$strict>, z.ZodObject<{
13
+ op: z.ZodLiteral<"set_status">;
14
+ status: z.ZodEnum<{
15
+ draft: "draft";
16
+ active: "active";
17
+ paused: "paused";
18
+ }>;
19
+ }, z.core.$strict>, z.ZodObject<{
20
+ op: z.ZodLiteral<"add_trigger">;
21
+ trigger: z.ZodType<import("./triggers.ts").Trigger, unknown, z.core.$ZodTypeInternals<import("./triggers.ts").Trigger, unknown>>;
22
+ index: z.ZodOptional<z.ZodNumber>;
23
+ }, z.core.$strict>, z.ZodObject<{
24
+ op: z.ZodLiteral<"replace_trigger">;
25
+ triggerId: z.ZodString;
26
+ trigger: z.ZodType<import("./triggers.ts").Trigger, unknown, z.core.$ZodTypeInternals<import("./triggers.ts").Trigger, unknown>>;
27
+ }, z.core.$strict>, z.ZodObject<{
28
+ op: z.ZodLiteral<"remove_trigger">;
29
+ triggerId: z.ZodString;
30
+ }, z.core.$strict>, z.ZodObject<{
31
+ op: z.ZodLiteral<"add_step">;
32
+ step: z.ZodType<Step, unknown, z.core.$ZodTypeInternals<Step, unknown>>;
33
+ parentId: z.ZodOptional<z.ZodString>;
34
+ branch: z.ZodOptional<z.ZodEnum<{
35
+ then: "then";
36
+ otherwise: "otherwise";
37
+ }>>;
38
+ index: z.ZodOptional<z.ZodNumber>;
39
+ }, z.core.$strict>, z.ZodObject<{
40
+ op: z.ZodLiteral<"update_step">;
41
+ stepId: z.ZodString;
42
+ patch: z.ZodRecord<z.ZodString, z.ZodUnknown>;
43
+ }, z.core.$strict>, z.ZodObject<{
44
+ op: z.ZodLiteral<"remove_step">;
45
+ stepId: z.ZodString;
46
+ }, z.core.$strict>, z.ZodObject<{
47
+ op: z.ZodLiteral<"move_step">;
48
+ stepId: z.ZodString;
49
+ parentId: z.ZodOptional<z.ZodString>;
50
+ branch: z.ZodOptional<z.ZodEnum<{
51
+ then: "then";
52
+ otherwise: "otherwise";
53
+ }>>;
54
+ index: z.ZodNumber;
55
+ }, z.core.$strict>, z.ZodObject<{
56
+ op: z.ZodLiteral<"duplicate_step">;
57
+ stepId: z.ZodString;
58
+ }, z.core.$strict>, z.ZodObject<{
59
+ op: z.ZodLiteral<"add_question">;
60
+ stepId: z.ZodString;
61
+ question: z.ZodObject<{
62
+ id: z.ZodString;
63
+ kind: z.ZodEnum<{
64
+ number: "number";
65
+ boolean: "boolean";
66
+ text: "text";
67
+ choice: "choice";
68
+ }>;
69
+ prompt: z.ZodString;
70
+ required: z.ZodOptional<z.ZodBoolean>;
71
+ choices: z.ZodOptional<z.ZodArray<z.ZodString>>;
72
+ }, z.core.$strict>;
73
+ index: z.ZodOptional<z.ZodNumber>;
74
+ }, z.core.$strict>, z.ZodObject<{
75
+ op: z.ZodLiteral<"remove_question">;
76
+ stepId: z.ZodString;
77
+ questionId: z.ZodString;
78
+ }, z.core.$strict>, z.ZodObject<{
79
+ op: z.ZodLiteral<"upsert_collection">;
80
+ collection: z.ZodObject<{
81
+ id: z.ZodString;
82
+ noun: z.ZodString;
83
+ plural: z.ZodOptional<z.ZodString>;
84
+ states: z.ZodArray<z.ZodObject<{
85
+ id: z.ZodString;
86
+ label: z.ZodString;
87
+ final: z.ZodOptional<z.ZodBoolean>;
88
+ }, z.core.$strict>>;
89
+ }, z.core.$strict>;
90
+ }, z.core.$strict>, z.ZodObject<{
91
+ op: z.ZodLiteral<"remove_collection">;
92
+ collectionId: z.ZodString;
93
+ }, z.core.$strict>, z.ZodObject<{
94
+ op: z.ZodLiteral<"upsert_list">;
95
+ list: z.ZodObject<{
96
+ id: z.ZodString;
97
+ name: z.ZodString;
98
+ items: z.ZodArray<z.ZodString>;
99
+ }, z.core.$strict>;
100
+ }, z.core.$strict>, z.ZodObject<{
101
+ op: z.ZodLiteral<"remove_list">;
102
+ listId: z.ZodString;
103
+ }, z.core.$strict>], "op">;
104
+ export type FlowOperation = z.infer<typeof OperationSchema>;
105
+ export type ApplyOperationsResult = {
106
+ ok: true;
107
+ flow: Flow;
108
+ diagnostics: FlowDiagnostic[];
109
+ } | {
110
+ ok: false;
111
+ flow: Flow;
112
+ failedAt: number | null;
113
+ diagnostics: FlowDiagnostic[];
114
+ };
115
+ /**
116
+ * Applies a batch of edits atomically. Operations are untrusted input and
117
+ * validated first; each runs on a copy; the result must parse as a flow
118
+ * (schema and semantic errors). On any failure the original flow is returned
119
+ * untouched with the diagnostics and the failing operation index (null when
120
+ * only the final flow is invalid).
121
+ */
122
+ export declare function applyOperations(flow: Flow, operations: unknown): ApplyOperationsResult;
@@ -0,0 +1,311 @@
1
+ import { z } from 'zod';
2
+ import { inspectPlainData } from "./data.js";
3
+ import { CollectionSchema, FLOW_STATUSES, ListSchema, parseFlow } from "./flow.js";
4
+ import { FLOW_ID_PATTERN, FLOW_LIMITS } from "./limits.js";
5
+ import { FlowIdSchema } from "./refs.js";
6
+ import { QuestionSchema, StepSchema } from "./steps.js";
7
+ import { TriggerSchema } from "./triggers.js";
8
+ import { childArms, listSteps } from "./walk.js";
9
+ /*
10
+ * Edits on a flow. Direct manipulation, a composer and an agent all go
11
+ * through `applyOperations`, so there is one way to change a flow: the batch
12
+ * is validated, applied to a copy, and the result must parse as a flow, or
13
+ * nothing changes.
14
+ */
15
+ const BranchArmSchema = z.enum(['then', 'otherwise']);
16
+ const IndexSchema = z.number().int().min(0).max(FLOW_LIMITS.maxSteps);
17
+ /** A shallow patch for a step: any step field except its id and type. */
18
+ const StepPatchSchema = z
19
+ .record(z.string().regex(/^[a-zA-Z][a-zA-Z0-9]{0,40}$/), z.unknown())
20
+ .refine((patch) => !('id' in patch) && !('type' in patch), 'A patch cannot change id or type');
21
+ export const OperationSchema = z.discriminatedUnion('op', [
22
+ z.strictObject({
23
+ op: z.literal('update_flow'),
24
+ patch: z.strictObject({
25
+ name: z.string().optional(),
26
+ description: z.string().optional(),
27
+ labels: z.array(z.string()).optional(),
28
+ }),
29
+ }),
30
+ z.strictObject({ op: z.literal('set_status'), status: z.enum(FLOW_STATUSES) }),
31
+ z.strictObject({
32
+ op: z.literal('add_trigger'),
33
+ trigger: TriggerSchema,
34
+ index: IndexSchema.optional(),
35
+ }),
36
+ z.strictObject({
37
+ op: z.literal('replace_trigger'),
38
+ triggerId: FlowIdSchema,
39
+ trigger: TriggerSchema,
40
+ }),
41
+ z.strictObject({ op: z.literal('remove_trigger'), triggerId: FlowIdSchema }),
42
+ z.strictObject({
43
+ op: z.literal('add_step'),
44
+ step: StepSchema,
45
+ parentId: FlowIdSchema.optional(),
46
+ branch: BranchArmSchema.optional(),
47
+ index: IndexSchema.optional(),
48
+ }),
49
+ z.strictObject({ op: z.literal('update_step'), stepId: FlowIdSchema, patch: StepPatchSchema }),
50
+ z.strictObject({ op: z.literal('remove_step'), stepId: FlowIdSchema }),
51
+ z.strictObject({
52
+ op: z.literal('move_step'),
53
+ stepId: FlowIdSchema,
54
+ parentId: FlowIdSchema.optional(),
55
+ branch: BranchArmSchema.optional(),
56
+ index: IndexSchema,
57
+ }),
58
+ z.strictObject({ op: z.literal('duplicate_step'), stepId: FlowIdSchema }),
59
+ z.strictObject({
60
+ op: z.literal('add_question'),
61
+ stepId: FlowIdSchema,
62
+ question: QuestionSchema,
63
+ index: IndexSchema.optional(),
64
+ }),
65
+ z.strictObject({
66
+ op: z.literal('remove_question'),
67
+ stepId: FlowIdSchema,
68
+ questionId: FlowIdSchema,
69
+ }),
70
+ z.strictObject({ op: z.literal('upsert_collection'), collection: CollectionSchema }),
71
+ z.strictObject({ op: z.literal('remove_collection'), collectionId: FlowIdSchema }),
72
+ z.strictObject({ op: z.literal('upsert_list'), list: ListSchema }),
73
+ z.strictObject({ op: z.literal('remove_list'), listId: FlowIdSchema }),
74
+ ]);
75
+ class OperationError extends Error {
76
+ }
77
+ const fail = (message) => {
78
+ throw new OperationError(message);
79
+ };
80
+ /** The list a step lives in (top level or a branch arm), resolved by parent id. */
81
+ function containerOf(flow, parentId, branch) {
82
+ if (parentId === undefined) {
83
+ if (branch !== undefined)
84
+ fail('A branch arm needs a parentId');
85
+ return flow.steps;
86
+ }
87
+ const parent = listSteps(flow.steps).find((l) => l.step.id === parentId)?.step;
88
+ if (!parent)
89
+ return fail(`No step has the id "${parentId}"`);
90
+ const arm = childArms(parent).find((a) => a.branch === (branch ?? 'then'));
91
+ if (!arm)
92
+ return fail(`Step "${parentId}" is not a branch`);
93
+ return arm.steps;
94
+ }
95
+ /** The list holding a step and its index there. */
96
+ function locate(flow, stepId) {
97
+ const visit = (list) => {
98
+ for (const [index, step] of list.entries()) {
99
+ if (step.id === stepId)
100
+ return { list, index, step };
101
+ for (const arm of childArms(step)) {
102
+ const found = visit(arm.steps);
103
+ if (found)
104
+ return found;
105
+ }
106
+ }
107
+ return null;
108
+ };
109
+ return visit(flow.steps) ?? fail(`No step has the id "${stepId}"`);
110
+ }
111
+ /** A free id derived from `base`: `base-copy`, `base-copy-2`… within the slug pattern. */
112
+ function freeId(base, taken) {
113
+ for (let n = 1; n < 10_000; n += 1) {
114
+ const suffix = n === 1 ? '-copy' : `-copy-${n}`;
115
+ const candidate = `${base.slice(0, 64 - suffix.length)}${suffix}`;
116
+ if (FLOW_ID_PATTERN.test(candidate) && !taken.has(candidate)) {
117
+ taken.add(candidate);
118
+ return candidate;
119
+ }
120
+ }
121
+ return fail('No free id for the copy');
122
+ }
123
+ /** Deep copy with every step id (nested ones too) renamed to a free id. */
124
+ function copyWithFreshIds(step, taken) {
125
+ const copy = structuredClone(step);
126
+ const rename = (s) => {
127
+ s.id = freeId(s.id, taken);
128
+ for (const arm of childArms(s))
129
+ arm.steps.forEach(rename);
130
+ };
131
+ rename(copy);
132
+ return copy;
133
+ }
134
+ const insertAt = (list, item, index) => {
135
+ list.splice(index === undefined ? list.length : Math.min(index, list.length), 0, item);
136
+ };
137
+ function apply(flow, operation) {
138
+ switch (operation.op) {
139
+ case 'update_flow':
140
+ Object.assign(flow, operation.patch);
141
+ return;
142
+ case 'set_status':
143
+ flow.status = operation.status;
144
+ return;
145
+ case 'add_trigger':
146
+ insertAt(flow.triggers, operation.trigger, operation.index);
147
+ return;
148
+ case 'replace_trigger': {
149
+ const index = flow.triggers.findIndex((t) => t.id === operation.triggerId);
150
+ if (index < 0)
151
+ fail(`No start has the id "${operation.triggerId}"`);
152
+ flow.triggers[index] = operation.trigger;
153
+ return;
154
+ }
155
+ case 'remove_trigger': {
156
+ const index = flow.triggers.findIndex((t) => t.id === operation.triggerId);
157
+ if (index < 0)
158
+ fail(`No start has the id "${operation.triggerId}"`);
159
+ flow.triggers.splice(index, 1);
160
+ return;
161
+ }
162
+ case 'add_step':
163
+ insertAt(containerOf(flow, operation.parentId, operation.branch), operation.step, operation.index);
164
+ return;
165
+ case 'update_step': {
166
+ const { list, index, step } = locate(flow, operation.stepId);
167
+ const next = { ...step };
168
+ for (const [key, value] of Object.entries(operation.patch)) {
169
+ if (value === undefined)
170
+ delete next[key];
171
+ else
172
+ next[key] = value;
173
+ }
174
+ // The merged step is re-validated with the whole flow at the end.
175
+ list[index] = next;
176
+ return;
177
+ }
178
+ case 'remove_step': {
179
+ const { list, index } = locate(flow, operation.stepId);
180
+ list.splice(index, 1);
181
+ return;
182
+ }
183
+ case 'move_step': {
184
+ const { list, index, step } = locate(flow, operation.stepId);
185
+ if (operation.parentId !== undefined) {
186
+ const inside = listSteps([step]).some((l) => l.step.id === operation.parentId);
187
+ if (inside)
188
+ fail('A step cannot move inside itself');
189
+ }
190
+ list.splice(index, 1);
191
+ insertAt(containerOf(flow, operation.parentId, operation.branch), step, operation.index);
192
+ return;
193
+ }
194
+ case 'duplicate_step': {
195
+ const { list, index, step } = locate(flow, operation.stepId);
196
+ const taken = new Set(listSteps(flow.steps).map((l) => l.step.id));
197
+ list.splice(index + 1, 0, copyWithFreshIds(step, taken));
198
+ return;
199
+ }
200
+ case 'add_question': {
201
+ const { step } = locate(flow, operation.stepId);
202
+ if (step.type !== 'ask') {
203
+ throw new OperationError(`Step "${operation.stepId}" does not ask questions`);
204
+ }
205
+ insertAt(step.questions, operation.question, operation.index);
206
+ return;
207
+ }
208
+ case 'remove_question': {
209
+ const { step } = locate(flow, operation.stepId);
210
+ if (step.type !== 'ask') {
211
+ throw new OperationError(`Step "${operation.stepId}" does not ask questions`);
212
+ }
213
+ const index = step.questions.findIndex((q) => q.id === operation.questionId);
214
+ if (index < 0)
215
+ fail(`No question has the id "${operation.questionId}"`);
216
+ step.questions.splice(index, 1);
217
+ return;
218
+ }
219
+ case 'upsert_collection': {
220
+ flow.collections ??= [];
221
+ const list = flow.collections;
222
+ const index = list.findIndex((c) => c.id === operation.collection.id);
223
+ if (index < 0)
224
+ list.push(operation.collection);
225
+ else
226
+ list[index] = operation.collection;
227
+ return;
228
+ }
229
+ case 'remove_collection': {
230
+ const list = flow.collections ?? [];
231
+ const index = list.findIndex((c) => c.id === operation.collectionId);
232
+ if (index < 0)
233
+ fail(`No collection has the id "${operation.collectionId}"`);
234
+ list.splice(index, 1);
235
+ return;
236
+ }
237
+ case 'upsert_list': {
238
+ flow.lists ??= [];
239
+ const list = flow.lists;
240
+ const index = list.findIndex((l) => l.id === operation.list.id);
241
+ if (index < 0)
242
+ list.push(operation.list);
243
+ else
244
+ list[index] = operation.list;
245
+ return;
246
+ }
247
+ case 'remove_list': {
248
+ const list = flow.lists ?? [];
249
+ const index = list.findIndex((l) => l.id === operation.listId);
250
+ if (index < 0)
251
+ fail(`No list has the id "${operation.listId}"`);
252
+ list.splice(index, 1);
253
+ return;
254
+ }
255
+ }
256
+ }
257
+ const opDiagnostic = (index, message, code = 'schema') => ({
258
+ severity: 'error',
259
+ code,
260
+ path: `operations[${index}]`,
261
+ message,
262
+ });
263
+ /**
264
+ * Applies a batch of edits atomically. Operations are untrusted input and
265
+ * validated first; each runs on a copy; the result must parse as a flow
266
+ * (schema and semantic errors). On any failure the original flow is returned
267
+ * untouched with the diagnostics and the failing operation index (null when
268
+ * only the final flow is invalid).
269
+ */
270
+ export function applyOperations(flow, operations) {
271
+ const problem = inspectPlainData(operations);
272
+ if (problem) {
273
+ return {
274
+ ok: false,
275
+ flow,
276
+ failedAt: null,
277
+ diagnostics: [opDiagnostic(0, problem.message, 'not_plain_data')],
278
+ };
279
+ }
280
+ const parsed = z.array(OperationSchema).max(FLOW_LIMITS.maxOperations).safeParse(operations);
281
+ if (!parsed.success) {
282
+ const first = parsed.error.issues[0];
283
+ const index = typeof first?.path[0] === 'number' ? first.path[0] : 0;
284
+ return {
285
+ ok: false,
286
+ flow,
287
+ failedAt: index,
288
+ diagnostics: parsed.error.issues.map((issue) => opDiagnostic(typeof issue.path[0] === 'number' ? issue.path[0] : 0, issue.message)),
289
+ };
290
+ }
291
+ const draft = structuredClone(flow);
292
+ for (const [index, operation] of parsed.data.entries()) {
293
+ try {
294
+ apply(draft, operation);
295
+ }
296
+ catch (error) {
297
+ if (!(error instanceof OperationError))
298
+ throw error;
299
+ return {
300
+ ok: false,
301
+ flow,
302
+ failedAt: index,
303
+ diagnostics: [opDiagnostic(index, error.message)],
304
+ };
305
+ }
306
+ }
307
+ const result = parseFlow(draft);
308
+ if (!result.ok)
309
+ return { ok: false, flow, failedAt: null, diagnostics: result.diagnostics };
310
+ return { ok: true, flow: result.flow, diagnostics: result.diagnostics };
311
+ }
@@ -0,0 +1,12 @@
1
+ import type { Flow } from './flow.ts';
2
+ export interface FlowRecipe {
3
+ id: string;
4
+ title: string;
5
+ summary: string;
6
+ build(overrides?: {
7
+ id?: string;
8
+ name?: string;
9
+ }): Flow;
10
+ }
11
+ export declare const FLOW_RECIPES: readonly FlowRecipe[];
12
+ export declare function findRecipe(id: string): FlowRecipe | undefined;